diff --git a/.changeset/compact-tool-cards.md b/.changeset/compact-tool-cards.md new file mode 100644 index 00000000000..563c0a78627 --- /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 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/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index ae39f40231e..44ab23c1dec 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 { @@ -358,13 +370,43 @@ 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. */ + /** `ctrl+o expand` / `ctrl+o collapse`, or null when there is nothing to toggle. */ + private expandShortcut(): string | null { + const hint = this.expandHintProvider?.() ?? 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); + } + 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 141562e4c05..11104a9545f 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,8 +28,12 @@ import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; +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; @@ -37,16 +42,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 +62,22 @@ 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(); + } + + /** The per-file bodies only render while expanded, so any attached Read is hidden content. */ + hasHiddenContent(): boolean { + 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 @@ -112,13 +134,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) => { @@ -130,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…`); @@ -146,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/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index cb6f95dcdec..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,8 @@ 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'; export interface ShellExecutionOptions { @@ -19,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; } @@ -33,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); } } @@ -63,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; @@ -72,26 +63,47 @@ 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', }), ); } + + /** 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 = ( _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, -): Component[] => [ +): 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 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; + // 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) { + 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 // 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/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts index 88c02c19e5f..726a6deaaeb 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,29 @@ 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. + */ + isExpanded(): boolean { + return this.expanded; + } + + hasHiddenContent(): boolean { + if (this.disposed || this.backgrounded) return false; + if (this.running) return this.runningHidesRows; + // 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(), + ) + ); + } + finishBackgrounded(): void { if (this.disposed || !this.running) return; this.running = false; @@ -155,6 +180,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 449b2c9abf7..fc22686b601 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'; @@ -32,9 +33,16 @@ 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 { 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'; +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'; @@ -49,6 +57,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 +412,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 +480,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 +490,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; @@ -540,6 +578,13 @@ export class ToolCallComponent extends Container { private expanded = false; private toolCall: ToolCallBlockData; private readonly markdownTheme = createMarkdownTheme(); + /** + * 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 collapsed render. */ + private truncatedAtLastRender = false; private result: ToolResultBlockData | undefined; private ui: TUI | undefined; private planPath: string | undefined; @@ -551,7 +596,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 +700,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; @@ -695,6 +740,24 @@ export class ToolCallComponent extends Container { i++; } + // 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' && this.toolCall.truncated !== true)) && + child.wasTruncated()) || + ((child instanceof TruncatedOutputComponent || child instanceof ShellExecutionComponent) && + child.wasTruncated()), + ); + } + if (allReused) { return cache!.lines; } @@ -727,6 +790,132 @@ 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 || 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 + // 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; + 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 '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 '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': + return true; + 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 '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 '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 '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': + case 'TodoList': + case 'EnterPlanMode': + return false; + default: + return nonEmptyLines(result.output).length > OUTCOME_MAX_LINES; + } + } + + /** + * 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 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']); + 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; + default: + return false; + } + } + setResult(result: ToolResultBlockData): void { this.result = result; // Result supersedes any live progress chatter; the result body is the @@ -793,8 +982,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, )}`; } @@ -1442,7 +1632,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 +1686,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 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. 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 +1729,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,13 +1738,20 @@ 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 { + // 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); @@ -1555,6 +1761,7 @@ export class ToolCallComponent extends Container { } private rebuildContent(): void { + this.hiddenContent = undefined; while (this.children.length > this.callPreviewEndIndex) { this.children.pop(); } @@ -1566,6 +1773,7 @@ export class ToolCallComponent extends Container { } private rebuildBody(): void { + this.hiddenContent = undefined; while (this.children.length > 2) { this.children.pop(); } @@ -1611,6 +1819,19 @@ 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 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( new ShellExecutionComponent({ result: { @@ -1618,10 +1839,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, }), ); } @@ -2044,21 +2262,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 +2333,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..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 @@ -9,10 +9,13 @@ */ 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 { nonEmptyLines } from './outcome'; import { strArg } from './types'; import { waitForChip } from './wait-for'; @@ -25,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 { @@ -87,16 +91,42 @@ const writeChip: ChipProvider = (toolCall) => formatWriteChip(computeWriteStats( const readChip: ChipProvider = (_toolCall, result) => pluralize(countNonEmptyLines(result.output), 'line'); -const grepChip: ChipProvider = (_toolCall, result) => { - const matches = countNonEmptyLines(result.output); - if (matches === 0) return 'no matches'; - return pluralize(matches, 'match', 'matches'); +// 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. 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 ''; + // 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'); +}; + +// Grep's default mode lists files, so the chip counts what the mode +// 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); + // A paginated count-mode page past the last row still carries the totals. + // 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); + // 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', undefined, stats.partial)}`; }; const globChip: ChipProvider = (_toolCall, result) => { - const files = countNonEmptyLines(result.output); - if (files === 0) return 'no files'; - return pluralize(files, 'file'); + const { entries, partial } = parseGlobOutput(result.output); + if (entries.length === 0) return partial ? '' : 'no files'; + return pluralize(entries.length, 'file', undefined, partial); }; const fetchChip: ChipProvider = (_toolCall, result) => @@ -116,6 +146,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/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 new file mode 100644 index 00000000000..4877d6e816d --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/grep-output.ts @@ -0,0 +1,195 @@ +/** + * 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; + /** 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 + * count is not derivable from the text: unnumbered content rows with + * 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; + /** 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 +// count-mode summary, and the pagination / filtering / timeout notices. +// 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 |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; +// 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 |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+):/; +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 []; + 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); + 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, partial }; + } + + 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); + } + const [, totalMatches, totalFiles] = COUNT_SUMMARY.exec(output) ?? []; + if (totalMatches !== undefined && totalFiles !== undefined) { + return { + mode, + entries, + total: Number(totalFiles), + matches: Number(totalMatches), + files: Number(totalFiles), + filesPartial: false, + partial, + }; + } + 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 + // 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; + // 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(); + let rows = 0; + for (const line of lines) { + if (numbered) { + const [, path, lineNumber] = CONTENT_MATCH.exec(line) ?? []; + if (path === undefined || lineNumber === undefined) continue; + rows++; + paths.add(path); + entries.push({ path, label: `${path}:${lineNumber}` }); + continue; + } + // 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(':', DRIVE_PREFIX.test(line) ? 2 : 0); + const path = idx > 0 ? line.slice(0, idx) : line; + rows++; + if (paths.has(path)) continue; + paths.add(path); + entries.push({ path, label: path }); + } + // 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 = hasContext ? undefined : PAGINATION_TOTAL.exec(output)?.[1]; + 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, + partial, + }; +} + +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/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 new file mode 100644 index 00000000000..1cca69113b0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/outcome.ts @@ -0,0 +1,67 @@ +/** + * 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 — 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_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'; + +// 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); + +/** + * 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 sanitizeShellOutput(text) + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => line.trimEnd()); +} + +/** 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, + flex: { text, style: dimOutcomeStyle, keep: 'head' }, + 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`. + */ +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, keep === 'first' ? 'below' : 'above')]; +} 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..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 @@ -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` (the original - * 3-line + ctrl+o behavior). + * 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. @@ -15,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'; @@ -56,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 ac31cec8e7b..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 @@ -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 most tools intentionally render an - * empty body and only expose details 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,67 +11,80 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { Text } from '@moonshot-ai/pi-tui'; -import chalk from 'chalk'; +import { OUTCOME_GLANCE_SAMPLES, OUTCOME_ROW_INDENT } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; + +import { parseGlobOutput, parseGrepOutput, searchCutShort } from './grep-output'; +import { outcomeRow, outcomeRows } from './outcome'; import { renderTruncated } from './truncated'; -import type { ResultRenderer } from './types'; +import { isSpilledToolOutput, type ResultRenderer } from './types'; -const GLANCE_SAMPLES = 3; +interface Glance { + readonly samples: string; + 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], -) => string; +) => Glance | null | 'fallback'; 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 + // 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(new Text(` ${chalk.dim(line)}`, 0, 0)); + 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( + 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) { - 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); +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: total - samples.length }; } -// 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; - 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. 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); + const labels = stats.entries.map((entry) => entry.label); + return sampleList(labels, Math.max(labels.length, stats.total)); }; -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}`; +const globGlance: GlanceFn = (toolCall, result) => { + if (searchCutShort(toolCall, result.output)) return 'fallback'; + return sampleList(parseGlobOutput(result.output).entries); }; // ── Exports ────────────────────────────────────────────────────────── @@ -83,8 +95,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/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index f9b4c7183db..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 @@ -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 { outcomeRows } from './outcome'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -31,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, @@ -76,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; @@ -100,8 +109,13 @@ export class TruncatedOutputComponent implements Component { } } +// 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) return outcomeRows(result.output, 'first'); return [ new TruncatedOutputComponent(result.output, { expanded: ctx.expanded, 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/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..e22e003af65 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/truncated-header-line.ts @@ -0,0 +1,226 @@ +/** + * 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'; + +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. */ + 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. + +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(TRUNCATION_ELLIPSIS); + let out = ''; + let used = 0; + 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 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(TRUNCATION_ELLIPSIS); + // 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. + if (windowed.length < text.length) units.shift(); + let out = ''; + let used = 0; + 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 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 { + if (fits(flex.text, width)) return flex.text; + return flex.keep === 'tail' ? keepTail(flex.text, width) : keepHead(flex.text, width); +} + +function layoutHeaderContent( + content: HeaderContent, + width: number, +): { line: string; truncated: boolean } { + const safeWidth = Math.max(1, width); + 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: 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 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(`${head}${tail}`, safeWidth, TRUNCATION_ELLIPSIS), + truncated: true, + }; + } + 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 { + 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[]; truncated: boolean } + | 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; + } + + /** + * 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 { 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/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index 8b20e9a95bc..2f599a48978 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -23,6 +23,27 @@ 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 = '…'; +// 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 = ' '; +// 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 // the activity pane. 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/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index eec3c49f282..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, 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'; @@ -190,6 +195,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 +463,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 +3460,49 @@ 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; + 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 '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..810f08cae9d 100644 --- a/apps/kimi-code/src/tui/utils/component-capabilities.ts +++ b/apps/kimi-code/src/tui/utils/component-capabilities.ts @@ -2,6 +2,16 @@ 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; + isExpanded(): boolean; +} + export interface Disposable { dispose(): void; } @@ -15,6 +25,25 @@ 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() + ); +} + +/** 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/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..ad5304bd326 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,65 @@ 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(); + }); +}); + +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/dialogs/agent-activity-viewer.test.ts b/apps/kimi-code/test/tui/components/dialogs/agent-activity-viewer.test.ts index a6908b919f5..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,12 +143,15 @@ 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'); - // grep glance renderer: path samples below the header (`path:line` form) + 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'); + 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); @@ -246,7 +251,7 @@ describe('formatSubagentActivityPreview', () => { { id: 't1', name: 'Grep', - args: { pattern: 'IEventBus' }, + args: { pattern: 'IEventBus', output_mode: 'content' }, status: 'done', startedAt: 0, result: { @@ -270,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 new file mode 100644 index 00000000000..be8ed06ebd2 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/read-group.test.ts @@ -0,0 +1,91 @@ +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('…'); + } + }); +}); + +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); + }); +}); + +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'); + }); +}); 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..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 @@ -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,77 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: 'first\nsecond\nthird\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('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', () => { + 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' } }, + { 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 +200,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/shell-run.test.ts b/apps/kimi-code/test/tui/components/messages/shell-run.test.ts index 96a4bc110dc..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 @@ -175,3 +175,54 @@ 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); + }); +}); + +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 b86141e496f..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 @@ -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 + 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('· 4 more 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,19 @@ 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, + // 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'); + + 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 +240,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 +253,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 +272,125 @@ 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: 'step a\nstep b\nstep c\ndone', + is_error: false, + }); - 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('· 3 more lines'); + 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\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. + 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 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', () => { + 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 +530,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 +844,17 @@ 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'); + // Three lines of output fit the collapsed card whole. + expect(collapsed).toContain('task_id: question-aaaaaaaa'); + expect(collapsed).toContain('description: Which database?'); + expect(collapsed).toContain('status: running'); + 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', () => { @@ -2109,3 +2230,343 @@ 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(); + }); + + 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(); + }); + + 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); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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 7942cdae8e2..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 @@ -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', () => { @@ -53,12 +53,86 @@ 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 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'); + expect( + chipFor( + 'Grep', + { pattern: 'foo' }, + result('a.ts\nb.ts\nResults truncated to 2 lines (total: 9). Use offset=2 to see more.'), + ), + ).toBe('9 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 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'), + ), + ), + // The timeout and cap notices mark the set incomplete: the count is a lower bound. + ).toBe('2+ files'); }); it('Glob chip shows file count', () => { @@ -142,3 +216,223 @@ describe('computeEditStats', () => { expect(stats.removed).toBe(0); }); }); + +describe('Bash chip', () => { + 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' } }; + // 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(''); + 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'); + }); +}); + +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'); // unreadable directories make the count a lower bound + }); +}); + +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', + ); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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'); + }); +}); + +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(''); + }); +}); + +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'); + }); +}); 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..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 @@ -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: first line marked, 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: marked 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,26 +126,74 @@ 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 strips trailing :line:text in content mode', () => { + 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( + 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', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo', output_mode: 'content' }), result('src/a.ts:42: foo()\nsrc/b.ts:7:foo'), + expandedCtx, + ), + ), + ); + 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).toContain('src/a.ts:42'); - expect(out).not.toContain('foo()'); + 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', () => { @@ -131,11 +202,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'); @@ -175,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(''); }); @@ -242,10 +324,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('… ('); @@ -372,3 +456,158 @@ 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'); + }); +}); + +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'); + }); +}); + +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. …', + ); + }); +}); + +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'); + }); +}); + +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); + }); +}); + +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.'); + }); +}); 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..de3de515889 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/truncated-header-line.test.ts @@ -0,0 +1,159 @@ +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('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', () => { + 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', () => { + 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); + }); + + 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); + }); +}); + +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); + }); +}); 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..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 @@ -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,81 @@ 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'); + }); + + 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', () => { 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); + }); +});