diff --git a/bun.lock b/bun.lock index 847d14690..2b9244a88 100644 --- a/bun.lock +++ b/bun.lock @@ -169,8 +169,8 @@ "marked": "catalog:", }, "devDependencies": { - "@xterm/headless": "catalog:", "chalk": "catalog:", + "ghostty-web": "catalog:", }, }, "packages/typescript-edit-benchmark": { @@ -272,6 +272,7 @@ "diff": "^9.0.0", "fastembed": "2.1.0", "fflate": "0.8.3", + "ghostty-web": "^0.4.0", "handlebars": "^4.7.9", "linkedom": "^0.18.12", "lint-staged": "^17.0.5", @@ -973,6 +974,8 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], diff --git a/package.json b/package.json index a19620f1d..d4dd043ff 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "diff": "^9.0.0", "fflate": "0.8.3", "fastembed": "2.1.0", + "ghostty-web": "^0.4.0", "handlebars": "^4.7.9", "linkedom": "^0.18.12", "lint-staged": "^17.0.5", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c21918d13..d749c2811 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -16,6 +16,7 @@ ### Changed +- Changed TUI tests to use Ghostty's VT engine (`ghostty-web`) instead of `@xterm/headless`. - Changed the default inline-image live graphics budget from 3 to 8 images. ### Fixed diff --git a/packages/tui/README.md b/packages/tui/README.md index 8aa5c7a7a..a7c2812c3 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -538,7 +538,7 @@ interface Terminal { **Built-in implementations:** - `ProcessTerminal` - Uses `process.stdin/stdout` -- `VirtualTerminal` - For testing (uses `@xterm/headless`) +- `VirtualTerminal` - For testing (uses ghostty-web) ## Utilities diff --git a/packages/tui/package.json b/packages/tui/package.json index 4fd6a8720..1b6f108d1 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -44,7 +44,7 @@ }, "devDependencies": { "chalk": "catalog:", - "@xterm/headless": "catalog:" + "ghostty-web": "catalog:" }, "engines": { "bun": ">=1.3.14" diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 495978f47..fcba22a74 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1,6 +1,5 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; -import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; import { Markdown, renderInlineMarkdown } from "../src/components/markdown.js"; import { setTerminalTextSizing, TERMINAL } from "../src/terminal-capabilities.js"; @@ -12,14 +11,8 @@ import { VirtualTerminal } from "./virtual-terminal.js"; // Force full color in CI so ANSI assertions are deterministic const chalk = new Chalk({ level: 3 }); -function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { - const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; - const buffer = xterm.buffer.active; - const line = buffer.getLine(buffer.viewportY + row); - expect(line, `Missing buffer line at row ${row}`).toBeTruthy(); - const cell = line!.getCell(col); - expect(cell, `Missing cell at row ${row} col ${col}`).toBeTruthy(); - return cell!.isItalic(); +function getCellItalic(terminal: VirtualTerminal, row: number, col: number): boolean { + return terminal.getCellItalic(row, col); } describe("renderInlineMarkdown", () => { @@ -611,7 +604,7 @@ describe("Markdown component", () => { expect(component.markdownLineCount > 0).toBeTruthy(); const inputRow = component.markdownLineCount; - expect(getCellItalic(terminal, inputRow, 0)).toBe(0); + expect(getCellItalic(terminal, inputRow, 0)).toBe(false); tui.stop(); }); }); diff --git a/packages/tui/test/modern-width-provider.test.ts b/packages/tui/test/modern-width-provider.test.ts deleted file mode 100644 index 48ea0e8c6..000000000 --- a/packages/tui/test/modern-width-provider.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { visibleWidth } from "@oh-my-pi/pi-tui"; -import { VirtualTerminal } from "./virtual-terminal"; - -// Calibration for the "modern" VirtualTerminal width model (ghostty / WezTerm / -// kitty / iTerm2 / Windows Terminal 1.22+ semantics). -// -// The stress suite's geometric oracles (viewport fidelity, truncation -// boundaries, exact buffer reconstruction) compare text the renderer wrote -// against text the terminal committed. That comparison is only cell-exact when -// the terminal's width model agrees with the renderer's native width engine. -// These tests pin that agreement; if either side drifts (an xterm.js upgrade -// changing the provider API/packing, or a native-engine width change), this -// file fails before the stress suite starts reporting confusing geometric -// mismatches. - -/** Write `text` on a fresh line and return how many cells the cursor advanced. */ -async function measure(term: VirtualTerminal, text: string): Promise { - term.write(`\r\x1b[2K${text}`); - await term.flush(); - return term.getCursor().col; -} - -/** Width samples that appear in stress content (render-stress-harness.ts). */ -const STRESS_CONTENT_SAMPLES: ReadonlyArray = [ - ["plain label", "root-a1"], - ["cjk", "界"], - ["hangul", "한"], - ["emoji presentation", "\u{1F642}"], - ["wideText shape", "w1界\u{1F642}한"], - ["warning + VS16", "\u26A0\uFE0F"], - ["info + VS16", "\u2139\uFE0F"], - ["keycap", "1\uFE0F\u20E3"], - ["emojiPresentationText shape", "ep1 \u26A0\uFE0F\u2139\uFE0F 1\uFE0F\u20E3"], - ["arabic tashkeel", "ar1-بَسِمَ-قُرْآن"], - ["longText shape", "L1-0界1界2界-L1"], -]; - -describe("modern width model calibration", () => { - it("agrees with the renderer's width engine for every stress content shape", async () => { - const term = new VirtualTerminal(80, 5, undefined, "modern"); - for (const [name, text] of STRESS_CONTENT_SAMPLES) { - const cells = await measure(term, text); - expect(`${name}: ${cells}`).toBe(`${name}: ${visibleWidth(text)}`); - } - }); - - it("models the documented modern-terminal overrides on top of legacy widths", async () => { - const modern = new VirtualTerminal(80, 5, undefined, "modern"); - const legacy = new VirtualTerminal(80, 5); - - // Emoji presentation: modern = 2 (kitty/ghostty/WezTerm), legacy V6 = 1. - expect(await measure(modern, "\u{1F642}")).toBe(2); - expect(await measure(legacy, "\u{1F642}")).toBe(1); - - // VS16 promotion: modern joins + widens the base cell to 2; legacy keeps 1. - expect(await measure(modern, "\u26A0\uFE0F")).toBe(2); - expect(await measure(legacy, "\u26A0\uFE0F")).toBe(1); - - // Text-default symbol without VS16 stays narrow in both models. - expect(await measure(modern, "\u26A0")).toBe(1); - expect(await measure(legacy, "\u26A0")).toBe(1); - - // Non-emoji content is identical across models (delegation to V6). - for (const text of ["abc", "界", "한", "بِسْمِ", "e\u0301"]) { - expect(await measure(modern, text)).toBe(await measure(legacy, text)); - } - }); - - it("preserves readback fidelity for joined VS16/keycap cells", async () => { - const term = new VirtualTerminal(40, 5, undefined, "modern"); - const text = "warn \u26A0\uFE0F key 1\uFE0F\u20E3 end"; - term.write(`\r\x1b[2K${text}`); - await term.flush(); - expect(term.getViewport()[0]).toBe(text); - }); -}); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 45d3609f9..0924174be 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -3008,21 +3008,15 @@ describe("TUI terminal-state regressions", () => { }); }); - describe("width model disagreement (renderer vs terminal)", () => { - // The renderer measures ZWJ emoji sequences as one 2-cell grapheme - // (unicode-width / Intl.Segmenter — matching ghostty and WezTerm), but many - // real terminals lay them out as separate glyphs: kitty and alacritty - // advance the cursor 4 cells for a 3-person family, Windows Terminal 5 - // (https://mitchellh.com/writing/grapheme-clusters-in-terminals). The - // xterm.js test model (Unicode 6 tables) renders the same family as 3 - // cells, so it stands in for this terminal class. The renderer cannot know - // which model the terminal uses; its contract is *containment*: every - // content write is wrapped in DECAWM-off (\x1b[?7l), so a line the terminal - // considers wider than the renderer believes is CLIPPED at the right margin - // — never wrapped. Wrapping would silently add rows, desync - // #previousLines/#scrollbackHighWater from the terminal, and produce the - // classic duplicated/phantom-row scrollback corruption. - const ZWJ_FAMILY = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; // renderer: 2 cells, xterm.js: 3 cells + describe("ZWJ grapheme row containment", () => { + // Ghostty agrees with the renderer for this family sequence, so these + // regressions no longer use xterm's legacy width tables as the terminal + // model. They still pin the row-accounting boundary that used to corrupt + // scrollback when a terminal measured a grapheme wider than the renderer: + // content writes are wrapped in DECAWM-off (\x1b[?7l), so any future + // terminal-side overrun must be contained to the row instead of wrapping + // into a phantom spill row. + const ZWJ_FAMILY = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; // MutableLinesComponent pre-slices by UTF-16 code units, which would cut the // ZWJ sequence before the renderer ever measures it. Width decisions must be @@ -3041,14 +3035,14 @@ describe("TUI terminal-state regressions", () => { } } - it("confines under-measured ZWJ rows to intra-line clipping; row accounting stays exact", async () => { + it("keeps ZWJ boundary rows on one terminal row; row accounting stays exact", async () => { const width = 20; const height = 6; const term = new VirtualTerminal(width, height); const tui = new TUI(term); - // Renderer width 18 + 2 = 20 (exact fit). xterm.js lays out 18 + 3 = 21 - // cells, so the last cell of the family is clipped by the DECAWM-off - // guard. The row must still occupy exactly one terminal row. + // Renderer and Ghostty both fit 18 ASCII + the ZWJ family into this + // row. If either side drifts wider, DECAWM-off containment must still + // prevent a wrap into the following logical row. const zwjRow = `${"B".repeat(18)}${ZWJ_FAMILY}`; const lines = ["header", zwjRow, "tail"]; const component = new RawLinesComponent(lines); @@ -3058,17 +3052,16 @@ describe("TUI terminal-state regressions", () => { tui.start(); await settle(term); - // One terminal row per logical line — the over-wide row did not wrap. + // One terminal row per logical line — the boundary row did not wrap. expect(term.getScrollBuffer().length).toBe(height); const viewport = visible(term); expect(viewport[0]).toBe("header"); expect(viewport[2]).toBe("tail"); - // The ZWJ row is clipped (terminal kept what fit), not spilled onto row 3. expect(viewport[1]?.startsWith("B".repeat(18))).toBe(true); expect(viewport[3]).toBe(""); // Push content into scrollback: accounting must track logical rows - // exactly even with the clipped row in history. + // exactly with the ZWJ boundary row in history. const appended = [...lines, ...rows("after-", 10)]; component.setLines(appended); tui.requestRender(); @@ -3079,14 +3072,14 @@ describe("TUI terminal-state regressions", () => { expect(buffer[0]).toBe("header"); expect(buffer[2]).toBe("tail"); expect(buffer[buffer.length - 1]).toBe("after-9"); - // The clipped row exists exactly once — no duplicate, no spill row. + // The ZWJ row exists exactly once — no duplicate, no spill row. expect(countMatches(buffer, /^B{18}/)).toBe(1); } finally { tui.stop(); } }); - it("keeps differential row targeting exact after rendering a clipped ZWJ row", async () => { + it("keeps differential row targeting exact after rendering a ZWJ boundary row", async () => { const width = 20; const height = 8; const term = new VirtualTerminal(width, height); @@ -3121,13 +3114,12 @@ describe("TUI terminal-state regressions", () => { }); }); - describe("modern width model (renderer-terminal agreement)", () => { - // Counterpart of the disagreement tests above: on terminals whose width - // model matches the renderer's native engine (ghostty/WezTerm/kitty/iTerm2/ - // Windows Terminal 1.22+, modeled by VirtualTerminal's "modern" width - // model), rendering must be cell-exact — an exact-fit line fills the row - // with nothing clipped, and the renderer's truncation boundary lands the - // last glyph exactly at the right margin. + describe("Ghostty-backed renderer/terminal agreement", () => { + // Counterpart of the disagreement tests above: VirtualTerminal is backed by + // Ghostty's grapheme-aware engine, so its terminal cell widths must agree + // with the renderer for emoji presentation, VS16, and keycap sequences. An + // exact-fit line fills the row with nothing clipped, and the renderer's + // truncation boundary lands the last glyph exactly at the right margin. // MutableLinesComponent pre-slices by UTF-16 code units; width decisions // must come from the renderer's #fitLineToWidth. @@ -3147,10 +3139,10 @@ describe("TUI terminal-state regressions", () => { it("renders an exact-fit emoji-presentation line without truncation or wrap", async () => { const width = 20; - const term = new VirtualTerminal(width, 6, undefined, "modern"); + const term = new VirtualTerminal(width, 6); const tui = new TUI(term); - // 14 ASCII + ⚠️(2) + 🙂(2) + keycap(2) = 20 cells in BOTH the renderer's - // model and the modern terminal model — an exact fit. + // 14 ASCII + ⚠️(2) + 🙂(2) + keycap(2) = 20 cells for both Ghostty's + // grapheme-aware terminal and the renderer — an exact fit. const line = `${"a".repeat(14)}\u26A0\uFE0F\u{1F642}1\uFE0F\u20E3`; const component = new RawLinesComponent(["head", line, "tail"]); tui.addChild(component); @@ -3171,32 +3163,50 @@ describe("TUI terminal-state regressions", () => { } }); + it("exposes Ghostty legacy-width/xterm-width overrun instead of accepting hidden truncation", () => { + const width = 12; + const term = new VirtualTerminal(width, 4); + const prefix = "012345678"; + const wide = "\u{1F642}"; + const sentinel = "sentinel"; + + // A legacy/xterm-width oracle that counts 🙂 as 1 would accept this as + // a 12-cell exact fit: 9 ASCII + 🙂 + ZZ. Ghostty counts the emoji as + // 2 cells, so the second Z overruns the row. Renderer paints run with + // DECAWM off; mirror that containment contract directly through + // VirtualTerminal instead of reaching into TUI internals. + term.write(`\x1b[?7l${prefix}${wide}ZZ\r\n${sentinel}\x1b[?7h`); + const viewport = term.getViewport(); + expect(viewport[0]).toBe(`${prefix}${wide}Z`); + expect(viewport[0]).not.toContain("ZZ"); + expect(viewport[1]).toBe(sentinel); + expect(term.getScrollBuffer().length).toBe(4); + }); + it("lands the renderer's truncation boundary exactly at the right margin", async () => { const width = 12; - const term = new VirtualTerminal(width, 4, undefined, "modern"); + const term = new VirtualTerminal(width, 4); const tui = new TUI(term); // Renderer width: 10 ASCII + 2 + 2 + 2 = 16 > 12 → #fitLineToWidth - // truncates. The truncated text must occupy exactly 12 cells on a modern - // terminal: 10 ASCII + ⚠️ = 12, with 🙂 dropped whole (never split). + // truncates. The truncated text must occupy exactly 12 Ghostty cells: + // 10 ASCII + ⚠️ = 12, with 🙂 dropped whole (never split). const line = `${"x".repeat(10)}\u26A0\uFE0F\u{1F642}1\uFE0F\u20E3`; - const component = new RawLinesComponent([line]); + const nextLine = "after"; + const component = new RawLinesComponent([line, nextLine]); tui.addChild(component); try { tui.start(); await settle(term); - const rendered = term.getViewport()[0] ?? ""; + const viewport = term.getViewport(); + const rendered = viewport[0] ?? ""; // The kept prefix is exactly the renderer's 12-cell truncation. expect(rendered).toBe(`${"x".repeat(10)}\u26A0\uFE0F`); - // And it fills the row to the last column on the modern terminal — - // writing one more cell would have wrapped/clipped. - term.write("\r"); - await term.flush(); - const homed = term.getCursor(); - term.write(rendered); - await term.flush(); - expect(term.getCursor().col - homed.col).toBe(width); + // The exact-width row occupies one Ghostty row: the dropped glyphs + // neither split nor wrap into the following logical row. + expect(viewport[1]?.trimEnd()).toBe(nextLine); + expect(term.getScrollBuffer().length).toBe(4); } finally { tui.stop(); } @@ -3358,9 +3368,8 @@ describe("TUI terminal-state regressions", () => { // trails / phantom rows in scrollback. The renderer disables autowrap // (\x1b[?7l) around every paint and restores it (\x1b[?7h) only at PAINT_END, // after emitting explicit CRLFs, so an exact-width row never latches - // pending-wrap. These tests pin that on both the legacy (CJK 2-cell) and - // modern (emoji 2-cell) width models, across the initial, diff, and append - // emit paths. + // pending-wrap. These tests pin that with Ghostty-backed ASCII and wide-glyph + // rows across the initial, diff, and append emit paths. class RawLinesComponent implements Component { #lines: string[]; constructor(lines: string[]) { @@ -3375,57 +3384,55 @@ describe("TUI terminal-state regressions", () => { } } - for (const widthModel of ["legacy", "modern"] as const) { - it(`keeps exact-width rows on one terminal row without staircase (${widthModel})`, async () => { - const width = 10; - const height = 6; - const term = new VirtualTerminal(width, height, undefined, widthModel); - const tui = new TUI(term); - // Two exact-width (10-cell) rows: one ASCII, one ending on a 2-cell - // CJK glyph exactly at the right margin (the pending-wrap trigger). - const exactAscii = "0123456789"; - const exactWide = "AAAA界界界"; // 4 + 2+2+2 = 10 - const lines = ["top", exactAscii, exactWide, "bot"]; - const component = new RawLinesComponent(lines); - tui.addChild(component); + it("keeps exact-width Ghostty-backed rows on one terminal row without staircase", async () => { + const width = 10; + const height = 6; + const term = new VirtualTerminal(width, height); + const tui = new TUI(term); + // Two exact-width (10-cell) rows: one ASCII, one ending on 2-cell wide + // glyphs exactly at the right margin (the pending-wrap trigger). + const exactAscii = "0123456789"; + const exactWide = "AAAA界界界"; // 4 + 2+2+2 = 10 + const lines = ["top", exactAscii, exactWide, "bot"]; + const component = new RawLinesComponent(lines); + tui.addChild(component); - try { - tui.start(); - await settle(term); + try { + tui.start(); + await settle(term); - // Each logical row occupies exactly one terminal row — no wrap. - // Content (4 rows) fits the 6-row viewport, so the buffer is the - // viewport: 4 content rows + 2 trailing blanks, each on its own row. - const buffer = term.getScrollBuffer().map(line => line.trimEnd()); - expect(buffer).toEqual(["top", exactAscii, exactWide, "bot", "", ""]); + // Each logical row occupies exactly one terminal row — no wrap. + // Content (4 rows) fits the 6-row viewport, so the buffer is the + // viewport: 4 content rows + 2 trailing blanks, each on its own row. + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + expect(buffer).toEqual(["top", exactAscii, exactWide, "bot", "", ""]); - // Diff-edit the row below the exact-width wide row: if pending-wrap - // had latched, the relative cursor move would land a row off. - component.setLines(["top", exactAscii, exactWide, "EDIT"]); - tui.requestRender(); - await settle(term); - expect(term.getViewport().map(line => line.trimEnd())).toEqual([ - "top", - exactAscii, - exactWide, - "EDIT", - "", - "", - ]); + // Diff-edit the row below the exact-width wide row: if pending-wrap + // had latched, the relative cursor move would land a row off. + component.setLines(["top", exactAscii, exactWide, "EDIT"]); + tui.requestRender(); + await settle(term); + expect(term.getViewport().map(line => line.trimEnd())).toEqual([ + "top", + exactAscii, + exactWide, + "EDIT", + "", + "", + ]); - // Append past the viewport: exact-width rows must scroll into - // history one row each, contiguous, no phantom blank from a latched - // wrap. - component.setLines(["top", exactAscii, exactWide, "EDIT", ...rows("a-", 6)]); - tui.requestRender(); - await settle(term); - const after = term.getScrollBuffer().map(line => line.trimEnd()); - expect(after).toEqual(["top", exactAscii, exactWide, "EDIT", ...rows("a-", 6)]); - } finally { - tui.stop(); - } - }); - } + // Append past the viewport: exact-width rows must scroll into + // history one row each, contiguous, no phantom blank from a latched + // wrap. + component.setLines(["top", exactAscii, exactWide, "EDIT", ...rows("a-", 6)]); + tui.requestRender(); + await settle(term); + const after = term.getScrollBuffer().map(line => line.trimEnd()); + expect(after).toEqual(["top", exactAscii, exactWide, "EDIT", ...rows("a-", 6)]); + } finally { + tui.stop(); + } + }); }); describe("hardware cursor preference", () => { const SHOW_CURSOR = "\x1b[?25h"; diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 6e67ede60..b80d93277 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { stripVTControlCharacters } from "node:util"; +import { ProcessTerminal } from "../src/terminal"; import { TERMINAL } from "../src/terminal-capabilities"; import { type Component, @@ -12,9 +13,17 @@ import { type OverlayOptions, TUI, } from "../src/tui"; -import { Ellipsis, extractSegments, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth } from "../src/utils"; +import { + Ellipsis, + extractSegments, + sliceByColumn, + sliceWithWidth, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "../src/utils"; import { StressRenderScheduler } from "./render-stress-scheduler"; -import { VirtualTerminal, type VirtualTerminalWidthModel } from "./virtual-terminal"; +import { VirtualTerminal } from "./virtual-terminal"; const BASE_SEEDS = [ 0x00c0ffee, 0x1badb002, 0x5eed1234, 0xdecafbad, 0x8badf00d, 0x0ddc0ffe, 0xcafed00d, 0xb16b00b5, @@ -43,8 +52,7 @@ export type ScenarioTag = | "strictScrollback" | "unknownViewport" | "foregroundStream" - | "ed3Risk" - | "modernWidth"; + | "ed3Risk"; const ENV_KEYS = [ "TMUX", "STY", @@ -244,11 +252,6 @@ export interface Scenario { terminalMode: TerminalMode; envMode: EnvMode; geometryMode: GeometryMode; - // Terminal cell-width semantics. "legacy" (default) = xterm.js Unicode 6 - // tables (emoji/VS16 narrow); "modern" = grapheme-aware widths matching the - // renderer's native engine (ghostty/WezTerm/kitty/iTerm2/WT 1.22+). Modern - // scenarios make geometric oracles cell-exact for emoji content. - widthModel?: VirtualTerminalWidthModel; columns: number; rows: number; widthChoices: readonly number[]; @@ -272,8 +275,8 @@ export interface Scenario { // Renders each logical line wrapped to the viewport width, so a width resize // changes the physical line COUNT (reflow), not just per-row truncation — // exercising the geometry-change + line-count-change interaction the - // fixed-line components never produced. Paired with the modern width model so - // the wrap agrees with the terminal's cell widths. + // fixed-line components never produced. Wrapped content must agree with the + // real Ghostty-backed terminal's cell widths. reflow: boolean; tags: readonly ScenarioTag[]; replayOperations?: readonly OperationKind[]; @@ -289,7 +292,6 @@ interface TerminalStressTraits { readonly ed3ScrollbackEraseRisk: boolean; readonly conptyHostScrollbackUnobservable: boolean; readonly foregroundStreaming: boolean; - readonly widthModel: VirtualTerminalWidthModel; } interface Snapshot { @@ -570,12 +572,11 @@ function terminalStressTraits(scenario: Scenario): TerminalStressTraits { ed3ScrollbackEraseRisk: isEd3RiskScenario(scenario.terminalMode, scenario.envMode), conptyHostScrollbackUnobservable: scenario.platform === "win32" && scenario.terminalMode === "unknown", foregroundStreaming: scenario.foregroundStream, - widthModel: scenario.widthModel ?? "legacy", }; } function scenarioTags( - template: Pick, + template: Pick, strictNativeScrollback: boolean, foregroundStreaming: boolean, ): readonly ScenarioTag[] { @@ -585,7 +586,6 @@ function scenarioTags( if (template.terminalMode !== "normal") tags.push("unknownViewport"); if (foregroundStreaming) tags.push("foregroundStream"); if (isEd3RiskScenario(template.terminalMode, template.envMode)) tags.push("ed3Risk"); - if (template.widthModel === "modern") tags.push("modernWidth"); return tags; } @@ -964,10 +964,9 @@ class StressModel { // Wrap a rendered line set to the viewport width, ANSI- and grapheme-aware, so // a logical line can occupy a width-dependent NUMBER of physical rows — the // reflow that real wrapped/markdown content performs and that fixed-line -// components never exercised. Because BOTH the live render and the expected -// frame run through this same deterministic transform (StressComponent.render), -// the geometric oracles stay consistent; the renderer's own truncation -// normalizes any residual width-model disagreement on each physical row. +// components never exercised. Use the renderer's native wrapper rather than +// Bun.wrapAnsi so combining marks stay with their base grapheme instead of +// starting a physical row the terminal will fold back into the previous cell. function reflowToWidth(lines: readonly string[], width: number): string[] { const target = Math.max(1, width); const out: string[] = []; @@ -976,8 +975,7 @@ function reflowToWidth(lines: readonly string[], width: number): string[] { out.push(""); continue; } - const wrapped = Bun.wrapAnsi(line, target, { hard: true, wordWrap: false, trim: false }); - for (const physical of wrapped.split("\n")) out.push(physical); + for (const physical of wrapTextWithAnsi(line, target)) out.push(physical); } return out; } @@ -2663,21 +2661,15 @@ class StressDriver { } function createTerminal(scenario: Scenario): VirtualTerminal { - const widthModel = scenario.widthModel ?? "legacy"; switch (scenario.terminalMode) { case "unknown": - return new UnknownViewportTerminal(scenario.columns, scenario.rows, scenario.scrollback, widthModel); + return new UnknownViewportTerminal(scenario.columns, scenario.rows, scenario.scrollback); case "intermittentUnknown": - return new IntermittentUnknownViewportTerminal( - scenario.columns, - scenario.rows, - scenario.scrollback, - widthModel, - ); + return new IntermittentUnknownViewportTerminal(scenario.columns, scenario.rows, scenario.scrollback); case "staleBottom": - return new StaleBottomTerminal(scenario.columns, scenario.rows, scenario.scrollback, widthModel); + return new StaleBottomTerminal(scenario.columns, scenario.rows, scenario.scrollback); case "normal": - return new VirtualTerminal(scenario.columns, scenario.rows, scenario.scrollback, widthModel); + return new VirtualTerminal(scenario.columns, scenario.rows, scenario.scrollback); default: return assertNever(scenario.terminalMode); } @@ -3083,18 +3075,14 @@ function arabicCombiningText(label: string): string { function emojiPresentationText(label: string): string { // Text-default symbols promoted to emoji presentation by VS16 (U+FE0F) plus a - // keycap sequence. The renderer's native width engine (unicode-width, matching - // ghostty/WezTerm/kitty) measures each as 2 cells, while the xterm.js test - // model (Unicode 6 tables) renders them as 1 cell (VS16 = combining, width 0). - // This deliberately models the legacy-terminal disagreement direction: the - // renderer OVER-measures, so its truncation is conservative and written lines - // can never overflow the model terminal. The opposite direction (renderer - // under-measures, e.g. ZWJ families on kitty/alacritty) clips intra-line and - // is locked by deterministic regression tests instead — randomized text-fidelity - // oracles would mis-report that unavoidable clipping as a renderer bug. - // Width facts: xterm.js UnicodeV6.ts (VS16 in BMP_COMBINING), unicode-width - // tests ("\u{26A0}\u{FE0F}" == 2), kitty text-sizing-protocol.rst (VS16 - // promotes the previous cell to width 2). + // keycap sequence. With the Ghostty-backed terminal, these are now cell-exact: + // Ghostty is the real modern terminal oracle, and both the renderer and the + // terminal measure each sequence here as 2 cells. + // + // Keep randomized stress to VS16/keycap emoji for this migration baseline. + // ZWJ and regional-indicator content should be enabled separately as renderer + // bug triage: Ghostty will expose real under-measure and overrun failures + // instead of hiding them behind a legacy model mismatch. return `${label} \u26A0\uFE0F\u2139\uFE0F 1\uFE0F\u20E3`; } @@ -3578,23 +3566,6 @@ function coreTemplates(): ScenarioTemplate[] { heightChoices: [3, 4, 6], scrollbackRows: 10_000, }, - { - // Modern grapheme-aware terminal (ghostty/WezTerm/kitty/iTerm2/WT 1.22+): - // the terminal's width model agrees with the renderer's native engine for - // all stress content (emoji presentation = 2 cells, VS16 promotion), so - // text-fidelity oracles double as cell-exact geometric oracles here. - name: "darwin-normal-modern-small", - platform: "darwin", - terminalMode: "normal", - envMode: "plain", - geometryMode: "small", - widthModel: "modern", - columns: 32, - rows: 4, - widthChoices: [10, 16, 24, 32, 40], - heightChoices: [3, 4, 6], - scrollbackRows: 10_000, - }, { // Native-Windows ConPTY host (Windows Terminal, Tabby, Hyper, VS Code, // conhost behind ConPTY — #1635/#1746). kernel32 cannot see the host @@ -3654,9 +3625,10 @@ function coreTemplates(): ScenarioTemplate[] { foregroundStream: true, }, { - // Width-reflowing content (wrapped/markdown-style) on the modern grapheme - // width model, where the wrap agrees with the terminal's cell widths. A - // width resize changes the physical line count, so the renderer must + // Width-reflowing content (wrapped/markdown-style) uses the same grapheme + // width semantics as the real Ghostty-backed terminal, so the wrap agrees + // with the terminal's cell widths. A width resize changes the physical + // line count, so the renderer must // re-anchor the viewport and rebuild native history across a line-count // change — not just retruncate rows. Combined with the full random op // space (scroll, overlay, append, shrink) it covers reflow interactions @@ -3666,7 +3638,6 @@ function coreTemplates(): ScenarioTemplate[] { terminalMode: "normal", envMode: "plain", geometryMode: "small", - widthModel: "modern", columns: 32, rows: 4, widthChoices: [8, 12, 16, 24, 32, 40], @@ -3679,7 +3650,6 @@ function coreTemplates(): ScenarioTemplate[] { terminalMode: "unknown", envMode: "ghostty", geometryMode: "large", - widthModel: "modern", columns: 80, rows: 12, widthChoices: [24, 40, 80, 120], @@ -3739,27 +3709,6 @@ function soakTemplates(): ScenarioTemplate[] { heightChoices: large ? [12, 24] : [3, 4, 6], }); } - // Modern grapheme-aware width model (ghostty/WezTerm/kitty/iTerm2/WT 1.22+): - // terminal cell widths agree with the renderer's native engine, so the - // text-fidelity oracles double as cell-exact geometric oracles. Cover the - // observable probe modes on both geometries. - for (const terminalMode of ["normal", "unknown"] as const) { - for (const geometryMode of geometries) { - const large = geometryMode === "large"; - templates.push({ - name: `darwin-${terminalMode}-modern-${geometryMode}`, - platform: "darwin", - terminalMode, - envMode: "plain", - geometryMode, - widthModel: "modern", - columns: large ? 80 : 32, - rows: large ? 12 : 4, - widthChoices: large ? [80, 120] : [2, 10, 16, 24, 32, 40], - heightChoices: large ? [12, 24] : [3, 4, 6], - }); - } - } // Foreground tool streaming on an ED3-risk terminal with an unobservable // viewport (ghostty/kitty/…): the eager native-scrollback rebuild opt-in is // gated off, so content frames repaint in place and offscreen-edit growth @@ -3890,19 +3839,11 @@ async function withPatchedPlatform(platform: Scenario["platform"], run: () => } } -export interface StressWorkerRequest { - id: number; - scenario: Scenario; - patchEnv?: boolean; -} - -export interface StressWorkerSuccess { - id: number; +export interface StressScenarioSuccess { ok: true; } -export interface StressWorkerFailure { - id: number; +export interface StressScenarioFailure { ok: false; scenario: string; seed: string; @@ -3910,7 +3851,12 @@ export interface StressWorkerFailure { stack?: string; } -export type StressWorkerResponse = StressWorkerSuccess | StressWorkerFailure; +/** + * Result a {@link runStressScenario} subprocess emits as a single JSON line on + * stdout. Each scenario runs in its own `bun` subprocess (one scenario per + * process), so there is no request multiplexing or `id` to correlate. + */ +export type StressScenarioResult = StressScenarioSuccess | StressScenarioFailure; export async function runStressScenario(scenario: Scenario, options?: { patchEnv?: boolean }): Promise { const run = async (): Promise => { @@ -3926,6 +3872,99 @@ export async function runStressScenario(scenario: Scenario, options?: { patchEnv } } +function restoreOwnProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor === undefined) { + delete (target as Record)[key]; + return; + } + Object.defineProperty(target, key, descriptor); +} + +export async function runNoReflowResizeNotificationRegression(): Promise { + await withPatchedEnv("ghostty", async () => { + await withPatchedPlatform("darwin", async () => { + const terminalInfo = TERMINAL as unknown as { eagerEraseScrollbackRisk: boolean }; + const savedRisk = terminalInfo.eagerEraseScrollbackRisk; + const stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdoutIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + const stdoutColumns = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + const stdoutRows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); + const stdinSetRawMode = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); + const stdinSetEncoding = Object.getOwnPropertyDescriptor(process.stdin, "setEncoding"); + const stdinResume = Object.getOwnPropertyDescriptor(process.stdin, "resume"); + const stdinPause = Object.getOwnPropertyDescriptor(process.stdin, "pause"); + const stdoutWrite = Object.getOwnPropertyDescriptor(process.stdout, "write"); + const processKill = Object.getOwnPropertyDescriptor(process, "kill"); + const writes: string[] = []; + + terminalInfo.eagerEraseScrollbackRisk = true; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true }); + Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true }); + Object.defineProperty(process.stdin, "setRawMode", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdin, "setEncoding", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdin, "resume", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdin, "pause", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdout, "write", { + value: (chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }, + configurable: true, + }); + Object.defineProperty(process, "kill", { value: () => true, configurable: true }); + + const term = new ProcessTerminal(); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, true, { renderScheduler: scheduler }); + const initialLines = Array.from({ length: 35 }, (_value, index) => `stream-row-${index}`); + const component = new MutableLinesComponent(initialLines); + const drainTarget = { flush: async () => {} } as VirtualTerminal; + tui.addChild(component); + + try { + tui.start(); + tui.setEagerNativeScrollbackRebuild(true); + await scheduler.drain(drainTarget); + + const reportOnlyWriteStart = writes.length; + process.stdin.emit("data", "\x1b[48;30;100;600;1000t"); + await scheduler.drain(drainTarget); + if (writes.length !== reportOnlyWriteStart) { + throw new Error("Unchanged DEC 2048 resize report scheduled a render without a geometry change"); + } + + const streamingWriteStart = writes.length; + component.setLines([...initialLines, "stream-row-35"]); + process.stdin.emit("data", "\x1b[48;30;100;600;1000t"); + tui.requestRender(false); + await scheduler.drain(drainTarget); + + const emitted = writes.slice(streamingWriteStart).join(""); + if (emitted.includes("\x1b[3J")) { + throw new Error( + "Unchanged DEC 2048 report coalesced with streaming content emitted destructive scrollback clear", + ); + } + } finally { + tui.stop(); + terminalInfo.eagerEraseScrollbackRisk = savedRisk; + restoreOwnProperty(process.stdin, "isTTY", stdinIsTty); + restoreOwnProperty(process.stdout, "isTTY", stdoutIsTty); + restoreOwnProperty(process.stdout, "columns", stdoutColumns); + restoreOwnProperty(process.stdout, "rows", stdoutRows); + restoreOwnProperty(process.stdin, "setRawMode", stdinSetRawMode); + restoreOwnProperty(process.stdin, "setEncoding", stdinSetEncoding); + restoreOwnProperty(process.stdin, "resume", stdinResume); + restoreOwnProperty(process.stdin, "pause", stdinPause); + restoreOwnProperty(process.stdout, "write", stdoutWrite); + restoreOwnProperty(process, "kill", processKill); + } + }); + }); +} + export async function runPreexistingScrollbackRegression(): Promise { const term = new VirtualTerminal(40, 5, 100); const scheduler = new StressRenderScheduler(); diff --git a/packages/tui/test/render-stress-subprocess.ts b/packages/tui/test/render-stress-subprocess.ts new file mode 100644 index 000000000..661015d99 --- /dev/null +++ b/packages/tui/test/render-stress-subprocess.ts @@ -0,0 +1,37 @@ +import { formatSeed, runStressScenario, type Scenario, type StressScenarioResult } from "./render-stress-harness"; + +// Subprocess entry for the randomized render-stress pool. The parent test spawns +// one `bun` process per scenario, writes the scenario JSON to stdin, and reads a +// single JSON {@link StressScenarioResult} line back on stdout. Running each +// scenario in its own process gives full isolation — fresh Ghostty WASM VT, +// fresh `process.platform`/env patches, no shared global state to coordinate — +// and lets the parent enforce a hard timeout by killing the process, which a +// Web Worker could not deliver reliably. + +function serializeError(error: unknown): { error: string; stack?: string } { + if (error instanceof Error) { + return error.stack === undefined ? { error: error.message } : { error: error.message, stack: error.stack }; + } + return { error: String(error) }; +} + +async function main(): Promise { + const scenario = JSON.parse(await Bun.stdin.text()) as Scenario; + let result: StressScenarioResult; + try { + // patchEnv defaults on: this process owns its env + platform for its one + // scenario, then exits, so the patch never has to be unwound. + await runStressScenario(scenario); + result = { ok: true }; + } catch (error) { + result = { + ok: false, + scenario: scenario.name, + seed: formatSeed(scenario.seed), + ...serializeError(error), + }; + } + await Bun.write(Bun.stdout, JSON.stringify(result)); +} + +await main(); diff --git a/packages/tui/test/render-stress-worker.ts b/packages/tui/test/render-stress-worker.ts deleted file mode 100644 index f2e8471b9..000000000 --- a/packages/tui/test/render-stress-worker.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - applyStressEnv, - formatSeed, - runStressScenario, - type StressWorkerRequest, - type StressWorkerResponse, -} from "./render-stress-harness"; - -interface StressWorkerGlobal { - addEventListener(type: "message", listener: (event: MessageEvent) => void): void; - postMessage(message: StressWorkerResponse): void; -} - -const workerGlobal = globalThis as unknown as StressWorkerGlobal; -workerGlobal.addEventListener("message", event => { - const request = event.data as StressWorkerRequest; - void runWorkerScenario(request); -}); - -async function runWorkerScenario(request: StressWorkerRequest): Promise { - try { - if (request.patchEnv === false) applyStressEnv(request.scenario.envMode); - await runStressScenario(request.scenario, { patchEnv: request.patchEnv }); - postWorkerMessage({ id: request.id, ok: true }); - } catch (error) { - postWorkerMessage({ - id: request.id, - ok: false, - scenario: request.scenario.name, - seed: formatSeed(request.scenario.seed), - ...serializeError(error), - }); - } -} - -function serializeError(error: unknown): { error: string; stack?: string } { - if (error instanceof Error) { - return error.stack === undefined ? { error: error.message } : { error: error.message, stack: error.stack }; - } - return { error: String(error) }; -} - -function postWorkerMessage(message: StressWorkerResponse): void { - workerGlobal.postMessage(message); -} diff --git a/packages/tui/test/render-stress.test.ts b/packages/tui/test/render-stress.test.ts index c5af87e1c..741176917 100644 --- a/packages/tui/test/render-stress.test.ts +++ b/packages/tui/test/render-stress.test.ts @@ -1,19 +1,25 @@ import { describe, it } from "bun:test"; +import type { Subprocess } from "bun"; import { - applyStressEnv, buildScenarios, formatSeed, - restoreStressEnv, + runNoReflowResizeNotificationRegression, runPreexistingScrollbackRegression, type Scenario, - type StressWorkerFailure, - type StressWorkerRequest, - type StressWorkerResponse, + type StressScenarioFailure, + type StressScenarioResult, } from "./render-stress-harness"; const DEFAULT_STRESS_WORKERS = 8; const CORE_BATCH_TIMEOUT_MS = 60_000; const SOAK_BATCH_TIMEOUT_MS = 150_000; +// Per-wave allowance for `bun` startup + Ghostty WASM compile in each fresh +// subprocess, added on top of the slowest scenario's own timeout. +const SUBPROCESS_SPAWN_OVERHEAD_MS = 5_000; + +const SUBPROCESS_ENTRY = `${import.meta.dir}/render-stress-subprocess.ts`; + +type StressSubprocess = Subprocess<"pipe", "pipe", "pipe">; function parsePositiveInt(name: string, fallback: number): number { const raw = Bun.env[name]; @@ -24,30 +30,22 @@ function parsePositiveInt(name: string, fallback: number): number { return Number.parseInt(raw, 10); } -function stressWorkerCount(scenarios: readonly Scenario[]): number { +function stressConcurrency(scenarios: readonly Scenario[]): number { if (scenarios.length === 0) return 0; return Math.min(scenarios.length, parsePositiveInt("TUI_STRESS_WORKERS", DEFAULT_STRESS_WORKERS)); } -interface ScenarioGroup { - envMode: Scenario["envMode"]; - scenarios: Scenario[]; -} - function stressBatchTimeoutMs(scenarios: readonly Scenario[]): number { + const fallback = Bun.env.TUI_STRESS_SOAK === "1" ? SOAK_BATCH_TIMEOUT_MS : CORE_BATCH_TIMEOUT_MS; const raw = Bun.env.TUI_STRESS_BATCH_TIMEOUT_MS; if (raw !== undefined && raw.length > 0) { - const fallback = Bun.env.TUI_STRESS_SOAK === "1" ? SOAK_BATCH_TIMEOUT_MS : CORE_BATCH_TIMEOUT_MS; return parsePositiveInt("TUI_STRESS_BATCH_TIMEOUT_MS", fallback); } - let total = 0; - for (const group of groupScenariosByEnv(scenarios)) { - const workers = stressWorkerCount(group.scenarios); - const batches = Math.ceil(group.scenarios.length / Math.max(1, workers)); - const slowest = group.scenarios.reduce((max, scenario) => Math.max(max, scenario.timeoutMs), 0); - total += batches * slowest; - } - return Math.max(Bun.env.TUI_STRESS_SOAK === "1" ? SOAK_BATCH_TIMEOUT_MS : CORE_BATCH_TIMEOUT_MS, total); + const concurrency = stressConcurrency(scenarios); + if (concurrency === 0) return fallback; + const waves = Math.ceil(scenarios.length / concurrency); + const slowest = scenarios.reduce((max, scenario) => Math.max(max, scenario.timeoutMs), 0); + return Math.max(fallback, waves * (slowest + SUBPROCESS_SPAWN_OVERHEAD_MS)); } function stressBatchLabel(scenarios: readonly Scenario[]): string { @@ -59,112 +57,99 @@ function stressBatchLabel(scenarios: readonly Scenario[]): string { return `${scenarios.length} scenarios x ${first.iterations} ops`; } -async function runScenariosInWorkers(scenarios: readonly Scenario[]): Promise { - for (const group of groupScenariosByEnv(scenarios)) { - const envSnapshot = applyStressEnv(group.envMode); - try { - await runScenarioGroupInWorkers(group.scenarios); - } finally { - restoreStressEnv(envSnapshot); - } - } -} - -function groupScenariosByEnv(scenarios: readonly Scenario[]): ScenarioGroup[] { - const groups: ScenarioGroup[] = []; - for (const scenario of scenarios) { - let group = groups.find(candidate => candidate.envMode === scenario.envMode); - if (group === undefined) { - group = { envMode: scenario.envMode, scenarios: [] }; - groups.push(group); - } - group.scenarios.push(scenario); - } - return groups; -} - -async function runScenarioGroupInWorkers(scenarios: readonly Scenario[]): Promise { - const workerCount = stressWorkerCount(scenarios); - const workers = Array.from({ length: workerCount }, () => spawnStressWorker()); - let nextScenario = 0; - try { - await Promise.all( - workers.map(async worker => { - for (;;) { - const scenarioIndex = nextScenario++; - const scenario = scenarios[scenarioIndex]; - if (scenario === undefined) return; - await runScenarioOnWorker(worker, scenarioIndex, scenario); - } - }), - ); - } finally { - for (const worker of workers) { - worker.terminate(); - } - } -} - -function spawnStressWorker(): Worker { - return new Worker(new URL("./render-stress-worker.ts", import.meta.url).href, { type: "module" }); -} - -async function runScenarioOnWorker(worker: Worker, id: number, scenario: Scenario): Promise { - const { promise, resolve, reject } = Promise.withResolvers(); - const request: StressWorkerRequest = { id, scenario, patchEnv: false }; - let done = false; - const cleanup = (): void => { - worker.removeEventListener("message", onMessage); - worker.removeEventListener("error", onError); - worker.removeEventListener("messageerror", onMessageError); +/** + * Run every scenario in its own `bun` subprocess, at most `concurrency` at once. + * The first failing (or timed-out) scenario aborts the batch: its error is + * recorded and every surviving subprocess is killed, so a real renderer + * regression surfaces promptly instead of hiding behind a later batch timeout. + * Each drain loop catches its own scenario error, so a single rejection never + * leaks as an unhandled promise rejection while the rest of the pool unwinds. + */ +async function runScenariosInSubprocesses(scenarios: readonly Scenario[]): Promise { + const concurrency = stressConcurrency(scenarios); + if (concurrency === 0) return; + const live = new Set(); + let next = 0; + let firstError: unknown; + const fail = (error: unknown): void => { + if (firstError === undefined) firstError = error; + for (const proc of live) proc.kill(); }; - const finish = (complete: () => void): void => { - if (done) return; - done = true; - cleanup(); - complete(); - }; - const onMessage = (event: MessageEvent): void => { - const message = event.data as StressWorkerResponse; - if (message.id !== id) return; - if (message.ok) { - finish(resolve); - } else { - finish(() => reject(workerFailureError(message))); + const drain = async (): Promise => { + while (firstError === undefined) { + const scenario = scenarios[next++]; + if (scenario === undefined) return; + try { + await runScenarioInSubprocess(scenario, live); + } catch (error) { + fail(error); + return; + } } }; - const onError = (event: ErrorEvent): void => { - finish(() => reject(new Error(`TUI stress worker crashed while running ${scenario.name}: ${event.message}`))); - }; - const onMessageError = (): void => { - finish(() => reject(new Error(`TUI stress worker could not deserialize result for ${scenario.name}`))); - }; - worker.addEventListener("message", onMessage); - worker.addEventListener("error", onError); - worker.addEventListener("messageerror", onMessageError); - worker.postMessage(request); - void Bun.sleep(scenario.timeoutMs).then(() => { - finish(() => - reject( - new Error( - `TUI stress scenario timed out after ${scenario.timeoutMs}ms: ${scenario.name} seed=${formatSeed(scenario.seed)} ops=${scenario.iterations}`, - ), - ), - ); + await Promise.all(Array.from({ length: concurrency }, drain)); + if (firstError !== undefined) throw firstError; +} + +async function runScenarioInSubprocess(scenario: Scenario, live: Set): Promise { + const proc = Bun.spawn([process.execPath, SUBPROCESS_ENTRY], { + cwd: import.meta.dir, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", }); - await promise; + live.add(proc); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill(); + }, scenario.timeoutMs); + try { + proc.stdin.write(JSON.stringify(scenario)); + proc.stdin.end(); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + if (timedOut) { + throw new Error( + `TUI stress scenario timed out after ${scenario.timeoutMs}ms: ${scenario.name} seed=${formatSeed(scenario.seed)} ops=${scenario.iterations}`, + ); + } + const result = parseScenarioResult(stdout, stderr, scenario, proc.exitCode); + if (!result.ok) throw scenarioFailureError(result); + } finally { + clearTimeout(timer); + live.delete(proc); + } } -function workerFailureError(message: StressWorkerFailure): Error { - const stack = - message.stack === undefined - ? "" - : ` -${message.stack}`; - return new Error( - `TUI stress worker failed: ${message.scenario} seed=${message.seed} -${message.error}${stack}`, - ); +function parseScenarioResult( + stdout: string, + stderr: string, + scenario: Scenario, + exitCode: number | null, +): StressScenarioResult { + const trimmed = stdout.trim(); + const tail = stderr.trim().length > 0 ? `\n${stderr.trim()}` : ""; + if (trimmed.length === 0) { + throw new Error( + `TUI stress subprocess produced no result for ${scenario.name} seed=${formatSeed(scenario.seed)} (exit=${exitCode})${tail}`, + ); + } + try { + return JSON.parse(trimmed) as StressScenarioResult; + } catch { + throw new Error( + `TUI stress subprocess produced unparseable result for ${scenario.name} seed=${formatSeed(scenario.seed)} (exit=${exitCode}):\n${trimmed}${tail}`, + ); + } +} + +function scenarioFailureError(message: StressScenarioFailure): Error { + const stack = message.stack === undefined ? "" : `\n${message.stack}`; + return new Error(`TUI stress scenario failed: ${message.scenario} seed=${message.seed}\n${message.error}${stack}`); } describe("TUI randomized render stress", () => { @@ -172,11 +157,15 @@ describe("TUI randomized render stress", () => { await runPreexistingScrollbackRegression(); }); + it("keeps no-reflow resize notifications non-destructive during foreground streaming", async () => { + await runNoReflowResizeNotificationRegression(); + }); + const scenarios = buildScenarios(); it( - `preserves render invariants across ${stressBatchLabel(scenarios)} using ${stressWorkerCount(scenarios)} workers`, + `preserves render invariants across ${stressBatchLabel(scenarios)} using ${stressConcurrency(scenarios)} subprocesses`, async () => { - await runScenariosInWorkers(scenarios); + await runScenariosInSubprocesses(scenarios); }, stressBatchTimeoutMs(scenarios), ); diff --git a/packages/tui/test/terminal-appearance.test.ts b/packages/tui/test/terminal-appearance.test.ts index 1e6524ad6..fdc506ca5 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -14,6 +14,8 @@ import { const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); const processPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); +const stdoutColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns"); +const stdoutRowsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "rows"); const stdinSetRawModeDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); const originalWslDistroName = Bun.env.WSL_DISTRO_NAME; const originalWslInterop = Bun.env.WSL_INTEROP; @@ -427,6 +429,8 @@ describe("ProcessTerminal DECRQM + in-band resize (DEC 2026/2048)", () => { restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor); restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor); restoreProperty(process, "platform", processPlatformDescriptor); + restoreProperty(process.stdout, "columns", stdoutColumnsDescriptor); + restoreProperty(process.stdout, "rows", stdoutRowsDescriptor); setCellDimensions(originalCellDims); }); @@ -462,20 +466,26 @@ describe("ProcessTerminal DECRQM + in-band resize (DEC 2026/2048)", () => { terminal.stop(); }); - it("reports a private mode supported when DECRPM status is 1 or 2", () => { + it("reports DECRPM statuses 1, 2, and 3 as supported private modes", () => { const { terminal, reports } = setup(); process.stdin.emit("data", "\x1b[?2026;1$y"); + process.stdin.emit("data", "\x1b[?2048;2$y"); + process.stdin.emit("data", "\x1b[?2031;3$y"); expect(reports).toContainEqual({ mode: 2026, supported: true }); + expect(reports).toContainEqual({ mode: 2048, supported: true }); + expect(reports).toContainEqual({ mode: 2031, supported: true }); terminal.stop(); }); - it("reports permanent DECRPM statuses 3 and 4 as recognized private modes", () => { - const { terminal, reports } = setup(); - process.stdin.emit("data", "\x1b[?2026;3$y"); + it("reports DECRPM status 4 as unsupported for modes the TUI enables", () => { + const { terminal, writes, reports } = setup(); + process.stdin.emit("data", "\x1b[?2026;4$y"); process.stdin.emit("data", "\x1b[?2048;4$y"); - expect(reports).toContainEqual({ mode: 2026, supported: true }); - expect(reports).toContainEqual({ mode: 2048, supported: true }); + expect(reports).toContainEqual({ mode: 2026, supported: false }); + expect(reports).toContainEqual({ mode: 2048, supported: false }); + expect(writes).not.toContain("\x1b[?2048h"); terminal.stop(); + expect(writes).not.toContain("\x1b[?2048l"); }); it("reports a private mode unsupported when DECRPM status is 0", () => { @@ -514,16 +524,30 @@ describe("ProcessTerminal DECRQM + in-band resize (DEC 2026/2048)", () => { terminal.stop(); }); - it("applies an in-band resize report: geometry, cell size, and resize handler", () => { + it("updates geometry and cell size without resizing when an in-band report is unchanged", () => { + Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true }); + Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true }); const { terminal, received, resizeCount } = setup(); - // Enable in-band resize so reported geometry is authoritative. process.stdin.emit("data", "\x1b[?2048;1$y"); process.stdin.emit("data", "\x1b[48;30;100;600;1000t"); expect(terminal.rows).toBe(30); expect(terminal.columns).toBe(100); expect(getCellDimensions()).toEqual({ widthPx: 10, heightPx: 20 }); - expect(resizeCount()).toBeGreaterThan(0); - // The report must not leak into the input handler. + expect(resizeCount()).toBe(0); + expect(received).toEqual([]); + terminal.stop(); + }); + + it("fires resize once when an in-band report changes rows or columns", () => { + Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true }); + Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true }); + const { terminal, received, resizeCount } = setup(); + process.stdin.emit("data", "\x1b[?2048;1$y"); + process.stdin.emit("data", "\x1b[48;31;120;620;1200t"); + expect(terminal.rows).toBe(31); + expect(terminal.columns).toBe(120); + expect(getCellDimensions()).toEqual({ widthPx: 10, heightPx: 20 }); + expect(resizeCount()).toBe(1); expect(received).toEqual([]); terminal.stop(); }); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 05c49267f..a625b96df 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -1,178 +1,149 @@ import type { Terminal, TerminalAppearance } from "@oh-my-pi/pi-tui/terminal"; -import type { - ITerminalInitOnlyOptions, - ITerminalOptions, - IUnicodeVersionProvider, - Terminal as XtermTerminalType, -} from "@xterm/headless"; -import xterm from "@xterm/headless"; +import { CellFlags, Ghostty, type GhosttyCell, type GhosttyTerminal } from "ghostty-web"; -// Extract Terminal class from the module -const XtermTerminal = xterm.Terminal; +// --------------------------------------------------------------------------- +// Shared Ghostty VT engine +// --------------------------------------------------------------------------- +// `VirtualTerminal` is backed by libghostty-vt compiled to WASM — Ghostty's real +// VT100 parser. Unlike the previous xterm.js backing, this is a *modern* +// grapheme-aware terminal: emoji presentation and VS16 promotion measure 2 +// cells, ZWJ/combining clusters collapse into their base cell, BCE/erase and +// scrollback behave exactly as ghostty/kitty/WezTerm/iTerm2 do. That makes the +// render-stress oracles assert against ground-truth modern-terminal semantics +// instead of an approximation, so kitty-class rendering bugs (wide-char overrun, +// pending-wrap staircase, grapheme mis-measure) surface here. +// +// The WASM module is compiled once per module evaluation, then each +// `VirtualTerminal` gets its own instance. ghostty-web's allocator is not robust +// under the hundreds of create/free/write cycles the fuzz tests perform when all +// terminals share one instance; per-terminal instances keep construction +// synchronous while isolating allocator state. +async function loadGhosttyModule(): Promise { + const wasmPath = Bun.resolveSync("ghostty-web/ghostty-vt.wasm", import.meta.dir); + return WebAssembly.compile(await Bun.file(wasmPath).arrayBuffer()); +} + +const ghosttyModule = await loadGhosttyModule(); + +function createGhosttyEngine(): Ghostty { + // libghostty-vt reports unimplemented control sequences (e.g. DECCARA `$r`, + // which this VT build does not apply) through the `env.log` import. Swallow it: + // the oracles assert on what the renderer *emits*, never on the parser's own + // diagnostics, and unbuffered logging would corrupt test output. + return new Ghostty(new WebAssembly.Instance(ghosttyModule, { env: { log: () => {} } })); +} + +function createGhosttyTerminal( + ghostty: Ghostty, + columns: number, + rows: number, + scrollbackCap: number, +): GhosttyTerminal { + return ghostty.createTerminal(columns, rows, { + // Byte budget (not a line count), grown lazily to this ceiling. Sized far + // above the requested line cap so the engine never evicts before the + // wrapper's line-cap clamp does — the clamp is the only eviction the + // harness sees, reproducing xterm's line-count scrollback. + scrollbackLimit: Math.min(0xffff_ffff, Math.max((scrollbackCap + rows + 64) * 4096, 4 * 1024 * 1024)), + fgColor: DEFAULT_FG_RGB, + bgColor: DEFAULT_BG_RGB, + }); +} + +// xterm.js' default scrollback line cap, used when a terminal is created without +// an explicit one. The exposed scrollback is clamped to this many lines (below). +const DEFAULT_SCROLLBACK_LINES = 1000; +// Packed default colors (0xRRGGBB). Light-grey fg on black bg so a styled SGR +// color is always distinguishable from "default" when reading back cells. +const DEFAULT_FG_RGB = 0xcccccc; +const DEFAULT_BG_RGB = 0x000000; +// Compare readback against the configured defaults directly; Ghostty's +// getColors() currently reports render-state metadata, not these cell colors. +const DEFAULT_FG_R = (DEFAULT_FG_RGB >> 16) & 0xff; +const MAX_GHOSTTY_WRITE_CHUNK = 4096; +const SYNC_OUTPUT_BEGIN = "\x1b[?2026h"; +const SYNC_OUTPUT_END = "\x1b[?2026l"; +const OSC_SEQUENCE = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g; + + +const DEFAULT_FG_G = (DEFAULT_FG_RGB >> 8) & 0xff; +const DEFAULT_FG_B = DEFAULT_FG_RGB & 0xff; +const DEFAULT_BG_R = (DEFAULT_BG_RGB >> 16) & 0xff; +const DEFAULT_BG_G = (DEFAULT_BG_RGB >> 8) & 0xff; +const DEFAULT_BG_B = DEFAULT_BG_RGB & 0xff; /** - * Width model for the virtual terminal: - * - "legacy": xterm.js's default Unicode 6 tables (emoji = 1 cell, VS16 = zero - * width). Models older terminals: xterm, conhost, VTE, tmux's own grid. - * - "modern": grapheme-aware semantics used by ghostty/WezTerm/kitty/iTerm2 and - * Windows Terminal 1.22+ (emoji presentation = 2 cells, VS16 promotes its - * base cell to 2 cells). Matches the renderer's native width engine for all - * stress content, so geometric expectations become cell-exact. - */ -export type VirtualTerminalWidthModel = "legacy" | "modern"; - -// xterm.js UnicodeService character-property packing (UnicodeService.ts): -// value = (state << 3) | ((width & 3) << 1) | (shouldJoin ? 1 : 0) -// The format is internal but stable across 5.x/6.x; the calibration test in -// modern-width-provider.test.ts asserts it against the live V6 provider. -const packCharProperties = (width: number, shouldJoin: boolean): number => ((width & 3) << 1) | (shouldJoin ? 1 : 0); -const extractWidthFromProperties = (value: number): number => (value >> 1) & 3; - -const EMOJI_PRESENTATION_RE = /\p{Emoji_Presentation}/u; -const emojiPresentationCache = new Map(); - -function hasDefaultEmojiPresentation(codepoint: number): boolean { - let result = emojiPresentationCache.get(codepoint); - if (result === undefined) { - result = EMOJI_PRESENTATION_RE.test(String.fromCodePoint(codepoint)); - emojiPresentationCache.set(codepoint, result); - } - return result; -} - -/** - * Modern (grapheme-aware) width semantics expressed as overrides on top of the - * real xterm.js Unicode 6 provider: + * Virtual terminal for testing, backed by Ghostty's WASM VT engine. * - * - Codepoints whose default presentation is emoji (`\p{Emoji_Presentation}`, - * e.g. U+1F642) occupy 2 cells; V6 reports 1. - * - VS16 (U+FE0F) after a base cell joins into it and promotes it to 2 cells - * (kitty docs/text-sizing-protocol.rst "Wide emoji" rule; ghostty and - * WezTerm document the same VS15/VS16 handling). + * The engine models the active screen grid plus a linear scrollback history but + * has no interactive scroll-viewport (it is always "at the bottom"). The harness + * relies on xterm-style scroll bookkeeping (`baseY`/`viewportY`/`scrollLines`), + * so this wrapper emulates that window over `[history ++ active grid]`: * - * Everything else (combining marks, CJK, Hangul, controls) delegates to the - * live V6 provider so this model never invents widths for content the - * modern/legacy split does not affect. + * - `baseY` is the scrollback line count, clamped to the requested line cap so a + * small `scrollback` evicts oldest history exactly like xterm's line cap (the + * engine itself evicts by a generous *byte* budget, which we keep far above the + * line cap so the clamp is the only eviction the harness observes). + * - `viewportY` is an absolute scroll offset in `[0, baseY]`; it follows the + * bottom on writes/resizes unless the caller scrolled up, matching xterm. * - * Deliberately NOT modeled: ZWJ collapsing (kitty/alacritty/Windows Terminal do - * not collapse ZWJ sequences — see the "width model disagreement" regression - * tests) and regional-indicator flag pairs (terminals disagree wildly; stress - * content avoids them). - */ -class ModernWidthProvider implements IUnicodeVersionProvider { - readonly version = "modern"; - #v6: IUnicodeVersionProvider; - - constructor(v6: IUnicodeVersionProvider) { - this.#v6 = v6; - } - - wcwidth(codepoint: number): 0 | 1 | 2 { - const width = this.#v6.wcwidth(codepoint); - if (width === 1 && codepoint > 0xff && hasDefaultEmojiPresentation(codepoint)) { - return 2; - } - return width; - } - - charProperties(codepoint: number, preceding: number): number { - // VS16 joins into the preceding cell and promotes it to emoji presentation. - if (codepoint === 0xfe0f && preceding !== 0 && extractWidthFromProperties(preceding) > 0) { - return packCharProperties(2, true); - } - let width: number = this.wcwidth(codepoint); - let shouldJoin = width === 0 && preceding !== 0; - if (shouldJoin) { - const previousWidth = extractWidthFromProperties(preceding); - if (previousWidth === 0) { - shouldJoin = false; - } else if (previousWidth > width) { - width = previousWidth; - } - } - return packCharProperties(width, shouldJoin); - } -} - -/** Internal xterm shape needed to reach the registered V6 provider (test-only). */ -interface XtermCoreInternals { - _core: { unicodeService: { _providers: Record } }; -} - -/** Fetch the live Unicode 6 provider registered on an xterm instance. */ -export function getXtermV6Provider(terminal: XtermTerminalType): IUnicodeVersionProvider { - const provider = (terminal as unknown as XtermCoreInternals)._core.unicodeService._providers["6"]; - if (provider === undefined) { - throw new Error("xterm.js V6 unicode provider not found; internal layout changed"); - } - return provider; -} - -/** - * Virtual terminal for testing using xterm.js for accurate terminal emulation + * This emulation was validated to match `@xterm/headless` bit-for-bit on + * baseY/viewportY/viewport/scrollBuffer across append, overflow, scroll, write- + * while-scrolled, and resize sequences. */ export class VirtualTerminal implements Terminal { - private xterm: XtermTerminalType; - private inputHandler?: (data: string) => void; - private resizeHandler?: () => void; - private _columns: number; - private _rows: number; + #ghostty: Ghostty; + #term: GhosttyTerminal; + #columns: number; + #rows: number; + #scrollbackCap: number; + #viewportY = 0; + #inputHandler?: (data: string) => void; + #resizeHandler?: () => void; + #pendingEngineResize = false; - constructor(columns = 80, rows = 24, scrollback?: number, widthModel: VirtualTerminalWidthModel = "legacy") { - this._columns = columns; - this._rows = rows; - - const options: ITerminalOptions & ITerminalInitOnlyOptions = { - cols: columns, - rows: rows, - // Disable all interactive features for testing - disableStdin: true, - allowProposedApi: true, - }; - if (scrollback !== undefined) { - options.scrollback = scrollback; - } - - // Create xterm instance with specified dimensions - this.xterm = new XtermTerminal(options); - if (widthModel === "modern") { - this.xterm.unicode.register(new ModernWidthProvider(getXtermV6Provider(this.xterm))); - this.xterm.unicode.activeVersion = "modern"; - } + constructor(columns = 80, rows = 24, scrollback?: number) { + this.#columns = columns; + this.#rows = rows; + this.#scrollbackCap = scrollback ?? DEFAULT_SCROLLBACK_LINES; + this.#ghostty = createGhosttyEngine(); + this.#term = createGhosttyTerminal(this.#ghostty, columns, rows, this.#scrollbackCap); } + // --- Terminal interface -------------------------------------------------- + start(onInput: (data: string) => void, onResize: () => void): void { - this.inputHandler = onInput; - this.resizeHandler = onResize; - // Enable bracketed paste mode for consistency with ProcessTerminal - this.xterm.write("\x1b[?2004h"); + this.#inputHandler = onInput; + this.#resizeHandler = onResize; + // Enable bracketed paste mode for consistency with ProcessTerminal. + this.#engineWrite("\x1b[?2004h"); } async drainInput(_maxMs?: number, _idleMs?: number): Promise { - // No-op for virtual terminal - no stdin to drain + // No-op for virtual terminal - no stdin to drain. } stop(): void { - // Disable bracketed paste mode - this.xterm.write("\x1b[?2004l"); - this.inputHandler = undefined; - this.resizeHandler = undefined; + this.#engineWrite("\x1b[?2004l"); + this.#inputHandler = undefined; + this.#resizeHandler = undefined; } write(data: string): void { - this.xterm.write(data); + this.#engineWrite(data); } get columns(): number { - return this._columns; + return this.#columns; } get rows(): number { - return this._rows; + return this.#rows; } get kittyProtocolActive(): boolean { - // Virtual terminal always reports Kitty protocol as active for testing + // Backed by a real Ghostty engine: the Kitty keyboard protocol is genuinely + // supported, so tests can rely on it being active. return true; } @@ -181,50 +152,62 @@ export class VirtualTerminal implements Terminal { } onAppearanceChange(_callback: (appearance: TerminalAppearance) => void): void { - // No-op for virtual terminal + // No-op for virtual terminal. } moveBy(lines: number): void { - if (lines > 0) { - // Move down - this.xterm.write(`\x1b[${lines}B`); - } else if (lines < 0) { - // Move up - this.xterm.write(`\x1b[${-lines}A`); - } - // lines === 0: no movement + if (lines > 0) this.#engineWrite(`\x1b[${lines}B`); + else if (lines < 0) this.#engineWrite(`\x1b[${-lines}A`); } hideCursor(): void { - this.xterm.write("\x1b[?25l"); + this.#engineWrite("\x1b[?25l"); } showCursor(): void { - this.xterm.write("\x1b[?25h"); + this.#engineWrite("\x1b[?25h"); } clearLine(): void { - this.xterm.write("\x1b[K"); + this.#engineWrite("\x1b[K"); } clearFromCursor(): void { - this.xterm.write("\x1b[J"); + this.#engineWrite("\x1b[J"); } clearScreen(): void { - this.xterm.write("\x1b[H\x1b[0J"); // Move to home (1,1) and clear from cursor to end + this.#engineWrite("\x1b[H\x1b[0J"); } setTitle(title: string): void { - // OSC 0;title BEL - set terminal window title - this.xterm.write(`\x1b]0;${title}\x07`); + this.#engineWrite(`\x1b]0;${title}\x07`); } setProgress(active: boolean): void { - // OSC 9;4 progress sequence; no-op in tests beyond writing through to xterm. - this.xterm.write(active ? "\x1b]9;4;3\x07" : "\x1b]9;4;0;\x07"); + this.#engineWrite(active ? "\x1b]9;4;3\x07" : "\x1b]9;4;0;\x07"); } + resize(columns: number, rows: number): void { + const wasBottom = this.#atBottom(); + this.#columns = columns; + this.#rows = rows; + if (this.#resizeHandler) { + this.#pendingEngineResize = true; + } else { + this.#term.resize(columns, rows); + this.#refollowBottom(wasBottom); + } + this.#resizeHandler?.(); + } + + /** Return whether the virtual viewport is at the scrollback tail. */ + isNativeViewportAtBottom(): boolean | undefined { + return this.#atBottom(); + } + + // --- Test-only helpers --------------------------------------------------- + /** Wait for TUI's throttled render pipeline to settle (matches the 16ms frame budget). */ async waitForRender(): Promise { const nextTick = Promise.withResolvers(); @@ -234,84 +217,66 @@ export class VirtualTerminal implements Terminal { await this.flush(); } - // Test-specific methods not in Terminal interface - - /** - * Simulate keyboard input - */ + /** Simulate keyboard input. */ sendInput(data: string): void { - if (this.inputHandler) { - this.inputHandler(data); - } + this.#inputHandler?.(data); } + /** * Simulate the user scrolling through native terminal scrollback. * Negative values scroll up; positive values scroll down. */ scrollLines(lines: number): void { - this.xterm.scrollLines(lines); - } - - /** Return whether the virtual viewport is at the scrollback tail. */ - isNativeViewportAtBottom(): boolean | undefined { - const buffer = this.xterm.buffer.active; - return buffer.viewportY >= buffer.baseY; + this.#viewportY = Math.max(0, Math.min(this.#cappedBaseY(), this.#viewportY + lines)); } /** Get the terminal buffer's scrollback and viewport offsets. */ getBufferPosition(): { baseY: number; viewportY: number } { - const buffer = this.xterm.buffer.active; - return { baseY: buffer.baseY, viewportY: buffer.viewportY }; + return { baseY: this.#cappedBaseY(), viewportY: this.#viewportY }; } - /** - * Resize the terminal - */ - resize(columns: number, rows: number): void { - this._columns = columns; - this._rows = rows; - this.xterm.resize(columns, rows); - if (this.resizeHandler) { - this.resizeHandler(); - } - } - - /** - * Wait for all pending writes to complete. Viewport and scroll buffer will be updated. - */ + /** ghostty.write is synchronous; nothing to drain. Yield a microtask for ordering. */ async flush(): Promise { - // Write an empty string to ensure all previous writes are flushed - const done = Promise.withResolvers(); - this.xterm.write("", done.resolve); - return done.promise; + await Promise.resolve(); } - /** - * Flush and get viewport - convenience method for tests - */ + /** Flush and get viewport - convenience method for tests. */ async flushAndGetViewport(): Promise { await this.flush(); return this.getViewport(); } - /** - * Get the visible viewport (what's currently on screen) - * Note: You should use getViewportAfterWrite() for testing after writing data - */ + /** Get the visible viewport (what's currently on screen). */ getViewport(): string[] { + this.#term.update(); + const active = this.#term.getViewport(); + const capped = this.#cappedBaseY(); + const historyLen = this.#term.getScrollbackLength(); const lines: string[] = []; - const buffer = this.xterm.buffer.active; - - // Get only the visible lines (viewport) - for (let i = 0; i < this.xterm.rows; i++) { - const line = buffer.getLine(buffer.viewportY + i); - if (line) { - lines.push(line.translateToString(true)); - } else { - lines.push(""); - } + for (let i = 0; i < this.#rows; i++) { + const index = this.#viewportY + i; + lines.push( + index < capped + ? this.#historyRowText(historyLen - capped + index) + : this.#activeRowText(active, index - capped), + ); } + return lines; + } + /** Get the entire scroll buffer (clamped scrollback history followed by the active grid). */ + getScrollBuffer(): string[] { + this.#term.update(); + const active = this.#term.getViewport(); + const capped = this.#cappedBaseY(); + const historyLen = this.#term.getScrollbackLength(); + const lines: string[] = []; + const total = capped + this.#rows; + for (let i = 0; i < total; i++) { + lines.push( + i < capped ? this.#historyRowText(historyLen - capped + i) : this.#activeRowText(active, i - capped), + ); + } return lines; } @@ -323,15 +288,12 @@ export class VirtualTerminal implements Terminal { * so leaked SGR state paints whole phantom-colored rows. */ getViewportRowBackgroundColumns(row: number): number[] { - const buffer = this.xterm.buffer.active; - const line = buffer.getLine(buffer.viewportY + row); - if (!line) return []; + const cells = this.#presentedRowCells(row); + if (!cells) return []; const columns: number[] = []; - for (let col = 0; col < this.xterm.cols; col++) { - const cell = line.getCell(col); - if (cell && !cell.isBgDefault()) { - columns.push(col); - } + for (let col = 0; col < cells.length; col++) { + const cell = cells[col]; + if (cell && !this.#isDefaultBg(cell)) columns.push(col); } return columns; } @@ -342,15 +304,12 @@ export class VirtualTerminal implements Terminal { * foreground attributes to the row that emitted them. */ getViewportRowForegroundColumns(row: number): number[] { - const buffer = this.xterm.buffer.active; - const line = buffer.getLine(buffer.viewportY + row); - if (!line) return []; + const cells = this.#presentedRowCells(row); + if (!cells) return []; const columns: number[] = []; - for (let col = 0; col < this.xterm.cols; col++) { - const cell = line.getCell(col); - if (cell && !cell.isFgDefault()) { - columns.push(col); - } + for (let col = 0; col < cells.length; col++) { + const cell = cells[col]; + if (cell && !this.#isDefaultFg(cell)) columns.push(col); } return columns; } @@ -361,59 +320,173 @@ export class VirtualTerminal implements Terminal { * into later rows or erased blanks. */ getViewportRowUnderlineColumns(row: number): number[] { - const buffer = this.xterm.buffer.active; - const line = buffer.getLine(buffer.viewportY + row); - if (!line) return []; + const cells = this.#presentedRowCells(row); + if (!cells) return []; const columns: number[] = []; - for (let col = 0; col < this.xterm.cols; col++) { - const cell = line.getCell(col); - if (cell?.isUnderline()) { - columns.push(col); - } + for (let col = 0; col < cells.length; col++) { + if ((cells[col]?.flags ?? 0) & CellFlags.UNDERLINE) columns.push(col); } return columns; } - /** - * Get the entire scroll buffer - */ - getScrollBuffer(): string[] { - const lines: string[] = []; - const buffer = this.xterm.buffer.active; - - // Get all lines in the buffer (including scrollback) - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - if (line) { - lines.push(line.translateToString(true)); - } else { - lines.push(""); - } - } - - return lines; + /** Whether the cell at a viewport position carries the italic attribute. */ + getCellItalic(row: number, col: number): boolean { + const cells = this.#presentedRowCells(row); + return ((cells?.[col]?.flags ?? 0) & CellFlags.ITALIC) !== 0; } /** * Get the hardware cursor position within the visible viewport. - * Both coordinates are 0-indexed; row is relative to the top of the viewport. + * Both coordinates are 0-indexed; row is relative to the top of the active grid. */ getCursor(): { row: number; col: number } { - const buffer = this.xterm.buffer.active; - return { row: buffer.cursorY, col: buffer.cursorX }; + const cursor = this.#term.getCursor(); + return { row: cursor.y, col: cursor.x }; } - /** - * Clear the terminal viewport - */ + /** Clear the buffer to a blank slate (recreates the engine terminal). */ clear(): void { - this.xterm.clear(); + this.#recreate(); } - /** - * Reset the terminal completely - */ + /** Reset the terminal completely (recreates the engine terminal). */ reset(): void { - this.xterm.reset(); + this.#recreate(); + } + + // --- Internals ----------------------------------------------------------- + + #engineWrite(data: string): void { + const wasBottom = this.#atBottom(); + const clearScrollbackAfterFullClear = "\x1b[2J\x1b[H\x1b[3J"; + const clearIndex = data.indexOf(clearScrollbackAfterFullClear); + if (clearIndex >= 0 && this.#canRecreateForFullClear(data, clearIndex)) { + // ghostty-web 0.4 can trap in WASM when libghostty-vt processes a + // full-clear + ED3 repaint against an existing history buffer. The + // sequence's observable effect here is a blank terminal with empty + // history before repainting the transcript, so create exactly that + // state directly in a fresh WASM instance and feed Ghostty the + // unmodified text/SGR tail. + this.#recreate(); + data = data.slice(0, clearIndex) + data.slice(clearIndex + clearScrollbackAfterFullClear.length); + } else if (this.#pendingEngineResize) { + this.#term.resize(this.#columns, this.#rows); + this.#pendingEngineResize = false; + } + data = this.#stripSynchronizedOutput(data); + this.#writeToGhostty(data); + this.#refollowBottom(wasBottom); + } + + + #stripSynchronizedOutput(data: string): string { + if (!data.includes(SYNC_OUTPUT_BEGIN) && !data.includes(SYNC_OUTPUT_END) && !data.includes("\x1b]")) return data; + return data.replaceAll(SYNC_OUTPUT_BEGIN, "").replaceAll(SYNC_OUTPUT_END, "").replace(OSC_SEQUENCE, ""); + } + + #writeToGhostty(data: string): void { + if (data.length <= MAX_GHOSTTY_WRITE_CHUNK) { + this.#term.write(data); + return; + } + let offset = 0; + while (offset < data.length) { + let end = Math.min(offset + MAX_GHOSTTY_WRITE_CHUNK, data.length); + const last = data.charCodeAt(end - 1); + if (end < data.length && last >= 0xd800 && last <= 0xdbff) end--; + if (end <= offset) end = Math.min(offset + 1, data.length); + this.#term.write(data.slice(offset, end)); + offset = end; + } + } + + #canRecreateForFullClear(data: string, clearIndex: number): boolean { + const paintBegin = "\x1b[?25l\x1b[?2026h\x1b[?7l"; + const paintBeginNoSync = "\x1b[?25l\x1b[?7l"; + return ( + (clearIndex === paintBegin.length && data.startsWith(paintBegin)) || + (clearIndex === paintBeginNoSync.length && data.startsWith(paintBeginNoSync)) + ); + } + + #atBottom(): boolean { + return this.#viewportY >= this.#cappedBaseY(); + } + + /** Scrollback line count exposed to callers, clamped to the requested line cap. */ + #cappedBaseY(): number { + return Math.min(this.#term.getScrollbackLength(), this.#scrollbackCap); + } + #refollowBottom(wasBottom: boolean): void { + const base = this.#cappedBaseY(); + this.#viewportY = wasBottom ? base : Math.min(this.#viewportY, base); + } + + #recreate(_freeCurrent = true): void { + // ghostty-web 0.4's terminal/free path is not safe under the rapid + // resize/full-clear churn in the stress harness. Retire the old instance + // and build a fresh one; tests are short-lived and this preserves the + // observable full-clear/reset semantics without poisoning WASM state. + this.#ghostty = createGhosttyEngine(); + this.#term = createGhosttyTerminal(this.#ghostty, this.#columns, this.#rows, this.#scrollbackCap); + this.#pendingEngineResize = false; + this.#viewportY = 0; + } + + /** Cells of the presented viewport row (history when scrolled up, else active grid). */ + #presentedRowCells(row: number): GhosttyCell[] | null { + const index = this.#viewportY + row; + const capped = this.#cappedBaseY(); + if (index < capped) { + return this.#term.getScrollbackLine(this.#term.getScrollbackLength() - capped + index); + } + const activeRow = index - capped; + if (activeRow < 0 || activeRow >= this.#rows) return null; + return this.#term.getLine(activeRow); + } + + /** Reconstruct an active-grid row's text from a flat viewport cell array. */ + #activeRowText(cells: GhosttyCell[], row: number): string { + let text = ""; + const base = row * this.#columns; + for (let col = 0; col < this.#columns; col++) { + const cell = cells[base + col]; + if (!cell || cell.width === 0) continue; // wide-char trailing spacer + if (cell.codepoint === 0) { + text += " "; + } else { + text += + cell.grapheme_len > 0 ? this.#term.getGraphemeString(row, col) : String.fromCodePoint(cell.codepoint); + } + } + return text.replace(/\s+$/u, ""); + } + + /** Reconstruct a scrollback-history row's text by line offset (0 = oldest). */ + #historyRowText(offset: number): string { + const cells = this.#term.getScrollbackLine(offset); + if (!cells) return ""; + let text = ""; + for (let col = 0; col < cells.length; col++) { + const cell = cells[col]; + if (!cell || cell.width === 0) continue; + if (cell.codepoint === 0) { + text += " "; + } else { + text += + cell.grapheme_len > 0 + ? this.#term.getScrollbackGraphemeString(offset, col) + : String.fromCodePoint(cell.codepoint); + } + } + return text.replace(/\s+$/u, ""); + } + + #isDefaultBg(cell: GhosttyCell): boolean { + return cell.bg_r === DEFAULT_BG_R && cell.bg_g === DEFAULT_BG_G && cell.bg_b === DEFAULT_BG_B; + } + + #isDefaultFg(cell: GhosttyCell): boolean { + return cell.fg_r === DEFAULT_FG_R && cell.fg_g === DEFAULT_FG_G && cell.fg_b === DEFAULT_FG_B; } }