opencode-vim 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,17 @@
1
1
  import type { KeyEvent } from "@opentui/core"
2
- import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext } from "@vimee/core"
2
+ import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext, resolveMotion } from "@vimee/core"
3
3
  import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
4
- import type { PromptContext } from "../../prompt/types"
5
- import { focusedInput, setInput, type EditBufferLike } from "./actions"
4
+ import { focusedInput, setInput, type EditBufferLike, type PromptContext } from "./actions"
6
5
  import type { VimConfig } from "./config"
7
6
  import type { VimLog } from "./log"
8
- import { createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
7
+ import { createGraphemeCodec } from "./graphemes"
8
+ import { charToDisplay, displayToChar, displayWidth, createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
9
9
  import type { createVimState } from "./state"
10
10
 
11
11
  type VimState = ReturnType<typeof createVimState>
12
- type HostAction = VimeeAction | { type: "submit" }
13
- type HostKeybindAction = "normal" | "submit"
14
- type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction }
12
+ type HostAction = VimeeAction | { type: "submit" } | { type: "command"; command: string }
13
+ type HostKeybindAction = "normal" | "submit" | "command"
14
+ type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction; command?: string }
15
15
  type HostRange = { start: number; end: number }
16
16
 
17
17
  const YANK_FLASH_MS = 250
@@ -19,8 +19,9 @@ const YANK_FLASH_MS = 250
19
19
  export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
20
20
 
