import assert from "node:assert/strict"; import test from "node:test"; import { spawnSync } from "node:child_process"; import { DEFAULT_OUTPUT_HISTORY_MAX_LINES, createTerminalOutputHistoryPreview, nextOutputHistoryPreviewTop, stripTerminalDisplayToPlainText, wrapOutputHistoryLineToRows, } from "./terminalOutputHistory.ts"; test("display chunks reduce to plain transcript text", () => { assert.deepEqual( stripTerminalDisplayToPlainText("\x1b[32mhello\x1b[0m world\r\n"), { text: "hello world\r\n", pending: "" }, ); assert.equal( stripTerminalDisplayToPlainText("\x1b]0;title\x07tail").text, "tail", ); assert.equal( stripTerminalDisplayToPlainText("\x1b]0;title\x1b\\tail").text, "tail", ); assert.equal(stripTerminalDisplayToPlainText("a\x07b\x00c").text, "abc"); }); test("escape sequences split across chunks are not leaked into the transcript", () => { const first = stripTerminalDisplayToPlainText("ok\x1b[3"); assert.equal(first.text, "ok"); assert.equal(first.pending, "\x1b[3"); const second = stripTerminalDisplayToPlainText("1mred", first.pending); assert.equal(second.text, "red"); assert.equal(second.pending, ""); }); test("escape sequences with intermediate bytes are consumed through their final byte", () => { // ESC ( B designates G0 (ncurses / terminal reset emit it constantly). assert.equal(stripTerminalDisplayToPlainText("\x1b(Bplain").text, "plain"); // ESC # 8 is DECALN. assert.equal(stripTerminalDisplayToPlainText("\x1b#8plain").text, "plain"); // Two-byte escapes without intermediates keep working. assert.equal(stripTerminalDisplayToPlainText("\x1bMplain").text, "plain"); const first = stripTerminalDisplayToPlainText("ok\x1b("); assert.equal(first.text, "ok"); assert.equal(first.pending, "\x1b("); assert.equal(stripTerminalDisplayToPlainText("Btail", first.pending).text, "tail"); }); test("a cursor-addressed frame without newlines stays inside the character budget", () => { const history = createTerminalOutputHistoryPreview({ maxChars: 64 }); for (let frame = 0; frame < 200; frame += 1) { history.append(`\x1b[Hframe ${frame} ${"x".repeat(80)}`); } const transcript = [...history.getLines()].join(""); assert.ok(transcript.length <= 2 * 64, `unbounded open line: ${transcript.length}`); // The newest frame still lands in the retained tail. assert.ok(transcript.includes("frame 199"), transcript.slice(-200)); }); test("8-bit control strings are consumed through their terminator", () => { // C1 OSC (0x9d) with C1 ST (0x9c) must not leak its payload. assert.equal(stripTerminalDisplayToPlainText("\x9d0;SECRET\x9ctail").text, "tail"); // DCS (0x90), SOS (0x98), PM (0x9e) and APC (0x9f) use the same terminator. assert.equal(stripTerminalDisplayToPlainText("\x90payload\x9ctail").text, "tail"); assert.equal(stripTerminalDisplayToPlainText("\x98payload\x9ctail").text, "tail"); assert.equal(stripTerminalDisplayToPlainText("\x9epayload\x9ctail").text, "tail"); assert.equal(stripTerminalDisplayToPlainText("\x9fpayload\x9ctail").text, "tail"); const first = stripTerminalDisplayToPlainText("ok\x9d0;title"); assert.equal(first.text, "ok"); assert.equal(first.pending, "\x9d0;title"); assert.equal(stripTerminalDisplayToPlainText("\x9ctail", first.pending).text, "tail"); }); test("tabs expand to terminal tab stops so preview rows wrap as they render", () => { const history = createTerminalOutputHistoryPreview(); history.append("a\tb\n"); history.append("abc\td\n"); assert.deepEqual([...history.getLines()], ["a b", "abc d"]); }); test("tab stops advance by cell columns, not characters", () => { const history = createTerminalOutputHistoryPreview(); history.append("中\tb\n"); assert.deepEqual([...history.getLines()], ["中 b"]); }); test("an oversized control string split across chunks leaks no payload", () => { const payload = "y".repeat(5000); const first = stripTerminalDisplayToPlainText(`ok\x1b]52;c;${payload}`); assert.equal(first.text, "ok"); assert.equal(first.pending.length, 4096); assert.equal(first.pending.startsWith("\x1b]"), true); const second = stripTerminalDisplayToPlainText("\x07tail", first.pending); assert.equal(second.text, "tail"); }); test("a span longer than the remaining budget continues into the next line", () => { const history = createTerminalOutputHistoryPreview({ maxChars: 16 }); history.append("aaaaaaaaaaaaaaaaTTTTTTTT\n"); assert.ok([...history.getLines()].join("").includes("TTTTTTTT")); }); test("erase-in-line after a carriage return drops the stale suffix", () => { const history = createTerminalOutputHistoryPreview(); history.append("downloading 100%\rdownloading 5%\x1b[K\n"); assert.deepEqual([...history.getLines()], ["downloading 5%"]); history.clear(); history.append("keep\r\x1b[2Knew\n"); assert.deepEqual([...history.getLines()], ["new"]); }); test("clear resets the tab stop column with the rest of the line state", () => { const history = createTerminalOutputHistoryPreview(); history.append("abc"); history.clear(); history.append("\tb\n"); assert.deepEqual([...history.getLines()], [" b"]); }); test("bare carriage returns overwrite the line they restart", () => { const history = createTerminalOutputHistoryPreview(); history.append("downloading 10%\r"); history.append("downloading 55%\r"); history.append("downloading 100%\r\n"); history.append("done\n"); assert.deepEqual([...history.getLines()], ["downloading 100%", "done"]); }); test("history keeps a bounded tail of lines", () => { const history = createTerminalOutputHistoryPreview({ maxLines: 3 }); for (let index = 0; index < 6; index += 1) history.append(`line ${index}\n`); assert.deepEqual( [...history.getLines()], ["line 3", "line 4", "line 5"], ); }); test("preview rows wrap long lines and flag the continuation rows", () => { const history = createTerminalOutputHistoryPreview(); history.append("ok\n"); history.append("abcdefgh\n"); const window = history.getPreviewRows({ cols: 4, rows: 3, top: 0 }); assert.equal(window.totalRows, 3); assert.deepEqual( window.rows.map((row) => row.text), ["ok", "abcd", "efgh"], ); assert.deepEqual( window.rows.map((row) => row.isWrapped), [false, false, true], ); }); test("preview rows flag lines committed by automatic wraps as soft-wrapped", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // "abcde" fills the viewport and "f" wraps; the transcript commits both // lines, but the preview must keep the soft-wrapped join between them. history.append("abcdef\n"); assert.deepEqual([...history.getLines()], ["abcde", "f"]); assert.deepEqual( history.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.isWrapped), [false, true], ); }); test("preview reflows adjacent soft-wrapped lines together when widened", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // Captured at five columns, the transcript holds two wrap segments; a // ten-column preview must rejoin them into the single row xterm shows // after its resize reflow. history.append("abcdef\n"); assert.deepEqual( history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows.map((row) => row.text), ["abcdef"], ); // A narrower preview keeps splitting the rejoined run, flagging the // continuation rows. assert.deepEqual( history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.text), ["abc", "def"], ); assert.deepEqual( history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.isWrapped), [false, true], ); }); test("CUB moves from the displayed last column while a wrap is deferred", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // "abcde" fills the row and defers the wrap; xterm displays the cursor on // the last column, so CUB 1 targets it minus one and X overwrites "e". history.append("abcde\x1b[DX"); assert.deepEqual([...history.getLines()], ["abcXe"]); history.clear(); // CUF from the same deferred state still clamps to the last column. history.append("abcde\x1b[CX"); assert.deepEqual([...history.getLines()], ["abcdX"]); }); test("preview windows clamp to the retained rows and pad short output", () => { const history = createTerminalOutputHistoryPreview(); history.append("one\ntwo\n"); assert.deepEqual( history.getPreviewRows({ cols: 10, rows: 2, top: 99 }).rows.map((row) => row.text), ["one", "two"], ); assert.deepEqual( history.getPreviewRows({ cols: 10, rows: 4, top: 0 }).rows.map((row) => row.text), ["one", "two", "", ""], ); }); test("preview rows keep wide characters intact at a column boundary", () => { // Four columns hold two CJK cells per row; the glyphs are never split. assert.deepEqual(wrapOutputHistoryLineToRows("中文中文中文", 4), ["中文", "中文", "中文"]); const history = createTerminalOutputHistoryPreview(); history.append("中文中文中文\n"); assert.deepEqual( history.getPreviewRows({ cols: 4, rows: 3, top: 0 }).rows.map((row) => row.text), ["中文", "中文", "中文"], ); }); test("wrap decisions measure with the live terminal's Unicode width provider", () => { // The configured `15-graphemes` runtime counts `🖥` as one xterm cell while // the local fallback counts two, so without the injected provider the // tracker commits `abcd` and puts `🖥X` on the continuation row while xterm // renders `abcd🖥` with `X` wrapped. The preview must match the terminal. const widthTerm = { _core: { unicodeService: { getStringCellWidth: (s: string) => [...s].length }, }, } as never; const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); history.setWidthTerminal(widthTerm); history.append("abcd🖥X"); assert.deepEqual(history.getLines(), ["abcd🖥", "X"]); const preview = createTerminalOutputHistoryPreview(); preview.setWidthTerminal(widthTerm); preview.append("abcd🖥X\n"); assert.deepEqual( preview.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.text), ["abcd🖥", "X"], ); }); test("wheel steps walk the preview from the newest row upwards", () => { const history = createTerminalOutputHistoryPreview(); for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`); const totalRows = history.getPreviewRows({ cols: 20, rows: 2, top: 0 }).totalRows; assert.equal(totalRows, 6); const bottom = nextOutputHistoryPreviewTop({ currentTop: null, lines: 0, rows: 2, totalRows, }); assert.equal(bottom, 4); assert.deepEqual( history.getPreviewRows({ cols: 20, rows: 2, top: bottom }).rows.map((row) => row.text), ["row 4", "row 5"], ); const up = nextOutputHistoryPreviewTop({ currentTop: bottom, lines: -3, rows: 2, totalRows }); assert.equal(up, 1); const top = nextOutputHistoryPreviewTop({ currentTop: up, lines: -30, rows: 2, totalRows }); assert.equal(top, 0); }); test("clear drops retained transcript and pending escapes", () => { const history = createTerminalOutputHistoryPreview(); history.append(`keep\n`); history.clear(); history.append("\x1b[3"); assert.deepEqual([...history.getLines()], []); assert.equal( history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).totalRows, 0, ); }); test("default retention bounds the preview history", () => { assert.equal(DEFAULT_OUTPUT_HISTORY_MAX_LINES > 0, true); const history = createTerminalOutputHistoryPreview({ maxLines: 2, maxChars: 6 }); history.append("aaaaaaaa\n"); history.append("bbbbbbbb\n"); history.append("cccccccc\n"); // Retained lines plus the open line stay within twice the character budget, // and the newest text survives. const transcript = [...history.getLines()].join(""); assert.ok(transcript.length <= 2 * 6, transcript); assert.ok(transcript.includes("cc"), transcript); }); test("Vim absolute row moves separate printed rows across arbitrary chunks", () => { const input = '\x1b[2;1HSMOKE-VIM-002\x1b[2;14H\x1b[K\x1b[3;1HSMOKE-VIM-003\x1b[3;14H\x1b[K\x1b[4;1HSMOKE-VIM-004'; for (let split = 0; split <= input.length; split++) { const history = createTerminalOutputHistoryPreview(); history.append(input.slice(0, split)); history.append(input.slice(split)); assert.deepEqual(history.getLines(), ['SMOKE-VIM-002', 'SMOKE-VIM-003', 'SMOKE-VIM-004']); } }); test("same-row cursor-home redraw stays one progress line", () => { const history = createTerminalOutputHistoryPreview(); for (let i = 0; i < 100; i++) history.append(`\x1b[1;1Hprogress ${i}\x1b[K`); assert.deepEqual(history.getLines(), ['progress 99']); }); test("vertical moves do not insert blank transcript rows or expose control strings", () => { const history = createTerminalOutputHistoryPreview(); history.append('one\r\n\x1b[2;1Htwo\x1b[1Bthree\x9b4;1Hfour'); history.append('\x1b]titleH\x07\x1b[999999999Bfive'); assert.deepEqual(history.getLines(), ['one', 'two', ' three', 'four', ' five']); history.clear(); history.append('\x1b[Hnew\x1b[HNEW\x1b[K'); assert.deepEqual(history.getLines(), ['NEW']); }); test("row controls retain requested and inherited terminal columns", () => { const history = createTerminalOutputHistoryPreview(); history.append('\x1b[2;10Htext\x1b[1Bnext\x1b[6dlast\x1b[1Eleft'); assert.deepEqual(history.getLines(), [' text', ' next', ' last', 'left']); history.clear(); history.append('\x9b2;3fAB\x1b[2;3HXY'); assert.deepEqual(history.getLines(), [' XY']); }); test("positioned output respects wide-cell boundaries and retained size limits", () => { const history = createTerminalOutputHistoryPreview({ maxChars: 64 }); history.append('\x1b[2;3H中文\x1b[2;5HX\x1b[K'); assert.deepEqual(history.getLines(), [' 中X']); history.clear(); history.append('\x1b[2;999999999Hend'); assert.ok(history.getLines().join('').length <= 128); assert.ok(history.getLines().join('').endsWith('end')); }); test("CHA and relative horizontal moves stay on the tracked row", () => { const history = createTerminalOutputHistoryPreview(); history.append('abcde\x1b[1GX'); assert.deepEqual(history.getLines(), ['Xbcde']); history.clear(); history.append('ab\x1b[3CX'); assert.deepEqual(history.getLines(), ['ab X']); history.clear(); history.append('abcde\x1b[2DX'); assert.deepEqual(history.getLines(), ['abcXe']); }); test("NEL and IND advance the row; RI moves back up", () => { const history = createTerminalOutputHistoryPreview(); history.append('foo\x1bEbar'); assert.deepEqual(history.getLines(), ['foo', 'bar']); history.clear(); history.append('foo\x1bDbar'); assert.deepEqual(history.getLines(), ['foo', ' bar']); history.clear(); history.append('\x1b[r\x1b[3;1Hfoo\x1bMbar'); assert.deepEqual(history.getLines(), ['foo', ' bar']); history.clear(); history.append('\x1b[2;3r\x1b[2;1Hfoo\x1bMbar'); assert.deepEqual(history.getLines(), ['foo', ' bar']); }); test("cursor placement alone does not append blank history rows", () => { const history = createTerminalOutputHistoryPreview(); history.append('abc\x1b[2;4H\x1b[1B\x1b[1B'); assert.deepEqual(history.getLines(), ['abc']); history.append('X'); assert.deepEqual(history.getLines(), ['abc', ' X']); }); test("positioned overwrites replace whole graphemes and preserve cell spacing", () => { for (const glyph of ['😀', '👩‍💻', '中']) { const history = createTerminalOutputHistoryPreview(); history.append(`A${glyph}B\x1b[1;2HX`); assert.deepEqual(history.getLines(), ['AX B']); history.clear(); history.append(`A${glyph}B\x1b[1;3HX`); assert.deepEqual(history.getLines(), ['A XB']); } const history = createTerminalOutputHistoryPreview(); history.append('AéB\x1b[1;2HX'); assert.deepEqual(history.getLines(), ['AXB']); }); test("EL 1 erases a wide glyph intersected by the erase boundary", () => { // The cursor lands on the wide glyph's first cell: xterm blanks both cells // and keeps the suffix at its columns. const history = createTerminalOutputHistoryPreview(); history.append('中A\x1b[1G\x1b[1K'); assert.deepEqual(history.getLines(), [' A']); history.clear(); // The cursor lands on the wide glyph's second cell: the glyph is erased too. history.append('A中B\x1b[1;3H\x1b[1K'); assert.deepEqual(history.getLines(), [' B']); }); test("cursor-row moves clamp to the reported terminal viewport", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); // Bottom-row address + a down move past the viewport: the terminal keeps // the cursor on row 24, so the status redraw must not split history lines. history.append("\x1b[24;1Hstatus\x1b[1BNEW"); assert.deepEqual(history.getLines(), ["statusNEW"]); history.clear(); // Absolute rows beyond the viewport clamp to the bottom row too: the redraw // overwrites the bottom row in place instead of adding a history line. history.append("\x1b[24;1Hrow24\x1b[999;1Hredraw"); assert.deepEqual(history.getLines(), ["redraw"]); history.clear(); // Without a reported viewport the legacy behavior applies (columns are // retained across row moves). const unclamped = createTerminalOutputHistoryPreview(); unclamped.append("\x1b[24;1Hstatus\x1b[1BNEW"); assert.deepEqual(unclamped.getLines(), ["status", " NEW"]); }); test("absolute cursor columns clamp to the reported viewport width", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(80); // CUP past the last column clamps to it on screen; without the clamp the // preview would pad 998 spaces and fabricate wrapped rows. history.append("\x1b[1;999HX"); assert.deepEqual(history.getLines(), [" ".repeat(79) + "X"]); }); test("line feeds on the bottom row stay clamped to it", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.append("\x1b[24;1Hstatus\r\nnew\x1b[24;1HNEW"); assert.deepEqual(history.getLines(), ["status", "NEW"]); }); test("shrinking the viewport clamps the tracked cursor row", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.append("\x1b[24;1Hbottom"); history.setViewportRows(10); // The terminal moved the cursor to the new bottom row, so a relative move // from there must stay a same-row redraw instead of committing a line. history.append("\x1b[1BX"); assert.deepEqual(history.getLines(), ["bottomX"]); }); test("relative row moves stop at the scroll region's bottom margin", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.append("\x1b[1;20r\x1b[20;1Hstatus\x1b[1BNEW"); assert.deepEqual(history.getLines(), ["statusNEW"]); }); test("invalid DECSTBM ranges are ignored", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); // Bottom not greater than top: xterm ignores the sequence, so the existing // text must stay on its row and later text must not home the cursor. history.append("\x1b[5;1Hbefore\x1b[20;10rafter"); assert.deepEqual(history.getLines(), ["beforeafter"]); history.clear(); // A top past the viewport clamps to it, leaving no valid region either. history.append("\x1b[5;1Hbefore\x1b[30;40rafter"); assert.deepEqual(history.getLines(), ["beforeafter"]); history.clear(); // A bottom past the viewport clamps to it; the valid region still applies. history.append("\x1b[1;999r\x1b[24;1Hstatus\x1b[999BNEW"); assert.deepEqual(history.getLines(), ["statusNEW"]); }); test("growing the viewport resets the scroll margins", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.append("\x1b[1;24r"); history.setViewportRows(40); // xterm resets the scroll region on resize, so a relative move past row 24 // reaches row 25 instead of stopping at the stale bottom margin. history.append("\x1b[24;1Hrow24\x1b[1Brow25"); assert.deepEqual(history.getLines(), ["row24", " row25"]); }); test("clear resets retained scroll margins between terminal boots", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.append("\x1b[1;20robsolete"); history.clear(); // The fresh xterm instance has a full-viewport region; the new session must // not inherit the previous boot's bottom margin. history.setViewportRows(24); history.append("\x1b[20;1Hrow20\x1b[1Brow21"); assert.deepEqual(history.getLines(), ["row20", " row21"]); }); test("narrowing the viewport clamps the tracked cursor column", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(80); history.append("\x1b[1;80H"); history.setViewportCols(10); // xterm clamps the cursor to the new last column; the next printable span // must be written there, not padded out to the old column 80. history.append("X"); assert.deepEqual(history.getLines(), [" ".repeat(9) + "X"]); }); test("narrowing the viewport trims the tracked cursor row like xterm", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(10); history.append("abcdefgh"); history.setViewportCols(5); // xterm trims the cursor row to the new width on a narrowing resize // (`reflowCursorLine` defaults to false, so `fgh` is discarded instead of // re-wrapped), so X overwrites the last retained cell rather than leaving // a phantom `fgh` tail plus an extra wrapped preview row. history.append("X"); assert.deepEqual(history.getLines(), ["abcdX"]); assert.deepEqual( history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows.map((row) => row.text), ["abcdX"], ); }); test("narrowing the viewport drops the trimmed wide-glyph tail", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(6); history.append("中文"); history.setViewportCols(3); // The second glyph spans cells 2-3 past the new width; xterm discards it // whole, so the open row keeps only the glyph that still fits and the next // printable span lands on the new last column. assert.deepEqual(history.getLines(), ["中"]); history.append("X"); assert.deepEqual(history.getLines(), ["中X"]); }); test("printable output past the viewport width wraps the tracked cursor", () => { // xterm wraps `f` onto the second row, so `CSI 2;1H` overwrites it there // instead of adding a separate line below a stale wrapped row. const input = "abcdef\x1b[2;1HXY"; for (let split = 0; split <= input.length; split++) { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); history.append(input.slice(0, split)); history.append(input.slice(split)); assert.deepEqual(history.getLines(), ["abcde", "XY"]); } }); test("a carriage return cancels the deferred wrap instead of splitting the row", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); history.append("abcde\rXYZ"); assert.deepEqual(history.getLines(), ["XYZde"]); }); test("wide glyphs wrap whole at the viewport width", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(4); history.append("中文中文中文\n"); assert.deepEqual(history.getLines(), ["中文", "中文", "中文"]); }); test("origin mode resolves absolute rows against the scroll region", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); // In origin mode both addresses target row 20 (top margin + 15, then the // bottom-margin clamp), so the redraw overwrites `status` in place instead // of retaining it as a stale separate line. history.append("\x1b[5;20r\x1b[?6h\x1b[16;1Hstatus\x1b[99;1HNEW"); assert.deepEqual(history.getLines(), ["NEWtus"]); history.clear(); // clear() forgets origin mode: rows are absolute again. history.setViewportRows(24); history.append("\x1b[5;20r\x1b[?6h\x1b[?6l\x1b[5;1Habsolute"); assert.deepEqual(history.getLines(), ["absolute"]); }); test("non-wrapping wide glyph at the right edge cannot block terminal capture", () => { const moduleUrl = new URL("./terminalOutputHistory.ts", import.meta.url).href; const script = ` import { createTerminalOutputHistoryPreview } from ${JSON.stringify(moduleUrl)}; const results = []; for (const glyph of ["中", "👩‍💻", "界界"]) { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); history.append(String.fromCharCode(27) + "[?7labcd" + glyph + "Z"); results.push(history.getLines()); } console.log(JSON.stringify(results)); `; const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { encoding: "utf8", timeout: 5000, }); assert.equal(result.error, undefined, String(result.error)); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), [["abcdZ"], ["abcdZ"], ["abcdZ"]]); }); test("unchanged viewport reports preserve pending wrap across display chunks", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); history.append("abcde"); history.setViewportRows(24); history.setViewportCols(5); history.append("f"); assert.deepEqual(history.getLines(), ["abcde", "f"]); }); test("a zero-width mark arriving after the wrap column joins the final cell", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // The base character fills the last column; its combining mark arrives in // the next chunk. xterm attaches the mark to that cell without wrapping. history.append("abcde"); history.append("́Z\n"); assert.deepEqual([...history.getLines()], ["abcdé", "Z"]); }); test("a standalone zero-width mark after a control starts the wrapped row", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // The SGR control xterm parses resets its preceding-join state, so the // combining mark that follows is a standalone grapheme: it must not attach // to the previous row's final cell; the pending wrap happens and the mark // begins the next row (like xterm's `15-graphemes` runtime). history.append("abcde\x1b[31m"); history.append("́Z\n"); assert.deepEqual([...history.getLines()], ["abcde", "́Z"]); // The same holds when the control rides inside one display chunk. history.clear(); history.setViewportRows(24); history.setViewportCols(5); history.append("abcde\x1b[31ḿZ\n"); assert.deepEqual([...history.getLines()], ["abcde", "́Z"]); }); test("combined private-mode controls apply every parameter", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // `CSI ?6;7l` resets both origin mode and DECAWM in one control; dropping // the control whole would leave autowrap on and wrap the overflow into a // second row (xterm keeps `abcdef` on one row as `abcdf`). history.append("\x1b[5;20r\x1b[?6;7l\x1b[5;1Habcdef"); assert.deepEqual(history.getLines(), ["abcdf"]); // `CSI ?6;7h` re-enables both: absolute rows clamp to the margins again and // printable output wraps once more. history.append("\x1b[?6;7h\x1b[99;1HNEW"); assert.deepEqual(history.getLines(), ["abcdf", "NEW"]); // Unrelated parameters ride along: xterm applies both `CSI ?6;25h` params // (25 is DECTCEM, untracked here), so dropping the control whole would // resolve the later CUP row absolutely instead of against the top margin. const combined = createTerminalOutputHistoryPreview(); combined.setViewportRows(24); combined.append("\x1b[5;20r\x1b[?6;25h\x1b[16;1Hstatus\x1b[99;1HOLD"); assert.deepEqual(combined.getLines(), ["OLDtus"]); }); test("a grapheme longer than the per-row piece cap is not dropped", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // One base character trailed by more combining marks than the piece cap // holds: cutting at a grapheme boundary yields an empty slice for it, and // returning early would silently drop this grapheme and everything after. const mark = "́"; history.append("abcd" + "e" + mark.repeat(80) + "Z"); assert.deepEqual(history.getLines(), ["abcde" + mark.repeat(80), "Z"]); }); test("discarding a non-fitting wide glyph does not re-pad the deferred gap", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // Cursor on the last column, DECAWM off: the wide glyph is discarded and the // next narrow character overwrites the last cell without padding it again. history.append("\x1b[?7l\x1b[1;5H中Z"); assert.deepEqual(history.getLines(), [" Z"]); }); test("restoring a cursor saved above a shrunken viewport clamps to the bottom row", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.append("\x1b[24;1Hsaved\x1b7"); history.setViewportRows(10); history.append("\x1b8X"); // The saved row 24 no longer exists; the restore lands on the bottom row so // the later bottom-row address writes `Y` on the same line as `X` instead of // keeping an obsolete extra line (the saved column is preserved). history.append("\x1b[10;1HY"); assert.deepEqual(history.getLines(), ["saved", "Y X"]); }); test("RIS resets the tracked modes, margins, and cursor", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // DECAWM off, then RIS: xterm re-enables autowrap and homes the cursor, so // `abcdef` wraps to `abcde`/`f` exactly like a fresh terminal. history.append("\x1b[?7l\x1bcabcdef"); assert.deepEqual(history.getLines(), ["abcde", "f"]); // RIS also clears the scroll region, DECOM, and the saved cursor. const reset = createTerminalOutputHistoryPreview(); reset.setViewportRows(24); reset.append("\x1b[5;20r\x1b[?6h\x1b7\x1bc\x1b[99;1HOLD"); // Without the reset, the CUP row would be relative to the top margin and // clamp to its bottom (row 20), rewriting the same tracked line as before. assert.deepEqual(reset.getLines(), ["OLD"]); }); test("RIS commits the open row even when the cursor is already homed", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(20); // xterm clears the display before homing the cursor, so output after RIS // must not overwrite a stale suffix of the cleared screen row. history.append("LONG\x1bcX"); assert.deepEqual(history.getLines(), ["LONG", "X"]); }); test("a grapheme split across display chunks is rejoined", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(5); history.setViewportCols(4); // The backend delivered the ZWJ sequence's base in one chunk and its // continuation in the next; xterm's grapheme provider joins them across // writes, so the preview must measure the joined cluster, not the chunks. history.append("👩"); history.append("‍💻Z"); assert.deepEqual(history.getLines(), ["👩‍💻Z"]); // The unsplit input is the reference behavior. const joined = createTerminalOutputHistoryPreview(); joined.setViewportRows(5); joined.setViewportCols(4); joined.append("👩‍💻Z"); assert.deepEqual(joined.getLines(), ["👩‍💻Z"]); }); test("a control between chunks resets the split-grapheme join", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(5); history.setViewportCols(5); // xterm resets its preceding-join state at every control it parses, so an // SGR between the base chunk and the continuation leaves two graphemes: // 👩 (2 cells) + ‍💻 (2 cells) + Z fill the row before Q wraps. history.append("👩"); history.append("\x1b[31m"); history.append("‍💻ZQ"); assert.deepEqual(history.getLines(), ["👩‍💻Z", "Q"]); // The unsplit input with the same control is the reference behavior. const joined = createTerminalOutputHistoryPreview(); joined.setViewportRows(5); joined.setViewportCols(5); joined.append("👩\x1b[31m‍💻ZQ"); assert.deepEqual(joined.getLines(), ["👩‍💻Z", "Q"]); }); test("a width-only resize resets the tracked scroll margins", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(80); history.append("\x1b[1;20r"); history.append("row20content"); // xterm resets its scroll region on any buffer resize; a width-only resize // must clear the old DECSTBM margins so relative moves clamp to the live // bottom row instead of the region's stale one. history.setViewportRows(24); history.setViewportCols(40); history.append("\x1b[20;1HX\x1b[BY"); assert.deepEqual(history.getLines(), ["row20content", "X", " Y"]); }); test("preview reflow retains leading, consecutive and trailing blank hard lines", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportCols(5); history.append("\nabcdef\n\n\nend\n\n"); assert.deepEqual([...history.getLines()], ["", "abcde", "f", "", "", "end", ""]); const preview = history.getPreviewRows({ cols: 10, rows: 6, top: 0 }); assert.equal(preview.totalRows, 6); assert.deepEqual(preview.rows.map((row) => row.text), ["", "abcdef", "", "", "end", ""]); }); test("blank-only history remains distinct from empty history", () => { const history = createTerminalOutputHistoryPreview(); assert.equal(history.getPreviewRowCount(10), 0); history.append("\n\n"); assert.equal(history.getPreviewRowCount(10), 2); }); test("reflow keeps an erase-blanked wrapped predecessor as its own row", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(5); // At five columns `abcde` fills row one and defers the wrap; `CSI 2K` // blanks it and `X` wraps to row two, so xterm shows a blank first row and // a wrapped `X` on the second. The preview must keep both rows instead of // joining the erased predecessor into its continuation. history.append("abcde\x1b[2KX"); assert.deepEqual([...history.getLines()], ["", "X"]); const preview = history.getPreviewRows({ cols: 5, rows: 2, top: 0 }); assert.equal(preview.totalRows, 2); assert.deepEqual(preview.rows.map((row) => row.text), ["", "X"]); assert.deepEqual(preview.rows.map((row) => row.isWrapped), [false, true]); }); test("a grapheme wider than the viewport wraps the following characters", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(1); // xterm renders the wide glyph on the first row and wraps `X` to the // second; retaining the whole tail as one row would shift every later // cursor-row transition one row behind the terminal. history.append("中X"); assert.deepEqual([...history.getLines()], ["中", "X"]); }); test("resizing invalidates cached preview text even when returning to its old width", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(10); history.append("abcdefgh"); assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows, [{ text: "abcdefgh", isWrapped: false }]); // A hidden preview is not read between these resizes. Returning to the // cached width must not resurrect characters trimmed from the live row. history.setViewportCols(5); history.setViewportCols(10); assert.deepEqual(history.getLines(), ["abcde"]); assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows, [{ text: "abcde", isWrapped: false }]); }); test("resizing invalidates cached preview row counts after trimming a wide line", () => { const history = createTerminalOutputHistoryPreview(); history.setViewportRows(24); history.setViewportCols(10); history.append("abcdefgh"); assert.equal(history.getPreviewRowCount(4), 2); history.setViewportCols(3); assert.equal(history.getPreviewRowCount(4), 1); assert.deepEqual(history.getPreviewRows({ cols: 4, rows: 1, top: 0 }).rows, [{ text: "abc", isWrapped: false }]); });