opencode-vim 0.0.20 → 0.0.22

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,10 +1,10 @@
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"
7
+ import { createGraphemeCodec } from "./graphemes"
8
8
  import { charToDisplay, displayToChar, displayWidth, createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
9
9
  import type { createVimState } from "./state"
10
10
 
@@ -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,24 +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
32
35
  let historyText: string | undefined
36
+ let activeInput: EditBufferLike | undefined
37
+ let mapWidth: number | undefined
38
+ let mapWrapMode: string | undefined
33
39
  const defaultHistoryKeys = {
34
40
  k: !hasNormalKeyPrefix(config, "k"),
35
41
  j: !hasNormalKeyPrefix(config, "j"),
36
42
  }
43
+ const appendMapped = hasNormalKeyPrefix(config, "A")
37
44
 
38
45
  return {
46
+ attach,
47
+ suspend,
48
+ isPending: () => vim.phase !== "idle" || vim.count > 0 || !!keybinds?.isPending(),
39
49
  handle(event: KeyEvent, key: string, ctx: PromptContext) {
40
50
  const ref = ctx.prompt()
41
51
  if (!ref) return false
52
+ const input = focusedInput(ctx)
53
+ attach(ctx)
42
54
 
43
55
  let vimeeKey = keyForVimee(event, key)
44
56
  if (!vimeeKey) return false
57
+ vimeeKey = codec.encode(vimeeKey)
45
58
 
46
59
  if (state.mode() === "insert") {
47
60
  if (vimeeKey === "Escape") {
48
- cancelPendingInsert(ctx, undefined, false)
61
+ cancelPendingInsert(ctx)
49
62
  enterNormal(ctx)
50
63
  state.setPending("")
51
64
  updateTimeout(ctx)
@@ -55,7 +68,6 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
55
68
  return handleInsertKeybind(event, vimeeKey, ctx)
56
69
  }
57
70
 
58
- const input = focusedInput(ctx)
59
71
  const text = input?.plainText ?? ref.current.input
60
72
  const dw = displayWidth(text)
61
73
  const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
@@ -80,14 +92,14 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
80
92
  const pendingBefore = pendingInsert
81
93
  sync(map, cursor)
82
94
 
83
- if (vimeeKey === "A" && appendVisualLine(input, map)) {
95
+ if (vimeeKey === "A" && vim.mode === "normal" && vim.phase === "idle" && !wasPending && !appendMapped && appendVisualLine(input, map)) {
84
96
  state.setPending("")
85
97
  updateTimeout(ctx)
86
98
  log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
87
99
  return true
88
100
  }
89
101
 
90
- const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, charOff, vim.count || 1) : undefined
102
+ const hostEnd = vimeeKey === "e" && vim.phase === "idle" ? charToDisplay(map.hostText, endMotionOffset(map.hostText, charOff, vim.count || 1)) : undefined
91
103
  const shouldFlashYank = shouldFlashYankFor(vimeeKey)
92
104
  const visualYankRange = visualYankRangeFor(map)
93
105
  vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
@@ -114,14 +126,14 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
114
126
  const actions = applyKeybind(resolved.definition, map)
115
127
  result = { newCtx: vim, actions }
116
128
  } else {
117
- result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false)
129
+ result = processKey(vimeeKey, event.ctrl)
118
130
  }
119
131
  vim = result.newCtx
120
132
  const actions = result.actions as HostAction[]
121
133
  if (hostEnd !== undefined && actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
122
134
  let clampFinalCursor = true
123
135
  const content = actions.find((action) => action.type === "content-change")?.content
124
- if (vimeeKey === "x" && content !== undefined) {
136
+ if (vimeeKey === "x" && state.mode() === "normal" && content !== undefined) {
125
137
  const next = nextMap(map, content)
126
138
  const nextDW = displayWidth(next.hostText)
127
139
  const target = clamp(displayOff, 0, Math.max(0, nextDW - 1))
@@ -143,33 +155,85 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
143
155
  return consumesKey(vimeeKey, actions, vim, keybindPending)
144
156
  },
145
157
  cleanup() {
146
- if (timer) clearTimeout(timer)
147
- if (yankTimer) clearTimeout(yankTimer)
158
+ suspend()
159
+ maps.clear()
148
160
  },
149
161
  }
150
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
+
151
192
  function sync(map: PromptMap, cursor: CursorPosition) {
152
193
  if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
153
194
  vim = { ...vim, cursor, mode: state.mode() }
154
195
  }
155
196
 
156
197
  function mapForHostText(text: string, input: ReturnType<typeof focusedInput>) {
157
- if (activeMap.hostText === text) return activeMap
158
- 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()
159
205
  rememberMap(activeMap)
160
- if (state.mode() === "insert") {
206
+ if (textChanged && state.mode() === "insert") {
161
207
  if (!nativeInsertUndoSaved) {
162
208
  buffer.saveUndoPoint(vim.cursor)
163
209
  nativeInsertUndoSaved = true
164
210
  }
165
211
  buffer.replaceContent(activeMap.vimText)
166
- } else {
212
+ } else if (textChanged) {
167
213
  buffer = new TextBuffer(activeMap.vimText)
168
214
  nativeInsertUndoSaved = false
215
+ } else {
216
+ buffer.replaceContent(activeMap.vimText)
169
217
  }
170
218
  return activeMap
171
219
  }
172
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
+
173
237
  function rememberMap(map: PromptMap) {
174
238
  maps.set(map.vimText, map)
175
239
  }
@@ -201,7 +265,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
201
265
  setCursor(input, currentMap, action.position)
202
266
  break
203
267
  case "mode-change":
204
- 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
+ }
205
273
  syncMode(state, action.mode)
206
274
  break
207
275
  case "quit":
@@ -220,7 +288,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
220
288
  syncVisualSelection(input, currentMap, ctx)
221
289
  }
222
290
 
223
- function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext) {
291
+ function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext): boolean {
224
292
  if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return false
225
293
 
226
294
  const wasPending = keybinds.isPending()
@@ -229,7 +297,12 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
229
297
 
230
298
  switch (resolved.status) {
231
299
  case "pending":
232
- 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))
233
306
  state.setPending(resolved.display)
234
307
  updateTimeout(ctx)
235
308
  return true
@@ -254,7 +327,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
254
327
  pendingInsert = ""
255
328
  state.setPending("")
256
329
  updateTimeout(ctx)
257
- return false
330
+ return wasPending ? handleInsertKeybind(event, key, ctx) : false
258
331
  }
