Files
NetMesh/components/terminal/replaySafeTerminalLog.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

504 lines
16 KiB
TypeScript

type CsiSequence = {
raw: string;
end: number;
final: string;
params: string;
};
const ESC = "\x1b";
const BEL = "\x07";
const ST = "\x9c";
const CSI = "\x9b";
const MAX_PENDING_ESCAPE_CHARS = 4096;
type ControlStringMode = "osc" | "string";
export type ReplaySafeTerminalLogSanitizerOptions = {
/** Seed when attaching mid-TUI so live redraws stay omitted until leave. */
alternateScreenActive?: boolean;
};
export interface ReplaySafeTerminalLogSanitizer {
append(input: string): string;
finish(): string;
}
const isCsiFinal = (ch: string): boolean => ch >= "@" && ch <= "~";
const readCsiSequence = (input: string, index: number): CsiSequence | null => {
const isEscCsi = input[index] === ESC && input[index + 1] === "[";
const isC1Csi = input[index] === CSI;
if (!isEscCsi && !isC1Csi) return null;
const paramsStart = isEscCsi ? index + 2 : index + 1;
for (let i = paramsStart; i < input.length; i += 1) {
if (isCsiFinal(input[i])) {
return {
raw: input.slice(index, i + 1),
end: i + 1,
final: input[i],
params: input.slice(paramsStart, i),
};
}
}
return null;
};
const startsCsiSequence = (input: string, index: number): boolean =>
(input[index] === ESC && input[index + 1] === "[") || input[index] === CSI;
const isEscControlStringIntroducer = (ch: string): boolean =>
ch === "]" || ch === "P" || ch === "_" || ch === "^" || ch === "X";
const isC1ControlStringIntroducer = (ch: string): boolean =>
ch === "\x90" || ch === "\x98" || ch === "\x9d" || ch === "\x9e" || ch === "\x9f";
const getControlStringStart = (
input: string,
index: number,
): { mode: ControlStringMode; dataStart: number } | null => {
const ch = input[index];
if (ch === ESC) {
const introducer = input[index + 1];
if (!isEscControlStringIntroducer(introducer)) return null;
return {
mode: introducer === "]" ? "osc" : "string",
dataStart: index + 2,
};
}
if (isC1ControlStringIntroducer(ch)) {
return {
mode: ch === "\x9d" ? "osc" : "string",
dataStart: index + 1,
};
}
return null;
};
const parseCsiParams = (params: string): Array<number | undefined> => {
const parameterBytes = params.replace(/[?><=]/g, "").replace(/[ -/]+$/g, "");
if (!parameterBytes) return [];
return parameterBytes.split(";").map((part) => {
if (!part) return undefined;
const n = Number.parseInt(part, 10);
return Number.isFinite(n) ? n : undefined;
});
};
const normalizedPosition = (value: number | undefined): number => Math.max(1, value ?? 1);
const isCursorHomeSequence = (sequence: CsiSequence): boolean => {
if (sequence.final !== "H" && sequence.final !== "f") return false;
const values = parseCsiParams(sequence.params);
return normalizedPosition(values[0]) === 1 && normalizedPosition(values[1]) === 1;
};
const isCursorMovementSequence = (sequence: CsiSequence): boolean =>
sequence.final === "A"
|| sequence.final === "B"
|| sequence.final === "C"
|| sequence.final === "D"
|| sequence.final === "E"
|| sequence.final === "F"
|| sequence.final === "H"
|| sequence.final === "f"
|| sequence.final === "G"
|| sequence.final === "`"
|| sequence.final === "d"
|| sequence.final === "a"
|| sequence.final === "e";
const isCursorStateSequence = (sequence: CsiSequence): boolean =>
sequence.final === "s"
|| sequence.final === "u"
|| (
(sequence.final === "h" || sequence.final === "l")
&& sequence.params.includes("?")
&& parseCsiParams(sequence.params).includes(1048)
);
const isUnsafeCursorReplaySequence = (sequence: CsiSequence): boolean =>
isCursorMovementSequence(sequence) || isCursorStateSequence(sequence);
const getEraseDisplayMode = (sequence: CsiSequence): number | null => {
if (sequence.final !== "J") return null;
const values = parseCsiParams(sequence.params);
return values[0] ?? 0;
};
const isEraseSequence = (sequence: CsiSequence): boolean =>
sequence.final === "J" || sequence.final === "K" || sequence.final === "X";
const isSafePendingAfterCursorHomeSequence = (sequence: CsiSequence): boolean =>
!isUnsafeCursorReplaySequence(sequence) && !isEraseSequence(sequence);
const getAlternateScreenMode = (sequence: CsiSequence): "enter" | "exit" | null => {
if (sequence.final !== "h" && sequence.final !== "l") return null;
if (!sequence.params.includes("?")) return null;
const isAlternateScreen = parseCsiParams(sequence.params).some((value) =>
value === 47 || value === 1047 || value === 1049,
);
if (!isAlternateScreen) return null;
return sequence.final === "h" ? "enter" : "exit";
};
const isC1SingleCharCursorControl = (ch: string): boolean =>
ch === "\x84" || ch === "\x85" || ch === "\x8d";
const isEscSingleCharCursorControl = (ch: string): boolean =>
ch === "D" || ch === "E" || ch === "M";
// ESC or any C1 control (0x80-0x9f): every branch of the sanitizer's parser
// that can rewrite output starts at one of these characters.
// eslint-disable-next-line no-control-regex
const REPLAY_CONTROL_CANDIDATE = /[\u001b\u0080-\u009f]/;
// eslint-disable-next-line no-control-regex
const REPLAY_CONTROL_CANDIDATE_SCAN = /[\u001b\u0080-\u009f]/g;
const hasReplayControlCandidate = (input: string): boolean =>
REPLAY_CONTROL_CANDIDATE.test(input);
/** Index of the next ESC/C1 control at or after `from`, or -1. */
const nextReplayControlCandidate = (input: string, from: number): number => {
REPLAY_CONTROL_CANDIDATE_SCAN.lastIndex = from;
const match = REPLAY_CONTROL_CANDIDATE_SCAN.exec(input);
return match === null ? -1 : match.index;
};
class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitizer {
private pendingInput = "";
private pendingCursorHome = "";
private pendingAfterCursorHome = "";
private replaySafePendingAfterCursorHome = "";
private pendingAfterCursorHomeOverflowed = false;
private controlStringMode: ControlStringMode | null = null;
private controlStringEscPending = false;
private discardingCsi = false;
private inClearCluster = false;
private protectingClearedHistory = false;
private alternateScreenActive: boolean;
private hasOutput = false;
private lastOutputChar = "";
constructor(options: ReplaySafeTerminalLogSanitizerOptions = {}) {
this.alternateScreenActive = options.alternateScreenActive === true;
}
append(input: string): string {
let output = "";
const data = this.pendingInput + input;
this.pendingInput = "";
const appendOutput = (next: string) => {
if (!next) return;
output += next;
this.hasOutput = true;
this.lastOutputChar = next[next.length - 1];
};
if (
!this.alternateScreenActive
&& !this.pendingCursorHome
&& !this.discardingCsi
&& !this.controlStringMode
&& !hasReplayControlCandidate(data)
) {
appendOutput(data);
if (data) {
this.inClearCluster = false;
}
return output;
}
const flushPendingCursorHome = () => {
if (!this.pendingCursorHome) return;
if (this.protectingClearedHistory) {
appendOutput(this.replaySafePendingAfterCursorHome);
} else {
appendOutput(this.pendingCursorHome);
appendOutput(this.pendingAfterCursorHome);
}
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
};
const emitClearSeparator = (preservePendingControls: boolean) => {
const preservedControls = preservePendingControls ? this.replaySafePendingAfterCursorHome : "";
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
if (!this.inClearCluster && this.hasOutput) {
appendOutput(/[\r\n]$/.test(this.lastOutputChar) ? "\r\n" : "\r\n\r\n");
}
appendOutput(preservedControls);
this.inClearCluster = true;
this.protectingClearedHistory = true;
};
for (let i = 0; i < data.length;) {
if (this.discardingCsi) {
i = this.consumeDiscardedCsi(data, i);
continue;
}
if (this.controlStringMode) {
i = this.consumeControlString(data, i);
continue;
}
const controlStringStart = getControlStringStart(data, i);
if (controlStringStart) {
this.controlStringMode = controlStringStart.mode;
this.controlStringEscPending = false;
i = controlStringStart.dataStart;
continue;
}
const sequence = readCsiSequence(data, i);
if (!sequence && startsCsiSequence(data, i)) {
this.setPendingEscapeInput(data.slice(i));
break;
}
if (sequence) {
const alternateScreenMode = getAlternateScreenMode(sequence);
if (alternateScreenMode) {
this.alternateScreenActive = alternateScreenMode === "enter";
if (alternateScreenMode === "enter") {
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
}
i = sequence.end;
continue;
}
if (this.alternateScreenActive) {
i = sequence.end;
continue;
}
if (isCursorHomeSequence(sequence)) {
if (!this.inClearCluster) {
if (!this.pendingCursorHome) {
flushPendingCursorHome();
}
this.pendingCursorHome = sequence.raw;
}
i = sequence.end;
continue;
}
if (this.protectingClearedHistory && isUnsafeCursorReplaySequence(sequence)) {
i = sequence.end;
continue;
}
const eraseMode = getEraseDisplayMode(sequence);
if (eraseMode !== null) {
if (eraseMode === 3) {
emitClearSeparator(false);
} else if (eraseMode === 1) {
emitClearSeparator(false);
} else if (eraseMode === 2 || (eraseMode === 0 && this.pendingCursorHome)) {
emitClearSeparator(true);
} else {
flushPendingCursorHome();
appendOutput(sequence.raw);
this.inClearCluster = false;
}
i = sequence.end;
continue;
}
if (this.pendingCursorHome) {
this.appendPendingAfterCursorHome(sequence);
i = sequence.end;
continue;
}
const preserveClearCluster = this.inClearCluster;
flushPendingCursorHome();
appendOutput(sequence.raw);
this.inClearCluster = preserveClearCluster;
i = sequence.end;
continue;
}
if (this.protectingClearedHistory && isC1SingleCharCursorControl(data[i])) {
i += 1;
continue;
}
if (this.alternateScreenActive) {
if (data[i] === ESC && i + 1 >= data.length) {
this.setPendingEscapeInput(data.slice(i));
break;
}
// RIS (ESC c) fully resets the terminal, including leaving the
// alternate screen. Process it before the omit-scan skip so later
// shell output is not dropped from the connection log.
if (data[i] === ESC && data[i + 1] === "c") {
this.alternateScreenActive = false;
emitClearSeparator(false);
i += 2;
continue;
}
const nextControl = nextReplayControlCandidate(data, i + 1);
i = nextControl === -1 ? data.length : nextControl;
continue;
}
if (data[i] === ESC) {
if (i + 1 >= data.length) {
this.setPendingEscapeInput(data.slice(i));
break;
}
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;
}
}
// 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();
const nextControl = nextReplayControlCandidate(data, i + 1);
const end = nextControl === -1 ? data.length : nextControl;
appendOutput(data.slice(i, end));
this.inClearCluster = false;
i = end;
}
return output;
}
finish(): string {
this.pendingInput = "";
this.controlStringMode = null;
this.controlStringEscPending = false;
this.discardingCsi = false;
let output = "";
if (this.pendingCursorHome) {
output = this.pendingCursorHome + this.pendingAfterCursorHome;
this.hasOutput = true;
this.lastOutputChar = output[output.length - 1];
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
}
return output;
}
private appendPendingAfterCursorHome(sequence: CsiSequence): void {
if (this.pendingAfterCursorHomeOverflowed) return;
const nextLength = this.pendingAfterCursorHome.length + sequence.raw.length;
if (nextLength > MAX_PENDING_ESCAPE_CHARS) {
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = true;
return;
}
this.pendingAfterCursorHome += sequence.raw;
if (isSafePendingAfterCursorHomeSequence(sequence)) {
this.replaySafePendingAfterCursorHome += sequence.raw;
}
}
private setPendingEscapeInput(input: string): void {
if (input.length > MAX_PENDING_ESCAPE_CHARS) {
this.pendingInput = "";
this.discardingCsi = true;
return;
}
this.pendingInput = input;
}
private consumeDiscardedCsi(input: string, index: number): number {
for (let i = index; i < input.length; i += 1) {
if (isCsiFinal(input[i])) {
this.discardingCsi = false;
return i + 1;
}
}
return input.length;
}
private consumeControlString(input: string, index: number): number {
let i = index;
if (this.controlStringEscPending) {
if (input[i] === "\\") {
this.controlStringMode = null;
this.controlStringEscPending = false;
return i + 1;
}
this.controlStringEscPending = false;
}
for (; i < input.length; i += 1) {
if (this.controlStringMode === "osc" && input[i] === BEL) {
this.controlStringMode = null;
return i + 1;
}
if (input[i] === ST) {
this.controlStringMode = null;
return i + 1;
}
if (input[i] === ESC) {
if (i + 1 >= input.length) {
this.controlStringEscPending = true;
return input.length;
}
if (input[i + 1] === "\\") {
this.controlStringMode = null;
return i + 2;
}
}
}
return input.length;
}
}
export const createReplaySafeTerminalLogSanitizer = (
options: ReplaySafeTerminalLogSanitizerOptions = {},
): ReplaySafeTerminalLogSanitizer =>
new ReplaySafeTerminalLogSanitizerImpl(options);
/**
* Convert terminal output into a form that can be replayed in LogView without
* allowing shell `clear` / ED2 / ED3 controls to wipe earlier log history.
*/
export function createReplaySafeTerminalLog(input: string): string {
const sanitizer = createReplaySafeTerminalLogSanitizer();
return sanitizer.append(input) + sanitizer.finish();
}