fix(tui): match swarm card styling to AgentGroup conventions and fix empty task

This commit is contained in:
Kaiyi 2026-05-29 19:42:33 +08:00
parent e873370920
commit 0d11fbc097
3 changed files with 117 additions and 51 deletions

View file

@ -11,12 +11,12 @@ import chalk from 'chalk';
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';
import { BRAILLE_SPINNER_FRAMES, COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering';
import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering';
import {
STREAMING_ARGS_FIELD_RE,
STREAMING_ARGS_PREVIEW_MAX_CHARS,
} from '#/tui/constant/streaming';
import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import type { ColorPalette } from '#/tui/theme/colors';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk';
@ -507,10 +507,11 @@ export class ToolCallComponent extends Container {
//
// Populated only when this tool call is the `Swarm` coordinator. The pure
// reducer in `swarm-dashboard-model` folds `applySwarm(event)` into this
// model; the body-building path renders the dashboard (phase line + one row
// per worker) instead of the normal progress/sub-tool/subagent blocks. A
// static spinner frame keeps the rendered lines stable so pi-tui's
// differential renderer never re-emits the card into scrollback.
// model; the body-building path renders the dashboard (one or two gutter
// lines per worker, mirroring `AgentGroupComponent`) instead of the normal
// progress/sub-tool/subagent blocks. No animated, per-render content is used
// so the rendered lines stay stable and pi-tui's differential renderer never
// re-emits the card into scrollback.
private swarmModel: SwarmModel | undefined;
/**
@ -1164,70 +1165,104 @@ export class ToolCallComponent extends Container {
// ── Swarm dashboard rendering ────────────────────────────────────
//
// A static spinner frame is used (rather than an animated, per-render frame)
// so the rendered lines stay identical across consecutive renders — the
// property that lets pi-tui's differential renderer keep one stable card.
// The swarm card mirrors `AgentGroupComponent`'s gutter/indent/color
// vocabulary. No animated, per-render content is used so the rendered lines
// stay identical across consecutive renders — the property that lets
// pi-tui's differential renderer keep one stable card.
private swarmSpinner(): string {
return BRAILLE_SPINNER_FRAMES[0]!;
}
/** Single-line header for the Swarm card (carried by `headerText`). */
/**
* Single-line header for the Swarm card (carried by `headerText`). Mirrors
* `AgentGroupComponent.buildHeader`: a status bullet (roleAssistant while
* active, success when terminal), the bold `Swarm` label, a dim `· title`
* segment (omitted when empty so no dangling `·`), and a dim phase/summary
* tail. The displayed task is sourced live from the tool-call args rather
* than the stale model so it reflects the fully-streamed task string.
*/
private buildSwarmHeader(): string {
const c = this.colors;
const m = this.swarmModel;
if (m === undefined) return '';
const title = m.task.length > 56 ? `${m.task.slice(0, 56)}` : m.task;
if (m.phase === 'done' || m.phase === 'cancelled') {
const bullet = chalk.hex(c.success)(STATUS_BULLET);
const rawTask = str(this.toolCall.args['task']);
const title = rawTask.length > 56 ? `${rawTask.slice(0, 56)}` : rawTask;
const label = chalk.hex(c.primary).bold('Swarm');
const titlePart = title.length > 0 ? chalk.dim(` · ${title}`) : '';
const terminal = m.phase === 'done' || m.phase === 'cancelled';
const bullet = terminal
? chalk.hex(c.success)(STATUS_BULLET)
: chalk.hex(c.roleAssistant)(STATUS_BULLET);
let tail: string;
if (terminal) {
const tag = m.phase === 'cancelled' ? ' · cancelled' : '';
const summary = `${String(m.workers.size)} workers · ${String(m.doneCount)}${String(m.failedCount)}${tag}`;
return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)} ${chalk.dim(`· ${summary}`)}`;
tail = chalk.dim(
` · ${String(m.workers.size)} workers · ${String(m.doneCount)}${String(m.failedCount)}${tag}`,
);
} else if (m.phase === 'planning') {
tail = chalk.dim(' · planning…');
} else if (m.phase === 'synthesizing') {
tail = chalk.dim(' · synthesizing…');
} else {
tail = chalk.dim(` · ${String(m.doneCount + m.failedCount)}/${String(m.total)} workers`);
}
const bullet = chalk.hex(c.roleAssistant)(STATUS_BULLET);
return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)}`;
return `${bullet}${label}${titlePart}${tail}`;
}
/** Renders the swarm phase line and one row per worker into the body. */
/**
* Renders one or two gutter lines per worker into the body, mirroring
* `AgentGroupComponent.appendLines` (the `├─`/`└─`/`` vocabulary, the
* 2-space lead, and the dim/primary/error coloring). While still planning
* with no workers yet, a single dim placeholder line keeps the card from
* rendering blank.
*/
private buildSwarmBody(): void {
const m = this.swarmModel;
if (m === undefined) return;
if (m.phase !== 'done' && m.phase !== 'cancelled') {
const phases = [
`Plan ${m.phase === 'planning' ? this.swarmSpinner() : '✓'}`,
`Workers ${String(m.doneCount + m.failedCount)}/${String(m.total)}`,
`Synthesize ${
m.phase === 'synthesizing'
? this.swarmSpinner()
: m.phase === 'working' || m.phase === 'planning'
? '·'
: '✓'
}`,
].join(' ');
this.addChild(new Text(` ${chalk.dim(phases)}`, 0, 0));
}
for (const w of m.workers.values()) {
this.addChild(new Text(this.buildSwarmWorkerLine(w), 0, 0));
const workers = [...m.workers.values()];
if (m.phase === 'planning' && workers.length === 0) {
this.addChild(new Text(` ${chalk.dim('└─ planning subtasks…')}`, 0, 0));
return;
}
workers.forEach((w, idx) => {
const isLast = idx === workers.length - 1;
for (const line of this.buildSwarmWorkerLine(w, isLast)) {
this.addChild(new Text(line, 0, 0));
}
});
}
private buildSwarmWorkerLine(w: WorkerRow): string {
/**
* Builds the gutter lines for one worker. Line 1 carries the branch, the
* role, and a dim stats tail; line 2 (omitted once the worker is done)
* carries the latest activity or the failure reason. Matches
* `AgentGroupComponent`'s two-line gutter format.
*/
private buildSwarmWorkerLine(w: WorkerRow, isLast: boolean): string[] {
const c = this.colors;
const branch1 = isLast ? '└─' : '├─';
const branch2 = isLast ? ' ' : '│ ';
const role = chalk.hex(c.primary)(w.role);
if (w.status === 'failed') {
return ` ${chalk.hex(c.error)(FAILURE_MARK)}${role} ${chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`)}`;
}
let statsPart = '';
if (w.status === 'done') {
const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : '';
return ` ${chalk.hex(c.success)('✓ ')}${role} ${chalk.dim(`${String(w.toolCount)} calls${tok}`)}`;
statsPart = chalk.dim(` · ${String(w.toolCount)} calls${tok}`);
} else if (w.status === 'retrying') {
statsPart = chalk.dim(' · retrying…');
} else if (w.status === 'running' && w.toolCount > 0) {
statsPart = chalk.dim(` · ${String(w.toolCount)} calls`);
}
if (w.status === 'retrying') {
return ` ${chalk.hex(c.roleAssistant)('⟳ ')}${role} ${chalk.dim('retrying…')}`;
const line1 = ` ${branch1} ${role}${statsPart}`;
if (w.status === 'done') {
return [line1];
}
if (w.status === 'failed') {
const errLine = chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`);
return [line1, ` ${branch2} ${errLine}`];
}
const raw = w.latestActivity ?? 'starting…';
const activity =
raw.length > SWARM_ACTIVITY_MAX_LENGTH ? `${raw.slice(0, SWARM_ACTIVITY_MAX_LENGTH)}` : raw;
return ` ${chalk.hex(c.roleAssistant)(this.swarmSpinner())} ${role} ${chalk.dim(`now: ${activity}`)}`;
return [line1, ` ${branch2} ${chalk.dim(`now: ${activity}`)}`];
}
private rebuildContent(): void {

View file

@ -98,7 +98,8 @@ describe('swarm dashboard wiring (translation)', () => {
const out = strip(dash.render(80).join('\n'));
expect(out).toContain('Researcher');
expect(out).toContain('boom');
expect(out).toMatch(/Workers 1\/1/);
// Active header tail reports worker progress (1 of 1 terminal).
expect(out).toMatch(/1\/1 workers/);
});
it('counts only real workers — planner/synthesizer/retry never become rows', () => {

View file

@ -35,7 +35,7 @@ describe('ToolCallComponent swarm mode', () => {
expect(read.render(80).join('\n')).toBe(before);
});
it('renders phase header and worker rows', () => {
it('renders the header and worker rows with the AgentGroup gutter style', () => {
const c = makeSwarm('compare error handling');
c.applySwarm({ t: 'planned', total: 2 });
c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Researcher' });
@ -49,6 +49,21 @@ describe('ToolCallComponent swarm mode', () => {
expect(out).toContain('Researcher');
expect(out).toContain('read foo.ts');
expect(out).toContain('Analyst');
// Active header tail reports worker progress (1 of 2 terminal).
expect(out).toContain('1/2 workers');
// Mirrors AgentGroup's gutter vocabulary: branch glyphs + "now:" activity.
expect(out).toContain('├─ Researcher');
expect(out).toContain('now: read foo.ts');
// The last worker (done) uses the closing branch and shows its call stats.
expect(out).toContain('└─ Analyst');
expect(out).toContain('1.8k tok');
});
it('shows a dim planning placeholder before any workers spawn', () => {
const c = makeSwarm('explore the repo');
const out = strip(c.render(80).join('\n'));
expect(out).toContain('planning…');
expect(out).toContain('└─ planning subtasks…');
});
it('produces byte-identical output across consecutive renders (stability)', () => {
@ -63,14 +78,14 @@ describe('ToolCallComponent swarm mode', () => {
expect(c.render(80).join('\n')).toBe(c.render(80).join('\n'));
});
it('shows a failed worker with its error', () => {
it('shows a failed worker with its error on the second gutter line', () => {
const c = makeSwarm('t');
c.applySwarm({ t: 'planned', total: 1 });
c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Scan' });
c.applySwarm({ t: 'worker.failed', id: 'a1', error: 'timeout' });
const out = strip(c.render(80).join('\n'));
expect(out).toContain('Scan');
expect(out).toContain('timeout');
expect(out).toContain('└─ Scan');
expect(out).toContain('failed: timeout');
});
it('finalizes to a cancelled header on an error result', () => {
@ -110,4 +125,19 @@ describe('ToolCallComponent swarm mode', () => {
expect(out).toMatch(/1 workers/);
expect(out).toContain('1✓');
});
it('reflects a task supplied via tool-call args after empty-args construction', () => {
// The coordinator's `tool.call.started` fires before the streamed args
// finish, so the task is empty at construction time. The header must read
// the live task from the tool call once `updateToolCall` syncs it.
const c = new ToolCallComponent(
{ id: 'tc-swarm', name: 'Swarm', args: {} },
undefined,
darkColors,
);
c.updateToolCall({ id: 'tc-swarm', name: 'Swarm', args: { task: 'explore the repo' } });
c.applySwarm({ t: 'planned', total: 2 });
const out = strip(c.render(80).join('\n'));
expect(out).toContain('explore the repo');
});
});