From bf60e534022e237c9d2ebe9b52f20d93eb23bc92 Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 7 May 2026 17:46:24 +0800 Subject: [PATCH] feat(cli): replace inline AgentExecutionDisplay with always-on LiveAgentPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface running subagents in a borderless, always-on roster anchored beneath the input footer (mirrors Claude Code's CoordinatorTaskPanel) and retire the verbose inline `AgentExecutionDisplay` frame whose per-tool-call mutations caused scrollback flicker. Detail / cancel / resume keep flowing through the existing BackgroundTasksDialog. LiveAgentPanel: - Two-column row: status icon + (optional) type + description + activity on the left (truncate-end), elapsed + tokens on the right in a flex-shrink:0 column so the cost/time fields are never hidden by long descriptions. - Re-pulls each agent from BackgroundTaskRegistry on every wall-clock tick so `recentActivities` stays fresh — the snapshot from `useBackgroundTaskView` only refreshes on `statusChange` to keep the footer pill / AppContainer quiet under heavy tool traffic. - Reaches for Config via raw ConfigContext (not useConfig) so the panel degrades to snapshot-only when no provider is mounted (test isolation). - Hides when any dialog is visible (auth / permission / bg tasks) and self-hides when no agent entries are live. - Drops `subagentType` from the row when it is the default `general-purpose` builtin to keep the line uncluttered; specialized types still bold-anchor the row. - Keeps terminal entries on screen for 8s so the user gets feedback when an agent finishes, then they fall off (BackgroundTasksDialog retains them long-term). Inline frame retirement: - ToolMessage's SubagentExecutionRenderer collapses to the focus- routed approval surfaces only (focus-holder banner + queued marker). All other agent state is owned by the panel + dialog. - AgentExecutionDisplay.tsx + test removed (-918 lines); the subagents/index export is dropped with a pointer to the new surfaces. Net diff: +97 / -1069. --- .../background-view/LiveAgentPanel.test.tsx | 268 +++++++ .../background-view/LiveAgentPanel.tsx | 296 +++++++ .../components/messages/ToolMessage.test.tsx | 122 +-- .../ui/components/messages/ToolMessage.tsx | 140 ++-- .../cli/src/ui/components/subagents/index.ts | 6 +- .../runtime/AgentExecutionDisplay.test.tsx | 193 ----- .../runtime/AgentExecutionDisplay.tsx | 725 ------------------ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 13 + 8 files changed, 677 insertions(+), 1086 deletions(-) create mode 100644 packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx create mode 100644 packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx delete mode 100644 packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx delete mode 100644 packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx diff --git a/packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx b/packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx new file mode 100644 index 000000000..5402f50bf --- /dev/null +++ b/packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx @@ -0,0 +1,268 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from '@testing-library/react'; +import { render } from 'ink-testing-library'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { LiveAgentPanel } from './LiveAgentPanel.js'; +import { BackgroundTaskViewStateContext } from '../../contexts/BackgroundTaskViewContext.js'; +import { ConfigContext } from '../../contexts/ConfigContext.js'; +import type { + AgentDialogEntry, + DialogEntry, +} from '../../hooks/useBackgroundTaskView.js'; + +function agentEntry( + overrides: Partial = {}, +): AgentDialogEntry { + return { + kind: 'agent', + agentId: 'a', + description: 'desc', + status: 'running', + startTime: 0, + abortController: new AbortController(), + ...overrides, + } as AgentDialogEntry; +} + +function shellEntry(overrides: Partial = {}): DialogEntry { + return { + kind: 'shell', + shellId: 'bg_x', + command: 'sleep 60', + cwd: '/tmp', + status: 'running', + startTime: 0, + outputPath: '/tmp/x.out', + abortController: new AbortController(), + ...overrides, + } as DialogEntry; +} + +function renderPanel( + options: { + entries: readonly DialogEntry[]; + dialogOpen?: boolean; + width?: number; + maxRows?: number; + /** + * Stub Config supplying a `getBackgroundTaskRegistry()` for the + * panel's per-tick live re-pull. Omit when the test cares only + * about the snapshot path (panel falls back gracefully). + */ + config?: Config; + } = { entries: [] }, +) { + const state = { + entries: options.entries, + selectedIndex: 0, + dialogMode: options.dialogOpen ? ('list' as const) : ('closed' as const), + dialogOpen: Boolean(options.dialogOpen), + pillFocused: false, + }; + // Wrap render() in act() so the panel's mount-time effect (the + // 1s wall-clock interval) is flushed inside React's scheduler boundary + // — silences the "update inside a test was not wrapped in act" + // warning ink-testing-library otherwise leaks for every render. + let result!: ReturnType; + act(() => { + result = render( + + + + + , + ); + }); + return result; +} + +/** + * Build a stub Config exposing only `getBackgroundTaskRegistry` — the + * one method the panel calls. Returning a Map-backed registry whose + * `get` reads from the live store lets a test mutate `recentActivities` + * after render and observe the panel pick up the new value on the next + * tick (the actual production behavior we want to lock in). + */ +function makeRegistryConfig(agents: readonly AgentDialogEntry[]): { + config: Config; + store: Map; +} { + const store = new Map(); + for (const a of agents) store.set(a.agentId, a); + const config = { + getBackgroundTaskRegistry: () => ({ + get: (id: string) => store.get(id), + }), + } as unknown as Config; + return { config, store }; +} + +describe('', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('hides when there are no agent entries', () => { + const { lastFrame } = renderPanel({ entries: [] }); + expect(lastFrame() ?? '').toBe(''); + }); + + it('hides when only non-agent entries exist (shell-only)', () => { + const { lastFrame } = renderPanel({ entries: [shellEntry()] }); + expect(lastFrame() ?? '').toBe(''); + }); + + it('hides when the background dialog is open (avoids duplicate roster)', () => { + const { lastFrame } = renderPanel({ + entries: [agentEntry({ subagentType: 'researcher' })], + dialogOpen: true, + }); + expect(lastFrame() ?? '').toBe(''); + }); + + it('renders header and a single running agent row', () => { + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'a-1', + subagentType: 'researcher', + description: 'researcher: scan repo for TODO markers', + startTime: -5_000, // 5s ago at fake-time 0 + recentActivities: [ + { name: 'Glob', description: '**/*.ts', at: -1000 }, + ], + }), + ], + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Active agents'); + // Running and total tally both 1. + expect(frame).toContain('(1/1)'); + expect(frame).toContain('researcher'); + expect(frame).toContain('scan repo for TODO markers'); + // Latest activity is rendered next to the row, with elapsed time. + expect(frame).toContain('Glob'); + expect(frame).toContain('5s'); + }); + + it('marks foreground agents with the [in turn] prefix', () => { + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'fg-1', + subagentType: 'editor', + description: 'editor: tighten import order', + flavor: 'foreground', + }), + ], + }); + expect(lastFrame() ?? '').toContain('[in turn]'); + }); + + it('windows from the tail when entries exceed maxRows', () => { + const entries = [ + agentEntry({ + agentId: 'a-1', + subagentType: 'old-agent', + description: 'old work', + }), + agentEntry({ + agentId: 'a-2', + subagentType: 'mid-agent', + description: 'mid work', + }), + agentEntry({ + agentId: 'a-3', + subagentType: 'fresh-agent', + description: 'fresh work', + }), + ]; + const { lastFrame } = renderPanel({ entries, maxRows: 2 }); + const frame = lastFrame() ?? ''; + // `more above` callout flagged with the dropped count. + expect(frame).toContain('1 more above'); + // Tail window keeps the newest two rows. + expect(frame).toContain('mid-agent'); + expect(frame).toContain('fresh-agent'); + // Oldest row falls outside the window. + expect(frame).not.toContain('old-agent'); + // Total tally still reflects every agent — windowing is a render + // concern, not a counting one. + expect(frame).toContain('(3/3)'); + }); + + it('re-pulls recentActivities from the live registry on each tick', () => { + // The snapshot from useBackgroundTaskView only refreshes on + // statusChange — appendActivity is intentionally silenced there to + // protect the footer pill / AppContainer from per-tool churn. The + // panel must reach back into the registry on every tick or it + // would freeze on whatever activities the snapshot captured at + // register time (typically empty, since register fires before any + // tools run). + const initial = agentEntry({ + agentId: 'live-1', + subagentType: 'researcher', + description: 'researcher: investigate', + recentActivities: [], // snapshot has nothing + }); + const { config, store } = makeRegistryConfig([initial]); + const { lastFrame } = renderPanel({ entries: [initial], config }); + + // First paint: snapshot says no activities, registry agrees. + expect(lastFrame() ?? '').not.toContain('Glob'); + + // Mutate the registry the way `appendActivity` would in production + // (replace the array reference on the same entry object) and + // advance the wall-clock tick. The panel should re-pull and show + // the new activity without needing a statusChange. + const live = store.get('live-1')!; + store.set('live-1', { + ...live, + recentActivities: [ + { name: 'Glob', description: '**/*.ts', at: Date.now() }, + ], + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(lastFrame() ?? '').toContain('Glob'); + }); + + it('shows terminal status briefly then falls off after the visibility window', () => { + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'done-1', + subagentType: 'finisher', + description: 'finisher: wrap up', + status: 'completed', + startTime: -2000, + endTime: 0, // just terminal + }), + ], + }); + // Within the visibility window the row is still on screen but the + // running tally drops to 0/1. + expect(lastFrame() ?? '').toContain('finisher'); + expect(lastFrame() ?? '').toContain('(0/1)'); + + act(() => { + vi.advanceTimersByTime(9000); + }); + // Past TERMINAL_VISIBLE_MS the row is evicted from the panel; with + // nothing left to show the panel hides itself. + expect(lastFrame() ?? '').toBe(''); + }); +}); diff --git a/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx b/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx new file mode 100644 index 000000000..14cd4c440 --- /dev/null +++ b/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx @@ -0,0 +1,296 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * LiveAgentPanel — always-on bottom-of-screen roster of running subagents. + * + * Mirrors Claude Code's CoordinatorTaskPanel ("Renders below the prompt + * input footer whenever local_agent tasks exist") — borderless rows of + * `status · name · activity · elapsed` so the panel sits lightly above + * the composer rather than competing with it for vertical space. The + * heavier bordered look stays with `BackgroundTasksDialog`, the + * Down-arrow detail view that handles selection, cancel, and resume. + * + * Replaces the inline `AgentExecutionDisplay` frame for live updates — + * that frame mutated on every tool-call and caused scrollback repaint + * flicker once the tool list grew past the terminal height. The panel + * sits outside `` so updates never disturb committed history, + * and the same per-agent registry already powers the footer pill and + * the dialog, so the three views never drift. + * + * Scope: read-only display. Cancel / detail / approval routing all stay + * with the existing pill+dialog (Down arrow → BackgroundTasksDialog) so + * this panel never competes for keyboard input. + */ + +import type React from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { useBackgroundTaskViewState } from '../../contexts/BackgroundTaskViewContext.js'; +import { ConfigContext } from '../../contexts/ConfigContext.js'; +import { theme } from '../../semantic-colors.js'; +import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; +import type { + AgentDialogEntry, + DialogEntry, +} from '../../hooks/useBackgroundTaskView.js'; + +interface LiveAgentPanelProps { + /** + * Maximum agent rows to render. The panel windows from the most recent + * launches downward when the list outgrows the budget — matches the + * BackgroundTasksDialog list-mode windowing convention. + */ + maxRows?: number; + /** + * Outer width budget so the panel respects the layout's main-area + * width when the terminal is narrow. Optional — caller defaults to + * the layout width when omitted. + */ + width?: number; +} + +const DEFAULT_MAX_ROWS = 5; +// Keep terminal entries on the panel briefly so the user gets visual +// feedback ("✓ done · 12s") when a subagent finishes, then they fall off +// and the user goes to BackgroundTasksDialog for a deeper look. Mirrors +// Claude Code's `RECENT_COMPLETED_TTL_MS = 30_000` knob, scaled down +// because the panel is denser and we have the dialog as the long-term +// review surface. +const TERMINAL_VISIBLE_MS = 8000; +// `general-purpose` is the default builtin subagent; printing the type +// every row when it's the default just clutters the line — the +// description carries all the meaningful identity. Specialized +// subagents (named in `subagents/builtin-agents.ts` or user-authored) +// still get their type rendered as a bold anchor. +const DEFAULT_SUBAGENT_TYPE = 'general-purpose'; + +type LivePanelEntry = AgentDialogEntry & { + /** True when the row is past its terminal-visibility window. */ + expired: boolean; +}; + +function isAgentEntry(entry: DialogEntry): entry is AgentDialogEntry { + return entry.kind === 'agent'; +} + +function statusIcon(entry: AgentDialogEntry): { glyph: string; color: string } { + switch (entry.status) { + case 'running': + return { glyph: '⊷', color: theme.status.warning }; + case 'paused': + return { glyph: '⏸', color: theme.status.warning }; + case 'completed': + return { glyph: '✔', color: theme.status.success }; + case 'failed': + return { glyph: '✖', color: theme.status.error }; + case 'cancelled': + return { glyph: '✖', color: theme.status.warning }; + default: + return { glyph: '○', color: theme.text.secondary }; + } +} + +function activityLabel(entry: AgentDialogEntry): string { + const last = entry.recentActivities?.at(-1); + if (!last) return ''; + const desc = last.description?.replace(/\s*\n\s*/g, ' ').trim(); + return desc ? `${last.name} ${desc}` : last.name; +} + +/** + * Strip the leading `subagentType:` prefix from `entry.description` if + * present so the row doesn't render `editor · editor: tighten…`. We + * intentionally do NOT call `buildBackgroundEntryLabel` here: the shared + * helper also caps at 40 chars + appends `…`, which then collides with + * the row-level `truncate-end` and produces a double-ellipsis on narrow + * terminals (e.g. `… FIXME ……`). The row's own truncation has the full + * width budget and is the right place to decide where to cut. + */ +function descriptionWithoutPrefix(entry: AgentDialogEntry): string { + const raw = entry.description ?? ''; + if (!entry.subagentType) return raw; + const lowerRaw = raw.toLowerCase(); + const prefix = entry.subagentType.toLowerCase() + ':'; + if (lowerRaw.startsWith(prefix)) { + return raw.slice(prefix.length).trimStart(); + } + return raw; +} + +function elapsedLabel(entry: AgentDialogEntry, now: number): string { + const startedAt = entry.startTime; + const endedAt = entry.endTime ?? now; + const ms = Math.max(0, endedAt - startedAt); + // Whole-second precision keeps the row stable between paint frames — + // a stopwatch ticking sub-seconds in a footer panel is a distraction. + const wholeSeconds = Math.floor(ms / 1000); + return formatDuration(wholeSeconds * 1000, { hideTrailingZeros: true }); +} + +export const LiveAgentPanel: React.FC = ({ + maxRows = DEFAULT_MAX_ROWS, + width, +}) => { + const { entries, dialogOpen } = useBackgroundTaskViewState(); + // Reach for Config via the raw context (NOT useConfig) so the panel + // can degrade to snapshot-only when no provider is mounted — e.g. + // unit tests that render the component in isolation. useConfig + // throws in that case, which would force every consumer to provide + // a stub Config just to satisfy the panel's "live registry re-pull". + const config = useContext(ConfigContext); + + // Wall-clock tick. Drives elapsed-time refresh, terminal-row eviction, + // AND the live registry re-pull below. Only runs while the panel + // actually has live work to display so we don't keep a useless + // interval alive in the steady state. + const [now, setNow] = useState(() => Date.now()); + const hasAgents = entries.some(isAgentEntry); + useEffect(() => { + if (!hasAgents) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [hasAgents]); + + // Re-pull each agent from the live registry on every tick so the row + // shows the latest `recentActivities` — `useBackgroundTaskView` + // intentionally only refreshes its snapshot on `statusChange` to keep + // the footer pill / AppContainer quiet under heavy tool traffic, but + // a glance roster MUST surface "what is this agent doing right now" + // or it stops being a glance surface. Mirrors the pattern in + // BackgroundTasksDialog's detail body, which re-reads the registry + // on its own activity tick. Falls back to the snapshot when Config + // isn't available (test fixtures) or the entry has unregistered + // between snapshots. + // + // NOTE: this useMemo MUST come before the `if (dialogOpen) return null` + // early-return below — React's rules of hooks require hook calls in + // identical order each render, so a conditional early-return that + // skips a subsequent hook is a violation. + const liveAgentSnapshots: AgentDialogEntry[] = useMemo(() => { + const snapshots = entries.filter(isAgentEntry); + if (!config) return snapshots; + const registry = config.getBackgroundTaskRegistry(); + return snapshots.map((snap) => { + const live = registry.get(snap.agentId); + return live ? { ...live, kind: 'agent' as const } : snap; + }); + // `now` is a deliberate dep so the memo recomputes each tick and + // captures the latest `recentActivities` mutated in place by the + // registry's appendActivity path. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entries, config, now]); + + // Defense in depth: don't compete with the dialog. Under + // DefaultAppLayout this branch is unreachable because the layout + // already gates the panel on `!uiState.dialogsVisible` (which folds + // in `bgTasksDialogOpen`), but we keep the internal gate so callers + // mounting the panel outside that layout still get the right + // behavior. + if (dialogOpen) return null; + + const visibleAgents: LivePanelEntry[] = liveAgentSnapshots + .map((entry) => ({ + ...entry, + expired: + entry.status !== 'running' && + entry.status !== 'paused' && + entry.endTime !== undefined && + now - entry.endTime > TERMINAL_VISIBLE_MS, + })) + .filter((entry) => !entry.expired); + + if (visibleAgents.length === 0) return null; + + // Window from the tail (newest launches) when the list outgrows the + // budget. Older live agents are still surfaced in the pill count and + // the dialog — the panel is a glance surface, not a full roster. + const overflow = Math.max(0, visibleAgents.length - maxRows); + const visible = overflow > 0 ? visibleAgents.slice(-maxRows) : visibleAgents; + + const runningCount = visibleAgents.filter( + (e) => e.status === 'running', + ).length; + + // Borderless layout, mirroring Claude Code's CoordinatorTaskPanel + // ("Renders below the prompt input footer whenever local_agent tasks + // exist" — plain rows under a single marginTop). The bordered look + // belongs to BackgroundTasksDialog (a real overlay); the always-on + // roster is a glance surface that should sit lightly above the + // composer rather than fight it for vertical space + border cells. + return ( + + + + Active agents + + {` (${runningCount}/${visibleAgents.length})`} + + {overflow > 0 && ( + + {` ^ ${overflow} more above`} + + )} + {visible.map((entry) => ( + + ))} + + ); +}; + +const AgentRow: React.FC<{ entry: AgentDialogEntry; now: number }> = ({ + entry, + now, +}) => { + const { glyph, color } = statusIcon(entry); + const label = descriptionWithoutPrefix(entry); + const flavorPrefix = entry.flavor === 'foreground' ? '[in turn] ' : ''; + const activity = activityLabel(entry); + const elapsed = elapsedLabel(entry, now); + const showType = + entry.subagentType !== undefined && + entry.subagentType !== DEFAULT_SUBAGENT_TYPE; + const tokenSuffix = + entry.stats?.totalTokens && entry.stats.totalTokens > 0 + ? ` · ${formatTokenCount(entry.stats.totalTokens)} tokens` + : ''; + + // Two-column row: a flex-grow left column (status icon + type + + // description + activity) that absorbs truncation, and a flex-shrink:0 + // right column (elapsed + tokens) that always renders in full. Without + // the split, a long activity label would push elapsed/tokens past the + // right edge and Ink's row-level `truncate-end` would silently eat the + // very fields the user needs to monitor a run. Two extra spaces after + // the status glyph give the bold anchor breathing room. + return ( + + + + {glyph} + {showType && ( + <> + {entry.subagentType} + {' · '} + + )} + {`${flavorPrefix}${label}`} + {activity && ( + {` · ${activity}`} + )} + + + + {` · ${elapsed}${tokenSuffix}`} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 274b39148..f1b2df54a 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -101,19 +101,6 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({ return MockMarkdown:{text}; }, })); -vi.mock('../subagents/index.js', () => ({ - AgentExecutionDisplay: function MockAgentExecutionDisplay({ - data, - }: { - data: { subagentName: string; taskDescription: string }; - }) { - return ( - - 🤖 {data.subagentName} • Task: {data.taskDescription} - - ); - }, -})); vi.mock('./ToolConfirmationMessage.js', () => ({ ToolConfirmationMessage: function MockToolConfirmationMessage() { // Sentinel string lets `isPending && pendingConfirmation` tests @@ -313,41 +300,13 @@ describe('', () => { expect(lowEmphasisFrame()).not.toContain('←'); }); - it('shows subagent execution display for task tool with proper result display', () => { - const subagentResultDisplay = { - type: 'task_execution' as const, - subagentName: 'file-search', - taskDescription: 'Search for files matching pattern', - taskPrompt: 'Search for files matching pattern', - status: 'running' as const, - }; - - const props: ToolMessageProps = { - name: 'task', - description: 'Delegate task to subagent', - resultDisplay: subagentResultDisplay, - status: ToolCallStatus.Executing, - contentWidth: 80, - callId: 'test-call-id-2', - confirmationDetails: undefined, - config: mockConfig, - }; - - const { lastFrame } = renderWithContext( - , - StreamingState.Responding, - ); - - const output = lastFrame(); - expect(output).toContain('🤖'); // Subagent execution display should show - expect(output).toContain('file-search'); // Actual subagent name - expect(output).toContain('Search for files matching pattern'); // Actual task description - }); - - describe('subagent live-render gating (isPending)', () => { - // The redesign hides the inline AgentExecutionDisplay while a - // foreground subagent runs (the pill+dialog handle drill-down). - // Only an active, focused approval prompt renders inline. + describe('subagent inline rendering (approval-only surface)', () => { + // The verbose inline AgentExecutionDisplay frame has been retired in + // favour of the always-on LiveAgentPanel (live progress) and + // BackgroundTasksDialog (history / detail). ToolMessage's only + // remaining inline subagent surface is the focus-routed approval + // prompt — both running and committed agent states render nothing + // inline now. const buildProps = (overrides: { data: { subagentName: string; @@ -360,11 +319,6 @@ describe('', () => { isFocused?: boolean; isWaitingForOtherApproval?: boolean; }): ToolMessageProps => { - // Spread the existing typed defaults so any future required field - // on `ToolMessageProps` becomes a compile-time miss instead of - // silently defaulting to undefined. Only the agent-specific - // `resultDisplay` shape uses a cast — its `pendingConfirmation` - // intentionally accepts a loose `object` fixture for these tests. const resultDisplay = { type: 'task_execution' as const, ...overrides.data, @@ -383,7 +337,7 @@ describe('', () => { }; }; - it('isPending && no pendingConfirmation → no inline frame', () => { + it('running subagent without confirmation → no inline frame', () => { const { lastFrame } = renderWithContext( ', () => { StreamingState.Responding, ); const output = lastFrame() ?? ''; - // The mocked AgentExecutionDisplay tags itself with '🤖' — its - // absence proves the inline frame was suppressed. - expect(output).not.toContain('🤖'); + // No approval surface; LiveAgentPanel + dialog handle the run. + expect(output).not.toContain('MockApprovalPrompt'); + expect(output).not.toContain('Approval requested by'); + expect(output).not.toContain('Queued approval:'); + }); + + it('completed subagent → no inline frame', () => { + // Committed subagents now live exclusively in BackgroundTasksDialog; + // the inline scrollback no longer paints a verbose summary. + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + expect(output).not.toContain('committed-agent'); expect(output).not.toContain('MockApprovalPrompt'); }); - it('isPending && pendingConfirmation && isFocused → renders banner with agent label', () => { + it('pendingConfirmation && isFocused → renders banner with agent label', () => { const { lastFrame } = renderWithContext( ', () => { StreamingState.Responding, ); const output = lastFrame() ?? ''; - // Banner shows up with the originating agent identified, and the - // approval prompt itself renders. expect(output).toContain('Approval requested by'); expect(output).toContain('fg-agent'); expect(output).toContain('MockApprovalPrompt'); - // The full agent frame (header / tool-call list) stays suppressed. - expect(output).not.toContain('🤖'); }); - it('isPending && pendingConfirmation && !isFocused → renders queued marker (one-line)', () => { + it('pendingConfirmation && !isFocused → renders queued marker (one-line)', () => { // Without this marker, a subagent waiting on another subagent's // approval would be invisible in the main view — the user would // have no inline signal that an approval is queued and would have @@ -456,32 +428,8 @@ describe('', () => { const output = lastFrame() ?? ''; expect(output).toContain('Queued approval:'); expect(output).toContain('queued-agent'); - // The full prompt + frame stay suppressed — only the focus-holder - // renders the active prompt above this row. expect(output).not.toContain('Approval requested by'); expect(output).not.toContain('MockApprovalPrompt'); - expect(output).not.toContain('🤖'); - }); - - it('!isPending → committed render shows full inline frame', () => { - const { lastFrame } = renderWithContext( - , - StreamingState.Idle, - ); - const output = lastFrame() ?? ''; - // -rendered scrollback: full frame, no flicker concern. - expect(output).toContain('🤖'); - expect(output).toContain('committed-agent'); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 314ded5ac..b926ccd07 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -24,7 +24,6 @@ import type { McpToolProgressData, FileDiff, } from '@qwen-code/qwen-code-core'; -import { AgentExecutionDisplay } from '../subagents/index.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; @@ -250,17 +249,18 @@ const PlanResultRenderer: React.FC<{ /** * Component to render subagent execution results. * - * Live (`isPending===true`): the inline frame is suppressed — running - * subagents are surfaced through the footer pill + dialog instead, which - * removes the live-area flicker that occurred when the frame's tool-call - * list grew past the terminal height. The one exception is an active - * approval prompt that holds the focus lock: that renders as a small - * banner with an agent-name label, since hiding it would block the run - * silently. + * The verbose inline frame has been retired in favour of the + * always-on `LiveAgentPanel` (live roster, anchored beneath the input + * footer) and `BackgroundTasksDialog` (Down-arrow detail view). This + * renderer now only emits the focus-routed approval surfaces: * - * Committed (`isPending===false`): renders the full `AgentExecutionDisplay` - * exactly as before. Ink's `` is append-only, so committed frames - * never flicker even when verbose. + * - focus-holder: full inline approval prompt so the user can answer + * without context-switching into the dialog. + * - queued sibling: a one-line marker so users know another subagent + * is waiting in line behind the focus-holder. + * + * All other agent state (running progress + completion summary) is + * rendered exclusively by the panel + dialog pair. */ const SubagentExecutionRenderer: React.FC<{ data: AgentResultDisplay; @@ -268,69 +268,41 @@ const SubagentExecutionRenderer: React.FC<{ childWidth: number; config: Config; isFocused?: boolean; - isPending?: boolean; - isWaitingForOtherApproval?: boolean; -}> = ({ - data, - availableHeight, - childWidth, - config, - isFocused, - isPending, - isWaitingForOtherApproval, -}) => { - if (isPending) { - if (data.pendingConfirmation && isFocused) { - // Active approval prompt for the focus-holding subagent — render - // inline so the user can act on it without opening the dialog. - const agentLabel = data.subagentName || 'agent'; - return ( - - - Approval requested by - - {agentLabel} - - : - - - - ); - } - if (data.pendingConfirmation) { - // Queued approval — another subagent currently holds the focus lock. - // A one-line marker keeps the user aware that something is waiting - // without opening the dialog; the full prompt renders on the - // focus-holder above and inside `BackgroundTasksDialog`. - const agentLabel = data.subagentName || 'agent'; - return ( - - - ⏳ Queued approval:{' '} +}> = ({ data, availableHeight, childWidth, config, isFocused }) => { + if (data.pendingConfirmation && isFocused) { + const agentLabel = data.subagentName || 'agent'; + return ( + + + Approval requested by + + {agentLabel} - {agentLabel} + : - ); - } - return null; + + + ); } - return ( - - ); + if (data.pendingConfirmation) { + const agentLabel = data.subagentName || 'agent'; + return ( + + + ⏳ Queued approval:{' '} + + {agentLabel} + + ); + } + return null; }; /** @@ -436,12 +408,21 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { isFocused?: boolean; /** * True when rendering inside `pendingHistoryItems` (live area), false once - * committed to ``. Foreground subagents suppress their inline - * frame in the live phase — the pill+dialog handle drill-down — but - * always render in scrollback. + * committed to ``. Subagents no longer paint an inline frame in + * either phase — `LiveAgentPanel` (always-on roster) and + * `BackgroundTasksDialog` (Down-arrow detail) own that surface — so this + * flag is purely informational at this layer; ToolGroupMessage still + * forwards it for non-subagent message types that key off live vs. + * committed rendering. */ isPending?: boolean; - /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ + /** + * Whether another subagent's approval currently holds the focus lock, + * blocking this one. Routed by `ToolGroupMessage`; vestigial for the + * subagent renderer (the queued marker reads the absence of `isFocused` + * directly), retained on the prop bag for call-site compatibility and + * future signaling needs. + */ isWaitingForOtherApproval?: boolean; } @@ -460,8 +441,11 @@ export const ToolMessage: React.FC = ({ config, forceShowResult, isFocused, - isPending, - isWaitingForOtherApproval, + // isPending / isWaitingForOtherApproval flow into ToolMessage from + // ToolGroupMessage but no longer drive the subagent render path + // (LiveAgentPanel + BackgroundTasksDialog own that surface). They stay + // on the props interface so callers don't churn, but skipping the + // destructure here keeps the implementation honest about what's read. executionStartTime, }) => { const settings = useSettings(); @@ -616,8 +600,6 @@ export const ToolMessage: React.FC = ({ childWidth={innerWidth} config={config} isFocused={isFocused} - isPending={isPending} - isWaitingForOtherApproval={isWaitingForOtherApproval} /> )} {effectiveDisplayRenderer.type === 'diff' && ( diff --git a/packages/cli/src/ui/components/subagents/index.ts b/packages/cli/src/ui/components/subagents/index.ts index 8f22a244d..ed38dfdb3 100644 --- a/packages/cli/src/ui/components/subagents/index.ts +++ b/packages/cli/src/ui/components/subagents/index.ts @@ -10,5 +10,7 @@ export { AgentCreationWizard } from './create/AgentCreationWizard.js'; // Management Dialog export { AgentsManagerDialog } from './manage/AgentsManagerDialog.js'; -// Execution Display -export { AgentExecutionDisplay } from './runtime/AgentExecutionDisplay.js'; +// Execution Display: the verbose inline frame was retired. Live progress +// is now rendered by `LiveAgentPanel` (always-on roster) and +// `BackgroundTasksDialog` (Down-arrow detail view); see +// docs/comparison/subagent-display-deep-dive.md for context. diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx deleted file mode 100644 index b5a148bc0..000000000 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { act } from 'react'; -import { render } from 'ink-testing-library'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AgentResultDisplay } from '@qwen-code/qwen-code-core'; -import { makeFakeConfig } from '@qwen-code/qwen-code-core'; -import { AgentExecutionDisplay } from './AgentExecutionDisplay.js'; - -let keypressHandler: - | ((key: { ctrl?: boolean; name?: string }) => void) - | undefined; - -vi.mock('../../../hooks/useKeypress.js', () => ({ - // The mock honours { isActive } so historical/completed displays don't - // capture the keypress handler — same scoping the production hook does. - useKeypress: vi.fn( - ( - handler: (key: { ctrl?: boolean; name?: string }) => void, - options?: { isActive?: boolean }, - ) => { - keypressHandler = options?.isActive === false ? undefined : handler; - }, - ), -})); - -function makeRunningData(toolCount: number): AgentResultDisplay { - return { - type: 'task_execution', - subagentName: 'reviewer', - subagentColor: 'blue', - status: 'running', - taskDescription: 'Review large output stability', - taskPrompt: `${'very-long-task-prompt '.repeat(20)}\nsecond\nthird`, - toolCalls: Array.from({ length: toolCount }, (_, index) => ({ - callId: `call-${index}`, - name: `tool-${index}`, - status: 'success', - description: `description-${index} ${'wide '.repeat(20)}`, - resultDisplay: `result-${index} ${'payload '.repeat(20)}`, - })), - }; -} - -function makeCompletedData(toolCount: number): AgentResultDisplay { - return { - ...makeRunningData(toolCount), - status: 'completed', - executionSummary: { - rounds: 3, - totalDurationMs: 12_345, - totalToolCalls: toolCount, - successfulToolCalls: toolCount, - failedToolCalls: 0, - successRate: 100, - inputTokens: 100, - outputTokens: 200, - thoughtTokens: 0, - cachedTokens: 0, - totalTokens: 4_321, - toolUsage: [], - }, - }; -} - -function visualRowCount(frame: string): number { - if (!frame) return 0; - return frame.split('\n').length; -} - -describe('', () => { - beforeEach(() => { - keypressHandler = undefined; - }); - - it('bounds expanded detail by the assigned visual height', () => { - const { lastFrame } = render( - , - ); - - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - - const frame = lastFrame() ?? ''; - expect(frame).toContain('Showing the first 1 visual lines'); - expect(frame).toContain('Showing the last 1 of 8 tools'); - expect(frame).toContain('tool-7'); - expect(frame).not.toContain('tool-0'); - }); - - it('keeps the rendered running frame within availableHeight', () => { - const availableHeight = 26; - const { lastFrame } = render( - , - ); - - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - - expect(visualRowCount(lastFrame() ?? '')).toBeLessThanOrEqual( - availableHeight, - ); - }); - - it('does not respond to ctrl+e when another running subagent has focus', () => { - // Two SubAgents running side-by-side share the live viewport. Only the - // focused one should react to Ctrl+E / Ctrl+F — otherwise both reflow - // together and the dual height-change reintroduces flicker. - const { lastFrame } = render( - , - ); - - const before = lastFrame() ?? ''; - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - expect(lastFrame() ?? '').toBe(before); - }); - - it('survives the running → completed transition while expanded', () => { - // Real path: subagent is running, the user expands it (ctrl+e) so - // displayMode becomes 'default', then the same instance rerenders with - // completed data. The completed-state budget must still hold the - // expanded layout inside availableHeight, and ctrl+e must become a - // no-op on the completed instance so it doesn't drag historical - // displays through mode toggles. - const availableHeight = 30; - const { lastFrame, rerender } = render( - , - ); - - // Expand the running display. - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - const expandedRunningFrame = lastFrame() ?? ''; - expect(expandedRunningFrame).toContain('Task Detail:'); - expect(expandedRunningFrame).toContain('Tools:'); - - // Re-render the same component instance with completed data, preserving - // displayMode. Without an overhead-aware completed budget the - // ExecutionSummary + ToolUsage blocks would push the frame past - // availableHeight here. - rerender( - , - ); - - const completedFrame = lastFrame() ?? ''; - expect(visualRowCount(completedFrame)).toBeLessThanOrEqual(availableHeight); - - // useKeypress is now `{ isActive: false }`; ctrl+e on the completed - // instance must not toggle anything. The mock unsets keypressHandler - // when isActive is false, so the call below is a no-op and the frame - // is identical. - const before = lastFrame() ?? ''; - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - expect(lastFrame() ?? '').toBe(before); - }); -}); diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx deleted file mode 100644 index a81ecc4cd..000000000 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ /dev/null @@ -1,725 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useMemo } from 'react'; -import { Box, Text } from 'ink'; -import type { - AgentResultDisplay, - AgentStatsSummary, - Config, -} 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 { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js'; -import { - getCachedStringWidth, - sliceTextByVisualHeight, - toCodePoints, -} from '../../../utils/textUtils.js'; - -export type DisplayMode = 'compact' | 'default' | 'verbose'; - -export interface AgentExecutionDisplayProps { - data: AgentResultDisplay; - availableHeight?: number; - childWidth: number; - config: Config; - /** - * Whether this subagent owns keyboard input for confirmations and - * Ctrl+E/Ctrl+F display shortcuts. - */ - isFocused?: boolean; - /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ - isWaitingForOtherApproval?: boolean; -} - -const getStatusColor = ( - status: - | AgentResultDisplay['status'] - | 'executing' - | 'success' - | 'awaiting_approval', -) => { - switch (status) { - case 'running': - case 'executing': - case 'awaiting_approval': - return theme.status.warning; - case 'completed': - case 'success': - return theme.status.success; - case 'background': - return theme.text.secondary; - case 'cancelled': - return theme.status.warning; - case 'failed': - return theme.status.error; - default: - return theme.text.secondary; - } -}; - -const getStatusText = (status: AgentResultDisplay['status']) => { - switch (status) { - case 'running': - return 'Running'; - case 'completed': - return 'Completed'; - case 'background': - return 'Running in background'; - case 'cancelled': - return 'User Cancelled'; - case 'failed': - return 'Failed'; - default: - return 'Unknown'; - } -}; - -const BackgroundManageHint: React.FC = () => ( - (↓ to manage) -); - -const MAX_TOOL_CALLS = 5; -const MAX_VERBOSE_TOOL_CALLS = 12; -const MAX_TASK_PROMPT_LINES = 5; -const DEFAULT_DETAIL_HEIGHT = 18; - -// Approximate fixed-row cost of the default/verbose layout, derived from the -// JSX structure below: 1 header + (1 "Task Detail:" label + 1 internal gap + -// optional 1 "...N task lines hidden..." footer) + (1 "Tools:" label + 1 -// marginBottom) + 1 footer + 3 inter-section gaps. We subtract this from the -// parent-provided `availableHeight` so the budget for the prompt and -// tool-call lists actually fits inside the assigned frame. -const RUNNING_FIXED_OVERHEAD = 10; -// In completed/cancelled/failed mode we lose the running footer but gain the -// ExecutionSummary block (header + 3 rows) and the ToolUsage block (header + -// up to 2 wrapped rows) plus an extra inter-block gap, so the overhead grows. -// Calibrated against the running→completed transition test: assigning <22 -// here lets the completed expanded frame edge past availableHeight when the -// SubAgent finishes mid-expand. -const COMPLETED_FIXED_OVERHEAD = 22; -// "Status icon + name + description" + "truncated output" — each tool call -// commits two visual rows in default/verbose mode. -const ROWS_PER_TOOL_CALL = 2; - -function truncateToVisualWidth(text: string, maxWidth: number): string { - const visualWidth = Math.max(1, Math.floor(maxWidth)); - const ellipsis = '...'; - const ellipsisWidth = getCachedStringWidth(ellipsis); - let currentWidth = 0; - let result = ''; - - for (const char of toCodePoints(text)) { - const charWidth = Math.max(getCachedStringWidth(char), 1); - if (currentWidth + charWidth > visualWidth) { - const availableWidth = Math.max(0, visualWidth - ellipsisWidth); - let trimmed = ''; - let trimmedWidth = 0; - for (const trimmedChar of toCodePoints(result)) { - const trimmedCharWidth = Math.max(getCachedStringWidth(trimmedChar), 1); - if (trimmedWidth + trimmedCharWidth > availableWidth) { - break; - } - trimmed += trimmedChar; - trimmedWidth += trimmedCharWidth; - } - return trimmed + ellipsis; - } - - result += char; - currentWidth += charWidth; - } - - return result; -} - -/** - * Component to display subagent execution progress and results. - * This is now a pure component that renders the provided SubagentExecutionResultDisplay data. - * Real-time updates are handled by the parent component updating the data prop. - */ -export const AgentExecutionDisplay: React.FC = ({ - data, - availableHeight, - childWidth, - config, - isFocused = true, - isWaitingForOtherApproval = false, -}) => { - const [displayMode, setDisplayMode] = React.useState('compact'); - const detailHeight = Math.max( - 4, - Math.floor(availableHeight ?? DEFAULT_DETAIL_HEIGHT), - ); - // Treat `availableHeight` as the *total* component budget. Subtract the - // fixed overhead (header, section labels, gaps, footer/result block) before - // splitting the remainder between the prompt preview and the tool-call - // list. This guarantees the rendered frame doesn't grow past the budget the - // parent layout assigned us, which is the precondition for Ink to keep the - // SubAgent display inside its static slot instead of clearing+redrawing. - const fixedOverhead = - data.status === 'running' - ? RUNNING_FIXED_OVERHEAD - : COMPLETED_FIXED_OVERHEAD; - const renderableBudget = Math.max(2, detailHeight - fixedOverhead); - // Prompt gets ~1/3 of the remainder, tool-call list gets the rest. Both are - // clamped to >=1 so we always render at least one of each kind, even in - // pathological "availableHeight smaller than overhead" cases. - const promptBudget = Math.max(1, Math.floor(renderableBudget / 3)); - const toolBudget = Math.max( - 1, - Math.floor((renderableBudget - promptBudget) / ROWS_PER_TOOL_CALL), - ); - const maxTaskPromptLines = - displayMode === 'verbose' - ? Math.min(8, promptBudget) - : Math.min(MAX_TASK_PROMPT_LINES, promptBudget); - const maxToolCalls = - displayMode === 'verbose' - ? 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]); - - // 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 - // truth. Counting `data.taskPrompt.split('\n').length` would only see hard - // newlines and miss soft-wrapped overflow, so a long single-line prompt - // could be visually truncated without surfacing the hint. - const promptChildWidth = Math.max(1, childWidth - 2); - const slicedPrompt = useMemo( - () => - sliceTextByVisualHeight( - data.taskPrompt, - maxTaskPromptLines, - promptChildWidth, - { minHeight: 1, overflowDirection: 'bottom' }, - ), - [data.taskPrompt, maxTaskPromptLines, promptChildWidth], - ); - - const footerText = React.useMemo(() => { - // This component only listens to keyboard shortcut events when the subagent is running - if (data.status !== 'running') return ''; - - if (displayMode === 'default') { - const hasMoreLines = slicedPrompt.hiddenLinesCount > 0; - const hasMoreToolCalls = - data.toolCalls && data.toolCalls.length > maxToolCalls; - - if (hasMoreToolCalls || hasMoreLines) { - return 'Press ctrl+e to show less, ctrl+f to show more.'; - } - return 'Press ctrl+e to show less.'; - } - - if (displayMode === 'verbose') { - return 'Press ctrl+f to show less.'; - } - - return ''; - }, [ - displayMode, - data.status, - data.toolCalls, - slicedPrompt.hiddenLinesCount, - maxToolCalls, - ]); - - // Handle keyboard shortcuts to control display mode. Scope the listener to - // the running subagent that currently holds focus — `data.status` rules - // out completed/historical instances mounted in scrollback, and - // `isFocused` rules out *parallel* running subagents that share the live - // viewport. Without the focus gate, two SubAgents running side by side - // would both toggle on a single Ctrl+E / Ctrl+F press and the resulting - // dual-reflow brings back the flicker this component is meant to - // prevent. - useKeypress( - (key) => { - if (key.ctrl && key.name === 'e') { - // ctrl+e toggles between compact and default - setDisplayMode((current) => - current === 'compact' ? 'default' : 'compact', - ); - } else if (key.ctrl && key.name === 'f') { - // ctrl+f toggles between default and verbose - setDisplayMode((current) => - current === 'default' ? 'verbose' : 'default', - ); - } - }, - { isActive: data.status === 'running' && isFocused }, - ); - - if (displayMode === 'compact') { - return ( - - {/* Header: Agent name and status */} - {!data.pendingConfirmation && ( - - - {data.subagentName} - - - - {data.status === 'background' && } - - )} - - {/* Running state: Show current tool call and progress */} - {data.status === 'running' && ( - <> - {/* Current tool call */} - {data.toolCalls && data.toolCalls.length > 0 && ( - - - {/* Show count of additional tool calls if there are more than 1 */} - {data.toolCalls.length > 1 && !data.pendingConfirmation && ( - - - +{data.toolCalls.length - 1} more tool calls (ctrl+e to - expand) - - - )} - - )} - - {/* Inline approval prompt when awaiting confirmation */} - {data.pendingConfirmation && ( - - {isWaitingForOtherApproval && ( - - - ⏳ Waiting for other approval... - - - )} - - - )} - - )} - - {/* Completed state: Show summary line */} - {data.status === 'completed' && data.executionSummary && ( - - - Execution Summary: {data.executionSummary.totalToolCalls} tool - uses · {data.executionSummary.totalTokens.toLocaleString()} tokens - · {fmtDuration(data.executionSummary.totalDurationMs)} - - - )} - - {/* Failed/Cancelled state: Show error reason */} - {data.status === 'failed' && ( - - - Failed: {data.terminateReason} - - - )} - - ); - } - - // Default and verbose modes use normal layout - return ( - - {/* Header with subagent name and status */} - - - {data.subagentName} - - - - {data.status === 'background' && } - - - {/* Task description */} - - - {/* Progress section for running tasks */} - {data.status === 'running' && - data.toolCalls && - data.toolCalls.length > 0 && ( - - - - )} - - {/* Inline approval prompt when awaiting confirmation */} - {data.pendingConfirmation && ( - - {isWaitingForOtherApproval && ( - - - ⏳ Waiting for other approval... - - - )} - - - )} - - {/* Results section for completed/failed tasks */} - {(data.status === 'completed' || - data.status === 'failed' || - data.status === 'cancelled') && ( - - )} - - {/* Footer with keyboard shortcuts */} - {footerText && ( - - {footerText} - - )} - - ); -}; - -/** - * Task prompt section. Receives the already-sliced prompt from the parent so - * footer hint and section content share one source of truth for whether - * content was hidden (covers soft-wrapped overflow in addition to explicit - * newlines). - */ -const TaskPromptSection: React.FC<{ - slicedPrompt: { text: string; hiddenLinesCount: number }; - displayMode: DisplayMode; - maxVisualLines: number; -}> = ({ slicedPrompt, displayMode, maxVisualLines }) => { - const shouldTruncate = slicedPrompt.hiddenLinesCount > 0; - - return ( - - - Task Detail: - {shouldTruncate && displayMode !== 'compact' && ( - - {' '} - Showing the first {maxVisualLines} visual lines. - - )} - - - {slicedPrompt.text} - - {slicedPrompt.hiddenLinesCount > 0 && ( - - - ... last {slicedPrompt.hiddenLinesCount} task line - {slicedPrompt.hiddenLinesCount === 1 ? '' : 's'} hidden ... - - - )} - - ); -}; - -/** - * Status dot component with similar height as text - */ -const StatusDot: React.FC<{ - status: AgentResultDisplay['status']; -}> = ({ status }) => ( - - - -); - -/** - * Status indicator component - */ -const StatusIndicator: React.FC<{ - status: AgentResultDisplay['status']; -}> = ({ status }) => { - const color = getStatusColor(status); - const text = getStatusText(status); - return {text}; -}; - -/** - * Tool calls list - format consistent with ToolInfo in ToolMessage.tsx - */ -const ToolCallsList: React.FC<{ - toolCalls: AgentResultDisplay['toolCalls']; - displayMode: DisplayMode; - maxToolCalls: number; - childWidth: number; -}> = ({ toolCalls, displayMode, maxToolCalls, childWidth }) => { - const calls = toolCalls || []; - const displayLimit = Math.max(1, Math.floor(maxToolCalls)); - const shouldTruncate = calls.length > displayLimit; - const displayCalls = calls.slice(-displayLimit); - - // Reverse the order to show most recent first - const reversedDisplayCalls = [...displayCalls].reverse(); - - return ( - - - Tools: - {shouldTruncate && displayMode !== 'compact' && ( - - {' '} - Showing the last {displayCalls.length} of {calls.length} tools. - - )} - - {reversedDisplayCalls.map((toolCall, index) => ( - - ))} - - ); -}; - -/** - * Individual tool call item - consistent with ToolInfo format - */ -const ToolCallItem: React.FC<{ - toolCall: { - name: string; - status: 'executing' | 'awaiting_approval' | 'success' | 'failed'; - error?: string; - args?: Record; - result?: string; - resultDisplay?: string; - description?: string; - }; - compact?: boolean; - childWidth?: number; -}> = ({ toolCall, compact = false, childWidth = 80 }) => { - const STATUS_INDICATOR_WIDTH = 3; - const textWidth = Math.max(8, childWidth - STATUS_INDICATOR_WIDTH - 1); - - // Map subagent status to ToolCallStatus-like display - const statusIcon = React.useMemo(() => { - const color = getStatusColor(toolCall.status); - switch (toolCall.status) { - case 'executing': - return ; // Using same as ToolMessage - case 'awaiting_approval': - return ?; - case 'success': - return ; - case 'failed': - return ( - - x - - ); - default: - return o; - } - }, [toolCall.status]); - - const description = React.useMemo(() => { - if (!toolCall.description) return ''; - const firstLine = toolCall.description.split('\n')[0]; - return truncateToVisualWidth(firstLine, textWidth); - }, [toolCall.description, textWidth]); - - // Get first line of resultDisplay for truncated output - const truncatedOutput = React.useMemo(() => { - if (!toolCall.resultDisplay) return ''; - const firstLine = toolCall.resultDisplay.split('\n')[0]; - return truncateToVisualWidth(firstLine, textWidth); - }, [toolCall.resultDisplay, textWidth]); - - return ( - - {/* First line: status icon + tool name + description (consistent with ToolInfo) */} - - {statusIcon} - - {toolCall.name}{' '} - {description} - {toolCall.error && ( - - {toolCall.error} - )} - - - - {/* Second line: truncated returnDisplay output - hidden in compact mode */} - {!compact && truncatedOutput && ( - - {truncatedOutput} - - )} - - ); -}; - -/** - * Execution summary details component - */ -const ExecutionSummaryDetails: React.FC<{ - data: AgentResultDisplay; - displayMode: DisplayMode; -}> = ({ data, displayMode: _displayMode }) => { - const stats = data.executionSummary; - - if (!stats) { - return ( - - • No summary available - - ); - } - - return ( - - - • Duration: {fmtDuration(stats.totalDurationMs)} - - - • Rounds: {stats.rounds} - - - • Tokens: {stats.totalTokens.toLocaleString()} - - - ); -}; - -/** - * Tool usage statistics component - */ -const ToolUsageStats: React.FC<{ - executionSummary?: AgentStatsSummary; -}> = ({ executionSummary }) => { - if (!executionSummary) { - return ( - - • No tool usage data available - - ); - } - - return ( - - - • Total Calls: {executionSummary.totalToolCalls} - - - • Success Rate:{' '} - - {executionSummary.successRate.toFixed(1)}% - {' '} - ( - - {executionSummary.successfulToolCalls} success - - ,{' '} - - {executionSummary.failedToolCalls} failed - - ) - - - ); -}; - -/** - * Results section for completed executions - matches the clean layout from the image - */ -const ResultsSection: React.FC<{ - data: AgentResultDisplay; - displayMode: DisplayMode; - maxToolCalls: number; - childWidth: number; -}> = ({ data, displayMode, maxToolCalls, childWidth }) => ( - - {/* Tool calls section - clean list format */} - {data.toolCalls && data.toolCalls.length > 0 && ( - - )} - - {/* Execution Summary section - hide when cancelled */} - {data.status === 'completed' && ( - - - Execution Summary: - - - - )} - - {/* Tool Usage section - hide when cancelled */} - {data.status === 'completed' && data.executionSummary && ( - - - Tool Usage: - - - - )} - - {/* Error reason for failed tasks */} - {data.status === 'cancelled' && ( - - ⏹ User Cancelled - - )} - {data.status === 'failed' && ( - - Task Failed: - {data.terminateReason} - - )} - -); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 4022b98ba..124ceef0a 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -16,6 +16,7 @@ import { BtwMessage } from '../components/messages/BtwMessage.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js'; import { AgentComposer } from '../components/agent-view/AgentComposer.js'; +import { LiveAgentPanel } from '../components/background-view/LiveAgentPanel.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAgentViewState } from '../contexts/AgentViewContext.js'; @@ -106,6 +107,18 @@ export const DefaultAppLayout: React.FC = () => { {/* Tab bar: visible whenever in-process agents exist and input is active */} {hasAgents && !uiState.dialogsVisible && } + + {/* + LiveAgentPanel — always-on roster of running subagents anchored + beneath the input footer (mirrors Claude Code's + CoordinatorAgentStatus position). Hidden whenever any dialog is + open (auth / permission / background tasks / etc.) so the modal + surface doesn't compete with the live roster, and the panel's + own internal self-hide handles the empty-roster case. + */} + {!isAgentTab && !uiState.dialogsVisible && ( + + )} ); };