mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
c12f30951f
commit
5ea3ec489e
6 changed files with 96 additions and 33 deletions
5
.changeset/fix-bash-tool-card-collapse.md
Normal file
5
.changeset/fix-bash-tool-card-collapse.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue