From 32e84a3249fa84d09a507140db952e249603619a Mon Sep 17 00:00:00 2001 From: yu-xin-c <2182712990@qq.com> Date: Fri, 14 Aug 2026 16:14:42 +0800 Subject: [PATCH 1/3] fix(tui): make folded assistant messages expandable --- .../expand-folded-assistant-messages.md | 5 ++ .../components/messages/assistant-message.ts | 4 ++ .../tui/components/messages/step-summary.ts | 53 +++++++++++++++++-- apps/kimi-code/src/tui/kimi-tui.ts | 14 +++++ .../components/messages/step-summary.test.ts | 48 +++++++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 27 ++++++++++ .../kimi-code/test/tui/message-replay.test.ts | 35 +++++++++++- 7 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 .changeset/expand-folded-assistant-messages.md diff --git a/.changeset/expand-folded-assistant-messages.md b/.changeset/expand-folded-assistant-messages.md new file mode 100644 index 00000000000..c4b38c2fe60 --- /dev/null +++ b/.changeset/expand-folded-assistant-messages.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Let Ctrl+O reveal assistant replies folded into transcript summaries. diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 00ed8ca124f..3d0ff8ed85e 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -45,6 +45,10 @@ export class AssistantMessageComponent implements Component { this.markRenderDirty(); } + getContent(): string { + return this.lastText; + } + updateContent(text: string, opts?: AssistantMarkdownOptions): void { const displayText = text.trim(); const transient = opts?.transient === true; diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts index 62d5add2f76..68da908a993 100644 --- a/apps/kimi-code/src/tui/components/messages/step-summary.ts +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -1,17 +1,22 @@ import type { Component } from '@moonshot-ai/pi-tui'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { currentTheme } from '#/tui/theme'; /** * A collapsed summary of older content within a turn. Accumulates counts of - * merged steps (thinking blocks and tool calls) and folded assistant messages, - * rendering them as a single muted line, e.g. + * merged steps (thinking blocks and tool calls) and folded assistant messages. + * The count stays as a single muted line until Ctrl+O reveals lightweight + * snapshots of the folded assistant messages, e.g. * `… thinking 5 times, call 50 tools, 12 messages`. */ export class StepSummaryComponent implements Component { private thinking = 0; private tool = 0; private message = 0; + private foldedMessageContent: string[] = []; + private expandedMessageRenderCache: { width: number; lines: string[] } | undefined; + private expanded = false; get isEmpty(): boolean { return this.thinking === 0 && this.tool === 0 && this.message === 0; @@ -23,14 +28,52 @@ export class StepSummaryComponent implements Component { this.message += message; } - invalidate(): void {} + addFoldedMessages(messages: readonly AssistantMessageComponent[]): void { + for (const message of messages) { + const content = message.getContent(); + if (content.length > 0) this.foldedMessageContent.push(content); + } + this.expandedMessageRenderCache = undefined; + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + if (!expanded) this.expandedMessageRenderCache = undefined; + } + + hasHiddenContent(): boolean { + return this.foldedMessageContent.length > 0; + } - render(_width: number): string[] { + isExpanded(): boolean { + return this.expanded; + } + + invalidate(): void { + this.expandedMessageRenderCache = undefined; + } + + render(width: number): string[] { const parts: string[] = []; if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); if (this.tool > 0) parts.push(`call ${this.tool} tools`); if (this.message > 0) parts.push(`${this.message} messages`); if (parts.length === 0) return []; - return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + const lines = [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + if (!this.expanded || this.foldedMessageContent.length === 0) return lines; + + if (this.expandedMessageRenderCache?.width !== width) { + // Reuse one Markdown renderer so a very long turn does not rebuild an + // equally large component tree when its folded messages are expanded. + const message = new AssistantMessageComponent(); + const expandedLines: string[] = []; + for (const content of this.foldedMessageContent) { + message.updateContent(content); + expandedLines.push(...message.render(width)); + } + this.expandedMessageRenderCache = { width, lines: expandedLines }; + } + lines.push(...this.expandedMessageRenderCache.lines); + return lines; } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 3adc64ff0d2..14a28796ef8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -3128,6 +3128,9 @@ export class KimiTUI { ...stepIndices.slice(0, stepMergeCount), ...assistantIndices.slice(0, assistantMergeCount), ]; + const foldedAssistants = assistantIndices + .slice(0, assistantMergeCount) + .map((idx) => children[idx] as AssistantMessageComponent); let thinkingCount = 0; let toolCount = 0; @@ -3146,6 +3149,8 @@ export class KimiTUI { summary = new StepSummaryComponent(); summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } + summary.addFoldedMessages(foldedAssistants); + summary.setExpanded(this.state.toolOutputExpanded && TRANSCRIPT_EXPAND_TURNS > 0); // Rebuild children: keep everything except the merged steps, with the summary // sitting right after the user message. @@ -3181,6 +3186,10 @@ export class KimiTUI { const newChildren: Component[] = []; const toDispose: Component[] = []; + const expandedTurnStart = + TRANSCRIPT_EXPAND_TURNS > 0 + ? Math.max(0, boundaries.length - TRANSCRIPT_EXPAND_TURNS) + : boundaries.length; for (let i = 0; i < boundaries[0]!; i++) newChildren.push(children[i]!); for (let t = 0; t < boundaries.length; t++) { @@ -3213,6 +3222,9 @@ export class KimiTUI { ...stepIndices.slice(0, stepMergeCount), ...assistantIndices.slice(0, assistantMergeCount), ]; + const foldedAssistants = assistantIndices + .slice(0, assistantMergeCount) + .map((idx) => children[idx] as AssistantMessageComponent); let thinkingCount = 0; let toolCount = 0; for (const idx of toMergeIndices) { @@ -3228,6 +3240,8 @@ export class KimiTUI { summary = new StepSummaryComponent(); summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } + summary.addFoldedMessages(foldedAssistants); + summary.setExpanded(this.state.toolOutputExpanded && t >= expandedTurnStart); newChildren.push(summary); for (const idx of toMergeIndices) toDispose.push(children[idx]!); const toMergeSet = new Set(toMergeIndices); diff --git a/apps/kimi-code/test/tui/components/messages/step-summary.test.ts b/apps/kimi-code/test/tui/components/messages/step-summary.test.ts index 85626c8ae6d..b7fa6885a9d 100644 --- a/apps/kimi-code/test/tui/components/messages/step-summary.test.ts +++ b/apps/kimi-code/test/tui/components/messages/step-summary.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it } from 'vitest'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } +function assistant(text: string): AssistantMessageComponent { + const component = new AssistantMessageComponent(); + component.updateContent(text); + return component; +} + describe('StepSummaryComponent', () => { it('renders nothing when empty', () => { const component = new StepSummaryComponent(); @@ -17,6 +24,7 @@ describe('StepSummaryComponent', () => { const component = new StepSummaryComponent(); component.addCounts(5, 50); const out = strip(component.render(80).join('\n')); + expect(component.hasHiddenContent()).toBe(false); expect(out).toContain('thinking 5 times'); expect(out).toContain('call 50 tools'); expect(out).not.toContain('messages'); @@ -32,4 +40,44 @@ describe('StepSummaryComponent', () => { expect(out).toContain('call 4 tools'); expect(out).toContain('8 messages'); }); + + it('reveals folded assistant message snapshots in order when expanded', () => { + const component = new StepSummaryComponent(); + const first = assistant('first hidden reply'); + component.addCounts(0, 0, 2); + component.addFoldedMessages([first, assistant('second hidden reply')]); + first.updateContent('mutated after folding'); + + expect(component.hasHiddenContent()).toBe(true); + expect(component.isExpanded()).toBe(false); + const collapsed = strip(component.render(80).join('\n')); + expect(collapsed).toContain('2 messages'); + expect(collapsed).not.toContain('first hidden reply'); + + component.setExpanded(true); + expect(component.isExpanded()).toBe(true); + const expanded = strip(component.render(80).join('\n')); + expect(expanded).toContain('first hidden reply'); + expect(expanded).toContain('second hidden reply'); + expect(expanded).not.toContain('mutated after folding'); + expect(expanded.indexOf('first hidden reply')).toBeLessThan( + expanded.indexOf('second hidden reply'), + ); + + component.setExpanded(false); + expect(component.isExpanded()).toBe(false); + expect(strip(component.render(80).join('\n'))).not.toContain('first hidden reply'); + }); + + it('renders a large set of folded snapshots', () => { + const component = new StepSummaryComponent(); + const messages = Array.from({ length: 100 }, (_, index) => assistant(`hidden reply ${index}`)); + component.addCounts(0, 0, messages.length); + component.addFoldedMessages(messages); + component.setExpanded(true); + + const expanded = strip(component.render(80).join('\n')); + expect(expanded).toContain('hidden reply 0'); + expect(expanded).toContain('hidden reply 99'); + }); }); 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 c7985d17d38..cbafb8bf192 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 @@ -8774,6 +8774,14 @@ describe('transcript step and assistant folding', () => { (entry) => entry.kind === 'assistant', ); expect(assistantEntries).toHaveLength(cycles); + + const collapsed = stripSgr(renderTranscript(driver)); + expect(collapsed).not.toContain('msg-0'); + + driver.toggleToolOutputExpansion(); + const expanded = stripSgr(renderTranscript(driver)); + expect(expanded).toContain('msg-0'); + expect(expanded.indexOf('msg-0')).toBeLessThan(expanded.indexOf('msg-1')); }); it('does not fold a turn within the caps', async () => { @@ -8822,6 +8830,10 @@ describe('transcript step and assistant folding', () => { const lastAssistant = assistants.at(-1)!; expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); + + expect(stripSgr(renderTranscript(driver))).not.toContain('msg-0'); + driver.toggleToolOutputExpansion(); + expect(stripSgr(renderTranscript(driver))).toContain('msg-0'); }); }); @@ -8871,6 +8883,21 @@ describe('footer ctrl+o hint', () => { expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); }); + it('offers expand for folded assistant messages and collapse once revealed', async () => { + const { driver } = await makeDriver(); + const message = new AssistantMessageComponent(); + message.updateContent('hidden assistant reply'); + const summary = new StepSummaryComponent(); + summary.addCounts(0, 0, 1); + summary.addFoldedMessages([message]); + driver.state.transcriptContainer.addChild(summary); + + expect(renderFooterLine1(driver)).toContain('ctrl+o expand'); + + driver.toggleToolOutputExpansion(); + expect(renderFooterLine1(driver)).toContain('ctrl+o collapse'); + }); + it('stays silent when every card shows its whole output', async () => { const { driver } = await makeDriver(); emitBashResult(driver, 'call_bash', ['line1', 'line2', 'line3'].join('\n')); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 77fdeb6fecc..f806c53c4de 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -20,6 +20,7 @@ import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; import { + TRANSCRIPT_EXPAND_TURNS, TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, TRANSCRIPT_KEEP_RECENT_STEPS, } from '#/tui/utils/transcript-window'; @@ -44,6 +45,7 @@ interface ReplayDriver { readonly sessionEventHandler: SessionEventHandler; init(): Promise; switchToSession(session: Session, statusMessage: string): Promise; + toggleToolOutputExpansion(): void; } function makeStartupInput(): KimiTUIStartupInput { @@ -1464,10 +1466,41 @@ describe('KimiTUI resume message replay', () => { expect(summaryText).toContain(`call ${40 - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); expect(summaryText).toContain(`${5 - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); - // The folded content is gone from view; the latest work stays. + // The folded content stays hidden until the global expansion toggle is used. const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); expect(transcript).not.toContain('final text 0'); expect(transcript).toContain('final text 4'); + + driver.toggleToolOutputExpansion(); + const expanded = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(expanded).toContain('final text 0'); + expect(expanded.indexOf('final text 0')).toBeLessThan(expanded.indexOf('final text 1')); + }); + + it('keeps replayed summaries outside the expansion turn window collapsed', async () => { + const replay: AgentReplayRecord[] = []; + const turnCount = TRANSCRIPT_EXPAND_TURNS + 1; + const messageCount = TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED + 1; + for (let turn = 0; turn < turnCount; turn++) { + replay.push(message('user', [{ type: 'text', text: `prompt ${turn}` }])); + for (let reply = 0; reply < messageCount; reply++) { + replay.push( + message('assistant', [{ type: 'text', text: `turn ${turn} hidden reply ${reply}` }]), + ); + } + } + + const initial = makeSession([]); + const resumed = makeSession(replay); + const driver = await makeDriver(initial); + driver.state.toolOutputExpanded = true; + await driver.switchToSession(resumed, 'Resumed session (ses-replay).'); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('turn 0 hidden reply 0'); + expect(transcript).toContain( + `turn ${turnCount - TRANSCRIPT_EXPAND_TURNS} hidden reply 0`, + ); }); }); From 19a19ff5e3a0226d0e61764453876339b6613a9b Mon Sep 17 00:00:00 2001 From: yu-xin-c <2182712990@qq.com> Date: Fri, 14 Aug 2026 23:39:25 +0800 Subject: [PATCH 2/3] docs(tui): document folded message expansion --- docs/en/guides/getting-started.md | 2 +- docs/en/guides/interaction.md | 2 +- docs/en/reference/keyboard.md | 4 ++-- docs/zh/guides/getting-started.md | 2 +- docs/zh/guides/interaction.md | 2 +- docs/zh/reference/keyboard.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index d0ff35c44dd..be4d2ebf669 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -140,7 +140,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 and compaction summaries | +| `Ctrl-O` | Collapse / expand tool output, folded assistant messages, and compaction summaries | For the full list, type `/help` or visit [Slash commands reference](../reference/slash-commands.md) and [Keyboard shortcuts](../reference/keyboard.md). diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 65b4b68c875..e99575685b8 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -130,7 +130,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 and compaction summaries +- **`Ctrl-O`**: globally toggle tool output, assistant messages folded into transcript summaries, and compaction summaries When the agent is waiting for background tasks through `WaitFor`, pressing `Ctrl-S` ends that wait early. Background tasks keep running and existing tool results are preserved. If other foreground tools remain in the same batch, the agent processes your message after they return. diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index a8fcef4e18f..3c5e7188f3e 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -69,9 +69,9 @@ Pressing `Ctrl-S` causes the model to see your message at the next interruptible | Shortcut | Function | | --- | --- | -| `Ctrl-O` | Expand or collapse tool output, shell command output, and compaction summaries | +| `Ctrl-O` | Expand or collapse tool output, shell command output, folded assistant messages, and compaction summaries | -When collapsed tool call results or shell command outputs 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. +When collapsed tool call results or shell command outputs exist in the history, press `Ctrl-O` to toggle between collapsed and expanded views. When older assistant messages have been folded into a transcript summary, the same shortcut reveals or hides those messages. After compaction, it also shows or hides the compaction summary in the compaction block. ## Approval Panel diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md index 6b4f0c0b25f..12aea4f7efc 100644 --- a/docs/zh/guides/getting-started.md +++ b/docs/zh/guides/getting-started.md @@ -140,7 +140,7 @@ Kimi Code CLI 会规划步骤、修改代码、运行测试,并在每一步告 | `Ctrl-C` | 中断输出;空闲时连按两次退出 | | `Shift-Tab` | 切换 Plan 模式 | | `Ctrl-S` | 输出中途插入消息,无需等待结束 | -| `Ctrl-O` | 折叠 / 展开工具输出和压缩摘要 | +| `Ctrl-O` | 折叠 / 展开工具输出、被摘要收起的 Assistant 消息和压缩摘要 | 想看完整列表,输入 `/help` 或访问[斜杠命令参考](../reference/slash-commands.md)和[键盘快捷键](../reference/keyboard.md)。 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 17c9ebb78e8..d6b37e4b032 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -130,7 +130,7 @@ Agent 思考或调用工具时,输入框仍然可用,支持以下额外操 - **`Ctrl-S`**:把输入框中的内容立即注入正在运行的轮次,无需等待结束 - **`Esc` / `Ctrl-C`**:中断当前轮次 -- **`Ctrl-O`**:全局切换工具输出和压缩摘要的折叠状态 +- **`Ctrl-O`**:全局切换工具输出、被对话记录摘要收起的 Assistant 消息和压缩摘要 Agent 正通过 `WaitFor` 等待后台任务时,按 `Ctrl-S` 会提前结束本次等待。后台任务继续运行,已有工具结果保留;如果同批还有其他前台工具,Agent 会在它们返回后处理新消息。 diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 90346a1130b..b33586a02c8 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -69,9 +69,9 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用 | 快捷键 | 功能 | | --- | --- | -| `Ctrl-O` | 展开或折叠工具输出、Shell 命令输出和压缩摘要 | +| `Ctrl-O` | 展开或折叠工具输出、Shell 命令输出、被摘要收起的 Assistant 消息和压缩摘要 | -历史中存在折叠的工具调用结果或 Shell 命令输出时,按 `Ctrl-O` 可在折叠和展开之间切换。压缩完成后,同一个快捷键也会在压缩块中显示或隐藏压缩摘要。 +历史中存在折叠的工具调用结果或 Shell 命令输出时,按 `Ctrl-O` 可在折叠和展开之间切换。较早的 Assistant 消息被收进对话记录摘要后,同一个快捷键可以显示或隐藏这些消息。压缩完成后,它也会在压缩块中显示或隐藏压缩摘要。 ## 审批面板 From 7a2a8e1fbafdea79b085d40b246ce6a69cbb33c7 Mon Sep 17 00:00:00 2001 From: yu-xin-c <2182712990@qq.com> Date: Fri, 14 Aug 2026 23:47:43 +0800 Subject: [PATCH 3/3] fix(tui): enforce live expansion cutoff --- apps/kimi-code/src/tui/kimi-tui.ts | 49 +++++++--- .../test/tui/kimi-tui-message-flow.test.ts | 93 +++++++++++++++++-- 2 files changed, 122 insertions(+), 20 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 14a28796ef8..fcd426d44e4 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2867,12 +2867,15 @@ export class KimiTUI { appendTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); const component = this.createTranscriptComponent(entry); + let advancesTurn = false; if (component) { markTranscriptComponent(component, entry); + advancesTurn = this.isTurnBoundaryComponent(component); this.state.transcriptContainer.addChild(component); } const trimmed = this.trimTranscriptWindow(); const merged = this.mergeCurrentTurnSteps(); + if (advancesTurn) this.applyFoldedSummaryExpansionState(); if (component || trimmed || merged) { this.state.ui.requestRender(); } @@ -2972,6 +2975,20 @@ export class KimiTUI { } const entry = getTranscriptComponentEntry(child); if (entry === undefined) return false; + return this.isTurnBoundaryEntry(entry); + } + + private isTurnBoundaryEntry(entry: TranscriptEntry): boolean { + if ( + entry.kind !== 'user' && + entry.kind !== 'skill_activation' && + entry.kind !== 'plugin_command' + ) { + return false; + } + // Inline skill cards belong to the following prompt's turn. Counting each + // card would shrink expansion and transcript windows for multi-skill prompts. + if (entry.kind === 'skill_activation' && entry.bundledWithPrompt === true) return false; // Live user messages / slash activations have an undefined turnId; replayed // ones get a `replay:N` turnId. Both start a new turn. Steer messages carry // a defined non-replay turnId and are not boundaries. @@ -3022,14 +3039,7 @@ export class KimiTUI { let boundariesToRemove = 0; for (const entry of toRemove) { - if ( - (entry.kind === 'user' || - entry.kind === 'skill_activation' || - entry.kind === 'plugin_command') && - entry.turnId === undefined - ) { - boundariesToRemove++; - } + if (this.isTurnBoundaryEntry(entry)) boundariesToRemove++; } if (boundariesToRemove === 0) { this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); @@ -3503,6 +3513,14 @@ export class KimiTUI { toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; + this.applyToolOutputExpansionState(); + // Differential render only — no destructive full redraw on expand/collapse. + // (When the expanded region reaches above the viewport, the engine's own + // fallback may still do a full render; that path is not forced from here.) + this.state.ui.requestRender(); + } + + private applyToolOutputExpansionState(): void { const children = this.state.transcriptContainer.children; const expandCutoff = this.expandCutoff(children); @@ -3511,10 +3529,17 @@ export class KimiTUI { if (!isExpandable(child)) continue; child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); } - // Differential render only — no destructive full redraw on expand/collapse. - // (When the expanded region reaches above the viewport, the engine's own - // fallback may still do a full render; that path is not forced from here.) - this.state.ui.requestRender(); + } + + private applyFoldedSummaryExpansionState(): void { + const children = this.state.transcriptContainer.children; + const expandCutoff = this.expandCutoff(children); + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + if (!(child instanceof StepSummaryComponent)) continue; + child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); + } } toggleTodoPanelExpansion(): void { 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 cbafb8bf192..04c50ef631e 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 @@ -30,10 +30,11 @@ import { AssistantMessageComponent } from '#/tui/components/messages/assistant-m import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { - groupTurns, + TRANSCRIPT_EXPAND_TURNS, TRANSCRIPT_KEEP_RECENT_ASSISTANT, TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, TRANSCRIPT_KEEP_RECENT_STEPS, + groupTurns, } from '#/tui/utils/transcript-window'; import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; @@ -8710,15 +8711,20 @@ describe('/effort support_efforts override', () => { }); describe('transcript step and assistant folding', () => { - function driveSteps(driver: MessageDriver, cycles: number): void { + function driveSteps( + driver: MessageDriver, + cycles: number, + turnId = 1, + messagePrefix = 'msg', + ): void { for (let i = 0; i < cycles; i++) { driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', sessionId: 'ses-1', - turnId: 1, - delta: `msg-${i} `, + turnId, + delta: `${messagePrefix}-${i} `, } as Event, vi.fn(), ); @@ -8727,8 +8733,8 @@ describe('transcript step and assistant folding', () => { type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', - turnId: 1, - toolCallId: `call_${i}`, + turnId, + toolCallId: `call_${turnId}_${i}`, name: 'Bash', args: { command: 'ls' }, } as Event, @@ -8739,8 +8745,8 @@ describe('transcript step and assistant folding', () => { type: 'tool.result', agentId: 'main', sessionId: 'ses-1', - turnId: 1, - toolCallId: `call_${i}`, + turnId, + toolCallId: `call_${turnId}_${i}`, output: 'ok', isError: undefined, } as Event, @@ -8835,6 +8841,77 @@ describe('transcript step and assistant folding', () => { driver.toggleToolOutputExpansion(); expect(stripSgr(renderTranscript(driver))).toContain('msg-0'); }); + + it('collapses expanded summaries after they leave the recent-turn window', async () => { + const { driver } = await makeDriver(); + const cycles = TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED + 1; + + for (let turnId = 1; turnId <= TRANSCRIPT_EXPAND_TURNS + 1; turnId++) { + driver.handleUserInput(`round ${turnId}`); + driveSteps(driver, cycles, turnId, `turn-${turnId}-msg`); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId, + reason: 'completed', + } as Event, + vi.fn(), + ); + if (turnId === 1) driver.toggleToolOutputExpansion(); + } + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('turn-1-msg-0'); + expect(transcript).toContain('turn-2-msg-0'); + expect(driver.state.toolOutputExpanded).toBe(true); + }); + + it('counts bundled skill cards and their prompt as one expansion turn', async () => { + const { driver } = await makeDriver(); + driver.appendTranscriptEntry({ + id: 'turn-1', + kind: 'user', + renderMode: 'plain', + content: 'first turn', + }); + + const message = new AssistantMessageComponent(); + message.updateContent('still inside the recent turn window'); + const summary = new StepSummaryComponent(); + summary.addCounts(0, 0, 1); + summary.addFoldedMessages([message]); + driver.state.transcriptContainer.addChild(summary); + + driver.appendTranscriptEntry({ + id: 'turn-2', + kind: 'user', + renderMode: 'plain', + content: 'second turn', + }); + for (let i = 0; i < 2; i++) { + driver.appendTranscriptEntry({ + id: `bundled-skill-${String(i)}`, + kind: 'skill_activation', + renderMode: 'plain', + content: `skill-${String(i)}`, + skillName: `skill-${String(i)}`, + skillTrigger: 'user-slash', + bundledWithPrompt: true, + }); + } + driver.appendTranscriptEntry({ + id: 'turn-3', + kind: 'user', + renderMode: 'plain', + content: 'third turn with bundled skills', + }); + + expect(stripSgr(renderTranscript(driver))).not.toContain('still inside the recent turn window'); + driver.toggleToolOutputExpansion(); + expect(stripSgr(renderTranscript(driver))).toContain('still inside the recent turn window'); + }); }); describe('footer ctrl+o hint', () => {