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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ Map non-English keyboard layout characters to Vim command keys.
}
```

### Vim escape sequence

Set a two-character sequence to leave insert mode without pressing `Escape`:

```json
{
"vim_escape_sequence": "jk"
}
```

## Neovim integration

Compatible with [`opencode.nvim`](https://github.com/nickjvandyke/opencode.nvim). Use the following server config:
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ export function Prompt(props: PromptProps) {
return !flashSpan || sel.start !== flashSpan.start || sel.end !== flashSpan.end
},
langmap: () => cfg.vim_langmap,
vimEscapeSequence: cfg.vim_escape_sequence,
submit,
commandPalette() {
keymap.dispatchCommand("command.palette.show")
Expand Down
73 changes: 66 additions & 7 deletions packages/tui/src/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,28 @@ export function createVimHandler(input: {
setRegister?: (register: VimRegister, notify?: boolean) => void
pasteOverSelection?: () => boolean
langmap?: Accessor<Record<string, string> | 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<typeof setTimeout> | null = null

function clearEscapePending() {
escapePending = false
if (escapeTimer) {
clearTimeout(escapeTimer)
escapeTimer = null
}
}

function hasModifier(event: VimEvent) {
return !!event.ctrl || !!event.meta || !!event.super
}
Expand Down Expand Up @@ -2142,20 +2158,63 @@ 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
// Escape always takes priority over pending escape sequence
if (event.name === "escape") {
clearEscapePending()
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(event)
if (escapePending) {

@leohenon leohenon Jun 29, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape key while this is pending will returns before the normal escape handling below. Can we make the escape key take priority here and add a small test?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed the change. Now, pressing j<esc> in Insert mode switches back to Normal mode.

clearEscapePending()
if (key === escapeSecond && !hasModifier(event)) {
// 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
}
}

clearEscapePending()
return false
}

clearEscapePending()

const mapped = langmapped(event)
const key = normalizedKeyName(mapped)
const result = dispatch(mapped, key)
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Info>
Expand Down
82 changes: 82 additions & 0 deletions packages/tui/test/cli/tui/vim-motions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ function createHandler(
snapshotDataEqual?: (before: unknown, after: unknown) => boolean
langmap?: Record<string, string>
copySearchAvailable?: boolean
vimEscapeSequence?: string
},
) {
const textarea = createTextarea(text, { strict: options?.strict })
Expand Down Expand Up @@ -379,6 +380,7 @@ function createHandler(
setRegister: options?.register?.set,
pasteOverSelection: options?.pasteOverSelection,
langmap: () => options?.langmap,
vimEscapeSequence: options?.vimEscapeSequence,
submit: options?.submit ?? (() => {}),
commandPalette() {
commandPaletteCalls.push(true)
Expand Down Expand Up @@ -6476,6 +6478,86 @@ function runFixture(f: ParaFixture) {
expect(ctx.state.register()).toEqual(f.reg)
}

describe("vim escape sequence", () => {
test("jk exits insert mode and removes the typed j", () => {
const ctx = createHandler("hello", { mode: "insert", vimEscapeSequence: "jk" })
ctx.textarea.cursorOffset = 3

expect(ctx.handler.handleKey(createEvent("j").event)).toBe(false)
ctx.textarea.insertText("j")
expect(ctx.state.mode()).toBe("insert")

const k = createEvent("k")
expect(ctx.handler.handleKey(k.event)).toBe(true)
expect(k.prevented()).toBe(true)
expect(ctx.state.mode()).toBe("normal")
expect(ctx.textarea.plainText).toBe("hello")
expect(ctx.textarea.cursorOffset).toBe(2)
})

test("jx stays in insert mode and keeps both chars", () => {
const ctx = createHandler("hello", { mode: "insert", vimEscapeSequence: "jk" })
ctx.textarea.cursorOffset = 3

expect(ctx.handler.handleKey(createEvent("j").event)).toBe(false)
ctx.textarea.insertText("j")
expect(ctx.state.mode()).toBe("insert")

expect(ctx.handler.handleKey(createEvent("x").event)).toBe(false)
ctx.textarea.insertText("x")
expect(ctx.state.mode()).toBe("insert")
expect(ctx.textarea.plainText).toBe("heljxlo")
})

test("j followed by timeout stays as normal input", async () => {
const ctx = createHandler("hello", { mode: "insert", vimEscapeSequence: "jk" })
ctx.textarea.cursorOffset = 3

expect(ctx.handler.handleKey(createEvent("j").event)).toBe(false)
ctx.textarea.insertText("j")
expect(ctx.state.mode()).toBe("insert")

await new Promise((resolve) => setTimeout(resolve, 350))

const k = createEvent("k")
expect(ctx.handler.handleKey(k.event)).toBe(false)
ctx.textarea.insertText("k")
expect(ctx.state.mode()).toBe("insert")
expect(ctx.textarea.plainText).toBe("heljklo")
})

test("j then ctrl+k does not complete the sequence", () => {
const ctx = createHandler("hello", { mode: "insert", vimEscapeSequence: "jk" })
ctx.textarea.cursorOffset = 3

expect(ctx.handler.handleKey(createEvent("j").event)).toBe(false)
ctx.textarea.insertText("j")
expect(ctx.state.mode()).toBe("insert")

const ctrlK = createEvent("k", { ctrl: true })
expect(ctx.handler.handleKey(ctrlK.event)).toBe(false)
expect(ctrlK.prevented()).toBe(false)
expect(ctx.state.mode()).toBe("insert")
expect(ctx.textarea.plainText).toBe("heljlo")
})

test("escape takes priority over pending escape sequence", () => {
const ctx = createHandler("hello", { mode: "insert", vimEscapeSequence: "jk" })
ctx.textarea.cursorOffset = 3

expect(ctx.handler.handleKey(createEvent("j").event)).toBe(false)
ctx.textarea.insertText("j")
expect(ctx.state.mode()).toBe("insert")

const esc = createEvent("escape")
expect(ctx.handler.handleKey(esc.event)).toBe(true)
expect(esc.prevented()).toBe(true)
expect(ctx.state.mode()).toBe("normal")
expect(ctx.textarea.plainText).toBe("heljlo")
expect(ctx.textarea.cursorOffset).toBe(3)
})
})

describe("vim paragraph operator parity", () => {
test("d} col 0 multi-line no trailing \\n: linewise", () => {
runFixture({
Expand Down
Loading