diff --git a/.changeset/double-esc-undo.md b/.changeset/double-esc-undo.md new file mode 100644 index 000000000..a48970c97 --- /dev/null +++ b/.changeset/double-esc-undo.md @@ -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. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 46f3a6965..da5126fda 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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; @@ -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) { diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index ed05d93e1..8c8f9807b 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -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, diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index a4cba595f..db3717f83 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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, @@ -35,6 +36,7 @@ export interface EditorKeyboardHost { detachCurrentForegroundTask(): void; cancelRunningShellCommand(): void; hideSessionPicker(): void; + openUndoSelector(): void; stop(exitCode?: number): Promise; handlePlanToggle(next: boolean): void; handleInputModeChange(mode: 'prompt' | 'bash'): void; @@ -44,6 +46,7 @@ export interface EditorKeyboardHost { export class EditorKeyboardController { private pendingExit: PendingExit | null = null; + private pendingUndoEsc: { readonly timer: ReturnType } | null = null; constructor( private readonly host: EditorKeyboardHost, @@ -63,6 +66,10 @@ export class EditorKeyboardController { host.updateEditorBorderHighlight(text); }; + editor.onNonEscapeInput = () => { + this.clearPendingUndoEsc(); + }; + editor.onCtrlC = () => { if (host.cancelInFlight !== undefined) { const cancel = host.cancelInFlight; @@ -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(); }; editor.onShiftTab = () => { @@ -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); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f2cee5888..b8730f18c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2614,6 +2614,10 @@ export class KimiTUI { this.restoreEditor(); } + openUndoSelector(): void { + void slashCommands.handleUndoCommand(this, ''); + } + private mountSessionPicker(options: { readonly onCancel: () => void; readonly onCtrlC?: () => void; diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 1aa68aa90..46b44f1e0 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -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(); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts new file mode 100644 index 000000000..5bc96cd74 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -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 unknown) | undefined>; + readonly openUndoSelector: ReturnType; + readonly cancelRunningShellCommand: ReturnType; +} + +function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { + const editor: Record 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 }; + expect(session.cancel).toHaveBeenCalled(); + }); +}); diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index 128c95680..3d7a52898 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -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: diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 42aabcf9a..4a1c80c80 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -39,6 +39,7 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用 | `Ctrl-V` | 粘贴剪贴板中的图片或视频(Unix / macOS) | | `Alt-V` | 粘贴剪贴板中的图片或视频(Windows) | | `Ctrl--` | 撤销(Undo) | +| `Esc` `Esc` | 双击打开撤销选择框(空闲状态下) | 按 `Ctrl-G` 会打开外部编辑器,编辑器按以下优先级选择: