opencode-vim 0.0.7 → 0.0.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vim",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "exports": {
5
5
  "./tui": {
6
6
  "import": "./tui.tsx"
@@ -1,3 +1,4 @@
1
+ import type { RGBA } from "@opentui/core"
1
2
  import type { TuiPromptInfo, TuiPromptRef } from "@opencode-ai/plugin/tui"
2
3
  import type { PromptContext } from "../../prompt/types"
3
4
  import type { VimCursorStyle } from "./config"
@@ -6,12 +7,17 @@ export type EditBufferLike = {
6
7
  cursorOffset?: number
7
8
  plainText?: string
8
9
  visualCursor?: VisualCursorLike
9
- editorView?: { getVisualEOL?: () => VisualCursorLike | undefined }
10
+ editorView?: { getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
10
11
  cursorStyle?: VimCursorStyle
12
+ selectionBg?: RGBA
13
+ selectionFg?: RGBA
11
14
  moveCursorLeft?: () => boolean
12
15
  moveCursorRight?: () => boolean
13
16
  moveCursorUp?: () => boolean
14
17
  moveCursorDown?: () => boolean
18
+ setSelection?: (start: number, end: number) => void
19
+ setSelectionInclusive?: (start: number, end: number) => void
20
+ clearSelection?: () => void
15
21
  gotoVisualLineEnd?: () => boolean
16
22
  gotoLineEnd?: () => void
17
23
  }
@@ -31,6 +31,8 @@ export type VimMappedAction = string
31
31
  const DEFAULT_CURSOR_STYLES: Record<VimMode, VimCursorStyle> = {
32
32
  insert: { style: "line", blinking: true },
33
33
  normal: { style: "block", blinking: true },
34
+ visual: { style: "block", blinking: true },
35
+ "visual-line": { style: "block", blinking: true },
34
36
  }
35
37
 
36
38
  export function createVimConfig(options: unknown): VimConfig {
@@ -42,6 +44,8 @@ export function createVimConfig(options: unknown): VimConfig {
42
44
  cursorStyles: {
43
45
  insert: { ...DEFAULT_CURSOR_STYLES.insert, ...input.cursorStyles?.insert },
44
46
  normal: { ...DEFAULT_CURSOR_STYLES.normal, ...input.cursorStyles?.normal },
47
+ visual: { ...DEFAULT_CURSOR_STYLES.visual, ...input.cursorStyles?.visual },
48
+ "visual-line": { ...DEFAULT_CURSOR_STYLES["visual-line"], ...input.cursorStyles?.["visual-line"] },
45
49
  },
46
50
  debug: input.debug ?? process.env.VIM_PROMPT_DEBUG === "1",
47
51
  debugPath: input.debugPath,
@@ -71,7 +75,7 @@ function readKeymaps(input: unknown): VimKeymaps | undefined {
71
75
  const source = input as Record<string, unknown>
72
76
  const keymaps: VimKeymaps = {}
73
77
 
74
- for (const mode of ["insert", "normal"] as const) {
78
+ for (const mode of ["insert", "normal", "visual", "visual-line"] as const) {
75
79
  const raw = source[mode]
76
80
  if (!raw || typeof raw !== "object") continue
77
81
  keymaps[mode] = {}
@@ -89,6 +93,8 @@ function readCursorStyles(input: unknown): VimOptions["cursorStyles"] {
89
93
  return {
90
94
  insert: readCursorStyle(source.insert),
91
95
  normal: readCursorStyle(source.normal),
96
+ visual: readCursorStyle(source.visual),
97
+ "visual-line": readCursorStyle(source["visual-line"]),
92
98
  }
93
99
  }
94
100
 
@@ -107,7 +113,7 @@ function isCursorStyle(value: unknown): value is VimCursorStyle["style"] {
107
113
  }
108
114
 
109
115
  function isMode(value: unknown): value is VimMode {
110
- return value === "insert" || value === "normal"
116
+ return value === "insert" || value === "normal" || value === "visual" || value === "visual-line"
111
117
  }
112
118
 
113
119
  function isMappedAction(value: unknown): value is VimMappedAction {
@@ -15,12 +15,13 @@ export function createPromptMap(hostText: string, input?: EditBufferLike): Promp
15
15
  export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
16
16
  const prefix = commonPrefix(map.vimText, vimText)
17
17
  const suffix = commonSuffix(map.vimText, vimText, prefix)
18
+ const inserted = vimText.slice(prefix, vimText.length - suffix)
18
19
  const synthetic = new Set<number>()
19
20
  let hostText = ""
20
21
 
21
22
  for (let vimOffset = 0; vimOffset < vimText.length; vimOffset++) {
22
23
  const oldOffset = previousOffset(map, vimOffset, vimText.length, prefix, suffix)
23
- if (oldOffset !== undefined && map.vimToHost[oldOffset] === undefined) {
24
+ if (oldOffset !== undefined && map.vimToHost[oldOffset] === undefined && preserveSynthetic(vimOffset, prefix, suffix, vimText.length, inserted)) {
24
25
  synthetic.add(vimOffset)
25
26
  continue
26
27
  }
@@ -31,6 +32,11 @@ export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
31
32
  return buildPromptMapFromSynthetic(hostText, vimText, synthetic)
32
33
  }
33
34
 
35
+ function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vimLength: number, inserted: string) {
36
+ if (!inserted.includes("\n")) return true
37
+ return vimOffset !== prefix - 1 && vimOffset !== vimLength - suffix
38
+ }
39
+
34
40
  export function hostPosition(map: PromptMap, hostOffset: number): CursorPosition {
35
41
  return positionFromOffset(map.vimText, map.hostToVim[clamp(hostOffset, 0, map.hostText.length)] ?? 0)
36
42
  }
@@ -1,7 +1,7 @@
1
1
  import { createSignal } from "solid-js"
2
2
  import type { VimLog } from "./log"
3
3
 
4
- export type VimMode = "normal" | "insert"
4
+ export type VimMode = "normal" | "insert" | "visual" | "visual-line"
5
5
 
6
6
  export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
7
7
  const [mode, setMode] = createSignal<VimMode>(defaultMode)
@@ -45,7 +45,7 @@ export function VimStatus(props: VimStatusProps) {
45
45
  return (
46
46
  <box paddingLeft={1} paddingRight={1} flexDirection="row">
47
47
  {pending() ? <text fg={props.theme.info}>{pending()} </text> : undefined}
48
- <text fg={props.disabled ? props.theme.textMuted : mode() === "normal" ? props.theme.warning : props.theme.success}>{mode() === "normal" ? "NORMAL" : "INSERT"}</text>
48
+ <text fg={props.disabled ? props.theme.textMuted : mode() === "insert" ? props.theme.success : props.theme.warning}>{modeLabel(mode())}</text>
49
49
  </box>
50
50
  )
51
51
 
@@ -84,3 +84,9 @@ export function VimStatus(props: VimStatusProps) {
84
84
  props.requestRender?.()
85
85
  }
86
86
  }
87
+
88
+ function modeLabel(mode: VimMode) {
89
+ if (mode === "visual") return "VISUAL"
90
+ if (mode === "visual-line") return "VISUAL LINE"
91
+ return mode === "normal" ? "NORMAL" : "INSERT"
92
+ }
@@ -10,6 +10,9 @@ import type { createVimState } from "./state"
10
10
 
11
11
  type VimState = ReturnType<typeof createVimState>
12
12
  type HostAction = VimeeAction | { type: "submit" }
13
+ type HostRange = { start: number; end: number }
14
+
15
+ const YANK_FLASH_MS = 250
13
16
 
14
17
  export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
15
18
 
@@ -20,6 +23,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
20
23
  let vim = createInitialContext({ line: 0, col: 0 })
21
24
  const keybinds = createKeybinds(config, log)
22
25
  let timer: ReturnType<typeof setTimeout> | undefined
26
+ let yankTimer: ReturnType<typeof setTimeout> | undefined
27
+ let yankFlashActive = false
23
28
  let pendingInsert = ""
24
29
  let nativeInsertUndoSaved = false
25
30
 
@@ -62,9 +67,24 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
62
67
  return true
63
68
  }
64
69
 
70
+ const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined
71
+ const shouldFlashYank = shouldFlashYankFor(vimeeKey)
72
+ const visualYankRange = visualYankRangeFor(map)
65
73
  const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
66
74
  vim = result.newCtx
67
- applyActions(result.actions as HostAction[], ctx, map)
75
+ if (hostEnd !== undefined && result.actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
76
+ let clampFinalCursor = true
77
+ const content = result.actions.find((action) => action.type === "content-change")?.content
78
+ if (vimeeKey === "x" && content !== undefined) {
79
+ const next = nextMap(map, content)
80
+ const target = clamp(offset, 0, Math.max(0, next.hostText.length - 1))
81
+ if (offset < map.hostText.length - 1 && map.hostText[offset + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
82
+ vim = { ...vim, cursor: hostPosition(next, target) }
83
+ clampFinalCursor = false
84
+ }
85
+ }
86
+ applyActions(result.actions as HostAction[], ctx, map, clampFinalCursor)
87
+ if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(result.actions), visualYankRange)
68
88
  syncMode(state, vim.mode)
69
89
  const keybindPending = keybinds?.isPending() ?? false
70
90
  if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, offset)
@@ -76,6 +96,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
76
96
  },
77
97
  cleanup() {
78
98
  if (timer) clearTimeout(timer)
99
+ if (yankTimer) clearTimeout(yankTimer)
79
100
  },
80
101
  }
81
102
 
@@ -113,7 +134,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
113
134
  return next
114
135
  }
115
136
 
116
- function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) {
137
+ function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap, clampFinalCursor = true) {
117
138
  const ref = ctx.prompt()
118
139
  const input = focusedInput(ctx)
119
140
  let currentMap = map
@@ -144,7 +165,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
144
165
  }
145
166
  }
146
167
 
147
- setCursor(input, currentMap, vim.cursor)
168
+ setCursor(input, currentMap, vim.cursor, clampFinalCursor)
169
+ syncVisualSelection(input, currentMap, ctx)
148
170
  }
149
171
 
150
172
  function handleInsertMode(event: KeyEvent, key: string, ctx: PromptContext, map: PromptMap, cursor: CursorPosition, offset: number) {
@@ -253,20 +275,190 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
253
275
  return hostOffset(map, position, vim.mode === "insert" ? "next" : "previous")
254
276
  }
255
277
 
256
- function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) {
278
+ function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition, clampCursor = true) {
257
279
  if (!input) return
258
280
  input.cursorOffset = cursorOffset(map, position)
259
- if (vim.mode !== "insert") clampNormalCursor(input)
281
+ if (clampCursor && vim.mode !== "insert") clampNormalCursor(input)
260
282
  }
