mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-12 14:10:08 +00:00
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:
parent
fc1ba5751c
commit
f5bef6c5dd
13 changed files with 1440 additions and 1127 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = '[31mEVIL[0m';
|
||||
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('[31m');
|
||||
expect(frame).not.toContain('[0m');
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import { makeFakeConfig } from '@qwen-code/qwen-code-core';
|
||||
import { AgentExecutionDisplay } from './AgentExecutionDisplay.js';
|
||||
|
||||
let keypressHandler:
|
||||
| ((key: { ctrl?: boolean; name?: string }) => void)
|
||||
| undefined;
|
||||
|
||||
vi.mock('../../../hooks/useKeypress.js', () => ({
|
||||
// The mock honours { isActive } so historical/completed displays don't
|
||||
// capture the keypress handler — same scoping the production hook does.
|
||||
useKeypress: vi.fn(
|
||||
(
|
||||
handler: (key: { ctrl?: boolean; name?: string }) => void,
|
||||
options?: { isActive?: boolean },
|
||||
) => {
|
||||
keypressHandler = options?.isActive === false ? undefined : handler;
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
function makeRunningData(toolCount: number): AgentResultDisplay {
|
||||
return {
|
||||
type: 'task_execution',
|
||||
subagentName: 'reviewer',
|
||||
subagentColor: 'blue',
|
||||
status: 'running',
|
||||
taskDescription: 'Review large output stability',
|
||||
taskPrompt: `${'very-long-task-prompt '.repeat(20)}\nsecond\nthird`,
|
||||
toolCalls: Array.from({ length: toolCount }, (_, index) => ({
|
||||
callId: `call-${index}`,
|
||||
name: `tool-${index}`,
|
||||
status: 'success',
|
||||
description: `description-${index} ${'wide '.repeat(20)}`,
|
||||
resultDisplay: `result-${index} ${'payload '.repeat(20)}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCompletedData(toolCount: number): AgentResultDisplay {
|
||||
return {
|
||||
...makeRunningData(toolCount),
|
||||
status: 'completed',
|
||||
executionSummary: {
|
||||
rounds: 3,
|
||||
totalDurationMs: 12_345,
|
||||
totalToolCalls: toolCount,
|
||||
successfulToolCalls: toolCount,
|
||||
failedToolCalls: 0,
|
||||
successRate: 100,
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
thoughtTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 4_321,
|
||||
toolUsage: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function visualRowCount(frame: string): number {
|
||||
if (!frame) return 0;
|
||||
return frame.split('\n').length;
|
||||
}
|
||||
|
||||
describe('<AgentExecutionDisplay />', () => {
|
||||
beforeEach(() => {
|
||||
keypressHandler = undefined;
|
||||
});
|
||||
|
||||
it('bounds expanded detail by the assigned visual height', () => {
|
||||
const { lastFrame } = render(
|
||||
<AgentExecutionDisplay
|
||||
data={makeRunningData(8)}
|
||||
availableHeight={8}
|
||||
childWidth={40}
|
||||
config={makeFakeConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keypressHandler?.({ ctrl: true, name: 'e' });
|
||||
});
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Showing the first 1 visual lines');
|
||||
expect(frame).toContain('Showing the last 1 of 8 tools');
|
||||
expect(frame).toContain('tool-7');
|
||||
expect(frame).not.toContain('tool-0');
|
||||
});
|
||||
|
||||
it('keeps the rendered running frame within availableHeight', () => {
|
||||
const availableHeight = 26;
|
||||
const { lastFrame } = render(
|
||||
<AgentExecutionDisplay
|
||||
data={makeRunningData(8)}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={80}
|
||||
config={makeFakeConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
keypressHandler?.({ ctrl: true, name: 'e' });
|
||||
});
|
||||
|
||||
expect(visualRowCount(lastFrame() ?? '')).toBeLessThanOrEqual(
|
||||
availableHeight,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not respond to ctrl+e when another running subagent has focus', () => {
|
||||
// Two SubAgents running side-by-side share the live viewport. Only the
|
||||
// focused one should react to Ctrl+E / Ctrl+F — otherwise both reflow
|
||||
// together and the dual height-change reintroduces flicker.
|
||||
const { lastFrame } = render(
|
||||
<AgentExecutionDisplay
|
||||
data={makeRunningData(2)}
|
||||
availableHeight={20}
|
||||
childWidth={80}
|
||||
config={makeFakeConfig()}
|
||||
isFocused={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const before = lastFrame() ?? '';
|
||||
act(() => {
|
||||
keypressHandler?.({ ctrl: true, name: 'e' });
|
||||
});
|
||||
expect(lastFrame() ?? '').toBe(before);
|
||||
});
|
||||
|
||||
it('survives the running → completed transition while expanded', () => {
|
||||
// Real path: subagent is running, the user expands it (ctrl+e) so
|
||||
// displayMode becomes 'default', then the same instance rerenders with
|
||||
// completed data. The completed-state budget must still hold the
|
||||
// expanded layout inside availableHeight, and ctrl+e must become a
|
||||
// no-op on the completed instance so it doesn't drag historical
|
||||
// displays through mode toggles.
|
||||
const availableHeight = 30;
|
||||
const { lastFrame, rerender } = render(
|
||||
<AgentExecutionDisplay
|
||||
data={makeRunningData(8)}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={80}
|
||||
config={makeFakeConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Expand the running display.
|
||||
act(() => {
|
||||
keypressHandler?.({ ctrl: true, name: 'e' });
|
||||
});
|
||||
const expandedRunningFrame = lastFrame() ?? '';
|
||||
expect(expandedRunningFrame).toContain('Task Detail:');
|
||||
expect(expandedRunningFrame).toContain('Tools:');
|
||||
|
||||
// Re-render the same component instance with completed data, preserving
|
||||
// displayMode. Without an overhead-aware completed budget the
|
||||
// ExecutionSummary + ToolUsage blocks would push the frame past
|
||||
// availableHeight here.
|
||||
rerender(
|
||||
<AgentExecutionDisplay
|
||||
data={makeCompletedData(8)}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={80}
|
||||
config={makeFakeConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const completedFrame = lastFrame() ?? '';
|
||||
expect(visualRowCount(completedFrame)).toBeLessThanOrEqual(availableHeight);
|
||||
|
||||
// useKeypress is now `{ isActive: false }`; ctrl+e on the completed
|
||||
// instance must not toggle anything. The mock unsets keypressHandler
|
||||
// when isActive is false, so the call below is a no-op and the frame
|
||||
// is identical.
|
||||
const before = lastFrame() ?? '';
|
||||
act(() => {
|
||||
keypressHandler?.({ ctrl: true, name: 'e' });
|
||||
});
|
||||
expect(lastFrame() ?? '').toBe(before);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,725 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
AgentResultDisplay,
|
||||
AgentStatsSummary,
|
||||
Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { COLOR_OPTIONS } from '../constants.js';
|
||||
import { fmtDuration } from '../utils.js';
|
||||
import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js';
|
||||
import {
|
||||
getCachedStringWidth,
|
||||
sliceTextByVisualHeight,
|
||||
toCodePoints,
|
||||
} from '../../../utils/textUtils.js';
|
||||
|
||||
export type DisplayMode = 'compact' | 'default' | 'verbose';
|
||||
|
||||
export interface AgentExecutionDisplayProps {
|
||||
data: AgentResultDisplay;
|
||||
availableHeight?: number;
|
||||
childWidth: number;
|
||||
config: Config;
|
||||
/**
|
||||
* Whether this subagent owns keyboard input for confirmations and
|
||||
* Ctrl+E/Ctrl+F display shortcuts.
|
||||
*/
|
||||
isFocused?: boolean;
|
||||
/** Whether another subagent's approval currently holds the focus lock, blocking this one. */
|
||||
isWaitingForOtherApproval?: boolean;
|
||||
}
|
||||
|
||||
const getStatusColor = (
|
||||
status:
|
||||
| AgentResultDisplay['status']
|
||||
| 'executing'
|
||||
| 'success'
|
||||
| 'awaiting_approval',
|
||||
) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'executing':
|
||||
case 'awaiting_approval':
|
||||
return theme.status.warning;
|
||||
case 'completed':
|
||||
case 'success':
|
||||
return theme.status.success;
|
||||
case 'background':
|
||||
return theme.text.secondary;
|
||||
case 'cancelled':
|
||||
return theme.status.warning;
|
||||
case 'failed':
|
||||
return theme.status.error;
|
||||
default:
|
||||
return theme.text.secondary;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: AgentResultDisplay['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Running';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'background':
|
||||
return 'Running in background';
|
||||
case 'cancelled':
|
||||
return 'User Cancelled';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const BackgroundManageHint: React.FC = () => (
|
||||
<Text color={theme.text.secondary}> (↓ to manage)</Text>
|
||||
);
|
||||
|
||||
const MAX_TOOL_CALLS = 5;
|
||||
const MAX_VERBOSE_TOOL_CALLS = 12;
|
||||
const MAX_TASK_PROMPT_LINES = 5;
|
||||
const DEFAULT_DETAIL_HEIGHT = 18;
|
||||
|
||||
// Approximate fixed-row cost of the default/verbose layout, derived from the
|
||||
// JSX structure below: 1 header + (1 "Task Detail:" label + 1 internal gap +
|
||||
// optional 1 "...N task lines hidden..." footer) + (1 "Tools:" label + 1
|
||||
// marginBottom) + 1 footer + 3 inter-section gaps. We subtract this from the
|
||||
// parent-provided `availableHeight` so the budget for the prompt and
|
||||
// tool-call lists actually fits inside the assigned frame.
|
||||
const RUNNING_FIXED_OVERHEAD = 10;
|
||||
// In completed/cancelled/failed mode we lose the running footer but gain the
|
||||
// ExecutionSummary block (header + 3 rows) and the ToolUsage block (header +
|
||||
// up to 2 wrapped rows) plus an extra inter-block gap, so the overhead grows.
|
||||
// Calibrated against the running→completed transition test: assigning <22
|
||||
// here lets the completed expanded frame edge past availableHeight when the
|
||||
// SubAgent finishes mid-expand.
|
||||
const COMPLETED_FIXED_OVERHEAD = 22;
|
||||
// "Status icon + name + description" + "truncated output" — each tool call
|
||||
// commits two visual rows in default/verbose mode.
|
||||
const ROWS_PER_TOOL_CALL = 2;
|
||||
|
||||
function truncateToVisualWidth(text: string, maxWidth: number): string {
|
||||
const visualWidth = Math.max(1, Math.floor(maxWidth));
|
||||
const ellipsis = '...';
|
||||
const ellipsisWidth = getCachedStringWidth(ellipsis);
|
||||
let currentWidth = 0;
|
||||
let result = '';
|
||||
|
||||
for (const char of toCodePoints(text)) {
|
||||
const charWidth = Math.max(getCachedStringWidth(char), 1);
|
||||
if (currentWidth + charWidth > visualWidth) {
|
||||
const availableWidth = Math.max(0, visualWidth - ellipsisWidth);
|
||||
let trimmed = '';
|
||||
let trimmedWidth = 0;
|
||||
for (const trimmedChar of toCodePoints(result)) {
|
||||
const trimmedCharWidth = Math.max(getCachedStringWidth(trimmedChar), 1);
|
||||
if (trimmedWidth + trimmedCharWidth > availableWidth) {
|
||||
break;
|
||||
}
|
||||
trimmed += trimmedChar;
|
||||
trimmedWidth += trimmedCharWidth;
|
||||
}
|
||||
return trimmed + ellipsis;
|
||||
}
|
||||
|
||||
result += char;
|
||||
currentWidth += charWidth;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display subagent execution progress and results.
|
||||
* This is now a pure component that renders the provided SubagentExecutionResultDisplay data.
|
||||
* Real-time updates are handled by the parent component updating the data prop.
|
||||
*/
|
||||
export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
||||
data,
|
||||
availableHeight,
|
||||
childWidth,
|
||||
config,
|
||||
isFocused = true,
|
||||
isWaitingForOtherApproval = false,
|
||||
}) => {
|
||||
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');
|
||||
const detailHeight = Math.max(
|
||||
4,
|
||||
Math.floor(availableHeight ?? DEFAULT_DETAIL_HEIGHT),
|
||||
);
|
||||
// Treat `availableHeight` as the *total* component budget. Subtract the
|
||||
// fixed overhead (header, section labels, gaps, footer/result block) before
|
||||
// splitting the remainder between the prompt preview and the tool-call
|
||||
// list. This guarantees the rendered frame doesn't grow past the budget the
|
||||
// parent layout assigned us, which is the precondition for Ink to keep the
|
||||
// SubAgent display inside its static slot instead of clearing+redrawing.
|
||||
const fixedOverhead =
|
||||
data.status === 'running'
|
||||
? RUNNING_FIXED_OVERHEAD
|
||||
: COMPLETED_FIXED_OVERHEAD;
|
||||
const renderableBudget = Math.max(2, detailHeight - fixedOverhead);
|
||||
// Prompt gets ~1/3 of the remainder, tool-call list gets the rest. Both are
|
||||
// clamped to >=1 so we always render at least one of each kind, even in
|
||||
// pathological "availableHeight smaller than overhead" cases.
|
||||
const promptBudget = Math.max(1, Math.floor(renderableBudget / 3));
|
||||
const toolBudget = Math.max(
|
||||
1,
|
||||
Math.floor((renderableBudget - promptBudget) / ROWS_PER_TOOL_CALL),
|
||||
);
|
||||
const maxTaskPromptLines =
|
||||
displayMode === 'verbose'
|
||||
? Math.min(8, promptBudget)
|
||||
: Math.min(MAX_TASK_PROMPT_LINES, promptBudget);
|
||||
const maxToolCalls =
|
||||
displayMode === 'verbose'
|
||||
? Math.min(MAX_VERBOSE_TOOL_CALLS, toolBudget)
|
||||
: Math.min(MAX_TOOL_CALLS, toolBudget);
|
||||
|
||||
const agentColor = useMemo(() => {
|
||||
const colorOption = COLOR_OPTIONS.find(
|
||||
(option) => option.name === data.subagentColor,
|
||||
);
|
||||
return colorOption?.value || theme.text.accent;
|
||||
}, [data.subagentColor]);
|
||||
|
||||
// Slice the prompt once at the parent so the rendered TaskPromptSection
|
||||
// and the footer's "ctrl+f to show more" hint share the same source of
|
||||
// truth. Counting `data.taskPrompt.split('\n').length` would only see hard
|
||||
// newlines and miss soft-wrapped overflow, so a long single-line prompt
|
||||
// could be visually truncated without surfacing the hint.
|
||||
const promptChildWidth = Math.max(1, childWidth - 2);
|
||||
const slicedPrompt = useMemo(
|
||||
() =>
|
||||
sliceTextByVisualHeight(
|
||||
data.taskPrompt,
|
||||
maxTaskPromptLines,
|
||||
promptChildWidth,
|
||||
{ minHeight: 1, overflowDirection: 'bottom' },
|
||||
),
|
||||
[data.taskPrompt, maxTaskPromptLines, promptChildWidth],
|
||||
);
|
||||
|
||||
const footerText = React.useMemo(() => {
|
||||
// This component only listens to keyboard shortcut events when the subagent is running
|
||||
if (data.status !== 'running') return '';
|
||||
|
||||
if (displayMode === 'default') {
|
||||
const hasMoreLines = slicedPrompt.hiddenLinesCount > 0;
|
||||
const hasMoreToolCalls =
|
||||
data.toolCalls && data.toolCalls.length > maxToolCalls;
|
||||
|
||||
if (hasMoreToolCalls || hasMoreLines) {
|
||||
return 'Press ctrl+e to show less, ctrl+f to show more.';
|
||||
}
|
||||
return 'Press ctrl+e to show less.';
|
||||
}
|
||||
|
||||
if (displayMode === 'verbose') {
|
||||
return 'Press ctrl+f to show less.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [
|
||||
displayMode,
|
||||
data.status,
|
||||
data.toolCalls,
|
||||
slicedPrompt.hiddenLinesCount,
|
||||
maxToolCalls,
|
||||
]);
|
||||
|
||||
// Handle keyboard shortcuts to control display mode. Scope the listener to
|
||||
// the running subagent that currently holds focus — `data.status` rules
|
||||
// out completed/historical instances mounted in scrollback, and
|
||||
// `isFocused` rules out *parallel* running subagents that share the live
|
||||
// viewport. Without the focus gate, two SubAgents running side by side
|
||||
// would both toggle on a single Ctrl+E / Ctrl+F press and the resulting
|
||||
// dual-reflow brings back the flicker this component is meant to
|
||||
// prevent.
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
// ctrl+e toggles between compact and default
|
||||
setDisplayMode((current) =>
|
||||
current === 'compact' ? 'default' : 'compact',
|
||||
);
|
||||
} else if (key.ctrl && key.name === 'f') {
|
||||
// ctrl+f toggles between default and verbose
|
||||
setDisplayMode((current) =>
|
||||
current === 'default' ? 'verbose' : 'default',
|
||||
);
|
||||
}
|
||||
},
|
||||
{ isActive: data.status === 'running' && isFocused },
|
||||
);
|
||||
|
||||
if (displayMode === 'compact') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Header: Agent name and status */}
|
||||
{!data.pendingConfirmation && (
|
||||
<Box flexDirection="row">
|
||||
<Text bold color={agentColor}>
|
||||
{data.subagentName}
|
||||
</Text>
|
||||
<StatusDot status={data.status} />
|
||||
<StatusIndicator status={data.status} />
|
||||
{data.status === 'background' && <BackgroundManageHint />}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Running state: Show current tool call and progress */}
|
||||
{data.status === 'running' && (
|
||||
<>
|
||||
{/* Current tool call */}
|
||||
{data.toolCalls && data.toolCalls.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<ToolCallItem
|
||||
toolCall={data.toolCalls[data.toolCalls.length - 1]}
|
||||
compact={true}
|
||||
/>
|
||||
{/* Show count of additional tool calls if there are more than 1 */}
|
||||
{data.toolCalls.length > 1 && !data.pendingConfirmation && (
|
||||
<Box flexDirection="row" paddingLeft={4}>
|
||||
<Text color={theme.text.secondary}>
|
||||
+{data.toolCalls.length - 1} more tool calls (ctrl+e to
|
||||
expand)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Inline approval prompt when awaiting confirmation */}
|
||||
{data.pendingConfirmation && (
|
||||
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
|
||||
{isWaitingForOtherApproval && (
|
||||
<Box marginBottom={0}>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
⏳ Waiting for other approval...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={data.pendingConfirmation}
|
||||
isFocused={isFocused}
|
||||
availableTerminalHeight={availableHeight}
|
||||
contentWidth={childWidth - 4}
|
||||
compactMode={true}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Completed state: Show summary line */}
|
||||
{data.status === 'completed' && data.executionSummary && (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Execution Summary: {data.executionSummary.totalToolCalls} tool
|
||||
uses · {data.executionSummary.totalTokens.toLocaleString()} tokens
|
||||
· {fmtDuration(data.executionSummary.totalDurationMs)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Failed/Cancelled state: Show error reason */}
|
||||
{data.status === 'failed' && (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color={theme.status.error}>
|
||||
Failed: {data.terminateReason}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Default and verbose modes use normal layout
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} gap={1}>
|
||||
{/* Header with subagent name and status */}
|
||||
<Box flexDirection="row">
|
||||
<Text bold color={agentColor}>
|
||||
{data.subagentName}
|
||||
</Text>
|
||||
<StatusDot status={data.status} />
|
||||
<StatusIndicator status={data.status} />
|
||||
{data.status === 'background' && <BackgroundManageHint />}
|
||||
</Box>
|
||||
|
||||
{/* Task description */}
|
||||
<TaskPromptSection
|
||||
slicedPrompt={slicedPrompt}
|
||||
displayMode={displayMode}
|
||||
maxVisualLines={maxTaskPromptLines}
|
||||
/>
|
||||
|
||||
{/* Progress section for running tasks */}
|
||||
{data.status === 'running' &&
|
||||
data.toolCalls &&
|
||||
data.toolCalls.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<ToolCallsList
|
||||
toolCalls={data.toolCalls}
|
||||
displayMode={displayMode}
|
||||
maxToolCalls={maxToolCalls}
|
||||
childWidth={childWidth - 2}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Inline approval prompt when awaiting confirmation */}
|
||||
{data.pendingConfirmation && (
|
||||
<Box flexDirection="column">
|
||||
{isWaitingForOtherApproval && (
|
||||
<Box marginBottom={0}>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
⏳ Waiting for other approval...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={data.pendingConfirmation}
|
||||
config={config}
|
||||
isFocused={isFocused}
|
||||
availableTerminalHeight={availableHeight}
|
||||
contentWidth={childWidth - 4}
|
||||
compactMode={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Results section for completed/failed tasks */}
|
||||
{(data.status === 'completed' ||
|
||||
data.status === 'failed' ||
|
||||
data.status === 'cancelled') && (
|
||||
<ResultsSection
|
||||
data={data}
|
||||
displayMode={displayMode}
|
||||
maxToolCalls={maxToolCalls}
|
||||
childWidth={childWidth - 2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer with keyboard shortcuts */}
|
||||
{footerText && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>{footerText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Task prompt section. Receives the already-sliced prompt from the parent so
|
||||
* footer hint and section content share one source of truth for whether
|
||||
* content was hidden (covers soft-wrapped overflow in addition to explicit
|
||||
* newlines).
|
||||
*/
|
||||
const TaskPromptSection: React.FC<{
|
||||
slicedPrompt: { text: string; hiddenLinesCount: number };
|
||||
displayMode: DisplayMode;
|
||||
maxVisualLines: number;
|
||||
}> = ({ slicedPrompt, displayMode, maxVisualLines }) => {
|
||||
const shouldTruncate = slicedPrompt.hiddenLinesCount > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.primary}>Task Detail: </Text>
|
||||
{shouldTruncate && displayMode !== 'compact' && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
Showing the first {maxVisualLines} visual lines.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingLeft={1}>
|
||||
<Text wrap="wrap">{slicedPrompt.text}</Text>
|
||||
</Box>
|
||||
{slicedPrompt.hiddenLinesCount > 0 && (
|
||||
<Box paddingLeft={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
... last {slicedPrompt.hiddenLinesCount} task line
|
||||
{slicedPrompt.hiddenLinesCount === 1 ? '' : 's'} hidden ...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Status dot component with similar height as text
|
||||
*/
|
||||
const StatusDot: React.FC<{
|
||||
status: AgentResultDisplay['status'];
|
||||
}> = ({ status }) => (
|
||||
<Box marginLeft={1} marginRight={1}>
|
||||
<Text color={getStatusColor(status)}>●</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Status indicator component
|
||||
*/
|
||||
const StatusIndicator: React.FC<{
|
||||
status: AgentResultDisplay['status'];
|
||||
}> = ({ status }) => {
|
||||
const color = getStatusColor(status);
|
||||
const text = getStatusText(status);
|
||||
return <Text color={color}>{text}</Text>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool calls list - format consistent with ToolInfo in ToolMessage.tsx
|
||||
*/
|
||||
const ToolCallsList: React.FC<{
|
||||
toolCalls: AgentResultDisplay['toolCalls'];
|
||||
displayMode: DisplayMode;
|
||||
maxToolCalls: number;
|
||||
childWidth: number;
|
||||
}> = ({ toolCalls, displayMode, maxToolCalls, childWidth }) => {
|
||||
const calls = toolCalls || [];
|
||||
const displayLimit = Math.max(1, Math.floor(maxToolCalls));
|
||||
const shouldTruncate = calls.length > displayLimit;
|
||||
const displayCalls = calls.slice(-displayLimit);
|
||||
|
||||
// Reverse the order to show most recent first
|
||||
const reversedDisplayCalls = [...displayCalls].reverse();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>Tools:</Text>
|
||||
{shouldTruncate && displayMode !== 'compact' && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
Showing the last {displayCalls.length} of {calls.length} tools.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{reversedDisplayCalls.map((toolCall, index) => (
|
||||
<ToolCallItem
|
||||
key={`${toolCall.name}-${index}`}
|
||||
toolCall={toolCall}
|
||||
childWidth={childWidth}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual tool call item - consistent with ToolInfo format
|
||||
*/
|
||||
const ToolCallItem: React.FC<{
|
||||
toolCall: {
|
||||
name: string;
|
||||
status: 'executing' | 'awaiting_approval' | 'success' | 'failed';
|
||||
error?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: string;
|
||||
resultDisplay?: string;
|
||||
description?: string;
|
||||
};
|
||||
compact?: boolean;
|
||||
childWidth?: number;
|
||||
}> = ({ toolCall, compact = false, childWidth = 80 }) => {
|
||||
const STATUS_INDICATOR_WIDTH = 3;
|
||||
const textWidth = Math.max(8, childWidth - STATUS_INDICATOR_WIDTH - 1);
|
||||
|
||||
// Map subagent status to ToolCallStatus-like display
|
||||
const statusIcon = React.useMemo(() => {
|
||||
const color = getStatusColor(toolCall.status);
|
||||
switch (toolCall.status) {
|
||||
case 'executing':
|
||||
return <Text color={color}>⊷</Text>; // Using same as ToolMessage
|
||||
case 'awaiting_approval':
|
||||
return <Text color={theme.status.warning}>?</Text>;
|
||||
case 'success':
|
||||
return <Text color={color}>✓</Text>;
|
||||
case 'failed':
|
||||
return (
|
||||
<Text color={color} bold>
|
||||
x
|
||||
</Text>
|
||||
);
|
||||
default:
|
||||
return <Text color={color}>o</Text>;
|
||||
}
|
||||
}, [toolCall.status]);
|
||||
|
||||
const description = React.useMemo(() => {
|
||||
if (!toolCall.description) return '';
|
||||
const firstLine = toolCall.description.split('\n')[0];
|
||||
return truncateToVisualWidth(firstLine, textWidth);
|
||||
}, [toolCall.description, textWidth]);
|
||||
|
||||
// Get first line of resultDisplay for truncated output
|
||||
const truncatedOutput = React.useMemo(() => {
|
||||
if (!toolCall.resultDisplay) return '';
|
||||
const firstLine = toolCall.resultDisplay.split('\n')[0];
|
||||
return truncateToVisualWidth(firstLine, textWidth);
|
||||
}, [toolCall.resultDisplay, textWidth]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1} marginBottom={0}>
|
||||
{/* First line: status icon + tool name + description (consistent with ToolInfo) */}
|
||||
<Box flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusIcon}</Box>
|
||||
<Text wrap="truncate-end">
|
||||
<Text>{toolCall.name}</Text>{' '}
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
{toolCall.error && (
|
||||
<Text color={theme.status.error}> - {toolCall.error}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Second line: truncated returnDisplay output - hidden in compact mode */}
|
||||
{!compact && truncatedOutput && (
|
||||
<Box flexDirection="row" paddingLeft={STATUS_INDICATOR_WIDTH}>
|
||||
<Text color={theme.text.secondary}>{truncatedOutput}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execution summary details component
|
||||
*/
|
||||
const ExecutionSummaryDetails: React.FC<{
|
||||
data: AgentResultDisplay;
|
||||
displayMode: DisplayMode;
|
||||
}> = ({ data, displayMode: _displayMode }) => {
|
||||
const stats = data.executionSummary;
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Text color={theme.text.secondary}>• No summary available</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Text>
|
||||
• <Text>Duration: {fmtDuration(stats.totalDurationMs)}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
• <Text>Rounds: {stats.rounds}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
• <Text>Tokens: {stats.totalTokens.toLocaleString()}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool usage statistics component
|
||||
*/
|
||||
const ToolUsageStats: React.FC<{
|
||||
executionSummary?: AgentStatsSummary;
|
||||
}> = ({ executionSummary }) => {
|
||||
if (!executionSummary) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Text color={theme.text.secondary}>• No tool usage data available</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Text>
|
||||
• <Text>Total Calls:</Text> {executionSummary.totalToolCalls}
|
||||
</Text>
|
||||
<Text>
|
||||
• <Text>Success Rate:</Text>{' '}
|
||||
<Text color={theme.status.success}>
|
||||
{executionSummary.successRate.toFixed(1)}%
|
||||
</Text>{' '}
|
||||
(
|
||||
<Text color={theme.status.success}>
|
||||
{executionSummary.successfulToolCalls} success
|
||||
</Text>
|
||||
,{' '}
|
||||
<Text color={theme.status.error}>
|
||||
{executionSummary.failedToolCalls} failed
|
||||
</Text>
|
||||
)
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Results section for completed executions - matches the clean layout from the image
|
||||
*/
|
||||
const ResultsSection: React.FC<{
|
||||
data: AgentResultDisplay;
|
||||
displayMode: DisplayMode;
|
||||
maxToolCalls: number;
|
||||
childWidth: number;
|
||||
}> = ({ data, displayMode, maxToolCalls, childWidth }) => (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* Tool calls section - clean list format */}
|
||||
{data.toolCalls && data.toolCalls.length > 0 && (
|
||||
<ToolCallsList
|
||||
toolCalls={data.toolCalls}
|
||||
displayMode={displayMode}
|
||||
maxToolCalls={maxToolCalls}
|
||||
childWidth={childWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Execution Summary section - hide when cancelled */}
|
||||
{data.status === 'completed' && (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>Execution Summary:</Text>
|
||||
</Box>
|
||||
<ExecutionSummaryDetails data={data} displayMode={displayMode} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Tool Usage section - hide when cancelled */}
|
||||
{data.status === 'completed' && data.executionSummary && (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={theme.text.primary}>Tool Usage:</Text>
|
||||
</Box>
|
||||
<ToolUsageStats executionSummary={data.executionSummary} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error reason for failed tasks */}
|
||||
{data.status === 'cancelled' && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.status.warning}>⏹ User Cancelled</Text>
|
||||
</Box>
|
||||
)}
|
||||
{data.status === 'failed' && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.status.error}>Task Failed: </Text>
|
||||
<Text color={theme.status.error}>{data.terminateReason}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -16,6 +16,7 @@ import { BtwMessage } from '../components/messages/BtwMessage.js';
|
|||
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
|
||||
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
|
||||
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
|
||||
import { LiveAgentPanel } from '../components/background-view/LiveAgentPanel.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useAgentViewState } from '../contexts/AgentViewContext.js';
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue