From fcf62011c32f6aa304900cb0f29ed4b3a09c7398 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 21 Apr 2026 06:31:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20background-agent=20UI=20=E2=80=94?= =?UTF-8?q?=20pill,=20combined=20dialog,=20detail=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces background agents in the TUI with a combined pill/dialog pattern and a responsive detail view that splits or full-swaps based on terminal height. Transcript state (messages, pending approvals, live outputs, shell PIDs) moves from AgentInteractive into AgentCore so both Arena and background agents render through a shared AgentChatContent component without duplicating renderer logic. --- packages/cli/src/gemini.tsx | 17 +- packages/cli/src/nonInteractiveCli.test.ts | 2 +- packages/cli/src/ui/AppContainer.tsx | 11 +- .../cli/src/ui/components/DialogManager.tsx | 12 + packages/cli/src/ui/components/Footer.tsx | 6 +- .../cli/src/ui/components/InputPrompt.tsx | 40 ++- .../agent-view/AgentChatContent.tsx | 259 ++++++++++++++ .../components/agent-view/AgentChatView.tsx | 225 +------------ .../ui/components/agent-view/AgentTabBar.tsx | 26 +- .../background-view/BackgroundTasksDialog.tsx | 315 ++++++++++++++++++ .../BackgroundTasksPill.test.tsx | 40 +++ .../background-view/BackgroundTasksPill.tsx | 36 ++ .../runtime/AgentExecutionDisplay.tsx | 6 + .../contexts/BackgroundAgentViewContext.tsx | 208 ++++++++++++ .../src/ui/hooks/useBackgroundAgentView.ts | 62 ++++ packages/cli/src/ui/hooks/useDialogClose.ts | 12 + .../agents/backends/InProcessBackend.test.ts | 78 +++-- .../core/src/agents/background-tasks.test.ts | 186 ++++++++++- packages/core/src/agents/background-tasks.ts | 162 +++++++-- .../core/src/agents/runtime/agent-core.ts | 159 ++++++++- .../core/src/agents/runtime/agent-headless.ts | 13 +- .../agents/runtime/agent-interactive.test.ts | 102 ++++++ .../src/agents/runtime/agent-interactive.ts | 113 ++----- packages/core/src/tools/agent/agent.test.ts | 7 + packages/core/src/tools/agent/agent.ts | 17 + 25 files changed, 1748 insertions(+), 366 deletions(-) create mode 100644 packages/cli/src/ui/components/agent-view/AgentChatContent.tsx create mode 100644 packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx create mode 100644 packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx create mode 100644 packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx create mode 100644 packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx create mode 100644 packages/cli/src/ui/hooks/useBackgroundAgentView.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 5ed93cc26..131f56d55 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -43,6 +43,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; +import { BackgroundAgentViewProvider } from './ui/contexts/BackgroundAgentViewContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { themeManager } from './ui/themes/theme-manager.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; @@ -241,13 +242,15 @@ export async function startInteractiveUI( - + + + diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 727efd083..c12c910bc 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -155,7 +155,7 @@ describe('runNonInteractive', () => { getBackgroundTaskRegistry: vi.fn().mockReturnValue({ setNotificationCallback: vi.fn(), setRegisterCallback: vi.fn(), - getRunning: vi.fn().mockReturnValue([]), + getAll: vi.fn().mockReturnValue([]), hasUnfinalizedAgents: vi.fn().mockReturnValue(false), abortAll: vi.fn(), }), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 96531d179..5b6e19358 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -119,6 +119,10 @@ import { import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; +import { + useBackgroundAgentViewState, + useBackgroundAgentViewActions, +} from './contexts/BackgroundAgentViewContext.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; @@ -835,6 +839,8 @@ export const AppContainer = (props: AppContainerProps) => { const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false); const agentViewState = useAgentViewState(); + const { dialogOpen: bgTasksDialogOpen } = useBackgroundAgentViewState(); + const { closeDialog: closeBgTasksDialog } = useBackgroundAgentViewActions(); // Prompt suggestion state const [promptSuggestion, setPromptSuggestion] = useState(null); @@ -1691,6 +1697,8 @@ export const AppContainer = (props: AppContainerProps) => { isFolderTrustDialogOpen, showWelcomeBackDialog, handleWelcomeBackClose, + isBackgroundTasksDialogOpen: bgTasksDialogOpen, + closeBackgroundTasksDialog: closeBgTasksDialog, }); const handleExit = useCallback( @@ -2009,7 +2017,8 @@ export const AppContainer = (props: AppContainerProps) => { isHooksDialogOpen || isApprovalModeDialogOpen || isResumeDialogOpen || - isExtensionsManagerDialogOpen; + isExtensionsManagerDialogOpen || + bgTasksDialogOpen; dialogsVisibleRef.current = dialogsVisible; const { diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 27d0c5aaa..6102c5b81 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -44,6 +44,8 @@ import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; import { MemoryDialog } from './MemoryDialog.js'; +import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js'; +import { useBackgroundAgentViewState } from '../contexts/BackgroundAgentViewContext.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -60,6 +62,7 @@ export const DialogManager = ({ const uiState = useUIState(); const uiActions = useUIActions(); + const { dialogOpen: bgTasksDialogOpen } = useBackgroundAgentViewState(); const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } = uiState; @@ -383,5 +386,14 @@ export const DialogManager = ({ ); } + // Background tasks dialog — lowest priority so other dialogs + // (permissions, trust prompts, auth, etc.) always take precedence. The + // dialog is part of the shared dialogsVisible machinery (see + // AppContainer) so its visibility mutes the composer and the global + // Ctrl+C / Esc handlers route through `closeAnyOpenDialog`. + if (bgTasksDialogOpen) { + return ; + } + return null; }; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index abe7e4569..aa818b862 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -12,6 +12,7 @@ import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; +import { BackgroundTasksPill } from './background-view/BackgroundTasksPill.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useStatusLine } from '../hooks/useStatusLine.js'; @@ -154,7 +155,10 @@ export const Footer: React.FC = () => { {line} ))} - {leftBottomContent} + + {leftBottomContent} + + {/* Right Section — never compressed, aligns to top so multi-line diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 5bbeba1e0..626cb03f6 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -47,6 +47,10 @@ import { useAgentViewState, useAgentViewActions, } from '../contexts/AgentViewContext.js'; +import { + useBackgroundAgentViewState, + useBackgroundAgentViewActions, +} from '../contexts/BackgroundAgentViewContext.js'; import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js'; import { BaseTextInput } from './BaseTextInput.js'; import type { RenderLineOptions } from './BaseTextInput.js'; @@ -124,7 +128,11 @@ export const InputPrompt: React.FC = ({ const { pasteWorkaround } = useKeypressContext(); const { agents, agentTabBarFocused } = useAgentViewState(); const { setAgentTabBarFocused } = useAgentViewActions(); + const { entries: bgEntries, dialogOpen: bgDialogOpen } = + useBackgroundAgentViewState(); + const { openDialog: openBgDialog } = useBackgroundAgentViewActions(); const hasAgents = agents.size > 0; + const hasBgAgents = bgEntries.length > 0; const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); @@ -425,11 +433,11 @@ export const InputPrompt: React.FC = ({ const handleInput = useCallback( (key: Key): boolean => { - // When the tab bar has focus, block all non-printable keys so arrow - // keys and shortcuts don't interfere. Printable characters fall - // through to BaseTextInput's default handler so the first keystroke - // appears in the input immediately (the tab bar handler releases - // focus on the same event). + // When the Arena tab bar has focus, block non-printable keys so + // arrow keys and shortcuts don't interfere. Printable characters + // fall through to BaseTextInput's default handler so the first + // keystroke appears in the input immediately (the tab bar handler + // releases focus on the same event). if (agentTabBarFocused) { if ( key.sequence && @@ -442,6 +450,16 @@ export const InputPrompt: React.FC = ({ return true; // consume non-printable keys } + // When the Background tasks dialog is open, swallow every key so + // nothing reaches the composer buffer — the dialog's own keypress + // handler owns selection, open/close, and stop actions. Unlike + // the tab bar we do NOT let printable chars type through, because + // the dialog doesn't auto-close on printable input and users + // would leak text into the hidden composer. + if (bgDialogOpen) { + return true; + } + // TODO(jacobr): this special case is likely not needed anymore. // We should probably stop supporting paste if the InputPrompt is not // focused. @@ -897,10 +915,19 @@ export const InputPrompt: React.FC = ({ if (inputHistory.navigateDown()) { return true; } + // Focus order on Down from an empty composer: + // team tab bar (if any Arena agents) → Background tasks dialog + // (if any bg agents) → otherwise stay put. The tab bar itself + // re-routes Down into the bg dialog once it has focus, so both + // surfaces remain reachable in sequence. if (hasAgents) { setAgentTabBarFocused(true); return true; } + if (hasBgAgents) { + openBgDialog(); + return true; + } return true; } } else { @@ -1064,8 +1091,11 @@ export const InputPrompt: React.FC = ({ parsePlaceholder, freePlaceholderId, agentTabBarFocused, + bgDialogOpen, hasAgents, + hasBgAgents, setAgentTabBarFocused, + openBgDialog, followup, onPromptSuggestionDismiss, ], diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx new file mode 100644 index 000000000..c36f22467 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Presentational transcript renderer for a single AgentCore. Subscribes + * to the core's event emitter internally and force-renders on updates, + * so consumers only pass state props and don't wire their own listeners. + */ + +import { Box, Text, Static } from 'ink'; +import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; +import { + AgentStatus, + AgentEventType, + getGitBranch, + type AgentCore, + type AgentStatusChangeEvent, +} from '@qwen-code/qwen-code-core'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; +import { ToolCallStatus } from '../../types.js'; +import { theme } from '../../semantic-colors.js'; +import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; +import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; +import { AgentHeader } from './AgentHeader.js'; + +export interface AgentChatContentProps { + /** The agent's AgentCore — the source of truth for transcript state. */ + core: AgentCore; + /** The agent's current lifecycle status (drives the spinner). */ + status: AgentStatus; + /** Stable identifier used for memo keys and the Static remount key. */ + instanceKey: string; + /** Optional display name shown in the header. */ + modelName?: string; + /** + * Active PTY PID for the embedded shell, if any. Only meaningful for + * Arena (interactive) agents. Pass `null`/omit for read-only surfaces. + */ + activePtyId?: number | null; + /** + * Whether the embedded shell currently has keyboard focus. Only + * meaningful for Arena agents. Pass `false`/omit for read-only surfaces. + */ + embeddedShellFocused?: boolean; + /** + * When true, tool groups in the live area render without the + * activePtyId/embeddedShellFocused props so no interactive shell + * affordance appears. Defaults to `false`. + */ + readonly?: boolean; + /** + * Per-tool wall-clock start timestamps used by the elapsed-time + * indicator. Lives on InteractiveAgent (not AgentCore), so it's + * passed in explicitly. Omit for read-only surfaces with no live + * timing. + */ + executionStartTimes?: ReadonlyMap; +} + +export const AgentChatContent = ({ + core, + status, + instanceKey, + modelName, + activePtyId, + embeddedShellFocused, + readonly = false, + executionStartTimes, +}: AgentChatContentProps) => { + const uiState = useUIState(); + const { historyRemountKey, availableTerminalHeight, constrainHeight } = + uiState; + const { columns: terminalWidth } = useTerminalSize(); + const contentWidth = terminalWidth - 4; + + // Force re-render on message updates and status changes. + // STREAM_TEXT is deliberately excluded — model text is shown only after + // each round completes (via committed messages), avoiding per-chunk re-renders. + const [, setRenderTick] = useState(0); + const tickRef = useRef(0); + const forceRender = useCallback(() => { + tickRef.current += 1; + setRenderTick(tickRef.current); + }, []); + + useEffect(() => { + const emitter = core.getEventEmitter(); + if (!emitter) return; + + const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender(); + const onToolCall = () => forceRender(); + const onToolResult = () => forceRender(); + const onRoundEnd = () => forceRender(); + const onApproval = () => forceRender(); + const onOutputUpdate = () => forceRender(); + const onFinish = () => forceRender(); + + emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.on(AgentEventType.TOOL_CALL, onToolCall); + emitter.on(AgentEventType.TOOL_RESULT, onToolResult); + emitter.on(AgentEventType.ROUND_END, onRoundEnd); + emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); + emitter.on(AgentEventType.FINISH, onFinish); + + return () => { + emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.off(AgentEventType.TOOL_CALL, onToolCall); + emitter.off(AgentEventType.TOOL_RESULT, onToolResult); + emitter.off(AgentEventType.ROUND_END, onRoundEnd); + emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); + emitter.off(AgentEventType.FINISH, onFinish); + }; + }, [core, forceRender]); + + const messages = core.getMessages(); + const pendingApprovals = core.getPendingApprovals(); + const liveOutputs = core.getLiveOutputs(); + const shellPids = core.getShellPids(); + const isRunning = + status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING; + + // tickRef.current in deps ensures we rebuild when events fire even if + // messages.length and pendingApprovals.size haven't changed (e.g. a + // tool result updates an existing entry in place). + const allItems = useMemo( + () => + agentMessagesToHistoryItems( + messages, + pendingApprovals, + liveOutputs, + shellPids, + executionStartTimes, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + instanceKey, + messages.length, + pendingApprovals.size, + liveOutputs.size, + shellPids.size, + executionStartTimes?.size, + tickRef.current, + ], + ); + + // Any tool_group with an Executing or Confirming tool — plus everything + // after it — stays in the live area so confirmation dialogs remain + // interactive (Ink's cannot receive input). + const splitIndex = useMemo(() => { + for (let idx = allItems.length - 1; idx >= 0; idx--) { + const item = allItems[idx]!; + if ( + item.type === 'tool_group' && + item.tools.some( + (t) => + t.status === ToolCallStatus.Executing || + t.status === ToolCallStatus.Confirming, + ) + ) { + return idx; + } + } + return allItems.length; + }, [allItems]); + + const committedItems = allItems.slice(0, splitIndex); + const pendingItems = allItems.slice(splitIndex); + + const agentWorkingDir = core.runtimeContext.getTargetDir() ?? ''; + // Cache the branch — it won't change during the agent's lifetime and + // getGitBranch uses synchronous execSync which blocks the render loop. + const agentGitBranch = useMemo( + () => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''), + // eslint-disable-next-line react-hooks/exhaustive-deps + [instanceKey], + ); + + const agentModelId = core.modelConfig.model ?? ''; + + // readonly surfaces never expose the embedded shell; pass undefined + // so HistoryItemDisplay doesn't render shell-input affordances. + const renderedActivePtyId = readonly ? null : (activePtyId ?? null); + const renderedEmbeddedShellFocused = readonly + ? false + : (embeddedShellFocused ?? false); + + return ( + + {/* Committed message history. + key includes historyRemountKey: when refreshStatic() clears the + terminal it bumps the key, forcing Static to remount and re-emit + all items on the cleared screen. */} + , + ...committedItems.map((item) => ( + + )), + ]} + > + {(item) => item} + + + {/* Live area — tool groups awaiting confirmation or still executing. + Must remain outside Static so confirmation dialogs are interactive. */} + {pendingItems.map((item) => ( + + ))} + + {/* Spinner */} + {isRunning && ( + + + + )} + + ); +}; + +// Re-exported helper for consumers that render an error panel when the +// backing agent/core isn't available (e.g. a race where the registry +// entry exists but `core` hasn't been attached yet). +export const AgentChatMissing = ({ label }: { label: string }) => ( + + {label} + +); diff --git a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx index 55389bc5e..f1253029b 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx @@ -5,45 +5,18 @@ */ /** - * @fileoverview AgentChatView — displays a single in-process agent's conversation. - * - * Renders the agent's message history using HistoryItemDisplay — the same - * component used by the main agent view. AgentMessage[] is converted to - * HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types - * are available without duplicating rendering logic. - * - * Layout: - * - Static area: finalized messages (efficient Ink ) - * - Live area: tool groups still executing / awaiting confirmation - * - Status line: spinner while the agent is running - * - * Model text output is shown only after each round completes (no live - * streaming), which avoids per-chunk re-renders and keeps the display simple. + * Arena wrapper around AgentChatContent. Owns the Ctrl+F embedded-shell + * focus toggle and resolves the selected agent from AgentViewContext. */ -import { Box, Text, Static } from 'ink'; -import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; -import { - AgentStatus, - AgentEventType, - getGitBranch, - type AgentStatusChangeEvent, -} from '@qwen-code/qwen-code-core'; +import { useState, useEffect } from 'react'; +import { AgentStatus } from '@qwen-code/qwen-code-core'; import { useAgentViewState, useAgentViewActions, } from '../../contexts/AgentViewContext.js'; -import { useUIState } from '../../contexts/UIStateContext.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; -import { ToolCallStatus } from '../../types.js'; -import { theme } from '../../semantic-colors.js'; -import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; import { useKeypress } from '../../hooks/useKeypress.js'; -import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; -import { AgentHeader } from './AgentHeader.js'; - -// ─── Main Component ───────────────────────────────────────── +import { AgentChatContent, AgentChatMissing } from './AgentChatContent.js'; interface AgentChatViewProps { agentId: string; @@ -52,68 +25,19 @@ interface AgentChatViewProps { export const AgentChatView = ({ agentId }: AgentChatViewProps) => { const { agents } = useAgentViewState(); const { setAgentShellFocused } = useAgentViewActions(); - const uiState = useUIState(); - const { historyRemountKey, availableTerminalHeight, constrainHeight } = - uiState; - const { columns: terminalWidth } = useTerminalSize(); const agent = agents.get(agentId); - const contentWidth = terminalWidth - 4; - - // Force re-render on message updates and status changes. - // STREAM_TEXT is deliberately excluded — model text is shown only after - // each round completes (via committed messages), avoiding per-chunk re-renders. - const [, setRenderTick] = useState(0); - const tickRef = useRef(0); - const forceRender = useCallback(() => { - tickRef.current += 1; - setRenderTick(tickRef.current); - }, []); - - useEffect(() => { - if (!agent) return; - - const emitter = agent.interactiveAgent.getEventEmitter(); - if (!emitter) return; - - const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender(); - const onToolCall = () => forceRender(); - const onToolResult = () => forceRender(); - const onRoundEnd = () => forceRender(); - const onApproval = () => forceRender(); - const onOutputUpdate = () => forceRender(); - - emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange); - emitter.on(AgentEventType.TOOL_CALL, onToolCall); - emitter.on(AgentEventType.TOOL_RESULT, onToolResult); - emitter.on(AgentEventType.ROUND_END, onRoundEnd); - emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); - emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); - - return () => { - emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange); - emitter.off(AgentEventType.TOOL_CALL, onToolCall); - emitter.off(AgentEventType.TOOL_RESULT, onToolResult); - emitter.off(AgentEventType.ROUND_END, onRoundEnd); - emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); - emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); - }; - }, [agent, forceRender]); const interactiveAgent = agent?.interactiveAgent; - const messages = interactiveAgent?.getMessages() ?? []; - const pendingApprovals = interactiveAgent?.getPendingApprovals(); - const liveOutputs = interactiveAgent?.getLiveOutputs(); + const core = interactiveAgent?.getCore(); + const status = interactiveAgent?.getStatus() ?? AgentStatus.INITIALIZING; const shellPids = interactiveAgent?.getShellPids(); const executionStartTimes = interactiveAgent?.getExecutionStartTimes(); - const status = interactiveAgent?.getStatus(); - const isRunning = - status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING; // Derive the active PTY PID: first shell PID among currently-executing tools. // Resets naturally to undefined when the tool finishes (shellPids cleared). const activePtyId = shellPids && shellPids.size > 0 - ? shellPids.values().next().value + ? (shellPids.values().next().value as number | undefined) : undefined; // Track whether the user has toggled input focus into the embedded shell. @@ -144,132 +68,19 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { { isActive: true }, ); - // Convert AgentMessage[] → HistoryItem[] via adapter. - // tickRef.current in deps ensures we rebuild when events fire even if - // messages.length and pendingApprovals.size haven't changed (e.g. a - // tool result updates an existing entry in place). - const allItems = useMemo( - () => - agentMessagesToHistoryItems( - messages, - pendingApprovals ?? new Map(), - liveOutputs, - shellPids, - executionStartTimes, - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - agentId, - messages.length, - pendingApprovals?.size, - liveOutputs?.size, - shellPids?.size, - executionStartTimes?.size, - tickRef.current, - ], - ); - - // Split into committed (Static) and pending (live area). - // Any tool_group with an Executing or Confirming tool — plus everything - // after it — stays in the live area so confirmation dialogs remain - // interactive (Ink's cannot receive input). - const splitIndex = useMemo(() => { - for (let idx = allItems.length - 1; idx >= 0; idx--) { - const item = allItems[idx]!; - if ( - item.type === 'tool_group' && - item.tools.some( - (t) => - t.status === ToolCallStatus.Executing || - t.status === ToolCallStatus.Confirming, - ) - ) { - return idx; - } - } - return allItems.length; // all committed - }, [allItems]); - - const committedItems = allItems.slice(0, splitIndex); - const pendingItems = allItems.slice(splitIndex); - - const core = interactiveAgent?.getCore(); - const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? ''; - // Cache the branch — it won't change during the agent's lifetime and - // getGitBranch uses synchronous execSync which blocks the render loop. - const agentGitBranch = useMemo( - () => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''), - // eslint-disable-next-line react-hooks/exhaustive-deps - [agentId], - ); - if (!agent || !interactiveAgent || !core) { - return ( - - - Agent "{agentId}" not found. - - - ); + return ; } - const agentModelId = core.modelConfig.model ?? ''; - return ( - - {/* Committed message history. - key includes historyRemountKey: when refreshStatic() clears the - terminal it bumps the key, forcing Static to remount and re-emit - all items on the cleared screen. */} - , - ...committedItems.map((item) => ( - - )), - ]} - > - {(item) => item} - - - {/* Live area — tool groups awaiting confirmation or still executing. - Must remain outside Static so confirmation dialogs are interactive. - Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */} - {pendingItems.map((item) => ( - - ))} - - {/* Spinner */} - {isRunning && ( - - - - )} - + ); }; diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx index c7b0b113c..efc0215ae 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -26,6 +26,10 @@ import { useAgentViewActions, type RegisteredAgent, } from '../../contexts/AgentViewContext.js'; +import { + useBackgroundAgentViewState, + useBackgroundAgentViewActions, +} from '../../contexts/BackgroundAgentViewContext.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useUIState } from '../../contexts/UIStateContext.js'; import { theme } from '../../semantic-colors.js'; @@ -59,9 +63,16 @@ function statusIndicator(agent: RegisteredAgent): { export const AgentTabBar: React.FC = () => { const { activeView, agents, agentShellFocused, agentTabBarFocused } = useAgentViewState(); - const { switchToNext, switchToPrevious, setAgentTabBarFocused } = - useAgentViewActions(); + const { + switchToNext, + switchToPrevious, + switchToMain, + setAgentTabBarFocused, + } = useAgentViewActions(); + const { entries: bgEntries } = useBackgroundAgentViewState(); + const { openDialog: openBgDialog } = useBackgroundAgentViewActions(); const { embeddedShellFocused } = useUIState(); + const hasBgAgents = bgEntries.length > 0; useKeypress( (key) => { @@ -74,6 +85,17 @@ export const AgentTabBar: React.FC = () => { switchToNext(); } else if (key.name === 'up') { setAgentTabBarFocused(false); + } else if (key.name === 'down') { + // Down cascades to the Background tasks dialog if any background + // agents exist. Switch to main first — DialogManager only mounts + // in the main-view branch of DefaultAppLayout, so opening the + // dialog while an agent tab is active would leave the user in a + // hidden-modal state. + if (hasBgAgents) { + setAgentTabBarFocused(false); + switchToMain(); + openBgDialog(); + } } else if ( key.sequence && key.sequence.length === 1 && diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx new file mode 100644 index 000000000..5dfe7e5f0 --- /dev/null +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -0,0 +1,315 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * BackgroundTasksDialog — overlay with two modes (`list`, `detail`). + * Key handling is scoped to this component; the composer is muted via + * the `bgDialogOpen` branch in InputPrompt while the dialog is open. + */ + +import type React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { + useBackgroundAgentViewState, + useBackgroundAgentViewActions, +} from '../../contexts/BackgroundAgentViewContext.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { theme } from '../../semantic-colors.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { + buildBackgroundEntryLabel, + type BackgroundAgentEntry, +} from '@qwen-code/qwen-code-core'; +import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; + +function statusSuffix(entry: BackgroundAgentEntry): string { + switch (entry.status) { + case 'running': + return '(running)'; + case 'completed': + return '(done)'; + case 'failed': + return '(failed)'; + case 'cancelled': + return '(stopped)'; + default: + return ''; + } +} + +function rowLabel(entry: BackgroundAgentEntry): string { + return buildBackgroundEntryLabel(entry, { includePrefix: false }); +} + +function elapsedFor(entry: BackgroundAgentEntry): string { + return formatDuration( + Math.max(0, (entry.endTime ?? Date.now()) - entry.startTime), + ); +} + +// ─── List mode ───────────────────────────────────────────── + +const ListBody: React.FC<{ + entries: readonly BackgroundAgentEntry[]; + selectedIndex: number; +}> = ({ entries, selectedIndex }) => { + if (entries.length === 0) { + return ( + + No tasks currently running + + ); + } + + const running = entries.filter((e) => e.status === 'running').length; + + return ( + + + + {running} active {running === 1 ? 'agent' : 'agents'} + + + + + Local agents + ({entries.length}) + + {entries.map((entry, idx) => { + const isSelected = idx === selectedIndex; + return ( + + + + {isSelected ? '\u203A ' : ' '} + + + + {rowLabel(entry)} + + {statusSuffix(entry)} + + ); + })} + + + ); +}; + +// ─── Detail mode ─────────────────────────────────────────── + +const DetailBody: React.FC<{ entry: BackgroundAgentEntry }> = ({ entry }) => { + const title = `${entry.subagentType ?? 'Agent'} \u203A ${rowLabel(entry)}`; + + const subtitleParts: string[] = []; + if (entry.status !== 'running') { + subtitleParts.push( + entry.status === 'completed' + ? 'Completed' + : entry.status === 'failed' + ? 'Failed' + : 'Stopped', + ); + } + subtitleParts.push(elapsedFor(entry)); + if (entry.stats?.totalTokens) { + subtitleParts.push(`${formatTokenCount(entry.stats.totalTokens)} tokens`); + } + if (entry.stats?.toolUses !== undefined) { + subtitleParts.push( + `${entry.stats.toolUses} tool${entry.stats.toolUses === 1 ? '' : 's'}`, + ); + } + + const activities = entry.recentActivities ?? []; + + return ( + + + {title} + + {subtitleParts.join(' \u00B7 ')} + + {activities.length > 0 && ( + + Progress + {activities + .slice() + .reverse() + .map((a, i) => ( + + + {i === 0 ? '\u203A ' : ' '} + + {a.name} + {a.description ? ( + {a.description} + ) : null} + + ))} + + )} + + {entry.prompt && ( + + Prompt + {entry.prompt} + + )} + + {entry.status === 'failed' && entry.error && ( + + + Error + + + {entry.error} + + + )} + + ); +}; + +// ─── Dialog shell ────────────────────────────────────────── + +export const BackgroundTasksDialog: React.FC = () => { + const { entries, selectedIndex, dialogOpen, dialogMode } = + useBackgroundAgentViewState(); + const { + moveSelectionUp, + moveSelectionDown, + closeDialog, + enterDetail, + exitDetail, + cancelSelected, + } = useBackgroundAgentViewActions(); + const config = useConfig(); + + const selectedEntry = useMemo( + () => entries[selectedIndex] ?? null, + [entries, selectedIndex], + ); + + // Tick up a local counter on each activity callback to force the + // detail body to re-render while it's open. The main status + // subscription in useBackgroundAgentView intentionally ignores + // activity updates so the Footer pill and AppContainer don't re-run + // on every tool call a background agent makes. + const [, bumpActivity] = useState(0); + useEffect(() => { + if (!dialogOpen || dialogMode !== 'detail') return; + const registry = config.getBackgroundTaskRegistry(); + const onActivity = () => bumpActivity((n) => n + 1); + registry.setActivityChangeCallback(onActivity); + return () => registry.setActivityChangeCallback(undefined); + }, [dialogOpen, dialogMode, config]); + + useKeypress( + (key) => { + if (!dialogOpen) return; + + if (dialogMode === 'list') { + if (key.name === 'up') { + moveSelectionUp(); + return; + } + if (key.name === 'down') { + moveSelectionDown(); + return; + } + if (key.name === 'return') { + if (selectedEntry) enterDetail(); + return; + } + if (key.name === 'escape' || key.name === 'left') { + closeDialog(); + return; + } + if (key.sequence === 'x' && !key.ctrl && !key.meta) { + cancelSelected(); + return; + } + // Note: the "stop all agents" chord (ctrl+x ctrl+k in claw-code) + // is intentionally deferred. `useKeypress` fires per keystroke, + // so collapsing the chord to plain ctrl+k makes a destructive + // action too easy to trigger by mistake. Stop-all will land in + // a follow-up PR once proper chord handling is in place. + return; + } + + // detail mode + if (key.name === 'left') { + exitDetail(); + return; + } + if ( + key.name === 'escape' || + key.name === 'return' || + key.name === 'space' + ) { + closeDialog(); + return; + } + if (key.sequence === 'x' && !key.ctrl && !key.meta) { + cancelSelected(); + return; + } + }, + { isActive: dialogOpen }, + ); + + if (!dialogOpen) return null; + + // Hint footer — context-sensitive. + const hints: string[] = []; + if (dialogMode === 'list') { + hints.push('\u2191/\u2193 select', 'Enter view'); + if (selectedEntry?.status === 'running') hints.push('x stop'); + hints.push('\u2190/Esc close'); + } else { + hints.push('\u2190 go back', 'Esc/Enter/Space close'); + if (selectedEntry?.status === 'running') hints.push('x stop'); + } + + return ( + + + + Background tasks + + + + {dialogMode === 'list' ? ( + + ) : selectedEntry ? ( + + ) : ( + + No entry to show. + + )} + + + {hints.join(' \u00B7 ')} + + + ); +}; diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx new file mode 100644 index 000000000..a7287ca3b --- /dev/null +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import type { BackgroundAgentEntry } from '@qwen-code/qwen-code-core'; +import { getPillLabel } from './BackgroundTasksPill.js'; + +function entry(overrides: Partial): BackgroundAgentEntry { + return { + agentId: 'a', + description: 'desc', + status: 'running', + startTime: 0, + abortController: new AbortController(), + ...overrides, + }; +} + +describe('getPillLabel', () => { + it('returns empty string for no running entries', () => { + expect(getPillLabel([])).toBe(''); + }); + + it('uses singular form for one running agent', () => { + expect(getPillLabel([entry({ agentId: 'a' })])).toBe('1 local agent'); + }); + + it('uses plural form for multiple running agents', () => { + expect( + getPillLabel([ + entry({ agentId: 'a' }), + entry({ agentId: 'b' }), + entry({ agentId: 'c' }), + ]), + ).toBe('3 local agents'); + }); +}); diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx new file mode 100644 index 000000000..889cb2f7b --- /dev/null +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { useBackgroundAgentViewState } from '../../contexts/BackgroundAgentViewContext.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; + if (n === 0) return ''; + return n === 1 ? '1 local agent' : `${n} local agents`; +} + +export const BackgroundTasksPill: React.FC = () => { + const { entries } = useBackgroundAgentViewState(); + const running = entries.filter((e) => e.status === 'running'); + if (running.length === 0) return null; + + const label = getPillLabel(running); + + return ( + + · + + {label} + + · ↓ to view + + ); +}; diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index c0a7d6506..a82d0fe1e 100644 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -73,6 +73,10 @@ const getStatusText = (status: AgentResultDisplay['status']) => { } }; +const BackgroundManageHint: React.FC = () => ( + (↓ to manage) +); + const MAX_TOOL_CALLS = 5; const MAX_TASK_PROMPT_LINES = 5; @@ -150,6 +154,7 @@ export const AgentExecutionDisplay: React.FC = ({ + {data.status === 'background' && } )} @@ -231,6 +236,7 @@ export const AgentExecutionDisplay: React.FC = ({ + {data.status === 'background' && } {/* Task description */} diff --git a/packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx b/packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx new file mode 100644 index 000000000..298f4a569 --- /dev/null +++ b/packages/cli/src/ui/contexts/BackgroundAgentViewContext.tsx @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * BackgroundAgentViewContext — React state for the Background tasks + * dialog. Subscription plumbing (registry callbacks → entries) lives in + * `useBackgroundAgentView`, invoked once here so it owns the single-slot + * `setStatusChangeCallback` for the TUI's lifetime. + */ + +import { + createContext, + useContext, + useCallback, + useMemo, + useState, +} from 'react'; +import { + type BackgroundAgentEntry, + type Config, +} from '@qwen-code/qwen-code-core'; +import { useBackgroundAgentView } from '../hooks/useBackgroundAgentView.js'; + +// ─── Types ────────────────────────────────────────────────── + +export type BackgroundDialogMode = 'closed' | 'list' | 'detail'; + +export interface BackgroundAgentViewState { + /** Live snapshot of every background agent entry, ordered by startTime. */ + entries: readonly BackgroundAgentEntry[]; + /** Index into `entries` for the currently focused row (0-based). */ + selectedIndex: number; + /** `'closed'` when the overlay isn't mounted; otherwise the active mode. */ + dialogMode: BackgroundDialogMode; + /** Convenience boolean: `dialogMode !== 'closed'`. */ + dialogOpen: boolean; +} + +export interface BackgroundAgentViewActions { + setSelectedIndex(index: number): void; + moveSelectionUp(): boolean; + moveSelectionDown(): boolean; + openDialog(): void; + closeDialog(): void; + enterDetail(): void; + exitDetail(): void; + /** Cancel the currently selected entry (no-op if not running). */ + cancelSelected(): void; +} + +// ─── Context ──────────────────────────────────────────────── + +export const BackgroundAgentViewStateContext = + createContext(null); +export const BackgroundAgentViewActionsContext = + createContext(null); + +// ─── Defaults (used when no provider is mounted) ──────────── + +const DEFAULT_STATE: BackgroundAgentViewState = { + entries: [], + selectedIndex: 0, + dialogMode: 'closed', + dialogOpen: false, +}; + +const noop = () => {}; +const noopBool = () => false; + +const DEFAULT_ACTIONS: BackgroundAgentViewActions = { + setSelectedIndex: noop, + moveSelectionUp: noopBool, + moveSelectionDown: noopBool, + openDialog: noop, + closeDialog: noop, + enterDetail: noop, + exitDetail: noop, + cancelSelected: noop, +}; + +// ─── Hooks ────────────────────────────────────────────────── + +export function useBackgroundAgentViewState(): BackgroundAgentViewState { + return useContext(BackgroundAgentViewStateContext) ?? DEFAULT_STATE; +} + +export function useBackgroundAgentViewActions(): BackgroundAgentViewActions { + return useContext(BackgroundAgentViewActionsContext) ?? DEFAULT_ACTIONS; +} + +// ─── Provider ─────────────────────────────────────────────── + +interface BackgroundAgentViewProviderProps { + config?: Config; + children: React.ReactNode; +} + +export function BackgroundAgentViewProvider({ + config, + children, +}: BackgroundAgentViewProviderProps) { + const { entries } = useBackgroundAgentView(config ?? null); + + const [rawSelectedIndex, setRawSelectedIndex] = useState(0); + const [dialogMode, setDialogMode] = useState('closed'); + const dialogOpen = dialogMode !== 'closed'; + + // Single clamp on read — `rawSelectedIndex` can fall out of range when + // entries shrink between renders. + const selectedIndex = + entries.length === 0 + ? 0 + : Math.min(Math.max(0, rawSelectedIndex), entries.length - 1); + + const setSelectedIndex = useCallback( + (index: number) => { + if (entries.length === 0) return; + setRawSelectedIndex(Math.max(0, Math.min(entries.length - 1, index))); + }, + [entries.length], + ); + + const moveSelectionUp = useCallback((): boolean => { + if (selectedIndex <= 0) return false; + setRawSelectedIndex(selectedIndex - 1); + return true; + }, [selectedIndex]); + + const moveSelectionDown = useCallback((): boolean => { + if (entries.length === 0) return false; + if (selectedIndex >= entries.length - 1) return false; + setRawSelectedIndex(selectedIndex + 1); + return true; + }, [entries.length, selectedIndex]); + + const openDialog = useCallback(() => { + setDialogMode('list'); + }, []); + + const closeDialog = useCallback(() => { + setDialogMode('closed'); + }, []); + + const enterDetail = useCallback(() => { + if (entries.length === 0) return; + setDialogMode('detail'); + }, [entries.length]); + + const exitDetail = useCallback(() => { + setDialogMode('list'); + }, []); + + const cancelSelected = useCallback(() => { + if (!config) return; + const target = entries[selectedIndex]; + if (!target) return; + try { + // cancel() is a no-op for non-running entries, so no pre-check here. + config.getBackgroundTaskRegistry().cancel(target.agentId); + } catch { + // Registry unavailable — ignore. The dialog stays open. + } + }, [config, entries, selectedIndex]); + + const state: BackgroundAgentViewState = useMemo( + () => ({ + entries, + selectedIndex, + dialogMode, + dialogOpen, + }), + [entries, selectedIndex, dialogMode, dialogOpen], + ); + + const actions: BackgroundAgentViewActions = useMemo( + () => ({ + setSelectedIndex, + moveSelectionUp, + moveSelectionDown, + openDialog, + closeDialog, + enterDetail, + exitDetail, + cancelSelected, + }), + [ + setSelectedIndex, + moveSelectionUp, + moveSelectionDown, + openDialog, + closeDialog, + enterDetail, + exitDetail, + cancelSelected, + ], + ); + + return ( + + + {children} + + + ); +} diff --git a/packages/cli/src/ui/hooks/useBackgroundAgentView.ts b/packages/cli/src/ui/hooks/useBackgroundAgentView.ts new file mode 100644 index 000000000..ac4a67b0a --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundAgentView.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * useBackgroundAgentView — subscribes to the background task registry's + * status-change callback and maintains a reactive snapshot of every + * `BackgroundAgentEntry`. + * + * Intentionally ignores activity updates (appendActivity). Tool-call + * traffic from a running background agent would otherwise churn the + * Footer pill and the AppContainer every few hundred ms. The detail + * dialog subscribes to the activity callback directly when it needs + * live Progress updates. + */ + +import { useState, useEffect } from 'react'; +import { + type BackgroundAgentEntry, + type Config, +} from '@qwen-code/qwen-code-core'; + +export interface UseBackgroundAgentViewResult { + entries: readonly BackgroundAgentEntry[]; +} + +export function useBackgroundAgentView( + config: Config | null, +): UseBackgroundAgentViewResult { + const [entries, setEntries] = useState([]); + + useEffect(() => { + if (!config) return; + const registry = config.getBackgroundTaskRegistry(); + + setEntries(sortEntries(registry.getAll())); + + // Every statusChange callback rebuilds the entries array. The registry + // mutates entries in place (e.g. finalizeCancelled attaches final stats + // while keeping status='cancelled'), so a dedupe keyed on status alone + // would drop those follow-up updates and leave open detail views stale. + // Status-change events are infrequent enough (register/complete/fail/ + // cancel/finalize) that skipping dedupe costs nothing. + const onStatusChange = () => { + setEntries(sortEntries(registry.getAll())); + }; + + registry.setStatusChangeCallback(onStatusChange); + + return () => { + registry.setStatusChangeCallback(undefined); + }; + }, [config]); + + return { entries }; +} + +function sortEntries(entries: BackgroundAgentEntry[]): BackgroundAgentEntry[] { + return [...entries].sort((a, b) => a.startTime - b.startTime); +} diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 2f8f8828b..ba1200294 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -57,6 +57,10 @@ export interface DialogCloseOptions { // Welcome back dialog showWelcomeBackDialog: boolean; handleWelcomeBackClose: () => void; + + // Background tasks dialog + isBackgroundTasksDialogOpen: boolean; + closeBackgroundTasksDialog: () => void; } /** @@ -114,6 +118,14 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.isBackgroundTasksDialogOpen) { + // Background tasks dialog — routed through closeAnyOpenDialog so + // Ctrl+C and the global escape path dismiss it without escalating + // to exit prompts. + options.closeBackgroundTasksDialog(); + return true; + } + // No dialog was open return false; }, [options]); diff --git a/packages/core/src/agents/backends/InProcessBackend.test.ts b/packages/core/src/agents/backends/InProcessBackend.test.ts index 952862e91..ba96f8313 100644 --- a/packages/core/src/agents/backends/InProcessBackend.test.ts +++ b/packages/core/src/agents/backends/InProcessBackend.test.ts @@ -21,34 +21,64 @@ vi.mock('../../core/contentGenerator.js', () => ({ }), })); -// Mock AgentCore and AgentInteractive to avoid real model calls +// Mock AgentCore and AgentInteractive to avoid real model calls. +// The mock must also expose the observable-state accessors that +// AgentInteractive now delegates to (getMessages, pendingApprovals, +// liveOutputs, shellPids, pushMessage, etc.) — otherwise agent lifecycle +// methods like abort() / addMessage() fail on missing prototype methods. vi.mock('../runtime/agent-core.js', () => ({ - AgentCore: vi.fn().mockImplementation(() => ({ - subagentId: 'mock-id', - name: 'mock-agent', - eventEmitter: { + AgentCore: vi.fn().mockImplementation(() => { + const messages: Array> = []; + const pendingApprovals = new Map(); + const liveOutputs = new Map(); + const shellPids = new Map(); + const emitter = { on: vi.fn(), off: vi.fn(), emit: vi.fn(), - }, - stats: { - start: vi.fn(), - getSummary: vi.fn().mockReturnValue({}), - }, - createChat: vi.fn().mockResolvedValue({}), - prepareTools: vi.fn().mockReturnValue([]), - runReasoningLoop: vi.fn().mockResolvedValue({ - text: 'Done', - terminateMode: null, - turnsUsed: 1, - }), - getEventEmitter: vi.fn().mockReturnValue({ - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - }), - getExecutionSummary: vi.fn().mockReturnValue({}), - })), + }; + return { + subagentId: 'mock-id', + name: 'mock-agent', + eventEmitter: emitter, + stats: { + start: vi.fn(), + getSummary: vi.fn().mockReturnValue({}), + }, + createChat: vi.fn().mockResolvedValue({}), + prepareTools: vi.fn().mockReturnValue([]), + runReasoningLoop: vi.fn().mockResolvedValue({ + text: 'Done', + terminateMode: null, + turnsUsed: 1, + }), + getEventEmitter: vi.fn().mockReturnValue(emitter), + getExecutionSummary: vi.fn().mockReturnValue({}), + getMessages: () => messages, + getPendingApprovals: () => pendingApprovals, + getLiveOutputs: () => liveOutputs, + getShellPids: () => shellPids, + pushMessage: ( + role: string, + content: string, + options?: { thought?: boolean; metadata?: Record }, + ) => { + const message: Record = { + role, + content, + timestamp: Date.now(), + }; + if (options?.thought) message['thought'] = true; + if (options?.metadata) message['metadata'] = options.metadata; + messages.push(message); + }, + setPendingApproval: (callId: string, details: unknown) => + pendingApprovals.set(callId, details), + deletePendingApproval: (callId: string) => + pendingApprovals.delete(callId), + clearPendingApprovals: () => pendingApprovals.clear(), + }; + }), })); function createMockToolRegistry() { diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 5ff207c14..71f5b44b5 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -218,7 +218,7 @@ describe('BackgroundTaskRegistry', () => { registry.complete('a', 'done'); - const running = registry.getRunning(); + const running = registry.getAll().filter((e) => e.status === 'running'); expect(running).toHaveLength(1); expect(running[0].agentId).toBe('b'); }); @@ -429,6 +429,190 @@ describe('BackgroundTaskRegistry', () => { expect(meta.toolUseId).toBeUndefined(); }); + it('getAll returns every entry regardless of status', () => { + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + registry.register({ + agentId: 'b', + description: 'agent b', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + registry.register({ + agentId: 'c', + description: 'agent c', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.complete('a', 'done'); + registry.fail('b', 'boom'); + + const all = registry.getAll(); + expect(all).toHaveLength(3); + expect(all.map((e) => e.status).sort()).toEqual([ + 'completed', + 'failed', + 'running', + ]); + // Callers that need only running entries filter getAll() themselves. + expect( + registry + .getAll() + .filter((e) => e.status === 'running') + .map((e) => e.agentId), + ).toEqual(['c']); + }); + + it('statusChange callback fires on register and every state transition', () => { + const seen: Array<{ id: string; status: string }> = []; + registry.setStatusChangeCallback((entry) => { + seen.push({ id: entry.agentId, status: entry.status }); + }); + + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + registry.register({ + agentId: 'b', + description: 'agent b', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + registry.complete('a', 'ok'); + registry.fail('b', 'err'); + + expect(seen).toEqual([ + { id: 'a', status: 'running' }, + { id: 'b', status: 'running' }, + { id: 'a', status: 'completed' }, + { id: 'b', status: 'failed' }, + ]); + }); + + it('statusChange callback errors do not break registry operations', () => { + registry.setStatusChangeCallback(() => { + throw new Error('listener broke'); + }); + + // Should not throw even though the callback does. + expect(() => + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }), + ).not.toThrow(); + expect(registry.get('a')?.status).toBe('running'); + }); + + it('statusChange callback can be cleared with undefined', () => { + const cb = vi.fn(); + registry.setStatusChangeCallback(cb); + registry.setStatusChangeCallback(undefined); + + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('appendActivity builds a rolling buffer capped at 5', () => { + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + for (let i = 0; i < 7; i++) { + registry.appendActivity('a', { + name: `Tool${i}`, + description: `call ${i}`, + at: i, + }); + } + + const activities = registry.get('a')!.recentActivities ?? []; + expect(activities.map((a) => a.name)).toEqual([ + 'Tool2', + 'Tool3', + 'Tool4', + 'Tool5', + 'Tool6', + ]); + }); + + it('appendActivity no-ops after the agent terminates', () => { + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.complete('a', 'done'); + registry.appendActivity('a', { name: 'Late', description: 'x', at: 99 }); + + expect(registry.get('a')!.recentActivities ?? []).toHaveLength(0); + }); + + it('appendActivity fires activityChange, not statusChange', () => { + const statusCb = vi.fn(); + const activityCb = vi.fn(); + registry.setStatusChangeCallback(statusCb); + registry.setActivityChangeCallback(activityCb); + + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + statusCb.mockClear(); + activityCb.mockClear(); + + registry.appendActivity('a', { name: 'T', description: 'd', at: 0 }); + + expect(statusCb).not.toHaveBeenCalled(); + expect(activityCb).toHaveBeenCalledOnce(); + expect(activityCb.mock.calls[0][0].agentId).toBe('a'); + }); + + it('stores prompt verbatim on the entry', () => { + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Run sleep 30 and report done.', + }); + expect(registry.get('a')!.prompt).toBe('Run sleep 30 and report done.'); + }); + it('escapes XML metacharacters in interpolated fields', () => { const callback = vi.fn(); registry.setNotificationCallback(callback); diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 881eb8481..e4bae3e10 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -17,6 +17,7 @@ import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('BACKGROUND_TASKS'); const MAX_DESCRIPTION_LENGTH = 40; +const MAX_RECENT_ACTIVITIES = 5; // Grace period after cancel() before emitting a fallback cancelled // notification. The natural handler (bgBody) almost always settles and @@ -27,6 +28,36 @@ const MAX_DESCRIPTION_LENGTH = 40; // doesn't feel hung. const CANCEL_GRACE_MS = 5000; +/** + * Single source of truth for the human-facing label of a background + * entry. Shared by the notification payload (model-facing) and the TUI + * dialog (user-facing) so the two surfaces never drift. + * + * When `includePrefix` is true (default), returns `subagentType: desc`; + * when false, returns the bare truncated description — used where the + * subagent type is already rendered separately (e.g. the dialog header). + */ +export function buildBackgroundEntryLabel( + entry: { description: string; subagentType?: string }, + options: { includePrefix?: boolean } = {}, +): string { + const { includePrefix = true } = options; + let raw = entry.description; + if ( + entry.subagentType && + raw.toLowerCase().startsWith(entry.subagentType.toLowerCase() + ':') + ) { + raw = raw.slice(entry.subagentType.length + 1).trimStart(); + } + const truncated = + raw.length > MAX_DESCRIPTION_LENGTH + ? raw.slice(0, MAX_DESCRIPTION_LENGTH - 1) + '\u2026' + : raw; + return includePrefix && entry.subagentType + ? `${entry.subagentType}: ${truncated}` + : truncated; +} + // Escape text so it is safe to interpolate into an XML element body. // Subagent-produced strings (description, result, error) can contain `<`, // `>`, or literal `` — without escaping, a subagent @@ -52,6 +83,21 @@ export interface AgentCompletionStats { durationMs: number; } +/** + * A compact record of a recent tool invocation — drives the Progress + * section of the detail dialog. The Agent tool maintains a rolling + * buffer of these on each background entry by subscribing to the + * subagent's event emitter. + */ +export interface BackgroundActivity { + /** Tool name (e.g. `Bash`, `Read`). */ + name: string; + /** Short one-line description — the tool's own render-friendly summary. */ + description: string; + /** Emission timestamp (ms). */ + at: number; +} + export interface BackgroundAgentEntry { agentId: string; description: string; @@ -64,7 +110,22 @@ export interface BackgroundAgentEntry { abortController: AbortController; stats?: AgentCompletionStats; toolUseId?: string; - /** Absolute path to the agent's plain-text transcript file. */ + /** + * The original user-supplied prompt for the background task. Surfaced + * verbatim in the detail dialog's Prompt section. Optional because + * resume-restored entries may not have it. + */ + prompt?: string; + /** + * Rolling buffer (newest last, capped at MAX_RECENT_ACTIVITIES) of + * recent tool invocations by this agent. Feeds the detail dialog's + * Progress section. Replaced as a new array each time an activity is + * appended so reference-based change detection works. Optional: + * callers may register without providing it, and `appendActivity` + * initializes the array lazily. + */ + recentActivities?: readonly BackgroundActivity[]; + /** Absolute path to the agent's on-disk JSONL transcript file. */ outputFile?: string; /** Messages queued by SendMessage, drained between tool rounds. */ pendingMessages?: string[]; @@ -92,10 +153,27 @@ export type BackgroundNotificationCallback = ( export type BackgroundRegisterCallback = (entry: BackgroundAgentEntry) => void; +/** + * Fires on entry status transitions — register, complete, fail, cancel. + * Intentionally does NOT fire on `appendActivity` so consumers that only + * care about the pill / roster (Footer, AppContainer) don't re-render + * on every tool call a background agent makes. + */ +export type BackgroundStatusChangeCallback = ( + entry: BackgroundAgentEntry, +) => void; + +/** Fires on `appendActivity` — scoped to detail-view consumers. */ +export type BackgroundActivityChangeCallback = ( + entry: BackgroundAgentEntry, +) => void; + export class BackgroundTaskRegistry { private readonly agents = new Map(); private notificationCallback?: BackgroundNotificationCallback; private registerCallback?: BackgroundRegisterCallback; + private statusChangeCallback?: BackgroundStatusChangeCallback; + private activityChangeCallback?: BackgroundActivityChangeCallback; register(entry: BackgroundAgentEntry): void { if (!entry.pendingMessages) entry.pendingMessages = []; @@ -109,6 +187,7 @@ export class BackgroundTaskRegistry { debugLogger.error('Failed to emit register callback:', error); } } + this.emitStatusChange(entry); } // Transition a still-running entry to 'completed' and emit the terminal @@ -136,6 +215,7 @@ export class BackgroundTaskRegistry { debugLogger.info(`Background agent completed: ${agentId}`); this.emitNotification(entry); + this.emitStatusChange(entry); } // See complete() for the cancelled → terminal path rationale. @@ -152,6 +232,7 @@ export class BackgroundTaskRegistry { debugLogger.info(`Background agent failed: ${agentId}`); this.emitNotification(entry); + this.emitStatusChange(entry); } // Cancellation aborts the signal and marks the entry as cancelled, but @@ -170,6 +251,7 @@ export class BackgroundTaskRegistry { entry.status = 'cancelled'; entry.endTime = Date.now(); debugLogger.info(`Background agent cancelled: ${agentId}`); + this.emitStatusChange(entry); const timer = setTimeout(() => { this.finalizeCancellationIfPending(agentId); @@ -197,6 +279,7 @@ export class BackgroundTaskRegistry { if (partialResult) entry.result = partialResult; entry.stats = stats; this.emitNotification(entry); + this.emitStatusChange(entry); } // Emit the terminal cancelled notification for entries that were cancelled @@ -208,16 +291,40 @@ export class BackgroundTaskRegistry { const entry = this.agents.get(agentId); if (!entry || entry.status !== 'cancelled' || entry.notified) return; this.emitNotification(entry); + this.emitStatusChange(entry); + } + + /** + * Append a recent tool activity to a running entry's rolling buffer. + * No-op if the entry is not running — late events after a cancellation + * shouldn't leak into the Progress section. + */ + appendActivity(agentId: string, activity: BackgroundActivity): void { + const entry = this.agents.get(agentId); + if (!entry || entry.status !== 'running') return; + + const prior = entry.recentActivities ?? []; + const next = [...prior, activity]; + if (next.length > MAX_RECENT_ACTIVITIES) { + next.splice(0, next.length - MAX_RECENT_ACTIVITIES); + } + entry.recentActivities = next; + this.emitActivityChange(entry); } get(agentId: string): BackgroundAgentEntry | undefined { return this.agents.get(agentId); } - getRunning(): BackgroundAgentEntry[] { - return Array.from(this.agents.values()).filter( - (e) => e.status === 'running', - ); + /** + * Snapshot of every entry regardless of status. Used by the TUI + * footer/dialog to render rows for still-running AND terminal-state + * agents; the headless holdback loop keys off `hasUnfinalizedAgents` + * instead, so callers that only need the running slice can filter + * this snapshot at the call site. + */ + getAll(): BackgroundAgentEntry[] { + return Array.from(this.agents.values()); } /** @@ -274,6 +381,18 @@ export class BackgroundTaskRegistry { this.registerCallback = cb; } + setStatusChangeCallback( + cb: BackgroundStatusChangeCallback | undefined, + ): void { + this.statusChangeCallback = cb; + } + + setActivityChangeCallback( + cb: BackgroundActivityChangeCallback | undefined, + ): void { + this.activityChangeCallback = cb; + } + abortAll(): void { for (const entry of Array.from(this.agents.values())) { this.cancel(entry.agentId); @@ -285,20 +404,7 @@ export class BackgroundTaskRegistry { } private buildDisplayLabel(entry: BackgroundAgentEntry): string { - // Strip the subagent type prefix if the description already starts with it - // to avoid duplication like "Explore: Explore: list ts files". - let rawDesc = entry.description; - if ( - entry.subagentType && - rawDesc.toLowerCase().startsWith(entry.subagentType.toLowerCase() + ':') - ) { - rawDesc = rawDesc.slice(entry.subagentType.length + 1).trimStart(); - } - const desc = - rawDesc.length > MAX_DESCRIPTION_LENGTH - ? rawDesc.slice(0, MAX_DESCRIPTION_LENGTH) + '...' - : rawDesc; - return entry.subagentType ? `${entry.subagentType}: ${desc}` : desc; + return buildBackgroundEntryLabel(entry); } private emitNotification(entry: BackgroundAgentEntry): void { @@ -366,4 +472,22 @@ export class BackgroundTaskRegistry { debugLogger.error('Failed to emit background notification:', error); } } + + private emitStatusChange(entry: BackgroundAgentEntry): void { + if (!this.statusChangeCallback) return; + try { + this.statusChangeCallback(entry); + } catch (error) { + debugLogger.error('Failed to emit background status change:', error); + } + } + + private emitActivityChange(entry: BackgroundAgentEntry): void { + if (!this.activityChangeCallback) return; + try { + this.activityChangeCallback(entry); + } catch (error) { + debugLogger.error('Failed to emit background activity change:', error); + } + } } diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index c7fecc0f4..99b408c03 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -28,6 +28,7 @@ import { import type { ToolConfirmationOutcome, ToolCallConfirmationDetails, + ToolResultDisplay, } from '../../tools/tools.js'; import { getInitialChatHistory } from '../../utils/environmentContext.js'; import type { @@ -44,6 +45,7 @@ import type { ModelConfig, RunConfig, ToolConfig, + AgentMessage, } from './agent-types.js'; import { AgentTerminateMode } from './agent-types.js'; import type { @@ -54,8 +56,9 @@ import type { AgentToolOutputUpdateEvent, AgentUsageEvent, AgentHooks, + AgentExternalMessageEvent, } from './agent-events.js'; -import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; +import { AgentEventEmitter, AgentEventType } from './agent-events.js'; import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js'; import { matchesMcpPattern } from '../../permissions/rule-parser.js'; import { ToolNames } from '../../tools/tool-names.js'; @@ -164,10 +167,26 @@ export class AgentCore { readonly modelConfig: ModelConfig; readonly runConfig: RunConfig; readonly toolConfig?: ToolConfig; - readonly eventEmitter?: AgentEventEmitter; + /** + * Event emitter for this agent. Always present — if the caller doesn't + * pass one, AgentCore allocates its own so the observable state below + * is populated regardless of who constructs the agent. + */ + readonly eventEmitter: AgentEventEmitter; readonly hooks?: AgentHooks; readonly stats = new AgentStatistics(); + // Observable state lives on Core (not a wrapper) so headless and + // background agents can be observed with the same accessors as + // interactive ones. Populated by listeners set up in the constructor. + private readonly messages: AgentMessage[] = []; + private readonly pendingApprovals = new Map< + string, + ToolCallConfirmationDetails + >(); + private readonly liveOutputs = new Map(); + private readonly shellPids = new Map(); + /** * Legacy execution stats maintained for aggregate tracking. */ @@ -218,8 +237,9 @@ export class AgentCore { this.modelConfig = modelConfig; this.runConfig = runConfig; this.toolConfig = toolConfig; - this.eventEmitter = eventEmitter; + this.eventEmitter = eventEmitter ?? new AgentEventEmitter(); this.hooks = hooks; + this.setupStateListeners(); } // ─── Chat Creation ──────────────────────────────────────── @@ -955,6 +975,64 @@ export class AgentCore { return [{ role: 'user', parts: toolResponseParts }]; } + // ─── Observable state accessors ──────────────────────────── + + getMessages(): readonly AgentMessage[] { + return this.messages; + } + + /** + * Tool calls currently awaiting user approval. Mutated by + * AgentInteractive's TOOL_WAITING_APPROVAL handler; headless agents + * never populate this because they run with + * `getShouldAvoidPermissionPrompts === true`. + */ + getPendingApprovals(): ReadonlyMap { + return this.pendingApprovals; + } + + getLiveOutputs(): ReadonlyMap { + return this.liveOutputs; + } + + getShellPids(): ReadonlyMap { + return this.shellPids; + } + + pushMessage( + role: AgentMessage['role'], + content: string, + options?: { thought?: boolean; metadata?: Record }, + ): void { + const message: AgentMessage = { + role, + content, + timestamp: Date.now(), + }; + if (options?.thought) { + message.thought = true; + } + if (options?.metadata) { + message.metadata = options.metadata; + } + this.messages.push(message); + } + + setPendingApproval( + callId: string, + details: ToolCallConfirmationDetails, + ): void { + this.pendingApprovals.set(callId, details); + } + + deletePendingApproval(callId: string): void { + this.pendingApprovals.delete(callId); + } + + clearPendingApprovals(): void { + this.pendingApprovals.clear(); + } + // ─── Stats & Events ─────────────────────────────────────── getEventEmitter(): AgentEventEmitter | undefined { @@ -1070,6 +1148,81 @@ export class AgentCore { // ─── Private Helpers ────────────────────────────────────── + /** + * TOOL_WAITING_APPROVAL is deliberately NOT listened to here because + * the correct response depends on whether the consumer is interactive + * (needs to wrap onConfirm with cancel-round behavior) or headless + * (approvals never fire). AgentInteractive owns that listener and + * writes into `pendingApprovals` via the public mutator API. + */ + private setupStateListeners(): void { + const emitter = this.eventEmitter; + + emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => { + if (event.thoughtText) { + this.pushMessage('assistant', event.thoughtText, { thought: true }); + } + if (event.text) { + this.pushMessage('assistant', event.text); + } + }); + + emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => { + this.pushMessage('tool_call', `Tool call: ${event.name}`, { + metadata: { + callId: event.callId, + toolName: event.name, + args: event.args, + description: event.description, + renderOutputAsMarkdown: event.isOutputMarkdown, + round: event.round, + }, + }); + }); + + emitter.on( + AgentEventType.TOOL_OUTPUT_UPDATE, + (event: AgentToolOutputUpdateEvent) => { + this.liveOutputs.set(event.callId, event.outputChunk); + if (event.pid !== undefined) { + this.shellPids.set(event.callId, event.pid); + } + }, + ); + + emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => { + this.liveOutputs.delete(event.callId); + this.shellPids.delete(event.callId); + this.pendingApprovals.delete(event.callId); + + const statusText = event.success ? 'succeeded' : 'failed'; + const summary = event.error + ? `Tool ${event.name} ${statusText}: ${event.error}` + : `Tool ${event.name} ${statusText}`; + this.pushMessage('tool_result', summary, { + metadata: { + callId: event.callId, + toolName: event.name, + success: event.success, + resultDisplay: event.resultDisplay, + outputFile: event.outputFile, + round: event.round, + }, + }); + }); + + // Mirror send_message injections into the observable message stream so + // the TUI detail dialog shows parent→child messages alongside what the + // JSONL transcript records. The framing prefix is stripped — that's a + // model-facing detail, not what the user wants to see in the dialog. + emitter.on( + AgentEventType.EXTERNAL_MESSAGE, + (event: AgentExternalMessageEvent) => { + this.pushMessage('user', event.text); + }, + ); + } + /** * Builds the system prompt with template substitution and optional * non-interactive instructions suffix. diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 8e593e93e..66d7a2da3 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -194,6 +194,16 @@ export class AgentHeadless { context: ContextState, externalSignal?: AbortSignal, ): Promise { + // Record the initial user turn in the observable message log before + // anything that can throw — createChat / prepareTools failures still + // get a transcript showing the task that was asked, which is what + // the background-agent detail view reads via AgentCore.getMessages(). + // Mirrors AgentInteractive's run loop. + const initialTaskText = String( + (context.get('task_prompt') as string) ?? 'Get Started!', + ); + this.core.pushMessage('user', initialTaskText); + const chat = await this.core.createChat(context); if (!chat) { @@ -215,9 +225,6 @@ export class AgentHeadless { const toolsList = await this.core.prepareTools(); - const initialTaskText = String( - (context.get('task_prompt') as string) ?? 'Get Started!', - ); const initialMessages = [ { role: 'user' as const, parts: [{ text: initialTaskText }] }, ]; diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 5560b665f..a37836038 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -8,6 +8,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AgentInteractive } from './agent-interactive.js'; import type { AgentCore } from './agent-core.js'; import { AgentEventEmitter, AgentEventType } from './agent-events.js'; +import type { + AgentRoundTextEvent, + AgentToolCallEvent, + AgentToolResultEvent, + AgentToolOutputUpdateEvent, +} from './agent-events.js'; import { ContextState } from './agent-headless.js'; import type { AgentInteractiveConfig } from './agent-types.js'; import { AgentStatus } from './agent-types.js'; @@ -31,6 +37,15 @@ function createMockCore( : overrides.chatValue !== undefined ? overrides.chatValue : createMockChat(); + + // Simulate the observable state that the real AgentCore now owns. + // AgentInteractive delegates its state accessors to these, so the mock + // needs to reflect mutations made via `pushMessage` / approval helpers. + const messages: Array> = []; + const pendingApprovals = new Map(); + const liveOutputs = new Map(); + const shellPids = new Map(); + const core = { subagentId: 'test-agent-abc123', name: 'test-agent', @@ -71,8 +86,95 @@ function createMockCore( outputTokens: 0, totalTokens: 0, }), + // Observable state surface (mirrors real AgentCore API). + getMessages: () => messages, + getPendingApprovals: () => pendingApprovals, + getLiveOutputs: () => liveOutputs, + getShellPids: () => shellPids, + pushMessage: ( + role: string, + content: string, + options?: { thought?: boolean; metadata?: Record }, + ) => { + const message: Record = { + role, + content, + timestamp: Date.now(), + }; + if (options?.thought) message['thought'] = true; + if (options?.metadata) message['metadata'] = options.metadata; + messages.push(message); + }, + setPendingApproval: (callId: string, details: unknown) => + pendingApprovals.set(callId, details), + deletePendingApproval: (callId: string) => pendingApprovals.delete(callId), + clearPendingApprovals: () => pendingApprovals.clear(), } as unknown as AgentCore; + // Mirror AgentCore.setupStateListeners: events on the shared emitter + // populate the state containers above. The real AgentCore wires this in + // its constructor; the mock does it here so tests that drive behavior + // by emitting events observe the same resulting state. + emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => { + if (event.thoughtText) { + (core.pushMessage as (...args: unknown[]) => void)( + 'assistant', + event.thoughtText, + { thought: true }, + ); + } + if (event.text) { + (core.pushMessage as (...args: unknown[]) => void)( + 'assistant', + event.text, + ); + } + }); + emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => { + (core.pushMessage as (...args: unknown[]) => void)( + 'tool_call', + `Tool call: ${event.name}`, + { + metadata: { + callId: event.callId, + toolName: event.name, + args: event.args, + description: event.description, + renderOutputAsMarkdown: event.isOutputMarkdown, + round: event.round, + }, + }, + ); + }); + emitter.on( + AgentEventType.TOOL_OUTPUT_UPDATE, + (event: AgentToolOutputUpdateEvent) => { + liveOutputs.set(event.callId, event.outputChunk); + if (event.pid !== undefined) { + shellPids.set(event.callId, event.pid); + } + }, + ); + emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => { + liveOutputs.delete(event.callId); + shellPids.delete(event.callId); + pendingApprovals.delete(event.callId); + const statusText = event.success ? 'succeeded' : 'failed'; + const summary = event.error + ? `Tool ${event.name} ${statusText}: ${event.error}` + : `Tool ${event.name} ${statusText}`; + (core.pushMessage as (...args: unknown[]) => void)('tool_result', summary, { + metadata: { + callId: event.callId, + toolName: event.name, + success: event.success, + resultDisplay: event.resultDisplay, + outputFile: event.outputFile, + round: event.round, + }, + }); + }); + return { core, emitter }; } diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index a01d9edb1..336370dc5 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -14,11 +14,9 @@ import { createDebugLogger } from '../../utils/debugLogger.js'; import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import type { - AgentRoundTextEvent, - AgentToolCallEvent, - AgentToolResultEvent, - AgentToolOutputUpdateEvent, AgentApprovalRequestEvent, + AgentToolOutputUpdateEvent, + AgentToolResultEvent, } from './agent-events.js'; import type { AgentStatsSummary } from './agent-statistics.js'; import type { AgentCore } from './agent-core.js'; @@ -54,7 +52,6 @@ export class AgentInteractive { readonly config: AgentInteractiveConfig; private readonly core: AgentCore; private readonly queue = new AsyncMessageQueue(); - private readonly messages: AgentMessage[] = []; private status: AgentStatus = AgentStatus.INITIALIZING; private error: string | undefined; @@ -67,28 +64,11 @@ export class AgentInteractive { private processing = false; private roundCancelledByUser = false; - // Pending tool approval requests. Keyed by callId. - // Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when - // the user responds. The UI reads this to show confirmation dialogs. - private readonly pendingApprovals = new Map< - string, - ToolCallConfirmationDetails - >(); - - // Live streaming output for currently-executing tools. Keyed by callId. - // Populated by TOOL_OUTPUT_UPDATE (replaces previous), cleared on TOOL_RESULT. - // The UI reads this via getLiveOutputs() to show real-time stdout. - private readonly liveOutputs = new Map(); - - // PTY PIDs for currently-executing shell tools. Keyed by callId. - // Populated by TOOL_OUTPUT_UPDATE when pid is present, cleared on TOOL_RESULT. - // The UI reads this via getShellPids() to enable interactive shell input. - private readonly shellPids = new Map(); - // Wall-clock timestamp when each currently-executing tool transitioned into // the scheduler's `executing` state. Keyed by callId. First TOOL_OUTPUT_UPDATE // carrying executionStartTime wins; later events that re-carry it are ignored - // so the timer is stable. + // so the timer is stable. Lives on InteractiveAgent (not AgentCore) because + // it's only consumed by the interactive UI's elapsed-time indicator. private readonly executionStartTimes = new Map(); constructor(config: AgentInteractiveConfig, core: AgentCore) { @@ -233,7 +213,7 @@ export class AgentInteractive { cancelCurrentRound(): void { this.roundCancelledByUser = true; this.roundAbortController?.abort(); - this.pendingApprovals.clear(); + this.core.clearPendingApprovals(); this.addMessage('info', 'Agent round cancelled.', { metadata: { level: 'warning' }, }); @@ -261,7 +241,7 @@ export class AgentInteractive { abort(): void { this.masterAbortController.abort(); this.queue.drain(); - this.pendingApprovals.clear(); + this.core.clearPendingApprovals(); } // ─── Message Queue ───────────────────────────────────────── @@ -276,10 +256,10 @@ export class AgentInteractive { } } - // ─── State Accessors ─────────────────────────────────────── + // ─── State Accessors (delegates to AgentCore) ────────────── getMessages(): readonly AgentMessage[] { - return this.messages; + return this.core.getMessages(); } getStatus(): AgentStatus { @@ -317,7 +297,7 @@ export class AgentInteractive { * The UI reads this to render confirmation dialogs inside ToolGroupMessage. */ getPendingApprovals(): ReadonlyMap { - return this.pendingApprovals; + return this.core.getPendingApprovals(); } /** @@ -326,7 +306,7 @@ export class AgentInteractive { * Entries are cleared when TOOL_RESULT arrives for the call. */ getLiveOutputs(): ReadonlyMap { - return this.liveOutputs; + return this.core.getLiveOutputs(); } /** @@ -336,7 +316,7 @@ export class AgentInteractive { * interactive shell input via HistoryItemDisplay's activeShellPtyId prop. */ getShellPids(): ReadonlyMap { - return this.shellPids; + return this.core.getShellPids(); } /** @@ -394,53 +374,20 @@ export class AgentInteractive { content: string, options?: { thought?: boolean; metadata?: Record }, ): void { - const message: AgentMessage = { - role, - content, - timestamp: Date.now(), - }; - if (options?.thought) { - message.thought = true; - } - if (options?.metadata) { - message.metadata = options.metadata; - } - this.messages.push(message); + this.core.pushMessage(role, content, options); } + /** + * Wraps TOOL_WAITING_APPROVAL's onConfirm so a Cancel outcome aborts + * the current round (headless agents bypass this path entirely). + * Core already owns the message / live-output / shell-PID listeners. + */ private setupEventListeners(): void { const emitter = this.core.eventEmitter; - if (!emitter) return; - - emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => { - if (event.thoughtText) { - this.addMessage('assistant', event.thoughtText, { thought: true }); - } - if (event.text) { - this.addMessage('assistant', event.text); - } - }); - - emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => { - this.addMessage('tool_call', `Tool call: ${event.name}`, { - metadata: { - callId: event.callId, - toolName: event.name, - args: event.args, - description: event.description, - renderOutputAsMarkdown: event.isOutputMarkdown, - round: event.round, - }, - }); - }); emitter.on( AgentEventType.TOOL_OUTPUT_UPDATE, (event: AgentToolOutputUpdateEvent) => { - this.liveOutputs.set(event.callId, event.outputChunk); - if (event.pid !== undefined) { - this.shellPids.set(event.callId, event.pid); - } if ( event.executionStartTime !== undefined && !this.executionStartTimes.has(event.callId) @@ -451,25 +398,7 @@ export class AgentInteractive { ); emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => { - this.liveOutputs.delete(event.callId); - this.shellPids.delete(event.callId); this.executionStartTimes.delete(event.callId); - this.pendingApprovals.delete(event.callId); - - const statusText = event.success ? 'succeeded' : 'failed'; - const summary = event.error - ? `Tool ${event.name} ${statusText}: ${event.error}` - : `Tool ${event.name} ${statusText}`; - this.addMessage('tool_result', summary, { - metadata: { - callId: event.callId, - toolName: event.name, - success: event.success, - resultDisplay: event.resultDisplay, - outputFile: event.outputFile, - round: event.round, - }, - }); }); emitter.on( @@ -481,17 +410,17 @@ export class AgentInteractive { outcome: Parameters[0], payload?: Parameters[1], ) => { - this.pendingApprovals.delete(event.callId); + this.core.deletePendingApproval(event.callId); // Nudge the UI to re-render so the tool transitions visually // from Confirming → Executing without waiting for the first // real TOOL_OUTPUT_UPDATE from the tool's execution. - this.core.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { + this.core.eventEmitter.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { subagentId: this.core.subagentId, round: event.round, callId: event.callId, outputChunk: '', timestamp: Date.now(), - } as AgentToolOutputUpdateEvent); + }); await event.respond(outcome, payload); // When the user denies a tool, cancel the round immediately // so the agent doesn't waste a turn "acknowledging" the denial. @@ -501,7 +430,7 @@ export class AgentInteractive { }, } as ToolCallConfirmationDetails; - this.pendingApprovals.set(event.callId, fullDetails); + this.core.setPendingApproval(event.callId, fullDetails); }, ); } diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index deebda503..ef0b677bb 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -1430,6 +1430,13 @@ describe('AgentTool', () => { getFinalText: vi.fn().mockReturnValue('Monitor done'), getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), getExecutionSummary: vi.fn().mockReturnValue({}), + // Background spawn subscribes to the core's event emitter to + // populate the entry's recentActivities buffer. Return a stub + // whose getEventEmitter() yields a minimal on/off surface so the + // test-time listener hookup doesn't throw. + getCore: vi.fn().mockReturnValue({ + getEventEmitter: () => ({ on: vi.fn(), off: vi.fn() }), + }), } as unknown as AgentHeadless; mockContextState = { set: vi.fn() } as unknown as ContextState; diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 68022a45d..0046532ce 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1097,9 +1097,25 @@ class AgentToolInvocation extends BaseToolInvocation { startTime: Date.now(), abortController: bgAbortController, toolUseId: this.callId, + prompt: this.params.prompt, outputFile: jsonlPath, }); + // Subscribe to the subagent's tool-call event stream so the + // detail dialog's Progress section reflects live activity. We + // capture the unsubscribe fn and call it when the agent + // terminates (success, failure, or cancel) to avoid holding the + // event emitter after the agent is gone. + const bgEmitter = bgSubagent.getCore().getEventEmitter(); + const onToolCall = (event: AgentToolCallEvent) => { + registry.appendActivity(hookOpts.agentId, { + name: event.name, + description: event.description, + at: event.timestamp, + }); + }; + bgEmitter?.on(AgentEventType.TOOL_CALL, onToolCall); + // Wire external message drain so SendMessage can inject messages // into this agent's reasoning loop between tool rounds. bgSubagent.setExternalMessageProvider(() => @@ -1175,6 +1191,7 @@ class AgentToolInvocation extends BaseToolInvocation { registry.fail(hookOpts.agentId, errorMsg, getCompletionStats()); } } finally { + bgEmitter?.off(AgentEventType.TOOL_CALL, onToolCall); cleanupJsonl?.(); } };