Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/compaction-summary-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show compaction summaries in the TUI after compaction. Press Ctrl-O to show or hide the summary.
79 changes: 68 additions & 11 deletions apps/kimi-code/src/tui/components/dialogs/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const BLINK_INTERVAL = 500;
export class CompactionComponent extends Container {
private readonly ui: TUI | undefined;
private readonly headerText: Text;
private instructionText: Text | undefined;
private readonly instruction: string | undefined;
private readonly tip: string | undefined;
private blinkOn = true;
Expand All @@ -32,6 +33,9 @@ export class CompactionComponent extends Container {
private canceled = false;
private tokensBefore: number | undefined;
private tokensAfter: number | undefined;
private summary: string | undefined;
private summaryText: Text | undefined;
private expanded = false;

constructor(ui?: TUI, instruction?: string | undefined, tip?: string) {
super();
Expand All @@ -51,32 +55,48 @@ export class CompactionComponent extends Container {

private addInstructionChild(): void {
if (this.instruction !== undefined) {
this.addChild(new Text(currentTheme.dim(` ${this.instruction}`), 0, 0));
this.instructionText = new Text(currentTheme.dim(` ${this.instruction}`), 0, 0);
this.addChild(this.instructionText);
}
}

private removeInstructionChild(): void {
if (this.instructionText === undefined) return;
const index = this.children.indexOf(this.instructionText);
if (index !== -1) {
this.children.splice(index, 1);
}
this.instructionText = undefined;
}

override invalidate(): void {
// Repaint the header with the active palette (it caches ANSI codes).
this.headerText.setText(this.buildHeader());
// Rebuild instruction line with fresh theme colours.
if (this.instruction !== undefined) {
// Remove the last child if it is the instruction line (it is always
// added after headerText and Spacer).
if (this.children.length > 2) {
this.children.pop();
}
this.addInstructionChild();
// Rebuild instruction and summary text with fresh theme colours, preserving
// header → instruction → summary child order.
const expanded = this.expanded;
this.removeInstructionChild();
if (expanded) {
this.removeSummaryChild();
}
this.addInstructionChild();
Comment thread
liruifengv marked this conversation as resolved.
if (expanded) {
this.addSummaryChild();
}
super.invalidate();
}

markDone(tokensBefore?: number, tokensAfter?: number): void {
markDone(tokensBefore?: number, tokensAfter?: number, summary?: string): void {
if (this.done || this.canceled) return;
this.done = true;
this.tokensBefore = tokensBefore;
this.tokensAfter = tokensAfter;
this.summary = summary;
this.stopBlink();
this.headerText.setText(this.buildHeader());
if (this.expanded) {
this.addSummaryChild();
}
this.ui?.requestRender();
}

Expand All @@ -88,6 +108,39 @@ export class CompactionComponent extends Container {
this.ui?.requestRender();
}

setExpanded(expanded: boolean): void {
if (this.expanded === expanded) return;
this.expanded = expanded;
if (expanded) {
this.addSummaryChild();
} else {
this.removeSummaryChild();
}
this.headerText.setText(this.buildHeader());
this.ui?.requestRender();
}

private addSummaryChild(): void {
if (this.summaryText !== undefined || this.summary === undefined || this.summary.length === 0) {
return;
}
const indentedSummary = this.summary
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
this.summaryText = new Text(currentTheme.dim(indentedSummary), 0, 0);
this.addChild(this.summaryText);
Comment thread
liruifengv marked this conversation as resolved.
}

private removeSummaryChild(): void {
if (this.summaryText === undefined) return;
const index = this.children.indexOf(this.summaryText);
if (index !== -1) {
this.children.splice(index, 1);
}
this.summaryText = undefined;
}

dispose(): void {
this.stopBlink();
}
Expand All @@ -100,7 +153,11 @@ export class CompactionComponent extends Container {
this.tokensBefore !== undefined && this.tokensAfter !== undefined
? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`)
: '';
return `${bullet}${label}${detail}`;
const shortcutHint =
this.summary !== undefined && this.summary.length > 0
? currentTheme.dim(` (Ctrl-O to ${this.expanded ? 'hide' : 'show'} compaction summary)`)
Comment thread
liruifengv marked this conversation as resolved.
: '';
return `${bullet}${label}${detail}${shortcutHint}`;
}
if (this.canceled) {
const bullet = currentTheme.fg('warning', STATUS_BULLET);
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/components/dialogs/help-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface HelpPanelCommand {
export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [
{ keys: 'Shift-Tab', description: 'Toggle plan mode' },
{ keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' },
{ keys: 'Ctrl-O', description: 'Toggle tool output expansion' },
{ keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' },
{ keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' },
{ keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' },
{ keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' },
Expand Down
6 changes: 5 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,11 @@ export class SessionEventHandler {
event: CompactionCompletedEvent,
sendQueued: (item: QueuedMessage) => void,
): void {
this.host.streamingUI.endCompaction(event.result.tokensBefore, event.result.tokensAfter);
this.host.streamingUI.endCompaction(
event.result.tokensBefore,
event.result.tokensAfter,
event.result.summary,
);
this.finishCompaction(sendQueued);
}

Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ export class SessionReplayRenderer {
this.host.appendTranscriptEntry({
...replayEntry(context, 'status', 'Compaction complete', 'plain'),
compactionData: {
summary: record.result.summary,
tokensBefore: record.result.tokensBefore,
tokensAfter: record.result.tokensAfter,
instruction: record.instruction,
Expand Down
7 changes: 5 additions & 2 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,13 +724,16 @@ export class StreamingUIController {
const block = new CompactionComponent(state.ui, instruction, currentWorkingTip()?.text);
this._activeCompactionBlock = block;
state.transcriptContainer.addChild(block);
if (state.toolOutputExpanded) {
block.setExpanded(true);
}
state.ui.requestRender();
}

endCompaction(tokensBefore?: number, tokensAfter?: number): void {
endCompaction(tokensBefore?: number, tokensAfter?: number, summary?: string): void {
const block = this._activeCompactionBlock;
if (block === undefined) return;
block.markDone(tokensBefore, tokensAfter);
block.markDone(tokensBefore, tokensAfter, summary);
Comment thread
liruifengv marked this conversation as resolved.
this._activeCompactionBlock = undefined;
this.host.state.ui.requestRender();
}
Expand Down
5 changes: 4 additions & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1740,7 +1740,10 @@ export class KimiTUI {
if (data.result === 'cancelled') {
block.markCanceled();
} else {
block.markDone(data.tokensBefore, data.tokensAfter);
block.markDone(data.tokensBefore, data.tokensAfter, data.summary);
Comment thread
liruifengv marked this conversation as resolved.
if (this.state.toolOutputExpanded) {
block.setExpanded(true);
}
}
return block;
}
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface BackgroundAgentStatusData {

export interface CompactionTranscriptData {
readonly result?: 'cancelled';
readonly summary?: string;
readonly tokensBefore?: number;
readonly tokensAfter?: number;
readonly instruction?: string;
Expand Down
78 changes: 78 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('CompactionComponent', () => {

expect(text).toContain('Compaction complete');
expect(text).not.toContain('Tip:');
expect(text).not.toContain('Ctrl-O');
} finally {
component.dispose();
}
Expand All @@ -70,6 +71,83 @@ describe('CompactionComponent', () => {
}
});

it('keeps the completed compaction summary hidden until expanded', () => {
const component = new CompactionComponent();

try {
component.markDone(120, 24, 'Keep the src/tui compaction notes.');
const collapsed = component.render(120).map(strip).join('\n');

expect(collapsed).toContain('Compaction complete');
expect(collapsed).toContain('120 → 24 tokens');
expect(collapsed).toContain('Ctrl-O to show compaction summary');
expect(collapsed).not.toContain('Keep the src/tui compaction notes.');

component.setExpanded(true);
const expanded = component.render(120).map(strip).join('\n');

expect(expanded).toContain('Compaction complete');
expect(expanded).toContain('Ctrl-O to hide compaction summary');
expect(expanded).toContain('Keep the src/tui compaction notes.');
} finally {
component.dispose();
}
});

it('hides the compaction summary again when collapsed', () => {
const component = new CompactionComponent();

try {
component.markDone(120, 24, 'Keep the src/tui compaction notes.');
component.setExpanded(true);
component.setExpanded(false);
const text = component.render(120).map(strip).join('\n');

expect(text).toContain('Compaction complete');
expect(text).toContain('Ctrl-O to show compaction summary');
expect(text).not.toContain('Ctrl-O to hide compaction summary');
expect(text).not.toContain('Keep the src/tui compaction notes.');
} finally {
component.dispose();
}
});

it('preserves the expanded summary when invalidating with an instruction', () => {
const component = new CompactionComponent(undefined, 'keep the recent files only');

try {
component.markDone(120, 24, 'Keep the src/tui compaction notes.');
component.setExpanded(true);
component.invalidate();
const text = component.render(120).map(strip).join('\n');

expect(text).toContain('keep the recent files only');
expect(text).toContain('Keep the src/tui compaction notes.');
expect(text.match(/keep the recent files only/g)).toHaveLength(1);
} finally {
component.dispose();
}
});

it('keeps expanded summary child order on invalidate', () => {
const component = new CompactionComponent(undefined, 'keep the recent files only');

try {
component.markDone(120, 24, 'Keep the src/tui compaction notes.');
component.setExpanded(true);
currentTheme.setPalette(lightColors);
component.invalidate();
const text = component.render(120).map(strip).join('\n');

expect(text).toContain('Keep the src/tui compaction notes.');
expect(text.indexOf('keep the recent files only')).toBeLessThan(
text.indexOf('Keep the src/tui compaction notes.'),
);
} finally {
component.dispose();
}
});

it('repaints the header with the active palette on invalidate', () => {
// Force truecolor so palette differences surface as ANSI codes even when
// the test runner has no TTY.
Expand Down
77 changes: 77 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2023,6 +2023,83 @@ command = "vim"
}
});

it('stores the live compaction summary and expands it with tool output expansion', async () => {
const { driver } = await makeDriver();
const sendQueued = vi.fn();

driver.sessionEventHandler.handleEvent(
{
type: 'compaction.started',
agentId: 'main',
sessionId: 'ses-1',
trigger: 'manual',
} as Event,
sendQueued,
);

driver.sessionEventHandler.handleEvent(
{
type: 'compaction.completed',
agentId: 'main',
sessionId: 'ses-1',
result: {
summary: 'Keep the src/tui compaction notes.',
compactedCount: 4,
tokensBefore: 120,
tokensAfter: 24,
},
} as Event,
sendQueued,
);

const collapsed = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n');
expect(collapsed).toContain('Compaction complete');
expect(collapsed).not.toContain('Keep the src/tui compaction notes.');

driver.state.editor.onToggleToolExpand?.();

const expanded = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n');
expect(driver.state.toolOutputExpanded).toBe(true);
expect(expanded).toContain('Keep the src/tui compaction notes.');
});

it('honors existing tool output expansion when a compaction block is created', async () => {
const { driver } = await makeDriver();
const sendQueued = vi.fn();

driver.state.editor.onToggleToolExpand?.();
expect(driver.state.toolOutputExpanded).toBe(true);

driver.sessionEventHandler.handleEvent(
{
type: 'compaction.started',
agentId: 'main',
sessionId: 'ses-1',
trigger: 'manual',
} as Event,
sendQueued,
);

driver.sessionEventHandler.handleEvent(
{
type: 'compaction.completed',
agentId: 'main',
sessionId: 'ses-1',
result: {
summary: 'Keep the src/tui compaction notes.',
compactedCount: 4,
tokensBefore: 120,
tokensAfter: 24,
},
} as Event,
sendQueued,
);

const transcript = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n');
expect(transcript).toContain('Compaction complete');
expect(transcript).toContain('Keep the src/tui compaction notes.');
});

it('renders an error instead of prompting when no model is selected', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.model = '';
Expand Down
Loading
Loading