mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb98341731 | ||
|
|
2d87407bfc | ||
|
|
0d447b7bec |
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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user