From 3bb7538ce0f479835d6a515e6894918aca4f378f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Nov 2026 11:51:23 +0000 Subject: [PATCH 1/8] perf: read the viewport once when the replay scans for painted graphemes --- .../ghosttyWeb/embeddedHarnessHtml.ts | 21 +++-- test/integration/renderer-backend.test.ts | 92 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts index 666368d2..f091acde 100644 --- a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts +++ b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts @@ -547,24 +547,31 @@ export const EMBEDDED_HARNESS_HTML = ` return cells; } - // One grapheme string per viewport cell, '' where getChars() is empty + // One grapheme string per viewport cell, '' where the codepoint is 0 // (blanks and wide-glyph spacers), the same rule as decodeSnapshotCell. + // It reads the viewport once: activeBuffer.getLine(row) copies the whole + // viewport out of the wasm module for every row, so a loop over the rows + // read each cell once per row. function getCellGraphemes() { const terminal = getReadyTerminal(); terminal.scrollToBottom(); - const { cols, rows, activeBuffer, viewportY } = - getNormalizedViewportState(terminal); + const { cols, rows } = getNormalizedViewportState(terminal); const wasmTerm = terminal.wasmTerm; invariant(wasmTerm, 'terminal WASM instance is unavailable'); - const nullCell = activeBuffer.getNullCell(); + wasmTerm.update(); + const cells = wasmTerm.getViewport(); + invariant( + cells.length >= rows * cols, + 'viewport cell count must cover the visible grid', + ); const grid = []; for (let row = 0; row < rows; row += 1) { - const line = activeBuffer.getLine(viewportY + row); const graphemes = []; for (let col = 0; col < cols; col += 1) { - const cell = line?.getCell(col) ?? nullCell; graphemes.push( - cell.getChars() === '' ? '' : wasmTerm.getGraphemeString(row, col), + cells[row * cols + col].codepoint === 0 + ? '' + : wasmTerm.getGraphemeString(row, col), ); } grid.push(graphemes); diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index 8a1ee507..f580e578 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -1180,6 +1180,98 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { }); }); + // The scan of a flush reads the viewport once, and a snapshot reads it row by + // row. Both must name the same grapheme in every cell, whatever the screen + // holds and however it came to hold it. + it('notes the graphemes of every cell as the per-row read of a snapshot names them', async () => { + await backend.boot(); + const { page } = backend as unknown as { page: Page }; + const noted = new Set(); + const sizes: string[] = []; + // Flushes the chunks, notes their graphemes, and returns the notes so far. + const flush = async (chunks: string[]): Promise => + page.evaluate( + `(async () => { + await window.__agentTty.writeBatch(${JSON.stringify(chunks)}, true, 1000); + return [...window.__agentTtyPainted.seen]; + })()`, + ) as Promise; + const readGrids = (): Promise<{ grid: string[][]; cells: string[][] }> => + page.evaluate( + `(() => { + const snapshot = window.__agentTty.getSnapshot({ includeCells: true }); + return { + grid: window.__agentTty.getCellGraphemes(), + cells: snapshot.cells.map((line) => line.cells.map((cell) => cell.char)), + }; + })()`, + ) as Promise<{ grid: string[][]; cells: string[][] }>; + const check = async (chunks: string[]): Promise => { + const painted = await flush(chunks); + const { grid, cells } = await readGrids(); + expect(grid).toEqual(cells); + sizes.push(`${String(grid[0]?.length)}x${String(grid.length)}`); + for (const grapheme of grid.flat()) { + if (grapheme !== '') { + noted.add(grapheme); + } + } + expect(new Set(painted)).toEqual(noted); + return grid; + }; + const resize = (cols: number, rows: number): Promise => + page.evaluate( + `window.__agentTty.resize(${String(cols)}, ${String(rows)})`, + ); + + await resize(80, 24); + // Narrow, wide, combining, joined and unassigned glyphs on one screen. + const mixed = await check([ + '\u001b[2J\u001b[Habc \u65e5\u672c\u8a9e e\u0301 \u{1F469}\u200D\u{1F4BB} \u{1F1EF}\u{1F1F5}', + '\r\n\u0915\u094D\u0937\u093F \u0378 \ue0b0 a\u0300\u0301 \u{1F44D}\u{1F3FD}', + ]); + expect(mixed[0]?.slice(0, 5)).toEqual(['a', 'b', 'c', ' ', '\u65e5']); + // A wide glyph's trailing spacer cell has no grapheme. + expect(mixed[0]?.[5]).toBe(''); + expect(noted).toContain('e\u0301'); + expect(noted).toContain('\u{1F469}\u200D\u{1F4BB}'); + expect(noted).toContain('\u0915\u094D'); + expect(noted).toContain('\u0937\u093F'); + expect(noted).toContain('\u0378'); + + // Every row changes by scrolling, and no flush writes into a row. + const scrolled = await check([ + Array.from({ length: 40 }, (_, line) => `line ${String(line)}`).join( + '\r\n', + ), + ]); + expect(scrolled[0]?.join('').trimEnd()).not.toBe( + mixed[0]?.join('').trimEnd(), + ); + + // The other screen has its own cells and returns the first screen's. + await check(['\u001b[?1049h\u001b[2J\u001b[Halt \u{1F600} \u00c5']); + await check(['\u001b[?1049l']); + + // The cell pool of ghostty-web grows with the screen and does not shrink, + // so the scan must index the viewport by the current width. + await resize(120, 40); + await check(['\u001b[2J\u001b[Hwide screen \u65e5\u672c']); + await resize(40, 10); + await check(['\u001b[2J\u001b[Hsmall \u65e5\u672c \u{1F600}']); + await resize(100, 30); + await check(['\u001b[2J\u001b[Hagain \u{1F1EF}\u{1F1F5}']); + expect(sizes).toEqual([ + '80x24', + '80x24', + '80x24', + '80x24', + '120x40', + '40x10', + '100x30', + ]); + }); + it('allows dispose to be called more than once', async () => { await backend.boot(); From ba5eb578421f4601c14c9184e900cd932a8e4760 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Nov 2026 11:54:56 +0000 Subject: [PATCH 2/8] test: scan the viewport in the task of a resize and after a shrink --- test/integration/renderer-backend.test.ts | 37 +++++++++++++++-------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index f580e578..d6d0d4dd 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -1196,12 +1196,18 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { return [...window.__agentTtyPainted.seen]; })()`, ) as Promise; - const readGrids = (): Promise<{ grid: string[][]; cells: string[][] }> => + // The scan reads first, then the snapshot reads the same screen. A script + // runs before the scan, in the same task. + const readGrids = ( + script = '', + ): Promise<{ grid: string[][]; cells: string[][] }> => page.evaluate( `(() => { + ${script} + const grid = window.__agentTty.getCellGraphemes(); const snapshot = window.__agentTty.getSnapshot({ includeCells: true }); return { - grid: window.__agentTty.getCellGraphemes(), + grid, cells: snapshot.cells.map((line) => line.cells.map((cell) => cell.char)), }; })()`, @@ -1219,12 +1225,18 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { expect(new Set(painted)).toEqual(noted); return grid; }; - const resize = (cols: number, rows: number): Promise => - page.evaluate( - `window.__agentTty.resize(${String(cols)}, ${String(rows)})`, + // The scan runs in the task of the resize, before any frame updates the + // render state, as the note after a resize event of a replay does. + const resizeAndRead = async (cols: number, rows: number): Promise => { + const { grid, cells } = await readGrids( + `window.__agentTty.resize(${String(cols)}, ${String(rows)});`, ); + expect(grid).toHaveLength(rows); + expect(grid.every((row) => row.length === cols)).toBe(true); + expect(grid).toEqual(cells); + }; - await resize(80, 24); + await resizeAndRead(80, 24); // Narrow, wide, combining, joined and unassigned glyphs on one screen. const mixed = await check([ '\u001b[2J\u001b[Habc \u65e5\u672c\u8a9e e\u0301 \u{1F469}\u200D\u{1F4BB} \u{1F1EF}\u{1F1F5}', @@ -1253,13 +1265,14 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { await check(['\u001b[?1049h\u001b[2J\u001b[Halt \u{1F600} \u00c5']); await check(['\u001b[?1049l']); - // The cell pool of ghostty-web grows with the screen and does not shrink, - // so the scan must index the viewport by the current width. - await resize(120, 40); - await check(['\u001b[2J\u001b[Hwide screen \u65e5\u672c']); - await resize(40, 10); + // The cell pool of ghostty-web grows with the screen and does not shrink. + // After a shrink its cells past the screen still hold the larger screen, + // so the scan must index the viewport by the width of the screen. + await resizeAndRead(120, 40); + await check(['x'.repeat(120 * 40)]); + await resizeAndRead(40, 10); await check(['\u001b[2J\u001b[Hsmall \u65e5\u672c \u{1F600}']); - await resize(100, 30); + await resizeAndRead(100, 30); await check(['\u001b[2J\u001b[Hagain \u{1F1EF}\u{1F1F5}']); expect(sizes).toEqual([ '80x24', From 99c4bac46942e3c7b2ba35d5268df4eff297f76a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Nov 2026 11:57:46 +0000 Subject: [PATCH 3/8] test: scan the viewport in the task of a write --- test/integration/renderer-backend.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index d6d0d4dd..cab3f110 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -1265,6 +1265,15 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { await check(['\u001b[?1049h\u001b[2J\u001b[Halt \u{1F600} \u00c5']); await check(['\u001b[?1049l']); + // A write changes the wasm screen at once, and the render state follows + // a frame later. A scan in the task of the write must not read the old + // state. + const fresh = await readGrids( + `void window.__agentTty.write('\\u001b[2J\\u001b[Hsync');`, + ); + expect(fresh.grid[0]?.slice(0, 4)).toEqual(['s', 'y', 'n', 'c']); + expect(fresh.grid).toEqual(fresh.cells); + // The cell pool of ghostty-web grows with the screen and does not shrink. // After a shrink its cells past the screen still hold the larger screen, // so the scan must index the viewport by the width of the screen. From 823c8aa0899eee012ea20b301b7792dfb41da0f9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Nov 2026 12:00:14 +0000 Subject: [PATCH 4/8] perf: leave the render state update to the viewport read --- src/renderer/ghosttyWeb/embeddedHarnessHtml.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts index f091acde..55f31d38 100644 --- a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts +++ b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts @@ -558,7 +558,6 @@ export const EMBEDDED_HARNESS_HTML = ` const { cols, rows } = getNormalizedViewportState(terminal); const wasmTerm = terminal.wasmTerm; invariant(wasmTerm, 'terminal WASM instance is unavailable'); - wasmTerm.update(); const cells = wasmTerm.getViewport(); invariant( cells.length >= rows * cols, From 82fd4eaac72a9d924485d26a7b0bc3a00497b572 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Nov 2026 16:26:51 +0000 Subject: [PATCH 5/8] perf: read the viewport once when a snapshot decodes the screen activeBuffer.getLine(row) re-reads the whole viewport out of the wasm module on every call. decodeVisibleLines and decodeVisibleCells called it once per row, so a snapshot or a wait poll read each cell once per row. They now read the viewport once with wasmTerm.getViewport() and take the text, width, colour and style of a cell from it. --- .../ghosttyWeb/embeddedHarnessHtml.ts | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts index 55f31d38..fc48ca64 100644 --- a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts +++ b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts @@ -71,7 +71,7 @@ export const EMBEDDED_HARNESS_HTML = `