From 5063d3c850f893574758b4295438d8e5d73784f5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:41:07 -0700 Subject: [PATCH 1/3] Fix hidden synchronized output state --- .../src/components/terminal-pane/pty-connection.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 42178124a25..a84e2bef756 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -791,6 +791,7 @@ export function connectPanePty( let pendingTerminalBellNotification = false let reattachIdleAgentCursorResetTimer: ReturnType | null = null let synchronizedForegroundOutputActive = false + let synchronizedHiddenOutputActive = false // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -3017,6 +3018,12 @@ export function connectPanePty( } writePtyOutputToXterm(rendererData, foreground) } + if (!foreground) { + synchronizedHiddenOutputActive = shouldSynchronizedOutputRemainActive( + data, + synchronizedHiddenOutputActive + ) + } schedulePendingStartupCommandDelivery() } From 0ef85e777cb46f1ae9ebb99a6194052aed9b603c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:44:11 -0700 Subject: [PATCH 2/3] Split shared agent title detection --- src/shared/agent-detection.ts | 542 ++--------------------------- src/shared/agent-title-core.ts | 104 ++++++ src/shared/agent-title-identity.ts | 98 ++++++ src/shared/agent-title-status.ts | 186 ++++++++++ src/shared/terminal-osc-title.ts | 33 ++ 5 files changed, 442 insertions(+), 521 deletions(-) create mode 100644 src/shared/agent-title-core.ts create mode 100644 src/shared/agent-title-identity.ts create mode 100644 src/shared/agent-title-status.ts create mode 100644 src/shared/terminal-osc-title.ts diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index c1d2880b42c..d75e74e0c7c 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -1,529 +1,29 @@ /** - * Shared agent detection utilities — used by both the main process (stats - * collection) and the renderer (activity indicators, unread badges). + * Compatibility barrel for shared terminal agent-title detection. * - * 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. + * Why shared: main and renderer both consume OSC titles for facts, stats, and + * UI state. Keep existing imports stable while the implementation stays split + * into focused modules that satisfy max-lines. */ -import { - AGY_AGENT_NAME_RE, - DROID_AGENT_NAME_RE, - HERMES_AGENT_NAME_RE, - titleHasAgentName, - titleHasAnyLegacyAgentName -} from './agent-name-token-match' +export type { AgentStatus } from './agent-title-core' +export { + isClaudeManagementTitle, + isCursorNativeAgentTitle, + isGeminiTerminalTitle, + isPiTerminalTitle, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE +} from './agent-title-core' +export { getAgentLabel, isClaudeAgent } from './agent-title-identity' +export { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + normalizeTerminalTitle +} from './agent-title-status' +export { extractAllOscTitles, extractLastOscTitle } from './terminal-osc-title' // Re-export so existing `agent-detection` importers keep working. export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' 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 containsLegacyAgentName(title: string): boolean { - return titleHasAnyLegacyAgentName(title) -} - -function containsAgentName(title: string): boolean { - return ( - containsLegacyAgentName(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, - initialTitle?: string -): { - handleTitle: (title: string) => void - /** Clear accumulated status so a stale working→idle transition cannot fire - * after the owning transport is torn down. */ - reset: () => void -} { - // Why: trackers that start mid-session (parked-tab byte watchers) must seed - // the last known status, or an agent that was working when its pane - // unmounted never produces a working→idle transition. Seeding sets state - // only — no callbacks fire. - 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: 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 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, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { - return 'Antigravity' - } - if (titleHasAgentName(title, 'opencode')) { - return 'OpenCode' - } - 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' - -/** - * True for cursor-agent's bare native title ("Cursor Agent", trimmed, - * case-insensitive). Title trackers drop it before it reaches stored state so - * cursor's per-turn re-emissions cannot stomp Orca's synthesized spinner - * titles. Anything with additional tokens ("⠋ Cursor Agent") passes through. - */ -export function isCursorNativeAgentTitle(title: string): boolean { - return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER -} - -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' - } - - // 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 = containsLegacyAgentName(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 -} diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts new file mode 100644 index 00000000000..295a3a89e8c --- /dev/null +++ b/src/shared/agent-title-core.ts @@ -0,0 +1,104 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAgentName, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' + +export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } + +export type AgentStatus = 'working' | 'permission' | 'idle' + +export const CLAUDE_IDLE = '\u2733' // ✳ +const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?` +export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp( + String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\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' // ✋ + +const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const +const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const + +// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as +// "~/codex/ready"; the left side also blocks path separators for Windows/Unix. +export const STRONG_IDLE_KEYWORDS_RE = new RegExp( + `(?= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function containsLegacyAgentName(title: string): boolean { + return titleHasAnyLegacyAgentName(title) +} + +export function containsAgentName(title: string): boolean { + return ( + containsLegacyAgentName(title) || + AGY_AGENT_NAME_RE.test(title) || + DROID_AGENT_NAME_RE.test(title) || + HERMES_AGENT_NAME_RE.test(title) + ) +} + +export function containsAny(title: string, words: readonly string[]): boolean { + const lower = title.toLowerCase() + return words.some((word) => lower.includes(word)) +} + +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + +export function isCursorNativeAgentTitle(title: string): boolean { + return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts new file mode 100644 index 00000000000..dd8dfbff5c7 --- /dev/null +++ b/src/shared/agent-title-identity.ts @@ -0,0 +1,98 @@ +import { + AGY_AGENT_NAME_RE, + CLAUDE_IDLE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + containsBrailleSpinner, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + titleHasAgentName +} from './agent-title-core' + +/** + * Returns true when the terminal title matches Claude Code's title conventions. + * Used to scope prompt-cache-timer behavior to Claude sessions only. + */ +export function isClaudeAgent(title: string): boolean { + if (!title || isClaudeManagementTitle(title)) { + return false + } + const lower = title.toLowerCase() + + // Why: Claude title prefixes are stronger than task text, which can mention + // other agents without changing the owning CLI. + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return true + } + if (title.startsWith('. ') || title.startsWith('* ')) { + return true + } + if (containsBrailleSpinner(title)) { + return !lower.includes('cursor') && !lower.includes('openclaude') + } + + const trimmedTitle = title.trimStart() + return ( + trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude') + ) +} + +export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } + // Why: Claude task titles can mention another CLI; the prefix is the identity + // signal, not arbitrary task text. + if ( + title.startsWith(`${CLAUDE_IDLE} `) || + title === CLAUDE_IDLE || + title.startsWith('. ') || + title.startsWith('* ') + ) { + return 'Claude Code' + } + if (isGeminiTerminalTitle(title)) { + return 'Gemini CLI' + } + if (isPiAgentTitle(title)) { + return 'Pi' + } + + 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, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return 'Antigravity' + } + if (titleHasAgentName(title, 'opencode')) { + return 'OpenCode' + } + if (titleHasAgentName(title, 'aider')) { + return 'Aider' + } + // Why: match explicit names before Claude's generic braille heuristic. + if (titleHasAgentName(title, 'cursor')) { + return 'Cursor' + } + if (DROID_AGENT_NAME_RE.test(title)) { + return 'Droid' + } + if (HERMES_AGENT_NAME_RE.test(title)) { + return 'Hermes' + } + if (isClaudeAgent(title)) { + return 'Claude Code' + } + + return null +} diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts new file mode 100644 index 00000000000..8015db7469b --- /dev/null +++ b/src/shared/agent-title-status.ts @@ -0,0 +1,186 @@ +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' + +/** + * 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' + } + } + + 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' + } + + 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' +} diff --git a/src/shared/terminal-osc-title.ts b/src/shared/terminal-osc-title.ts new file mode 100644 index 00000000000..c6a20e05c40 --- /dev/null +++ b/src/shared/terminal-osc-title.ts @@ -0,0 +1,33 @@ +// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching +const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g + +/** + * Extract the last OSC title-set sequence from raw PTY data. + * Agent CLIs set OSC titles to announce identity and status. + */ +export function extractLastOscTitle(data: string): string | null { + if (!data.includes('\x1b]')) { + return null + } + let last: string | null = null + for (const m of data.matchAll(OSC_TITLE_RE)) { + last = m[2] + } + return last +} + +/** + * Extract all OSC title-set sequences from raw PTY data, in order. + * Why separate from extractLastOscTitle: coalesced PTY chunks can contain both + * working and idle transitions, and UI status trackers need each title. + */ +export function extractAllOscTitles(data: string): string[] { + if (!data.includes('\x1b]')) { + return [] + } + const titles: string[] = [] + for (const m of data.matchAll(OSC_TITLE_RE)) { + titles.push(m[2]) + } + return titles +} From c4580fe1aa4d760215ecd8b1a3f197963c0d62f0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:46:40 -0700 Subject: [PATCH 3/3] Add hidden startup query window helper --- src/renderer/src/components/terminal-pane/pty-connection.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 96467390bc7..742e5bba600 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2441,6 +2441,10 @@ export function connectPanePty( // hidden xterm is dirty, skipping is safer than sending stale CPR/DECRQM. } + function isHiddenStartupRendererQueryWindowActive(): boolean { + return shouldSnapshotHiddenCodexOutput && hiddenStartupRendererQueryPending.length > 0 + } + function takeHiddenStartupRendererQueryPendingForForeground(data: string): { statelessQueryData: string statefulQueryData: string