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-tool-output-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix thinking text and tool output display for subagents.
65 changes: 59 additions & 6 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -59,6 +66,7 @@ interface SubToolActivity {
name: string;
args: Record<string, unknown>;
phase: 'ongoing' | 'done' | 'failed';
output?: string;
readonly orderSeq: number;
}

Expand Down Expand Up @@ -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 { }
Expand All @@ -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}`,
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -808,19 +825,22 @@ export class ToolCallComponent extends Container {
name: string,
args: Record<string, unknown>,
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, {
id,
name,
args,
phase,
...(output !== undefined ? { output } : {}),
orderSeq: ++this.subToolOrderSeq,
});
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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)];
}
}

Expand Down
128 changes: 124 additions & 4 deletions apps/kimi-code/test/tui/components/messages/tool-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading