From 6cb25527c7a8aae0f5dcc3cec4a98ceb63582bf7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:15:52 -0700 Subject: [PATCH] fix terminal agent icon ownership --- .../command-code-output-ownership.test.ts | 38 ++++++++++ .../command-code-output-ownership.ts | 20 ++++++ .../parked-terminal-command-status.test.ts | 69 ++++++++++++++++++- .../parked-terminal-command-status.ts | 24 +++++++ .../terminal-pane/pty-connection.test.ts | 45 ++++++++++++ .../terminal-pane/pty-connection.ts | 16 +++++ src/shared/command-code-output-status.test.ts | 13 ++++ src/shared/command-code-output-status.ts | 2 +- 8 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts create mode 100644 src/renderer/src/components/terminal-pane/command-code-output-ownership.ts diff --git a/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts b/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts new file mode 100644 index 00000000000..e876e09387b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' + +describe('canCommandCodeOutputOwnPane', () => { + it('allows the banner fallback when no stronger identity exists', () => { + expect(canCommandCodeOutputOwnPane({})).toBe(true) + expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'unknown' })).toBe(true) + }) + + it('allows Command Code ownership evidence', () => { + expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'command-code' })).toBe(true) + expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'command-code' })).toBe(true) + }) + + it('rejects another agent owner or foreground process', () => { + expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'claude' })).toBe(false) + expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'claude' })).toBe(false) + }) + + it('prefers the current foreground process over stale pane ownership', () => { + expect( + canCommandCodeOutputOwnPane({ + foregroundAgent: 'command-code', + paneOwnerAgent: 'claude' + }) + ).toBe(true) + expect( + canCommandCodeOutputOwnPane({ + foregroundAgent: 'claude', + paneOwnerAgent: 'command-code' + }) + ).toBe(false) + }) + + it('rejects output observed at a confirmed shell prompt', () => { + expect(canCommandCodeOutputOwnPane({ shellForeground: true })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts new file mode 100644 index 00000000000..78cf1ac14f0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts @@ -0,0 +1,20 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { TuiAgent } from '../../../../shared/types' + +export function canCommandCodeOutputOwnPane(args: { + foregroundAgent?: TuiAgent | null + shellForeground?: boolean + paneOwnerAgent?: AgentType | null +}): boolean { + if (args.foregroundAgent) { + return args.foregroundAgent === 'command-code' + } + if (args.shellForeground) { + return false + } + return ( + !args.paneOwnerAgent || + args.paneOwnerAgent === 'unknown' || + args.paneOwnerAgent === 'command-code' + ) +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index 24991e01914..eb386065306 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' const PTY_ID_LOCAL = 'pty-1' const PTY_ID_SSH = 'ssh:target-1@@pty-9' @@ -13,7 +14,10 @@ const DONE_SETTLE_MS = 1500 const ROUTING = { connectionId: null } type MockStoreState = { + tabsByWorktree: Record agentStatusByPaneKey: Record + paneForegroundAgentByPaneKey: Record + agentLaunchConfigByPaneKey: Record runtimePaneTitlesByTabId: Record> setAgentStatus: ReturnType dropAgentStatus: ReturnType @@ -40,7 +44,10 @@ vi.mock('@/lib/connection-owner-resolution', () => ({ function makeMockStoreState(): MockStoreState { return { + tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID }] }, agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + agentLaunchConfigByPaneKey: {}, runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } }, setAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), @@ -110,6 +117,66 @@ describe('createParkedTerminalCommandStatusPolicy', () => { policy.dispose() }) + it('rejects scrape status when a Claude hook owns the pane', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('False prompt') + policy.onCommandCodeDone('False prompt') + vi.advanceTimersByTime(DONE_SETTLE_MS) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('rejects scrape status when Claude launch metadata owns the pane', async () => { + mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }] + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('False prompt') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('rejects scrape status when Claude is the foreground process', async () => { + mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { + agent: 'claude', + shellForeground: false + } + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('False prompt') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('lets a current Command Code process reclaim stale Claude ownership', async () => { + mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }] + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' }) + mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { + agent: 'command-code', + shellForeground: false + } + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('New Command Code prompt') + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + PANE_KEY, + { + state: 'working', + prompt: 'New Command Code prompt', + agentType: 'command-code' + }, + '✳ Build feature', + undefined, + ROUTING + ) + policy.dispose() + }) + it('leaves a settled done row alone when working repeats the same prompt', async () => { mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done', diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 781625318bd..c00165a1f38 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -5,6 +5,7 @@ * (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane. */ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' @@ -15,6 +16,7 @@ import { openCommandCodeDoneSettle, setCommandCodeDoneSettleExecutor } from './command-code-done-settle' +import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' export type ParkedTerminalCommandStatusPolicy = { onCommandFinished: (bestEffortExitCode: number | null) => void @@ -60,6 +62,22 @@ export function createParkedTerminalCommandStatusPolicy(options: { }) } + const canApplyCommandCodeOutputStatus = (): boolean => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === tabId) + const foreground = state.paneForegroundAgentByPaneKey[paneKey] + const paneOwnerAgent = resolvePaneAgentOwner({ + launchAgent: tab?.launchAgent, + startupLaunchAgent: state.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType, + hookAgent: state.agentStatusByPaneKey[paneKey]?.agentType + }) + return canCommandCodeOutputOwnPane({ + foregroundAgent: foreground?.agent, + shellForeground: foreground?.shellForeground, + paneOwnerAgent + }) + } + // Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference // option: parked panes receive no key events, so inference never has evidence here. const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => { @@ -145,6 +163,9 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only). onCommandCodeWorking: (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } clearCommandCodeOutputDoneTimer() const routing = resolveRouting() if (!routing) { @@ -178,6 +199,9 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering // the composer while tools run, so only complete the row if no active repaint arrives. onCommandCodeDone: (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } const normalizedPrompt = prompt.trim() if (!normalizedPrompt) { cancelCommandCodeDoneSettle(paneKey) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 8bb448ed11a..c42e1e370e0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -18999,6 +18999,51 @@ describe('connectPanePty', () => { }) }) + it('rejects Command Code facts when Claude owns the pane', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + const claudeStatus = { + paneKey, + state: 'done' as const, + prompt: 'Previous Claude turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude' as const, + stateHistory: [] + } + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: null, launchAgent: 'claude' }] + } + mockStoreState.agentStatusByPaneKey[paneKey] = claudeStatus + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'claude', + shellForeground: false + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-false-cc') + mockStoreState.setAgentStatus.mockClear() + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-false-cc', + seq: 1, + facts: [ + { kind: 'command-code-working', prompt: 'False prompt' }, + { kind: 'command-code-done', prompt: 'False prompt' } + ] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(claudeStatus) + }) + it('keeps Command Code working when a working fact lands before the done settles', async () => { enableMainAuthority() const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b1eb0900865..07cc6d316ae 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -267,6 +267,7 @@ import { openCommandCodeDoneSettle, setCommandCodeDoneSettleExecutor } from './command-code-done-settle' +import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' import { isTerminalTabParked } from './terminal-parked-watcher-registry' import { getExecutionHostIdForWorktree, @@ -2818,7 +2819,19 @@ export function connectPanePty( .setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing) } + const canApplyCommandCodeOutputStatus = (): boolean => { + const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey] + return canCommandCodeOutputOwnPane({ + foregroundAgent: foreground?.agent, + shellForeground: foreground?.shellForeground, + paneOwnerAgent: getAuthoritativePaneAgent() + }) + } + const seedCommandCodeOutputWorkingStatus = (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } clearCommandCodeOutputDoneTimer() const routing = resolveCurrentAgentStatusRouting() if (!routing) { @@ -2885,6 +2898,9 @@ export function connectPanePty( ) const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey) const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } const normalizedPrompt = prompt.trim() if (!normalizedPrompt) { cancelCommandCodeDoneSettle(cacheKey) diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts index a86c89a174b..1e84f97adac 100644 --- a/src/shared/command-code-output-status.test.ts +++ b/src/shared/command-code-output-status.test.ts @@ -74,6 +74,19 @@ describe('createCommandCodeOutputStatusDetector', () => { expect(onWorking).not.toHaveBeenCalled() }) + it('does not arm when another agent merely discusses Command Code', () => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking + }) + + expect(detector.observe('// Why: Command Code exposes transcript prompts')).toBe(false) + expect(detector.observe('\r\n✻ Thinking...')).toBe(false) + + expect(onWorking).not.toHaveBeenCalled() + }) + it.each([ 'Pondering', 'Contemplating', diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts index 41e4c9eefc1..276104c1e4a 100644 --- a/src/shared/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -112,7 +112,7 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp( `(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:Executing:\\s+\\S|Running\\s*\\()` ) const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./ -const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/ +const COMMAND_CODE_BANNER_RE = /(?:^|[\r\n])\s*#\s*Command Code\s+v\d+(?:\.\d+){1,3}\b/ function cleanPromptCandidate(value: string): string { return cleanCommandCodePromptCandidate(stripTerminalControl(value))