feat(cli): background-agent UI — pill, combined dialog, detail view (#3488)

* feat(cli): background-task UI — pill, combined dialog, detail view

Adds the user-facing surface for background tasks on top of the
model-facing agent control primitives merged in #3471. A dedicated
pill in the footer summarises running tasks, ↓ focuses it, and Enter
opens a combined dialog listing every task with a detail view that
shows the original prompt, live stats, and a rolling progress feed
of recent tool invocations.

Also renames BackgroundAgent* to BackgroundTask* for consistency with
the user-facing terminology and the task_* tool family.

* chore: trigger CI
This commit is contained in:
tanzhenxin 2026-04-28 10:57:59 +08:00 committed by GitHub
parent d09c19c0c5
commit 03c88b7308
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2322 additions and 428 deletions

View file

@ -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(
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider settings={settings}>
<AgentViewProvider config={config}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
<BackgroundTaskViewProvider config={config}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</BackgroundTaskViewProvider>
</AgentViewProvider>
</VimModeProvider>
</SessionStatsProvider>

View file

@ -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(),
}),

View file

@ -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;

View file

@ -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<string | null>(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(

View file

@ -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 (
<BackgroundTasksDialog
availableTerminalHeight={terminalHeight - staticExtraHeight}
terminalWidth={mainAreaWidth}
/>
);
}
return null;
};

View file

@ -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(
<SettingsContext.Provider value={mockSettings}>
<ConfigContext.Provider value={createMockConfig() as never}>
<VimModeProvider settings={mockSettings}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
<KeypressProvider kittyProtocolEnabled={false}>
<VimModeProvider settings={mockSettings}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</KeypressProvider>
</ConfigContext.Provider>
</SettingsContext.Provider>,
);

View file

@ -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}
</Text>
))}
<Text wrap="truncate">{leftBottomContent}</Text>
<Box flexDirection="row" flexShrink={1}>
<Text wrap="truncate">{leftBottomContent}</Text>
<BackgroundTasksPill />
</Box>
</Box>
{/* Right Section never compressed, aligns to top so multi-line

View file

@ -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<InputPromptProps> = ({
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<InputPromptProps> = ({
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<InputPromptProps> = ({
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<InputPromptProps> = ({
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<InputPromptProps> = ({
parsePlaceholder,
freePlaceholderId,
agentTabBarFocused,
bgDialogOpen,
bgPillFocused,
hasAgents,
hasBgAgents,
setAgentTabBarFocused,
setBgPillFocused,
followup,
onPromptSuggestionDismiss,
],

View file

@ -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 <Static> 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 (
<Box flexDirection="column">
{/* 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. */}
<Static
key={`agent-${instanceKey}-${historyRemountKey}`}
items={[
<AgentHeader
key="agent-header"
modelId={agentModelId}
modelName={modelName}
workingDirectory={agentWorkingDir}
gitBranch={agentGitBranch}
/>,
...committedItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={false}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
/>
)),
]}
>
{(item) => item}
</Static>
{/* Live area tool groups awaiting confirmation or still executing.
Must remain outside Static so confirmation dialogs are interactive. */}
{pendingItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={true}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
isFocused={!readonly}
activeShellPtyId={activePtyId}
embeddedShellFocused={embeddedShellFocused}
/>
))}
{/* Spinner */}
{isRunning && (
<Box marginX={2} marginTop={1}>
<GeminiRespondingSpinner />
</Box>
)}
</Box>
);
};
// 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 }) => (
<Box marginX={2}>
<Text color={theme.status.error}>{label}</Text>
</Box>
);

View file

@ -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 <Static>)
* - 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 <Static> 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 (
<Box marginX={2}>
<Text color={theme.status.error}>
Agent &quot;{agentId}&quot; not found.
</Text>
</Box>
);
return <AgentChatMissing label={`Agent "${agentId}" not found.`} />;
}
const agentModelId = core.modelConfig.model ?? '';
return (
<Box flexDirection="column">
{/* 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. */}
<Static
key={`agent-${agentId}-${historyRemountKey}`}
items={[
<AgentHeader
key="agent-header"
modelId={agentModelId}
modelName={agent.modelName}
workingDirectory={agentWorkingDir}
gitBranch={agentGitBranch}
/>,
...committedItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={false}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
/>
)),
]}
>
{(item) => item}
</Static>
{/* 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) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={true}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
isFocused={true}
activeShellPtyId={activePtyId ?? null}
embeddedShellFocused={embeddedShellFocused}
/>
))}
{/* Spinner */}
{isRunning && (
<Box marginX={2} marginTop={1}>
<GeminiRespondingSpinner />
</Box>
)}
</Box>
<AgentChatContent
core={core}
interactiveAgent={interactiveAgent}
instanceKey={agentId}
modelName={agent.modelName}
/>
);
};