261
283
 
262
284
  function appendVisualLine(input: EditBufferLike | undefined, map: PromptMap) {
263
285
  if (!input?.gotoVisualLineEnd) return false
286
+ clearVisualSelection(input)
264
287
  input.gotoVisualLineEnd()
265
288
  vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? map.hostText.length), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
266
289
  nativeInsertUndoSaved = false
267
290
  syncMode(state, "insert")
268
291
  return true
269
292
  }
293
+
294
+ function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) {
295
+ if (!input) return
296
+ if (!isVisualMode(vim.mode) || !vim.visualAnchor) {
297
+ if (!yankFlashActive) clearVisualSelection(input)
298
+ return
299
+ }
300
+
301
+ cancelYankFlash()
302
+ const range = vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor)
303
+ if (!range) {
304
+ clearVisualSelection(input)
305
+ return
306
+ }
307
+
308
+ setSelection(input, range.start, range.end, ctx)
309
+ }
310
+
311
+ function visualYankRangeFor(map: PromptMap) {
312
+ if (!isVisualMode(vim.mode) || !vim.visualAnchor) return undefined
313
+ return vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor)
314
+ }
315
+
316
+ function flashYank(ctx: PromptContext, map: PromptMap, action: YankAction | undefined, visualRange: HostRange | undefined) {
317
+ if (!action) return
318
+ const input = focusedInput(ctx)
319
+ if (!input) return
320
+ const range = visualRange ?? yankedTextRange(map, vim.cursor, action.text)
321
+ if (!range) return
322
+
323
+ cancelYankFlash()
324
+ yankFlashActive = true
325
+ setYankSelection(input, range.start, range.end, ctx)
326
+ ctx.requestRender()
327
+ yankTimer = setTimeout(() => {
328
+ yankTimer = undefined
329
+ if (!yankFlashActive) return
330
+ yankFlashActive = false
331
+ clearVisualSelection(input)
332
+ ctx.requestRender()
333
+ }, YANK_FLASH_MS)
334
+ }
335
+
336
+ function cancelYankFlash() {
337
+ if (yankTimer) clearTimeout(yankTimer)
338
+ yankTimer = undefined
339
+ yankFlashActive = false
340
+ }
341
+
342
+ function shouldFlashYankFor(key: string) {
343
+ if (isVisualMode(vim.mode)) return key === "y"
344
+ return vim.operator === "y"
345
+ }
346
+ }
347
+
348
+ function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
349
+ const anchorOffset = hostOffset(map, anchor, "next")
350
+ const cursorOffset = hostOffset(map, cursor, "previous")
351
+ return hostRange(map, anchorOffset, cursorOffset)
352
+ }
353
+
354
+ function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
355
+ const startLine = Math.min(anchor.line, cursor.line)
356
+ const endLine = Math.max(anchor.line, cursor.line)
357
+ const start = hostOffset(map, { line: startLine, col: 0 }, "next")
358
+ const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) }, "previous")
359
+ return hostRange(map, start, end)
360
+ }
361
+
362
+ function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string): HostRange | undefined {
363
+ if (!text) return undefined
364
+ if (text.endsWith("\n")) {
365
+ const lineCount = text.split("\n").length - 1
366
+ return visualLineRange(map, cursor, { line: cursor.line + Math.max(0, lineCount - 1), col: 0 })
367
+ }
368
+
369
+ const start = vimOffsetFromPosition(map.vimText, cursor)
370
+ return vimOffsetRange(map, start, start + text.length - 1)
371
+ }
372
+
373
+ function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
374
+ const start = hostFromVimOffset(map, Math.min(left, right), "next")
375
+ const end = hostFromVimOffset(map, Math.max(left, right), "previous")
376
+ return hostRange(map, start, end)
377
+ }
378
+
379
+ function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined {
380
+ if (!map.hostText) return undefined
381
+ const start = clamp(Math.min(left, right), 0, Math.max(0, map.hostText.length - 1))
382
+ const end = clamp(Math.max(left, right), 0, Math.max(0, map.hostText.length - 1))
383
+ return { start, end }
384
+ }
385
+
386
+ type YankAction = Extract<VimeeAction, { type: "yank" }>
387
+
388
+ function yankAction(actions: VimeeAction[]): YankAction | undefined {
389
+ return actions.find((action): action is YankAction => action.type === "yank")
390
+ }
391
+
392
+ function setSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) {
393
+ setSelectionColors(input, start, end, ctx.api.theme.current.warning, ctx.api.theme.current.background)
394
+ }
395
+
396
+ function setYankSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) {
397
+ setSelectionColors(input, start, end, ctx.api.theme.current.info, ctx.api.theme.current.background)
398
+ }
399
+
400
+ function setSelectionColors(input: EditBufferLike, start: number, end: number, background: PromptContext["api"]["theme"]["current"]["warning"], foreground: PromptContext["api"]["theme"]["current"]["background"]) {
401
+ input.selectionBg = background
402
+ input.selectionFg = foreground
403
+
404
+ if (input.setSelectionInclusive) {
405
+ input.setSelectionInclusive(start, end)
406
+ return
407
+ }
408
+
409
+ const exclusiveEnd = end + 1
410
+ if (input.setSelection) {
411
+ input.setSelection(start, exclusiveEnd)
412
+ return
413
+ }
414
+
415
+ input.editorView?.setSelection?.(start, exclusiveEnd, background, foreground)
416
+ }
417
+
418
+ function clearVisualSelection(input: EditBufferLike) {
419
+ if (input.clearSelection) {
420
+ input.clearSelection()
421
+ return
422
+ }
423
+
424
+ input.editorView?.resetSelection?.()
425
+ }
426
+
427
+ function isVisualMode(mode: VimContext["mode"]): mode is "visual" | "visual-line" {
428
+ return mode === "visual" || mode === "visual-line"
429
+ }
430
+
431
+ function vimLineLength(text: string, line: number) {
432
+ return text.split("\n")[line]?.length ?? 0
433
+ }
434
+
435
+ function vimOffsetFromPosition(text: string, position: CursorPosition) {
436
+ const lines = text.split("\n")
437
+ const line = clamp(position.line, 0, Math.max(0, lines.length - 1))
438
+ let offset = 0
439
+ for (let index = 0; index < line; index++) offset += lines[index].length + 1
440
+ return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
441
+ }
442
+
443
+ function hostFromVimOffset(map: PromptMap, offset: number, bias: "previous" | "next") {
444
+ const current = clamp(offset, 0, map.vimText.length)
445
+ if (current === map.vimText.length) return map.hostText.length
446
+
447
+ const host = map.vimToHost[current]
448
+ if (host !== undefined) return host
449
+
450
+ if (bias === "previous") {
451
+ for (let previous = current - 1; previous >= 0; previous--) {
452
+ const previousHost = map.vimToHost[previous]
453
+ if (previousHost !== undefined) return previousHost
454
+ }
455
+ }
456
+
457
+ for (let next = current + 1; next < map.vimToHost.length; next++) {
458
+ const nextHost = map.vimToHost[next]
459
+ if (nextHost !== undefined) return nextHost
460
+ }
461
+ return map.hostText.length
270
462
  }
