mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat: redesign the subagent card (#1345)
Stable height across running, done, failed and backgrounded states: all share the same header + one-line tool summary + two-row content window, so the card no longer shrinks when a run finishes. Add a braille spinner in the header while active, collapse sub-tool calls into a one-line summary, and have the two-row window follow the live stream (tool output, text, or thinking) instead of showing thinking and text side by side. Mute the window tones so a brief text or tool-output segment no longer flashes white against dim thinking.
This commit is contained in:
parent
175b95f3af
commit
3ed22e35a4
3 changed files with 235 additions and 149 deletions
5
.changeset/subagent-card-stable-ui.md
Normal file
5
.changeset/subagent-card-stable-ui.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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}`,
|
||||
|
|
@ -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;
|
||||
/**
|
||||
|
|
@ -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 ──────────────────────────────────────────
|
||||
//
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -1758,92 +1772,133 @@ export class ToolCallComponent extends Container {
|
|||
return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000));
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1);
|
||||
if (
|
||||
this.getDerivedSubagentPhase() !== 'done' &&
|
||||
this.subagentThinkingText.trim().length > 0
|
||||
) {
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (outputLine !== undefined) {
|
||||
this.addChild(
|
||||
new PrefixedWrappedLine(
|
||||
` ${currentTheme.fg('text', '└')} `,
|
||||
' ',
|
||||
currentTheme.fg('text', outputLine),
|
||||
),
|
||||
);
|
||||
}
|
||||
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 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 buildSingleSubagentBlock(): void {
|
||||
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());
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
current?.phase === 'ongoing' &&
|
||||
current.output !== undefined &&
|
||||
current.output.trim().length > 0 &&
|
||||
(current.name === 'Bash' || isGenericToolResult(current.name))
|
||||
) {
|
||||
return { text: current.output, tone: 'text' };
|
||||
}
|
||||
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 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 {
|
||||
|
|
|
|||
|
|
@ -929,14 +929,15 @@ describe('ToolCallComponent', () => {
|
|||
out = strip(component.render(120).join('\n'));
|
||||
expect(out).toContain('Explore Agent Running (explore project xxx) · 1 tool · 10s');
|
||||
expect(out).toContain('Using Read (apps/kimi-code/src/tui/utils/background-agent-status.ts)');
|
||||
// Thinking and text are mutually exclusive in the active window: the most
|
||||
// recently streamed (text) wins, so thinking is hidden entirely.
|
||||
expect(out).not.toContain('think1');
|
||||
expect(out).toContain('think2');
|
||||
expect(out).toContain('think3');
|
||||
expect(out).toContain('◌ think2');
|
||||
expect(out).not.toContain('think2');
|
||||
expect(out).not.toContain('think3');
|
||||
expect(out).not.toContain('answer1');
|
||||
expect(out).not.toContain('answer2');
|
||||
expect(out).toContain('answer2');
|
||||
expect(out).toContain('answer3');
|
||||
expect(out).toContain('└ answer3');
|
||||
expect(out).toContain('│ answer3');
|
||||
|
||||
vi.setSystemTime(22_000);
|
||||
component.onSubagentCompleted({ resultSummary: 'summary fallback' });
|
||||
|
|
@ -950,7 +951,7 @@ describe('ToolCallComponent', () => {
|
|||
out = strip(component.render(120).join('\n'));
|
||||
expect(out).toContain('Explore Agent Completed (explore project xxx) · 1 tool · 12s');
|
||||
expect(out).not.toContain('think3');
|
||||
expect(out).toContain('└ answer3');
|
||||
expect(out).toContain('│ answer3');
|
||||
expect(out).not.toContain('Used Agent');
|
||||
expect(out).not.toContain('parent duplicate result');
|
||||
expect(out).not.toContain('summary fallback');
|
||||
|
|
@ -1000,7 +1001,7 @@ describe('ToolCallComponent', () => {
|
|||
component.dispose();
|
||||
});
|
||||
|
||||
it('keeps the single subagent tool area to the latest four activities', () => {
|
||||
it('summarizes subagent tools as a count plus the current tool', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const component = new ToolCallComponent(
|
||||
|
|
@ -1030,16 +1031,17 @@ describe('ToolCallComponent', () => {
|
|||
|
||||
const out = strip(component.render(120).join('\n'));
|
||||
expect(out).toContain('Explore Agent Running (inspect tools) · 5 tools · 0s');
|
||||
expect(out).not.toContain('file1.ts');
|
||||
expect(out).toContain('Used Read (file2.ts)');
|
||||
expect(out).toContain('Used Read (file3.ts)');
|
||||
expect(out).toContain('Used Read (file4.ts)');
|
||||
expect(out).not.toContain('… Using Grep (auth)');
|
||||
expect(out).toContain('• Using Grep (auth)');
|
||||
// Only the current (most recent ongoing) tool appears in the summary line.
|
||||
expect(out).toContain('Using Grep (auth)');
|
||||
// No per-tool activity rows are rendered.
|
||||
expect(out).not.toContain('file1.ts');
|
||||
expect(out).not.toContain('file2.ts');
|
||||
expect(out).not.toContain('file3.ts');
|
||||
expect(out).not.toContain('file4.ts');
|
||||
expect(out).not.toContain('Used Read');
|
||||
});
|
||||
|
||||
it('keeps the single subagent tool window stable when older tools update', () => {
|
||||
it('keeps the subagent tool summary pinned to the most recent tool', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const component = new ToolCallComponent(
|
||||
|
|
@ -1075,14 +1077,16 @@ describe('ToolCallComponent', () => {
|
|||
});
|
||||
|
||||
const out = strip(component.render(120).join('\n'));
|
||||
// The updated/finished older tool must not surface in the summary.
|
||||
expect(out).not.toContain('file1-updated.ts');
|
||||
expect(out).toContain('Using Read (file2.ts)');
|
||||
expect(out).toContain('Using Read (file3.ts)');
|
||||
expect(out).toContain('Using Read (file4.ts)');
|
||||
expect(out).not.toContain('file2.ts');
|
||||
expect(out).not.toContain('file3.ts');
|
||||
expect(out).not.toContain('file4.ts');
|
||||
// Only the most recent ongoing tool is shown.
|
||||
expect(out).toContain('Using Read (file5.ts)');
|
||||
});
|
||||
|
||||
it('wraps single subagent thinking and output with hanging indentation', () => {
|
||||
it('wraps the single subagent active window with a hanging gutter', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const component = new ToolCallComponent(
|
||||
|
|
@ -1098,24 +1102,17 @@ describe('ToolCallComponent', () => {
|
|||
agentName: 'explore',
|
||||
runInBackground: false,
|
||||
});
|
||||
component.appendSubagentText(
|
||||
'thinking words that should wrap with a clean hanging indent',
|
||||
'thinking',
|
||||
);
|
||||
component.appendSubagentText(
|
||||
'output words that should also wrap with a clean hanging indent',
|
||||
'text',
|
||||
);
|
||||
|
||||
const lines = strip(component.render(34).join('\n')).split('\n');
|
||||
// Thinking is scrolled to its last two display rows, so the head of the
|
||||
// wrapped paragraph drops and the ◌ marker hangs on the first kept row.
|
||||
expect(lines.some((l) => l.includes('◌ wrap with a clean hanging'))).toBe(true);
|
||||
expect(lines.join('\n')).not.toContain('thinking words that should');
|
||||
expect(lines).toContain(' indent ');
|
||||
// Output keeps its full hanging-indent wrap (unchanged behavior).
|
||||
expect(lines).toContain(' └ output words that should also ');
|
||||
expect(lines).toContain(' wrap with a clean hanging ');
|
||||
const joined = strip(component.render(34).join('\n'));
|
||||
// The two-row window drops the head of the wrapped paragraph.
|
||||
expect(joined).not.toContain('output words that should');
|
||||
// Every kept row carries the `│` gutter as a hanging indent.
|
||||
expect(joined).toContain('│ wrap with a clean hanging');
|
||||
expect(joined).toContain('│ indent');
|
||||
});
|
||||
|
||||
it('scrolls single subagent thinking to the last two display rows', () => {
|
||||
|
|
@ -1146,7 +1143,7 @@ describe('ToolCallComponent', () => {
|
|||
expect(lines.join('\n')).not.toContain('seg00');
|
||||
});
|
||||
|
||||
it('shows and truncates a single subagent Bash tool output', () => {
|
||||
it('shows a two-row tail of an ongoing subagent Bash output', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const component = new ToolCallComponent(
|
||||
|
|
@ -1168,25 +1165,25 @@ describe('ToolCallComponent', () => {
|
|||
args: { command: 'ls -la' },
|
||||
});
|
||||
const output = Array.from({ length: 10 }, (_, i) => `bash-line-${String(i)}`).join('\n');
|
||||
component.finishSubToolCall({ tool_call_id: 'sub_bash:cmd', output, is_error: false });
|
||||
component.appendSubToolLiveOutput('sub_bash:cmd', output);
|
||||
|
||||
let out = strip(component.render(120).join('\n'));
|
||||
expect(out).toContain('Used Bash (ls -la)');
|
||||
expect(out).toContain('bash-line-0');
|
||||
expect(out).toContain('bash-line-2');
|
||||
expect(out).not.toContain('bash-line-3');
|
||||
expect(out).toContain('... (7 more lines)');
|
||||
// Subagent output is fixed-truncated: no ctrl+o promise.
|
||||
expect(out).toContain('Using Bash (ls -la)');
|
||||
// The active window keeps only the last two rows of live output.
|
||||
expect(out).toContain('bash-line-8');
|
||||
expect(out).toContain('bash-line-9');
|
||||
expect(out).not.toContain('bash-line-7');
|
||||
// No ctrl+o promise for the subagent window.
|
||||
expect(out).not.toContain('ctrl+o');
|
||||
|
||||
// The global ctrl+o expand toggle must NOT expand subagent output.
|
||||
// The global ctrl+o expand toggle must NOT expand the window.
|
||||
component.setExpanded(true);
|
||||
out = strip(component.render(120).join('\n'));
|
||||
expect(out).not.toContain('bash-line-9');
|
||||
expect(out).toContain('... (7 more lines)');
|
||||
expect(out).toContain('bash-line-9');
|
||||
expect(out).not.toContain('bash-line-7');
|
||||
});
|
||||
|
||||
it('truncates unknown subagent tool output but leaves recognized tools as rows', () => {
|
||||
it('shows live output for generic subagent tools but not for recognized ones', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const component = new ToolCallComponent(
|
||||
|
|
@ -1202,6 +1199,7 @@ describe('ToolCallComponent', () => {
|
|||
agentName: 'explore',
|
||||
runInBackground: false,
|
||||
});
|
||||
// A finished recognized tool: its output body never reaches the window.
|
||||
component.appendSubToolCall({
|
||||
id: 'sub_mixed:read',
|
||||
name: 'Read',
|
||||
|
|
@ -1212,23 +1210,22 @@ describe('ToolCallComponent', () => {
|
|||
output: 'recognized-read-body\nhidden-read-line',
|
||||
is_error: false,
|
||||
});
|
||||
// An ongoing generic (MCP) tool: its live output is the active stream.
|
||||
component.appendSubToolCall({
|
||||
id: 'sub_mixed:mcp',
|
||||
name: 'mcp__server__do',
|
||||
args: {},
|
||||
});
|
||||
const mcpOut = Array.from({ length: 5 }, (_, i) => `mcp-line-${String(i)}`).join('\n');
|
||||
component.finishSubToolCall({ tool_call_id: 'sub_mixed:mcp', output: mcpOut, is_error: false });
|
||||
component.appendSubToolLiveOutput('sub_mixed:mcp', mcpOut);
|
||||
|
||||
const out = strip(component.render(120).join('\n'));
|
||||
// Recognized tool: activity row only, no output body.
|
||||
expect(out).toContain('Used Read (foo.ts)');
|
||||
// Recognized tool output never appears.
|
||||
expect(out).not.toContain('recognized-read-body');
|
||||
// Unknown/MCP tool: truncated output body, no ctrl+o promise.
|
||||
expect(out).toContain('mcp-line-0');
|
||||
expect(out).toContain('mcp-line-2');
|
||||
expect(out).not.toContain('mcp-line-3');
|
||||
expect(out).toContain('... (2 more lines)');
|
||||
// Generic tool output shows as the two-row active window tail.
|
||||
expect(out).toContain('mcp-line-3');
|
||||
expect(out).toContain('mcp-line-4');
|
||||
expect(out).not.toContain('mcp-line-2');
|
||||
expect(out).not.toContain('ctrl+o');
|
||||
});
|
||||
|
||||
|
|
@ -1254,11 +1251,40 @@ describe('ToolCallComponent', () => {
|
|||
|
||||
const out = strip(component.render(120).join('\n'));
|
||||
expect(out).toContain('Explore Agent Failed (check failure) · 0 tools · 3s');
|
||||
expect(out).toContain('└ subagent exceeded max_steps');
|
||||
expect(out).toContain('│ subagent exceeded max_steps');
|
||||
expect(out).not.toContain('Using Agent');
|
||||
expect(out).not.toContain('Used Agent');
|
||||
});
|
||||
|
||||
it('keeps the same card height between running and done', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
const component = new ToolCallComponent(
|
||||
{
|
||||
id: 'call_agent_height',
|
||||
name: 'Agent',
|
||||
args: { description: 'height stable' },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
component.onSubagentSpawned({
|
||||
agentId: 'sub_height',
|
||||
agentName: 'explore',
|
||||
runInBackground: false,
|
||||
});
|
||||
component.appendSubToolCall({ id: 'sub_height:read', name: 'Read', args: { path: 'a.ts' } });
|
||||
component.appendSubagentText('short answer', 'text');
|
||||
|
||||
const runningLines = strip(component.render(120).join('\n')).split('\n').length;
|
||||
|
||||
component.onSubagentCompleted({ resultSummary: 'short answer' });
|
||||
component.setResult({ tool_call_id: 'call_agent_height', output: 'done', is_error: false });
|
||||
|
||||
const doneLines = strip(component.render(120).join('\n')).split('\n').length;
|
||||
|
||||
expect(doneLines).toBe(runningLines);
|
||||
});
|
||||
|
||||
describe('background agent terminal state vs spawn-success ToolResult', () => {
|
||||
// The Agent tool returns a "task spawned" result the moment a
|
||||
// run_in_background=true call lands. That result is not an error and its
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue