feat(cli): replace inline AgentExecutionDisplay with always-on LiveAgentPanel

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.
This commit is contained in:
wenshao 2026-05-07 17:46:24 +08:00
parent b1ec8d64c7
commit bf60e53402
8 changed files with 677 additions and 1086 deletions

View file

@ -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> = {},
): AgentDialogEntry {
return {
kind: 'agent',
agentId: 'a',
description: 'desc',
status: 'running',
startTime: 0,
abortController: new AbortController(),
...overrides,
} as AgentDialogEntry;
}
function shellEntry(overrides: Partial<DialogEntry> = {}): 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<typeof render>;
act(() => {
result = render(
<ConfigContext.Provider value={options.config}>
<BackgroundTaskViewStateContext.Provider value={state}>
<LiveAgentPanel width={options.width} maxRows={options.maxRows} />
</BackgroundTaskViewStateContext.Provider>
</ConfigContext.Provider>,
);
});
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<string, AgentDialogEntry>;
} {
const store = new Map<string, AgentDialogEntry>();
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('<LiveAgentPanel />', () => {
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('');
});
});

View file

@ -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 `<Static>` 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<LiveAgentPanelProps> = ({
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 (
<Box flexDirection="column" marginTop={1} width={width} paddingX={2}>
<Box>
<Text bold color={theme.text.accent}>
Active agents
</Text>
<Text
color={theme.text.secondary}
>{` (${runningCount}/${visibleAgents.length})`}</Text>
</Box>
{overflow > 0 && (
<Box>
<Text
color={theme.text.secondary}
>{` ^ ${overflow} more above`}</Text>
</Box>
)}
{visible.map((entry) => (
<AgentRow key={entry.agentId} entry={entry} now={now} />
))}
</Box>
);
};
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 (
<Box flexDirection="row">
<Box flexGrow={1} flexShrink={1}>
<Text wrap="truncate-end">
<Text color={color}>{glyph} </Text>
{showType && (
<>
<Text bold>{entry.subagentType}</Text>
<Text color={theme.text.secondary}>{' · '}</Text>
</>
)}
<Text color={theme.text.secondary}>{`${flavorPrefix}${label}`}</Text>
{activity && (
<Text color={theme.text.secondary}>{` · ${activity}`}</Text>
)}
</Text>
</Box>
<Box flexShrink={0}>
<Text
color={theme.text.secondary}
>{` · ${elapsed}${tokenSuffix}`}</Text>
</Box>
</Box>
);
};

View file

