Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b2d1ce8
feat(kimi-code): two-line collapsed tool cards with width-aware headers
RealKai42 Sep 4, 2026
b090435
feat(kimi-code): show short tool output whole and point at hidden output
RealKai42 Sep 4, 2026
6870f34
feat(kimi-code): mark hidden tool output with counts and direction
RealKai42 Sep 4, 2026
2f1ee91
fix(kimi-code): keep tool notices and context rows out of result counts
RealKai42 Sep 4, 2026
44d8107
fix(kimi-code): align the ctrl+o hint with what expansion reveals
RealKai42 Sep 4, 2026
a332a8e
fix(kimi-code): tighten the collapsed-card hidden-content signals
RealKai42 Sep 4, 2026
987e4c3
fix(kimi-code): count paginated Grep totals and wrapped error preview…
RealKai42 Sep 4, 2026
261f520
fix(kimi-code): treat spilled tool output as an envelope, not as results
RealKai42 Sep 4, 2026
ac68756
fix(kimi-code): widen the hidden-content signal to failed previews an…
RealKai42 Sep 4, 2026
779b39d
fix(kimi-code): keep result chips honest on incomplete, narrow, and c…
RealKai42 Sep 4, 2026
7f3a38f
fix(kimi-code): stop the expand hint lying on goal cards, ! cards, an…
RealKai42 Sep 4, 2026
c24562b
fix(kimi-code): read zero context flags as none and mirror the Edit c…
RealKai42 Sep 4, 2026
b5c0edf
fix(kimi-code): keep the collapse hint for expanded cards outside the…
RealKai42 Sep 4, 2026
481944d
fix(kimi-code): render Edit and Write results the same way in both st…
RealKai42 Sep 4, 2026
8c8a170
fix(kimi-code): keep a Read group's failure count on narrow rows
RealKai42 Sep 4, 2026
261a440
fix(kimi-code): show cut-short empty searches and keep the hint with …
RealKai42 Sep 4, 2026
341ebbb
fix(kimi-code): count unreadable directories as an incomplete Glob an…
RealKai42 Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/compact-tool-cards.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 58 additions & 16 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof setInterval> | null = null;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Comment thread
RealKai42 marked this conversation as resolved.
Comment on lines +340 to +341

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let the Ctrl+O hint displace inline tips

When status_line.items explicitly contains tips and the terminal is narrow, the rotating tip has already been included in leftWidth, while buildRightText() receives no tip candidate that it can discard. The resulting remaining can be zero, so the footer truncates the inline tip and omits ctrl+o expand even at widths where removing that tip would leave enough room for the fixed hint; exclude or replace the inline tip whenever the shortcut needs its space.

Useful? React with 👍 / 👎.


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 {
Expand Down Expand Up @@ -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.
Expand Down
68 changes: 52 additions & 16 deletions apps/kimi-code/src/tui/components/messages/read-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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;
Expand All @@ -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<typeof setTimeout> | null = null;
private lastFlushPhases = new Map<string, ToolCallReadSnapshot['phase']>();
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('');
Comment thread
RealKai42 marked this conversation as resolved.
this.addChild(this.headerText);
this.bodyContainer = new Container();
this.addChild(this.bodyContainer);
Expand All @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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…`);
Expand All @@ -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 {
Expand Down
52 changes: 32 additions & 20 deletions apps/kimi-code/src/tui/components/messages/shell-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

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

Expand All @@ -63,35 +56,54 @@ export class ShellExecutionComponent extends Container {
private addResultPreview(
result: ToolResultBlockData,
expanded: boolean,
previewLines: number,
tailOutput: boolean,
expandHint: boolean,
): void {
if (!result.output) return;
this.addChild(
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,
}),
];
};
Loading
Loading