opencode-vim 0.0.22 → 0.0.23

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,30 +1,35 @@
1
1
  import type { KeyEvent } from "@opentui/core"
2
- import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext, resolveMotion } from "@vimee/core"
2
+ import { TextBuffer, createInitialContext, createKeybindMap, executeOperatorOnRange, 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
4
  import { focusedInput, setInput, type EditBufferLike, type PromptContext } from "./actions"
5
5
  import type { VimConfig } from "./config"
6
6
  import type { VimLog } from "./log"
7
7
  import { createGraphemeCodec } from "./graphemes"
8
- import { charToDisplay, displayToChar, displayWidth, createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
8
+ import { charToDisplay, displayToChar, displayWidth, createPromptMap, hostFromVimOffset, 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" } | { type: "command"; command: string }
12
+ type HostAction = VimeeAction & { register?: string } | { type: "submit" } | { type: "command"; command: string }
13
13
  type HostKeybindAction = "normal" | "submit" | "command"
14
14
  type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction; command?: string }
15
15
  type HostRange = { start: number; end: number }
16
16
 
17
- const YANK_FLASH_MS = 250
17
+ export const YANK_FLASH_MS = 250
18
18
 
19
19
  export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
20
20
 
21
- export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog) {
21
+ type AdapterOptions = {
22
+ readOnly?: boolean
23
+ onYank?: (text: string) => void
24
+ readClipboard?: () => Promise<string>
25
+ }
26
+
27
+ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog, options: AdapterOptions = {}) {
22
28
  const codec = createGraphemeCodec()
23
29
  let buffer = new TextBuffer("")
24
- let activeMap = createPromptMap("", undefined, codec)
25
- const maps = new Map([[activeMap.vimText, activeMap]])
30
+ let activeMap = createPromptMap("", codec)
26
31
  let vim = createInitialContext({ line: 0, col: 0 })
27
- const keybinds = createKeybinds(config, log)
32
+ const keybinds = options.readOnly ? undefined : createKeybinds(config, log)
28
33
  let timer: ReturnType<typeof setTimeout> | undefined
29
34
  let yankTimer: ReturnType<typeof setTimeout> | undefined
30
35
  let yankFlashActive = false
@@ -34,19 +39,20 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
34
39
  let nativeInsertUndoSaved = false
35
40
  let historyText: string | undefined
36
41
  let activeInput: EditBufferLike | undefined
37
- let mapWidth: number | undefined
38
- let mapWrapMode: string | undefined
42
+ let preferredColumn: number | undefined
43
+ let preferredScreen = false
44
+ let generation = 0
39
45
  const defaultHistoryKeys = {
40
46
  k: !hasNormalKeyPrefix(config, "k"),
41
47
  j: !hasNormalKeyPrefix(config, "j"),
42
48
  }
43
- const appendMapped = hasNormalKeyPrefix(config, "A")
44
49
 
45
50
  return {
46
51
  attach,
47
52
  suspend,
53
+ setRegister(text: string) { vim = { ...vim, register: codec.encode(text) } },
48
54
  isPending: () => vim.phase !== "idle" || vim.count > 0 || !!keybinds?.isPending(),
49
- handle(event: KeyEvent, key: string, ctx: PromptContext) {
55
+ handle(event: KeyEvent, key: string, ctx: PromptContext): boolean | Promise<boolean> {
50
56
  const ref = ctx.prompt()
51
57
  if (!ref) return false
52
58
  const input = focusedInput(ctx)
@@ -54,6 +60,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
54
60
 
55
61
  let vimeeKey = keyForVimee(event, key)
56
62
  if (!vimeeKey) return false
63
+ if (options.readOnly && !readOnlyKey(vimeeKey, event.ctrl, vim)) {
64
+ vim = resetContext(vim)
65
+ state.setPending("")
66
+ return true
67
+ }
57
68
  vimeeKey = codec.encode(vimeeKey)
58
69
 
59
70
  if (state.mode() === "insert") {
@@ -73,7 +84,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
73
84
  const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
74
85
  const charOff = displayToChar(text, displayOff)
75
86
 
76
- const canBrowseHistory = vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
87
+ const canBrowseHistory = !options.readOnly && vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
77
88
  const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined
78
89
  if (historyCommand) {
79
90
  const result = dispatchCommand(historyCommand, ctx)
@@ -85,21 +96,13 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
85
96
  }
86
97
  historyText = undefined
87
98
 
88
- const map = mapForHostText(text, input)
99
+ const map = mapForHostText(text)
89
100
  const cursor = hostPosition(map, displayOff)
90
101
 
91
102
  const wasPending = keybinds?.isPending() ?? false
92
103
  const pendingBefore = pendingInsert
93
104
  sync(map, cursor)
94
105
 
95
- if (vimeeKey === "A" && vim.mode === "normal" && vim.phase === "idle" && !wasPending && !appendMapped && appendVisualLine(input, map)) {
96
- state.setPending("")
97
- updateTimeout(ctx)
98
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
99
- return true
100
- }
101
-
102
- const hostEnd = vimeeKey === "e" && vim.phase === "idle" ? charToDisplay(map.hostText, endMotionOffset(map.hostText, charOff, vim.count || 1)) : undefined
103
106
  const shouldFlashYank = shouldFlashYankFor(vimeeKey)
104
107
  const visualYankRange = visualYankRangeFor(map)
105
108
  vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
@@ -121,43 +124,60 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
121
124
  return true
122
125
  }
123
126
 
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)
127
+ const commandKey = vimeeKey
128
+ const definition = resolved?.status === "matched" ? resolved.definition : undefined
129
+ if (options.readClipboard && needsClipboard(commandKey, event.ctrl, definition)) {
130
+ const before = generation
131
+ return options.readClipboard().then((text) => {
132
+ // A focus change, mouse move or external edit cancels a pending put.
133
+ if (before !== generation || input?.isDestroyed || input?.plainText !== map.hostText || input?.cursorOffset !== displayOff) return false
134
+ vim = { ...vim, register: codec.encode(text) }
135
+ return finish()
136
+ })
130
137
  }
131
- vim = result.newCtx
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) }
134
- let clampFinalCursor = true
135
- const content = actions.find((action) => action.type === "content-change")?.content
136
- if (vimeeKey === "x" && state.mode() === "normal" && content !== undefined) {
137
- const next = nextMap(map, content)
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) {
141
- vim = { ...vim, cursor: hostPosition(next, target) }
142
- clampFinalCursor = false
138
+ return finish()
139
+
140
+ function finish() {
141
+ let result
142
+ if (resolved?.status === "matched") {
143
+ const actions = applyKeybind(resolved.definition, ctx, map)
144
+ result = { newCtx: vim, actions }
145
+ } else {
146
+ // Match LazyVim's uncounted j/k and arrow mappings in normal/visual mode.
147
+ // Apply this at the input boundary so custom mapping sequences stay literal.
148
+ if (!event.ctrl && vim.phase === "idle" && vim.count === 0 && (commandKey === "j" || commandKey === "k" || commandKey === "ArrowDown" || commandKey === "ArrowUp")) {
149
+ vim = processKey("g").newCtx
150
+ }
151
+ result = processKey(commandKey, event.ctrl)
143
152
  }
153
+ vim = result.newCtx
154
+ const actions = result.actions as HostAction[]
155
+ if (resolved?.status !== "matched") applyActions(actions, ctx, map)
156
+ if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange)
157
+ syncMode(state, vim.mode)
158
+
159
+ const keybindPending = keybinds?.isPending() ?? false
160
+ if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
161
+ pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
162
+ state.setPending(pendingDisplay(vim, keybindPending))
163
+ updateTimeout(ctx)
164
+ log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
165
+ return consumesKey(commandKey, actions, vim, keybindPending)
144
166
  }
145
- applyActions(actions, ctx, map, clampFinalCursor)
146
- if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange)
147
- syncMode(state, vim.mode)
148
-
149
- const keybindPending = keybinds?.isPending() ?? false
150
- if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
151
- pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
152
- state.setPending(pendingDisplay(vim, keybindPending))
153
- updateTimeout(ctx)
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)
156
- },
157
- cleanup() {
158
- suspend()
159
- maps.clear()
160
167
  },
