mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e127cf70bc | ||
|
|
7c2cd9f4b6 | ||
|
|
c1b64032a6 | ||
|
|
909c5dddfc | ||
|
|
0fa1e4aa5c | ||
|
|
550a81a9cf | ||
|
|
7176acbe2d | ||
|
|
d0ffd2c13d | ||
|
|
a889114f61 | ||
|
|
9d7ea99e3f | ||
|
|
92aaabc693 | ||
|
|
27024cd935 | ||
|
|
d48a089825 | ||
|
|
d7ccefbf4f | ||
|
|
759d662812 | ||
|
|
5e468f10c8 | ||
|
|
6a3ce78e53 | ||
|
|
27377b20d5 | ||
|
|
66491e738d | ||
|
|
19cd5d1cd3 | ||
|
|
8c8b87646b | ||
|
|
409e6f0821 | ||
|
|
f721002829 | ||
|
|
7a3f0b496b | ||
|
|
b95fe52392 | ||
|
|
49d98da9b7 | ||
|
|
fbe3726461 | ||
|
|
402c07c29f | ||
|
|
02da767463 | ||
|
|
dc90b0afd5 | ||
|
|
f1e5a0fe49 | ||
|
|
f18ac2de36 |
No files matched your search
@@ -638,6 +638,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
// Keep the history tracker's viewport size current so cursor-row and
|
||||
// cursor-column moves are clamped exactly like the terminal clamps them.
|
||||
const captureTerm = termRef.current;
|
||||
if (captureTerm) {
|
||||
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
|
||||
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
|
||||
// The tracker's wrap decisions must measure cell widths the way this
|
||||
// terminal's Unicode provider does, or the preview diverges from what
|
||||
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
|
||||
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
|
||||
}
|
||||
const readableCommandData = commandLogRewriterRef.current.append(data);
|
||||
// The alternate-screen preview reads the raw display stream (before the
|
||||
// replay sanitizer, which drops alternate-screen output on purpose) but
|
||||
|
||||
@@ -2781,6 +2781,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
},
|
||||
);
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// An idle alternate-screen application may emit nothing after resizing.
|
||||
// Update history now, using xterm's cursor after any normal-buffer reflow.
|
||||
const buffer = term.buffer.active;
|
||||
const cursorLine = buffer.getLine(buffer.baseY + buffer.cursorY);
|
||||
ctx.terminalOutputHistory?.syncViewportAfterResize({
|
||||
cols, rows,
|
||||
cursorX: buffer.cursorX,
|
||||
cursorY: buffer.cursorY,
|
||||
cursorLine: cursorLine?.translateToString(true, 0, cols) ?? "",
|
||||
isWrapped: cursorLine?.isWrapped ?? false,
|
||||
});
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
|
||||
@@ -159,6 +160,58 @@ test("preview rows wrap long lines and flag the continuation rows", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -184,6 +237,32 @@ test("preview rows keep wide characters intact at a column boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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`);
|
||||
@@ -233,4 +312,571 @@ test("default retention bounds the preview history", () => {
|
||||
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 }]);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { createTerminalOutputHistoryPreview } from "./terminalOutputHistory.ts";
|
||||
|
||||
const { Terminal: XTerm } = createRequire(import.meta.url)("@xterm/xterm") as { Terminal: typeof Terminal };
|
||||
// Exercise the production resize listener without mounting the WebGL/UI runtime.
|
||||
// The terminal buffer and history collector are real; only unrelated effects
|
||||
// (atlas clearing and backend resize scheduling) are inert in this harness.
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const start = runtime.indexOf(" term.onResize(({ cols, rows }) => {");
|
||||
assert.notEqual(start, -1);
|
||||
const listenerSource = runtime.slice(start, runtime.indexOf("\n });", start) + "\n });".length);
|
||||
function createHarness(cols: number) {
|
||||
const term = new XTerm({ cols, rows: 24, allowProposedApi: true, convertEol: true });
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
new Function("term", "ctx", "clearWebglTextureAtlas", "resizeScheduler", listenerSource)(
|
||||
term, { terminalOutputHistory: history, sessionRef: { current: null } }, () => {}, { schedule() {} },
|
||||
);
|
||||
const write = async (data: string) => {
|
||||
history.setViewportRows(term.rows);
|
||||
history.setViewportCols(term.cols);
|
||||
history.append(data);
|
||||
await new Promise<void>((resolve) => term.write(data, resolve));
|
||||
};
|
||||
return { term, history, write };
|
||||
}
|
||||
|
||||
for (const alternate of [false, true]) test(`idle ${alternate ? "alternate" : "normal"}-screen resize updates history before any new output`, async () => {
|
||||
const { term, history, write } = createHarness(10);
|
||||
try {
|
||||
await write(`${alternate ? "\x1b[?1049h" : ""}abcdefgh`);
|
||||
history.getPreviewRows({ cols: 10, rows: 1, top: 0 });
|
||||
term.resize(5, 24);
|
||||
assert.equal(history.getPreviewRowCount(5), 1);
|
||||
assert.equal(history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows[0].text,
|
||||
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
|
||||
term.resize(10, 24);
|
||||
assert.equal(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows[0].text,
|
||||
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
|
||||
test("resize uses xterm's reflowed cursor for later same-row redraws", async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write("abcdef\n");
|
||||
term.resize(10, 24);
|
||||
assert.equal(term.buffer.active.cursorY, 1);
|
||||
await write("X\x1b[2;2HY");
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).rows.map(row => row.text),
|
||||
[term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
|
||||
for (const chunks of [["abc中X"], ["abc中XYZ"], ["abc中文X"], ["abc中文", "X"], ["abc中\x1b[5GZ"]]) {
|
||||
test(`no-autowrap final wide cell matches xterm: ${JSON.stringify(chunks)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write("\x1b[?7l");
|
||||
for (const chunk of chunks) await write(chunk);
|
||||
assert.equal(history.getLines().at(-1), term.buffer.active.getLine(0)?.translateToString(true));
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const [save, restore] of [["\x1b7", "\x1b8"], ["\x1b[s", "\x1b[u"]]) {
|
||||
test(`cursor restore also restores wrapping mode: ${JSON.stringify(save)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write(`\x1b[?7l${save}\x1b[?7h${restore}abcdef`);
|
||||
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
test(`cursor restore also restores origin mode: ${JSON.stringify(save)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write(`\x1b[2;5r\x1b[?6h${save}\x1b[?6lA${restore}X\x1b[1;2HY`);
|
||||
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user