feat(tui): show full text for long bash commands and AskUserQuestion content (#150)

* feat(tui): reveal full bash command on ctrl+o expand

The Bash tool header truncates long commands at 60 chars and the
result renderer never showed the command body, so the full command
was nowhere to be found in the UI. When the user expands the card
with ctrl+o, render the complete multi-line command above the
output.

* feat(tui): wrap long text in AskUserQuestion dialog instead of truncating

Replace single-line truncation with hanging-indent word wrap for the
question prompt, body description, option label, option description,
and submit-tab review entries. Long content now flows onto multiple
rows instead of being cut off with an ellipsis.
This commit is contained in:
liruifengv 2026-05-28 15:54:00 +08:00 committed by GitHub
parent 76cbf86e20
commit 8b5a25161c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 253 additions and 25 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Show the full Bash command when expanding a Bash tool card with `ctrl+o`. The header still truncates long commands at 60 chars, but the expanded view now reveals the complete multi-line command above the output.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Wrap long question, body, and option text in the AskUserQuestion dialog instead of truncating with an ellipsis. The question prompt, body description, option label, option description, and submit-tab review entries now flow onto multiple lines with a hanging indent.

View file

@ -14,6 +14,7 @@ import {
type Focusable,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from '@earendil-works/pi-tui';
import chalk from 'chalk';
@ -39,6 +40,36 @@ interface DisplayOption {
readonly kind: 'preset' | 'other';
}
/**
* Push `content` to `lines`, wrapping it to fit `width` with a hanging
* indent. The first physical line starts with `firstPrefix`; continuation
* lines get `continuationPrefix`. Pass `tone` to wrap every emitted line
* in a single ANSI span (cleaner for selection highlights and matches the
* pre-wrap rendering tests expect); leave it undefined when the prefixes
* already carry their own mixed styling.
*/
function appendWrapped(
lines: string[],
firstPrefix: string,
continuationPrefix: string,
content: string,
width: number,
tone?: (s: string) => string,
): void {
const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
const contentWidth = Math.max(1, width - prefixWidth);
const wrapped = wrapTextWithAnsi(content, contentWidth);
const styleLine = tone ?? ((s: string) => s);
if (wrapped.length === 0) {
lines.push(styleLine(firstPrefix));
return;
}
lines.push(styleLine(`${firstPrefix}${wrapped[0] ?? ''}`));
for (let i = 1; i < wrapped.length; i++) {
lines.push(styleLine(`${continuationPrefix}${wrapped[i] ?? ''}`));
}
}
export class QuestionDialogComponent extends Container implements Focusable {
focused = false;
@ -432,7 +463,7 @@ export class QuestionDialogComponent extends Container implements Focusable {
this.pushTabs(lines);
lines.push('');
lines.push(accent(` ? ${question.question}`));
appendWrapped(lines, ' ? ', ' ', question.question, renderWidth, accent);
if (this.isEditingOther()) {
lines.push(dim(' Type your answer, then press Enter to save.'));
}
@ -442,7 +473,7 @@ export class QuestionDialogComponent extends Container implements Focusable {
const bodyLines = question.body.trim().split('\n');
const visibleBodyLines = bodyLines.slice(0, MAX_BODY_LINES);
for (const bodyLine of visibleBodyLines) {
lines.push(dim(` ${bodyLine}`));
appendWrapped(lines, ' ', ' ', bodyLine, renderWidth, dim);
}
if (bodyLines.length > visibleBodyLines.length) {
lines.push(dim(` ... ${String(bodyLines.length - visibleBodyLines.length)} more lines`));
@ -473,31 +504,34 @@ export class QuestionDialogComponent extends Container implements Focusable {
const label = this.renderOptionLabel(questionIdx, option, isCursor);
let line: string;
let tone: (s: string) => string;
let prefix: string;
if (question.multi_select) {
const checked = isSelected ? '✓' : ' ';
const body = `[${checked}] ${label}`;
if (isSelected && isCursor) line = success.bold(` ${body}`);
else if (isSelected) line = success(` ${body}`);
else if (isCursor) line = accent(` ${body}`);
else line = dim(` ${body}`);
prefix = ` [${checked}] `;
if (isSelected && isCursor) tone = (s) => success.bold(s);
else if (isSelected) tone = success;
else if (isCursor) tone = accent;
else tone = dim;
} else if (isSelected && this.isAnswered(questionIdx)) {
line = isCursor
? success.bold(` → [${String(num)}] ${label}`)
: success(` [${String(num)}] ${label}`);
prefix = isCursor ? ` → [${String(num)}] ` : ` [${String(num)}] `;
tone = isCursor ? (s) => success.bold(s) : success;
} else if (isCursor) {
line = accent(` → [${String(num)}] ${label}`);
prefix = ` → [${String(num)}] `;
tone = accent;
} else {
line = dim(` [${String(num)}] ${label}`);
prefix = ` [${String(num)}] `;
tone = dim;
}
lines.push(line);
const continuation = ' '.repeat(visibleWidth(prefix));
appendWrapped(lines, prefix, continuation, label, renderWidth, tone);
if (
option.description !== undefined &&
option.description.length > 0 &&
!(this.isEditingOther() && isCursor && isOther)
) {
lines.push(dim(` ${option.description}`));
appendWrapped(lines, ' ', ' ', option.description, renderWidth, dim);
}
}
@ -539,9 +573,21 @@ export class QuestionDialogComponent extends Container implements Focusable {
const question = this.request.data.questions[i];
if (question === undefined) continue;
const answer = this.answers[i];
lines.push(` ${dim('Q')} ${question.question}`);
appendWrapped(
lines,
` ${dim('Q')} `,
' ',
question.question,
renderWidth,
);
if (answer !== undefined && answer.length > 0) {
lines.push(` ${accent('→')} ${text(answer)}`);
appendWrapped(
lines,
` ${accent('→')} `,
' ',
text(answer),
renderWidth,
);
} else {
lines.push(` ${dim('→')} ${dim(NOT_ANSWERED_LABEL)}`);
}

View file

@ -2,7 +2,6 @@ import type { Component } from '@earendil-works/pi-tui';
import { Container, Text } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering';
import type { ColorPalette } from '#/tui/theme/colors';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
@ -15,6 +14,11 @@ export interface ShellExecutionOptions {
readonly colors: ColorPalette;
readonly expanded?: boolean;
readonly showCommand?: boolean;
/**
* Max command lines to render. `undefined` means no cap used by the
* ctrl+o expanded view so the user can see the full multi-line command
* even when the header preview was truncated.
*/
readonly commandPreviewLines?: number;
readonly resultPreviewLines?: number;
}
@ -24,10 +28,7 @@ export class ShellExecutionComponent extends Container {
super();
if (options.showCommand === true) {
this.addCommandPreview(
options.command ?? '',
options.commandPreviewLines ?? COMMAND_PREVIEW_LINES,
);
this.addCommandPreview(options.command ?? '', options.commandPreviewLines);
}
if (options.result !== undefined) {
@ -40,9 +41,10 @@ export class ShellExecutionComponent extends Container {
}
}
private addCommandPreview(command: string, previewLines: number): void {
private addCommandPreview(command: string, previewLines: number | undefined): void {
if (command.length === 0) return;
const lines = command.split('\n').slice(0, previewLines);
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(chalk.dim(prefix + line), 2, 0));
@ -84,5 +86,10 @@ export const shellExecutionResultRenderer: ResultRenderer = (
result,
colors: ctx.colors,
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

@ -10,6 +10,12 @@ function strip(text: string): string {
return text.replaceAll(/\u001B\[[0-9;]*m/g, '');
}
// Collapse all whitespace runs so wrapped content can be matched against its
// original (single-line) form without caring where the line break landed.
function flatten(text: string): string {
return strip(text).replaceAll(/\s+/g, ' ').trim();
}
beforeAll(() => {
chalk.level = 3;
});
@ -436,4 +442,95 @@ describe('QuestionDialogComponent', () => {
expect(collected).toEqual([]);
});
describe('long-content wrapping', () => {
const longQuestion =
'Please confirm whether this dangerous shell command should really be executed in the current workspace, including all of its side effects on the filesystem and the network.';
const longBody =
'This single-line body description is intentionally written without any embedded newlines so the renderer is forced to wrap it across multiple rows instead of truncating with an ellipsis.';
const longLabel =
'Apply changes to every file under the current workspace including nested submodules and lockfiles';
const longDescription =
'This option will rewrite history on the remote branch and force-push, so collaborators will need to re-sync their local checkouts before continuing any work.';
it('wraps the question text across multiple lines instead of truncating', () => {
const pending = makePending([
{
question: longQuestion,
multi_select: false,
options: [{ label: 'Yes' }, { label: 'No' }],
},
]);
const { dialog } = makeDialog(pending);
const rendered = dialog.render(40);
const joined = rendered.map((line) => strip(line).trimEnd()).join('\n');
const flat = flatten(rendered.join('\n'));
expect(joined).not.toContain('…');
// Question text should span multiple physical lines.
expect(joined.split('\n').filter((l) => l.includes('?') || /Please|workspace|side/.test(l)).length).toBeGreaterThan(1);
// And the full content should still be reconstructable.
expect(flat).toContain(longQuestion);
});
it('wraps body lines that exceed the terminal width', () => {
const pending = makePending([
{
question: 'Q?',
body: longBody,
multi_select: false,
options: [{ label: 'A' }],
},
]);
const { dialog } = makeDialog(pending);
const rendered = dialog.render(40);
const joined = rendered.map((line) => strip(line).trimEnd()).join('\n');
const flat = flatten(rendered.join('\n'));
expect(joined).not.toContain('…');
expect(flat).toContain(longBody);
});
it('wraps long option labels and descriptions', () => {
const pending = makePending([
{
question: 'Q?',
multi_select: false,
options: [
{
label: longLabel,
description: longDescription,
},
],
},
]);
const { dialog } = makeDialog(pending);
const rendered = dialog.render(40);
const joined = rendered.map((line) => strip(line).trimEnd()).join('\n');
const flat = flatten(rendered.join('\n'));
expect(joined).not.toContain('…');
expect(flat).toContain(longLabel);
expect(flat).toContain(longDescription);
});
it('wraps long questions in the submit-tab review', () => {
const pending = makePending([
{
question: longQuestion,
multi_select: false,
options: [{ label: 'Yes' }, { label: 'No' }],
},
]);
const { dialog } = makeDialog(pending);
dialog.handleInput('1');
const rendered = dialog.render(40);
const joined = rendered.map((line) => strip(line).trimEnd()).join('\n');
const flat = flatten(rendered.join('\n'));
expect(joined).toContain('Review your answer before submit');
expect(joined).not.toContain('…');
expect(flat).toContain(longQuestion);
});
});
});

View file

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { ShellExecutionComponent } from '#/tui/components/messages/shell-execution';
import {
ShellExecutionComponent,
shellExecutionResultRenderer,
} from '#/tui/components/messages/shell-execution';
import { darkColors } from '#/tui/theme/colors';
function strip(text: string): string {
@ -52,4 +55,69 @@ describe('ShellExecutionComponent', () => {
expect(expandedOutput).toContain('line5');
expect(expandedOutput).not.toContain('ctrl+o to expand');
});
it('renders unbounded command preview when previewLines is undefined', () => {
const cmd = Array.from({ length: 20 }, (_, i) => `step${String(i + 1)}`).join('\n');
const component = new ShellExecutionComponent({
command: cmd,
colors: darkColors,
showCommand: true,
commandPreviewLines: undefined,
});
const output = component.render(100).map(strip).join('\n');
expect(output).toContain('$ step1');
expect(output).toContain('step20');
});
describe('shellExecutionResultRenderer', () => {
const longCmd = `echo ${'a'.repeat(200)}\necho done`;
it('omits the command preview when collapsed', () => {
const components = shellExecutionResultRenderer(
{
id: 'call_1',
name: 'Bash',
args: { command: longCmd },
},
{
tool_call_id: 'call_1',
output: 'ok',
is_error: false,
},
{ expanded: false, colors: darkColors },
);
const rendered = components
.flatMap((c) => c.render(100))
.map(strip)
.join('\n');
expect(rendered).not.toContain('$ echo');
expect(rendered).toContain('ok');
});
it('reveals the full multi-line command when expanded', () => {
const components = shellExecutionResultRenderer(
{
id: 'call_1',
name: 'Bash',
args: { command: longCmd },
},
{
tool_call_id: 'call_1',
output: 'ok',
is_error: false,
},
{ expanded: true, colors: darkColors },
);
const rendered = components
.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');
});
});
});