168
+ cleanup: suspend,
169
+ }
170
+
171
+ function needsClipboard(key: string, ctrl: boolean, definition?: KeybindDefinition) {
172
+ if (definition) {
173
+ if ("execute" in definition) return false
174
+ return parseKeySequence(definition.keys).some((token) => ["p", "P", ".", "@"].includes(token))
175
+ }
176
+ if (ctrl) return false
177
+ if (vim.phase === "macro-execute-pending") return true
178
+ if (vim.phase !== "idle" || vim.selectedRegister) return false
179
+ if (key === "p" || key === "P") return true
180
+ return key === "." && vim.lastChange.some((token) => ["p", "P", "@"].includes(token))
161
181
  }
162
182
 
163
183
  function attach(ctx: PromptContext) {
@@ -165,17 +185,15 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
165
185
  if (activeInput === input) return
166
186
  suspend()
167
187
  activeInput = input
168
- activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", input, codec)
169
- maps.clear()
170
- rememberMap(activeMap)
188
+ activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", codec)
171
189
  buffer = new TextBuffer(activeMap.vimText)
172
190
  nativeInsertUndoSaved = false
173
191
  vim = { ...resetContext(vim), cursor: hostPosition(activeMap, input?.cursorOffset ?? 0), mode: state.mode() }
174
- mapWidth = input?.width
175
- mapWrapMode = input?.wrapMode
176
192
  }
