Skip to content
Merged
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/double-esc-undo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo.
11 changes: 11 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ function getNewlineInput(data: string): string | undefined {

export class CustomEditor extends Editor {
public onEscape?: () => void;
/**
* Fired for every input that is not a lone Escape. Used to disarm a pending
* double-Esc so only two consecutive Escape presses trigger the shortcut.
*/
public onNonEscapeInput?: () => void;
public onCtrlD?: () => void;
public onCtrlC?: () => void;
public onToggleToolExpand?: () => void;
Expand Down Expand Up @@ -296,6 +301,12 @@ export class CustomEditor extends Editor {
return;
}

// Any input other than a lone Escape breaks a pending double-Esc sequence,
// so the shortcut only fires for two consecutive Escape presses.
if (!matchesKey(normalized, Key.escape)) {
this.onNonEscapeInput?.();
}

// When a paste marker was just expanded, discard the trailing bracketed
// paste data that the terminal sends alongside the Ctrl-V keystroke.
if (this.consumingPaste) {
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/constant/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const CTRL_C_HINT = 'Press Ctrl+C again to exit';
export const MAIN_AGENT_ID = 'main';
export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.';
export const EXIT_CONFIRM_WINDOW_MS = 1500;
// Time window for treating two consecutive Esc presses as a double-Esc, which
// opens the undo selector. Kept short (double-click feel) so two deliberate
// presses far apart don't accidentally trigger undo.
export const DOUBLE_ESC_WINDOW_MS = 600;

export function isManagedUsageProvider(
providerKey: string | undefined,
Expand Down
35 changes: 35 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/exte
import {
CTRL_C_HINT,
CTRL_D_HINT,
DOUBLE_ESC_WINDOW_MS,
EXIT_CONFIRM_WINDOW_MS,
LLM_NOT_SET_MESSAGE,
NO_ACTIVE_SESSION_MESSAGE,
Expand Down Expand Up @@ -35,6 +36,7 @@ export interface EditorKeyboardHost {
detachCurrentForegroundTask(): void;
cancelRunningShellCommand(): void;
hideSessionPicker(): void;
openUndoSelector(): void;
stop(exitCode?: number): Promise<void>;
handlePlanToggle(next: boolean): void;
handleInputModeChange(mode: 'prompt' | 'bash'): void;
Expand All @@ -44,6 +46,7 @@ export interface EditorKeyboardHost {

export class EditorKeyboardController {
private pendingExit: PendingExit | null = null;
private pendingUndoEsc: { readonly timer: ReturnType<typeof setTimeout> } | null = null;

constructor(
private readonly host: EditorKeyboardHost,
Expand All @@ -63,6 +66,10 @@ export class EditorKeyboardController {
host.updateEditorBorderHighlight(text);
};

editor.onNonEscapeInput = () => {
this.clearPendingUndoEsc();
};

editor.onCtrlC = () => {
if (host.cancelInFlight !== undefined) {
const cancel = host.cancelInFlight;
Expand Down Expand Up @@ -124,18 +131,30 @@ export class EditorKeyboardController {
if (this.pendingExit) this.clearPendingExit();
if (host.state.activeDialog === 'session-picker') {
host.hideSessionPicker();
this.clearPendingUndoEsc();
return;
}
if (host.state.appState.isCompacting) {
this.cancelCurrentCompaction();
this.clearPendingUndoEsc();
return;
}
if (host.btwPanelController.closeOrCancel()) {
this.clearPendingUndoEsc();
return;
}
if (host.state.appState.streamingPhase !== 'idle') {
this.cancelCurrentStream();
this.clearPendingUndoEsc();
return;
}
// Idle: a second Esc within the double-tap window opens the undo selector.
if (this.pendingUndoEsc !== null) {
this.clearPendingUndoEsc();
host.openUndoSelector();
return;
}
this.armPendingUndoEsc();
Comment thread
liruifengv marked this conversation as resolved.
};

editor.onShiftTab = () => {
Expand Down Expand Up @@ -264,6 +283,22 @@ export class EditorKeyboardController {
this.pendingExit = null;
}

private armPendingUndoEsc(): void {
this.clearPendingUndoEsc();
const timer = setTimeout(() => {
if (this.pendingUndoEsc?.timer === timer) {
this.pendingUndoEsc = null;
}
}, DOUBLE_ESC_WINDOW_MS);
this.pendingUndoEsc = { timer };
}

private clearPendingUndoEsc(): void {
if (!this.pendingUndoEsc) return;
clearTimeout(this.pendingUndoEsc.timer);
this.pendingUndoEsc = null;
}

private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void {
this.clearPendingExit();
this.host.state.footer.setTransientHint(hint);
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2614,6 +2614,10 @@ export class KimiTUI {
this.restoreEditor();
}

openUndoSelector(): void {
void slashCommands.handleUndoCommand(this, '');
}

private mountSessionPicker(options: {
readonly onCancel: () => void;
readonly onCtrlC?: () => void;
Expand Down
23 changes: 23 additions & 0 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,29 @@ describe('CustomEditor autocomplete Escape handling', () => {
});
});

describe('CustomEditor onNonEscapeInput', () => {
it('fires for a printable key and not for a lone Escape', () => {
const editor = makeEditor();
const onNonEscapeInput = vi.fn();
editor.onNonEscapeInput = onNonEscapeInput;

editor.handleInput('a');
expect(onNonEscapeInput).toHaveBeenCalledOnce();

editor.handleInput('\u001B');
expect(onNonEscapeInput).toHaveBeenCalledOnce();
});

it('fires for control keys so they break a pending double-Esc', () => {
const editor = makeEditor();
const onNonEscapeInput = vi.fn();
editor.onNonEscapeInput = onNonEscapeInput;

editor.handleInput('\u0003');
expect(onNonEscapeInput).toHaveBeenCalledOnce();
});
});

describe('CustomEditor slash argument completion refresh', () => {
it('reopens /add-dir directory completions after tab completion and entering slash', async () => {
const editor = makeEditor();
Expand Down
121 changes: 121 additions & 0 deletions apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DOUBLE_ESC_WINDOW_MS } from '#/tui/constant/kimi-tui';
import {
EditorKeyboardController,
type EditorKeyboardHost,
} from '#/tui/controllers/editor-keyboard';
import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';

interface Harness {
readonly host: EditorKeyboardHost;
readonly editor: Record<string, ((...args: never[]) => unknown) | undefined>;
readonly openUndoSelector: ReturnType<typeof vi.fn>;
readonly cancelRunningShellCommand: ReturnType<typeof vi.fn>;
}

function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {};
const openUndoSelector = vi.fn();
const cancelRunningShellCommand = vi.fn();
const session = { cancel: vi.fn(async () => {}) };

const host = {
state: {
editor,
activeDialog: null,
appState: {
streamingPhase: options.streamingPhase ?? 'idle',
isCompacting: options.isCompacting ?? false,
},
footer: { setTransientHint: vi.fn() },
ui: { requestRender: vi.fn() },
},
session,
btwPanelController: { closeOrCancel: vi.fn(() => false) },
openUndoSelector,
cancelRunningShellCommand,
} as unknown as EditorKeyboardHost;

const controller = new EditorKeyboardController(
host,
undefined as unknown as ImageAttachmentStore,
);
controller.install();

return { host, editor, openUndoSelector, cancelRunningShellCommand };
}

function pressEscape(editor: Harness['editor']): void {
const handler = editor['onEscape'];
if (handler === undefined) throw new Error('onEscape handler not installed');
(handler as () => void)();
}

function pressNonEscape(editor: Harness['editor']): void {
const handler = editor['onNonEscapeInput'];
if (handler === undefined) throw new Error('onNonEscapeInput handler not installed');
(handler as () => void)();
}

describe('EditorKeyboardController double-Esc undo', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('opens the undo selector when Esc is pressed twice within the window while idle', () => {
const { editor, openUndoSelector } = createHarness();

pressEscape(editor);
expect(openUndoSelector).not.toHaveBeenCalled();

pressEscape(editor);
expect(openUndoSelector).toHaveBeenCalledOnce();
});

it('does nothing for a single Esc while idle', () => {
const { editor, openUndoSelector } = createHarness();

pressEscape(editor);

expect(openUndoSelector).not.toHaveBeenCalled();
});

it('does not trigger when the second Esc arrives after the window expires', () => {
const { editor, openUndoSelector } = createHarness();

pressEscape(editor);
vi.advanceTimersByTime(DOUBLE_ESC_WINDOW_MS + 1);
pressEscape(editor);

expect(openUndoSelector).not.toHaveBeenCalled();
});

it('does not trigger when another key is pressed between the two Esc presses', () => {
const { editor, openUndoSelector } = createHarness();

pressEscape(editor);
pressNonEscape(editor);
pressEscape(editor);

expect(openUndoSelector).not.toHaveBeenCalled();
});

it('does not trigger undo while streaming; Esc cancels the stream instead', () => {
const { editor, host, openUndoSelector, cancelRunningShellCommand } = createHarness({
streamingPhase: 'waiting',
});

pressEscape(editor);
pressEscape(editor);

expect(openUndoSelector).not.toHaveBeenCalled();
expect(cancelRunningShellCommand).toHaveBeenCalled();
const session = host.session as unknown as { cancel: ReturnType<typeof vi.fn> };
expect(session.cancel).toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions docs/en/reference/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Type `!` in an empty input box to enter shell mode and run terminal commands dir
| `Ctrl-V` | Paste an image or video from the clipboard (Unix / macOS) |
| `Alt-V` | Paste an image or video from the clipboard (Windows) |
| `Ctrl--` | Undo |
| `Esc` `Esc` | Open the undo selector (double-press while idle) |

Pressing `Ctrl-G` opens an external editor, selected according to the following priority:

Expand Down
1 change: 1 addition & 0 deletions docs/zh/reference/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用
| `Ctrl-V` | 粘贴剪贴板中的图片或视频(Unix / macOS) |
| `Alt-V` | 粘贴剪贴板中的图片或视频(Windows) |
| `Ctrl--` | 撤销(Undo) |
| `Esc` `Esc` | 双击打开撤销选择框(空闲状态下) |

按 `Ctrl-G` 会打开外部编辑器,编辑器按以下优先级选择:

Expand Down
Loading