View file

@ -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 &&

View file

@ -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>): BackgroundTaskEntry {
return {
agentId: 'a',
description: 'desc',
status: 'running',
startTime: 0,
abortController: new AbortController(),
...overrides,
};
}
interface ProbeHandle {
actions: ReturnType<typeof useBackgroundTaskViewActions>;
state: ReturnType<typeof useBackgroundTaskViewState>;
setEntries: (next: readonly BackgroundTaskEntry[]) => void;
}
interface Harness {
cancel: ReturnType<typeof vi.fn>;
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 (
<ConfigContext.Provider value={config}>
<BackgroundTaskViewProvider config={config}>
<Probe entriesSetter={setEntries} />
<BackgroundTasksDialog
availableTerminalHeight={30}
terminalWidth={80}
/>
</BackgroundTaskViewProvider>
</ConfigContext.Provider>
);
}
function Probe({
entriesSetter,
}: {
entriesSetter: (e: readonly BackgroundTaskEntry[]) => void;
}) {
handle.current = {
actions: useBackgroundTaskViewActions(),
state: useBackgroundTaskViewState(),
setEntries: entriesSetter,
};
return null;
}
const { lastFrame } = render(<Harness />);
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);
});
});

View file

@ -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<string, string> = Object.fromEntries(
(Object.keys(ToolNames) as Array<keyof typeof ToolNames>).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<BackgroundTaskEntry['status'], string> = {
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 (
<Box flexDirection="column">
<Box paddingX={1}>
<Text bold>Local agents</Text>
<Text color={theme.text.secondary}> (0)</Text>
</Box>
<Box paddingX={1}>
<Text color={theme.text.secondary}>No tasks currently running</Text>
</Box>
</Box>
);
}
// 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 (
<Box flexDirection="column">
<Box paddingX={1}>
<Text bold>Local agents</Text>
<Text color={theme.text.secondary}> ({entries.length})</Text>
</Box>
<Box flexDirection="column">
{hiddenAbove > 0 && (
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{` ^ ${hiddenAbove} more above`}
</Text>
</Box>
)}
{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 (
<Box key={entry.agentId} flexDirection="row" paddingX={1}>
<Text color={isSelected ? theme.text.accent : undefined}>
{isSelected ? '> ' : ' '}
</Text>
<Text color={labelColor}>{rowLabel(entry)}</Text>
</Box>
);
})}
{hiddenBelow > 0 && (
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{` v ${hiddenBelow} more below`}
</Text>
</Box>
)}
</Box>
</Box>
);
};
// ─── 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 (
<MaxSizedBox
maxHeight={maxHeight}
maxWidth={maxWidth}
overflowDirection="bottom"
>
<Box>
<Text bold color={theme.text.accent}>
{title}
</Text>
</Box>
<Box>
{terminal && (
<Text color={terminal.color}>
{`${terminal.icon} ${STATUS_VERBS[entry.status]} \u00B7 `}
</Text>
)}
<Text color={theme.text.secondary}>
{dimSubtitleParts.join(' \u00B7 ')}
</Text>
</Box>
{activities.length > 0 && (
<Fragment>
<Box />
<Box>
<Text bold dimColor>
Progress
</Text>
</Box>
{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 (
<Box key={`${a.at}-${i}`}>
<Text
color={isLast ? theme.text.primary : theme.text.secondary}
>
{prefix}
{label}
</Text>
</Box>
);
})}
</Fragment>
)}
{visiblePromptLines.length > 0 && (
<Fragment>
<Box />
<Box>
<Text bold dimColor>
Prompt
</Text>
</Box>
{visiblePromptLines.map((line, i) => (
<Box key={`prompt-${i}`}>
<Text wrap="truncate-end">{line || ' '}</Text>
</Box>
))}
</Fragment>
)}
{hasError && (
<Fragment>
<Box />
<Box>
<Text bold color={theme.status.error}>
Error
</Text>
</Box>
<Box>
<Text color={theme.status.error} wrap="wrap">
{entry.error}
</Text>
</Box>
</Fragment>
)}
</MaxSizedBox>
);
};
// ─── Dialog shell ──────────────────────────────────────────
interface BackgroundTasksDialogProps {
availableTerminalHeight: number;
terminalWidth: number;
}
export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
marginTop={1}
paddingX={1}
>
{dialogMode === 'list' && (
<Box paddingX={1}>
<Text bold color={theme.text.accent}>
Background tasks
</Text>
</Box>
)}
<Box marginTop={dialogMode === 'list' ? 1 : 0}>
{dialogMode === 'list' ? (
<ListBody
entries={entries}
selectedIndex={selectedIndex}
maxRows={listMaxRows}
/>
) : selectedEntry ? (
<DetailBody
entry={selectedEntry}
maxHeight={detailContentHeight}
maxWidth={detailContentWidth}
/>
) : (
<Box paddingX={1}>
<Text color={theme.text.secondary}>No entry to show.</Text>
</Box>
)}
</Box>
<Box marginTop={1} paddingX={1}>
<Text color={theme.text.secondary}>{hints.join(' \u00B7 ')}</Text>
</Box>
</Box>
);
};

