From fd5fdb8f3f1d883cfc10d6cd9cacc5e7f944d4a2 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 3 Jul 2026 14:59:22 +0800 Subject: [PATCH] feat: redesign the subagent card 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. --- .changeset/subagent-card-stable-ui.md | 5 + .../src/tui/components/messages/tool-call.ts | 233 +++++++++++------- .../tui/components/messages/tool-call.test.ts | 130 ++++++---- 3 files changed, 227 insertions(+), 141 deletions(-) create mode 100644 .changeset/subagent-card-stable-ui.md diff --git a/.changeset/subagent-card-stable-ui.md b/.changeset/subagent-card-stable-ui.md new file mode 100644 index 000000000..008af848c --- /dev/null +++ b/.changeset/subagent-card-stable-ui.md @@ -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. diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index dd8d57189..9b2a2d20f 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -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 | 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 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)) ) { - // 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 { diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index eabe7fbe4..2b796829e 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -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