opencode-vim 0.0.23 → 0.0.24

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.
Files changed (38) hide show
  1. package/README.md +11 -0
  2. package/dist/tui.js +3326 -0
  3. package/package.json +6 -6
  4. package/src/clipboard.tsx +0 -50
  5. package/src/modules/snippets/index.tsx +0 -17
  6. package/src/modules/snippets/loader.ts +0 -147
  7. package/src/modules/snippets/search.ts +0 -118
  8. package/src/modules/snippets/skill-loader.ts +0 -81
  9. package/src/modules/snippets/state.ts +0 -40
  10. package/src/modules/snippets/trigger.ts +0 -68
  11. package/src/modules/snippets/types.ts +0 -50
  12. package/src/modules/snippets/view.tsx +0 -674
  13. package/src/modules/vim/actions.ts +0 -82
  14. package/src/modules/vim/config.ts +0 -137
  15. package/src/modules/vim/edit.test.ts +0 -51
  16. package/src/modules/vim/edit.ts +0 -33
  17. package/src/modules/vim/graphemes.ts +0 -38
  18. package/src/modules/vim/index.tsx +0 -200
  19. package/src/modules/vim/keys.ts +0 -38
  20. package/src/modules/vim/log.ts +0 -37
  21. package/src/modules/vim/map.test.ts +0 -45
  22. package/src/modules/vim/map.ts +0 -90
  23. package/src/modules/vim/state.test.ts +0 -18
  24. package/src/modules/vim/state.tsx +0 -46
  25. package/src/modules/vim/view.tsx +0 -130
  26. package/src/modules/vim/vimee.test.ts +0 -211
  27. package/src/modules/vim/vimee.ts +0 -1211
  28. package/src/plugin.tsx +0 -116
  29. package/src/prompt/host.tsx +0 -38
  30. package/src/prompt/modules.ts +0 -16
  31. package/src/prompt/root.tsx +0 -69
  32. package/src/prompt/types.ts +0 -42
  33. package/src/session.tsx +0 -291
  34. package/src/transcript-items.tsx +0 -168
  35. package/src/transcript.tsx +0 -179
  36. package/src/update.ts +0 -120
  37. package/tui.tsx +0 -340
  38. package/view.tsx +0 -29
