mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(tui): subagent thinking text and tool output display (#541)
This commit is contained in:
parent
b47734ca0b
commit
2db1bd9675
7 changed files with 292 additions and 16 deletions
5
.changeset/subagent-tool-output-preview.md
Normal file
5
.changeset/subagent-tool-output-preview.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix thinking text and tool output display for subagents.
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<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, {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue