[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
503
components/terminal/replaySafeTerminalLog.ts
Normal file
503
components/terminal/replaySafeTerminalLog.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user