Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subagent-card-stable-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep subagent cards at a stable height and show a live status spinner with a compact two-row activity window.
233 changes: 144 additions & 89 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { Component, TUI } from '@moonshot-ai/pi-tui';
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';
import {
BRAILLE_SPINNER_FRAMES,
BRAILLE_SPINNER_INTERVAL_MS,
COMMAND_PREVIEW_LINES,
RESULT_PREVIEW_LINES,
THINKING_PREVIEW_LINES,
Expand All @@ -33,16 +35,14 @@ import { ShellExecutionComponent } from './shell-execution';
import { countNonEmptyLines, pickChip } from './tool-renderers/chip';
import { buildGoalToolHeader } from './tool-renderers/goal';
import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry';
import { TruncatedOutputComponent } from './tool-renderers/truncated';

const MAX_ARG_LENGTH = 60;
const MAX_SUB_TOOL_CALLS_SHOWN = 4;
const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4;
// Hanging indent for a sub-tool's previewed output, nested under its activity row.
const SUBAGENT_SUBTOOL_OUTPUT_INDENT = 6;
// Cap the Agent `description` in the single-subagent header so a long prompt
// cannot wrap the header onto a second row and break the card's stable height.
const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60;
const APPROVED_PLAN_MARKER = '## Approved Plan:';
const STREAMING_PROGRESS_INTERVAL_MS = 1000;
const SUBAGENT_ELAPSED_INTERVAL_MS = 1000;
const PROGRESS_URL_RE = /https?:\/\/\S+/g;
const ABORTED_MARK = '⊘';
const MAX_LIVE_OUTPUT_CHARS = 50_000;
Expand Down Expand Up @@ -474,6 +474,10 @@ class PrefixedWrappedLine implements Component {
// unwrapped paragraph scrolls within a fixed window instead of growing
// unbounded. The first kept row still gets `firstPrefix`.
private readonly tailLines?: number,
// When set, the output is padded with empty continuation rows until it
// reaches this many display rows, so a short paragraph still fills a
// fixed-height window. Applied after `tailLines`.
private readonly minLines?: number,
) { }

invalidate(): void {
Expand All @@ -498,6 +502,9 @@ class PrefixedWrappedLine implements Component {
this.tailLines !== undefined && wrapped.length > this.tailLines
? wrapped.slice(wrapped.length - this.tailLines)
: wrapped;
if (this.minLines !== undefined) {
while (lines.length < this.minLines) lines.push('');
}
const rendered = lines
.map((line, index) =>
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
Expand Down Expand Up @@ -548,6 +555,9 @@ export class ToolCallComponent extends Container {
*/
private subagentText = '';
private subagentThinkingText = '';
/** Tracks whether the child agent's latest streamed delta was text or thinking,
* so the active window can follow whichever is currently live. */
private lastSubagentStreamKind: SubagentTextKind = 'text';
// ── Subagent lifecycle state from subagent.spawned/started/completed/failed ──
private subagentPhase: SubagentPhase | undefined;
/**
Expand Down Expand Up @@ -577,6 +587,7 @@ export class ToolCallComponent extends Container {
private subagentElapsedTimer: ReturnType<typeof setInterval> | undefined;
private subagentStartedAtMs: number | undefined;
private subagentEndedAtMs: number | undefined;
private subagentSpinnerFrame = 0;

// ── Live progress lines ──────────────────────────────────────────
//
Expand Down Expand Up @@ -1036,11 +1047,14 @@ export class ToolCallComponent extends Container {
this.stopSubagentElapsedTimer();
return;
}
// Drives both the braille spinner in the header and the elapsed-seconds
// refresh. Only the header text changes on a tick, so we avoid rebuilding
// the body (which would defeat the per-component render caches).
this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
this.headerText.setText(this.buildHeader());
this.invalidate();
this.notifySnapshotChange();
this.ui?.requestRender();
}, SUBAGENT_ELAPSED_INTERVAL_MS);
}, BRAILLE_SPINNER_INTERVAL_MS);
}

private stopSubagentElapsedTimer(): void {
Expand Down Expand Up @@ -1260,6 +1274,7 @@ export class ToolCallComponent extends Container {
}

appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void {
this.lastSubagentStreamKind = kind;
if (kind === 'thinking') {
this.subagentThinkingText += text;
} else {
Expand Down Expand Up @@ -1697,25 +1712,24 @@ export class ToolCallComponent extends Container {

private buildSingleSubagentHeader(): string {
const phase = this.getDerivedSubagentPhase();
const isFailed = phase === 'failed';
const isDone = phase === 'done';
const bullet = isFailed
? currentTheme.fg('error', '✗ ')
: isDone
? currentTheme.fg('success', STATUS_BULLET)
: currentTheme.fg('text', STATUS_BULLET);
const marker = this.buildSingleSubagentMarker(phase);
const labelText = formatSubagentLabel(this.subagentAgentName);
const label = currentTheme.boldFg('primary', labelText);
const status = this.formatSingleSubagentStatus(phase);
const description = str(this.toolCall.args['description']);
const rawDescription = str(this.toolCall.args['description']);
const description =
rawDescription.length > MAX_SUBAGENT_DESCRIPTION_LENGTH
? `${rawDescription.slice(0, MAX_SUBAGENT_DESCRIPTION_LENGTH - 1)}…`
: rawDescription;
const descriptionPlain = description.length > 0 ? ` (${description})` : '';
const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : '';
const statsText = this.formatSingleSubagentStatsText();
if (isDone) {
return `${bullet}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`;
return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`;
}
const stats = currentTheme.dim(statsText);
return `${bullet}${label} ${status}${descriptionText}${stats}`;
return `${marker}${label} ${status}${descriptionText}${stats}`;
}

private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string {
Expand Down Expand Up @@ -1758,92 +1772,133 @@ export class ToolCallComponent extends Container {
return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000));
}

private buildSingleSubagentMarker(phase: SubagentPhase | undefined): string {
if (phase === 'failed') return currentTheme.fg('error', '✗ ');
if (phase === 'done') return currentTheme.fg('success', STATUS_BULLET);
if (phase === 'backgrounded') return currentTheme.dim('◐ ');
// Active (queued / spawning / running): a braille spinner reads as alive
// where a static bullet looked frozen.
const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0];
return currentTheme.fg('primary', `${frame} `);
}

private buildSingleSubagentBlock(): void {
for (const activity of this.getRecentSubToolActivities()) {
const mark =
activity.phase === 'failed'
? currentTheme.fg('error', '✗')
: activity.phase === 'done'
? currentTheme.fg('success', '•')
: currentTheme.fg('text', '•');
const verb = activity.phase === 'ongoing' ? 'Using' : 'Used';
this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0));
this.addSubToolOutputPreview(activity);
}

if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) {
const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1);
if (errorLine !== undefined) {
this.addChild(
new PrefixedWrappedLine(
` ${currentTheme.fg('error', '└')} `,
' ',
currentTheme.fg('error', errorLine),
),
);
}
const phase = this.getDerivedSubagentPhase();

// Every state shares the same skeleton — header, a one-line tool summary,
// and a fixed two-row content window — so the card height is identical
// while running and after it finishes (no end-of-run shrink).
this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0));

if (phase === 'failed') {
this.addChild(this.buildSingleSubagentResultWindow('error'));
return;
}
if (phase === 'done' || phase === 'backgrounded') {
this.addChild(this.buildSingleSubagentResultWindow('output'));
return;
}
this.addChild(this.buildSingleSubagentActiveWindow());
}

const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1);
/** Most-recently-started sub-tool, preferring one that is still running. */
private getCurrentSubToolActivity(): SubToolActivity | undefined {
let latestOngoing: SubToolActivity | undefined;
let latest: SubToolActivity | undefined;
for (const activity of this.subToolActivities.values()) {
if (latest === undefined || activity.orderSeq > latest.orderSeq) latest = activity;
if (
activity.phase === 'ongoing' &&
(latestOngoing === undefined || activity.orderSeq > latestOngoing.orderSeq)
) {
latestOngoing = activity;
}
}
return latestOngoing ?? latest;
}

/**
* The single live stream shown in the active window. A running sub-tool with
* previewable output (Bash or any tool without a dedicated renderer) wins;
* otherwise the most-recently-updated of the child agent's text / thinking.
*/
private getActiveSubagentContent(): { text: string; tone: 'text' | 'thinking' } | undefined {
const current = this.getCurrentSubToolActivity();
if (
this.getDerivedSubagentPhase() !== 'done' &&
this.subagentThinkingText.trim().length > 0
current?.phase === 'ongoing' &&
current.output !== undefined &&
current.output.trim().length > 0 &&
(current.name === 'Bash' || isGenericToolResult(current.name))
Comment thread
liruifengv marked this conversation as resolved.
Comment thread
liruifengv marked this conversation as resolved.
) {
// Scroll thinking within a fixed two-row window (width-aware), matching
// the main agent's live thinking instead of growing without bound.
this.addChild(
new PrefixedWrappedLine(
` ${currentTheme.dim('◌')} `,
' ',
currentTheme.dim(this.subagentThinkingText.trimEnd()),
THINKING_PREVIEW_LINES,
),
);
return { text: current.output, tone: 'text' };
}
if (outputLine !== undefined) {
this.addChild(
new PrefixedWrappedLine(
` ${currentTheme.fg('text', '└')} `,
' ',
currentTheme.fg('text', outputLine),
),
);
if (this.lastSubagentStreamKind === 'thinking' && this.subagentThinkingText.trim().length > 0) {
return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' };
}
if (this.subagentText.trim().length > 0) {
return { text: this.subagentText, tone: 'text' };
}
if (this.subagentThinkingText.trim().length > 0) {
return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' };
}
return undefined;
}

private addSubToolOutputPreview(activity: SubToolActivity): void {
const output = activity.output;
if (output === undefined || output.trim().length === 0) return;
// Mirror the main agent: Bash and any tool without a dedicated renderer
// (every MCP tool included) get a truncated output preview. Recognized
// tools keep their compact activity row only.
if (activity.name !== 'Bash' && !isGenericToolResult(activity.name)) return;
this.addChild(
new TruncatedOutputComponent(output, {
// Subagent output is always fixed-truncated; it does not take part in
// the ctrl+o expand toggle, so don't advertise it either.
expanded: false,
expandHint: false,
isError: activity.phase === 'failed',
maxLines: RESULT_PREVIEW_LINES,
indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT,
tail: activity.phase === 'ongoing',
}),
private buildSingleSubagentSummaryLine(): string {
const toolCount = this.subToolActivities.size;
const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`;
const current = this.getCurrentSubToolActivity();
if (current === undefined) {
return currentTheme.dim(` · ${countLabel}`);
}
const verb = current.phase === 'ongoing' ? 'Using' : 'Used';
const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir);
const nameCol = currentTheme.fg('primary', current.name);
const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : '';
const mark =
current.phase === 'failed'
? currentTheme.fg('error', ' ✗')
: current.phase === 'done'
? currentTheme.fg('success', ' ✓')
: '';
return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`;
}

private buildSingleSubagentActiveWindow(): Component {
const gutter = currentTheme.dim('│');
const content = this.getActiveSubagentContent();
// Keep both tones muted: a bright `fg('text')` here flashed white whenever
// the window flipped between thinking and a brief text/tool-output segment.
const styled =
content === undefined
? currentTheme.dim('…')
: content.tone === 'thinking'
? currentTheme.dim(content.text)
: currentTheme.fg('textDim', content.text);
// Always exactly two rows (padded when short) so the live window matches
// the finished card's height.
return new PrefixedWrappedLine(
` ${gutter} `,
` ${gutter} `,
styled,
THINKING_PREVIEW_LINES,
THINKING_PREVIEW_LINES,
);
}

private getRecentSubToolActivities(): SubToolActivity[] {
return [...this.subToolActivities.values()]
.toSorted((a, b) => a.orderSeq - b.orderSeq)
.slice(-MAX_SINGLE_SUBAGENT_TOOL_ROWS);
}

private formatSubToolActivity(verb: string, activity: SubToolActivity): string {
const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir);
const nameCol = currentTheme.fg('primary', activity.name);
const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : '';
return `${verb} ${nameCol}${argCol}`;
private buildSingleSubagentResultWindow(kind: 'output' | 'error'): Component {
const gutter = currentTheme.dim('│');
const source = kind === 'error' ? this.subagentError : this.subagentText;
const text = source === undefined ? '' : tailNonEmptyLines(source, 2).join('\n');
const styled =
kind === 'error' ? currentTheme.fg('error', text) : currentTheme.fg('text', text);
return new PrefixedWrappedLine(
` ${gutter} `,
` ${gutter} `,
styled,
THINKING_PREVIEW_LINES,
THINKING_PREVIEW_LINES,
);
}

private buildCallPreview(): void {
Expand Down
Loading
Loading