diff --git a/.changeset/fix-tool-output-preview-truncation.md b/.changeset/fix-tool-output-preview-truncation.md new file mode 100644 index 0000000000..a2171abde7 --- /dev/null +++ b/.changeset/fix-tool-output-preview-truncation.md @@ -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. diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index 3271353e26..bb5ecd2929 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -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; @@ -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, + }), + ); } } 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 0fd4a53f10..6c213e796d 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -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; 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 2bd9cf01be..ffd353dff2 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 @@ -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, + }), + ]; }; diff --git a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts index 44684367cb..f59121c4b7 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts @@ -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`; 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 aebe874353..c17e997d28 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 @@ -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('... ('); + }); });