From 8388b3d5e902cfd96f8e079bdd759ff01cb486ea Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 7 May 2026 08:46:44 +0000 Subject: [PATCH] feat(cli): inline compact tree for live agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a header + per-agent row tree that renders inline while a tool group containing agent calls is still pending. Each agent row shows the live tool count, token total, and last activity, with `├─ / └─ / ⎿` connectors that match the existing dialog's tree style. Approval banner placement is preserved: when the focus-holding agent has a pending confirmation, the existing confirmation prompt renders above the tree, and queued siblings surface a `· ⏳ Queued approval` suffix on their row. Replaces the previously empty live area for foreground subagents, where users had to open the footer pill to see what their agents were doing. Tree height stays fixed per agent (1 row for backgrounded agents, 2 rows for foreground), so the live frame doesn't churn as new tools fire. The committed scrollback path is unchanged — the existing display still renders terminal groups. This is the first of four PRs in the inline-agent display refactor; later PRs will unify the two paths once foreground retention lands. Refactors the background-task registry's per-tool activity callback from a single-slot setter to a multi-listener subscribe API so the inline tree and the detail dialog can both subscribe independently. --- .../BackgroundTasksDialog.test.tsx | 2 +- .../background-view/BackgroundTasksDialog.tsx | 27 +- .../messages/ToolGroupMessage.test.tsx | 212 +++++++++++ .../components/messages/ToolGroupMessage.tsx | 219 +++++++---- .../cli/src/ui/components/subagents/index.ts | 2 + .../runtime/AgentExecutionDisplay.tsx | 13 +- .../subagents/runtime/AgentTree.test.tsx | 340 ++++++++++++++++++ .../subagents/runtime/AgentTree.tsx | 330 +++++++++++++++++ .../cli/src/ui/components/subagents/utils.ts | 8 + packages/cli/src/ui/utils/formatters.ts | 26 ++ .../core/src/agents/background-tasks.test.ts | 50 ++- packages/core/src/agents/background-tasks.ts | 40 ++- 12 files changed, 1161 insertions(+), 108 deletions(-) create mode 100644 packages/cli/src/ui/components/subagents/runtime/AgentTree.test.tsx create mode 100644 packages/cli/src/ui/components/subagents/runtime/AgentTree.tsx diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx index e1ece35c8d5..d38d5e30310 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx @@ -138,7 +138,7 @@ function setup(initial: readonly DialogEntry[]): Harness { const config = { getBackgroundTaskRegistry: () => ({ cancel, - setActivityChangeCallback: vi.fn(), + addActivityChangeListener: vi.fn(() => () => {}), get: (id: string) => { const match = currentEntries.find( (e) => e.kind === 'agent' && e.agentId === id, diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index a63b2e38636..59f2aee887a 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -24,12 +24,14 @@ import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { buildBackgroundEntryLabel, - ToolDisplayNames, - ToolNames, type BackgroundTaskEntry, type MonitorEntry, } from '@qwen-code/qwen-code-core'; -import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; +import { + formatActivityLabel, + formatDuration, + formatTokenCount, +} from '../../utils/formatters.js'; import { type AgentDialogEntry, type DialogEntry, @@ -41,22 +43,6 @@ import { // `paused` state, so dialog handlers can switch on a single combined enum. type EntryStatus = DialogEntry['status']; -// Tool-name → display-name lookup (`run_shell_command` → `Shell`). -const TOOL_DISPLAY_BY_NAME: Record = Object.fromEntries( - (Object.keys(ToolNames) as Array).map((key) => [ - ToolNames[key], - ToolDisplayNames[key], - ]), -); - -function formatActivityLabel(name: string, description: string | undefined) { - const display = TOOL_DISPLAY_BY_NAME[name] ?? name; - const singleLineDesc = description - ? description.replace(/\s*\n\s*/g, ' ').trim() - : ''; - return singleLineDesc ? `${display}(${singleLineDesc})` : display; -} - const STATUS_VERBS: Record = { running: 'Running', paused: 'Paused', @@ -901,8 +887,7 @@ export const BackgroundTasksDialog: React.FC = ({ if (entry.agentId !== selectedAgentIdForActivity) return; setActivityTick((n) => n + 1); }; - registry.setActivityChangeCallback(onActivity); - return () => registry.setActivityChangeCallback(undefined); + return registry.addActivityChangeListener(onActivity); }, [dialogOpen, dialogMode, config, selectedAgentIdForActivity]); // Wall-clock tick for the running agent's duration. Activity callbacks diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 9610f277103..c663e804c63 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -82,6 +82,33 @@ vi.mock('./ToolConfirmationMessage.js', () => ({ }, })); +vi.mock('../subagents/index.js', () => ({ + AgentTree: function MockAgentTree({ + agents, + }: { + agents: Array<{ + callId: string; + data: { subagentName?: string }; + isFocused?: boolean; + }>; + }) { + return ( + + MockAgentTree[ + {agents + .map( + (a) => + `${a.callId}:${a.data.subagentName ?? '?'}:focused=${String( + Boolean(a.isFocused), + )}`, + ) + .join('|')} + ] + + ); + }, +})); + describe('', () => { const mockConfig: Config = {} as Config; @@ -467,6 +494,191 @@ describe('', () => { }); }); + describe('Live agent grouping', () => { + const createRunningSubagentDisplay = ( + name: string, + ): AgentResultDisplay => ({ + type: 'task_execution', + subagentName: name, + taskDescription: `${name} task`, + taskPrompt: `Run ${name}`, + status: 'running', + }); + + it('routes contiguous live agents into a single AgentTree', () => { + const { lastFrame } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + // Both agents collapse into one tree node. + expect(frame).toContain('MockAgentTree[agent-1:reviewer'); + expect(frame).toContain('agent-2:reviewer'); + // No per-tool MockSubagent rows for live agents. + expect(frame).not.toContain('MockSubagent[agent-1]'); + expect(frame).not.toContain('MockSubagent[agent-2]'); + }); + + it('splits the tree when a non-agent call sits between agent calls', () => { + const { lastFrame } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + // Two separate trees, one per contiguous run. + expect(frame).toContain('MockAgentTree[agent-1:reviewer'); + expect(frame).toContain('MockAgentTree[agent-2:reviewer'); + // Non-agent call still renders via the per-tool path. + expect(frame).toContain('MockTool[shell-1]'); + }); + + it('passes focus only to the first pending-confirmation agent', () => { + const pendingDisplay: AgentResultDisplay = { + ...createRunningSubagentDisplay('reviewer'), + pendingConfirmation: { + type: 'info', + title: 'Approve?', + prompt: 'allow?', + onConfirm: vi.fn(), + }, + }; + const { lastFrame } = renderWithProviders( + , + ); + + // Frame may soft-wrap inside the group border; collapse before + // matching so the assertion isn't sensitive to terminal width. + const frame = (lastFrame() ?? '').replace(/[│\n]/g, ''); + expect(frame).toContain('agent-pending:reviewer:focused=true'); + expect(frame).toContain('agent-running:reviewer:focused=false'); + }); + + it('queues the agent banner when a direct tool confirmation is active', () => { + const pendingDisplay: AgentResultDisplay = { + ...createRunningSubagentDisplay('reviewer'), + pendingConfirmation: { + type: 'info', + title: 'Approve agent action?', + prompt: 'allow agent action?', + onConfirm: vi.fn(), + }, + }; + const { lastFrame } = renderWithProviders( + , + ); + // Direct tool's Confirming row keeps focus; agent banner does not + // light up its own focused branch even though the agent has a + // pending confirmation. + const frame = (lastFrame() ?? '').replace(/[│\n]/g, ''); + expect(frame).toContain('agent-pending:reviewer:focused=false'); + }); + + it('falls back to per-tool live rendering once the group commits', () => { + const completed: AgentResultDisplay = { + type: 'task_execution', + subagentName: 'reviewer', + taskDescription: 'reviewer task', + taskPrompt: 'review it', + status: 'completed', + }; + const { lastFrame } = renderWithProviders( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).not.toContain('MockAgentTree'); + expect(frame).toContain('MockSubagent[agent-1]'); + }); + }); + describe('Border Color Logic', () => { it('uses yellow border when tools are pending', () => { const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })]; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 4a78ccdb52b..fe96867d622 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -12,36 +12,41 @@ import { ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { CompactToolGroupDisplay } from './CompactToolGroupDisplay.js'; +import { AgentTree } from '../subagents/index.js'; +import type { AgentTreeAgent } from '../subagents/index.js'; import { theme } from '../../semantic-colors.js'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { useCompactMode } from '../../contexts/CompactModeContext.js'; import type { AgentResultDisplay } from '@qwen-code/qwen-code-core'; -function isAgentWithPendingConfirmation( +function isAgentResult( rd: IndividualToolCallDisplay['resultDisplay'], ): rd is AgentResultDisplay { return ( typeof rd === 'object' && rd !== null && 'type' in rd && - (rd as AgentResultDisplay).type === 'task_execution' && - (rd as AgentResultDisplay).pendingConfirmation !== undefined + (rd as AgentResultDisplay).type === 'task_execution' ); } +function isAgentWithPendingConfirmation( + rd: IndividualToolCallDisplay['resultDisplay'], +): rd is AgentResultDisplay { + return isAgentResult(rd) && rd.pendingConfirmation !== undefined; +} + function isRunningAgent( rd: IndividualToolCallDisplay['resultDisplay'], ): rd is AgentResultDisplay { - return ( - typeof rd === 'object' && - rd !== null && - 'type' in rd && - (rd as AgentResultDisplay).type === 'task_execution' && - (rd as AgentResultDisplay).status === 'running' - ); + return isAgentResult(rd) && rd.status === 'running'; } +type AgentToolCall = IndividualToolCallDisplay & { + resultDisplay: AgentResultDisplay; +}; + interface ToolGroupMessageProps { groupId: number; toolCalls: IndividualToolCallDisplay[]; @@ -286,68 +291,148 @@ export const ToolGroupMessage: React.FC = ({ ); })()} - {toolCalls.map((tool) => { - const isConfirming = toolAwaitingApproval?.callId === tool.callId; - // A subagent's inline confirmation should only receive keyboard focus - // when (1) there is no direct tool-level confirmation active, and (2) - // this tool currently holds the subagent keyboard focus. Pending - // confirmations keep the existing first-come focus lock; otherwise the - // first running subagent owns Ctrl+E/Ctrl+F so the compact hint remains - // actionable without making parallel subagents toggle in lock-step. - const isSubagentFocused = - isFocused && - !toolAwaitingApproval && - keyboardFocusedSubagentCallId === tool.callId; - // Show the waiting indicator only when this subagent genuinely has a - // pending confirmation AND another subagent holds the focus lock. - const isWaitingForOtherApproval = - isAgentWithPendingConfirmation(tool.resultDisplay) && - focusedSubagentCallId !== null && - focusedSubagentCallId !== tool.callId; - return ( - - - - - {tool.status === ToolCallStatus.Confirming && - isConfirming && - tool.confirmationDetails && ( - { + const isConfirming = toolAwaitingApproval?.callId === tool.callId; + // A subagent's inline confirmation should only receive keyboard + // focus when (1) there is no direct tool-level confirmation + // active, and (2) this tool currently holds the subagent + // keyboard focus. Pending confirmations keep the existing + // first-come focus lock; otherwise the first running subagent + // owns Ctrl+E/Ctrl+F so the compact hint remains actionable + // without making parallel subagents toggle in lock-step. + const isSubagentFocused = + isFocused && + !toolAwaitingApproval && + keyboardFocusedSubagentCallId === tool.callId; + // Show the waiting indicator only when this subagent genuinely + // has a pending confirmation AND another subagent holds the + // focus lock. + const isWaitingForOtherApproval = + isAgentWithPendingConfirmation(tool.resultDisplay) && + focusedSubagentCallId !== null && + focusedSubagentCallId !== tool.callId; + return ( + + + - )} - - ); - })} + + {tool.status === ToolCallStatus.Confirming && + isConfirming && + tool.confirmationDetails && ( + + )} + + ); + }, + (agentTools) => { + const treeAgents: AgentTreeAgent[] = agentTools.map((tool) => { + const hasPending = + tool.resultDisplay.pendingConfirmation !== undefined; + // Direct tool-level confirmations (`toolAwaitingApproval`) + // own the keyboard until resolved. While one is active, no + // subagent banner can claim focus — surface the agent's + // request as queued so the user knows it's still waiting. + const isQueued = + hasPending && + (toolAwaitingApproval !== undefined || + (focusedSubagentCallId !== null && + focusedSubagentCallId !== tool.callId)); + const treeFocused = + isFocused && + !toolAwaitingApproval && + focusedSubagentCallId === tool.callId; + return { + callId: tool.callId, + data: tool.resultDisplay, + isFocused: treeFocused, + isWaitingForOtherApproval: isQueued, + }; + }); + return ( + + ); + }, + )} ); }; + +/** + * Walk `toolCalls` and emit one node per render unit: + * - During the live phase, contiguous runs of agent-typed calls + * collapse into a single `` node via `renderAgentRun`. + * - All other calls (and every call once the group commits) render + * individually via `renderSingle`. + * + * The contiguous-run rule means a non-agent call between two agent + * calls splits them into two trees, matching the design spec. + */ +function renderRuns( + toolCalls: readonly IndividualToolCallDisplay[], + isPending: boolean | undefined, + renderSingle: (tool: IndividualToolCallDisplay) => React.ReactNode, + renderAgentRun: (agentTools: readonly AgentToolCall[]) => React.ReactNode, +): React.ReactNode[] { + if (!isPending) { + return toolCalls.map((tool) => renderSingle(tool)); + } + const out: React.ReactNode[] = []; + let buffer: AgentToolCall[] = []; + const flush = () => { + if (buffer.length === 0) return; + out.push(renderAgentRun(buffer)); + buffer = []; + }; + for (const tool of toolCalls) { + if (isAgentResult(tool.resultDisplay)) { + buffer.push(tool as AgentToolCall); + } else { + flush(); + out.push(renderSingle(tool)); + } + } + flush(); + return out; +} diff --git a/packages/cli/src/ui/components/subagents/index.ts b/packages/cli/src/ui/components/subagents/index.ts index 8f22a244d6b..63ad21f4e82 100644 --- a/packages/cli/src/ui/components/subagents/index.ts +++ b/packages/cli/src/ui/components/subagents/index.ts @@ -12,3 +12,5 @@ export { AgentsManagerDialog } from './manage/AgentsManagerDialog.js'; // Execution Display export { AgentExecutionDisplay } from './runtime/AgentExecutionDisplay.js'; +export { AgentTree } from './runtime/AgentTree.js'; +export type { AgentTreeAgent, AgentTreeProps } from './runtime/AgentTree.js'; diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index a81ecc4cdbb..63551a8cd34 100644 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -13,8 +13,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; -import { COLOR_OPTIONS } from '../constants.js'; -import { fmtDuration } from '../utils.js'; +import { fmtDuration, getAgentColor } from '../utils.js'; import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js'; import { getCachedStringWidth, @@ -185,12 +184,10 @@ export const AgentExecutionDisplay: React.FC = ({ ? Math.min(MAX_VERBOSE_TOOL_CALLS, toolBudget) : Math.min(MAX_TOOL_CALLS, toolBudget); - const agentColor = useMemo(() => { - const colorOption = COLOR_OPTIONS.find( - (option) => option.name === data.subagentColor, - ); - return colorOption?.value || theme.text.accent; - }, [data.subagentColor]); + const agentColor = useMemo( + () => getAgentColor(data.subagentColor), + [data.subagentColor], + ); // Slice the prompt once at the parent so the rendered TaskPromptSection // and the footer's "ctrl+f to show more" hint share the same source of diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentTree.test.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentTree.test.tsx new file mode 100644 index 00000000000..05ab5e242bb --- /dev/null +++ b/packages/cli/src/ui/components/subagents/runtime/AgentTree.test.tsx @@ -0,0 +1,340 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { describe, expect, it, vi } from 'vitest'; +import type { AgentResultDisplay } from '@qwen-code/qwen-code-core'; +import { makeFakeConfig } from '@qwen-code/qwen-code-core'; +import { AgentTree, type AgentTreeAgent } from './AgentTree.js'; + +vi.mock('../../messages/ToolConfirmationMessage.js', () => ({ + ToolConfirmationMessage: ({ + confirmationDetails, + }: { + confirmationDetails: { type?: string }; + }) => ( + {`[approval banner type=${confirmationDetails?.type ?? 'unknown'}]`} + ), +})); + +function makeAgent( + overrides: Partial & { + callId?: string; + isFocused?: boolean; + isWaitingForOtherApproval?: boolean; + } = {}, +): AgentTreeAgent { + const { + callId = 'call-x', + isFocused, + isWaitingForOtherApproval, + ...rest + } = overrides; + return { + callId, + data: { + type: 'task_execution', + subagentName: 'reviewer', + taskDescription: 'review files', + taskPrompt: 'review the files', + status: 'running', + ...rest, + }, + isFocused, + isWaitingForOtherApproval, + }; +} + +function registerEntry( + config: ReturnType, + callId: string, + opts: { + toolUses?: number; + totalTokens?: number; + activity?: { name: string; description?: string }; + } = {}, +): void { + const registry = config.getBackgroundTaskRegistry(); + registry.register({ + agentId: `agent-${callId}`, + description: 'agent for ' + callId, + flavor: 'foreground', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + toolUseId: callId, + stats: { + totalTokens: opts.totalTokens ?? 0, + toolUses: opts.toolUses ?? 0, + durationMs: 0, + }, + }); + if (opts.activity) { + registry.appendActivity(`agent-${callId}`, { + name: opts.activity.name, + description: opts.activity.description ?? '', + at: Date.now(), + }); + } +} + +describe('', () => { + it('renders header + tree row + initializing for a single running agent', () => { + const config = makeFakeConfig(); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Running 1 reviewer agents…'); + expect(frame).toContain('└─'); + expect(frame).toContain('⎿'); + expect(frame).toContain('Initializing…'); + }); + + it('promotes a shared subagentName into the header and drops it from rows', () => { + const config = makeFakeConfig(); + const agents = ['a', 'b', 'c'].map((id) => + makeAgent({ + callId: id, + subagentName: 'reviewer', + taskDescription: `task ${id}`, + }), + ); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Running 3 reviewer agents…'); + // Common name is hidden inside rows; per-task descriptions remain visible. + expect(frame).toContain('task a'); + expect(frame).toContain('task b'); + expect(frame).toContain('task c'); + // Two non-last rows + one last row. + expect(frame.split('\n').filter((l) => l.includes('├─')).length).toBe(2); + expect(frame.split('\n').filter((l) => l.includes('└─')).length).toBe(1); + }); + + it('keeps the generic header when subagentNames differ', () => { + const config = makeFakeConfig(); + const agents = [ + makeAgent({ callId: 'a', subagentName: 'reviewer' }), + makeAgent({ callId: 'b', subagentName: 'researcher' }), + ]; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Running 2 agents…'); + // Both subagent names appear inline because they differ. + expect(frame).toContain('reviewer'); + expect(frame).toContain('researcher'); + }); + + it('collapses backgrounded async agents to a single row with no row 2', () => { + const config = makeFakeConfig(); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Running in the background'); + // Row 2 is suppressed → no `⎿` for the collapsed agent. + expect(frame).not.toContain('⎿'); + }); + + it('renders Done on row 2 for a finished sibling inside a still-pending tree', () => { + const config = makeFakeConfig(); + const completed = makeAgent({ + callId: 'a', + subagentName: 'reviewer', + status: 'completed', + executionSummary: { + rounds: 1, + totalDurationMs: 100, + totalToolCalls: 4, + successfulToolCalls: 4, + failedToolCalls: 0, + successRate: 100, + inputTokens: 0, + outputTokens: 0, + thoughtTokens: 0, + cachedTokens: 0, + totalTokens: 1234, + toolUsage: [], + }, + }); + const running = makeAgent({ callId: 'b', subagentName: 'reviewer' }); + registerEntry(config, 'b', { + toolUses: 2, + totalTokens: 800, + activity: { name: 'grep_search', description: 'TODO' }, + }); + + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Done'); + // Running sibling's row 2 reflects the registry's last activity. + expect(frame.toLowerCase()).toContain('todo'); + // Completed agent's stats come from executionSummary fallback. + expect(frame).toContain('4 tool uses'); + expect(frame).toContain('1.2k tokens'); + }); + + it('updates row 2 when the registry emits an activity event', () => { + const config = makeFakeConfig(); + registerEntry(config, 'a', { + toolUses: 1, + activity: { name: 'grep_search', description: 'first' }, + }); + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? '').toContain('first'); + + act(() => { + config.getBackgroundTaskRegistry().appendActivity('agent-a', { + name: 'run_shell_command', + description: 'ls -la', + at: Date.now(), + }); + }); + const updated = lastFrame() ?? ''; + expect(updated).toContain('ls -la'); + expect(updated).not.toContain('first'); + }); + + it('renders the approval banner above the tree when an agent is focus-locked', () => { + const config = makeFakeConfig(); + const agents = [ + makeAgent({ + callId: 'a', + subagentName: 'reviewer', + pendingConfirmation: { + type: 'exec', + rootCommand: 'rm -rf /', + } as AgentResultDisplay['pendingConfirmation'], + isFocused: true, + }), + makeAgent({ callId: 'b', subagentName: 'reviewer' }), + ]; + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Approval requested by'); + expect(frame).toContain('[approval banner'); + // Tree still renders below the banner. + const bannerIndex = frame.indexOf('[approval banner'); + const treeIndex = frame.indexOf('Running 2 reviewer agents…'); + expect(bannerIndex).toBeLessThan(treeIndex); + }); + + it('marks agents queued behind another approval with a queued marker', () => { + const config = makeFakeConfig(); + const agents = [ + makeAgent({ + callId: 'a', + subagentName: 'reviewer', + pendingConfirmation: { + type: 'exec', + rootCommand: 'first', + } as AgentResultDisplay['pendingConfirmation'], + isFocused: true, + }), + makeAgent({ + callId: 'b', + subagentName: 'reviewer', + pendingConfirmation: { + type: 'exec', + rootCommand: 'second', + } as AgentResultDisplay['pendingConfirmation'], + isWaitingForOtherApproval: true, + }), + ]; + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? '').toContain('⏳ Queued approval'); + }); + + it('keeps the Running header when a finished sibling and a backgrounded sibling coexist', () => { + const config = makeFakeConfig(); + const finished = makeAgent({ + callId: 'a', + subagentName: 'reviewer', + status: 'completed', + executionSummary: { + rounds: 1, + totalDurationMs: 100, + totalToolCalls: 1, + successfulToolCalls: 1, + failedToolCalls: 0, + successRate: 100, + inputTokens: 0, + outputTokens: 0, + thoughtTokens: 0, + cachedTokens: 0, + totalTokens: 100, + toolUsage: [], + }, + }); + const backgrounded = makeAgent({ + callId: 'b', + subagentName: 'reviewer', + status: 'background', + }); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Running 2 reviewer agents…'); + expect(frame).not.toContain('agents finished'); + expect(frame).not.toContain('background agents launched'); + }); + + it('marks failed agents with the terminate reason on row 2', () => { + const config = makeFakeConfig(); + const failed = makeAgent({ + callId: 'a', + status: 'failed', + terminateReason: 'tool error: syntax', + }); + const running = makeAgent({ callId: 'b' }); + const { lastFrame } = render( + , + ); + expect(lastFrame() ?? '').toContain('tool error: syntax'); + }); +}); diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentTree.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentTree.tsx new file mode 100644 index 00000000000..c3cae8ab03b --- /dev/null +++ b/packages/cli/src/ui/components/subagents/runtime/AgentTree.tsx @@ -0,0 +1,330 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { Box, Text } from 'ink'; +import type { + AgentResultDisplay, + BackgroundActivity, + BackgroundTaskEntry, + Config, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../../../semantic-colors.js'; +import { getAgentColor } from '../utils.js'; +import { + formatActivityLabel, + formatTokenCount, +} from '../../../utils/formatters.js'; +import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js'; + +/** A single agent contributed to a tree by its parent `ToolGroupMessage`. */ +export interface AgentTreeAgent { + /** Tool call identifier; mirrors the registry entry's `toolUseId`. */ + callId: string; + data: AgentResultDisplay; + /** + * True when this terminal currently has the focus lock for this + * agent's pending confirmation. Drives whether the approval banner + * renders inline above the tree. + */ + isFocused?: boolean; + /** + * True when this agent has a pending confirmation but another + * agent in the same group currently holds the focus lock. + * Surfaced as a `⏳ Queued approval` annotation on the row. + */ + isWaitingForOtherApproval?: boolean; +} + +export interface AgentTreeProps { + agents: AgentTreeAgent[]; + config: Config; + childWidth: number; + availableHeight?: number; +} + +interface AgentDerived { + toolUses: number; + tokens: number | null; + lastActivity: BackgroundActivity | null; + isAsync: boolean; + isDone: boolean; + isError: boolean; +} + +const TREE_HEAD_LAST = '└─'; +const TREE_HEAD_BRANCH = '├─'; +const TREE_TAIL_LAST = ' ⎿ '; +const TREE_TAIL_BRANCH = '│ ⎿ '; + +/** + * Compact tree of running agents in a single tool group. + * + * Replaces the suppressed-during-live `AgentExecutionDisplay` for the + * `isPending` phase of a `ToolGroupMessage`. Renders a header line plus + * one or two visual rows per agent, with `├─/└─/⎿` connectors so the + * group reads as a tree without a Box border. Subscribes to the + * background-task registry's activity stream so row 2 (the last-tool + * label) stays current as tools fire. + * + * Approval banner: when the focus-holding agent has a pending + * confirmation, the banner renders *above* the tree. The tree continues + * to render below so siblings remain visible while the user decides. + */ +export const AgentTree: React.FC = ({ + agents, + config, + childWidth, + availableHeight, +}) => { + const [, setActivityTick] = useState(0); + + // Re-render when any agent in the tree emits an activity event. + // `callIdsKey` is the stable signature of the agent set; using it + // (rather than the `agents` array identity) keeps the listener from + // resubscribing on every parent render. + const callIdsKey = agents.map((a) => a.callId).join('\x00'); + useEffect(() => { + const ids = new Set(callIdsKey ? callIdsKey.split('\x00') : []); + const registry = config.getBackgroundTaskRegistry(); + return registry.addActivityChangeListener((entry) => { + if (entry.toolUseId && ids.has(entry.toolUseId)) { + setActivityTick((n) => n + 1); + } + }); + }, [config, callIdsKey]); + + const registry = config.getBackgroundTaskRegistry(); + const entriesByCallId = new Map(); + for (const entry of registry.getAll()) { + if (entry.toolUseId) entriesByCallId.set(entry.toolUseId, entry); + } + + const derived: AgentDerived[] = agents.map((a) => + deriveAgentState(a, entriesByCallId.get(a.callId)), + ); + + const focused = agents.find((a) => a.isFocused && a.data.pendingConfirmation); + + const commonType = sharedSubagentName(agents); + const headerText = deriveHeaderText(agents.length, derived, commonType); + + return ( + + {focused && ( + + + Approval requested by + + {focused.data.subagentName || 'agent'} + + : + + + + )} + + {headerText} + + {agents.map((agent, index) => ( + + ))} + + ); +}; + +interface AgentRowProps { + agent: AgentTreeAgent; + derived: AgentDerived; + isLast: boolean; + /** + * True when every agent in the tree shares the same `subagentName`. + * The shared name has already been promoted into the group header, + * so the row drops it and shows just the task description. + */ + hideName: boolean; +} + +const AgentRow: React.FC = ({ + agent, + derived, + isLast, + hideName, +}) => { + const { data } = agent; + const { toolUses, tokens, lastActivity, isAsync, isDone, isError } = derived; + const headChar = isLast ? TREE_HEAD_LAST : TREE_HEAD_BRANCH; + const tailPrefix = isLast ? TREE_TAIL_LAST : TREE_TAIL_BRANCH; + const taskDescription = data.taskDescription; + const showTask = !!taskDescription && taskDescription.length > 0; + const color = getAgentColor(data.subagentColor); + + return ( + + + {headChar} + + {hideName ? ( + showTask && ( + + {taskDescription} + + ) + ) : ( + <> + + {data.subagentName || 'agent'} + + {showTask && ( + ({taskDescription}) + )} + + )} + {isAsync ? ( + + {' · Running in the background'} + + ) : ( + <> + {' · '} + + {toolUses} tool {toolUses === 1 ? 'use' : 'uses'} + + {tokens !== null && ( + <> + {' · '} + {formatTokenCount(tokens)} tokens + + )} + + )} + {agent.isWaitingForOtherApproval && ( + + {' · ⏳ Queued approval'} + + )} + + + {!isAsync && ( + + {tailPrefix} + + {rowTwoText(data, lastActivity, isDone, isError)} + + + )} + + ); +}; + +function deriveAgentState( + agent: AgentTreeAgent, + entry: BackgroundTaskEntry | undefined, +): AgentDerived { + const { data } = agent; + const isAsync = data.status === 'background'; + const isDone = + data.status === 'completed' || + data.status === 'failed' || + data.status === 'cancelled'; + const isError = data.status === 'failed' || data.status === 'cancelled'; + + // Prefer the live registry entry's stats while it exists; fall back + // to the terminal `executionSummary` once foreground entries are + // unregistered. Without the fallback, finished siblings inside a + // still-pending group would render as `0 tool uses · 0 tokens`. + let toolUses = 0; + let tokens: number | null = null; + let lastActivity: BackgroundActivity | null = null; + if (entry) { + toolUses = entry.stats?.toolUses ?? 0; + tokens = entry.stats?.totalTokens ?? null; + const buf = entry.recentActivities; + lastActivity = buf && buf.length > 0 ? buf[buf.length - 1] : null; + } + if (data.executionSummary && (!entry || isDone)) { + toolUses = data.executionSummary.totalToolCalls; + tokens = data.executionSummary.totalTokens; + } else if (tokens === null && typeof data.tokenCount === 'number') { + tokens = data.tokenCount; + } + + return { toolUses, tokens, lastActivity, isAsync, isDone, isError }; +} + +function deriveHeaderText( + count: number, + derived: AgentDerived[], + commonType: string | null, +): string { + // `isAsync` agents are still running detached from the parent — they + // are not "finished," so the finished/launched headers must require + // genuine terminal status. A run that mixes a finished foreground + // agent with a backgrounded sibling stays in the "Running…" branch + // until the backgrounded sibling also terminates. + const allFinished = derived.every((d) => d.isDone); + const allAsync = derived.every((d) => d.isAsync); + + if (allAsync) { + return `${count} background agents launched (↓ to manage)`; + } + if (allFinished) { + return commonType + ? `${count} ${commonType} agents finished` + : `${count} agents finished`; + } + return commonType + ? `Running ${count} ${commonType} agents…` + : `Running ${count} agents…`; +} + +function rowTwoText( + data: AgentResultDisplay, + lastActivity: BackgroundActivity | null, + isDone: boolean, + isError: boolean, +): string { + if (isError) { + return ( + data.terminateReason ?? + (data.status === 'failed' ? 'Failed' : 'Cancelled') + ); + } + if (isDone) { + return 'Done'; + } + if (lastActivity) { + return formatActivityLabel(lastActivity.name, lastActivity.description); + } + return 'Initializing…'; +} + +/** + * Returns the shared `subagentName` if every agent uses the same one + * (and the name is non-empty), else `null`. Used to decide whether the + * header should promote the name (`Running 3 reviewer agents…`) and + * the per-row labels should drop it. + */ +function sharedSubagentName(agents: AgentTreeAgent[]): string | null { + if (agents.length === 0) return null; + const first = agents[0].data.subagentName; + if (!first) return null; + return agents.every((a) => a.data.subagentName === first) ? first : null; +} diff --git a/packages/cli/src/ui/components/subagents/utils.ts b/packages/cli/src/ui/components/subagents/utils.ts index 73011662a3b..ca0651954cb 100644 --- a/packages/cli/src/ui/components/subagents/utils.ts +++ b/packages/cli/src/ui/components/subagents/utils.ts @@ -5,6 +5,7 @@ */ import { COLOR_OPTIONS, TOTAL_WIZARD_STEPS } from './constants.js'; +import { theme } from '../../semantic-colors.js'; export const shouldShowColor = (color?: string): boolean => color !== undefined && color !== 'auto'; @@ -14,6 +15,13 @@ export const getColorForDisplay = (colorName?: string): string | undefined => ? undefined : COLOR_OPTIONS.find((color) => color.name === colorName)?.value; +/** + * Resolve a subagent color to a renderable value, falling back to the + * theme accent for `auto`, missing, or unknown names. + */ +export const getAgentColor = (colorName?: string): string => + getColorForDisplay(colorName) ?? theme.text.accent; + /** * Sanitizes user input by removing dangerous characters and normalizing whitespace. */ diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 36ed878d481..b516d4f4244 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -4,6 +4,32 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ToolDisplayNames, ToolNames } from '@qwen-code/qwen-code-core'; + +const TOOL_DISPLAY_BY_NAME: Record = Object.fromEntries( + (Object.keys(ToolNames) as Array).map((key) => [ + ToolNames[key], + ToolDisplayNames[key], + ]), +); + +/** + * Render a tool invocation as a short label suitable for inline display + * (e.g. `Shell(list /home/user)`). Falls back to the bare display name + * when no description is available, and to the raw tool name when the + * tool has no entry in the display-name map. + */ +export function formatActivityLabel( + name: string, + description: string | undefined, +): string { + const display = TOOL_DISPLAY_BY_NAME[name] ?? name; + const singleLineDesc = description + ? description.replace(/\s*\n\s*/g, ' ').trim() + : ''; + return singleLineDesc ? `${display}(${singleLineDesc})` : display; +} + export const formatMemoryUsage = (bytes: number): string => { const gb = bytes / (1024 * 1024 * 1024); if (bytes < 1024 * 1024) { diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 3c781ec2836..7ad2fd06683 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -726,7 +726,7 @@ describe('BackgroundTaskRegistry', () => { const statusCb = vi.fn(); const activityCb = vi.fn(); registry.setStatusChangeCallback(statusCb); - registry.setActivityChangeCallback(activityCb); + registry.addActivityChangeListener(activityCb); registry.register({ agentId: 'a', @@ -745,6 +745,54 @@ describe('BackgroundTaskRegistry', () => { expect(activityCb.mock.calls[0][0].agentId).toBe('a'); }); + it('fans appendActivity out to every registered listener', () => { + const a = vi.fn(); + const b = vi.fn(); + const unsubA = registry.addActivityChangeListener(a); + registry.addActivityChangeListener(b); + + registry.register({ + agentId: 'x', + description: 'agent x', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.appendActivity('x', { name: 'T', description: 'd', at: 0 }); + expect(a).toHaveBeenCalledOnce(); + expect(b).toHaveBeenCalledOnce(); + + unsubA(); + registry.appendActivity('x', { name: 'T', description: 'd2', at: 1 }); + // a unsubscribed; only b should pick up the second event. + expect(a).toHaveBeenCalledOnce(); + expect(b).toHaveBeenCalledTimes(2); + }); + + it('keeps emitting to remaining listeners when one throws', () => { + const bad = vi.fn(() => { + throw new Error('boom'); + }); + const good = vi.fn(); + registry.addActivityChangeListener(bad); + registry.addActivityChangeListener(good); + + registry.register({ + agentId: 'y', + description: 'agent y', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + expect(() => + registry.appendActivity('y', { name: 'T', description: 'd', at: 0 }), + ).not.toThrow(); + expect(bad).toHaveBeenCalledOnce(); + expect(good).toHaveBeenCalledOnce(); + }); + it('stores prompt verbatim on the entry', () => { registry.register({ agentId: 'a', diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 8ef752f7013..75273eec220 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -207,7 +207,8 @@ export class BackgroundTaskRegistry { private notificationCallback?: BackgroundNotificationCallback; private registerCallback?: BackgroundRegisterCallback; private statusChangeCallback?: BackgroundStatusChangeCallback; - private activityChangeCallback?: BackgroundActivityChangeCallback; + private readonly activityChangeListeners = + new Set(); register(entry: BackgroundTaskEntry): void { if (!entry.pendingMessages) entry.pendingMessages = []; @@ -512,10 +513,23 @@ export class BackgroundTaskRegistry { this.statusChangeCallback = cb; } - setActivityChangeCallback( - cb: BackgroundActivityChangeCallback | undefined, - ): void { - this.activityChangeCallback = cb; + /** + * Subscribe to per-tool activity events. Returns an `unsubscribe` + * function so consumers can clean up from a `useEffect` return value. + * + * Multiple listeners are supported because the inline tree (live + * agent rows) and the detail dialog both want activity ticks while + * they're visible. Listeners are stored in insertion order; an + * individual listener that throws is logged and skipped without + * stopping the rest. + */ + addActivityChangeListener( + listener: BackgroundActivityChangeCallback, + ): () => void { + this.activityChangeListeners.add(listener); + return () => { + this.activityChangeListeners.delete(listener); + }; } abortAll(options: BackgroundTaskCancelOptions = {}): void { @@ -626,11 +640,17 @@ export class BackgroundTaskRegistry { } private emitActivityChange(entry: BackgroundTaskEntry): void { - if (!this.activityChangeCallback) return; - try { - this.activityChangeCallback(entry); - } catch (error) { - debugLogger.error('Failed to emit background activity change:', error); + if (this.activityChangeListeners.size === 0) return; + // Snapshot before iterating so a listener that adds or removes + // another listener can't extend the loop or accidentally receive + // the in-progress event. + const snapshot = Array.from(this.activityChangeListeners); + for (const listener of snapshot) { + try { + listener(entry); + } catch (error) { + debugLogger.error('Failed to emit background activity change:', error); + } } } }