21
21
  export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog) {
22
+ const codec = createGraphemeCodec()
22
23
  let buffer = new TextBuffer("")
23
- let activeMap = createPromptMap("")
24
+ let activeMap = createPromptMap("", undefined, codec)
24
25
  const maps = new Map([[activeMap.vimText, activeMap]])
25
26
  let vim = createInitialContext({ line: 0, col: 0 })
26
27
  const keybinds = createKeybinds(config, log)
@@ -28,28 +29,36 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
28
29
  let yankTimer: ReturnType<typeof setTimeout> | undefined
29
30
  let yankFlashActive = false
30
31
  let pendingInsert = ""
32
+ let pendingTarget: { input: EditBufferLike; offset: number } | undefined
33
+ let pendingContext: PromptContext | undefined
31
34
  let nativeInsertUndoSaved = false
35
+ let historyText: string | undefined
36
+ let activeInput: EditBufferLike | undefined
37
+ let mapWidth: number | undefined
38
+ let mapWrapMode: string | undefined
39
+ const defaultHistoryKeys = {
40
+ k: !hasNormalKeyPrefix(config, "k"),
41
+ j: !hasNormalKeyPrefix(config, "j"),
42
+ }
43
+ const appendMapped = hasNormalKeyPrefix(config, "A")
32
44
 
33
45
  return {
46
+ attach,
47
+ suspend,
48
+ isPending: () => vim.phase !== "idle" || vim.count > 0 || !!keybinds?.isPending(),
34
49
  handle(event: KeyEvent, key: string, ctx: PromptContext) {
35
50
  const ref = ctx.prompt()
36
51
  if (!ref) return false
52
+ const input = focusedInput(ctx)
53
+ attach(ctx)
37
54
 
38
55
  let vimeeKey = keyForVimee(event, key)
39
56
  if (!vimeeKey) return false
40
-
41
- if (state.mode() === "normal" && key === "<CR>") {
42
- ref.submit()
43
- return true
44
- }
57
+ vimeeKey = codec.encode(vimeeKey)
45
58
 
46
59
  if (state.mode() === "insert") {
47
- if (key === "<CR>") {
48
- cancelPendingInsert(ctx)
49
- return false
50
- }
51
60
  if (vimeeKey === "Escape") {
52
- cancelPendingInsert(ctx, undefined, false)
61
+ cancelPendingInsert(ctx)
53
62
  enterNormal(ctx)
54
63
  state.setPending("")
55
64
  updateTimeout(ctx)
@@ -59,82 +68,172 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
59
68
  return handleInsertKeybind(event, vimeeKey, ctx)
60
69
  }
61
70
 
62
- const input = focusedInput(ctx)
63
71
  const text = input?.plainText ?? ref.current.input
64
- const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
72
+ const dw = displayWidth(text)
73
+ const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
74
+ const charOff = displayToChar(text, displayOff)
75
+
76
+ const canBrowseHistory = vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
77
+ const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined
78
+ if (historyCommand) {
79
+ const result = dispatchCommand(historyCommand, ctx)
80
+ historyText = result.ok ? (focusedInput(ctx)?.plainText ?? ref.current.input) : undefined
81
+ state.setPending("")
82
+ updateTimeout(ctx)
83
+ log("vimee.history", { key, command: historyCommand, handled: result.ok })
84
+ return true
85
+ }
86
+ historyText = undefined
65
87
 
66
88
  const map = mapForHostText(text, input)
67
- const cursor = hostPosition(map, offset)
89
+ const cursor = hostPosition(map, displayOff)
68
90
 
69
91
  const wasPending = keybinds?.isPending() ?? false
70
92
  const pendingBefore = pendingInsert
71
93
  sync(map, cursor)
72
94
 
73
- if (vimeeKey === "A" && appendVisualLine(input, map)) {
95
+ if (vimeeKey === "A" && vim.mode === "normal" && vim.phase === "idle" && !wasPending && !appendMapped && appendVisualLine(input, map)) {
74
96
  state.setPending("")
75
97
  updateTimeout(ctx)
76
98
  log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
77
99
  return true
78
100
  }
79
101
 
80
- const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined
102
+ const hostEnd = vimeeKey === "e" && vim.phase === "idle" ? charToDisplay(map.hostText, endMotionOffset(map.hostText, charOff, vim.count || 1)) : undefined
81
103
  const shouldFlashYank = shouldFlashYankFor(vimeeKey)
82
104
  const visualYankRange = visualYankRangeFor(map)
83
105
  vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
84
106
  const textObjectHandled = handleTextObject(vimeeKey, ctx, map)
85
107
  if (textObjectHandled !== undefined) return textObjectHandled
86
- const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
108
+ const resolved = keybinds?.resolve(vimeeKey, vim.mode, event.ctrl)
109
+ if (resolved?.status === "pending") {
110
+ vim = { ...vim, statusMessage: resolved.display }
111
+ state.setPending(resolved.display)
112
+ updateTimeout(ctx)
113
+ log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: [] })
114
+ return true
115
+ }
116
+ if (vimeeKey === "Enter" && state.mode() === "normal" && vim.phase === "idle" && !wasPending && resolved?.status !== "matched") {
117
+ ref.submit()
118
+ state.setPending("")
119
+ updateTimeout(ctx)
120
+ log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["submit"] })
121
+ return true
122
+ }
123
+
124
+ let result
125
+ if (resolved?.status === "matched") {
126
+ const actions = applyKeybind(resolved.definition, map)
127
+ result = { newCtx: vim, actions }
128
+ } else {
129
+ result = processKey(vimeeKey, event.ctrl)
130
+ }
87
131
  vim = result.newCtx
88
- if (hostEnd !== undefined && result.actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
132
+ const actions = result.actions as HostAction[]
133
+ if (hostEnd !== undefined && actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
89
134
  let clampFinalCursor = true
90
- const content = result.actions.find((action) => action.type === "content-change")?.content
91
- if (vimeeKey === "x" && content !== undefined) {
135
+ const content = actions.find((action) => action.type === "content-change")?.content
136
+ if (vimeeKey === "x" && state.mode() === "normal" && content !== undefined) {
92
137
  const next = nextMap(map, content)
93
- const target = clamp(offset, 0, Math.max(0, next.hostText.length - 1))
94
- if (offset < map.hostText.length - 1 && map.hostText[offset + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
138
+ const nextDW = displayWidth(next.hostText)
139
+ const target = clamp(displayOff, 0, Math.max(0, nextDW - 1))
140
+ if (charOff < map.hostText.length - 1 && map.hostText[charOff + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
95
141
  vim = { ...vim, cursor: hostPosition(next, target) }
96
142
  clampFinalCursor = false
97
143
  }
98
144
  }
99
- applyActions(result.actions as HostAction[], ctx, map, clampFinalCursor)
100
- if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(result.actions), visualYankRange)
145
+ applyActions(actions, ctx, map, clampFinalCursor)
146
+ if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange)
101
147
  syncMode(state, vim.mode)
148
+
102
149
  const keybindPending = keybinds?.isPending() ?? false
103
- if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, offset)
150
+ if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
104
151
  pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
105
152
  state.setPending(pendingDisplay(vim, keybindPending))
106
153
  updateTimeout(ctx)
107
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
108
- return consumesKey(vimeeKey, result.actions, vim, keybindPending)
154
+ log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
155
+ return consumesKey(vimeeKey, actions, vim, keybindPending)
109
156
  },
110
157
  cleanup() {
111
- if (timer) clearTimeout(timer)
112
- if (yankTimer) clearTimeout(yankTimer)
158
+ suspend()
159
+ maps.clear()
113
160
  },
114
161
  }
115
162
 
163
+ function attach(ctx: PromptContext) {
164
+ const input = focusedInput(ctx)
165
+ if (activeInput === input) return
166
+ suspend()
167
+ activeInput = input
168
+ activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", input, codec)
169
+ maps.clear()
170
+ rememberMap(activeMap)
171
+ buffer = new TextBuffer(activeMap.vimText)
172
+ nativeInsertUndoSaved = false
173
+ vim = { ...resetContext(vim), cursor: hostPosition(activeMap, input?.cursorOffset ?? 0), mode: state.mode() }
174
+ mapWidth = input?.width
175
+ mapWrapMode = input?.wrapMode
176
+ }
177
+
178
+ function suspend() {
179
+ if (pendingContext) cancelPendingInsert(pendingContext)
180
+ if (timer) clearTimeout(timer)
181
+ timer = undefined
182
+ keybinds?.cancel()
183
+ cancelYankFlash()
184
+ if (activeInput && !activeInput.isDestroyed) clearVisualSelection(activeInput)
185
+ if (isVisualMode(state.mode())) state.setMode("normal")
186
+ vim = resetContext(vim)
187
+ state.setPending("")
188
+ pendingTarget = undefined
189
+ pendingContext = undefined
190
+ }
191
+
116
192
  function sync(map: PromptMap, cursor: CursorPosition) {
117
193
  if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
118
194
  vim = { ...vim, cursor, mode: state.mode() }
119
195
  }
120
196
 
121
197
  function mapForHostText(text: string, input: ReturnType<typeof focusedInput>) {
122
- if (activeMap.hostText === text) return activeMap
123
- activeMap = createPromptMap(text, input)
198
+ const textChanged = activeMap.hostText !== text
199
+ if (!textChanged && mapWidth === input?.width && mapWrapMode === input?.wrapMode) return activeMap
200
+ if (textChanged && state.mode() === "insert") recordNativeChange(text)
201
+ activeMap = createPromptMap(text, input, codec)
202
+ mapWidth = input?.width
203
+ mapWrapMode = input?.wrapMode
204
+ if (textChanged && state.mode() !== "insert") maps.clear()
124
205
  rememberMap(activeMap)
125
- if (state.mode() === "insert") {
206
+ if (textChanged && state.mode() === "insert") {
126
207
  if (!nativeInsertUndoSaved) {
127
208
  buffer.saveUndoPoint(vim.cursor)
128
209
  nativeInsertUndoSaved = true
129
210
  }
130
211
  buffer.replaceContent(activeMap.vimText)
131
- } else {
212
+ } else if (textChanged) {
132
213
  buffer = new TextBuffer(activeMap.vimText)
133
214
  nativeInsertUndoSaved = false
215
+ } else {
216
+ buffer.replaceContent(activeMap.vimText)
134
217
  }
135
218
  return activeMap
136
219
  }
137
220
 
221
+ function recordNativeChange(text: string) {
222
+ const before = codec.encode(activeMap.hostText)
223
+ const after = codec.encode(text)
224
+ const start = codec.encode(activeMap.hostText.slice(0, displayToChar(activeMap.hostText, hostOffset(activeMap, vim.cursor)))).length
225
+ let prefix = 0
226
+ while (prefix < start && prefix < after.length && before[prefix] === after[prefix]) prefix++
227
+ let suffix = 0
228
+ while (suffix < before.length - start && suffix < after.length - prefix && before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++
229
+ const keys = [...vim.pendingChange]
230
+ if (!keys.length) keys.push("i")
231
+ for (let index = prefix; index < start; index++) keys.push("Backspace")
232
+ for (let index = start; index < before.length - suffix; index++) keys.push("Delete")
233
+ for (const key of after.slice(prefix, after.length - suffix)) keys.push(key === "\n" ? "Enter" : key === "\t" ? "Tab" : key)
234
+ vim = { ...vim, pendingChange: keys }
235
+ }
236
+
138
237
  function rememberMap(map: PromptMap) {
139
238
  maps.set(map.vimText, map)
140
239
  }
@@ -166,7 +265,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
166
265
  setCursor(input, currentMap, action.position)
167
266
  break
168
267
  case "mode-change":
169
- nativeInsertUndoSaved = false
268
+ nativeInsertUndoSaved = action.mode === "insert" && actions.some((item) => item.type === "content-change")
269
+ if (action.mode === "insert") {
270
+ cancelYankFlash()
271
+ if (input) clearVisualSelection(input)
272
+ }
170
273
  syncMode(state, action.mode)
171
274
  break
172
275
  case "quit":
@@ -175,6 +278,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
175
278
  case "submit":
176
279
  ref.submit()
177
280
  break
281
+ case "command":
282
+ dispatchCommand(action.command, ctx)
283
+ return
178
284
  }
179
285
  }
180
286
 
@@ -182,7 +288,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
182
288
  syncVisualSelection(input, currentMap, ctx)
183
289
  }
184
290
 
185
- function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext) {
291
+ function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext): boolean {
186
292
  if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return false
187
293
 
188
294
  const wasPending = keybinds.isPending()
@@ -191,7 +297,12 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
191
297
 
192
298
  switch (resolved.status) {
193
299
  case "pending":
194
- pendingInsert = plainPending(resolved.display)
300
+ if (!wasPending) {
301
+ const input = focusedInput(ctx)
302
+ pendingTarget = input ? { input, offset: input.cursorOffset ?? 0 } : undefined
303
+ pendingContext = ctx
304
+ }
305
+ pendingInsert = codec.decode(plainPending(resolved.display))
195
306
  state.setPending(resolved.display)
196
307
  updateTimeout(ctx)
197
308
  return true
@@ -216,7 +327,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
216
327
  pendingInsert = ""
217
328
  state.setPending("")
218
329
  updateTimeout(ctx)
219
- return false
330
+ return wasPending ? handleInsertKeybind(event, key, ctx) : false
220
331
  }
221
332
  }
222
333
 
@@ -228,6 +339,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
228
339
  case "submit":
229
340
  ctx.prompt()?.submit()
230
341
  return true
342
+ case "command":
343
+ dispatchCommand((definition as HostKeybindDefinition).command!, ctx)
344
+ return true
231
345
  default:
232
346
  return false
233
347
  }
@@ -237,14 +351,20 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
237
351
  const ref = ctx.prompt()
238
352
  const input = focusedInput(ctx)
239
353
  const text = input?.plainText ?? ref?.current.input ?? ""
240
- const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
354
+ const map = mapForHostText(text, input)
241
355
 
242
356
  if (input && text.length > 0) {
243
- input.cursorOffset = Math.max(0, offset - 1)
357
+ const dw = displayWidth(text)
358
+ const offset = clamp(input?.cursorOffset ?? dw, 0, dw)
359
+ const charOffset = displayToChar(text, offset)
360
+ if (charOffset > 0 && text[charOffset - 1] !== "\n") {
361
+ input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1)))
362
+ }
244
363
  clampNormalCursor(input)
245
364
  }
246
365
 
247
- vim = { ...resetContext(vim), mode: "normal", statusMessage: "" }
366
+ const lastChange = nativeInsertUndoSaved ? [...vim.pendingChange, "Escape"] : vim.lastChange
367
+ vim = { ...resetContext(vim), cursor: hostPosition(map, input?.cursorOffset ?? 0), mode: "normal", statusMessage: "", lastChange, pendingChange: [] }
248
368
  nativeInsertUndoSaved = false
