From 5ea3ec489e0a7d66b844c39ee65162fd6a8ed8b1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 6 Jul 2026 16:50:07 +0800 Subject: [PATCH] fix(tui): stabilize Bash card height and distinguish command from output (#1419) Keep the command preview in the body after the result lands so a multi-line command with short output no longer collapses the card. Render the command in textDim with a shellMode $ and the result one shade dimmer in textMuted, and simplify the header to "Running a command" / "Ran a command". --- .changeset/fix-bash-tool-card-collapse.md | 5 ++ .../components/messages/shell-execution.ts | 24 ++++++---- .../src/tui/components/messages/tool-call.ts | 30 +++++++++--- .../messages/tool-renderers/truncated.ts | 8 +++- .../messages/shell-execution.test.ts | 15 +++--- .../tui/components/messages/tool-call.test.ts | 47 ++++++++++++++----- 6 files changed, 96 insertions(+), 33 deletions(-) create mode 100644 .changeset/fix-bash-tool-card-collapse.md diff --git a/.changeset/fix-bash-tool-card-collapse.md b/.changeset/fix-bash-tool-card-collapse.md new file mode 100644 index 000000000..dcaa20c11 --- /dev/null +++ b/.changeset/fix-bash-tool-card-collapse.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the Bash tool card collapsing in height when a multi-line command finishes with short output, and visually separate the command from its output. 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 5e10b84de..cb6f95dcd 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -48,8 +48,15 @@ export class ShellExecutionComponent extends Container { const allLines = command.split('\n'); const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); for (const [i, line] of lines.entries()) { - const prefix = i === 0 ? '$ ' : ' '; - this.addChild(new Text(currentTheme.dim(prefix + line), 2, 0)); + // Distinguish the command (input) from the result (output): the `$` + // prompt uses the dedicated shell-mode hue, the command body uses + // `textDim`, and the result below is rendered one step dimmer in + // `textMuted` so the two stay separable without a connecting glyph. + const text = + i === 0 + ? currentTheme.fg('shellMode', '$ ') + currentTheme.dim(line) + : ` ${currentTheme.dim(line)}`; + this.addChild(new Text(text, 2, 0)); } } @@ -68,24 +75,23 @@ export class ShellExecutionComponent extends Container { maxLines: previewLines, tail: tailOutput, expandHint, + color: 'textMuted', }), ); } } export const shellExecutionResultRenderer: ResultRenderer = ( - toolCall: ToolCallBlockData, + _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, ): Component[] => [ + // Result only. The command preview is owned by ToolCallComponent's + // buildCallPreview across the whole lifecycle (streaming, running, and + // done); rendering it here too would duplicate the command once the result + // lands. new ShellExecutionComponent({ - command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', result, expanded: ctx.expanded, - // Header truncates long bash commands to 60 chars. When the user expands - // the card with ctrl+o, reveal the full command (no line cap) so they - // can read what actually ran. - showCommand: ctx.expanded, - commandPreviewLines: undefined, }), ]; 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 9b2a2d20f..978dfa15b 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1444,6 +1444,20 @@ export class ToolCallComponent extends Container { return `${bullet}${currentTheme.boldFg(tone, label)}`; } + if (toolCall.name === 'Bash') { + // The command itself is rendered in the body (with a `$` prompt), so the + // header only names the action — repeating the command in parentheses + // would duplicate the body. Wording mirrors the other label-only headers + // (e.g. AskUserQuestion): the whole label takes the tone colour. + if (isTruncated) { + return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`; + } + const label = isFinished ? 'Ran a command' : 'Running a command'; + const tone = isError ? 'error' : 'primary'; + const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; + return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`; + } + const goalHeader = buildGoalToolHeader({ toolCall, result, @@ -1962,11 +1976,15 @@ export class ToolCallComponent extends Container { for (const line of lines) { this.addChild(new Text(line, 2, 0)); } - } else if (name === 'Bash' && this.result === undefined) { - // While a long-running Bash call is in-flight (args finalized, no result - // yet), surface its command in the body so the user can see what is - // running and expand it with ctrl+o. Once the result lands, buildContent's - // shellExecutionResultRenderer takes over command rendering. + } else if (name === 'Bash') { + // Surface the command in the body across the whole lifecycle — while + // streaming, running, and after the result lands. Keeping the collapsed + // command preview here (instead of yielding to the result renderer once + // the result lands) avoids a height collapse when a multi-line command + // finishes with short output: the command block stays put and only the + // live-output tail swaps for the result. Owned solely by buildCallPreview + // so the command never renders twice; shellExecutionResultRenderer + // renders the result only. const command = str(this.toolCall.args['command']); if (command.length === 0) return; this.addChild( @@ -2039,7 +2057,7 @@ export class ToolCallComponent extends Container { new ShellExecutionComponent({ command: cmd, showCommand: true, - commandPreviewLines: COMMAND_PREVIEW_LINES, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, }), ); } 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 036ae0a20..dc1066bec 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 @@ -1,6 +1,7 @@ import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -44,6 +45,10 @@ export class TruncatedOutputComponent implements Component { // When true, collapsed rendering keeps the latest visual rows instead of // the first rows. This is useful for live output from a running command. tail?: boolean; + // Foreground colour for successful (non-error) output. Defaults to + // `textDim`; Bash passes `textMuted` so its result sits one shade below + // the `textDim` command. Error output always uses `error`. + color?: keyof ColorPalette; }, ) { this.expanded = options.expanded; @@ -52,8 +57,9 @@ export class TruncatedOutputComponent implements Component { this.expandHint = options.expandHint ?? true; this.tail = options.tail ?? false; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); + const successColor = options.color ?? 'textDim'; this.textComponent = new Text( - options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.fg(successColor, cleaned), this.indent, 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 128aa2cdd..bb501c05b 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 @@ -112,7 +112,7 @@ describe('ShellExecutionComponent', () => { describe('shellExecutionResultRenderer', () => { const longCmd = `echo ${'a'.repeat(200)}\necho done`; - it('omits the command preview when collapsed', () => { + it('renders only the result and leaves the command to the call preview', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -131,11 +131,14 @@ describe('ShellExecutionComponent', () => { .flatMap((c) => c.render(100)) .map(strip) .join('\n'); + // Command is owned by ToolCallComponent.buildCallPreview, not the + // renderer — rendering it here too would duplicate it once the result + // lands. expect(rendered).not.toContain('$ echo'); expect(rendered).toContain('ok'); }); - it('reveals the full multi-line command when expanded', () => { + it('still renders only the result when expanded', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -144,7 +147,7 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, { expanded: true }, @@ -154,9 +157,9 @@ describe('ShellExecutionComponent', () => { .flatMap((c) => c.render(300)) .map(strip) .join('\n'); - expect(rendered).toContain(`$ echo ${'a'.repeat(200)}`); - expect(rendered).toContain('echo done'); - expect(rendered).toContain('ok'); + expect(rendered).not.toContain('$ echo'); + expect(rendered).toContain('line4'); + expect(rendered).toContain('line5'); }); }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 2b796829e..06d7ec419 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -186,7 +186,7 @@ describe('ToolCallComponent', () => { component.appendLiveOutput('line2\n'); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Using Bash'); + expect(out).toContain('Running a command'); expect(out).toContain('line1'); expect(out).toContain('line2'); }); @@ -209,12 +209,12 @@ describe('ToolCallComponent', () => { }); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Bash'); + expect(out).toContain('Ran a command'); expect(out).toContain('final-only'); expect(out).not.toContain('streamed-only'); }); - describe('in-flight Bash command preview (args finalized, no result yet)', () => { + describe('Bash command preview', () => { const longCommand = Array.from({ length: 15 }, (_, i) => `echo step${String(i + 1)}`).join( '\n', ); @@ -226,7 +226,7 @@ describe('ToolCallComponent', () => { ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain('Using Bash'); + expect(collapsed).toContain('Running a command'); expect(collapsed).toContain('echo step1'); expect(collapsed).toContain('echo step10'); expect(collapsed).not.toContain('echo step11'); @@ -238,7 +238,7 @@ describe('ToolCallComponent', () => { expect(expanded).toContain('echo step15'); }); - it('yields command rendering to the result renderer once the result lands', () => { + it('keeps the command preview after the result lands to avoid a height collapse', () => { const component = new ToolCallComponent( { id: 'call_bash_done', name: 'Bash', args: { command: longCommand } }, undefined, @@ -249,12 +249,37 @@ describe('ToolCallComponent', () => { component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); - // Collapsed result view delegates to shellExecutionResultRenderer, which - // hides the command — so the in-flight buildCallPreview preview must be - // gone, otherwise the command would render twice when expanded. + // Collapsed result view still shows the command preview (capped at + // COMMAND_PREVIEW_LINES) so a multi-line command with short output does + // not collapse the card. The command is owned by buildCallPreview, so it + // must appear exactly once — the result renderer no longer renders it. const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Bash'); - expect(out).not.toContain('$ echo step1'); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ echo step1'); + expect(out).toContain('echo step10'); + expect(out).not.toContain('echo step11'); + expect(out).toContain('done'); + expect(out.split('$ echo step1').length - 1).toBe(1); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo step11'); + expect(expanded).toContain('echo step15'); + }); + + it('keeps the command preview when the command produces no output', () => { + const component = new ToolCallComponent( + { id: 'call_bash_empty', name: 'Bash', args: { command: 'mkdir -p a/b/c\necho done' } }, + { tool_call_id: 'call_bash_empty', output: '', is_error: false }, + ); + + // buildContent early-returns on empty output, but the command preview + // (owned by buildCallPreview) must still render so the card does not + // collapse to just the header. + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ mkdir -p a/b/c'); + expect(out).toContain('echo done'); }); }); @@ -275,7 +300,7 @@ describe('ToolCallComponent', () => { ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain(`${STATUS_BULLET}Used Bash`); + expect(collapsed).toContain(`${STATUS_BULLET}Ran a command`); expect(collapsed).not.toContain('system-reminder'); expect(collapsed).not.toContain('task tools');