259
332
  }
260
333
 
@@ -278,15 +351,20 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
278
351
  const ref = ctx.prompt()
279
352
  const input = focusedInput(ctx)
280
353
  const text = input?.plainText ?? ref?.current.input ?? ""
354
+ const map = mapForHostText(text, input)
281
355
 
282
356
  if (input && text.length > 0) {
283
357
  const dw = displayWidth(text)
284
358
  const offset = clamp(input?.cursorOffset ?? dw, 0, dw)
285
- input.cursorOffset = Math.max(0, offset - 1)
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
+ }
286
363
  clampNormalCursor(input)
287
364
  }
288
365
 
289
- 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: [] }
290
368
  nativeInsertUndoSaved = false
291
369
  syncMode(state, "normal")
292
370
  }
@@ -308,13 +386,97 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
308
386
 
309
387
  let actions: HostAction[] = []
310
388
  for (const token of parseKeySequence(definition.keys)) {
311
- const result = processKeystroke(keyToken(token), vim, buffer, tokenCtrl(token), false)
389
+ const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token))
312
390
  vim = result.newCtx
313
391
  actions = [...actions, ...(result.actions as HostAction[])]
314
392
  }
315
393
  return actions
316
394
  }
317
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
+
318
480
  function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
319
481
  if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined
320
482
  const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer)
@@ -332,15 +494,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
332
494
  }
333
495
  if (!textObjectOperator(vim.operator)) return undefined
334
496
 
335
- const originalCursor = vim.cursor
336
- const result = executeTextObject(vim.operator, range, buffer, vim)
337
- if (vim.operator === "y") {
338
- result.context = { ...result.context, cursor: originalCursor }
339
- result.actions = result.actions.map((action) => (action.type === "cursor-move" ? { ...action, position: originalCursor } : action))
340
- }
341
- vim = result.context
497
+ const result = processKey(key)
498
+ vim = result.newCtx
342
499
  applyActions(result.actions, ctx, map)
343
- 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)
344
501
  syncMode(state, vim.mode)
345
502
  state.setPending("")
346
503
  updateTimeout(ctx)
@@ -368,11 +525,23 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
368
525
  keybinds.cancel()
369
526
  if (flush) flushPendingInsert(ctx, pendingInsert, offset)