177
193
 
178
194
  function suspend() {
195
+ generation++
196
+ preferredColumn = undefined
179
197
  if (pendingContext) cancelPendingInsert(pendingContext)
180
198
  if (timer) clearTimeout(timer)
181
199
  timer = undefined
@@ -190,30 +208,24 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
190
208
  }
191
209
 
192
210
  function sync(map: PromptMap, cursor: CursorPosition) {
211
+ if (cursor.line !== vim.cursor.line || cursor.col !== vim.cursor.col) preferredColumn = undefined
193
212
  if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
194
213
  vim = { ...vim, cursor, mode: state.mode() }
195
214
  }
196
215
 
197
- function mapForHostText(text: string, input: ReturnType<typeof focusedInput>) {
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()
205
- rememberMap(activeMap)
206
- if (textChanged && state.mode() === "insert") {
216
+ function mapForHostText(text: string) {
217
+ if (activeMap.hostText === text) return activeMap
218
+ if (state.mode() === "insert") recordNativeChange(text)
219
+ activeMap = createPromptMap(text, codec)
220
+ if (state.mode() === "insert") {
207
221
  if (!nativeInsertUndoSaved) {
208
222
  buffer.saveUndoPoint(vim.cursor)
209
223
  nativeInsertUndoSaved = true
210
224
  }
211
225
  buffer.replaceContent(activeMap.vimText)
212
- } else if (textChanged) {
226
+ } else {
213
227
  buffer = new TextBuffer(activeMap.vimText)
214
228
  nativeInsertUndoSaved = false
215
- } else {
216
- buffer.replaceContent(activeMap.vimText)
217
229
  }
218
230
  return activeMap
219
231
  }
@@ -234,19 +246,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
234
246
  vim = { ...vim, pendingChange: keys }
235
247
  }
236
248
 
237
- function rememberMap(map: PromptMap) {
238
- maps.set(map.vimText, map)
239
- }
240
-
241
- function nextMap(map: PromptMap, vimText: string) {
242
- const known = maps.get(vimText)
243
- if (known) return known
244
- const next = derivePromptMap(map, vimText)
245
- rememberMap(next)
246
- return next
247
- }
248
-
249
- function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap, clampFinalCursor = true) {
249
+ function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) {
250
250
  const ref = ctx.prompt()
251
251
  const input = focusedInput(ctx)
252
252
  let currentMap = map
@@ -255,8 +255,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
255
255
  for (const action of actions) {
256
256
  switch (action.type) {
257
257
  case "content-change":
258
- rememberMap(currentMap)
259
- currentMap = nextMap(currentMap, action.content)
258
+ currentMap = createPromptMap(codec.decode(action.content), codec)
260
259
  activeMap = currentMap
261
260
  setInput(ref, currentMap.hostText)
262
261
  buffer.replaceContent(action.content)
@@ -264,6 +263,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
264
263
  case "cursor-move":
265
264
  setCursor(input, currentMap, action.position)
266
265
  break
266
+ case "yank":
267
+ if (!action.register) options.onYank?.(codec.decode(action.text))
268
+ break
267
269
  case "mode-change":
268
270
  nativeInsertUndoSaved = action.mode === "insert" && actions.some((item) => item.type === "content-change")
269
271
  if (action.mode === "insert") {
@@ -284,7 +286,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
284
286
  }
285
287
  }
286
288
 
287
- setCursor(input, currentMap, vim.cursor, clampFinalCursor)
289
+ setCursor(input, currentMap, vim.cursor)
288
290
  syncVisualSelection(input, currentMap, ctx)
289
291
  }
290
292
 
@@ -351,7 +353,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
351
353
  const ref = ctx.prompt()
352
354
  const input = focusedInput(ctx)
353
355
  const text = input?.plainText ?? ref?.current.input ?? ""
354
- const map = mapForHostText(text, input)
356
+ const map = mapForHostText(text)
355
357
 
356
358
  if (input && text.length > 0) {
357
359
  const dw = displayWidth(text)
@@ -377,10 +379,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
377
379
  return undefined
378
380
  }
379
381
 
380
- function applyKeybind(definition: KeybindDefinition, map: PromptMap) {
382
+ function applyKeybind(definition: KeybindDefinition, ctx: PromptContext, map: PromptMap) {
381
383
  if ("execute" in definition) {
382
384
  const actions = definition.execute(vim, buffer) as HostAction[]
383
- vim = { ...vim, cursor: hostPosition(map, cursorOffset(map, vim.cursor)) }
385
+ vim = { ...vim, cursor: hostPosition(map, hostOffset(map, vim.cursor)) }
386
+ applyActions(actions, ctx, map)
384
387
  return actions
385
388
  }
386
389
 
@@ -388,12 +391,37 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
388
391
  for (const token of parseKeySequence(definition.keys)) {
389
392
  const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token))
390
393
  vim = result.newCtx
394
+ // Keep the host layout current for screen motions later in the mapping.
395
+ applyActions(result.actions, ctx, activeMap)
391
396
  actions = [...actions, ...(result.actions as HostAction[])]
392
397
  }
393
398
  return actions
394
399
  }
