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

177 lines
5.1 KiB
TypeScript

import type { CodingCliProviderId } from './codingCliProviders';
const ESC = String.fromCharCode(0x1b);
const BEL = String.fromCharCode(0x07);
type ControlSequenceMode =
| 'text'
| 'esc'
| 'escIntermediate'
| 'csi'
| 'string'
| 'stringEsc';
function createTerminalControlSequenceStripper() {
let mode: ControlSequenceMode = 'text';
let stringAllowsBel = false;
const feed = (text: string): string => {
let visible = '';
for (const char of text) {
const code = char.charCodeAt(0);
if (mode === 'text') {
if (char === ESC) mode = 'esc';
else visible += char;
} else if (mode === 'esc') {
if (char === '[') {
mode = 'csi';
} else if (char === ']' || char === 'P' || char === 'X' || char === '^' || char === '_') {
mode = 'string';
stringAllowsBel = char === ']';
} else if (code >= 0x20 && code <= 0x2f) {
mode = 'escIntermediate';
} else {
mode = 'text';
// ESC final bytes span 0x30-0x7e. Preserve only invalid bytes rather
// than silently swallowing ordinary output.
if (code < 0x30 || code > 0x7e) visible += char;
}
} else if (mode === 'escIntermediate') {
if (code >= 0x20 && code <= 0x2f) continue;
mode = char === ESC ? 'esc' : 'text';
if (char !== ESC && (code < 0x30 || code > 0x7e)) visible += char;
} else if (mode === 'csi') {
if (code >= 0x40 && code <= 0x7e) mode = 'text';
} else if (mode === 'string') {
if (stringAllowsBel && char === BEL) mode = 'text';
else if (char === ESC) mode = 'stringEsc';
} else if (char === '\\') {
mode = 'text';
} else if (stringAllowsBel && char === BEL) {
mode = 'text';
} else if (char !== ESC) {
mode = 'string';
}
}
return visible;
};
return {
feed,
reset: () => {
mode = 'text';
stringAllowsBel = false;
},
};
}
/** Strip ANSI/OSC sequences so startup banners remain readable. */
export function stripTerminalControlSequences(text: string): string {
return createTerminalControlSequenceStripper().feed(text);
}
type OutputSignature = {
id: CodingCliProviderId;
test: (text: string) => boolean;
};
/**
* Startup banners and prompts emitted by coding CLIs.
* Codex does not put its name in OSC titles by default (openai/codex#18740),
* but always prints an "OpenAI Codex" header when the TUI starts.
*/
const OUTPUT_SIGNATURES: readonly OutputSignature[] = [
{
id: 'codex',
test: (text) => /(?:^|\s)(?:>\s*)?OpenAI Codex(?:\s*\(|$|\s)/i.test(text),
},
{
id: 'claude',
// Match Claude's actual welcome banner, not installer messages such as
// "Setting up Claude Code..." which are ordinary shell output.
test: (text) => /\bWelcome to Claude Code\b/i.test(text) || text.includes('✳'),
},
{
id: 'copilot',
test: (text) => /GitHub Copilot/i.test(text),
},
{
id: 'gemini',
test: (text) => /Gemini CLI/i.test(text),
},
{
id: 'droid',
test: (text) => /Factory Droid/i.test(text) || /Factory\.ai/i.test(text),
},
{
id: 'opencode',
// The installer prints the brand and a shaded ASCII wordmark. The TUI's
// startup logo uses a distinct space-filled third row, so require both
// TUI rows instead of matching ordinary OpenCode text.
test: (text) => (
/█▀▀█\s+█▀▀█\s+█▀▀█\s+█▀▀▄[\s\S]{0,512}█ {2}█\s+█ {2}█\s+█▀▀▀\s+█ {2}█/.test(text)
),
},
{
id: 'kimi',
test: (text) => /\bMoonshot\b/i.test(text) || /\bKimi\b/i.test(text),
},
] as const;
const OUTPUT_SCAN_BUFFER_LIMIT = 8192;
const OUTPUT_SCAN_BYTE_LIMIT = 16384;
export function inferCodingCliProviderFromOutput(text: string): CodingCliProviderId | undefined {
const normalized = stripTerminalControlSequences(text);
if (!normalized.trim()) return undefined;
for (const signature of OUTPUT_SIGNATURES) {
if (signature.test(normalized)) {
return signature.id;
}
}
return undefined;
}
export type CodingCliOutputScanner = {
feed: (chunk: string) => CodingCliProviderId | undefined;
reset: () => void;
isExhausted: () => boolean;
};
/** Rolling buffer scanner for live terminal output chunks. */
export function createCodingCliOutputScanner(): CodingCliOutputScanner {
let visibleBuffer = '';
let bytesFed = 0;
let exhausted = false;
const controlSequenceStripper = createTerminalControlSequenceStripper();
const feed = (chunk: string): CodingCliProviderId | undefined => {
if (!chunk || exhausted) return undefined;
bytesFed += chunk.length;
visibleBuffer = `${visibleBuffer}${controlSequenceStripper.feed(chunk)}`
.slice(-OUTPUT_SCAN_BUFFER_LIMIT);
const providerId = inferCodingCliProviderFromOutput(visibleBuffer);
if (providerId) return providerId;
if (bytesFed >= OUTPUT_SCAN_BYTE_LIMIT) {
exhausted = true;
}
return undefined;
};
const reset = () => {
visibleBuffer = '';
bytesFed = 0;
exhausted = false;
controlSequenceStripper.reset();
};
const isExhausted = () => exhausted;
return { feed, reset, isExhausted };
}