View file

@ -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>): 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');
});
});

View file

@ -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 (
<>
<Text color={theme.text.secondary}> · </Text>
<Text inverse={pillFocused}>{label}</Text>
</>
);
};

View file

@ -73,6 +73,10 @@ const getStatusText = (status: AgentResultDisplay['status']) => {
}
};
const BackgroundManageHint: React.FC = () => (
<Text color={theme.text.secondary}> ( to manage)</Text>
);
const MAX_TOOL_CALLS = 5;
const MAX_TASK_PROMPT_LINES = 5;
@ -150,6 +154,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
{data.status === 'background' && <BackgroundManageHint />}
</Box>
)}
@ -231,6 +236,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
{data.status === 'background' && <BackgroundManageHint />}
</Box>
{/* Task description */}

View file

@ -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<BackgroundTaskViewState | null>(null);
export const BackgroundTaskViewActionsContext =
createContext<BackgroundTaskViewActions | null>(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<BackgroundDialogMode>('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 (
<BackgroundTaskViewStateContext.Provider value={state}>
<BackgroundTaskViewActionsContext.Provider value={actions}>
{children}
</BackgroundTaskViewActionsContext.Provider>
</BackgroundTaskViewStateContext.Provider>
);
}

View file

@ -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<BackgroundTaskEntry[]>([]);
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 };
}

View file

@ -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]);

View file

@ -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<Record<string, unknown>> = [];
const pendingApprovals = new Map<string, unknown>();
const liveOutputs = new Map<string, unknown>();
const shellPids = new Map<string, number>();
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<string, unknown> },
) => {
const message: Record<string, unknown> = {
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() {

View file

@ -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);

View file

@ -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 `</task-notification>` — without escaping, a subagent
@ -40,7 +71,7 @@ function escapeXml(text: string): string {
.replace(/>/g, '&gt;');
}
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<string, BackgroundAgentEntry>();
private readonly agents = new Map<string, BackgroundTaskEntry>();
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);
}
}
}

View file

@ -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<string, ToolResultDisplay>();
private readonly shellPids = new Map<string, number>();
/**
* 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<string, ToolCallConfirmationDetails> {
return this.pendingApprovals;
}
getLiveOutputs(): ReadonlyMap<string, ToolResultDisplay> {
return this.liveOutputs;
}
getShellPids(): ReadonlyMap<string, number> {
return this.shellPids;
}
pushMessage(
role: AgentMessage['role'],
content: string,
options?: { thought?: boolean; metadata?: Record<string, unknown> },
): 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.

View file

@ -194,6 +194,16 @@ export class AgentHeadless {
context: ContextState,
externalSignal?: AbortSignal,
): Promise<void> {
// 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 }] },
];

View file

@ -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<Record<string, unknown>> = [];
const pendingApprovals = new Map<string, unknown>();
const liveOutputs = new Map<string, unknown>();
const shellPids = new Map<string, number>();
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<string, unknown> },
) => {
const message: Record<string, unknown> = {
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 };
}

View file

@ -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<string>();
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<string, ToolResultDisplay>();
// 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<string, number>();
// 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<string, number>();
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<string, ToolCallConfirmationDetails> {
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<string, ToolResultDisplay> {
return this.liveOutputs;
return this.core.getLiveOutputs();
}
/**
@ -336,7 +316,7 @@ export class AgentInteractive {
* interactive shell input via HistoryItemDisplay's activeShellPtyId prop.
*/
getShellPids(): ReadonlyMap<string, number> {
return this.shellPids;
return this.core.getShellPids();
}
/**
@ -394,53 +374,20 @@ export class AgentInteractive {
content: string,
options?: { thought?: boolean; metadata?: Record<string, unknown> },
): 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<ToolCallConfirmationDetails['onConfirm']>[0],
payload?: Parameters<ToolCallConfirmationDetails['onConfirm']>[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);
},
);
}

View file

@ -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;

View file

@ -1122,9 +1122,48 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
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<AgentParams, ToolResult> {
const summary = bgSubagent.getExecutionSummary();
return {
totalTokens: summary.totalTokens,
toolUses: summary.totalToolCalls,
toolUses: liveToolCallCount,
durationMs: summary.totalDurationMs,
};
};
@ -1200,6 +1239,8 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
registry.fail(hookOpts.agentId, errorMsg, getCompletionStats());
}
} finally {
bgEmitter.off(AgentEventType.TOOL_CALL, onToolCall);
bgEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata);
cleanupJsonl?.();
}
};