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
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 @@ -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")
Expand Down
71 changes: 64 additions & 7 deletions packages/tui/src/component/vim/vim-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,28 @@ export function createVimHandler(input: {
register?: () => VimRegister
setRegister?: (register: VimRegister, notify?: boolean) => void
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 @@ -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)
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