@ -101,19 +101,6 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({
return <Text>MockMarkdown:{text}</Text>;
},
}));
vi.mock('../subagents/index.js', () => ({
AgentExecutionDisplay: function MockAgentExecutionDisplay({
data,
}: {
data: { subagentName: string; taskDescription: string };
}) {
return (
<Text>
🤖 {data.subagentName} Task: {data.taskDescription}
</Text>
);
},
}));
vi.mock('./ToolConfirmationMessage.js', () => ({
ToolConfirmationMessage: function MockToolConfirmationMessage() {
// Sentinel string lets `isPending && pendingConfirmation` tests
@ -313,41 +300,13 @@ describe('<ToolMessage />', () => {
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(
<ToolMessage {...props} />,
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('<ToolMessage />', () => {
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('<ToolMessage />', () => {
};
};
it('isPending && no pendingConfirmation → no inline frame', () => {
it('running subagent without confirmation → no inline frame', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...buildProps({
@ -399,13 +353,35 @@ describe('<ToolMessage />', () => {
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(
<ToolMessage
{...buildProps({
data: {
subagentName: 'committed-agent',
taskDescription: 'Already done',
taskPrompt: 'Already done',
status: 'completed',
},
isPending: false,
})}
/>,
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(
<ToolMessage
{...buildProps({
@ -423,16 +399,12 @@ describe('<ToolMessage />', () => {
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('<ToolMessage />', () => {
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(
<ToolMessage
{...buildProps({
data: {
subagentName: 'committed-agent',
taskDescription: 'Already done',
taskPrompt: 'Already done',
status: 'completed',
},
isPending: false,
})}
/>,
StreamingState.Idle,
);
const output = lastFrame() ?? '';
// <Static>-rendered scrollback: full frame, no flicker concern.
expect(output).toContain('🤖');
expect(output).toContain('committed-agent');
});
});

View file

@ -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 `<Static>` 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 (
<Box flexDirection="column" paddingLeft={1}>
<Box>
<Text color={theme.text.secondary}>Approval requested by </Text>
<Text bold color={theme.text.accent}>
{agentLabel}
</Text>
<Text color={theme.text.secondary}>:</Text>
</Box>
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 2}
compactMode={true}
config={config}
/>
</Box>
);
}
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 (
<Box paddingLeft={1}>
<Text color={theme.text.secondary} dimColor>
Queued approval:{' '}
}> = ({ data, availableHeight, childWidth, config, isFocused }) => {
if (data.pendingConfirmation && isFocused) {
const agentLabel = data.subagentName || 'agent';
return (
<Box flexDirection="column" paddingLeft={1}>
<Box>
<Text color={theme.text.secondary}>Approval requested by </Text>
<Text bold color={theme.text.accent}>
{agentLabel}
</Text>
<Text dimColor>{agentLabel}</Text>
<Text color={theme.text.secondary}>:</Text>
</Box>
);
}
return null;
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 2}
compactMode={true}
config={config}
/>
</Box>
);
}
return (
<AgentExecutionDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
config={config}
isFocused={isFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
);
if (data.pendingConfirmation) {
const agentLabel = data.subagentName || 'agent';
return (
<Box paddingLeft={1}>
<Text color={theme.text.secondary} dimColor>
Queued approval:{' '}
</Text>
<Text dimColor>{agentLabel}</Text>
</Box>
);
}
return null;
};
/**
@ -436,12 +408,21 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
isFocused?: boolean;
/**
* True when rendering inside `pendingHistoryItems` (live area), false once
* committed to `<Static>`. Foreground subagents suppress their inline
* frame in the live phase the pill+dialog handle drill-down but
* always render in scrollback.
* committed to `<Static>`. 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<ToolMessageProps> = ({
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<ToolMessageProps> = ({
childWidth={innerWidth}
config={config}
isFocused={isFocused}
isPending={isPending}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
)}
{effectiveDisplayRenderer.type === 'diff' && (

View file

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

View file

@ -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('<AgentExecutionDisplay />', () => {
beforeEach(() => {
keypressHandler = undefined;
});
it('bounds expanded detail by the assigned visual height', () => {
const { lastFrame } = render(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={8}
childWidth={40}
config={makeFakeConfig()}
/>,
);
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(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);
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(
<AgentExecutionDisplay
data={makeRunningData(2)}
availableHeight={20}
childWidth={80}
config={makeFakeConfig()}
isFocused={false}
/>,
);
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(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);
// 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(
<AgentExecutionDisplay
data={makeCompletedData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);
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);
});
});

View file

@ -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 = () => (
<Text color={theme.text.secondary}> ( to manage)</Text>
);
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<AgentExecutionDisplayProps> = ({
data,
availableHeight,
childWidth,
config,
isFocused = true,
isWaitingForOtherApproval = false,
}) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('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 (
<Box flexDirection="column">
{/* Header: Agent name and status */}
{!data.pendingConfirmation && (
<Box flexDirection="row">
<Text bold color={agentColor}>
{data.subagentName}
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
{data.status === 'background' && <BackgroundManageHint />}
</Box>
)}
{/* Running state: Show current tool call and progress */}
{data.status === 'running' && (
<>
{/* Current tool call */}
{data.toolCalls && data.toolCalls.length > 0 && (
<Box flexDirection="column">
<ToolCallItem
toolCall={data.toolCalls[data.toolCalls.length - 1]}
compact={true}
/>
{/* Show count of additional tool calls if there are more than 1 */}
{data.toolCalls.length > 1 && !data.pendingConfirmation && (
<Box flexDirection="row" paddingLeft={4}>
<Text color={theme.text.secondary}>
+{data.toolCalls.length - 1} more tool calls (ctrl+e to
expand)
</Text>
</Box>
)}
</Box>
)}
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}
config={config}
/>
</Box>
)}
</>
)}
{/* Completed state: Show summary line */}
{data.status === 'completed' && data.executionSummary && (
<Box flexDirection="row" marginTop={1}>
<Text color={theme.text.secondary}>
Execution Summary: {data.executionSummary.totalToolCalls} tool
uses · {data.executionSummary.totalTokens.toLocaleString()} tokens
· {fmtDuration(data.executionSummary.totalDurationMs)}
</Text>
</Box>
)}
{/* Failed/Cancelled state: Show error reason */}
{data.status === 'failed' && (
<Box flexDirection="row" marginTop={1}>
<Text color={theme.status.error}>
Failed: {data.terminateReason}
</Text>
</Box>
)}
</Box>
);
}
// Default and verbose modes use normal layout
return (
<Box flexDirection="column" paddingX={1} gap={1}>
{/* Header with subagent name and status */}
<Box flexDirection="row">
<Text bold color={agentColor}>
{data.subagentName}
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
{data.status === 'background' && <BackgroundManageHint />}
</Box>
{/* Task description */}
<TaskPromptSection
slicedPrompt={slicedPrompt}
displayMode={displayMode}
maxVisualLines={maxTaskPromptLines}
/>
{/* Progress section for running tasks */}
{data.status === 'running' &&
data.toolCalls &&
data.toolCalls.length > 0 && (
<Box flexDirection="column">
<ToolCallsList
toolCalls={data.toolCalls}
displayMode={displayMode}
maxToolCalls={maxToolCalls}
childWidth={childWidth - 2}
/>
</Box>
)}
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column">
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
config={config}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}
/>
</Box>
)}
{/* Results section for completed/failed tasks */}
{(data.status === 'completed' ||
data.status === 'failed' ||
data.status === 'cancelled') && (
<ResultsSection
data={data}
displayMode={displayMode}
maxToolCalls={maxToolCalls}
childWidth={childWidth - 2}
/>
)}
{/* Footer with keyboard shortcuts */}
{footerText && (
<Box flexDirection="row">
<Text color={theme.text.secondary}>{footerText}</Text>
</Box>
)}
</Box>
);
};
/**
* 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 (
<Box flexDirection="column" gap={1}>
<Box flexDirection="row">
<Text color={theme.text.primary}>Task Detail: </Text>
{shouldTruncate && displayMode !== 'compact' && (
<Text color={theme.text.secondary}>
{' '}
Showing the first {maxVisualLines} visual lines.
</Text>
)}
</Box>
<Box paddingLeft={1}>
<Text wrap="wrap">{slicedPrompt.text}</Text>
</Box>
{slicedPrompt.hiddenLinesCount > 0 && (
<Box paddingLeft={1}>
<Text color={theme.text.secondary} wrap="truncate">
... last {slicedPrompt.hiddenLinesCount} task line
{slicedPrompt.hiddenLinesCount === 1 ? '' : 's'} hidden ...
</Text>
</Box>
)}
</Box>
);
};
/**
* Status dot component with similar height as text
*/
const StatusDot: React.FC<{
status: AgentResultDisplay['status'];
}> = ({ status }) => (
<Box marginLeft={1} marginRight={1}>
<Text color={getStatusColor(status)}></Text>
</Box>
);
/**
* Status indicator component
*/
const StatusIndicator: React.FC<{
status: AgentResultDisplay['status'];
}> = ({ status }) => {
const color = getStatusColor(status);
const text = getStatusText(status);
return <Text color={color}>{text}</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 (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Tools:</Text>
{shouldTruncate && displayMode !== 'compact' && (
<Text color={theme.text.secondary}>
{' '}
Showing the last {displayCalls.length} of {calls.length} tools.
</Text>
)}
</Box>
{reversedDisplayCalls.map((toolCall, index) => (
<ToolCallItem
key={`${toolCall.name}-${index}`}
toolCall={toolCall}
childWidth={childWidth}
/>
))}
</Box>
);
};
/**
* 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<string, unknown>;
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 <Text color={color}></Text>; // Using same as ToolMessage
case 'awaiting_approval':
return <Text color={theme.status.warning}>?</Text>;
case 'success':
return <Text color={color}></Text>;
case 'failed':
return (
<Text color={color} bold>
x
</Text>
);
default:
return <Text color={color}>o</Text>;
}
}, [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 (
<Box flexDirection="column" paddingLeft={1} marginBottom={0}>
{/* First line: status icon + tool name + description (consistent with ToolInfo) */}
<Box flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusIcon}</Box>
<Text wrap="truncate-end">
<Text>{toolCall.name}</Text>{' '}
<Text color={theme.text.secondary}>{description}</Text>
{toolCall.error && (
<Text color={theme.status.error}> - {toolCall.error}</Text>
)}
</Text>
</Box>
{/* Second line: truncated returnDisplay output - hidden in compact mode */}
{!compact && truncatedOutput && (
<Box flexDirection="row" paddingLeft={STATUS_INDICATOR_WIDTH}>
<Text color={theme.text.secondary}>{truncatedOutput}</Text>
</Box>
)}
</Box>
);
};
/**
* Execution summary details component
*/
const ExecutionSummaryDetails: React.FC<{
data: AgentResultDisplay;
displayMode: DisplayMode;
}> = ({ data, displayMode: _displayMode }) => {
const stats = data.executionSummary;
if (!stats) {
return (
<Box flexDirection="column" paddingLeft={1}>
<Text color={theme.text.secondary}> No summary available</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingLeft={1}>
<Text>
<Text>Duration: {fmtDuration(stats.totalDurationMs)}</Text>
</Text>
<Text>
<Text>Rounds: {stats.rounds}</Text>
</Text>
<Text>
<Text>Tokens: {stats.totalTokens.toLocaleString()}</Text>
</Text>
</Box>
);
};
/**
* Tool usage statistics component
*/
const ToolUsageStats: React.FC<{
executionSummary?: AgentStatsSummary;
}> = ({ executionSummary }) => {
if (!executionSummary) {
return (
<Box flexDirection="column" paddingLeft={1}>
<Text color={theme.text.secondary}> No tool usage data available</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingLeft={1}>
<Text>
<Text>Total Calls:</Text> {executionSummary.totalToolCalls}
</Text>
<Text>
<Text>Success Rate:</Text>{' '}
<Text color={theme.status.success}>
{executionSummary.successRate.toFixed(1)}%
</Text>{' '}
(
<Text color={theme.status.success}>
{executionSummary.successfulToolCalls} success
</Text>
,{' '}
<Text color={theme.status.error}>
{executionSummary.failedToolCalls} failed
</Text>
)
</Text>
</Box>
);
};
/**
* 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 }) => (
<Box flexDirection="column" gap={1}>
{/* Tool calls section - clean list format */}
{data.toolCalls && data.toolCalls.length > 0 && (
<ToolCallsList
toolCalls={data.toolCalls}
displayMode={displayMode}
maxToolCalls={maxToolCalls}
childWidth={childWidth}
/>
)}
{/* Execution Summary section - hide when cancelled */}
{data.status === 'completed' && (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Execution Summary:</Text>
</Box>
<ExecutionSummaryDetails data={data} displayMode={displayMode} />
</Box>
)}
{/* Tool Usage section - hide when cancelled */}
{data.status === 'completed' && data.executionSummary && (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Tool Usage:</Text>
</Box>
<ToolUsageStats executionSummary={data.executionSummary} />
</Box>
)}
{/* Error reason for failed tasks */}
{data.status === 'cancelled' && (
<Box flexDirection="row">
<Text color={theme.status.warning}> User Cancelled</Text>
</Box>
)}
{data.status === 'failed' && (
<Box flexDirection="row">
<Text color={theme.status.error}>Task Failed: </Text>
<Text color={theme.status.error}>{data.terminateReason}</Text>
</Box>
)}
</Box>
);

View file

@ -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 && <AgentTabBar />}
{/*
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 && (
<LiveAgentPanel width={uiState.mainAreaWidth} />
)}
</Box>
);
};