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".
This commit is contained in:
liruifengv 2026-07-06 16:50:07 +08:00 committed by GitHub
parent c12f30951f
commit 5ea3ec489e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 96 additions and 33 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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