diff --git a/.changeset/bash-expand-full-command.md b/.changeset/bash-expand-full-command.md new file mode 100644 index 000000000..a6ee8a02b --- /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/.changeset/wrap-askuserquestion-long-text.md b/.changeset/wrap-askuserquestion-long-text.md new file mode 100644 index 000000000..0cfecd748 --- /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 534e2c2f2..6c31e342c 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/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index 45279fd94..3271353e2 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/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 6a492f406..a015e4176 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); + }); + }); + }); 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 86e31ded1..44684367c 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'); + }); + }); });