diff --git a/src/renderer/src/lib/tab-agent-from-signals.ts b/src/renderer/src/lib/tab-agent-from-signals.ts index 383edba70070..91efeb4ecd77 100644 --- a/src/renderer/src/lib/tab-agent-from-signals.ts +++ b/src/renderer/src/lib/tab-agent-from-signals.ts @@ -1,4 +1,5 @@ import { titleShowsNoAgent } from '../../../shared/agent-detection' +import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence' import { isClaudeIdentityFrameTitle, resolveExplicitTerminalTitleAgentType @@ -58,6 +59,12 @@ export function resolveLaunchedAgentExitEvidence(args: { return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal) } +// Why: a name only in free text (task wording) is a mention, not identity, so it can't displace a known owner (#26513). +function titleEvidenceIsStrong(title: string): boolean { + const { reason } = collectAgentTitleEvidence(title) + return reason === 'anchored' || reason === 'vendor-marker' +} + /** * Identity-first precedence: live hook > process > title > completed > sleeping * > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence. @@ -128,7 +135,8 @@ export function resolveTabAgentFromSignals(args: { explicitTitleAgent !== priorIdentity && !shareCompatibleTitleIdentityGroup(rawTitleAgent, priorIdentity) && titleClaimsIdentity && - (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) + (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) && + titleEvidenceIsStrong(args.title) // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. const titleAgent = processProvesShell || diff --git a/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts b/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts new file mode 100644 index 000000000000..1a361683b794 --- /dev/null +++ b/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' + +const MENTION_ONLY_TITLE = '◑ Tab title showing Codex instead of Claude Code' +const GENUINE_CODEX_TITLE = 'Task - codex' + +const BASE = { isRemote: false, hasObservedAgentSignal: true } as const + +describe('title that only mentions another agent', () => { + it('is free-text evidence yet still parses as a codex title', () => { + expect(collectAgentTitleEvidence(MENTION_ONLY_TITLE)).toMatchObject({ + agent: null, + reason: 'free-text-only' + }) + expect(resolveExplicitTerminalTitleAgentType(MENTION_ONLY_TITLE)).toBe('codex') + }) + + it.each([ + ['live hook', { hookAgent: 'claude' }], + ['foreground process', { hookAgent: null, processAgent: 'claude' }], + ['completed hook', { hookAgent: null, focusedCompletedHookAgent: 'claude' }], + ['launch intent', { hookAgent: null, launchAgent: 'claude' }] + ] as const)('keeps claude from the %s', (_label, signals) => { + expect(resolveTabAgentFromSignals({ ...BASE, title: MENTION_ONLY_TITLE, ...signals })).toBe( + 'claude' + ) + }) +}) + +describe('title that genuinely names another agent', () => { + it('is anchored evidence for codex', () => { + expect(collectAgentTitleEvidence(GENUINE_CODEX_TITLE)).toMatchObject({ + agent: 'codex', + reason: 'anchored' + }) + }) + + it.each([ + ['completed hook', { hookAgent: null, focusedCompletedHookAgent: 'claude' }], + ['launch intent', { hookAgent: null, launchAgent: 'claude' }] + ] as const)('still replaces a stale %s identity', (_label, signals) => { + expect(resolveTabAgentFromSignals({ ...BASE, title: GENUINE_CODEX_TITLE, ...signals })).toBe( + 'codex' + ) + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts index f1e186d6f1bc..92032d44fc9f 100644 --- a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -336,11 +336,9 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { ).toBe('pi') }) - it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => { - // hasObservedAgentSignal starts false for one mount commit; a completed hook - // is itself activity evidence, so the reuse title reclaims immediately - // instead of flashing the prior agent's idle identity. (claude ran+idled, - // then a hookless codex reused the pane and emits its own title.) + it('keeps the completed identity over a free-text Codex title on mount', () => { + // A "⠋ Codex" title names Codex only in free text, so it must not replace the + // completed claude identity, neither on mount nor once activity is observed. const onMount = resolveTabAgentFromSignals({ hasObservedAgentSignal: false, isRemote: false, @@ -357,7 +355,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { focusedCompletedHookAgent: 'claude', launchAgent: undefined }) - expect(onMount).toBe('codex') - expect(afterObserved).toBe('codex') + expect(onMount).toBe('claude') + expect(afterObserved).toBe('claude') }) }) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 2eacce3e9312..75f9eefc5a05 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { isShellProcess } from '../../../shared/agent-detection' import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing' import { hasRemoteRuntimePtyForTab } from './tab-agent-remote-pty-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' @@ -12,168 +11,15 @@ import { resolveSiblingRetainedTabAgent, resolveSiblingTabAgent } from './tab-agent' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import { - isClaudeIdentityFrameTitle, - resolveExplicitTerminalTitleAgentType -} from '../../../shared/terminal-title-agent-type' -import { resolveSignalAgentForLaunchOwner } from './tab-agent-from-signals' -import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' -import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' + resolveLaunchedAgentExitEvidence, + resolveTabAgentFromSignals +} from './tab-agent-from-signals' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TerminalAgent } from '../../../shared/terminal-agent' -import type { TuiAgent } from '../../../shared/tui-agent' -import { agentTypeToIconAgent } from './agent-status' -// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence. -function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { - const trimmed = title.trim() - return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) -} - -/** - * Probe-free evidence a launched agent exited: title shows no agent, no live - * hook remains, and either the hook completed or observed activity vanished. - * Vanished-activity is local-only — remote rows also drop on transport blips. - */ -export function resolveLaunchedAgentExitEvidence(args: { - title: string - defaultTitle?: string - isRemote: boolean - hasObservedAgentSignal: boolean - hookAgent: TerminalAgent | null - siblingHookAgent?: TerminalAgent | null - hasCompletedHook: boolean - processAgent?: TerminalAgent | null - processShellForeground?: boolean -}): boolean { - if (args.hookAgent || args.siblingHookAgent || args.processAgent) { - return false - } - // Why: OSC 133;D (foreground back at shell) is title-independent exit evidence; local-only — remote panes have no shell-foreground producer. - if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) { - return true - } - if (!titleShowsNoAgent(args.title, args.defaultTitle)) { - return false - } - return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal) -} - -export function resolveTabAgentFromSignals(args: { - hasObservedAgentSignal: boolean - isRemote: boolean - title: string - defaultTitle?: string - hookAgent: TerminalAgent | null - siblingHookAgent?: TerminalAgent | null - focusedCompletedHookAgent?: TerminalAgent | null - siblingCompletedHookAgent?: TerminalAgent | null - processAgent?: TerminalAgent | null - processShellForeground?: boolean - sleepingSessionAgent?: TerminalAgent | null - launchAgent?: TuiAgent -}): TerminalAgent | null { - const launchAgent = args.launchAgent ?? null - // Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP). - const owner = agentTypeToIconAgent( - resolvePaneAgentOwner({ - launchAgent, - hookAgent: args.hookAgent, - completedHookAgent: args.focusedCompletedHookAgent, - sleepingSessionAgent: args.sleepingSessionAgent - }) - ) - - // The live/idle split governs title override; siblings normalize against launch intent only. - const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner) - const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) - // Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there. - const processProvesShell = !args.isRemote && args.processShellForeground === true - const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null - const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle) - const idleIdentitySuppressed = - !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook - const idleFocusedIdentity = idleIdentitySuppressed - ? null - : resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner) - // Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity. - const idleSiblingIdentity = resolveSignalAgentForLaunchOwner( - args.siblingCompletedHookAgent, - launchAgent - ) - const sleepingSessionAgent = args.sleepingSessionAgent ?? null - - // Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins. - const explicitTitleAgent = resolveSignalAgentForLaunchOwner( - resolveExplicitTerminalTitleAgentType(args.title), - owner - ) - const priorIdentity = idleFocusedIdentity ?? launchAgent - const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title) - // Why: a "claude" token in another agent's task text is a mention, not identity, so it must - // not take a pane from its known owner — only a title that PRESENTS Claude may (#8940). - const titleClaimsIdentity = - explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title) - // Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal. - const titleReclaimsReusedPane = - priorIdentity !== null && - explicitTitleAgent !== null && - explicitTitleAgent !== priorIdentity && - titleClaimsIdentity && - (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) - // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. - const titleAgent = - processProvesShell || - sleepingSessionAgent || - (nativeOpenCodeTitle && idleFocusedIdentity !== null) - ? null - : titleReclaimsReusedPane - ? explicitTitleAgent - : priorIdentity - ? null - : explicitTitleAgent - - const launchedAgentExited = resolveLaunchedAgentExitEvidence({ - title: args.title, - defaultTitle: args.defaultTitle, - isRemote: args.isRemote, - hasObservedAgentSignal: args.hasObservedAgentSignal, - hookAgent: liveFocusedIdentity, - siblingHookAgent: liveSiblingIdentity, - hasCompletedHook, - processAgent: args.processAgent, - processShellForeground: args.processShellForeground - }) - const activeLaunchAgent = launchedAgentExited ? null : launchAgent - // Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon. - const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner) - // Identity-first precedence (see JSDoc): live hook > process > title > completed > sleeping > launch > sibling. - return ( - liveFocusedIdentity ?? - processAgent ?? - titleAgent ?? - idleFocusedIdentity ?? - sleepingSessionAgent ?? - activeLaunchAgent ?? - liveSiblingIdentity ?? - idleSiblingIdentity - ) -} - -/** - * Resolve which coding-harness agent a terminal tab is running, for its tab-bar - * icon. A pane's IDENTITY (separate from activity state), from the same - * already-computed state as the sidebar rows — no foreground probing. - * Identity-first precedence: - * - * 1. Live focused hook — ground truth while the agent works; never title-overridden. - * 2. Process identity — recognized foreground process (local only); re-owned within its title-identity group so OMP's nested `pi` (shell → omp → pi) can't flip the icon. - * 3. Title — only a reuse override or legacy standalone identity; native OpenCode titles cannot displace durable ownership. - * 4. Idle focused identity — the pane's completed hook or sidebar-retained completion; suppressed locally once OSC 133;D proves exit. - * 5. Sleeping session identity — current provider-session ownership. - * 6. launchAgent — bootstrap before any hook/process signal; cleared once exit evidence shows it left. - * 7. Sibling-pane identity (live, then completed/retained) — split-tab fallback. - */ +/** Resolve which coding-harness agent a terminal tab is running, for its tab-bar icon; precedence lives on resolveTabAgentFromSignals. */ export function useTabAgent(tab: TerminalTab): TerminalAgent | null { const focusedHookAgent = useAppStore((s) => resolveFocusedTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)