249
369
  syncMode(state, "normal")
250
370
  }
@@ -266,13 +386,97 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
266
386
 
267
387
  let actions: HostAction[] = []
268
388
  for (const token of parseKeySequence(definition.keys)) {
269
- const result = processKeystroke(keyToken(token), vim, buffer, tokenCtrl(token), false)
389
+ const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token))
270
390
  vim = result.newCtx
271
391
  actions = [...actions, ...(result.actions as HostAction[])]
272
392
  }
273
393
  return actions
274
394
  }
275
395
 
396
+ function processKey(key: string, ctrl = false): { newCtx: VimContext; actions: HostAction[] } {
397
+ let wordRange: MotionRange | undefined
398
+ if (!ctrl && vim.mode !== "insert" && (key === "w" || key === "W") && (vim.phase === "idle" || vim.phase === "operator-pending")) {
399
+ wordRange = forwardWordRange(key, vim.cursor, buffer, vim.count || 1)
400
+ if (vim.phase === "idle") {
401
+ const cursor = { ...wordRange.end, col: Math.min(wordRange.end.col, Math.max(0, buffer.getLineLength(wordRange.end.line) - 1)) }
402
+ return { newCtx: { ...resetContext(vim), cursor }, actions: [{ type: "cursor-move", position: cursor }] }
403
+ }
404
+ }
405
+ if (key === "." && !ctrl && vim.mode === "normal" && vim.phase === "idle" && vim.lastChange.length) {
406
+ // A repeated insert/change is one undo step, including native typing.
407
+ const original = buffer
408
+ const before = buffer.getContent()
409
+ const cursor = vim.cursor
410
+ const keys = [...vim.lastChange]
411
+ if (vim.count) {
412
+ while (/^[0-9]$/.test(keys[0] ?? "")) keys.shift()
413
+ keys.unshift(...String(vim.count))
414
+ }
415
+ vim = { ...vim, count: 0 }
416
+ const actions: HostAction[] = []
417
+ buffer = new TextBuffer(before)
418
+ try {
419
+ for (const token of keys) {
420
+ const result = processKey(token)
421
+ vim = result.newCtx
422
+ actions.push(...result.actions)
423
+ }
424
+ if (buffer.getContent() !== before) {
425
+ original.saveUndoPoint(cursor)
426
+ original.replaceContent(buffer.getContent())
427
+ }
428
+ } finally {
429
+ buffer = original
430
+ }
431
+ return { newCtx: { ...vim, lastChange: keys, pendingChange: [] }, actions }
432
+ }
433
+
434
+ let range: MotionRange | undefined
435
+ const operator = vim.operator
436
+ if (!ctrl && operator && textObjectOperator(operator)) {
437
+ if (vim.phase === "operator-pending") {
438
+ const count = vim.count || 1
439
+ if (key === "l" || key === "ArrowRight") {
440
+ // Vimee 0.3 treats l as inclusive; Vim's operator motion is exclusive.
441
+ range = { start: vim.cursor, end: { line: vim.cursor.line, col: Math.min(buffer.getLineLength(vim.cursor.line), vim.cursor.col + count) }, inclusive: false, linewise: false }
442
+ } else if (key === operator) {
443
+ range = { start: { line: vim.cursor.line, col: 0 }, end: { line: Math.min(buffer.getLineCount() - 1, vim.cursor.line + count - 1), col: 0 }, inclusive: true, linewise: true }
444
+ } else if (operator === "c" && key === "w" && /\S/.test(buffer.getLine(vim.cursor.line)[vim.cursor.col] ?? "")) {
445
+ range = resolveMotion("e", { ...vim.cursor, col: vim.cursor.col - 1 }, buffer, count, vim.count > 0, vim)?.range
446
+ if (range) range.start = vim.cursor
447
+ } else if (wordRange) {
448
+ range = wordRange
449
+ if (operator === "d" && range.end.line > range.start.line && /\S/.test(buffer.getLine(range.start.line)[range.start.col] ?? "") && range.end.col === Math.max(0, buffer.getLine(range.end.line).search(/\S/))) {
450
+ range.end = { line: range.end.line - 1, col: buffer.getLineLength(range.end.line - 1) }
451
+ }
452
+ } else if (operator === "c") {
453
+ const motion = resolveMotion(key, vim.cursor, buffer, count, vim.count > 0, vim)
454
+ if (motion?.range.linewise) range = motion.range
455
+ }
456
+ } else if (vim.phase === "text-object-pending" && vim.textObjectModifier) {
457
+ range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer) ?? undefined
458
+ }
459
+ }
460
+ if (range && operator) {
461
+ const keys: string[] = []
462
+ if (vim.count) keys.push(...String(vim.count))
463
+ for (const token of vim.pendingChange) {
464
+ if (!/^[0-9]$/.test(token)) keys.push(token)
465
+ }
466
+ keys.push(key)
467
+ const result = executeTextObject(operator, range, buffer, vim)
468
+ if (operator === "y") result.context.pendingChange = []
469
+ else if (result.context.mode === "insert") result.context.pendingChange = keys
470
+ else { result.context.lastChange = keys; result.context.pendingChange = [] }
471
+ return { newCtx: result.context, actions: result.actions }
472
+ }
473
+
474
+ // The engine saves undo points for yanks too. Run read-only operations
475
+ // against a disposable buffer so they cannot alter the editing history.
476
+ const yanking = operator === "y" || (isVisualMode(vim.mode) && key === "y") || (vim.mode === "normal" && key === "Y")
477
+ return processKeystroke(key, vim, yanking ? new TextBuffer(buffer.getContent()) : buffer, ctrl, false)
478
+ }
479
+
276
480
  function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
