From cfafb5f34c82392c72a100575e4525e1250dd8fb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 2 Jun 2026 14:32:06 +0800 Subject: [PATCH 1/4] fix: trim trailing empty lines from shell output previews --- .../components/messages/shell-execution.ts | 3 +- .../messages/tool-renderers/truncated.ts | 10 +++++- .../messages/shell-execution.test.ts | 31 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) 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..7300fd25e5 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 { trimTrailingEmptyLines } from './tool-renderers/truncated'; export interface ShellExecutionOptions { readonly command?: string; @@ -64,7 +65,7 @@ export class ShellExecutionComponent extends Container { return; } - const lines = result.output.split('\n'); + const lines = trimTrailingEmptyLines(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)); 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..e75682de2f 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 @@ -5,10 +5,18 @@ import chalk from 'chalk'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; +export function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0 && lines[end - 1].length === 0) { + end--; + } + return lines.slice(0, end); +} + 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'); + const lines = trimTrailingEmptyLines(result.output.split('\n')); if (ctx.expanded) { return [new Text(tint(result.output), 2, 0)]; } 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..36fe9968f8 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,37 @@ describe('ShellExecutionComponent', () => { expect(output).toContain('step20'); }); + it('末尾空行不计入 preview 配额,避免间距过大', () => { + const component = new ShellExecutionComponent({ + result: { + tool_call_id: 'call_shell', + output: 'hello\n\n\n', // 1 行内容 + 2 行末尾空行 + 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('中间空行保留,仅 trim 末尾空行', () => { + const component = new ShellExecutionComponent({ + result: { + tool_call_id: 'call_shell', + output: 'a\n\nb\n\n\n', // 中间 1 空行 + 末尾 2 空行 + 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'); + }); + describe('shellExecutionResultRenderer', () => { const longCmd = `echo ${'a'.repeat(200)}\necho done`; From 136d2cf5a4259efd858dfe527ebc9ec841a44c96 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 2 Jun 2026 14:34:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20append=20=E2=80=A6=20to=20multi-line?= =?UTF-8?q?=20Bash=20command=20header=20previews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/kimi-code/src/tui/components/messages/tool-call.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; From 390e981505bd32242dfd00cf7139a376cc637af9 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 2 Jun 2026 14:44:02 +0800 Subject: [PATCH 3/4] fix: truncate tool output by visual wrapped lines instead of raw newlines --- .../components/messages/shell-execution.ts | 26 +++----- .../messages/tool-renderers/truncated.ts | 65 +++++++++++++++---- .../messages/shell-execution.test.ts | 16 +++++ .../messages/tool-renderers/registry.test.ts | 9 +++ 4 files changed, 87 insertions(+), 29 deletions(-) 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 7300fd25e5..2202c3ff3e 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -7,7 +7,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; import { PREVIEW_LINES } from './tool-renderers/types'; -import { trimTrailingEmptyLines } from './tool-renderers/truncated'; +import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { readonly command?: string; @@ -56,24 +56,16 @@ export class ShellExecutionComponent extends Container { result: ToolResultBlockData, colors: ColorPalette, expanded: boolean, - previewLines: number, + _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 = trimTrailingEmptyLines(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, + colors, + }), + ); } } 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 e75682de2f..6144e1925f 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,6 +2,8 @@ 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'; @@ -13,18 +15,57 @@ export function trimTrailingEmptyLines(lines: string[]): string[] { return lines.slice(0, end); } -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 = trimTrailingEmptyLines(result.output.split('\n')); - if (ctx.expanded) { - return [new Text(tint(result.output), 2, 0)]; +/** + * 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; + + constructor( + output: string, + options: { + expanded: boolean; + isError: boolean; + colors: ColorPalette; + }, + ) { + this.expanded = options.expanded; + 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); } - 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)); + + invalidate(): void { + this.textComponent.invalidate(); } - return out; + + render(width: number): string[] { + const contentLines = this.textComponent.render(width); + + if (this.expanded || contentLines.length <= PREVIEW_LINES) { + return contentLines; + } + + const shown = contentLines.slice(0, PREVIEW_LINES); + const remaining = contentLines.length - PREVIEW_LINES; + return [ + ...shown, + chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), + ]; + } +} + +export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { + if (!result.output) return []; + return [ + new TruncatedOutputComponent(result.output, { + expanded: ctx.expanded, + isError: result.is_error, + 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 36fe9968f8..a635677e04 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 @@ -101,6 +101,22 @@ describe('ShellExecutionComponent', () => { 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('... ('); + }); }); From fd7da49fa7820bc2420b7ee063c4e54696f496ed Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 2 Jun 2026 14:47:35 +0800 Subject: [PATCH 4/4] chore: add changeset for tool output preview fixes --- .../fix-tool-output-preview-truncation.md | 5 +++++ .../tui/components/messages/shell-execution.ts | 5 +++-- .../messages/tool-renderers/truncated.ts | 17 +++++++++++------ .../components/messages/shell-execution.test.ts | 8 ++++---- 4 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-tool-output-preview-truncation.md 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 2202c3ff3e..bb5ecd2929 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -56,14 +56,15 @@ export class ShellExecutionComponent extends Container { result: ToolResultBlockData, colors: ColorPalette, expanded: boolean, - _previewLines: number, + previewLines: number, ): void { if (!result.output) return; this.addChild( new TruncatedOutputComponent(result.output, { expanded, - isError: result.is_error, + isError: result.is_error ?? false, colors, + maxLines: previewLines, }), ); } 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 6144e1925f..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 @@ -9,7 +9,9 @@ import { PREVIEW_LINES } from './types'; export function trimTrailingEmptyLines(lines: string[]): string[] { let end = lines.length; - while (end > 0 && lines[end - 1].length === 0) { + while (end > 0) { + const line = lines[end - 1]; + if (line === undefined || line.length > 0) break; end--; } return lines.slice(0, end); @@ -24,16 +26,19 @@ export function trimTrailingEmptyLines(lines: string[]): string[] { 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; + 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); @@ -46,12 +51,12 @@ export class TruncatedOutputComponent implements Component { render(width: number): string[] { const contentLines = this.textComponent.render(width); - if (this.expanded || contentLines.length <= PREVIEW_LINES) { + if (this.expanded || contentLines.length <= this.maxLines) { return contentLines; } - const shown = contentLines.slice(0, PREVIEW_LINES); - const remaining = contentLines.length - PREVIEW_LINES; + 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)`), @@ -64,7 +69,7 @@ export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { return [ new TruncatedOutputComponent(result.output, { expanded: ctx.expanded, - isError: result.is_error, + 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 a635677e04..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,11 +70,11 @@ describe('ShellExecutionComponent', () => { expect(output).toContain('step20'); }); - it('末尾空行不计入 preview 配额,避免间距过大', () => { + 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 行内容 + 2 行末尾空行 + output: 'hello\n\n\n', // 1 content line + 2 trailing empty lines is_error: false, }, colors: darkColors, @@ -85,11 +85,11 @@ describe('ShellExecutionComponent', () => { expect(output).not.toContain('... (2 more lines'); }); - it('中间空行保留,仅 trim 末尾空行', () => { + 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 空行 + 末尾 2 空行 + output: 'a\n\nb\n\n\n', // 1 internal empty line + 2 trailing empty lines is_error: false, }, colors: darkColors,