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

Fix tool output preview rendering: trim trailing empty lines, append ellipsis to multi-line Bash command headers, and truncate long single-line output by visual wrapped lines instead of raw newline count.
24 changes: 9 additions & 15 deletions apps/kimi-code/src/tui/components/messages/shell-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';

import type { ResultRenderer } from './tool-renderers/types';
import { PREVIEW_LINES } from './tool-renderers/types';
import { TruncatedOutputComponent } from './tool-renderers/truncated';

export interface ShellExecutionOptions {
readonly command?: string;
Expand Down Expand Up @@ -58,21 +59,14 @@ export class ShellExecutionComponent extends Container {
previewLines: number,
): void {
if (!result.output) return;
const tint = result.is_error ? chalk.hex(colors.error) : chalk.dim;
if (expanded) {
this.addChild(new Text(tint(result.output), 2, 0));
return;
}

const lines = result.output.split('\n');
const shown = lines.slice(0, previewLines);
const remaining = lines.length - shown.length;
this.addChild(new Text(tint(shown.join('\n')), 2, 0));
if (remaining > 0) {
this.addChild(
new Text(chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), 2, 0),
);
}
this.addChild(
new TruncatedOutputComponent(result.output, {
expanded,
isError: result.is_error ?? false,
colors,
maxLines: previewLines,
}),
);
}
}

Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,9 @@ function extractKeyArgument(
const val = args[key];
if (typeof val === 'string' && val.length > 0) {
const firstLine = val.split('\n')[0] ?? val;
return formatKeyArgument(toolName, key, firstLine, workspaceDir);
const displayValue =
toolName === 'Bash' && val.includes('\n') ? `${firstLine}…` : firstLine;
return formatKeyArgument(toolName, key, displayValue, workspaceDir);
}
}
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,75 @@ import type { Component } from '@earendil-works/pi-tui';
import { Text } from '@earendil-works/pi-tui';
import chalk from 'chalk';

import type { ColorPalette } from '#/tui/theme/colors';

import type { ResultRenderer } from './types';
import { PREVIEW_LINES } from './types';

export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => {
if (!result.output) return [];
const tint = result.is_error ? chalk.hex(ctx.colors.error) : chalk.dim;
const lines = result.output.split('\n');
if (ctx.expanded) {
return [new Text(tint(result.output), 2, 0)];
export function trimTrailingEmptyLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0) {
const line = lines[end - 1];
if (line === undefined || line.length > 0) break;
end--;
}
return lines.slice(0, end);
}

/**
* Component that renders tool output with wrap-aware line truncation.
* Uses pi-tui's Text component to compute actual visual wrapped lines,
* then caps at PREVIEW_LINES. This handles long single-line output (e.g.
* JSON blobs) that would otherwise wrap to dozens of visual rows.
*/
export class TruncatedOutputComponent implements Component {
private readonly textComponent: Text;
private readonly expanded: boolean;
private readonly maxLines: number;

constructor(
output: string,
options: {
expanded: boolean;
isError: boolean | undefined;
colors: ColorPalette;
maxLines?: number;
},
) {
this.expanded = options.expanded;
this.maxLines = options.maxLines ?? PREVIEW_LINES;
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);
}

invalidate(): void {
this.textComponent.invalidate();
}
const shown = lines.slice(0, PREVIEW_LINES);
const remaining = lines.length - shown.length;
const out: Component[] = [new Text(tint(shown.join('\n')), 2, 0)];
if (remaining > 0) {
out.push(new Text(chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), 2, 0));

render(width: number): string[] {
const contentLines = this.textComponent.render(width);

if (this.expanded || contentLines.length <= this.maxLines) {
return contentLines;
}

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)`),
];
}
return out;
}

export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => {
if (!result.output) return [];
return [
new TruncatedOutputComponent(result.output, {
expanded: ctx.expanded,
isError: result.is_error ?? false,
colors: ctx.colors,
}),
];
};
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,53 @@ describe('ShellExecutionComponent', () => {
expect(output).toContain('step20');
});

it('does not count trailing empty lines toward the preview cap', () => {
const component = new ShellExecutionComponent({
result: {
tool_call_id: 'call_shell',
output: 'hello\n\n\n', // 1 content line + 2 trailing empty lines
is_error: false,
},
colors: darkColors,
});

const output = component.render(100).map(strip).join('\n');
expect(output).toContain('hello');
expect(output).not.toContain('... (2 more lines');
});

it('preserves internal empty lines while trimming only trailing ones', () => {
const component = new ShellExecutionComponent({
result: {
tool_call_id: 'call_shell',
output: 'a\n\nb\n\n\n', // 1 internal empty line + 2 trailing empty lines
is_error: false,
},
colors: darkColors,
});

const output = component.render(100).map(strip).join('\n');
expect(output).toContain('a');
expect(output).toContain('b');
expect(output).not.toContain('... (2 more lines');
});

it('truncates long single-line output by wrapped visual lines', () => {
const component = new ShellExecutionComponent({
result: {
tool_call_id: 'call_shell',
output: 'x'.repeat(500),
is_error: false,
},
colors: darkColors,
});

const out = strip(component.render(20).join('\n'));
expect(out).toContain('x');
expect(out).not.toContain('x'.repeat(500));
expect(out).toContain('... (');
});

describe('shellExecutionResultRenderer', () => {
const longCmd = `echo ${'a'.repeat(200)}\necho done`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,13 @@ describe('tool-result registry', () => {
);
expect(out).toContain('ENOENT: foo.ts not found');
});

it('truncates unknown tool output by wrapped visual lines, not raw newlines', () => {
const renderer = pickResultRenderer('SomethingUnknown');
const longLine = 'x'.repeat(500);
const out = strip(joinRender(renderer(call('SomethingUnknown'), result(longLine), ctx), 20));
expect(out).toContain('x');
expect(out).not.toContain(longLine);
expect(out).toContain('... (');
});
});
Loading