277
481
  if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined
278
482
  const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer)
@@ -290,15 +494,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
290
494
  }
291
495
  if (!textObjectOperator(vim.operator)) return undefined
292
496
 
293
- const originalCursor = vim.cursor
294
- const result = executeTextObject(vim.operator, range, buffer, vim)
295
- if (vim.operator === "y") {
296
- result.context = { ...result.context, cursor: originalCursor }
297
- result.actions = result.actions.map((action) => (action.type === "cursor-move" ? { ...action, position: originalCursor } : action))
298
- }
299
- vim = result.context
497
+ const result = processKey(key)
498
+ vim = result.newCtx
300
499
  applyActions(result.actions, ctx, map)
301
- if (flashRange) flashYank(ctx, activeMap, { type: "yank", text: result.yankedText }, flashRange)
500
+ if (flashRange) flashYank(ctx, activeMap, result.actions.find((action): action is YankAction => action.type === "yank"), flashRange)
302
501
  syncMode(state, vim.mode)
303
502
  state.setPending("")
304
503
  updateTimeout(ctx)
@@ -326,21 +525,34 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
326
525
  keybinds.cancel()
327
526
  if (flush) flushPendingInsert(ctx, pendingInsert, offset)
328
527
  pendingInsert = ""
528
+ pendingTarget = undefined
529
+ pendingContext = undefined
329
530
  state.setPending("")
330
531
  }
331
532
 
332
- function flushPendingInsert(ctx: PromptContext, value: string, offset?: number) {
533
+ function flushPendingInsert(ctx: PromptContext, value: string, charOffset?: number) {
333
534
  if (!value || state.mode() !== "insert") return
535
+ if (pendingTarget?.input.isDestroyed) return
536
+ if (pendingTarget?.input.insertText && !pendingTarget.input.isDestroyed) {
537
+ const { input, offset } = pendingTarget
538
+ const cursor = input.cursorOffset ?? 0
539
+ input.clearSelection?.()
540
+ input.cursorOffset = offset
541
+ input.insertText!(value)
542
+ input.cursorOffset = cursor >= offset ? cursor + displayWidth(value) : cursor
543
+ return
544
+ }
334
545
  const ref = ctx.prompt()
335
546
  if (!ref) return
336
547
  const input = focusedInput(ctx)
337
548
  const text = input?.plainText ?? ref.current.input
338
- const insertAt = clamp(offset ?? input?.cursorOffset ?? text.length, 0, text.length)
339
- const currentOffset = input?.cursorOffset ?? insertAt
340
- const next = text.slice(0, insertAt) + value + text.slice(insertAt)
549
+ const currentDisplayOff = input?.cursorOffset ?? 0
550
+ const currentCharOff = displayToChar(text, currentDisplayOff)
551
+ const insertAtChar = charOffset ?? currentCharOff
552
+ const next = text.slice(0, insertAtChar) + value + text.slice(insertAtChar)
341
553
  setInput(ref, next)
342
- const nextOffset = currentOffset >= insertAt ? currentOffset + value.length : currentOffset
343
- if (input) input.cursorOffset = nextOffset
554
+ const nextCharOff = currentCharOff >= insertAtChar ? currentCharOff + value.length : currentCharOff
555
+ if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff))
344
556
  }
345
557
 
346
558
  function cursorOffset(map: PromptMap, position: CursorPosition) {
@@ -357,7 +569,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
357
569
  if (!input?.gotoVisualLineEnd) return false
358
570
  clearVisualSelection(input)
359
571
  input.gotoVisualLineEnd()
360
- vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? map.hostText.length), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
572
+ vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? displayWidth(map.hostText)), mode: "insert", phase: "idle", count: 0, operator: null, pendingChange: ["A"], statusMessage: "-- INSERT --" }
361
573
  nativeInsertUndoSaved = false
362
574
  syncMode(state, "insert")
363
575
  return true
@@ -417,6 +629,30 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
417
629
  }
418
630
  }
419
631
 
632
+ function forwardWordRange(key: string, start: CursorPosition, buffer: TextBuffer, count: number): MotionRange {
633
+ let { line, col } = start
634
+ for (let index = 0; index < count; index++) {
635
+ const text = buffer.getLine(line)
636
+ const kind = wordClass(text[col], key === "W")
637
+ while (col < text.length && wordClass(text[col], key === "W") === kind) col++
638
+ while (col < text.length && /\s/.test(text[col])) col++
639
+ while (col >= buffer.getLineLength(line) && line < buffer.getLineCount() - 1) {
640
+ line++
641
+ col = 0
642
+ const next = buffer.getLine(line)
643
+ if (!next.length) break
644
+ while (col < next.length && /\s/.test(next[col])) col++
645
+ if (col < next.length) break
646
+ }
647
+ }
648
+ return { start, end: { line, col }, linewise: false, inclusive: false }
649
+ }
650
+
651
+ function wordClass(char: string | undefined, big: boolean) {
652
+ if (!char || /\s/.test(char)) return "space"
653
+ return big || /\w/.test(char) ? "word" : "punctuation"
654
+ }
655
+
420
656
  function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
421
657
  const anchorOffset = hostOffset(map, anchor, "next")