395
400
 
396
401
  function processKey(key: string, ctrl = false): { newCtx: VimContext; actions: HostAction[] } {
402
+ const register = vim.selectedRegister
403
+ const moving = !ctrl && vim.mode !== "insert" && (vim.phase === "idle" || vim.phase === "operator-pending" || vim.phase === "g-pending")
404
+ const vertical = moving && (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp")
405
+ const lineEnd = moving && key === "$" && vim.phase === "idle"
406
+ const result = processKeyInner(key, ctrl)
407
+ if (register) {
408
+ for (const action of result.actions) {
409
+ if (action.type === "yank") action.register = register
410
+ }
411
+ }
412
+ if (result.actions.some((action) => action.type === "content-change" || action.type === "mode-change")) preferredColumn = undefined
413
+ else if (!vertical && result.actions.some((action) => action.type === "cursor-move")) preferredColumn = lineEnd ? Infinity : undefined
414
+ return result
415
+ }
416
+
417
+ function processKeyInner(key: string, ctrl: boolean): { newCtx: VimContext; actions: HostAction[] } {
418
+ let range = ctrl ? undefined : lineMotion(key)
419
+ if (range && vim.operator && (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp") && range.start.line === range.end.line && range.start.col === range.end.col) {
420
+ return { newCtx: { ...resetContext(vim), pendingChange: [] }, actions: [] }
421
+ }
422
+ if (range && !vim.operator) {
423
+ return { newCtx: { ...resetContext(vim), cursor: range.end }, actions: [{ type: "cursor-move", position: range.end }] }
424
+ }
397
425
  let wordRange: MotionRange | undefined
398
426
  if (!ctrl && vim.mode !== "insert" && (key === "w" || key === "W") && (vim.phase === "idle" || vim.phase === "operator-pending")) {
399
427
  wordRange = forwardWordRange(key, vim.cursor, buffer, vim.count || 1)
@@ -431,9 +459,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
431
459
  return { newCtx: { ...vim, lastChange: keys, pendingChange: [] }, actions }
432
460
  }
433
461
 
434
- let range: MotionRange | undefined
435
462
  const operator = vim.operator
436
- if (!ctrl && operator && textObjectOperator(operator)) {
463
+ if (!range && !ctrl && operator && textObjectOperator(operator)) {
437
464
  if (vim.phase === "operator-pending") {
438
465
  const count = vim.count || 1
439
466
  if (key === "l" || key === "ArrowRight") {
@@ -458,6 +485,15 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
458
485
  }
459
486
  }
460
487
  if (range && operator) {
488
+ // Vim's exclusive motions ending at column zero stop before the newline.
489
+ // Starting in the first nonblank column makes that range linewise instead.
490
+ if (vim.phase === "g-pending" && !range.inclusive) {
491
+ const ordered = orderedRange(range)
492
+ if (ordered.end.line > ordered.start.line && ordered.end.col === 0) {
493
+ const linewise = ordered.start.col <= Math.max(0, buffer.getLine(ordered.start.line).search(/\S/))
494
+ range = { start: ordered.start, end: { line: ordered.end.line - 1, col: buffer.getLineLength(ordered.end.line - 1) }, linewise, inclusive: false }
495
+ }
496
+ }
461
497
  const keys: string[] = []
462
498
  if (vim.count) keys.push(...String(vim.count))
463
499
  for (const token of vim.pendingChange) {
@@ -474,7 +510,73 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
474
510
  // The engine saves undo points for yanks too. Run read-only operations
475
511
  // against a disposable buffer so they cannot alter the editing history.
476
512
  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)
513
+ const register = vim.selectedRegister ? vim.registers[vim.selectedRegister] ?? "" : vim.register
514
+ const emptyLinePaste = !ctrl && vim.mode === "normal" && vim.phase === "idle" && (key === "p" || key === "P")
515
+ && buffer.getContent() === "" && register.endsWith("\n")
516
+ const result = processKeystroke(key, vim, yanking ? new TextBuffer(buffer.getContent()) : buffer, ctrl, false)
517
+ if (emptyLinePaste) {
518
+ // Linewise puts should fill an empty prompt, not keep its placeholder line.
519
+ // Remove just that line, keeping the engine's undo point and repeat keys.
520
+ buffer.deleteLines(key === "p" ? 0 : buffer.getLineCount() - 1, 1)
521
+ result.newCtx.cursor = { line: 0, col: 0 }
522
+ for (const action of result.actions) {
523
+ if (action.type === "content-change") action.content = buffer.getContent()
524
+ if (action.type === "cursor-move") action.position = result.newCtx.cursor
525
+ }
526
+ }
527
+ return result
528
+ }
529
+
530
+ function lineMotion(key: string): MotionRange | undefined {
531
+ if (vim.mode !== "normal" && !isVisualMode(vim.mode)) return
532
+ const screen = vim.phase === "g-pending"
533
+ if (!screen && vim.phase !== "idle" && vim.phase !== "operator-pending") return
534
+ const down = key === "j" || key === "ArrowDown"
535
+ const up = key === "k" || key === "ArrowUp"
536
+ if (!down && !up && !(screen && (key === "0" || key === "^" || key === "$"))) return
537
+
538
+ const map = buffer.getContent() === activeMap.vimText ? activeMap : createPromptMap(codec.decode(buffer.getContent()), codec)
539
+ const offset = hostOffset(map, vim.cursor)
540
+ const count = vim.count || 1
541
+ let start: number
542
+ let end: number
543
+ let column: number
544
+ if (screen) {
545
+ const lines = activeInput?.editorView?.getLogicalLineInfo?.()
546
+ if (!lines?.lineStartCols.length) return
547
+ let row = 0
548
+ while (row + 1 < lines.lineStartCols.length && lines.lineStartCols[row + 1] <= offset) row++
549
+ column = offset - lines.lineStartCols[row]
550
+ const delta = down ? count : up ? -count : key === "$" ? count - 1 : 0
551
+ const target = clamp(row + delta, 0, lines.lineStartCols.length - 1)
552
+ start = lines.lineStartCols[target]
553
+ end = start + Math.max(0, lines.lineWidthCols[target] - 1)
554
+ if ((down || up) && target === row) return { start: vim.cursor, end: vim.cursor, linewise: false, inclusive: false }
555
+ } else {
556
+ const lineStart = hostOffset(map, { line: vim.cursor.line, col: 0 })
557
+ column = offset - lineStart
558
+ const line = clamp(vim.cursor.line + (down ? count : -count), 0, buffer.getLineCount() - 1)
559
+ start = hostOffset(map, { line, col: 0 })
560
+ end = Math.max(start, hostOffset(map, { line, col: buffer.getLineLength(line) }) - 1)
561
+ }
562
+
563
+ let target: number
564
+ if (down || up) {
565
+ if (preferredColumn === undefined || (preferredScreen !== screen && preferredColumn !== Infinity)) preferredColumn = column
566
+ preferredScreen = screen
567
+ target = Math.min(start + preferredColumn, end)
568
+ } else if (key === "$") {
569
+ target = end
570
+ } else {
571
+ target = start
572
+ if (key === "^") {
573
+ const from = displayToChar(map.hostText, start)
574
+ const to = displayToChar(map.hostText, end + 1)
575
+ const first = map.hostText.slice(from, to).search(/\S/)
576
+ if (first >= 0) target = charToDisplay(map.hostText, from + first)
577
+ }
578
+ }
579
+ return { start: vim.cursor, end: hostPosition(map, target), linewise: !screen, inclusive: !screen || key === "$" }
478
580
  }
479
581
 
480
582
  function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
@@ -555,24 +657,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
555
657
  if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff))
556
658
  }
557
659
 
558
- function cursorOffset(map: PromptMap, position: CursorPosition) {
559
- return hostOffset(map, position, vim.mode === "insert" ? "next" : "previous")
560
- }
561
-
562
- function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition, clampCursor = true) {
660
+ function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) {
563
661
  if (!input) return
564
- input.cursorOffset = cursorOffset(map, position)
565
- if (clampCursor && vim.mode !== "insert") clampNormalCursor(input)
566
- }
567
-
568
- function appendVisualLine(input: EditBufferLike | undefined, map: PromptMap) {
569
- if (!input?.gotoVisualLineEnd) return false
570
- clearVisualSelection(input)
571
- input.gotoVisualLineEnd()
572
- vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? displayWidth(map.hostText)), mode: "insert", phase: "idle", count: 0, operator: null, pendingChange: ["A"], statusMessage: "-- INSERT --" }
573
- nativeInsertUndoSaved = false
574
- syncMode(state, "insert")
575
- return true
662
+ input.cursorOffset = hostOffset(map, position)
663
+ if (vim.mode !== "insert") clampNormalCursor(input)
576
664
  }
