test(tui): migrated VirtualTerminal to ghostty-web VT engine

- Replaced @xterm/headless backing with libghostty-vt WASM for grapheme-aware widths.
- Removed legacy/modern width-model split now that the terminal matches the renderer.
- Reframed width-disagreement regressions around Ghostty row containment.
- Added non-destructive in-band resize handling and regression coverage.
This commit is contained in:
can1357
2026-06-04 03:48:31 +02:00
parent b0cf24ca0d
commit a5a983c26b
14 changed files with 774 additions and 729 deletions
+4 -1
View File
@@ -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=="],
+1
View File
@@ -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",
+1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -44,7 +44,7 @@
},
"devDependencies": {
"chalk": "catalog:",
"@xterm/headless": "catalog:"
"ghostty-web": "catalog:"
},
"engines": {
"bun": ">=1.3.14"
+3 -10
View File
@@ -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();
});
});
@@ -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<number> {
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<readonly [name: string, text: string]> = [
["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);
});
});
+104 -97
View File
@@ -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";
+136 -97
View File
@@ -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<Scenario, "envMode" | "terminalMode" | "geometryMode" | "widthModel">,
template: Pick<Scenario, "envMode" | "terminalMode" | "geometryMode">,
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<T>(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<void> {
const run = async (): Promise<void> => {
@@ -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<string, unknown>)[key];
return;
}
Object.defineProperty(target, key, descriptor);
}
export async function runNoReflowResizeNotificationRegression(): Promise<void> {
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<void> {
const term = new VirtualTerminal(40, 5, 100);
const scheduler = new StressRenderScheduler();
@@ -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<void> {
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();
-45
View File
@@ -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<void> {
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);
}
+111 -122
View File
@@ -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<void> {
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<void> {
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<void> {
const { promise, resolve, reject } = Promise.withResolvers<void>();
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<void> {
const concurrency = stressConcurrency(scenarios);
if (concurrency === 0) return;
const live = new Set<StressSubprocess>();
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<void> => {
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<StressSubprocess>): Promise<void> {
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),
);
+34 -10
View File
@@ -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();
});
+341 -268
View File
@@ -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<WebAssembly.Module> {
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<number, boolean>();
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<string, IUnicodeVersionProvider> } };
}
/** 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<void> {
// 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<void> {
const nextTick = Promise.withResolvers<void>();
@@ -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<void> {
// Write an empty string to ensure all previous writes are flushed
const done = Promise.withResolvers<void>();
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<string[]> {
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;
}
}