Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bash-expand-full-command.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/wrap-askuserquestion-long-text.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 63 additions & 17 deletions apps/kimi-code/src/tui/components/dialogs/question-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type Focusable,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from '@earendil-works/pi-tui';
import chalk from 'chalk';

Expand All @@ -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;

Expand Down Expand Up @@ -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.'));
}
Expand All @@ -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`));
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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)}`);
}
Expand Down
21 changes: 14 additions & 7 deletions apps/kimi-code/src/tui/components/messages/shell-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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));
Expand Down Expand Up @@ -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,
}),
];
97 changes: 97 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down Expand Up @@ -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);
});
});

});
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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');
});
});
});
Loading