577
665
 
578
666
  function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) {
@@ -629,6 +717,15 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
629
717
  }
630
718
  }
631
719
 
720
+ function readOnlyKey(key: string, ctrl: boolean, vim: VimContext) {
721
+ if (key === "Escape") return true
722
+ if (ctrl) return false
723
+ if (vim.phase === "char-pending" || vim.phase === "text-object-pending" || vim.phase === "register-pending") return true
724
+ if (vim.phase === "g-pending") return ["g", "j", "k", "0", "^", "$", "ArrowDown", "ArrowUp"].includes(key)
725
+ if ((vim.phase === "operator-pending" || isVisualMode(vim.mode)) && (key === "i" || key === "a")) return true
726
+ return /^[0-9hjklwWbBeE$^gGfFtT;,vVy%{}()"]$/.test(key) || ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(key)
727
+ }
728
+
632
729
  function forwardWordRange(key: string, start: CursorPosition, buffer: TextBuffer, count: number): MotionRange {
633
730
  let { line, col } = start
634
731
  for (let index = 0; index < count; index++) {
@@ -654,16 +751,16 @@ function wordClass(char: string | undefined, big: boolean) {
654
751
  }
655
752
 
656
753
  function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
657
- const anchorOffset = hostOffset(map, anchor, "next")
658
- const cursorOffset = hostOffset(map, cursor, "previous")
754
+ const anchorOffset = hostOffset(map, anchor)
755
+ const cursorOffset = hostOffset(map, cursor)
659
756
  return hostRange(map, anchorOffset, cursorOffset)
660
757
  }
661
758
 
662
759
  function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
663
760
  const startLine = Math.min(anchor.line, cursor.line)
664
761
  const endLine = Math.max(anchor.line, cursor.line)
665
- const start = hostOffset(map, { line: startLine, col: 0 }, "next")
666
- const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) }, "previous")
762
+ const start = hostOffset(map, { line: startLine, col: 0 })
763
+ const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) })
667
764
  return hostRange(map, start, end)
