diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 80f934b3c..90e72a93d 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -1,501 +1,34 @@ /** - * Shared agent detection utilities — used by both the main process (stats - * collection) and the renderer (activity indicators, unread badges). + * Compatibility barrel for terminal-title agent detection — used by the main + * process (stats collection), the renderer (activity indicators, unread + * badges), and shared siblings. * - * Why shared: the main process needs the same OSC title extraction and agent - * status detection for stat tracking that the renderer uses for UI indicators. - * Duplicating this logic would risk drift between the two detection paths. + * The implementation was split into domain modules in Phase 3 of the title + * evidence work: identity/label detection → `terminal-title-agent-type`, status + * classification → `terminal-title-status`, and display normalization → + * `terminal-title-display`. This barrel is kept so the existing main/mobile/ + * renderer import paths that reference `agent-detection` stay stable. */ -import { - AGY_AGENT_NAME_RE, - DROID_AGENT_NAME_RE, - HERMES_AGENT_NAME_RE, - titleHasAgentName, - titleHasAnyLegacyAgentName -} from './agent-name-token-match' -import { - getPiCompatibleSyntheticAgentLabel, - getPiCompatibleSyntheticAgentStatus, - isLegacyPiCompatibleTitle -} from './pi-compatible-synthetic-title' - -// Re-export so existing `agent-detection` importers keep working. -export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' +export { titleHasAgentName } from './agent-name-token-match' export { extractAllOscTitles, extractLastOscTitle, MAX_OSC_TITLE_CHARS } from './osc-title-extraction' export { isShellProcess } from './shell-process-detection' - -export type AgentStatus = 'working' | 'permission' | 'idle' - -const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) -const CLAUDE_MANAGEMENT_TITLE_RE = - /^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i - -const GEMINI_WORKING = '\u2726' // ✦ -const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ -const GEMINI_IDLE = '\u25C7' // ◇ -const GEMINI_PERMISSION = '\u270B' // ✋ - -// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles -// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'. -// `as const` so consumers receive literal-union types. -const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const - -// Why: working keywords used inside `detectAgentStatusFromTitle` to map -// titles like "Codex working", "Aider thinking", "OpenCode running" to -// AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay -// in lock-step when stripping working indicators from stale titles. -const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const - -// Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that -// would make the "keyword" part of a larger token. Plain `\b` alone is -// insufficient because `-` is a non-word character in JS regex, so `\bready\b` -// still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and -// `r`). -// -// Lookarounds are intentionally ASYMMETRIC: -// - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`, -// Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle -// signal by having the agent name sit earlier in the same path and the -// keyword land right after a path separator. Orca is a cross-platform -// Electron app, so Windows path separators must be handled too. -// - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like -// "Codex done." / "Aider idle." / "OpenCode ready!" still match — path -// separators after the keyword are not a false-positive vector in -// practice and blocking them would regress trailing-punctuation titles. -// -// Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain -// substring false positives ("already"/"redone"/"idleness"). -export const STRONG_IDLE_KEYWORDS_RE = new RegExp( - `(?= 0x2800 && codePoint <= 0x28ff) { - return true - } - } - return false -} - -function containsAgentName(title: string): boolean { - return ( - titleHasAnyLegacyAgentName(title) || - AGY_AGENT_NAME_RE.test(title) || - DROID_AGENT_NAME_RE.test(title) || - HERMES_AGENT_NAME_RE.test(title) - ) -} - -function containsAny(title: string, words: readonly string[]): boolean { - const lower = title.toLowerCase() - return words.some((word) => lower.includes(word)) -} - -/** - * Strip working-status indicators from a title so that - * `detectAgentStatusFromTitle` will no longer return 'working'. - * Used to clear stale titles when an agent exits without resetting its title. - */ -export function clearWorkingIndicators(title: string): string { - let cleaned = title - - // Gemini working symbols - cleaned = cleaned.replace(GEMINI_WORKING, '') - cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') - - // Braille spinner characters (U+2800–U+28FF) - // eslint-disable-next-line no-control-regex -- intentional unicode range - cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') - - // Claude Code ". " working prefix - if (cleaned.startsWith('. ')) { - cleaned = cleaned.slice(2) - } - - // Strip working keywords that detectAgentStatusFromTitle would pick up - // when the title also contains an agent name. - if (containsAgentName(cleaned)) { - cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') - } - - // Collapse whitespace after removals - cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() - - return cleaned || title -} - -/** - * Tracks agent status transitions from terminal title changes. - * Fires `onBecameIdle` when an agent transitions from working to idle/permission, - * like haunt's attention flag — the key trigger for unread notifications. - */ -export function createAgentStatusTracker( - onBecameIdle: (title: string) => void, - onBecameWorking?: () => void, - onAgentExited?: () => void -): { - handleTitle: (title: string) => void - /** Clear accumulated status so a stale working→idle transition cannot fire - * after the owning transport is torn down. */ - reset: () => void -} { - let lastStatus: AgentStatus | null = null - - return { - handleTitle(title: string): void { - const newStatus = detectAgentStatusFromTitle(title) - if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { - onBecameIdle(title) - } - if (lastStatus !== 'working' && newStatus === 'working') { - onBecameWorking?.() - } - // Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"), - // detectAgentStatusFromTitle returns null. If we were idle or in a permission - // prompt, this means the user exited the agent — clear session-tied state - // (like the prompt-cache countdown). We intentionally do NOT fire this when - // lastStatus is 'working', because active agents can briefly flash shell - // titles during internal operations without actually exiting. - if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { - lastStatus = null - onAgentExited?.() - } - if (newStatus !== null) { - lastStatus = newStatus - } - }, - reset(): void { - lastStatus = null - } - } -} - -/** - * Normalize high-churn agent titles into stable display labels before storing - * them in app state. Gemini CLI can emit per-keystroke title updates, which - * otherwise causes broad rerenders and visible flashing. - */ -export function normalizeTerminalTitle(title: string): string { - if (!title) { - return title - } - - if (isGeminiTerminalTitle(title)) { - const status = detectAgentStatusFromTitle(title) - if (status === 'permission') { - return `${GEMINI_PERMISSION} Gemini CLI` - } - if (status === 'working') { - return `${GEMINI_WORKING} Gemini CLI` - } - if (status === 'idle') { - return `${GEMINI_IDLE} Gemini CLI` - } - } - - // Why: Pi's titlebar extension animates every 80ms with different braille - // frames. Collapsing those frames into one stable label avoids renderer - // churn while preserving the working/idle transition Orca keys off. - if (isPiAgentTitle(title)) { - const status = detectAgentStatusFromTitle(title) - if (status === 'working') { - return '\u280b Pi' - } - if (status === 'idle') { - return 'Pi' - } - } - - return title -} - -/** - * Returns true when the terminal title matches Claude Code's title conventions. - * Used to scope prompt-cache-timer behavior to Claude sessions only — other - * agents have different (or no) caching semantics. - */ -export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { - return false - } - const lower = title.toLowerCase() - - // Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ", - // braille spinners) followed by the task description. The task text can - // legitimately mention other agents, so Claude-specific prefixes must win. - if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { - return true - } - // Why: ". " (working) and "* " (idle) are Claude Code title conventions. - // Other supported agents do not use them, and rejecting titles that mention - // another agent in the task text caused false negatives for real Claude tabs. - if (title.startsWith('. ') || title.startsWith('* ')) { - return true - } - if (containsBrailleSpinner(title)) { - // Why: named non-Claude agents can carry braille spinners too; Claude-only - // prompt-cache paths must not fire for those explicit agent titles. - return !lower.includes('cursor') && !lower.includes('openclaude') - } - // Why: permission/action-required Claude titles can omit the usual prefixes. - // Token-match so cwd/worktree titles like "claude-scratch" do not become - // Claude tabs, while task text that merely mentions Claude still stays out. - const trimmedTitle = title.trimStart() - if ( - trimmedTitle.toLowerCase().startsWith('claude') && - titleHasAgentName(trimmedTitle, 'claude') - ) { - return true - } - - return false -} - -export function isClaudeManagementTitle(title: string): boolean { - return CLAUDE_MANAGEMENT_TITLE_RE.test(title) -} - -export function getAgentLabel(title: string): string | null { - if (isClaudeManagementTitle(title)) { - return null - } - // Why: Claude Code title text is often the task title. If that task mentions - // another CLI, the Claude-specific prefix is the identity signal, not the words. - if ( - title.startsWith(`${CLAUDE_IDLE} `) || - title === CLAUDE_IDLE || - title.startsWith('. ') || - title.startsWith('* ') - ) { - return 'Claude Code' - } - if (isGeminiTerminalTitle(title)) { - return 'Gemini CLI' - } - // Why: Pi-compatible synthetic titles can carry braille spinners, which the - // generic agent-title heuristics would otherwise claim first. - const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) - if (piCompatibleSyntheticAgentLabel) { - return piCompatibleSyntheticAgentLabel - } - // Why: Pi working titles include a braille spinner prefix, which would be - // mistaken for Claude Code if we checked `isClaudeAgent` first. - if (isPiAgentTitle(title)) { - return 'Pi' - } - // Why: Codex/OpenCode/Aider can also use braille spinner prefixes while - // working. Prefer explicit name matches before Claude's generic spinner - // heuristic so mixed-agent hovercards stay truthful. Token-match (not - // substring) so cwd/worktree titles like "opencode-blinker" don't mint a - // false agent identity. - if (titleHasAgentName(title, 'codex')) { - return 'Codex' - } - if (titleHasAgentName(title, 'openclaude')) { - return 'OpenClaude' - } - if (titleHasAgentName(title, 'copilot')) { - return 'GitHub Copilot' - } - if (titleHasAgentName(title, 'grok')) { - return 'Grok' - } - if (titleHasAgentName(title, 'devin')) { - return 'Devin' - } - if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { - return 'Antigravity' - } - if (titleHasAgentName(title, 'opencode')) { - return 'OpenCode' - } - if (titleHasAgentName(title, 'mimo')) { - return 'MiMo Code' - } - if (titleHasAgentName(title, 'aider')) { - return 'Aider' - } - // Why: the cursor-agent native title is the literal string "Cursor Agent" - // (verified against the 2026.04.17 release) — Orca synthesizes the same - // label from hook events so the braille-spinner + agent-name path lights - // up working/permission/idle transitions in the renderer. Match before - // `isClaudeAgent` because Claude's generic braille heuristic would - // otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a - // cwd like "~/cursor-rules" can't masquerade as a Cursor agent. - if (titleHasAgentName(title, 'cursor')) { - return 'Cursor' - } - // Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic. - // Token matching avoids labeling ordinary Android terminal titles as Droid. - if (DROID_AGENT_NAME_RE.test(title)) { - return 'Droid' - } - // Why: synthesized "⠋ Hermes" working titles need to be matched before - // Claude's generic braille-spinner heuristic. - if (HERMES_AGENT_NAME_RE.test(title)) { - return 'Hermes' - } - if (isClaudeAgent(title)) { - return 'Claude Code' - } - - return null -} - -// Why: cursor-agent's native OSC title is the literal string "Cursor Agent" -// across the entire turn — it carries zero working/idle information. Orca -// synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor - -// action required" for permission) from cursor's hook events; the bare -// native title must be a no-op so cursor's per-turn re-emissions cannot -// stomp the synthesized state back to idle. -const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' - -export function detectAgentStatusFromTitle(title: string): AgentStatus | null { - if (!title) { - return null - } - if (isClaudeManagementTitle(title)) { - return null - } - // Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's - // native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor - - // action required") is either an Orca-synthesized working/permission title - // or a tighter match worth classifying. - if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { - return null - } - - // Gemini CLI symbols are the most specific and should take precedence. - if (title.includes(GEMINI_PERMISSION)) { - return 'permission' - } - if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { - return 'working' - } - if (title.includes(GEMINI_IDLE)) { - return 'idle' - } - - // Why: resolve synthetic Pi/OMP permission/idle labels before the broader - // Pi and braille-spinner checks below. - const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) - if (piCompatibleSyntheticAgentStatus) { - return piCompatibleSyntheticAgentStatus - } - - // Claude Code uses ✳ prefix for idle — must check before braille/agent-name - // because the title text is the task description, not "Claude Code". - if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { - return 'idle' - } - - if (isPiTerminalTitle(title)) { - return 'idle' - } - - if (containsBrailleSpinner(title)) { - return 'working' - } - - const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) - const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) - const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) - const hasLegacyAgentName = titleHasAnyLegacyAgentName(title) - if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) { - if (containsAny(title, ['action required', 'permission', 'waiting'])) { - return 'permission' - } - // Why: hyphen/word-char-aware boundary match (not plain substring, and - // stricter than `\b` — which treats `-` as a boundary) so titles like - // "~/codex already built" do not classify as idle via the substring - // "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment. - if (STRONG_IDLE_KEYWORDS_RE.test(title)) { - return 'idle' - } - // Why: hyphen/word-char-aware boundary match (not plain substring, and - // stricter than `\b`) so titles like "~/codex reworking diff" or - // "is-thinking-cap" do not classify as working via the substrings - // "reworking" ⊃ "working" or the `-`-adjacent "thinking" in - // "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a - // false 'working' is worse than a false 'idle' because it drives - // active-agent UI (spinners, counts). - if (STRONG_WORKING_KEYWORDS_RE.test(title)) { - return 'working' - } - - // Claude Code title prefixes: ". " = working, "* " = idle - if (title.startsWith('. ')) { - return 'working' - } - if (title.startsWith('* ')) { - return 'idle' - } - - // Why: Factory Droid can publish native titles like "Factory Droid needs - // input" while an Execute tool is still sleeping. Droid's hook events are - // authoritative; don't turn a name-only native title into a completion. - if (hasDroidAgentName && !hasLegacyAgentName) { - return null - } - - return 'idle' - } - - return null -} +export { + getAgentLabel, + isClaudeAgent, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiTerminalTitle +} from './terminal-title-agent-type' +export type { AgentStatus } from './terminal-title-status' +export { + createAgentStatusTracker, + detectAgentStatusFromTitle, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE +} from './terminal-title-status' +export { clearWorkingIndicators, normalizeTerminalTitle } from './terminal-title-display' diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 3671ef929..21c206dff 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -1,6 +1,193 @@ -import { getAgentLabel, titleHasAgentName } from './agent-detection' +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAgentName +} from './agent-name-token-match' +import { + getPiCompatibleSyntheticAgentLabel, + isLegacyPiCompatibleTitle +} from './pi-compatible-synthetic-title' import type { TuiAgent } from './types' +export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) +const CLAUDE_MANAGEMENT_TITLE_RE = + /^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i + +export const GEMINI_WORKING = '\u2726' // ✦ +export const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ +export const GEMINI_IDLE = '\u25C7' // ◇ +export const GEMINI_PERMISSION = '\u270B' // ✋ + +export function containsBrailleSpinner(title: string): boolean { + for (const char of title) { + const codePoint = char.codePointAt(0) + if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function isGeminiTerminalTitle(title: string): boolean { + // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. + if ( + title.includes(GEMINI_PERMISSION) || + title.includes(GEMINI_WORKING) || + title.includes(GEMINI_SILENT_WORKING) || + title.includes(GEMINI_IDLE) + ) { + return true + } + // Why: Pi/OMP titles include cwd/session text; substring matching made + // paths like "gemini-project" masquerade as Gemini CLI. + if (isPiAgentTitle(title)) { + return false + } + return titleHasAgentName(title, 'gemini') +} + +export function isPiTerminalTitle(title: string): boolean { + return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) +} + +export function isPiAgentTitle(title: string): boolean { + return isLegacyPiCompatibleTitle(title) +} + +/** + * Returns true when the terminal title matches Claude Code's title conventions. + * Used to scope prompt-cache-timer behavior to Claude sessions only — other + * agents have different (or no) caching semantics. + */ +export function isClaudeAgent(title: string): boolean { + if (!title || isClaudeManagementTitle(title)) { + return false + } + const lower = title.toLowerCase() + + // Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ", + // braille spinners) followed by the task description. The task text can + // legitimately mention other agents, so Claude-specific prefixes must win. + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return true + } + // Why: ". " (working) and "* " (idle) are Claude Code title conventions. + // Other supported agents do not use them, and rejecting titles that mention + // another agent in the task text caused false negatives for real Claude tabs. + if (title.startsWith('. ') || title.startsWith('* ')) { + return true + } + if (containsBrailleSpinner(title)) { + // Why: named non-Claude agents can carry braille spinners too; Claude-only + // prompt-cache paths must not fire for those explicit agent titles. + return !lower.includes('cursor') && !lower.includes('openclaude') + } + // Why: permission/action-required Claude titles can omit the usual prefixes. + // Token-match so cwd/worktree titles like "claude-scratch" do not become + // Claude tabs, while task text that merely mentions Claude still stays out. + const trimmedTitle = title.trimStart() + if ( + trimmedTitle.toLowerCase().startsWith('claude') && + titleHasAgentName(trimmedTitle, 'claude') + ) { + return true + } + + return false +} + +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + +export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } + // Why: Claude Code title text is often the task title. If that task mentions + // another CLI, the Claude-specific prefix is the identity signal, not the words. + if ( + title.startsWith(`${CLAUDE_IDLE} `) || + title === CLAUDE_IDLE || + title.startsWith('. ') || + title.startsWith('* ') + ) { + return 'Claude Code' + } + if (isGeminiTerminalTitle(title)) { + return 'Gemini CLI' + } + // Why: Pi-compatible synthetic titles can carry braille spinners, which the + // generic agent-title heuristics would otherwise claim first. + const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) + if (piCompatibleSyntheticAgentLabel) { + return piCompatibleSyntheticAgentLabel + } + // Why: Pi working titles include a braille spinner prefix, which would be + // mistaken for Claude Code if we checked `isClaudeAgent` first. + if (isPiAgentTitle(title)) { + return 'Pi' + } + // Why: Codex/OpenCode/Aider can also use braille spinner prefixes while + // working. Prefer explicit name matches before Claude's generic spinner + // heuristic so mixed-agent hovercards stay truthful. Token-match (not + // substring) so cwd/worktree titles like "opencode-blinker" don't mint a + // false agent identity. + if (titleHasAgentName(title, 'codex')) { + return 'Codex' + } + if (titleHasAgentName(title, 'openclaude')) { + return 'OpenClaude' + } + if (titleHasAgentName(title, 'copilot')) { + return 'GitHub Copilot' + } + if (titleHasAgentName(title, 'grok')) { + return 'Grok' + } + if (titleHasAgentName(title, 'devin')) { + return 'Devin' + } + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return 'Antigravity' + } + if (titleHasAgentName(title, 'opencode')) { + return 'OpenCode' + } + if (titleHasAgentName(title, 'mimo')) { + return 'MiMo Code' + } + if (titleHasAgentName(title, 'aider')) { + return 'Aider' + } + // Why: the cursor-agent native title is the literal string "Cursor Agent" + // (verified against the 2026.04.17 release) — Orca synthesizes the same + // label from hook events so the braille-spinner + agent-name path lights + // up working/permission/idle transitions in the renderer. Match before + // `isClaudeAgent` because Claude's generic braille heuristic would + // otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a + // cwd like "~/cursor-rules" can't masquerade as a Cursor agent. + if (titleHasAgentName(title, 'cursor')) { + return 'Cursor' + } + // Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic. + // Token matching avoids labeling ordinary Android terminal titles as Droid. + if (DROID_AGENT_NAME_RE.test(title)) { + return 'Droid' + } + // Why: synthesized "⠋ Hermes" working titles need to be matched before + // Claude's generic braille-spinner heuristic. + if (HERMES_AGENT_NAME_RE.test(title)) { + return 'Hermes' + } + if (isClaudeAgent(title)) { + return 'Claude Code' + } + + return null +} + // Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for // agents whose foreground PROCESS name isn't self-identifying (Claude Code runs // as `node`, but its "✳ Claude Code" title resolves here). Agents whose process @@ -24,16 +211,6 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OMP: 'omp' } -function containsBrailleSpinner(title: string): boolean { - for (const char of title) { - const codePoint = char.codePointAt(0) - if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) { - return true - } - } - return false -} - function hasGenericClaudeStatusPrefix(title: string): boolean { return ( containsBrailleSpinner(title) || diff --git a/src/shared/terminal-title-display.ts b/src/shared/terminal-title-display.ts new file mode 100644 index 000000000..a74678e34 --- /dev/null +++ b/src/shared/terminal-title-display.ts @@ -0,0 +1,99 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' +import { + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + isGeminiTerminalTitle, + isPiAgentTitle +} from './terminal-title-agent-type' +import { + detectAgentStatusFromTitle, + STRONG_WORKING_KEYWORDS_RE_GLOBAL +} from './terminal-title-status' + +function containsAgentName(title: string): boolean { + return ( + titleHasAnyLegacyAgentName(title) || + AGY_AGENT_NAME_RE.test(title) || + DROID_AGENT_NAME_RE.test(title) || + HERMES_AGENT_NAME_RE.test(title) + ) +} + +/** + * Strip working-status indicators from a title so that + * `detectAgentStatusFromTitle` will no longer return 'working'. + * Used to clear stale titles when an agent exits without resetting its title. + */ +export function clearWorkingIndicators(title: string): string { + let cleaned = title + + // Gemini working symbols + cleaned = cleaned.replace(GEMINI_WORKING, '') + cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') + + // Braille spinner characters (U+2800–U+28FF) + // eslint-disable-next-line no-control-regex -- intentional unicode range + cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') + + // Claude Code ". " working prefix + if (cleaned.startsWith('. ')) { + cleaned = cleaned.slice(2) + } + + // Strip working keywords that detectAgentStatusFromTitle would pick up + // when the title also contains an agent name. + if (containsAgentName(cleaned)) { + cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') + } + + // Collapse whitespace after removals + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() + + return cleaned || title +} + +/** + * Normalize high-churn agent titles into stable display labels before storing + * them in app state. Gemini CLI can emit per-keystroke title updates, which + * otherwise causes broad rerenders and visible flashing. + */ +export function normalizeTerminalTitle(title: string): string { + if (!title) { + return title + } + + if (isGeminiTerminalTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'permission') { + return `${GEMINI_PERMISSION} Gemini CLI` + } + if (status === 'working') { + return `${GEMINI_WORKING} Gemini CLI` + } + if (status === 'idle') { + return `${GEMINI_IDLE} Gemini CLI` + } + } + + // Why: Pi's titlebar extension animates every 80ms with different braille + // frames. Collapsing those frames into one stable label avoids renderer + // churn while preserving the working/idle transition Orca keys off. + if (isPiAgentTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'working') { + return '\u280b Pi' + } + if (status === 'idle') { + return 'Pi' + } + } + + return title +} diff --git a/src/shared/terminal-title-status.ts b/src/shared/terminal-title-status.ts new file mode 100644 index 000000000..5d8cd0a45 --- /dev/null +++ b/src/shared/terminal-title-status.ts @@ -0,0 +1,230 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' +import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' +import { + CLAUDE_IDLE, + containsBrailleSpinner, + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + isClaudeManagementTitle, + isPiTerminalTitle +} from './terminal-title-agent-type' + +export type AgentStatus = 'working' | 'permission' | 'idle' + +// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles +// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'. +// `as const` so consumers receive literal-union types. +const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const + +// Why: working keywords used inside `detectAgentStatusFromTitle` to map +// titles like "Codex working", "Aider thinking", "OpenCode running" to +// AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay +// in lock-step when stripping working indicators from stale titles. +const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const + +// Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that +// would make the "keyword" part of a larger token. Plain `\b` alone is +// insufficient because `-` is a non-word character in JS regex, so `\bready\b` +// still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and +// `r`). +// +// Lookarounds are intentionally ASYMMETRIC: +// - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`, +// Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle +// signal by having the agent name sit earlier in the same path and the +// keyword land right after a path separator. Orca is a cross-platform +// Electron app, so Windows path separators must be handled too. +// - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like +// "Codex done." / "Aider idle." / "OpenCode ready!" still match — path +// separators after the keyword are not a false-positive vector in +// practice and blocking them would regress trailing-punctuation titles. +// +// Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain +// substring false positives ("already"/"redone"/"idleness"). +export const STRONG_IDLE_KEYWORDS_RE = new RegExp( + `(? lower.includes(word)) +} + +/** + * Tracks agent status transitions from terminal title changes. + * Fires `onBecameIdle` when an agent transitions from working to idle/permission, + * like haunt's attention flag — the key trigger for unread notifications. + */ +export function createAgentStatusTracker( + onBecameIdle: (title: string) => void, + onBecameWorking?: () => void, + onAgentExited?: () => void +): { + handleTitle: (title: string) => void + /** Clear accumulated status so a stale working→idle transition cannot fire + * after the owning transport is torn down. */ + reset: () => void +} { + let lastStatus: AgentStatus | null = null + + return { + handleTitle(title: string): void { + const newStatus = detectAgentStatusFromTitle(title) + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle(title) + } + if (lastStatus !== 'working' && newStatus === 'working') { + onBecameWorking?.() + } + // Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"), + // detectAgentStatusFromTitle returns null. If we were idle or in a permission + // prompt, this means the user exited the agent — clear session-tied state + // (like the prompt-cache countdown). We intentionally do NOT fire this when + // lastStatus is 'working', because active agents can briefly flash shell + // titles during internal operations without actually exiting. + if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { + lastStatus = null + onAgentExited?.() + } + if (newStatus !== null) { + lastStatus = newStatus + } + }, + reset(): void { + lastStatus = null + } + } +} + +// Why: cursor-agent's native OSC title is the literal string "Cursor Agent" +// across the entire turn — it carries zero working/idle information. Orca +// synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor - +// action required" for permission) from cursor's hook events; the bare +// native title must be a no-op so cursor's per-turn re-emissions cannot +// stomp the synthesized state back to idle. +const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' + +export function detectAgentStatusFromTitle(title: string): AgentStatus | null { + if (!title) { + return null + } + if (isClaudeManagementTitle(title)) { + return null + } + // Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's + // native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor - + // action required") is either an Orca-synthesized working/permission title + // or a tighter match worth classifying. + if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { + return null + } + + // Gemini CLI symbols are the most specific and should take precedence. + if (title.includes(GEMINI_PERMISSION)) { + return 'permission' + } + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { + return 'working' + } + if (title.includes(GEMINI_IDLE)) { + return 'idle' + } + + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader + // Pi and braille-spinner checks below. + const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) + if (piCompatibleSyntheticAgentStatus) { + return piCompatibleSyntheticAgentStatus + } + + // Claude Code uses ✳ prefix for idle — must check before braille/agent-name + // because the title text is the task description, not "Claude Code". + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return 'idle' + } + + if (isPiTerminalTitle(title)) { + return 'idle' + } + + if (containsBrailleSpinner(title)) { + return 'working' + } + + const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) + const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) + const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) + const hasLegacyAgentName = titleHasAnyLegacyAgentName(title) + if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) { + if (containsAny(title, ['action required', 'permission', 'waiting'])) { + return 'permission' + } + // Why: hyphen/word-char-aware boundary match (not plain substring, and + // stricter than `\b` — which treats `-` as a boundary) so titles like + // "~/codex already built" do not classify as idle via the substring + // "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment. + if (STRONG_IDLE_KEYWORDS_RE.test(title)) { + return 'idle' + } + // Why: hyphen/word-char-aware boundary match (not plain substring, and + // stricter than `\b`) so titles like "~/codex reworking diff" or + // "is-thinking-cap" do not classify as working via the substrings + // "reworking" ⊃ "working" or the `-`-adjacent "thinking" in + // "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a + // false 'working' is worse than a false 'idle' because it drives + // active-agent UI (spinners, counts). + if (STRONG_WORKING_KEYWORDS_RE.test(title)) { + return 'working' + } + + // Claude Code title prefixes: ". " = working, "* " = idle + if (title.startsWith('. ')) { + return 'working' + } + if (title.startsWith('* ')) { + return 'idle' + } + + // Why: Factory Droid can publish native titles like "Factory Droid needs + // input" while an Execute tool is still sleeping. Droid's hook events are + // authoritative; don't turn a name-only native title into a completion. + if (hasDroidAgentName && !hasLegacyAgentName) { + return null + } + + return 'idle' + } + + return null +}