From 279ac08ae235ba5f7c756d3060406be83c22a740 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 15:47:15 +0800 Subject: [PATCH 1/2] 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. --- .changeset/bash-expand-full-command.md | 5 ++ .../components/messages/shell-execution.ts | 21 ++++-- .../messages/shell-execution.test.ts | 70 ++++++++++++++++++- 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 .changeset/bash-expand-full-command.md diff --git a/.changeset/bash-expand-full-command.md b/.changeset/bash-expand-full-command.md new file mode 100644 index 0000000000..a6ee8a02b6 --- /dev/null +++ b/.changeset/bash-expand-full-command.md @@ -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. 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 45279fd94f..3271353e26 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -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, }), ]; 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 86e31ded11..44684367cb 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 @@ -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'); + }); + }); }); From 8faa14403dfdcd1fd6228abe1f2eb7dede8b3aaf Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 15:47:20 +0800 Subject: [PATCH 2/2] 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. --- .changeset/wrap-askuserquestion-long-text.md | 5 + .../tui/components/dialogs/question-dialog.ts | 80 +++++++++++---- .../dialogs/question-dialog.test.ts | 97 +++++++++++++++++++ 3 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 .changeset/wrap-askuserquestion-long-text.md diff --git a/.changeset/wrap-askuserquestion-long-text.md b/.changeset/wrap-askuserquestion-long-text.md new file mode 100644 index 0000000000..0cfecd7481 --- /dev/null +++ b/.changeset/wrap-askuserquestion-long-text.md @@ -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. diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index 534e2c2f2f..6c31e342cc 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -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)}`); } diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 6a492f4067..a015e41761 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -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); + }); + }); + });