fix(tui): subagent thinking text and tool output display (#541)

This commit is contained in:
liruifengv 2026-06-08 16:19:15 +08:00 committed by GitHub
parent b47734ca0b
commit 2db1bd9675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 292 additions and 16 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix thinking text and tool output display for subagents.

View file

@ -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)

View file

@ -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':

View file

@ -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)];
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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');
});
});