422
658
  const cursorOffset = hostOffset(map, cursor, "previous")
@@ -457,14 +693,15 @@ function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange
457
693
 
458
694
  function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined {
459
695
  if (!map.hostText) return undefined
460
- const start = clamp(Math.min(left, right), 0, Math.max(0, map.hostText.length - 1))
461
- const end = clamp(Math.max(left, right), 0, Math.max(0, map.hostText.length - 1))
696
+ const dw = displayWidth(map.hostText)
697
+ const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1))
698
+ const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1))
462
699
  return { start, end }
463
700
  }
464
701
 
465
702
  type YankAction = Extract<VimeeAction, { type: "yank" }>
466
703
 
467
- function yankAction(actions: VimeeAction[]): YankAction | undefined {
704
+ function yankAction(actions: HostAction[]): YankAction | undefined {
468
705
  return actions.find((action): action is YankAction => action.type === "yank")
469
706
  }
470
707
 
@@ -521,32 +758,71 @@ function vimOffsetFromPosition(text: string, position: CursorPosition) {
521
758
 
522
759
  function hostFromVimOffset(map: PromptMap, offset: number, bias: "previous" | "next") {
523
760
  const current = clamp(offset, 0, map.vimText.length)
524
- if (current === map.vimText.length) return map.hostText.length
761
+ if (current === map.vimText.length) return displayWidth(map.hostText)
525
762
 
526
763
  const host = map.vimToHost[current]
527
- if (host !== undefined) return host
764
+ if (host !== undefined) return charToDisplay(map.hostText, host)
528
765
 
529
766
  if (bias === "previous") {
530
767
  for (let previous = current - 1; previous >= 0; previous--) {
531
768
  const previousHost = map.vimToHost[previous]
532
- if (previousHost !== undefined) return previousHost
769
+ if (previousHost !== undefined) return charToDisplay(map.hostText, previousHost)
533
770
  }
534
771
  }
535
772
 
536
773
  for (let next = current + 1; next < map.vimToHost.length; next++) {
537
774
  const nextHost = map.vimToHost[next]
538
- if (nextHost !== undefined) return nextHost
775
+ if (nextHost !== undefined) return charToDisplay(map.hostText, nextHost)
539
776
  }
540
- return map.hostText.length
777
+ return displayWidth(map.hostText)
541
778
  }
542
779
 
543
780
  function clampNormalCursor(input: EditBufferLike) {
544
781
  const cursor = input.visualCursor
545
- const eol = input.editorView?.getVisualEOL?.()
546
782
  const offset = input.cursorOffset
547
- if (!cursor || !eol || offset === undefined) return
783
+ const text = input.plainText
784
+ if (!cursor || offset === undefined || text === undefined) return
548
785
  if (cursor.visualCol === 0) return
549
- if (cursor.visualRow === eol.visualRow && (cursor.offset === eol.offset || offset === eol.offset)) input.cursorOffset = Math.max(0, offset - 1)
786
+ const dw = displayWidth(text)
787
+ if (offset >= dw) {
788
+ input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, dw - 1)))
789
+ return
790
+ }
791
+ const charIdx = displayToChar(text, offset)
792
+ if (charIdx < text.length && text[charIdx] === '\n') {
793
+ input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1)))
794
+ }
795
+ }
796
+
797
+ function dispatchCommand(command: string, ctx: PromptContext) {
798
+ const input = focusedInput(ctx)
799
+ if (command === "prompt.history.previous" && input) input.cursorOffset = 0
800
+ if (command === "prompt.history.next" && input) input.cursorOffset = input.plainText?.length ?? 0
801
+
802
+ const result = ctx.api.keymap.dispatchCommand(command)
803
+
804
+ // The host currently checks history-next using character length, then leaves
805
+ // the cursor in that unit. Restore the display-width offset after dispatch.
806
+ if (command !== "prompt.history.next" || !result.ok) return result
807
+ const nextInput = focusedInput(ctx)
808
+ if (nextInput?.plainText !== undefined) nextInput.cursorOffset = displayWidth(nextInput.plainText)
809
+ return result
810
+ }
811
+
812
+ function defaultHistoryCommand(key: string, text: string, historyText: string | undefined, enabled: Record<"j" | "k", boolean>) {
813
+ if (key !== "j" && key !== "k") return
814
+ if (!enabled[key]) return
815
+ if (text.length > 0 && text !== historyText) return
816
+ return key === "k" ? "prompt.history.previous" : "prompt.history.next"
817
+ }
818
+
819
+ function hasNormalKeyPrefix(config: VimConfig, key: string) {
820
+ for (const sequence of Object.keys(config.keymaps.normal ?? {})) {
821
+ try {
822
+ if (keyToken(parseKeySequence(sequence)[0] ?? "") === key) return true
823
+ } catch {}
824
+ }
825
+ return false
550
826
  }
