mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 15:52:14 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5559c298d0 | ||
|
|
2424ea52a1 | ||
|
|
1b9eeab558 | ||
|
|
f09061157a | ||
|
|
72ab8717be | ||
|
|
fb7bb6692d |
No files matched your search
@@ -185,21 +185,31 @@ test("pending cursor-home lookahead controls are bounded before clear", () => {
|
||||
assert.equal(log.includes("\x1b[31m"), false);
|
||||
});
|
||||
|
||||
test("alternate-screen entry protects preserved replay history", () => {
|
||||
test("alternate-screen entry drops TUI frames from connection logs", () => {
|
||||
const log = createReplaySafeTerminalLog(
|
||||
"before\n\x1b[?1049h\x1b[Hvim screen\n\x1b[?1049lafter\n"
|
||||
+ "\x1b[?47h\x1b[10;5Htop screen\n\x1b[?47l"
|
||||
+ "\x1b[?25lcursor hidden\n",
|
||||
);
|
||||
|
||||
assert.equal(log, "before\n\r\nvim screen\nafter\n\r\ntop screen\n\x1b[?25lcursor hidden\n");
|
||||
assert.equal(log, "before\nafter\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("\x1b[?25l"), true);
|
||||
assert.equal(log.includes("vim screen"), false);
|
||||
assert.equal(log.includes("top screen"), false);
|
||||
});
|
||||
|
||||
test("alternate-screen content stays dropped across split chunks", () => {
|
||||
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||||
const log =
|
||||
sanitizer.append("shell\n\x1b[?1049h~\r\n")
|
||||
+ sanitizer.append("~\r\nbuffer\r\n\x1b[?1049l")
|
||||
+ sanitizer.append("after\n")
|
||||
+ sanitizer.finish();
|
||||
|
||||
assert.equal(log, "shell\nafter\n");
|
||||
});
|
||||
|
||||
test("dec cursor save mode is not treated as alternate screen", () => {
|
||||
|
||||
@@ -175,6 +175,7 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
private discardingCsi = false;
|
||||
private inClearCluster = false;
|
||||
private protectingClearedHistory = false;
|
||||
private alternateScreenActive = false;
|
||||
private hasOutput = false;
|
||||
private lastOutputChar = "";
|
||||
|
||||
@@ -190,10 +191,18 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
this.lastOutputChar = next[next.length - 1];
|
||||
};
|
||||
|
||||
const clearPendingCursorHome = () => {
|
||||
this.pendingCursorHome = "";
|
||||
this.pendingAfterCursorHome = "";
|
||||
this.replaySafePendingAfterCursorHome = "";
|
||||
this.pendingAfterCursorHomeOverflowed = false;
|
||||
};
|
||||
|
||||
if (
|
||||
!this.pendingCursorHome
|
||||
&& !this.discardingCsi
|
||||
&& !this.controlStringMode
|
||||
&& !this.alternateScreenActive
|
||||
&& !hasReplayControlCandidate(data)
|
||||
) {
|
||||
appendOutput(data);
|
||||
@@ -203,6 +212,19 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
return output;
|
||||
}
|
||||
|
||||
// Fullscreen TUI frames are connection-log chrome (vim "~" lines, etc.).
|
||||
// Drop them here so later txt/html export still works after enter/leave
|
||||
// sequences are stripped for replay safety.
|
||||
if (
|
||||
this.alternateScreenActive
|
||||
&& !this.pendingCursorHome
|
||||
&& !this.discardingCsi
|
||||
&& !this.controlStringMode
|
||||
&& !hasReplayControlCandidate(data)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const flushPendingCursorHome = () => {
|
||||
if (!this.pendingCursorHome) return;
|
||||
if (this.protectingClearedHistory) {
|
||||
@@ -211,18 +233,12 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
appendOutput(this.pendingCursorHome);
|
||||
appendOutput(this.pendingAfterCursorHome);
|
||||
}
|
||||
this.pendingCursorHome = "";
|
||||
this.pendingAfterCursorHome = "";
|
||||
this.replaySafePendingAfterCursorHome = "";
|
||||
this.pendingAfterCursorHomeOverflowed = false;
|
||||
clearPendingCursorHome();
|
||||
};
|
||||
|
||||
const emitClearSeparator = (preservePendingControls: boolean) => {
|
||||
const preservedControls = preservePendingControls ? this.replaySafePendingAfterCursorHome : "";
|
||||
this.pendingCursorHome = "";
|
||||
this.pendingAfterCursorHome = "";
|
||||
this.replaySafePendingAfterCursorHome = "";
|
||||
this.pendingAfterCursorHomeOverflowed = false;
|
||||
clearPendingCursorHome();
|
||||
if (!this.inClearCluster && this.hasOutput) {
|
||||
appendOutput(/[\r\n]$/.test(this.lastOutputChar) ? "\r\n" : "\r\n\r\n");
|
||||
}
|
||||
@@ -261,12 +277,20 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
const alternateScreenMode = getAlternateScreenMode(sequence);
|
||||
if (alternateScreenMode) {
|
||||
if (alternateScreenMode === "enter") {
|
||||
emitClearSeparator(false);
|
||||
this.alternateScreenActive = true;
|
||||
clearPendingCursorHome();
|
||||
} else {
|
||||
this.alternateScreenActive = false;
|
||||
}
|
||||
i = sequence.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.alternateScreenActive) {
|
||||
i = sequence.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCursorHomeSequence(sequence)) {
|
||||
if (!this.inClearCluster) {
|
||||
if (!this.pendingCursorHome) {
|
||||
@@ -314,6 +338,11 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.alternateScreenActive && isC1SingleCharCursorControl(data[i])) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.protectingClearedHistory && isC1SingleCharCursorControl(data[i])) {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -326,24 +355,32 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
}
|
||||
|
||||
if (data[i + 1] === "c") {
|
||||
this.alternateScreenActive = false;
|
||||
emitClearSeparator(false);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.protectingClearedHistory && (data[i + 1] === "7" || data[i + 1] === "8")) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.protectingClearedHistory && isEscSingleCharCursorControl(data[i + 1])) {
|
||||
i += 2;
|
||||
continue;
|
||||
if (!this.alternateScreenActive) {
|
||||
if (this.protectingClearedHistory && (data[i + 1] === "7" || data[i + 1] === "8")) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (this.protectingClearedHistory && isEscSingleCharCursorControl(data[i + 1])) {
|
||||
i += 2;
|
||||
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.
|
||||
if (this.alternateScreenActive) {
|
||||
const nextControl = nextReplayControlCandidate(data, i + 1);
|
||||
i = nextControl === -1 ? data.length : nextControl;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingCursorHome();
|
||||
const nextControl = nextReplayControlCandidate(data, i + 1);
|
||||
const end = nextControl === -1 ? data.length : nextControl;
|
||||
@@ -361,6 +398,14 @@ class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitiz
|
||||
this.controlStringEscPending = false;
|
||||
this.discardingCsi = false;
|
||||
|
||||
if (this.alternateScreenActive) {
|
||||
this.pendingCursorHome = "";
|
||||
this.pendingAfterCursorHome = "";
|
||||
this.replaySafePendingAfterCursorHome = "";
|
||||
this.pendingAfterCursorHomeOverflowed = false;
|
||||
return "";
|
||||
}
|
||||
|
||||
let output = "";
|
||||
if (this.pendingCursorHome) {
|
||||
output = this.pendingCursorHome + this.pendingAfterCursorHome;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Session-log alternate-screen filter.
|
||||
*
|
||||
* Fullscreen TUIs (vim/vi, less, htop, ...) switch to the terminal alternate
|
||||
* buffer via DEC private modes 47 / 1047 / 1049. That buffer is UI chrome for
|
||||
* the interactive session -- including vim's "~" empty-line markers -- and
|
||||
* should not land in rendered (txt/html) session logs. Raw logs keep the
|
||||
* original byte stream and must not use this filter. Mirror the trigger-filter
|
||||
* approach: drop enter/leave sequences and everything between them, keeping
|
||||
* main-screen shell output intact.
|
||||
*/
|
||||
|
||||
const ESC = "\x1b";
|
||||
/** 8-bit C1 CSI (0x9b); xterm accepts this as equivalent to ESC [. */
|
||||
const C1_CSI = "\x9b";
|
||||
/** 8-bit C1 ST (0x9c); valid OSC/DCS string terminator alongside BEL and ESC \\. */
|
||||
const C1_ST = "\x9c";
|
||||
const ALT_SCREEN_MODES = new Set([47, 1047, 1049]);
|
||||
/**
|
||||
* Incomplete CSI/OSC tails are normally tiny. Cap retention so an unterminated
|
||||
* control cannot grow pendingEscape without bound or rescan ever-larger prefixes.
|
||||
* Matches components/terminal/runtime/terminalControlSequenceLimits.ts.
|
||||
*/
|
||||
const MAX_PENDING_ESCAPE_CHARS = 64 * 1024;
|
||||
|
||||
function readCsiSequence(input, startIndex, scanFrom = 0) {
|
||||
let bodyStart = -1;
|
||||
if (input[startIndex] === ESC && input[startIndex + 1] === "[") {
|
||||
bodyStart = startIndex + 2;
|
||||
} else if (input[startIndex] === C1_CSI) {
|
||||
bodyStart = startIndex + 1;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = Math.max(bodyStart, scanFrom); index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return {
|
||||
sequence: input.slice(startIndex, index + 1),
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOscSequence(input, startIndex, scanFrom = 0) {
|
||||
if (input[startIndex] !== ESC || input[startIndex + 1] !== "]") return null;
|
||||
for (let index = Math.max(startIndex + 2, scanFrom); index < input.length; index += 1) {
|
||||
if (input[index] === "\x07" || input[index] === C1_ST) {
|
||||
return {
|
||||
sequence: input.slice(startIndex, index + 1),
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
if (input[index] === ESC && input[index + 1] === "\\") {
|
||||
return {
|
||||
sequence: input.slice(startIndex, index + 2),
|
||||
end: index + 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readEscapeSequence(input, startIndex, scanFrom = 0) {
|
||||
if (input[startIndex] === C1_CSI) {
|
||||
return readCsiSequence(input, startIndex, scanFrom);
|
||||
}
|
||||
if (input[startIndex] !== ESC) return null;
|
||||
if (startIndex + 1 >= input.length) return null;
|
||||
|
||||
const next = input[startIndex + 1];
|
||||
if (next === "[") return readCsiSequence(input, startIndex, scanFrom);
|
||||
if (next === "]") return readOscSequence(input, startIndex, scanFrom);
|
||||
|
||||
// ECMA-48 escape sequences: ESC Intermediate* Final
|
||||
// Intermediate 0x20-0x2F (e.g. "(" in ESC ( B); Final 0x30-0x7E
|
||||
// (covers DECSC ESC 7, charset ESC ( B, RIS ESC c, ...).
|
||||
for (let index = Math.max(startIndex + 1, scanFrom); index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code >= 0x20 && code <= 0x2f) {
|
||||
continue;
|
||||
}
|
||||
if (code >= 0x30 && code <= 0x7e) {
|
||||
return {
|
||||
sequence: input.slice(startIndex, index + 1),
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
// Malformed escape: consume ESC alone so pendingEscape never stalls.
|
||||
return {
|
||||
sequence: input.slice(startIndex, startIndex + 1),
|
||||
end: startIndex + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume offset for the next chunk. OSC may end with a lone ESC awaiting `\`,
|
||||
* so re-check that final ESC when more bytes arrive.
|
||||
*/
|
||||
function nextPendingScanFrom(remainder) {
|
||||
if (
|
||||
remainder.length >= 2 &&
|
||||
remainder[0] === ESC &&
|
||||
remainder[1] === "]" &&
|
||||
remainder.endsWith(ESC)
|
||||
) {
|
||||
return remainder.length - 1;
|
||||
}
|
||||
return remainder.length;
|
||||
}
|
||||
|
||||
function getAlternateScreenAction(sequence) {
|
||||
// RIS (ESC c) restores the normal screen without a DECSET leave.
|
||||
if (sequence === `${ESC}c`) return "leave";
|
||||
|
||||
let params = null;
|
||||
if (sequence.startsWith(`${ESC}[`) && sequence.length >= 3) {
|
||||
params = sequence.slice(2, -1);
|
||||
} else if (sequence.startsWith(C1_CSI) && sequence.length >= 2) {
|
||||
params = sequence.slice(1, -1);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const final = sequence.at(-1);
|
||||
if (final !== "h" && final !== "l") return null;
|
||||
if (!params.startsWith("?")) return null;
|
||||
|
||||
const modes = params
|
||||
.slice(1)
|
||||
.split(";")
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.filter(Number.isFinite);
|
||||
|
||||
if (!modes.some((mode) => ALT_SCREEN_MODES.has(mode))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return final === "h" ? "enter" : "leave";
|
||||
}
|
||||
|
||||
function createSessionLogAlternateScreenFilter() {
|
||||
let alternateScreenActive = false;
|
||||
let pendingEscape = "";
|
||||
let pendingScanFrom = 0;
|
||||
|
||||
return {
|
||||
append(chunk) {
|
||||
if (!chunk) return "";
|
||||
|
||||
let input;
|
||||
let resumeScanFrom = 0;
|
||||
if (pendingEscape) {
|
||||
input = `${pendingEscape}${chunk}`;
|
||||
resumeScanFrom = pendingScanFrom;
|
||||
pendingEscape = "";
|
||||
pendingScanFrom = 0;
|
||||
} else {
|
||||
input = chunk;
|
||||
}
|
||||
let output = "";
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const ch = input[index];
|
||||
if (ch !== ESC && ch !== C1_CSI) {
|
||||
if (!alternateScreenActive) {
|
||||
output += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const sequence = readEscapeSequence(input, index, resumeScanFrom);
|
||||
resumeScanFrom = 0;
|
||||
if (!sequence) {
|
||||
const remainder = input.slice(index);
|
||||
if (remainder.length <= MAX_PENDING_ESCAPE_CHARS) {
|
||||
pendingEscape = remainder;
|
||||
pendingScanFrom = nextPendingScanFrom(remainder);
|
||||
break;
|
||||
}
|
||||
// Incomplete control exceeded the retention cap: drop the introducer
|
||||
// and resume so later bytes are not withheld forever.
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = getAlternateScreenAction(sequence.sequence);
|
||||
if (action === "enter") {
|
||||
alternateScreenActive = true;
|
||||
} else if (action === "leave") {
|
||||
alternateScreenActive = false;
|
||||
} else if (!alternateScreenActive) {
|
||||
output += sequence.sequence;
|
||||
}
|
||||
|
||||
index = sequence.end - 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
},
|
||||
finish() {
|
||||
// Incomplete escape tails are controls, never printable shell text.
|
||||
pendingEscape = "";
|
||||
pendingScanFrom = 0;
|
||||
return "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSessionLogAlternateScreenFilter,
|
||||
getAlternateScreenAction,
|
||||
MAX_PENDING_ESCAPE_CHARS,
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
createSessionLogAlternateScreenFilter,
|
||||
MAX_PENDING_ESCAPE_CHARS,
|
||||
} = require("./sessionLogAlternateScreenFilter.cjs");
|
||||
|
||||
test("drops vim alternate-screen content including empty-line markers", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const output =
|
||||
filter.append("before vim\r\n") +
|
||||
filter.append("\x1b[?1049h~\r\n~\r\nfile content\r\n\x1b[?1049l") +
|
||||
filter.append("after vim\r\n") +
|
||||
filter.finish();
|
||||
|
||||
assert.equal(output, "before vim\r\nafter vim\r\n");
|
||||
});
|
||||
|
||||
test("supports DECSET 47 / 1047 and split enter/leave sequences", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
let output = filter.append("shell\n\x1b[?47");
|
||||
assert.equal(output, "shell\n");
|
||||
|
||||
output += filter.append("h~empty\n");
|
||||
assert.equal(output, "shell\n");
|
||||
|
||||
output += filter.append("\x1b[?1;10");
|
||||
assert.equal(output, "shell\n");
|
||||
|
||||
output += filter.append("47lback\n");
|
||||
output += filter.finish();
|
||||
assert.equal(output, "shell\nback\n");
|
||||
});
|
||||
|
||||
test("keeps normal CSI sequences outside alternate screen", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const output =
|
||||
filter.append("red \x1b[31mtext\x1b[0m\n") + filter.finish();
|
||||
assert.equal(output, "red \x1b[31mtext\x1b[0m\n");
|
||||
});
|
||||
|
||||
test("consumes non-CSI ESC forms such as DECSC and charset designation", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const output =
|
||||
filter.append("save\x1b7 then \x1b(Bcharset\n") + filter.finish();
|
||||
assert.equal(output, "save\x1b7 then \x1b(Bcharset\n");
|
||||
});
|
||||
|
||||
test("buffers incomplete ESC intermediate sequences across chunks", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
let output = filter.append("pre\x1b(");
|
||||
assert.equal(output, "pre");
|
||||
output += filter.append("Bpost\n");
|
||||
output += filter.finish();
|
||||
assert.equal(output, "pre\x1b(Bpost\n");
|
||||
});
|
||||
|
||||
test("recovers when an unterminated OSC exceeds the pending-control cap", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const oversized = `\x1b]${"x".repeat(MAX_PENDING_ESCAPE_CHARS)}recovered\n`;
|
||||
const output = filter.append(`pre${oversized}`) + filter.finish();
|
||||
assert.equal(output, `pre]${"x".repeat(MAX_PENDING_ESCAPE_CHARS)}recovered\n`);
|
||||
});
|
||||
|
||||
test("unterminated OSC grown one byte at a time recovers at the pending-control cap", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
let output = filter.append("pre\x1b]");
|
||||
// Grow to the retention cap without rescanning the whole prefix each chunk.
|
||||
for (let i = 0; i < MAX_PENDING_ESCAPE_CHARS - 2; i += 1) {
|
||||
output += filter.append("x");
|
||||
}
|
||||
assert.equal(output, "pre");
|
||||
// One more byte pushes past the cap: drop the ESC introducer and emit the rest.
|
||||
output += filter.append("x");
|
||||
output += filter.append("recovered\n");
|
||||
output += filter.finish();
|
||||
assert.equal(
|
||||
output,
|
||||
`pre]${"x".repeat(MAX_PENDING_ESCAPE_CHARS - 1)}recovered\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test("OSC ST split across chunks after a trailing ESC completes without rescan stalls", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
let output = filter.append("pre\x1b]0;title\x1b");
|
||||
assert.equal(output, "pre");
|
||||
output += filter.append("\\after\n");
|
||||
output += filter.finish();
|
||||
assert.equal(output, "pre\x1b]0;title\x1b\\after\n");
|
||||
});
|
||||
|
||||
test("RIS clears alternate screen so logging resumes", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const output =
|
||||
filter.append("before\n\x1b[?1049h~tui\n\x1bc") +
|
||||
filter.append("after reset\n") +
|
||||
filter.finish();
|
||||
assert.equal(output, "before\nafter reset\n");
|
||||
});
|
||||
|
||||
test("recognizes 8-bit C1 CSI alternate-screen enter/leave", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const output =
|
||||
filter.append("before\n\x9b?1049h~tui\n\x9b?1049l") +
|
||||
filter.append("after\n") +
|
||||
filter.finish();
|
||||
assert.equal(output, "before\nafter\n");
|
||||
});
|
||||
|
||||
test("recognizes C1 ST as an OSC string terminator", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const output =
|
||||
filter.append("pre\x1b]0;title\x9cafter\n") + filter.finish();
|
||||
assert.equal(output, "pre\x1b]0;title\x9cafter\n");
|
||||
});
|
||||
|
||||
test("C1-ST-terminated OSC does not truncate later shell output", () => {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
const output =
|
||||
filter.append("shell\n\x1b]0;vim\x9c\x1b[?1049h~\n\x1b[?1049lback\n") +
|
||||
filter.finish();
|
||||
assert.equal(output, "shell\n\x1b]0;vim\x9cback\n");
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
} = require("./sessionLogsBridge.cjs");
|
||||
const { createTerminalTextRenderer } = require("./terminalLogSanitizer.cjs");
|
||||
const { createProgrammaticCommandLogRewriter } = require("./programmaticCommandLog.cjs");
|
||||
const { createSessionLogAlternateScreenFilter } = require("./sessionLogAlternateScreenFilter.cjs");
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
@@ -221,6 +222,8 @@ function createStreamEntry(sessionId, opts) {
|
||||
hostLabel,
|
||||
startTime,
|
||||
buffer: "",
|
||||
// Raw logs keep the original byte stream; filter only rendered txt/html.
|
||||
alternateScreenFilter: isRaw ? null : createSessionLogAlternateScreenFilter(),
|
||||
programmaticCommandLogRewriter: createProgrammaticCommandLogRewriter(),
|
||||
sudoAutofillRewrites: [],
|
||||
sudoAutofillPending: "",
|
||||
@@ -392,9 +395,12 @@ function appendData(sessionId, dataChunk) {
|
||||
}
|
||||
|
||||
function appendBufferedData(entry, dataChunk) {
|
||||
const readableData = entry.programmaticCommandLogRewriter
|
||||
? entry.programmaticCommandLogRewriter.append(dataChunk)
|
||||
const withoutAlternateScreen = entry.alternateScreenFilter
|
||||
? entry.alternateScreenFilter.append(dataChunk)
|
||||
: dataChunk;
|
||||
const readableData = entry.programmaticCommandLogRewriter
|
||||
? entry.programmaticCommandLogRewriter.append(withoutAlternateScreen)
|
||||
: withoutAlternateScreen;
|
||||
entry.buffer += sanitizeSudoAutofillLogData(entry, readableData);
|
||||
|
||||
// Immediate flush if buffer is large
|
||||
@@ -445,6 +451,7 @@ async function stopStream(sessionId, expectedToken) {
|
||||
entry.pendingInitialLineLeadingCarriageReturn = false;
|
||||
appendBufferedData(entry, "\n\r");
|
||||
}
|
||||
entry.alternateScreenFilter?.finish();
|
||||
const readablePending = entry.programmaticCommandLogRewriter?.finish();
|
||||
if (readablePending) {
|
||||
entry.buffer += sanitizeSudoAutofillLogData(entry, readablePending);
|
||||
|
||||
@@ -853,6 +853,41 @@ test("raw stream keeps original bytes when timestamps are enabled", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("txt streams omit vim alternate-screen content; raw keeps the byte stream (issue #2871)", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `stream-alt-screen-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const rawSessionId = `raw-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const txtSessionId = `txt-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const vimChunk = "before\r\n\x1b[?1049h~\r\n~\r\nbuffer line\r\n\x1b[?1049lafter\r\n";
|
||||
|
||||
try {
|
||||
startStream(rawSessionId, {
|
||||
hostLabel: "raw-host",
|
||||
hostname: "raw.example",
|
||||
directory,
|
||||
format: "raw",
|
||||
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
|
||||
});
|
||||
appendData(rawSessionId, vimChunk);
|
||||
const rawPath = await stopStream(rawSessionId);
|
||||
assert.equal(fs.readFileSync(rawPath, "utf8"), vimChunk);
|
||||
|
||||
startStream(txtSessionId, {
|
||||
hostLabel: "txt-host",
|
||||
hostname: "txt.example",
|
||||
directory,
|
||||
format: "txt",
|
||||
startTime: Date.UTC(2026, 0, 2, 3, 4, 6),
|
||||
});
|
||||
appendData(txtSessionId, vimChunk);
|
||||
const txtPath = await stopStream(txtSessionId);
|
||||
assert.equal(fs.readFileSync(txtPath, "utf8"), "before\nafter");
|
||||
} finally {
|
||||
await stopStream(rawSessionId);
|
||||
await stopStream(txtSessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForFileContent(directory, expectedContent) {
|
||||
const deadline = Date.now() + 3000;
|
||||
let lastContent = "";
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* line-editing output actually leaves on screen.
|
||||
*/
|
||||
|
||||
const { createSessionLogAlternateScreenFilter } = require("./sessionLogAlternateScreenFilter.cjs");
|
||||
|
||||
const CSI_FINAL_RE = /[@-~]/;
|
||||
const DEFAULT_FOREGROUND = "#d4d4d4";
|
||||
const DEFAULT_BACKGROUND = "#1e1e1e";
|
||||
@@ -425,15 +427,20 @@ class TerminalTextRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
function filterAlternateScreenForLog(terminalData) {
|
||||
const filter = createSessionLogAlternateScreenFilter();
|
||||
return filter.append(terminalData || "") + filter.finish();
|
||||
}
|
||||
|
||||
function terminalDataToPlainText(terminalData) {
|
||||
const renderer = new TerminalTextRenderer();
|
||||
renderer.feed(terminalData || "");
|
||||
renderer.feed(filterAlternateScreenForLog(terminalData));
|
||||
return renderer.finish();
|
||||
}
|
||||
|
||||
function terminalDataToHtmlContent(terminalData) {
|
||||
const renderer = new TerminalTextRenderer();
|
||||
renderer.feed(terminalData || "");
|
||||
renderer.feed(filterAlternateScreenForLog(terminalData));
|
||||
renderer.finish();
|
||||
return renderer.toHtmlContent();
|
||||
}
|
||||
|
||||
@@ -151,3 +151,10 @@ test("standalone ED3 preserves current visible screen", () => {
|
||||
"before\n\nscreen\nafter",
|
||||
);
|
||||
});
|
||||
|
||||
test("plain text rendering drops vim alternate-screen empty-line markers", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("shell\n\x1b[?1049h~\r\n~\r\nbuffer\r\n\x1b[?1049lafter\n"),
|
||||
"shell\nafter",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user