From 97bd6557b6f66e53011d4a783986ac0e99404de3 Mon Sep 17 00:00:00 2001 From: MicrogGrey Date: Mon, 8 Jun 2026 20:02:00 +0800 Subject: [PATCH 1/2] feat(tui): auto-undo interrupted prompt when turn has no output When the user interrupts a turn (Esc or Ctrl-C) before the model has produced any substantial content, automatically withdraw the prompt from the transcript and restore its text to the editor. A semantic guard scans the current turn: if an assistant message with non-empty text or any tool_call already exists, the cancel only stops the stream and leaves the prompt in place (unchanged behavior). Esc and Ctrl-C share the same semantics -- both are user-initiated interrupts, so the decision rests solely on the guard, not on which key was pressed. Extracts the transcript-cleanup logic from handleUndoCommand into a reusable applyUndoToTranscriptState so the auto-undo path can share it. Co-Authored-By: Claude Opus 4.8 --- apps/kimi-code/src/tui/commands/undo.ts | 17 +++++++- .../tui/controllers/session-event-handler.ts | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 03c3b025e..bcbe4c883 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -57,6 +57,21 @@ export async function handleUndoCommand( return; } + applyUndoToTranscriptState(host, count, entries, lastUserIndex); +} + +export interface UndoTranscriptHost { + state: SlashCommandHost['state']; +} + +export function applyUndoToTranscriptState( + host: UndoTranscriptHost, + count: number, + entries: TranscriptEntry[] = host.state.transcriptEntries, + lastUserIndex: number | undefined = findUndoAnchorEntryIndex(entries, count), +): void { + if (lastUserIndex === undefined) return; + const children = host.state.transcriptContainer.children; const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count); if (lastUserComponentIndex !== undefined) { @@ -176,7 +191,7 @@ function isUndoContextComponent(child: Component): boolean { ); } -function renderWelcome(host: SlashCommandHost): void { +function renderWelcome(host: UndoTranscriptHost): void { if ( host.state.transcriptContainer.children.some( (child) => child instanceof WelcomeComponent, diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 24f866ae1..90d23563a 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -83,6 +83,8 @@ import type { } from '../types'; import type { TUIState } from '../tui-state'; import { createGoal as startGoalCommand } from '../commands/goal'; +import { applyUndoToTranscriptState } from '../commands/undo'; +import type { UndoTranscriptHost } from '../commands/undo'; export interface SessionEventHost { state: TUIState; @@ -110,6 +112,7 @@ export interface SessionEventHost { shiftQueuedMessage(): QueuedMessage | undefined; readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; + updateEditorBorderHighlight(text?: string): void; } export class SessionEventHandler { @@ -317,6 +320,7 @@ export class SessionEventHandler { this.host.streamingUI.flushNow(); if (event.reason === 'cancelled') { this.markActiveAgentSwarmsCancelled(); + void this.tryAutoUndo(); } const todos = this.host.state.todoPanel.getTodos(); if (todos.length > 0 && todos.every((t) => t.status === 'done')) { @@ -374,6 +378,42 @@ export class SessionEventHandler { this.subAgentEventHandler.markActiveAgentSwarmsCancelled(); } + private async tryAutoUndo(): Promise { + // Esc 与 Ctrl-C 在 kimi-code 中语义一致(都是用户主动中断), + // 是否自动撤回只取决于语义守卫:本轮尚无实质内容时才撤回。 + const { host } = this; + const session = host.session; + if (session === undefined) return; + + // 语义守卫:本轮是否已产生实质内容(assistant 文字或 tool_call) + const entries = host.state.transcriptEntries; + let promptText: string | undefined; + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e === undefined) continue; + if (e.kind === 'user') { promptText = e.content; break; } + if (e.kind === 'assistant' && e.content.trim().length > 0) return; + if (e.kind === 'tool_call') return; + } + if (promptText === undefined) return; + + // 执行撤回 + try { + await session.undoHistory(1); + } catch { + return; // 触及 compaction boundary,静默放弃 + } + + applyUndoToTranscriptState(host as UndoTranscriptHost, 1); + + // 回填文字 + if (promptText.length > 0) { + host.state.editor.setText(promptText); + host.updateEditorBorderHighlight(promptText); + host.state.ui.requestRender(); + } + } + private isAnthropicSessionActive(): boolean { const { state } = this.host; const providerKey = state.appState.availableModels[state.appState.model]?.provider; From 2728e693b399c089b02f0cdbc12b5b9f1f63b3dc Mon Sep 17 00:00:00 2001 From: MicrogGrey Date: Mon, 8 Jun 2026 21:42:40 +0800 Subject: [PATCH 2/2] feat(tui): add tests, changeset, and docs for auto-undo on cancel - Fix semantic guard: check for any assistant entry (not non-empty content), since content is flushed into the entry object in place and may still be empty at guard evaluation time - Add four unit tests covering the auto-undo scenarios: no-output cancel withdraws prompt and refills editor; streamed text and tool_call prevent auto-undo; completed turns are not affected - Add changeset for @moonshot-ai/kimi-code (minor bump) - Update en/zh interaction guide and keyboard reference to document the new Esc/Ctrl-C behaviour Co-Authored-By: Claude Opus 4.8 --- .changeset/auto-undo-interrupted-prompt.md | 5 + .../tui/controllers/session-event-handler.ts | 37 +++++--- .../src/tui/controllers/streaming-ui.ts | 4 + .../test/tui/kimi-tui-message-flow.test.ts | 93 ++++++++++++++++++- docs/en/guides/interaction.md | 4 +- docs/en/reference/keyboard.md | 4 +- docs/zh/guides/interaction.md | 4 +- docs/zh/reference/keyboard.md | 4 +- 8 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 .changeset/auto-undo-interrupted-prompt.md diff --git a/.changeset/auto-undo-interrupted-prompt.md b/.changeset/auto-undo-interrupted-prompt.md new file mode 100644 index 000000000..f37d7e4ed --- /dev/null +++ b/.changeset/auto-undo-interrupted-prompt.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +When Esc or Ctrl-C interrupts a turn before the model has produced any output, the prompt is now automatically withdrawn from the transcript and its text is restored to the editor. If the model has already streamed text or issued a tool call, the interrupt only cancels the stream (unchanged behavior). diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 90d23563a..adc14a900 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -320,8 +320,15 @@ export class SessionEventHandler { this.host.streamingUI.flushNow(); if (event.reason === 'cancelled') { this.markActiveAgentSwarmsCancelled(); - void this.tryAutoUndo(); + // tryAutoUndo runs the undo before finalizeTurn so queued messages + // cannot be dispatched while the cancelled prompt is still in context. + void this.tryAutoUndo(sendQueued); + return; } + this.finalizeTurnTail(sendQueued); + } + + private finalizeTurnTail(sendQueued: (item: QueuedMessage) => void): void { const todos = this.host.state.todoPanel.getTodos(); if (todos.length > 0 && todos.every((t) => t.status === 'done')) { this.host.streamingUI.setTodoList([]); @@ -378,40 +385,44 @@ export class SessionEventHandler { this.subAgentEventHandler.markActiveAgentSwarmsCancelled(); } - private async tryAutoUndo(): Promise { - // Esc 与 Ctrl-C 在 kimi-code 中语义一致(都是用户主动中断), - // 是否自动撤回只取决于语义守卫:本轮尚无实质内容时才撤回。 + private async tryAutoUndo(sendQueued: (item: QueuedMessage) => void): Promise { + // Esc and Ctrl-C share the same "user interrupt" semantics in kimi-code. + // Whether to auto-undo depends solely on the semantic guard below. const { host } = this; const session = host.session; - if (session === undefined) return; + if (session === undefined) { this.finalizeTurnTail(sendQueued); return; } - // 语义守卫:本轮是否已产生实质内容(assistant 文字或 tool_call) + // Semantic guard: has this turn produced any substantial output? + // Check both transcript entries and live tool calls not yet written to transcript. + if (host.streamingUI.hasAnyActiveToolCall()) { this.finalizeTurnTail(sendQueued); return; } const entries = host.state.transcriptEntries; let promptText: string | undefined; for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]; if (e === undefined) continue; if (e.kind === 'user') { promptText = e.content; break; } - if (e.kind === 'assistant' && e.content.trim().length > 0) return; - if (e.kind === 'tool_call') return; + if (e.kind === 'assistant') { this.finalizeTurnTail(sendQueued); return; } + if (e.kind === 'tool_call') { this.finalizeTurnTail(sendQueued); return; } } - if (promptText === undefined) return; + if (promptText === undefined) { this.finalizeTurnTail(sendQueued); return; } - // 执行撤回 + // Withdraw the prompt before finalizing so queued messages cannot be + // dispatched while the cancelled prompt is still in context. try { await session.undoHistory(1); } catch { - return; // 触及 compaction boundary,静默放弃 + this.finalizeTurnTail(sendQueued); return; // hit compaction boundary } applyUndoToTranscriptState(host as UndoTranscriptHost, 1); - // 回填文字 + // Restore the prompt text to the editor. if (promptText.length > 0) { host.state.editor.setText(promptText); host.updateEditorBorderHighlight(promptText); - host.state.ui.requestRender(); } + + this.finalizeTurnTail(sendQueued); } private isAnthropicSessionActive(): boolean { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 881489ea7..756ddb5c0 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -144,6 +144,10 @@ export class StreamingUIController { return this._activeToolCalls.has(id); } + hasAnyActiveToolCall(): boolean { + return this._activeToolCalls.size > 0; + } + setActiveToolCall(id: string, toolCall: ToolCallBlockData): void { this._activeToolCalls.set(id, toolCall); } 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 c7266fae2..67e13fc15 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 @@ -1,6 +1,6 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { deleteAllKittyImages, @@ -1114,6 +1114,95 @@ command = "vim" expect(transcript).not.toContain('review'); }); + it('auto-undoes a cancelled turn that produced no output and refills the editor', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('write me a poem'); + driver.state.appState.streamingPhase = 'waiting'; + + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', sessionId: 'ses-1', turnId: 1, reason: 'cancelled' } as Event, + vi.fn(), + ); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + await vi.waitFor(() => { + expect(driver.state.transcriptEntries).toEqual([]); + }); + expect(driver.state.editor.getText()).toBe('write me a poem'); + }); + + it('does not auto-undo when the cancelled turn already streamed text', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('write me a poem'); + driver.state.appState.streamingPhase = 'waiting'; + // simulate assistant text arriving + driver.sessionEventHandler.handleEvent( + { type: 'assistant.delta', agentId: 'main', sessionId: 'ses-1', turnId: 1, delta: 'Once upon' } as Event, + vi.fn(), + ); + + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', sessionId: 'ses-1', turnId: 1, reason: 'cancelled' } as Event, + vi.fn(), + ); + + await Promise.resolve(); + expect(session.undoHistory).not.toHaveBeenCalled(); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ kind: 'user', content: 'write me a poem' }), + expect.objectContaining({ kind: 'assistant' }), + ]); + }); + + it('does not auto-undo when the cancelled turn already issued a tool_call', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('read the README'); + driver.state.appState.streamingPhase = 'waiting'; + // directly push a tool_call entry to simulate a tool call having started + driver.state.transcriptEntries.push({ + id: 'tc-entry-1', + kind: 'tool_call', + turnId: '1', + renderMode: 'plain', + content: 'Read file', + }); + + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', sessionId: 'ses-1', turnId: 1, reason: 'cancelled' } as Event, + vi.fn(), + ); + + await Promise.resolve(); + expect(session.undoHistory).not.toHaveBeenCalled(); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ kind: 'user', content: 'read the README' }), + expect.objectContaining({ kind: 'tool_call' }), + ]); + }); + + it('does not auto-undo when the turn ended with reason completed', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'waiting'; + + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', sessionId: 'ses-1', turnId: 1, reason: 'completed' } as Event, + vi.fn(), + ); + + await Promise.resolve(); + expect(session.undoHistory).not.toHaveBeenCalled(); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ kind: 'user', content: 'hello' }), + ]); + }); + it('sends pasted image placeholders as image content parts', async () => { const { driver, session } = await makeDriver(); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; @@ -3028,7 +3117,7 @@ command = "vim" driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource'); + expect(session.installPlugin).toHaveBeenCalledWith(resolve('/tmp/proj-a', 'plugins/kimi-datasource')); }); }); diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 92907d2c5..486f3bb4b 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -6,7 +6,7 @@ Kimi Code CLI runs as an interactive TUI (terminal user interface) built around The input box accepts free-form text. Press `Enter` to send, or `Shift-Enter` / `Ctrl-J` to insert a newline. When the input box is empty, press `↑` / `↓` to browse the input history for the current working directory. -**Exiting the CLI**: press `Ctrl-D` with the input box empty, press `Ctrl-C` twice while idle, or type `/exit`. Pressing `Ctrl-C` or `Esc` during streaming output interrupts the current turn — it does not exit the program. +**Exiting the CLI**: press `Ctrl-D` with the input box empty, press `Ctrl-C` twice while idle, or type `/exit`. Pressing `Ctrl-C` or `Esc` during streaming output interrupts the current turn — it does not exit the program. If the model has not produced any output yet when interrupted, the prompt is automatically withdrawn from the context and its text is restored to the input box. ## Pasting images and video @@ -69,7 +69,7 @@ YOLO mode skips confirmation for file writes and command execution. Only use it The input box remains usable while the agent is thinking or calling tools, and supports the following extra actions: - **`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 +- **`Esc` / `Ctrl-C`**: interrupt the current turn. If the model has not produced any output yet, the prompt is automatically withdrawn and its text is restored to the input box. - **`Ctrl-O`**: globally toggle the collapsed/expanded state of tool output ## External editor diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index 5c4eea7ff..ef8898f63 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -53,8 +53,8 @@ While streaming output is active, the input box can still receive input and supp | Shortcut | Function | | --- | --- | | `Ctrl-S` | Steer: inject the current input directly into the running turn | -| `Esc` | Interrupt the current streaming output | -| `Ctrl-C` | Interrupt the current streaming output | +| `Esc` | Interrupt the current streaming output. If no output has been produced yet, the prompt is automatically withdrawn and its text is restored to the input box. | +| `Ctrl-C` | Interrupt the current streaming output. If no output has been produced yet, the prompt is automatically withdrawn and its text is restored to the input box. | Pressing `Ctrl-S` causes the model to see your message at the next interruptible point, without waiting for the current turn to finish. diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index ca61d1f62..177b17001 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -6,7 +6,7 @@ Kimi Code CLI 以交互式 TUI 运行,核心由输入框、对话视图和状 输入框接受自由文本:`Enter` 发送,`Shift-Enter` 或 `Ctrl-J` 插入换行。输入框为空时按 `↑` / `↓` 浏览当前工作目录的历史输入。 -**退出 CLI**:输入框为空时按 `Ctrl-D`,或空闲状态下连按 `Ctrl-C` 两次,或输入 `/exit`。流式输出期间按 `Ctrl-C` 或 `Esc` 是中断当前轮次,不会退出程序。 +**退出 CLI**:输入框为空时按 `Ctrl-D`,或空闲状态下连按 `Ctrl-C` 两次,或输入 `/exit`。流式输出期间按 `Ctrl-C` 或 `Esc` 是中断当前轮次,不会退出程序。若 model 尚未产生任何输出,该 prompt 会被自动撤回,文字恢复到输入框。 ## 粘贴图片与视频 @@ -69,7 +69,7 @@ YOLO 模式会跳过文件写入和命令执行的确认,请只在受信任的 Agent 思考或调用工具时,输入框仍然可用,支持以下额外操作: - **`Ctrl-S`**:把输入框中的内容立即注入正在运行的轮次,无需等待结束 -- **`Esc` / `Ctrl-C`**:中断当前轮次 +- **`Esc` / `Ctrl-C`**:中断当前轮次。若 model 尚未产生任何输出,该 prompt 会被自动撤回,文字恢复到输入框。 - **`Ctrl-O`**:全局切换工具输出的折叠状态 ## 外部编辑器 diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index ddc6db4b9..9e3ddd2f3 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -53,8 +53,8 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用 | 快捷键 | 功能 | | --- | --- | | `Ctrl-S` | Steer:将当前输入立即注入正在运行的轮次 | -| `Esc` | 中断当前流式输出 | -| `Ctrl-C` | 中断当前流式输出 | +| `Esc` | 中断当前流式输出。若 model 尚未产生任何输出,该 prompt 会被自动撤回,文字恢复到输入框。 | +| `Ctrl-C` | 中断当前流式输出。若 model 尚未产生任何输出,该 prompt 会被自动撤回,文字恢复到输入框。 | 按 `Ctrl-S` 时,模型会在下一个可中断的时机立刻看到你的消息,无需等待当前轮次结束。