370
527
  pendingInsert = ""
528
+ pendingTarget = undefined
529
+ pendingContext = undefined
371
530
  state.setPending("")
372
531
  }
373
532
 
374
533
  function flushPendingInsert(ctx: PromptContext, value: string, charOffset?: number) {
375
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
+ }
376
545
  const ref = ctx.prompt()
377
546
  if (!ref) return
378
547
  const input = focusedInput(ctx)
@@ -400,7 +569,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
400
569
  if (!input?.gotoVisualLineEnd) return false
401
570
  clearVisualSelection(input)
402
571
  input.gotoVisualLineEnd()
403
- vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? displayWidth(map.hostText)), 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 --" }
404
573
  nativeInsertUndoSaved = false
405
574
  syncMode(state, "insert")
406
575
  return true
@@ -460,6 +629,30 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
460
629
  }
461
630
  }
462
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
+
463
656
  function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
464
657
  const anchorOffset = hostOffset(map, anchor, "next")
465
658
  const cursorOffset = hostOffset(map, cursor, "previous")
@@ -592,12 +785,12 @@ function clampNormalCursor(input: EditBufferLike) {
592
785
  if (cursor.visualCol === 0) return
593
786
  const dw = displayWidth(text)
594
787
  if (offset >= dw) {
595
- input.cursorOffset = Math.max(0, dw - 1)
788
+ input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, dw - 1)))
596
789
  return
597
790
  }
598
791
  const charIdx = displayToChar(text, offset)
599
792
  if (charIdx < text.length && text[charIdx] === '\n') {
600
- input.cursorOffset = Math.max(0, offset - 1)
793
+ input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1)))
601
794
  }
602
795
  }
603
796
 
@@ -623,7 +816,7 @@ function defaultHistoryCommand(key: string, text: string, historyText: string |
623
816
  return key === "k" ? "prompt.history.previous" : "prompt.history.next"
624
817
  }
625
818
 
626
- function hasNormalKeyPrefix(config: VimConfig, key: "j" | "k") {
819
+ function hasNormalKeyPrefix(config: VimConfig, key: string) {
627
820
  for (const sequence of Object.keys(config.keymaps.normal ?? {})) {
628
821
  try {
629
822
  if (keyToken(parseKeySequence(sequence)[0] ?? "") === key) return true
@@ -697,6 +890,7 @@ function keybindAction(action: string): HostKeybindDefinition {
697
890
  }
698
891
 
699
892
  function keyForVimee(event: KeyEvent, key: string) {
893
+ if (key === "<C-[>") return "Escape"
700
894
  if (event.ctrl) return event.name?.toLowerCase()
701
895
  const token = keyToken(key)
702
896
  return token.startsWith("<") ? undefined : token
@@ -728,10 +922,12 @@ function syncMode(state: VimState, mode: VimContext["mode"]) {
728
922
  }
729
923
 
730
924
  function pendingDisplay(ctx: VimContext, keybindPending: boolean) {
731
- if (ctx.phase === "operator-pending") return ctx.operator ?? ""
732
- 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 ?? "")
733
929
  if (keybindPending) return ctx.statusMessage
734
- return ""
930
+ return count
735
931
  }
736
932
 
737
933
  function plainPending(value: string) {
@@ -771,7 +967,7 @@ function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuf
771
967
  start: { line: cursor.line, col: start },
772
968
  end: { line: cursor.line, col: Math.max(start, end) },
773
969
  linewise: false,
774
- inclusive: true,
970
+ inclusive: end >= start,
775
971
  }
776
972
  }
777
973
 
@@ -863,7 +1059,7 @@ function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
863
1059
  }
864
1060
 
865
1061
  function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
866
- buffer.saveUndoPoint(ctx.cursor)
1062
+ if (operator !== "y") buffer.saveUndoPoint(ctx.cursor)
867
1063
  const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer)
868
1064
  const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
869
1065
  const context = {
@@ -888,9 +1084,9 @@ function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffe
888
1084
  }
889
1085
 
890
1086
  buffer.deleteLines(startLine, lineCount)
891
- if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
1087
+ if (operator === "c") buffer.insertLine(startLine, "")
1088
+ else if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
892
1089
  const line = Math.min(startLine, buffer.getLineCount() - 1)
893
- if (operator === "c") buffer.insertLine(line, "")
894
1090
  return {
895
1091
  actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
896
1092
  cursor: { line, col: 0 },