diff --git a/README.md b/README.md index 14b6bd4..0207ddd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin - **24-bit color** — full RGB SGR support - **Auto-resize** — `ResizeObserver`-based terminal resizing - **WebSocket transport** — connect to a PTY backend with binary framing and reconnection +- **Inline images** — render PNGs via the [Kitty terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) as `` overlays aligned to the cell grid ## Development diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx index 9c03113..441594b 100644 --- a/apps/docs/src/app/api-reference/page.mdx +++ b/apps/docs/src/app/api-reference/page.mdx @@ -58,6 +58,12 @@ The React and Vue `` components and the vanilla `WTerm` constructor al false Enable debug mode. Exposes a DebugAdapter on the WTerm instance (wt.debug) for inspecting escape sequences, cell data, render performance, and unhandled CSI sequences. + + images + boolean + true + Enable inline image rendering via the Kitty terminal graphics protocol. APC {"\\x1b_G…\\x1b\\\\"} sequences are intercepted and rendered as <img> overlays above the cell grid. Set to false to pass the bytes through to the core unchanged. + onData (data: string) => void diff --git a/apps/docs/src/app/configuration/page.mdx b/apps/docs/src/app/configuration/page.mdx index ded05f5..79b02c4 100644 --- a/apps/docs/src/app/configuration/page.mdx +++ b/apps/docs/src/app/configuration/page.mdx @@ -2,7 +2,7 @@ ## Options -The React and Vue `Terminal` components and the vanilla `WTerm` constructor accept the same core options — `cols`, `rows`, `core`, `wasmUrl`, `autoResize`, `cursorBlink`, and `debug` — plus event callbacks `onData`, `onTitle`, and `onResize` (exposed as `@data`, `@title`, `@resize` events in Vue). +The React and Vue `Terminal` components and the vanilla `WTerm` constructor accept the same core options — `cols`, `rows`, `core`, `wasmUrl`, `autoResize`, `cursorBlink`, `debug`, and `images` — plus event callbacks `onData`, `onTitle`, and `onResize` (exposed as `@data`, `@title`, `@resize` events in Vue). See the full [API Reference](/api-reference#terminal-options) for types, defaults, and descriptions. @@ -10,6 +10,12 @@ See the full [API Reference](/api-reference#terminal-options) for types, default By default, wterm uses its built-in lightweight Zig WASM core (~12 KB). To use a different terminal emulation backend, pass a `TerminalCore` instance via the `core` option. See the [Ghostty Core](/ghostty) page for an example using libghostty. +### Inline images + +wterm intercepts [Kitty terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) APC sequences and renders the transmitted PNGs as `` overlays absolutely positioned over the cell grid. Enabled by default — set `images: false` to disable the filter and pass the bytes through to the core unchanged. + +Supported actions: `t` (transmit), `T` (transmit + display), `p` (put placement), `d` (delete). Supported format: PNG (`f=100`) via direct base64 transport, including chunked transfers (`m=1`/`m=0`). Not yet supported: raw RGB/RGBA frames, file/shared-memory transports, virtual-placement via Unicode placeholders, animations, Sixel, and iTerm2 inline images. + ### React-only The React `Terminal` component adds `theme`, `onReady`, and `onError` on top of the shared options. It also spreads standard HTML attributes onto the root `div`, so you can pass `className`, `style`, ARIA attributes, and other DOM props directly. diff --git a/examples/kitty-images/README.md b/examples/kitty-images/README.md new file mode 100644 index 0000000..98a3045 --- /dev/null +++ b/examples/kitty-images/README.md @@ -0,0 +1,29 @@ +# Kitty Graphics Protocol Example + +A self-contained demo of inline image rendering in `wterm` via the [Kitty terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). + +Generates a PNG on the fly with `` and transmits it through the protocol's chunked APC `\x1b_G...\x1b\\` transport. `@wterm/dom` intercepts the sequence and renders the image as an absolutely-positioned overlay aligned to the cell grid. + +## Setup + +From the monorepo root: + +```bash +pnpm install +pnpm --filter kitty-images-example dev +``` + +Opens at `kitty-images.wterm.localhost` via [portless](https://github.com/vercel-labs/portless). + +## How It Works + +- `WTerm` enables image handling by default; APC `_G...` sequences are stripped from the byte stream before it reaches the VT core. +- The protocol payload is base64-encoded PNG bytes. Multi-chunk transfers (`m=1` … `m=0`) accumulate before decoding. +- Each placement is anchored to the cursor row at the moment the sequence is processed. Because new lines are inserted into scrollback above the grid, the image stays pixel-aligned with its content as the screen scrolls. + +## Key Files + +| File | Description | +| ------------- | ------------------------------------------------------------------- | +| `src/main.ts` | Generates a PNG with canvas and transmits it as a chunked Kitty APC | +| `index.html` | Minimal HTML with `
` | diff --git a/examples/kitty-images/index.html b/examples/kitty-images/index.html new file mode 100644 index 0000000..4e76c85 --- /dev/null +++ b/examples/kitty-images/index.html @@ -0,0 +1,27 @@ + + + + + + wterm — Kitty Graphics Protocol + + + +
+ + + diff --git a/examples/kitty-images/package.json b/examples/kitty-images/package.json new file mode 100644 index 0000000..c4ca369 --- /dev/null +++ b/examples/kitty-images/package.json @@ -0,0 +1,21 @@ +{ + "name": "kitty-images-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)", + "dev": "portless kitty-images.wterm vite", + "build": "vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@wterm/dom": "workspace:*", + "@wterm/ghostty": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vite": "^6.3.5" + } +} diff --git a/examples/kitty-images/src/main.ts b/examples/kitty-images/src/main.ts new file mode 100644 index 0000000..c82c081 --- /dev/null +++ b/examples/kitty-images/src/main.ts @@ -0,0 +1,78 @@ +import { WTerm } from "@wterm/dom"; +import { GhosttyCore } from "@wterm/ghostty"; +import "@wterm/dom/css"; + +const el = document.getElementById("terminal")!; + +const core = await GhosttyCore.load(); +const term = new WTerm(el, { core }); +await term.init(); + +term.write( + "\x1b[1;36mwterm\x1b[0m — \x1b[1;35mKitty graphics protocol\x1b[0m demo\r\n\r\n", +); + +term.write( + "PNG image transmitted via APC `\\x1b_G...` sequence and rendered\r\n" + + "as an absolutely-positioned overlay above the cell grid.\r\n\r\n", +); + +const pngBytes = await drawSamplePng(200, 100); +const b64 = base64FromBytes(pngBytes); + +// Chunked transfer: real apps split the base64 payload across multiple +// `m=1` chunks with a final `m=0`. Demo it here with 4 KiB chunks. +const CHUNK = 4096; +let offset = 0; +let first = true; +while (offset < b64.length) { + const end = Math.min(offset + CHUNK, b64.length); + const isLast = end >= b64.length; + const slice = b64.slice(offset, end); + const control = first + ? `a=T,f=100,i=1,c=25,r=6,m=${isLast ? 0 : 1}` + : `i=1,m=${isLast ? 0 : 1}`; + term.write(`\x1b_G${control};${slice}\x1b\\`); + offset = end; + first = false; +} + +term.write( + "\r\n\x1b[2mYour PNG above. Press any key — input echoes.\x1b[0m\r\n", +); + +async function drawSamplePng(w: number, h: number): Promise { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d")!; + + const grad = ctx.createLinearGradient(0, 0, w, h); + grad.addColorStop(0, "#ff7eb6"); + grad.addColorStop(0.5, "#be95ff"); + grad.addColorStop(1, "#33b1ff"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + + ctx.fillStyle = "rgba(255,255,255,0.95)"; + ctx.font = "bold 28px system-ui, sans-serif"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + ctx.fillText("wterm 🚀", w / 2, h / 2); + + const blob: Blob = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b!), "image/png"), + ); + return new Uint8Array(await blob.arrayBuffer()); +} + +function base64FromBytes(bytes: Uint8Array): string { + let s = ""; + const STEP = 0x8000; + for (let i = 0; i < bytes.length; i += STEP) { + s += String.fromCharCode( + ...bytes.subarray(i, Math.min(i + STEP, bytes.length)), + ); + } + return btoa(s); +} diff --git a/examples/kitty-images/tsconfig.json b/examples/kitty-images/tsconfig.json new file mode 100644 index 0000000..11e5462 --- /dev/null +++ b/examples/kitty-images/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["src", "*.d.ts"] +} diff --git a/examples/kitty-images/vite.config.ts b/examples/kitty-images/vite.config.ts new file mode 100644 index 0000000..87d46cd --- /dev/null +++ b/examples/kitty-images/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + target: "esnext", + }, +}); diff --git a/examples/kitty-images/wterm-dom.d.ts b/examples/kitty-images/wterm-dom.d.ts new file mode 100644 index 0000000..aefbc0a --- /dev/null +++ b/examples/kitty-images/wterm-dom.d.ts @@ -0,0 +1 @@ +declare module "@wterm/dom/css"; diff --git a/packages/@wterm/dom/README.md b/packages/@wterm/dom/README.md index 22098d6..94ae7d9 100644 --- a/packages/@wterm/dom/README.md +++ b/packages/@wterm/dom/README.md @@ -46,6 +46,7 @@ new WTerm(element: HTMLElement, options?: WTermOptions) | `autoResize` | `boolean` | `true` | Auto-resize based on container dimensions | | `cursorBlink` | `boolean` | `false` | Enable cursor blinking animation | | `debug` | `boolean` | `false` | Enable debug mode. Exposes a `DebugAdapter` on the instance (`wt.debug`) for inspecting escape sequences, cell data, render performance, and unhandled CSI sequences. | +| `images` | `boolean` | `true` | Enable inline image rendering via the [Kitty terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). When enabled, `\x1b_G…\x1b\\` APC sequences are intercepted and rendered as `` overlays above the cell grid. Set to `false` to pass the bytes through to the core unchanged. | | `onData` | `(data: string) => void` | — | Called when the terminal produces data (user input or host response). When omitted, input is echoed back automatically. | | `onTitle` | `(title: string) => void` | — | Called when the terminal title changes | | `onResize` | `(cols: number, rows: number) => void` | — | Called on resize | @@ -79,6 +80,20 @@ ws.connect(); term.onData = (data) => ws.send(data); ``` +## Inline images (Kitty graphics protocol) + +When `images: true` (default), wterm intercepts Kitty graphics protocol APC sequences and renders the transmitted PNG as an absolutely-positioned `` overlay aligned to the cell grid. Supports inline base64 transfers (`f=100`) and multi-chunk `m=1`/`m=0` payloads. Actions: `t` (transmit), `T` (transmit + display), `p` (put placement), `d` (delete). + +```ts +const png = new Uint8Array( + await fetch("/icon.png").then((r) => r.arrayBuffer()), +); +const b64 = btoa(String.fromCharCode(...png)); +term.write(`\x1b_Ga=T,f=100,i=1,c=20,r=5;${b64}\x1b\\`); +``` + +Not yet supported: raw RGB/RGBA frames (`f=24`/`f=32`), file/shared-memory transports, virtual-placement via Unicode placeholders, animations, Sixel, and iTerm2 inline images. + ## Themes Import the stylesheet and apply a theme class to the terminal element: diff --git a/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts b/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts new file mode 100644 index 0000000..9760d3f --- /dev/null +++ b/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest"; +import { + KittyGraphicsFilter, + MAX_PENDING_CHUNKS, + type StreamEvent, +} from "../kitty-graphics.js"; + +const enc = new TextEncoder(); + +function feed(filter: KittyGraphicsFilter, s: string): StreamEvent[] { + return filter.push(enc.encode(s)); +} + +function textOf(ev: StreamEvent): string { + if (ev.type !== "text") throw new Error("expected text event"); + return new TextDecoder().decode(ev.bytes); +} + +function base64(bytes: ArrayLike): string { + let s = ""; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); +} + +describe("KittyGraphicsFilter", () => { + it("passes through plain text untouched", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, "hello world"); + expect(events).toHaveLength(1); + expect(textOf(events[0])).toBe("hello world"); + }); + + it("passes through normal CSI escape sequences", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, "\x1b[1;31mred\x1b[0m"); + expect(events).toHaveLength(1); + expect(textOf(events[0])).toBe("\x1b[1;31mred\x1b[0m"); + }); + + it("passes through non-Kitty APC sequences", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, "before\x1b_Xfoo\x1b\\after"); + expect(events).toHaveLength(1); + expect(textOf(events[0])).toBe("before\x1b_Xfoo\x1b\\after"); + }); + + it("extracts a single complete Kitty graphics APC and emits text around it", () => { + const f = new KittyGraphicsFilter(); + const png = base64([1, 2, 3, 4]); + const events = feed(f, `prefix\x1b_Ga=T,f=100,i=1;${png}\x1b\\suffix`); + + expect(events).toHaveLength(3); + expect(textOf(events[0])).toBe("prefix"); + expect(events[1].type).toBe("graphics"); + if (events[1].type === "graphics") { + expect(events[1].event.control.a).toBe("T"); + expect(events[1].event.control.f).toBe(100); + expect(events[1].event.control.i).toBe(1); + expect(Array.from(events[1].event.data)).toEqual([1, 2, 3, 4]); + } + expect(textOf(events[2])).toBe("suffix"); + }); + + it("accepts BEL as a terminator", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, `\x1b_Ga=t,i=5,f=100;${base64([9])}\x07tail`); + expect(events).toHaveLength(2); + expect(events[0].type).toBe("graphics"); + expect(textOf(events[1])).toBe("tail"); + }); + + it("handles a chunked transfer split across writes", () => { + const f = new KittyGraphicsFilter(); + // Real apps base64-encode the full payload, then split the base64 stream + // into fixed-size chunks. Intermediate chunks therefore have no `=` pad. + const full = base64([10, 11, 12, 13, 14, 15, 16, 17]); + const split = Math.floor(full.length / 2); + const chunkA = full.slice(0, split); + const chunkB = full.slice(split); + + const first = feed(f, `\x1b_Ga=T,f=100,i=42,m=1;${chunkA}\x1b\\`); + expect(first).toHaveLength(0); + + const second = feed(f, `\x1b_Gi=42,m=0;${chunkB}\x1b\\done`); + expect(second).toHaveLength(2); + expect(second[0].type).toBe("graphics"); + if (second[0].type === "graphics") { + expect(second[0].event.control.i).toBe(42); + expect(Array.from(second[0].event.data)).toEqual([ + 10, 11, 12, 13, 14, 15, 16, 17, + ]); + } + expect(textOf(second[1])).toBe("done"); + }); + + it("handles APC bytes split across multiple push calls", () => { + const f = new KittyGraphicsFilter(); + const png = base64([1, 2]); + const full = `\x1b_Ga=T,f=100,i=7;${png}\x1b\\`; + const events: StreamEvent[] = []; + for (const ch of full) events.push(...feed(f, ch)); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("graphics"); + if (events[0].type === "graphics") { + expect(Array.from(events[0].event.data)).toEqual([1, 2]); + } + }); + + it("emits a delete action with no payload", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, "\x1b_Ga=d,d=a\x1b\\"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("graphics"); + if (events[0].type === "graphics") { + expect(events[0].event.control.a).toBe("d"); + expect(events[0].event.control.d).toBe("a"); + expect(events[0].event.data.length).toBe(0); + } + }); + + it("does not consume `\\x1b` followed by something other than `_`", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, "\x1b[A"); + expect(events).toHaveLength(1); + expect(textOf(events[0])).toBe("\x1b[A"); + }); + + it("does not consume `\\x1b_` followed by something other than `G`", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, "\x1b_Q;foo\x1b\\"); + expect(events).toHaveLength(1); + expect(textOf(events[0])).toBe("\x1b_Q;foo\x1b\\"); + }); + + it("coalesces contiguous text runs in a single push", () => { + const f = new KittyGraphicsFilter(); + const events = feed(f, "a\x1bb\x1b_c"); + expect(events).toHaveLength(1); + expect(textOf(events[0])).toBe("a\x1bb\x1b_c"); + }); + + it("reset clears in-flight state", () => { + const f = new KittyGraphicsFilter(); + feed(f, "\x1b_Ga=T,f=100,i=99;abc"); // never terminates + f.reset(); + const events = feed(f, "hi"); + expect(events).toHaveLength(1); + expect(textOf(events[0])).toBe("hi"); + }); + + it("silently drops an APC whose payload is invalid base64", () => { + const f = new KittyGraphicsFilter(); + // "abcde" survives the strip regex but has length 5 — invalid base64 + // (length must be a multiple of 4), so `atob` throws. + const events = feed(f, `before\x1b_Ga=T,f=100,i=1;abcde\x1b\\after`); + // No graphics event emitted; surrounding text still passes through. + const graphics = events.filter((e) => e.type === "graphics"); + expect(graphics).toHaveLength(0); + const text = events + .filter((e) => e.type === "text") + .map(textOf) + .join(""); + expect(text).toBe("beforeafter"); + }); + + it("evicts the oldest pending chunk when exceeding the chunk-count cap", () => { + const f = new KittyGraphicsFilter(); + // Start MAX_PENDING_CHUNKS + 1 distinct in-flight transfers (m=1, never closed). + for (let i = 0; i < MAX_PENDING_CHUNKS + 1; i++) { + const events = feed( + f, + `\x1b_Ga=T,f=100,i=${100 + i},m=1;${base64([i])}\x1b\\`, + ); + // Each chunk-with-more emits nothing. + expect(events).toHaveLength(0); + } + // The oldest entry (i=100) was evicted. Closing it now should not produce + // a graphics event because no pending entry remains and m=0 is treated as + // a fresh standalone APC with empty payload. + const closeOldest = feed(f, `\x1b_Gi=100,m=0;\x1b\\`); + const oldestGraphics = closeOldest.filter((e) => e.type === "graphics"); + // A fresh standalone APC still emits one graphics event (with empty data), + // but it must NOT contain the originally-buffered byte. + expect(oldestGraphics).toHaveLength(1); + if (oldestGraphics[0].type === "graphics") { + expect(Array.from(oldestGraphics[0].event.data)).toEqual([]); + } + // The newest entry (i=100 + MAX_PENDING_CHUNKS) is still pending — closing + // it should yield the originally-buffered byte. + const newestId = 100 + MAX_PENDING_CHUNKS; + const closeNewest = feed(f, `\x1b_Gi=${newestId},m=0;\x1b\\`); + expect(closeNewest).toHaveLength(1); + if (closeNewest[0].type === "graphics") { + expect(Array.from(closeNewest[0].event.data)).toEqual([MAX_PENDING_CHUNKS]); + } + }); +}); diff --git a/packages/@wterm/dom/src/__tests__/wterm.test.ts b/packages/@wterm/dom/src/__tests__/wterm.test.ts index 5ca798f..c47f5ea 100644 --- a/packages/@wterm/dom/src/__tests__/wterm.test.ts +++ b/packages/@wterm/dom/src/__tests__/wterm.test.ts @@ -159,11 +159,12 @@ describe("WTerm", () => { }); describe("write", () => { - it("calls bridge.writeString for string data", async () => { + it("forwards string data as encoded bytes to bridge.writeRaw", async () => { const term = new WTerm(element, { autoResize: false }); await term.init(); term.write("hello"); - expect(mockBridge.writeString).toHaveBeenCalledWith("hello"); + const encoded = new TextEncoder().encode("hello"); + expect(mockBridge.writeRaw).toHaveBeenCalledWith(encoded); }); it("calls bridge.writeRaw for Uint8Array data", async () => { @@ -174,10 +175,38 @@ describe("WTerm", () => { expect(mockBridge.writeRaw).toHaveBeenCalledWith(bytes); }); + it("falls back to writeString when images are disabled", async () => { + const term = new WTerm(element, { autoResize: false, images: false }); + await term.init(); + term.write("hello"); + expect(mockBridge.writeString).toHaveBeenCalledWith("hello"); + }); + it("is a no-op before init", () => { const term = new WTerm(element); term.write("hello"); expect(mockBridge.writeString).not.toHaveBeenCalled(); + expect(mockBridge.writeRaw).not.toHaveBeenCalled(); + }); + + it("intercepts Kitty APC sequences when images are enabled", async () => { + const term = new WTerm(element, { autoResize: false }); + await term.init(); + vi.mocked(mockBridge.writeRaw).mockClear(); + const prefix = "hello "; + const suffix = " world"; + // a=d delete-all APC — control-only, no payload to decode + const apc = "\x1b_Ga=d,d=a\x1b\\"; + term.write(prefix + apc + suffix); + const encoder = new TextEncoder(); + const allBytes = vi + .mocked(mockBridge.writeRaw) + .mock.calls.flatMap((c) => Array.from(c[0])); + // Every byte the bridge saw must come from prefix + suffix only. + const expected = Array.from(encoder.encode(prefix + suffix)); + expect(allBytes).toEqual(expected); + // And ESC (0x1b) / underscore (0x5f) sequence should never reach the bridge. + expect(allBytes.includes(0x1b)).toBe(false); }); }); @@ -244,7 +273,9 @@ describe("WTerm", () => { }), ); - expect(mockBridge.writeString).toHaveBeenCalledWith("a"); + expect(mockBridge.writeRaw).toHaveBeenCalledWith( + new TextEncoder().encode("a"), + ); }); it("calls onData instead of write when provided", async () => { diff --git a/packages/@wterm/dom/src/image-overlay.ts b/packages/@wterm/dom/src/image-overlay.ts new file mode 100644 index 0000000..199706f --- /dev/null +++ b/packages/@wterm/dom/src/image-overlay.ts @@ -0,0 +1,200 @@ +import type { KittyGraphicsEvent, KittyControl } from "./kitty-graphics.js"; + +interface Stored { + /** PNG image bytes. */ + data: Uint8Array; +} + +interface Placement { + el: HTMLImageElement; + placementId: number; + imageId: string; + /** Cell column at top-left of the image. */ + col: number; + /** Pixel offset from the top of `term-grid` to the image's top edge. */ + topPx: number; + /** Object URL backing `el.src`; revoked when the placement is dropped. */ + objectUrl: string; +} + +const FMT_PNG = 100; + +/** + * Overlay layer that renders inline images (Kitty graphics protocol) on top + * of the terminal cell grid. Images are positioned absolutely inside the + * `term-grid` container so they remain aligned with their original content + * as new lines scroll into history. + */ +export class ImageOverlay { + private container: HTMLElement; + private layer: HTMLDivElement; + /** Source images keyed by `i:` (id) or `I:` (number). */ + private images = new Map(); + /** Active placements keyed by `${imageId}#${placementId}`. */ + private placements = new Map(); + private charWidthPx = 0; + private rowHeightPx = 0; + + constructor(termGridContainer: HTMLElement) { + this.container = termGridContainer; + this.layer = document.createElement("div"); + this.layer.className = "term-image-layer"; + this.container.appendChild(this.layer); + } + + /** Tell the overlay how many CSS pixels one cell occupies. */ + setCellMetrics(charWidthPx: number, rowHeightPx: number): void { + this.charWidthPx = charWidthPx; + this.rowHeightPx = rowHeightPx; + } + + /** + * Handle an incoming Kitty graphics event. `anchor` describes the current + * cursor position when the event arrives (used as the placement origin + * unless overridden by the event's own coordinates). + */ + handle( + event: KittyGraphicsEvent, + anchor: { row: number; col: number; scrollbackCount: number }, + ): void { + const action = String(event.control.a ?? "T"); + + if (action === "d") { + this._delete(event.control); + return; + } + + if (action === "t" || action === "T") { + this._store(event); + } + + if (action === "T" || action === "p") { + this._place(event.control, anchor); + } + } + + /** Remove everything. Used on terminal reset / destroy. */ + clear(): void { + this.images.clear(); + for (const p of this.placements.values()) { + URL.revokeObjectURL(p.objectUrl); + p.el.remove(); + } + this.placements.clear(); + } + + destroy(): void { + this.clear(); + this.layer.remove(); + } + + private _store(event: KittyGraphicsEvent): void { + const f = event.control.f ?? FMT_PNG; + if (f !== FMT_PNG) { + // Only PNG inline transfer is supported in this MVP. + return; + } + const key = imageKey(event.control); + this.images.set(key, { data: event.data }); + } + + private _place( + control: KittyControl, + anchor: { row: number; col: number; scrollbackCount: number }, + ): void { + const key = imageKey(control); + const stored = this.images.get(key); + if (!stored || stored.data.length === 0) return; + + const placementId = typeof control.p === "number" ? control.p : 0; + const dedupeKey = `${key}#${placementId}`; + const existing = this.placements.get(dedupeKey); + if (existing) { + URL.revokeObjectURL(existing.objectUrl); + existing.el.remove(); + } + + const col = anchor.col + (typeof control.X === "number" ? control.X : 0); + const totalRow = + anchor.scrollbackCount + + anchor.row + + (typeof control.Y === "number" ? control.Y : 0); + + const img = document.createElement("img"); + img.className = "term-image"; + img.draggable = false; + img.alt = ""; + const blob = new Blob([new Uint8Array(stored.data)], { type: "image/png" }); + const objectUrl = URL.createObjectURL(blob); + img.src = objectUrl; + const revoke = (): void => URL.revokeObjectURL(objectUrl); + img.addEventListener("load", revoke, { once: true }); + img.addEventListener("error", revoke, { once: true }); + + const topPx = totalRow * this.rowHeightPx; + const leftPx = col * this.charWidthPx; + + img.style.position = "absolute"; + img.style.top = `${topPx}px`; + img.style.left = `${leftPx}px`; + if (typeof control.c === "number" && this.charWidthPx > 0) { + img.style.width = `${control.c * this.charWidthPx}px`; + } + if (typeof control.r === "number" && this.rowHeightPx > 0) { + img.style.height = `${control.r * this.rowHeightPx}px`; + } + if (typeof control.z === "number") { + img.style.zIndex = String(control.z); + } + + this.layer.appendChild(img); + this.placements.set(dedupeKey, { + el: img, + placementId, + imageId: key, + col, + topPx, + objectUrl, + }); + } + + private _delete(control: KittyControl): void { + const specRaw = control.d; + const spec = typeof specRaw === "string" ? specRaw : "a"; + const lower = spec.toLowerCase(); + + // Uppercase variants in the spec mean "also free image data". We treat + // upper and lower the same here since we don't separately track on-disk + // resources. + const removeMatching = (pred: (p: Placement) => boolean): void => { + for (const [k, p] of this.placements) { + if (pred(p)) { + URL.revokeObjectURL(p.objectUrl); + p.el.remove(); + this.placements.delete(k); + } + } + }; + + switch (lower) { + case "a": + removeMatching(() => true); + break; + case "i": { + const key = imageKey(control); + removeMatching((p) => p.imageId === key); + if (spec === "I") this.images.delete(key); + break; + } + default: + // Other delete specifiers (z-index, range, etc.) not implemented. + break; + } + } +} + +function imageKey(control: KittyControl): string { + if (typeof control.i === "number") return `i:${control.i}`; + if (typeof control.I === "number") return `I:${control.I}`; + return "i:0"; +} diff --git a/packages/@wterm/dom/src/index.ts b/packages/@wterm/dom/src/index.ts index 1ab81ec..0d3c724 100644 --- a/packages/@wterm/dom/src/index.ts +++ b/packages/@wterm/dom/src/index.ts @@ -10,4 +10,11 @@ export type { PerfStats, UnhandledEntry, } from "./debug.js"; +export { + KittyGraphicsFilter, + type KittyGraphicsEvent, + type KittyControl, + type StreamEvent, +} from "./kitty-graphics.js"; +export { ImageOverlay } from "./image-overlay.js"; export * from "@wterm/core"; diff --git a/packages/@wterm/dom/src/kitty-graphics.ts b/packages/@wterm/dom/src/kitty-graphics.ts new file mode 100644 index 0000000..e1e9e72 --- /dev/null +++ b/packages/@wterm/dom/src/kitty-graphics.ts @@ -0,0 +1,360 @@ +/** + * Streaming parser for the Kitty terminal graphics protocol. + * + * Intercepts APC sequences of the form `\x1b_G;\x1b\\` + * (or BEL-terminated `\x1b_G;\x07`) from a byte stream, + * splits the input into pass-through text and graphics events, and + * accumulates chunked transfers (`m=1` followed by `m=0`). + * + * Spec: https://sw.kovidgoyal.net/kitty/graphics-protocol/ + */ + +export type KittyAction = "t" | "T" | "p" | "d" | "f" | "a" | "q"; + +export interface KittyControl { + /** Action: t (transmit), T (transmit+display), p (put), d (delete), etc. */ + a?: string; + /** Format: 100 = PNG, 24 = RGB, 32 = RGBA. */ + f?: number; + /** Transport: d (direct base64, default), f (file), t (temp file), s (shared mem). */ + t?: string; + /** Image id. */ + i?: number; + /** Image number. */ + I?: number; + /** More chunks follow (1) or last chunk (0). */ + m?: number; + /** Quiet mode. */ + q?: number; + /** Source pixel width (for raw formats). */ + s?: number; + /** Source pixel height (for raw formats). */ + v?: number; + /** Columns to fit. */ + c?: number; + /** Rows to fit. */ + r?: number; + /** Placement id. */ + p?: number; + /** Z-index. */ + z?: number; + /** Source rect: x, y offset. */ + x?: number; + y?: number; + /** Source rect: width, height. */ + w?: number; + h?: number; + /** Cell offset for placement. */ + X?: number; + Y?: number; + /** Cursor-movement policy (0 default = move, 1 = don't move). */ + C?: number; + /** Delete specifier: `a` (all), `i` (by id), etc. */ + d?: string; + /** Catch-all for additional keys. */ + [key: string]: number | string | undefined; +} + +export interface KittyGraphicsEvent { + control: KittyControl; + /** Raw decoded payload bytes. Empty for control-only commands like delete. */ + data: Uint8Array; +} + +export type StreamEvent = + | { type: "text"; bytes: Uint8Array } + | { type: "graphics"; event: KittyGraphicsEvent }; + +const ESC = 0x1b; +const BEL = 0x07; +const BACKSLASH = 0x5c; +const UNDERSCORE = 0x5f; +const G = 0x47; + +const enum State { + Idle = 0, + EscSeen = 1, + UnderscoreSeen = 2, + InKittyApc = 3, + InKittyApcEsc = 4, +} + +interface PendingChunk { + control: KittyControl; + /** Concatenated base64 payload across chunks. */ + payload: string; +} + +/** Maximum number of simultaneously-buffered chunked transfers. */ +export const MAX_PENDING_CHUNKS = 8; +/** Maximum total base64 bytes held across all in-flight chunked transfers. */ +export const MAX_PENDING_BASE64_BYTES = 32 * 1024 * 1024; + +/** + * Stateful streaming filter. Feed it raw bytes via {@link push}, receive + * an ordered list of pass-through and graphics events. + */ +export class KittyGraphicsFilter { + private state: State = State.Idle; + /** Buffered Kitty APC payload (bytes between `\x1b_G` and the terminator). */ + private apcBuf: number[] = []; + /** Pending chunked transfers keyed by image id (i=) or image number (-I=). */ + private pendingChunks = new Map(); + /** Running total of buffered base64 bytes across all pending chunks. */ + private pendingBytes = 0; + + /** + * Push a chunk of bytes through the filter. Returns the ordered list of + * pass-through text segments and completed graphics events. + */ + push(input: Uint8Array): StreamEvent[] { + const events: StreamEvent[] = []; + let textStart = -1; + + const flushText = (endExclusive: number): void => { + if (textStart >= 0 && endExclusive > textStart) { + events.push({ + type: "text", + bytes: input.slice(textStart, endExclusive), + }); + } + textStart = -1; + }; + + const startText = (idx: number): void => { + if (textStart < 0) textStart = idx; + }; + + const emitBytesLiteral = (bytes: number[]): void => { + if (bytes.length === 0) return; + events.push({ type: "text", bytes: new Uint8Array(bytes) }); + }; + + for (let i = 0; i < input.length; i++) { + const b = input[i]; + + switch (this.state) { + case State.Idle: + if (b === ESC) { + flushText(i); + this.state = State.EscSeen; + } else { + startText(i); + } + break; + + case State.EscSeen: + if (b === UNDERSCORE) { + this.state = State.UnderscoreSeen; + } else if (b === ESC) { + emitBytesLiteral([ESC]); + // stay in EscSeen with the new ESC + } else { + emitBytesLiteral([ESC, b]); + this.state = State.Idle; + } + break; + + case State.UnderscoreSeen: + if (b === G) { + this.state = State.InKittyApc; + this.apcBuf = []; + } else if (b === ESC) { + // \x1b_ — flush ESC + _ to output, reprocess ESC + emitBytesLiteral([ESC, UNDERSCORE]); + this.state = State.EscSeen; + } else { + emitBytesLiteral([ESC, UNDERSCORE, b]); + this.state = State.Idle; + } + break; + + case State.InKittyApc: + if (b === ESC) { + this.state = State.InKittyApcEsc; + } else if (b === BEL) { + this._completeApc(events); + this.state = State.Idle; + } else { + this.apcBuf.push(b); + } + break; + + case State.InKittyApcEsc: + if (b === BACKSLASH) { + this._completeApc(events); + this.state = State.Idle; + } else { + // Not ST; treat the ESC as part of the payload and reprocess `b`. + this.apcBuf.push(ESC); + this.state = State.InKittyApc; + i--; + } + break; + } + } + + flushText(input.length); + return coalesce(events); + } + + /** + * Discard any partially-accumulated state. Call when the upstream stream + * is reset (e.g. core re-init) so a half-buffered APC doesn't leak into + * the next session. + */ + reset(): void { + this.state = State.Idle; + this.apcBuf = []; + this.pendingChunks.clear(); + this.pendingBytes = 0; + } + + private _completeApc(events: StreamEvent[]): void { + const buf = this.apcBuf; + this.apcBuf = []; + + const semi = buf.indexOf(0x3b); + let ctrlBytes: number[]; + let payloadBytes: number[]; + if (semi < 0) { + ctrlBytes = buf; + payloadBytes = []; + } else { + ctrlBytes = buf.slice(0, semi); + payloadBytes = buf.slice(semi + 1); + } + + const control = parseControl(decodeAscii(ctrlBytes)); + const payloadB64 = decodeAscii(payloadBytes); + + const more = control.m === 1; + const key = chunkKey(control); + + if (more || this.pendingChunks.has(key)) { + const existing = this.pendingChunks.get(key); + if (existing) { + if (this.pendingBytes + payloadB64.length > MAX_PENDING_BASE64_BYTES) { + this.pendingBytes -= existing.payload.length; + this.pendingChunks.delete(key); + return; + } + existing.payload += payloadB64; + this.pendingBytes += payloadB64.length; + // Merge controls — later chunks may omit fields. Keep first-chunk + // values, but `m` reflects the latest. + existing.control.m = control.m; + } else { + if (payloadB64.length > MAX_PENDING_BASE64_BYTES) { + return; + } + if (this.pendingChunks.size >= MAX_PENDING_CHUNKS) { + const oldestKey = this.pendingChunks.keys().next().value; + if (oldestKey !== undefined) { + const oldest = this.pendingChunks.get(oldestKey); + if (oldest) this.pendingBytes -= oldest.payload.length; + this.pendingChunks.delete(oldestKey); + } + } + this.pendingChunks.set(key, { + control: { ...control }, + payload: payloadB64, + }); + this.pendingBytes += payloadB64.length; + } + + if (more) return; + + const completed = this.pendingChunks.get(key); + this.pendingChunks.delete(key); + if (!completed) return; + this.pendingBytes -= completed.payload.length; + let data: Uint8Array; + try { + data = decodeBase64(completed.payload); + } catch { + return; + } + events.push({ + type: "graphics", + event: { + control: completed.control, + data, + }, + }); + return; + } + + let data: Uint8Array; + try { + data = decodeBase64(payloadB64); + } catch { + return; + } + events.push({ + type: "graphics", + event: { + control, + data, + }, + }); + } +} + +/** Merge adjacent text events into a single chunk so callers see one writeRaw per contiguous run. */ +function coalesce(events: StreamEvent[]): StreamEvent[] { + if (events.length < 2) return events; + const out: StreamEvent[] = []; + for (const ev of events) { + const last = out[out.length - 1]; + if (ev.type === "text" && last && last.type === "text") { + const merged = new Uint8Array(last.bytes.length + ev.bytes.length); + merged.set(last.bytes, 0); + merged.set(ev.bytes, last.bytes.length); + out[out.length - 1] = { type: "text", bytes: merged }; + } else { + out.push(ev); + } + } + return out; +} + +function chunkKey(control: KittyControl): string { + if (typeof control.i === "number") return `i:${control.i}`; + if (typeof control.I === "number") return `I:${control.I}`; + return "default"; +} + +const LATIN1 = new TextDecoder("latin1"); + +function decodeAscii(bytes: number[]): string { + return LATIN1.decode(new Uint8Array(bytes)); +} + +function parseControl(s: string): KittyControl { + const out: KittyControl = {}; + if (!s) return out; + for (const part of s.split(",")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const key = part.slice(0, eq).trim(); + const val = part.slice(eq + 1).trim(); + if (!key) continue; + const asNum = Number(val); + if (val !== "" && !Number.isNaN(asNum) && /^-?\d+$/.test(val)) { + out[key] = asNum; + } else { + out[key] = val; + } + } + return out; +} + +function decodeBase64(b64: string): Uint8Array { + if (!b64) return new Uint8Array(0); + const cleaned = b64.replace(/[^A-Za-z0-9+/=]/g, ""); + const binary = atob(cleaned); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} diff --git a/packages/@wterm/dom/src/png.ts b/packages/@wterm/dom/src/png.ts new file mode 100644 index 0000000..74f257b --- /dev/null +++ b/packages/@wterm/dom/src/png.ts @@ -0,0 +1,30 @@ +/** + * Extract `(width, height)` in pixels from a PNG file's IHDR chunk without + * decoding the image. Returns `null` when the input is not a valid PNG. + */ +export function pngDimensions( + bytes: Uint8Array, +): { width: number; height: number } | null { + if (bytes.length < 24) return null; + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + if ( + bytes[0] !== 0x89 || + bytes[1] !== 0x50 || + bytes[2] !== 0x4e || + bytes[3] !== 0x47 || + bytes[4] !== 0x0d || + bytes[5] !== 0x0a || + bytes[6] !== 0x1a || + bytes[7] !== 0x0a + ) { + return null; + } + // The first chunk after the signature must be IHDR. Width/height live at + // bytes 16..23 as big-endian uint32. + const width = + (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; + const height = + (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; + if (width <= 0 || height <= 0) return null; + return { width: width >>> 0, height: height >>> 0 }; +} diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index a5ed237..7dda307 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -218,7 +218,16 @@ export class Renderer { setup(cols: number, rows: number): void { this.cols = cols; this.rows = rows; - this.container.innerHTML = ""; + // Remove only renderer-owned row elements so overlay layers (e.g. inline + // image overlay) survive resize. + for (const child of Array.from(this.container.children)) { + if ( + (child as HTMLElement).classList.contains("term-row") || + (child as HTMLElement).classList.contains("term-scrollback-row") + ) { + child.remove(); + } + } this.rowEls = []; this.prevRowBg = []; this._scrollbackRowEls = []; diff --git a/packages/@wterm/dom/src/terminal.css b/packages/@wterm/dom/src/terminal.css index a88e475..50a4600 100644 --- a/packages/@wterm/dom/src/terminal.css +++ b/packages/@wterm/dom/src/terminal.css @@ -45,11 +45,26 @@ .term-grid { display: block; + position: relative; white-space: pre; contain: layout paint style; will-change: contents; } +.term-image-layer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; +} + +.term-image { + position: absolute; + pointer-events: auto; + user-select: none; + -webkit-user-drag: none; +} + .term-row { display: block; height: var(--term-row-height); diff --git a/packages/@wterm/dom/src/wterm.ts b/packages/@wterm/dom/src/wterm.ts index 0885ef9..f316b38 100644 --- a/packages/@wterm/dom/src/wterm.ts +++ b/packages/@wterm/dom/src/wterm.ts @@ -2,6 +2,9 @@ import { WasmBridge, type TerminalCore } from "@wterm/core"; import { Renderer } from "./renderer.js"; import { InputHandler } from "./input.js"; import { DebugAdapter } from "./debug.js"; +import { KittyGraphicsFilter } from "./kitty-graphics.js"; +import { ImageOverlay } from "./image-overlay.js"; +import { pngDimensions } from "./png.js"; export interface WTermOptions { cols?: number; @@ -15,6 +18,12 @@ export interface WTermOptions { autoResize?: boolean; cursorBlink?: boolean; debug?: boolean; + /** + * Enable inline image rendering via the Kitty terminal graphics protocol. + * Defaults to `true`. Set to `false` to pass APC `_G` sequences through + * to the core unchanged. + */ + images?: boolean; onData?: (data: string) => void; onTitle?: (title: string) => void; onResize?: (cols: number, rows: number) => void; @@ -39,7 +48,12 @@ export class WTerm { private _destroyed = false; private _shouldScrollToBottom = false; private _rowHeight = 0; + private _charWidth = 0; private _onClickFocus: () => void; + private _imagesEnabled: boolean; + private _kittyFilter: KittyGraphicsFilter | null = null; + private _imageOverlay: ImageOverlay | null = null; + private _textEncoder = new TextEncoder(); onData: ((data: string) => void) | null; onTitle: ((title: string) => void) | null; @@ -55,6 +69,7 @@ export class WTerm { this.rows = options.rows || 24; this.autoResize = options.autoResize !== false; this._debugEnabled = options.debug ?? false; + this._imagesEnabled = options.images !== false; this.onData = options.onData || null; this.onTitle = options.onTitle || null; @@ -94,6 +109,20 @@ export class WTerm { this.renderer = new Renderer(this._container); this.renderer.setup(this.cols, this.rows); + if (this._imagesEnabled) { + this._kittyFilter = new KittyGraphicsFilter(); + this._imageOverlay = new ImageOverlay(this._container); + const cellSize = this._measureCharSize(); + if (cellSize) { + this._charWidth = cellSize.charWidth; + this._rowHeight = cellSize.rowHeight; + this._imageOverlay.setCellMetrics( + cellSize.charWidth, + cellSize.rowHeight, + ); + } + } + this.input = new InputHandler( this.element, (data) => { @@ -145,7 +174,26 @@ export class WTerm { if (!this.bridge) return; if (this.debug) this.debug.traceWrite(data); this._shouldScrollToBottom = this._isScrolledToBottom(); - if (typeof data === "string") { + + if (this._kittyFilter && this._imageOverlay) { + const bytes = + typeof data === "string" ? this._textEncoder.encode(data) : data; + const events = this._kittyFilter.push(bytes); + for (const ev of events) { + if (ev.type === "text") { + this.bridge.writeRaw(ev.bytes); + continue; + } + const cursor = this.bridge.getCursor(); + const scrollbackCount = this.bridge.getScrollbackCount(); + this._imageOverlay.handle(ev.event, { + row: cursor.row, + col: cursor.col, + scrollbackCount, + }); + this._advanceCursorForImage(ev.event); + } + } else if (typeof data === "string") { this.bridge.writeString(data); } else { this.bridge.writeRaw(data); @@ -153,6 +201,30 @@ export class WTerm { this._scheduleRender(); } + private _advanceCursorForImage(event: { + control: Record; + data: Uint8Array; + }): void { + if (!this.bridge) return; + const action = String(event.control.a ?? "T"); + if (action !== "T" && action !== "p") return; + if (event.control.C === 1) return; + + let cellRows: number | null = null; + if (typeof event.control.r === "number" && event.control.r > 0) { + cellRows = event.control.r; + } else if (event.data.length > 0 && this._rowHeight > 0) { + const dims = pngDimensions(event.data); + if (dims) { + cellRows = Math.max(1, Math.ceil(dims.height / this._rowHeight)); + } + } + + if (cellRows == null) return; + const newlines = "\n".repeat(cellRows); + this.bridge.writeRaw(this._textEncoder.encode(newlines)); + } + resize(cols: number, rows: number): void { if (!this.bridge) return; this._shouldScrollToBottom = this._isScrolledToBottom(); @@ -290,6 +362,9 @@ export class WTerm { if (measured) { charWidth = measured.charWidth; rowHeight = measured.rowHeight; + this._charWidth = charWidth; + this._rowHeight = rowHeight; + this._imageOverlay?.setCellMetrics(charWidth, rowHeight); } for (const entry of entries) { @@ -310,6 +385,9 @@ export class WTerm { if (this.rafId != null) cancelAnimationFrame(this.rafId); if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.input) this.input.destroy(); + if (this._imageOverlay) this._imageOverlay.destroy(); + this._imageOverlay = null; + this._kittyFilter = null; this.element.removeEventListener("click", this._onClickFocus); this.element.innerHTML = ""; if ( diff --git a/packages/@wterm/react/src/Terminal.tsx b/packages/@wterm/react/src/Terminal.tsx index f37062b..b313049 100644 --- a/packages/@wterm/react/src/Terminal.tsx +++ b/packages/@wterm/react/src/Terminal.tsx @@ -26,6 +26,11 @@ export interface TerminalProps extends Omit< cursorBlink?: boolean; /** Enable debug mode (init-only — changing after mount has no effect). */ debug?: boolean; + /** + * Enable inline image rendering via the Kitty terminal graphics protocol + * (init-only). Defaults to `true`. + */ + images?: boolean; onData?: (data: string) => void; onTitle?: (title: string) => void; onResize?: (cols: number, rows: number) => void; @@ -50,6 +55,7 @@ const Terminal = forwardRef(function Terminal( autoResize = false, cursorBlink = false, debug = false, + images, onData, onTitle, onResize, @@ -103,6 +109,7 @@ const Terminal = forwardRef(function Terminal( autoResize: autoResizeRef.current, cursorBlink, debug, + images, onData: callbacksRef.current.onData ? (data: string) => callbacksRef.current.onData?.(data) : undefined, diff --git a/packages/@wterm/vue/src/Terminal.ts b/packages/@wterm/vue/src/Terminal.ts index cc6e4df..481eaf5 100644 --- a/packages/@wterm/vue/src/Terminal.ts +++ b/packages/@wterm/vue/src/Terminal.ts @@ -92,6 +92,12 @@ const Terminal = defineComponent({ * @defaultValue false */ debug: Boolean, + /** + * Enable inline image rendering via the Kitty terminal graphics protocol + * (init-only — changing after mount has no effect). + * @defaultValue true + */ + images: { type: Boolean, default: true }, }, // Object form: validator signatures carry emit payload types to @@ -140,6 +146,7 @@ const Terminal = defineComponent({ autoResize: props.autoResize, cursorBlink: props.cursorBlink, debug: props.debug, + images: props.images, onData: hasDataListener ? (data: string) => emit("data", data) : undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 432b5e2..a0c1e0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - next-themes@0.4.6: - hash: 73478c20fb87207168b5c0fa9ccabf0c042408da2bc5a36131ed1c8bb57bf5a3 - path: patches/next-themes@0.4.6.patch + next-themes@0.4.6: 73478c20fb87207168b5c0fa9ccabf0c042408da2bc5a36131ed1c8bb57bf5a3 importers: @@ -127,7 +125,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + version: 16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) tailwindcss: specifier: ^4 version: 4.2.2 @@ -151,6 +149,22 @@ importers: specifier: ^6.3.5 version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + examples/kitty-images: + dependencies: + '@wterm/dom': + specifier: workspace:* + version: link:../../packages/@wterm/dom + '@wterm/ghostty': + specifier: workspace:* + version: link:../../packages/@wterm/ghostty + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^6.3.5 + version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + examples/local: dependencies: '@tailwindcss/postcss': @@ -210,7 +224,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + version: 16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) tsx: specifier: ^4.19.4 version: 4.21.0 @@ -1495,89 +1509,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1756,24 +1786,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.3': resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.3': resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.3': resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.3': resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} @@ -2568,36 +2602,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} @@ -2680,66 +2720,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -2856,24 +2909,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -3208,6 +3265,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3248,41 +3306,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -5382,24 +5448,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -11277,7 +11347,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.3 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) @@ -11320,7 +11390,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -11335,11 +11405,36 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.7 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.16 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) @@ -11357,7 +11452,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11386,7 +11481,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3