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:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chalk": "catalog:",
|
||||
"@xterm/headless": "catalog:"
|
||||
"ghostty-web": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user