From b2d1ce8eb6f5e3b775a25508bb30f0d7d323fe5b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 15:59:27 +0800 Subject: [PATCH 01/17] feat(kimi-code): two-line collapsed tool cards with width-aware headers Collapsed cards now show the call on line one and a single dim outcome row on line two: a Bash command's last output line (the live tail while running) with a line-count chip, a Grep/Glob path sample, or a generic tool's first output line. Read groups stay header-only and honor the global expand state; failed calls keep their preview. Headers split into head / flexible / tail segments so a long command fills the terminal width before the chip. --- .changeset/compact-tool-cards.md | 5 + .../src/tui/components/messages/read-group.ts | 32 ++- .../components/messages/shell-execution.ts | 22 +- .../src/tui/components/messages/tool-call.ts | 112 +++++++--- .../messages/tool-renderers/chip.ts | 8 + .../messages/tool-renderers/outcome.ts | 36 ++++ .../messages/tool-renderers/registry.ts | 4 +- .../messages/tool-renderers/summary.ts | 11 +- .../messages/tool-renderers/truncated.ts | 9 + .../messages/truncated-header-line.ts | 128 +++++++++++ .../src/tui/controllers/streaming-ui.ts | 1 + .../dialogs/agent-activity-viewer.test.ts | 13 +- .../components/messages/read-group.test.ts | 61 ++++++ .../messages/shell-execution.test.ts | 34 ++- .../tui/components/messages/tool-call.test.ts | 201 ++++++++++++++---- .../messages/tool-renderers/chip.test.ts | 12 +- .../messages/tool-renderers/media.test.ts | 12 +- .../messages/tool-renderers/registry.test.ts | 84 ++++++-- .../messages/truncated-header-line.test.ts | 88 ++++++++ 19 files changed, 750 insertions(+), 123 deletions(-) create mode 100644 .changeset/compact-tool-cards.md create mode 100644 apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts create mode 100644 apps/kimi-code/src/tui/components/messages/truncated-header-line.ts create mode 100644 apps/kimi-code/test/tui/components/messages/read-group.test.ts create mode 100644 apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts diff --git a/.changeset/compact-tool-cards.md b/.changeset/compact-tool-cards.md new file mode 100644 index 00000000000..6a890341aba --- /dev/null +++ b/.changeset/compact-tool-cards.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Collapse finished tool calls in the transcript to two lines: the header names the call (a Bash card carries the command and its line count) and one outcome row shows the command's last output line, a Grep/Glob path sample, or a tool's first output line; the full output and a Read group's file list appear after `Ctrl+O`. Failed calls keep their output preview, and Edit/Write previews are unchanged. diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 141562e4c05..3016bf6884b 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -4,7 +4,8 @@ * It follows the same structure as `AgentGroupComponent`, with a smaller * surface: * - one summary header and a tree body listing each file path and status; - * - permanently grouped, while the body remains visible; + * - permanently grouped; the body is shown only while expanded (ctrl+o), + * the collapsed group is the header line alone; * - 200ms throttling, matching AgentGroup; * - state stays in each `ToolCallComponent`; the group only reads snapshots. * @@ -27,6 +28,7 @@ import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; +import { TruncatedHeaderLine } from './truncated-header-line'; const THROTTLE_MS = 200; @@ -37,16 +39,17 @@ interface ReadEntry { export class ReadGroupComponent extends Container { private readonly entries: ReadEntry[] = []; - private readonly headerText: Text; + private readonly headerText: TruncatedHeaderLine; private readonly bodyContainer: Container; private throttleTimer: ReturnType | null = null; private lastFlushPhases = new Map(); private _invalidating = false; + private expanded = false; constructor(private readonly ui: TUI | undefined) { super(); this.addChild(new Spacer(1)); - this.headerText = new Text('', 0, 0); + this.headerText = new TruncatedHeaderLine(''); this.addChild(this.headerText); this.bodyContainer = new Container(); this.addChild(this.bodyContainer); @@ -56,6 +59,13 @@ export class ReadGroupComponent extends Container { return this.entries.length; } + /** Global ctrl+o toggle: the per-file body is only rendered while expanded. */ + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.flushRender(); + } + /** * Borrows a standalone `ToolCallComponent` into the group as a hidden state * container. Snapshot changes trigger throttled refreshes. Re-attaching the @@ -112,13 +122,15 @@ export class ReadGroupComponent extends Container { this.headerText.setText(this.buildHeader(snapshots.length, pending, failed, totalLines)); this.bodyContainer.clear(); - const visibleSnapshots = snapshots.filter( - (snap) => snap.filePath !== undefined && snap.filePath.length > 0, - ); - visibleSnapshots.forEach((snap, idx) => { - const isLast = idx === visibleSnapshots.length - 1; - this.bodyContainer.addChild(new Text(this.buildBodyLine(snap, isLast), 0, 0)); - }); + if (this.expanded) { + const visibleSnapshots = snapshots.filter( + (snap) => snap.filePath !== undefined && snap.filePath.length > 0, + ); + visibleSnapshots.forEach((snap, idx) => { + const isLast = idx === visibleSnapshots.length - 1; + this.bodyContainer.addChild(new Text(this.buildBodyLine(snap, isLast), 0, 0)); + }); + } this.lastFlushPhases.clear(); this.entries.forEach((entry, i) => { 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 cb6f95dcdec..a12641a80f4 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -6,6 +6,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; import { PREVIEW_LINES } from './tool-renderers/types'; +import { lastNonEmptyLine, outcomeLine } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { @@ -85,13 +86,22 @@ export const shellExecutionResultRenderer: ResultRenderer = ( _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, -): Component[] => [ +): Component[] => { + // Collapsed: the command's last output line is the card's outcome row + // (most commands conclude on their last line); the rest waits for ctrl+o. + // A failing command keeps its multi-line preview so the error is visible. + if (!ctx.expanded && result.is_error !== true) { + const last = lastNonEmptyLine(result.output); + return last === undefined ? [] : [outcomeLine(last)]; + } // 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({ - result, - expanded: ctx.expanded, - }), -]; + return [ + new ShellExecutionComponent({ + result, + expanded: ctx.expanded, + }), + ]; +}; diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 449b2c9abf7..f7e0f72ad10 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -32,9 +32,11 @@ import { formatTokenCount } from '#/utils/usage/usage-format'; import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; +import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; +import { lastNonEmptyLine, outcomeLine } from './tool-renderers/outcome'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; import { buildWaitForHeader } from './tool-renderers/wait-for'; @@ -49,6 +51,10 @@ const STREAMING_PROGRESS_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; const ABORTED_MARK = '⊘'; const MAX_LIVE_OUTPUT_CHARS = 50_000; +// One shared reference: the header line compares segment styles by identity +// to keep its render cache across rebuilds, and the palette is read at call +// time so theme switches still apply. +const dimHeaderStyle = (text: string): string => currentTheme.dim(text); /** Delay before a long-running foreground Bash/Agent card advertises Ctrl+B. */ const DETACH_HINT_DELAY_MS = 10_000; @@ -400,24 +406,47 @@ function makeWorkspaceRelativePath(filePath: string, workspaceDir: string | unde return relativePath; } -function formatKeyArgument( +function displayKeyArgument( toolName: string, key: string, value: string, workspaceDir: string | undefined, ): string { - const displayValue = - toolName === 'Read' && PATH_KEYS.has(key) - ? makeWorkspaceRelativePath(value, workspaceDir) - : value; - return truncateArgValue(key, displayValue); + return toolName === 'Read' && PATH_KEYS.has(key) + ? makeWorkspaceRelativePath(value, workspaceDir) + : value; } +/** + * The header's key argument, untruncated: the width-aware header line sizes + * it to the terminal. `keep` says which end must survive a cut — paths keep + * their file name, everything else keeps its start. + */ +export interface KeyArgument { + readonly text: string; + readonly keep: 'head' | 'tail'; +} + +/** + * Capped variant for contexts without a width-aware header (subagent + * summaries, the activity viewer): the first {@link MAX_ARG_LENGTH} + * characters, keeping a path's file name. + */ export function extractKeyArgument( toolName: string, args: Record, workspaceDir?: string, ): string | null { + const detail = extractKeyArgumentDetail(toolName, args, workspaceDir); + if (detail === null) return null; + return truncateArgValue(detail.keep === 'tail' ? 'path' : 'value', detail.text); +} + +export function extractKeyArgumentDetail( + toolName: string, + args: Record, + workspaceDir?: string, +): KeyArgument | null { const keyMap: Record = { Bash: ['command'], Read: ['path', 'file_path'], @@ -445,7 +474,7 @@ export function extractKeyArgument( if (args['include_ignored'] === true) { summary += ' · include ignored'; } - return truncateArgValue('pattern', summary); + return { text: summary, keep: 'head' }; } const candidates = keyMap[toolName] ?? Object.keys(args); @@ -455,7 +484,10 @@ export function extractKeyArgument( const firstLine = val.split('\n')[0] ?? val; const displayValue = toolName === 'Bash' && val.includes('\n') ? `${firstLine}…` : firstLine; - return formatKeyArgument(toolName, key, displayValue, workspaceDir); + return { + text: displayKeyArgument(toolName, key, displayValue, workspaceDir), + keep: PATH_KEYS.has(key) ? 'tail' : 'head', + }; } } return null; @@ -551,7 +583,7 @@ export class ToolCallComponent extends Container { * the plan body even without a `## Approved Plan:` marker. */ private currentPlan: string | undefined; - private headerText: Text; + private headerText: TruncatedHeaderLine; private callPreviewEndIndex = 0; // ── Subagent state ─────────────────────────────────────────────── @@ -655,7 +687,7 @@ export class ToolCallComponent extends Container { this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); - this.headerText = new Text(this.buildHeader(), 0, 0); + this.headerText = new TruncatedHeaderLine(this.buildHeader()); this.addChild(this.headerText); this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; @@ -1442,7 +1474,7 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } - private buildHeader(): string { + private buildHeader(): HeaderContent { const { toolCall, result } = this; const isFinished = result !== undefined; const isError = result?.is_error ?? false; @@ -1496,17 +1528,26 @@ export class ToolCallComponent extends Container { } 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. + // The collapsed card is this header plus one outcome row, so the header + // carries the command's first line; the full command and its output only + // render in the body once expanded (ctrl+o). 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 command = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; - return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`; + const head = `${bullet}${currentTheme.boldFg(tone, label)}`; + if (command === null) return `${head}${chipStr}`; + // The command takes whatever width the label and the chip leave over, + // so it fills a wide terminal and the chip survives a narrow one. + return { + head: `${head}${currentTheme.dim(' · $ ')}`, + flex: { text: command.text, style: dimHeaderStyle, keep: 'head' }, + tail: chipStr, + }; } const goalHeader = buildGoalToolHeader({ @@ -1530,7 +1571,7 @@ export class ToolCallComponent extends Container { } const verb = isFinished ? 'Used' : isTruncated ? 'Truncated' : 'Using'; - const keyArg = extractKeyArgument(toolCall.name, toolCall.args, this.workspaceDir); + const keyArg = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); const decoded = decodeMcpToolName(toolCall.name); const verbStyled = isTruncated ? currentTheme.fg('error', verb) @@ -1539,10 +1580,15 @@ export class ToolCallComponent extends Container { decoded !== null ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` : currentTheme.boldFg('primary', toolCall.name); - const argStr = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; let chipStr = ''; if (isFinished && result) chipStr = this.buildHeaderChip(result); - return `${bullet}${verbStyled} ${toolLabel}${argStr}${chipStr}`; + const head = `${bullet}${verbStyled} ${toolLabel}`; + if (keyArg === null) return `${head}${chipStr}`; + return { + head: `${head}${currentTheme.dim(' (')}`, + flex: { text: keyArg.text, style: dimHeaderStyle, keep: keyArg.keep }, + tail: `${currentTheme.dim(')')}${chipStr}`, + }; } private buildHeaderChip(result: ToolResultBlockData): string { @@ -1611,6 +1657,14 @@ export class ToolCallComponent extends Container { private buildLiveOutputBlock(): void { if (this.result !== undefined) return; if (this.liveOutput.length === 0) return; + // Collapsed: the newest output line is the card's outcome row while the + // command runs, so progress stays visible; the result's last line takes + // the same row once it lands. ctrl+o shows the whole live tail. + if (!this.expanded) { + const latest = lastNonEmptyLine(this.liveOutput); + if (latest !== undefined) this.addChild(outcomeLine(latest)); + return; + } this.addChild( new ShellExecutionComponent({ result: { @@ -2044,21 +2098,19 @@ export class ToolCallComponent extends Container { this.addChild(new Text(line, 2, 0)); } } 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. + // Collapsed: the header already carries the command's first line, so no + // command body is added; the outcome row comes from the live tail or the + // result renderer. Expanded: the full command, across the whole lifecycle. + // Owned solely by buildCallPreview so the command never renders twice; + // shellExecutionResultRenderer renders the result only. + if (!this.expanded) return; const command = str(this.toolCall.args['command']); if (command.length === 0) return; this.addChild( new ShellExecutionComponent({ command, showCommand: true, - commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, + commandPreviewLines: undefined, }), ); } @@ -2117,14 +2169,14 @@ export class ToolCallComponent extends Container { this.addChild(new Text(currentTheme.dim(progress), 2, 0)); return; } - if (name === 'Bash') { + if (name === 'Bash' && this.expanded) { const cmd = extractPartialStringField(previewText, 'command'); if (cmd === undefined || cmd.length === 0) return; this.addChild( new ShellExecutionComponent({ command: cmd, showCommand: true, - commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, + commandPreviewLines: undefined, }), ); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index 37536c14005..71789d02118 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -87,6 +87,13 @@ const writeChip: ChipProvider = (toolCall) => formatWriteChip(computeWriteStats( const readChip: ChipProvider = (_toolCall, result) => pluralize(countNonEmptyLines(result.output), 'line'); +// The collapsed Bash card shows only the command and the last output line, +// so the line count is what tells the user there is more behind ctrl+o. +const bashChip: ChipProvider = (_toolCall, result) => { + const lines = countNonEmptyLines(result.output); + return lines === 0 ? '' : pluralize(lines, 'line'); +}; + const grepChip: ChipProvider = (_toolCall, result) => { const matches = countNonEmptyLines(result.output); if (matches === 0) return 'no matches'; @@ -116,6 +123,7 @@ const goalStatusOutputChip: ChipProvider = (_toolCall, result) => result.is_error ? '' : goalStatusChip(result.output); const REGISTRY: Record = { + Bash: bashChip, Edit: editChip, Write: writeChip, Read: readChip, diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts new file mode 100644 index 00000000000..9741a856f15 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -0,0 +1,36 @@ +/** + * The collapsed card's second row: one dim, width-truncated line that states + * the outcome of the call — a command's last output line, a Grep glance, an + * MCP tool's first line. Cards without a telling line stay single-row. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +import { TruncatedHeaderLine } from '../truncated-header-line'; + +const OUTCOME_INDENT = ' '; + +// One shared reference so the line's render cache survives rebuilds (segment +// styles are compared by identity); the palette is read at call time. +const dimOutcomeStyle = (text: string): string => currentTheme.dim(text); + +export function firstNonEmptyLine(text: string): string | undefined { + return text.split('\n').find((line) => line.trim().length > 0)?.trimEnd(); +} + +export function lastNonEmptyLine(text: string): string | undefined { + return text + .split('\n') + .findLast((line) => line.trim().length > 0) + ?.trimEnd(); +} + +export function outcomeLine(text: string): Component { + return new TruncatedHeaderLine({ + head: OUTCOME_INDENT, + flex: { text, style: dimOutcomeStyle, keep: 'head' }, + tail: '', + }); +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts index eedc4316a38..4cc32afede7 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -3,8 +3,8 @@ * * Each tool name maps to a `ResultRenderer` that turns the tool's * `ToolResultBlockData` into renderable Components. Tools without an - * explicit entry fall through to `renderTruncated` (the original - * 3-line + ctrl+o behavior). + * explicit entry fall through to `renderTruncated` (header plus the first + * output line when collapsed, full output on ctrl+o, errors always previewed). * * Keep this dispatch flat — tool names live next to the renderer they * choose, so adding a new tool means appending one case. diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index ac31cec8e7b..1b0aa5144e4 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -2,9 +2,9 @@ * Summary-style renderers — produce optional inline-glance content for * tools whose raw output is high-volume but low-information (Grep, * Glob). The numeric summary (line counts, exit codes, sizes) lives in - * the header chip (see chip.ts), so most tools intentionally render an - * empty body and only expose details when the global expand toggle is - * on. + * the header chip (see chip.ts), so every tool here renders the glance + * line as the collapsed card's outcome row; the raw output only appears + * when the global expand toggle is on. * * Errors always fall through to the truncated renderer so the user * sees the actual error message, not a synthetic summary. @@ -14,6 +14,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { outcomeLine } from './outcome'; import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; @@ -29,10 +30,12 @@ function withGlance(glance: GlanceFn | null): ResultRenderer { if (result.is_error) return renderTruncated(toolCall, result, ctx); const out: Component[] = []; + // The glance is the collapsed card's outcome row (one width-truncated + // line); the raw output only follows once expanded. if (glance !== null) { const line = glance(toolCall, result); if (line.length > 0) { - out.push(new Text(` ${chalk.dim(line)}`, 0, 0)); + out.push(ctx.expanded ? new Text(` ${chalk.dim(line)}`, 0, 0) : outcomeLine(line)); } } if (ctx.expanded && result.output.length > 0) { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index f9b4c7183db..ebbaa20a411 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -3,6 +3,7 @@ import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; +import { firstNonEmptyLine, outcomeLine } from './outcome'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -100,8 +101,16 @@ export class TruncatedOutputComponent implements Component { } } +// Collapsed cards show the header plus one outcome row: a successful result +// contributes its first non-empty line, the rest waits for the global ctrl+o +// expand; errors always keep their multi-line preview so a failure is never +// reduced to a single line. export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { if (!result.output) return []; + if (!ctx.expanded && result.is_error !== true) { + const first = firstNonEmptyLine(result.output); + return first === undefined ? [] : [outcomeLine(first)]; + } return [ new TruncatedOutputComponent(result.output, { expanded: ctx.expanded, diff --git a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts new file mode 100644 index 00000000000..a00124b9490 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts @@ -0,0 +1,128 @@ +/** + * Single-row line shared by the tool card header, the Read group header and + * the collapsed card's outcome row. + * + * A header is either a plain string, truncated at the render width, or three + * segments: a fixed head (bullet + label), a flexible middle (command, update + * preview, key argument) and a fixed tail (the result chip). The middle gets + * whatever width is left after the head and the tail, so on a wide terminal + * it fills the row and on a narrow one the chip still survives. `keep` + * decides which end of the middle survives a cut: commands keep their start, + * paths keep their file name. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; + +const ELLIPSIS = '…'; + +export interface HeaderFlex { + /** Plain text; `style` is applied after the cut so the ellipsis is styled too. */ + readonly text: string; + readonly style?: (text: string) => string; + readonly keep: 'head' | 'tail'; +} + +export interface HeaderSegments { + readonly head: string; + readonly flex: HeaderFlex; + readonly tail: string; +} + +export type HeaderContent = string | HeaderSegments; + +// The middle is plain text and gets styled after the cut, so it is cut by +// hand here: pi-tui's truncateToWidth wraps its ellipsis in a reset sequence, +// which would break the caller's styling around it. + +/** Grapheme clusters, so an emoji or a combining sequence is never split by a cut. */ +function graphemes(text: string): string[] { + return Array.from(new Intl.Segmenter().segment(text), (segment) => segment.segment); +} + +/** Keep the start of `text` up to a trailing ellipsis, within `width` cells. */ +function keepHead(text: string, width: number): string { + const budget = width - visibleWidth(ELLIPSIS); + let out = ''; + let used = 0; + for (const cluster of graphemes(text)) { + const clusterWidth = visibleWidth(cluster); + if (used + clusterWidth > budget) break; + out += cluster; + used += clusterWidth; + } + return `${out}${ELLIPSIS}`; +} + +/** Keep the end of `text` behind a leading ellipsis, within `width` cells. */ +function keepTail(text: string, width: number): string { + const budget = width - visibleWidth(ELLIPSIS); + let out = ''; + let used = 0; + for (const cluster of graphemes(text).toReversed()) { + const clusterWidth = visibleWidth(cluster); + if (used + clusterWidth > budget) break; + out = cluster + out; + used += clusterWidth; + } + return `${ELLIPSIS}${out}`; +} + +function fitFlex(flex: HeaderFlex, width: number): string { + if (visibleWidth(flex.text) <= width) return flex.text; + return flex.keep === 'tail' ? keepTail(flex.text, width) : keepHead(flex.text, width); +} + +export function renderHeaderContent(content: HeaderContent, width: number): string { + const safeWidth = Math.max(1, width); + if (typeof content === 'string') return truncateToWidth(content, safeWidth, ELLIPSIS); + const { head, flex, tail } = content; + const style = flex.style ?? ((text: string) => text); + const available = safeWidth - visibleWidth(head) - visibleWidth(tail); + // Below two cells there is no room for even an ellipsis plus one character + // of the middle: give up on the layout and cut the whole row from the end. + if (available < 2) { + return truncateToWidth(`${head}${style(flex.text)}${tail}`, safeWidth, ELLIPSIS); + } + return `${head}${style(fitFlex(flex, available))}${tail}`; +} + +function sameContent(a: HeaderContent, b: HeaderContent): boolean { + if (typeof a === 'string' || typeof b === 'string') return a === b; + return ( + a.head === b.head && + a.tail === b.tail && + a.flex.text === b.flex.text && + a.flex.keep === b.flex.keep && + a.flex.style === b.flex.style + ); +} + +export class TruncatedHeaderLine implements Component { + // The card and the gutter container reuse a child's output by array + // identity, so an unchanged header must hand back the same array — a fresh + // one per frame would defeat both caches on every paint. + private cache: { content: HeaderContent; width: number; lines: string[] } | undefined; + + constructor(private content: HeaderContent) {} + + setText(content: HeaderContent): void { + if (sameContent(this.content, content)) return; + this.content = content; + this.cache = undefined; + } + + invalidate(): void { + this.cache = undefined; + } + + render(width: number): string[] { + const cache = this.cache; + if (cache !== undefined && cache.content === this.content && cache.width === width) { + return cache.lines; + } + const lines = [renderHeaderContent(this.content, width)]; + this.cache = { content: this.content, width, lines }; + return lines; + } +} diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 5b6a35d7f54..96c19c6a39f 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -894,6 +894,7 @@ export class StreamingUIController { private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { const { state } = this.host; const group = new ReadGroupComponent(state.ui); + if (state.toolOutputExpanded) group.setExpanded(true); const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { diff --git a/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts b/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts index a6908b919f5..dfef86d7fd4 100644 --- a/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts @@ -144,11 +144,14 @@ describe('AgentActivityViewer', () => { expect(text).toContain('── step 0 ──'); expect(text).toContain('Looking for the event bus definition.'); expect(text).toContain('Used Grep (IEventBus) · 2 matches'); - // grep glance renderer: path samples below the header (`path:line` form) + // The grep glance (path samples in `path:line` form) is the collapsed + // card's outcome row. expect(text).toContain('src/a.ts:1, src/b.ts:2'); + viewer.handleInput(CTRL_O); + expect(renderPlain(viewer)).toContain('src/a.ts:1, src/b.ts:2'); }); - it('collapses long output by default and expands it with ctrl+o', () => { + it('hides successful output by default and reveals it with ctrl+o', () => { const longOutput = Array.from({ length: 10 }, (_, i) => `line ${String(i + 1)}`).join('\n'); const makeRecord = (): SubagentActivityRecord => record({ @@ -173,8 +176,10 @@ describe('AgentActivityViewer', () => { const collapsed = makeViewer({ record: makeRecord() }); const collapsedText = renderPlain(collapsed); - expect(collapsedText).toContain('ctrl+o to expand'); - expect(collapsedText).not.toContain('line 10'); + // Collapsed: the last output line is the outcome row, nothing else. + expect(collapsedText).toContain('Bash'); + expect(collapsedText).toContain('line 10'); + expect(collapsedText).not.toContain('line 9'); collapsed.handleInput(CTRL_O); const expandedText = renderPlain(collapsed); diff --git a/apps/kimi-code/test/tui/components/messages/read-group.test.ts b/apps/kimi-code/test/tui/components/messages/read-group.test.ts new file mode 100644 index 00000000000..b35d7065076 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/read-group.test.ts @@ -0,0 +1,61 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { ReadGroupComponent } from '#/tui/components/messages/read-group'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function readCall(id: string, path: string, lines: number): ToolCallComponent { + return new ToolCallComponent( + { id, name: 'Read', args: { path } }, + { + tool_call_id: id, + output: Array.from({ length: lines }, (_, i) => `${String(i + 1)}\tline`).join('\n'), + is_error: false, + }, + ); +} + +function makeGroup(): ReadGroupComponent { + const group = new ReadGroupComponent(undefined); + group.attach('r1', readCall('r1', 'src/very/deeply/nested/directory/alpha-component.ts', 120)); + group.attach('r2', readCall('r2', 'src/very/deeply/nested/directory/beta-component.ts', 80)); + return group; +} + +function rows(group: ReadGroupComponent, width: number): string[] { + return group.render(width).map(strip).filter((line) => line.trim().length > 0); +} + +describe('ReadGroupComponent', () => { + it('collapses to a single header row and hides the per-file body until expanded', () => { + const group = makeGroup(); + + const collapsed = rows(group, 100); + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toContain('Read 2 files · 200 lines'); + expect(collapsed[0]).not.toContain('alpha-component.ts'); + + group.setExpanded(true); + const expanded = rows(group, 100); + expect(expanded[0]).toContain('Read 2 files · 200 lines'); + expect(expanded.join('\n')).toContain('alpha-component.ts · 120 lines'); + expect(expanded.join('\n')).toContain('beta-component.ts · 80 lines'); + + group.setExpanded(false); + expect(rows(group, 100)).toHaveLength(1); + }); + + it('truncates the header to the terminal width instead of wrapping', () => { + const group = makeGroup(); + for (const width of [16, 24]) { + const collapsed = rows(group, width); + expect(collapsed).toHaveLength(1); + expect(visibleWidth(collapsed[0]!)).toBeLessThanOrEqual(width); + expect(collapsed[0]).toContain('…'); + } + }); +}); 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 132737c8655..7f063e08f29 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 @@ -112,7 +112,7 @@ describe('ShellExecutionComponent', () => { describe('shellExecutionResultRenderer', () => { const longCmd = `echo ${'a'.repeat(200)}\necho done`; - it('renders only the result and leaves the command to the call preview', () => { + it('renders only the last output line as the outcome row while collapsed', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -121,12 +121,40 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: 'first\nsecond\n\nTests 12 passed\n\n', is_error: false, }, { expanded: false }, ); + const rendered = components.flatMap((c) => c.render(100)).map(strip); + expect(rendered).toEqual([' Tests 12 passed']); + }); + + it('renders no outcome row for a successful result without output', () => { + const components = shellExecutionResultRenderer( + { id: 'call_1', name: 'Bash', args: { command: 'true' } }, + { tool_call_id: 'call_1', output: '\n \n', is_error: false }, + { expanded: false }, + ); + expect(components).toEqual([]); + }); + + it('keeps a failing result previewed while collapsed and leaves the command to the call preview', () => { + const components = shellExecutionResultRenderer( + { + id: 'call_1', + name: 'Bash', + args: { command: longCmd }, + }, + { + tool_call_id: 'call_1', + output: 'boom', + is_error: true, + }, + { expanded: false }, + ); + const rendered = components .flatMap((c) => c.render(100)) .map(strip) @@ -135,7 +163,7 @@ describe('ShellExecutionComponent', () => { // renderer — rendering it here too would duplicate it once the result // lands. expect(rendered).not.toContain('$ echo'); - expect(rendered).toContain('ok'); + expect(rendered).toContain('boom'); }); it('still renders only the result when expanded', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index b86141e496f..3691fed23b5 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -157,21 +157,45 @@ describe('ToolCallComponent', () => { }, ); - const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain('line1'); - expect(collapsed).toContain('line2'); - expect(collapsed).toContain('line3'); - expect(collapsed).not.toContain('line4'); - expect(collapsed).toContain('… (2 more lines, ctrl+o to expand)'); + // Collapsed: the header (command + line-count chip) plus one outcome row + // holding the last output line; the rest waits for ctrl+o. + const collapsedLines = component.render(100).map(strip).filter((line) => line.trim().length > 0); + expect(collapsedLines).toHaveLength(2); + expect(collapsedLines[0]).toContain('Ran a command'); + expect(collapsedLines[0]).toContain('$ printf output'); + expect(collapsedLines[0]).toContain('· 5 lines'); + expect(collapsedLines[1]).toBe(' line5'); component.setExpanded(true); const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('line1'); expect(expanded).toContain('line4'); expect(expanded).toContain('line5'); expect(expanded).not.toContain('ctrl+o to expand'); }); + it('keeps a failing command\'s output visible while collapsed', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_err', + name: 'Bash', + args: { command: 'false' }, + }, + { + tool_call_id: 'call_shell_err', + output: ['err1', 'err2', 'err3', 'err4', 'err5'].join('\n'), + is_error: true, + }, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('err1'); + expect(collapsed).toContain('err3'); + expect(collapsed).not.toContain('err4'); + expect(collapsed).toContain('… (2 more lines, ctrl+o to expand)'); + }); + it('renders live Bash output while the command is running', () => { const component = new ToolCallComponent( { @@ -185,10 +209,18 @@ describe('ToolCallComponent', () => { component.appendLiveOutput('line1\n'); component.appendLiveOutput('line2\n'); - const out = strip(component.render(100).join('\n')); - expect(out).toContain('Running a command'); - expect(out).toContain('line1'); - expect(out).toContain('line2'); + // Collapsed: the header plus the newest live line as the outcome row, so + // progress stays visible; the whole live tail waits for ctrl+o. + const rows = component.render(100).map(strip).filter((line) => line.trim().length > 0); + expect(rows).toHaveLength(2); + expect(rows[0]).toContain('Running a command'); + expect(rows[0]).toContain('$ printf output'); + expect(rows[1]).toBe(' line2'); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('line1'); + expect(expanded).toContain('line2'); }); it('clears live Bash output when the final result arrives', () => { @@ -207,6 +239,7 @@ describe('ToolCallComponent', () => { output: 'final-only\n', is_error: false, }); + component.setExpanded(true); const out = strip(component.render(100).join('\n')); expect(out).toContain('Ran a command'); @@ -219,17 +252,17 @@ describe('ToolCallComponent', () => { '\n', ); - it('shows the truncated command while running and reveals the rest when expanded', () => { + it('keeps a running multi-line command to its first line until expanded', () => { const component = new ToolCallComponent( { id: 'call_bash_running', name: 'Bash', args: { command: longCommand } }, undefined, ); - const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain('Running a command'); - expect(collapsed).toContain('echo step1'); - expect(collapsed).toContain('echo step10'); - expect(collapsed).not.toContain('echo step11'); + const collapsed = component.render(100).map(strip).filter((line) => line.trim().length > 0); + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toContain('Running a command'); + expect(collapsed[0]).toContain('$ echo step1…'); + expect(collapsed[0]).not.toContain('echo step2'); component.setExpanded(true); @@ -238,48 +271,121 @@ describe('ToolCallComponent', () => { expect(expanded).toContain('echo step15'); }); - it('keeps the command preview after the result lands to avoid a height collapse', () => { + it('settles on header plus outcome row after the result lands and shows everything once expanded', () => { const component = new ToolCallComponent( { id: 'call_bash_done', name: 'Bash', args: { command: longCommand } }, undefined, ); - // Sanity: while running, the in-flight preview shows the command. - expect(strip(component.render(100).join('\n'))).toContain('$ echo step1'); - component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); - // 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('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); + const collapsed = component.render(100).map(strip).filter((line) => line.trim().length > 0); + expect(collapsed).toHaveLength(2); + expect(collapsed[0]).toContain('Ran a command'); + expect(collapsed[0]).toContain('$ echo step1…'); + expect(collapsed[0]).toContain('· 1 line'); + expect(collapsed[1]).toBe(' done'); component.setExpanded(true); + // The command is owned by buildCallPreview, so it appears exactly once — + // the result renderer renders the output only. const expanded = strip(component.render(100).join('\n')); expect(expanded).toContain('echo step11'); expect(expanded).toContain('echo step15'); + expect(expanded).toContain('done'); + // Header keeps the truncated first line (`$ echo step1…`); the full + // command body must appear exactly once below it. + expect(expanded.match(/\$ echo step1(?!…)/g)).toHaveLength(1); }); - it('keeps the command preview when the command produces no output', () => { + it('carries the command in the header 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'); + const collapsed = component.render(100).map(strip).filter((line) => line.trim().length > 0); + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toContain('Ran a command'); + expect(collapsed[0]).toContain('$ mkdir -p a/b/c…'); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo done'); + }); + }); + + describe('collapsed header width', () => { + it('truncates a long Bash header to the terminal width instead of wrapping', () => { + const command = `pnpm exec vitest run ${'test/very/long/path/'.repeat(6)}spec.test.ts --reporter=verbose`; + const component = new ToolCallComponent( + { id: 'call_bash_narrow', name: 'Bash', args: { command } }, + { tool_call_id: 'call_bash_narrow', output: 'ok', is_error: false }, + ); + for (const width of [40, 60, 80]) { + const rows = component.render(width).map(strip).filter((line) => line.trim().length > 0); + // Header plus the outcome row holding the command's output ("ok"). + expect(rows).toHaveLength(2); + expect(visibleWidth(rows[0]!)).toBeLessThanOrEqual(width); + expect(rows[0]).toContain('Ran a command'); + expect(rows[0]).toContain('…'); + } + }); + + it('hands back the same header array while the header is unchanged', () => { + const component = new ToolCallComponent( + { id: 'call_bash_cached', name: 'Bash', args: { command: 'ls' } }, + undefined, + ); + // children[0] is the leading spacer; the header line follows it. The + // card and the gutter reuse a child's output by array identity, so an + // unchanged header must return the very same array across frames. + const header = component.children[1]!; + const first = header.render(100); + expect(header.render(100)).toBe(first); + expect(header.render(80)).not.toBe(first); + + component.setResult({ tool_call_id: 'call_bash_cached', output: 'ok', is_error: false }); + const finished = header.render(100); + expect(finished).not.toBe(first); + expect(strip(finished[0]!)).toContain('Ran a command'); + expect(header.render(100)).toBe(finished); + }); + + it('lets a long Bash command fill a wide terminal and keeps the chip', () => { + const command = + 'git log --oneline -5 origin/main -- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts'; + const component = new ToolCallComponent( + { id: 'call_bash_wide', name: 'Bash', args: { command } }, + { tool_call_id: 'call_bash_wide', output: 'ok', is_error: false }, + ); + // The command is the flexible middle segment: on a wide terminal it is + // shown in full, on a narrow one it is cut with an ellipsis before the chip. + const wide = component.render(160).map(strip).filter((line) => line.trim().length > 0); + expect(wide).toHaveLength(2); + expect(wide[0]).toContain(`$ ${command}`); + expect(wide[0]).not.toContain('…'); + + const narrow = component.render(70).map(strip).filter((line) => line.trim().length > 0); + expect(narrow).toHaveLength(2); + expect(visibleWidth(narrow[0]!)).toBeLessThanOrEqual(70); + // The command is cut to the remaining width; the line-count chip survives. + expect(narrow[0]).toMatch(/\$ git log .*… · 1 line$/); + }); + + it('keeps the file name of a long Read path on a narrow terminal', () => { + const path = + '/Users/someone/.kimi-code/sessions/session_5b2c/agents/main/tasks/bash-4g77gs5f/output.log'; + const component = new ToolCallComponent( + { id: 'call_read_narrow', name: 'Read', args: { path } }, + { tool_call_id: 'call_read_narrow', output: '1\ta\n2\tb', is_error: false }, + ); + const rows = component.render(60).map(strip).filter((line) => line.trim().length > 0); + expect(rows).toHaveLength(1); + expect(visibleWidth(rows[0]!)).toBeLessThanOrEqual(60); + expect(rows[0]).toContain('(…'); + expect(rows[0]).toContain('/output.log)'); + expect(rows[0]).toContain('2 lines'); }); }); @@ -419,6 +525,9 @@ describe('ToolCallComponent', () => { }, ); + // Successful output only renders once expanded; the point here is that a + // reminder tag mid-body must not suppress the whole output. + component.setExpanded(true); const out = strip(component.render(100).join('\n')); expect(out).toContain('first line'); }); @@ -730,10 +839,16 @@ describe('ToolCallComponent', () => { }, ); - const out = strip(component.render(100).join('\n')); - expect(out).toContain('Started background question'); - expect(out).toContain('question-aaaaaaaa'); - expect(out).not.toContain('Collected your answers'); + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('Started background question'); + // The outcome row carries the result's first line: the task id. + expect(collapsed).toContain('task_id: question-aaaaaaaa'); + expect(collapsed).not.toContain('description: Which database?'); + expect(collapsed).not.toContain('Collected your answers'); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('question-aaaaaaaa'); }); it('renders GetGoal as a goal check without raw JSON', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 7942cdae8e2..e7ccf699a4e 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -26,7 +26,7 @@ function chipFor(name: string, args: Record, out: ToolResultBlo describe('chip registry', () => { it('Bash has no chip (exit code is not surfaced)', () => { - expect(pickChip('Bash')).toBeUndefined(); + expect(pickChip('AskUserQuestion')).toBeUndefined(); }); it('Edit chip shows +N -M from args diff', () => { @@ -142,3 +142,13 @@ describe('computeEditStats', () => { expect(stats.removed).toBe(0); }); }); + +describe('Bash chip', () => { + it('counts the non-empty output lines and stays silent for no output', () => { + const chip = pickChip('Bash')!; + const call = { id: 'tc', name: 'Bash', args: { command: 'ls' } }; + expect(chip(call, { tool_call_id: 'tc', output: 'a\n\nb\nc\n', is_error: false })).toBe('3 lines'); + expect(chip(call, { tool_call_id: 'tc', output: 'only', is_error: false })).toBe('1 line'); + expect(chip(call, { tool_call_id: 'tc', output: '', is_error: false })).toBe(''); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts index 691f1d88a13..cb3ec3ac647 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -138,11 +138,21 @@ describe('readMediaSummary renderer', () => { }); it('falls back to truncated renderer when the output is not the media envelope', () => { - const out = strip( + // Collapsed: the fallback renderer's outcome row is the first output line. + const collapsed = strip( joinRender( readMediaSummary(call('ReadMediaFile'), result('"some plain string output"'), ctx), ), ); + expect(collapsed).toBe(' "some plain string output"'); + const out = strip( + joinRender( + readMediaSummary(call('ReadMediaFile'), result('"some plain string output"'), { + ...ctx, + expanded: true, + }), + ), + ); expect(out).toContain('some plain string output'); }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 1c4d3b27329..969f82beace 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -58,21 +58,44 @@ function goalOutput(overrides: Record = {}): string { } describe('tool-result registry', () => { - it('falls back to truncated renderer for unknown tools', () => { + it('falls back to truncated renderer for unknown tools: header-only collapsed, full when expanded', () => { const renderer = pickResultRenderer('SomethingUnknown'); - const out = strip(joinRender(renderer(call('SomethingUnknown'), result('a\nb\nc\nd\ne'), ctx))); + const collapsed = strip( + joinRender(renderer(call('SomethingUnknown'), result('\na\nb\nc\nd\ne'), ctx)), + ); + expect(collapsed).toBe(' a'); + + const expanded = strip( + joinRender(renderer(call('SomethingUnknown'), result('a\nb\nc\nd\ne'), expandedCtx)), + ); + expect(expanded).toContain('a'); + expect(expanded).toContain('e'); + expect(expanded).not.toContain('ctrl+o to expand'); + }); + + it('keeps a failing unknown tool\'s output previewed while collapsed', () => { + const renderer = pickResultRenderer('SomethingUnknown'); + const out = strip( + joinRender( + renderer(call('SomethingUnknown'), result('a\nb\nc\nd\ne', true), ctx), + ), + ); expect(out).toContain('a'); - expect(out).toContain('b'); expect(out).toContain('c'); expect(out).not.toContain('\nd'); expect(out).toContain('… (2 more lines, ctrl+o to expand)'); }); - it('uses truncated renderer for Bash to preserve raw output UX', () => { + it('uses the shell renderer for Bash: last line collapsed, raw output expanded', () => { const renderer = pickResultRenderer('Bash'); - const out = strip(joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), ctx))); + expect(strip(joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), ctx)))).toBe( + ' four', + ); + const out = strip( + joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), expandedCtx)), + ); expect(out).toContain('one'); - expect(out).toContain('… (1 more lines, ctrl+o to expand)'); + expect(out).toContain('four'); }); it('Read renders no body when collapsed (header chip carries the count)', () => { @@ -92,7 +115,7 @@ describe('tool-result registry', () => { expect(out).toContain('bar'); }); - it('Grep glance lists path samples below the chip', () => { + it('Grep renders its glance as the outcome row when collapsed', () => { const renderer = pickResultRenderer('Grep'); const out = strip( joinRender( @@ -103,11 +126,22 @@ describe('tool-result registry', () => { ), ), ); - expect(out).toContain('src/a.ts'); - expect(out).toContain('src/b.ts'); - expect(out).toContain('src/c.ts'); - expect(out).toContain('+2 more'); - expect(out).not.toContain('src/d.ts'); + expect(out).toBe(' src/a.ts, src/b.ts, src/c.ts, +2 more'); + }); + + it('Grep glance lists path samples above the raw output when expanded', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo' }), + result('src/a.ts\nsrc/b.ts\nsrc/c.ts\nsrc/d.ts\nsrc/e.ts'), + expandedCtx, + ), + ), + ); + expect(out).toContain('src/a.ts, src/b.ts, src/c.ts, +2 more'); + expect(out).toContain('src/d.ts'); }); it('Grep glance strips trailing :line:text in content mode', () => { @@ -117,12 +151,11 @@ describe('tool-result registry', () => { renderer( call('Grep', { pattern: 'foo' }), result('src/a.ts:42: foo()\nsrc/b.ts:7:foo'), - ctx, + expandedCtx, ), ), ); - expect(out).toContain('src/a.ts:42'); - expect(out).not.toContain('foo()'); + expect(out).toContain('src/a.ts:42, src/b.ts:7'); }); it('Grep with empty result renders nothing in collapsed state', () => { @@ -131,11 +164,22 @@ describe('tool-result registry', () => { expect(out.trim()).toBe(''); }); - it('Glob glance lists path samples', () => { + it('Glob glance lists path samples when expanded', () => { const renderer = pickResultRenderer('Glob'); + expect( + strip( + joinRender( + renderer(call('Glob', { pattern: '**/*.ts' }), result('a.ts\nb.ts\nc.ts\nd.ts'), ctx), + ), + ), + ).toBe(' a.ts, b.ts, c.ts, +1 more'); const out = strip( joinRender( - renderer(call('Glob', { pattern: '**/*.ts' }), result('a.ts\nb.ts\nc.ts\nd.ts'), ctx), + renderer( + call('Glob', { pattern: '**/*.ts' }), + result('a.ts\nb.ts\nc.ts\nd.ts'), + expandedCtx, + ), ), ); expect(out).toContain('a.ts'); @@ -242,10 +286,12 @@ describe('tool-result registry', () => { expect(isGenericToolResult('Edit')).toBe(false); }); - it('truncates unknown tool output by wrapped visual lines, not raw newlines', () => { + it('truncates a failing unknown tool\'s output by wrapped visual lines, not raw newlines', () => { const renderer = pickResultRenderer('SomethingUnknown'); const longLine = 'x'.repeat(500); - const out = strip(joinRender(renderer(call('SomethingUnknown'), result(longLine), ctx), 20)); + const out = strip( + joinRender(renderer(call('SomethingUnknown'), result(longLine, true), ctx), 20), + ); expect(out).toContain('x'); expect(out).not.toContain(longLine); expect(out).toContain('… ('); diff --git a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts new file mode 100644 index 00000000000..02754bf4f94 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts @@ -0,0 +1,88 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { + renderHeaderContent, + TruncatedHeaderLine, + type HeaderSegments, +} from '#/tui/components/messages/truncated-header-line'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +const upper = (text: string): string => text.toUpperCase(); + +function segments(text: string, keep: 'head' | 'tail', tail = ' · 3 lines'): HeaderSegments { + return { head: '● Ran a command · $ ', flex: { text, keep }, tail }; +} + +describe('renderHeaderContent', () => { + it('truncates a plain string at the width', () => { + expect(strip(renderHeaderContent('short', 40))).toBe('short'); + const cut = strip(renderHeaderContent('x'.repeat(50), 20)); + expect(visibleWidth(cut)).toBeLessThanOrEqual(20); + expect(cut.endsWith('…')).toBe(true); + }); + + it('lets the middle fill the row and keeps the tail when it fits', () => { + const line = strip(renderHeaderContent(segments('git status --short', 'head'), 80)); + expect(line).toBe('● Ran a command · $ git status --short · 3 lines'); + }); + + it('cuts the middle from its end and still shows the tail on a narrow row', () => { + const command = + 'git log --oneline -5 origin/main -- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts'; + const line = strip(renderHeaderContent(segments(command, 'head'), 60)); + expect(visibleWidth(line)).toBeLessThanOrEqual(60); + expect(line.startsWith('● Ran a command · $ git log')).toBe(true); + expect(line.endsWith('… · 3 lines')).toBe(true); + }); + + it('keeps the end of a path-like middle behind a leading ellipsis', () => { + const path = + '/Users/someone/.kimi-code/sessions/session_5b2c/agents/main/tasks/bash-4g77gs5f/output.log'; + const line = strip( + renderHeaderContent( + { head: '● Used Read (', flex: { text: path, keep: 'tail' }, tail: ') · 8 lines' }, + 60, + ), + ); + expect(visibleWidth(line)).toBeLessThanOrEqual(60); + expect(line).toContain('(…'); + expect(line.endsWith('/output.log) · 8 lines')).toBe(true); + }); + + it('measures wide characters by cells, not by code units', () => { + const line = strip( + renderHeaderContent(segments('运行全部测试并生成覆盖率报告然后上传', 'head', ''), 30), + ); + expect(visibleWidth(line)).toBeLessThanOrEqual(30); + expect(line.endsWith('…')).toBe(true); + }); + + it('styles the middle after the cut so the ellipsis is styled too', () => { + const line = renderHeaderContent( + { head: 'H ', flex: { text: 'abcdefghij', keep: 'head', style: upper }, tail: ' T' }, + 10, + ); + expect(line).toBe('H ABCDE… T'); + }); + + it('falls back to cutting the whole row when even the fixed parts overflow', () => { + const line = strip(renderHeaderContent(segments('ls', 'head'), 12)); + expect(visibleWidth(line)).toBeLessThanOrEqual(12); + expect(line.endsWith('…')).toBe(true); + }); +}); + +describe('TruncatedHeaderLine', () => { + it('reuses its rendered array across structurally equal headers', () => { + const line = new TruncatedHeaderLine(segments('ls', 'head')); + const first = line.render(80); + line.setText(segments('ls', 'head')); + expect(line.render(80)).toBe(first); + line.setText(segments('ls -la', 'head')); + expect(line.render(80)).not.toBe(first); + }); +}); From b090435cd58a70c100d28914d6a4525367b6d50b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 17:17:45 +0800 Subject: [PATCH 02/17] feat(kimi-code): show short tool output whole and point at hidden output Collapsed cards now show up to three output lines before falling back to a single outcome row, the Grep chip counts files or matches according to output_mode, and the tools' pagination and empty-result notices no longer count as results. While the recent turns hold tool output that ctrl+o would reveal or hide, the footer shows ctrl+o expand or ctrl+o collapse. --- .changeset/compact-tool-cards.md | 2 +- .../src/tui/components/chrome/footer.ts | 58 +++++++--- .../src/tui/components/messages/read-group.ts | 5 + .../components/messages/shell-execution.ts | 29 ++--- .../src/tui/components/messages/tool-call.ts | 60 +++++++++-- .../messages/tool-renderers/chip.ts | 24 +++-- .../messages/tool-renderers/grep-output.ts | 101 ++++++++++++++++++ .../messages/tool-renderers/media.ts | 5 +- .../messages/tool-renderers/outcome.ts | 34 ++++-- .../messages/tool-renderers/registry.ts | 5 +- .../messages/tool-renderers/summary.ts | 60 ++++------- .../messages/tool-renderers/truncated.ts | 15 ++- apps/kimi-code/src/tui/kimi-tui.ts | 47 +++++--- .../src/tui/utils/component-capabilities.ts | 18 ++++ .../src/tui/utils/transcript-window.ts | 15 +++ .../test/tui/components/chrome/footer.test.ts | 43 ++++++++ .../dialogs/agent-activity-viewer.test.ts | 8 +- .../components/messages/read-group.test.ts | 7 ++ .../messages/shell-execution.test.ts | 12 ++- .../tui/components/messages/tool-call.test.ts | 60 +++++++++-- .../messages/tool-renderers/chip.test.ts | 51 +++++++-- .../messages/tool-renderers/registry.test.ts | 23 +++- .../test/tui/kimi-tui-message-flow.test.ts | 54 ++++++++++ apps/kimi-code/test/tui/tasks-browser.test.ts | 4 +- .../test/tui/utils/transcript-window.test.ts | 17 ++- 25 files changed, 610 insertions(+), 147 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts diff --git a/.changeset/compact-tool-cards.md b/.changeset/compact-tool-cards.md index 6a890341aba..64044c373fc 100644 --- a/.changeset/compact-tool-cards.md +++ b/.changeset/compact-tool-cards.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Collapse finished tool calls in the transcript to two lines: the header names the call (a Bash card carries the command and its line count) and one outcome row shows the command's last output line, a Grep/Glob path sample, or a tool's first output line; the full output and a Read group's file list appear after `Ctrl+O`. Failed calls keep their output preview, and Edit/Write previews are unchanged. +Collapse finished tool calls in the transcript to two lines: the header names the call (a Bash card carries the command and its line count) and the outcome rows show the output whole when it is three lines or fewer, otherwise the command's last output line, a Grep/Glob path sample, or a tool's first output line; the full output and a Read group's file list appear after `Ctrl+O`. Failed calls keep their output preview, and Edit/Write previews are unchanged. A Grep card's chip now reads `N files` or `N matches across K files` according to its output mode, and the tools' pagination and empty-result notices no longer count as results. While the recent turns hold tool output that `Ctrl+O` would reveal or hide, the footer shows `ctrl+o expand` or `ctrl+o collapse`. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index ae39f40231e..725a672ebf2 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -34,6 +34,9 @@ import { usagePercentFromRatio, } from '#/utils/usage/usage-format'; +/** What the footer's fixed ctrl+o hint offers: expand collapsed tool output, or collapse it again. */ +export type ToolOutputExpandHint = 'expand' | 'collapse'; + const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git'] as const; const MAX_CWD_SEGMENTS = 3; @@ -196,6 +199,7 @@ export class FooterComponent implements Component { private gitCacheWorkDir: string; private transientHint: string | null = null; private warningHint: string | null = null; + private expandHintProvider: (() => ToolOutputExpandHint | null) | null = null; private goalSnapshotKey: string | null = null; private goalObservedAtMs = Date.now(); private goalTimer: ReturnType | null = null; @@ -271,6 +275,16 @@ export class FooterComponent implements Component { this.warningHint = hint; } + /** + * Source of the fixed `ctrl+o expand` / `ctrl+o collapse` hint on line 1: + * `expand` while the transcript holds collapsed tool output ctrl+o can + * reveal, `collapse` once it is shown, `null` when there is nothing to + * toggle. Read on every render so it tracks the transcript exactly. + */ + setExpandHintProvider(provider: () => ToolOutputExpandHint | null): void { + this.expandHintProvider = provider; + } + /** * Sync both background-task badges with live counts. Each non-zero * count produces its own bracketed badge on line 1; zeros hide them @@ -311,26 +325,24 @@ export class FooterComponent implements Component { const leftLine = left.join(' '); const leftWidth = visibleWidth(leftLine); - // Rotating hint tips stay on the right unless they were given an - // inline slot in items (rendered above at their configured position) - // or the user dropped 'tips' from items. - let tipText = ''; + // The right side holds the fixed ctrl+o hint (while the transcript has + // tool output to expand or collapse) and the rotating tips, unless the + // tips were given an inline slot in items or dropped from items. The + // hint never rotates and wins over a tip that no longer fits. const tipsInline = order.includes('tips'); const showTips = !tipsInline && (configured === null || configured.includes('tips')); + const tipCandidates: string[] = []; if (showTips) { const { primary, pair } = tipsForIndex(currentTipIndex()); - const gap = 2; - const remaining = Math.max(0, width - leftWidth - gap); - if (pair && visibleWidth(pair) <= remaining) { - tipText = pair; - } else if (primary && visibleWidth(primary) <= remaining) { - tipText = primary; - } + if (pair) tipCandidates.push(pair); + if (primary) tipCandidates.push(primary); } + const remaining = Math.max(0, width - leftWidth - 2); + const rightText = this.buildRightText(tipCandidates, remaining, colors); - if (tipText) { - const pad = width - leftWidth - visibleWidth(tipText); - line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); + if (rightText.length > 0) { + const pad = width - leftWidth - visibleWidth(rightText); + line1 = leftLine + ' '.repeat(Math.max(0, pad)) + rightText; } else if (leftWidth <= width) { line1 = leftLine; } else { @@ -365,6 +377,24 @@ export class FooterComponent implements Component { return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; } + /** The fixed ctrl+o hint plus the first rotating tip that still fits beside it. */ + private buildRightText(tips: readonly string[], remaining: number, colors: ColorPalette): string { + const hint = this.expandHintProvider?.() ?? null; + if (hint === null) { + const tip = tips.find((candidate) => visibleWidth(candidate) <= remaining); + return tip === undefined ? '' : chalk.hex(colors.textMuted)(tip); + } + const shortcut = `ctrl+o ${hint}`; + for (const tip of tips) { + if (visibleWidth(`${shortcut}${TIP_SEPARATOR}${tip}`) <= remaining) { + return ( + chalk.hex(colors.textDim)(shortcut) + chalk.hex(colors.textMuted)(`${TIP_SEPARATOR}${tip}`) + ); + } + } + return visibleWidth(shortcut) <= remaining ? chalk.hex(colors.textDim)(shortcut) : ''; + } + /** * Rendered pieces per status-line slot. Empty-content slots (e.g. no goal, * outside a git repo) yield an empty list so composition just skips them. diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 3016bf6884b..9fab3ab946a 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -66,6 +66,11 @@ export class ReadGroupComponent extends Container { this.flushRender(); } + /** The per-file bodies only render while expanded, so any attached Read is hidden content. */ + hasHiddenContent(): boolean { + return this.entries.length > 0; + } + /** * Borrows a standalone `ToolCallComponent` into the group as a hidden state * container. Snapshot changes trigger throttled refreshes. Re-attaching the 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 a12641a80f4..96dc0821f0c 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -6,7 +6,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; import { PREVIEW_LINES } from './tool-renderers/types'; -import { lastNonEmptyLine, outcomeLine } from './tool-renderers/outcome'; +import { outcomeRows } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; export interface ShellExecutionOptions { @@ -20,8 +20,6 @@ export interface ShellExecutionOptions { * even when the header preview was truncated. */ readonly commandPreviewLines?: number; - readonly resultPreviewLines?: number; - readonly tailOutput?: boolean; readonly expandHint?: boolean; } @@ -34,13 +32,7 @@ export class ShellExecutionComponent extends Container { } if (options.result !== undefined) { - this.addResultPreview( - options.result, - options.expanded ?? false, - options.resultPreviewLines ?? PREVIEW_LINES, - options.tailOutput ?? false, - options.expandHint ?? true, - ); + this.addResultPreview(options.result, options.expanded ?? false, options.expandHint ?? true); } } @@ -64,8 +56,6 @@ export class ShellExecutionComponent extends Container { private addResultPreview( result: ToolResultBlockData, expanded: boolean, - previewLines: number, - tailOutput: boolean, expandHint: boolean, ): void { if (!result.output) return; @@ -73,8 +63,7 @@ export class ShellExecutionComponent extends Container { new TruncatedOutputComponent(result.output, { expanded, isError: result.is_error ?? false, - maxLines: previewLines, - tail: tailOutput, + maxLines: PREVIEW_LINES, expandHint, color: 'textMuted', }), @@ -87,13 +76,11 @@ export const shellExecutionResultRenderer: ResultRenderer = ( result: ToolResultBlockData, ctx, ): Component[] => { - // Collapsed: the command's last output line is the card's outcome row - // (most commands conclude on their last line); the rest waits for ctrl+o. - // A failing command keeps its multi-line preview so the error is visible. - if (!ctx.expanded && result.is_error !== true) { - const last = lastNonEmptyLine(result.output); - return last === undefined ? [] : [outcomeLine(last)]; - } + // Collapsed: short output is shown whole; longer output contributes its + // last line (most commands conclude on their last line) and the rest waits + // for ctrl+o. A failing command keeps its multi-line preview so the error + // is visible. + if (!ctx.expanded && result.is_error !== true) return outcomeRows(result.output, 'last'); // 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 diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index f7e0f72ad10..7382ca25143 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -36,7 +36,8 @@ import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; -import { lastNonEmptyLine, outcomeLine } from './tool-renderers/outcome'; +import { computeEditStats, computeWriteStats } from './tool-renderers/chip'; +import { lastNonEmptyLine, nonEmptyLines, OUTCOME_MAX_LINES, outcomeLine } from './tool-renderers/outcome'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; import { buildWaitForHeader } from './tool-renderers/wait-for'; @@ -572,6 +573,8 @@ export class ToolCallComponent extends Container { private expanded = false; private toolCall: ToolCallBlockData; private readonly markdownTheme = createMarkdownTheme(); + /** Memo for hasHiddenContent(); reset whenever the body is rebuilt or live output grows. */ + private hiddenContent: boolean | undefined = undefined; private result: ToolResultBlockData | undefined; private ui: TUI | undefined; private planPath: string | undefined; @@ -759,6 +762,50 @@ export class ToolCallComponent extends Container { this.rebuildBody(); } + /** + * Whether ctrl+o would reveal anything this card keeps out of its collapsed + * form. Mirrors the collapsed rules of buildCallPreview and the result + * renderers (short output shown whole, bodies that only render expanded); + * the footer reads it to decide whether to advertise ctrl+o. + */ + hasHiddenContent(): boolean { + this.hiddenContent ??= this.computeHiddenContent(); + return this.hiddenContent; + } + + private computeHiddenContent(): boolean { + const { name, args } = this.toolCall; + if (name === 'Bash' && str(args['command']).includes('\n')) return true; + const { result } = this; + if (result === undefined) return nonEmptyLines(this.liveOutput).length > 1; + if (result.output.length === 0) return false; + if (result.output.trimStart().startsWith('')) return false; + if (result.is_error === true) return nonEmptyLines(result.output).length > RESULT_PREVIEW_LINES; + switch (name) { + case 'Read': + case 'ReadMediaFile': + case 'FetchURL': + case 'WebSearch': + case 'Think': + case 'Grep': + case 'Glob': + return true; + case 'Edit': { + const stats = computeEditStats(args); + return stats.added + stats.removed > COMMAND_PREVIEW_LINES; + } + case 'Write': + return computeWriteStats(args).lines > COMMAND_PREVIEW_LINES; + case 'AgentSwarm': + case 'TodoList': + case 'EnterPlanMode': + case 'AskUserQuestion': + return false; + default: + return nonEmptyLines(result.output).length > OUTCOME_MAX_LINES; + } + } + setResult(result: ToolResultBlockData): void { this.result = result; // Result supersedes any live progress chatter; the result body is the @@ -825,8 +872,9 @@ export class ToolCallComponent extends Container { appendLiveOutput(text: string): void { if (this.result !== undefined || text.length === 0) return; this.liveOutput += text; + this.hiddenContent = undefined; if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) { - this.liveOutput = `[...truncated]\n${this.liveOutput.slice( + this.liveOutput = `[…truncated]\n${this.liveOutput.slice( this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS, )}`; } @@ -1528,7 +1576,7 @@ export class ToolCallComponent extends Container { } if (toolCall.name === 'Bash') { - // The collapsed card is this header plus one outcome row, so the header + // The collapsed card is this header plus its outcome rows, so the header // carries the command's first line; the full command and its output only // render in the body once expanded (ctrl+o). Wording mirrors the other label-only // headers (e.g. AskUserQuestion): the whole label takes the tone colour. @@ -1612,6 +1660,7 @@ export class ToolCallComponent extends Container { } private rebuildBody(): void { + this.hiddenContent = undefined; while (this.children.length > 2) { this.children.pop(); } @@ -1672,10 +1721,7 @@ export class ToolCallComponent extends Container { output: this.liveOutput, is_error: false, }, - expanded: this.expanded, - resultPreviewLines: RESULT_PREVIEW_LINES, - tailOutput: true, - expandHint: false, + expanded: true, }), ); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index 71789d02118..1c6b7078fcc 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -12,7 +12,9 @@ import { computeDiffLines } from '#/tui/components/media/diff-preview'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import { goalStatusChip } from './goal'; +import { parseGlobOutput, parseGrepOutput } from './grep-output'; import { readMediaChip } from './media'; +import { OUTCOME_MAX_LINES } from './outcome'; import { strArg } from './types'; import { waitForChip } from './wait-for'; @@ -87,21 +89,27 @@ const writeChip: ChipProvider = (toolCall) => formatWriteChip(computeWriteStats( const readChip: ChipProvider = (_toolCall, result) => pluralize(countNonEmptyLines(result.output), 'line'); -// The collapsed Bash card shows only the command and the last output line, -// so the line count is what tells the user there is more behind ctrl+o. +// A collapsed Bash card shows its output whole when it fits the outcome +// rows, so the chip only appears once there is more behind ctrl+o. const bashChip: ChipProvider = (_toolCall, result) => { const lines = countNonEmptyLines(result.output); - return lines === 0 ? '' : pluralize(lines, 'line'); + return lines <= OUTCOME_MAX_LINES ? '' : pluralize(lines, 'line'); }; -const grepChip: ChipProvider = (_toolCall, result) => { - const matches = countNonEmptyLines(result.output); - if (matches === 0) return 'no matches'; - return pluralize(matches, 'match', 'matches'); +// Grep's default mode lists files, so the chip counts what the mode +// returns: files, or matches and the files they fall in. +const grepChip: ChipProvider = (toolCall, result) => { + const stats = parseGrepOutput(toolCall, result.output); + if (stats.entries.length === 0) return 'no matches'; + if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file'); + const matches = pluralize(stats.matches, 'match', 'matches'); + return stats.files === 1 + ? `${matches} in 1 file` + : `${matches} across ${pluralize(stats.files, 'file')}`; }; const globChip: ChipProvider = (_toolCall, result) => { - const files = countNonEmptyLines(result.output); + const files = parseGlobOutput(result.output).length; if (files === 0) return 'no files'; return pluralize(files, 'file'); }; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts new file mode 100644 index 00000000000..255298c0c5a --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -0,0 +1,101 @@ +/** + * Shape-aware reading of Grep and Glob output for the header chip and the + * glance row. Both tools append notices (pagination, sensitive-file + * filtering, timeouts) and an empty-result sentence around the result lines; + * those must stay out of the counts and the path samples. + */ + +import type { ToolCallBlockData } from '#/tui/types'; + +import { strArg } from './types'; + +export type GrepMode = 'files_with_matches' | 'content' | 'count_matches'; + +export interface GrepEntry { + /** File the entry belongs to. */ + readonly path: string; + /** What the glance shows for it: `path`, `path:line`, or `path:count`. */ + readonly label: string; +} + +export interface GrepStats { + readonly mode: GrepMode; + readonly entries: readonly GrepEntry[]; + /** + * What the mode counts: files in `files_with_matches`, matching lines in + * `content`, the summed per-file counts in `count_matches`. + */ + readonly matches: number; + readonly files: number; +} + +// Lines the tools add around the results: the empty-result sentence, the +// count-mode summary, and the pagination / filtering / timeout notices. +const NOTICE = + /^(?:No matches found|No non-sensitive matches found|Found \d+ total (?:non-sensitive )?occurrences? across |Filtered \d+ sensitive file|Results truncated to \d+ lines|\[Output truncated at \d+ bytes|Grep timed out after )/; + +// `path:line:text`; context lines use `-` separators and are not matches. +const CONTENT_MATCH = /^(.+?):(\d+):/; +const COUNT_LINE = /^(.+):(\d+)$/; + +function resultLines(output: string): string[] { + if (output.length === 0) return []; + return output + .split('\n') + .filter((line) => line.length > 0 && line !== '--' && !NOTICE.test(line)); +} + +export function grepMode(toolCall: ToolCallBlockData): GrepMode { + const mode = strArg(toolCall.args, 'output_mode'); + return mode === 'content' || mode === 'count_matches' ? mode : 'files_with_matches'; +} + +export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): GrepStats { + const mode = grepMode(toolCall); + const lines = resultLines(output); + + if (mode === 'files_with_matches') { + const entries = lines.map((path) => ({ path, label: path })); + return { mode, entries, matches: entries.length, files: entries.length }; + } + + if (mode === 'count_matches') { + const entries: GrepEntry[] = []; + let matches = 0; + for (const line of lines) { + const [, path, count] = COUNT_LINE.exec(line) ?? []; + if (path === undefined || count === undefined) continue; + entries.push({ path, label: line }); + matches += Number(count); + } + return { mode, entries, matches, files: entries.length }; + } + + // Content mode: with line numbers (the default) only `path:line:` rows are + // matches; without them every row is one, and the path ends at its first + // colon. + const numbered = toolCall.args['-n'] !== false; + const entries: GrepEntry[] = []; + const paths = new Set(); + for (const line of lines) { + let path: string; + let label: string; + if (numbered) { + const [, matchPath, lineNumber] = CONTENT_MATCH.exec(line) ?? []; + if (matchPath === undefined || lineNumber === undefined) continue; + path = matchPath; + label = `${path}:${lineNumber}`; + } else { + const idx = line.indexOf(':'); + path = idx > 0 ? line.slice(0, idx) : line; + label = path; + } + entries.push({ path, label }); + paths.add(path); + } + return { mode, entries, matches: entries.length, files: paths.size }; +} + +export function parseGlobOutput(output: string): string[] { + return resultLines(output); +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts index b798cc8e5b5..528e24fe204 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts @@ -15,7 +15,8 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { Text } from '@moonshot-ai/pi-tui'; -import chalk from 'chalk'; + +import { currentTheme } from '#/tui/theme'; import type { ChipProvider } from './chip'; import { renderTruncated } from './truncated'; @@ -129,7 +130,7 @@ export const readMediaSummary: ResultRenderer = (toolCall, result, ctx) => { if (summary === null) return renderTruncated(toolCall, result, ctx); if (!ctx.expanded) return []; - const dim = chalk.dim; + const dim = (text: string): string => currentTheme.dim(text); const out: Component[] = []; if (summary.path !== undefined) { out.push(new Text(` ${dim(summary.path)}`, 0, 0)); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts index 9741a856f15..4e54d83ab24 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -1,7 +1,9 @@ /** - * The collapsed card's second row: one dim, width-truncated line that states - * the outcome of the call — a command's last output line, a Grep glance, an - * MCP tool's first line. Cards without a telling line stay single-row. + * The collapsed card's outcome rows: dim, width-truncated lines under the + * header that state what came of the call. Output short enough to fit + * (`OUTCOME_MAX_LINES`) is shown whole; longer output contributes one telling + * line — a command's last line, an MCP tool's first line — and the rest waits + * for ctrl+o. Cards without any output stay single-row. */ import type { Component } from '@moonshot-ai/pi-tui'; @@ -10,21 +12,24 @@ import { currentTheme } from '#/tui/theme'; import { TruncatedHeaderLine } from '../truncated-header-line'; +/** Non-empty output lines a collapsed card shows in full before it falls back to one. */ +export const OUTCOME_MAX_LINES = 3; + const OUTCOME_INDENT = ' '; // One shared reference so the line's render cache survives rebuilds (segment // styles are compared by identity); the palette is read at call time. const dimOutcomeStyle = (text: string): string => currentTheme.dim(text); -export function firstNonEmptyLine(text: string): string | undefined { - return text.split('\n').find((line) => line.trim().length > 0)?.trimEnd(); +export function nonEmptyLines(text: string): string[] { + return text + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => line.trimEnd()); } export function lastNonEmptyLine(text: string): string | undefined { - return text - .split('\n') - .findLast((line) => line.trim().length > 0) - ?.trimEnd(); + return nonEmptyLines(text).at(-1); } export function outcomeLine(text: string): Component { @@ -34,3 +39,14 @@ export function outcomeLine(text: string): Component { tail: '', }); } + +/** + * Rows for a finished call's output: every line when there are at most + * `OUTCOME_MAX_LINES`, otherwise the one line named by `keep`. + */ +export function outcomeRows(output: string, keep: 'first' | 'last'): Component[] { + const lines = nonEmptyLines(output); + if (lines.length <= OUTCOME_MAX_LINES) return lines.map((line) => outcomeLine(line)); + const line = keep === 'first' ? lines[0] : lines.at(-1); + return line === undefined ? [] : [outcomeLine(line)]; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts index 4cc32afede7..2bbeb866379 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -3,8 +3,9 @@ * * Each tool name maps to a `ResultRenderer` that turns the tool's * `ToolResultBlockData` into renderable Components. Tools without an - * explicit entry fall through to `renderTruncated` (header plus the first - * output line when collapsed, full output on ctrl+o, errors always previewed). + * explicit entry fall through to `renderTruncated` (short output shown whole + * when collapsed, otherwise its first line; full output on ctrl+o; errors + * always previewed). * * Keep this dispatch flat — tool names live next to the renderer they * choose, so adding a new tool means appending one case. diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 1b0aa5144e4..28d9b1152d3 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -1,10 +1,9 @@ /** - * Summary-style renderers — produce optional inline-glance content for - * tools whose raw output is high-volume but low-information (Grep, - * Glob). The numeric summary (line counts, exit codes, sizes) lives in - * the header chip (see chip.ts), so every tool here renders the glance - * line as the collapsed card's outcome row; the raw output only appears - * when the global expand toggle is on. + * Summary-style renderers — produce an inline glance for tools whose raw + * output is high-volume but low-information (Grep, Glob). The numeric + * summary (line counts, sizes) lives in the header chip (see chip.ts); the + * glance is the collapsed card's outcome row, and the raw output only + * appears when the global expand toggle is on. * * Errors always fall through to the truncated renderer so the user * sees the actual error message, not a synthetic summary. @@ -12,8 +11,10 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { Text } from '@moonshot-ai/pi-tui'; -import chalk from 'chalk'; +import { currentTheme } from '#/tui/theme'; + +import { parseGlobOutput, parseGrepOutput } from './grep-output'; import { outcomeLine } from './outcome'; import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; @@ -35,48 +36,31 @@ function withGlance(glance: GlanceFn | null): ResultRenderer { if (glance !== null) { const line = glance(toolCall, result); if (line.length > 0) { - out.push(ctx.expanded ? new Text(` ${chalk.dim(line)}`, 0, 0) : outcomeLine(line)); + out.push(ctx.expanded ? new Text(` ${currentTheme.dim(line)}`, 0, 0) : outcomeLine(line)); } } if (ctx.expanded && result.output.length > 0) { - out.push(new Text(chalk.dim(result.output), 4, 0)); + out.push(new Text(currentTheme.dim(result.output), 4, 0)); } return out; }; } -function nonEmptyLines(text: string): string[] { - if (text.length === 0) return []; - return text.split('\n').filter((line) => line.length > 0); -} - -// Strip a trailing `:line:col:text` so the glance shows the file path -// only, even when grep is in `content` mode (`src/foo.ts:42: foo()`). -function pathFromGrepLine(line: string): string { - const idx = line.indexOf(':'); - if (idx <= 0) return line; - const second = line.indexOf(':', idx + 1); - if (second <= 0) return line; - return line.slice(0, second); -} - -const grepGlance: GlanceFn = (_toolCall, result) => { - const lines = nonEmptyLines(result.output); - if (lines.length === 0) return ''; - const samples = lines.slice(0, GLANCE_SAMPLES).map(pathFromGrepLine); - const remaining = lines.length - samples.length; +function sampleList(labels: readonly string[]): string { + if (labels.length === 0) return ''; + const samples = labels.slice(0, GLANCE_SAMPLES); + const remaining = labels.length - samples.length; const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; return `${samples.join(', ')}${tail}`; -}; +} -const globGlance: GlanceFn = (_toolCall, result) => { - const lines = nonEmptyLines(result.output); - if (lines.length === 0) return ''; - const samples = lines.slice(0, GLANCE_SAMPLES); - const remaining = lines.length - samples.length; - const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; - return `${samples.join(', ')}${tail}`; -}; +// Path samples in the shape the mode returns — `path`, `path:line` (the +// matched text is dropped), or `path:count` — with the tool's notices left +// out. +const grepGlance: GlanceFn = (toolCall, result) => + sampleList(parseGrepOutput(toolCall, result.output).entries.map((entry) => entry.label)); + +const globGlance: GlanceFn = (_toolCall, result) => sampleList(parseGlobOutput(result.output)); // ── Exports ────────────────────────────────────────────────────────── diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index ebbaa20a411..470dbf93586 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -3,7 +3,7 @@ import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; -import { firstNonEmptyLine, outcomeLine } from './outcome'; +import { outcomeRows } from './outcome'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -101,16 +101,13 @@ export class TruncatedOutputComponent implements Component { } } -// Collapsed cards show the header plus one outcome row: a successful result -// contributes its first non-empty line, the rest waits for the global ctrl+o -// expand; errors always keep their multi-line preview so a failure is never -// reduced to a single line. +// Collapsed cards show the header plus the outcome rows: a successful result +// is shown whole when short, otherwise contributes its first non-empty line, +// and the rest waits for the global ctrl+o expand; errors always keep their +// multi-line preview so a failure is never reduced to a single line. export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { if (!result.output) return []; - if (!ctx.expanded && result.is_error !== true) { - const first = firstNonEmptyLine(result.output); - return first === undefined ? [] : [outcomeLine(first)]; - } + if (!ctx.expanded && result.is_error !== true) return outcomeRows(result.output, 'first'); return [ new TruncatedOutputComponent(result.output, { expanded: ctx.expanded, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index eec3c49f282..8a1c72764d0 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -155,7 +155,7 @@ import { type TUIStartupOptions, type TUIStartupState, } from './types'; -import { hasDispose, isExpandable } from './utils/component-capabilities'; +import { hasDispose, hasHiddenContent, isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; @@ -190,6 +190,7 @@ import { } from './utils/transcript-component-metadata'; import { nextTranscriptId } from './utils/transcript-id'; import { + expandCutoffIndex, TRANSCRIPT_EXPAND_TURNS, TRANSCRIPT_HYSTERESIS, TRANSCRIPT_KEEP_RECENT_ASSISTANT, @@ -457,6 +458,7 @@ export class KimiTUI { this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); + this.state.footer.setExpandHintProvider(() => this.toolOutputExpandHint()); this.uninstallRainbowDance = installRainbowDance(() => { this.state.ui.requestRender(); }); @@ -3453,24 +3455,39 @@ export class KimiTUI { ); } - toggleToolOutputExpansion(): void { - this.state.toolOutputExpanded = !this.state.toolOutputExpanded; - const children = this.state.transcriptContainer.children; - - // A component is expandable only if it sits at or after the start of the - // (totalTurns - expandTurns)-th turn — i.e. it belongs to one of the most - // recent `expandTurns` turns. Position-based so it also covers streaming - // components that have no entry in the metadata map. + /** + * Index of the first transcript child ctrl+o may expand: a component is + * expandable only if it sits at or after the start of the + * (totalTurns - expandTurns)-th turn, i.e. it belongs to one of the most + * recent `expandTurns` turns. Position-based so it also covers streaming + * components that have no entry in the metadata map. + */ + private expandCutoff(children: readonly Component[]): number { const boundaries: number[] = []; for (let i = 0; i < children.length; i++) { if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); } - const expandCutoff = - TRANSCRIPT_EXPAND_TURNS <= 0 - ? children.length - : boundaries.length > TRANSCRIPT_EXPAND_TURNS - ? boundaries[boundaries.length - TRANSCRIPT_EXPAND_TURNS]! - : 0; + return expandCutoffIndex(children.length, boundaries, TRANSCRIPT_EXPAND_TURNS); + } + + /** + * What the footer's ctrl+o hint should offer: `expand` while a card in the + * expandable window keeps content out of its collapsed form, `collapse` + * once the toggle shows it, `null` when ctrl+o would change nothing. + */ + private toolOutputExpandHint(): 'expand' | 'collapse' | null { + const children = this.state.transcriptContainer.children; + const cutoff = this.expandCutoff(children); + for (let i = children.length - 1; i >= cutoff; i--) { + if (hasHiddenContent(children[i])) return this.state.toolOutputExpanded ? 'collapse' : 'expand'; + } + return null; + } + + toggleToolOutputExpansion(): void { + this.state.toolOutputExpanded = !this.state.toolOutputExpanded; + const children = this.state.transcriptContainer.children; + const expandCutoff = this.expandCutoff(children); for (let i = 0; i < children.length; i++) { const child = children[i]!; diff --git a/apps/kimi-code/src/tui/utils/component-capabilities.ts b/apps/kimi-code/src/tui/utils/component-capabilities.ts index 5b4f813568d..67d4a9674cf 100644 --- a/apps/kimi-code/src/tui/utils/component-capabilities.ts +++ b/apps/kimi-code/src/tui/utils/component-capabilities.ts @@ -2,6 +2,15 @@ export interface Expandable { setExpanded(expanded: boolean): void; } +/** + * An expandable component that can say whether ctrl+o would change what it + * shows — content it keeps out of its collapsed form. Drives the footer's + * `ctrl+o expand` / `ctrl+o collapse` hint. + */ +export interface HidesContent extends Expandable { + hasHiddenContent(): boolean; +} + export interface Disposable { dispose(): void; } @@ -15,6 +24,15 @@ export function isExpandable(obj: unknown): obj is Expandable { ); } +export function hasHiddenContent(obj: unknown): boolean { + return ( + isExpandable(obj) && + 'hasHiddenContent' in obj && + typeof (obj as HidesContent).hasHiddenContent === 'function' && + (obj as HidesContent).hasHiddenContent() + ); +} + export function hasDispose(value: unknown): value is Disposable { return ( typeof value === 'object' && diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts index 7f53fe65683..d94f228b274 100644 --- a/apps/kimi-code/src/tui/utils/transcript-window.ts +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -123,3 +123,18 @@ export function turnsToTrim( } return toRemove; } + +/** + * Index of the first transcript child ctrl+o may expand: the start of the + * (turns - expandTurns)-th turn, given the child indexes of the turn + * boundaries. `expandTurns <= 0` disables expanding (the cutoff is past the + * last child); fewer boundaries than `expandTurns` means everything expands. + */ +export function expandCutoffIndex( + childCount: number, + boundaries: readonly number[], + expandTurns: number, +): number { + if (expandTurns <= 0) return childCount; + return boundaries.length > expandTurns ? boundaries[boundaries.length - expandTurns]! : 0; +} diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index cb69e6697ff..59ee331ba85 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -232,3 +232,46 @@ describe('FooterComponent line-2 hints', () => { expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long'); }); }); + +describe('FooterComponent ctrl+o hint', () => { + function plain(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); + } + function line1(footer: FooterComponent, width = 160): string { + return plain(footer.render(width)[0] ?? ''); + } + + it('shows no hint while there is no tool output to toggle', () => { + const footer = new FooterComponent(appState); + footer.setExpandHintProvider(() => null); + expect(line1(footer)).not.toContain('ctrl+o'); + footer.dispose(); + }); + + it('offers expand while collapsed output exists and collapse once it is shown', () => { + const footer = new FooterComponent(appState); + let hint: 'expand' | 'collapse' | null = 'expand'; + footer.setExpandHintProvider(() => hint); + expect(line1(footer)).toContain('ctrl+o expand'); + hint = 'collapse'; + expect(line1(footer)).toContain('ctrl+o collapse'); + footer.dispose(); + }); + + it('keeps the hint and drops the rotating tip when only one of them fits', () => { + // Same left-hand slots without the tips: measures the space the hint competes for. + const noTips = new FooterComponent({ + ...appState, + statusLine: { items: ['mode', 'model', 'cwd'], command: null }, + }); + const leftWidth = plain(noTips.render(200)[0] ?? '').trimEnd().length; + noTips.dispose(); + + const footer = new FooterComponent(appState); + footer.setExpandHintProvider(() => 'expand'); + const narrow = line1(footer, leftWidth + 2 + 'ctrl+o expand'.length); + expect(narrow.endsWith('ctrl+o expand')).toBe(true); + expect(narrow).not.toContain(' | '); + footer.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts b/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts index dfef86d7fd4..12158c387ad 100644 --- a/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts @@ -125,7 +125,7 @@ describe('AgentActivityViewer', () => { { id: 't1', name: 'Grep', - args: { pattern: 'IEventBus' }, + args: { pattern: 'IEventBus', output_mode: 'content' }, status: 'done', startedAt: 0, result: { @@ -143,7 +143,7 @@ describe('AgentActivityViewer', () => { const text = renderPlain(viewer); expect(text).toContain('── step 0 ──'); expect(text).toContain('Looking for the event bus definition.'); - expect(text).toContain('Used Grep (IEventBus) · 2 matches'); + expect(text).toContain('Used Grep (IEventBus) · 2 matches across 2 files'); // The grep glance (path samples in `path:line` form) is the collapsed // card's outcome row. expect(text).toContain('src/a.ts:1, src/b.ts:2'); @@ -251,7 +251,7 @@ describe('formatSubagentActivityPreview', () => { { id: 't1', name: 'Grep', - args: { pattern: 'IEventBus' }, + args: { pattern: 'IEventBus', output_mode: 'content' }, status: 'done', startedAt: 0, result: { @@ -275,7 +275,7 @@ describe('formatSubagentActivityPreview', () => { ); expect(text).toContain('── step 0 ──'); expect(text).toContain('Looking around.'); - expect(text).toContain('✓ Used Grep (IEventBus) · 2 matches'); + expect(text).toContain('✓ Used Grep (IEventBus) · 2 matches across 2 files'); expect(text).toContain('● Using Read (/repo/src/a.ts)'); expect(text).toContain('│ reading…'); // live tail for the in-flight call expect(text).toContain('Result:'); diff --git a/apps/kimi-code/test/tui/components/messages/read-group.test.ts b/apps/kimi-code/test/tui/components/messages/read-group.test.ts index b35d7065076..9cace522c4e 100644 --- a/apps/kimi-code/test/tui/components/messages/read-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/read-group.test.ts @@ -59,3 +59,10 @@ describe('ReadGroupComponent', () => { } }); }); + +describe('ReadGroupComponent hasHiddenContent', () => { + it('is true once a Read is attached, since the file bodies only render expanded', () => { + expect(new ReadGroupComponent(undefined).hasHiddenContent()).toBe(false); + expect(makeGroup().hasHiddenContent()).toBe(true); + }); +}); 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 7f063e08f29..853d42da006 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 @@ -121,7 +121,7 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'first\nsecond\n\nTests 12 passed\n\n', + output: 'first\nsecond\nthird\n\nTests 12 passed\n\n', is_error: false, }, { expanded: false }, @@ -131,6 +131,16 @@ describe('ShellExecutionComponent', () => { expect(rendered).toEqual([' Tests 12 passed']); }); + it('shows a short result whole while collapsed', () => { + const components = shellExecutionResultRenderer( + { id: 'call_1', name: 'Bash', args: { command: 'git status --short' } }, + { tool_call_id: 'call_1', output: ' M src/a.ts\n?? src/b.ts\n', is_error: false }, + { expanded: false }, + ); + const rendered = components.flatMap((c) => c.render(100)).map(strip); + expect(rendered).toEqual([' M src/a.ts', ' ?? src/b.ts']); + }); + it('renders no outcome row for a successful result without output', () => { const components = shellExecutionResultRenderer( { id: 'call_1', name: 'Bash', args: { command: 'true' } }, diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 3691fed23b5..a141ac3d818 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -277,13 +277,17 @@ describe('ToolCallComponent', () => { undefined, ); - component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); + component.setResult({ + tool_call_id: 'call_bash_done', + output: 'step a\nstep b\nstep c\ndone', + is_error: false, + }); const collapsed = component.render(100).map(strip).filter((line) => line.trim().length > 0); expect(collapsed).toHaveLength(2); expect(collapsed[0]).toContain('Ran a command'); expect(collapsed[0]).toContain('$ echo step1…'); - expect(collapsed[0]).toContain('· 1 line'); + expect(collapsed[0]).toContain('· 4 lines'); expect(collapsed[1]).toBe(' done'); component.setExpanded(true); @@ -357,7 +361,7 @@ describe('ToolCallComponent', () => { 'git log --oneline -5 origin/main -- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts'; const component = new ToolCallComponent( { id: 'call_bash_wide', name: 'Bash', args: { command } }, - { tool_call_id: 'call_bash_wide', output: 'ok', is_error: false }, + { tool_call_id: 'call_bash_wide', output: 'ok\nok\nok\nok', is_error: false }, ); // The command is the flexible middle segment: on a wide terminal it is // shown in full, on a narrow one it is cut with an ellipsis before the chip. @@ -370,7 +374,7 @@ describe('ToolCallComponent', () => { expect(narrow).toHaveLength(2); expect(visibleWidth(narrow[0]!)).toBeLessThanOrEqual(70); // The command is cut to the remaining width; the line-count chip survives. - expect(narrow[0]).toMatch(/\$ git log .*… · 1 line$/); + expect(narrow[0]).toMatch(/\$ git log .*… · 4 lines$/); }); it('keeps the file name of a long Read path on a narrow terminal', () => { @@ -841,9 +845,10 @@ describe('ToolCallComponent', () => { const collapsed = strip(component.render(100).join('\n')); expect(collapsed).toContain('Started background question'); - // The outcome row carries the result's first line: the task id. + // Three lines of output fit the collapsed card whole. expect(collapsed).toContain('task_id: question-aaaaaaaa'); - expect(collapsed).not.toContain('description: Which database?'); + expect(collapsed).toContain('description: Which database?'); + expect(collapsed).toContain('status: running'); expect(collapsed).not.toContain('Collected your answers'); component.setExpanded(true); @@ -2224,3 +2229,46 @@ describe('ToolCallComponent', () => { }); }); }); + +describe('ToolCallComponent hasHiddenContent', () => { + function card( + name: string, + args: Record, + output?: string, + isError = false, + ): ToolCallComponent { + return new ToolCallComponent( + { id: 'tc', name, args }, + output === undefined ? undefined : { tool_call_id: 'tc', output, is_error: isError }, + ); + } + + it('is false while a short Bash result is shown whole and true once lines are folded', () => { + expect(card('Bash', { command: 'ls' }, 'a\nb\nc').hasHiddenContent()).toBe(false); + expect(card('Bash', { command: 'ls' }, 'a\nb\nc\nd').hasHiddenContent()).toBe(true); + }); + + it('counts a multi-line command as hidden because only its first line is in the header', () => { + expect(card('Bash', { command: 'echo a\necho b' }, 'ok').hasHiddenContent()).toBe(true); + }); + + it('treats bodies that only render when expanded as hidden', () => { + expect(card('Read', { path: 'a.ts' }, '1\tfoo').hasHiddenContent()).toBe(true); + expect(card('Grep', { pattern: 'x' }, 'a.ts').hasHiddenContent()).toBe(true); + }); + + it('is false for a short failure preview and for suppressed bodies', () => { + expect(card('Bash', { command: 'ls' }, 'boom', true).hasHiddenContent()).toBe(false); + expect(card('AskUserQuestion', {}, 'a\nb\nc\nd\ne').hasHiddenContent()).toBe(false); + }); + + it('follows the live output while running and the result once it lands', () => { + const component = card('Bash', { command: 'ls' }); + expect(component.hasHiddenContent()).toBe(false); + component.appendLiveOutput('one\ntwo\n'); + expect(component.hasHiddenContent()).toBe(true); + component.setResult({ tool_call_id: 'tc', output: 'one\ntwo', is_error: false }); + expect(component.hasHiddenContent()).toBe(false); + component.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index e7ccf699a4e..11deda8e2bc 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -53,12 +53,49 @@ describe('chip registry', () => { expect(chipFor('Read', { path: 'a.ts' }, result('1\tfoo'))).toBe('1 line'); }); - it('Grep chip shows match count', () => { - expect(chipFor('Grep', { pattern: 'foo' }, result('a.ts\nb.ts\nc.ts'))).toBe('3 matches'); + it('Grep chip counts files in the default files_with_matches mode', () => { + expect(chipFor('Grep', { pattern: 'foo' }, result('a.ts\nb.ts\nc.ts'))).toBe('3 files'); + expect(chipFor('Grep', { pattern: 'foo' }, result('a.ts'))).toBe('1 file'); }); - it('Grep chip says "no matches" on empty result', () => { + it('Grep chip counts matches and their files in content mode', () => { + const content = { pattern: 'foo', output_mode: 'content' }; + expect(chipFor('Grep', content, result('src/a.ts:1:foo\nsrc/a.ts:9:foo\nsrc/b.ts:2:foo'))).toBe( + '3 matches across 2 files', + ); + expect(chipFor('Grep', content, result('src/a.ts:1:foo\nsrc/a.ts:9:foo'))).toBe( + '2 matches in 1 file', + ); + // Context lines and group separators are not matches. + expect( + chipFor('Grep', content, result('src/a.ts-1-import x\nsrc/a.ts:2:foo\n--\nsrc/b.ts:5:foo')), + ).toBe('2 matches across 2 files'); + }); + + it('Grep chip sums the per-file counts in count_matches mode', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'count_matches' }, + result('Found 5 total occurrences across 2 files.\nsrc/a.ts:3\nsrc/b.ts:2'), + ), + ).toBe('5 matches across 2 files'); + }); + + it('Grep chip leaves the notices out of the count', () => { expect(chipFor('Grep', { pattern: 'foo' }, result(''))).toBe('no matches'); + expect(chipFor('Grep', { pattern: 'foo' }, result('No matches found'))).toBe('no matches'); + expect( + chipFor( + 'Grep', + { pattern: 'foo' }, + result('a.ts\nb.ts\nResults truncated to 2 lines (total: 9). Use offset=2 to see more.'), + ), + ).toBe('2 files'); + }); + + it('Glob chip leaves the empty-result sentence out of the count', () => { + expect(chipFor('Glob', { pattern: '*.ts' }, result('No matches found'))).toBe('no files'); }); it('Glob chip shows file count', () => { @@ -144,11 +181,13 @@ describe('computeEditStats', () => { }); describe('Bash chip', () => { - it('counts the non-empty output lines and stays silent for no output', () => { + it('counts the output lines once they outgrow the collapsed card', () => { const chip = pickChip('Bash')!; const call = { id: 'tc', name: 'Bash', args: { command: 'ls' } }; - expect(chip(call, { tool_call_id: 'tc', output: 'a\n\nb\nc\n', is_error: false })).toBe('3 lines'); - expect(chip(call, { tool_call_id: 'tc', output: 'only', is_error: false })).toBe('1 line'); + expect(chip(call, { tool_call_id: 'tc', output: 'a\n\nb\nc\nd\n', is_error: false })).toBe('4 lines'); + // Up to three lines are shown whole on the collapsed card, so no chip. + expect(chip(call, { tool_call_id: 'tc', output: 'a\n\nb\nc\n', is_error: false })).toBe(''); + expect(chip(call, { tool_call_id: 'tc', output: 'only', is_error: false })).toBe(''); expect(chip(call, { tool_call_id: 'tc', output: '', is_error: false })).toBe(''); }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 969f82beace..474d899f0a8 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -149,7 +149,7 @@ describe('tool-result registry', () => { const out = strip( joinRender( renderer( - call('Grep', { pattern: 'foo' }), + call('Grep', { pattern: 'foo', output_mode: 'content' }), result('src/a.ts:42: foo()\nsrc/b.ts:7:foo'), expandedCtx, ), @@ -158,6 +158,27 @@ describe('tool-result registry', () => { expect(out).toContain('src/a.ts:42, src/b.ts:7'); }); + it('Grep glance skips the count_matches summary line', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo', output_mode: 'count_matches' }), + result('Found 5 total occurrences across 2 files.\nsrc/a.ts:3\nsrc/b.ts:2'), + ctx, + ), + ), + ); + expect(out).toBe(' src/a.ts:3, src/b.ts:2'); + }); + + it('shows a short unknown-tool output whole while collapsed', () => { + const renderer = pickResultRenderer('SomethingUnknown'); + expect(strip(joinRender(renderer(call('SomethingUnknown'), result('a\nb'), ctx)))).toBe( + ' a\n b', + ); + }); + it('Grep with empty result renders nothing in collapsed state', () => { const renderer = pickResultRenderer('Grep'); const out = joinRender(renderer(call('Grep', { pattern: 'foo' }), result(''), ctx)); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index aaa76f4302d..c2d084338ba 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -127,6 +127,7 @@ interface MessageDriver { }; init(): Promise; handleUserInput(text: string): void; + toggleToolOutputExpansion(): void; appendTranscriptEntry(entry: TranscriptEntry): void; persistInputHistory(text: string): Promise; sendQueuedMessage(session: unknown, item: QueuedMessage): void; @@ -8646,6 +8647,59 @@ describe('transcript step and assistant folding', () => { }); }); +describe('footer ctrl+o hint', () => { + function emitBashResult(driver: MessageDriver, toolCallId: string, output: string): void { + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId, + name: 'Bash', + args: { command: 'pnpm test' }, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId, + output, + isError: undefined, + } as Event, + vi.fn(), + ); + } + + function renderFooterLine1(driver: MessageDriver): string { + return stripSgr(driver.state.footer.render(160)[0] ?? ''); + } + + it('offers expand while a card hides output and collapse once it is shown', async () => { + const { driver } = await makeDriver(); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + + emitBashResult(driver, 'call_bash', ['line1', 'line2', 'line3', 'line4', 'Tests 5 passed'].join('\n')); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + + driver.toggleToolOutputExpansion(); + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + + driver.toggleToolOutputExpansion(); + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + }); + + it('stays silent when every card shows its whole output', async () => { + const { driver } = await makeDriver(); + emitBashResult(driver, 'call_bash', ['line1', 'line2', 'line3'].join('\n')); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + }); +}); + describe('KimiTUI session rating survey', () => { it('runs the end-to-end rating flow after five user turns', async () => { vi.useFakeTimers(); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index d173af25ff6..4522628697e 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -654,7 +654,7 @@ describe('TasksBrowserController — opening an agent task', () => { turnId: 1, toolCallId: 't1', name: 'Grep', - args: { pattern: 'foo' }, + args: { pattern: 'foo', output_mode: 'content' }, } as Event); store.applyEvent({ sessionId: 's1', @@ -672,7 +672,7 @@ describe('TasksBrowserController — opening an agent task', () => { const browser = state.tasksBrowser as { tailOutput?: string }; expect(browser.tailOutput).toContain('── step 0 ──'); - expect(browser.tailOutput).toContain('✓ Used Grep (foo) · 2 matches'); + expect(browser.tailOutput).toContain('✓ Used Grep (foo) · 2 matches across 2 files'); controller.close(); }); }); diff --git a/apps/kimi-code/test/tui/utils/transcript-window.test.ts b/apps/kimi-code/test/tui/utils/transcript-window.test.ts index 4fbc23fec66..29edbca446f 100644 --- a/apps/kimi-code/test/tui/utils/transcript-window.test.ts +++ b/apps/kimi-code/test/tui/utils/transcript-window.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { TranscriptEntry } from '#/tui/types'; -import { groupTurns, readEnvInt, turnsToTrim } from '#/tui/utils/transcript-window'; +import { expandCutoffIndex, groupTurns, readEnvInt, turnsToTrim } from '#/tui/utils/transcript-window'; let seq = 0; function makeEntry( @@ -114,3 +114,18 @@ describe('readEnvInt', () => { expect(readEnvInt(KEY, 7)).toBe(7); }); }); + +describe('expandCutoffIndex', () => { + it('starts the window at the (turns - expandTurns)-th boundary', () => { + expect(expandCutoffIndex(20, [0, 5, 10, 15], 3)).toBe(5); + }); + + it('expands everything while there are no more turns than the window', () => { + expect(expandCutoffIndex(20, [0, 5, 10], 3)).toBe(0); + expect(expandCutoffIndex(20, [], 3)).toBe(0); + }); + + it('disables expanding when the window is zero', () => { + expect(expandCutoffIndex(20, [0, 5, 10, 15], 0)).toBe(20); + }); +}); From 6870f348bb980129d9e385a213bfad7dd08b9cfe Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 17:37:43 +0800 Subject: [PATCH 03/17] feat(kimi-code): mark hidden tool output with counts and direction A collapsed Bash card's chip now counts the hidden lines (`N more lines`), its last-line outcome row carries a leading ellipsis, a generic tool's first-line row a trailing one, and the Grep glance keeps its "+N more" count in the fixed tail so width cuts drop samples, never the count. Background Bash results identify the task by their first metadata line instead of trailing internal hints, and header truncation now treats ANSI escapes as atomic zero-width units and stays bounded by the terminal width for huge arguments. --- .changeset/compact-tool-cards.md | 2 +- .../components/messages/shell-execution.ts | 10 ++- .../src/tui/components/messages/tool-call.ts | 11 ++- .../messages/tool-renderers/chip.ts | 5 +- .../messages/tool-renderers/outcome.ts | 35 +++++--- .../messages/tool-renderers/summary.ts | 35 +++++--- .../messages/truncated-header-line.ts | 83 ++++++++++++++----- apps/kimi-code/src/tui/constant/rendering.ts | 7 ++ .../messages/shell-execution.test.ts | 29 ++++++- .../tui/components/messages/tool-call.test.ts | 23 ++--- .../messages/tool-renderers/chip.test.ts | 6 +- .../messages/tool-renderers/registry.test.ts | 25 +++++- .../messages/truncated-header-line.test.ts | 25 ++++++ 13 files changed, 224 insertions(+), 72 deletions(-) diff --git a/.changeset/compact-tool-cards.md b/.changeset/compact-tool-cards.md index 64044c373fc..bb24153c1b3 100644 --- a/.changeset/compact-tool-cards.md +++ b/.changeset/compact-tool-cards.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Collapse finished tool calls in the transcript to two lines: the header names the call (a Bash card carries the command and its line count) and the outcome rows show the output whole when it is three lines or fewer, otherwise the command's last output line, a Grep/Glob path sample, or a tool's first output line; the full output and a Read group's file list appear after `Ctrl+O`. Failed calls keep their output preview, and Edit/Write previews are unchanged. A Grep card's chip now reads `N files` or `N matches across K files` according to its output mode, and the tools' pagination and empty-result notices no longer count as results. While the recent turns hold tool output that `Ctrl+O` would reveal or hide, the footer shows `ctrl+o expand` or `ctrl+o collapse`. +Collapse finished tool calls in the transcript to two lines: the header names the call (a Bash card carries the command and, once output is hidden, a `N more lines` count) and the outcome rows show the output whole when it is three lines or fewer, otherwise one telling line marked with an ellipsis on the side it was cut from — the command's last line, a Grep/Glob path sample with its `+N more` count, or a tool's first output line; the full output and a Read group's file list appear after `Ctrl+O`. Failed calls keep their output preview, and Edit/Write previews are unchanged. A Grep card's chip now reads `N files` or `N matches across K files` according to its output mode, and the tools' pagination and empty-result notices no longer count as results. While the recent turns hold tool output that `Ctrl+O` would reveal or hide, the footer shows `ctrl+o expand` or `ctrl+o collapse`. 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 96dc0821f0c..0746a1ed1ff 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -78,9 +78,13 @@ export const shellExecutionResultRenderer: ResultRenderer = ( ): Component[] => { // Collapsed: short output is shown whole; longer output contributes its // last line (most commands conclude on their last line) and the rest waits - // for ctrl+o. A failing command keeps its multi-line preview so the error - // is visible. - if (!ctx.expanded && result.is_error !== true) return outcomeRows(result.output, 'last'); + // for ctrl+o. A background or detached start returns a metadata block + // (task_id first, internal next_step/human_shell_hint lines last), so it + // shows its first line to identify the task instead of the trailing hint. + // A failing command keeps its multi-line preview so the error is visible. + if (!ctx.expanded && result.is_error !== true) { + return outcomeRows(result.output, result.output.startsWith('task_id:') ? 'first' : 'last'); + } // 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 diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 7382ca25143..2f67db820d7 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -37,7 +37,7 @@ import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; import { computeEditStats, computeWriteStats } from './tool-renderers/chip'; -import { lastNonEmptyLine, nonEmptyLines, OUTCOME_MAX_LINES, outcomeLine } from './tool-renderers/outcome'; +import { nonEmptyLines, OUTCOME_MAX_LINES, outcomeLine } from './tool-renderers/outcome'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; import { buildWaitForHeader } from './tool-renderers/wait-for'; @@ -1710,8 +1710,13 @@ export class ToolCallComponent extends Container { // command runs, so progress stays visible; the result's last line takes // the same row once it lands. ctrl+o shows the whole live tail. if (!this.expanded) { - const latest = lastNonEmptyLine(this.liveOutput); - if (latest !== undefined) this.addChild(outcomeLine(latest)); + const lines = nonEmptyLines(this.liveOutput); + const latest = lines.at(-1); + // With earlier output above it, the newest line carries the same + // leading ellipsis the finished card's last-line row uses. + if (latest !== undefined) { + this.addChild(outcomeLine(latest, lines.length > 1 ? 'above' : undefined)); + } return; } this.addChild( diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index 1c6b7078fcc..5e1094ef526 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -90,10 +90,11 @@ const readChip: ChipProvider = (_toolCall, result) => pluralize(countNonEmptyLines(result.output), 'line'); // A collapsed Bash card shows its output whole when it fits the outcome -// rows, so the chip only appears once there is more behind ctrl+o. +// rows; once one line stands in for the rest, the chip counts the hidden +// lines, not the total. const bashChip: ChipProvider = (_toolCall, result) => { const lines = countNonEmptyLines(result.output); - return lines <= OUTCOME_MAX_LINES ? '' : pluralize(lines, 'line'); + return lines <= OUTCOME_MAX_LINES ? '' : pluralize(lines - 1, 'more line'); }; // Grep's default mode lists files, so the chip counts what the mode diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts index 4e54d83ab24..6cf66906a0b 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -2,12 +2,14 @@ * The collapsed card's outcome rows: dim, width-truncated lines under the * header that state what came of the call. Output short enough to fit * (`OUTCOME_MAX_LINES`) is shown whole; longer output contributes one telling - * line — a command's last line, an MCP tool's first line — and the rest waits - * for ctrl+o. Cards without any output stay single-row. + * line — a command's last line, an MCP tool's first line — marked with an + * ellipsis on the side it was cut from, and the rest waits for ctrl+o. Cards + * without any output stay single-row. */ import type { Component } from '@moonshot-ai/pi-tui'; +import { OUTCOME_ROW_INDENT, TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; import { TruncatedHeaderLine } from '../truncated-header-line'; @@ -15,8 +17,6 @@ import { TruncatedHeaderLine } from '../truncated-header-line'; /** Non-empty output lines a collapsed card shows in full before it falls back to one. */ export const OUTCOME_MAX_LINES = 3; -const OUTCOME_INDENT = ' '; - // One shared reference so the line's render cache survives rebuilds (segment // styles are compared by identity); the palette is read at call time. const dimOutcomeStyle = (text: string): string => currentTheme.dim(text); @@ -28,18 +28,29 @@ export function nonEmptyLines(text: string): string[] { .map((line) => line.trimEnd()); } -export function lastNonEmptyLine(text: string): string | undefined { - return nonEmptyLines(text).at(-1); -} - -export function outcomeLine(text: string): Component { +/** One outcome row with a custom fixed tail (the Grep glance's `, +N more`). */ +export function outcomeRow(head: string, text: string, tail: string): Component { return new TruncatedHeaderLine({ - head: OUTCOME_INDENT, + head, flex: { text, style: dimOutcomeStyle, keep: 'head' }, - tail: '', + tail: tail.length > 0 ? dimOutcomeStyle(tail) : '', }); } +/** + * One outcome row. `more` marks hidden output with an ellipsis on the side it + * was cut from — `above` when this is the last line of a longer output, + * `below` when it is the first. The marker lives in the fixed head/tail so a + * width cut never eats it. + */ +export function outcomeLine(text: string, more?: 'above' | 'below'): Component { + return outcomeRow( + more === 'above' ? `${OUTCOME_ROW_INDENT}${TRUNCATION_ELLIPSIS} ` : OUTCOME_ROW_INDENT, + text, + more === 'below' ? ` ${TRUNCATION_ELLIPSIS}` : '', + ); +} + /** * Rows for a finished call's output: every line when there are at most * `OUTCOME_MAX_LINES`, otherwise the one line named by `keep`. @@ -48,5 +59,5 @@ export function outcomeRows(output: string, keep: 'first' | 'last'): Component[] const lines = nonEmptyLines(output); if (lines.length <= OUTCOME_MAX_LINES) return lines.map((line) => outcomeLine(line)); const line = keep === 'first' ? lines[0] : lines.at(-1); - return line === undefined ? [] : [outcomeLine(line)]; + return line === undefined ? [] : [outcomeLine(line, keep === 'first' ? 'below' : 'above')]; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 28d9b1152d3..518b82bbf33 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -12,31 +12,44 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { Text } from '@moonshot-ai/pi-tui'; +import { OUTCOME_ROW_INDENT } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; import { parseGlobOutput, parseGrepOutput } from './grep-output'; -import { outcomeLine } from './outcome'; +import { outcomeRow } from './outcome'; import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; const GLANCE_SAMPLES = 3; +interface Glance { + readonly samples: string; + readonly moreCount: number; +} + type GlanceFn = ( toolCall: Parameters[0], result: Parameters[1], -) => string; +) => Glance | null; function withGlance(glance: GlanceFn | null): ResultRenderer { return (toolCall, result, ctx) => { if (result.is_error) return renderTruncated(toolCall, result, ctx); const out: Component[] = []; - // The glance is the collapsed card's outcome row (one width-truncated - // line); the raw output only follows once expanded. + // Collapsed: the glance is the card's outcome row — path samples in the + // flexible middle and the "+N more" count in the fixed tail, so a width + // cut drops samples, never the count. Expanded: one joined line above + // the raw output. if (glance !== null) { - const line = glance(toolCall, result); - if (line.length > 0) { - out.push(ctx.expanded ? new Text(` ${currentTheme.dim(line)}`, 0, 0) : outcomeLine(line)); + const parts = glance(toolCall, result); + if (parts !== null) { + const tail = parts.moreCount > 0 ? `, +${String(parts.moreCount)} more` : ''; + out.push( + ctx.expanded + ? new Text(` ${currentTheme.dim(`${parts.samples}${tail}`)}`, 0, 0) + : outcomeRow(OUTCOME_ROW_INDENT, parts.samples, tail), + ); } } if (ctx.expanded && result.output.length > 0) { @@ -46,12 +59,10 @@ function withGlance(glance: GlanceFn | null): ResultRenderer { }; } -function sampleList(labels: readonly string[]): string { - if (labels.length === 0) return ''; +function sampleList(labels: readonly string[]): Glance | null { + if (labels.length === 0) return null; const samples = labels.slice(0, GLANCE_SAMPLES); - const remaining = labels.length - samples.length; - const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; - return `${samples.join(', ')}${tail}`; + return { samples: samples.join(', '), moreCount: labels.length - samples.length }; } // Path samples in the shape the mode returns — `path`, `path:line` (the diff --git a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts index a00124b9490..05b6f3005d5 100644 --- a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts +++ b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts @@ -14,7 +14,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; -const ELLIPSIS = '…'; +import { TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; export interface HeaderFlex { /** Plain text; `style` is applied after the cut so the ellipsis is styled too. */ @@ -35,54 +35,95 @@ export type HeaderContent = string | HeaderSegments; // hand here: pi-tui's truncateToWidth wraps its ellipsis in a reset sequence, // which would break the caller's styling around it. -/** Grapheme clusters, so an emoji or a combining sequence is never split by a cut. */ -function graphemes(text: string): string[] { - return Array.from(new Intl.Segmenter().segment(text), (segment) => segment.segment); +// ANSI escape sequences (CSI, OSC) — tool output can carry them — are +// zero-width atomic units: a cut must neither count their bytes toward the +// budget nor split a sequence in half and leak a malformed one. +const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; + +interface TextUnit { + readonly text: string; + readonly width: number; +} + +/** Grapheme clusters and whole escape sequences, in order; escape sequences measure zero width. */ +function* textUnits(text: string): Generator { + const segmenter = new Intl.Segmenter(); + let offset = 0; + for (const match of text.matchAll(ANSI_ESCAPE_PATTERN)) { + if (match.index > offset) { + for (const segment of segmenter.segment(text.slice(offset, match.index))) { + yield { text: segment.segment, width: visibleWidth(segment.segment) }; + } + } + yield { text: match[0], width: 0 }; + offset = match.index + match[0].length; + } + for (const segment of segmenter.segment(text.slice(offset))) { + yield { text: segment.segment, width: visibleWidth(segment.segment) }; + } } /** Keep the start of `text` up to a trailing ellipsis, within `width` cells. */ function keepHead(text: string, width: number): string { - const budget = width - visibleWidth(ELLIPSIS); + const budget = width - visibleWidth(TRUNCATION_ELLIPSIS); let out = ''; let used = 0; - for (const cluster of graphemes(text)) { - const clusterWidth = visibleWidth(cluster); - if (used + clusterWidth > budget) break; - out += cluster; - used += clusterWidth; + let truncated = false; + // Lazy iteration: only about one row of clusters is ever walked, so a huge + // argument (a base64 payload in an MCP call) costs nothing here. + for (const unit of textUnits(text)) { + if (used + unit.width > budget) { + truncated = true; + break; + } + out += unit.text; + used += unit.width; } - return `${out}${ELLIPSIS}`; + return truncated ? `${out}${TRUNCATION_ELLIPSIS}` : out; } /** Keep the end of `text` behind a leading ellipsis, within `width` cells. */ function keepTail(text: string, width: number): string { - const budget = width - visibleWidth(ELLIPSIS); + const budget = width - visibleWidth(TRUNCATION_ELLIPSIS); + // One cell needs at most one code unit of payload; the window adds headroom + // only for zero-width escape sequences, so the segmented slice stays + // bounded by the terminal width instead of the whole argument. + const windowed = text.length > budget + 64 ? text.slice(-(budget + 64)) : text; + const units = [...textUnits(windowed)]; + // The window edge may have split a grapheme or an escape sequence; drop + // whatever partial unit it left behind the leading ellipsis. + if (windowed.length < text.length) units.shift(); let out = ''; let used = 0; - for (const cluster of graphemes(text).toReversed()) { - const clusterWidth = visibleWidth(cluster); - if (used + clusterWidth > budget) break; - out = cluster + out; - used += clusterWidth; + let truncated = windowed.length < text.length; + for (const unit of units.toReversed()) { + if (used + unit.width > budget) { + truncated = true; + break; + } + out = unit.text + out; + used += unit.width; } - return `${ELLIPSIS}${out}`; + return truncated ? `${TRUNCATION_ELLIPSIS}${out}` : out; } function fitFlex(flex: HeaderFlex, width: number): string { - if (visibleWidth(flex.text) <= width) return flex.text; + // Two cells per code unit is the worst case (wide chars), so beyond twice + // the width a cut is certain and the whole string is never measured. + if (flex.text.length <= width * 2 && visibleWidth(flex.text) <= width) return flex.text; return flex.keep === 'tail' ? keepTail(flex.text, width) : keepHead(flex.text, width); } export function renderHeaderContent(content: HeaderContent, width: number): string { const safeWidth = Math.max(1, width); - if (typeof content === 'string') return truncateToWidth(content, safeWidth, ELLIPSIS); + if (typeof content === 'string') return truncateToWidth(content, safeWidth, TRUNCATION_ELLIPSIS); const { head, flex, tail } = content; const style = flex.style ?? ((text: string) => text); const available = safeWidth - visibleWidth(head) - visibleWidth(tail); // Below two cells there is no room for even an ellipsis plus one character // of the middle: give up on the layout and cut the whole row from the end. if (available < 2) { - return truncateToWidth(`${head}${style(flex.text)}${tail}`, safeWidth, ELLIPSIS); + return truncateToWidth(`${head}${style(flex.text)}${tail}`, safeWidth, TRUNCATION_ELLIPSIS); } return `${head}${style(fitFlex(flex, available))}${tail}`; } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index 8b20e9a95bc..45aa3200c4c 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -23,6 +23,13 @@ export const SHELL_OUTPUT_PREVIEW_LINES = 10; export const THINKING_PREVIEW_LINES = 2; export const COMMAND_PREVIEW_LINES = 10; +// The ellipsis marking a single-row line (card header, outcome row) that was +// cut to the terminal width or that stands in for hidden output lines. +export const TRUNCATION_ELLIPSIS = '…'; +// Left indent of a collapsed tool card's outcome rows, aligning them with +// the message-body indent. +export const OUTCOME_ROW_INDENT = ' '; + // Cap on the step-retry detail line under the waiting spinner, so huge // provider error bodies (occasionally whole HTML error pages) can't flood // the activity pane. 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 853d42da006..c61c4357370 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 @@ -128,7 +128,34 @@ describe('ShellExecutionComponent', () => { ); const rendered = components.flatMap((c) => c.render(100)).map(strip); - expect(rendered).toEqual([' Tests 12 passed']); + expect(rendered).toEqual([' … Tests 12 passed']); + }); + + it('identifies a background task by its first metadata line while collapsed', () => { + const components = shellExecutionResultRenderer( + { + id: 'call_1', + name: 'Bash', + args: { command: 'npm run build', run_in_background: true }, + }, + { + tool_call_id: 'call_1', + output: [ + 'task_id: bash-abc123', + 'pid: 12345', + 'description: npm run build', + 'status: running', + 'automatic_notification: true', + 'next_step: The completion arrives automatically in a later turn.', + 'human_shell_hint: The task is visible in the background-task panel.', + ].join('\n'), + is_error: false, + }, + { expanded: false }, + ); + + const rendered = components.flatMap((c) => c.render(100)).map(strip); + expect(rendered).toEqual([' task_id: bash-abc123 …']); }); it('shows a short result whole while collapsed', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index a141ac3d818..cba6da2cf88 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -157,14 +157,14 @@ describe('ToolCallComponent', () => { }, ); - // Collapsed: the header (command + line-count chip) plus one outcome row - // holding the last output line; the rest waits for ctrl+o. + // Collapsed: the header (command + hidden-line chip) plus one outcome row + // holding the last output line, marked as standing in for the rest. const collapsedLines = component.render(100).map(strip).filter((line) => line.trim().length > 0); expect(collapsedLines).toHaveLength(2); expect(collapsedLines[0]).toContain('Ran a command'); expect(collapsedLines[0]).toContain('$ printf output'); - expect(collapsedLines[0]).toContain('· 5 lines'); - expect(collapsedLines[1]).toBe(' line5'); + expect(collapsedLines[0]).toContain('· 4 more lines'); + expect(collapsedLines[1]).toBe(' … line5'); component.setExpanded(true); @@ -209,13 +209,14 @@ describe('ToolCallComponent', () => { component.appendLiveOutput('line1\n'); component.appendLiveOutput('line2\n'); - // Collapsed: the header plus the newest live line as the outcome row, so - // progress stays visible; the whole live tail waits for ctrl+o. + // Collapsed: the header plus the newest live line as the outcome row, + // marked as standing in for the lines above it; the whole live tail waits + // for ctrl+o. const rows = component.render(100).map(strip).filter((line) => line.trim().length > 0); expect(rows).toHaveLength(2); expect(rows[0]).toContain('Running a command'); expect(rows[0]).toContain('$ printf output'); - expect(rows[1]).toBe(' line2'); + expect(rows[1]).toBe(' … line2'); component.setExpanded(true); const expanded = strip(component.render(100).join('\n')); @@ -287,8 +288,8 @@ describe('ToolCallComponent', () => { expect(collapsed).toHaveLength(2); expect(collapsed[0]).toContain('Ran a command'); expect(collapsed[0]).toContain('$ echo step1…'); - expect(collapsed[0]).toContain('· 4 lines'); - expect(collapsed[1]).toBe(' done'); + expect(collapsed[0]).toContain('· 3 more lines'); + expect(collapsed[1]).toBe(' … done'); component.setExpanded(true); // The command is owned by buildCallPreview, so it appears exactly once — @@ -373,8 +374,8 @@ describe('ToolCallComponent', () => { const narrow = component.render(70).map(strip).filter((line) => line.trim().length > 0); expect(narrow).toHaveLength(2); expect(visibleWidth(narrow[0]!)).toBeLessThanOrEqual(70); - // The command is cut to the remaining width; the line-count chip survives. - expect(narrow[0]).toMatch(/\$ git log .*… · 4 lines$/); + // The command is cut to the remaining width; the hidden-line chip survives. + expect(narrow[0]).toMatch(/\$ git log .*… · 3 more lines$/); }); it('keeps the file name of a long Read path on a narrow terminal', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 11deda8e2bc..4073676ace2 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -181,10 +181,12 @@ describe('computeEditStats', () => { }); describe('Bash chip', () => { - it('counts the output lines once they outgrow the collapsed card', () => { + it('counts the hidden output lines once they outgrow the collapsed card', () => { const chip = pickChip('Bash')!; const call = { id: 'tc', name: 'Bash', args: { command: 'ls' } }; - expect(chip(call, { tool_call_id: 'tc', output: 'a\n\nb\nc\nd\n', is_error: false })).toBe('4 lines'); + // One outcome line stands in for the rest, so the chip counts what is hidden. + expect(chip(call, { tool_call_id: 'tc', output: 'a\n\nb\nc\nd\n', is_error: false })).toBe('3 more lines'); + expect(chip(call, { tool_call_id: 'tc', output: 'a\nb\nc\nd\ne', is_error: false })).toBe('4 more lines'); // Up to three lines are shown whole on the collapsed card, so no chip. expect(chip(call, { tool_call_id: 'tc', output: 'a\n\nb\nc\n', is_error: false })).toBe(''); expect(chip(call, { tool_call_id: 'tc', output: 'only', is_error: false })).toBe(''); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 474d899f0a8..45a7009f53d 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -58,12 +58,12 @@ function goalOutput(overrides: Record = {}): string { } describe('tool-result registry', () => { - it('falls back to truncated renderer for unknown tools: header-only collapsed, full when expanded', () => { + it('falls back to truncated renderer for unknown tools: first line marked, full when expanded', () => { const renderer = pickResultRenderer('SomethingUnknown'); const collapsed = strip( joinRender(renderer(call('SomethingUnknown'), result('\na\nb\nc\nd\ne'), ctx)), ); - expect(collapsed).toBe(' a'); + expect(collapsed).toBe(' a …'); const expanded = strip( joinRender(renderer(call('SomethingUnknown'), result('a\nb\nc\nd\ne'), expandedCtx)), @@ -86,10 +86,10 @@ describe('tool-result registry', () => { expect(out).toContain('… (2 more lines, ctrl+o to expand)'); }); - it('uses the shell renderer for Bash: last line collapsed, raw output expanded', () => { + it('uses the shell renderer for Bash: marked last line collapsed, raw output expanded', () => { const renderer = pickResultRenderer('Bash'); expect(strip(joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), ctx)))).toBe( - ' four', + ' … four', ); const out = strip( joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), expandedCtx)), @@ -129,6 +129,23 @@ describe('tool-result registry', () => { expect(out).toBe(' src/a.ts, src/b.ts, src/c.ts, +2 more'); }); + it('keeps the "+N more" count when the glance samples overflow the width', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo' }), + result('src/aaaa.ts\nsrc/bbbb.ts\nsrc/cccc.ts\nsrc/dddd.ts\nsrc/eeee.ts'), + ctx, + ), + 40, + ), + ); + // The samples are cut to fit; the count in the fixed tail always survives. + expect(out.endsWith(', +2 more')).toBe(true); + expect(out).toContain('…'); + }); + it('Grep glance lists path samples above the raw output when expanded', () => { const renderer = pickResultRenderer('Grep'); const out = strip( diff --git a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts index 02754bf4f94..8332c693433 100644 --- a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts +++ b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts @@ -74,6 +74,31 @@ describe('renderHeaderContent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(12); expect(line.endsWith('…')).toBe(true); }); + + it('keeps ANSI escape sequences atomic and zero-width when cutting', () => { + const colored = '\x1b[32mabcdef\x1b[0mghijkl'; + // 2 (head) + 5 for the middle: the whole opening sequence plus 4 visible + // cells, then the ellipsis. The sequence is never split or measured. + const line = renderHeaderContent( + { head: 'H ', flex: { text: colored, keep: 'head' }, tail: '' }, + 7, + ); + expect(line).toBe('H \x1b[32mabcd…'); + expect(visibleWidth(line)).toBeLessThanOrEqual(7); + }); + + it('cuts a huge argument without walking it whole', () => { + const huge = `prefix-${'x'.repeat(200_000)}-suffix`; + const head = strip(renderHeaderContent(segments(huge, 'head', ''), 40)); + expect(head.startsWith('● Ran a command · $ prefix-xxx')).toBe(true); + expect(head.endsWith('…')).toBe(true); + expect(visibleWidth(head)).toBeLessThanOrEqual(40); + + const tail = strip(renderHeaderContent(segments(huge, 'tail', ''), 40)); + expect(tail).toContain('$ …'); + expect(tail.endsWith('-suffix')).toBe(true); + expect(visibleWidth(tail)).toBeLessThanOrEqual(40); + }); }); describe('TruncatedHeaderLine', () => { From 2f1ee9178d56bb8bee4f03d141f64dbbc974b65a Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 17:50:39 +0800 Subject: [PATCH 04/17] fix(kimi-code): keep tool notices and context rows out of result counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glob's timeout, truncation, and warning lines no longer inflate the file count or pose as glance samples, and unnumbered Grep content with context flags falls back to an exact file count instead of claiming matches it cannot distinguish. The footer's ctrl+o hint now also appears when an outcome row is cut by the terminal width — one to three very long lines hide the remainder that ctrl+o reveals wrapped. --- .changeset/compact-tool-cards.md | 2 +- .../src/tui/components/messages/tool-call.ts | 15 ++++++- .../messages/tool-renderers/chip.ts | 5 ++- .../messages/tool-renderers/grep-output.ts | 22 +++++++--- .../messages/truncated-header-line.ts | 43 ++++++++++++++++--- .../tui/components/messages/tool-call.test.ts | 28 ++++++++++++ .../messages/tool-renderers/chip.test.ts | 36 ++++++++++++++++ .../messages/truncated-header-line.test.ts | 13 ++++++ 8 files changed, 148 insertions(+), 16 deletions(-) diff --git a/.changeset/compact-tool-cards.md b/.changeset/compact-tool-cards.md index bb24153c1b3..563c0a78627 100644 --- a/.changeset/compact-tool-cards.md +++ b/.changeset/compact-tool-cards.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Collapse finished tool calls in the transcript to two lines: the header names the call (a Bash card carries the command and, once output is hidden, a `N more lines` count) and the outcome rows show the output whole when it is three lines or fewer, otherwise one telling line marked with an ellipsis on the side it was cut from — the command's last line, a Grep/Glob path sample with its `+N more` count, or a tool's first output line; the full output and a Read group's file list appear after `Ctrl+O`. Failed calls keep their output preview, and Edit/Write previews are unchanged. A Grep card's chip now reads `N files` or `N matches across K files` according to its output mode, and the tools' pagination and empty-result notices no longer count as results. While the recent turns hold tool output that `Ctrl+O` would reveal or hide, the footer shows `ctrl+o expand` or `ctrl+o collapse`. +Collapse finished tool calls in the transcript to a header plus one marked outcome row: short output is shown whole, hidden output is counted (`N more lines`, `+N more`) and revealed by `Ctrl+O`, which the footer advertises while it is available. diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 2f67db820d7..2d74fee8468 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -575,6 +575,8 @@ export class ToolCallComponent extends Container { private readonly markdownTheme = createMarkdownTheme(); /** Memo for hasHiddenContent(); reset whenever the body is rebuilt or live output grows. */ private hiddenContent: boolean | undefined = undefined; + /** Width-dependent half of hasHiddenContent(); recomputed on every render. */ + private truncatedAtLastRender = false; private result: ToolResultBlockData | undefined; private ui: TUI | undefined; private planPath: string | undefined; @@ -730,6 +732,17 @@ export class ToolCallComponent extends Container { i++; } + // An outcome row cut to this width hides the remainder of a long line, + // which ctrl+o reveals wrapped. The header (child 1) is excluded — a cut + // key argument is not what ctrl+o reveals for most tools; Bash is the + // exception, its full command renders in the body once expanded. + this.truncatedAtLastRender = this.children.some( + (child, index) => + child instanceof TruncatedHeaderLine && + (index !== 1 || this.toolCall.name === 'Bash') && + child.wasTruncated(), + ); + if (allReused) { return cache!.lines; } @@ -770,7 +783,7 @@ export class ToolCallComponent extends Container { */ hasHiddenContent(): boolean { this.hiddenContent ??= this.computeHiddenContent(); - return this.hiddenContent; + return this.hiddenContent || this.truncatedAtLastRender; } private computeHiddenContent(): boolean { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index 5e1094ef526..a43a7244215 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -98,11 +98,14 @@ const bashChip: ChipProvider = (_toolCall, result) => { }; // Grep's default mode lists files, so the chip counts what the mode -// returns: files, or matches and the files they fall in. +// returns: files, or matches and the files they fall in. Unnumbered content +// with context flags mixes match and context rows, so only the file count +// is exact there. const grepChip: ChipProvider = (toolCall, result) => { const stats = parseGrepOutput(toolCall, result.output); if (stats.entries.length === 0) return 'no matches'; if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file'); + if (stats.matches === null) return pluralize(stats.files, 'file'); const matches = pluralize(stats.matches, 'match', 'matches'); return stats.files === 1 ? `${matches} in 1 file` diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index 255298c0c5a..44d7dac93c2 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -23,16 +23,20 @@ export interface GrepStats { readonly entries: readonly GrepEntry[]; /** * What the mode counts: files in `files_with_matches`, matching lines in - * `content`, the summed per-file counts in `count_matches`. + * `content`, the summed per-file counts in `count_matches`. `null` when the + * count is not derivable from the text: unnumbered content rows with + * context flags are indistinguishable from context rows. */ - readonly matches: number; + readonly matches: number | null; readonly files: number; } // Lines the tools add around the results: the empty-result sentence, the // count-mode summary, and the pagination / filtering / timeout notices. +// Glob prepends its own diagnostics (timeout, truncation, read warnings) +// and appends an exact-cap count line. const NOTICE = - /^(?:No matches found|No non-sensitive matches found|Found \d+ total (?:non-sensitive )?occurrences? across |Filtered \d+ sensitive file|Results truncated to \d+ lines|\[Output truncated at \d+ bytes|Grep timed out after )/; + /^(?:No matches found|No non-sensitive matches found|Found \d+ total (?:non-sensitive )?occurrences? across |Found \d+ matches$|Filtered \d+ sensitive file|Results truncated to \d+ lines|\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at |Only the first )/; // `path:line:text`; context lines use `-` separators and are not matches. const CONTENT_MATCH = /^(.+?):(\d+):/; @@ -72,9 +76,15 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr } // Content mode: with line numbers (the default) only `path:line:` rows are - // matches; without them every row is one, and the path ends at its first - // colon. + // matches; without them every match row is `path:text`, and context rows + // (`-A`/`-B`/`-C`) look exactly the same — the backend separates fields + // with ':' unconditionally — so an exact match count is unknowable then. const numbered = toolCall.args['-n'] !== false; + const hasContext = + toolCall.args['-A'] !== undefined || + toolCall.args['-B'] !== undefined || + toolCall.args['-C'] !== undefined; + const countable = numbered || !hasContext; const entries: GrepEntry[] = []; const paths = new Set(); for (const line of lines) { @@ -93,7 +103,7 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr entries.push({ path, label }); paths.add(path); } - return { mode, entries, matches: entries.length, files: paths.size }; + return { mode, entries, matches: countable ? entries.length : null, files: paths.size }; } export function parseGlobOutput(output: string): string[] { diff --git a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts index 05b6f3005d5..cc2a3e9b799 100644 --- a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts +++ b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts @@ -114,18 +114,35 @@ function fitFlex(flex: HeaderFlex, width: number): string { return flex.keep === 'tail' ? keepTail(flex.text, width) : keepHead(flex.text, width); } -export function renderHeaderContent(content: HeaderContent, width: number): string { +function layoutHeaderContent( + content: HeaderContent, + width: number, +): { line: string; truncated: boolean } { const safeWidth = Math.max(1, width); - if (typeof content === 'string') return truncateToWidth(content, safeWidth, TRUNCATION_ELLIPSIS); + if (typeof content === 'string') { + return { + line: truncateToWidth(content, safeWidth, TRUNCATION_ELLIPSIS), + truncated: visibleWidth(content) > safeWidth, + }; + } const { head, flex, tail } = content; const style = flex.style ?? ((text: string) => text); const available = safeWidth - visibleWidth(head) - visibleWidth(tail); // Below two cells there is no room for even an ellipsis plus one character // of the middle: give up on the layout and cut the whole row from the end. if (available < 2) { - return truncateToWidth(`${head}${style(flex.text)}${tail}`, safeWidth, TRUNCATION_ELLIPSIS); + const row = `${head}${style(flex.text)}${tail}`; + return { + line: truncateToWidth(row, safeWidth, TRUNCATION_ELLIPSIS), + truncated: visibleWidth(row) > safeWidth, + }; } - return `${head}${style(fitFlex(flex, available))}${tail}`; + const fitted = fitFlex(flex, available); + return { line: `${head}${style(fitted)}${tail}`, truncated: fitted !== flex.text }; +} + +export function renderHeaderContent(content: HeaderContent, width: number): string { + return layoutHeaderContent(content, width).line; } function sameContent(a: HeaderContent, b: HeaderContent): boolean { @@ -143,7 +160,9 @@ export class TruncatedHeaderLine implements Component { // The card and the gutter container reuse a child's output by array // identity, so an unchanged header must hand back the same array — a fresh // one per frame would defeat both caches on every paint. - private cache: { content: HeaderContent; width: number; lines: string[] } | undefined; + private cache: + | { content: HeaderContent; width: number; lines: string[]; truncated: boolean } + | undefined; constructor(private content: HeaderContent) {} @@ -157,13 +176,23 @@ export class TruncatedHeaderLine implements Component { this.cache = undefined; } + /** + * Whether the last render cut any part of the row — an outcome row cut to + * the terminal width hides the remainder of a long line, which ctrl+o + * reveals wrapped. Drives the footer's ctrl+o hint. + */ + wasTruncated(): boolean { + return this.cache?.truncated ?? false; + } + render(width: number): string[] { const cache = this.cache; if (cache !== undefined && cache.content === this.content && cache.width === width) { return cache.lines; } - const lines = [renderHeaderContent(this.content, width)]; - this.cache = { content: this.content, width, lines }; + const { line, truncated } = layoutHeaderContent(this.content, width); + const lines = [line]; + this.cache = { content: this.content, width, lines, truncated }; return lines; } } diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index cba6da2cf88..36e48287594 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2272,4 +2272,32 @@ describe('ToolCallComponent hasHiddenContent', () => { expect(component.hasHiddenContent()).toBe(false); component.dispose(); }); + + it('counts a width-cut outcome row as hidden at that width', () => { + const longLine = 'x'.repeat(120); + const component = card('Bash', { command: 'ls' }, `${longLine}\nshort`); + // Two lines are shown whole, so by line count nothing is hidden… + expect(component.hasHiddenContent()).toBe(false); + // …but at 40 columns the first row is cut and ctrl+o reveals it wrapped. + component.render(40); + expect(component.hasHiddenContent()).toBe(true); + component.render(200); + expect(component.hasHiddenContent()).toBe(false); + component.dispose(); + }); + + it('counts a width-cut Bash command header as hidden, but not a cut key argument', () => { + const bash = card('Bash', { command: `echo ${'a'.repeat(150)}` }, 'ok'); + bash.render(40); + // The full command renders in the body once expanded. + expect(bash.hasHiddenContent()).toBe(true); + bash.dispose(); + + const generic = card('TaskOutput', { task_id: `bg-${'x'.repeat(150)}` }, 'ok'); + generic.render(40); + // The output is shown whole and a cut header argument is not what ctrl+o + // reveals for a generic tool. + expect(generic.hasHiddenContent()).toBe(false); + generic.dispose(); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 4073676ace2..0f74fefa319 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -82,6 +82,21 @@ describe('chip registry', () => { ).toBe('5 matches across 2 files'); }); + it('Grep chip counts files only when unnumbered content rows can be context', () => { + // `-n: false` with context flags: match and context rows are both + // `path:text` (the backend separates fields with ':' unconditionally), + // so an exact match count is unknowable and the chip falls back to files. + const unnumberedContext = { pattern: 'foo', output_mode: 'content', '-n': false, '-C': 1 }; + expect( + chipFor('Grep', unnumberedContext, result('src/a.ts:import x\nsrc/a.ts:foo\nsrc/b.ts:foo')), + ).toBe('2 files'); + // Without context flags every row is a match, so the count stays exact. + const unnumbered = { pattern: 'foo', output_mode: 'content', '-n': false }; + expect(chipFor('Grep', unnumbered, result('src/a.ts:foo\nsrc/a.ts:bar\nsrc/b.ts:foo'))).toBe( + '3 matches across 2 files', + ); + }); + it('Grep chip leaves the notices out of the count', () => { expect(chipFor('Grep', { pattern: 'foo' }, result(''))).toBe('no matches'); expect(chipFor('Grep', { pattern: 'foo' }, result('No matches found'))).toBe('no matches'); @@ -98,6 +113,27 @@ describe('chip registry', () => { expect(chipFor('Glob', { pattern: '*.ts' }, result('No matches found'))).toBe('no files'); }); + it('Glob chip leaves the backend diagnostics out of the count', () => { + expect( + chipFor( + 'Glob', + { pattern: '**/*.ts' }, + result( + [ + 'Glob timed out after 60s; partial results returned.', + '[stdout truncated at 65536 bytes; results may be incomplete — use a more specific pattern]', + 'Glob completed with warnings; some directories could not be read: EACCES /root', + '[Truncated at 200 matches — use a more specific pattern]', + 'Only the first 200 matches are returned.', + 'a.ts', + 'b.ts', + 'Found 200 matches', + ].join('\n'), + ), + ), + ).toBe('2 files'); + }); + it('Glob chip shows file count', () => { expect(chipFor('Glob', { pattern: '**/*.ts' }, result('a.ts\nb.ts'))).toBe('2 files'); }); diff --git a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts index 8332c693433..411291aea57 100644 --- a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts +++ b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts @@ -110,4 +110,17 @@ describe('TruncatedHeaderLine', () => { line.setText(segments('ls -la', 'head')); expect(line.render(80)).not.toBe(first); }); + + it('reports whether the last render cut the row', () => { + const command = + 'git log --oneline -5 origin/main -- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts'; + const line = new TruncatedHeaderLine(segments(command, 'head')); + expect(line.wasTruncated()).toBe(false); + line.render(160); + expect(line.wasTruncated()).toBe(false); + line.render(60); + expect(line.wasTruncated()).toBe(true); + line.render(160); + expect(line.wasTruncated()).toBe(false); + }); }); From 44d810737377edb0177d5242bccb598b61884cb8 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 17:59:30 +0800 Subject: [PATCH 05/17] fix(kimi-code): align the ctrl+o hint with what expansion reveals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Edit check now uses the same clustered diff render as the preview (context rows and inter-hunk separators count toward the cap), and an ExitPlanMode outcome card no longer reports hidden content — the plan is fully rendered by the call preview and its result body is expansion-independent. OUTCOME_MAX_LINES moves to the TUI constant directory with the other shared rendering limits. --- .../src/tui/components/messages/tool-call.ts | 26 +++++++++++--- .../messages/tool-renderers/chip.ts | 2 +- .../messages/tool-renderers/outcome.ts | 5 +-- apps/kimi-code/src/tui/constant/rendering.ts | 3 ++ .../tui/components/messages/tool-call.test.ts | 36 +++++++++++++++++++ 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 2d74fee8468..466b21b3260 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -13,6 +13,7 @@ import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS, COMMAND_PREVIEW_LINES, + OUTCOME_MAX_LINES, RESULT_PREVIEW_LINES, THINKING_PREVIEW_LINES, } from '#/tui/constant/rendering'; @@ -36,8 +37,8 @@ import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; -import { computeEditStats, computeWriteStats } from './tool-renderers/chip'; -import { nonEmptyLines, OUTCOME_MAX_LINES, outcomeLine } from './tool-renderers/outcome'; +import { computeWriteStats } from './tool-renderers/chip'; +import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; import { buildWaitForHeader } from './tool-renderers/wait-for'; @@ -804,11 +805,28 @@ export class ToolCallComponent extends Container { case 'Glob': return true; case 'Edit': { - const stats = computeEditStats(args); - return stats.added + stats.removed > COMMAND_PREVIEW_LINES; + const oldStr = str(args['old_string']); + const newStr = str(args['new_string']); + if (oldStr.length === 0 && newStr.length === 0) return false; + // Mirror buildCallPreview exactly: context rows and inter-hunk + // separators also consume the preview cap, so counting only + // added/removed rows undercounts changes in distant hunks. + const filePath = str(args['file_path'] ?? args['path']); + return ( + renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3 }).length > + COMMAND_PREVIEW_LINES + ); } case 'Write': return computeWriteStats(args).lines > COMMAND_PREVIEW_LINES; + case 'ExitPlanMode': + // An approved plan is fully rendered by the call preview and the + // outcome body is expansion-independent; only a non-outcome result + // (an error message) can have more to show behind ctrl+o. + return ( + !isExitPlanModeOutcomeOutput(result.output) && + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); case 'AgentSwarm': case 'TodoList': case 'EnterPlanMode': diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index a43a7244215..d74a57410e7 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -9,12 +9,12 @@ */ import { computeDiffLines } from '#/tui/components/media/diff-preview'; +import { OUTCOME_MAX_LINES } from '#/tui/constant/rendering'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import { goalStatusChip } from './goal'; import { parseGlobOutput, parseGrepOutput } from './grep-output'; import { readMediaChip } from './media'; -import { OUTCOME_MAX_LINES } from './outcome'; import { strArg } from './types'; import { waitForChip } from './wait-for'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts index 6cf66906a0b..9627545efdf 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -9,14 +9,11 @@ import type { Component } from '@moonshot-ai/pi-tui'; -import { OUTCOME_ROW_INDENT, TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; +import { OUTCOME_MAX_LINES, OUTCOME_ROW_INDENT, TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; import { TruncatedHeaderLine } from '../truncated-header-line'; -/** Non-empty output lines a collapsed card shows in full before it falls back to one. */ -export const OUTCOME_MAX_LINES = 3; - // One shared reference so the line's render cache survives rebuilds (segment // styles are compared by identity); the palette is read at call time. const dimOutcomeStyle = (text: string): string => currentTheme.dim(text); diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index 45aa3200c4c..e97c5d6c220 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -29,6 +29,9 @@ export const TRUNCATION_ELLIPSIS = '…'; // Left indent of a collapsed tool card's outcome rows, aligning them with // the message-body indent. export const OUTCOME_ROW_INDENT = ' '; +// Non-empty output lines a collapsed tool card shows in full before it falls +// back to one telling outcome row. +export const OUTCOME_MAX_LINES = 3; // Cap on the step-retry detail line under the waiting spinner, so huge // provider error bodies (occasionally whole HTML error pages) can't flood diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 36e48287594..633e90671ea 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2300,4 +2300,40 @@ describe('ToolCallComponent hasHiddenContent', () => { expect(generic.hasHiddenContent()).toBe(false); generic.dispose(); }); + + it('is false for an ExitPlanMode outcome card and true for a non-outcome result', () => { + const approved = [ + 'Exited plan mode. Selected approach: rebuild the parser', + '', + '## Approved Plan:', + '1. read the grammar', + '2. port the tests', + '3. run the suite', + ].join('\n'); + // The plan is fully rendered by the call preview and the outcome body is + // expansion-independent, so ctrl+o would change nothing. + expect(card('ExitPlanMode', {}, approved).hasHiddenContent()).toBe(false); + // A non-outcome result (an error message) still counts by lines. + expect(card('ExitPlanMode', {}, 'a\nb\nc\nd').hasHiddenContent()).toBe(true); + }); + + it('counts an Edit with distant hunks as hidden when the clustered preview overflows', () => { + const lines = Array.from({ length: 30 }, (_, i) => `line${String(i + 1)}`); + const oldStr = lines.join('\n'); + const distant = [...lines]; + distant[0] = 'line1 changed'; + distant[29] = 'line30 changed'; + // Two changed rows far apart: context rows and the inter-hunk separator + // push the clustered preview past the cap even though added+removed is 2. + expect( + card('Edit', { file_path: 'a.ts', old_string: oldStr, new_string: distant.join('\n') }, 'ok').hasHiddenContent(), + ).toBe(true); + + const nearby = [...lines]; + nearby[0] = 'line1 changed'; + nearby[1] = 'line2 changed'; + expect( + card('Edit', { file_path: 'a.ts', old_string: oldStr, new_string: nearby.join('\n') }, 'ok').hasHiddenContent(), + ).toBe(false); + }); }); From a332a8eef96ebad465c8fd528685bfe8006fbc42 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 18:22:25 +0800 Subject: [PATCH 06/17] fix(kimi-code): tighten the collapsed-card hidden-content signals Outcome rows drop terminal control sequences before they are cut, a failed Bash card leaves the hidden-line count to its preview trailer, an unnumbered Grep glance lists each file once, a solo subagent card never reports hidden content, and the glance sample cap joins the other collapsed-card limits in the rendering constants. --- .../src/tui/components/messages/tool-call.ts | 10 +++++- .../messages/tool-renderers/chip.ts | 4 ++- .../messages/tool-renderers/grep-output.ts | 28 +++++++++------- .../messages/tool-renderers/outcome.ts | 9 +++++- .../messages/tool-renderers/summary.ts | 6 ++-- apps/kimi-code/src/tui/constant/rendering.ts | 3 ++ .../tui/components/messages/tool-call.test.ts | 17 ++++++++++ .../messages/tool-renderers/chip.test.ts | 20 ++++++++++++ .../messages/tool-renderers/registry.test.ts | 32 +++++++++++++++++++ 9 files changed, 110 insertions(+), 19 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 466b21b3260..92bf0fd47f0 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -574,7 +574,10 @@ export class ToolCallComponent extends Container { private expanded = false; private toolCall: ToolCallBlockData; private readonly markdownTheme = createMarkdownTheme(); - /** Memo for hasHiddenContent(); reset whenever the body is rebuilt or live output grows. */ + /** + * Memo for hasHiddenContent(); reset whenever the body or the result-driven + * content is rebuilt, or live output grows. + */ private hiddenContent: boolean | undefined = undefined; /** Width-dependent half of hasHiddenContent(); recomputed on every render. */ private truncatedAtLastRender = false; @@ -789,6 +792,10 @@ export class ToolCallComponent extends Container { private computeHiddenContent(): boolean { const { name, args } = this.toolCall; + // A solo Agent card with subagent state never renders its result body and + // its subagent block is a fixed-height window either way, so ctrl+o + // changes nothing there. + if (this.isSingleSubagentView()) return false; if (name === 'Bash' && str(args['command']).includes('\n')) return true; const { result } = this; if (result === undefined) return nonEmptyLines(this.liveOutput).length > 1; @@ -1680,6 +1687,7 @@ export class ToolCallComponent extends Container { } private rebuildContent(): void { + this.hiddenContent = undefined; while (this.children.length > this.callPreviewEndIndex) { this.children.pop(); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index d74a57410e7..e61e7131a7b 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -91,8 +91,10 @@ const readChip: ChipProvider = (_toolCall, result) => // A collapsed Bash card shows its output whole when it fits the outcome // rows; once one line stands in for the rest, the chip counts the hidden -// lines, not the total. +// lines, not the total. A failed command keeps its multi-line preview, whose +// own trailer already counts what is left, so the chip stays out of its way. const bashChip: ChipProvider = (_toolCall, result) => { + if (result.is_error === true) return ''; const lines = countNonEmptyLines(result.output); return lines <= OUTCOME_MAX_LINES ? '' : pluralize(lines - 1, 'more line'); }; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index 44d7dac93c2..605a152bfc3 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -20,6 +20,7 @@ export interface GrepEntry { export interface GrepStats { readonly mode: GrepMode; + /** Glance samples in output order; unnumbered content rows collapse to one entry per file. */ readonly entries: readonly GrepEntry[]; /** * What the mode counts: files in `files_with_matches`, matching lines in @@ -87,23 +88,26 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr const countable = numbered || !hasContext; const entries: GrepEntry[] = []; const paths = new Set(); + let rows = 0; for (const line of lines) { - let path: string; - let label: string; if (numbered) { - const [, matchPath, lineNumber] = CONTENT_MATCH.exec(line) ?? []; - if (matchPath === undefined || lineNumber === undefined) continue; - path = matchPath; - label = `${path}:${lineNumber}`; - } else { - const idx = line.indexOf(':'); - path = idx > 0 ? line.slice(0, idx) : line; - label = path; + const [, path, lineNumber] = CONTENT_MATCH.exec(line) ?? []; + if (path === undefined || lineNumber === undefined) continue; + rows++; + paths.add(path); + entries.push({ path, label: `${path}:${lineNumber}` }); + continue; } - entries.push({ path, label }); + // Unnumbered rows are labelled by their path alone, so the glance lists + // each file once instead of repeating it per match or context row. + const idx = line.indexOf(':'); + const path = idx > 0 ? line.slice(0, idx) : line; + rows++; + if (paths.has(path)) continue; paths.add(path); + entries.push({ path, label: path }); } - return { mode, entries, matches: countable ? entries.length : null, files: paths.size }; + return { mode, entries, matches: countable ? rows : null, files: paths.size }; } export function parseGlobOutput(output: string): string[] { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts index 9627545efdf..1cca69113b0 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -11,6 +11,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { OUTCOME_MAX_LINES, OUTCOME_ROW_INDENT, TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; +import { sanitizeShellOutput } from '#/tui/utils/shell-output'; import { TruncatedHeaderLine } from '../truncated-header-line'; @@ -18,8 +19,14 @@ import { TruncatedHeaderLine } from '../truncated-header-line'; // styles are compared by identity); the palette is read at call time. const dimOutcomeStyle = (text: string): string => currentTheme.dim(text); +/** + * Output lines worth a row, with terminal control sequences removed: an + * outcome row is a dim one-line digest, so a tool's own colours are noise + * there, and a colour left open past the width cut would bleed into the + * row's ellipsis and tail. Expanded bodies keep the raw output. + */ export function nonEmptyLines(text: string): string[] { - return text + return sanitizeShellOutput(text) .split('\n') .filter((line) => line.trim().length > 0) .map((line) => line.trimEnd()); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 518b82bbf33..d0140939ddc 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -12,7 +12,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { Text } from '@moonshot-ai/pi-tui'; -import { OUTCOME_ROW_INDENT } from '#/tui/constant/rendering'; +import { OUTCOME_GLANCE_SAMPLES, OUTCOME_ROW_INDENT } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; import { parseGlobOutput, parseGrepOutput } from './grep-output'; @@ -20,8 +20,6 @@ import { outcomeRow } from './outcome'; import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; -const GLANCE_SAMPLES = 3; - interface Glance { readonly samples: string; readonly moreCount: number; @@ -61,7 +59,7 @@ function withGlance(glance: GlanceFn | null): ResultRenderer { function sampleList(labels: readonly string[]): Glance | null { if (labels.length === 0) return null; - const samples = labels.slice(0, GLANCE_SAMPLES); + const samples = labels.slice(0, OUTCOME_GLANCE_SAMPLES); return { samples: samples.join(', '), moreCount: labels.length - samples.length }; } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index e97c5d6c220..803496c7e6f 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -32,6 +32,9 @@ export const OUTCOME_ROW_INDENT = ' '; // Non-empty output lines a collapsed tool card shows in full before it falls // back to one telling outcome row. export const OUTCOME_MAX_LINES = 3; +// Path samples a collapsed Grep/Glob card lists in its glance row before +// counting the rest as "+N more". +export const OUTCOME_GLANCE_SAMPLES = 3; // Cap on the step-retry detail line under the waiting spinner, so huge // provider error bodies (occasionally whole HTML error pages) can't flood diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 633e90671ea..335b3dc6e4c 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2337,3 +2337,20 @@ describe('ToolCallComponent hasHiddenContent', () => { ).toBe(false); }); }); + +describe('ToolCallComponent hasHiddenContent for a solo subagent card', () => { + it('is false because the fixed subagent window never changes with ctrl+o', () => { + const component = new ToolCallComponent( + { id: 'call_agent', name: 'Agent', args: { description: 'explore' } }, + undefined, + ); + component.onSubagentSpawned({ agentId: 'sub_1', agentName: 'explore', runInBackground: false }); + component.setResult({ + tool_call_id: 'call_agent', + output: 'line 1\nline 2\nline 3\nline 4\nline 5', + is_error: false, + }); + expect(component.hasHiddenContent()).toBe(false); + component.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 0f74fefa319..f49fc5e8c66 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -229,3 +229,23 @@ describe('Bash chip', () => { expect(chip(call, { tool_call_id: 'tc', output: '', is_error: false })).toBe(''); }); }); + +describe('Bash chip on a failed command', () => { + it('stays silent so the error preview trailer owns the hidden-line count', () => { + const chip = pickChip('Bash')!; + const call = { id: 'tc', name: 'Bash', args: { command: 'ls' } }; + expect(chip(call, { tool_call_id: 'tc', output: 'a\nb\nc\nd\ne', is_error: true })).toBe(''); + }); +}); + +describe('Grep chip without line numbers', () => { + it('counts every row as a match but each file once', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'content', '-n': false }, + result('a.ts:foo\na.ts:foo again\nb.ts:foo'), + ), + ).toBe('3 matches across 2 files'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 45a7009f53d..55b732cc30f 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -456,3 +456,35 @@ describe('tool-result registry', () => { expect(out).toContain('Task not found: bash-x'); }); }); + +describe('outcome rows', () => { + function plain(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); + } + + it('lists each file once in an unnumbered Grep glance', () => { + const renderer = pickResultRenderer('Grep'); + const out = plain( + joinRender( + renderer( + call('Grep', { pattern: 'foo', output_mode: 'content', '-n': false }), + result('a.ts:foo\na.ts:foo again\nb.ts:foo'), + ctx, + ), + ), + ); + expect(out).toBe(' a.ts, b.ts'); + }); + + it('strips terminal colours from an outcome row', () => { + const renderer = pickResultRenderer('Bash'); + const rows = renderer( + call('Bash', { command: 'pnpm test' }), + result('FAIL src/a.test.ts'), + ctx, + ).flatMap((component) => component.render(100)); + expect(rows).toHaveLength(1); + expect(rows[0]).not.toContain(''); + expect(plain(rows[0] ?? '')).toBe(' FAIL src/a.test.ts'); + }); +}); From 987e4c3cb421407ca5aaf38f6346932793ae1862 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 18:45:35 +0800 Subject: [PATCH 07/17] fix(kimi-code): count paginated Grep totals and wrapped error previews as hidden The Grep chip and glance use the tool's count-mode summary and pagination total instead of the current page, Windows drive letters stay inside unnumbered content paths, Glob's ripgrep stderr continuation lines no longer count as files, a background question follows the line-count rule, and an error preview whose long line wraps past its row cap keeps the footer's ctrl+o hint on. --- .../components/messages/shell-execution.ts | 7 +++ .../src/tui/components/messages/tool-call.ts | 38 ++++++++++----- .../messages/tool-renderers/grep-output.ts | 25 ++++++++-- .../messages/tool-renderers/summary.ts | 15 ++++-- .../messages/tool-renderers/truncated.ts | 8 ++++ .../tui/components/messages/tool-call.test.ts | 48 +++++++++++++++++++ .../messages/tool-renderers/chip.test.ts | 38 ++++++++++++++- .../messages/tool-renderers/registry.test.ts | 32 +++++++++++++ 8 files changed, 188 insertions(+), 23 deletions(-) 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 0746a1ed1ff..8b2e76999f6 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -69,6 +69,13 @@ export class ShellExecutionComponent extends Container { }), ); } + + /** Whether the collapsed result preview last cut rows away; drives the footer's ctrl+o hint. */ + wasTruncated(): boolean { + return this.children.some( + (child) => child instanceof TruncatedOutputComponent && child.wasTruncated(), + ); + } } export const shellExecutionResultRenderer: ResultRenderer = ( diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 92bf0fd47f0..8dcdf590b58 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -39,6 +39,7 @@ import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; import { computeWriteStats } from './tool-renderers/chip'; import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; +import { TruncatedOutputComponent } from './tool-renderers/truncated'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; import { buildWaitForHeader } from './tool-renderers/wait-for'; @@ -579,7 +580,7 @@ export class ToolCallComponent extends Container { * content is rebuilt, or live output grows. */ private hiddenContent: boolean | undefined = undefined; - /** Width-dependent half of hasHiddenContent(); recomputed on every render. */ + /** Width-dependent half of hasHiddenContent(); recomputed on every collapsed render. */ private truncatedAtLastRender = false; private result: ToolResultBlockData | undefined; private ui: TUI | undefined; @@ -736,16 +737,22 @@ export class ToolCallComponent extends Container { i++; } - // An outcome row cut to this width hides the remainder of a long line, - // which ctrl+o reveals wrapped. The header (child 1) is excluded — a cut - // key argument is not what ctrl+o reveals for most tools; Bash is the - // exception, its full command renders in the body once expanded. - this.truncatedAtLastRender = this.children.some( - (child, index) => - child instanceof TruncatedHeaderLine && - (index !== 1 || this.toolCall.name === 'Bash') && - child.wasTruncated(), - ); + // An outcome row cut to this width hides the remainder of a long line, and + // an error preview cut to its row cap hides the rest of a wrapped error; + // ctrl+o reveals both. The header (child 1) is excluded — a cut key + // argument is not what ctrl+o reveals for most tools; Bash is the + // exception, its full command renders in the body once expanded. The + // value is kept while expanded so the footer can still offer collapse. + if (!this.expanded) { + this.truncatedAtLastRender = this.children.some( + (child, index) => + (child instanceof TruncatedHeaderLine && + (index !== 1 || this.toolCall.name === 'Bash') && + child.wasTruncated()) || + ((child instanceof TruncatedOutputComponent || child instanceof ShellExecutionComponent) && + child.wasTruncated()), + ); + } if (allReused) { return cache!.lines; @@ -834,10 +841,17 @@ export class ToolCallComponent extends Container { !isExitPlanModeOutcomeOutput(result.output) && nonEmptyLines(result.output).length > OUTCOME_MAX_LINES ); + case 'AskUserQuestion': + // A foreground question renders its answers in an expansion-independent + // view; a background one returns a metadata block through the generic + // renderer and follows the line-count rule (the legacy engine's block + // runs past the outcome rows). + return ( + args['background'] === true && nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); case 'AgentSwarm': case 'TodoList': case 'EnterPlanMode': - case 'AskUserQuestion': return false; default: return nonEmptyLines(result.output).length > OUTCOME_MAX_LINES; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index 605a152bfc3..b7bb55716ce 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -29,19 +29,28 @@ export interface GrepStats { * context flags are indistinguishable from context rows. */ readonly matches: number | null; + /** Files in the whole result set when the tool reported a total (paginated results), else the files seen. */ readonly files: number; } // Lines the tools add around the results: the empty-result sentence, the // count-mode summary, and the pagination / filtering / timeout notices. -// Glob prepends its own diagnostics (timeout, truncation, read warnings) -// and appends an exact-cap count line. +// Glob prepends its own diagnostics (timeout, truncation, read warnings whose +// ripgrep stderr continues on `rg:` lines) and appends an exact-cap count line. const NOTICE = - /^(?:No matches found|No non-sensitive matches found|Found \d+ total (?:non-sensitive )?occurrences? across |Found \d+ matches$|Filtered \d+ sensitive file|Results truncated to \d+ lines|\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at |Only the first )/; + /^(?:No matches found|No non-sensitive matches found|Found \d+ total (?:non-sensitive )?occurrences? across |Found \d+ matches$|Filtered \d+ sensitive file|Results truncated to \d+ lines|\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at |Only the first |rg: )/; + +// Totals the tool reports for the whole result set when it paginates: the +// count-mode summary covers every file, and the pagination notice's total is +// the full line count — the file count in files mode. +const COUNT_SUMMARY = /^Found (\d+) total (?:non-sensitive )?occurrences? across (\d+) files?\.$/m; +const PAGINATION_TOTAL = /^Results truncated to \d+ lines \(total: (\d+)/m; // `path:line:text`; context lines use `-` separators and are not matches. const CONTENT_MATCH = /^(.+?):(\d+):/; const COUNT_LINE = /^(.+):(\d+)$/; +// A Windows drive letter carries its own colon; the separator search skips it. +const DRIVE_PREFIX = /^[A-Za-z]:[\\/]/; function resultLines(output: string): string[] { if (output.length === 0) return []; @@ -61,7 +70,9 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr if (mode === 'files_with_matches') { const entries = lines.map((path) => ({ path, label: path })); - return { mode, entries, matches: entries.length, files: entries.length }; + const total = PAGINATION_TOTAL.exec(output)?.[1]; + const files = total === undefined ? entries.length : Number(total); + return { mode, entries, matches: files, files }; } if (mode === 'count_matches') { @@ -73,6 +84,10 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr entries.push({ path, label: line }); matches += Number(count); } + const [, totalMatches, totalFiles] = COUNT_SUMMARY.exec(output) ?? []; + if (totalMatches !== undefined && totalFiles !== undefined) { + return { mode, entries, matches: Number(totalMatches), files: Number(totalFiles) }; + } return { mode, entries, matches, files: entries.length }; } @@ -100,7 +115,7 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr } // Unnumbered rows are labelled by their path alone, so the glance lists // each file once instead of repeating it per match or context row. - const idx = line.indexOf(':'); + const idx = line.indexOf(':', DRIVE_PREFIX.test(line) ? 2 : 0); const path = idx > 0 ? line.slice(0, idx) : line; rows++; if (paths.has(path)) continue; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index d0140939ddc..5f179588995 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -57,17 +57,22 @@ function withGlance(glance: GlanceFn | null): ResultRenderer { }; } -function sampleList(labels: readonly string[]): Glance | null { +function sampleList(labels: readonly string[], total = labels.length): Glance | null { if (labels.length === 0) return null; const samples = labels.slice(0, OUTCOME_GLANCE_SAMPLES); - return { samples: samples.join(', '), moreCount: labels.length - samples.length }; + return { samples: samples.join(', '), moreCount: total - samples.length }; } // Path samples in the shape the mode returns — `path`, `path:line` (the // matched text is dropped), or `path:count` — with the tool's notices left -// out. -const grepGlance: GlanceFn = (toolCall, result) => - sampleList(parseGrepOutput(toolCall, result.output).entries.map((entry) => entry.label)); +// out. In files and count mode every entry is a file, so a paginated result +// counts "+N more" against the tool's total file count, not just the page. +const grepGlance: GlanceFn = (toolCall, result) => { + const stats = parseGrepOutput(toolCall, result.output); + const labels = stats.entries.map((entry) => entry.label); + const total = stats.mode === 'content' ? labels.length : Math.max(labels.length, stats.files); + return sampleList(labels, total); +}; const globGlance: GlanceFn = (_toolCall, result) => sampleList(parseGlobOutput(result.output)); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index 470dbf93586..1a7db98d3fb 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -32,6 +32,8 @@ export class TruncatedOutputComponent implements Component { private readonly indent: number; private readonly expandHint: boolean; private readonly tail: boolean; + /** Whether the last collapsed render cut rows; kept while expanded so the footer can still offer collapse. */ + private truncatedAtLastRender = false; constructor( output: string, @@ -77,8 +79,14 @@ export class TruncatedOutputComponent implements Component { return ' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')); } + /** Whether the collapsed preview last cut rows away, which ctrl+o reveals. */ + wasTruncated(): boolean { + return this.truncatedAtLastRender; + } + render(width: number): string[] { const contentLines = this.textComponent.render(width); + if (!this.expanded) this.truncatedAtLastRender = contentLines.length > this.maxLines; if (this.expanded || contentLines.length <= this.maxLines) { return contentLines; diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 335b3dc6e4c..e07b3cec6b0 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2354,3 +2354,51 @@ describe('ToolCallComponent hasHiddenContent for a solo subagent card', () => { component.dispose(); }); }); + +describe('ToolCallComponent hasHiddenContent for width-cut and background results', () => { + function card( + name: string, + args: Record, + output: string, + isError = false, + ): ToolCallComponent { + return new ToolCallComponent( + { id: 'tc', name, args }, + { tool_call_id: 'tc', output, is_error: isError }, + ); + } + + it('follows the line-count rule for a background question', () => { + const legacyBlock = [ + 'task_id: question-aaaaaaaa', + 'description: Which database?', + 'status: running', + 'automatic_notification: true', + 'next_step: Continue your current work.', + 'next_step: Use TaskOutput for a snapshot.', + 'next_step: Use TaskStop only to cancel.', + 'human_shell_hint: The pending question is also visible in /tasks.', + ].join('\n'); + expect(card('AskUserQuestion', { background: true }, legacyBlock).hasHiddenContent()).toBe(true); + const shortBlock = 'task_id: question-aaaaaaaa\nstatus: running\nnext_step: Continue your work.'; + expect(card('AskUserQuestion', { background: true }, shortBlock).hasHiddenContent()).toBe(false); + expect(card('AskUserQuestion', {}, legacyBlock).hasHiddenContent()).toBe(false); + }); + + it('treats a failure whose one long line wraps past the preview as hidden, and keeps that while expanded', () => { + const longError = `Error: ${'x'.repeat(200)}`; + const bash = card('Bash', { command: 'ls' }, longError, true); + expect(bash.hasHiddenContent()).toBe(false); + bash.render(40); + expect(bash.hasHiddenContent()).toBe(true); + bash.setExpanded(true); + bash.render(40); + expect(bash.hasHiddenContent()).toBe(true); + bash.dispose(); + + const generic = card('SomethingUnknown', {}, longError, true); + generic.render(40); + expect(generic.hasHiddenContent()).toBe(true); + generic.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index f49fc5e8c66..b2d29051429 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -106,7 +106,7 @@ describe('chip registry', () => { { pattern: 'foo' }, result('a.ts\nb.ts\nResults truncated to 2 lines (total: 9). Use offset=2 to see more.'), ), - ).toBe('2 files'); + ).toBe('9 files'); }); it('Glob chip leaves the empty-result sentence out of the count', () => { @@ -249,3 +249,39 @@ describe('Grep chip without line numbers', () => { ).toBe('3 matches across 2 files'); }); }); + +describe('Grep chip on paginated and unusual output', () => { + it('uses the count-mode summary total instead of the current page', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'count_matches', head_limit: 2 }, + result( + 'Found 40 total occurrences across 12 files.\nResults truncated to 2 lines (total: 12). Use offset=2 to see more.\na.ts:3\nb.ts:2', + ), + ), + ).toBe('40 matches across 12 files'); + }); + + it('keeps a Windows drive letter inside the path of an unnumbered content row', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'content', '-n': false }, + result('C:/outside/a.ts:foo\nC:/outside/b.ts:foo'), + ), + ).toBe('2 matches across 2 files'); + }); + + it('leaves the continuation lines of a Glob traversal warning out of the file count', () => { + expect( + chipFor( + 'Glob', + { pattern: '**/*.ts' }, + result( + 'Glob completed with warnings; some directories could not be read: rg: /x: Permission denied (os error 13)\nrg: /y: Permission denied (os error 13)\na.ts\nb.ts', + ), + ), + ).toBe('2 files'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 55b732cc30f..9d32a02379f 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -488,3 +488,35 @@ describe('outcome rows', () => { expect(plain(rows[0] ?? '')).toBe(' FAIL src/a.test.ts'); }); }); + +describe('Grep glance on paginated and Windows output', () => { + it('counts "+N more" against the tool-reported file total of a paginated result', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo', head_limit: 4 }), + result( + 'a.ts\nb.ts\nc.ts\nd.ts\nResults truncated to 4 lines (total: 10). Use offset=4 to see more.', + ), + ctx, + ), + ), + ); + expect(out).toBe(' a.ts, b.ts, c.ts, +7 more'); + }); + + it('keeps a Windows drive letter in an unnumbered content glance', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo', output_mode: 'content', '-n': false }), + result('C:/outside/a.ts:foo\nC:/outside/b.ts:foo'), + ctx, + ), + ), + ); + expect(out).toBe(' C:/outside/a.ts, C:/outside/b.ts'); + }); +}); From 261f520666c924662f35a101672f946d94498c68 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 19:04:04 +0800 Subject: [PATCH 08/17] fix(kimi-code): treat spilled tool output as an envelope, not as results An oversized result reaches the TUI as agent-core's truncation envelope; cards now render its first line as the outcome row and carry no chip instead of counting its metadata as files or lines. The Grep chip keeps the count-mode totals on an empty page, and the Bash chip counts rows the way the outcome rows do, so whitespace-only rows never claim hidden lines. --- .../components/messages/shell-execution.ts | 10 +++++-- .../src/tui/components/messages/tool-call.ts | 3 ++ .../messages/tool-renderers/chip.ts | 8 +++-- .../messages/tool-renderers/summary.ts | 8 +++-- .../messages/tool-renderers/types.ts | 9 ++++++ .../tui/components/messages/tool-call.test.ts | 25 ++++++++++++++++ .../messages/tool-renderers/chip.test.ts | 23 ++++++++++++++ .../messages/tool-renderers/registry.test.ts | 30 +++++++++++++++++++ 8 files changed, 109 insertions(+), 7 deletions(-) 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 8b2e76999f6..8e49b97cf91 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -5,7 +5,7 @@ import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { ResultRenderer } from './tool-renderers/types'; -import { PREVIEW_LINES } from './tool-renderers/types'; +import { isSpilledToolOutput, PREVIEW_LINES } from './tool-renderers/types'; import { outcomeRows } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; @@ -87,10 +87,14 @@ export const shellExecutionResultRenderer: ResultRenderer = ( // last line (most commands conclude on their last line) and the rest waits // for ctrl+o. A background or detached start returns a metadata block // (task_id first, internal next_step/human_shell_hint lines last), so it - // shows its first line to identify the task instead of the trailing hint. + // shows its first line to identify the task instead of the trailing hint; + // an oversized result's truncation envelope likewise leads with the line + // that says the output was saved to a file. // A failing command keeps its multi-line preview so the error is visible. if (!ctx.expanded && result.is_error !== true) { - return outcomeRows(result.output, result.output.startsWith('task_id:') ? 'first' : 'last'); + const leadsWithMetadata = + result.output.startsWith('task_id:') || isSpilledToolOutput(result.output); + return outcomeRows(result.output, leadsWithMetadata ? 'first' : 'last'); } // Result only. The command preview is owned by ToolCallComponent's // buildCallPreview across the whole lifecycle (streaming, running, and diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 8dcdf590b58..ef076f366a8 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -40,6 +40,7 @@ import { buildGoalToolHeader } from './tool-renderers/goal'; import { computeWriteStats } from './tool-renderers/chip'; import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; +import { isSpilledToolOutput } from './tool-renderers/types'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; import { buildWaitForHeader } from './tool-renderers/wait-for'; @@ -1692,6 +1693,8 @@ export class ToolCallComponent extends Container { } private buildHeaderChip(result: ToolResultBlockData): string { + // The truncation envelope of an oversized result is not countable data. + if (isSpilledToolOutput(result.output)) return ''; const provider = pickChip(this.toolCall.name); if (provider === undefined) return ''; const text = provider(this.toolCall, result); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index e61e7131a7b..93dbb6020c2 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -15,6 +15,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import { goalStatusChip } from './goal'; import { parseGlobOutput, parseGrepOutput } from './grep-output'; import { readMediaChip } from './media'; +import { nonEmptyLines } from './outcome'; import { strArg } from './types'; import { waitForChip } from './wait-for'; @@ -95,7 +96,9 @@ const readChip: ChipProvider = (_toolCall, result) => // own trailer already counts what is left, so the chip stays out of its way. const bashChip: ChipProvider = (_toolCall, result) => { if (result.is_error === true) return ''; - const lines = countNonEmptyLines(result.output); + // Counted the way the outcome rows are, so whitespace-only rows neither + // count as hidden nor leave the chip claiming more than the card holds. + const lines = nonEmptyLines(result.output).length; return lines <= OUTCOME_MAX_LINES ? '' : pluralize(lines - 1, 'more line'); }; @@ -105,7 +108,8 @@ const bashChip: ChipProvider = (_toolCall, result) => { // is exact there. const grepChip: ChipProvider = (toolCall, result) => { const stats = parseGrepOutput(toolCall, result.output); - if (stats.entries.length === 0) return 'no matches'; + // A paginated count-mode page past the last row still carries the totals. + if (stats.files === 0) return 'no matches'; if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file'); if (stats.matches === null) return pluralize(stats.files, 'file'); const matches = pluralize(stats.matches, 'match', 'matches'); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 5f179588995..96b12c3d57e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -18,7 +18,7 @@ import { currentTheme } from '#/tui/theme'; import { parseGlobOutput, parseGrepOutput } from './grep-output'; import { outcomeRow } from './outcome'; import { renderTruncated } from './truncated'; -import type { ResultRenderer } from './types'; +import { isSpilledToolOutput, type ResultRenderer } from './types'; interface Glance { readonly samples: string; @@ -32,7 +32,11 @@ type GlanceFn = ( function withGlance(glance: GlanceFn | null): ResultRenderer { return (toolCall, result, ctx) => { - if (result.is_error) return renderTruncated(toolCall, result, ctx); + // A spilled result is the truncation envelope, not data: its first line + // tells the user the output was saved to a file. + if (result.is_error || isSpilledToolOutput(result.output)) { + return renderTruncated(toolCall, result, ctx); + } const out: Component[] = []; // Collapsed: the glance is the card's outcome row — path samples in the diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts index da3dc3a5a79..a58d9e7adf6 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -15,6 +15,15 @@ export type ResultRenderer = ( export const PREVIEW_LINES = RESULT_PREVIEW_LINES; +/** + * Whether a tool result is the truncation envelope agent-core substitutes for + * output over its size cap (metadata, `output_path`, and a head/tail preview). + * Renderers that count or sample result lines must not read it as data. + */ +export function isSpilledToolOutput(output: string): boolean { + return output.startsWith('Tool output exceeded '); +} + export function strArg(args: Record, ...keys: string[]): string { for (const key of keys) { const v = args[key]; diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index e07b3cec6b0..90ef7ee2c6a 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2402,3 +2402,28 @@ describe('ToolCallComponent hasHiddenContent for width-cut and background result generic.dispose(); }); }); + +describe('ToolCallComponent with spilled tool output', () => { + it('drops the chip and shows the envelope line for an oversized Read', () => { + const envelope = [ + 'Tool output exceeded 50000 characters; the full output was saved to a file.', + 'tool_name: Read', + 'tool_call_id: call_read_big', + 'output_size_chars: 90000', + 'output_path: /tmp/kimi/tool-output.txt', + 'next_step: Use Read with output_path to page through the saved output, or Grep to search it.', + '', + '[preview: chars [0, 10)]', + '1\tline one', + ].join('\n'); + const component = new ToolCallComponent( + { id: 'call_read_big', name: 'Read', args: { path: 'big.log' } }, + { tool_call_id: 'call_read_big', output: envelope, is_error: false }, + ); + const rows = component.render(120).map(strip).filter((line) => line.trim().length > 0); + expect(rows[0]).toContain('Used Read (big.log)'); + expect(rows[0]).not.toContain('lines'); + expect(rows[1]).toContain('Tool output exceeded 50000 characters'); + component.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index b2d29051429..22bc2d24625 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -285,3 +285,26 @@ describe('Grep chip on paginated and unusual output', () => { ).toBe('2 files'); }); }); + +describe('Grep chip on an empty count-mode page', () => { + it('keeps the summary totals when the offset is past the last row', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'count_matches', offset: 12 }, + result('Found 40 total occurrences across 12 files.'), + ), + ).toBe('40 matches across 12 files'); + }); +}); + +describe('Bash chip and whitespace-only rows', () => { + it('counts rows the way the outcome rows do, so blank separators never claim hidden lines', () => { + const chip = pickChip('Bash')!; + const call = { id: 'tc', name: 'Bash', args: { command: 'ls' } }; + expect(chip(call, { tool_call_id: 'tc', output: 'a\n \nb\nc', is_error: false })).toBe(''); + expect(chip(call, { tool_call_id: 'tc', output: 'a\n \nb\nc\nd', is_error: false })).toBe( + '3 more lines', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 9d32a02379f..1055b2e7d23 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -520,3 +520,33 @@ describe('Grep glance on paginated and Windows output', () => { expect(out).toBe(' C:/outside/a.ts, C:/outside/b.ts'); }); }); + +const SPILLED_OUTPUT = [ + 'Tool output exceeded 50000 characters; the full output was saved to a file.', + 'tool_name: Grep', + 'tool_call_id: call_1', + 'output_size_chars: 61234', + 'output_path: /tmp/kimi/tool-output.txt', + 'next_step: Use Read with output_path to page through the saved output, or Grep to search it.', + '', + '[preview: chars [0, 20)]', + 'src/a.ts\nsrc/b.ts', +].join('\n'); + +describe('spilled tool output', () => { + it('shows the Grep envelope as a plain outcome row instead of parsing it as results', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip(joinRender(renderer(call('Grep', { pattern: 'foo' }), result(SPILLED_OUTPUT), ctx))); + expect(out).toBe( + ' Tool output exceeded 50000 characters; the full output was saved to a file. …', + ); + }); + + it('leads a spilled Bash result with the envelope line rather than the preview tail', () => { + const renderer = pickResultRenderer('Bash'); + const out = strip(joinRender(renderer(call('Bash', { command: 'cat big.log' }), result(SPILLED_OUTPUT), ctx))); + expect(out).toBe( + ' Tool output exceeded 50000 characters; the full output was saved to a file. …', + ); + }); +}); From ac687568b9a13a6782ee4177a72a6c2f0db13bf9 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 20:03:41 +0800 Subject: [PATCH 09/17] fix(kimi-code): widen the hidden-content signal to failed previews and ! cards A paginated content search reports the tool's match total, a capped Edit or Write preview counts as hidden even when the call failed, and a user-run ! command card tells the footer about its running tail or capped result. --- .../src/tui/components/messages/shell-run.ts | 17 +++++++ .../src/tui/components/messages/tool-call.ts | 48 ++++++++++++------- .../messages/tool-renderers/chip.ts | 2 + .../messages/tool-renderers/grep-output.ts | 33 +++++++++++-- .../messages/tool-renderers/summary.ts | 7 ++- .../tui/components/messages/shell-run.test.ts | 34 +++++++++++++ .../tui/components/messages/tool-call.test.ts | 27 +++++++++++ .../messages/tool-renderers/chip.test.ts | 21 ++++++++ .../messages/tool-renderers/registry.test.ts | 18 +++++++ 9 files changed, 183 insertions(+), 24 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts index 88c02c19e5f..193ffdd0890 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-run.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -45,6 +45,8 @@ export class ShellRunComponent extends Container { private backgrounded = false; private disposed = false; private expanded = false; + // Whether the collapsed running tail leaves rows (or a capped buffer) behind; refreshed by renderText(). + private runningHidesRows = false; private readonly startedAt = Date.now(); private timer: ReturnType | undefined; @@ -74,6 +76,19 @@ export class ShellRunComponent extends Container { this.flush(); } + /** + * Whether ctrl+o would change the card: a running tail with earlier rows + * (or a capped buffer) behind it, or a finished preview cut to its row cap. + * Drives the footer's ctrl+o hint. + */ + hasHiddenContent(): boolean { + if (this.disposed || this.backgrounded) return false; + if (this.running) return this.runningHidesRows; + return this.children.some( + (child) => child instanceof TruncatedOutputComponent && child.wasTruncated(), + ); + } + finishBackgrounded(): void { if (this.disposed || !this.running) return; this.running = false; @@ -155,6 +170,8 @@ export class ShellRunComponent extends Container { const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); const dim = (s: string): string => currentTheme.fg('textDim', s); const trimmed = sanitizeShellOutput(this.combined).trimEnd(); + const lineCount = trimmed.length === 0 ? 0 : trimmed.split('\n').length; + this.runningHidesRows = this.combinedTruncated || lineCount > RUNNING_TAIL_LINES; let body: string; let extra = 0; if (trimmed.length === 0) { diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index ef076f366a8..e8ceaec36d0 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -804,7 +804,7 @@ export class ToolCallComponent extends Container { // its subagent block is a fixed-height window either way, so ctrl+o // changes nothing there. if (this.isSingleSubagentView()) return false; - if (name === 'Bash' && str(args['command']).includes('\n')) return true; + if (this.callPreviewHidesContent()) return true; const { result } = this; if (result === undefined) return nonEmptyLines(this.liveOutput).length > 1; if (result.output.length === 0) return false; @@ -819,21 +819,6 @@ export class ToolCallComponent extends Container { case 'Grep': case 'Glob': return true; - case 'Edit': { - const oldStr = str(args['old_string']); - const newStr = str(args['new_string']); - if (oldStr.length === 0 && newStr.length === 0) return false; - // Mirror buildCallPreview exactly: context rows and inter-hunk - // separators also consume the preview cap, so counting only - // added/removed rows undercounts changes in distant hunks. - const filePath = str(args['file_path'] ?? args['path']); - return ( - renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3 }).length > - COMMAND_PREVIEW_LINES - ); - } - case 'Write': - return computeWriteStats(args).lines > COMMAND_PREVIEW_LINES; case 'ExitPlanMode': // An approved plan is fully rendered by the call preview and the // outcome body is expansion-independent; only a non-outcome result @@ -859,6 +844,37 @@ export class ToolCallComponent extends Container { } } + /** + * Whether the args-driven call preview keeps content out of the collapsed + * card whatever the result: a multi-line Bash command shows only its first + * line in the header, and the Edit diff and Write content previews are + * capped, so a failed call can still have more to show behind ctrl+o. + */ + private callPreviewHidesContent(): boolean { + const { name, args } = this.toolCall; + switch (name) { + case 'Bash': + return str(args['command']).includes('\n'); + case 'Edit': { + const oldStr = str(args['old_string']); + const newStr = str(args['new_string']); + if (oldStr.length === 0 && newStr.length === 0) return false; + // Mirror buildCallPreview exactly: context rows and inter-hunk + // separators also consume the preview cap, so counting only + // added/removed rows undercounts changes in distant hunks. + const filePath = str(args['file_path'] ?? args['path']); + return ( + renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3 }).length > + COMMAND_PREVIEW_LINES + ); + } + case 'Write': + return computeWriteStats(args).lines > COMMAND_PREVIEW_LINES; + default: + return false; + } + } + setResult(result: ToolResultBlockData): void { this.result = result; // Result supersedes any live progress chatter; the result body is the diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index 93dbb6020c2..b8f6e0957c9 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -113,6 +113,8 @@ const grepChip: ChipProvider = (toolCall, result) => { if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file'); if (stats.matches === null) return pluralize(stats.files, 'file'); const matches = pluralize(stats.matches, 'match', 'matches'); + // A paginated content result only shows the files on its page. + if (stats.filesPartial) return matches; return stats.files === 1 ? `${matches} in 1 file` : `${matches} across ${pluralize(stats.files, 'file')}`; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index b7bb55716ce..517a6841dad 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -22,6 +22,11 @@ export interface GrepStats { readonly mode: GrepMode; /** Glance samples in output order; unnumbered content rows collapse to one entry per file. */ readonly entries: readonly GrepEntry[]; + /** + * Entries in the whole result set — the tool-reported total when the + * result is paginated — which the glance counts its "+N more" against. + */ + readonly total: number; /** * What the mode counts: files in `files_with_matches`, matching lines in * `content`, the summed per-file counts in `count_matches`. `null` when the @@ -31,6 +36,8 @@ export interface GrepStats { readonly matches: number | null; /** Files in the whole result set when the tool reported a total (paginated results), else the files seen. */ readonly files: number; + /** True when a paginated content result only shows the files on its page, so `files` is a lower bound. */ + readonly filesPartial: boolean; } // Lines the tools add around the results: the empty-result sentence, the @@ -72,7 +79,7 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr const entries = lines.map((path) => ({ path, label: path })); const total = PAGINATION_TOTAL.exec(output)?.[1]; const files = total === undefined ? entries.length : Number(total); - return { mode, entries, matches: files, files }; + return { mode, entries, total: files, matches: files, files, filesPartial: false }; } if (mode === 'count_matches') { @@ -86,9 +93,16 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr } const [, totalMatches, totalFiles] = COUNT_SUMMARY.exec(output) ?? []; if (totalMatches !== undefined && totalFiles !== undefined) { - return { mode, entries, matches: Number(totalMatches), files: Number(totalFiles) }; + return { + mode, + entries, + total: Number(totalFiles), + matches: Number(totalMatches), + files: Number(totalFiles), + filesPartial: false, + }; } - return { mode, entries, matches, files: entries.length }; + return { mode, entries, total: entries.length, matches, files: entries.length, filesPartial: false }; } // Content mode: with line numbers (the default) only `path:line:` rows are @@ -122,7 +136,18 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr paths.add(path); entries.push({ path, label: path }); } - return { mode, entries, matches: countable ? rows : null, files: paths.size }; + // Without context flags every paginated row is a match, so the tool's + // total is the exact match count; the files beyond the page stay unknown. + const paginatedTotal = countable ? PAGINATION_TOTAL.exec(output)?.[1] : undefined; + const matches = countable ? (paginatedTotal === undefined ? rows : Number(paginatedTotal)) : null; + return { + mode, + entries, + total: numbered && matches !== null ? matches : paths.size, + matches, + files: paths.size, + filesPartial: paginatedTotal !== undefined, + }; } export function parseGlobOutput(output: string): string[] { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 96b12c3d57e..24051089666 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -69,13 +69,12 @@ function sampleList(labels: readonly string[], total = labels.length): Glance | // Path samples in the shape the mode returns — `path`, `path:line` (the // matched text is dropped), or `path:count` — with the tool's notices left -// out. In files and count mode every entry is a file, so a paginated result -// counts "+N more" against the tool's total file count, not just the page. +// out. A paginated result counts "+N more" against the tool-reported total, +// not just the page. const grepGlance: GlanceFn = (toolCall, result) => { const stats = parseGrepOutput(toolCall, result.output); const labels = stats.entries.map((entry) => entry.label); - const total = stats.mode === 'content' ? labels.length : Math.max(labels.length, stats.files); - return sampleList(labels, total); + return sampleList(labels, Math.max(labels.length, stats.total)); }; const globGlance: GlanceFn = (_toolCall, result) => sampleList(parseGlobOutput(result.output)); diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts index 96a4bc110dc..8f513f6696b 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -175,3 +175,37 @@ describe('ShellRunComponent finished collapse', () => { expect(expanded).toContain('boom'); }); }); + +describe('ShellRunComponent hasHiddenContent', () => { + let component: ShellRunComponent | undefined; + + afterEach(() => { + component?.dispose(); + component = undefined; + }); + + function create(): ShellRunComponent { + component = new ShellRunComponent(() => {}); + return component; + } + + it('reports hidden rows while the running tail leaves earlier output behind', () => { + const c = create(); + c.append('one\ntwo\nthree\n'); + expect(c.hasHiddenContent()).toBe(false); + c.append('four\nfive\nsix\nseven\n'); + expect(c.hasHiddenContent()).toBe(true); + }); + + it('follows the finished preview cap after a collapsed render', () => { + const c = create(); + c.finish(Array.from({ length: 20 }, (_, i) => `row ${String(i + 1)}`).join('\n'), '', false); + c.render(100); + expect(c.hasHiddenContent()).toBe(true); + + const short = create(); + short.finish('done', '', false); + short.render(100); + expect(short.hasHiddenContent()).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 90ef7ee2c6a..b92655a4074 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2427,3 +2427,30 @@ describe('ToolCallComponent with spilled tool output', () => { component.dispose(); }); }); + +describe('ToolCallComponent hasHiddenContent with a capped call preview', () => { + const twelveLines = Array.from({ length: 12 }, (_, i) => `line ${String(i + 1)}`).join('\n'); + + it('counts a capped Write preview as hidden even when the call failed with a short error', () => { + const failed = new ToolCallComponent( + { id: 'call_write', name: 'Write', args: { path: 'a.txt', content: twelveLines } }, + { tool_call_id: 'call_write', output: 'Permission denied', is_error: true }, + ); + expect(failed.hasHiddenContent()).toBe(true); + failed.dispose(); + + const running = new ToolCallComponent( + { id: 'call_write_running', name: 'Write', args: { path: 'a.txt', content: twelveLines } }, + undefined, + ); + expect(running.hasHiddenContent()).toBe(true); + running.dispose(); + + const short = new ToolCallComponent( + { id: 'call_write_short', name: 'Write', args: { path: 'a.txt', content: 'one\ntwo' } }, + { tool_call_id: 'call_write_short', output: 'Permission denied', is_error: true }, + ); + expect(short.hasHiddenContent()).toBe(false); + short.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 22bc2d24625..cbfd4e3741d 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -308,3 +308,24 @@ describe('Bash chip and whitespace-only rows', () => { ); }); }); + +describe('Grep chip on paginated content results', () => { + const page = 'src/a.ts:1:foo\nsrc/a.ts:9:foo\nsrc/b.ts:2:foo'; + const notice = 'Results truncated to 3 lines (total: 1000). Use offset=3 to see more.'; + + it('reports the tool total and leaves the files out, since only the page is known', () => { + expect( + chipFor('Grep', { pattern: 'foo', output_mode: 'content', head_limit: 3 }, result(`${page}\n${notice}`)), + ).toBe('1000 matches'); + }); + + it('still counts only the page files when context rows make matches uncountable', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'content', '-n': false, '-C': 1, head_limit: 3 }, + result(`src/a.ts:foo\nsrc/a.ts:bar\nsrc/b.ts:foo\n${notice}`), + ), + ).toBe('2 files'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 1055b2e7d23..1f0277ed9ae 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -550,3 +550,21 @@ describe('spilled tool output', () => { ); }); }); + +describe('Grep glance on a paginated content result', () => { + it('counts "+N more" against the tool-reported match total', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo', output_mode: 'content', head_limit: 3 }), + result( + 'src/a.ts:1:foo\nsrc/a.ts:9:foo\nsrc/b.ts:2:foo\nResults truncated to 3 lines (total: 1000). Use offset=3 to see more.', + ), + ctx, + ), + ), + ); + expect(out).toBe(' src/a.ts:1, src/a.ts:9, src/b.ts:2, +997 more'); + }); +}); From 779b39d2657fa0757012b0f2d0b0bc198ddc3415 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 20:24:34 +0800 Subject: [PATCH 10/17] fix(kimi-code): keep result chips honest on incomplete, narrow, and cut-off cards Grep and Glob counts read as lower bounds (`12+ files`) when the tool reports a timeout or output cap, a header too narrow for its fixed parts drops the middle and cuts the head before the chip, and a call whose arguments were cut off by max_tokens no longer claims hidden content. --- .../src/tui/components/messages/tool-call.ts | 6 ++- .../messages/tool-renderers/chip.ts | 19 +++++----- .../messages/tool-renderers/grep-output.ts | 30 +++++++++++++-- .../messages/tool-renderers/summary.ts | 2 +- .../messages/truncated-header-line.ts | 26 +++++++++++-- .../tui/components/messages/tool-call.test.ts | 18 +++++++++ .../messages/tool-renderers/chip.test.ts | 38 ++++++++++++++++++- .../messages/truncated-header-line.test.ts | 20 ++++++++-- 8 files changed, 135 insertions(+), 24 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index e8ceaec36d0..1badee6cb15 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -748,7 +748,8 @@ export class ToolCallComponent extends Container { this.truncatedAtLastRender = this.children.some( (child, index) => (child instanceof TruncatedHeaderLine && - (index !== 1 || this.toolCall.name === 'Bash') && + (index !== 1 || + (this.toolCall.name === 'Bash' && this.toolCall.truncated !== true)) && child.wasTruncated()) || ((child instanceof TruncatedOutputComponent || child instanceof ShellExecutionComponent) && child.wasTruncated()), @@ -804,6 +805,9 @@ export class ToolCallComponent extends Container { // its subagent block is a fixed-height window either way, so ctrl+o // changes nothing there. if (this.isSingleSubagentView()) return false; + // Arguments cut off by max_tokens: the card shows a fixed "call never + // executed" note in place of any preview, so there is nothing to expand. + if (this.toolCall.truncated === true && this.result === undefined) return false; if (this.callPreviewHidesContent()) return true; const { result } = this; if (result === undefined) return nonEmptyLines(this.liveOutput).length > 1; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index b8f6e0957c9..27bd92e576b 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -28,8 +28,9 @@ export function countNonEmptyLines(text: string): number { return n; } -function pluralize(n: number, singular: string, plural?: string): string { - return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +// `partial` marks a lower bound (`12+ files`) when the tool reported an incomplete result set. +function pluralize(n: number, singular: string, plural?: string, partial = false): string { + return `${String(n)}${partial ? '+' : ''} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; } function formatBytes(bytes: number): string { @@ -110,20 +111,20 @@ const grepChip: ChipProvider = (toolCall, result) => { const stats = parseGrepOutput(toolCall, result.output); // A paginated count-mode page past the last row still carries the totals. if (stats.files === 0) return 'no matches'; - if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file'); - if (stats.matches === null) return pluralize(stats.files, 'file'); - const matches = pluralize(stats.matches, 'match', 'matches'); + if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file', undefined, stats.partial); + if (stats.matches === null) return pluralize(stats.files, 'file', undefined, stats.partial); + const matches = pluralize(stats.matches, 'match', 'matches', stats.partial); // A paginated content result only shows the files on its page. if (stats.filesPartial) return matches; return stats.files === 1 ? `${matches} in 1 file` - : `${matches} across ${pluralize(stats.files, 'file')}`; + : `${matches} across ${pluralize(stats.files, 'file', undefined, stats.partial)}`; }; const globChip: ChipProvider = (_toolCall, result) => { - const files = parseGlobOutput(result.output).length; - if (files === 0) return 'no files'; - return pluralize(files, 'file'); + const { entries, partial } = parseGlobOutput(result.output); + if (entries.length === 0) return 'no files'; + return pluralize(entries.length, 'file', undefined, partial); }; const fetchChip: ChipProvider = (_toolCall, result) => diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index 517a6841dad..fe15bca027c 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -38,6 +38,14 @@ export interface GrepStats { readonly files: number; /** True when a paginated content result only shows the files on its page, so `files` is a lower bound. */ readonly filesPartial: boolean; + /** True when the tool reported an incomplete result set (timeout or output cap): every count is a lower bound. */ + readonly partial: boolean; +} + +export interface GlobStats { + readonly entries: readonly string[]; + /** True when Glob timed out or hit its match cap: the count is a lower bound. */ + readonly partial: boolean; } // Lines the tools add around the results: the empty-result sentence, the @@ -52,6 +60,9 @@ const NOTICE = // the full line count — the file count in files mode. const COUNT_SUMMARY = /^Found (\d+) total (?:non-sensitive )?occurrences? across (\d+) files?\.$/m; const PAGINATION_TOTAL = /^Results truncated to \d+ lines \(total: (\d+)/m; +// Notices that mark the result set itself as incomplete, as opposed to merely paginated. +const INCOMPLETE = + /^(?:\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |\[stdout truncated at |\[Truncated at \d+ matches|Only the first \d+ matches)/m; // `path:line:text`; context lines use `-` separators and are not matches. const CONTENT_MATCH = /^(.+?):(\d+):/; @@ -74,12 +85,13 @@ export function grepMode(toolCall: ToolCallBlockData): GrepMode { export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): GrepStats { const mode = grepMode(toolCall); const lines = resultLines(output); + const partial = INCOMPLETE.test(output); if (mode === 'files_with_matches') { const entries = lines.map((path) => ({ path, label: path })); const total = PAGINATION_TOTAL.exec(output)?.[1]; const files = total === undefined ? entries.length : Number(total); - return { mode, entries, total: files, matches: files, files, filesPartial: false }; + return { mode, entries, total: files, matches: files, files, filesPartial: false, partial }; } if (mode === 'count_matches') { @@ -100,9 +112,18 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr matches: Number(totalMatches), files: Number(totalFiles), filesPartial: false, + partial, }; } - return { mode, entries, total: entries.length, matches, files: entries.length, filesPartial: false }; + return { + mode, + entries, + total: entries.length, + matches, + files: entries.length, + filesPartial: false, + partial, + }; } // Content mode: with line numbers (the default) only `path:line:` rows are @@ -147,9 +168,10 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr matches, files: paths.size, filesPartial: paginatedTotal !== undefined, + partial, }; } -export function parseGlobOutput(output: string): string[] { - return resultLines(output); +export function parseGlobOutput(output: string): GlobStats { + return { entries: resultLines(output), partial: INCOMPLETE.test(output) }; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 24051089666..8c432d94f00 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -77,7 +77,7 @@ const grepGlance: GlanceFn = (toolCall, result) => { return sampleList(labels, Math.max(labels.length, stats.total)); }; -const globGlance: GlanceFn = (_toolCall, result) => sampleList(parseGlobOutput(result.output)); +const globGlance: GlanceFn = (_toolCall, result) => sampleList(parseGlobOutput(result.output).entries); // ── Exports ────────────────────────────────────────────────────────── diff --git a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts index cc2a3e9b799..6b6ea75f5be 100644 --- a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts +++ b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts @@ -129,12 +129,30 @@ function layoutHeaderContent( const style = flex.style ?? ((text: string) => text); const available = safeWidth - visibleWidth(head) - visibleWidth(tail); // Below two cells there is no room for even an ellipsis plus one character - // of the middle: give up on the layout and cut the whole row from the end. + // of the middle: drop the middle and keep the fixed parts, cutting the head + // from its end when even those overflow, so the tail (the result chip) + // stays visible whenever it can fit at all. if (available < 2) { - const row = `${head}${style(flex.text)}${tail}`; + const headWidth = visibleWidth(head); + const tailWidth = visibleWidth(tail); + if (headWidth + tailWidth <= safeWidth) { + const marker = + flex.text.length > 0 && safeWidth - headWidth - tailWidth >= 1 + ? style(TRUNCATION_ELLIPSIS) + : ''; + return { line: `${head}${marker}${tail}`, truncated: flex.text.length > 0 }; + } + if (safeWidth - tailWidth >= 2) { + // The head is already styled, so pi-tui's cutter (which resets styles + // around its ellipsis) is the right tool here. + return { + line: `${truncateToWidth(head, safeWidth - tailWidth, TRUNCATION_ELLIPSIS)}${tail}`, + truncated: true, + }; + } return { - line: truncateToWidth(row, safeWidth, TRUNCATION_ELLIPSIS), - truncated: visibleWidth(row) > safeWidth, + line: truncateToWidth(`${head}${tail}`, safeWidth, TRUNCATION_ELLIPSIS), + truncated: true, }; } const fitted = fitFlex(flex, available); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index b92655a4074..37b506ea263 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2454,3 +2454,21 @@ describe('ToolCallComponent hasHiddenContent with a capped call preview', () => short.dispose(); }); }); + +describe('ToolCallComponent hasHiddenContent for a call truncated by max_tokens', () => { + it('reports nothing to expand, since the card only shows the never-executed note', () => { + const component = new ToolCallComponent( + { + id: 'call_cut', + name: 'Bash', + args: { command: 'echo one\necho two\necho three' }, + truncated: true, + }, + undefined, + ); + expect(component.hasHiddenContent()).toBe(false); + component.render(30); + expect(component.hasHiddenContent()).toBe(false); + component.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index cbfd4e3741d..c5605b861c5 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -131,7 +131,8 @@ describe('chip registry', () => { ].join('\n'), ), ), - ).toBe('2 files'); + // The timeout and cap notices mark the set incomplete: the count is a lower bound. + ).toBe('2+ files'); }); it('Glob chip shows file count', () => { @@ -329,3 +330,38 @@ describe('Grep chip on paginated content results', () => { ).toBe('2 files'); }); }); + +describe('Grep and Glob chips on an incomplete result set', () => { + it('marks the counts as lower bounds when Grep timed out or hit its output cap', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo' }, + result( + 'a.ts\nb.ts\nGrep timed out after 30s; partial results returned. Narrow the path, glob, or pattern and retry for complete results.', + ), + ), + ).toBe('2+ files'); + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'count_matches' }, + result( + 'Found 40 total occurrences across 12 files.\na.ts:30\nb.ts:10\n[Output truncated at 1048576 bytes of rg output — the result set is incomplete. Narrow the pattern, path, or glob filters and re-run to recover complete results.]', + ), + ), + ).toBe('40+ matches across 12+ files'); + }); + + it('marks a capped Glob result the same way', () => { + expect( + chipFor( + 'Glob', + { pattern: '**/*.ts' }, + result( + '[Truncated at 1000 matches — use a more specific pattern]\nOnly the first 1000 matches are returned.\na.ts\nb.ts', + ), + ), + ).toBe('2+ files'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts index 411291aea57..501f70f5600 100644 --- a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts +++ b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts @@ -69,10 +69,22 @@ describe('renderHeaderContent', () => { expect(line).toBe('H ABCDE… T'); }); - it('falls back to cutting the whole row when even the fixed parts overflow', () => { - const line = strip(renderHeaderContent(segments('ls', 'head'), 12)); - expect(visibleWidth(line)).toBeLessThanOrEqual(12); - expect(line.endsWith('…')).toBe(true); + it('drops the middle before the fixed parts when the row is too narrow for it', () => { + const content = { head: 'HEAD ', flex: { text: 'abcdef', keep: 'head' as const }, tail: ' T' }; + // One spare cell: the middle collapses to an ellipsis between the fixed parts. + expect(renderHeaderContent(content, 8)).toBe('HEAD … T'); + // No spare cell: the middle is dropped outright, both fixed parts stay. + expect(renderHeaderContent(content, 7)).toBe('HEAD T'); + }); + + it('cuts the head from its end so the tail survives when even the fixed parts overflow', () => { + const content = { head: 'HEAD ', flex: { text: 'abcdef', keep: 'head' as const }, tail: ' T' }; + const line = strip(renderHeaderContent(content, 5)); + expect(line).toBe('HE… T'); + // Below two cells for the head there is nothing left to keep: cut from the end. + const tiny = strip(renderHeaderContent(content, 3)); + expect(visibleWidth(tiny)).toBeLessThanOrEqual(3); + expect(tiny.endsWith('…')).toBe(true); }); it('keeps ANSI escape sequences atomic and zero-width when cutting', () => { From 7f3a38f52691a8c80d775fe053841964e3639d2b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 20:52:53 +0800 Subject: [PATCH 11/17] fix(kimi-code): stop the expand hint lying on goal cards, ! cards, and paginated context A pagination total only stands in for the match count when no context flag is set, a ! command card that finished while expanded still counts its rows past the preview cap, parsed goal snapshots and bodiless goal updates report nothing to expand, header fitting measures graphemes instead of trusting code-unit length, and the escape pattern and tail window join the rendering constants. --- .../src/tui/components/messages/shell-run.ts | 10 ++++-- .../src/tui/components/messages/tool-call.ts | 12 ++++++- .../messages/tool-renderers/goal.ts | 2 +- .../messages/tool-renderers/grep-output.ts | 2 +- .../messages/truncated-header-line.ts | 36 ++++++++++++------- apps/kimi-code/src/tui/constant/rendering.ts | 8 +++++ .../tui/components/messages/shell-run.test.ts | 17 +++++++++ .../tui/components/messages/tool-call.test.ts | 34 ++++++++++++++++++ .../messages/tool-renderers/chip.test.ts | 14 ++++++++ .../messages/truncated-header-line.test.ts | 21 +++++++++++ 10 files changed, 138 insertions(+), 18 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts index 193ffdd0890..8a4e3fcff57 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-run.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -84,8 +84,14 @@ export class ShellRunComponent extends Container { hasHiddenContent(): boolean { if (this.disposed || this.backgrounded) return false; if (this.running) return this.runningHidesRows; - return this.children.some( - (child) => child instanceof TruncatedOutputComponent && child.wasTruncated(), + // More physical lines than the collapsed cap is hidden for certain, even + // for a card that finished while already expanded; a wrapped overflow + // shows up once a collapsed render has recorded it. + return ( + this.finalOutput.split('\n').length > SHELL_OUTPUT_PREVIEW_LINES || + this.children.some( + (child) => child instanceof TruncatedOutputComponent && child.wasTruncated(), + ) ); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 1badee6cb15..91b1e4c2c36 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -36,7 +36,7 @@ import { PlanBoxComponent } from './plan-box'; import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; -import { buildGoalToolHeader } from './tool-renderers/goal'; +import { buildGoalToolHeader, parseGoalToolOutput } from './tool-renderers/goal'; import { computeWriteStats } from './tool-renderers/chip'; import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; @@ -839,6 +839,16 @@ export class ToolCallComponent extends Container { return ( args['background'] === true && nonEmptyLines(result.output).length > OUTCOME_MAX_LINES ); + case 'CreateGoal': + case 'GetGoal': + // A parsed goal renders the same fixed snapshot in both states; only an + // unparsable result falls back to the line-count rule. + return ( + parseGoalToolOutput(result.output) === undefined && + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'SetGoalBudget': + case 'UpdateGoal': case 'AgentSwarm': case 'TodoList': case 'EnterPlanMode': diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts index 1b38fd2782c..2b36ed5489a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -160,7 +160,7 @@ function formatGoalToolArgument( } } -function parseGoalToolOutput(output: string): GoalSnapshotView | null | undefined { +export function parseGoalToolOutput(output: string): GoalSnapshotView | null | undefined { const goal = parseGoalValue(output); if (goal === undefined || goal === null) return goal; const objective = stringField(goal, 'objective'); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index fe15bca027c..70036b53a45 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -159,7 +159,7 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr } // Without context flags every paginated row is a match, so the tool's // total is the exact match count; the files beyond the page stay unknown. - const paginatedTotal = countable ? PAGINATION_TOTAL.exec(output)?.[1] : undefined; + const paginatedTotal = hasContext ? undefined : PAGINATION_TOTAL.exec(output)?.[1]; const matches = countable ? (paginatedTotal === undefined ? rows : Number(paginatedTotal)) : null; return { mode, diff --git a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts index 6b6ea75f5be..e22e003af65 100644 --- a/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts +++ b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts @@ -14,7 +14,11 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; -import { TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; +import { + ANSI_ESCAPE_PATTERN, + TAIL_WINDOW_UNITS_PER_CELL, + TRUNCATION_ELLIPSIS, +} from '#/tui/constant/rendering'; export interface HeaderFlex { /** Plain text; `style` is applied after the cut so the ellipsis is styled too. */ @@ -35,11 +39,6 @@ export type HeaderContent = string | HeaderSegments; // hand here: pi-tui's truncateToWidth wraps its ellipsis in a reset sequence, // which would break the caller's styling around it. -// ANSI escape sequences (CSI, OSC) — tool output can carry them — are -// zero-width atomic units: a cut must neither count their bytes toward the -// budget nor split a sequence in half and leak a malformed one. -const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; - interface TextUnit { readonly text: string; readonly width: number; @@ -85,10 +84,13 @@ function keepHead(text: string, width: number): string { /** Keep the end of `text` behind a leading ellipsis, within `width` cells. */ function keepTail(text: string, width: number): string { const budget = width - visibleWidth(TRUNCATION_ELLIPSIS); - // One cell needs at most one code unit of payload; the window adds headroom - // only for zero-width escape sequences, so the segmented slice stays - // bounded by the terminal width instead of the whole argument. - const windowed = text.length > budget + 64 ? text.slice(-(budget + 64)) : text; + // The segmented slice stays bounded by the terminal width instead of the + // whole argument. ZWJ emoji and combining sequences pack many code units + // into one cell, so the window keeps TAIL_WINDOW_UNITS_PER_CELL per cell + // plus headroom for zero-width escape sequences; only sequences denser than + // that lose fitting clusters to the cut. + const window = budget * TAIL_WINDOW_UNITS_PER_CELL + 64; + const windowed = text.length > window ? text.slice(-window) : text; const units = [...textUnits(windowed)]; // The window edge may have split a grapheme or an escape sequence; drop // whatever partial unit it left behind the leading ellipsis. @@ -107,10 +109,18 @@ function keepTail(text: string, width: number): string { return truncated ? `${TRUNCATION_ELLIPSIS}${out}` : out; } +/** Whether `text` fits `width` cells, measured lazily so a huge argument is never walked whole. */ +function fits(text: string, width: number): boolean { + let used = 0; + for (const unit of textUnits(text)) { + used += unit.width; + if (used > width) return false; + } + return true; +} + function fitFlex(flex: HeaderFlex, width: number): string { - // Two cells per code unit is the worst case (wide chars), so beyond twice - // the width a cut is certain and the whole string is never measured. - if (flex.text.length <= width * 2 && visibleWidth(flex.text) <= width) return flex.text; + if (fits(flex.text, width)) return flex.text; return flex.keep === 'tail' ? keepTail(flex.text, width) : keepHead(flex.text, width); } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index 803496c7e6f..2f599a48978 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -26,6 +26,14 @@ export const COMMAND_PREVIEW_LINES = 10; // The ellipsis marking a single-row line (card header, outcome row) that was // cut to the terminal width or that stands in for hidden output lines. export const TRUNCATION_ELLIPSIS = '…'; +// ANSI escape sequences (CSI, OSC) — tool output can carry them — that a +// width-aware cut must treat as zero-width atomic units: never counted toward +// the budget, never split in half. +export const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; +// Code units a single terminal cell may hold before a tail-preserving cut's +// window can no longer see it: a ZWJ family emoji is about eleven per two +// cells, and combining sequences run longer. +export const TAIL_WINDOW_UNITS_PER_CELL = 16; // Left indent of a collapsed tool card's outcome rows, aligning them with // the message-body indent. export const OUTCOME_ROW_INDENT = ' '; diff --git a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts index 8f513f6696b..aec0646a191 100644 --- a/apps/kimi-code/test/tui/components/messages/shell-run.test.ts +++ b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts @@ -209,3 +209,20 @@ describe('ShellRunComponent hasHiddenContent', () => { expect(short.hasHiddenContent()).toBe(false); }); }); + +describe('ShellRunComponent hasHiddenContent when finished while expanded', () => { + let component: ShellRunComponent | undefined; + + afterEach(() => { + component?.dispose(); + component = undefined; + }); + + it('still reports the rows a collapse would hide, without a prior collapsed render', () => { + component = new ShellRunComponent(() => {}); + component.setExpanded(true); + component.finish(Array.from({ length: 20 }, (_, i) => `row ${String(i + 1)}`).join('\n'), '', false); + component.render(100); + expect(component.hasHiddenContent()).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 37b506ea263..e1ebb4f797e 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2472,3 +2472,37 @@ describe('ToolCallComponent hasHiddenContent for a call truncated by max_tokens' component.dispose(); }); }); + +describe('ToolCallComponent hasHiddenContent for goal cards', () => { + it('reports nothing to expand for a parsed goal snapshot or a bodiless goal update', () => { + // The tool wraps the snapshot in a `goal` envelope (null when there is no goal). + const snapshot = JSON.stringify( + { + goal: { + goalId: 'g1', + objective: 'Ship the feature', + status: 'active', + turnsUsed: 3, + tokensUsed: 100, + wallClockMs: 1000, + budget: { tokenBudget: null, turnBudget: null, wallClockBudgetMs: null }, + }, + }, + null, + 2, + ); + const getGoal = new ToolCallComponent( + { id: 'call_get_goal', name: 'GetGoal', args: {} }, + { tool_call_id: 'call_get_goal', output: snapshot, is_error: false }, + ); + expect(getGoal.hasHiddenContent()).toBe(false); + getGoal.dispose(); + + const update = new ToolCallComponent( + { id: 'call_update_goal', name: 'UpdateGoal', args: { status: 'paused' } }, + { tool_call_id: 'call_update_goal', output: snapshot, is_error: false }, + ); + expect(update.hasHiddenContent()).toBe(false); + update.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index c5605b861c5..28444a092d1 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -365,3 +365,17 @@ describe('Grep and Glob chips on an incomplete result set', () => { ).toBe('2+ files'); }); }); + +describe('Grep chip on paginated numbered content with context rows', () => { + it('counts the page rows instead of the pagination total, which includes context rows', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'content', '-C': 1, head_limit: 4 }, + result( + 'src/a.ts-1-import x\nsrc/a.ts:2:foo\nsrc/a.ts-3-export y\n--\nResults truncated to 4 lines (total: 12). Use offset=4 to see more.', + ), + ), + ).toBe('1 match in 1 file'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts index 501f70f5600..de3de515889 100644 --- a/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts +++ b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts @@ -136,3 +136,24 @@ describe('TruncatedHeaderLine', () => { expect(line.wasTruncated()).toBe(false); }); }); + +describe('graphemes that pack many code units into a cell', () => { + // A ZWJ family emoji: 2 cells, 11 UTF-16 code units. + const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'; + + it('never assumes a cut from code-unit length alone', () => { + const text = family.repeat(10); + expect(visibleWidth(text)).toBe(20); + const line = renderHeaderContent({ head: '', flex: { text, keep: 'head' }, tail: '' }, 20); + expect(line).toBe(text); + const tailKept = renderHeaderContent({ head: '', flex: { text, keep: 'tail' }, tail: '' }, 20); + expect(tailKept).toBe(text); + }); + + it('keeps whole emoji clusters at the tail when it does have to cut', () => { + const text = `${'x'.repeat(30)}${family.repeat(5)}`; + const line = renderHeaderContent({ head: '', flex: { text, keep: 'tail' }, tail: '' }, 9); + expect(line).toBe(`…${family.repeat(4)}`); + expect(visibleWidth(line)).toBe(9); + }); +}); From c24562bbe7c59dea88ccf58187ba698a20f920db Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 21:12:08 +0800 Subject: [PATCH 12/17] fix(kimi-code): read zero context flags as none and mirror the Edit cap exactly A Grep call with -A/-B/-C set to zero produces no context rows, so its matches stay countable; the Edit hidden-content check now renders the preview capped and uncapped and compares them, so a body that fills the cap exactly no longer counts as cut. --- .../src/tui/components/messages/tool-call.ts | 17 ++++++++------ .../messages/tool-renderers/grep-output.ts | 9 ++++---- .../tui/components/messages/tool-call.test.ts | 22 +++++++++++++++++++ .../messages/tool-renderers/chip.test.ts | 12 ++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 91b1e4c2c36..9adbcfa46a3 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -873,14 +873,17 @@ export class ToolCallComponent extends Container { const oldStr = str(args['old_string']); const newStr = str(args['new_string']); if (oldStr.length === 0 && newStr.length === 0) return false; - // Mirror buildCallPreview exactly: context rows and inter-hunk - // separators also consume the preview cap, so counting only - // added/removed rows undercounts changes in distant hunks. + // Mirror buildCallPreview exactly by rendering both ways: the cap + // applies to body rows at cluster boundaries under a header row, so + // the capped render differs from the full one only when it cut rows + // (its trailer then replaces them). const filePath = str(args['file_path'] ?? args['path']); - return ( - renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3 }).length > - COMMAND_PREVIEW_LINES - ); + const full = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3 }); + const capped = renderDiffLinesClustered(oldStr, newStr, filePath, { + contextLines: 3, + maxLines: COMMAND_PREVIEW_LINES, + }); + return capped.length !== full.length || capped.at(-1) !== full.at(-1); } case 'Write': return computeWriteStats(args).lines > COMMAND_PREVIEW_LINES; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index 70036b53a45..a41d062ab45 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -131,10 +131,11 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr // (`-A`/`-B`/`-C`) look exactly the same — the backend separates fields // with ':' unconditionally — so an exact match count is unknowable then. const numbered = toolCall.args['-n'] !== false; - const hasContext = - toolCall.args['-A'] !== undefined || - toolCall.args['-B'] !== undefined || - toolCall.args['-C'] !== undefined; + // The schema allows zero, which asks for no context rows at all. + const hasContext = ['-A', '-B', '-C'].some((flag) => { + const value = toolCall.args[flag]; + return typeof value === 'number' && value > 0; + }); const countable = numbered || !hasContext; const entries: GrepEntry[] = []; const paths = new Set(); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index e1ebb4f797e..0b4978b1ef0 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2506,3 +2506,25 @@ describe('ToolCallComponent hasHiddenContent for goal cards', () => { update.dispose(); }); }); + +describe('ToolCallComponent hasHiddenContent at the Edit preview cap', () => { + function editCard(lineCount: number): ToolCallComponent { + const oldStr = Array.from({ length: lineCount }, (_, i) => `old ${String(i + 1)}`).join('\n'); + const newStr = Array.from({ length: lineCount }, (_, i) => `new ${String(i + 1)}`).join('\n'); + return new ToolCallComponent( + { id: 'call_edit', name: 'Edit', args: { path: 'a.ts', old_string: oldStr, new_string: newStr } }, + { tool_call_id: 'call_edit', output: 'Edited a.ts', is_error: false }, + ); + } + + it('is false when the body fills the cap exactly, since the header row is not capped', () => { + // 5 replaced lines render as 5 deletions plus 5 additions: 10 body rows. + const exact = editCard(5); + expect(exact.hasHiddenContent()).toBe(false); + exact.dispose(); + // 6 replaced lines are 12 body rows: the capped preview cuts two of them. + const over = editCard(6); + expect(over.hasHiddenContent()).toBe(true); + over.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 28444a092d1..29f27c399f3 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -379,3 +379,15 @@ describe('Grep chip on paginated numbered content with context rows', () => { ).toBe('1 match in 1 file'); }); }); + +describe('Grep chip with a zero-valued context flag', () => { + it('keeps the exact match count, since -C 0 asks for no context rows', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'content', '-n': false, '-C': 0 }, + result('src/a.ts:foo\nsrc/a.ts:foo again\nsrc/b.ts:foo'), + ), + ).toBe('3 matches across 2 files'); + }); +}); From b5c0edffaba71ec12cec507f0bcd509823a5f097 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 21:31:59 +0800 Subject: [PATCH 13/17] fix(kimi-code): keep the collapse hint for expanded cards outside the window Toggling ctrl+o off collapses every expanded card, including one that slid before the three-turn cutoff since it was expanded, so the footer now keeps offering collapse while any expanded card hides content. Grep context detection follows the backend's -C precedence, where a defined -C makes -A and -B moot. --- .../src/tui/components/messages/read-group.ts | 4 ++++ .../src/tui/components/messages/shell-run.ts | 4 ++++ .../src/tui/components/messages/tool-call.ts | 5 +++++ .../messages/tool-renderers/grep-output.ts | 9 +++++--- apps/kimi-code/src/tui/kimi-tui.ts | 19 ++++++++++++++-- .../src/tui/utils/component-capabilities.ts | 11 ++++++++++ .../messages/tool-renderers/chip.test.ts | 12 ++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 22 +++++++++++++++++++ 8 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 9fab3ab946a..0861579617f 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -71,6 +71,10 @@ export class ReadGroupComponent extends Container { return this.entries.length > 0; } + isExpanded(): boolean { + return this.expanded; + } + /** * Borrows a standalone `ToolCallComponent` into the group as a hidden state * container. Snapshot changes trigger throttled refreshes. Re-attaching the diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts index 8a4e3fcff57..726a6deaaeb 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-run.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -81,6 +81,10 @@ export class ShellRunComponent extends Container { * (or a capped buffer) behind it, or a finished preview cut to its row cap. * Drives the footer's ctrl+o hint. */ + isExpanded(): boolean { + return this.expanded; + } + hasHiddenContent(): boolean { if (this.disposed || this.backgrounded) return false; if (this.running) return this.runningHidesRows; diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 9adbcfa46a3..cd45da3806c 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -799,6 +799,11 @@ export class ToolCallComponent extends Container { return this.hiddenContent || this.truncatedAtLastRender; } + /** Whether the global ctrl+o toggle currently has this card expanded. */ + isExpanded(): boolean { + return this.expanded; + } + private computeHiddenContent(): boolean { const { name, args } = this.toolCall; // A solo Agent card with subagent state never renders its result body and diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index a41d062ab45..be9aeb53e81 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -131,11 +131,14 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr // (`-A`/`-B`/`-C`) look exactly the same — the backend separates fields // with ':' unconditionally — so an exact match count is unknowable then. const numbered = toolCall.args['-n'] !== false; - // The schema allows zero, which asks for no context rows at all. - const hasContext = ['-A', '-B', '-C'].some((flag) => { + // The schema allows zero, which asks for no context rows at all, and a + // defined `-C` makes the backend drop `-A`/`-B` entirely. + const positive = (flag: string): boolean => { const value = toolCall.args[flag]; return typeof value === 'number' && value > 0; - }); + }; + const hasContext = + typeof toolCall.args['-C'] === 'number' ? positive('-C') : positive('-A') || positive('-B'); const countable = numbered || !hasContext; const entries: GrepEntry[] = []; const paths = new Set(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 8a1c72764d0..e1c274bc2bc 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -155,7 +155,12 @@ import { type TUIStartupOptions, type TUIStartupState, } from './types'; -import { hasDispose, hasHiddenContent, isExpandable } from './utils/component-capabilities'; +import { + hasDispose, + hasHiddenContent, + isExpandable, + isExpandedComponent, +} from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; @@ -3477,9 +3482,19 @@ export class KimiTUI { */ private toolOutputExpandHint(): 'expand' | 'collapse' | null { const children = this.state.transcriptContainer.children; + if (this.state.toolOutputExpanded) { + // Toggling off collapses every expanded card, including one that slid + // out of the expansion window since it was expanded, so any expanded + // card with hidden content keeps the collapse hint on. + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (isExpandedComponent(child) && hasHiddenContent(child)) return 'collapse'; + } + return null; + } const cutoff = this.expandCutoff(children); for (let i = children.length - 1; i >= cutoff; i--) { - if (hasHiddenContent(children[i])) return this.state.toolOutputExpanded ? 'collapse' : 'expand'; + if (hasHiddenContent(children[i])) return 'expand'; } return null; } diff --git a/apps/kimi-code/src/tui/utils/component-capabilities.ts b/apps/kimi-code/src/tui/utils/component-capabilities.ts index 67d4a9674cf..810f08cae9d 100644 --- a/apps/kimi-code/src/tui/utils/component-capabilities.ts +++ b/apps/kimi-code/src/tui/utils/component-capabilities.ts @@ -9,6 +9,7 @@ export interface Expandable { */ export interface HidesContent extends Expandable { hasHiddenContent(): boolean; + isExpanded(): boolean; } export interface Disposable { @@ -33,6 +34,16 @@ export function hasHiddenContent(obj: unknown): boolean { ); } +/** Whether an expandable component currently shows its expanded form. */ +export function isExpandedComponent(obj: unknown): boolean { + return ( + isExpandable(obj) && + 'isExpanded' in obj && + typeof (obj as HidesContent).isExpanded === 'function' && + (obj as HidesContent).isExpanded() + ); +} + export function hasDispose(value: unknown): value is Disposable { return ( typeof value === 'object' && diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 29f27c399f3..87822db3b4d 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -391,3 +391,15 @@ describe('Grep chip with a zero-valued context flag', () => { ).toBe('3 matches across 2 files'); }); }); + +describe('Grep chip when -C overrides -A/-B', () => { + it('follows the effective flag, since a defined -C makes the backend drop -A and -B', () => { + expect( + chipFor( + 'Grep', + { pattern: 'foo', output_mode: 'content', '-n': false, '-C': 0, '-A': 2 }, + result('src/a.ts:foo\nsrc/a.ts:foo again\nsrc/b.ts:foo'), + ), + ).toBe('3 matches across 2 files'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index c2d084338ba..8d8002cb002 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8698,6 +8698,28 @@ describe('footer ctrl+o hint', () => { emitBashResult(driver, 'call_bash', ['line1', 'line2', 'line3'].join('\n')); expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); }); + + it('keeps the collapse hint for an expanded card that slid out of the expansion window', async () => { + const { driver } = await makeDriver(); + emitBashResult(driver, 'call_bash', ['line1', 'line2', 'line3', 'line4', 'Tests 5 passed'].join('\n')); + driver.toggleToolOutputExpansion(); + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + + // Four later user turns move the expanded card before the three-turn + // cutoff; nothing collapses it, and ctrl+o would still visibly collapse it. + for (let i = 0; i < 4; i++) { + driver.appendTranscriptEntry({ + id: `later-${String(i)}`, + kind: 'user', + renderMode: 'plain', + content: `next ${String(i)}`, + }); + } + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + + driver.toggleToolOutputExpansion(); + expect(renderFooterLine1(driver)).not.toContain('ctrl+o'); + }); }); describe('KimiTUI session rating survey', () => { From 481944d5eceb252a50cf150e278b00fc2b45168f Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 21:50:01 +0800 Subject: [PATCH 14/17] fix(kimi-code): render Edit and Write results the same way in both states Their one-line success acknowledgements repeat what the header, chip and preview already show, so they render in neither state; any other successful output shows as an outcome row in both. The footer's expand hint therefore depends only on the capped preview for these cards. --- .../src/tui/components/messages/tool-call.ts | 5 ++++ .../messages/tool-renderers/registry.ts | 7 ++--- .../messages/tool-renderers/summary.ts | 16 ++++++++-- .../messages/tool-renderers/registry.test.ts | 29 ++++++++++++++++++- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index cd45da3806c..3187227539b 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -852,6 +852,11 @@ export class ToolCallComponent extends Container { parseGoalToolOutput(result.output) === undefined && nonEmptyLines(result.output).length > OUTCOME_MAX_LINES ); + case 'Edit': + case 'Write': + // The result body renders the same way in both states (the call + // preview is checked above), so only the preview can hide content. + return false; case 'SetGoalBudget': case 'UpdateGoal': case 'AgentSwarm': diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts index 2bbeb866379..1da165e2daa 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -16,14 +16,13 @@ import { shellExecutionResultRenderer } from '../shell-execution'; import { goalSummary } from './goal'; import { waitForSummary } from './wait-for'; import { - editSummary, fetchSummary, + fileChangeSummary, globSummary, grepSummary, readSummary, thinkSummary, webSearchSummary, - writeSummary, } from './summary'; import { renderTruncated } from './truncated'; import type { ResultRenderer } from './types'; @@ -57,9 +56,9 @@ export function pickResultRenderer(toolName: string): ResultRenderer { case 'Think': return thinkSummary; case 'Edit': - return editSummary; + return fileChangeSummary; case 'Write': - return writeSummary; + return fileChangeSummary; case 'CreateGoal': case 'GetGoal': case 'SetGoalBudget': diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 8c432d94f00..ba260b45992 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -16,7 +16,7 @@ import { OUTCOME_GLANCE_SAMPLES, OUTCOME_ROW_INDENT } from '#/tui/constant/rende import { currentTheme } from '#/tui/theme'; import { parseGlobOutput, parseGrepOutput } from './grep-output'; -import { outcomeRow } from './outcome'; +import { outcomeRow, outcomeRows } from './outcome'; import { renderTruncated } from './truncated'; import { isSpilledToolOutput, type ResultRenderer } from './types'; @@ -87,8 +87,18 @@ export const readSummary: ResultRenderer = withGlance(null); export const fetchSummary: ResultRenderer = withGlance(null); export const webSearchSummary: ResultRenderer = withGlance(null); export const thinkSummary: ResultRenderer = withGlance(null); -export const editSummary: ResultRenderer = withGlance(null); -export const writeSummary: ResultRenderer = withGlance(null); + +// Edit and Write acknowledge success with one line the card already tells +// (`Replaced N occurrences in path`, `Wrote N bytes to path`): the header +// carries the path, the chip the size, and the call preview the change. Any +// other successful output (`No changes to make…`) is worth a row, shown the +// same way in both states so ctrl+o has nothing to add. +const FILE_CHANGE_ACK = /^(?:Replaced \d+ occurrences? in |(?:Wrote|Appended) \d+ bytes to )/; +export const fileChangeSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + if (FILE_CHANGE_ACK.test(result.output)) return []; + return outcomeRows(result.output, 'first'); +}; // Tools that benefit from inline path samples below the chip. export const grepSummary: ResultRenderer = withGlance(grepGlance); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 1f0277ed9ae..6f9e4cda678 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -257,7 +257,7 @@ describe('tool-result registry', () => { it('Write renders no body when collapsed', () => { const renderer = pickResultRenderer('Write'); const out = joinRender( - renderer(call('Write', { path: 'a.txt', content: 'a\nb\n' }), result('Wrote'), ctx), + renderer(call('Write', { path: 'a.txt', content: 'a\nb\n' }), result('Wrote 4 bytes to a.txt'), ctx), ); expect(out.trim()).toBe(''); }); @@ -568,3 +568,30 @@ describe('Grep glance on a paginated content result', () => { expect(out).toBe(' src/a.ts:1, src/a.ts:9, src/b.ts:2, +997 more'); }); }); + +describe('Edit and Write results render the same way in both states', () => { + it('drops the success acknowledgement even when expanded', () => { + const renderer = pickResultRenderer('Edit'); + const out = joinRender( + renderer( + call('Edit', { path: 'foo.ts', old_string: 'a', new_string: 'b' }), + result('Replaced 1 occurrence in foo.ts'), + expandedCtx, + ), + ); + expect(out.trim()).toBe(''); + const write = pickResultRenderer('Write'); + expect( + joinRender(write(call('Write', { path: 'a.txt', content: 'a' }), result('Appended 1 bytes to a.txt'), expandedCtx)).trim(), + ).toBe(''); + }); + + it('keeps any other successful output as an outcome row in both states', () => { + const renderer = pickResultRenderer('Edit'); + const output = 'No changes to make: old_string and new_string are exactly the same.'; + const collapsed = strip(joinRender(renderer(call('Edit', { path: 'foo.ts' }), result(output), ctx))); + const expanded = strip(joinRender(renderer(call('Edit', { path: 'foo.ts' }), result(output), expandedCtx))); + expect(collapsed).toBe(` ${output}`); + expect(expanded).toBe(collapsed); + }); +}); From 8c8a170a4f585b4e7f9afd0acc247af47ff8518e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 22:09:29 +0800 Subject: [PATCH 15/17] fix(kimi-code): keep a Read group's failure count on narrow rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finished header is three segments — label, line count, `· N failed` tail — so a narrow row drops the line count before the failure count, which is the only sign of partial failure while the per-file body is collapsed. --- .../src/tui/components/messages/read-group.ts | 29 ++++++++++++++----- .../components/messages/read-group.test.ts | 23 +++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 0861579617f..11104a9545f 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -28,9 +28,12 @@ import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; -import { TruncatedHeaderLine } from './truncated-header-line'; +import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line'; const THROTTLE_MS = 200; +// One shared reference: the header line compares segment styles by identity +// to keep its render cache across rebuilds; the palette is read at call time. +const dimHeaderStyle = (text: string): string => currentTheme.dim(text); interface ReadEntry { readonly toolCallId: string; @@ -151,9 +154,12 @@ export class ReadGroupComponent extends Container { this.ui?.requestRender(); } - private buildHeader(total: number, pending: number, failed: number, totalLines: number): string { - const dim = (text: string): string => currentTheme.dim(text); - + private buildHeader( + total: number, + pending: number, + failed: number, + totalLines: number, + ): HeaderContent { if (pending > 0) { const bullet = currentTheme.fg('text', STATUS_BULLET); const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`); @@ -167,11 +173,20 @@ export class ReadGroupComponent extends Container { return `${bullet}${label}${currentTheme.fg('error', ' · failed')}`; } + // Three segments so a narrow row drops the line count before the failure + // count: with the per-file body hidden while collapsed, that tail is the + // only sign that some of the reads failed. const bullet = currentTheme.fg('success', STATUS_BULLET); const label = currentTheme.boldFg('primary', `Read ${String(total)} files`); - const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); - const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : ''; - return `${bullet}${label}${linesPart}${failPart}`; + return { + head: `${bullet}${label}`, + flex: { + text: ` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`, + style: dimHeaderStyle, + keep: 'head', + }, + tail: failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : '', + }; } private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { diff --git a/apps/kimi-code/test/tui/components/messages/read-group.test.ts b/apps/kimi-code/test/tui/components/messages/read-group.test.ts index 9cace522c4e..be8ed06ebd2 100644 --- a/apps/kimi-code/test/tui/components/messages/read-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/read-group.test.ts @@ -66,3 +66,26 @@ describe('ReadGroupComponent hasHiddenContent', () => { expect(makeGroup().hasHiddenContent()).toBe(true); }); }); + +describe('ReadGroupComponent header on a narrow terminal', () => { + function failedRead(id: string, path: string): ToolCallComponent { + return new ToolCallComponent( + { id, name: 'Read', args: { path } }, + { tool_call_id: id, output: 'ENOENT: no such file or directory', is_error: true }, + ); + } + + it('keeps the failure count visible when the row is cut', () => { + const group = new ReadGroupComponent(undefined); + group.attach('ok', readCall('ok', 'src/very/deeply/nested/directory/alpha-component.ts', 120)); + group.attach('bad', failedRead('bad', 'src/very/deeply/nested/directory/missing.ts')); + + const wide = rows(group, 120); + expect(wide[0]).toContain('Read 2 files · 120 lines · 1 failed'); + + const narrow = rows(group, 26); + expect(visibleWidth(narrow[0]!)).toBeLessThanOrEqual(26); + expect(narrow[0]!.endsWith('1 failed')).toBe(true); + expect(narrow[0]).not.toContain('lines'); + }); +}); From 261a440848b0efd99bfbc15e4ca662fbd270b172 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 23:40:01 +0800 Subject: [PATCH 16/17] fix(kimi-code): show cut-short empty searches and keep the hint with a status command A Grep or Glob the tool cut short before any row renders its notice instead of an exact-looking empty result and carries no chip, a ReadMediaFile result that is not a media envelope follows the line-count rule, and the footer's ctrl+o hint moves to line 2 when a status_line.command owns line 1. --- .../src/tui/components/chrome/footer.ts | 22 +++++++++++++---- .../src/tui/components/messages/tool-call.ts | 9 ++++++- .../messages/tool-renderers/chip.ts | 6 +++-- .../messages/tool-renderers/summary.ts | 13 ++++++++-- .../test/tui/components/chrome/footer.test.ts | 19 +++++++++++++++ .../tui/components/messages/tool-call.test.ts | 24 +++++++++++++++++++ .../messages/tool-renderers/chip.test.ts | 21 ++++++++++++++++ .../messages/tool-renderers/registry.test.ts | 16 +++++++++++++ 8 files changed, 120 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 725a672ebf2..44ab23c1dec 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -370,21 +370,33 @@ export class FooterComponent implements Component { ' '.repeat(pad) + chalk.hex(colors.text)(contextText); } else { - const leftPad = Math.max(0, width - contextWidth); - line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + // A status_line.command owns line 1 outright, so the ctrl+o hint moves + // down here; the transient and warning hints above take precedence. + const shortcut = customLine !== null ? this.expandShortcut() : null; + const left = + shortcut !== null && visibleWidth(shortcut) + 1 + contextWidth <= width + ? chalk.hex(colors.textDim)(shortcut) + : ''; + const leftPad = Math.max(0, width - visibleWidth(left) - contextWidth); + line2 = left + ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); } return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; } /** The fixed ctrl+o hint plus the first rotating tip that still fits beside it. */ - private buildRightText(tips: readonly string[], remaining: number, colors: ColorPalette): string { + /** `ctrl+o expand` / `ctrl+o collapse`, or null when there is nothing to toggle. */ + private expandShortcut(): string | null { const hint = this.expandHintProvider?.() ?? null; - if (hint === null) { + return hint === null ? null : `ctrl+o ${hint}`; + } + + private buildRightText(tips: readonly string[], remaining: number, colors: ColorPalette): string { + const shortcut = this.expandShortcut(); + if (shortcut === null) { const tip = tips.find((candidate) => visibleWidth(candidate) <= remaining); return tip === undefined ? '' : chalk.hex(colors.textMuted)(tip); } - const shortcut = `ctrl+o ${hint}`; for (const tip of tips) { if (visibleWidth(`${shortcut}${TIP_SEPARATOR}${tip}`) <= remaining) { return ( diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 3187227539b..8a0d05a276b 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -37,6 +37,7 @@ import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader, parseGoalToolOutput } from './tool-renderers/goal'; +import { parseReadMediaOutput } from './tool-renderers/media'; import { computeWriteStats } from './tool-renderers/chip'; import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; @@ -820,8 +821,14 @@ export class ToolCallComponent extends Container { if (result.output.trimStart().startsWith('')) return false; if (result.is_error === true) return nonEmptyLines(result.output).length > RESULT_PREVIEW_LINES; switch (name) { - case 'Read': case 'ReadMediaFile': + // A media envelope renders its body only when expanded; anything else + // falls back to the generic renderer and its line-count rule. + return ( + parseReadMediaOutput(result.output) !== null || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); + case 'Read': case 'FetchURL': case 'WebSearch': case 'Think': diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index 27bd92e576b..d6c2257fd4a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -110,7 +110,9 @@ const bashChip: ChipProvider = (_toolCall, result) => { const grepChip: ChipProvider = (toolCall, result) => { const stats = parseGrepOutput(toolCall, result.output); // A paginated count-mode page past the last row still carries the totals. - if (stats.files === 0) return 'no matches'; + // A search the tool cut short before any row is not an empty result; the + // glance shows the notice instead and the chip stays out of its way. + if (stats.files === 0) return stats.partial ? '' : 'no matches'; if (stats.mode === 'files_with_matches') return pluralize(stats.files, 'file', undefined, stats.partial); if (stats.matches === null) return pluralize(stats.files, 'file', undefined, stats.partial); const matches = pluralize(stats.matches, 'match', 'matches', stats.partial); @@ -123,7 +125,7 @@ const grepChip: ChipProvider = (toolCall, result) => { const globChip: ChipProvider = (_toolCall, result) => { const { entries, partial } = parseGlobOutput(result.output); - if (entries.length === 0) return 'no files'; + if (entries.length === 0) return partial ? '' : 'no files'; return pluralize(entries.length, 'file', undefined, partial); }; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index ba260b45992..8a40d203c6e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -25,10 +25,13 @@ interface Glance { readonly moreCount: number; } +// `'fallback'` hands the result to the generic renderer: a search the tool cut +// short before any row is only its notice, which beats an exact-looking +// empty glance. type GlanceFn = ( toolCall: Parameters[0], result: Parameters[1], -) => Glance | null; +) => Glance | null | 'fallback'; function withGlance(glance: GlanceFn | null): ResultRenderer { return (toolCall, result, ctx) => { @@ -45,6 +48,7 @@ function withGlance(glance: GlanceFn | null): ResultRenderer { // the raw output. if (glance !== null) { const parts = glance(toolCall, result); + if (parts === 'fallback') return renderTruncated(toolCall, result, ctx); if (parts !== null) { const tail = parts.moreCount > 0 ? `, +${String(parts.moreCount)} more` : ''; out.push( @@ -73,11 +77,16 @@ function sampleList(labels: readonly string[], total = labels.length): Glance | // not just the page. const grepGlance: GlanceFn = (toolCall, result) => { const stats = parseGrepOutput(toolCall, result.output); + if (stats.partial && stats.entries.length === 0) return 'fallback'; const labels = stats.entries.map((entry) => entry.label); return sampleList(labels, Math.max(labels.length, stats.total)); }; -const globGlance: GlanceFn = (_toolCall, result) => sampleList(parseGlobOutput(result.output).entries); +const globGlance: GlanceFn = (_toolCall, result) => { + const { entries, partial } = parseGlobOutput(result.output); + if (partial && entries.length === 0) return 'fallback'; + return sampleList(entries); +}; // ── Exports ────────────────────────────────────────────────────────── diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 59ee331ba85..ad5304bd326 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -275,3 +275,22 @@ describe('FooterComponent ctrl+o hint', () => { footer.dispose(); }); }); + +describe('FooterComponent ctrl+o hint with a status_line command', () => { + it('moves the hint to line 2 when a command owns line 1', async () => { + const footer = new FooterComponent({ + ...appState, + statusLine: { items: null, command: 'printf "my-custom-status"' }, + }); + footer.setExpandHintProvider(() => 'expand'); + footer.render(120); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const [line1, line2] = footer.render(120).map((line) => line.replaceAll(/\[[0-9;]*m/g, '')); + expect(line1).toContain('my-custom-status'); + expect(line1).not.toContain('ctrl+o'); + expect(line2).toContain('ctrl+o expand'); + expect(line2).toContain('context:'); + footer.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 0b4978b1ef0..1cf780f6459 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2528,3 +2528,27 @@ describe('ToolCallComponent hasHiddenContent at the Edit preview cap', () => { over.dispose(); }); }); + +describe('ToolCallComponent hasHiddenContent for ReadMediaFile', () => { + function mediaCard(output: string): ToolCallComponent { + return new ToolCallComponent( + { id: 'call_media', name: 'ReadMediaFile', args: { path: '/tmp/a.png' } }, + { tool_call_id: 'call_media', output, is_error: false }, + ); + } + + it('is true for a media envelope and follows the line-count rule for anything else', () => { + const envelope = JSON.stringify([ + { type: 'text', text: '' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,iVBORw0KGgo=' } }, + { type: 'text', text: '' }, + ]); + const media = mediaCard(envelope); + expect(media.hasHiddenContent()).toBe(true); + media.dispose(); + + const plain = mediaCard('unsupported format\nfalling back to text'); + expect(plain.hasHiddenContent()).toBe(false); + plain.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 87822db3b4d..2e382730e3a 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -403,3 +403,24 @@ describe('Grep chip when -C overrides -A/-B', () => { ).toBe('3 matches across 2 files'); }); }); + +describe('chips for a search the tool cut short before any row', () => { + it('stay silent so the notice row is not contradicted by an exact-looking count', () => { + expect( + chipFor( + 'Glob', + { pattern: '**/*.ts' }, + result('Glob timed out after 60s; partial results returned.'), + ), + ).toBe(''); + expect( + chipFor( + 'Grep', + { pattern: 'foo' }, + result( + '[Output truncated at 1048576 bytes of rg output — the result set is incomplete. Narrow the pattern, path, or glob filters and re-run to recover complete results.]', + ), + ), + ).toBe(''); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 6f9e4cda678..773c22c6455 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -595,3 +595,19 @@ describe('Edit and Write results render the same way in both states', () => { expect(expanded).toBe(collapsed); }); }); + +describe('a search the tool cut short before any row', () => { + it('shows the Glob timeout notice instead of an exact-looking empty result', () => { + const renderer = pickResultRenderer('Glob'); + const out = strip( + joinRender( + renderer( + call('Glob', { pattern: '**/*.ts' }), + result('Glob timed out after 60s; partial results returned.'), + ctx, + ), + ), + ); + expect(out).toBe(' Glob timed out after 60s; partial results returned.'); + }); +}); From 341ebbb3b50f9bda311d18d030983e352f6dabfd Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 5 Sep 2026 00:20:18 +0800 Subject: [PATCH 17/17] fix(kimi-code): count unreadable directories as an incomplete Glob and stop hinting on notice-only searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Glob that skipped unreadable directories reports its file count as a lower bound, and a search cut short before any row — which renders only the tool's notice, the same way in both states — no longer makes the footer offer ctrl+o. --- .../src/tui/components/messages/tool-call.ts | 11 +++++++++-- .../messages/tool-renderers/grep-output.ts | 16 +++++++++++++++- .../messages/tool-renderers/summary.ts | 11 +++++------ .../tui/components/messages/tool-call.test.ts | 18 ++++++++++++++++++ .../messages/tool-renderers/chip.test.ts | 14 +++++++++++++- 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 8a0d05a276b..fc22686b601 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -37,6 +37,7 @@ import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader, parseGoalToolOutput } from './tool-renderers/goal'; +import { searchCutShort } from './tool-renderers/grep-output'; import { parseReadMediaOutput } from './tool-renderers/media'; import { computeWriteStats } from './tool-renderers/chip'; import { nonEmptyLines, outcomeLine } from './tool-renderers/outcome'; @@ -828,12 +829,18 @@ export class ToolCallComponent extends Container { parseReadMediaOutput(result.output) !== null || nonEmptyLines(result.output).length > OUTCOME_MAX_LINES ); + case 'Grep': + case 'Glob': + // A search cut short before any row shows only the tool's notice, the + // same way in both states; every other result hides its body. + return ( + !searchCutShort(this.toolCall, result.output) || + nonEmptyLines(result.output).length > OUTCOME_MAX_LINES + ); case 'Read': case 'FetchURL': case 'WebSearch': case 'Think': - case 'Grep': - case 'Glob': return true; case 'ExitPlanMode': // An approved plan is fully rendered by the call preview and the diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts index be9aeb53e81..4877d6e816d 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -62,7 +62,7 @@ const COUNT_SUMMARY = /^Found (\d+) total (?:non-sensitive )?occurrences? across const PAGINATION_TOTAL = /^Results truncated to \d+ lines \(total: (\d+)/m; // Notices that mark the result set itself as incomplete, as opposed to merely paginated. const INCOMPLETE = - /^(?:\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |\[stdout truncated at |\[Truncated at \d+ matches|Only the first \d+ matches)/m; + /^(?:\[Output truncated at \d+ bytes|Grep timed out after |Glob timed out after |Glob completed with warnings|\[stdout truncated at |\[Truncated at \d+ matches|Only the first \d+ matches)/m; // `path:line:text`; context lines use `-` separators and are not matches. const CONTENT_MATCH = /^(.+?):(\d+):/; @@ -179,3 +179,17 @@ export function parseGrepOutput(toolCall: ToolCallBlockData, output: string): Gr export function parseGlobOutput(output: string): GlobStats { return { entries: resultLines(output), partial: INCOMPLETE.test(output) }; } + +/** + * Whether a Grep or Glob result is only the tool's notice: the search was cut + * short (timeout, output cap, unreadable directories) before any row. Such a + * card shows the notice as a plain outcome row, the same way in both states. + */ +export function searchCutShort(toolCall: ToolCallBlockData, output: string): boolean { + if (toolCall.name === 'Glob') { + const { entries, partial } = parseGlobOutput(output); + return partial && entries.length === 0; + } + const stats = parseGrepOutput(toolCall, output); + return stats.partial && stats.entries.length === 0; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index 8a40d203c6e..dd0f45c4cf2 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -15,7 +15,7 @@ import { Text } from '@moonshot-ai/pi-tui'; import { OUTCOME_GLANCE_SAMPLES, OUTCOME_ROW_INDENT } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; -import { parseGlobOutput, parseGrepOutput } from './grep-output'; +import { parseGlobOutput, parseGrepOutput, searchCutShort } from './grep-output'; import { outcomeRow, outcomeRows } from './outcome'; import { renderTruncated } from './truncated'; import { isSpilledToolOutput, type ResultRenderer } from './types'; @@ -76,16 +76,15 @@ function sampleList(labels: readonly string[], total = labels.length): Glance | // out. A paginated result counts "+N more" against the tool-reported total, // not just the page. const grepGlance: GlanceFn = (toolCall, result) => { + if (searchCutShort(toolCall, result.output)) return 'fallback'; const stats = parseGrepOutput(toolCall, result.output); - if (stats.partial && stats.entries.length === 0) return 'fallback'; const labels = stats.entries.map((entry) => entry.label); return sampleList(labels, Math.max(labels.length, stats.total)); }; -const globGlance: GlanceFn = (_toolCall, result) => { - const { entries, partial } = parseGlobOutput(result.output); - if (partial && entries.length === 0) return 'fallback'; - return sampleList(entries); +const globGlance: GlanceFn = (toolCall, result) => { + if (searchCutShort(toolCall, result.output)) return 'fallback'; + return sampleList(parseGlobOutput(result.output).entries); }; // ── Exports ────────────────────────────────────────────────────────── diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 1cf780f6459..16814973ed8 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2552,3 +2552,21 @@ describe('ToolCallComponent hasHiddenContent for ReadMediaFile', () => { plain.dispose(); }); }); + +describe('ToolCallComponent hasHiddenContent for a search cut short before any row', () => { + it('is false, since the notice renders the same way in both states', () => { + const glob = new ToolCallComponent( + { id: 'call_glob', name: 'Glob', args: { pattern: '**/*.ts' } }, + { tool_call_id: 'call_glob', output: 'Glob timed out after 60s; partial results returned.', is_error: false }, + ); + expect(glob.hasHiddenContent()).toBe(false); + glob.dispose(); + + const grep = new ToolCallComponent( + { id: 'call_grep', name: 'Grep', args: { pattern: 'foo' } }, + { tool_call_id: 'call_grep', output: 'a.ts\nGrep timed out after 30s; partial results returned.', is_error: false }, + ); + expect(grep.hasHiddenContent()).toBe(true); + grep.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 2e382730e3a..9c0ee355904 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -283,7 +283,7 @@ describe('Grep chip on paginated and unusual output', () => { 'Glob completed with warnings; some directories could not be read: rg: /x: Permission denied (os error 13)\nrg: /y: Permission denied (os error 13)\na.ts\nb.ts', ), ), - ).toBe('2 files'); + ).toBe('2+ files'); // unreadable directories make the count a lower bound }); }); @@ -424,3 +424,15 @@ describe('chips for a search the tool cut short before any row', () => { ).toBe(''); }); }); + +describe('Glob chip with unreadable directories', () => { + it('reads as a lower bound, since part of the tree was skipped', () => { + expect( + chipFor( + 'Glob', + { pattern: '**/*.ts' }, + result('Glob completed with warnings; some directories could not be read: rg: /x: Permission denied\na.ts\nb.ts'), + ), + ).toBe('2+ files'); + }); +});