diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index e7da7582a..1add91580 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -44,6 +44,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 { BackgroundTaskViewProvider } from './ui/contexts/BackgroundTaskViewContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js';
import {
@@ -251,13 +252,15 @@ export async function startInteractiveUI(
-
+
+
+
diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts
index a6c9761e0..b2757d845 100644
--- a/packages/cli/src/nonInteractiveCli.test.ts
+++ b/packages/cli/src/nonInteractiveCli.test.ts
@@ -166,7 +166,7 @@ describe('runNonInteractive', () => {
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
setNotificationCallback: vi.fn(),
setRegisterCallback: vi.fn(),
- getRunning: vi.fn().mockReturnValue([]),
+ getAll: vi.fn().mockReturnValue([]),
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
abortAll: vi.fn(),
}),
diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts
index f13c79f0c..5232f6096 100644
--- a/packages/cli/src/nonInteractiveCli.ts
+++ b/packages/cli/src/nonInteractiveCli.ts
@@ -5,7 +5,7 @@
*/
import type {
- BackgroundAgentStatus,
+ BackgroundTaskStatus,
Config,
ToolCallRequestInfo,
} from '@qwen-code/qwen-code-core';
@@ -302,7 +302,7 @@ export async function runNonInteractive(
sdkNotification?: {
task_id: string;
tool_use_id?: string;
- status: BackgroundAgentStatus;
+ status: BackgroundTaskStatus;
usage?: {
total_tokens: number;
tool_uses: number;
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 8d66b3a14..e33beca8a 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -127,6 +127,10 @@ import {
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
+import {
+ useBackgroundTaskViewState,
+ useBackgroundTaskViewActions,
+} from './contexts/BackgroundTaskViewContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
@@ -900,6 +904,8 @@ export const AppContainer = (props: AppContainerProps) => {
const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false);
const agentViewState = useAgentViewState();
+ const { dialogOpen: bgTasksDialogOpen } = useBackgroundTaskViewState();
+ const { closeDialog: closeBgTasksDialog } = useBackgroundTaskViewActions();
// Prompt suggestion state
const [promptSuggestion, setPromptSuggestion] = useState(null);
@@ -1593,7 +1599,8 @@ export const AppContainer = (props: AppContainerProps) => {
isResumeDialogOpen ||
isDeleteDialogOpen ||
isExtensionsManagerDialogOpen ||
- isRewindSelectorOpen;
+ isRewindSelectorOpen ||
+ bgTasksDialogOpen;
dialogsVisibleRef.current = dialogsVisible;
const shouldShowStickyTodos =
stickyTodos !== null &&
@@ -1918,6 +1925,8 @@ export const AppContainer = (props: AppContainerProps) => {
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
+ isBackgroundTasksDialogOpen: bgTasksDialogOpen,
+ closeBackgroundTasksDialog: closeBgTasksDialog,
});
const handleExit = useCallback(
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index f884d089a..e15ad77b4 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -47,6 +47,8 @@ import { HooksManagementDialog } from './hooks/HooksManagementDialog.js';
import { SessionPicker } from './SessionPicker.js';
import { RewindSelector } from './RewindSelector.js';
import { MemoryDialog } from './MemoryDialog.js';
+import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js';
+import { useBackgroundTaskViewState } from '../contexts/BackgroundTaskViewContext.js';
import { t } from '../../i18n/index.js';
interface DialogManagerProps {
@@ -64,6 +66,7 @@ export const DialogManager = ({
const uiState = useUIState();
const uiActions = useUIActions();
+ const { dialogOpen: bgTasksDialogOpen } = useBackgroundTaskViewState();
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
uiState;
@@ -436,5 +439,19 @@ 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.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index c405dbe3e..064206a92 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -13,6 +13,7 @@ import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
+import { KeypressProvider } from '../contexts/KeypressContext.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../hooks/useTerminalSize.js');
@@ -97,11 +98,13 @@ const renderWithWidth = (width: number, uiState: UIState) => {
return render(
-
-
-
-
-
+
+
+
+
+
+
+
,
);
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index cb2e2f47f..efebd5e90 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';
@@ -173,7 +174,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 cf457454d..9e8eb31c9 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 {
+ useBackgroundTaskViewState,
+ useBackgroundTaskViewActions,
+} from '../contexts/BackgroundTaskViewContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
import { BaseTextInput } from './BaseTextInput.js';
import type { RenderLineOptions } from './BaseTextInput.js';
@@ -124,7 +128,16 @@ export const InputPrompt: React.FC = ({
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
+ const {
+ entries: bgEntries,
+ dialogOpen: bgDialogOpen,
+ pillFocused: bgPillFocused,
+ } = useBackgroundTaskViewState();
+ const { setPillFocused: setBgPillFocused } = useBackgroundTaskViewActions();
const hasAgents = agents.size > 0;
+ // 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);
@@ -445,12 +458,12 @@ 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).
- if (agentTabBarFocused) {
+ // When the Arena tab bar or background pill 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
+ // (each surface's own handler releases focus on the same event).
+ if (agentTabBarFocused || bgPillFocused) {
if (
key.sequence &&
key.sequence.length === 1 &&
@@ -462,6 +475,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.
@@ -929,10 +952,20 @@ 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 pill
+ // (if any bg agents) → otherwise stay put. The pill itself
+ // opens the dialog on Enter; the tab bar re-routes Down into
+ // the pill once it has focus, so both surfaces remain reachable
+ // in sequence.
if (hasAgents) {
setAgentTabBarFocused(true);
return true;
}
+ if (hasBgAgents) {
+ setBgPillFocused(true);
+ return true;
+ }
return true;
}
} else {
@@ -1096,8 +1129,12 @@ export const InputPrompt: React.FC = ({
parsePlaceholder,
freePlaceholderId,
agentTabBarFocused,
+ bgDialogOpen,
+ bgPillFocused,
hasAgents,
+ hasBgAgents,
setAgentTabBarFocused,
+ setBgPillFocused,
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..9f086df4f
--- /dev/null
+++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx
@@ -0,0 +1,274 @@
+/**
+ * @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 AgentInteractive,
+ type AgentStatusChangeEvent,
+} from '@qwen-code/qwen-code-core';
+import { useUIState } from '../../contexts/UIStateContext.js';
+import { useTerminalSize } from '../../hooks/useTerminalSize.js';
+import { useKeypress } from '../../hooks/useKeypress.js';
+import { useAgentViewActions } from '../../contexts/AgentViewContext.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 InteractiveAgent wrapper, if any. Present for live arena tabs;
+ * omit for read-only transcript surfaces. When provided, drives the
+ * spinner and the embedded-shell affordance — all reads happen inside
+ * this component, which re-renders on the relevant events, so state
+ * stays fresh without plumbing props from an ancestor that doesn't
+ * subscribe.
+ */
+ interactiveAgent?: AgentInteractive | null;
+ /** Stable identifier used for memo keys and the Static remount key. */
+ instanceKey: string;
+ /** Optional display name shown in the header. */
+ modelName?: string;
+}
+
+export const AgentChatContent = ({
+ core,
+ interactiveAgent,
+ instanceKey,
+ modelName,
+}: AgentChatContentProps) => {
+ const readonly = !interactiveAgent;
+ 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();
+
+ 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();
+
+ // Read status/PTY/timing state fresh on every render — this component
+ // re-renders on STATUS_CHANGE/TOOL_CALL/TOOL_OUTPUT_UPDATE so the reads
+ // stay current without prop plumbing from a non-subscribed ancestor.
+ const status = interactiveAgent?.getStatus() ?? AgentStatus.COMPLETED;
+ const executionStartTimes = interactiveAgent?.getExecutionStartTimes();
+ const activePtyId =
+ shellPids.size > 0
+ ? ((shellPids.values().next().value as number | undefined) ?? null)
+ : null;
+ const isRunning =
+ status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
+
+ // Embedded-shell focus (Ctrl+F toggle). Lives here so the auto-reset
+ // effect sees a fresh activePtyId — AgentChatView above us doesn't
+ // subscribe to agent events, so driving this from there would leave
+ // focus stuck on a terminated PTY.
+ const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
+ const { setAgentShellFocused } = useAgentViewActions();
+
+ useEffect(() => {
+ if (readonly) return;
+ setAgentShellFocused(embeddedShellFocused);
+ return () => setAgentShellFocused(false);
+ }, [embeddedShellFocused, readonly, setAgentShellFocused]);
+
+ useEffect(() => {
+ if (!activePtyId) setEmbeddedShellFocused(false);
+ }, [activePtyId]);
+
+ useKeypress(
+ (key) => {
+ if (readonly) return;
+ if (key.ctrl && key.name === 'f') {
+ if (activePtyId || embeddedShellFocused) {
+ setEmbeddedShellFocused((prev) => !prev);
+ }
+ }
+ },
+ { isActive: !readonly },
+ );
+
+ // 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 ?? '';
+
+ 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..117738eb5 100644
--- a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx
+++ b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx
@@ -5,45 +5,13 @@
*/
/**
- * @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. Resolves the selected agent
+ * from AgentViewContext; the content component owns live-state reads
+ * and the Ctrl+F embedded-shell toggle.
*/
-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 {
- 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 { useAgentViewState } from '../../contexts/AgentViewContext.js';
+import { AgentChatContent, AgentChatMissing } from './AgentChatContent.js';
interface AgentChatViewProps {
agentId: string;
@@ -51,225 +19,21 @@ 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 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
- : undefined;
-
- // Track whether the user has toggled input focus into the embedded shell.
- // Mirrors the main agent's embeddedShellFocused in AppContainer.
- const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false);
-
- // Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation
- // when an agent's embedded shell is focused.
- useEffect(() => {
- setAgentShellFocused(embeddedShellFocused);
- return () => setAgentShellFocused(false);
- }, [embeddedShellFocused, setAgentShellFocused]);
-
- // Reset focus when the shell exits (activePtyId disappears).
- useEffect(() => {
- if (!activePtyId) setEmbeddedShellFocusedLocal(false);
- }, [activePtyId]);
-
- // Ctrl+F: toggle shell input focus when a PTY is active.
- useKeypress(
- (key) => {
- if (key.ctrl && key.name === 'f') {
- if (activePtyId || embeddedShellFocused) {
- setEmbeddedShellFocusedLocal((prev) => !prev);
- }
- }
- },
- { 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..f92f8365c 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 {
+ useBackgroundTaskViewState,
+ useBackgroundTaskViewActions,
+} from '../../contexts/BackgroundTaskViewContext.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 } = useBackgroundTaskViewState();
+ const { setPillFocused: setBgPillFocused } = useBackgroundTaskViewActions();
const { embeddedShellFocused } = useUIState();
+ const hasBgAgents = bgEntries.length > 0;
useKeypress(
(key) => {
@@ -74,6 +85,15 @@ export const AgentTabBar: React.FC = () => {
switchToNext();
} else if (key.name === 'up') {
setAgentTabBarFocused(false);
+ } else if (key.name === 'down') {
+ // Switch to main first — the footer pill only renders under the
+ // main view, so focusing it from an agent tab would strand focus
+ // on an offscreen surface.
+ if (hasBgAgents) {
+ setAgentTabBarFocused(false);
+ switchToMain();
+ setBgPillFocused(true);
+ }
} else if (
key.sequence &&
key.sequence.length === 1 &&
diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx
new file mode 100644
index 000000000..8d2ce1c2f
--- /dev/null
+++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { useState } from 'react';
+import { act } from '@testing-library/react';
+import { render } from 'ink-testing-library';
+import type { BackgroundTaskEntry, Config } from '@qwen-code/qwen-code-core';
+import { BackgroundTasksDialog } from './BackgroundTasksDialog.js';
+import {
+ BackgroundTaskViewProvider,
+ useBackgroundTaskViewActions,
+ useBackgroundTaskViewState,
+} from '../../contexts/BackgroundTaskViewContext.js';
+import { ConfigContext } from '../../contexts/ConfigContext.js';
+import { useBackgroundTaskView } from '../../hooks/useBackgroundTaskView.js';
+import { useKeypress } from '../../hooks/useKeypress.js';
+
+vi.mock('../../hooks/useBackgroundTaskView.js', () => ({
+ useBackgroundTaskView: vi.fn(),
+}));
+
+vi.mock('../../hooks/useKeypress.js', () => ({
+ useKeypress: vi.fn(),
+}));
+
+const mockedUseBackgroundTaskView = vi.mocked(useBackgroundTaskView);
+const mockedUseKeypress = vi.mocked(useKeypress);
+
+function entry(overrides: Partial): BackgroundTaskEntry {
+ return {
+ agentId: 'a',
+ description: 'desc',
+ status: 'running',
+ startTime: 0,
+ abortController: new AbortController(),
+ ...overrides,
+ };
+}
+
+interface ProbeHandle {
+ actions: ReturnType;
+ state: ReturnType;
+ setEntries: (next: readonly BackgroundTaskEntry[]) => void;
+}
+
+interface Harness {
+ cancel: ReturnType;
+ setEntries: (next: readonly BackgroundTaskEntry[]) => void;
+ pressKey: (key: { name?: string; sequence?: string }) => void;
+ call: (fn: () => void) => void;
+ lastFrame: () => string | undefined;
+ probe: { current: ProbeHandle | null };
+}
+
+function setup(initial: readonly BackgroundTaskEntry[]): Harness {
+ const handlers: Array<(key: { name?: string; sequence?: string }) => void> =
+ [];
+ mockedUseKeypress.mockImplementation((cb, opts) => {
+ if (opts?.isActive !== false) handlers.push(cb as never);
+ });
+
+ const cancel = vi.fn();
+ const config = {
+ getBackgroundTaskRegistry: () => ({
+ cancel,
+ setActivityChangeCallback: vi.fn(),
+ }),
+ } as unknown as Config;
+
+ const handle: { current: ProbeHandle | null } = { current: null };
+
+ // Wrapper holds the entries in React state so updates propagate normally.
+ // The hook mock is bound to this wrapper via the closure below.
+ function Harness() {
+ const [entries, setEntries] = useState(initial);
+ mockedUseBackgroundTaskView.mockImplementation(() => ({ entries }));
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ function Probe({
+ entriesSetter,
+ }: {
+ entriesSetter: (e: readonly BackgroundTaskEntry[]) => void;
+ }) {
+ handle.current = {
+ actions: useBackgroundTaskViewActions(),
+ state: useBackgroundTaskViewState(),
+ setEntries: entriesSetter,
+ };
+ return null;
+ }
+
+ const { lastFrame } = render();
+
+ return {
+ cancel,
+ setEntries(next) {
+ handlers.length = 0;
+ act(() => handle.current!.setEntries(next));
+ },
+ pressKey(key) {
+ act(() => {
+ for (const h of handlers) h(key);
+ });
+ },
+ call(fn) {
+ act(() => fn());
+ },
+ lastFrame,
+ probe: handle,
+ };
+}
+
+describe('BackgroundTasksDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('exits to list mode when the running entry being viewed flips to a terminal status', () => {
+ const running = entry({ agentId: 'a', status: 'running' });
+ const h = setup([running]);
+
+ h.call(() => h.probe.current!.actions.openDialog());
+ h.call(() => h.probe.current!.actions.enterDetail());
+ expect(h.probe.current!.state.dialogMode).toBe('detail');
+
+ h.setEntries([{ ...running, status: 'completed' }]);
+
+ expect(h.probe.current!.state.dialogMode).toBe('list');
+ });
+
+ it('exits to list mode after cancelling the running entry being viewed in detail', () => {
+ const running = entry({ agentId: 'a', status: 'running' });
+ const h = setup([running]);
+
+ h.call(() => h.probe.current!.actions.openDialog());
+ h.call(() => h.probe.current!.actions.enterDetail());
+ expect(h.probe.current!.state.dialogMode).toBe('detail');
+
+ h.pressKey({ sequence: 'x' });
+ expect(h.cancel).toHaveBeenCalledWith('a');
+
+ // Registry would push the cancelled status; simulate that update.
+ h.setEntries([{ ...running, status: 'cancelled' }]);
+
+ expect(h.probe.current!.state.dialogMode).toBe('list');
+ });
+
+ it('keeps detail mode when an already-terminal entry is opened (no spurious fallback)', () => {
+ const done = entry({ agentId: 'a', status: 'completed' });
+ const h = setup([done]);
+
+ h.call(() => h.probe.current!.actions.openDialog());
+ h.call(() => h.probe.current!.actions.enterDetail());
+ expect(h.probe.current!.state.dialogMode).toBe('detail');
+
+ // The auto-fallback ref must only trigger on a running → terminal
+ // transition. Re-rendering with a fresh terminal entry must not evict
+ // the user from detail.
+ h.setEntries([{ ...done }]);
+ expect(h.probe.current!.state.dialogMode).toBe('detail');
+ });
+
+ it('clamps selectedIndex when entries shrink', () => {
+ const a = entry({ agentId: 'a' });
+ const b = entry({ agentId: 'b' });
+ const c = entry({ agentId: 'c' });
+ const h = setup([a, b, c]);
+
+ h.call(() => h.probe.current!.actions.openDialog());
+ h.call(() => h.probe.current!.actions.moveSelectionDown());
+ h.call(() => h.probe.current!.actions.moveSelectionDown());
+ expect(h.probe.current!.state.selectedIndex).toBe(2);
+
+ h.setEntries([a]);
+ expect(h.probe.current!.state.selectedIndex).toBe(0);
+
+ h.setEntries([]);
+ expect(h.probe.current!.state.selectedIndex).toBe(0);
+ });
+});
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..1f5f20067
--- /dev/null
+++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx
@@ -0,0 +1,572 @@
+/**
+ * @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 { Fragment, useEffect, useMemo, useRef, useState } from 'react';
+import { Box, Text } from 'ink';
+import stringWidth from 'string-width';
+import {
+ useBackgroundTaskViewState,
+ useBackgroundTaskViewActions,
+} from '../../contexts/BackgroundTaskViewContext.js';
+import { useKeypress } from '../../hooks/useKeypress.js';
+import { MaxSizedBox } from '../shared/MaxSizedBox.js';
+import { theme } from '../../semantic-colors.js';
+import { useConfig } from '../../contexts/ConfigContext.js';
+import {
+ buildBackgroundEntryLabel,
+ ToolDisplayNames,
+ ToolNames,
+ type BackgroundTaskEntry,
+} from '@qwen-code/qwen-code-core';
+
+// Tool-name → display-name lookup (`run_shell_command` → `Shell`).
+const TOOL_DISPLAY_BY_NAME: Record = Object.fromEntries(
+ (Object.keys(ToolNames) as Array).map((key) => [
+ ToolNames[key],
+ ToolDisplayNames[key],
+ ]),
+);
+
+function formatActivityLabel(name: string, description: string | undefined) {
+ const display = TOOL_DISPLAY_BY_NAME[name] ?? name;
+ const singleLineDesc = description
+ ? description.replace(/\s*\n\s*/g, ' ').trim()
+ : '';
+ return singleLineDesc ? `${display}(${singleLineDesc})` : display;
+}
+import { formatDuration, formatTokenCount } from '../../utils/formatters.js';
+
+const STATUS_VERBS: Record = {
+ running: 'Running',
+ completed: 'Completed',
+ failed: 'Failed',
+ cancelled: 'Stopped',
+};
+
+interface StatusPresentation {
+ icon: string;
+ color: string;
+ labelColor: string;
+}
+
+function terminalStatusPresentation(
+ status: BackgroundTaskEntry['status'],
+): StatusPresentation | null {
+ switch (status) {
+ case 'completed':
+ return {
+ icon: '\u2714',
+ color: theme.status.success,
+ labelColor: theme.text.secondary,
+ };
+ case 'failed':
+ return {
+ icon: '\u2716',
+ color: theme.status.error,
+ labelColor: theme.status.errorDim,
+ };
+ case 'cancelled':
+ return {
+ icon: '\u2716',
+ color: theme.status.warning,
+ labelColor: theme.status.warningDim,
+ };
+ default:
+ return null;
+ }
+}
+
+function rowLabel(entry: BackgroundTaskEntry): string {
+ return buildBackgroundEntryLabel(entry, { includePrefix: false });
+}
+
+function elapsedFor(entry: BackgroundTaskEntry): string {
+ const elapsedMs = Math.max(
+ 0,
+ (entry.endTime ?? Date.now()) - entry.startTime,
+ );
+ // Round down to whole seconds — the detail subtitle is a glanceable
+ // indicator, not a stopwatch, and sub-second precision flickers distract
+ // from the actual status change.
+ const wholeSeconds = Math.floor(elapsedMs / 1000);
+ return formatDuration(wholeSeconds * 1000, { hideTrailingZeros: true });
+}
+
+// Manually truncate to an exact cell width so each row lines up with the
+// others regardless of content length. Relying on Ink's `wrap="truncate-end"`
+// inside MaxSizedBox produced inconsistent row widths when some rows fit and
+// others needed ellipsis, breaking the left-column alignment of the prefix.
+function truncateToWidth(text: string, maxWidth: number): string {
+ if (maxWidth <= 0) return '';
+ if (stringWidth(text) <= maxWidth) return text;
+ const ellipsis = '…';
+ const ellipsisWidth = stringWidth(ellipsis);
+ const target = Math.max(0, maxWidth - ellipsisWidth);
+ let width = 0;
+ let result = '';
+ for (const char of text) {
+ const charWidth = stringWidth(char);
+ if (width + charWidth > target) break;
+ width += charWidth;
+ result += char;
+ }
+ return result + ellipsis;
+}
+
+// ─── List mode ─────────────────────────────────────────────
+
+const ListBody: React.FC<{
+ entries: readonly BackgroundTaskEntry[];
+ selectedIndex: number;
+ maxRows: number;
+}> = ({ entries, selectedIndex, maxRows }) => {
+ // Keep the "Local agents (N)" section header rendered even when the list
+ // is empty, so the overlay doesn't collapse into a single line of
+ // empty-state text when the last agent finishes while the dialog is open.
+ if (entries.length === 0) {
+ return (
+
+
+ Local agents
+ (0)
+
+
+ No tasks currently running
+
+
+ );
+ }
+
+ // Window entries around selectedIndex. When the list fits, show
+ // everything; otherwise centre the selection and clamp to the ends.
+ // "+N more above/below" lines consume one row each on the respective
+ // side, so subtract them from the available row budget.
+ const fits = entries.length <= maxRows;
+ const effectiveRows = Math.max(1, fits ? maxRows : maxRows - 2);
+ const windowStart = fits
+ ? 0
+ : Math.max(
+ 0,
+ Math.min(
+ selectedIndex - Math.floor(effectiveRows / 2),
+ entries.length - effectiveRows,
+ ),
+ );
+ const windowEnd = fits
+ ? entries.length
+ : Math.min(entries.length, windowStart + effectiveRows);
+ const hiddenAbove = windowStart;
+ const hiddenBelow = entries.length - windowEnd;
+ const visible = entries.slice(windowStart, windowEnd);
+
+ return (
+
+
+ Local agents
+ ({entries.length})
+
+
+ {hiddenAbove > 0 && (
+
+
+ {` ^ ${hiddenAbove} more above`}
+
+
+ )}
+ {visible.map((entry, visibleIdx) => {
+ const idx = windowStart + visibleIdx;
+ const isSelected = idx === selectedIndex;
+ const terminal = terminalStatusPresentation(entry.status);
+ const labelColor = isSelected
+ ? theme.text.accent
+ : terminal
+ ? terminal.labelColor
+ : theme.text.primary;
+ return (
+
+
+ {isSelected ? '> ' : ' '}
+
+ {rowLabel(entry)}
+
+ );
+ })}
+ {hiddenBelow > 0 && (
+
+
+ {` v ${hiddenBelow} more below`}
+
+
+ )}
+
+
+ );
+};
+
+// ─── Detail mode ───────────────────────────────────────────
+
+const DetailBody: React.FC<{
+ entry: BackgroundTaskEntry;
+ maxHeight: number;
+ maxWidth: number;
+}> = ({ entry, maxHeight, maxWidth }) => {
+ const title = `${entry.subagentType ?? 'Agent'} \u203A ${rowLabel(entry)}`;
+
+ const terminal = terminalStatusPresentation(entry.status);
+ const dimSubtitleParts: string[] = [elapsedFor(entry)];
+ if (entry.stats?.totalTokens) {
+ dimSubtitleParts.push(
+ `${formatTokenCount(entry.stats.totalTokens)} tokens`,
+ );
+ }
+ if (entry.stats?.toolUses !== undefined) {
+ dimSubtitleParts.push(
+ `${entry.stats.toolUses} tool${entry.stats.toolUses === 1 ? '' : 's'}`,
+ );
+ }
+
+ // Registry stores activities newest-last; keep that order so the live
+ // row sits at the bottom of the Progress block. Cap at 5 in case the
+ // registry ever raises its buffer.
+ const activities = (entry.recentActivities ?? []).slice(-5);
+ const hasError = entry.status === 'failed' && Boolean(entry.error);
+
+ // Prompt: show at most 5 newline-delimited segments, each row truncated
+ // to one visual line. Append an ellipsis if the source had more.
+ const promptLines = entry.prompt ? entry.prompt.split('\n') : [];
+ const visiblePromptLines = promptLines.slice(0, 5);
+ const promptTruncated = promptLines.length > visiblePromptLines.length;
+ if (promptTruncated && visiblePromptLines.length > 0) {
+ const lastIdx = visiblePromptLines.length - 1;
+ visiblePromptLines[lastIdx] =
+ `${visiblePromptLines[lastIdx].trimEnd()}\u2026`;
+ }
+
+ return (
+
+
+
+ {title}
+
+
+
+ {terminal && (
+
+ {`${terminal.icon} ${STATUS_VERBS[entry.status]} \u00B7 `}
+
+ )}
+
+ {dimSubtitleParts.join(' \u00B7 ')}
+
+
+
+ {activities.length > 0 && (
+
+
+
+
+ Progress
+
+
+ {activities.map((a, i) => {
+ const isLast = i === activities.length - 1;
+ // ASCII `>` is unambiguously one cell wide in every terminal
+ // font, so `> ` (2 cells) aligns with a two-space indent on the
+ // other rows. Unicode chevrons rendered with inconsistent width
+ // broke alignment in some fonts.
+ const prefix = isLast ? '> ' : ' ';
+ const label = truncateToWidth(
+ formatActivityLabel(a.name, a.description),
+ Math.max(0, maxWidth - stringWidth(prefix)),
+ );
+ return (
+
+
+ {prefix}
+ {label}
+
+
+ );
+ })}
+
+ )}
+
+ {visiblePromptLines.length > 0 && (
+
+
+
+
+ Prompt
+
+
+ {visiblePromptLines.map((line, i) => (
+
+ {line || ' '}
+
+ ))}
+
+ )}
+
+ {hasError && (
+
+
+
+
+ Error
+
+
+
+
+ {entry.error}
+
+
+
+ )}
+
+ );
+};
+
+// ─── Dialog shell ──────────────────────────────────────────
+
+interface BackgroundTasksDialogProps {
+ availableTerminalHeight: number;
+ terminalWidth: number;
+}
+
+export const BackgroundTasksDialog: React.FC = ({
+ availableTerminalHeight,
+ terminalWidth,
+}) => {
+ const { entries, selectedIndex, dialogOpen, dialogMode } =
+ useBackgroundTaskViewState();
+ const {
+ moveSelectionUp,
+ moveSelectionDown,
+ closeDialog,
+ enterDetail,
+ exitDetail,
+ cancelSelected,
+ } = useBackgroundTaskViewActions();
+ const config = useConfig();
+
+ // Progress and Prompt are each self-capped at 5 rows inside DetailBody,
+ // so the body never grows unbounded. Use all available height (minus the
+ // dialog chrome) as the MaxSizedBox budget so nothing gets clipped just
+ // because the terminal is short. Chrome = border(2) + title(1) + two
+ // marginTops(2) + hint(1) = 6 rows.
+ const detailContentHeight = Math.max(10, availableTerminalHeight - 6);
+ // Rounded border + paddingX=1 on the outer Box ≈ 4 horizontal cells.
+ const detailContentWidth = Math.max(10, terminalWidth - 4);
+
+ // List mode row budget: terminal height minus chrome (border 2 + title 1
+ // + two marginTops 2 + hint 1) and list header ("N active agents" 1 +
+ // marginTop 1 + "Local agents (N)" 1) = 10.
+ const listMaxRows = Math.max(3, availableTerminalHeight - 10);
+
+ 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 useBackgroundTaskView 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);
+ const selectedAgentId = selectedEntry?.agentId;
+ useEffect(() => {
+ if (!dialogOpen || dialogMode !== 'detail' || !selectedAgentId) return;
+ const registry = config.getBackgroundTaskRegistry();
+ const onActivity = (entry: BackgroundTaskEntry) => {
+ if (entry.agentId !== selectedAgentId) return;
+ bumpActivity((n) => n + 1);
+ };
+ registry.setActivityChangeCallback(onActivity);
+ return () => registry.setActivityChangeCallback(undefined);
+ }, [dialogOpen, dialogMode, config, selectedAgentId]);
+
+ // Wall-clock tick for the running agent's duration. Activity callbacks
+ // fire when tools run, but duration needs to advance even when the agent
+ // is quietly thinking — otherwise the "33s" line freezes between tool uses.
+ const selectedStatus = selectedEntry?.status;
+ useEffect(() => {
+ if (
+ !dialogOpen ||
+ dialogMode !== 'detail' ||
+ !selectedAgentId ||
+ selectedStatus !== 'running'
+ )
+ return;
+ const id = setInterval(() => bumpActivity((n) => n + 1), 1000);
+ return () => clearInterval(id);
+ }, [dialogOpen, dialogMode, selectedAgentId, selectedStatus]);
+
+ // Auto-fallback to the list view when the selected agent reaches a
+ // 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: BackgroundTaskEntry['status'];
+ } | null>(null);
+ useEffect(() => {
+ if (!dialogOpen || dialogMode !== 'detail') {
+ initialDetailStatusRef.current = null;
+ return;
+ }
+ // 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();
+ return;
+ }
+ const seen = initialDetailStatusRef.current;
+ if (!seen || seen.agentId !== selectedAgentId) {
+ // First render in detail mode for this entry — remember the status we
+ // opened with so we can detect a transition away from 'running' later.
+ if (selectedStatus) {
+ initialDetailStatusRef.current = {
+ agentId: selectedAgentId,
+ status: selectedStatus,
+ };
+ }
+ return;
+ }
+ if (
+ seen.status === 'running' &&
+ selectedStatus &&
+ selectedStatus !== 'running'
+ ) {
+ exitDetail();
+ }
+ }, [dialogOpen, dialogMode, selectedAgentId, selectedStatus, exitDetail]);
+
+ 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 (
+
+ {dialogMode === 'list' && (
+
+
+ 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..ffdd53e8b
--- /dev/null
+++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { BackgroundTaskEntry } from '@qwen-code/qwen-code-core';
+import { getPillLabel } from './BackgroundTasksPill.js';
+
+function entry(overrides: Partial): BackgroundTaskEntry {
+ return {
+ agentId: 'a',
+ description: 'desc',
+ status: 'running',
+ startTime: 0,
+ abortController: new AbortController(),
+ ...overrides,
+ };
+}
+
+describe('getPillLabel', () => {
+ 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');
+ });
+
+ 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
new file mode 100644
index 000000000..fc8edfb4b
--- /dev/null
+++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useCallback } from 'react';
+import { Text } from 'ink';
+import {
+ useBackgroundTaskViewState,
+ useBackgroundTaskViewActions,
+} from '../../contexts/BackgroundTaskViewContext.js';
+import { useKeypress, type Key } from '../../hooks/useKeypress.js';
+import { theme } from '../../semantic-colors.js';
+import type { BackgroundTaskEntry } from '@qwen-code/qwen-code-core';
+
+/**
+ * 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 BackgroundTaskEntry[]): 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 } = useBackgroundTaskViewState();
+ const { openDialog, setPillFocused } = useBackgroundTaskViewActions();
+
+ const onKeypress = useCallback(
+ (key: Key) => {
+ if (key.name === 'return') {
+ openDialog();
+ } else if (key.name === 'up' || key.name === 'escape') {
+ setPillFocused(false);
+ } else if (
+ key.sequence &&
+ key.sequence.length === 1 &&
+ !key.ctrl &&
+ !key.meta
+ ) {
+ setPillFocused(false);
+ }
+ },
+ [openDialog, setPillFocused],
+ );
+
+ useKeypress(onKeypress, { isActive: pillFocused });
+
+ if (entries.length === 0) return null;
+
+ const label = getPillLabel(entries);
+
+ return (
+ <>
+ ·
+ {label}
+ >
+ );
+};
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/BackgroundTaskViewContext.tsx b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx
new file mode 100644
index 000000000..cdb6a3605
--- /dev/null
+++ b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx
@@ -0,0 +1,214 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * BackgroundTaskViewContext — React state for the Background tasks
+ * dialog. Subscription plumbing (registry callbacks → entries) lives in
+ * `useBackgroundTaskView`, invoked once here so it owns the single-slot
+ * `setStatusChangeCallback` for the TUI's lifetime.
+ */
+
+import {
+ createContext,
+ useContext,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ type BackgroundTaskEntry,
+ type Config,
+} from '@qwen-code/qwen-code-core';
+import { useBackgroundTaskView } from '../hooks/useBackgroundTaskView.js';
+
+// ─── Types ──────────────────────────────────────────────────
+
+export type BackgroundDialogMode = 'closed' | 'list' | 'detail';
+
+export interface BackgroundTaskViewState {
+ /** Live snapshot of every background agent entry, ordered by startTime. */
+ entries: readonly BackgroundTaskEntry[];
+ /** 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;
+ /**
+ * True when the footer pill owns keyboard focus (highlighted, awaiting
+ * Enter to open the dialog). Mirrors the Arena tab-bar focus pattern.
+ */
+ pillFocused: boolean;
+}
+
+export interface BackgroundTaskViewActions {
+ moveSelectionUp(): boolean;
+ moveSelectionDown(): boolean;
+ openDialog(): void;
+ closeDialog(): void;
+ enterDetail(): void;
+ exitDetail(): void;
+ /** Cancel the currently selected entry (no-op if not running). */
+ cancelSelected(): void;
+ setPillFocused(focused: boolean): void;
+}
+
+// ─── Context ────────────────────────────────────────────────
+
+export const BackgroundTaskViewStateContext =
+ createContext(null);
+export const BackgroundTaskViewActionsContext =
+ createContext(null);
+
+// ─── Defaults (used when no provider is mounted) ────────────
+
+const DEFAULT_STATE: BackgroundTaskViewState = {
+ entries: [],
+ selectedIndex: 0,
+ dialogMode: 'closed',
+ dialogOpen: false,
+ pillFocused: false,
+};
+
+const noop = () => {};
+const noopBool = () => false;
+
+const DEFAULT_ACTIONS: BackgroundTaskViewActions = {
+ moveSelectionUp: noopBool,
+ moveSelectionDown: noopBool,
+ openDialog: noop,
+ closeDialog: noop,
+ enterDetail: noop,
+ exitDetail: noop,
+ cancelSelected: noop,
+ setPillFocused: noop,
+};
+
+// ─── Hooks ──────────────────────────────────────────────────
+
+export function useBackgroundTaskViewState(): BackgroundTaskViewState {
+ return useContext(BackgroundTaskViewStateContext) ?? DEFAULT_STATE;
+}
+
+export function useBackgroundTaskViewActions(): BackgroundTaskViewActions {
+ return useContext(BackgroundTaskViewActionsContext) ?? DEFAULT_ACTIONS;
+}
+
+// ─── Provider ───────────────────────────────────────────────
+
+interface BackgroundTaskViewProviderProps {
+ config?: Config;
+ children: React.ReactNode;
+}
+
+export function BackgroundTaskViewProvider({
+ config,
+ children,
+}: BackgroundTaskViewProviderProps) {
+ const { entries } = useBackgroundTaskView(config ?? null);
+
+ const [rawSelectedIndex, setRawSelectedIndex] = useState(0);
+ const [dialogMode, setDialogMode] = useState('closed');
+ const [pillFocused, setPillFocused] = useState(false);
+ const dialogOpen = dialogMode !== 'closed';
+ const hasEntries = entries.length > 0;
+
+ // 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 && !hasEntries) setPillFocused(false);
+ }, [pillFocused, hasEntries]);
+
+ // rawSelectedIndex can fall out of range when entries shrink; clamp on read.
+ const selectedIndex =
+ entries.length === 0
+ ? 0
+ : Math.min(Math.max(0, rawSelectedIndex), entries.length - 1);
+
+ 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');
+ setPillFocused(false);
+ }, []);
+
+ 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;
+ // cancel() is a no-op for non-running entries, so no pre-check here.
+ config.getBackgroundTaskRegistry().cancel(target.agentId);
+ }, [config, entries, selectedIndex]);
+
+ const state: BackgroundTaskViewState = useMemo(
+ () => ({
+ entries,
+ selectedIndex,
+ dialogMode,
+ dialogOpen,
+ pillFocused,
+ }),
+ [entries, selectedIndex, dialogMode, dialogOpen, pillFocused],
+ );
+
+ const actions: BackgroundTaskViewActions = useMemo(
+ () => ({
+ moveSelectionUp,
+ moveSelectionDown,
+ openDialog,
+ closeDialog,
+ enterDetail,
+ exitDetail,
+ cancelSelected,
+ setPillFocused,
+ }),
+ [
+ moveSelectionUp,
+ moveSelectionDown,
+ openDialog,
+ closeDialog,
+ enterDetail,
+ exitDetail,
+ cancelSelected,
+ setPillFocused,
+ ],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/cli/src/ui/hooks/useBackgroundTaskView.ts b/packages/cli/src/ui/hooks/useBackgroundTaskView.ts
new file mode 100644
index 000000000..c55040b6e
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useBackgroundTaskView.ts
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * useBackgroundTaskView — subscribes to the background task registry's
+ * status-change callback and maintains a reactive snapshot of every
+ * `BackgroundTaskEntry`, 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
+ * 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 BackgroundTaskEntry,
+ type Config,
+} from '@qwen-code/qwen-code-core';
+
+export interface UseBackgroundTaskViewResult {
+ entries: readonly BackgroundTaskEntry[];
+}
+
+export function useBackgroundTaskView(
+ config: Config | null,
+): UseBackgroundTaskViewResult {
+ const [entries, setEntries] = useState([]);
+
+ useEffect(() => {
+ if (!config) return;
+ const registry = config.getBackgroundTaskRegistry();
+
+ // getAll() returns a fresh array in registration (= startTime) order.
+ setEntries(registry.getAll());
+
+ const onStatusChange = () => {
+ setEntries(registry.getAll());
+ };
+
+ registry.setStatusChangeCallback(onStatusChange);
+
+ return () => {
+ registry.setStatusChangeCallback(undefined);
+ };
+ }, [config]);
+
+ return { entries };
+}
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 6bddb0b7a..fb515a5d5 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 a767c2404..e69155dd9 100644
--- a/packages/core/src/agents/background-tasks.test.ts
+++ b/packages/core/src/agents/background-tasks.test.ts
@@ -246,7 +246,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');
});
@@ -457,6 +457,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 1184614bd..8e30e1828 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
@@ -40,7 +71,7 @@ function escapeXml(text: string): string {
.replace(/>/g, '>');
}
-export type BackgroundAgentStatus =
+export type BackgroundTaskStatus =
| 'running'
| 'completed'
| 'failed'
@@ -52,11 +83,26 @@ export interface AgentCompletionStats {
durationMs: number;
}
-export interface BackgroundAgentEntry {
+/**
+ * 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 BackgroundTaskEntry {
agentId: string;
description: string;
subagentType?: string;
- status: BackgroundAgentStatus;
+ status: BackgroundTaskStatus;
startTime: number;
endTime?: number;
result?: 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[];
@@ -79,7 +140,7 @@ export interface BackgroundAgentEntry {
export interface NotificationMeta {
agentId: string;
- status: BackgroundAgentStatus;
+ status: BackgroundTaskStatus;
stats?: AgentCompletionStats;
toolUseId?: string;
}
@@ -90,14 +151,31 @@ export type BackgroundNotificationCallback = (
meta: NotificationMeta,
) => void;
-export type BackgroundRegisterCallback = (entry: BackgroundAgentEntry) => void;
+export type BackgroundRegisterCallback = (entry: BackgroundTaskEntry) => 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: BackgroundTaskEntry,
+) => void;
+
+/** Fires on `appendActivity` — scoped to detail-view consumers. */
+export type BackgroundActivityChangeCallback = (
+ entry: BackgroundTaskEntry,
+) => void;
export class BackgroundTaskRegistry {
- private readonly agents = new Map();
+ private readonly agents = new Map();
private notificationCallback?: BackgroundNotificationCallback;
private registerCallback?: BackgroundRegisterCallback;
+ private statusChangeCallback?: BackgroundStatusChangeCallback;
+ private activityChangeCallback?: BackgroundActivityChangeCallback;
- register(entry: BackgroundAgentEntry): void {
+ register(entry: BackgroundTaskEntry): void {
if (!entry.pendingMessages) entry.pendingMessages = [];
this.agents.set(entry.agentId, entry);
debugLogger.info(`Registered background agent: ${entry.agentId}`);
@@ -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);
}
- get(agentId: string): BackgroundAgentEntry | undefined {
+ /**
+ * 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): BackgroundTaskEntry | 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
+ * tasks; the headless holdback loop keys off `hasUnfinalizedTasks`
+ * instead, so callers that only need the running slice can filter
+ * this snapshot at the call site.
+ */
+ getAll(): BackgroundTaskEntry[] {
+ 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);
@@ -284,24 +403,11 @@ export class BackgroundTaskRegistry {
debugLogger.info('Aborted all background agents');
}
- 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;
+ private buildDisplayLabel(entry: BackgroundTaskEntry): string {
+ return buildBackgroundEntryLabel(entry);
}
- private emitNotification(entry: BackgroundAgentEntry): void {
+ private emitNotification(entry: BackgroundTaskEntry): void {
// Mark notified *before* invoking the callback so that a re-entrant
// terminal call inside the callback chain (cancel → complete race)
// sees the flag and short-circuits, rather than firing twice.
@@ -366,4 +472,22 @@ export class BackgroundTaskRegistry {
debugLogger.error('Failed to emit background notification:', error);
}
}
+
+ private emitStatusChange(entry: BackgroundTaskEntry): void {
+ if (!this.statusChangeCallback) return;
+ try {
+ this.statusChangeCallback(entry);
+ } catch (error) {
+ debugLogger.error('Failed to emit background status change:', error);
+ }
+ }
+
+ private emitActivityChange(entry: BackgroundTaskEntry): 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 de8f231cf..8f8202965 100644
--- a/packages/core/src/agents/runtime/agent-core.ts
+++ b/packages/core/src/agents/runtime/agent-core.ts
@@ -29,6 +29,7 @@ import {
import type {
ToolConfirmationOutcome,
ToolCallConfirmationDetails,
+ ToolResultDisplay,
} from '../../tools/tools.js';
import { getInitialChatHistory } from '../../utils/environmentContext.js';
import { FinishReason } from '@google/genai';
@@ -46,6 +47,7 @@ import type {
ModelConfig,
RunConfig,
ToolConfig,
+ AgentMessage,
} from './agent-types.js';
import { AgentTerminateMode } from './agent-types.js';
import type {
@@ -56,8 +58,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';
@@ -172,10 +175,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.
*/
@@ -226,8 +245,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 ────────────────────────────────────────
@@ -1000,9 +1020,67 @@ 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 {
+ getEventEmitter(): AgentEventEmitter {
return this.eventEmitter;
}
@@ -1115,6 +1193,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..b7fbba1df 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 {
@@ -307,7 +287,7 @@ export class AgentInteractive {
return this.core;
}
- getEventEmitter(): AgentEventEmitter | undefined {
+ getEventEmitter(): AgentEventEmitter {
return this.core.getEventEmitter();
}
@@ -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 4ea21c180..ac0c55c2e 100644
--- a/packages/core/src/tools/agent/agent.test.ts
+++ b/packages/core/src/tools/agent/agent.test.ts
@@ -1435,6 +1435,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 675a62f14..ec2ed6e3f 100644
--- a/packages/core/src/tools/agent/agent.ts
+++ b/packages/core/src/tools/agent/agent.ts
@@ -1122,9 +1122,48 @@ 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();
+ // Local counter of tool invocations that have been *started*. The
+ // core's executionStats.totalToolCalls only increments when a tool
+ // result arrives, so using it as the live toolUses number leaves the
+ // subtitle one behind the Progress list while a tool is in flight.
+ // Tracking TOOL_CALL ourselves keeps the subtitle in sync with the
+ // rows the user actually sees.
+ let liveToolCallCount = 0;
+ const refreshLiveStats = () => {
+ const entry = registry.get(hookOpts.agentId);
+ if (!entry || entry.status !== 'running') return;
+ const summary = bgSubagent.getExecutionSummary();
+ entry.stats = {
+ totalTokens: summary.totalTokens,
+ toolUses: liveToolCallCount,
+ durationMs: summary.totalDurationMs,
+ };
+ };
+ const onToolCall = (event: AgentToolCallEvent) => {
+ liveToolCallCount += 1;
+ refreshLiveStats();
+ registry.appendActivity(hookOpts.agentId, {
+ name: event.name,
+ description: event.description,
+ at: event.timestamp,
+ });
+ };
+ const onUsageMetadata = () => {
+ refreshLiveStats();
+ };
+ bgEmitter.on(AgentEventType.TOOL_CALL, onToolCall);
+ bgEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata);
+
// Wire external message drain so SendMessage can inject messages
// into this agent's reasoning loop between tool rounds.
bgSubagent.setExternalMessageProvider(() =>
@@ -1135,7 +1174,7 @@ class AgentToolInvocation extends BaseToolInvocation {
const summary = bgSubagent.getExecutionSummary();
return {
totalTokens: summary.totalTokens,
- toolUses: summary.totalToolCalls,
+ toolUses: liveToolCallCount,
durationMs: summary.totalDurationMs,
};
};
@@ -1200,6 +1239,8 @@ class AgentToolInvocation extends BaseToolInvocation {
registry.fail(hookOpts.agentId, errorMsg, getCompletionStats());
}
} finally {
+ bgEmitter.off(AgentEventType.TOOL_CALL, onToolCall);
+ bgEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata);
cleanupJsonl?.();
}
};