668
765
  }
669
766
 
@@ -686,8 +783,8 @@ function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefi
686
783
  }
687
784
 
688
785
  function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
689
- const start = hostFromVimOffset(map, Math.min(left, right), "next")
690
- const end = hostFromVimOffset(map, Math.max(left, right), "previous")
786
+ const start = hostFromVimOffset(map, Math.min(left, right))
787
+ const end = hostFromVimOffset(map, Math.max(left, right))
691
788
  return hostRange(map, start, end)
692
789
  }
693
790
 
@@ -756,27 +853,6 @@ function vimOffsetFromPosition(text: string, position: CursorPosition) {
756
853
  return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
757
854
  }
758
855
 
759
- function hostFromVimOffset(map: PromptMap, offset: number, bias: "previous" | "next") {
760
- const current = clamp(offset, 0, map.vimText.length)
761
- if (current === map.vimText.length) return displayWidth(map.hostText)
762
-
763
- const host = map.vimToHost[current]
764
- if (host !== undefined) return charToDisplay(map.hostText, host)
765
-
766
- if (bias === "previous") {
767
- for (let previous = current - 1; previous >= 0; previous--) {
768
- const previousHost = map.vimToHost[previous]
769
- if (previousHost !== undefined) return charToDisplay(map.hostText, previousHost)
770
- }
771
- }
772
-
773
- for (let next = current + 1; next < map.vimToHost.length; next++) {
774
- const nextHost = map.vimToHost[next]
775
- if (nextHost !== undefined) return charToDisplay(map.hostText, nextHost)
776
- }
777
- return displayWidth(map.hostText)
778
- }
779
-
780
856
  function clampNormalCursor(input: EditBufferLike) {
781
857
  const cursor = input.visualCursor
782
858
  const offset = input.cursorOffset
@@ -825,27 +901,6 @@ function hasNormalKeyPrefix(config: VimConfig, key: string) {
825
901
  return false
826
902
  }
827
903
 
828
- function endMotionOffset(text: string, offset: number, count: number) {
829
- let index = offset
830
- for (let step = 0; step < count; step++) {
831
- index++
832
- while (index < text.length && isWhitespace(text[index])) index++
833
- const kind = wordKind(text[index])
834
- if (!kind) return Math.max(0, text.length - 1)
835
- while (wordKind(text[index + 1]) === kind) index++
836
- }
837
- return clamp(index, 0, Math.max(0, text.length - 1))
838
- }
839
-
840
- function isWhitespace(value: string | undefined) {
841
- return value === " " || value === "\t" || value === "\n"
842
- }
843
-
844
- function wordKind(value: string | undefined) {
845
- if (!value || isWhitespace(value)) return undefined
846
- return /\w/.test(value) ? "word" : "punctuation"
847
- }
848
-
849
904
  function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined {
850
905
  const map = createKeybindMap()
851
906
  let count = 0
@@ -1060,7 +1115,16 @@ function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
1060
1115
 
1061
1116
  function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
1062
1117
  if (operator !== "y") buffer.saveUndoPoint(ctx.cursor)
1063
- const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer)
1118
+ if (operator === ">" || operator === "<") {
1119
+ const result = executeOperatorOnRange(operator, range, buffer, ctx.cursor, { style: ctx.indentStyle, width: ctx.indentWidth })
1120
+ const cursor = { line: result.newCursor.line, col: Math.max(0, buffer.getLine(result.newCursor.line).search(/\S/)) }
1121
+ return {
1122
+ actions: [...result.actions, { type: "cursor-move", position: cursor }] as VimeeAction[],
1123
+ context: { ...resetContext(ctx), cursor },
1124
+ yankedText: "",
1125
+ }
1126
+ }
1127
+ const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer, ctx.cursor) : executeCharwiseTextObject(operator, range, buffer)
1064
1128
  const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
1065
1129
  const context = {
1066
1130
  ...resetContext(ctx),
@@ -1074,13 +1138,14 @@ function executeTextObject(operator: Operator, range: MotionRange, buffer: TextB
1074
1138
  return { actions, context, yankedText: result.yankedText }
1075
1139
  }
1076
1140
 
1077
- function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
1141
+ function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, cursor: CursorPosition) {
1078
1142
  const startLine = Math.min(range.start.line, range.end.line)
1079
1143
  const endLine = Math.max(range.start.line, range.end.line)
1080
1144
  const lineCount = endLine - startLine + 1
1081
1145
  const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n"
1082
1146
  if (operator === "y") {
1083
- return { actions: [] as VimeeAction[], cursor: { line: startLine, col: 0 }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" }
1147
+ const col = Math.min(cursor.col, Math.max(0, buffer.getLineLength(startLine) - 1))
1148
+ return { actions: [] as VimeeAction[], cursor: { line: startLine, col }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" }
1084
1149
  }
1085
1150
 
1086
1151
  buffer.deleteLines(startLine, lineCount)