Compare commits

...
4 changed files with 145 additions and 7 deletions

No files matched your search

@@ -192,16 +192,39 @@ test("alternate-screen entry protects preserved replay history", () => {
+ "\x1b[?25lcursor hidden\n",
);
assert.equal(log, "before\n\r\nvim screen\nafter\n\r\ntop screen\n\x1b[?25lcursor hidden\n");
// Alternate-screen bodies (vim/less/etc.) are dropped so cursor-stripped
// empty-line markers like "~" do not scramble connection-log replay.
assert.equal(log, "before\n\r\nafter\n\r\n\x1b[?25lcursor hidden\n");
assert.equal(log.includes("\x1b[?1049h"), false);
assert.equal(log.includes("\x1b[?1049l"), false);
assert.equal(log.includes("\x1b[?47h"), false);
assert.equal(log.includes("\x1b[?47l"), false);
assert.equal(log.includes("\x1b[H"), false);
assert.equal(log.includes("\x1b[10;5H"), false);
assert.equal(log.includes("vim screen"), false);
assert.equal(log.includes("top screen"), false);
assert.equal(log.includes("\x1b[?25l"), true);
});
test("alternate-screen vim empty-line tildes are not recorded", () => {
const log = createReplaySafeTerminalLog(
"shell\n\x1b[?1049h\x1b[H\x1b[2J\x1b[1;1H~\x1b[2;1H~\x1b[3;1H~\x1b[4;1Hedit\x1b[?1049l$ \n",
);
assert.equal(log, "shell\n\r\n$ \n");
assert.equal(log.includes("~"), false);
assert.equal(log.includes("edit"), false);
});
test("soft reset leaves alternate-screen discard mode", () => {
const log = createReplaySafeTerminalLog(
"before\n\x1b[?1049h\x1b[Hvim\x1bcafter soft reset\n",
);
assert.equal(log, "before\n\r\nafter soft reset\n");
assert.equal(log.includes("vim"), false);
});
test("dec cursor save mode is not treated as alternate screen", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[?1048h\x1b[?1048l\x1b[10;5Hpositioned\n");
+36 -1
View File
@@ -175,6 +175,7 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
private discardingCsi = false;
private inClearCluster = false;
private protectingClearedHistory = false;
private inAlternateScreen = false;
private hasOutput = false;
private lastOutputChar = "";
@@ -184,7 +185,7 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
this.pendingInput = "";
const appendOutput = (next: string) => {
if (!next) return;
if (!next || this.inAlternateScreen) return;
output += next;
this.hasOutput = true;
this.lastOutputChar = next[next.length - 1];
@@ -194,6 +195,7 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
!this.pendingCursorHome
&& !this.discardingCsi
&& !this.controlStringMode
&& !this.inAlternateScreen
&& !hasReplayControlCandidate(data)
) {
appendOutput(data);
@@ -261,12 +263,22 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
const alternateScreenMode = getAlternateScreenMode(sequence);
if (alternateScreenMode) {
if (alternateScreenMode === "enter") {
// Emit the history separator before entering discard mode so the
// blank gap is still recorded for connection-log replay.
emitClearSeparator(false);
this.inAlternateScreen = true;
} else {
this.inAlternateScreen = false;
}
i = sequence.end;
continue;
}
if (this.inAlternateScreen) {
i = sequence.end;
continue;
}
if (isCursorHomeSequence(sequence)) {
if (!this.inClearCluster) {
if (!this.pendingCursorHome) {
@@ -314,6 +326,11 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
continue;
}
if (this.inAlternateScreen && isC1SingleCharCursorControl(data[i])) {
i += 1;
continue;
}
if (this.protectingClearedHistory && isC1SingleCharCursorControl(data[i])) {
i += 1;
continue;
@@ -326,11 +343,23 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
}
if (data[i + 1] === "c") {
this.inAlternateScreen = false;
emitClearSeparator(false);
i += 2;
continue;
}
if (this.inAlternateScreen) {
if (isEscSingleCharCursorControl(data[i + 1]) || data[i + 1] === "7" || data[i + 1] === "8") {
i += 2;
continue;
}
// Unknown single-char ESC while discarding the alternate screen:
// consume it so it cannot leak into the connection-log replay.
i += 2;
continue;
}
if (this.protectingClearedHistory && (data[i + 1] === "7" || data[i + 1] === "8")) {
i += 2;
continue;
@@ -342,6 +371,12 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
}
}
if (this.inAlternateScreen) {
const nextControl = nextReplayControlCandidate(data, i + 1);
i = nextControl === -1 ? data.length : nextControl;
continue;
}
// Plain span: no branch above can trigger until the next ESC/C1
// control. Hop there with a native scan and append it in one slice.
flushPendingCursorHome();
+52 -5
View File
@@ -8,6 +8,9 @@
*/
const CSI_FINAL_RE = /[@-~]/;
/** 8-bit C1 CSI (0x9b); equivalent to ESC [. */
const C1_CSI = "\x9b";
const ALTERNATE_SCREEN_MODES = new Set([47, 1047, 1049]);
const DEFAULT_FOREGROUND = "#d4d4d4";
const DEFAULT_BACKGROUND = "#1e1e1e";
const BASIC_COLORS = [
@@ -44,6 +47,7 @@ class TerminalTextRenderer {
this.justStartedLogScreen = false;
this.hasPreservedScreenHistory = false;
this.pendingClearedScreen = null;
this.inAlternateScreen = false;
}
feed(input) {
@@ -63,10 +67,7 @@ class TerminalTextRenderer {
toString({ includePendingClearedScreen = false } = {}) {
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
return lines
.map((line) => line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, ""))
.join("\n")
.replace(/\n+$/g, "");
return formatPlainTextLines(lines);
}
toHtmlContent({ includePendingClearedScreen = false } = {}) {
@@ -112,23 +113,33 @@ class TerminalTextRenderer {
this.state = "esc";
this.escapeBuffer = "";
break;
case C1_CSI:
// Same as ESC [: enter CSI parameter collection.
this.state = "csi";
this.escapeBuffer = "";
break;
case "\b":
if (this.inAlternateScreen) break;
this.col = Math.max(0, this.col - 1);
break;
case "\r":
if (this.inAlternateScreen) break;
this.col = 0;
this.cursorMovedHomeByCsi = false;
break;
case "\n":
if (this.inAlternateScreen) break;
this.row += 1;
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "\t":
if (this.inAlternateScreen) break;
this.#writeText(" ".repeat(8 - (this.col % 8)));
break;
default:
if (this.inAlternateScreen) break;
if (this.#isPrintable(ch)) this.#writeText(ch);
break;
}
@@ -145,6 +156,10 @@ class TerminalTextRenderer {
this.escapeBuffer = "";
return;
}
// RIS (ESC c) restores the normal screen; stop discarding TUI output.
if (ch === "c") {
this.inAlternateScreen = false;
}
// Single-character ESC sequences are terminal controls. Ignore them for
// logs, but consume them so they never leak into txt/html output.
this.state = "normal";
@@ -165,50 +180,70 @@ class TerminalTextRenderer {
const n = values[0] || 1;
switch (final) {
case "h":
case "l": {
if (params.includes("?")) {
const modes = values.filter((value) => Number.isFinite(value));
if (modes.some((mode) => ALTERNATE_SCREEN_MODES.has(mode))) {
this.inAlternateScreen = final === "h";
}
}
break;
}
case "A":
if (this.inAlternateScreen) break;
this.row = Math.max(this.screenBaseRow, this.row - n);
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "B":
case "E":
if (this.inAlternateScreen) break;
this.row += n;
if (final === "E") this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "C":
if (this.inAlternateScreen) break;
this.col += n;
this.cursorMovedHomeByCsi = false;
break;
case "D":
if (this.inAlternateScreen) break;
this.col = Math.max(0, this.col - n);
this.cursorMovedHomeByCsi = false;
break;
case "F":
if (this.inAlternateScreen) break;
this.row = Math.max(this.screenBaseRow, this.row - n);
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "G":
if (this.inAlternateScreen) break;
this.col = Math.max(0, n - 1);
this.cursorMovedHomeByCsi = false;
break;
case "H":
case "f":
if (this.inAlternateScreen) break;
this.row = this.screenBaseRow + Math.max(0, (values[0] || 1) - 1);
this.col = Math.max(0, (values[1] || 1) - 1);
this.cursorMovedHomeByCsi = this.row === this.screenBaseRow && this.col === 0;
this.#ensureLine();
break;
case "J":
if (this.inAlternateScreen) break;
this.#eraseDisplay(values[0] || 0);
break;
case "K":
if (this.inAlternateScreen) break;
this.#eraseLine(values[0] || 0);
break;
case "m":
if (this.inAlternateScreen) break;
this.#applySgr(values);
break;
default:
@@ -421,7 +456,8 @@ class TerminalTextRenderer {
#isPrintable(ch) {
const code = ch.codePointAt(0);
if (code === undefined) return false;
return code >= 0x20 && code !== 0x7f;
// Exclude C0 DEL and C1 controls (0x80-0x9f); 0x9b is handled as CSI above.
return code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f);
}
}
@@ -479,6 +515,17 @@ function trimTrailingBlankLines(lines) {
return lines.slice(0, length);
}
function lineToPlainText(line) {
return line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, "");
}
function formatPlainTextLines(lines) {
return lines
.map((line) => lineToPlainText(line))
.join("\n")
.replace(/\n+$/g, "");
}
function renderLineHtml(line) {
let html = "";
let runText = "";
@@ -151,3 +151,36 @@ test("standalone ED3 preserves current visible screen", () => {
"before\n\nscreen\nafter",
);
});
test("alternate-screen vim empty-line tildes are not recorded", () => {
assert.equal(
terminalDataToPlainText(
"shell\n\x1b[?1049h\x1b[H\x1b[2J\x1b[1;1H~\x1b[2;1H~\x1b[3;1H~\x1b[4;1Hedit\x1b[?1049l$ \n",
),
"shell\n$",
);
});
test("8-bit C1 CSI alternate-screen entry discards TUI body", () => {
assert.equal(
terminalDataToPlainText(
"shell\n\x9b?1049h\x1b[H\x1b[2J\x1b[1;1H~\x1b[2;1H~\x1b[3;1H~\x1b[4;1Hedit\x9b?1049l$ \n",
),
"shell\n$",
);
});
test("8-bit C1 CSI alternate-screen exit clears discard mode", () => {
assert.equal(
terminalDataToPlainText("shell\n\x1b[?1049hvim body\x9b?1049lafter\n"),
"shell\nafter",
);
});
test("consecutive literal tilde lines are preserved outside alternate screen", () => {
assert.equal(terminalDataToPlainText("printf\n~\n~\nnext\n"), "printf\n~\n~\nnext");
});
test("a single tilde content line is preserved", () => {
assert.equal(terminalDataToPlainText("echo\n~\nnext\n"), "echo\n~\nnext");
});