diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b4b495c65..85f0124b0 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -135,7 +135,9 @@ export const InputPrompt: React.FC = ({ } = useBackgroundAgentViewState(); const { setPillFocused: setBgPillFocused } = useBackgroundAgentViewActions(); const hasAgents = agents.size > 0; - const hasRunningBgAgents = bgEntries.some((e) => e.status === 'running'); + // Includes terminal entries — the pill stays open so users can reopen + // the dialog to inspect final state after the last agent finishes. + const hasBgAgents = bgEntries.length > 0; const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); @@ -947,7 +949,7 @@ export const InputPrompt: React.FC = ({ setAgentTabBarFocused(true); return true; } - if (hasRunningBgAgents) { + if (hasBgAgents) { setBgPillFocused(true); return true; } @@ -1117,7 +1119,7 @@ export const InputPrompt: React.FC = ({ bgDialogOpen, bgPillFocused, hasAgents, - hasRunningBgAgents, + hasBgAgents, setAgentTabBarFocused, setBgPillFocused, followup, diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index 54d982412..970de4808 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -419,9 +419,11 @@ export const BackgroundTasksDialog: React.FC = ({ }, [dialogOpen, dialogMode, selectedAgentId, selectedStatus]); // Auto-fallback to the list view when the selected agent reaches a - // terminal state while the user is watching the detail view. We only - // exit on the running → terminal *transition* — if the user deliberately - // opened an already-completed entry, they stay on it. + // terminal state while the user is watching it live. We only exit on + // the running → terminal *transition* — if the user deliberately + // opened an already-completed entry, they stay on it. The detail + // view itself renders terminal state fine, so this is a UX choice + // (return focus to the running roster) rather than a correctness fix. const initialDetailStatusRef = useRef<{ agentId: string; status: BackgroundAgentEntry['status']; @@ -431,11 +433,10 @@ export const BackgroundTasksDialog: React.FC = ({ initialDetailStatusRef.current = null; return; } - // The viewed entry disappeared out from under us — most commonly because - // the user pressed `x` and the live-entries filter dropped it once the - // registry flipped its status to terminal. Without this fallback the - // dialog would sit in detail mode with a stranded "No entry to show" - // screen. + // Defensive fallback: if the viewed entry has somehow gone missing, + // drop back to the list so we don't sit on a "No entry to show" screen. + // Hitting this path now is unlikely — terminal entries stay in the + // registry — but the entry could disappear if the registry is reset. if (!selectedAgentId) { initialDetailStatusRef.current = null; exitDetail(); diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx index 09654c621..9c576191c 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx @@ -33,4 +33,29 @@ describe('getPillLabel', () => { ]), ).toBe('3 local agents'); }); + + it('counts only running entries when running and terminal mix', () => { + expect( + getPillLabel([ + entry({ agentId: 'a', status: 'running' }), + entry({ agentId: 'b', status: 'completed' }), + entry({ agentId: 'c', status: 'cancelled' }), + ]), + ).toBe('1 local agent'); + }); + + it('uses singular done form for one terminal-only entry', () => { + expect(getPillLabel([entry({ agentId: 'a', status: 'completed' })])).toBe( + '1 local agent done', + ); + }); + + it('uses plural done form when all entries are terminal', () => { + expect( + getPillLabel([ + entry({ agentId: 'a', status: 'completed' }), + entry({ agentId: 'b', status: 'failed' }), + ]), + ).toBe('2 local agents done'); + }); }); diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx index 6396aa327..2ae78abc6 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx @@ -15,16 +15,24 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import type { BackgroundAgentEntry } from '@qwen-code/qwen-code-core'; -/** Single source of truth for pluralising the pill label. */ -export function getPillLabel(running: readonly BackgroundAgentEntry[]): string { - const n = running.length; - return n === 1 ? '1 local agent' : `${n} local agents`; +/** + * Pill label: counts running entries while any are running; once everything + * has terminated, switches to a "done" form so the pill still invites + * reopening the dialog to inspect final state. + */ +export function getPillLabel(entries: readonly BackgroundAgentEntry[]): string { + const running = entries.filter((e) => e.status === 'running').length; + if (running > 0) { + return running === 1 ? '1 local agent' : `${running} local agents`; + } + return entries.length === 1 + ? '1 local agent done' + : `${entries.length} local agents done`; } export const BackgroundTasksPill: React.FC = () => { const { entries, pillFocused } = useBackgroundAgentViewState(); const { openDialog, setPillFocused } = useBackgroundAgentViewActions(); - const running = entries.filter((e) => e.status === 'running'); const onKeypress = useCallback( (key: Key) => { @@ -46,9 +54,9 @@ export const BackgroundTasksPill: React.FC = () => { useKeypress(onKeypress, { isActive: pillFocused }); - if (running.length === 0) return null; + if (entries.length === 0) return null; - const label = getPillLabel(running); + const label = getPillLabel(entries); return ( <> diff --git a/packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx b/packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx index 873cce847..0cf8cd2aa 100644 --- a/packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx +++ b/packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx @@ -115,14 +115,15 @@ export function BackgroundAgentViewProvider({ const [dialogMode, setDialogMode] = useState('closed'); const [pillFocused, setPillFocused] = useState(false); const dialogOpen = dialogMode !== 'closed'; - const hasRunning = entries.some((e) => e.status === 'running'); + const hasEntries = entries.length > 0; - // Drop stale pill focus as soon as the pill loses its reason to exist — - // without this, InputPrompt's input-blocking branch would stay on after - // the last running agent finishes. + // Drop stale pill focus once the pill itself unmounts — i.e., when the + // registry is empty. The pill stays rendered while terminal entries + // exist (so the user can reopen the dialog post-termination), so we + // intentionally do *not* drop focus on the running → terminal flip. useEffect(() => { - if (pillFocused && !hasRunning) setPillFocused(false); - }, [pillFocused, hasRunning]); + if (pillFocused && !hasEntries) setPillFocused(false); + }, [pillFocused, hasEntries]); // rawSelectedIndex can fall out of range when entries shrink; clamp on read. const selectedIndex = diff --git a/packages/cli/src/ui/hooks/useBackgroundAgentView.ts b/packages/cli/src/ui/hooks/useBackgroundAgentView.ts index f523c51f2..92ed5059e 100644 --- a/packages/cli/src/ui/hooks/useBackgroundAgentView.ts +++ b/packages/cli/src/ui/hooks/useBackgroundAgentView.ts @@ -7,7 +7,9 @@ /** * useBackgroundAgentView — subscribes to the background task registry's * status-change callback and maintains a reactive snapshot of every - * `BackgroundAgentEntry`. + * `BackgroundAgentEntry`, including terminal ones. Surfaces that only + * care about live work (the footer pill, the composer's Down-arrow + * route) filter for `running` themselves. * * Intentionally ignores activity updates (appendActivity). Tool-call * traffic from a running background agent would otherwise churn the @@ -26,17 +28,6 @@ export interface UseBackgroundAgentViewResult { entries: readonly BackgroundAgentEntry[]; } -// The registry keeps terminal entries for the whole session so the model -// can still read their transcripts and the notification path stays deduped. -// The dialog only surfaces live work, so filter terminal statuses out here -// — otherwise "0 active agents" in the header would disagree with -// "Local agents (N)" in the list once anything finishes. -function selectLiveEntries( - all: readonly BackgroundAgentEntry[], -): BackgroundAgentEntry[] { - return all.filter((e) => e.status === 'running'); -} - export function useBackgroundAgentView( config: Config | null, ): UseBackgroundAgentViewResult { @@ -46,12 +37,11 @@ export function useBackgroundAgentView( if (!config) return; const registry = config.getBackgroundTaskRegistry(); - // getAll() returns entries in registration order, which is startTime - // order — no sort needed. - setEntries(selectLiveEntries(registry.getAll())); + // getAll() returns a fresh array in registration (= startTime) order. + setEntries(registry.getAll()); const onStatusChange = () => { - setEntries(selectLiveEntries(registry.getAll())); + setEntries(registry.getAll()); }; registry.setStatusChangeCallback(onStatusChange);