mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-20 01:01:53 +00:00
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:
parent
b1ec8d64c7
commit
bf60e53402
8 changed files with 677 additions and 1086 deletions
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue