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

* 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
   `<Text>` — 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 — `<icon> <name>:
   <description> · 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).
This commit is contained in:
Shaojin Wen 2026-05-07 21:38:18 +08:00 committed by GitHub
parent fc1ba5751c
commit f5bef6c5dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1440 additions and 1127 deletions

View file

@ -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<BackgroundTasksDialogProps> = ({
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') {

View file

@ -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);

View file

@ -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> = {},
): AgentDialogEntry {
return {
kind: 'agent',
agentId: 'a',
description: 'desc',
status: 'running',
startTime: 0,
abortController: new AbortController(),
...overrides,
} as AgentDialogEntry;
}
function shellEntry(overrides: Partial<DialogEntry> = {}): DialogEntry {
return {
kind: 'shell',
shellId: 'bg_x',
command: 'sleep 60',
cwd: '/tmp',
status: 'running',
startTime: 0,
outputPath: '/tmp/x.out',
abortController: new AbortController(),
...overrides,
} as DialogEntry;
}
function renderPanel(
options: {
entries: readonly DialogEntry[];
dialogOpen?: boolean;
width?: number;
maxRows?: number;
/**
* Stub Config supplying a `getBackgroundTaskRegistry()` for the
* panel's per-tick live re-pull. Omit when the test cares only
* about the snapshot path (panel falls back gracefully).
*/
config?: Config;
} = { entries: [] },
) {
const state = {
entries: options.entries,
selectedIndex: 0,
dialogMode: options.dialogOpen ? ('list' as const) : ('closed' as const),
dialogOpen: Boolean(options.dialogOpen),
pillFocused: false,
};
// Wrap render() in act() so the panel's mount-time effect (the
// 1s wall-clock interval) is flushed inside React's scheduler boundary
// — silences the "update inside a test was not wrapped in act"
// warning ink-testing-library otherwise leaks for every render.
let result!: ReturnType<typeof render>;
act(() => {
result = render(
<ConfigContext.Provider value={options.config}>
<BackgroundTaskViewStateContext.Provider value={state}>
<LiveAgentPanel width={options.width} maxRows={options.maxRows} />
</BackgroundTaskViewStateContext.Provider>
</ConfigContext.Provider>,
);
});
return result;
}
/**
* Build a stub Config exposing only `getBackgroundTaskRegistry` the
* one method the panel calls. Returning a Map-backed registry whose
* `get` reads from the live store lets a test mutate `recentActivities`
* after render and observe the panel pick up the new value on the next
* tick (the actual production behavior we want to lock in).
*/
function makeRegistryConfig(agents: readonly AgentDialogEntry[]): {
config: Config;
store: Map<string, AgentDialogEntry>;
} {
const store = new Map<string, AgentDialogEntry>();
for (const a of agents) store.set(a.agentId, a);
const config = {
getBackgroundTaskRegistry: () => ({
get: (id: string) => store.get(id),
}),
} as unknown as Config;
return { config, store };
}
describe('<LiveAgentPanel />', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
});
afterEach(() => {
vi.useRealTimers();
});
it('hides when there are no agent entries', () => {
const { lastFrame } = renderPanel({ entries: [] });
expect(lastFrame() ?? '').toBe('');
});
it('hides when only non-agent entries exist (shell-only)', () => {
const { lastFrame } = renderPanel({ entries: [shellEntry()] });
expect(lastFrame() ?? '').toBe('');
});
it('hides when the background dialog is open (avoids duplicate roster)', () => {
const { lastFrame } = renderPanel({
entries: [agentEntry({ subagentType: 'researcher' })],
dialogOpen: true,
});
expect(lastFrame() ?? '').toBe('');
});
it('renders header and a single running agent row', () => {
const { lastFrame } = renderPanel({
entries: [
agentEntry({
agentId: 'a-1',
subagentType: 'researcher',
description: 'researcher: scan repo for TODO markers',
startTime: -5_000, // 5s ago at fake-time 0
recentActivities: [
{ name: 'Glob', description: '**/*.ts', at: -1000 },
],
}),
],
});
const frame = lastFrame() ?? '';
expect(frame).toContain('Active agents');
// Running and total tally both 1.
expect(frame).toContain('(1/1)');
expect(frame).toContain('researcher');
expect(frame).toContain('scan repo for TODO markers');
// Latest activity is rendered next to the row, with elapsed time.
expect(frame).toContain('Glob');
expect(frame).toContain('5s');
});
it('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 <Text> 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');
});
});

View file

@ -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 `<Static>` so updates never disturb committed history,
* and the same per-agent registry already powers the footer pill and
* the dialog, so the three views never drift.
*
* Scope: read-only display. Cancel / detail / approval routing all stay
* with the existing pill+dialog (Down arrow BackgroundTasksDialog) so
* this panel never competes for keyboard input.
*/
import type React from 'react';
import { useContext, useEffect, useMemo, 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<string, string> = Object.fromEntries(
(Object.keys(ToolNames) as Array<keyof typeof ToolNames>).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<LiveAgentPanelProps> = ({
maxRows = DEFAULT_MAX_ROWS,
width,
}) => {
const { entries, dialogOpen } = useBackgroundTaskViewState();
// Reach for Config via the raw context (NOT useConfig) so the panel
// can degrade to snapshot-only when no provider is mounted — e.g.
// unit tests that render the component in isolation. useConfig
// throws in that case, which would force every consumer to provide
// a stub Config just to satisfy the panel's "live registry re-pull".
const config = useContext(ConfigContext);
// Wall-clock tick. Drives elapsed-time refresh, terminal-row eviction,
// AND the live registry re-pull below. 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<Map<string, number>>(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<string>();
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 (
<LiveAgentPanelBody
snapshots={liveAgentSnapshots}
now={now}
maxRows={maxRows}
width={width}
/>
);
};
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 (
<Box flexDirection="column" marginTop={1} width={width} paddingX={2}>
<Box>
<Text bold color={theme.text.accent}>
Active agents
</Text>
<Text
color={theme.text.secondary}
>{` (${activeCount}/${visibleAgents.length})`}</Text>
</Box>
{overflow > 0 && (
<Box>
{/*
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.
*/}
<Text
color={theme.text.secondary}
>{` ^ ${overflow} more above (↓ to view all)`}</Text>
</Box>
)}
{visible.map((entry) => (
<AgentRow key={entry.agentId} entry={entry} now={now} />
))}
</Box>
);
};
const AgentRow: React.FC<{ entry: AgentDialogEntry; now: number }> = ({
entry,
now,
}) => {
const { glyph, color } = statusIcon(entry);
// 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 `<Text>` 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 (
<Box flexDirection="row">
<Box flexShrink={1}>
<Text wrap="truncate-end">
<Text color={color}>{`${glyph} `}</Text>
{showType && (
<>
<Text bold>{safeSubagentType}</Text>
<Text color={theme.text.secondary}>{': '}</Text>
</>
)}
<Text color={theme.text.secondary}>{label}</Text>
{activity && (
<Text color={theme.text.secondary}>{` (${activity})`}</Text>
)}
</Text>
</Box>
<Box flexShrink={0}>
<Text color={theme.text.secondary}>{tail}</Text>
</Box>
</Box>
);
};

View file

@ -51,9 +51,9 @@ interface ToolGroupMessageProps {
/**
* True when this tool group is being rendered live (in
* `pendingHistoryItems`). False once it commits to Ink's `<Static>`.
* 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<ToolGroupMessageProps> = ({
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<ToolGroupMessageProps> = ({
})()}
{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 (
<Box key={tool.callId} flexDirection="column" minHeight={1}>
<Box flexDirection="row" alignItems="center">
@ -328,8 +327,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isAgentWithPendingConfirmation(tool.resultDisplay)
}
isFocused={isSubagentFocused}
isPending={isPending}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&

View file

@ -101,23 +101,10 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({
return <Text>MockMarkdown:{text}</Text>;
},
}));
vi.mock('../subagents/index.js', () => ({
AgentExecutionDisplay: function MockAgentExecutionDisplay({
data,
}: {
data: { subagentName: string; taskDescription: string };
}) {
return (
<Text>
🤖 {data.subagentName} Task: {data.taskDescription}
</Text>
);
},
}));
vi.mock('./ToolConfirmationMessage.js', () => ({
ToolConfirmationMessage: function MockToolConfirmationMessage() {
// Sentinel string lets `isPending && pendingConfirmation` tests
// 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 <Text>MockApprovalPrompt</Text>;
},
}));
@ -313,58 +300,24 @@ describe('<ToolMessage />', () => {
expect(lowEmphasisFrame()).not.toContain('←');
});
it('shows subagent execution display for task tool with proper result display', () => {
const subagentResultDisplay = {
type: 'task_execution' as const,
subagentName: 'file-search',
taskDescription: 'Search for files matching pattern',
taskPrompt: 'Search for files matching pattern',
status: 'running' as const,
};
const props: ToolMessageProps = {
name: 'task',
description: 'Delegate task to subagent',
resultDisplay: subagentResultDisplay,
status: ToolCallStatus.Executing,
contentWidth: 80,
callId: 'test-call-id-2',
confirmationDetails: undefined,
config: mockConfig,
};
const { lastFrame } = renderWithContext(
<ToolMessage {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('🤖'); // Subagent execution display should show
expect(output).toContain('file-search'); // Actual subagent name
expect(output).toContain('Search for files matching pattern'); // Actual task description
});
describe('subagent live-render gating (isPending)', () => {
// The redesign hides the inline AgentExecutionDisplay while a
// foreground subagent runs (the pill+dialog handle drill-down).
// Only an active, focused approval prompt renders inline.
describe('subagent inline rendering (approval-only surface)', () => {
// The verbose inline AgentExecutionDisplay frame has been retired in
// favour of the always-on LiveAgentPanel (live progress) and
// BackgroundTasksDialog (history / detail). ToolMessage's only
// remaining inline subagent surface is the focus-routed approval
// prompt — both running and committed agent states render nothing
// inline now.
const buildProps = (overrides: {
data: {
subagentName: string;
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('<ToolMessage />', () => {
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(
<ToolMessage
{...buildProps({
@ -393,19 +344,69 @@ describe('<ToolMessage />', () => {
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(
<ToolMessage
{...buildProps({
data: {
subagentName: 'committed-agent',
taskDescription: 'Already done',
taskPrompt: 'Already done',
status: 'completed',
},
})}
/>,
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(
<ToolMessage
{...buildProps({
data: {
subagentName: 'failed-agent',
taskDescription: 'Crashed early',
taskPrompt: 'Crashed early',
status: 'failed',
terminateReason: 'Network timeout',
},
})}
/>,
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(
<ToolMessage
{...buildProps({
@ -416,23 +417,18 @@ describe('<ToolMessage />', () => {
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('<ToolMessage />', () => {
status: 'running',
pendingConfirmation: {} as object,
},
isPending: true,
isFocused: false,
})}
/>,
@ -456,32 +451,8 @@ describe('<ToolMessage />', () => {
const output = lastFrame() ?? '';
expect(output).toContain('Queued approval:');
expect(output).toContain('queued-agent');
// The full prompt + frame stay suppressed — only the focus-holder
// renders the active prompt above this row.
expect(output).not.toContain('Approval requested by');
expect(output).not.toContain('MockApprovalPrompt');
expect(output).not.toContain('🤖');
});
it('!isPending → committed render shows full inline frame', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...buildProps({
data: {
subagentName: 'committed-agent',
taskDescription: 'Already done',
taskPrompt: 'Already done',
status: 'completed',
},
isPending: false,
})}
/>,
StreamingState.Idle,
);
const output = lastFrame() ?? '';
// <Static>-rendered scrollback: full frame, no flicker concern.
expect(output).toContain('🤖');
expect(output).toContain('committed-agent');
});
});

View file

@ -24,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 `<Static>` 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: `<icon> <type>: <description> · 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 (
<Box flexDirection="column" paddingLeft={1}>
<Box>
<Text color={theme.text.secondary}>Approval requested by </Text>
<Text bold color={theme.text.accent}>
{agentLabel}
</Text>
<Text color={theme.text.secondary}>:</Text>
</Box>
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 2}
compactMode={true}
config={config}
/>
</Box>
);
}
if (data.pendingConfirmation) {
// Queued approval — another subagent currently holds the focus lock.
// A one-line marker keeps the user aware that something is waiting
// without opening the dialog; the full prompt renders on the
// focus-holder above and inside `BackgroundTasksDialog`.
const agentLabel = data.subagentName || 'agent';
return (
<Box paddingLeft={1}>
<Text color={theme.text.secondary} dimColor>
Queued approval:{' '}
}> = ({ data, availableHeight, childWidth, config, isFocused }) => {
if (data.pendingConfirmation && isFocused) {
const agentLabel = data.subagentName || 'agent';
return (
<Box flexDirection="column" paddingLeft={1}>
<Box>
<Text color={theme.text.secondary}>Approval requested by </Text>
<Text bold color={theme.text.accent}>
{agentLabel}
</Text>
<Text dimColor>{agentLabel}</Text>
<Text color={theme.text.secondary}>:</Text>
</Box>
);
}
return null;
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 2}
compactMode={true}
config={config}
/>
</Box>
);
}
if (data.pendingConfirmation) {
const agentLabel = data.subagentName || 'agent';
return (
<Box paddingLeft={1}>
<Text color={theme.text.secondary} dimColor>
Queued approval:{' '}
</Text>
<Text dimColor>{agentLabel}</Text>
</Box>
);
}
// 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 <SubagentScrollbackSummary data={data} />;
}
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 (
<AgentExecutionDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
config={config}
isFocused={isFocused}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
<Box paddingLeft={1}>
<Text wrap="truncate-end">
<Text color={color}>{`${glyph} `}</Text>
<Text bold>{typePrefix}</Text>
<Text color={theme.text.secondary}>{data.taskDescription}</Text>
<Text color={theme.text.secondary}>{tail}</Text>
<Text color={theme.text.secondary}>{reason}</Text>
</Text>
</Box>
);
};
@ -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 `<Static>`. 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<ToolMessageProps> = ({
@ -460,8 +495,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
config,
forceShowResult,
isFocused,
isPending,
isWaitingForOtherApproval,
executionStartTime,
}) => {
const settings = useSettings();
@ -616,8 +649,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
childWidth={innerWidth}
config={config}
isFocused={isFocused}
isPending={isPending}
isWaitingForOtherApproval={isWaitingForOtherApproval}
/>
)}
{effectiveDisplayRenderer.type === 'diff' && (

View file

@ -10,5 +10,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).

View file

@ -1,193 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { render } from 'ink-testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentResultDisplay } from '@qwen-code/qwen-code-core';
import { makeFakeConfig } from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from './AgentExecutionDisplay.js';
let keypressHandler:
| ((key: { ctrl?: boolean; name?: string }) => void)
| undefined;
vi.mock('../../../hooks/useKeypress.js', () => ({
// The mock honours { isActive } so historical/completed displays don't
// capture the keypress handler — same scoping the production hook does.
useKeypress: vi.fn(
(
handler: (key: { ctrl?: boolean; name?: string }) => void,
options?: { isActive?: boolean },
) => {
keypressHandler = options?.isActive === false ? undefined : handler;
},
),
}));
function makeRunningData(toolCount: number): AgentResultDisplay {
return {
type: 'task_execution',
subagentName: 'reviewer',
subagentColor: 'blue',
status: 'running',
taskDescription: 'Review large output stability',
taskPrompt: `${'very-long-task-prompt '.repeat(20)}\nsecond\nthird`,
toolCalls: Array.from({ length: toolCount }, (_, index) => ({
callId: `call-${index}`,
name: `tool-${index}`,
status: 'success',
description: `description-${index} ${'wide '.repeat(20)}`,
resultDisplay: `result-${index} ${'payload '.repeat(20)}`,
})),
};
}
function makeCompletedData(toolCount: number): AgentResultDisplay {
return {
...makeRunningData(toolCount),
status: 'completed',
executionSummary: {
rounds: 3,
totalDurationMs: 12_345,
totalToolCalls: toolCount,
successfulToolCalls: toolCount,
failedToolCalls: 0,
successRate: 100,
inputTokens: 100,
outputTokens: 200,
thoughtTokens: 0,
cachedTokens: 0,
totalTokens: 4_321,
toolUsage: [],
},
};
}
function visualRowCount(frame: string): number {
if (!frame) return 0;
return frame.split('\n').length;
}
describe('<AgentExecutionDisplay />', () => {
beforeEach(() => {
keypressHandler = undefined;
});
it('bounds expanded detail by the assigned visual height', () => {
const { lastFrame } = render(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={8}
childWidth={40}
config={makeFakeConfig()}
/>,
);
act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});
const frame = lastFrame() ?? '';
expect(frame).toContain('Showing the first 1 visual lines');
expect(frame).toContain('Showing the last 1 of 8 tools');
expect(frame).toContain('tool-7');
expect(frame).not.toContain('tool-0');
});
it('keeps the rendered running frame within availableHeight', () => {
const availableHeight = 26;
const { lastFrame } = render(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);
act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});
expect(visualRowCount(lastFrame() ?? '')).toBeLessThanOrEqual(
availableHeight,
);
});
it('does not respond to ctrl+e when another running subagent has focus', () => {
// Two SubAgents running side-by-side share the live viewport. Only the
// focused one should react to Ctrl+E / Ctrl+F — otherwise both reflow
// together and the dual height-change reintroduces flicker.
const { lastFrame } = render(
<AgentExecutionDisplay
data={makeRunningData(2)}
availableHeight={20}
childWidth={80}
config={makeFakeConfig()}
isFocused={false}
/>,
);
const before = lastFrame() ?? '';
act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});
expect(lastFrame() ?? '').toBe(before);
});
it('survives the running → completed transition while expanded', () => {
// Real path: subagent is running, the user expands it (ctrl+e) so
// displayMode becomes 'default', then the same instance rerenders with
// completed data. The completed-state budget must still hold the
// expanded layout inside availableHeight, and ctrl+e must become a
// no-op on the completed instance so it doesn't drag historical
// displays through mode toggles.
const availableHeight = 30;
const { lastFrame, rerender } = render(
<AgentExecutionDisplay
data={makeRunningData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);
// Expand the running display.
act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});
const expandedRunningFrame = lastFrame() ?? '';
expect(expandedRunningFrame).toContain('Task Detail:');
expect(expandedRunningFrame).toContain('Tools:');
// Re-render the same component instance with completed data, preserving
// displayMode. Without an overhead-aware completed budget the
// ExecutionSummary + ToolUsage blocks would push the frame past
// availableHeight here.
rerender(
<AgentExecutionDisplay
data={makeCompletedData(8)}
availableHeight={availableHeight}
childWidth={80}
config={makeFakeConfig()}
/>,
);
const completedFrame = lastFrame() ?? '';
expect(visualRowCount(completedFrame)).toBeLessThanOrEqual(availableHeight);
// useKeypress is now `{ isActive: false }`; ctrl+e on the completed
// instance must not toggle anything. The mock unsets keypressHandler
// when isActive is false, so the call below is a no-op and the frame
// is identical.
const before = lastFrame() ?? '';
act(() => {
keypressHandler?.({ ctrl: true, name: 'e' });
});
expect(lastFrame() ?? '').toBe(before);
});
});

View file

@ -1,725 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import type {
AgentResultDisplay,
AgentStatsSummary,
Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { COLOR_OPTIONS } from '../constants.js';
import { fmtDuration } from '../utils.js';
import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js';
import {
getCachedStringWidth,
sliceTextByVisualHeight,
toCodePoints,
} from '../../../utils/textUtils.js';
export type DisplayMode = 'compact' | 'default' | 'verbose';
export interface AgentExecutionDisplayProps {
data: AgentResultDisplay;
availableHeight?: number;
childWidth: number;
config: Config;
/**
* Whether this subagent owns keyboard input for confirmations and
* Ctrl+E/Ctrl+F display shortcuts.
*/
isFocused?: boolean;
/** Whether another subagent's approval currently holds the focus lock, blocking this one. */
isWaitingForOtherApproval?: boolean;
}
const getStatusColor = (
status:
| AgentResultDisplay['status']
| 'executing'
| 'success'
| 'awaiting_approval',
) => {
switch (status) {
case 'running':
case 'executing':
case 'awaiting_approval':
return theme.status.warning;
case 'completed':
case 'success':
return theme.status.success;
case 'background':
return theme.text.secondary;
case 'cancelled':
return theme.status.warning;
case 'failed':
return theme.status.error;
default:
return theme.text.secondary;
}
};
const getStatusText = (status: AgentResultDisplay['status']) => {
switch (status) {
case 'running':
return 'Running';
case 'completed':
return 'Completed';
case 'background':
return 'Running in background';
case 'cancelled':
return 'User Cancelled';
case 'failed':
return 'Failed';
default:
return 'Unknown';
}
};
const BackgroundManageHint: React.FC = () => (
<Text color={theme.text.secondary}> ( to manage)</Text>
);
const MAX_TOOL_CALLS = 5;
const MAX_VERBOSE_TOOL_CALLS = 12;
const MAX_TASK_PROMPT_LINES = 5;
const DEFAULT_DETAIL_HEIGHT = 18;
// Approximate fixed-row cost of the default/verbose layout, derived from the
// JSX structure below: 1 header + (1 "Task Detail:" label + 1 internal gap +
// optional 1 "...N task lines hidden..." footer) + (1 "Tools:" label + 1
// marginBottom) + 1 footer + 3 inter-section gaps. We subtract this from the
// parent-provided `availableHeight` so the budget for the prompt and
// tool-call lists actually fits inside the assigned frame.
const RUNNING_FIXED_OVERHEAD = 10;
// In completed/cancelled/failed mode we lose the running footer but gain the
// ExecutionSummary block (header + 3 rows) and the ToolUsage block (header +
// up to 2 wrapped rows) plus an extra inter-block gap, so the overhead grows.
// Calibrated against the running→completed transition test: assigning <22
// here lets the completed expanded frame edge past availableHeight when the
// SubAgent finishes mid-expand.
const COMPLETED_FIXED_OVERHEAD = 22;
// "Status icon + name + description" + "truncated output" — each tool call
// commits two visual rows in default/verbose mode.
const ROWS_PER_TOOL_CALL = 2;
function truncateToVisualWidth(text: string, maxWidth: number): string {
const visualWidth = Math.max(1, Math.floor(maxWidth));
const ellipsis = '...';
const ellipsisWidth = getCachedStringWidth(ellipsis);
let currentWidth = 0;
let result = '';
for (const char of toCodePoints(text)) {
const charWidth = Math.max(getCachedStringWidth(char), 1);
if (currentWidth + charWidth > visualWidth) {
const availableWidth = Math.max(0, visualWidth - ellipsisWidth);
let trimmed = '';
let trimmedWidth = 0;
for (const trimmedChar of toCodePoints(result)) {
const trimmedCharWidth = Math.max(getCachedStringWidth(trimmedChar), 1);
if (trimmedWidth + trimmedCharWidth > availableWidth) {
break;
}
trimmed += trimmedChar;
trimmedWidth += trimmedCharWidth;
}
return trimmed + ellipsis;
}
result += char;
currentWidth += charWidth;
}
return result;
}
/**
* Component to display subagent execution progress and results.
* This is now a pure component that renders the provided SubagentExecutionResultDisplay data.
* Real-time updates are handled by the parent component updating the data prop.
*/
export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
data,
availableHeight,
childWidth,
config,
isFocused = true,
isWaitingForOtherApproval = false,
}) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');
const detailHeight = Math.max(
4,
Math.floor(availableHeight ?? DEFAULT_DETAIL_HEIGHT),
);
// Treat `availableHeight` as the *total* component budget. Subtract the
// fixed overhead (header, section labels, gaps, footer/result block) before
// splitting the remainder between the prompt preview and the tool-call
// list. This guarantees the rendered frame doesn't grow past the budget the
// parent layout assigned us, which is the precondition for Ink to keep the
// SubAgent display inside its static slot instead of clearing+redrawing.
const fixedOverhead =
data.status === 'running'
? RUNNING_FIXED_OVERHEAD
: COMPLETED_FIXED_OVERHEAD;
const renderableBudget = Math.max(2, detailHeight - fixedOverhead);
// Prompt gets ~1/3 of the remainder, tool-call list gets the rest. Both are
// clamped to >=1 so we always render at least one of each kind, even in
// pathological "availableHeight smaller than overhead" cases.
const promptBudget = Math.max(1, Math.floor(renderableBudget / 3));
const toolBudget = Math.max(
1,
Math.floor((renderableBudget - promptBudget) / ROWS_PER_TOOL_CALL),
);
const maxTaskPromptLines =
displayMode === 'verbose'
? Math.min(8, promptBudget)
: Math.min(MAX_TASK_PROMPT_LINES, promptBudget);
const maxToolCalls =
displayMode === 'verbose'
? Math.min(MAX_VERBOSE_TOOL_CALLS, toolBudget)
: Math.min(MAX_TOOL_CALLS, toolBudget);
const agentColor = useMemo(() => {
const colorOption = COLOR_OPTIONS.find(
(option) => option.name === data.subagentColor,
);
return colorOption?.value || theme.text.accent;
}, [data.subagentColor]);
// Slice the prompt once at the parent so the rendered TaskPromptSection
// and the footer's "ctrl+f to show more" hint share the same source of
// truth. Counting `data.taskPrompt.split('\n').length` would only see hard
// newlines and miss soft-wrapped overflow, so a long single-line prompt
// could be visually truncated without surfacing the hint.
const promptChildWidth = Math.max(1, childWidth - 2);
const slicedPrompt = useMemo(
() =>
sliceTextByVisualHeight(
data.taskPrompt,
maxTaskPromptLines,
promptChildWidth,
{ minHeight: 1, overflowDirection: 'bottom' },
),
[data.taskPrompt, maxTaskPromptLines, promptChildWidth],
);
const footerText = React.useMemo(() => {
// This component only listens to keyboard shortcut events when the subagent is running
if (data.status !== 'running') return '';
if (displayMode === 'default') {
const hasMoreLines = slicedPrompt.hiddenLinesCount > 0;
const hasMoreToolCalls =
data.toolCalls && data.toolCalls.length > maxToolCalls;
if (hasMoreToolCalls || hasMoreLines) {
return 'Press ctrl+e to show less, ctrl+f to show more.';
}
return 'Press ctrl+e to show less.';
}
if (displayMode === 'verbose') {
return 'Press ctrl+f to show less.';
}
return '';
}, [
displayMode,
data.status,
data.toolCalls,
slicedPrompt.hiddenLinesCount,
maxToolCalls,
]);
// Handle keyboard shortcuts to control display mode. Scope the listener to
// the running subagent that currently holds focus — `data.status` rules
// out completed/historical instances mounted in scrollback, and
// `isFocused` rules out *parallel* running subagents that share the live
// viewport. Without the focus gate, two SubAgents running side by side
// would both toggle on a single Ctrl+E / Ctrl+F press and the resulting
// dual-reflow brings back the flicker this component is meant to
// prevent.
useKeypress(
(key) => {
if (key.ctrl && key.name === 'e') {
// ctrl+e toggles between compact and default
setDisplayMode((current) =>
current === 'compact' ? 'default' : 'compact',
);
} else if (key.ctrl && key.name === 'f') {
// ctrl+f toggles between default and verbose
setDisplayMode((current) =>
current === 'default' ? 'verbose' : 'default',
);
}
},
{ isActive: data.status === 'running' && isFocused },
);
if (displayMode === 'compact') {
return (
<Box flexDirection="column">
{/* Header: Agent name and status */}
{!data.pendingConfirmation && (
<Box flexDirection="row">
<Text bold color={agentColor}>
{data.subagentName}
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
{data.status === 'background' && <BackgroundManageHint />}
</Box>
)}
{/* Running state: Show current tool call and progress */}
{data.status === 'running' && (
<>
{/* Current tool call */}
{data.toolCalls && data.toolCalls.length > 0 && (
<Box flexDirection="column">
<ToolCallItem
toolCall={data.toolCalls[data.toolCalls.length - 1]}
compact={true}
/>
{/* Show count of additional tool calls if there are more than 1 */}
{data.toolCalls.length > 1 && !data.pendingConfirmation && (
<Box flexDirection="row" paddingLeft={4}>
<Text color={theme.text.secondary}>
+{data.toolCalls.length - 1} more tool calls (ctrl+e to
expand)
</Text>
</Box>
)}
</Box>
)}
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}
config={config}
/>
</Box>
)}
</>
)}
{/* Completed state: Show summary line */}
{data.status === 'completed' && data.executionSummary && (
<Box flexDirection="row" marginTop={1}>
<Text color={theme.text.secondary}>
Execution Summary: {data.executionSummary.totalToolCalls} tool
uses · {data.executionSummary.totalTokens.toLocaleString()} tokens
· {fmtDuration(data.executionSummary.totalDurationMs)}
</Text>
</Box>
)}
{/* Failed/Cancelled state: Show error reason */}
{data.status === 'failed' && (
<Box flexDirection="row" marginTop={1}>
<Text color={theme.status.error}>
Failed: {data.terminateReason}
</Text>
</Box>
)}
</Box>
);
}
// Default and verbose modes use normal layout
return (
<Box flexDirection="column" paddingX={1} gap={1}>
{/* Header with subagent name and status */}
<Box flexDirection="row">
<Text bold color={agentColor}>
{data.subagentName}
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
{data.status === 'background' && <BackgroundManageHint />}
</Box>
{/* Task description */}
<TaskPromptSection
slicedPrompt={slicedPrompt}
displayMode={displayMode}
maxVisualLines={maxTaskPromptLines}
/>
{/* Progress section for running tasks */}
{data.status === 'running' &&
data.toolCalls &&
data.toolCalls.length > 0 && (
<Box flexDirection="column">
<ToolCallsList
toolCalls={data.toolCalls}
displayMode={displayMode}
maxToolCalls={maxToolCalls}
childWidth={childWidth - 2}
/>
</Box>
)}
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column">
{isWaitingForOtherApproval && (
<Box marginBottom={0}>
<Text color={theme.text.secondary} dimColor>
Waiting for other approval...
</Text>
</Box>
)}
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
config={config}
isFocused={isFocused}
availableTerminalHeight={availableHeight}
contentWidth={childWidth - 4}
compactMode={true}
/>
</Box>
)}
{/* Results section for completed/failed tasks */}
{(data.status === 'completed' ||
data.status === 'failed' ||
data.status === 'cancelled') && (
<ResultsSection
data={data}
displayMode={displayMode}
maxToolCalls={maxToolCalls}
childWidth={childWidth - 2}
/>
)}
{/* Footer with keyboard shortcuts */}
{footerText && (
<Box flexDirection="row">
<Text color={theme.text.secondary}>{footerText}</Text>
</Box>
)}
</Box>
);
};
/**
* Task prompt section. Receives the already-sliced prompt from the parent so
* footer hint and section content share one source of truth for whether
* content was hidden (covers soft-wrapped overflow in addition to explicit
* newlines).
*/
const TaskPromptSection: React.FC<{
slicedPrompt: { text: string; hiddenLinesCount: number };
displayMode: DisplayMode;
maxVisualLines: number;
}> = ({ slicedPrompt, displayMode, maxVisualLines }) => {
const shouldTruncate = slicedPrompt.hiddenLinesCount > 0;
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="row">
<Text color={theme.text.primary}>Task Detail: </Text>
{shouldTruncate && displayMode !== 'compact' && (
<Text color={theme.text.secondary}>
{' '}
Showing the first {maxVisualLines} visual lines.
</Text>
)}
</Box>
<Box paddingLeft={1}>
<Text wrap="wrap">{slicedPrompt.text}</Text>
</Box>
{slicedPrompt.hiddenLinesCount > 0 && (
<Box paddingLeft={1}>
<Text color={theme.text.secondary} wrap="truncate">
... last {slicedPrompt.hiddenLinesCount} task line
{slicedPrompt.hiddenLinesCount === 1 ? '' : 's'} hidden ...
</Text>
</Box>
)}
</Box>
);
};
/**
* Status dot component with similar height as text
*/
const StatusDot: React.FC<{
status: AgentResultDisplay['status'];
}> = ({ status }) => (
<Box marginLeft={1} marginRight={1}>
<Text color={getStatusColor(status)}></Text>
</Box>
);
/**
* Status indicator component
*/
const StatusIndicator: React.FC<{
status: AgentResultDisplay['status'];
}> = ({ status }) => {
const color = getStatusColor(status);
const text = getStatusText(status);
return <Text color={color}>{text}</Text>;
};
/**
* Tool calls list - format consistent with ToolInfo in ToolMessage.tsx
*/
const ToolCallsList: React.FC<{
toolCalls: AgentResultDisplay['toolCalls'];
displayMode: DisplayMode;
maxToolCalls: number;
childWidth: number;
}> = ({ toolCalls, displayMode, maxToolCalls, childWidth }) => {
const calls = toolCalls || [];
const displayLimit = Math.max(1, Math.floor(maxToolCalls));
const shouldTruncate = calls.length > displayLimit;
const displayCalls = calls.slice(-displayLimit);
// Reverse the order to show most recent first
const reversedDisplayCalls = [...displayCalls].reverse();
return (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Tools:</Text>
{shouldTruncate && displayMode !== 'compact' && (
<Text color={theme.text.secondary}>
{' '}
Showing the last {displayCalls.length} of {calls.length} tools.
</Text>
)}
</Box>
{reversedDisplayCalls.map((toolCall, index) => (
<ToolCallItem
key={`${toolCall.name}-${index}`}
toolCall={toolCall}
childWidth={childWidth}
/>
))}
</Box>
);
};
/**
* Individual tool call item - consistent with ToolInfo format
*/
const ToolCallItem: React.FC<{
toolCall: {
name: string;
status: 'executing' | 'awaiting_approval' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
resultDisplay?: string;
description?: string;
};
compact?: boolean;
childWidth?: number;
}> = ({ toolCall, compact = false, childWidth = 80 }) => {
const STATUS_INDICATOR_WIDTH = 3;
const textWidth = Math.max(8, childWidth - STATUS_INDICATOR_WIDTH - 1);
// Map subagent status to ToolCallStatus-like display
const statusIcon = React.useMemo(() => {
const color = getStatusColor(toolCall.status);
switch (toolCall.status) {
case 'executing':
return <Text color={color}></Text>; // Using same as ToolMessage
case 'awaiting_approval':
return <Text color={theme.status.warning}>?</Text>;
case 'success':
return <Text color={color}></Text>;
case 'failed':
return (
<Text color={color} bold>
x
</Text>
);
default:
return <Text color={color}>o</Text>;
}
}, [toolCall.status]);
const description = React.useMemo(() => {
if (!toolCall.description) return '';
const firstLine = toolCall.description.split('\n')[0];
return truncateToVisualWidth(firstLine, textWidth);
}, [toolCall.description, textWidth]);
// Get first line of resultDisplay for truncated output
const truncatedOutput = React.useMemo(() => {
if (!toolCall.resultDisplay) return '';
const firstLine = toolCall.resultDisplay.split('\n')[0];
return truncateToVisualWidth(firstLine, textWidth);
}, [toolCall.resultDisplay, textWidth]);
return (
<Box flexDirection="column" paddingLeft={1} marginBottom={0}>
{/* First line: status icon + tool name + description (consistent with ToolInfo) */}
<Box flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusIcon}</Box>
<Text wrap="truncate-end">
<Text>{toolCall.name}</Text>{' '}
<Text color={theme.text.secondary}>{description}</Text>
{toolCall.error && (
<Text color={theme.status.error}> - {toolCall.error}</Text>
)}
</Text>
</Box>
{/* Second line: truncated returnDisplay output - hidden in compact mode */}
{!compact && truncatedOutput && (
<Box flexDirection="row" paddingLeft={STATUS_INDICATOR_WIDTH}>
<Text color={theme.text.secondary}>{truncatedOutput}</Text>
</Box>
)}
</Box>
);
};
/**
* Execution summary details component
*/
const ExecutionSummaryDetails: React.FC<{
data: AgentResultDisplay;
displayMode: DisplayMode;
}> = ({ data, displayMode: _displayMode }) => {
const stats = data.executionSummary;
if (!stats) {
return (
<Box flexDirection="column" paddingLeft={1}>
<Text color={theme.text.secondary}> No summary available</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingLeft={1}>
<Text>
<Text>Duration: {fmtDuration(stats.totalDurationMs)}</Text>
</Text>
<Text>
<Text>Rounds: {stats.rounds}</Text>
</Text>
<Text>
<Text>Tokens: {stats.totalTokens.toLocaleString()}</Text>
</Text>
</Box>
);
};
/**
* Tool usage statistics component
*/
const ToolUsageStats: React.FC<{
executionSummary?: AgentStatsSummary;
}> = ({ executionSummary }) => {
if (!executionSummary) {
return (
<Box flexDirection="column" paddingLeft={1}>
<Text color={theme.text.secondary}> No tool usage data available</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingLeft={1}>
<Text>
<Text>Total Calls:</Text> {executionSummary.totalToolCalls}
</Text>
<Text>
<Text>Success Rate:</Text>{' '}
<Text color={theme.status.success}>
{executionSummary.successRate.toFixed(1)}%
</Text>{' '}
(
<Text color={theme.status.success}>
{executionSummary.successfulToolCalls} success
</Text>
,{' '}
<Text color={theme.status.error}>
{executionSummary.failedToolCalls} failed
</Text>
)
</Text>
</Box>
);
};
/**
* Results section for completed executions - matches the clean layout from the image
*/
const ResultsSection: React.FC<{
data: AgentResultDisplay;
displayMode: DisplayMode;
maxToolCalls: number;
childWidth: number;
}> = ({ data, displayMode, maxToolCalls, childWidth }) => (
<Box flexDirection="column" gap={1}>
{/* Tool calls section - clean list format */}
{data.toolCalls && data.toolCalls.length > 0 && (
<ToolCallsList
toolCalls={data.toolCalls}
displayMode={displayMode}
maxToolCalls={maxToolCalls}
childWidth={childWidth}
/>
)}
{/* Execution Summary section - hide when cancelled */}
{data.status === 'completed' && (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Execution Summary:</Text>
</Box>
<ExecutionSummaryDetails data={data} displayMode={displayMode} />
</Box>
)}
{/* Tool Usage section - hide when cancelled */}
{data.status === 'completed' && data.executionSummary && (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Tool Usage:</Text>
</Box>
<ToolUsageStats executionSummary={data.executionSummary} />
</Box>
)}
{/* Error reason for failed tasks */}
{data.status === 'cancelled' && (
<Box flexDirection="row">
<Text color={theme.status.warning}> User Cancelled</Text>
</Box>
)}
{data.status === 'failed' && (
<Box flexDirection="row">
<Text color={theme.status.error}>Task Failed: </Text>
<Text color={theme.status.error}>{data.terminateReason}</Text>
</Box>
)}
</Box>
);

View file

@ -16,6 +16,7 @@ import { BtwMessage } from '../components/messages/BtwMessage.js';
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
import { LiveAgentPanel } from '../components/background-view/LiveAgentPanel.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAgentViewState } from '../contexts/AgentViewContext.js';
@ -100,6 +101,34 @@ export const DefaultAppLayout: React.FC = () => {
</>
)}
<ExitWarning />
{/*
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 && (
<LiveAgentPanel width={uiState.terminalWidth} />
)}
</Box>
</>
)}

View file

@ -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<SubagentConfig, 'level' | 'filePath'>
> = [
{
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.

View file

@ -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';