mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
ce41f4b58d
commit
b9258ee07d
17 changed files with 284 additions and 27 deletions
5
.changeset/compaction-summary-toggle.md
Normal file
5
.changeset/compaction-summary-toggle.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ Agent 思考或调用工具时,输入框仍然可用,支持以下额外操
|
|||
|
||||
- **`Ctrl-S`**:把输入框中的内容立即注入正在运行的轮次,无需等待结束
|
||||
- **`Esc` / `Ctrl-C`**:中断当前轮次
|
||||
- **`Ctrl-O`**:全局切换工具输出的折叠状态
|
||||
- **`Ctrl-O`**:全局切换工具输出和压缩摘要的折叠状态
|
||||
|
||||
## 外部编辑器
|
||||
|
||||
|
|
|
|||
|
|
@ -67,9 +67,9 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用
|
|||
|
||||
| 快捷键 | 功能 |
|
||||
| --- | --- |
|
||||
| `Ctrl-O` | 展开或折叠工具输出 |
|
||||
| `Ctrl-O` | 展开或折叠工具输出和压缩摘要 |
|
||||
|
||||
历史中存在折叠的工具调用结果时,按 `Ctrl-O` 可在折叠和展开之间切换。
|
||||
历史中存在折叠的工具调用结果时,按 `Ctrl-O` 可在折叠和展开之间切换。压缩完成后,同一个快捷键也会在压缩块中显示或隐藏压缩摘要。
|
||||
|
||||
## 审批面板
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue