From 2db1bd9675ef3b6adf3833f05b7b6d87a137c6eb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 8 Jun 2026 16:19:15 +0800 Subject: [PATCH] fix(tui): subagent thinking text and tool output display (#541) --- .changeset/subagent-tool-output-preview.md | 5 + .../src/tui/components/messages/tool-call.ts | 65 ++++++++- .../messages/tool-renderers/registry.ts | 10 ++ .../messages/tool-renderers/truncated.ts | 20 ++- .../tui/components/messages/tool-call.test.ts | 128 +++++++++++++++++- .../messages/tool-renderers/registry.test.ts | 14 +- .../messages/tool-renderers/truncated.test.ts | 66 +++++++++ 7 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 .changeset/subagent-tool-output-preview.md create mode 100644 apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts diff --git a/.changeset/subagent-tool-output-preview.md b/.changeset/subagent-tool-output-preview.md new file mode 100644 index 000000000..00212923a --- /dev/null +++ b/.changeset/subagent-tool-output-preview.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix thinking text and tool output display for subagents. 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 7fd287bc6..d098fdcd6 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -11,7 +11,11 @@ import chalk from 'chalk'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; -import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; +import { + COMMAND_PREVIEW_LINES, + RESULT_PREVIEW_LINES, + THINKING_PREVIEW_LINES, +} from '#/tui/constant/rendering'; import { STREAMING_ARGS_FIELD_RE, STREAMING_ARGS_PREVIEW_MAX_CHARS, @@ -27,11 +31,14 @@ import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; -import { pickResultRenderer } from './tool-renderers/registry'; +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; const APPROVED_PLAN_MARKER = '## Approved Plan:'; const STREAMING_PROGRESS_INTERVAL_MS = 1000; const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; @@ -59,6 +66,7 @@ interface SubToolActivity { name: string; args: Record; phase: 'ongoing' | 'done' | 'failed'; + output?: string; readonly orderSeq: number; } @@ -453,6 +461,10 @@ class PrefixedWrappedLine implements Component { private readonly firstPrefix: string, private readonly continuationPrefix: string, private readonly text: string, + // When set, only the last N wrapped display rows are kept, so a long + // unwrapped paragraph scrolls within a fixed window instead of growing + // unbounded. The first kept row still gets `firstPrefix`. + private readonly tailLines?: number, ) { } invalidate(): void { } @@ -463,7 +475,11 @@ class PrefixedWrappedLine implements Component { visibleWidth(this.continuationPrefix), ); const contentWidth = Math.max(1, width - prefixWidth); - const lines = new Text(this.text, 0, 0).render(contentWidth); + const wrapped = new Text(this.text, 0, 0).render(contentWidth); + const lines = + this.tailLines !== undefined && wrapped.length > this.tailLines + ? wrapped.slice(wrapped.length - this.tailLines) + : wrapped; return lines.map((line, index) => index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, ); @@ -688,6 +704,7 @@ export class ToolCallComponent extends Container { call.name, call.args, call.result.is_error === true ? 'failed' : 'done', + call.result.output, ); } while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { @@ -808,12 +825,14 @@ export class ToolCallComponent extends Container { name: string, args: Record, phase: SubToolActivity['phase'], + output?: string, ): void { const existing = this.subToolActivities.get(id); if (existing !== undefined) { existing.name = name; existing.args = args; existing.phase = phase; + if (output !== undefined) existing.output = output; return; } this.subToolActivities.set(id, { @@ -821,6 +840,7 @@ export class ToolCallComponent extends Container { name, args, phase, + ...(output !== undefined ? { output } : {}), orderSeq: ++this.subToolOrderSeq, }); } @@ -1174,6 +1194,7 @@ export class ToolCallComponent extends Container { ongoing.name, ongoing.args, result.is_error === true ? 'failed' : 'done', + result.output, ); while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { this.finishedSubCalls.shift(); @@ -1537,6 +1558,7 @@ export class ToolCallComponent extends Container { : chalk.hex(this.colors.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) { @@ -1554,10 +1576,19 @@ export class ToolCallComponent extends Container { } const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1); - const thinkingLine = tailNonEmptyLines(this.subagentThinkingText, 1).at(-1); - if (this.getDerivedSubagentPhase() !== 'done' && thinkingLine !== undefined) { + 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(` ${chalk.dim('◌')} `, ' ', chalk.dim(thinkingLine)), + new PrefixedWrappedLine( + ` ${chalk.dim('◌')} `, + ' ', + chalk.dim(this.subagentThinkingText.trimEnd()), + THINKING_PREVIEW_LINES, + ), ); } if (outputLine !== undefined) { @@ -1571,6 +1602,28 @@ export class ToolCallComponent extends Container { } } + private addSubToolOutputPreview(activity: SubToolActivity): void { + if (activity.phase === 'ongoing') return; + 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', + colors: this.colors, + maxLines: RESULT_PREVIEW_LINES, + indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, + }), + ); + } + private getRecentSubToolActivities(): SubToolActivity[] { return [...this.subToolActivities.values()] .toSorted((a, b) => a.orderSeq - b.orderSeq) diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts index 2d59b805b..577281774 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -25,6 +25,16 @@ import { import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; +/** + * True when a tool has no dedicated renderer and falls back to the generic + * truncated output (every MCP tool and any tool not listed below). Used to + * decide whether subagent sub-tool output should be previewed the same way + * the main agent previews it. + */ +export function isGenericToolResult(toolName: string): boolean { + return pickResultRenderer(toolName) === renderTruncated; +} + export function pickResultRenderer(toolName: string): ResultRenderer { switch (toolName) { case 'Read': diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index ffd353dff..54706fc99 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -7,6 +7,8 @@ import type { ColorPalette } from '#/tui/theme/colors'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; +const DEFAULT_INDENT = 2; + export function trimTrailingEmptyLines(lines: string[]): string[] { let end = lines.length; while (end > 0) { @@ -27,6 +29,8 @@ export class TruncatedOutputComponent implements Component { private readonly textComponent: Text; private readonly expanded: boolean; private readonly maxLines: number; + private readonly indent: number; + private readonly expandHint: boolean; constructor( output: string, @@ -35,13 +39,19 @@ export class TruncatedOutputComponent implements Component { isError: boolean | undefined; colors: ColorPalette; maxLines?: number; + indent?: number; + // When false, the truncation footer omits the "ctrl+o to expand" promise + // (for contexts whose output is fixed-truncated and never expands). + expandHint?: boolean; }, ) { this.expanded = options.expanded; this.maxLines = options.maxLines ?? PREVIEW_LINES; + this.indent = options.indent ?? DEFAULT_INDENT; + this.expandHint = options.expandHint ?? true; const tint = options.isError ? chalk.hex(options.colors.error) : chalk.dim; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); - this.textComponent = new Text(tint(cleaned), 2, 0); + this.textComponent = new Text(tint(cleaned), this.indent, 0); } invalidate(): void { @@ -57,10 +67,10 @@ export class TruncatedOutputComponent implements Component { const shown = contentLines.slice(0, this.maxLines); const remaining = contentLines.length - this.maxLines; - return [ - ...shown, - chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), - ]; + const hint = this.expandHint + ? `... (${String(remaining)} more lines, ctrl+o to expand)` + : `... (${String(remaining)} more lines)`; + return [...shown, ' '.repeat(this.indent) + chalk.dim(hint)]; } } 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 bcd1156a3..dbac42876 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 @@ -621,9 +621,9 @@ describe('ToolCallComponent', () => { 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)'); expect(out).not.toContain('think1'); - expect(out).not.toContain('think2'); + expect(out).toContain('think2'); expect(out).toContain('think3'); - expect(out).toContain('◌ think3'); + expect(out).toContain('◌ think2'); expect(out).not.toContain('answer1'); expect(out).not.toContain('answer2'); expect(out).toContain('answer3'); @@ -758,13 +758,133 @@ describe('ToolCallComponent', () => { ); const lines = strip(component.render(34).join('\n')).split('\n'); - expect(lines).toContain(' ◌ thinking words that should '); - expect(lines).toContain(' wrap with a clean hanging '); + // 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 '); }); + it('scrolls single subagent thinking to the last two display rows', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_scroll', + name: 'Agent', + args: { description: 'long think' }, + }, + undefined, + darkColors, + ); + component.onSubagentSpawned({ + agentId: 'sub_scroll', + agentName: 'explore', + runInBackground: false, + }); + // A single long logical line (no newlines) wraps to many display rows; + // only the last THINKING_PREVIEW_LINES (2) should remain visible. + const segs = Array.from({ length: 30 }, (_, i) => `seg${String(i).padStart(2, '0')}`); + component.appendSubagentText(segs.join(' '), 'thinking'); + + const lines = strip(component.render(40).join('\n')).split('\n'); + const thinkingRows = lines.filter((l) => /seg\d\d/.test(l)); + expect(thinkingRows.length).toBe(2); + expect(lines.join('\n')).toContain('seg29'); + expect(lines.join('\n')).not.toContain('seg00'); + }); + + it('shows and truncates a single subagent Bash tool output', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_bash_out', + name: 'Agent', + args: { description: 'run bash' }, + }, + undefined, + darkColors, + ); + component.onSubagentSpawned({ + agentId: 'sub_bash', + agentName: 'explore', + runInBackground: false, + }); + component.appendSubToolCall({ + id: 'sub_bash:cmd', + name: 'Bash', + 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 }); + + 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).not.toContain('ctrl+o'); + + // The global ctrl+o expand toggle must NOT expand subagent output. + component.setExpanded(true); + out = strip(component.render(120).join('\n')); + expect(out).not.toContain('bash-line-9'); + expect(out).toContain('... (7 more lines)'); + }); + + it('truncates unknown subagent tool output but leaves recognized tools as rows', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_mixed', + name: 'Agent', + args: { description: 'mixed tools' }, + }, + undefined, + darkColors, + ); + component.onSubagentSpawned({ + agentId: 'sub_mixed', + agentName: 'explore', + runInBackground: false, + }); + component.appendSubToolCall({ + id: 'sub_mixed:read', + name: 'Read', + args: { path: 'foo.ts' }, + }); + component.finishSubToolCall({ + tool_call_id: 'sub_mixed:read', + output: 'recognized-read-body\nhidden-read-line', + is_error: false, + }); + 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 }); + + const out = strip(component.render(120).join('\n')); + // Recognized tool: activity row only, no output body. + expect(out).toContain('Used Read (foo.ts)'); + 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)'); + expect(out).not.toContain('ctrl+o'); + }); + it('renders failed single subagents with the dedicated header and error text', () => { vi.useFakeTimers(); vi.setSystemTime(1000); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index c17e997d2..4252e965c 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -1,7 +1,10 @@ import type { Component } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; -import { pickResultRenderer } from '#/tui/components/messages/tool-renderers/registry'; +import { + isGenericToolResult, + pickResultRenderer, +} from '#/tui/components/messages/tool-renderers/registry'; import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; @@ -163,6 +166,15 @@ describe('tool-result registry', () => { expect(out).toContain('ENOENT: foo.ts not found'); }); + it('flags only fallback (truncated) tools as generic results', () => { + expect(isGenericToolResult('SomethingUnknown')).toBe(true); + expect(isGenericToolResult('mcp__server__do')).toBe(true); + expect(isGenericToolResult('Bash')).toBe(false); + expect(isGenericToolResult('Read')).toBe(false); + expect(isGenericToolResult('Grep')).toBe(false); + expect(isGenericToolResult('Edit')).toBe(false); + }); + it('truncates unknown tool output by wrapped visual lines, not raw newlines', () => { const renderer = pickResultRenderer('SomethingUnknown'); const longLine = 'x'.repeat(500); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts new file mode 100644 index 000000000..5b54fe6a6 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +describe('TruncatedOutputComponent', () => { + it('indents content and the truncation hint by the configured amount', () => { + const component = new TruncatedOutputComponent(['a', 'b', 'c', 'd', 'e'].join('\n'), { + expanded: false, + isError: false, + colors: darkColors, + maxLines: 2, + indent: 6, + }); + + const lines = strip(component.render(80).join('\n')).split('\n'); + expect(lines[0]?.startsWith(' a')).toBe(true); + expect(lines[1]?.startsWith(' b')).toBe(true); + expect(lines[2]).toBe(' ... (3 more lines, ctrl+o to expand)'); + }); + + it('defaults to a two-space indent for both content and hint', () => { + const component = new TruncatedOutputComponent('x\ny\nz', { + expanded: false, + isError: false, + colors: darkColors, + maxLines: 1, + }); + + const lines = strip(component.render(80).join('\n')).split('\n'); + expect(lines[0]?.startsWith(' x')).toBe(true); + expect(lines[1]).toBe(' ... (2 more lines, ctrl+o to expand)'); + }); + + it('omits the ctrl+o promise when expandHint is false', () => { + const component = new TruncatedOutputComponent('a\nb\nc\nd', { + expanded: false, + isError: false, + colors: darkColors, + maxLines: 2, + indent: 4, + expandHint: false, + }); + + const lines = strip(component.render(80).join('\n')).split('\n'); + expect(lines[2]).toBe(' ... (2 more lines)'); + }); + + it('renders all lines without a hint when expanded', () => { + const component = new TruncatedOutputComponent('a\nb\nc\nd', { + expanded: true, + isError: false, + colors: darkColors, + maxLines: 2, + indent: 4, + }); + + const out = strip(component.render(80).join('\n')); + expect(out).toContain('d'); + expect(out).not.toContain('more lines, ctrl+o'); + }); +});