Files
NetMesh/domain/terminalOutputTriggerFilter.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

523 lines
14 KiB
TypeScript

const MAX_PENDING_ECHO = 4096;
const ALTERNATE_SCREEN_MODES = new Set([47, 1047, 1049]);
export type TerminalAlternateScreenAction = 'enter' | 'leave';
export type TerminalOutputChunkMeta = {
rawLen: number;
dropReason?:
| 'empty-chunk'
| 'awaiting-submitted-echo'
| 'alternate-screen-active'
| 'pending-escape'
| 'user-echo-only'
| 'control-only'
| 'editing-input-line';
submittedEchoLine?: string;
submittedEchoCandidateLen: number;
afterSubmittedLen: number;
afterUserEchoLen: number;
scannableLen: number;
alternateScreenAction?: TerminalAlternateScreenAction;
};
export type TerminalOutputTriggerFilter = {
noteUserInput: (data: string) => void;
processServerChunk: (chunk: string) => {
scannableText: string;
alternateScreenActive: boolean;
meta: TerminalOutputChunkMeta;
};
reset: () => void;
resetInputState: () => void;
resetPendingEscape: () => void;
markInputEchoUncertain: (lineCount?: number) => void;
};
export function getTerminalAlternateScreenAction(sequence: string): TerminalAlternateScreenAction | null {
if (!sequence.startsWith('\x1b[') || sequence.length < 3) return null;
const final = sequence.at(-1);
if (final !== 'h' && final !== 'l') return null;
const params = sequence.slice(2, -1);
if (!params.startsWith('?')) return null;
const modes = params
.slice(1)
.split(';')
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => ALTERNATE_SCREEN_MODES.has(mode))) {
return null;
}
return final === 'h' ? 'enter' : 'leave';
}
function readCsiSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
if (input[startIndex] !== '\x1b' || input[startIndex + 1] !== '[') return null;
for (let index = startIndex + 2; 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;
}
/** OSC: ESC ] payload BEL (0x07) or ST (ESC \\). */
function readOscSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
if (input[startIndex] !== '\x1b' || input[startIndex + 1] !== ']') return null;
for (let index = startIndex + 2; index < input.length; index += 1) {
if (input[index] === '\x07') {
return {
sequence: input.slice(startIndex, index + 1),
end: index + 1,
};
}
if (input[index] === '\x1b' && input[index + 1] === '\\') {
return {
sequence: input.slice(startIndex, index + 2),
end: index + 2,
};
}
}
return null;
}
function readEscapeSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
if (input[startIndex] !== '\x1b') return null;
if (startIndex + 1 >= input.length) return null;
const next = input[startIndex + 1];
if (next === '[') return readCsiSequence(input, startIndex);
if (next === ']') return readOscSequence(input, startIndex);
const nextCode = next.charCodeAt(0);
if (nextCode >= 0x40 && nextCode <= 0x7e) {
return {
sequence: input.slice(startIndex, startIndex + 2),
end: startIndex + 2,
};
}
return null;
}
function stripUserEcho(pendingEcho: string, chunk: string): { pendingEcho: string; filtered: string } {
if (!pendingEcho || !chunk) {
return { pendingEcho, filtered: chunk };
}
let echoIndex = 0;
let chunkIndex = 0;
let matchedPrintable = 0;
while (echoIndex < pendingEcho.length && chunkIndex < chunk.length) {
if (chunk[chunkIndex] === '\x1b') {
const sequence = readEscapeSequence(chunk, chunkIndex);
if (!sequence) break;
chunkIndex = sequence.end;
continue;
}
if (pendingEcho[echoIndex] === chunk[chunkIndex]) {
echoIndex += 1;
chunkIndex += 1;
matchedPrintable += 1;
continue;
}
break;
}
if (matchedPrintable > 0) {
return {
pendingEcho: pendingEcho.slice(echoIndex),
filtered: chunk.slice(chunkIndex),
};
}
return {
pendingEcho: '',
filtered: chunk,
};
}
function isPrintableInput(char: string): boolean {
const code = char.codePointAt(0);
return code !== undefined && code >= 0x20 && code !== 0x7f;
}
function applyInputToLine(line: string, data: string): { line: string; submittedLine: string | null } {
let nextLine = line;
let submittedLine: string | null = null;
for (const char of stripInputControlWrappers(data)) {
if (char === '\r' || char === '\n') {
submittedLine = nextLine;
nextLine = '';
continue;
}
if (char === '\x7f' || char === '\b') {
nextLine = nextLine.slice(0, -1);
continue;
}
if (isPrintableInput(char)) {
nextLine += char;
}
}
return { line: nextLine, submittedLine };
}
function consumeSuppressedEchoLines(
input: string,
lineCount: number,
pendingLf: boolean,
onEscapeSequence?: (sequence: string) => void,
): {
input: string;
lineCount: number;
pendingLf: boolean;
pendingEscape?: string;
} {
let index = 0;
let remainingLines = lineCount;
let nextPendingLf = false;
if (pendingLf && input[index] === '\n') {
index += 1;
}
while (remainingLines > 0 && index < input.length) {
const char = input[index];
if (char === '\x1b') {
const sequence = readEscapeSequence(input, index);
if (sequence) {
onEscapeSequence?.(sequence.sequence);
index = sequence.end;
continue;
}
return {
input: '',
lineCount: remainingLines,
pendingLf: nextPendingLf,
pendingEscape: input.slice(index),
};
}
if (char === '\r') {
remainingLines -= 1;
index += 1;
if (input[index] === '\n') {
index += 1;
} else if (index === input.length) {
nextPendingLf = true;
}
continue;
}
if (char === '\n') {
remainingLines -= 1;
index += 1;
continue;
}
index += 1;
}
return {
input: input.slice(index),
lineCount: remainingLines,
pendingLf: nextPendingLf,
};
}
const BRACKETED_PASTE_START = '\x1b[200~';
const BRACKETED_PASTE_END = '\x1b[201~';
function stripInputControlWrappers(data: string): string {
return data.split(BRACKETED_PASTE_START).join('').split(BRACKETED_PASTE_END).join('');
}
function appendPendingEcho(pendingEcho: string, data: string): string {
let next = pendingEcho;
let index = 0;
const sanitized = stripInputControlWrappers(data);
while (index < sanitized.length) {
const char = sanitized[index];
if (char === '\r' || char === '\n') {
index += 1;
continue;
}
if (char === '\x1b') {
const sequence = readEscapeSequence(sanitized, index);
if (sequence) {
index = sequence.end;
continue;
}
index += 1;
continue;
}
if (char === '\x7f') {
next = next.slice(0, -1);
index += 1;
continue;
}
if (char === '\x08') {
next += char;
index += 1;
continue;
}
if (isPrintableInput(char)) {
next += char;
index += 1;
continue;
}
index += 1;
}
return next.slice(-MAX_PENDING_ECHO);
}
function stripSubmittedLineEcho(
submittedLine: string | null,
candidate: string,
chunk: string,
): { submittedLine: string | null; candidate: string; filtered: string } {
if (submittedLine === null || submittedLine.length === 0) {
return { submittedLine: null, candidate: '', filtered: chunk };
}
const nextCandidate = candidate + chunk;
const echoVariants = [
`${submittedLine}\r\n`,
`${submittedLine}\n`,
`${submittedLine}\r`,
];
const fullEcho = echoVariants.find((variant) => nextCandidate.startsWith(variant));
if (fullEcho) {
return {
submittedLine: null,
candidate: '',
filtered: nextCandidate.slice(fullEcho.length),
};
}
if (echoVariants.some((variant) => variant.startsWith(nextCandidate))) {
return { submittedLine, candidate: nextCandidate, filtered: '' };
}
return {
submittedLine: null,
candidate: '',
filtered: nextCandidate,
};
}
export function createTerminalOutputTriggerFilter(): TerminalOutputTriggerFilter {
let pendingEcho = '';
let currentInputLine = '';
let currentInputLineOverflowed = false;
let submittedEchoLine: string | null = null;
let submittedEchoCandidate = '';
let alternateScreenActive = false;
let pendingEscape = '';
let inputEchoSuppressLines = 0;
let inputEchoSuppressPendingLf = false;
const resetInputState = () => {
pendingEcho = '';
currentInputLine = '';
currentInputLineOverflowed = false;
submittedEchoLine = null;
submittedEchoCandidate = '';
};
const markInputEchoUncertain = (lineCount = 1) => {
resetInputState();
inputEchoSuppressPendingLf = false;
inputEchoSuppressLines = Math.max(inputEchoSuppressLines, Math.max(1, lineCount));
};
const reset = () => {
resetInputState();
alternateScreenActive = false;
pendingEscape = '';
inputEchoSuppressLines = 0;
inputEchoSuppressPendingLf = false;
};
const resetPendingEscape = () => {
pendingEscape = '';
};
const noteUserInput = (data: string) => {
if (!data) return;
const inputState = applyInputToLine(currentInputLine, data);
const inputLineOverflowed = currentInputLineOverflowed || inputState.line.length > MAX_PENDING_ECHO;
currentInputLine = inputState.line.slice(-MAX_PENDING_ECHO);
if (inputState.submittedLine !== null) {
if (inputLineOverflowed || inputState.submittedLine.length > MAX_PENDING_ECHO) {
markInputEchoUncertain();
} else {
submittedEchoLine = inputState.submittedLine;
submittedEchoCandidate = '';
pendingEcho = '';
}
return;
}
currentInputLineOverflowed = inputLineOverflowed;
pendingEcho = appendPendingEcho(pendingEcho, data);
};
const processServerChunk = (chunk: string) => {
let input = pendingEscape ? `${pendingEscape}${chunk}` : chunk;
pendingEscape = '';
let alternateScreenAction: TerminalAlternateScreenAction | undefined;
const noteAlternateScreenSequence = (sequence: string) => {
if (!sequence.startsWith('\x1b[')) return;
const action = getTerminalAlternateScreenAction(sequence);
if (action === 'enter') {
alternateScreenActive = true;
pendingEcho = '';
alternateScreenAction = action;
} else if (action === 'leave') {
alternateScreenActive = false;
pendingEcho = '';
alternateScreenAction = action;
}
};
if (inputEchoSuppressLines > 0 || inputEchoSuppressPendingLf) {
const suppressed = consumeSuppressedEchoLines(
input,
inputEchoSuppressLines,
inputEchoSuppressPendingLf,
noteAlternateScreenSequence,
);
input = suppressed.input;
inputEchoSuppressLines = suppressed.lineCount;
inputEchoSuppressPendingLf = suppressed.pendingLf;
pendingEscape = suppressed.pendingEscape ?? '';
if (!input) {
return {
scannableText: '',
alternateScreenActive,
meta: {
rawLen: chunk.length,
dropReason: 'awaiting-submitted-echo',
submittedEchoLine: undefined,
submittedEchoCandidateLen: 0,
afterSubmittedLen: 0,
afterUserEchoLen: 0,
scannableLen: 0,
alternateScreenAction,
},
};
}
if (inputEchoSuppressLines > 0) {
return {
scannableText: '',
alternateScreenActive,
meta: {
rawLen: chunk.length,
dropReason: 'awaiting-submitted-echo',
submittedEchoLine: undefined,
submittedEchoCandidateLen: 0,
afterSubmittedLen: 0,
afterUserEchoLen: 0,
scannableLen: 0,
alternateScreenAction,
},
};
}
}
const submitted = stripSubmittedLineEcho(submittedEchoLine, submittedEchoCandidate, input);
submittedEchoLine = submitted.submittedLine;
submittedEchoCandidate = submitted.candidate;
if (!submitted.filtered) {
return {
scannableText: '',
alternateScreenActive,
meta: {
rawLen: chunk.length,
dropReason: chunk.length === 0
? 'empty-chunk'
: 'awaiting-submitted-echo',
submittedEchoLine: submittedEchoLine ?? undefined,
submittedEchoCandidateLen: submittedEchoCandidate.length,
afterSubmittedLen: 0,
afterUserEchoLen: 0,
scannableLen: 0,
},
};
}
const { pendingEcho: nextPendingEcho, filtered } = stripUserEcho(pendingEcho, submitted.filtered);
pendingEcho = nextPendingEcho;
let scannableText = '';
let hitPendingEscape = false;
for (let index = 0; index < filtered.length; index += 1) {
if (filtered[index] !== '\x1b') {
if (!alternateScreenActive) {
scannableText += filtered[index];
}
continue;
}
const sequence = readEscapeSequence(filtered, index);
if (!sequence) {
pendingEscape = filtered.slice(index);
hitPendingEscape = true;
break;
}
noteAlternateScreenSequence(sequence.sequence);
index = sequence.end - 1;
}
let dropReason: TerminalOutputChunkMeta['dropReason'];
if (currentInputLine.length > 0) {
scannableText = '';
dropReason = 'editing-input-line';
} else if (!scannableText) {
if (hitPendingEscape) {
dropReason = 'pending-escape';
} else if (alternateScreenActive) {
dropReason = 'alternate-screen-active';
} else if (filtered.length === 0) {
dropReason = 'user-echo-only';
} else {
dropReason = 'control-only';
}
}
return {
scannableText,
alternateScreenActive,
meta: {
rawLen: chunk.length,
dropReason,
submittedEchoLine: submittedEchoLine ?? undefined,
submittedEchoCandidateLen: submittedEchoCandidate.length,
afterSubmittedLen: submitted.filtered.length,
afterUserEchoLen: filtered.length,
scannableLen: scannableText.length,
alternateScreenAction,
},
};
};
return {
noteUserInput,
processServerChunk,
reset,
resetInputState,
resetPendingEscape,
markInputEchoUncertain,
};
}