@@ -1,1211 +0,0 @@
1
- import type { KeyEvent } from "@opentui/core"
2
- import { TextBuffer, createInitialContext, createKeybindMap, executeOperatorOnRange, parseKeySequence, processKeystroke, resetContext, resolveMotion } from "@vimee/core"
3
- import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
4
- import { focusedInput, setInput, type EditBufferLike, type PromptContext } from "./actions"
5
- import type { VimConfig } from "./config"
6
- import type { VimLog } from "./log"
7
- import { createGraphemeCodec } from "./graphemes"
8
- import { charToDisplay, displayToChar, displayWidth, createPromptMap, hostFromVimOffset, hostOffset, hostPosition, type PromptMap } from "./map"
9
- import type { createVimState } from "./state"
10
-
11
- type VimState = ReturnType<typeof createVimState>
12
- type HostAction = VimeeAction & { register?: string } | { type: "submit" } | { type: "command"; command: string }
13
- type HostKeybindAction = "normal" | "submit" | "command"
14
- type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction; command?: string }
15
- type HostRange = { start: number; end: number }
16
-
17
- export const YANK_FLASH_MS = 250
18
-
19
- export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
20
-
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 = {}) {
28
- const codec = createGraphemeCodec()
29
- let buffer = new TextBuffer("")
30
- let activeMap = createPromptMap("", codec)
31
- let vim = createInitialContext({ line: 0, col: 0 })
32
- const keybinds = options.readOnly ? undefined : createKeybinds(config, log)
33
- let timer: ReturnType<typeof setTimeout> | undefined
34
- let yankTimer: ReturnType<typeof setTimeout> | undefined
35
- let yankFlashActive = false
36
- let pendingInsert = ""
37
- let pendingTarget: { input: EditBufferLike; offset: number } | undefined
38
- let pendingContext: PromptContext | undefined
39
- let nativeInsertUndoSaved = false
40
- let historyText: string | undefined
41
- let activeInput: EditBufferLike | undefined
42
- let preferredColumn: number | undefined
43
- let preferredScreen = false
44
- let generation = 0
45
- const defaultHistoryKeys = {
46
- k: !hasNormalKeyPrefix(config, "k"),
47
- j: !hasNormalKeyPrefix(config, "j"),
48
- }
49
-
50
- return {
51
- attach,
52
- suspend,
53
- setRegister(text: string) { vim = { ...vim, register: codec.encode(text) } },
54
- isPending: () => vim.phase !== "idle" || vim.count > 0 || !!keybinds?.isPending(),
55
- handle(event: KeyEvent, key: string, ctx: PromptContext): boolean | Promise<boolean> {
56
- const ref = ctx.prompt()
57
- if (!ref) return false
58
- const input = focusedInput(ctx)
59
- attach(ctx)
60
-
61
- let vimeeKey = keyForVimee(event, key)
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
- }
68
- vimeeKey = codec.encode(vimeeKey)
69
-
70
- if (state.mode() === "insert") {
71
- if (vimeeKey === "Escape") {
72
- cancelPendingInsert(ctx)
73
- enterNormal(ctx)
74
- state.setPending("")
75
- updateTimeout(ctx)
76
- log("vimee.key", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
77
- return true
78
- }
79
- return handleInsertKeybind(event, vimeeKey, ctx)
80
- }
81
-
82
- const text = input?.plainText ?? ref.current.input
83
- const dw = displayWidth(text)
84
- const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
85
- const charOff = displayToChar(text, displayOff)
86
-
87
- const canBrowseHistory = !options.readOnly && vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
88
- const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined
89
- if (historyCommand) {
90
- const result = dispatchCommand(historyCommand, ctx)
91
- historyText = result.ok ? (focusedInput(ctx)?.plainText ?? ref.current.input) : undefined
92
- state.setPending("")
93
- updateTimeout(ctx)
94
- log("vimee.history", { key, command: historyCommand, handled: result.ok })
95
- return true
96
- }
97
- historyText = undefined
98
-
99
- const map = mapForHostText(text)
100
- const cursor = hostPosition(map, displayOff)
101
-
102
- const wasPending = keybinds?.isPending() ?? false
103
- const pendingBefore = pendingInsert
104
- sync(map, cursor)
105
-
106
- const shouldFlashYank = shouldFlashYankFor(vimeeKey)
107
- const visualYankRange = visualYankRangeFor(map)
108
- vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
109
- const textObjectHandled = handleTextObject(vimeeKey, ctx, map)
110
- if (textObjectHandled !== undefined) return textObjectHandled
111
- const resolved = keybinds?.resolve(vimeeKey, vim.mode, event.ctrl)
112
- if (resolved?.status === "pending") {
113
- vim = { ...vim, statusMessage: resolved.display }
114
- state.setPending(resolved.display)
115
- updateTimeout(ctx)
116
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: [] })
117
- return true
118
- }
119
- if (vimeeKey === "Enter" && state.mode() === "normal" && vim.phase === "idle" && !wasPending && resolved?.status !== "matched") {
120
- ref.submit()
121
- state.setPending("")
122
- updateTimeout(ctx)
123
- log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["submit"] })
124
- return true
125
- }
126
-
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
- })
137
- }
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)
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)
166
- }
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))
181
- }
182
-
183
- function attach(ctx: PromptContext) {
184
- const input = focusedInput(ctx)
185
- if (activeInput === input) return
186
- suspend()
187
- activeInput = input
188
- activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", codec)
189
- buffer = new TextBuffer(activeMap.vimText)
190
- nativeInsertUndoSaved = false
191
- vim = { ...resetContext(vim), cursor: hostPosition(activeMap, input?.cursorOffset ?? 0), mode: state.mode() }
192
- }
193
-
194
- function suspend() {
195
- generation++
196
- preferredColumn = undefined
197
- if (pendingContext) cancelPendingInsert(pendingContext)
198
- if (timer) clearTimeout(timer)
199
- timer = undefined
200
- keybinds?.cancel()
201
- cancelYankFlash()
202
- if (activeInput && !activeInput.isDestroyed) clearVisualSelection(activeInput)
203
- if (isVisualMode(state.mode())) state.setMode("normal")
204
- vim = resetContext(vim)
205
- state.setPending("")
206
- pendingTarget = undefined
207
- pendingContext = undefined
208
- }
209
-
210
- function sync(map: PromptMap, cursor: CursorPosition) {
211
- if (cursor.line !== vim.cursor.line || cursor.col !== vim.cursor.col) preferredColumn = undefined
212
- if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
213
- vim = { ...vim, cursor, mode: state.mode() }
214
- }
215
-
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") {
221
- if (!nativeInsertUndoSaved) {
222
- buffer.saveUndoPoint(vim.cursor)
223
- nativeInsertUndoSaved = true
224
- }
225
- buffer.replaceContent(activeMap.vimText)
226
- } else {
227
- buffer = new TextBuffer(activeMap.vimText)
228
- nativeInsertUndoSaved = false
229
- }
230
- return activeMap
231
- }
232
-
233
- function recordNativeChange(text: string) {
234
- const before = codec.encode(activeMap.hostText)
235
- const after = codec.encode(text)
236
- const start = codec.encode(activeMap.hostText.slice(0, displayToChar(activeMap.hostText, hostOffset(activeMap, vim.cursor)))).length
237
- let prefix = 0
238
- while (prefix < start && prefix < after.length && before[prefix] === after[prefix]) prefix++
239
- let suffix = 0
240
- while (suffix < before.length - start && suffix < after.length - prefix && before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++
241
- const keys = [...vim.pendingChange]
242
- if (!keys.length) keys.push("i")
243
- for (let index = prefix; index < start; index++) keys.push("Backspace")
244
- for (let index = start; index < before.length - suffix; index++) keys.push("Delete")
245
- for (const key of after.slice(prefix, after.length - suffix)) keys.push(key === "\n" ? "Enter" : key === "\t" ? "Tab" : key)
246
- vim = { ...vim, pendingChange: keys }
247
- }
248
-
249
- function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) {
250
- const ref = ctx.prompt()
251
- const input = focusedInput(ctx)
252
- let currentMap = map
253
- if (!ref) return
254
-
255
- for (const action of actions) {
256
- switch (action.type) {
257
- case "content-change":
258
- currentMap = createPromptMap(codec.decode(action.content), codec)
259
- activeMap = currentMap
260
- setInput(ref, currentMap.hostText)
261
- buffer.replaceContent(action.content)
262
- break
263
- case "cursor-move":
264
- setCursor(input, currentMap, action.position)
265
- break
266
- case "yank":
267
- if (!action.register) options.onYank?.(codec.decode(action.text))
268
- break
269
- case "mode-change":
270
- nativeInsertUndoSaved = action.mode === "insert" && actions.some((item) => item.type === "content-change")
271
- if (action.mode === "insert") {
272
- cancelYankFlash()
273
- if (input) clearVisualSelection(input)
274
- }
275
- syncMode(state, action.mode)
276
- break
277
- case "quit":
278
- ref.blur()
279
- break
280
- case "submit":
281
- ref.submit()
282
- break
283
- case "command":
284
- dispatchCommand(action.command, ctx)
285
- return
286
- }
287
- }
288
-
289
- setCursor(input, currentMap, vim.cursor)
290
- syncVisualSelection(input, currentMap, ctx)
291
- }
292
-
293
- function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext): boolean {
294
- if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return false
295
-
296
- const wasPending = keybinds.isPending()
297
- const pendingBefore = pendingInsert
298
- const resolved = keybinds.resolve(key, "insert", event.ctrl)
299
-
300
- switch (resolved.status) {
301
- case "pending":
302
- if (!wasPending) {
303
- const input = focusedInput(ctx)
304
- pendingTarget = input ? { input, offset: input.cursorOffset ?? 0 } : undefined
305
- pendingContext = ctx
306
- }
307
- pendingInsert = codec.decode(plainPending(resolved.display))
308
- state.setPending(resolved.display)
309
- updateTimeout(ctx)
310
- return true
311
- case "matched": {
312
- if (applyInsertKeybind(resolved.definition, ctx)) {
313
- pendingInsert = ""
314
- state.setPending("")
315
- updateTimeout(ctx)
316
- log("vimee.keybind", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
317
- return true
318
- }
319
-
320
- if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore)
321
- pendingInsert = ""
322
- state.setPending("")
323
- updateTimeout(ctx)
324
- log("vimee.keybind.unsupported", { key })
325
- return false
326
- }
327
- case "none":
328
- if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore)
329
- pendingInsert = ""
330
- state.setPending("")
331
- updateTimeout(ctx)
332
- return wasPending ? handleInsertKeybind(event, key, ctx) : false
333
- }
334
- }
335
-
336
- function applyInsertKeybind(definition: KeybindDefinition, ctx: PromptContext) {
337
- switch (insertHostAction(definition)) {
338
- case "normal":
339
- enterNormal(ctx)
340
- return true
341
- case "submit":
342
- ctx.prompt()?.submit()
343
- return true
344
- case "command":
345
- dispatchCommand((definition as HostKeybindDefinition).command!, ctx)
346
- return true
347
- default:
348
- return false
349
- }
350
- }
351
-
352
- function enterNormal(ctx: PromptContext) {
353
- const ref = ctx.prompt()
354
- const input = focusedInput(ctx)
355
- const text = input?.plainText ?? ref?.current.input ?? ""
356
- const map = mapForHostText(text)
357
-
358
- if (input && text.length > 0) {
359
- const dw = displayWidth(text)
360
- const offset = clamp(input?.cursorOffset ?? dw, 0, dw)
361
- const charOffset = displayToChar(text, offset)
362
- if (charOffset > 0 && text[charOffset - 1] !== "\n") {
363
- input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1)))
364
- }
365
- clampNormalCursor(input)
366
- }
367
-
368
- const lastChange = nativeInsertUndoSaved ? [...vim.pendingChange, "Escape"] : vim.lastChange
369
- vim = { ...resetContext(vim), cursor: hostPosition(map, input?.cursorOffset ?? 0), mode: "normal", statusMessage: "", lastChange, pendingChange: [] }
370
- nativeInsertUndoSaved = false
371
- syncMode(state, "normal")
372
- }
373
-
374
- function insertHostAction(definition: KeybindDefinition): HostKeybindAction | undefined {
375
- const action = (definition as HostKeybindDefinition).hostAction
376
- if (action) return action
377
- if ("execute" in definition) return undefined
378
- if (definition.keys === "<Esc>" || definition.keys === "<C-[>" || definition.keys === "Escape") return "normal"
379
- return undefined
380
- }
381
-
382
- function applyKeybind(definition: KeybindDefinition, ctx: PromptContext, map: PromptMap) {
383
- if ("execute" in definition) {
384
- const actions = definition.execute(vim, buffer) as HostAction[]
385
- vim = { ...vim, cursor: hostPosition(map, hostOffset(map, vim.cursor)) }
386
- applyActions(actions, ctx, map)
387
- return actions
388
- }
389
-
390
- let actions: HostAction[] = []
391
- for (const token of parseKeySequence(definition.keys)) {
392
- const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token))
393
- vim = result.newCtx
394
- // Keep the host layout current for screen motions later in the mapping.
395
- applyActions(result.actions, ctx, activeMap)
396
- actions = [...actions, ...(result.actions as HostAction[])]
397
- }
398
- return actions
399
- }
400
-
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
- }
425
- let wordRange: MotionRange | undefined
426
- if (!ctrl && vim.mode !== "insert" && (key === "w" || key === "W") && (vim.phase === "idle" || vim.phase === "operator-pending")) {
427
- wordRange = forwardWordRange(key, vim.cursor, buffer, vim.count || 1)
428
- if (vim.phase === "idle") {
429
- const cursor = { ...wordRange.end, col: Math.min(wordRange.end.col, Math.max(0, buffer.getLineLength(wordRange.end.line) - 1)) }
430
- return { newCtx: { ...resetContext(vim), cursor }, actions: [{ type: "cursor-move", position: cursor }] }
431
- }
432
- }
433
- if (key === "." && !ctrl && vim.mode === "normal" && vim.phase === "idle" && vim.lastChange.length) {
434
- // A repeated insert/change is one undo step, including native typing.
435
- const original = buffer
436
- const before = buffer.getContent()
437
- const cursor = vim.cursor
438
- const keys = [...vim.lastChange]
439
- if (vim.count) {
440
- while (/^[0-9]$/.test(keys[0] ?? "")) keys.shift()
441
- keys.unshift(...String(vim.count))
442
- }
443
- vim = { ...vim, count: 0 }
444
- const actions: HostAction[] = []
445
- buffer = new TextBuffer(before)
446
- try {
447
- for (const token of keys) {
448
- const result = processKey(token)
449
- vim = result.newCtx
450
- actions.push(...result.actions)
451
- }
452
- if (buffer.getContent() !== before) {
453
- original.saveUndoPoint(cursor)
454
- original.replaceContent(buffer.getContent())
455
- }
456
- } finally {
457
- buffer = original
458
- }
459
- return { newCtx: { ...vim, lastChange: keys, pendingChange: [] }, actions }
460
- }
461
-
462
- const operator = vim.operator
463
- if (!range && !ctrl && operator && textObjectOperator(operator)) {
464
- if (vim.phase === "operator-pending") {
465
- const count = vim.count || 1
466
- if (key === "l" || key === "ArrowRight") {
467
- // Vimee 0.3 treats l as inclusive; Vim's operator motion is exclusive.
468
- 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 }
469
- } else if (key === operator) {
470
- 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 }
471
- } else if (operator === "c" && key === "w" && /\S/.test(buffer.getLine(vim.cursor.line)[vim.cursor.col] ?? "")) {
472
- range = resolveMotion("e", { ...vim.cursor, col: vim.cursor.col - 1 }, buffer, count, vim.count > 0, vim)?.range
473
- if (range) range.start = vim.cursor
474
- } else if (wordRange) {
475
- range = wordRange
476
- 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/))) {
477
- range.end = { line: range.end.line - 1, col: buffer.getLineLength(range.end.line - 1) }
478
- }
479
- } else if (operator === "c") {
480
- const motion = resolveMotion(key, vim.cursor, buffer, count, vim.count > 0, vim)
481
- if (motion?.range.linewise) range = motion.range
482
- }
483
- } else if (vim.phase === "text-object-pending" && vim.textObjectModifier) {
484
- range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer) ?? undefined
485
- }
486
- }
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
- }
497
- const keys: string[] = []
498
- if (vim.count) keys.push(...String(vim.count))
499
- for (const token of vim.pendingChange) {
500
- if (!/^[0-9]$/.test(token)) keys.push(token)
501
- }
502
- keys.push(key)
503
- const result = executeTextObject(operator, range, buffer, vim)
504
- if (operator === "y") result.context.pendingChange = []
505
- else if (result.context.mode === "insert") result.context.pendingChange = keys
506
- else { result.context.lastChange = keys; result.context.pendingChange = [] }
507
- return { newCtx: result.context, actions: result.actions }
508
- }
509
-
510
- // The engine saves undo points for yanks too. Run read-only operations
511
- // against a disposable buffer so they cannot alter the editing history.
512
- const yanking = operator === "y" || (isVisualMode(vim.mode) && key === "y") || (vim.mode === "normal" && key === "Y")
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 === "$" }
580
- }
581
-
582
- function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
583
- if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined
584
- const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer)
585
- if (!range) return undefined
586
-
587
- const flashRange = vim.operator === "y" ? motionHostRange(map, range) : undefined
588
-
589
- if (!vim.operator) {
590
- vim = { ...resetContext(vim), visualAnchor: range.start, cursor: range.end }
591
- applyActions([{ type: "cursor-move", position: range.end }], ctx, map)
592
- state.setPending("")
593
- updateTimeout(ctx)
594
- log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["cursor-move"] })
595
- return true
596
- }
597
- if (!textObjectOperator(vim.operator)) return undefined
598
-
599
- const result = processKey(key)
600
- vim = result.newCtx
601
- applyActions(result.actions, ctx, map)
602
- if (flashRange) flashYank(ctx, activeMap, result.actions.find((action): action is YankAction => action.type === "yank"), flashRange)
603
- syncMode(state, vim.mode)
604
- state.setPending("")
605
- updateTimeout(ctx)
606
- log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
607
- return true
608
- }
609
-
610
- function updateTimeout(ctx: PromptContext) {
611
- if (timer) clearTimeout(timer)
612
- timer = undefined
613
- if (!keybinds?.isPending()) return
614
- timer = setTimeout(() => {
615
- keybinds.cancel()
616
- flushPendingInsert(ctx, pendingInsert)
617
- pendingInsert = ""
618
- state.setPending("")
619
- ctx.requestRender()
620
- }, config.keymapTimeout)
621
- }
622
-
623
- function cancelPendingInsert(ctx: PromptContext, offset?: number, flush = true) {
624
- if (!keybinds?.isPending()) return
625
- if (timer) clearTimeout(timer)
626
- timer = undefined
627
- keybinds.cancel()
628
- if (flush) flushPendingInsert(ctx, pendingInsert, offset)
629
- pendingInsert = ""
630
- pendingTarget = undefined
631
- pendingContext = undefined
632
- state.setPending("")
633
- }
634
-
635
- function flushPendingInsert(ctx: PromptContext, value: string, charOffset?: number) {
636
- if (!value || state.mode() !== "insert") return
637
- if (pendingTarget?.input.isDestroyed) return
638
- if (pendingTarget?.input.insertText && !pendingTarget.input.isDestroyed) {
639
- const { input, offset } = pendingTarget
640
- const cursor = input.cursorOffset ?? 0
641
- input.clearSelection?.()
642
- input.cursorOffset = offset
643
- input.insertText!(value)
644
- input.cursorOffset = cursor >= offset ? cursor + displayWidth(value) : cursor
645
- return
646
- }
647
- const ref = ctx.prompt()
648
- if (!ref) return
649
- const input = focusedInput(ctx)
650
- const text = input?.plainText ?? ref.current.input
651
- const currentDisplayOff = input?.cursorOffset ?? 0
652
- const currentCharOff = displayToChar(text, currentDisplayOff)
653
- const insertAtChar = charOffset ?? currentCharOff
654
- const next = text.slice(0, insertAtChar) + value + text.slice(insertAtChar)
655
- setInput(ref, next)
656
- const nextCharOff = currentCharOff >= insertAtChar ? currentCharOff + value.length : currentCharOff
657
- if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff))
658
- }
659
-
660
- function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) {
661
- if (!input) return
662
- input.cursorOffset = hostOffset(map, position)
663
- if (vim.mode !== "insert") clampNormalCursor(input)
664
- }
665
-
666
- function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) {
667
- if (!input) return
668
- if (!isVisualMode(vim.mode) || !vim.visualAnchor) {
669
- if (!yankFlashActive) clearVisualSelection(input)
670
- return
671
- }
672
-
673
- cancelYankFlash()
674
- const range = vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor)
675
- if (!range) {
676
- clearVisualSelection(input)
677
- return
678
- }
679
-
680
- setSelection(input, range.start, range.end, ctx)
681
- }
682
-
683
- function visualYankRangeFor(map: PromptMap) {
684
- if (!isVisualMode(vim.mode) || !vim.visualAnchor) return undefined
685
- return vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor)
686
- }
687
-
688
- function flashYank(ctx: PromptContext, map: PromptMap, action: YankAction | undefined, visualRange: HostRange | undefined) {
689
- if (!action) return
690
- const input = focusedInput(ctx)
691
- if (!input) return
692
- const range = visualRange ?? yankedTextRange(map, vim.cursor, action.text)
693
- if (!range) return
694
-
695
- cancelYankFlash()
696
- yankFlashActive = true
697
- setYankSelection(input, range.start, range.end, ctx)
698
- ctx.requestRender()
699
- yankTimer = setTimeout(() => {
700
- yankTimer = undefined
701
- if (!yankFlashActive) return
702
- yankFlashActive = false
703
- clearVisualSelection(input)
704
- ctx.requestRender()
705
- }, YANK_FLASH_MS)
706
- }
707
-
708
- function cancelYankFlash() {
709
- if (yankTimer) clearTimeout(yankTimer)
710
- yankTimer = undefined
711
- yankFlashActive = false
712
- }
713
-
714
- function shouldFlashYankFor(key: string) {
715
- if (isVisualMode(vim.mode)) return key === "y"
716
- return vim.operator === "y"
717
- }
718
- }
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
-
729
- function forwardWordRange(key: string, start: CursorPosition, buffer: TextBuffer, count: number): MotionRange {
730
- let { line, col } = start
731
- for (let index = 0; index < count; index++) {
732
- const text = buffer.getLine(line)
733
- const kind = wordClass(text[col], key === "W")
734
- while (col < text.length && wordClass(text[col], key === "W") === kind) col++
735
- while (col < text.length && /\s/.test(text[col])) col++
736
- while (col >= buffer.getLineLength(line) && line < buffer.getLineCount() - 1) {
737
- line++
738
- col = 0
739
- const next = buffer.getLine(line)
740
- if (!next.length) break
741
- while (col < next.length && /\s/.test(next[col])) col++
742
- if (col < next.length) break
743
- }
744
- }
745
- return { start, end: { line, col }, linewise: false, inclusive: false }
746
- }
747
-
748
- function wordClass(char: string | undefined, big: boolean) {
749
- if (!char || /\s/.test(char)) return "space"
750
- return big || /\w/.test(char) ? "word" : "punctuation"
751
- }
752
-
753
- function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
754
- const anchorOffset = hostOffset(map, anchor)
755
- const cursorOffset = hostOffset(map, cursor)
756
- return hostRange(map, anchorOffset, cursorOffset)
757
- }
758
-
759
- function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
760
- const startLine = Math.min(anchor.line, cursor.line)
761
- const endLine = Math.max(anchor.line, cursor.line)
762
- const start = hostOffset(map, { line: startLine, col: 0 })
763
- const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) })
764
- return hostRange(map, start, end)
765
- }
766
-
767
- function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string): HostRange | undefined {
768
- if (!text) return undefined
769
- if (text.endsWith("\n")) {
770
- const lineCount = text.split("\n").length - 1
771
- return visualLineRange(map, cursor, { line: cursor.line + Math.max(0, lineCount - 1), col: 0 })
772
- }
773
-
774
- const start = vimOffsetFromPosition(map.vimText, cursor)
775
- return vimOffsetRange(map, start, start + text.length - 1)
776
- }
777
-
778
- function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined {
779
- if (range.linewise) return visualLineRange(map, range.start, range.end)
780
- const start = vimOffsetFromPosition(map.vimText, range.start)
781
- const end = vimOffsetFromPosition(map.vimText, range.end)
782
- return vimOffsetRange(map, start, end)
783
- }
784
-
785
- function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
786
- const start = hostFromVimOffset(map, Math.min(left, right))
787
- const end = hostFromVimOffset(map, Math.max(left, right))
788
- return hostRange(map, start, end)
789
- }
790
-
791
- function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined {
792
- if (!map.hostText) return undefined
793
- const dw = displayWidth(map.hostText)
794
- const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1))
795
- const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1))
796
- return { start, end }
797
- }
798
-
799
- type YankAction = Extract<VimeeAction, { type: "yank" }>
800
-
801
- function yankAction(actions: HostAction[]): YankAction | undefined {
802
- return actions.find((action): action is YankAction => action.type === "yank")
803
- }
804
-
805
- function setSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) {
806
- setSelectionColors(input, start, end, ctx.api.theme.current.warning, ctx.api.theme.current.background)
807
- }
808
-
809
- function setYankSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) {
810
- setSelectionColors(input, start, end, ctx.api.theme.current.info, ctx.api.theme.current.background)
811
- }
812
-
813
- function setSelectionColors(input: EditBufferLike, start: number, end: number, background: PromptContext["api"]["theme"]["current"]["warning"], foreground: PromptContext["api"]["theme"]["current"]["background"]) {
814
- input.selectionBg = background
815
- input.selectionFg = foreground
816
-
817
- if (input.setSelectionInclusive) {
818
- input.setSelectionInclusive(start, end)
819
- return
820
- }
821
-
822
- const exclusiveEnd = end + 1
823
- if (input.setSelection) {
824
- input.setSelection(start, exclusiveEnd)
825
- return
826
- }
827
-
828
- input.editorView?.setSelection?.(start, exclusiveEnd, background, foreground)
829
- }
830
-
831
- function clearVisualSelection(input: EditBufferLike) {
832
- if (input.clearSelection) {
833
- input.clearSelection()
834
- return
835
- }
836
-
837
- input.editorView?.resetSelection?.()
838
- }
839
-
840
- function isVisualMode(mode: VimContext["mode"]): mode is "visual" | "visual-line" {
841
- return mode === "visual" || mode === "visual-line"
842
- }
843
-
844
- function vimLineLength(text: string, line: number) {
845
- return text.split("\n")[line]?.length ?? 0
846
- }
847
-
848
- function vimOffsetFromPosition(text: string, position: CursorPosition) {
849
- const lines = text.split("\n")
850
- const line = clamp(position.line, 0, Math.max(0, lines.length - 1))
851
- let offset = 0
852
- for (let index = 0; index < line; index++) offset += lines[index].length + 1
853
- return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
854
- }
855
-
856
- function clampNormalCursor(input: EditBufferLike) {
857
- const cursor = input.visualCursor
858
- const offset = input.cursorOffset
859
- const text = input.plainText
860
- if (!cursor || offset === undefined || text === undefined) return
861
- if (cursor.visualCol === 0) return
862
- const dw = displayWidth(text)
863
- if (offset >= dw) {
864
- input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, dw - 1)))
865
- return
866
- }
867
- const charIdx = displayToChar(text, offset)
868
- if (charIdx < text.length && text[charIdx] === '\n') {
869
- input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1)))
870
- }
871
- }
872
-
873
- function dispatchCommand(command: string, ctx: PromptContext) {
874
- const input = focusedInput(ctx)
875
- if (command === "prompt.history.previous" && input) input.cursorOffset = 0
876
- if (command === "prompt.history.next" && input) input.cursorOffset = input.plainText?.length ?? 0
877
-
878
- const result = ctx.api.keymap.dispatchCommand(command)
879
-
880
- // The host currently checks history-next using character length, then leaves
881
- // the cursor in that unit. Restore the display-width offset after dispatch.
882
- if (command !== "prompt.history.next" || !result.ok) return result
883
- const nextInput = focusedInput(ctx)
884
- if (nextInput?.plainText !== undefined) nextInput.cursorOffset = displayWidth(nextInput.plainText)
885
- return result
886
- }
887
-
888
- function defaultHistoryCommand(key: string, text: string, historyText: string | undefined, enabled: Record<"j" | "k", boolean>) {
889
- if (key !== "j" && key !== "k") return
890
- if (!enabled[key]) return
891
- if (text.length > 0 && text !== historyText) return
892
- return key === "k" ? "prompt.history.previous" : "prompt.history.next"
893
- }
894
-
895
- function hasNormalKeyPrefix(config: VimConfig, key: string) {
896
- for (const sequence of Object.keys(config.keymaps.normal ?? {})) {
897
- try {
898
- if (keyToken(parseKeySequence(sequence)[0] ?? "") === key) return true
899
- } catch {}
900
- }
901
- return false
902
- }
903
-
904
- function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined {
905
- const map = createKeybindMap()
906
- let count = 0
907
-
908
- for (const [mode, keymaps] of Object.entries(config.keymaps) as Array<[VimeeMode, Record<string, string> | undefined]>) {
909
- if (!keymaps) continue
910
- for (const [keys, action] of Object.entries(keymaps)) {
911
- try {
912
- const tokens = parseKeySequence(keys)
913
- if (tokens.length > 1 && tokens[0] === "<CR>") throw new Error("<CR> cannot start a multi-key mapping")
914
- map.addKeybind(mode, keys as ValidKeySequence<typeof keys>, keybindAction(action))
915
- count++
916
- } catch (error) {
917
- log("vimee.keymap.invalid", { mode, keys, action, error: error instanceof Error ? error.message : String(error) })
918
- }
919
- }
920
- }
921
-
922
- return count > 0 ? map : undefined
923
- }
924
-
925
- function keybindAction(action: string): HostKeybindDefinition {
926
- if (action.startsWith("command:")) {
927
- const command = action.slice(8).trim()
928
- if (!command) throw new Error("Command name is required")
929
- return {
930
- execute: () => [{ type: "command", command } as unknown as VimeeAction],
931
- hostAction: "command",
932
- command,
933
- }
934
- }
935
- switch (action) {
936
- case "normal":
937
- return { keys: "<Esc>", hostAction: "normal" }
938
- case "insert":
939
- return { keys: "i" }
940
- case "submit":
941
- return { execute: () => [{ type: "submit" } as unknown as VimeeAction], hostAction: "submit" }
942
- default:
943
- return { keys: action }
944
- }
945
- }
946
-
947
- function keyForVimee(event: KeyEvent, key: string) {
948
- if (key === "<C-[>") return "Escape"
949
- if (event.ctrl) return event.name?.toLowerCase()
950
- const token = keyToken(key)
951
- return token.startsWith("<") ? undefined : token
952
- }
953
-
954
- function keyToken(token: string) {
955
- if (token === "<Esc>" || token === "<C-[>") return "Escape"
956
- if (token === "<CR>") return "Enter"
957
- if (token === "<Tab>") return "Tab"
958
- if (token === "<BS>") return "Backspace"
959
- if (token === "<Del>") return "Delete"
960
- if (token === "<Space>") return " "
961
- if (token === "<Up>") return "ArrowUp"
962
- if (token === "<Down>") return "ArrowDown"
963
- if (token === "<Left>") return "ArrowLeft"
964
- if (token === "<Right>") return "ArrowRight"
965
- if (token === "<Home>") return "Home"
966
- if (token === "<End>") return "End"
967
- if (token.startsWith("<C-") && token.endsWith(">")) return token.slice(3, -1).toLowerCase()
968
- return token
969
- }
970
-
971
- function tokenCtrl(token: string) {
972
- return token.startsWith("<C-") && token !== "<C-[>"
973
- }
974
-
975
- function syncMode(state: VimState, mode: VimContext["mode"]) {
976
- state.setMode(mode === "insert" || mode === "visual" || mode === "visual-line" ? mode : "normal")
977
- }
978
-
979
- function pendingDisplay(ctx: VimContext, keybindPending: boolean) {
980
- const count = ctx.count > 0 ? String(ctx.count) : ""
981
- if (ctx.phase === "operator-pending") return count + (ctx.operator ?? "")
982
- if (ctx.phase === "text-object-pending") return count + (ctx.operator ?? "") + (ctx.textObjectModifier ?? "")
983
- if (ctx.phase === "char-pending") return count + (ctx.operator ?? "") + (ctx.charCommand ?? "")
984
- if (keybindPending) return ctx.statusMessage
985
- return count
986
- }
987
-
988
- function plainPending(value: string) {
989
- return value.includes("<") || value.includes(">") ? "" : value
990
- }
991
-
992
- function consumesKey(key: string, actions: HostAction[], ctx: VimContext, keybindPending: boolean) {
993
- if (actions.length > 0) return true
994
- if (keybindPending) return true
995
- if (ctx.phase !== "idle") return true
996
- return ctx.mode !== "insert" || key === "Escape"
997
- }
998
-
999
- function textObjectAlias(key: string, ctx: VimContext) {
1000
- if (ctx.phase !== "text-object-pending") return undefined
1001
- if (key === "b") return "("
1002
- if (key === "B") return "{"
1003
- return undefined
1004
- }
1005
-
1006
- function textObjectOperator(operator: Operator): operator is "y" | "d" | "c" {
1007
- return operator === "y" || operator === "d" || operator === "c"
1008
- }
1009
-
1010
- function resolvePromptTextObject(modifier: "i" | "a", key: string, cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
1011
- if (key === "q") return quoteRange(modifier, cursor, buffer)
1012
- if (key !== "p") return null
1013
- return paragraphRange(modifier, cursor, buffer)
1014
- }
1015
-
1016
- function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
1017
- const pair = quoteObjectPair(cursor, buffer)
1018
- if (!pair) return null
1019
- const start = modifier === "i" ? pair.open + 1 : pair.open
1020
- const end = modifier === "i" ? pair.close - 1 : pair.close
1021
- return {
1022
- start: { line: cursor.line, col: start },
1023
- end: { line: cursor.line, col: Math.max(start, end) },
1024
- linewise: false,
1025
- inclusive: end >= start,
1026
- }
1027
- }
1028
-
1029
- function paragraphRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
1030
- const count = buffer.getLineCount()
1031
- if (count === 0) return null
1032
-
1033
- let start = clamp(cursor.line, 0, count - 1)
1034
- while (start < count && blankLine(buffer.getLine(start))) start++
1035
- if (start >= count) {
1036
- start = clamp(cursor.line, 0, count - 1)
1037
- while (start >= 0 && blankLine(buffer.getLine(start))) start--
1038
- }
1039
- if (start < 0 || start >= count) return null
1040
-
1041
- let end = start
1042
- while (start > 0 && !blankLine(buffer.getLine(start - 1))) start--
1043
- while (end < count - 1 && !blankLine(buffer.getLine(end + 1))) end++
1044
-
1045
- if (modifier === "a") {
1046
- if (end < count - 1 && blankLine(buffer.getLine(end + 1))) {
1047
- end++
1048
- while (end < count - 1 && blankLine(buffer.getLine(end + 1))) end++
1049
- } else {
1050
- while (start > 0 && blankLine(buffer.getLine(start - 1))) start--
1051
- }
1052
- }
1053
-
1054
- return {
1055
- start: { line: start, col: 0 },
1056
- end: { line: end, col: Math.max(0, buffer.getLineLength(end) - 1) },
1057
- linewise: true,
1058
- inclusive: true,
1059
- }
1060
- }
1061
-
1062
- function quoteObjectPair(cursor: CursorPosition, buffer: TextBuffer) {
1063
- let best: { open: number; close: number; distance: number } | undefined
1064
- for (const quote of ['"', "'", "`"] as const) {
1065
- const pair = quotePair(cursor, buffer, quote)
1066
- if (!pair) continue
1067
- if (!best || pair.distance < best.distance) best = pair
1068
- }
1069
- return best
1070
- }
1071
-
1072
- function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
1073
- const line = buffer.getLine(cursor.line)
1074
- let open = -1
1075
- let close = -1
1076
- let inQuote = false
1077
- let quoteStart = -1
1078
-
1079
- for (let index = 0; index < line.length; index++) {
1080
- if (line[index] !== quote || escaped(line, index)) continue
1081
- if (!inQuote) {
1082
- quoteStart = index
1083
- inQuote = true
1084
- continue
1085
- }
1086
- if (cursor.col >= quoteStart && cursor.col <= index) {
1087
- return { open: quoteStart, close: index, distance: 0 }
1088
- }
1089
- inQuote = false
1090
- }
1091
-
1092
- for (let index = cursor.col; index < line.length; index++) {
1093
- if (line[index] !== quote || escaped(line, index)) continue
1094
- if (open === -1) open = index
1095
- else {
1096
- close = index
1097
- break
1098
- }
1099
- }
1100
- if (open !== -1 && close !== -1) return { open, close, distance: open - cursor.col }
1101
-
1102
- close = -1
1103
- open = -1
1104
- for (let index = cursor.col; index >= 0; index--) {
1105
- if (line[index] !== quote || escaped(line, index)) continue
1106
- if (close === -1) close = index
1107
- else {
1108
- open = index
1109
- break
1110
- }
1111
- }
1112
- if (open !== -1 && close !== -1) return { open, close, distance: cursor.col - close }
1113
- return undefined
1114
- }
1115
-
1116
- function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
1117
- if (operator !== "y") buffer.saveUndoPoint(ctx.cursor)
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)
1128
- const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
1129
- const context = {
1130
- ...resetContext(ctx),
1131
- mode: result.mode,
1132
- cursor: result.cursor,
1133
- register: result.yankedText,
1134
- registers,
1135
- statusMessage: result.statusMessage,
1136
- }
1137
- const actions: VimeeAction[] = [{ type: "yank", text: result.yankedText }, ...result.actions, { type: "mode-change", mode: result.mode }, { type: "cursor-move", position: result.cursor }]
1138
- return { actions, context, yankedText: result.yankedText }
1139
- }
1140
-
1141
- function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, cursor: CursorPosition) {
1142
- const startLine = Math.min(range.start.line, range.end.line)
1143
- const endLine = Math.max(range.start.line, range.end.line)
1144
- const lineCount = endLine - startLine + 1
1145
- const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n"
1146
- if (operator === "y") {
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` : "" }
1149
- }
1150
-
1151
- buffer.deleteLines(startLine, lineCount)
1152
- if (operator === "c") buffer.insertLine(startLine, "")
1153
- else if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
1154
- const line = Math.min(startLine, buffer.getLineCount() - 1)
1155
- return {
1156
- actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
1157
- cursor: { line, col: 0 },
1158
- mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
1159
- yankedText,
1160
- statusMessage: lineCount >= 2 ? `${lineCount} fewer lines` : "",
1161
- }
1162
- }
1163
-
1164
- function executeCharwiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
1165
- const ordered = orderedRange(range)
1166
- const endCol = range.inclusive ? ordered.end.col + 1 : ordered.end.col
1167
- const yankedText = textInRange(buffer, ordered.start, { line: ordered.end.line, col: endCol })
1168
- if (operator === "y") {
1169
- return { actions: [] as VimeeAction[], cursor: ordered.start, mode: "normal" as VimeeMode, yankedText, statusMessage: yankedText.split("\n").length >= 2 ? `${yankedText.split("\n").length} lines yanked` : "" }
1170
- }
1171
-
1172
- const linesBefore = buffer.getLineCount()
1173
- buffer.deleteRange(ordered.start.line, ordered.start.col, ordered.end.line, endCol)
1174
- const linesRemoved = linesBefore - buffer.getLineCount()
1175
- return {
1176
- actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
1177
- cursor: { line: ordered.start.line, col: operator === "c" ? ordered.start.col : Math.min(ordered.start.col, Math.max(0, buffer.getLineLength(ordered.start.line) - 1)) },
1178
- mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
1179
- yankedText,
1180
- statusMessage: linesRemoved >= 2 ? `${linesRemoved} fewer lines` : "",
1181
- }
1182
- }
1183
-
1184
- function orderedRange(range: MotionRange) {
1185
- if (range.start.line > range.end.line || (range.start.line === range.end.line && range.start.col > range.end.col)) {
1186
- return { start: range.end, end: range.start }
1187
- }
1188
- return { start: range.start, end: range.end }
1189
- }
1190
-
1191
- function textInRange(buffer: TextBuffer, start: CursorPosition, end: CursorPosition) {
1192
- if (start.line === end.line) return buffer.getLine(start.line).slice(start.col, end.col)
1193
- const lines = [buffer.getLine(start.line).slice(start.col)]
1194
- for (let line = start.line + 1; line < end.line; line++) lines.push(buffer.getLine(line))
1195
- lines.push(buffer.getLine(end.line).slice(0, end.col))
1196
- return lines.join("\n")
1197
- }
1198
-
1199
- function blankLine(line: string) {
1200
- return line.trim().length === 0
1201
- }
1202
-
1203
- function escaped(line: string, index: number) {
1204
- let slashCount = 0
1205
- for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor--) slashCount++
1206
- return slashCount % 2 === 1
1207
- }
1208
-
1209
- function clamp(value: number, min: number, max: number) {
1210
- return Math.max(min, Math.min(max, value))
1211
- }