From 7e77f86d18af8df96164b719be0f88579cc98da8 Mon Sep 17 00:00:00 2001 From: Nikhil Singh Date: Sun, 14 Jun 2026 17:00:36 +0530 Subject: [PATCH] feat(vim): add configurable two-key escape sequence Add vim_escape_sequence config option to tui.json for exiting vim insert mode with a two-character sequence (e.g., 'jk'). - Add vim_escape_sequence to TUI config schema (exactly 2 chars) - Thread config through prompt component to vim handler - Implement 300ms pending state with proper deleteRange cleanup - Falls back to default Escape key when unset Example tui.json: {"vim_escape_sequence": "jk"} --- packages/tui/src/component/prompt/index.tsx | 1 + packages/tui/src/component/vim/vim-handler.ts | 71 +++++++++++++++++-- packages/tui/src/config/index.tsx | 3 + 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index f99959b558e8..4db23bb4f5c7 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -615,6 +615,7 @@ export function Prompt(props: PromptProps) { register: () => (useSystemClipboardRegister() ? clipboardRegister : vimState.register()), setRegister: setVimRegister, langmap: () => cfg.vim_langmap, + vimEscapeSequence: cfg.vim_escape_sequence, submit, scroll(action) { if (action === "line-down") keymap.dispatchCommand("session.line.down") diff --git a/packages/tui/src/component/vim/vim-handler.ts b/packages/tui/src/component/vim/vim-handler.ts index 1ddf219dee5d..44c47b4c3bb2 100644 --- a/packages/tui/src/component/vim/vim-handler.ts +++ b/packages/tui/src/component/vim/vim-handler.ts @@ -168,12 +168,28 @@ export function createVimHandler(input: { register?: () => VimRegister setRegister?: (register: VimRegister, notify?: boolean) => void langmap?: Accessor | undefined> + vimEscapeSequence?: string }) { let wantedColumn: VimWantedColumn | undefined let pendingOperatorCount = 1 let pendingOperatorFind: { operation: VimOperator; find: VimFindOperator } | undefined let pendingTextObject: { operation: VimOperator; scope: VimTextObjectScope } | undefined + // Two-key escape sequence support (e.g., "jk" to escape insert mode) + const escapeSeq = input.vimEscapeSequence + const escapeFirst = escapeSeq?.[0] + const escapeSecond = escapeSeq?.[1] + let escapePending = false + let escapeTimer: ReturnType | null = null + + function clearEscapePending() { + escapePending = false + if (escapeTimer) { + clearTimeout(escapeTimer) + escapeTimer = null + } + } + function hasModifier(event: VimEvent) { return !!event.ctrl || !!event.meta || !!event.super } @@ -2104,20 +2120,61 @@ export function createVimHandler(input: { } if (input.state.isCopy()) { + clearEscapePending() const mapped = input.copySearchActive?.() ? event : langmapped(event) return copy(mapped, normalizedKeyName(mapped)) } if (input.state.isInsert()) { - if (event.name !== "escape") return false - input.state.setMode("normal") - input.state.commitEdit(snapshot()) - moveLeft(input.textarea()) - repeat.commit(snapshot()) - event.preventDefault() - return true + // Two-key escape sequence support (e.g., "jk" to exit insert mode) + if (escapeSeq) { + const key = normalizedKeyName(langmapped(event)) + if (escapePending) { + clearEscapePending() + if (key === escapeSecond) { + // Remove the first char that was already typed using proper textarea API + const pos = input.textarea().cursorOffset + if (pos > 0) { + const start = input.textarea().editBuffer.offsetToPosition(pos - 1) + const end = input.textarea().editBuffer.offsetToPosition(pos) + if (start && end) { + input.textarea().deleteRange(start.row, start.col, end.row, end.col) + input.textarea().cursorOffset = pos - 1 + } + } + // Trigger escape + input.state.setMode("normal") + input.state.commitEdit(snapshot()) + moveLeft(input.textarea()) + repeat.commit(snapshot()) + event.preventDefault() + return true + } + // Not the escape sequence; first char already typed, let current key through + return false + } + if (key === escapeFirst && !hasModifier(event)) { + escapePending = true + escapeTimer = setTimeout(clearEscapePending, 300) + return false // Let first char type normally + } + } + + if (event.name === "escape") { + clearEscapePending() + input.state.setMode("normal") + input.state.commitEdit(snapshot()) + moveLeft(input.textarea()) + repeat.commit(snapshot()) + event.preventDefault() + return true + } + clearEscapePending() + return false } + clearEscapePending() + const mapped = langmapped(event) const key = normalizedKeyName(mapped) const result = dispatch(mapped, key) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index ad9a145a6627..d70f9d1cf98b 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -87,6 +87,9 @@ export const Info = Schema.Struct({ description: "Use the system clipboard instead of Vim's internal register for yank and paste", }), vim_langmap: Schema.optional(VimLangmap), + vim_escape_sequence: Schema.optional(Schema.String.check(Schema.isPattern(/^.{2}$/u))).annotate({ + description: "Two-character sequence to exit vim insert mode (e.g., 'jk')", + }), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) export type Info = Schema.Schema.Type