Files
Netcatty/domain/codingCliOutputDetect.ts
T
陈大猫 c6bdcffb89 feat: dynamic terminal tab titles (fixes #1572) (#1576)
* feat: add dynamic terminal tab titles for shell-reported session names

Follow OSC 0/2 window titles on session tabs so tools like Claude Code can show live session state, with a per-host opt-out to keep connection names instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address dynamic tab title review findings

Restore the missing cn import, clear dynamic titles when the shell sends an empty OSC title, and drop stored dynamic titles when a host disables the feature.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: preserve renamed session labels and align focus sidebar titles

Prefer user-assigned customName over shell titles, skip no-op dynamic title updates, and use resolveSessionTabTitle in the workspace focus sidebar.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: show coding agent icons on session tabs

Detect Claude Code, Codex, OpenCode, Droid, and other CLIs from OSC titles and launch commands, reusing shared agent icon assets inspired by freshell pane icons.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: harden coding CLI detection across SSH and startup paths

Wire output scanning through attachSessionToTerminal, seed providers from
auto startup commands, prevent title signals from overriding command
detection, clear sticky state on shell titles, and release output scanners
on session close.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: reset CLI output scanner when provider state clears

Prevent exited-agent banners from immediately re-sticking the tab icon
after the shell title returns to a normal shell name.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: update sticky CLI provider when title signals a different agent

Allow agent switches in the same terminal while still resetting the output
scanner whenever the provider identity changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: avoid clearing CLI provider on blank titles and cap output scan

Ignore transient empty OSC titles and stop banner scanning after 16KB of
unmatched output so regular SSH sessions do not pay perpetual scan cost.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: prefer runtime CLI provider over host startup command on tabs

Runtime detection from commands, output, and titles should win over static
host startup commands when resolving the session tab icon.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: satisfy eslint for terminal escape stripping regexes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 14:20:05 +08:00

111 lines
2.9 KiB
TypeScript

import type { CodingCliProviderId } from './codingCliProviders';
const ESC = String.fromCharCode(0x1b);
const BEL = String.fromCharCode(0x07);
const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-9:;?]*[ -/]*[@-~]`, 'g');
const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g');
const LONE_ESC_RE = new RegExp(`${ESC}[@-_]`, 'g');
/** Strip ANSI/OSC sequences so startup banners remain readable. */
export function stripTerminalControlSequences(text: string): string {
return text
.replace(ANSI_CSI_RE, '')
.replace(OSC_SEQUENCE_RE, '')
.replace(LONE_ESC_RE, '');
}
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',
test: (text) => /Claude Code/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',
test: (text) => /\bOpenCode\b/i.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 buffer = '';
let bytesFed = 0;
let exhausted = false;
const feed = (chunk: string): CodingCliProviderId | undefined => {
if (!chunk || exhausted) return undefined;
bytesFed += chunk.length;
buffer = `${buffer}${stripTerminalControlSequences(chunk)}`.slice(-OUTPUT_SCAN_BUFFER_LIMIT);
const providerId = inferCodingCliProviderFromOutput(buffer);
if (providerId) return providerId;
if (bytesFed >= OUTPUT_SCAN_BYTE_LIMIT) {
exhausted = true;
}
return undefined;
};
const reset = () => {
buffer = '';
bytesFed = 0;
exhausted = false;
};
const isExhausted = () => exhausted;
return { feed, reset, isExhausted };
}