Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
883 lines
36 KiB
TypeScript
883 lines
36 KiB
TypeScript
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 }]);
|
||
});
|