271
463
 
272
464
  function clampNormalCursor(input: EditBufferLike) {
@@ -278,6 +470,27 @@ function clampNormalCursor(input: EditBufferLike) {
278
470
  if (cursor.visualRow === eol.visualRow && (cursor.offset === eol.offset || offset === eol.offset)) input.cursorOffset = Math.max(0, offset - 1)
279
471
  }
280
472
 
473
+ function endMotionOffset(text: string, offset: number, count: number) {
474
+ let index = offset
475
+ for (let step = 0; step < count; step++) {
476
+ index++
477
+ while (index < text.length && isWhitespace(text[index])) index++
478
+ const kind = wordKind(text[index])
479
+ if (!kind) return Math.max(0, text.length - 1)
480
+ while (wordKind(text[index + 1]) === kind) index++
481
+ }
482
+ return clamp(index, 0, Math.max(0, text.length - 1))
483
+ }
484
+
485
+ function isWhitespace(value: string | undefined) {
486
+ return value === " " || value === "\t" || value === "\n"
487
+ }
488
+
489
+ function wordKind(value: string | undefined) {
490
+ if (!value || isWhitespace(value)) return undefined
491
+ return /\w/.test(value) ? "word" : "punctuation"
492
+ }
493
+
281
494
  function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined {
282
495
  const map = createKeybindMap()
283
496
  let count = 0
@@ -338,7 +551,7 @@ function tokenCtrl(token: string) {
338
551
  }
339
552
 
340
553
  function syncMode(state: VimState, mode: VimContext["mode"]) {
341
- state.setMode(mode === "insert" ? "insert" : "normal")
554
+ state.setMode(mode === "insert" || mode === "visual" || mode === "visual-line" ? mode : "normal")
342
555
  }
343
556
 
344
557
  function pendingDisplay(ctx: VimContext, keybindPending: boolean) {