diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index a63b2e386..10bdf87bf 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -105,9 +105,12 @@ function terminalStatusPresentation( } // Foreground agent rows get this prefix so users can tell at a glance -// that cancelling one will end the parent's current turn — a much heavier -// consequence than cancelling a truly async background entry. -const FOREGROUND_ROW_PREFIX = '[in turn]'; +// that cancelling one will unblock — and end — the parent's current +// turn, a much heavier consequence than cancelling a truly async +// background entry. `[blocking]` reads more directly than the earlier +// `[in turn]` (which was widely misread as "queued / sequential" — +// the opposite meaning). +const FOREGROUND_ROW_PREFIX = '[blocking]'; const SHELL_ROW_PREFIX = '[shell]'; function rowLabel(entry: DialogEntry): string { @@ -1083,9 +1086,11 @@ export const BackgroundTasksDialog: React.FC = ({ const hints: string[] = []; if (showCancelConfirmHint) { // Force the confirmation step into the hint row so the user sees - // exactly what the next `x` will do. + // exactly what the next `x` will do. Phrasing matches the + // `[blocking]` row prefix \u2014 "blocking turn" reads as "your input + // is waiting on this", which is what the cancel actually unblocks. hints.push( - 'x again to confirm stop \u00b7 ends current turn', + 'x again to confirm stop \u00b7 ends the blocking turn', 'Esc cancel', ); } else if (dialogMode === 'list') { diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx index 347b80fa9..6509efa6e 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx @@ -74,7 +74,14 @@ export const BackgroundTasksPill: React.FC = () => { const onKeypress = useCallback( (key: Key) => { - if (key.name === 'return') { + // `return` and `down` both open the dialog. Down completes the + // focus chain Composer ↓ → AgentTabBar ↓ → Pill ↓ → Dialog, + // so users can `↓ ↓ (↓)` their way from an empty composer + // straight into the roster without having to remember the + // Enter shortcut. The LiveAgentPanel's overflow callout + // (`↓ to view all`) relies on this; without a Down handler + // the chain dead-ends at the highlighted pill. + if (key.name === 'return' || key.name === 'down') { openDialog(); } else if (key.name === 'up' || key.name === 'escape') { setPillFocused(false); diff --git a/packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx b/packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx new file mode 100644 index 000000000..68e4a6999 --- /dev/null +++ b/packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx @@ -0,0 +1,657 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from '@testing-library/react'; +import { render } from 'ink-testing-library'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { LiveAgentPanel } from './LiveAgentPanel.js'; +import { BackgroundTaskViewStateContext } from '../../contexts/BackgroundTaskViewContext.js'; +import { ConfigContext } from '../../contexts/ConfigContext.js'; +import type { + AgentDialogEntry, + DialogEntry, +} from '../../hooks/useBackgroundTaskView.js'; + +function agentEntry( + overrides: Partial = {}, +): AgentDialogEntry { + return { + kind: 'agent', + agentId: 'a', + description: 'desc', + status: 'running', + startTime: 0, + abortController: new AbortController(), + ...overrides, + } as AgentDialogEntry; +} + +function shellEntry(overrides: Partial = {}): DialogEntry { + return { + kind: 'shell', + shellId: 'bg_x', + command: 'sleep 60', + cwd: '/tmp', + status: 'running', + startTime: 0, + outputPath: '/tmp/x.out', + abortController: new AbortController(), + ...overrides, + } as DialogEntry; +} + +function renderPanel( + options: { + entries: readonly DialogEntry[]; + dialogOpen?: boolean; + width?: number; + maxRows?: number; + /** + * Stub Config supplying a `getBackgroundTaskRegistry()` for the + * panel's per-tick live re-pull. Omit when the test cares only + * about the snapshot path (panel falls back gracefully). + */ + config?: Config; + } = { entries: [] }, +) { + const state = { + entries: options.entries, + selectedIndex: 0, + dialogMode: options.dialogOpen ? ('list' as const) : ('closed' as const), + dialogOpen: Boolean(options.dialogOpen), + pillFocused: false, + }; + // Wrap render() in act() so the panel's mount-time effect (the + // 1s wall-clock interval) is flushed inside React's scheduler boundary + // — silences the "update inside a test was not wrapped in act" + // warning ink-testing-library otherwise leaks for every render. + let result!: ReturnType; + act(() => { + result = render( + + + + + , + ); + }); + return result; +} + +/** + * Build a stub Config exposing only `getBackgroundTaskRegistry` — the + * one method the panel calls. Returning a Map-backed registry whose + * `get` reads from the live store lets a test mutate `recentActivities` + * after render and observe the panel pick up the new value on the next + * tick (the actual production behavior we want to lock in). + */ +function makeRegistryConfig(agents: readonly AgentDialogEntry[]): { + config: Config; + store: Map; +} { + const store = new Map(); + for (const a of agents) store.set(a.agentId, a); + const config = { + getBackgroundTaskRegistry: () => ({ + get: (id: string) => store.get(id), + }), + } as unknown as Config; + return { config, store }; +} + +describe('', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('hides when there are no agent entries', () => { + const { lastFrame } = renderPanel({ entries: [] }); + expect(lastFrame() ?? '').toBe(''); + }); + + it('hides when only non-agent entries exist (shell-only)', () => { + const { lastFrame } = renderPanel({ entries: [shellEntry()] }); + expect(lastFrame() ?? '').toBe(''); + }); + + it('hides when the background dialog is open (avoids duplicate roster)', () => { + const { lastFrame } = renderPanel({ + entries: [agentEntry({ subagentType: 'researcher' })], + dialogOpen: true, + }); + expect(lastFrame() ?? '').toBe(''); + }); + + it('renders header and a single running agent row', () => { + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'a-1', + subagentType: 'researcher', + description: 'researcher: scan repo for TODO markers', + startTime: -5_000, // 5s ago at fake-time 0 + recentActivities: [ + { name: 'Glob', description: '**/*.ts', at: -1000 }, + ], + }), + ], + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Active agents'); + // Running and total tally both 1. + expect(frame).toContain('(1/1)'); + expect(frame).toContain('researcher'); + expect(frame).toContain('scan repo for TODO markers'); + // Latest activity is rendered next to the row, with elapsed time. + expect(frame).toContain('Glob'); + expect(frame).toContain('5s'); + }); + + it('elides the default `general-purpose` subagent type from the row', () => { + // The DEFAULT_BUILTIN_SUBAGENT_TYPE elision suppresses the + // redundant `general-purpose: ` prefix on rows where the type + // adds no identity beyond the description. A future regression + // that flipped the comparison (or hard-coded the wrong literal) + // would silently re-introduce the prefix without failing + // existing cases. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'gp-1', + subagentType: 'general-purpose', + description: 'investigate the change in component layer', + }), + ], + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('investigate the change'); + expect(frame).not.toContain('general-purpose:'); + }); + + it('truncates the description tail when the panel width is too narrow', () => { + // The width prop is plumbed all the way down to the row's outer + // Box; without exercising a narrow case the truncation behavior + // (left flex-shrink + truncate-end) is uncovered. Anchor the + // test on the right-pinned tail (` · Ns`) which must remain + // visible regardless of how aggressive the truncation gets. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'narrow-1', + subagentType: 'researcher', + description: + 'researcher: scan the entire repository for occurrences of TODO and FIXME markers and triage them by area', + startTime: -3_000, + }), + ], + width: 50, + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('…'); + // Right column still intact at the tail. + expect(frame).toContain('▶ 3s'); + }); + + it('clears the 1s tick interval when unmounted with live work in flight', () => { + // The closest existing case (`tears the 1s tick down when the + // bg-tasks dialog opens`) short-circuits BEFORE the interval is + // ever scheduled. This case mounts with a running agent — the + // interval IS scheduled — and asserts unmount tears it down so + // setNow can't fire on a discarded fiber. + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + const running = agentEntry({ + agentId: 'unmount-1', + subagentType: 'researcher', + description: 'researcher: investigate', + startTime: -1_000, + }); + const { config } = makeRegistryConfig([running]); + const { unmount } = renderPanel({ entries: [running], config }); + // Interval scheduled because there's running work. + expect(setIntervalSpy).toHaveBeenCalled(); + const intervalIdsBefore = setIntervalSpy.mock.results + .map((r) => r.value) + .filter(Boolean); + act(() => unmount()); + // Each interval the panel scheduled should be cleared on unmount. + for (const id of intervalIdsBefore) { + expect(clearIntervalSpy).toHaveBeenCalledWith(id); + } + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + + it('maps internal tool names to user-facing display names in the activity field', () => { + // `recentActivities[].name` carries the internal tool name from + // AgentToolCallEvent (e.g. `run_shell_command`, `glob`). Without + // mapping through ToolDisplayNames the panel would surface those + // raw identifiers while BackgroundTasksDialog shows `Shell` / + // `Glob` — vocabulary drift between two views of the same data. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'shell-1', + subagentType: 'researcher', + description: 'researcher: scan repo', + recentActivities: [ + { name: 'run_shell_command', description: 'rg TODO', at: 0 }, + ], + }), + ], + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Shell'); + expect(frame).not.toContain('run_shell_command'); + }); + + it('renders elapsed + token count for completed agents with stats', () => { + // Locks in the cost-visibility win the panel is partly motivated + // by — completed entries should surface `▶ Ns · Nk tokens`. Using + // a completed entry (rather than running) so the assertion is + // stable against the running-tally heuristic. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'done-1', + subagentType: 'researcher', + description: 'researcher: investigate', + status: 'completed', + startTime: -12_000, + endTime: 0, + stats: { totalTokens: 2400, toolUses: 5, durationMs: 12_000 }, + }), + ], + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('12s'); + expect(frame).toContain('2.4k tokens'); + }); + + it('counts paused agents as active in the header tally', () => { + // The header read "Active agents (running/total)" but the + // panel ALSO renders paused agents as active rows (warning + // color, ⏸ glyph). With only paused entries the tally would + // read "(0/1)" — visually contradicting the row that's clearly + // present. Numerator now includes paused. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'paused-1', + subagentType: 'researcher', + description: 'researcher: paused waiting on resume', + status: 'paused', + }), + ], + }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('(1/1)'); + expect(frame).toContain('⏸'); + }); + + it.each([ + ['paused', '⏸'], + ['failed', '✖'], + ['cancelled', '✖'], + ] as const)('renders the %s status with the %s glyph', (status, glyph) => { + // Status routing is otherwise uncovered for paused / failed / + // cancelled — a future regression that flattened the switch + // would slip past the existing running / completed cases. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: `${status}-1`, + subagentType: 'researcher', + description: 'researcher: status routing fixture', + status, + // paused entries don't carry an endTime; failed / cancelled do. + endTime: status === 'paused' ? undefined : 0, + }), + ], + }); + expect(lastFrame() ?? '').toContain(glyph); + }); + + it('strips the subagentType: prefix from the description case-insensitively', () => { + // `descriptionWithoutPrefix` lowercases both sides — the existing + // tests only feed lowercase prefixes, so a future revert to + // strict `startsWith` would silently re-introduce + // `Researcher: Researcher: …` double-prefix on capitalised inputs. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'cap-1', + subagentType: 'researcher', + description: 'Researcher: cap-mismatch description', + }), + ], + }); + const frame = lastFrame() ?? ''; + // The prefix MUST have been stripped — the descriptive tail + // should appear exactly once, with no leading "Researcher: ". + expect(frame).toContain('cap-mismatch description'); + expect(frame).not.toContain('Researcher: cap-mismatch'); + }); + + it('does NOT surface a flavor marker on foreground agents', () => { + // Foreground vs background distinction stays with BackgroundTasksDialog + // (where cancel semantics differ); the panel reads as a glance roster + // and the marker added more confusion than signal. + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'fg-1', + subagentType: 'editor', + description: 'editor: tighten import order', + flavor: 'foreground', + }), + ], + }); + const frame = lastFrame() ?? ''; + // Neither the legacy `[in turn]` (pre-rename) nor the current + // `[blocking]` (BackgroundTasksDialog convention) should bleed + // into the glance panel — only the dialog surfaces the flavor + // distinction, where the cancel semantics warrant it. + expect(frame).not.toContain('[in turn]'); + expect(frame).not.toContain('[blocking]'); + expect(frame).toContain('editor'); + expect(frame).toContain('tighten import order'); + }); + + 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 and points + // at the dialog (the only surface where the user can scroll + // through the full roster + take action). + expect(frame).toContain('1 more above'); + expect(frame).toContain('to view all'); + // 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(''); + }); + + it('reconciles snapshots the live registry no longer knows about as neutral-finished', () => { + // `unregisterForeground` calls `emitStatusChange(entry)` BEFORE + // it deletes the entry, so a snapshot taken on that callback + // captures the agent as "still running" while the very next + // render's `registry.get()` returns undefined. Naively falling + // back to the snap leaves a ghost-running row that never clears; + // dropping the row outright makes the agent disappear instantly + // and the user loses the "what just finished?" beat. Synthesize + // a terminal version so the 8s visibility window gives feedback + // and then evicts the row cleanly. + // + // The synthesis MUST use a neutral glyph rather than the success + // ✔ — foreground subagents do not transition through + // `complete`/`fail`/`cancel` on the registry before unregister, + // so the panel cannot tell whether the run succeeded or failed. + // Showing ✔ for 8s on a run the user just saw fail (via the + // inline tool result) would be a confusing lie. + const ghost = agentEntry({ + agentId: 'ghost-1', + subagentType: 'editor', + description: 'editor: long-gone foreground task', + status: 'running', + }); + const { config } = makeRegistryConfig([]); + const { lastFrame } = renderPanel({ entries: [ghost], config }); + let frame = lastFrame() ?? ''; + expect(frame).toContain('editor'); + expect(frame).toContain('long-gone foreground task'); + // The synthesis sets status='completed' for the visibility-window + // logic but flags `synthesized: true` so the row renders the + // neutral `·` glyph instead of the success `✔`. + expect(frame).toContain('(0/1)'); + expect(frame).not.toContain('✔'); + expect(frame).toContain('·'); + // After the visibility window the row evicts and the panel hides. + act(() => { + vi.advanceTimersByTime(9000); + }); + frame = lastFrame() ?? ''; + expect(frame).toBe(''); + }); + + it('escapes ANSI control codes in user-controlled strings', () => { + // `subagentType` (subagent config) and `recentActivities[].description` + // (LLM-generated) can carry ANSI escape sequences. Without + // sanitization they bleed through Ink's and corrupt the + // panel chrome (color overrides, cursor moves, screen clears). + // HistoryItemDisplay applies `escapeAnsiCtrlCodes` for the same + // reason; the panel must do the same. + const malicious = 'EVIL'; + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'ansi-1', + subagentType: malicious, + description: `${malicious}: scan ${malicious} repo`, + recentActivities: [{ name: 'Glob', description: malicious, at: 0 }], + }), + ], + }); + const frame = lastFrame() ?? ''; + // Raw escape sequence MUST NOT appear; the JSON-string-escaped + // form (visible literal ``) is acceptable since it's + // inert at the terminal level. + expect(frame).not.toContain(''); + expect(frame).not.toContain(''); + // The visible word survives the escaping (the wrapper became + // visible literals, the payload didn't disappear). + expect(frame).toContain('EVIL'); + }); + + it('keeps the success glyph for entries the registry still tracks (non-synthesized)', () => { + // Sibling assertion to the synthesized case above — when the + // registry HAS the entry (an authentic completed transition, + // e.g. a background subagent reaching `complete()`), the panel + // should keep rendering the green ✔. The neutral glyph is + // synthesis-only. + const real = agentEntry({ + agentId: 'real-1', + subagentType: 'researcher', + description: 'researcher: real completion', + status: 'completed', + startTime: -3_000, + endTime: 0, + }); + const { config } = makeRegistryConfig([real]); + const { lastFrame } = renderPanel({ entries: [real], config }); + const frame = lastFrame() ?? ''; + expect(frame).toContain('✔'); + expect(frame).not.toContain('·'); + }); + + it('keeps terminal snapshots visible until the TTL even when the registry forgot them', () => { + // Cancelled / failed foreground subagents go through + // `cancel`/`fail` (which stamp `endTime` and emit statusChange) + // followed by `unregisterForeground` (which deletes silently). + // The snap captures the real `endTime`, so the panel must keep + // it on screen until the visibility window expires — dropping + // immediately would contradict the "brief terminal visibility" + // contract the synthesized-completion path also relies on. + const cancelled = agentEntry({ + agentId: 'cancelled-1', + subagentType: 'researcher', + description: 'researcher: was cancelled then unregistered', + status: 'cancelled', + startTime: -2_000, + endTime: 0, // fresh terminal at fake-time 0 + }); + const { config } = makeRegistryConfig([]); + const { lastFrame } = renderPanel({ entries: [cancelled], config }); + let frame = lastFrame() ?? ''; + // Within the window the row stays on screen with the cancelled + // glyph (✖, warning color routing — see status-icon test). + expect(frame).toContain('was cancelled'); + expect(frame).toContain('✖'); + // After TERMINAL_VISIBLE_MS the row evicts and the panel hides. + act(() => { + vi.advanceTimersByTime(9000); + }); + frame = lastFrame() ?? ''; + expect(frame).toBe(''); + }); + + it('drops rows where the snapshot is terminal AND has no endTime', () => { + // Defensive: terminal status without endTime is an upstream + // invariant violation (`complete`/`fail`/`cancel` always stamp + // endTime). Drop rather than render an entry the visibility + // window has no way to evict. + const broken = agentEntry({ + agentId: 'broken-1', + subagentType: 'researcher', + description: 'researcher: malformed snapshot', + status: 'failed', + endTime: undefined, + }); + const { config } = makeRegistryConfig([]); + const { lastFrame } = renderPanel({ entries: [broken], config }); + expect(lastFrame() ?? '').toBe(''); + }); + + it('tears the 1s tick down when the bg-tasks dialog opens', () => { + // While the dialog is open the panel returns null and the dialog + // owns the same data — a still-running interval is a wasted + // re-render budget. Verify by checking that advancing the clock + // past the visibility window with dialogOpen=true does not flip + // the panel into its "expired" state (which would only happen if + // the tick advanced `now`). + const initial = agentEntry({ + agentId: 'live-1', + subagentType: 'researcher', + description: 'researcher: investigate', + status: 'completed', + startTime: -2000, + endTime: 0, + }); + const { config } = makeRegistryConfig([initial]); + const { lastFrame } = renderPanel({ + entries: [initial], + config, + dialogOpen: true, + }); + // Dialog open → panel hidden, no opportunity for `now` to drift. + expect(lastFrame() ?? '').toBe(''); + act(() => { + vi.advanceTimersByTime(60_000); + }); + // Still hidden. The fact that we got here without the panel ever + // mounting an interval means subsequent renders won't churn either. + expect(lastFrame() ?? '').toBe(''); + }); + + it('still shows the snapshot when no Config is mounted (test fixtures)', () => { + // Without a Config provider the panel can't reach the registry, so + // it has to trust the snapshot — this is the one place the legacy + // "fall back to snap" behavior is correct (and the seven other + // tests in this file rely on it). + const { lastFrame } = renderPanel({ + entries: [ + agentEntry({ + agentId: 'snap-only', + subagentType: 'researcher', + description: 'researcher: snapshot-only path', + }), + ], + }); + expect(lastFrame() ?? '').toContain('researcher'); + expect(lastFrame() ?? '').toContain('snapshot-only path'); + }); +}); diff --git a/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx b/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx new file mode 100644 index 000000000..88c895088 --- /dev/null +++ b/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx @@ -0,0 +1,521 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * LiveAgentPanel — always-on bottom-of-screen roster of running subagents. + * + * Mirrors Claude Code's CoordinatorTaskPanel ("Renders below the prompt + * input footer whenever local_agent tasks exist") — borderless rows of + * `status · name · activity · elapsed` so the panel sits lightly above + * the composer rather than competing with it for vertical space. The + * heavier bordered look stays with `BackgroundTasksDialog`, the + * Down-arrow detail view that handles selection, cancel, and resume. + * + * Replaces the inline `AgentExecutionDisplay` frame for live updates — + * that frame mutated on every tool-call and caused scrollback repaint + * flicker once the tool list grew past the terminal height. The panel + * sits outside `` so updates never disturb committed history, + * and the same per-agent registry already powers the footer pill and + * the dialog, so the three views never drift. + * + * Scope: read-only display. Cancel / detail / approval routing all stay + * with the existing pill+dialog (Down arrow → BackgroundTasksDialog) so + * this panel never competes for keyboard input. + */ + +import type React from 'react'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text } from 'ink'; +import { + DEFAULT_BUILTIN_SUBAGENT_TYPE as CORE_DEFAULT_SUBAGENT_TYPE, + ToolDisplayNames, + ToolNames, +} from '@qwen-code/qwen-code-core'; +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 { escapeAnsiCtrlCodes } from '../../utils/textUtils.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; +// Re-export under a panel-local alias so the source of truth stays +// in `subagents/builtin-agents.ts` (a backend rename of the default +// type would otherwise silently re-introduce the redundant +// `general-purpose:` prefix on every row). Specialized subagents +// (other builtins or user-authored types) still get their type +// rendered as a bold anchor. +const DEFAULT_SUBAGENT_TYPE = CORE_DEFAULT_SUBAGENT_TYPE; + +type LivePanelEntry = AgentDialogEntry & { + /** True when the row is past its terminal-visibility window. */ + expired: boolean; + /** + * True when the row was synthesized because the registry forgot + * the entry — we know the agent is no longer running but NOT + * whether it succeeded, failed, or was cancelled (foreground + * subagents don't transition through `complete`/`fail`/`cancel` + * before `unregisterForeground`). Renders with a neutral glyph + * and color so the panel never claims a green ✔ on a run that + * the user just saw fail in the inline tool result. + */ + synthesized?: boolean; +}; + +function isAgentEntry(entry: DialogEntry): entry is AgentDialogEntry { + return entry.kind === 'agent'; +} + +// Bullet glyphs mirror Claude Code's CoordinatorTaskPanel — `○` for +// active slots (running / paused) so the row reads as a uniform list, +// terminal states keep distinct check / cross marks so they're easy +// to scan at a glance. +function statusIcon(entry: AgentDialogEntry & { synthesized?: boolean }): { + glyph: string; + color: string; +} { + if (entry.synthesized) { + // Outcome unknown — registry forgot the entry without going + // through complete / fail / cancel. Use a neutral marker so + // we don't lie about success. + return { glyph: '·', color: theme.text.secondary }; + } + 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 }; + } +} + +// Internal-tool-name → user-facing display-name lookup +// (`run_shell_command` → `Shell`, `glob` → `Glob`, …). Mirrors the +// same map BackgroundTasksDialog uses so the two surfaces stay +// vocabulary-consistent — without it the panel would surface raw +// internal identifiers like `run_shell_command` while the dialog +// shows `Shell` for the same agent. +const TOOL_DISPLAY_BY_NAME: Record = Object.fromEntries( + (Object.keys(ToolNames) as Array).map((key) => [ + ToolNames[key], + ToolDisplayNames[key], + ]), +); + +function activityLabel(entry: AgentDialogEntry): string { + const last = entry.recentActivities?.at(-1); + if (!last) return ''; + const display = TOOL_DISPLAY_BY_NAME[last.name] ?? last.name; + const desc = last.description?.replace(/\s*\n\s*/g, ' ').trim(); + return desc ? `${display} ${desc}` : display; +} + +/** + * Strip the leading `subagentType:` prefix from `entry.description` if + * present so the row doesn't render `editor · editor: tighten…`. We + * intentionally do NOT call `buildBackgroundEntryLabel` here: the shared + * helper also caps at 40 chars + appends `…`, which then collides with + * the row-level `truncate-end` and produces a double-ellipsis on narrow + * terminals (e.g. `… FIXME ……`). The row's own truncation has the full + * width budget and is the right place to decide where to cut. + */ +function descriptionWithoutPrefix(entry: AgentDialogEntry): string { + const raw = entry.description ?? ''; + if (!entry.subagentType) return raw; + const lowerRaw = raw.toLowerCase(); + const prefix = entry.subagentType.toLowerCase() + ':'; + if (lowerRaw.startsWith(prefix)) { + return raw.slice(prefix.length).trimStart(); + } + return raw; +} + +function elapsedLabel(entry: AgentDialogEntry, now: number): string { + const startedAt = entry.startTime; + const endedAt = entry.endTime ?? now; + const ms = Math.max(0, endedAt - startedAt); + // Whole-second precision keeps the row stable between paint frames — + // a stopwatch ticking sub-seconds in a footer panel is a distraction. + const wholeSeconds = Math.floor(ms / 1000); + return formatDuration(wholeSeconds * 1000, { hideTrailingZeros: true }); +} + +export const LiveAgentPanel: React.FC = ({ + maxRows = DEFAULT_MAX_ROWS, + width, +}) => { + const { entries, dialogOpen } = useBackgroundTaskViewState(); + // Reach for Config via the raw context (NOT useConfig) so the panel + // can degrade to snapshot-only when no provider is mounted — e.g. + // unit tests that render the component in isolation. useConfig + // throws in that case, which would force every consumer to provide + // a stub Config just to satisfy the panel's "live registry re-pull". + const config = useContext(ConfigContext); + + // Wall-clock tick. Drives elapsed-time refresh, terminal-row eviction, + // AND the live registry re-pull below. The gate must consider: + // - `dialogOpen` — when the bg-tasks dialog is up, the panel + // renders null (`if (dialogOpen) return null` below), so any + // ticks the interval fires are wasted re-renders. + // - live agents (running / paused) — always need elapsed updates. + // - terminal agents still inside the 8s visibility window — need + // ticks to drive their eviction. + // `BackgroundTaskRegistry.getAll()` retains terminal entries + // indefinitely, so a naive `entries.some(isAgentEntry)` gate would + // keep ticking forever after the last entry's window closed; the + // `dialogOpen` arm closes the corresponding gap on the dialog side. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (dialogOpen) return; + const needsTick = (whenMs: number) => + entries.some((e) => { + if (!isAgentEntry(e)) return false; + if (e.status === 'running' || e.status === 'paused') return true; + if (e.endTime === undefined) return false; + return whenMs - e.endTime <= TERMINAL_VISIBLE_MS; + }); + if (!needsTick(Date.now())) return; + const id = setInterval(() => { + const wallNow = Date.now(); + // Always advance `now` first so the final render reflects the + // latest expiry state; THEN check if there's still work to do + // and clear the interval if not. Without the up-front update + // the row that just expired would linger one extra second. + setNow(wallNow); + if (!needsTick(wallNow)) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }, [entries, dialogOpen]); + + // 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. + // + // Four reconciliation paths between the snapshot and the registry: + // 1. Both agree → use live (newest `recentActivities`). + // 2. Snap says still-live (running / paused) but registry forgot + // → most commonly a foreground subagent that finished: + // `unregisterForeground` fires `emitStatusChange(entry)` BEFORE + // it deletes the entry, so the snapshot captures the old + // "still running" state and the next render's `registry.get` + // returns undefined. Synthesize a terminal version with + // `endTime = first-seen-missing` (pinned so subsequent ticks + // don't re-stamp it) and `synthesized: true` so the 8s + // visibility window gives the user a "the agent finished" + // beat without claiming a green ✔ on a run we can't + // actually verify (foreground subagents don't transition + // through complete / fail / cancel before unregister, so the + // true outcome is unknowable here). + // 3. Snap is already terminal AND has `endTime` → keep the snap + // as-is. Canonical case: a foreground subagent that was + // cancelled / failed (which stamps `endTime` and emits + // statusChange) and then `unregisterForeground`'d. The snap + // carries the real terminal state and timestamp, so the row + // reads accurately; the visibleAgents filter evicts it once + // `now - endTime > TERMINAL_VISIBLE_MS` like any other + // terminal entry. + // 4. Snap is terminal but has NO `endTime` → drop. This is an + // upstream invariant violation (`complete`/`fail`/`cancel` + // always stamp endTime); rendering would leave a row the + // visibility window has no way to evict. + // + // When `config` itself is undefined (test fixtures that render + // without ConfigContext) the panel degrades to snapshot-only — + // there's no live source of truth to reconcile against. + // + // 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. + // First-seen-missing timestamps for synthesized terminal entries. + // We need this to survive across useMemo recomputes — without it, + // each tick would re-synthesize the entry with a fresh `now` as + // `endTime`, the visibility-window check (`now - endTime > 8000`) + // would always evaluate to 0, and the row would never expire. The + // ref outlives both the snapshot and the tick state. + const missingSinceRef = useRef>(new Map()); + + const liveAgentSnapshots: AgentDialogEntry[] = useMemo(() => { + const snapshots = entries.filter(isAgentEntry); + if (!config) return snapshots; + const registry = config.getBackgroundTaskRegistry(); + // `now` participates in the dependency array so the memo recomputes + // each tick and picks up `recentActivities` the registry mutated in + // place via appendActivity. Reading it here makes the dependency + // semantically honest — without this read a future "remove dead + // dep" cleanup would silently freeze the panel on the first + // tool-call after a snapshot refresh. + const reconcileAt = now; + const seenIds = new Set(); + const next = snapshots + .map((snap) => { + seenIds.add(snap.agentId); + const live = registry.get(snap.agentId); + if (live) { + // Recovered (or never went missing) — drop any stale + // missing-since record so a future re-disappearance + // gets a fresh timestamp. + missingSinceRef.current.delete(snap.agentId); + return { ...live, kind: 'agent' as const }; + } + if (snap.status === 'running' || snap.status === 'paused') { + // Pin the disappearance time on first observation so + // subsequent ticks don't keep resetting endTime to `now`. + let missingSince = missingSinceRef.current.get(snap.agentId); + if (missingSince === undefined) { + missingSince = reconcileAt; + missingSinceRef.current.set(snap.agentId, missingSince); + } + // Mark synthesized so the row renders with a neutral glyph + // — we know the agent is no longer running but cannot tell + // whether it succeeded, failed, or was cancelled (foreground + // subagents are unregistered without transitioning through + // complete / fail / cancel on the registry). Status stays + // `'completed'` purely so the visibility-window filter + // treats the row as terminal; `synthesized` overrides the + // glyph + color in `statusIcon`. + return { + ...snap, + status: 'completed' as const, + endTime: snap.endTime ?? missingSince, + synthesized: true, + } as AgentDialogEntry; + } + // Snap is already terminal but the registry forgot. Canonical + // case: a foreground subagent that was cancelled / failed + // (`cancel` / `fail` set `endTime` and emit statusChange) and + // then `unregisterForeground`'d. The snap carries the real + // `endTime`, so keep showing it — the visibleAgents filter + // below evicts it once `now - endTime > TERMINAL_VISIBLE_MS`. + // Without this branch cancelled / failed foreground tasks + // would disappear instantly, contradicting the panel's "brief + // terminal visibility" contract the synthesized-completion + // path relies on. + if (snap.endTime !== undefined) return snap; + // Defensive fallback: terminal snap with no endTime is an + // invariant violation upstream (complete / fail / cancel + // always stamp endTime). Drop rather than render an entry + // the visibility window has no way to evict. + return null; + }) + .filter((e): e is AgentDialogEntry => e !== null); + // GC: drop missing-since records for agents that are no longer + // even in the snapshot (e.g. statusChange refreshed and the + // entry left useBackgroundTaskView's view entirely). + for (const id of missingSinceRef.current.keys()) { + if (!seenIds.has(id)) missingSinceRef.current.delete(id); + } + return next; + }, [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. + // + // The early-return is the LAST statement of this component on + // purpose — pure rendering moves to LiveAgentPanelBody so that + // future refactors which add a hook can't accidentally drop it + // below the `dialogOpen` guard (`Rendered fewer hooks than + // expected` is the canonical bug shape this guards against). + if (dialogOpen) return null; + return ( + + ); +}; + +const LiveAgentPanelBody: React.FC<{ + snapshots: AgentDialogEntry[]; + now: number; + maxRows: number; + width: number | undefined; +}> = ({ snapshots, now, maxRows, width }) => { + const visibleAgents: LivePanelEntry[] = snapshots + .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; + + // Include paused entries in the "active" tally — they appear in + // the panel as active rows (same warning color, ⏸ glyph) and the + // header read "Active agents (0/1)" with one paused agent visible + // is misleading. The tally now matches what the user sees: the + // numerator counts every row that's NOT in a terminal/synthesized + // resting state. + const activeCount = visibleAgents.filter( + (e) => e.status === 'running' || e.status === 'paused', + ).length; + + // Borderless layout, mirroring Claude Code's CoordinatorTaskPanel + // ("Renders below the prompt input footer whenever local_agent tasks + // exist" — plain rows under a single marginTop). The bordered look + // belongs to BackgroundTasksDialog (a real overlay); the always-on + // roster is a glance surface that should sit lightly above the + // composer rather than fight it for vertical space + border cells. + return ( + + + + Active agents + + {` (${activeCount}/${visibleAgents.length})`} + + {overflow > 0 && ( + + {/* + The panel is read-only (no keyboard focus — that would + steal input from the composer), so when the roster + overflows the row budget we point users at the dialog + that DOES support selection / scroll / cancel / resume. + Same keystroke the footer pill uses, kept in sync so + users only have to learn one thing. + */} + {` ^ ${overflow} more above (↓ to view all)`} + + )} + {visible.map((entry) => ( + + ))} + + ); +}; + +const AgentRow: React.FC<{ entry: AgentDialogEntry; now: number }> = ({ + entry, + now, +}) => { + const { glyph, color } = statusIcon(entry); + // ANSI sanitize every user-controlled string before it reaches Ink. + // `subagentType` comes from subagent config (user-authored or model- + // chosen) and `recentActivities[].description` is LLM-generated; + // both can carry terminal control sequences that would otherwise + // bleed through Ink's `` and corrupt the panel chrome. + // HistoryItemDisplay applies the same `escapeAnsiCtrlCodes` to its + // user-facing content for the same reason. + const label = escapeAnsiCtrlCodes(descriptionWithoutPrefix(entry)); + // Note: foreground vs background is intentionally not surfaced here. + // BackgroundTasksDialog tags foreground rows with `[blocking]` + // (formerly `[in turn]`) to warn that cancelling will end the + // current turn — useful in the dialog where `x` triggers a real + // cancel. The glance panel has no cancel surface, so the marker + // reads as ambient noise. Keep the dialog as the place that + // surfaces the flavor distinction. + const activity = escapeAnsiCtrlCodes(activityLabel(entry)); + const elapsed = elapsedLabel(entry, now); + const showType = + entry.subagentType !== undefined && + entry.subagentType !== DEFAULT_SUBAGENT_TYPE; + const safeSubagentType = showType + ? escapeAnsiCtrlCodes(entry.subagentType ?? '') + : ''; + const tokenSuffix = + entry.stats?.totalTokens && entry.stats.totalTokens > 0 + ? ` · ${formatTokenCount(entry.stats.totalTokens)} tokens` + : ''; + + // Layout (Claude Code's CoordinatorTaskPanel visual + our + // right-pin to keep elapsed / tokens from being clipped): + // + // [○ type: desc (activity)] [▶ 13s · 2.4k tokens] + // ^ flex-shrink:1 ^ flex-shrink:0 + // truncate-end always intact + // + // - Status glyph at the left (`○` for live slots, ✔/✖/⏸ for + // terminal — see `statusIcon`). + // - `type:` prefix when not the default `general-purpose`. + // - Activity wrapped in parentheses so it reads as an annotation + // on the description rather than a sibling field. + // - `▶` separates the description from elapsed / tokens, mirroring + // the leaked CoordinatorTaskPanel pattern (`PLAY_ICON`). + // - The left column has flex-shrink:1 (no flex-grow) so the two + // columns sit side by side at intrinsic widths; empty slack + // falls off the row tail rather than opening a visual gap + // between the description and the right-pinned elapsed. + const tail = ` ▶ ${elapsed}${tokenSuffix}`; + return ( + + + + {`${glyph} `} + {showType && ( + <> + {safeSubagentType} + {': '} + + )} + {label} + {activity && ( + {` (${activity})`} + )} + + + + {tail} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 4a78ccdb5..b3562623c 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -51,9 +51,9 @@ interface ToolGroupMessageProps { /** * True when this tool group is being rendered live (in * `pendingHistoryItems`). False once it commits to Ink's ``. - * The subagent renderer uses this to suppress the live frame for - * foreground subagents (the pill+dialog handle live drill-down) while - * keeping the committed scrollback render unchanged. + * Currently consumed by upstream callers but not by the group body + * itself — the subagent renderer used to gate its live frame on + * this; that gating moved to LiveAgentPanel + BackgroundTasksDialog. */ isPending?: boolean; activeShellPtyId?: number | null; @@ -78,7 +78,10 @@ export const ToolGroupMessage: React.FC = ({ availableTerminalHeight, contentWidth, isFocused = true, - isPending = false, + // `isPending` stays on the props interface for upstream compat + // (HistoryItemDisplay et al. forward it) but the group body no + // longer reads it. Skip the destructure so TS catches accidental + // re-introductions of dead state. activeShellPtyId, embeddedShellFocused, memoryWriteCount, @@ -288,22 +291,18 @@ export const ToolGroupMessage: React.FC = ({ })()} {toolCalls.map((tool) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; - // A subagent's inline confirmation should only receive keyboard focus - // when (1) there is no direct tool-level confirmation active, and (2) - // this tool currently holds the subagent keyboard focus. Pending - // confirmations keep the existing first-come focus lock; otherwise the - // first running subagent owns Ctrl+E/Ctrl+F so the compact hint remains - // actionable without making parallel subagents toggle in lock-step. + // A subagent's inline approval prompt should only receive keyboard + // focus when (1) there is no direct tool-level confirmation active + // and (2) this tool currently holds the subagent keyboard focus. + // Pending confirmations keep the first-come focus lock so users + // answer one approval at a time; LiveAgentPanel + BackgroundTasksDialog + // own all live progress / drill-down (the legacy Ctrl+E / Ctrl+F + // shortcuts on the inline AgentExecutionDisplay frame were retired + // alongside the frame itself). const isSubagentFocused = isFocused && !toolAwaitingApproval && keyboardFocusedSubagentCallId === tool.callId; - // Show the waiting indicator only when this subagent genuinely has a - // pending confirmation AND another subagent holds the focus lock. - const isWaitingForOtherApproval = - isAgentWithPendingConfirmation(tool.resultDisplay) && - focusedSubagentCallId !== null && - focusedSubagentCallId !== tool.callId; return ( @@ -328,8 +327,6 @@ export const ToolGroupMessage: React.FC = ({ isAgentWithPendingConfirmation(tool.resultDisplay) } isFocused={isSubagentFocused} - isPending={isPending} - isWaitingForOtherApproval={isWaitingForOtherApproval} /> {tool.status === ToolCallStatus.Confirming && diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 274b39148..403983f34 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -101,23 +101,10 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({ return MockMarkdown:{text}; }, })); -vi.mock('../subagents/index.js', () => ({ - AgentExecutionDisplay: function MockAgentExecutionDisplay({ - data, - }: { - data: { subagentName: string; taskDescription: string }; - }) { - return ( - - 🤖 {data.subagentName} • Task: {data.taskDescription} - - ); - }, -})); vi.mock('./ToolConfirmationMessage.js', () => ({ ToolConfirmationMessage: function MockToolConfirmationMessage() { - // Sentinel string lets `isPending && pendingConfirmation` tests - // assert the banner renders (instead of being suppressed). + // Sentinel string lets the focus-routed approval tests assert + // the banner renders (instead of being suppressed). return MockApprovalPrompt; }, })); @@ -313,58 +300,24 @@ describe('', () => { expect(lowEmphasisFrame()).not.toContain('←'); }); - it('shows subagent execution display for task tool with proper result display', () => { - const subagentResultDisplay = { - type: 'task_execution' as const, - subagentName: 'file-search', - taskDescription: 'Search for files matching pattern', - taskPrompt: 'Search for files matching pattern', - status: 'running' as const, - }; - - const props: ToolMessageProps = { - name: 'task', - description: 'Delegate task to subagent', - resultDisplay: subagentResultDisplay, - status: ToolCallStatus.Executing, - contentWidth: 80, - callId: 'test-call-id-2', - confirmationDetails: undefined, - config: mockConfig, - }; - - const { lastFrame } = renderWithContext( - , - StreamingState.Responding, - ); - - const output = lastFrame(); - expect(output).toContain('🤖'); // Subagent execution display should show - expect(output).toContain('file-search'); // Actual subagent name - expect(output).toContain('Search for files matching pattern'); // Actual task description - }); - - describe('subagent live-render gating (isPending)', () => { - // The redesign hides the inline AgentExecutionDisplay while a - // foreground subagent runs (the pill+dialog handle drill-down). - // Only an active, focused approval prompt renders inline. + describe('subagent inline rendering (approval-only surface)', () => { + // The verbose inline AgentExecutionDisplay frame has been retired in + // favour of the always-on LiveAgentPanel (live progress) and + // BackgroundTasksDialog (history / detail). ToolMessage's only + // remaining inline subagent surface is the focus-routed approval + // prompt — both running and committed agent states render nothing + // inline now. const buildProps = (overrides: { data: { subagentName: string; taskDescription: string; taskPrompt: string; - status: 'running' | 'completed'; + status: 'running' | 'completed' | 'failed' | 'cancelled'; pendingConfirmation?: object; + terminateReason?: string; }; - isPending?: boolean; 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, @@ -377,13 +330,11 @@ describe('', () => { status: ToolCallStatus.Executing, callId: 'gated-task-call', forceShowResult: true, // mirror ToolGroupMessage's forceShowResult - isPending: overrides.isPending, isFocused: overrides.isFocused, - isWaitingForOtherApproval: overrides.isWaitingForOtherApproval, }; }; - it('isPending && no pendingConfirmation → no inline frame', () => { + it('running subagent without confirmation → no inline frame', () => { const { lastFrame } = renderWithContext( ', () => { taskPrompt: 'Search', status: 'running', }, - isPending: true, })} />, 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 → renders a one-line scrollback summary', () => { + // The verbose 15-row inline frame is retired (it caused + // scrollback flicker), but the conversation history needs to + // keep a permanent record after the panel's 8s window expires + // and the dialog closes. A single line preserves the history + // without re-introducing the flicker. + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + // One-line summary: success glyph + agent name + description. + expect(output).toContain('✔'); + expect(output).toContain('committed-agent'); + expect(output).toContain('Already done'); + // No approval prompt — completed subagents don't sit on the + // focus lock. expect(output).not.toContain('MockApprovalPrompt'); }); - it('isPending && pendingConfirmation && isFocused → renders banner with agent label', () => { + it('failed subagent → renders summary with terminate reason', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + expect(output).toContain('✖'); + expect(output).toContain('failed-agent'); + expect(output).toContain('Crashed early'); + expect(output).toContain('Network timeout'); + }); + + it('pendingConfirmation && isFocused → renders banner with agent label', () => { const { lastFrame } = renderWithContext( ', () => { status: 'running', pendingConfirmation: {} as object, }, - isPending: true, isFocused: true, })} />, 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 @@ -447,7 +443,6 @@ describe('', () => { status: 'running', pendingConfirmation: {} as object, }, - isPending: true, isFocused: false, })} />, @@ -456,32 +451,8 @@ describe('', () => { const output = lastFrame() ?? ''; expect(output).toContain('Queued approval:'); expect(output).toContain('queued-agent'); - // The full prompt + frame stay suppressed — only the focus-holder - // renders the active prompt above this row. expect(output).not.toContain('Approval requested by'); expect(output).not.toContain('MockApprovalPrompt'); - expect(output).not.toContain('🤖'); - }); - - it('!isPending → committed render shows full inline frame', () => { - const { lastFrame } = renderWithContext( - , - StreamingState.Idle, - ); - const output = lastFrame() ?? ''; - // -rendered scrollback: full frame, no flicker concern. - expect(output).toContain('🤖'); - expect(output).toContain('committed-agent'); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 314ded5ac..b1299e693 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -24,11 +24,11 @@ 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'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; +import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import type { LoadedSettings } from '../../../config/settings.js'; @@ -250,17 +250,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. Three surfaces remain: * - * Committed (`isPending===false`): renders the full `AgentExecutionDisplay` - * exactly as before. Ink's `` is append-only, so committed frames - * never flicker even when verbose. + * - **Live phase (running)**: nothing inline — `LiveAgentPanel` (the + * always-on bottom roster) and `BackgroundTasksDialog` (Down-arrow + * detail view) own progress reporting. + * - **Approval prompt (focus-locked)**: full inline approval banner so + * the user can answer without context-switching into the dialog; + * sibling subagents render a queued marker. + * - **Committed phase (terminal — completed / failed / cancelled)**: a + * single-line scrollback summary so the conversation history retains + * a permanent record after the panel's 8s window expires and the + * dialog closes. Format: ` : · N tools · Xs · Yk tokens`. */ const SubagentExecutionRenderer: React.FC<{ data: AgentResultDisplay; @@ -268,68 +269,109 @@ const SubagentExecutionRenderer: React.FC<{ childWidth: number; config: Config; isFocused?: boolean; - isPending?: boolean; - isWaitingForOtherApproval?: boolean; -}> = ({ - data, - availableHeight, - childWidth, - config, - isFocused, - isPending, - isWaitingForOtherApproval, -}) => { - if (isPending) { - if (data.pendingConfirmation && isFocused) { - // Active approval prompt for the focus-holding subagent — render - // inline so the user can act on it without opening the dialog. - const agentLabel = data.subagentName || 'agent'; - return ( - - - Approval requested by - - {agentLabel} - - : - - - - ); - } - if (data.pendingConfirmation) { - // Queued approval — another subagent currently holds the focus lock. - // A one-line marker keeps the user aware that something is waiting - // without opening the dialog; the full prompt renders on the - // focus-holder above and inside `BackgroundTasksDialog`. - const agentLabel = data.subagentName || 'agent'; - return ( - - - ⏳ Queued approval:{' '} +}> = ({ data, availableHeight, childWidth, config, isFocused }) => { + if (data.pendingConfirmation && isFocused) { + const agentLabel = data.subagentName || 'agent'; + return ( + + + Approval requested by + + {agentLabel} - {agentLabel} + : - ); - } - return null; + + + ); } + if (data.pendingConfirmation) { + const agentLabel = data.subagentName || 'agent'; + return ( + + + ⏳ Queued approval:{' '} + + {agentLabel} + + ); + } + // Terminal phase: render a single-line scrollback summary so the + // conversation history keeps a permanent record after the panel's + // 8s visibility window expires (LiveAgentPanel evicts terminal rows; + // BackgroundTasksDialog only retains them while open). Skip + // `running` / `background` since the panel + dialog cover those. + if ( + data.status === 'completed' || + data.status === 'failed' || + data.status === 'cancelled' + ) { + return ; + } + return null; +}; + +/** + * One-line summary that lands in scrollback when a subagent reaches a + * terminal state. The verbose 15-row frame is retired (it caused + * scrollback flicker); this single line preserves the persistent + * record without re-introducing the flicker. + * + * ✔ researcher: investigate import order · 5 tools · 12s · 2.4k tokens + */ +const SubagentScrollbackSummary: React.FC<{ + data: AgentResultDisplay; +}> = ({ data }) => { + const { glyph, color } = (() => { + switch (data.status) { + 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 }; + } + })(); + const stats = data.executionSummary; + const parts: string[] = []; + if (stats?.totalToolCalls !== undefined) { + parts.push( + `${stats.totalToolCalls} tool${stats.totalToolCalls === 1 ? '' : 's'}`, + ); + } + if (stats?.totalDurationMs !== undefined) { + parts.push( + formatDuration(stats.totalDurationMs, { hideTrailingZeros: true }), + ); + } + if (stats?.totalTokens && stats.totalTokens > 0) { + parts.push(`${formatTokenCount(stats.totalTokens)} tokens`); + } + const tail = parts.length > 0 ? ` · ${parts.join(' · ')}` : ''; + const typePrefix = data.subagentName ? `${data.subagentName}: ` : ''; + const reason = + data.status !== 'completed' && data.terminateReason + ? ` · ${data.terminateReason}` + : ''; return ( - + + + {`${glyph} `} + {typePrefix} + {data.taskDescription} + {tail} + {reason} + + ); }; @@ -430,19 +472,12 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { config?: Config; forceShowResult?: boolean; /** - * Whether this subagent owns keyboard input for confirmations and - * Ctrl+E/Ctrl+F display shortcuts. + * Whether this subagent owns keyboard input for the inline approval + * surface — when true the focus-holder banner renders and the + * underlying ToolConfirmationMessage receives keystrokes; when false + * sibling subagents render a dim "Queued approval" marker instead. */ isFocused?: boolean; - /** - * True when rendering inside `pendingHistoryItems` (live area), false once - * committed to ``. Foreground subagents suppress their inline - * frame in the live phase — the pill+dialog handle drill-down — but - * always render in scrollback. - */ - isPending?: boolean; - /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ - isWaitingForOtherApproval?: boolean; } export const ToolMessage: React.FC = ({ @@ -460,8 +495,6 @@ export const ToolMessage: React.FC = ({ config, forceShowResult, isFocused, - isPending, - isWaitingForOtherApproval, executionStartTime, }) => { const settings = useSettings(); @@ -616,8 +649,6 @@ export const ToolMessage: React.FC = ({ childWidth={innerWidth} config={config} isFocused={isFocused} - isPending={isPending} - isWaitingForOtherApproval={isWaitingForOtherApproval} /> )} {effectiveDisplayRenderer.type === 'diff' && ( diff --git a/packages/cli/src/ui/components/subagents/index.ts b/packages/cli/src/ui/components/subagents/index.ts index 8f22a244d..12c43f7e9 100644 --- a/packages/cli/src/ui/components/subagents/index.ts +++ b/packages/cli/src/ui/components/subagents/index.ts @@ -10,5 +10,6 @@ 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, beneath the +// composer) and `BackgroundTasksDialog` (Down-arrow detail view). diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx deleted file mode 100644 index b5a148bc0..000000000 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { act } from 'react'; -import { render } from 'ink-testing-library'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AgentResultDisplay } from '@qwen-code/qwen-code-core'; -import { makeFakeConfig } from '@qwen-code/qwen-code-core'; -import { AgentExecutionDisplay } from './AgentExecutionDisplay.js'; - -let keypressHandler: - | ((key: { ctrl?: boolean; name?: string }) => void) - | undefined; - -vi.mock('../../../hooks/useKeypress.js', () => ({ - // The mock honours { isActive } so historical/completed displays don't - // capture the keypress handler — same scoping the production hook does. - useKeypress: vi.fn( - ( - handler: (key: { ctrl?: boolean; name?: string }) => void, - options?: { isActive?: boolean }, - ) => { - keypressHandler = options?.isActive === false ? undefined : handler; - }, - ), -})); - -function makeRunningData(toolCount: number): AgentResultDisplay { - return { - type: 'task_execution', - subagentName: 'reviewer', - subagentColor: 'blue', - status: 'running', - taskDescription: 'Review large output stability', - taskPrompt: `${'very-long-task-prompt '.repeat(20)}\nsecond\nthird`, - toolCalls: Array.from({ length: toolCount }, (_, index) => ({ - callId: `call-${index}`, - name: `tool-${index}`, - status: 'success', - description: `description-${index} ${'wide '.repeat(20)}`, - resultDisplay: `result-${index} ${'payload '.repeat(20)}`, - })), - }; -} - -function makeCompletedData(toolCount: number): AgentResultDisplay { - return { - ...makeRunningData(toolCount), - status: 'completed', - executionSummary: { - rounds: 3, - totalDurationMs: 12_345, - totalToolCalls: toolCount, - successfulToolCalls: toolCount, - failedToolCalls: 0, - successRate: 100, - inputTokens: 100, - outputTokens: 200, - thoughtTokens: 0, - cachedTokens: 0, - totalTokens: 4_321, - toolUsage: [], - }, - }; -} - -function visualRowCount(frame: string): number { - if (!frame) return 0; - return frame.split('\n').length; -} - -describe('', () => { - beforeEach(() => { - keypressHandler = undefined; - }); - - it('bounds expanded detail by the assigned visual height', () => { - const { lastFrame } = render( - , - ); - - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - - const frame = lastFrame() ?? ''; - expect(frame).toContain('Showing the first 1 visual lines'); - expect(frame).toContain('Showing the last 1 of 8 tools'); - expect(frame).toContain('tool-7'); - expect(frame).not.toContain('tool-0'); - }); - - it('keeps the rendered running frame within availableHeight', () => { - const availableHeight = 26; - const { lastFrame } = render( - , - ); - - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - - expect(visualRowCount(lastFrame() ?? '')).toBeLessThanOrEqual( - availableHeight, - ); - }); - - it('does not respond to ctrl+e when another running subagent has focus', () => { - // Two SubAgents running side-by-side share the live viewport. Only the - // focused one should react to Ctrl+E / Ctrl+F — otherwise both reflow - // together and the dual height-change reintroduces flicker. - const { lastFrame } = render( - , - ); - - const before = lastFrame() ?? ''; - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - expect(lastFrame() ?? '').toBe(before); - }); - - it('survives the running → completed transition while expanded', () => { - // Real path: subagent is running, the user expands it (ctrl+e) so - // displayMode becomes 'default', then the same instance rerenders with - // completed data. The completed-state budget must still hold the - // expanded layout inside availableHeight, and ctrl+e must become a - // no-op on the completed instance so it doesn't drag historical - // displays through mode toggles. - const availableHeight = 30; - const { lastFrame, rerender } = render( - , - ); - - // Expand the running display. - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - const expandedRunningFrame = lastFrame() ?? ''; - expect(expandedRunningFrame).toContain('Task Detail:'); - expect(expandedRunningFrame).toContain('Tools:'); - - // Re-render the same component instance with completed data, preserving - // displayMode. Without an overhead-aware completed budget the - // ExecutionSummary + ToolUsage blocks would push the frame past - // availableHeight here. - rerender( - , - ); - - const completedFrame = lastFrame() ?? ''; - expect(visualRowCount(completedFrame)).toBeLessThanOrEqual(availableHeight); - - // useKeypress is now `{ isActive: false }`; ctrl+e on the completed - // instance must not toggle anything. The mock unsets keypressHandler - // when isActive is false, so the call below is a no-op and the frame - // is identical. - const before = lastFrame() ?? ''; - act(() => { - keypressHandler?.({ ctrl: true, name: 'e' }); - }); - expect(lastFrame() ?? '').toBe(before); - }); -}); diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx deleted file mode 100644 index a81ecc4cd..000000000 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ /dev/null @@ -1,725 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useMemo } from 'react'; -import { Box, Text } from 'ink'; -import type { - AgentResultDisplay, - AgentStatsSummary, - Config, -} from '@qwen-code/qwen-code-core'; -import { theme } from '../../../semantic-colors.js'; -import { useKeypress } from '../../../hooks/useKeypress.js'; -import { COLOR_OPTIONS } from '../constants.js'; -import { fmtDuration } from '../utils.js'; -import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js'; -import { - getCachedStringWidth, - sliceTextByVisualHeight, - toCodePoints, -} from '../../../utils/textUtils.js'; - -export type DisplayMode = 'compact' | 'default' | 'verbose'; - -export interface AgentExecutionDisplayProps { - data: AgentResultDisplay; - availableHeight?: number; - childWidth: number; - config: Config; - /** - * Whether this subagent owns keyboard input for confirmations and - * Ctrl+E/Ctrl+F display shortcuts. - */ - isFocused?: boolean; - /** Whether another subagent's approval currently holds the focus lock, blocking this one. */ - isWaitingForOtherApproval?: boolean; -} - -const getStatusColor = ( - status: - | AgentResultDisplay['status'] - | 'executing' - | 'success' - | 'awaiting_approval', -) => { - switch (status) { - case 'running': - case 'executing': - case 'awaiting_approval': - return theme.status.warning; - case 'completed': - case 'success': - return theme.status.success; - case 'background': - return theme.text.secondary; - case 'cancelled': - return theme.status.warning; - case 'failed': - return theme.status.error; - default: - return theme.text.secondary; - } -}; - -const getStatusText = (status: AgentResultDisplay['status']) => { - switch (status) { - case 'running': - return 'Running'; - case 'completed': - return 'Completed'; - case 'background': - return 'Running in background'; - case 'cancelled': - return 'User Cancelled'; - case 'failed': - return 'Failed'; - default: - return 'Unknown'; - } -}; - -const BackgroundManageHint: React.FC = () => ( - (↓ to manage) -); - -const MAX_TOOL_CALLS = 5; -const MAX_VERBOSE_TOOL_CALLS = 12; -const MAX_TASK_PROMPT_LINES = 5; -const DEFAULT_DETAIL_HEIGHT = 18; - -// Approximate fixed-row cost of the default/verbose layout, derived from the -// JSX structure below: 1 header + (1 "Task Detail:" label + 1 internal gap + -// optional 1 "...N task lines hidden..." footer) + (1 "Tools:" label + 1 -// marginBottom) + 1 footer + 3 inter-section gaps. We subtract this from the -// parent-provided `availableHeight` so the budget for the prompt and -// tool-call lists actually fits inside the assigned frame. -const RUNNING_FIXED_OVERHEAD = 10; -// In completed/cancelled/failed mode we lose the running footer but gain the -// ExecutionSummary block (header + 3 rows) and the ToolUsage block (header + -// up to 2 wrapped rows) plus an extra inter-block gap, so the overhead grows. -// Calibrated against the running→completed transition test: assigning <22 -// here lets the completed expanded frame edge past availableHeight when the -// SubAgent finishes mid-expand. -const COMPLETED_FIXED_OVERHEAD = 22; -// "Status icon + name + description" + "truncated output" — each tool call -// commits two visual rows in default/verbose mode. -const ROWS_PER_TOOL_CALL = 2; - -function truncateToVisualWidth(text: string, maxWidth: number): string { - const visualWidth = Math.max(1, Math.floor(maxWidth)); - const ellipsis = '...'; - const ellipsisWidth = getCachedStringWidth(ellipsis); - let currentWidth = 0; - let result = ''; - - for (const char of toCodePoints(text)) { - const charWidth = Math.max(getCachedStringWidth(char), 1); - if (currentWidth + charWidth > visualWidth) { - const availableWidth = Math.max(0, visualWidth - ellipsisWidth); - let trimmed = ''; - let trimmedWidth = 0; - for (const trimmedChar of toCodePoints(result)) { - const trimmedCharWidth = Math.max(getCachedStringWidth(trimmedChar), 1); - if (trimmedWidth + trimmedCharWidth > availableWidth) { - break; - } - trimmed += trimmedChar; - trimmedWidth += trimmedCharWidth; - } - return trimmed + ellipsis; - } - - result += char; - currentWidth += charWidth; - } - - return result; -} - -/** - * Component to display subagent execution progress and results. - * This is now a pure component that renders the provided SubagentExecutionResultDisplay data. - * Real-time updates are handled by the parent component updating the data prop. - */ -export const AgentExecutionDisplay: React.FC = ({ - data, - availableHeight, - childWidth, - config, - isFocused = true, - isWaitingForOtherApproval = false, -}) => { - const [displayMode, setDisplayMode] = React.useState('compact'); - const detailHeight = Math.max( - 4, - Math.floor(availableHeight ?? DEFAULT_DETAIL_HEIGHT), - ); - // Treat `availableHeight` as the *total* component budget. Subtract the - // fixed overhead (header, section labels, gaps, footer/result block) before - // splitting the remainder between the prompt preview and the tool-call - // list. This guarantees the rendered frame doesn't grow past the budget the - // parent layout assigned us, which is the precondition for Ink to keep the - // SubAgent display inside its static slot instead of clearing+redrawing. - const fixedOverhead = - data.status === 'running' - ? RUNNING_FIXED_OVERHEAD - : COMPLETED_FIXED_OVERHEAD; - const renderableBudget = Math.max(2, detailHeight - fixedOverhead); - // Prompt gets ~1/3 of the remainder, tool-call list gets the rest. Both are - // clamped to >=1 so we always render at least one of each kind, even in - // pathological "availableHeight smaller than overhead" cases. - const promptBudget = Math.max(1, Math.floor(renderableBudget / 3)); - const toolBudget = Math.max( - 1, - Math.floor((renderableBudget - promptBudget) / ROWS_PER_TOOL_CALL), - ); - const maxTaskPromptLines = - displayMode === 'verbose' - ? Math.min(8, promptBudget) - : Math.min(MAX_TASK_PROMPT_LINES, promptBudget); - const maxToolCalls = - displayMode === 'verbose' - ? Math.min(MAX_VERBOSE_TOOL_CALLS, toolBudget) - : Math.min(MAX_TOOL_CALLS, toolBudget); - - const agentColor = useMemo(() => { - const colorOption = COLOR_OPTIONS.find( - (option) => option.name === data.subagentColor, - ); - return colorOption?.value || theme.text.accent; - }, [data.subagentColor]); - - // Slice the prompt once at the parent so the rendered TaskPromptSection - // and the footer's "ctrl+f to show more" hint share the same source of - // truth. Counting `data.taskPrompt.split('\n').length` would only see hard - // newlines and miss soft-wrapped overflow, so a long single-line prompt - // could be visually truncated without surfacing the hint. - const promptChildWidth = Math.max(1, childWidth - 2); - const slicedPrompt = useMemo( - () => - sliceTextByVisualHeight( - data.taskPrompt, - maxTaskPromptLines, - promptChildWidth, - { minHeight: 1, overflowDirection: 'bottom' }, - ), - [data.taskPrompt, maxTaskPromptLines, promptChildWidth], - ); - - const footerText = React.useMemo(() => { - // This component only listens to keyboard shortcut events when the subagent is running - if (data.status !== 'running') return ''; - - if (displayMode === 'default') { - const hasMoreLines = slicedPrompt.hiddenLinesCount > 0; - const hasMoreToolCalls = - data.toolCalls && data.toolCalls.length > maxToolCalls; - - if (hasMoreToolCalls || hasMoreLines) { - return 'Press ctrl+e to show less, ctrl+f to show more.'; - } - return 'Press ctrl+e to show less.'; - } - - if (displayMode === 'verbose') { - return 'Press ctrl+f to show less.'; - } - - return ''; - }, [ - displayMode, - data.status, - data.toolCalls, - slicedPrompt.hiddenLinesCount, - maxToolCalls, - ]); - - // Handle keyboard shortcuts to control display mode. Scope the listener to - // the running subagent that currently holds focus — `data.status` rules - // out completed/historical instances mounted in scrollback, and - // `isFocused` rules out *parallel* running subagents that share the live - // viewport. Without the focus gate, two SubAgents running side by side - // would both toggle on a single Ctrl+E / Ctrl+F press and the resulting - // dual-reflow brings back the flicker this component is meant to - // prevent. - useKeypress( - (key) => { - if (key.ctrl && key.name === 'e') { - // ctrl+e toggles between compact and default - setDisplayMode((current) => - current === 'compact' ? 'default' : 'compact', - ); - } else if (key.ctrl && key.name === 'f') { - // ctrl+f toggles between default and verbose - setDisplayMode((current) => - current === 'default' ? 'verbose' : 'default', - ); - } - }, - { isActive: data.status === 'running' && isFocused }, - ); - - if (displayMode === 'compact') { - return ( - - {/* Header: Agent name and status */} - {!data.pendingConfirmation && ( - - - {data.subagentName} - - - - {data.status === 'background' && } - - )} - - {/* Running state: Show current tool call and progress */} - {data.status === 'running' && ( - <> - {/* Current tool call */} - {data.toolCalls && data.toolCalls.length > 0 && ( - - - {/* Show count of additional tool calls if there are more than 1 */} - {data.toolCalls.length > 1 && !data.pendingConfirmation && ( - - - +{data.toolCalls.length - 1} more tool calls (ctrl+e to - expand) - - - )} - - )} - - {/* Inline approval prompt when awaiting confirmation */} - {data.pendingConfirmation && ( - - {isWaitingForOtherApproval && ( - - - ⏳ Waiting for other approval... - - - )} - - - )} - - )} - - {/* Completed state: Show summary line */} - {data.status === 'completed' && data.executionSummary && ( - - - Execution Summary: {data.executionSummary.totalToolCalls} tool - uses · {data.executionSummary.totalTokens.toLocaleString()} tokens - · {fmtDuration(data.executionSummary.totalDurationMs)} - - - )} - - {/* Failed/Cancelled state: Show error reason */} - {data.status === 'failed' && ( - - - Failed: {data.terminateReason} - - - )} - - ); - } - - // Default and verbose modes use normal layout - return ( - - {/* Header with subagent name and status */} - - - {data.subagentName} - - - - {data.status === 'background' && } - - - {/* Task description */} - - - {/* Progress section for running tasks */} - {data.status === 'running' && - data.toolCalls && - data.toolCalls.length > 0 && ( - - - - )} - - {/* Inline approval prompt when awaiting confirmation */} - {data.pendingConfirmation && ( - - {isWaitingForOtherApproval && ( - - - ⏳ Waiting for other approval... - - - )} - - - )} - - {/* Results section for completed/failed tasks */} - {(data.status === 'completed' || - data.status === 'failed' || - data.status === 'cancelled') && ( - - )} - - {/* Footer with keyboard shortcuts */} - {footerText && ( - - {footerText} - - )} - - ); -}; - -/** - * Task prompt section. Receives the already-sliced prompt from the parent so - * footer hint and section content share one source of truth for whether - * content was hidden (covers soft-wrapped overflow in addition to explicit - * newlines). - */ -const TaskPromptSection: React.FC<{ - slicedPrompt: { text: string; hiddenLinesCount: number }; - displayMode: DisplayMode; - maxVisualLines: number; -}> = ({ slicedPrompt, displayMode, maxVisualLines }) => { - const shouldTruncate = slicedPrompt.hiddenLinesCount > 0; - - return ( - - - Task Detail: - {shouldTruncate && displayMode !== 'compact' && ( - - {' '} - Showing the first {maxVisualLines} visual lines. - - )} - - - {slicedPrompt.text} - - {slicedPrompt.hiddenLinesCount > 0 && ( - - - ... last {slicedPrompt.hiddenLinesCount} task line - {slicedPrompt.hiddenLinesCount === 1 ? '' : 's'} hidden ... - - - )} - - ); -}; - -/** - * Status dot component with similar height as text - */ -const StatusDot: React.FC<{ - status: AgentResultDisplay['status']; -}> = ({ status }) => ( - - - -); - -/** - * Status indicator component - */ -const StatusIndicator: React.FC<{ - status: AgentResultDisplay['status']; -}> = ({ status }) => { - const color = getStatusColor(status); - const text = getStatusText(status); - return {text}; -}; - -/** - * Tool calls list - format consistent with ToolInfo in ToolMessage.tsx - */ -const ToolCallsList: React.FC<{ - toolCalls: AgentResultDisplay['toolCalls']; - displayMode: DisplayMode; - maxToolCalls: number; - childWidth: number; -}> = ({ toolCalls, displayMode, maxToolCalls, childWidth }) => { - const calls = toolCalls || []; - const displayLimit = Math.max(1, Math.floor(maxToolCalls)); - const shouldTruncate = calls.length > displayLimit; - const displayCalls = calls.slice(-displayLimit); - - // Reverse the order to show most recent first - const reversedDisplayCalls = [...displayCalls].reverse(); - - return ( - - - Tools: - {shouldTruncate && displayMode !== 'compact' && ( - - {' '} - Showing the last {displayCalls.length} of {calls.length} tools. - - )} - - {reversedDisplayCalls.map((toolCall, index) => ( - - ))} - - ); -}; - -/** - * Individual tool call item - consistent with ToolInfo format - */ -const ToolCallItem: React.FC<{ - toolCall: { - name: string; - status: 'executing' | 'awaiting_approval' | 'success' | 'failed'; - error?: string; - args?: Record; - result?: string; - resultDisplay?: string; - description?: string; - }; - compact?: boolean; - childWidth?: number; -}> = ({ toolCall, compact = false, childWidth = 80 }) => { - const STATUS_INDICATOR_WIDTH = 3; - const textWidth = Math.max(8, childWidth - STATUS_INDICATOR_WIDTH - 1); - - // Map subagent status to ToolCallStatus-like display - const statusIcon = React.useMemo(() => { - const color = getStatusColor(toolCall.status); - switch (toolCall.status) { - case 'executing': - return ; // Using same as ToolMessage - case 'awaiting_approval': - return ?; - case 'success': - return ; - case 'failed': - return ( - - x - - ); - default: - return o; - } - }, [toolCall.status]); - - const description = React.useMemo(() => { - if (!toolCall.description) return ''; - const firstLine = toolCall.description.split('\n')[0]; - return truncateToVisualWidth(firstLine, textWidth); - }, [toolCall.description, textWidth]); - - // Get first line of resultDisplay for truncated output - const truncatedOutput = React.useMemo(() => { - if (!toolCall.resultDisplay) return ''; - const firstLine = toolCall.resultDisplay.split('\n')[0]; - return truncateToVisualWidth(firstLine, textWidth); - }, [toolCall.resultDisplay, textWidth]); - - return ( - - {/* First line: status icon + tool name + description (consistent with ToolInfo) */} - - {statusIcon} - - {toolCall.name}{' '} - {description} - {toolCall.error && ( - - {toolCall.error} - )} - - - - {/* Second line: truncated returnDisplay output - hidden in compact mode */} - {!compact && truncatedOutput && ( - - {truncatedOutput} - - )} - - ); -}; - -/** - * Execution summary details component - */ -const ExecutionSummaryDetails: React.FC<{ - data: AgentResultDisplay; - displayMode: DisplayMode; -}> = ({ data, displayMode: _displayMode }) => { - const stats = data.executionSummary; - - if (!stats) { - return ( - - • No summary available - - ); - } - - return ( - - - • Duration: {fmtDuration(stats.totalDurationMs)} - - - • Rounds: {stats.rounds} - - - • Tokens: {stats.totalTokens.toLocaleString()} - - - ); -}; - -/** - * Tool usage statistics component - */ -const ToolUsageStats: React.FC<{ - executionSummary?: AgentStatsSummary; -}> = ({ executionSummary }) => { - if (!executionSummary) { - return ( - - • No tool usage data available - - ); - } - - return ( - - - • Total Calls: {executionSummary.totalToolCalls} - - - • Success Rate:{' '} - - {executionSummary.successRate.toFixed(1)}% - {' '} - ( - - {executionSummary.successfulToolCalls} success - - ,{' '} - - {executionSummary.failedToolCalls} failed - - ) - - - ); -}; - -/** - * Results section for completed executions - matches the clean layout from the image - */ -const ResultsSection: React.FC<{ - data: AgentResultDisplay; - displayMode: DisplayMode; - maxToolCalls: number; - childWidth: number; -}> = ({ data, displayMode, maxToolCalls, childWidth }) => ( - - {/* Tool calls section - clean list format */} - {data.toolCalls && data.toolCalls.length > 0 && ( - - )} - - {/* Execution Summary section - hide when cancelled */} - {data.status === 'completed' && ( - - - Execution Summary: - - - - )} - - {/* Tool Usage section - hide when cancelled */} - {data.status === 'completed' && data.executionSummary && ( - - - Tool Usage: - - - - )} - - {/* Error reason for failed tasks */} - {data.status === 'cancelled' && ( - - ⏹ User Cancelled - - )} - {data.status === 'failed' && ( - - Task Failed: - {data.terminateReason} - - )} - -); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 4022b98ba..d62d21c68 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -16,6 +16,7 @@ import { BtwMessage } from '../components/messages/BtwMessage.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js'; import { AgentComposer } from '../components/agent-view/AgentComposer.js'; +import { LiveAgentPanel } from '../components/background-view/LiveAgentPanel.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAgentViewState } from '../contexts/AgentViewContext.js'; @@ -100,6 +101,34 @@ export const DefaultAppLayout: React.FC = () => { )} + {/* + 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. + + The panel renders INSIDE `mainControlsRef` so its rows + are picked up by `measureElement` in `AppContainer`'s + `controlsHeight` useLayoutEffect — `availableTerminalHeight` + then subtracts the panel's footprint and pending history + items in MainContent stop racing it for screen real + estate. (Pre-fix: the panel rendered outside the ref, + long Read/Bash output could push the composer + panel + off-screen — a regression vs PR #3768 which suppressed + the inline frame in the live phase.) + + Panel uses `terminalWidth`, not `mainAreaWidth` — + `mainAreaWidth` is hard-capped at 100 cols (intended + for markdown / code blocks where soft-wrap matters); + live progress lines have nothing to soft-wrap, so the + panel wants the full terminal width. + */} + {!uiState.dialogsVisible && ( + + )} )} diff --git a/packages/core/src/subagents/builtin-agents.ts b/packages/core/src/subagents/builtin-agents.ts index 8ddd8f2c0..74102c80a 100644 --- a/packages/core/src/subagents/builtin-agents.ts +++ b/packages/core/src/subagents/builtin-agents.ts @@ -7,6 +7,15 @@ import { ToolDisplayNames, ToolNames } from '../tools/tool-names.js'; import type { SubagentConfig } from './types.js'; +/** + * Canonical name of the default builtin subagent. Exported so UI + * surfaces (e.g. `LiveAgentPanel`'s default-type elision) can compare + * against the same source of truth instead of redeclaring the literal + * — a rename here would otherwise silently break "skip the type + * prefix when it's the default" logic. + */ +export const DEFAULT_BUILTIN_SUBAGENT_TYPE = 'general-purpose'; + /** * Registry of built-in subagents that are always available to all users. * These agents are embedded in the codebase and cannot be modified or deleted. @@ -16,7 +25,7 @@ export class BuiltinAgentRegistry { Omit > = [ { - name: 'general-purpose', + name: DEFAULT_BUILTIN_SUBAGENT_TYPE, description: 'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.', systemPrompt: `You are a general-purpose agent. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials. diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index c05c38697..8194798db 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -26,7 +26,10 @@ export type { export { SubagentError } from './types.js'; // Built-in agents registry -export { BuiltinAgentRegistry } from './builtin-agents.js'; +export { + BuiltinAgentRegistry, + DEFAULT_BUILTIN_SUBAGENT_TYPE, +} from './builtin-agents.js'; // Validation system export { SubagentValidator } from './validation.js';