mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-16 04:05:58 +00:00
fix(tui): match swarm card styling to AgentGroup conventions and fix empty task
This commit is contained in:
parent
e873370920
commit
0d11fbc097
3 changed files with 117 additions and 51 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue