From 970696a008ecd9ea0296715d4809b0aeb4032497 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:32:34 -0700 Subject: [PATCH] fix(sidebar): show Cursor rows and stop a stray "claude" title hijacking OpenCode (#12466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): show Cursor rows and stop a stray "claude" title hijacking OpenCode Two defects in the same title-resolution path. **#10258** — Cursor's only native OSC title is the literal `cursor agent`, which both title trackers dropped unconditionally. A hookless Cursor pane therefore had neither a status entry nor any title carrying Cursor identity, so the worktree card showed nothing at all. **#8940** — two owner-blind paths let an incidental `claude` token anywhere in an OpenCode session or task title outrank the pane's known owner, so the tab icon and sidebar row flipped to Claude Code. #10258: let the literal through exactly once as identity, so a restored or mobile tab keeps its Cursor row instead of vanishing. #8940: require an *identity frame* — after stripping status decoration the title must PRESENT Claude, not merely mention it — before a Claude title may reclaim a pane from its prior identity, and make the sidebar row builder owner-aware. > These two are in one PR because they share the `ownerAgentType` plumbing through `buildTitleDerivedAgentRow` — split apart, neither half compiles on its own. Fixes #10258 Fixes #8940 Co-authored-by: Orca * test(e2e): add recordable proof for sidebar-agent-row-identity Fails on origin/main, passes on this branch. Test: sidebar keeps a Cursor pane visible and an OpenCode pane out of Claude Code hands Co-authored-by: Orca * fix(terminal): preserve restored Cursor identity * test(terminal): cover restored Cursor redraw suppression * refactor(terminal): tighten Cursor identity handling and Claude frame matching Review follow-ups on the title-resolution path: - pty-transport dropped a native Cursor literal that main emits whenever a non-Cursor title preceded it, re-introducing the #10258 blank row in the renderer path. The pre-filter now projects the predecessor the drain will actually see, and defers to the drain gate while facts are still queued. - applyTrackedPtyTitle threaded the cursor flag through 12 sites, including ptyRecordChanged bookkeeping the sole caller ignores. Force the status null once, and the activity-gated effects fall out unchanged. - isClaudeIdentityFrameTitle missed a multiplexer-wrapped Claude title ("zsh | Claude Code"), costing a genuine Claude pane its identity. Reuse the ' | ' segment split that agent-title-owner already had inline. - Keep title normalization on launchAgent: it only rewrites within an identity group (OMP wraps Pi), so a split does not make it wrong, and the hook-row path normalizes the same way. - Drop the tab.ptyId tracker fallback, which read a pty that the pane identity check had just rejected. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 113 +++++++++- src/main/runtime/orca-runtime.ts | 94 ++++++-- .../worktree-title-derived-agent-rows.test.ts | 93 ++++++++ .../worktree-title-derived-agent-rows.ts | 68 ++++-- .../parked-terminal-byte-watcher.test.ts | 17 +- .../terminal-pane/pty-transport.test.ts | 3 +- .../components/terminal-pane/pty-transport.ts | 58 +++-- .../terminal-title-tracker-parity.test.ts | 130 +++++++++-- src/renderer/src/lib/use-tab-agent.test.ts | 34 +++ src/renderer/src/lib/use-tab-agent.ts | 10 +- src/shared/agent-detection.ts | 1 + src/shared/agent-title-core.ts | 7 + src/shared/agent-title-owner.ts | 15 +- src/shared/terminal-output-side-effects.ts | 25 ++- src/shared/terminal-title-agent-type.test.ts | 32 +++ src/shared/terminal-title-agent-type.ts | 22 ++ src/shared/terminal-title-wrapper-segments.ts | 20 ++ tests/e2e/sidebar-agent-row-identity.spec.ts | 204 ++++++++++++++++++ 18 files changed, 856 insertions(+), 90 deletions(-) create mode 100644 src/shared/terminal-title-wrapper-segments.ts create mode 100644 tests/e2e/sidebar-agent-row-identity.spec.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 69b4ff823..ec656c701 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -9087,6 +9087,103 @@ describe('OrcaRuntimeService', () => { expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) }) + it('emits live Cursor identity without storing it as liveness evidence', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' } + ]) + expect((await runtime.listTerminals()).terminals[0].title).not.toBe('Cursor Agent') + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({ + facts: [{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }] + }) + }) + + it('keeps live Cursor identity in mobile titles without making it agent liveness', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 1', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-cursor', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + ptyId: 'pty-1', + title: 'Terminal 1', + isActive: true + } + ] + } + ] + }) + + runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100) + + const terminal = (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0] + expect(terminal).toMatchObject({ type: 'terminal', title: 'Cursor Agent' }) + expect(terminal).not.toHaveProperty('agentStatus') + }) + + it('lets an explicit terminal rename override cached Cursor identity and restores it after clearing', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + + runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100) + const mobileTerminal = ( + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + ).tabs.find((tab) => tab.type === 'terminal') + if (mobileTerminal?.type !== 'terminal' || !mobileTerminal.terminal) { + throw new Error('expected mobile terminal handle') + } + expect(mobileTerminal.terminal).toBe(created.handle) + + await runtime.renameTerminal(mobileTerminal.terminal, 'Pinned Cursor') + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject( + { type: 'terminal', title: 'Pinned Cursor' } + ) + + await runtime.renameTerminal(mobileTerminal.terminal, null) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject( + { type: 'terminal', title: 'Cursor Agent' } + ) + }) + it('confirms title-based agent exits against the foreground process', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -9898,12 +9995,12 @@ describe('OrcaRuntimeService', () => { expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull() }) - it('drops the cursor-agent literal from record-fallback snapshots', () => { + it('keeps the cursor-agent literal in record-fallback snapshots only without a tracker title', () => { const { runtime } = createSideEffectRuntime() syncSinglePty(runtime) runtime.onPtyData('pty-1', 'plain output\n', 100) - // Simulate a record title restored by a path that bypassed the tracker (which itself refuses to store the bare native title). + // Simulate a record title restored by a path that bypassed the tracker. const records = ( runtime as unknown as { ptysById: Map @@ -9911,7 +10008,17 @@ describe('OrcaRuntimeService', () => { ).ptysById records.get('pty-1')!.lastOscTitle = 'Cursor Agent' - expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull() + // Why: a hookless Cursor pane has no other identity to restore (#10258). + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({ + facts: [{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }] + }) + + // A synthesized Cursor title owns the pane; the bare literal must not replay over it. + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({ + facts: [{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }] + }) }) it('emits the chunk agentStatus events before its side-effect batch', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2e8daee6b..543bb8c15 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -27,6 +27,7 @@ import type { import { createTerminalTitleTracker, stripBrailleSpinnerGlyphs, + type TerminalTitleFactMeta, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' @@ -10210,11 +10211,14 @@ export class OrcaRuntimeService { getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle - // Why: the cursor-agent literal drop applies to every title surface; a - // record-fallback snapshot must not replay the bare native title the - // tracker would have refused to emit live. - const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null + // Why: a record-fallback snapshot must not replay the bare cursor-agent literal over a + // tracker title Orca synthesized from hooks — but with no tracker title it is the pane's + // only Cursor identity, so restored/mobile tabs keep it (#10258). + const rawTitle = + recordTitle && (normalizedTitle === null || !isCursorNativeAgentTitle(recordTitle)) + ? recordTitle + : null if (normalizedTitle === null && !rawTitle) { return null } @@ -10248,12 +10252,37 @@ export class OrcaRuntimeService { return null } + private isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean { + return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true + } + + /** Display fallback for identities intentionally omitted from liveness records. */ + private getTrackedDisplayTitleForPty(ptyId: string): string | null { + return ( + this.getTrackedRawTitleForPty(ptyId) ?? + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? + null + ) + } + + private getUnpersistedTrackedTitleForPty(ptyId: string | null): string | null { + if (!ptyId || this.getTrackedRawTitleForPty(ptyId) !== null) { + return null + } + // Why: a manual title is authoritative until explicitly cleared with null. + const pty = this.ptysById.get(ptyId) + if (pty && pty.title !== null) { + return null + } + return this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? null + } + /** Why: synthetic agent title frames no longer ride pty:data, so neither * renderer xterm nor the headless emulator observes them. Mobile-parity * snapshot titles must prefer main's tracker over snapshot lastTitle, or * hook-driven spinner/idle titles vanish from mobile tabs. */ private preferTrackedLastTitle(ptyId: string, snapshot: T): T { - const tracked = this.getTrackedRawTitleForPty(ptyId) + const tracked = this.getTrackedDisplayTitleForPty(ptyId) if (!tracked) { return snapshot } @@ -10296,8 +10325,11 @@ export class OrcaRuntimeService { rawTitle, ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) - const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle) - if (!changed) { + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) + // Why: an identity-only cursor title records nothing, so on a fresh pane `changed` is + // false — but the tracker title is that pane's only Cursor identity and still has to + // fan out to the sidebar and mobile (#10258). + if (!changed && !this.isLiveCursorNativeTitle(rawTitle, meta)) { return } const live = this.ptyTitleTrackersByPtyId.get(ptyId) @@ -10377,25 +10409,42 @@ export class OrcaRuntimeService { /** Apply one observed OSC title (raw form) to the PTY and leaf records. * Returns true when the PTY record's title or status changed. */ - private applyTrackedPtyTitle(ptyId: string, rawTitle: string, normalizedTitle: string): boolean { + private applyTrackedPtyTitle( + ptyId: string, + rawTitle: string, + normalizedTitle: string, + meta?: TerminalTitleFactMeta + ): boolean { // Why: status is detected from the RAW title (mirrors the renderer tracker), // so working/idle transitions are unaffected by normalization; the records // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to // one stable stored label (#7880) instead of churning `ps`/mobile tabs. - const agentStatus = detectAgentStatusFromTitle(rawTitle) + // + // Why the identity-only case: the bare cursor-agent literal identifies the pane without + // asserting activity, so it records NO title/status evidence — only the tracker keeps it, + // for display (#10258). Nulling the status here rather than trusting the detector keeps + // that contract local, since every activity-gated effect below is keyed on status. + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) + const recordedTitle = identityOnlyTitle ? null : normalizedTitle + const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle) let ptyRecordChanged = false const pty = this.ptysById.get(ptyId) if (pty) { const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() - pty.lastOscTitle = normalizedTitle - pty.lastOscTitleAt = observedAt - pty.lastOscTitleEpochMs = Date.now() + pty.lastOscTitle = recordedTitle + pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt + pty.lastOscTitleEpochMs = identityOnlyTitle ? null : Date.now() pty.lastAgentStatus = agentStatus pty.lastAgentStatusObservedLive = true - this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) - ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus + if (identityOnlyTitle) { + pty.managementTitle = null + pty.managementTitleAt = null + } else { + this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) + } + ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolvePtyTuiIdleWaiters(pty, ptyId) } @@ -10428,10 +10477,10 @@ export class OrcaRuntimeService { // daemon-hosted terminals (no renderer pushing pane titles) had no // way to clear a stale 'working' status after the agent exited and // the shell took over the title — the stuck-spinner bug in #1437. - leaf.lastOscTitle = normalizedTitle - leaf.lastOscTitleAt = this.nextTitleObservationSequence() const prevStatus = leaf.lastAgentStatus const prevObservedLive = leaf.lastAgentStatusObservedLive + leaf.lastOscTitle = recordedTitle + leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() // Why: when a new OSC title doesn't classify as an agent state (e.g. // bare shell title after the agent exits), clear lastAgentStatus so // it is no longer sticky. Tui-idle waiters that needed the previous @@ -30335,6 +30384,12 @@ export class OrcaRuntimeService { const hookAgentStatus = tab.agentStatus ? this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) : null + // Why not tab.ptyId: findPtyForMobileTerminalTab already rejected it when it returned + // null, because persisted ids can collide with an unrelated pane after restart — reading + // that pane's tracker would publish its title here, ahead of every other source. + const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty( + liveLeafPtyId ?? pty?.ptyId ?? null + ) const leafTitle = leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, @@ -30362,7 +30417,7 @@ export class OrcaRuntimeService { pty?.foregroundAgent ?? null const title = normalizeCompatibleAgentTitleForOwner( - leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, + trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, ownerAgent ) const liveTitleEvidence = leafTitle ?? ptyTitle @@ -30532,6 +30587,9 @@ export class OrcaRuntimeService { ? { providerSession: hookRow.providerSession } : {} const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty( + pty?.ptyId ?? leaf?.ptyId ?? null + ) const ptyTitle = pty ? getLatestAgentCandidateTitle( { title: pty.title, updatedAt: pty.titleUpdatedAt }, @@ -30572,7 +30630,7 @@ export class OrcaRuntimeService { pty?.foregroundAgent ?? null const terminalTitle = normalizeCompatibleAgentTitleForOwner( - (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, + trackerOnlyTitle ?? (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, ownerAgent ) // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). 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 b227cb37c..f20ace97b 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 @@ -271,4 +271,97 @@ describe('buildTitleDerivedAgentRows', () => { expect(rows).toHaveLength(0) }) + + // #10258: Cursor's native title is deliberately status-less, which used to hide the pane. + it('adds an idle Cursor row for the bare native cursor-agent title', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'cursor', title: 'Cursor Agent' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Cursor Agent' } }, + ptyIdsByTabId: { 'tab-1': ['pty-cursor'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual([ + ['cursor', 'idle', 'Idle'] + ]) + }) + + it('keeps the Cursor row running while a synthesized spinner title is painted', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'cursor' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Cursor Agent' } }, + ptyIdsByTabId: { 'tab-1': ['pty-cursor'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['cursor', 'working']]) + }) + + // #8940: an OpenCode pane's own task text must not hand the row to Claude Code. + it('keeps an OpenCode-launched pane OpenCode across its own status frames', () => { + const frames: [string, string][] = [ + ['OC | ⠋ ask claude about this', 'working'], + ['⠋ OpenCode', 'working'], + ['⠋ use Claude Sonnet', 'working'], + ['⠋ claude 스타일로 리팩터', 'working'], + ['. Compare Opencode Vs Orca', 'working'], + ['OpenCode ready', 'idle'] + ] + + for (const [title, state] of frames) { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'opencode' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: title } }, + ptyIdsByTabId: { 'tab-1': ['pty-opencode'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows.map((row) => [row.agentType, row.state, row.entry.prompt])).toEqual([ + ['opencode', state, 'OpenCode'] + ]) + } + }) + + it('still resolves Claude from a title that presents Claude, owner or not', () => { + const rowsFor = (title: string, launchAgent?: TuiAgent) => + buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', launchAgent ? { launchAgent } : {})], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: title } }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude']) + // Pane reuse: the user exited OpenCode and ran claude in the same pane. + expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude']) + // No owner to defend the pane: naming Claude stays the only available identity. + expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude']) + expect(rowsFor('zsh', 'opencode')).toHaveLength(0) + }) + + it('does not brand a split pane with the tab-scoped launch agent', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'opencode' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ implementing the feature' } }, + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() }, + now: 2000 + }) + + expect(rows).toHaveLength(0) + }) }) 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 068bdd599..ceba63d3e 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 @@ -1,6 +1,6 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-status' -import { containsBrailleSpinner } from '../../../../shared/agent-title-core' +import { isCursorAgentTitle } from '../../../../shared/agent-title-core' import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { @@ -19,6 +19,8 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' +import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' const EMPTY_RUNTIME_TITLES: Record> = {} const EMPTY_LIVE_PTY_IDS: Record = {} @@ -84,6 +86,7 @@ export function buildTitleDerivedAgentRows(args: { tab, leafId, title, + ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId), now: args.now, runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey }) @@ -104,6 +107,7 @@ export function buildTitleDerivedAgentRows(args: { tab, leafId, title: tab.title, + ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId), now: args.now, runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey }) @@ -125,15 +129,24 @@ function buildTitleDerivedAgentRow(args: { tab: TerminalTab leafId: string title: string + ownerAgentType: AgentType | null now: number runtimeAgentOrchestrationByPaneKey?: Record }): DashboardAgentRow | null { + // Why launchAgent, not ownerAgentType: this only rewrites a title within its own identity + // group (OMP wraps Pi and emits Pi frames), which stays correct in a split. Pane ownership + // is a separate, stricter question — it decides identity, so it uses ownerAgentType below. const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent) const isClaudeAgentsTitle = isClaudeManagementTitle(title) // Why: `claude agents` is a live Claude Code Agent Teams surface, but the // shared detector keeps it neutral so runtime liveness probes do not treat // the management/list screen as active work. - const status = isClaudeAgentsTitle ? 'idle' : classifyTitleActivity(title) + // Why (cursor): the native `cursor agent` literal is deliberately status-less so a + // redraw cannot stomp hook state — but it still identifies a live pane, so the row + // reads idle instead of vanishing (#10258). + const status = isClaudeAgentsTitle + ? 'idle' + : (classifyTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'idle' : null)) const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title) if (!status || !label) { return null @@ -143,16 +156,15 @@ function buildTitleDerivedAgentRow(args: { } const paneKey = makePaneKey(args.tab.id, args.leafId) const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey] - const titleAgentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(title, label) - // Why: a braille spinner proves activity, not identity, so the resolver drops - // it. Hook-less agents over SSH (Codex, #8711) surface only spinner+cwd titles; - // fall back to the tab's launch identity instead of hiding the pane. Gated on - // the spinner on purpose — unlike the hook path's unconditional launchAgent - // fallback (resolveRowAgentType), this path manufactures agent-ness from a - // title alone, so a non-agent title must never become a row. Residual: a split - // pane whose own title carries a braille glyph is still attributed to launchAgent. - const agentType = - titleAgentType ?? (containsBrailleSpinner(title) ? (args.tab.launchAgent ?? null) : null) + const titleAgentType = isClaudeAgentsTitle + ? 'claude' + : resolveTitleDerivedAgentType(title, label, args.ownerAgentType) + // Why: a status frame proves activity, not identity, so the resolver drops it. + // Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940) + // surface only decorated task titles; fall back to the pane's known owner instead + // of hiding the pane. Safe because the `!status || !label` gate above already + // rejects plain shell titles — this path must never manufacture a row from one. + const agentType = titleAgentType ?? args.ownerAgentType if (!agentType) { return null } @@ -184,7 +196,11 @@ function buildTitleDerivedAgentRow(args: { } } -export function resolveTitleDerivedAgentType(title: string, label: string): AgentType | null { +export function resolveTitleDerivedAgentType( + title: string, + label: string, + ownerAgentType?: AgentType | null +): AgentType | null { const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown' if (agentType !== 'claude') { return agentType @@ -192,7 +208,29 @@ export function resolveTitleDerivedAgentType(title: string, label: string): Agen // Why: Claude's task-title spinner heuristic has no provider identity. In // split panes it can match arbitrary terminal spinners, so sidebar rows only // accept Claude when the title itself names Claude. - return CLAUDE_AGENT_TOKEN_RE.test(title) ? agentType : null + if (!CLAUDE_AGENT_TOKEN_RE.test(title)) { + return null + } + // Why: a "claude" word inside another agent's task text is a mention, not identity. + // Only a title that PRESENTS Claude may take a pane away from its known owner (#8940). + const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null + if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) { + return null + } + return agentType +} + +function resolveTitleDerivedPaneOwner( + tab: TerminalTab, + layout: TerminalLayoutSnapshot | undefined, + leafId: string +): AgentType | null { + // Why: launchAgent is tab-scoped, so it is pane ownership only while the tab has one + // leaf; applying it inside a split would let one pane brand its sibling. + if (layout?.root?.type !== 'leaf' || layout.root.leafId !== leafId) { + return null + } + return resolvePaneAgentOwner({ launchAgent: tab.launchAgent }) } /** @@ -210,7 +248,7 @@ export function resolveAgentTypeFromTerminalTitle( const label = resolveTitleActivityLabel(normalizedTitle) return label ? (resolveCompatibleAgentTypeForOwner( - resolveTitleDerivedAgentType(normalizedTitle, label), + resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType), ownerAgentType ) ?? null) : null diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index cd3cfd999..933492549 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -175,14 +175,25 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) - it('drops the bare cursor-agent native title before it reaches the store', async () => { + // Why: a hookless Cursor pane has no other identity, so the literal reaches the store + // once (#10258); its redraw repeats must not stomp a synthesized Cursor title. + it('stores the bare cursor-agent native title once, then keeps the synthetic title', async () => { const { dispose } = await startWatcher() + emit('\x1b]0;Cursor Agent\x07') + emit('\x1b]0;Cursor Agent\x07') + emit('\x1b]0;⠋ Cursor Agent\x07') emit('\x1b]0;Cursor Agent\x07') flushSideEffects() - expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled() - expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([ + [TAB_ID, PANE_ID, 'Cursor Agent'], + [TAB_ID, PANE_ID, '⠋ Cursor Agent'] + ]) + expect(mockStoreState.updateTabTitle.mock.calls).toEqual([ + [TAB_ID, 'Cursor Agent'], + [TAB_ID, '⠋ Cursor Agent'] + ]) dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 72b340d26..6bbba35a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -801,7 +801,8 @@ describe('createIpcPtyTransport', () => { await vi.runOnlyPendingTimersAsync() expect(vi.getTimerCount()).toBe(0) - expect(onTitleChange).not.toHaveBeenCalled() + // Why: the literal is the pane's identity once (#10258); the redraw repeats stay ignored. + expect(onTitleChange.mock.calls).toEqual([['Cursor Agent', 'Cursor Agent']]) } finally { vi.useRealTimers() } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 95528b430..edba63981 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -4,7 +4,9 @@ import { clearWorkingIndicators, createAgentStatusTracker, normalizeTerminalTitle, - extractAllOscTitles + extractAllOscTitles, + isCursorNativeAgentTitle, + shouldSuppressCursorNativeTitle } from '../../../../shared/agent-detection' import { isTerminalInputTooLargeWithDeferredMeasurement, @@ -112,27 +114,27 @@ type PendingPtySideEffect = { suppressAttentionEvents: boolean } -function isIgnoredCursorNativeTitle(title: string): boolean { - return title.trim().toLowerCase() === 'cursor agent' -} - -function removeIgnoredCursorNativeTitles(titles: string[]): boolean { +// Why: mirrors main's applyObservedTitle — the literal survives whenever the title before it +// is not already Cursor-owned, which is how a pane re-establishes Cursor identity after the +// shell prompt repaints the title (#10258). Only the redraw repeats are dropped, so they cost +// neither an allocation nor a drain slot; `processObservedTitles` re-checks and stays +// authoritative, so this must never drop a title that gate would have emitted. +function removeSuppressedCursorNativeTitles( + titles: string[], + precedingTitle: string | null +): boolean { let writeIndex = 0 - let removed = false - for (let readIndex = 0; readIndex < titles.length; readIndex += 1) { - const title = titles[readIndex] - if (isIgnoredCursorNativeTitle(title)) { - removed = true + let previousTitle = precedingTitle + for (const title of titles) { + if (isCursorNativeAgentTitle(title) && shouldSuppressCursorNativeTitle(previousTitle)) { continue } - if (writeIndex !== readIndex) { - titles[writeIndex] = title - } + previousTitle = normalizeTerminalTitle(title) + titles[writeIndex] = title writeIndex += 1 } - if (removed) { - titles.length = writeIndex - } + const removed = writeIndex < titles.length + titles.length = writeIndex return removed } @@ -176,6 +178,10 @@ export function createPtyOutputProcessor({ retained: () => pendingSideEffects.length } const disposePendingSideEffectGauge = registerPtySideEffectPendingGauge(pendingSideEffectGauge) + const initialAgentStatusTitle = + initialAgentTitle !== undefined && !isCursorNativeAgentTitle(initialAgentTitle) + ? initialAgentTitle + : undefined const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( @@ -184,7 +190,7 @@ export function createPtyOutputProcessor({ }, onAgentBecameWorking, onAgentExited, - initialAgentTitle + initialAgentStatusTitle ) : null @@ -288,7 +294,13 @@ export function createPtyOutputProcessor({ const scannedForTitles = Boolean(onTitleChange && data.includes('\x1b]')) const titles = scannedForTitles ? extractAllOscTitles(data) : [] // Why: Cursor emits this ignored title every redraw; keep one queue fact instead of an allocation and drain slot per frame. - const ignoredCursorNativeTitle = removeIgnoredCursorNativeTitles(titles) + // Why the drained check: `lastEmittedTitle` only advances on drain, so while facts are + // still queued it is not the predecessor the drain will see — leave that call to the gate. + const drained = pendingSideEffectIndex >= pendingSideEffects.length + const ignoredCursorNativeTitle = removeSuppressedCursorNativeTitles( + titles, + drained ? lastEmittedTitle : null + ) const deliveredPayloads = onAgentStatus && !suppressAttentionEvents && payloads.length > 0 ? payloads : [] const containsBell = Boolean( @@ -436,6 +448,14 @@ export function createPtyOutputProcessor({ if (titles.length > 0) { clearStaleTitleTimer() for (const title of titles) { + if (isCursorNativeAgentTitle(title)) { + // Why: identity for a hookless Cursor pane (#10258), never activity — the literal's + // null status would read as an agent exit, and a repeat must not stomp hook state. + if (!shouldSuppressCursorNativeTitle(lastEmittedTitle)) { + applyObservedTerminalTitle(title, true) + } + continue + } applyObservedTerminalTitle(title, suppressAgentTracker) } } else if (titleScanEffect === 'ignored-cursor-native') { diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index ae5fa99b6..6dd817ca6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -28,16 +28,20 @@ type TitleFactEvent = type TitleFactPath = { events: TitleFactEvent[] feed: (chunk: string) => void + flush: () => void } -function createRendererPath(): TitleFactPath { +// Why deferDrain: flushing per chunk hides schedule-vs-drain divergence, because the renderer +// decides what to queue against a `lastEmittedTitle` that only later chunks advance. +function createRendererPath(initialTitle?: string, deferDrain = false): TitleFactPath { const events: TitleFactEvent[] = [] const processor = createPtyOutputProcessor({ onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), onAgentBecameWorking: () => events.push({ kind: 'became-working' }), onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), onAgentExited: () => events.push({ kind: 'agent-exited' }), - onBell: () => events.push({ kind: 'bell' }) + onBell: () => events.push({ kind: 'bell' }), + initialAgentTitle: initialTitle }) const callbacks = { onData: () => {} } return { @@ -47,28 +51,36 @@ function createRendererPath(): TitleFactPath { // Why: the renderer defers side effects behind a setTimeout(0) drain to // protect xterm paint. Flush synchronously so both paths observe each // chunk at the same fake-timer instant. - processor.flushPendingSideEffects() - } + if (!deferDrain) { + processor.flushPendingSideEffects() + } + }, + flush: () => processor.flushPendingSideEffects() } } -function createMainPath(): TitleFactPath { +function createMainPath(initialTitle?: string): TitleFactPath { const events: TitleFactEvent[] = [] // Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999 // processor strips status payloads before the title tracker sees the chunk. const processAgentStatusChunk = createAgentStatusOscProcessor() - const tracker = createTerminalTitleTracker({ - onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), - onAgentBecameWorking: () => events.push({ kind: 'became-working' }), - onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), - onAgentExited: () => events.push({ kind: 'agent-exited' }), - onBell: () => events.push({ kind: 'bell' }) - }) + const tracker = createTerminalTitleTracker( + { + onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) + }, + initialTitle !== undefined ? { initialTitle } : undefined + ) return { events, feed(chunk: string): void { tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) - } + }, + // Why: main applies every title inline, so there is nothing to defer. + flush: () => {} } } @@ -115,6 +127,63 @@ describe('main title tracker parity with the renderer transport processor', () = expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' }) }) + // Why: #10258 — a hookless Cursor pane's only identity is the bare literal, so both + // paths must emit it once (never as an agent state) before the repeats are dropped. + it('emits the bare cursor-agent native title once in both paths', () => { + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + { kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' } + ]) + }) + + it('lets a synthesized Cursor title follow the native literal in both paths', () => { + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.map((event) => event.kind)).toEqual([ + 'title', + 'title', + 'became-working' + ]) + }) + + it('keeps a restored native Cursor title identity-only before synthesized work', () => { + const restoredPaths = { + renderer: createRendererPath('Cursor Agent'), + main: createMainPath('Cursor Agent') + } + + feedBoth(restoredPaths, `${ESC}]0;Cursor Agent${BEL}`) + expect(restoredPaths.main.events).toEqual([]) + + feedBoth(restoredPaths, `${ESC}]0;⠋ Cursor Agent${BEL}`) + + expect(restoredPaths.main.events).toEqual(restoredPaths.renderer.events) + expect(restoredPaths.main.events).toEqual([ + { kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }, + { kind: 'became-working' } + ]) + }) + + it('keeps native/synthesized Cursor title order inside one coalesced chunk', () => { + feedBoth( + paths, + `${ESC}]0;Cursor Agent${BEL}${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]0;Cursor Agent${BEL}` + ) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + { kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' }, + { kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }, + { kind: 'became-working' } + ]) + }) + it('drops the bare cursor-agent native title in both paths', () => { feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`) feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) @@ -124,6 +193,41 @@ describe('main title tracker parity with the renderer transport processor', () = expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }]) }) + // Why: once the shell prompt repaints the title, the literal is again the pane's only Cursor + // identity — dropping it there would leave a re-entered cursor-agent with no row (#10258). + it('re-emits the native literal after a non-Cursor title took the pane', () => { + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;zsh${BEL}${ESC}]0;Cursor Agent${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.filter((event) => event.kind === 'title')).toEqual([ + { kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' }, + { kind: 'title', normalized: 'zsh', raw: 'zsh' }, + { kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' } + ]) + }) + + // Why one flush: the renderer prunes titles when the chunk is scheduled but only advances its + // emitted-title memory on drain, so chunks that drain together must not decide against a + // predecessor that is already stale. + it('re-emits the native literal across chunks that drain together', () => { + const deferred = { + renderer: createRendererPath(undefined, true), + main: createMainPath() + } + + feedBoth(deferred, `${ESC}]0;Cursor Agent${BEL}`) + // Why drain here: it leaves the renderer's emitted-title memory on the literal, which is + // exactly the stale predecessor the next two chunks must not be judged against. + deferred.renderer.flush() + feedBoth(deferred, `${ESC}]0;zsh${BEL}`) + feedBoth(deferred, `${ESC}]0;Cursor Agent${BEL}`) + deferred.renderer.flush() + + expect(deferred.main.events).toEqual(deferred.renderer.events) + expect(deferred.main.events.filter((event) => event.kind === 'title')).toHaveLength(3) + }) + it('clears a stale working title after the 3s timeout in both paths', () => { feedBoth(paths, `${ESC}]0;. Claude working${BEL}`) feedBoth(paths, 'output with no title\r\n') diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 287b73b9c..a0bcbea57 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -272,6 +272,40 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('opencode') }) + // Why: #8940 — an OpenCode session whose task text mentions Claude flipped the tab icon + // to Claude Code as soon as its hook row went stale (restart, mobile, between turns). + it('keeps an OpenCode tab OpenCode when its task title merely mentions Claude', () => { + for (const title of [ + 'OC | ⠋ ask claude about this', + '⠋ OpenCode', + '⠋ use Claude Sonnet', + '⠋ claude 스타일로 리팩터', + 'OpenCode ready' + ]) { + for (const hasObservedAgentSignal of [true, false]) { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal, + isRemote: false, + title, + hookAgent: null, + launchAgent: 'opencode' + }) + ).toBe('opencode') + } + } + // Real pane reuse: the title PRESENTS Claude, so it still reclaims the pane. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '✳ Claude Code', + hookAgent: null, + launchAgent: 'opencode' + }) + ).toBe('claude') + }) + it('does not let an explicit title override launch identity before any activity is observed', () => { expect( resolveTabAgentFromSignals({ diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 711f0f106..017126a86 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -12,7 +12,10 @@ import { resolveSiblingRetainedTabAgent, resolveSiblingTabAgent } from './tab-agent' -import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import { + isClaudeIdentityFrameTitle, + resolveExplicitTerminalTitleAgentType +} from '../../../shared/terminal-title-agent-type' import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' @@ -115,11 +118,16 @@ export function resolveTabAgentFromSignals(args: { ) 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 = diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 7097a4152..a2e419c4f 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -15,6 +15,7 @@ export { isCursorNativeAgentTitle, isGeminiTerminalTitle, isPiTerminalTitle, + shouldSuppressCursorNativeTitle, STRONG_IDLE_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE } from './agent-title-core' diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 15dc12d65..83dcc12bf 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -130,3 +130,10 @@ export function isCursorAgentTitle(title: string | null | undefined): boolean { // treat the controlled synthetic Cursor spinner title as Cursor identity. return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed) } + +// Why: cursor-agent re-emits its bare native title every redraw, which would stomp +// Orca's hook-synthesized spinner state, but only once a Cursor-owned title already +// owns the pane. A hookless Cursor pane still needs the literal once, for identity. +export function shouldSuppressCursorNativeTitle(lastEmittedTitle: string | null): boolean { + return lastEmittedTitle !== null && isCursorAgentTitle(lastEmittedTitle) +} diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index 021f1dda2..9b88ea12f 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -6,6 +6,7 @@ import { type SyntheticAgentTitleProfile } from './synthetic-agent-title' import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' type TitleProfileMatch = { profile: SyntheticAgentTitleProfile @@ -36,18 +37,8 @@ function getProfileForTitleLabel(label: string | null): TitleLabelProfileMatch | * Resolves the synthetic title profile matching a given terminal title. */ function getProfileForTitle(title: string): TitleProfileMatch | null { - // Multiplexers/session wrappers prefix dynamic titles with ` | `, so inspect - // each suffix to preserve the inner compatible agent identity. - const candidates = [title] - let wrapperSeparatorIndex = title.indexOf(' | ') - while (wrapperSeparatorIndex >= 0) { - const wrappedPaneTitle = title.slice(wrapperSeparatorIndex + 3).trim() - if (wrappedPaneTitle && !candidates.includes(wrappedPaneTitle)) { - candidates.push(wrappedPaneTitle) - } - wrapperSeparatorIndex = title.indexOf(' | ', wrapperSeparatorIndex + 3) - } - + // Why each segment: a wrapper prefix must not hide the inner compatible agent identity. + const candidates = getWrapperTitleSegments(title) let fallback: TitleProfileMatch | null = null for (const candidate of candidates) { const labelProfile = getProfileForTitleLabel(getAgentLabel(candidate)) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 32dc33724..31f7df27e 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -11,7 +11,8 @@ import { detectAgentStatusFromTitle, extractAllOscTitles, isCursorNativeAgentTitle, - normalizeTerminalTitle + normalizeTerminalTitle, + shouldSuppressCursorNativeTitle } from './agent-detection' import { createBellDetector } from './terminal-bell-detector' import { @@ -137,6 +138,10 @@ export function createTerminalTitleTracker( let staleTitleTimer: ReturnType | null = null // Why: flags the stale-timer clear so its idle callback carries timer provenance, not a genuine task-complete. let applyingStaleWorkingTitleClear = false + const initialAgentStatusTitle = + options.initialTitle !== undefined && !isCursorNativeAgentTitle(options.initialTitle) + ? options.initialTitle + : undefined const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( @@ -148,7 +153,7 @@ export function createTerminalTitleTracker( }, onAgentBecameWorking, onAgentExited, - options.initialTitle + initialAgentStatusTitle ) : null @@ -162,6 +167,13 @@ export function createTerminalTitleTracker( function applyObservedTitle(rawTitle: string): void { // Why: cursor-agent re-emits its bare native title mid-turn; passing it through would stomp Orca's synthesized spinner state. if (isCursorNativeAgentTitle(rawTitle)) { + if (shouldSuppressCursorNativeTitle(lastEmittedTitle)) { + return + } + // Why: a hookless Cursor pane needs the literal once so it has an identity (#10258), + // but never as activity — its null status would read as an exit in the status tracker. + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + onTitle?.(lastEmittedTitle, rawTitle) return } lastEmittedTitle = normalizeTerminalTitle(rawTitle) @@ -258,12 +270,15 @@ export function createTerminalTitleTracker( handleChunk, applySyntheticTitleFrame, seedInitialTitle(rawTitle: string): void { - // Why: the cursor-agent literal drop applies to seeds too — a bare native title would stomp synthesized spinner state. - if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) { + if (lastEmittedTitle !== null || !rawTitle) { return } lastEmittedTitle = normalizeTerminalTitle(rawTitle) - agentTracker?.seedTitle(rawTitle) + // Why: the cursor-agent literal seeds identity only — feeding its null status to the + // tracker would make the next real frame look like an agent exit. + if (!isCursorNativeAgentTitle(rawTitle)) { + agentTracker?.seedTitle(rawTitle) + } }, restoreLastAgentExit(): AgentStatus | null { return agentTracker?.restoreLastExit() ?? null diff --git a/src/shared/terminal-title-agent-type.test.ts b/src/shared/terminal-title-agent-type.test.ts index d0fda7dcc..d20d03b5b 100644 --- a/src/shared/terminal-title-agent-type.test.ts +++ b/src/shared/terminal-title-agent-type.test.ts @@ -3,6 +3,7 @@ import { getAgentLabel as getSharedAgentLabel } from './agent-title-identity' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { isClaudeAgent, + isClaudeIdentityFrameTitle, isGrokRotatingWorkingTitle, resolveExplicitTerminalTitleAgentType, resolveTerminalTitleAgentType @@ -90,6 +91,37 @@ describe('resolveExplicitTerminalTitleAgentType', () => { expect(resolveExplicitTerminalTitleAgentType('. Claude Code compare Opencode')).toBe('claude') }) + // Why (#8940): only a title that PRESENTS Claude may take a pane from its known owner — + // a "claude" token inside another agent's task text is a mention, not identity. + it('separates Claude identity frames from an incidental claude token', () => { + for (const title of [ + 'Claude Code', + '✳ Claude Code', + '⠋ Claude Code', + 'claude', + '. claude', + 'Claude Code ready', + 'Claude - action required', + // A multiplexer prefix must not read as task text and cost Claude its identity. + 'zsh | ⠋ Claude Code', + 'tmux | dev | Claude Code ready' + ]) { + expect(isClaudeIdentityFrameTitle(title)).toBe(true) + } + for (const title of [ + '⠋ use Claude Sonnet', + 'OC | ⠋ ask claude about this', + '⠋ port the claude prompt', + '. ship it with claude', + '⠋ claude 스타일로 리팩터', + '. Claude Code compare Opencode', + '✳ investigating startup', + '✳' + ]) { + expect(isClaudeIdentityFrameTitle(title)).toBe(false) + } + }) + it('returns null for plain shell and unknown titles', () => { expect(resolveExplicitTerminalTitleAgentType('Terminal 1')).toBeNull() expect(resolveExplicitTerminalTitleAgentType('zsh')).toBeNull() diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index fa92bb198..628764873 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -5,7 +5,9 @@ import { titleHasAgentName } from './agent-name-token-match' import { 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 @@ -241,6 +243,26 @@ 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, + * so it must not take a pane away from its known owner (#8940) — owner-blind consumers + * 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() + ) + ) +} + function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean { return ( titleAgent === 'claude' && diff --git a/src/shared/terminal-title-wrapper-segments.ts b/src/shared/terminal-title-wrapper-segments.ts new file mode 100644 index 000000000..eea29d5ed --- /dev/null +++ b/src/shared/terminal-title-wrapper-segments.ts @@ -0,0 +1,20 @@ +const WRAPPER_SEPARATOR = ' | ' + +/** + * A wrapped terminal title split into the texts an identity check should consider, whole + * title first and innermost pane title last. Multiplexers and session wrappers prefix the + * pane's own dynamic title (`zsh | ⠋ Claude Code`), so a check anchored to the start of the + * string would miss the agent that actually owns the pane. + */ +export function getWrapperTitleSegments(title: string): string[] { + const segments = [title] + let separatorIndex = title.indexOf(WRAPPER_SEPARATOR) + while (separatorIndex >= 0) { + const wrapped = title.slice(separatorIndex + WRAPPER_SEPARATOR.length).trim() + if (wrapped && !segments.includes(wrapped)) { + segments.push(wrapped) + } + separatorIndex = title.indexOf(WRAPPER_SEPARATOR, separatorIndex + WRAPPER_SEPARATOR.length) + } + return segments +} diff --git a/tests/e2e/sidebar-agent-row-identity.spec.ts b/tests/e2e/sidebar-agent-row-identity.spec.ts new file mode 100644 index 000000000..c7a88a7eb --- /dev/null +++ b/tests/e2e/sidebar-agent-row-identity.spec.ts @@ -0,0 +1,204 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal' +import { waitForRestoredTerminalInputReady } from './helpers/restored-terminal-input-readiness' +import { worktreeRow } from './worktree-row-locators' + +/** + * Sidebar agent-row identity, driven by real OSC titles on real PTYs. + * + * #10258 — Cursor's only native OSC title is the literal `cursor agent`; it was + * dropped unconditionally, so a hookless Cursor pane produced no sidebar row. + * #8940 — an incidental `claude` token inside an OpenCode task title outranked + * the pane's known owner, flipping the row label + identity icon to Claude Code. + */ + +// The literal Cursor emits on every redraw — the pane's ONLY identity signal. +const CURSOR_NATIVE_OSC_TITLE = 'Cursor Agent' +// An OpenCode task title that merely MENTIONS claude (see #8940). +const OPENCODE_TASK_OSC_TITLE = '⠋ use Claude Sonnet' + +/** Printed banner that proves the emitter ran; the OSC title trails it in the same chunk. */ +const PANE_HOLD_MARKER = 'agent pane holding' + +// Why: the emitter must never exit — a returning shell prompt would repaint its own +// cwd title over the agent title, which a real TUI holding the pane never allows. +// Why one write: a later title-less chunk arms the stale-title probe, which strips +// the working frame the row depends on. +function oscTitleHolderScript(escapedTitle: string): string { + return [ + `process.stdout.write('${PANE_HOLD_MARKER}\\r\\n\\u001b]0;${escapedTitle}\\u0007')`, + 'setInterval(() => {}, 1e9)', + '' + ].join('\n') +} + +async function useFullAgentActivityRows(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + // Why: 'full' renders every agent row with its label inline, so the proof + // reads off the rendered sidebar instead of a collapsed summary pill. + state.setAgentActivityDisplayMode('full') + if (!state.worktreeCardProperties.includes('inline-agents')) { + state.toggleWorktreeCardProperty('inline-agents') + } + }) +} + +function paneTitles(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const byPane = window.__store?.getState().runtimePaneTitlesByTabId?.[tabId] ?? {} + return Object.values(byPane).filter((title): title is string => typeof title === 'string') + }, tabId) +} + +/** + * Opens a terminal tab launched as `launchAgent`, exactly like the tab-bar quick + * launch, and proves the shell round-trips a command before returning — a cold + * PTY silently swallows the emitter command otherwise. + */ +async function openAgentTab( + page: Page, + worktreeId: string, + launchAgent: 'cursor' | 'opencode' +): Promise<{ tabId: string; ptyId: string }> { + const tabId = await page.evaluate( + ({ worktreeId, launchAgent }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + launchAgent + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, launchAgent } + ) + await waitForActiveTerminalManager(page) + // Why the raised budget: the first tab of a cold app can bind its PTY well past + // the helper's 15s default on a loaded machine. + const ptyId = await waitForActivePanePtyId(page, 45_000) + expect( + await waitForRestoredTerminalInputReady(page, ptyId, 20_000), + `the shell in the ${launchAgent} tab never echoed a probe command` + ).toBe(true) + return { tabId, ptyId } +} + +/** + * Identity of each rendered sidebar agent row, read off the row's identity icon + * tooltip (the first titled span in the row) — i.e. the glyph the user sees. + * Read in one evaluate so a sidebar re-render cannot split the snapshot. + */ +function sidebarAgentRowIdentities(page: Page, agentListSelector: string): Promise { + return page.evaluate((selector) => { + const list = document.querySelector(selector) + return list + ? [...list.children] + .map((row) => row.querySelector('span[title]')?.getAttribute('title') ?? '') + .filter((identity) => identity.length > 0) + .sort() + : [] + }, agentListSelector) +} + +/** + * Waits for the rendered row identities to hold still, then returns them, so the + * claim below is asserted against one settled snapshot with a readable diff. + */ +async function settledSidebarAgentRowIdentities( + page: Page, + agentListSelector: string +): Promise { + const requiredStableReads = 4 + let previousKey = '' + let stableReads = 0 + let settled: string[] = [] + await expect + .poll( + async () => { + settled = await sidebarAgentRowIdentities(page, agentListSelector) + const key = JSON.stringify(settled) + stableReads = key === previousKey && settled.length > 0 ? stableReads + 1 : 0 + previousKey = key + return stableReads >= requiredStableReads + }, + { + timeout: 20_000, + intervals: [200], + message: 'the sidebar agent rows never settled on a stable set' + } + ) + .toBe(true) + return settled +} + +test('sidebar keeps a Cursor pane visible and an OpenCode pane out of Claude Code hands', async ({ + orcaPage +}) => { + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await useFullAgentActivityRows(orcaPage) + + const openCode = await openAgentTab(orcaPage, worktreeId, 'opencode') + const openCodeScript = await runNodeScriptInTerminal( + orcaPage, + openCode.ptyId, + // ⠋ is the braille spinner frame OpenCode paints ahead of its task text. + oscTitleHolderScript('\\u280b use Claude Sonnet') + ) + await waitForTerminalOutput(orcaPage, PANE_HOLD_MARKER, 15_000) + // Precondition, not the claim under test: this title is filtered on neither + // branch, so a failure here means the PTY never emitted it. + await expect + .poll(() => paneTitles(orcaPage, openCode.tabId), { + timeout: 15_000, + message: 'the OpenCode task title never reached the renderer' + }) + .toContain(OPENCODE_TASK_OSC_TITLE) + + const cursor = await openAgentTab(orcaPage, worktreeId, 'cursor') + const cursorScript = await runNodeScriptInTerminal( + orcaPage, + cursor.ptyId, + oscTitleHolderScript(CURSOR_NATIVE_OSC_TITLE) + ) + // Settle gate: the emitter has run, so the literal has been offered to the title + // pipeline — kept as Cursor identity on the fix, dropped on main. + await waitForTerminalOutput(orcaPage, PANE_HOLD_MARKER, 15_000) + + // Only the active worktree's card has agents, so this resolves to one list. + const agentListSelector = `[data-worktree-sidebar] [aria-label="Agents"]` + const agentList = worktreeRow(orcaPage, worktreeId).locator('[aria-label="Agents"]') + await expect(agentList.locator('> div').first()).toBeVisible() + + // #10258: the Cursor pane gets a row at all. #8940: the OpenCode pane stays OpenCode. + expect(await settledSidebarAgentRowIdentities(orcaPage, agentListSelector)).toEqual([ + 'Cursor', + 'OpenCode' + ]) + + // Both panes are on the card: the Cursor row exists at all (#10258) next to the + // OpenCode row still labelled by its own task text (#8940). + await expect(agentList.locator('> div')).toHaveCount(2) + await expect(agentList).toContainText('Cursor') + await expect(agentList).toContainText('use Claude Sonnet') + + openCodeScript.cleanup() + cursorScript.cleanup() +})