import { AGY_AGENT_NAME_RE, BRAILLE_SPINNER_RE, CLAUDE_IDLE, CURSOR_NATIVE_TITLE_LOWER, DROID_AGENT_NAME_RE, GEMINI_IDLE, GEMINI_PERMISSION, GEMINI_SILENT_WORKING, GEMINI_WORKING, HERMES_AGENT_NAME_RE, STRONG_IDLE_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE_GLOBAL, containsAgentName, containsAny, containsBrailleSpinner, containsLegacyAgentName, isClaudeManagementTitle, isGeminiTerminalTitle, isPiAgentTitle, isPiTerminalTitle } from './agent-title-core' import type { AgentStatus } from './agent-title-core' import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' /** * Strip working-status indicators so stale exit titles stop reporting working. */ export function clearWorkingIndicators(title: string): string { let cleaned = title cleaned = cleaned.replace(GEMINI_WORKING, '') cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '') if (cleaned.startsWith('. ')) { cleaned = cleaned.slice(2) } if (containsAgentName(cleaned)) { cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') } cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() return cleaned || title } /** * Tracks agent status transitions from terminal title changes. */ export function createAgentStatusTracker( onBecameIdle: (title: string) => void, onBecameWorking?: () => void, onAgentExited?: () => void, initialTitle?: string ): { handleTitle: (title: string) => void seedTitle: (title: string) => void reset: () => void } { // Why: trackers restored mid-session need a last-known status without firing // callbacks, or a hidden working agent can miss its later idle transition. let lastStatus: AgentStatus | null = initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : 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: reverting to a plain shell prompt after idle/permission means the // agent exited; while working it can just be a transient internal title. if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { lastStatus = null onAgentExited?.() } if (newStatus !== null) { lastStatus = newStatus } }, seedTitle(title: string): void { lastStatus = detectAgentStatusFromTitle(title) }, reset(): void { lastStatus = null } } } /** * Normalize high-churn agent titles into stable display labels before storage. */ 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 animates every 80ms; collapse frames while preserving status. if (isPiAgentTitle(title)) { const status = detectAgentStatusFromTitle(title) if (status === 'working') { return '\u280b Pi' } if (status === 'idle') { return 'Pi' } } // Why: Grok Build interpolates a rotating status/tool phrase between the // spinner and its name, so its working frames change the title many times per // turn. Collapse them to one stable label; idle/session titles carry no // spinner and pass through, so the meaningful final title still shows (#7863). if (isGrokRotatingWorkingTitle(title)) { return '\u280b Grok' } return title } export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title || isClaudeManagementTitle(title)) { return null } if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { return null } 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 } 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 = containsLegacyAgentName(title) if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) { return null } if (containsAny(title, ['action required', 'permission', 'waiting'])) { return 'permission' } // Why: boundary-aware regexes avoid cwd/path and substring false positives. if (STRONG_IDLE_KEYWORDS_RE.test(title)) { return 'idle' } if (STRONG_WORKING_KEYWORDS_RE.test(title)) { return 'working' } if (title.startsWith('. ')) { return 'working' } if (title.startsWith('* ')) { return 'idle' } // Why: Droid hook events are authoritative; native name-only titles should // not turn a still-sleeping execute tool into completion. if (hasDroidAgentName && !hasLegacyAgentName) { return null } return 'idle' }