From 95db1b1ac2bc90a964b3b6028165060758736578 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 21 May 2026 10:28:05 -0700 Subject: [PATCH 1/3] feat: render inline images via Kitty graphics protocol Intercepts APC `\x1b_G...\x1b\\` sequences from input streams in `@wterm/dom`, decodes the base64 PNG payload (including chunked `m=1`/`m=0` transfers), and renders the image as an absolutely- positioned `` overlay inside the cell grid. The overlay layer stays aligned with its anchor row across scrollback growth because the renderer inserts new scrollback rows above existing grid rows, keeping content's pixel position invariant. Supports actions `t`/`T`/`p`/`d` with PNG format (`f=100`) over the direct base64 transport, plus `c=`/`r=` cell-fit sizing, `i=`/`I=` identification, and the `C=1` no-cursor-movement opt-out. The default `T`/`p` cursor advance is implemented by writing newlines to the core based on the image's row count (taken from `r=` or parsed from the PNG IHDR). Not in scope: raw RGB/RGBA frames, file/shared-memory transports, virtual placement via Unicode placeholders, animations, Sixel, and iTerm2 inline images. Opt out with `new WTerm(el, { images: false })`. The same option is threaded through `@wterm/react` and `@wterm/vue`. Closes #60 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Lars Trieloff --- README.md | 19 +- apps/docs/src/app/api-reference/page.mdx | 581 +++++++++++++----- apps/docs/src/app/configuration/page.mdx | 8 +- examples/kitty-images/README.md | 29 + examples/kitty-images/index.html | 27 + examples/kitty-images/package.json | 21 + examples/kitty-images/src/main.ts | 78 +++ examples/kitty-images/tsconfig.json | 13 + examples/kitty-images/vite.config.ts | 7 + examples/kitty-images/wterm-dom.d.ts | 1 + packages/@wterm/dom/README.md | 49 +- .../dom/src/__tests__/kitty-graphics.test.ts | 147 +++++ .../@wterm/dom/src/__tests__/wterm.test.ts | 17 +- packages/@wterm/dom/src/image-overlay.ts | 192 ++++++ packages/@wterm/dom/src/index.ts | 7 + packages/@wterm/dom/src/kitty-graphics.ts | 321 ++++++++++ packages/@wterm/dom/src/png.ts | 30 + packages/@wterm/dom/src/renderer.ts | 11 +- packages/@wterm/dom/src/terminal.css | 29 +- packages/@wterm/dom/src/wterm.ts | 80 ++- packages/@wterm/react/src/Terminal.tsx | 7 + packages/@wterm/vue/src/Terminal.ts | 7 + pnpm-lock.yaml | 115 +++- 23 files changed, 1602 insertions(+), 194 deletions(-) create mode 100644 examples/kitty-images/README.md create mode 100644 examples/kitty-images/index.html create mode 100644 examples/kitty-images/package.json create mode 100644 examples/kitty-images/src/main.ts create mode 100644 examples/kitty-images/tsconfig.json create mode 100644 examples/kitty-images/vite.config.ts create mode 100644 examples/kitty-images/wterm-dom.d.ts create mode 100644 packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts create mode 100644 packages/@wterm/dom/src/image-overlay.ts create mode 100644 packages/@wterm/dom/src/kitty-graphics.ts create mode 100644 packages/@wterm/dom/src/png.ts diff --git a/README.md b/README.md index 14b6bd4..c7ed67b 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin ## Packages -| Package | Description | -|---|---| -| [`@wterm/core`](packages/@wterm/core) | Headless WASM bridge, `TerminalCore` interface, WebSocket transport | -| [`@wterm/dom`](packages/@wterm/dom) | DOM renderer, input handler — vanilla JS terminal | -| [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) | -| [`@wterm/vue`](packages/@wterm/vue) | Vue 3 component + template ref API | -| [`@wterm/ghostty`](packages/@wterm/ghostty) | Full-featured VT emulation core powered by libghostty | -| [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash | -| [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal | +| Package | Description | +| ----------------------------------------------- | ------------------------------------------------------------------- | +| [`@wterm/core`](packages/@wterm/core) | Headless WASM bridge, `TerminalCore` interface, WebSocket transport | +| [`@wterm/dom`](packages/@wterm/dom) | DOM renderer, input handler — vanilla JS terminal | +| [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) | +| [`@wterm/vue`](packages/@wterm/vue) | Vue 3 component + template ref API | +| [`@wterm/ghostty`](packages/@wterm/ghostty) | Full-featured VT emulation core powered by libghostty | +| [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash | +| [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal | ## Features @@ -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..8e6381c 100644 --- a/apps/docs/src/app/api-reference/page.mdx +++ b/apps/docs/src/app/api-reference/page.mdx @@ -17,62 +17,152 @@ The React and Vue `` components and the vanilla `WTerm` constructor al - cols - number - 80 + + cols + + + number + + + 80 + Initial column count - rows - number - 24 + + rows + + + number + + + 24 + Initial row count - core - TerminalCore + + core + + + TerminalCore + — - A pre-constructed terminal core instance. When provided, wasmUrl is ignored and this core is used instead of loading the built-in Zig WASM binary. See Ghostty Core for an example. - - - wasmUrl - string + + A pre-constructed terminal core instance. When provided,{" "} + wasmUrl is ignored and this core is used instead of loading + the built-in Zig WASM binary. See Ghostty Core{" "} + for an example. + + + + + wasmUrl + + + string + — - URL to serve the WASM binary separately. When omitted, the ~12 KB binary is decoded from an inlined base64 string. Ignored when core is provided. - - - autoResize - boolean - true (vanilla) / false (React, Vue) - Automatically resize the terminal to fit its container using a ResizeObserver - - - cursorBlink - boolean - false + + URL to serve the WASM binary separately. When omitted, the ~12 KB binary + is decoded from an inlined base64 string. Ignored when core{" "} + is provided. + + + + + autoResize + + + boolean + + + true (vanilla) / false (React, Vue) + + + Automatically resize the terminal to fit its container using a{" "} + ResizeObserver + + + + + cursorBlink + + + boolean + + + false + Enable cursor blinking animation - debug - boolean - 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. - - - onData - (data: string) => void + + debug + + + boolean + + + 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 + — - 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 produces data (user input or host response). + When omitted, input is echoed back automatically. + + + + + onTitle + + + (title: string) => void + — Called when the terminal title changes via an escape sequence - onResize - (cols: number, rows: number) => void + + onResize + + + (cols: number, rows: number) => void + — Called after the terminal is resized @@ -93,19 +183,39 @@ The React `` component adds these props on top of the shared options a - theme - string - Name of a built-in or custom theme (see Themes) - - - onReady - (wt: WTerm) => void - Called with the underlying WTerm instance after WASM loads and initialization completes - - - onError - (error: unknown) => void - Called if WASM loading or initialization fails. When omitted, errors are logged to the console. + + theme + + + string + + + Name of a built-in or custom theme (see Themes) + + + + + onReady + + + (wt: WTerm) => void + + + Called with the underlying WTerm instance after WASM loads + and initialization completes + + + + + onError + + + (error: unknown) => void + + + Called if WASM loading or initialization fails. When omitted, errors are + logged to the console. + @@ -124,9 +234,16 @@ The Vue `` component adds these props on top of the shared options abo - theme - string - Name of a built-in or custom theme (see Themes). Applied as a theme-<name> class on the root element. + + theme + + + string + + + Name of a built-in or custom theme (see Themes). + Applied as a theme-<name> class on the root element. + @@ -143,28 +260,54 @@ The Vue `` component adds these props on top of the shared options abo - data - (data: string) - Emitted when the terminal produces data (user input or host response). When no listener is attached, input is echoed back automatically. - - - title - (title: string) + + data + + + (data: string) + + + Emitted when the terminal produces data (user input or host response). + When no listener is attached, input is echoed back automatically. + + + + + title + + + (title: string) + Emitted when the terminal title changes via an escape sequence. - resize - (cols: number, rows: number) + + resize + + + (cols: number, rows: number) + Emitted after the terminal is resized. - ready - (wt: WTerm) - Emitted once after WTerm.init() resolves, carrying the underlying WTerm instance. - - - error - (err: unknown) + + ready + + + (wt: WTerm) + + + Emitted once after WTerm.init() resolves, carrying the + underlying WTerm instance. + + + + + error + + + (err: unknown) + Emitted if WASM loading or initialization fails. @@ -183,23 +326,33 @@ Instance methods on the vanilla `WTerm` class: - init(): Promise<WTerm> + + init(): Promise<WTerm> + Load WASM and start rendering - write(data: string | Uint8Array) + + write(data: string | Uint8Array) + Write data to the terminal - resize(cols, rows) + + resize(cols, rows) + Resize the terminal grid - focus() + + focus() + Focus the terminal input - destroy() + + destroy() + Clean up event listeners, observers, and DOM @@ -223,23 +376,41 @@ const { ref, write, resize, focus } = useTerminal(); - ref - RefObject<TerminalHandle> - Pass to <Terminal ref={ref}> - - - write - (data: string | Uint8Array) => void + + ref + + + RefObject<TerminalHandle> + + + Pass to <Terminal ref={ref}> + + + + + write + + + (data: string | Uint8Array) => void + Write data to the terminal - resize - (cols: number, rows: number) => void + + resize + + + (cols: number, rows: number) => void + Resize the terminal grid - focus - () => void + + focus + + + () => void + Focus the terminal input @@ -283,24 +454,47 @@ const term = useTemplateRef("term"); - write - (data: string | Uint8Array) => void - Write data to the terminal. Safe to call after the ready event; calls before mount are ignored. - - - resize - (cols: number, rows: number) => void + + write + + + (data: string | Uint8Array) => void + + + Write data to the terminal. Safe to call after the ready{" "} + event; calls before mount are ignored. + + + + + resize + + + (cols: number, rows: number) => void + Resize the terminal grid. Calls before mount are ignored. - focus - () => void + + focus + + + () => void + Focus the terminal input. - instance - WTerm | null - Underlying WTerm instance. null until the component has mounted; the WASM bridge is only available after the ready event. + + instance + + + WTerm | null + + + Underlying WTerm instance. null until the + component has mounted; the WASM bridge is only available after the{" "} + ready event. + @@ -322,44 +516,76 @@ Connect to a PTY backend over WebSocket with automatic reconnection and send buf - url - string + + url + + + string + — WebSocket server URL - reconnect - boolean - true + + reconnect + + + boolean + + + true + Automatically reconnect on disconnect with exponential backoff - maxReconnectDelay - number - 30000 + + maxReconnectDelay + + + number + + + 30000 + Maximum delay between reconnection attempts (ms) - onData - (data: Uint8Array | string) => void + + onData + + + (data: Uint8Array | string) => void + — Called when data is received from the server - onOpen - () => void + + onOpen + + + () => void + — Called when the connection opens - onClose - () => void + + onClose + + + () => void + — Called when the connection closes - onError - (event: Event) => void + + onError + + + (event: Event) => void + — Called when a WebSocket error occurs @@ -377,15 +603,24 @@ Connect to a PTY backend over WebSocket with automatic reconnection and send buf - connect(url?) + + connect(url?) + Open the WebSocket connection. Optionally override the URL. - send(data: string | Uint8Array) - Send data to the server. If the socket is not yet open, data is buffered and flushed on connect. + + send(data: string | Uint8Array) + + + Send data to the server. If the socket is not yet open, data is buffered + and flushed on connect. + - close() + + close() + Close the connection and stop reconnection attempts. @@ -403,8 +638,12 @@ Connect to a PTY backend over WebSocket with automatic reconnection and send buf - connected - boolean + + connected + + + boolean + Whether the WebSocket is currently open @@ -436,75 +675,121 @@ When no URL is provided, the ~12 KB WASM binary is decoded from a base64 string - WasmBridge.load(url?): Promise<WasmBridge> + + WasmBridge.load(url?): Promise<WasmBridge> + Load the WASM binary and return a new bridge instance - init(cols, rows) + + init(cols, rows) + Initialize the terminal grid - writeString(str) + + writeString(str) + Write a UTF-8 string (including escape sequences) to the terminal - writeRaw(data: Uint8Array) - Write raw bytes to the terminal (chunked to 8192 bytes internally) + + writeRaw(data: Uint8Array) + + + Write raw bytes to the terminal (chunked to 8192 bytes internally) + - resize(cols, rows) + + resize(cols, rows) + Resize the terminal grid - getCell(row, col): CellData + + getCell(row, col): CellData + Get cell data at a grid position - getCursor(): CursorState + + getCursor(): CursorState + Get current cursor position and visibility - getCols() / getRows() + + getCols() / getRows() + Get current grid dimensions - isDirtyRow(row): boolean - Check if a row has changed since last clearDirty() + + isDirtyRow(row): boolean + + + Check if a row has changed since last clearDirty() + - clearDirty() + + clearDirty() + Reset all dirty-row flags - getTitle(): string | null - Get pending title change (via OSC escape), or null if unchanged + + getTitle(): string | null + + + Get pending title change (via OSC escape), or null if + unchanged + - getResponse(): string | null - Get pending host response (e.g. DSR), or null. Reading clears the buffer. + + getResponse(): string | null + + + Get pending host response (e.g. DSR), or null. Reading + clears the buffer. + - getScrollbackCount(): number + + getScrollbackCount(): number + Number of lines in the scrollback buffer - getScrollbackCell(offset, col): CellData + + getScrollbackCell(offset, col): CellData + Get cell data from a scrollback line - getScrollbackLineLen(offset): number + + getScrollbackLineLen(offset): number + Get the length of a scrollback line - cursorKeysApp(): boolean + + cursorKeysApp(): boolean + Whether cursor keys are in application mode - bracketedPaste(): boolean + + bracketedPaste(): boolean + Whether bracketed paste mode is active - usingAltScreen(): boolean + + usingAltScreen(): boolean + Whether the alternate screen buffer is active @@ -514,10 +799,10 @@ When no URL is provided, the ~12 KB WASM binary is decoded from a base64 string ```ts interface CellData { - char: number; // Unicode code point - fg: number; // Foreground color index (256 = default) - bg: number; // Background color index (256 = default) - flags: number; // Style flags (bold, italic, underline, etc.) + char: number; // Unicode code point + fg: number; // Foreground color index (256 = default) + bg: number; // Background color index (256 = default) + flags: number; // Style flags (bold, italic, underline, etc.) } interface CursorState { 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..8805cfd 100644 --- a/packages/@wterm/dom/README.md +++ b/packages/@wterm/dom/README.md @@ -38,27 +38,28 @@ new WTerm(element: HTMLElement, options?: WTermOptions) **Options:** -| Option | Type | Default | Description | -|---|---|---|---| -| `cols` | `number` | `80` | Initial column count | -| `rows` | `number` | `24` | Initial row count | -| `wasmUrl` | `string` | — | Optional URL to serve the WASM binary separately (embedded by default) | -| `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. | -| `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 | +| Option | Type | Default | Description | +| ------------- | -------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cols` | `number` | `80` | Initial column count | +| `rows` | `number` | `24` | Initial row count | +| `wasmUrl` | `string` | — | Optional URL to serve the WASM binary separately (embedded by default) | +| `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 | **Methods:** -| Method | Description | -|---|---| -| `init(): Promise` | Load WASM and start rendering | -| `write(data: string \| Uint8Array)` | Write data to the terminal | -| `resize(cols, rows)` | Resize the terminal grid | -| `focus()` | Focus the terminal element | -| `destroy()` | Clean up event listeners and DOM | +| Method | Description | +| ----------------------------------- | -------------------------------- | +| `init(): Promise` | Load WASM and start rendering | +| `write(data: string \| Uint8Array)` | Write data to the terminal | +| `resize(cols, rows)` | Resize the terminal grid | +| `focus()` | Focus the terminal element | +| `destroy()` | Clean up event listeners and DOM | ### `WebSocketTransport` @@ -79,6 +80,18 @@ 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 = await fetch("/icon.png").then((r) => r.bytes()); +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..ace6866 --- /dev/null +++ b/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { KittyGraphicsFilter, 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"); + }); +}); diff --git a/packages/@wterm/dom/src/__tests__/wterm.test.ts b/packages/@wterm/dom/src/__tests__/wterm.test.ts index 5ca798f..7df4f1d 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,18 @@ 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(); }); }); @@ -244,7 +253,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..41cfbcf --- /dev/null +++ b/packages/@wterm/dom/src/image-overlay.ts @@ -0,0 +1,192 @@ +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; +} + +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()) 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); + if (!key) return; + this.images.set(key, { data: event.data }); + } + + private _place( + control: KittyControl, + anchor: { row: number; col: number; scrollbackCount: number }, + ): void { + const key = imageKey(control); + if (!key) return; + 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) 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" }); + img.src = URL.createObjectURL(blob); + img.addEventListener("load", () => URL.revokeObjectURL(img.src), { + 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, + }); + } + + 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)) { + p.el.remove(); + this.placements.delete(k); + } + } + }; + + switch (lower) { + case "a": + removeMatching(() => true); + break; + case "i": { + const key = imageKey(control); + if (!key) return; + 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 | null { + 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..a0b1d1b --- /dev/null +++ b/packages/@wterm/dom/src/kitty-graphics.ts @@ -0,0 +1,321 @@ +/** + * 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; +} + +/** + * 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(); + + /** + * 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(); + } + + 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) { + existing.payload += payloadB64; + // Merge controls — later chunks may omit fields. Keep first-chunk + // values, but `m` reflects the latest. + existing.control.m = control.m; + } else { + this.pendingChunks.set(key, { + control: { ...control }, + payload: payloadB64, + }); + } + + if (more) return; + + const completed = this.pendingChunks.get(key); + this.pendingChunks.delete(key); + if (!completed) return; + events.push({ + type: "graphics", + event: { + control: completed.control, + data: decodeBase64(completed.payload), + }, + }); + return; + } + + events.push({ + type: "graphics", + event: { + control, + data: decodeBase64(payloadB64), + }, + }); + } +} + +/** 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"; +} + +function decodeAscii(bytes: number[]): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return s; +} + +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..9f4b2e1 100644 --- a/packages/@wterm/dom/src/terminal.css +++ b/packages/@wterm/dom/src/terminal.css @@ -20,7 +20,8 @@ --term-color-14: #4ec9b0; --term-color-15: #ffffff; - --term-font-family: 'Menlo', 'Consolas', 'DejaVu Sans Mono', 'Courier New', monospace; + --term-font-family: + "Menlo", "Consolas", "DejaVu Sans Mono", "Courier New", monospace; --term-font-size: 14px; --term-line-height: 1.2; --term-row-height: 17px; @@ -45,11 +46,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); @@ -84,8 +100,15 @@ } @keyframes cursor-blink { - 0%, 100% { background: var(--term-cursor); color: var(--term-bg); } - 50% { background: transparent; color: inherit; } + 0%, + 100% { + background: var(--term-cursor); + color: var(--term-bg); + } + 50% { + background: transparent; + color: inherit; + } } .wterm.has-scrollback { 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 From 429e1585fecf31cda4bd2070c1cd2b860dd7f431 Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Thu, 21 May 2026 12:00:06 -0700 Subject: [PATCH 2/3] Revert unrelated reformatting; keep only Kitty-graphics additions Restores the original compact formatting on lines the Kitty graphics feature does not need to touch, addressing PR #77 review feedback: - packages/@wterm/dom/src/terminal.css: --term-font-family back to a single line with single quotes; @keyframes cursor-blink back to 2-line compact form. Keeps position: relative on .term-grid plus the new .term-image-layer / .term-image rules. - packages/@wterm/dom/README.md: Options and Methods tables restored to compact pipe-table style; new images row added in the same style; Inline images section preserved. - apps/docs/src/app/api-reference/page.mdx: pre-existing cells back to one line; new images row added in the same compact JSX style. - README.md: Packages table restored to compact form; Inline images Features bullet preserved. --- README.md | 18 +- apps/docs/src/app/api-reference/page.mdx | 587 ++++++----------------- packages/@wterm/dom/README.md | 38 +- packages/@wterm/dom/src/terminal.css | 14 +- 4 files changed, 185 insertions(+), 472 deletions(-) diff --git a/README.md b/README.md index c7ed67b..0207ddd 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin ## Packages -| Package | Description | -| ----------------------------------------------- | ------------------------------------------------------------------- | -| [`@wterm/core`](packages/@wterm/core) | Headless WASM bridge, `TerminalCore` interface, WebSocket transport | -| [`@wterm/dom`](packages/@wterm/dom) | DOM renderer, input handler — vanilla JS terminal | -| [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) | -| [`@wterm/vue`](packages/@wterm/vue) | Vue 3 component + template ref API | -| [`@wterm/ghostty`](packages/@wterm/ghostty) | Full-featured VT emulation core powered by libghostty | -| [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash | -| [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal | +| Package | Description | +|---|---| +| [`@wterm/core`](packages/@wterm/core) | Headless WASM bridge, `TerminalCore` interface, WebSocket transport | +| [`@wterm/dom`](packages/@wterm/dom) | DOM renderer, input handler — vanilla JS terminal | +| [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) | +| [`@wterm/vue`](packages/@wterm/vue) | Vue 3 component + template ref API | +| [`@wterm/ghostty`](packages/@wterm/ghostty) | Full-featured VT emulation core powered by libghostty | +| [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash | +| [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal | ## Features diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx index 8e6381c..441594b 100644 --- a/apps/docs/src/app/api-reference/page.mdx +++ b/apps/docs/src/app/api-reference/page.mdx @@ -17,152 +17,68 @@ The React and Vue `` components and the vanilla `WTerm` constructor al - - cols - - - number - - - 80 - + cols + number + 80 Initial column count - - rows - - - number - - - 24 - + rows + number + 24 Initial row count - - core - - - TerminalCore - + core + TerminalCore — - - A pre-constructed terminal core instance. When provided,{" "} - wasmUrl is ignored and this core is used instead of loading - the built-in Zig WASM binary. See Ghostty Core{" "} - for an example. - - - - - wasmUrl - - - string - + A pre-constructed terminal core instance. When provided, wasmUrl is ignored and this core is used instead of loading the built-in Zig WASM binary. See Ghostty Core for an example. + + + wasmUrl + string — - - URL to serve the WASM binary separately. When omitted, the ~12 KB binary - is decoded from an inlined base64 string. Ignored when core{" "} - is provided. - - - - - autoResize - - - boolean - - - true (vanilla) / false (React, Vue) - - - Automatically resize the terminal to fit its container using a{" "} - ResizeObserver - - - - - cursorBlink - - - boolean - - - false - + URL to serve the WASM binary separately. When omitted, the ~12 KB binary is decoded from an inlined base64 string. Ignored when core is provided. + + + autoResize + boolean + true (vanilla) / false (React, Vue) + Automatically resize the terminal to fit its container using a ResizeObserver + + + cursorBlink + boolean + false Enable cursor blinking animation - - debug - - - boolean - - - 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 - + debug + boolean + 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 — - - 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 produces data (user input or host response). When omitted, input is echoed back automatically. + + + onTitle + (title: string) => void — Called when the terminal title changes via an escape sequence - - onResize - - - (cols: number, rows: number) => void - + onResize + (cols: number, rows: number) => void — Called after the terminal is resized @@ -183,39 +99,19 @@ The React `` component adds these props on top of the shared options a - - theme - - - string - - - Name of a built-in or custom theme (see Themes) - - - - - onReady - - - (wt: WTerm) => void - - - Called with the underlying WTerm instance after WASM loads - and initialization completes - - - - - onError - - - (error: unknown) => void - - - Called if WASM loading or initialization fails. When omitted, errors are - logged to the console. - + theme + string + Name of a built-in or custom theme (see Themes) + + + onReady + (wt: WTerm) => void + Called with the underlying WTerm instance after WASM loads and initialization completes + + + onError + (error: unknown) => void + Called if WASM loading or initialization fails. When omitted, errors are logged to the console. @@ -234,16 +130,9 @@ The Vue `` component adds these props on top of the shared options abo - - theme - - - string - - - Name of a built-in or custom theme (see Themes). - Applied as a theme-<name> class on the root element. - + theme + string + Name of a built-in or custom theme (see Themes). Applied as a theme-<name> class on the root element. @@ -260,54 +149,28 @@ The Vue `` component adds these props on top of the shared options abo - - data - - - (data: string) - - - Emitted when the terminal produces data (user input or host response). - When no listener is attached, input is echoed back automatically. - - - - - title - - - (title: string) - + data + (data: string) + Emitted when the terminal produces data (user input or host response). When no listener is attached, input is echoed back automatically. + + + title + (title: string) Emitted when the terminal title changes via an escape sequence. - - resize - - - (cols: number, rows: number) - + resize + (cols: number, rows: number) Emitted after the terminal is resized. - - ready - - - (wt: WTerm) - - - Emitted once after WTerm.init() resolves, carrying the - underlying WTerm instance. - - - - - error - - - (err: unknown) - + ready + (wt: WTerm) + Emitted once after WTerm.init() resolves, carrying the underlying WTerm instance. + + + error + (err: unknown) Emitted if WASM loading or initialization fails. @@ -326,33 +189,23 @@ Instance methods on the vanilla `WTerm` class: - - init(): Promise<WTerm> - + init(): Promise<WTerm> Load WASM and start rendering - - write(data: string | Uint8Array) - + write(data: string | Uint8Array) Write data to the terminal - - resize(cols, rows) - + resize(cols, rows) Resize the terminal grid - - focus() - + focus() Focus the terminal input - - destroy() - + destroy() Clean up event listeners, observers, and DOM @@ -376,41 +229,23 @@ const { ref, write, resize, focus } = useTerminal(); - - ref - - - RefObject<TerminalHandle> - - - Pass to <Terminal ref={ref}> - - - - - write - - - (data: string | Uint8Array) => void - + ref + RefObject<TerminalHandle> + Pass to <Terminal ref={ref}> + + + write + (data: string | Uint8Array) => void Write data to the terminal - - resize - - - (cols: number, rows: number) => void - + resize + (cols: number, rows: number) => void Resize the terminal grid - - focus - - - () => void - + focus + () => void Focus the terminal input @@ -454,47 +289,24 @@ const term = useTemplateRef("term"); - - write - - - (data: string | Uint8Array) => void - - - Write data to the terminal. Safe to call after the ready{" "} - event; calls before mount are ignored. - - - - - resize - - - (cols: number, rows: number) => void - + write + (data: string | Uint8Array) => void + Write data to the terminal. Safe to call after the ready event; calls before mount are ignored. + + + resize + (cols: number, rows: number) => void Resize the terminal grid. Calls before mount are ignored. - - focus - - - () => void - + focus + () => void Focus the terminal input. - - instance - - - WTerm | null - - - Underlying WTerm instance. null until the - component has mounted; the WASM bridge is only available after the{" "} - ready event. - + instance + WTerm | null + Underlying WTerm instance. null until the component has mounted; the WASM bridge is only available after the ready event. @@ -516,76 +328,44 @@ Connect to a PTY backend over WebSocket with automatic reconnection and send buf - - url - - - string - + url + string — WebSocket server URL - - reconnect - - - boolean - - - true - + reconnect + boolean + true Automatically reconnect on disconnect with exponential backoff - - maxReconnectDelay - - - number - - - 30000 - + maxReconnectDelay + number + 30000 Maximum delay between reconnection attempts (ms) - - onData - - - (data: Uint8Array | string) => void - + onData + (data: Uint8Array | string) => void — Called when data is received from the server - - onOpen - - - () => void - + onOpen + () => void — Called when the connection opens - - onClose - - - () => void - + onClose + () => void — Called when the connection closes - - onError - - - (event: Event) => void - + onError + (event: Event) => void — Called when a WebSocket error occurs @@ -603,24 +383,15 @@ Connect to a PTY backend over WebSocket with automatic reconnection and send buf - - connect(url?) - + connect(url?) Open the WebSocket connection. Optionally override the URL. - - send(data: string | Uint8Array) - - - Send data to the server. If the socket is not yet open, data is buffered - and flushed on connect. - + send(data: string | Uint8Array) + Send data to the server. If the socket is not yet open, data is buffered and flushed on connect. - - close() - + close() Close the connection and stop reconnection attempts. @@ -638,12 +409,8 @@ Connect to a PTY backend over WebSocket with automatic reconnection and send buf - - connected - - - boolean - + connected + boolean Whether the WebSocket is currently open @@ -675,121 +442,75 @@ When no URL is provided, the ~12 KB WASM binary is decoded from a base64 string - - WasmBridge.load(url?): Promise<WasmBridge> - + WasmBridge.load(url?): Promise<WasmBridge> Load the WASM binary and return a new bridge instance - - init(cols, rows) - + init(cols, rows) Initialize the terminal grid - - writeString(str) - + writeString(str) Write a UTF-8 string (including escape sequences) to the terminal - - writeRaw(data: Uint8Array) - - - Write raw bytes to the terminal (chunked to 8192 bytes internally) - + writeRaw(data: Uint8Array) + Write raw bytes to the terminal (chunked to 8192 bytes internally) - - resize(cols, rows) - + resize(cols, rows) Resize the terminal grid - - getCell(row, col): CellData - + getCell(row, col): CellData Get cell data at a grid position - - getCursor(): CursorState - + getCursor(): CursorState Get current cursor position and visibility - - getCols() / getRows() - + getCols() / getRows() Get current grid dimensions - - isDirtyRow(row): boolean - - - Check if a row has changed since last clearDirty() - + isDirtyRow(row): boolean + Check if a row has changed since last clearDirty() - - clearDirty() - + clearDirty() Reset all dirty-row flags - - getTitle(): string | null - - - Get pending title change (via OSC escape), or null if - unchanged - + getTitle(): string | null + Get pending title change (via OSC escape), or null if unchanged - - getResponse(): string | null - - - Get pending host response (e.g. DSR), or null. Reading - clears the buffer. - + getResponse(): string | null + Get pending host response (e.g. DSR), or null. Reading clears the buffer. - - getScrollbackCount(): number - + getScrollbackCount(): number Number of lines in the scrollback buffer - - getScrollbackCell(offset, col): CellData - + getScrollbackCell(offset, col): CellData Get cell data from a scrollback line - - getScrollbackLineLen(offset): number - + getScrollbackLineLen(offset): number Get the length of a scrollback line - - cursorKeysApp(): boolean - + cursorKeysApp(): boolean Whether cursor keys are in application mode - - bracketedPaste(): boolean - + bracketedPaste(): boolean Whether bracketed paste mode is active - - usingAltScreen(): boolean - + usingAltScreen(): boolean Whether the alternate screen buffer is active @@ -799,10 +520,10 @@ When no URL is provided, the ~12 KB WASM binary is decoded from a base64 string ```ts interface CellData { - char: number; // Unicode code point - fg: number; // Foreground color index (256 = default) - bg: number; // Background color index (256 = default) - flags: number; // Style flags (bold, italic, underline, etc.) + char: number; // Unicode code point + fg: number; // Foreground color index (256 = default) + bg: number; // Background color index (256 = default) + flags: number; // Style flags (bold, italic, underline, etc.) } interface CursorState { diff --git a/packages/@wterm/dom/README.md b/packages/@wterm/dom/README.md index 8805cfd..8e1a28c 100644 --- a/packages/@wterm/dom/README.md +++ b/packages/@wterm/dom/README.md @@ -38,28 +38,28 @@ new WTerm(element: HTMLElement, options?: WTermOptions) **Options:** -| Option | Type | Default | Description | -| ------------- | -------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cols` | `number` | `80` | Initial column count | -| `rows` | `number` | `24` | Initial row count | -| `wasmUrl` | `string` | — | Optional URL to serve the WASM binary separately (embedded by default) | -| `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 | +| Option | Type | Default | Description | +|---|---|---|---| +| `cols` | `number` | `80` | Initial column count | +| `rows` | `number` | `24` | Initial row count | +| `wasmUrl` | `string` | — | Optional URL to serve the WASM binary separately (embedded by default) | +| `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 | **Methods:** -| Method | Description | -| ----------------------------------- | -------------------------------- | -| `init(): Promise` | Load WASM and start rendering | -| `write(data: string \| Uint8Array)` | Write data to the terminal | -| `resize(cols, rows)` | Resize the terminal grid | -| `focus()` | Focus the terminal element | -| `destroy()` | Clean up event listeners and DOM | +| Method | Description | +|---|---| +| `init(): Promise` | Load WASM and start rendering | +| `write(data: string \| Uint8Array)` | Write data to the terminal | +| `resize(cols, rows)` | Resize the terminal grid | +| `focus()` | Focus the terminal element | +| `destroy()` | Clean up event listeners and DOM | ### `WebSocketTransport` diff --git a/packages/@wterm/dom/src/terminal.css b/packages/@wterm/dom/src/terminal.css index 9f4b2e1..50a4600 100644 --- a/packages/@wterm/dom/src/terminal.css +++ b/packages/@wterm/dom/src/terminal.css @@ -20,8 +20,7 @@ --term-color-14: #4ec9b0; --term-color-15: #ffffff; - --term-font-family: - "Menlo", "Consolas", "DejaVu Sans Mono", "Courier New", monospace; + --term-font-family: 'Menlo', 'Consolas', 'DejaVu Sans Mono', 'Courier New', monospace; --term-font-size: 14px; --term-line-height: 1.2; --term-row-height: 17px; @@ -100,15 +99,8 @@ } @keyframes cursor-blink { - 0%, - 100% { - background: var(--term-cursor); - color: var(--term-bg); - } - 50% { - background: transparent; - color: inherit; - } + 0%, 100% { background: var(--term-cursor); color: var(--term-bg); } + 50% { background: transparent; color: inherit; } } .wterm.has-scrollback { From 40a408fb573dc3cb3f86d5e931553d7bec7108ce Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Thu, 21 May 2026 14:57:29 -0700 Subject: [PATCH 3/3] Harden Kitty graphics filter; fix blob-URL leak; add APC-passthrough test Addresses automated review feedback on PR #77 (Copilot + Vercel VADE): image-overlay.ts: - Track objectUrl per Placement; revoke on replace, _delete, and clear - Add error listener alongside load to revoke on decode failures - imageKey() now returns string (always); remove three dead null guards kitty-graphics.ts: - Wrap decodeBase64 in try/catch; silently drop malformed APCs - Cap pendingChunks at MAX_PENDING_CHUNKS=8 and MAX_PENDING_BASE64_BYTES=32MiB with oldest-entry eviction; track running pendingBytes - Replace per-byte decodeAscii loop with TextDecoder('latin1') dom/README.md: - Use new Uint8Array(await r.arrayBuffer()) in the inline-images snippet instead of non-portable Response.bytes() Tests: - wterm.test.ts: new integration test asserts the bridge never sees ESC bytes when images are enabled and a Kitty APC is in the input stream - kitty-graphics.test.ts: cover invalid-base64 silent drop and chunk-count cap eviction 84/84 @wterm/dom tests pass; pnpm -r type-check clean. --- packages/@wterm/dom/README.md | 4 +- .../dom/src/__tests__/kitty-graphics.test.ts | 53 ++++++++++++++++++- .../@wterm/dom/src/__tests__/wterm.test.ts | 20 +++++++ packages/@wterm/dom/src/image-overlay.ts | 28 ++++++---- packages/@wterm/dom/src/kitty-graphics.ts | 49 +++++++++++++++-- 5 files changed, 137 insertions(+), 17 deletions(-) diff --git a/packages/@wterm/dom/README.md b/packages/@wterm/dom/README.md index 8e1a28c..94ae7d9 100644 --- a/packages/@wterm/dom/README.md +++ b/packages/@wterm/dom/README.md @@ -85,7 +85,9 @@ term.onData = (data) => ws.send(data); 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 = await fetch("/icon.png").then((r) => r.bytes()); +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\\`); ``` diff --git a/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts b/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts index ace6866..9760d3f 100644 --- a/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts +++ b/packages/@wterm/dom/src/__tests__/kitty-graphics.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { KittyGraphicsFilter, type StreamEvent } from "../kitty-graphics.js"; +import { + KittyGraphicsFilter, + MAX_PENDING_CHUNKS, + type StreamEvent, +} from "../kitty-graphics.js"; const enc = new TextEncoder(); @@ -144,4 +148,51 @@ describe("KittyGraphicsFilter", () => { 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 7df4f1d..c47f5ea 100644 --- a/packages/@wterm/dom/src/__tests__/wterm.test.ts +++ b/packages/@wterm/dom/src/__tests__/wterm.test.ts @@ -188,6 +188,26 @@ describe("WTerm", () => { 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); + }); }); describe("resize", () => { diff --git a/packages/@wterm/dom/src/image-overlay.ts b/packages/@wterm/dom/src/image-overlay.ts index 41cfbcf..199706f 100644 --- a/packages/@wterm/dom/src/image-overlay.ts +++ b/packages/@wterm/dom/src/image-overlay.ts @@ -13,6 +13,8 @@ interface Placement { 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; @@ -74,7 +76,10 @@ export class ImageOverlay { /** Remove everything. Used on terminal reset / destroy. */ clear(): void { this.images.clear(); - for (const p of this.placements.values()) p.el.remove(); + for (const p of this.placements.values()) { + URL.revokeObjectURL(p.objectUrl); + p.el.remove(); + } this.placements.clear(); } @@ -90,7 +95,6 @@ export class ImageOverlay { return; } const key = imageKey(event.control); - if (!key) return; this.images.set(key, { data: event.data }); } @@ -99,14 +103,16 @@ export class ImageOverlay { anchor: { row: number; col: number; scrollbackCount: number }, ): void { const key = imageKey(control); - if (!key) return; 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) existing.el.remove(); + if (existing) { + URL.revokeObjectURL(existing.objectUrl); + existing.el.remove(); + } const col = anchor.col + (typeof control.X === "number" ? control.X : 0); const totalRow = @@ -119,10 +125,11 @@ export class ImageOverlay { img.draggable = false; img.alt = ""; const blob = new Blob([new Uint8Array(stored.data)], { type: "image/png" }); - img.src = URL.createObjectURL(blob); - img.addEventListener("load", () => URL.revokeObjectURL(img.src), { - once: true, - }); + 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; @@ -147,6 +154,7 @@ export class ImageOverlay { imageId: key, col, topPx, + objectUrl, }); } @@ -161,6 +169,7 @@ export class ImageOverlay { 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); } @@ -173,7 +182,6 @@ export class ImageOverlay { break; case "i": { const key = imageKey(control); - if (!key) return; removeMatching((p) => p.imageId === key); if (spec === "I") this.images.delete(key); break; @@ -185,7 +193,7 @@ export class ImageOverlay { } } -function imageKey(control: KittyControl): string | null { +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/kitty-graphics.ts b/packages/@wterm/dom/src/kitty-graphics.ts index a0b1d1b..e1e9e72 100644 --- a/packages/@wterm/dom/src/kitty-graphics.ts +++ b/packages/@wterm/dom/src/kitty-graphics.ts @@ -85,6 +85,11 @@ interface PendingChunk { 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. @@ -95,6 +100,8 @@ export class KittyGraphicsFilter { 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 @@ -200,6 +207,7 @@ export class KittyGraphicsFilter { this.state = State.Idle; this.apcBuf = []; this.pendingChunks.clear(); + this.pendingBytes = 0; } private _completeApc(events: StreamEvent[]): void { @@ -226,15 +234,33 @@ export class KittyGraphicsFilter { 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; @@ -242,21 +268,34 @@ export class KittyGraphicsFilter { 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: decodeBase64(completed.payload), + data, }, }); return; } + let data: Uint8Array; + try { + data = decodeBase64(payloadB64); + } catch { + return; + } events.push({ type: "graphics", event: { control, - data: decodeBase64(payloadB64), + data, }, }); } @@ -286,10 +325,10 @@ function chunkKey(control: KittyControl): string { return "default"; } +const LATIN1 = new TextDecoder("latin1"); + function decodeAscii(bytes: number[]): string { - let s = ""; - for (const b of bytes) s += String.fromCharCode(b); - return s; + return LATIN1.decode(new Uint8Array(bytes)); } function parseControl(s: string): KittyControl {