diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 6fcd0a6719c..1d24f651984 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -202,6 +202,62 @@ describe('buildTitleDerivedAgentRows', () => { } }) + // Why: cline 3.0.55 writes OSC 0 `Cline` and posts no hooks, so the title path is the + // only thing that can put the pane in the sidebar at all (STA-3906 / #13823). + it('adds an idle row for a registry-declared agent that only writes its own name', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { title: 'Cline' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Cline' } }, + ptyIdsByTabId: { 'tab-1': ['pty-cline'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual([ + ['cline', 'idle', 'Idle'] + ]) + expect(rows[0]?.paneKey).toBe(makePaneKey('tab-1', LEAF_ID_1)) + }) + + it('does not invent a Cline row from Claude task text that mentions cline', () => { + const unowned = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ use cline for the sidebar fix' } }, + ptyIdsByTabId: { 'tab-1': ['pty-claude-task'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + expect(unowned).toHaveLength(0) + + const owned = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'claude' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ use cline for the sidebar fix' } }, + ptyIdsByTabId: { 'tab-1': ['pty-claude-task'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + expect(owned.map((row) => row.agentType)).toEqual(['claude']) + }) + + it('keeps a cline-named worktree path out of the sidebar', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { title: '~/orca/workspaces/cline-scratch' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: '~/orca/workspaces/cline-scratch' } }, + ptyIdsByTabId: { 'tab-1': ['pty-shell'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + expect(rows).toHaveLength(0) + }) + it('attributes a spinner-only title to the launched agent when the title has no identity', () => { const launchAgent: TuiAgent = 'codex' const rows = buildWorktreeAgentRows({ diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 70289b4b3fb..8d48ae6ff21 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -21,6 +21,7 @@ import { } from '../../../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' +import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' const EMPTY_RUNTIME_TITLES: Record> = {} const EMPTY_LIVE_PTY_IDS: Record = {} @@ -44,6 +45,12 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record = { OMP: 'omp' } +// Why: labels that already equal an agent's canonical display name need no second +// table — registry-declared agents (agent-identity-frame.ts) resolve straight through. +const DISPLAY_NAME_TO_AGENT_TYPE: Record = Object.fromEntries( + ALL_TUI_AGENTS.map((agent) => [TUI_AGENT_DISPLAY_NAMES[agent], agent as AgentType]) +) + const CLAUDE_AGENT_TOKEN_RE = /(? { + it('claims a title that is only the agent name, decorated or multiplexer-wrapped', () => { + for (const title of ['Cline', 'cline', 'cline.cmd', 'cline.exe', '⠋ Cline', 'zsh | Cline']) { + expect(resolveAgentIdentityFrameType(title)).toBe('cline') + expect(isAgentIdentityFrameTitleFor(title, 'cline')).toBe(true) + } + }) + + it('rejects the name inside task text, a path, or a hyphenated compound', () => { + for (const title of [ + '⠋ use cline for the sidebar fix', + 'port the cline prompt', + '~/cline-scratch', + 'cline-rules', + 'src/cline/index.ts', + 'clinent' + ]) { + expect(resolveAgentIdentityFrameType(title)).not.toBe('cline') + expect(isAgentIdentityFrameTitleFor(title, 'cline')).toBe(false) + } + }) + + it('accepts at most one status word, never free-form trailing text', () => { + expect(isAgentIdentityFrameTitleFor('Cline ready', 'cline')).toBe(true) + expect(isAgentIdentityFrameTitleFor('Cline - action required', 'cline')).toBe(true) + expect(isAgentIdentityFrameTitleFor('Cline working on the parser', 'cline')).toBe(false) + expect(isAgentIdentityFrameTitleFor('Cline ready idle', 'cline')).toBe(false) + }) + + it('is not declared for agents whose names are too common to anchor on', () => { + // Why: `amp`/`pi`/`cn` would claim ordinary shell titles; the registry stays observed-only. + expect(AGENT_IDENTITY_FRAMES.amp).toBeUndefined() + expect(resolveAgentIdentityFrameType('amp')).toBeNull() + expect(resolveAgentIdentityFrameType('crush')).toBeNull() + }) + + // Why: this is the whole point of the registry — a new runtime is data, not five call sites. + it('builds the same frame shape for any declared name', () => { + const re = buildAgentIdentityFrameRe({ names: ['widgetcli'], executableSuffix: true }) + expect(re.test('widgetcli')).toBe(true) + expect(re.test('widgetcli.exe')).toBe(true) + expect(re.test('widgetcli - action required')).toBe(true) + expect(re.test('use widgetcli here')).toBe(false) + }) +}) + +describe('Cline title visibility (STA-3906 / #13823)', () => { + // Observed against cline 3.0.55: the CLI emits OSC 0 `Cline` and never varies it. + it('resolves the live Cline title to identity, label, and an idle status', () => { + expect(getAgentLabel('Cline')).toBe('Cline') + expect(resolveTerminalTitleAgentType('Cline')).toBe('cline') + expect(resolveExplicitTerminalTitleAgentType('Cline')).toBe('cline') + expect(detectAgentStatusFromTitle('Cline')).toBe('idle') + }) + + it('maps decorated Cline frames to status without inventing status from mentions', () => { + expect(detectAgentStatusFromTitle('Cline ready')).toBe('idle') + expect(detectAgentStatusFromTitle('Cline working')).toBe('working') + expect(detectAgentStatusFromTitle('Cline - action required')).toBe('permission') + expect(detectAgentStatusFromTitle('⠋ Cline')).toBe('working') + for (const title of ['port the cline prompt', '~/cline-scratch', 'cline-rules']) { + expect(detectAgentStatusFromTitle(title)).toBeNull() + } + }) + + it('keeps Claude task text that mentions cline as Claude', () => { + expect(getAgentLabel('⠋ use cline for the sidebar fix')).toBe('Claude Code') + expect(resolveTerminalTitleAgentType('⠋ use cline for the sidebar fix')).toBe('claude') + // Why: the spinner still proves activity; the mention must not rebrand the pane. + expect(detectAgentStatusFromTitle('⠋ use cline for the sidebar fix')).toBe('working') + }) +}) + +// Why: the Claude frame moved onto the shared builder — pin the behavior it had before. +describe('isClaudeIdentityFrameTitle after the shared-builder move', () => { + it('accepts Claude frames and rejects Claude mentions', () => { + for (const title of [ + 'Claude', + 'claude code', + 'Claude Code', + '✳ Claude Code', + 'Claude ready', + 'Claude Code - action required', + 'zsh | ⠋ Claude Code' + ]) { + expect(isClaudeIdentityFrameTitle(title)).toBe(true) + } + for (const title of [ + '⠋ ask claude about the parser', + 'claude-scratch', + '~/claude/worktrees', + 'Claude Code review the diff' + ]) { + expect(isClaudeIdentityFrameTitle(title)).toBe(false) + } + }) +}) diff --git a/src/shared/agent-identity-frame.ts b/src/shared/agent-identity-frame.ts new file mode 100644 index 00000000000..eb451dcdcaa --- /dev/null +++ b/src/shared/agent-identity-frame.ts @@ -0,0 +1,92 @@ +/** + * One shape for "this title PRESENTS agent X", shared by every consumer that + * turns an OSC title into agent identity. + * + * Why a registry and not another per-agent predicate: an identity frame is the + * agent's own name plus, at most, one status word. That shape was already + * hand-copied per agent (Claude here, Pi/OMP in pi-compatible-synthetic-title, + * Cursor's closed literal set in agent-title-core), so every new runtime meant a + * new regex threaded through five call sites. Declaring the names instead keeps + * the next runtime a one-row change. + * + * Why frames and not name tokens: a name inside free-form task text is a + * mention, not identity, and must never take a pane away from its owner + * (#8940). Anchoring the whole title is what separates the two. + */ +import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' +import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' +import type { TuiAgent } from './tui-agent' + +/** The status words agents append to their own name — Orca's synthetic titles + * use the same vocabulary (synthetic-agent-title.ts), so a frame must accept both. */ +const IDENTITY_STATUS_WORDS = ['ready', 'idle', 'done', 'working', 'thinking', 'running'] as const + +// Why: Windows titles can surface the launcher process name (`cline.cmd`). +const EXECUTABLE_SUFFIX = String.raw`(?:\.(?:exe|cmd|bat|ps1))?` + +export type AgentIdentityFrameSpec = { + /** Lowercase names the agent presents itself under, longest first. */ + names: readonly string[] + /** Accept a Windows launcher extension after the name. */ + executableSuffix?: boolean +} + +export function buildAgentIdentityFrameRe(spec: AgentIdentityFrameSpec): RegExp { + const names = spec.names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + const suffix = spec.executableSuffix ? EXECUTABLE_SUFFIX : '' + return new RegExp( + `^(?:${names})${suffix}(?:\\s+(?:${IDENTITY_STATUS_WORDS.join('|')}))?(?:\\s*-\\s*action required)?$` + ) +} + +/** + * Agents identified by the title they write about themselves. Only add a runtime + * here once its CLI is observed emitting the title — an unobserved name widens + * the false-positive surface for every ordinary shell title. + */ +export const AGENT_IDENTITY_FRAMES: Partial> = { + claude: { names: ['claude code', 'claude'] }, + // Verified against cline 3.0.55: it emits OSC 0 `Cline` and never varies it, + // so Orca sees identity but no status transitions (STA-3906). + cline: { names: ['cline'], executableSuffix: true } +} + +const IDENTITY_FRAME_RES = new Map( + Object.entries(AGENT_IDENTITY_FRAMES).map(([agent, spec]) => [ + agent as TuiAgent, + buildAgentIdentityFrameRe(spec) + ]) +) + +/** + * Whether `title` presents `agent`, once multiplexer prefixes and leading status + * decoration are stripped. + * + * Why segments: a multiplexer prefix (`zsh | ⠋ Claude Code`) would otherwise read + * as task text and cost a genuine agent pane its identity. + */ +export function isAgentIdentityFrameTitleFor( + title: string | null | undefined, + agent: TuiAgent +): boolean { + const re = IDENTITY_FRAME_RES.get(agent) + if (!re || typeof title !== 'string') { + return false + } + return getWrapperTitleSegments(title).some((segment) => + re.test(stripLeadingAgentTitleDecorationOrEmpty(segment).trim().toLowerCase()) + ) +} + +/** The agent a title presents, or null when the title is task text, a path, or a bare shell title. */ +export function resolveAgentIdentityFrameType(title: string | null | undefined): TuiAgent | null { + if (typeof title !== 'string') { + return null + } + for (const agent of IDENTITY_FRAME_RES.keys()) { + if (isAgentIdentityFrameTitleFor(title, agent)) { + return agent + } + } + return null +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index cdfc6946777..15f0d65d7dd 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -10,8 +10,10 @@ import { isPiAgentTitle, titleHasAgentName } from './agent-title-core' +import { resolveAgentIdentityFrameType } from './agent-identity-frame' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' +import { TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' /** * Returns true when the terminal title matches Claude Code's title conventions. @@ -32,9 +34,15 @@ export function isClaudeAgent(title: string): boolean { return true } if (containsAgentSpinnerGlyph(title)) { - // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its - // identity title, not the token, so a Claude title mentioning a cursor stays Claude. - return !isCursorAgentTitle(title) && !lower.includes('openclaude') + // Why: named non-Claude agents carry braille spinners too. Gate them by their + // identity frame, not a name token, so Claude task text that merely mentions + // one (`⠋ use cline for the fix`) stays Claude. + const frameAgent = resolveAgentIdentityFrameType(title) + return ( + (frameAgent === null || frameAgent === 'claude') && + !isCursorAgentTitle(title) && + !lower.includes('openclaude') + ) } const trimmedTitle = title.trimStart() @@ -102,6 +110,12 @@ export function getAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'aider')) { return 'Aider' } + // Why: registry-declared agents identify themselves by frame only, so this must + // run before Claude's generic braille heuristic but after the token matches above. + const frameAgent = resolveAgentIdentityFrameType(title) + if (frameAgent && frameAgent !== 'claude') { + return TUI_AGENT_DISPLAY_NAMES[frameAgent] + } // Why: `cursor` is ordinary editor vocabulary, not identity. Match Cursor's closed // title set (mirrors @cursor routing), before `isClaudeAgent` claims the braille frame. if (isCursorAgentTitle(title)) { diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 5c13bb56e95..7e6cbbd6de7 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -1,3 +1,4 @@ +import { resolveAgentIdentityFrameType } from './agent-identity-frame' import { AGY_AGENT_NAME_RE, BRAILLE_SPINNER_RE, @@ -196,7 +197,16 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { 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) { + // Why: registry-declared agents are deliberately absent from AGENT_NAMES — their + // names are too common to token-match — so their whole-title frame is the evidence. + const hasIdentityFrame = resolveAgentIdentityFrameType(title) !== null + if ( + !hasLegacyAgentName && + !hasDroidAgentName && + !hasHermesAgentName && + !hasAgyAgentName && + !hasIdentityFrame + ) { return null } if (containsAny(title, ['action required', 'permission', 'waiting'])) { diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index c679933c88e..5080bfc61c1 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -4,14 +4,14 @@ import { HERMES_AGENT_NAME_RE, titleHasAgentName } from './agent-name-token-match' +import { isAgentIdentityFrameTitleFor, resolveAgentIdentityFrameType } from './agent-identity-frame' import { containsAgentSpinnerGlyph, isCursorAgentTitle } from './agent-title-core' -import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' import { isOpenCodeNativeTitle } from './opencode-terminal-title' -import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { TUI_AGENT_DISPLAY_NAMES, ALL_TUI_AGENTS } from './tui-agent-display-names' import type { TuiAgent } from './tui-agent' export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) @@ -97,9 +97,15 @@ export function isClaudeAgent(title: string): boolean { return true } if (containsAgentSpinnerGlyph(title)) { - // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its - // identity title, not the token, so a Claude title mentioning a cursor stays Claude. - return !isCursorAgentTitle(title) && !lower.includes('openclaude') + // Why: named non-Claude agents carry braille spinners too. Gate them by their + // identity frame, not a name token, so Claude task text that merely mentions + // one (`⠋ use cline for the fix`) stays Claude. + const frameAgent = resolveAgentIdentityFrameType(title) + return ( + (frameAgent === null || frameAgent === 'claude') && + !isCursorAgentTitle(title) && + !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 @@ -184,6 +190,12 @@ export function getAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'aider')) { return 'Aider' } + // Why: registry-declared agents identify themselves by frame only, so this must + // run before Claude's generic braille heuristic but after the token matches above. + const frameAgent = resolveAgentIdentityFrameType(title) + if (frameAgent && frameAgent !== 'claude') { + return TUI_AGENT_DISPLAY_NAMES[frameAgent] + } // Why: `cursor` is ordinary editor vocabulary, not identity. Match Cursor's closed // title set (mirrors @cursor routing), before `isClaudeAgent` claims the braille frame. if (isCursorAgentTitle(title)) { @@ -229,6 +241,12 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OMP: 'omp' } +// Why: labels that already equal an agent's canonical display name need no second +// table — registry-declared agents (agent-identity-frame.ts) resolve straight through. +const DISPLAY_NAME_TO_AGENT = new Map( + ALL_TUI_AGENTS.map((agent) => [TUI_AGENT_DISPLAY_NAMES[agent], agent]) +) + function hasGenericClaudeStatusPrefix(title: string): boolean { return ( containsAgentSpinnerGlyph(title) || @@ -239,10 +257,6 @@ function hasGenericClaudeStatusPrefix(title: string): boolean { ) } -// Claude's own name plus, at most, one of its status words — never free-form task text. -const CLAUDE_IDENTITY_FRAME_RE = - /^claude(?: code)?(?:\s+(?:ready|idle|done|working|thinking|running))?(?:\s*-\s*action required)?$/ - /** * Whether a title PRESENTS Claude rather than merely mentioning it, once its leading * status decoration is stripped. A "claude" token inside free-form task text is a mention, @@ -250,13 +264,7 @@ const CLAUDE_IDENTITY_FRAME_RE = * keep using `resolveExplicitTerminalTitleAgentType`, whose token match is looser. */ export function isClaudeIdentityFrameTitle(title: string): boolean { - // Why segments: a multiplexer prefix (`zsh | ⠋ Claude Code`) would otherwise read as task - // text and cost a genuine Claude pane its identity. - return getWrapperTitleSegments(title).some((segment) => - CLAUDE_IDENTITY_FRAME_RE.test( - stripLeadingAgentTitleDecorationOrEmpty(segment).trim().toLowerCase() - ) - ) + return isAgentIdentityFrameTitleFor(title, 'claude') } function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean { @@ -269,7 +277,10 @@ function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { const label = getAgentLabel(title) - return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null + if (!label) { + return null + } + return TITLE_LABEL_TO_AGENT[label] ?? DISPLAY_NAME_TO_AGENT.get(label) ?? null } /**