feat(tui): show compaction summary with Ctrl-O (#1346)

* feat(tui): show compaction summary with Ctrl-O

* docs: document compaction summary toggle

* fix(tui): preserve compaction summary expansion state

* fix(tui): preserve compaction summary expansion across replay and theme changes
This commit is contained in:
liruifengv 2026-07-06 13:32:37 +08:00 committed by GitHub
parent ce41f4b58d
commit b9258ee07d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 284 additions and 27 deletions

View file

@ -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.

View file

@ -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;
@ -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();
@ -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();
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();
}
@ -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);
}
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();
}
@ -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)`)
: '';
return `${bullet}${label}${detail}${shortcutHint}`;
}
if (this.canceled) {
const bullet = currentTheme.fg('warning', STATUS_BULLET);

View file

@ -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' },

View file

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

View file

@ -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,

View file

@ -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);
this._activeCompactionBlock = undefined;
this.host.state.ui.requestRender();
}

View file

@ -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);
if (this.state.toolOutputExpanded) {
block.setExpanded(true);
}
}
return block;
}

View file

@ -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;

View file

@ -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();
}
@ -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.

View file

@ -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 = '';

View file

@ -1030,15 +1030,43 @@ describe('KimiTUI resume message replay', () => {
(entry) => entry.compactionData !== undefined,
);
expect(compactionEntry?.compactionData).toEqual({
summary: 'Compacted transcript summary.',
tokensBefore: 120,
tokensAfter: 24,
instruction: 'preserve implementation notes',
});
const collapsed = stripAnsi(driver.state.transcriptContainer.render(120).join('\n'));
expect(collapsed).toContain('Compaction complete');
expect(collapsed).toContain('120 → 24 tokens');
expect(collapsed).toContain('preserve implementation notes');
expect(collapsed).not.toContain('Compacted transcript summary.');
driver.state.editor.onToggleToolExpand?.();
const expanded = stripAnsi(driver.state.transcriptContainer.render(120).join('\n'));
expect(expanded).toContain('Compacted transcript summary.');
});
it('initializes replayed compaction blocks as expanded when tool output is already expanded', async () => {
const initial = makeSession([]);
const resumed = makeSession([
{
time: REPLAY_TIME,
type: 'compaction',
result: {
summary: 'Compacted transcript summary.',
compactedCount: 4,
tokensBefore: 120,
tokensAfter: 24,
},
},
]);
const driver = await makeDriver(initial);
driver.state.toolOutputExpanded = true;
await driver.switchToSession(resumed, 'Resumed session (ses-replay).');
const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n'));
expect(transcript).toContain('Compaction complete');
expect(transcript).toContain('120 → 24 tokens');
expect(transcript).toContain('preserve implementation notes');
expect(transcript).not.toContain('Compacted transcript summary.');
expect(transcript).toContain('Compacted transcript summary.');
});
it('renders replayed cancelled compaction records as cancelled compaction blocks', async () => {

View file

@ -155,7 +155,7 @@ For a first-time user, the following is all you need to know:
| `Ctrl-C` | Interrupt output; press twice while idle to exit |
| `Shift-Tab` | Toggle Plan mode |
| `Ctrl-S` | Inject a message mid-stream without waiting for the current response to finish |
| `Ctrl-O` | Collapse / expand tool output |
| `Ctrl-O` | Collapse / expand tool output and compaction summaries |
For the full list, type `/help` or visit [Slash commands reference](../reference/slash-commands.md) and [Keyboard shortcuts](../reference/keyboard.md).

View file

@ -81,7 +81,7 @@ The input box remains usable while the agent is thinking or calling tools, and s
- **`Ctrl-S`**: inject the content in the input box into the running turn immediately, without waiting for it to finish
- **`Esc` / `Ctrl-C`**: interrupt the current turn
- **`Ctrl-O`**: globally toggle the collapsed/expanded state of tool output
- **`Ctrl-O`**: globally toggle the collapsed/expanded state of tool output and compaction summaries
## External editor

View file

@ -67,9 +67,9 @@ Pressing `Ctrl-S` causes the model to see your message at the next interruptible
| Shortcut | Function |
| --- | --- |
| `Ctrl-O` | Expand or collapse tool output |
| `Ctrl-O` | Expand or collapse tool output and compaction summaries |
When collapsed tool call results exist in the history, press `Ctrl-O` to toggle between collapsed and expanded views.
When collapsed tool call results exist in the history, press `Ctrl-O` to toggle between collapsed and expanded views. After compaction, the same shortcut shows or hides the compaction summary in the compaction block.
## Approval Panel

View file

@ -155,7 +155,7 @@ Kimi Code CLI 会规划步骤、修改代码、运行测试,并在每一步告
| `Ctrl-C` | 中断输出;空闲时连按两次退出 |
| `Shift-Tab` | 切换 Plan 模式 |
| `Ctrl-S` | 输出中途插入消息,无需等待结束 |
| `Ctrl-O` | 折叠 / 展开工具输出 |
| `Ctrl-O` | 折叠 / 展开工具输出和压缩摘要 |
想看完整列表,输入 `/help` 或访问[斜杠命令参考](../reference/slash-commands.md)和[键盘快捷键](../reference/keyboard.md)。

View file

@ -81,7 +81,7 @@ Agent 思考或调用工具时,输入框仍然可用,支持以下额外操
- **`Ctrl-S`**:把输入框中的内容立即注入正在运行的轮次,无需等待结束
- **`Esc` / `Ctrl-C`**:中断当前轮次
- **`Ctrl-O`**:全局切换工具输出的折叠状态
- **`Ctrl-O`**:全局切换工具输出和压缩摘要的折叠状态
## 外部编辑器

View file

@ -67,9 +67,9 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用
| 快捷键 | 功能 |
| --- | --- |
| `Ctrl-O` | 展开或折叠工具输出 |
| `Ctrl-O` | 展开或折叠工具输出和压缩摘要 |
历史中存在折叠的工具调用结果时,按 `Ctrl-O` 可在折叠和展开之间切换。
历史中存在折叠的工具调用结果时,按 `Ctrl-O` 可在折叠和展开之间切换。压缩完成后,同一个快捷键也会在压缩块中显示或隐藏压缩摘要。
## 审批面板