551
827
 
552
828
  function endMotionOffset(text: string, offset: number, count: number) {
@@ -578,6 +854,8 @@ function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined
578
854
  if (!keymaps) continue
579
855
  for (const [keys, action] of Object.entries(keymaps)) {
580
856
  try {
857
+ const tokens = parseKeySequence(keys)
858
+ if (tokens.length > 1 && tokens[0] === "<CR>") throw new Error("<CR> cannot start a multi-key mapping")
581
859
  map.addKeybind(mode, keys as ValidKeySequence<typeof keys>, keybindAction(action))
582
860
  count++
583
861
  } catch (error) {
@@ -590,6 +868,15 @@ function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined
590
868
  }
591
869
 
592
870
  function keybindAction(action: string): HostKeybindDefinition {
871
+ if (action.startsWith("command:")) {
872
+ const command = action.slice(8).trim()
873
+ if (!command) throw new Error("Command name is required")
874
+ return {
875
+ execute: () => [{ type: "command", command } as unknown as VimeeAction],
876
+ hostAction: "command",
877
+ command,
878
+ }
879
+ }
593
880
  switch (action) {
594
881
  case "normal":
595
882
  return { keys: "<Esc>", hostAction: "normal" }
@@ -603,6 +890,7 @@ function keybindAction(action: string): HostKeybindDefinition {
603
890
  }
604
891
 
605
892
  function keyForVimee(event: KeyEvent, key: string) {
893
+ if (key === "<C-[>") return "Escape"
606
894
  if (event.ctrl) return event.name?.toLowerCase()
607
895
  const token = keyToken(key)
608
896
  return token.startsWith("<") ? undefined : token
@@ -634,17 +922,19 @@ function syncMode(state: VimState, mode: VimContext["mode"]) {
634
922
  }
635
923
 
636
924
  function pendingDisplay(ctx: VimContext, keybindPending: boolean) {
637
- if (ctx.phase === "operator-pending") return ctx.operator ?? ""
638
- if (ctx.phase === "text-object-pending") return ctx.textObjectModifier ?? ""
925
+ const count = ctx.count > 0 ? String(ctx.count) : ""
926
+ if (ctx.phase === "operator-pending") return count + (ctx.operator ?? "")
927
+ if (ctx.phase === "text-object-pending") return count + (ctx.operator ?? "") + (ctx.textObjectModifier ?? "")
928
+ if (ctx.phase === "char-pending") return count + (ctx.operator ?? "") + (ctx.charCommand ?? "")
639
929
  if (keybindPending) return ctx.statusMessage
640
- return ""
930
+ return count
641
931
  }
642
932
 
643
933
  function plainPending(value: string) {
644
934
  return value.includes("<") || value.includes(">") ? "" : value
645
935
  }
646
936
 
647
- function consumesKey(key: string, actions: VimeeAction[], ctx: VimContext, keybindPending: boolean) {
937
+ function consumesKey(key: string, actions: HostAction[], ctx: VimContext, keybindPending: boolean) {
648
938
  if (actions.length > 0) return true
649
939
  if (keybindPending) return true
650
940
  if (ctx.phase !== "idle") return true
@@ -677,7 +967,7 @@ function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuf
677
967
  start: { line: cursor.line, col: start },
678
968
  end: { line: cursor.line, col: Math.max(start, end) },
679
969
  linewise: false,
680
- inclusive: true,
970
+ inclusive: end >= start,
681
971
  }
682
972
  }
683
973
 
@@ -769,7 +1059,7 @@ function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
769
1059
  }
770
1060
 
771
1061
  function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
772
- buffer.saveUndoPoint(ctx.cursor)
1062
+ if (operator !== "y") buffer.saveUndoPoint(ctx.cursor)
773
1063
  const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer)
774
1064
  const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
775
1065
  const context = {
@@ -794,9 +1084,9 @@ function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffe
794
1084
  }
795
1085
 
796
1086
  buffer.deleteLines(startLine, lineCount)
797
- if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
1087
+ if (operator === "c") buffer.insertLine(startLine, "")
1088
+ else if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
798
1089
  const line = Math.min(startLine, buffer.getLineCount() - 1)
799
- if (operator === "c") buffer.insertLine(line, "")
800
1090
  return {
801
1091
  actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
802
1092
  cursor: { line, col: 0 },