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/auto-undo-interrupted-prompt.md
Original file line number Diff line number Diff line change
@@ -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).
17 changes: 16 additions & 1 deletion apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,6 +112,7 @@ export interface SessionEventHost {
shiftQueuedMessage(): QueuedMessage | undefined;
readonly btwPanelController: BtwPanelController;
readonly tasksBrowserController: TasksBrowserController;
updateEditorBorderHighlight(text?: string): void;
}

export class SessionEventHandler {
Expand Down Expand Up @@ -317,7 +320,15 @@ export class SessionEventHandler {
this.host.streamingUI.flushNow();
if (event.reason === 'cancelled') {
this.markActiveAgentSwarmsCancelled();
// 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([]);
Expand Down Expand Up @@ -374,6 +385,46 @@ export class SessionEventHandler {
this.subAgentEventHandler.markActiveAgentSwarmsCancelled();
}

private async tryAutoUndo(sendQueued: (item: QueuedMessage) => void): Promise<void> {
// 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) { this.finalizeTurnTail(sendQueued); return; }

// 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') { this.finalizeTurnTail(sendQueued); return; }
if (e.kind === 'tool_call') { this.finalizeTurnTail(sendQueued); 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 {
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);
}

this.finalizeTurnTail(sendQueued);
}

private isAnthropicSessionActive(): boolean {
const { state } = this.host;
const providerKey = state.appState.availableModels[state.appState.model]?.provider;
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
93 changes: 91 additions & 2 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'));
});
});

Expand Down
4 changes: 2 additions & 2 deletions docs/en/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/en/reference/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 会被自动撤回,文字恢复到输入框。

## 粘贴图片与视频

Expand Down Expand Up @@ -69,7 +69,7 @@ YOLO 模式会跳过文件写入和命令执行的确认,请只在受信任的
Agent 思考或调用工具时,输入框仍然可用,支持以下额外操作:

- **`Ctrl-S`**:把输入框中的内容立即注入正在运行的轮次,无需等待结束
- **`Esc` / `Ctrl-C`**:中断当前轮次
- **`Esc` / `Ctrl-C`**:中断当前轮次。若 model 尚未产生任何输出,该 prompt 会被自动撤回,文字恢复到输入框。
- **`Ctrl-O`**:全局切换工具输出的折叠状态

## 外部编辑器
Expand Down
4 changes: 2 additions & 2 deletions docs/zh/reference/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用
| 快捷键 | 功能 |
| --- | --- |
| `Ctrl-S` | Steer:将当前输入立即注入正在运行的轮次 |
| `Esc` | 中断当前流式输出 |
| `Ctrl-C` | 中断当前流式输出 |
| `Esc` | 中断当前流式输出。若 model 尚未产生任何输出,该 prompt 会被自动撤回,文字恢复到输入框。 |
| `Ctrl-C` | 中断当前流式输出。若 model 尚未产生任何输出,该 prompt 会被自动撤回,文字恢复到输入框。 |

按 `Ctrl-S` 时,模型会在下一个可中断的时机立刻看到你的消息,无需等待当前轮次结束。

Expand Down
Loading