feat(cli): dense inline panel + keyboard navigation for parallel agent fan-out#4477
Conversation
When a tool group is composed entirely of multiple `task_execution`
agent invocations (the shape produced by `/review`'s 9-agent fan-out),
`CompactToolGroupDisplay` collapses the whole group into one line —
`Agent × 9 / <last agent's name>` — even though the agents may run
for minutes each. Status, current activity, elapsed, and tokens are
all hidden, so the most informative span of a multi-agent run is
also the most opaque.
Route those groups to a new `InlineParallelAgentsDisplay` that
surfaces every agent on its own row:
╭─ Parallel agents · 9 · 3/9 done ───────────────────────╮
│ ○ Agent 1: Correctness ReadFile server.ts 3m 38s · 7.0k tok
│ ✔ Agent 2: Security 12s · 8.1k tok
│ ○ Agent 3: Code Quality ReadFile index.ts 3m 38s · 9.0k tok
│ ...
╰────────────────────────────────────────────────────────╯
Each row pulls activity / elapsed live from `BackgroundTaskRegistry`
on a 1s tick (same pattern LiveAgentPanel uses). When the registry
unregisters a finished foreground subagent, the row falls back to
`AgentResultDisplay.executionSummary` for elapsed + tokens so the
column doesn't blank out the moment an agent completes.
A new `InlineAgentClaimContext` lets the inline display claim the
agentIds it's rendering; `LiveAgentPanel` filters those out so the
same agent never appears in both surfaces. Claims are refcounted so
React's commit/cleanup interleave doesn't transiently drop a claim
while the same id is being re-claimed by a remount.
Routing conditions in `ToolGroupMessage`:
- Live phase only (`isPending`). Once the group commits to
`<Static>`, the expanded path with `SubagentScrollbackSummary`
owns the permanent record.
- Group has ≥2 calls and ONLY agent calls — mixed groups keep
the legacy renderer so sibling tools stay visible.
- No pending confirmation — those go through the normal renderer
so the keyboard-focus surface for approval stays intact.
Verified visually in tmux with `/review <pr-url> --comment` against
qwen-code PR 4472: all 9 agents render with live status / activity /
elapsed / tokens, transition glyphs ○→✔ in place, and the footer
LiveAgentPanel correctly suppresses the duplicate rows.
11 new tests + all 57 prior tests on touched files pass.
📋 Review SummaryThis PR introduces 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
wenshao
left a comment
There was a problem hiding this comment.
Summary
Well-structured feature PR that adds a dense inline panel for parallel agent fan-out. The architecture is sound — claim context prevents duplicate rows across surfaces, the 1s tick pattern mirrors LiveAgentPanel, and the routing conditions are clearly documented. tsc clean, ESLint clean, 12 new tests + 57 existing tests all pass.
Two test gaps flagged below as Suggestions; six additional low-confidence items noted in the terminal review output.
— qwen3.7-max via Qwen Code /review
| // suppresses the same rows — no duplication across surfaces. | ||
| const isPureAgentGroup = | ||
| toolCalls.length >= 2 && toolCalls.every(isSubagentToolEntry); | ||
| if (isPending && isPureAgentGroup && !hasSubagentPendingConfirmation) { |
There was a problem hiding this comment.
[Suggestion] The routing branch isPending && isPureAgentGroup && !hasSubagentPendingConfirmation is the primary integration point for the feature but has no test coverage. The existing ToolGroupMessage.test.tsx (32 tests) does not exercise any of the five predicate conditions: (a) isPending=true + pure agent group routes to InlineParallelAgentsDisplay; (b) isPending=false falls through; (c) mixed groups fall through; (d) single agent falls through; (e) pending confirmation falls through.
A regression in any predicate would silently skip the new panel or incorrectly route groups. Consider adding tests for at least the happy path and two fall-through cases.
— qwen3.7-max via Qwen Code /review
- Split InlineAgentClaimContext into read + write contexts so claimers (InlineParallelAgentsDisplay) don't re-render every time some unrelated inline display claims its own ids. The write API now has a stable identity for the lifetime of the provider; only readers (LiveAgentPanel) react to claim-set changes. Add a test that locks in the stability invariant so a future memo on `[version]` is caught before it ships. - Extract `isPureParallelAgentGroup` predicate in ToolGroupMessage alongside the existing `isSubagentToolEntry` / `isTerminalSubagentTool` helpers so the routing intent reads at the call site instead of being spelled out inline. - Document that `recentActivities` is mutated in place by the registry; the rows useMemo's `now`-keyed re-read is what surfaces the latest entry, so `activityLabel` must treat the value as a tick-snapshot rather than closing over the live array. - Document the exhaustiveness of the `hasLiveAgent` status check (`running | background`) against the upstream `AgentResultDisplay.status` union so a future status addition is caught. - Document why `NAME_COL_WIDTH = 26` (fits /review's longest agent label at full length on typical 100-col widths) instead of leaving it as a magic number. - Add an inline comment in gemini.tsx explaining why InlineAgentClaimProvider sits inside BackgroundTaskViewProvider (visible to both surfaces, no provider-boundary crossing).
wenshao
left a comment
There was a problem hiding this comment.
Second-opinion review (glm-5.1). 9 agents + verification + reverse audit. No Critical issues found. 3 new Suggestions below — code duplication, test gaps for the 1s timer tick, and missing dev-mode logging for registry misses. Prior suggestions (ToolGroupMessage routing tests, LiveAgentPanel isClaimed filter tests) remain open.
| tokenCount?: number; | ||
| } | ||
|
|
||
| // Internal tool name → display name lookup (mirrors LiveAgentPanel so |
There was a problem hiding this comment.
[Suggestion] TOOL_DISPLAY_BY_NAME and activityLabel are structurally identical to their counterparts in LiveAgentPanel.tsx. The comment says "mirrors LiveAgentPanel" but no extraction was done.
If ToolNames/ToolDisplayNames mapping changes or activity formatting logic evolves, both sites must be kept in sync manually — a silent divergence would make the two panels render the same tool differently.
| // Internal tool name → display name lookup (mirrors LiveAgentPanel so | |
| // Extract to a shared utility, e.g. packages/cli/src/ui/utils/toolDisplayUtils.ts: | |
| // export const TOOL_DISPLAY_BY_NAME = Object.fromEntries( | |
| // (Object.keys(ToolNames) as Array<keyof typeof ToolNames>).map((key) => [ | |
| // ToolNames[key], ToolDisplayNames[key], | |
| // ]), | |
| // ); | |
| // export function formatActivityLabel(name: string, description?: string): string { ... } |
— glm-5.1 via Qwen Code /review
| ms = Math.max(0, end - row.startTime); | ||
| } else if (row.fallbackElapsedMs !== undefined) { | ||
| ms = Math.max(0, row.fallbackElapsedMs); | ||
| } |
There was a problem hiding this comment.
[Suggestion] The 1-second setInterval tick that drives live activity/elapsed updates is never exercised in tests. vi.useFakeTimers() is set up in beforeEach but vi.advanceTimersByTime(1000) is never called — the entire live-update loop is dead code in the test suite.
Additionally, statusGlyph branches for failed/cancelled/background (line ~175) and the startTime+endTime elapsed path (line ~200) are untested.
Suggested test additions:
- Render a running agent, assert initial elapsed, then
vi.advanceTimersByTime(1000)and assert elapsed updated - Render agents with
failed/cancelled/backgroundstatus and assert correct glyphs +doneCount - Provide a registry entry with both
startTimeandendTimeand assert fixed-duration elapsed
— glm-5.1 via Qwen Code /review
| const recent = live?.recentActivities?.at(-1); | ||
| return { | ||
| agentId, | ||
| callId: toolCall.callId, |
There was a problem hiding this comment.
[Suggestion] When registry?.get(agentId) returns undefined for a running agent (e.g. deriveAgentId diverges from the core construction, or the registry evicts the entry early), the row silently falls back to empty startTime/recentActivity with no diagnostic output. The panel renders correctly but shows stale/empty data — indistinguishable from "agents are slow."
Consider adding a dev-mode warning:
| callId: toolCall.callId, | |
| const rows: RowData[] = useMemo(() => { | |
| const registry = config?.getBackgroundTaskRegistry(); | |
| void now; | |
| if (process.env.NODE_ENV === 'development') { | |
| const liveButMissing = agentEntries | |
| .filter((e) => { | |
| const agentId = deriveAgentId(e.toolCall, e.result); | |
| return (e.result.status === 'running' || e.result.status === 'background') | |
| && registry?.get(agentId) === undefined; | |
| }); | |
| if (liveButMissing.length > 0) { | |
| console.warn( | |
| '[InlineParallelAgentsDisplay] %d running agents not found in registry;' | |
| + ' deriveAgentId may be out of sync', | |
| liveButMissing.length, | |
| ); | |
| } | |
| } |
— glm-5.1 via Qwen Code /review
…anel - Dense panel renders only in committed phase, replacing verbose per-agent ToolMessage expansion with one compact row per agent. - Live phase: pure parallel agent groups return null so LiveAgentPanel below the input is the sole live surface (fixes terminal agents rendering as verbose ToolMessages above input during live phase). - Remove InlineAgentClaimContext (no longer needed without live-phase inline panel).
- Show InlineParallelAgentsDisplay in both live and committed phases with all agents (running + completed). LiveAgentPanel below the composer also shows running agents during live phase (brief overlap that resolves as agents complete and expire from the panel). - Increase LiveAgentPanel DEFAULT_MAX_ROWS from 5 to 12 so all 9 /review agents are visible without truncation. - Add totalAgentCount prop to InlineParallelAgentsDisplay for correct header when rendering a subset of agents.
wenshao
left a comment
There was a problem hiding this comment.
5 Suggestions (4 test gaps + 1 comment accuracy) and 3 Nice to have items. No Critical issues. Build passes, 70 tests pass, tsc/ESLint clean.
— qwen3.7-max via Qwen Code /review
| return { glyph: '○', color: theme.status.warning }; | ||
| case 'completed': | ||
| return { glyph: '✔', color: theme.status.success }; | ||
| case 'failed': |
There was a problem hiding this comment.
[Suggestion] failed and cancelled arms of statusGlyph are never exercised by any test. The done-tally test only uses status: 'completed' — no test passes status: 'failed' or status: 'cancelled'. Consequently, the doneCount filter that counts failed/cancelled as terminal is also unverified.
Add a test with a mix of terminal statuses:
it('counts failed and cancelled agents in the done tally', () => {
const toolCalls = [
agentToolCall({ callId: 'c1', subagentName: 'g', taskDescription: 'A1', status: 'failed' }),
agentToolCall({ callId: 'c2', subagentName: 'g', taskDescription: 'A2', status: 'cancelled' }),
agentToolCall({ callId: 'c3', subagentName: 'g', taskDescription: 'A3', status: 'running' }),
];
const { lastFrame } = renderInline({ toolCalls });
expect(lastFrame() ?? '').toContain('2/3 done');
});— qwen3.7-max via Qwen Code /review
| // and the trailing suffix stay readable. | ||
| const NAME_COL_WIDTH = 26; | ||
|
|
||
| function truncateMiddle(input: string, max: number): string { |
There was a problem hiding this comment.
[Suggestion] truncateMiddle has three branches (input fits, max <= 1, and actual middle-truncation with Math.ceil/Math.floor head/tail math), but only the first branch is implicitly exercised — all test agent names are ≤26 characters, well under NAME_COL_WIDTH = 26.
Real /review agent labels like "Agent 6c: Maintainer review" exceed 26 chars and WILL trigger truncation. Add a test with a long name:
agentToolCall({ callId: 'c1', subagentName: 'g',
taskDescription: 'Agent 1: Correctness deep review of all modules' }),
// Assert frame contains '…' and the leading prefix portion— qwen3.7-max via Qwen Code /review
| ? { name: recent.name, description: recent.description } | ||
| : undefined, | ||
| tokenCount: | ||
| result.tokenCount ?? |
There was a problem hiding this comment.
[Suggestion] The token-count fallback chain result.tokenCount ?? live?.stats?.totalTokens ?? result.executionSummary?.totalTokens has three tiers, but only tier 3 is tested. Tier 1 (result.tokenCount) and tier 2 (live?.stats?.totalTokens from the registry for a running agent) are never exercised.
Tier 2 is the primary path for live running agents — the registry accumulates stats.totalTokens while result.tokenCount remains undefined until completion. Extend the "surfaces live activity" test's registry entry to include stats: { totalTokens: 5000 } and assert 5k tok appears. Separately, pass tokenCount: 800 through the seed and assert it takes priority.
— qwen3.7-max via Qwen Code /review
| (Object.keys(ToolNames) as Array<keyof typeof ToolNames>).map((key) => [ | ||
| ToolNames[key], | ||
| ToolDisplayNames[key], | ||
| ]), |
There was a problem hiding this comment.
[Suggestion] The JSDoc claims appendActivity "intentionally mutates that array in place…not by replacing the reference" — but background-tasks.ts:482-494 actually does the opposite:
const next = [...prior, activity]; // new array via spread
entry.recentActivities = next; // reassignment, not mutationThe entry object reference is stable but recentActivities is reassigned on every append. The rows memo still works because it re-reads registry.get(agentId).recentActivities.at(-1) on each tick, but the comment's reasoning about "in-place mutation" is factually wrong and would mislead a future maintainer attempting to optimize the memo.
Correct the comment to describe the actual mechanism.
— qwen3.7-max via Qwen Code /review
- Add "main" as first entry in LiveAgentPanel, followed by running agents - ↓ from Composer focuses LiveAgentPanel (selects "main") - ↓/↑ navigates between entries with ▸ selection indicator - Enter on agent opens BackgroundTasksDialog directly in detail mode - Esc or ↑-at-top returns focus to Composer - Printable chars auto-unfocus and type through to Composer - Simplify header from "Active agents (N/N)" to "Active agents (N)" - Add livePanelFocused + livePanelSelectedIndex state to BackgroundTaskViewContext, keyboard handling stays in InputPrompt
wenshao
left a comment
There was a problem hiding this comment.
6 Critical + 6 Suggestion findings in this round (4th opinion). All prior open comments acknowledged.
— qwen3.7-max via Qwen Code /review
| setLivePanelFocused(false); | ||
| return true; | ||
| } | ||
| if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { |
There was a problem hiding this comment.
[Suggestion] Two key-handling issues in this catch-all area:
- Ctrl+C swallowed: The
return trueon the next line silently consumes Ctrl+C. In raw terminal mode (Ink), Ctrl+C doesn't generate SIGINT — it's a regular keypress. Users navigating the agent panel can't abort with Ctrl+C without first pressing Esc. - Tab/Backspace match printable-char check:
key.sequence.length === 1matches\t(Tab),\x7f(Backspace), and other non-printable single-byte sequences. These unfocus the panel and fall through to BaseTextInput, potentially inserting a tab or deleting a character.
| if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { | |
| if (key.ctrl && key.name === 'c') { | |
| setLivePanelFocused(false); | |
| return false; | |
| } | |
| if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta | |
| && key.name !== 'tab' && key.name !== 'backspace' && key.name !== 'delete') { | |
| setLivePanelFocused(false); | |
| return false; | |
| } |
— qwen3.7-max via Qwen Code /review
| // input — so reverse here back to ASC for rendering. Doing the | ||
| // reverse before the slice means the tail-window math (drop the | ||
| // OLDEST when the list overflows) stays unchanged. | ||
| const visibleAgentsAsc = [...visibleAgents].reverse(); |
There was a problem hiding this comment.
[Suggestion] The diff deletes the 4-line comment explaining WHY [...visibleAgents].reverse() exists. The remaining code is a non-obvious decision — entries arrive newest-first from useBackgroundTaskView but the panel renders top-to-bottom in launch order (oldest at top, newest at bottom above the composer). Without this comment, a future cleanup could plausibly remove the .reverse() as "unnecessary."
| const visibleAgentsAsc = [...visibleAgents].reverse(); | |
| // Entries arrive newest-first from context; reverse so the panel | |
| // reads oldest→top, newest→bottom (above the composer). | |
| const visibleAgentsAsc = [...visibleAgents].reverse(); |
— qwen3.7-max via Qwen Code /review
| <AgentRow key={entry.agentId} entry={entry} now={now} /> | ||
| {visible.map((entry, idx) => ( | ||
| <AgentRow | ||
| key={entry.agentId} |
There was a problem hiding this comment.
[Suggestion] The hint text "^ N more above (↓ to view all)" was unchanged, but its meaning shifted with this PR. Previously ↓ from the composer opened the dialog. Now ↓ enters the panel's keyboard navigation, which does NOT scroll — overflow entries (the oldest agents, dropped by .slice(-maxRows)) are unreachable via keyboard. The hint should be updated (e.g., "Enter on main to view all in dialog") or the panel should support scroll-through-window during keyboard navigation.
— qwen3.7-max via Qwen Code /review
| * (e.g. only terminal agents during the live phase). When omitted, | ||
| * defaults to the number of agent entries in `toolCalls`. | ||
| */ | ||
| totalAgentCount?: number; |
There was a problem hiding this comment.
[Suggestion] totalAgentCount is declared as an optional prop with JSDoc, but the sole caller in ToolGroupMessage.tsx never passes it. The prop describes a scenario ("toolCalls is a subset") that no code path exercises. Per the project's simplicity-first principle, remove the prop and simplify to const total = rows.length. If a future caller needs it, add it then.
| totalAgentCount?: number; | |
| contentWidth: number; | |
| } |
— qwen3.7-max via Qwen Code /review
| agentId, | ||
| callId: toolCall.callId, | ||
| name: result.taskDescription || result.subagentName, | ||
| status: result.status, |
There was a problem hiding this comment.
[Suggestion] status: result.status reads from the static toolCalls prop, while activity/elapsed/tokens reconcile from the live registry on each tick. When an agent finishes and unregisterForeground fires, the registry entry disappears but this glyph stays ○ (running) until the parent re-renders with an updated toolCalls prop. During the same window, activity and elapsed are correctly refreshed from the registry — creating a visual contradiction: a "running" glyph next to stale activity.
Consider reading live?.status from the registry in the rows useMemo and preferring it over result.status when available.
— qwen3.7-max via Qwen Code /review
| {headerLabel} | ||
| </Text> | ||
| </Box> | ||
| {rows.map((row) => ( |
There was a problem hiding this comment.
[Suggestion] rows.map renders every agent without any maxRows cap or windowing. LiveAgentPanel has maxRows = 12 and windows from the tail. For a /review 9-agent fan-out during the live phase, the user sees a bordered 12-line panel here PLUS up to 12 more rows in LiveAgentPanel below the composer — 20+ terminal lines of duplicate content. On smaller terminal heights, conversation context is pushed far off-screen.
Consider adding a maxRows prop with the same tail-windowing pattern, or gate this component on !isPending so the live phase renders only LiveAgentPanel and the committed phase renders only this panel.
— qwen3.7-max via Qwen Code /review
Add livePanelFocused, livePanelSelectedIndex, enterBgDetailFromPanel, setBgSelectedIndex, bgEntries.length to InputPrompt handleInput deps. Add isDetailMode to BackgroundTasksDialog effect deps. Remove unused setBgPillFocused import.
- Use bgAgentCount (agent-only) instead of bgEntries.length for keyboard navigation bounds (was counting shells/monitors/dreams). - Auto-clear livePanelFocused when no agent entries remain (prevents stuck focus state when LiveAgentPanel returns null).
pomelo-nwu
left a comment
There was a problem hiding this comment.
Tested locally with tmux — looks great
Built the branch and ran a few parallel fan-outs in tmux. The dense inline panel + LiveAgentPanel coexistence works as advertised. Approving with a couple of small things to flag.
Screenshot 1 — Live phase (5 parallel agents, 4 done + 1 running)
╭──────────────────────────────────────────────────────────────────────────╮
│ Parallel agents · 5 · 4/5 done │
│ ✔ Search useState count 42s · 1.0k tok │
│ ○ Search useEffect count Glob '**/*.tsx' in path '/… 2m 8s · 921 tok │
│ ✔ Search useMemo count 51s · 2.3k tok │
│ ✔ Search useCallback count 41s · 1.2k tok │
│ ✔ Search useRef count 46s · 1.3k tok │
╰──────────────────────────────────────────────────────────────────────────╯
... (composer)
YOLO mode (Shift + Tab to toggle) · 1 local agent, 1 monitor
main
○ Search useEffect count (Glob '**/*.tsx' in path … ▶ 2m 8s · 182k tokens
Dense panel keeps all 5 rows with status / activity / elapsed / tokens; LiveAgentPanel below the composer correctly filters down to only the still-running one. The "brief visual overlap" you described in the PR is barely noticeable in practice — clean.
Screenshot 2 — Keyboard navigation (↓↓ from input)
main
▸ ○ Search useEffect count (Glob '**/*.tsx' in path… ▶ 2m 46s · 182k tokens
↑↓ navigate · Enter detail · Esc back
▸ indicator, the navigate hint line, and the focus transitions all render correctly.
Findings worth a look
1. CI Lint is red — must-fix before merge. This is what's failing CI, not a real test failure:
packages/cli/src/ui/components/InputPrompt.tsx
1301:5 warning React Hook useCallback has missing dependencies:
'bgEntries.length', 'enterBgDetail', 'livePanelFocused',
'livePanelSelectedIndex', 'setBgSelectedIndex', 'setLivePanelFocused',
'setLivePanelSelectedIndex'.
✖ 1 problem (0 errors, 1 warning)
ESLint found too many warnings (maximum: 0).
Easy fix; just add the missing deps (or intentionally suppress with a comment if any need to be stable).
2. Enter-to-detail mapping may be off when monitor tasks are mixed in. Repro: LiveAgentPanel ▸ selecting an agent row (grep useMemo in tsx), then Enter — but the detail that opened was a different [monitor] Wait for sleep 25 ... task, not the selected agent. The selectedIndex from the panel doesn't seem to map cleanly to the dialog's target when the background-task list contains both agents and monitors. Worth a second look — this is core to the new keyboard-navigation UX.
3. ← from detail doesn't return to panel selection (at least for monitor detail). PR description says: "← from detail: returns to LiveAgentPanel selection (not the dialog list)". In my repro ← took me back to the BackgroundTasksDialog list view instead. Could be that the detail-from-panel mode only kicks in for agents and monitors fall through to the old path — if so, worth a comment in the code.
4. Stale comment in InlineParallelAgentsDisplay.tsx:15. Header doc says "Rendered in the committed phase only; during the live phase LiveAgentPanel below the composer owns the per-agent roster." — but the implementation now renders in both phases (per commit 205fa024 "dense panel both phases"). Quick comment tidy.
Verdict
Core feature (dense panel) is a huge UX improvement over Agent × 9, design is right. Approving. The two keyboard-nav issues (#2, #3) aren't blockers for me — feature still useful without them — but please address before they become muscle memory.
中文说明(点开)
本地拉分支用 tmux 跑了几次并行 fan-out 验证,dense panel + LiveAgentPanel 同屏共存确实如描述所说。Approve,但有几点提一下:
截图 1:5 个并行 agent,4 个 ✔ 完成 + 1 个 ○ 进行中,dense panel 完整保留 5 行(含状态/activity/耗时/token),LiveAgentPanel 自动过滤只剩还在跑的那个。"brief visual overlap" 在实际使用中几乎不察觉,干净。
截图 2:键盘导航 ↓↓ 后 ▸ 指示器和 ↑↓ navigate · Enter detail · Esc back 提示都正常出现。
需要看一下的几点:
-
CI Lint 红灯,合并前必修 —— 不是测试失败,是
InputPrompt.tsx:1301的 useCallback 缺 7 个依赖,max-warnings 0把 CI 顶掉了。 -
Enter 进 detail 时索引疑似错位:LiveAgentPanel 上
▸明明选中的是 agent 行,Enter 后打开的却是 monitor 任务的 detail。当后台列表里 agent + monitor 混合时映射有问题,这是新键盘导航的核心 UX,值得排查。 -
← 返回不符合 PR 描述:PR 说"← from detail: returns to LiveAgentPanel selection (not the dialog list)",实测 ← 回到的是
BackgroundTasksDialoglist 而不是 panel。可能detail-from-panel模式只对 agent 生效,monitor 没走新路径。 -
文件头注释过时:
InlineParallelAgentsDisplay.tsx:15注释还写"Rendered in the committed phase only",但 commit205fa024已改成 both phases。
核心功能体验比旧 Agent × 9 提升明显,方向对。Approve。键盘导航的 2/3 不算 blocker(即使有问题主功能也已经有用),但建议尽快修复,避免变成肌肉记忆。
🧪 本地构建验证报告 (维护者)在隔离 worktree 中对 PR 合并后状态做了完整验证。整体方向 OK, 视觉效果与 PR mockup 一致, 但有一处关联测试遗漏需要补。 验证环境
静态检查
单元测试
|
| 维度 | 状态 |
|---|---|
| 构建 / 类型 / lint | ✅ 全清 |
| PR 自带 3 个测试文件 (63 用例) | ✅ 全过 |
| 视觉渲染 vs PR mockup | ✅ 三种场景帧对帧一致 |
| 真实 CLI 烟测 | ✅ 启动 / 无 agent 状态下 ↓ 不崩 |
关联测试 (AgentTabBar.test.tsx) |
建议: 补上 AgentTabBar.test.tsx 的 3 行 mock + 断言更新, 再 merge. 改动行为本身 (Ctrl+N → setLivePanelFocused) 与 PR 整体的"输入框 → LiveAgentPanel"导航链是一致的, 只是 PR 没把这个旁路 test 跟着更新, 而 vi.mock(...) + as never 联合压住了 TS 在 mock 不完整时本该报的错, 才让这个回归在作者本地的 typecheck/vitest run 改动文件 时没暴露出来.
Diff 本身约束良好, 实现按照 PR 描述的"≥2 agent / 无 non-agent tool / 无 approval pending"门控, LiveAgentPanel 与 InlineParallelAgentsDisplay 的双阶段渲染策略也符合 mockup, 整体可以 merge.
✅ 本地真实测试验证报告 (PR #4477)环境:Linux 6.12 / Node 22 / 本地 tmux + 真实模型(glm-4.7)/ 仓库已 checkout 到该 PR 分支 (HEAD 一、自动化测试
二、tmux 真机交互测试在 tmux (200×60) 中启动 1. 密集 inline 面板(
|
| 步骤 | 操作 | 实际渲染(捕获自 tmux) | 判断 |
|---|---|---|---|
| 1 | 输入框空,按 ↓ | ▸ main + 2 行 ○ 执行 50 次 grep 搜索 + 提示行 ↑↓ navigate · Enter detail · Esc back |
✅ 焦点正确进入面板,初始选中 main,提示行只在 focused 时出现 |
| 2 | 再按 ↓ | main 上无 ▸,第一个 agent 行变 ▸ ○ ... |
✅ 选择下移到 agent 1 |
| 3 | 再按 ↓ | ▸ 移到 agent 2 |
✅ 选择下移到 agent 2 |
| 4 | agent 2 行再按 ↓ | ▸ 仍停在 agent 2 |
✅ 边界 clamp 正确(if (livePanelSelectedIndex < maxIdx)) |
| 5 | 按 ↑ | ▸ 上移到 agent 1,且 activity 列变为实时内容 (Grep 'paint\s+\w+' in path ...) |
✅ 选择上移;同时确认 1s tick 持续刷新 activity |
| 6 | agent 行按 Enter | 弹出 BackgroundTasksDialog,标题 general-purpose › 执行 50 次 grep 搜索,含 Progress trail / Prompt 摘要 / token 计数,底部提示 ← back · Esc close · x stop |
✅ 进入 detail-from-panel 模式,enterBgDetailFromPanel() + 预选目标 agent 都生效 |
| 7 | 详情中按 ← | 关闭详情,回到面板,▸ 仍停在刚才那个 agent |
✅ exitDetail 在 'detail-from-panel' 模式下正确返回 panel(而非 list),保留选择 |
| 8 | Esc 路径 | 走代码 if (key.name === 'escape') { setLivePanelFocused(false); return true; } |
由于 grep agent 在按键之间完成、面板已自动 unfocus(hasAgentEntries 变 false 触发 setLivePanelFocusedRaw(false)),Esc 透传到了模型 turn 的取消路径。属于设计内行为:面板不在了就没什么好释放。代码路径本身正确 |
备注:截屏文件已保存到
/tmp/qwen-pr-test/nav-*.txt共 9 份,每一步的 tmux pane 都有原始捕获。
3. 视觉密度对比
旧版(被取代):Agent × 9 / <last name> 折叠成一行,N 分钟跑过去只看到一个名字。
新版(本 PR):N 行 × (状态 · 名字 · activity · 耗时 · token),9 行就完整覆盖 /review 全部并发面,信息密度提升一个数量级,且 elapsed/token 一直右对齐到边界,肉眼可扫。
三、Edge-case 复盘
- ✔ Pending approval 抑制 dense 面板:测试中 4 个 agent 同时被起,其中一个等审批,dense 面板自动让位给审批 UI(
isPureParallelAgentGroup && !hasSubagentPendingConfirmation守卫) - ✔ Queued approval 渲染:第二个 agent 在排队等待时显示
⏳ Queued approval: general-purpose,符合 first-come-first-served 锁 - ✔ 终态后 8s 内可见:单 agent 完成后短暂保留在 LiveAgentPanel 顶部(
TERMINAL_VISIBLE_MS = 8000),随后自动剔除 - ✔ 元数据兜底链:
tokenCount ?? live?.stats?.totalTokens ?? executionSummary?.totalTokens在 unregisterForeground 之后仍能正确展示 - ✔ 超长 agent 名字截断:
NAME_COL_WIDTH = 26+ middle-truncate 工作正常(本次测试未触发,但代码逻辑正确)
四、结论
| 维度 | 结论 |
|---|---|
| 自动化测试 | 全绿(64 单测 + tsc + eslint + build) |
| Dense 面板渲染 | live / committed 两阶段都正确,pending approval 时正确退让 |
| 键盘导航 | ↓ 入栈 / ↑↓ 移动 / 边界 clamp / Enter → 详情 / ← 回 panel / 选择保持 —— 全部按设计工作 |
| 与现有 LiveAgentPanel/BackgroundTasksDialog 集成 | 兼容,无重复 UI,detail-from-panel 新模式打通了 ← back 闭环 |
| 实测体验 | 显著优于旧版「Agent × N」一行;在 9-agent /review 场景下尤其有价值 |
建议:可合并。 没有发现回归或交互坑。
本验证在本地 tmux + 真实模型对话中完成;所有捕获原始数据可在 /tmp/qwen-pr-test/ 找到。
Why
Commands like
/reviewfan out into 9 parallel agents, each running for minutes. The old display had two problems:Agent × 9 / <last name>line — almost zero information for a multi-minute run.ToolMessagewith very low information density — many lines per agent with mostly redundant chrome.Additionally, there was no keyboard path from the input box to the running agents — users had to know about the hidden footer pill to access the background tasks dialog.
What changes
1. Dense inline panel (
InlineParallelAgentsDisplay)For tool groups composed entirely of ≥2 parallel agent invocations, render a dense panel with one row per agent showing status / name / activity / elapsed / tokens:
Display strategy:
○with live activity from the registry on a 1s tick; completed agents show✔with fallback data fromexecutionSummary.<Static>as the permanent scrollback record.2. LiveAgentPanel keyboard navigation
▸selection indicatorBackgroundTasksDialogdirectly in detail mode (full agent view)When this kicks in
Other changes
LiveAgentPanel.DEFAULT_MAX_ROWSincreased from 5 → 12.Active agents (N/N)toActive agents (N).totalAgentCountprop toInlineParallelAgentsDisplayfor correct header when rendering a subset.livePanelFocused,livePanelSelectedIndex,enterDetailFromPaneltoBackgroundTaskViewContext.detail-from-paneldialog mode so ← returns to panel instead of list.Test plan
npx tsc --noEmitclean.中文说明
为什么
/review等命令会并行启动 9 个 agent,每个运行数分钟。旧的显示有两个问题:Agent × 9 / <last name>,几乎没有信息。ToolMessage,显示密度很低。此外,从输入框到运行中的 agent 没有键盘导航路径。
改了什么
1. 密集内联面板(
InlineParallelAgentsDisplay)纯并行 agent 组(≥2 个),每个 agent 一行,显示状态/名称/活动/耗时/token。Live + Committed 两阶段都渲染。LiveAgentPanel 在下方同时显示运行中的 agent(短暂重复,完成后自动淘汰)。
2. LiveAgentPanel 键盘导航
mainmain和 agent 行间导航,选中行显示▸触发条件
其他改动
DEFAULT_MAX_ROWS5 → 12Active agents (N)detail-from-paneldialog 模式,← 回到 panel 而非 list效果截屏