From f5bef6c5dd34d9acbcc3f5ee522bf52c83decca2 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 7 May 2026 21:38:18 +0800 Subject: [PATCH] feat(cli): replace inline AgentExecutionDisplay with always-on LiveAgentPanel (#3909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): replace inline AgentExecutionDisplay with always-on LiveAgentPanel Surface running subagents in a borderless, always-on roster anchored beneath the input footer (mirrors Claude Code's CoordinatorTaskPanel) and retire the verbose inline `AgentExecutionDisplay` frame whose per-tool-call mutations caused scrollback flicker. Detail / cancel / resume keep flowing through the existing BackgroundTasksDialog. LiveAgentPanel: - Two-column row: status icon + (optional) type + description + activity on the left (truncate-end), elapsed + tokens on the right in a flex-shrink:0 column so the cost/time fields are never hidden by long descriptions. - Re-pulls each agent from BackgroundTaskRegistry on every wall-clock tick so `recentActivities` stays fresh — the snapshot from `useBackgroundTaskView` only refreshes on `statusChange` to keep the footer pill / AppContainer quiet under heavy tool traffic. - Reaches for Config via raw ConfigContext (not useConfig) so the panel degrades to snapshot-only when no provider is mounted (test isolation). - Hides when any dialog is visible (auth / permission / bg tasks) and self-hides when no agent entries are live. - Drops `subagentType` from the row when it is the default `general-purpose` builtin to keep the line uncluttered; specialized types still bold-anchor the row. - Keeps terminal entries on screen for 8s so the user gets feedback when an agent finishes, then they fall off (BackgroundTasksDialog retains them long-term). Inline frame retirement: - ToolMessage's SubagentExecutionRenderer collapses to the focus- routed approval surfaces only (focus-holder banner + queued marker). All other agent state is owned by the panel + dialog. - AgentExecutionDisplay.tsx + test removed (-918 lines); the subagents/index export is dropped with a pointer to the new surfaces. Net diff: +97 / -1069. * fix(cli): move elapsed + tokens to the front of LiveAgentPanel rows The two-column layout used `flex-grow:1` on the left description column, which puffed it out to fill the row even when content was short — leaving a visible gap between the description tail and the right-pinned elapsed/tokens whenever the terminal was wider than the content. Worse, the gap made it look like display space was being wasted while the description still got truncated. Move elapsed + tokens to the front of the row (right after the status icon) so: - Time and cost are pinned at a stable left position and are NEVER at risk of being truncated, regardless of description / activity length. - The row reads as one tight left-to-right line — no flex-grow, no internal gap. On wide terminals the unused width sits at the row tail (invisible), where it belongs. - Description + activity become the truncatable tail; `truncate-end` cuts only when the line genuinely overflows the panel width. Also wrap the icon-plus-spaces span in a template literal so the two spaces of breathing room after the glyph survive a prettier pass. Verified at 60 / 100 / 200 cols: at 200 cols the row renders flush with no trailing ellipsis and no internal gap; at 60 cols the time + tokens stay at the front and the description tail truncates with `…`. * fix(cli): switch LiveAgentPanel row to layout C (right-pinned, no flex-grow) Iterating on the row layout based on visual review: - Layout A (time first, single Text) put numbers ahead of identity, which broke the natural left-to-right reading order. - Layout B (right-pinned with flex-grow:1 on left) puffed the left column out to fill the row, leaving a visible gap between the description tail and the right-pinned elapsed when the terminal was wider than the content. Layout C keeps the right column flex-shrink:0 so elapsed + tokens are never clipped, but DROPS flex-grow on the left so the two columns sit side-by-side: empty slack falls off the row tail (invisible) instead of opening a gap inside the row. Identity (type) and intent (description / activity) read first, cost reads last — matching the natural visual hierarchy. When the row overflows the panel width the left column truncates with `…` mid-row, while elapsed + tokens stay intact. Verified at 60 / 100 / 200 cols — at 200 cols there is no internal gap and no trailing ellipsis; at 60 cols time + tokens stay visible on the right and the description / activity tail truncates with `…`. * fix(cli): port Claude Code's bullet + arrow visual to LiveAgentPanel rows Adopt the leaked CoordinatorTaskPanel visual conventions: - `○` replaces `⊷` for live (running) slots — matches Claude Code's use of `figures.circle` for the active-agent bullet, gives a uniform list look across the running roster. Terminal states keep distinct check / cross marks (✔ / ✖) so they're easy to scan at a glance. - `▶` separates the description from elapsed / tokens, mirroring Claude's `PLAY_ICON` suffix marker. - Activity is wrapped in `( ... )` so it reads as an annotation on the description rather than a sibling field, and the type prefix switches from ` · ` to `: ` (e.g. `editor: tighten import order`) to match Claude's `name: description` pattern. The two-column flex layout from layout C is preserved — left column flex-shrink:1 with truncate-end, right column (` ▶ Ns · Nk tokens`) flex-shrink:0 so elapsed + tokens are never clipped, regardless of how long the description / activity grows. This is the one intentional divergence from Claude's literal pattern, which puts elapsed at the row tail without pinning and lets it disappear off narrow terminals. Verified at 60 / 100 / 200 cols: at 200 cols the row is flush with no internal gap; at 60 cols the description / activity tail truncates with `…` while elapsed + tokens stay visible on the right. * fix(cli): widen LiveAgentPanel, drop [in turn] marker, point overflow at dialog Three usability fixes from review: 1. Use `terminalWidth` instead of `mainAreaWidth`. The latter is capped at 100 cols (intended for markdown / code where soft-wrap matters), which on a 200-col terminal left half the screen empty to the right of an already-truncating row. Live progress lines have nothing to soft-wrap, so the panel wants the full width. 2. Drop the `[in turn]` foreground marker. The flavor distinction matters in BackgroundTasksDialog (cancel semantics differ for foreground vs background entries) but in the glance panel the marker reads as cryptic noise — users asked what it meant. Keep the dialog as the surface that surfaces it. 3. Annotate the overflow callout with `(↓ to view all)`. The panel is intentionally read-only (it has no keyboard focus so it can't steal input from the composer), so when the roster outgrows the row budget we point users at the existing dialog — same keystroke the footer pill uses, kept in sync so users only learn one gesture. * fix(cli): make Down on focused BackgroundTasksPill open the dialog The focus chain Composer → AgentTabBar → BackgroundTasksPill is walked with the Down arrow, but Down dead-ended at the pill — the pill only opened the dialog on Enter. Users who followed the LiveAgentPanel's "(↓ to view all)" overflow callout reached the highlighted pill and got stuck there, defeating the hint. Route Down on the focused pill into openDialog so the chain completes naturally: Composer ↓ → AgentTabBar ↓ → Pill ↓ → Dialog. Enter still works, so existing muscle memory keeps functioning. * fix(cli): address Copilot review on LiveAgentPanel — interval gate, ghost rows, dead doc Three findings from Copilot's PR review: 1. **1s interval kept ticking forever after expiry.** The gate was `entries.some(isAgentEntry)`, but `BackgroundTaskRegistry.getAll()` retains terminal entries indefinitely — once the last visible row passed its 8s window the panel returned null but the interval kept firing setNow each second, churning re-renders for nothing on screen. The gate now considers visibility (running / paused OR terminal-within-window) and the interval clears itself once the condition flips false. New entries restart the interval via the `entries` dep. 2. **Ghost rows when registry forgets an entry.** The live re-pull fell back to the snapshot when `registry.get()` returned undefined. The canonical case is a foreground subagent that unregisters silently after its statusChange fires (`unregisterForeground` deletes without emitting a follow-up transition) — the snapshot still says `running`, so the row would never clear. Trust the registry: when it says the entry is gone, drop the row. The snapshot-only fallback is preserved for the no-Config case (test fixtures). 3. **Dead doc reference.** The trailing comment in `subagents/index.ts` pointed at `docs/comparison/subagent-display-deep-dive.md`, which doesn't exist in this repo (it lives in the codeagents knowledge base, not qwen-code). Dropped the dead pointer; the in-tree pointers to `LiveAgentPanel` and `BackgroundTasksDialog` already tell readers where to look. Coverage delta: +2 cases on `LiveAgentPanel.test.tsx` — `drops snapshot rows the live registry no longer knows about` (issue #2) and `still shows the snapshot when no Config is mounted` (locks in the test-fixture fallback that issue #2's stricter rule would otherwise have broken). * fix(cli): address Copilot second-pass review — dialogOpen tick gate, isFocused doc Two further findings from Copilot: 1. **Interval kept ticking while bg-tasks dialog was open.** The first-pass fix already torn the tick down once all rows expired from the visibility window, but `dialogOpen` was a separate reason the panel returned null and was missed by the gate. Add `dialogOpen` to the useEffect deps and short-circuit when true, so the dialog's tenure is interval-free. 2. **Stale `isFocused` doc comment in `ToolMessageProps`.** The comment claimed the prop controlled the now-retired `Ctrl+E / Ctrl+F` display shortcuts (those died with the inline `AgentExecutionDisplay` frame). Rewrite the comment to describe the only remaining behavior — the focus-routed approval surface (focus-holder banner vs. queued-sibling marker). Coverage delta: +1 case on `LiveAgentPanel.test.tsx` — `tears the 1s tick down when the bg-tasks dialog opens` advances 60s of fake time with `dialogOpen=true` and asserts no panel state drift. * fix(cli): reconcile registry-missing snapshots as just-finished + address review nits Hot-fix: the previous round's "drop the row when registry.get() returns undefined" was too aggressive. `unregisterForeground` calls `emitStatusChange(entry)` BEFORE it deletes the entry, so the snapshot useBackgroundTaskView captures still says "running" while the very next render's registry.get sees nothing. Dropping the row outright made foreground subagents disappear from the panel the instant they finished — users saw "SubAgents 不显示了" on tasks that ran-and-immediately-completed. Reconciliation now has three branches: 1. live found → use live (newest recentActivities). 2. snap says still-live but registry forgot → synthesize a terminal version with endTime pinned to the FIRST observation so the 8s visibility window gives the user a "the agent finished" beat then evicts cleanly. The first-seen-missing timestamp is held in a useRef map (without it, each tick resets endTime to `now` and the row never expires). 3. snap is already terminal but registry forgot → drop (no useful state to keep showing). Also addresses three smaller review notes (deepseek-v4-pro via /review): - DEFAULT_SUBAGENT_TYPE is now imported from @qwen-code/qwen-code-core (a new DEFAULT_BUILTIN_SUBAGENT_TYPE export referenced by both BuiltinAgentRegistry's seed entry and the panel's default-type elision). A backend rename now propagates instead of silently re-introducing the redundant `general-purpose:` prefix on every row. - The useMemo body now reads `now` (as `reconcileAt`) so the dependency is semantically honest — a future "remove dead dep" cleanup can no longer silently freeze the panel on the first tool-call after a snapshot refresh. Coverage delta: +5 cases on LiveAgentPanel.test.tsx — token rendering on completed entries, status-icon routing for paused / failed / cancelled (parametrized), case-insensitive prefix stripping in descriptionWithoutPrefix, plus the rewritten ghost-row case (synthesized terminal lingers 8s then evicts) and a sibling case asserting already-terminal snapshots with empty registry still drop. 17 LiveAgentPanel tests pass. * refactor(cli): split LiveAgentPanelBody + drop dead isPending/isWaitingForOtherApproval props Two structural reviews from deepseek-v4-pro via /review: 1. **Hook-order footgun.** The `if (dialogOpen) return null` guard sat between the hook block and a substantial block of pure-render code; an extension that added `useMemo`/`useRef`/`useCallback` below the guard would crash with "Rendered fewer hooks than expected" the next time `dialogOpen` toggled. Extract the pure- render logic into `LiveAgentPanelBody` so the guard becomes the parent's last statement, and any future "add a hook to the body" refactor naturally lands in the inner component (hook-free today, hook-free for as long as it stays a presentational FC). 2. **Dead pass-through removed.** `isPending` and `isWaitingForOtherApproval` were dead in the subagent renderer (LiveAgentPanel + BackgroundTasksDialog own that surface) but ToolGroupMessage still computed `isWaitingForOtherApproval` and forwarded both into ToolMessage. Drop them from `ToolMessageProps`, drop the computation + forwarding in ToolGroupMessage, drop the test factory references. ToolGroupMessage keeps `isPending` on its own props for upstream caller compatibility (HistoryItemDisplay et al. forward it) but stops destructuring it; the doc comment now points at the surfaces that own that gating today. Coverage: existing 115 cases across LiveAgentPanel / BackgroundTasksDialog / BackgroundTasksPill / ToolMessage / ToolGroupMessage all green; the panel split is purely structural and the dead-prop removal was verified TS-clean before commit. * fix(cli): map internal tool names to user-facing display names in LiveAgentPanel rows `recentActivities[].name` carries the internal tool name from AgentToolCallEvent (e.g. `run_shell_command`, `glob`). Rendering it verbatim surfaced raw identifiers in the panel (`run_shell_command rg TODO`) while BackgroundTasksDialog already mapped through ToolDisplayNames to show user-facing names (`Shell rg TODO`) — the two surfaces' vocabularies drifted on the same data. Mirror the dialog's `TOOL_DISPLAY_BY_NAME` lookup so the panel and dialog speak the same vocabulary. Added a test asserting `run_shell_command` renders as `Shell` and the raw name is not surfaced. Coverage delta: +1 case on LiveAgentPanel.test.tsx (18 total). * fix(cli): keep already-terminal snapshots visible until TTL + close 4 test gaps 1. **Terminal snap + missing registry now follows the visibility window.** 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 previous "drop on missing registry" branch made cancelled / failed foreground tasks disappear instantly — contradicting the panel's "brief terminal visibility" contract that the synthesized-completion path also relies on. Keep the snap as-is when `endTime` is set; the visibleAgents filter evicts it after `TERMINAL_VISIBLE_MS` like any other terminal entry. Defensive fallback drops the pathological "terminal status with no endTime" shape. (Copilot finding on PRRT_kwDOPB-92c6AS4z9.) 2. **Close 4 test gaps flagged by tanzhenxin's review:** - `elides the default 'general-purpose' subagent type from the row` locks in DEFAULT_BUILTIN_SUBAGENT_TYPE comparison. - `truncates the description tail when the panel width is too narrow` exercises the `width` prop (existing cases ignored it) and anchors on the right-pinned `▶ 3s` tail staying intact. - `clears the 1s tick interval when unmounted with live work in flight` spies on setInterval / clearInterval so a discarded fiber can't keep firing setNow. - `keeps terminal snapshots visible until the TTL even when the registry forgot them` covers the cancelled / failed foreground reconciliation path with a `✖` glyph + 9s eviction assertion. Coverage: 22 LiveAgentPanel tests pass (was 18). All TS clean. * refactor(cli): rename FOREGROUND_ROW_PREFIX from `[in turn]` to `[blocking]` User feedback: `[in turn]` reads as "queued / sequential" — the opposite of what it actually means (the row is blocking the user's current turn). Even maintainers had to chase the source comment to confirm the semantics, which is a strong signal that end users are not getting the warning the prefix is supposed to deliver. `[blocking]` reads more directly: "this row is what's holding up your input", which is what cancelling it actually unblocks. Update the in-row prefix and the cancel-confirmation hint in lockstep (`x again to confirm stop · ends the blocking turn`) so the two surfaces share vocabulary. LiveAgentPanel still suppresses the marker in its row (the panel has no cancel surface, so the warning has nothing actionable to pair with); update the historical comment + reinforce the test guard so neither the legacy `[in turn]` nor the new `[blocking]` bleeds into the glance roster. * fix(cli): address 4 review findings — height budget, neutral synthesis glyph, stale comments 1. **`availableTerminalHeight` regression** (claude-opus-4-7 via /qreview, DefaultAppLayout.tsx:127). LiveAgentPanel rendered OUTSIDE the `mainControlsRef` Box, so its 2-7 rows were not measured by `controlsHeight` and not subtracted from `availableTerminalHeight = terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight` in AppContainer. Pending tool results in MainContent could render past the visible area and push the composer / panel off-screen — a regression vs PR #3768 which suppressed the inline frame in the live phase. Move the panel INSIDE the `mainControlsRef` Box so `measureElement` picks up its rows automatically; no new infrastructure needed. 2. **Neutral glyph for synthesized terminal rows** (claude-opus-4-7, LiveAgentPanel.tsx:278). The synthesis branch hardcoded `status: 'completed'` regardless of the actual outcome. Foreground subagents that errored or were cancelled were rendered with the green ✔ for 8s — directly contradicting the inline tool result the user just saw (`Subagent execution failed.` / `Agent was cancelled by the user.`). Add a `synthesized` flag on the synthesized rows; `statusIcon` checks it first and returns a neutral `·` + secondary color, so the panel never lies about an outcome it cannot determine. Status stays `'completed'` purely so the visibility-window filter treats the row as terminal. 3. **PR description claim retired** (Copilot, ToolMessage.tsx:411). The body's Reviewer Notes section claimed `isPending` / `isWaitingForOtherApproval` were kept on `ToolMessageProps` as vestigial pass-through; the props were actually removed in commit 93036b1d0. Will update the PR description in a follow-up non-code edit (the comment thread itself is on now-outdated line 411). 4. **Stale comment in ToolGroupMessage** (Copilot, ToolGroupMessage.tsx:303). The comment above the focus-routing logic referenced the retired Ctrl+E / Ctrl+F display shortcuts. Rewrite it to describe the current behavior — focus routing for inline approval prompts only — and call out where the live progress / drill-down moved (LiveAgentPanel + BackgroundTasksDialog). Coverage delta: existing `reconciles snapshots…` test rewritten to assert `·` instead of `✔` (and explicitly assert `not.toContain('✔')`); new sibling `keeps the success glyph for entries the registry still tracks (non-synthesized)` locks the non-synthesis path against a future regression that always returns the neutral glyph. 68 background-view tests + 128 messages/layouts tests pass. * fix(cli): ANSI-escape user-controlled strings + restore one-line scrollback summary Two findings from deepseek-v4-pro via /review: 1. **ANSI sanitization on the panel** (LiveAgentPanel.tsx:368). The row was rendering `entry.subagentType`, `descriptionWithoutPrefix` output, and `recentActivities[].description` straight through Ink's `` — both subagent config (user-authored) and tool-call description (LLM-generated) can contain terminal control sequences that bleed through and corrupt the panel chrome. Apply `escapeAnsiCtrlCodes` to all three (HistoryItemDisplay does the same on its user-facing content for the same reason). 2. **One-line scrollback summary for terminal subagents** (ToolMessage.tsx:295). The previous round retired the verbose inline frame entirely and SubagentExecutionRenderer returned null for all non-approval states. The result: completed subagent results disappeared from scrollback the moment LiveAgentPanel's 8s window expired. Reopening a session or dismissing the dialog left no record. Add a single-line `SubagentScrollbackSummary` for completed / failed / cancelled states — ` : · N tools · Xs · Yk tokens` (plus terminateReason on non-success). One row per agent, no flicker risk; the verbose frame stays retired. Coverage: +3 cases — `escapes ANSI control codes in user-controlled strings` (LiveAgentPanel), `completed subagent → renders a one-line scrollback summary` and `failed subagent → renders summary with terminate reason` (ToolMessage). 24 + 34 panel/ToolMessage tests pass; broader 2894-test cli/ui sweep clean. Note: this restores some inline rendering that the original feature choice ("inline AgentExecutionDisplay 完全移除") removed entirely. The compromise — one line per terminal agent vs the old 15-row frame — preserves history while keeping the flicker fix that motivated the retirement. * fix(cli): paused agents count as active in tally + sync reconciliation comment Two findings from Copilot: 1. **Header tally now matches what the user sees.** The numerator was `runningCount` (status === 'running'), but the panel also renders paused entries as active rows (warning color, ⏸ glyph). With only paused agents present the header read "(0/1)" while the row was clearly visible — a confusing mismatch. Rename to `activeCount` and include paused in the count. New test `counts paused agents as active in the header tally` locks the tally + glyph in. 2. **Reconciliation block comment now describes the four real paths**, not the older three: 1. live found → use live 2. snap still-live + registry forgot → synthesize neutral terminal 3. snap terminal + endTime present → keep snap, let TTL filter evict it (the cancelled / failed foreground path the previous round added) 4. snap terminal + no endTime → drop (upstream invariant violation) Comment is now in lockstep with the implementation; the prior "drop terminal-with-empty-registry outright" wording was stale after the TTL-keep change. Coverage: 25 LiveAgentPanel tests pass (was 24). --- .../background-view/BackgroundTasksDialog.tsx | 15 +- .../background-view/BackgroundTasksPill.tsx | 9 +- .../background-view/LiveAgentPanel.test.tsx | 657 ++++++++++++++++ .../background-view/LiveAgentPanel.tsx | 521 +++++++++++++ .../components/messages/ToolGroupMessage.tsx | 33 +- .../components/messages/ToolMessage.test.tsx | 165 ++-- .../ui/components/messages/ToolMessage.tsx | 199 +++-- .../cli/src/ui/components/subagents/index.ts | 5 +- .../runtime/AgentExecutionDisplay.test.tsx | 193 ----- .../runtime/AgentExecutionDisplay.tsx | 725 ------------------ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 29 + packages/core/src/subagents/builtin-agents.ts | 11 +- packages/core/src/subagents/index.ts | 5 +- 13 files changed, 1440 insertions(+), 1127 deletions(-) create mode 100644 packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx create mode 100644 packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx delete mode 100644 packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.test.tsx delete mode 100644 packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx diff --git a/packages/cli/src/ui/components/background-view/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';