opencode-vim 0.0.8 → 0.0.10

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.8",
3
+ "version": "0.0.10",
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 {
@@ -2,6 +2,7 @@
2
2
  import type { KeyEvent, ParsedKey } from "@opentui/core"
3
3
  import { useKeyboard } from "@opentui/solid"
4
4
  import { onCleanup } from "solid-js"
5
+ import type { Accessor } from "solid-js"
5
6
  import type { PromptContext, PromptModule } from "../../prompt/types"
6
7
  import { applyVimCursorStyle, focusedInput } from "./actions"
7
8
  import type { VimConfig } from "./config"
@@ -13,7 +14,7 @@ import { createVimState } from "./state"
13
14
  import { createVimeeAdapter } from "./vimee"
14
15
  import { VimStatus } from "./view"
15
16
 
16
- export function createVimModule(options?: unknown): PromptModule {
17
+ export function createVimModule(options?: unknown, enabled: Accessor<boolean> = () => true): PromptModule {
17
18
  const config = createVimConfig(options)
18
19
  const log = createVimLog(config)
19
20
  const state = createVimState(config.defaultMode, log)
@@ -26,15 +27,15 @@ export function createVimModule(options?: unknown): PromptModule {
26
27
  log("module.setup", { kind: ctx.kind, sessionID: ctx.sessionID, workspaceID: ctx.workspaceID })
27
28
  },
28
29
  renderAbove(ctx) {
29
- return <VimKeyboard ctx={ctx} config={config} state={state} log={log} />
30
+ return <VimKeyboard ctx={ctx} config={config} state={state} enabled={enabled} log={log} />
30
31
  },
31
32
  renderRight(ctx) {
32
- return <VimStatus mode={state.mode} pending={() => readablePending(state.pending())} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} />
33
+ return <VimStatus mode={state.mode} pending={() => readablePending(state.pending())} enabled={enabled} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} />
33
34
  },
34
35
  }
35
36
  }
36
37
 
37
- function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; log: VimLog }) {
38
+ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; enabled: Accessor<boolean>; log: VimLog }) {
38
39
  let cursorStyleMode = ""
39
40
  const vimee = createVimeeAdapter(props.state, props.config, props.log)
40
41
  props.log("keyboard.mount", { kind: props.ctx.kind })
@@ -53,8 +54,8 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
53
54
  pending: props.state.pending(),
54
55
  })
55
56
 
56
- if (props.ctx.disabled || props.ctx.visible === false) {
57
- props.log("keyboard.skip", { disabled: props.ctx.disabled, visible: props.ctx.visible })
57
+ if (!props.enabled() || props.ctx.disabled || props.ctx.visible === false) {
58
+ props.log("keyboard.skip", { enabled: props.enabled(), disabled: props.ctx.disabled, visible: props.ctx.visible })
58
59
  return
59
60
  }
60
61
 
@@ -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)
@@ -10,6 +10,7 @@ const STATUS_SYNC_MS = 50
10
10
  type VimStatusProps = {
11
11
  mode: Accessor<VimMode>
12
12
  pending: Accessor<string | undefined>
13
+ enabled: Accessor<boolean>
13
14
  theme: PromptContext["api"]["theme"]["current"]
14
15
  pendingDisplayDelay?: number
15
16
  disabled?: boolean
@@ -20,6 +21,7 @@ type VimStatusProps = {
20
21
  export function VimStatus(props: VimStatusProps) {
21
22
  const [mode, setMode] = createSignal(props.mode())
22
23
  const [pending, setPending] = createSignal<string | undefined>()
24
+ const [enabled, setEnabled] = createSignal(props.enabled())
23
25
  let pendingTimer: ReturnType<typeof setTimeout> | undefined
24
26
  let scheduledPending: string | undefined
25
27
 
@@ -33,6 +35,13 @@ export function VimStatus(props: VimStatusProps) {
33
35
  props.requestRender?.()
34
36
  }
35
37
 
38
+ const nextEnabled = props.enabled()
39
+ if (enabled() !== nextEnabled) {
40
+ props.log?.("status.enabled", { from: enabled(), to: nextEnabled })
41
+ setEnabled(nextEnabled)
42
+ props.requestRender?.()
43
+ }
44
+
36
45
  syncPending(nextPending)
37
46
  }
38
47
 
@@ -44,8 +53,8 @@ export function VimStatus(props: VimStatusProps) {
44
53
 
45
54
  return (
46
55
  <box paddingLeft={1} paddingRight={1} flexDirection="row">
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>
56
+ {enabled() && pending() ? <text fg={props.theme.info}>{pending()} </text> : undefined}
57
+ {enabled() ? <text fg={props.disabled ? props.theme.textMuted : mode() === "insert" ? props.theme.success : props.theme.warning}>{modeLabel(mode())}</text> : undefined}
49
58
  </box>
50
59
  )
51
60
 
@@ -84,3 +93,9 @@ export function VimStatus(props: VimStatusProps) {
84
93
  props.requestRender?.()
85
94
  }
86
95
  }
96
+
97
+ function modeLabel(mode: VimMode) {
98
+ if (mode === "visual") return "VISUAL"
99
+ if (mode === "visual-line") return "VISUAL LINE"
100
+ return mode === "normal" ? "NORMAL" : "INSERT"
101
+ }
@@ -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
 
@@ -63,10 +68,23 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
63
68
  }
64
69
 
65
70
  const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined
71
+ const shouldFlashYank = shouldFlashYankFor(vimeeKey)
72
+ const visualYankRange = visualYankRangeFor(map)
66
73
  const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
67
74
  vim = result.newCtx
68
75
  if (hostEnd !== undefined && result.actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
69
- applyActions(result.actions as HostAction[], ctx, map)
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)
70
88
  syncMode(state, vim.mode)
71
89
  const keybindPending = keybinds?.isPending() ?? false
72
90
  if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, offset)
@@ -78,6 +96,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
78
96
  },
79
97
  cleanup() {
80
98
  if (timer) clearTimeout(timer)
99
+ if (yankTimer) clearTimeout(yankTimer)
81
100
  },
82
101
  }
83
102
 
@@ -115,7 +134,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
115
134
  return next
116
135
  }
117
136
 
118
- function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) {
137
+ function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap, clampFinalCursor = true) {
119
138
  const ref = ctx.prompt()
120
139
  const input = focusedInput(ctx)
121
140
  let currentMap = map
@@ -146,7 +165,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
146
165
  }
147
166
  }
148
167
 
149
- setCursor(input, currentMap, vim.cursor)
168
+ setCursor(input, currentMap, vim.cursor, clampFinalCursor)
169
+ syncVisualSelection(input, currentMap, ctx)
150
170
  }
151
171
 
152
172
  function handleInsertMode(event: KeyEvent, key: string, ctx: PromptContext, map: PromptMap, cursor: CursorPosition, offset: number) {
@@ -255,20 +275,190 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
255
275
  return hostOffset(map, position, vim.mode === "insert" ? "next" : "previous")
256
276
  }
257
277
 
258
- function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) {
278
+ function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition, clampCursor = true) {
259
279
  if (!input) return
260
280
  input.cursorOffset = cursorOffset(map, position)
261
- if (vim.mode !== "insert") clampNormalCursor(input)
281
+ if (clampCursor && vim.mode !== "insert") clampNormalCursor(input)
262
282
  }
263
283
 
264
284
  function appendVisualLine(input: EditBufferLike | undefined, map: PromptMap) {
265
285
  if (!input?.gotoVisualLineEnd) return false
286
+ clearVisualSelection(input)
266
287
  input.gotoVisualLineEnd()
267
288
  vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? map.hostText.length), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
268
289
  nativeInsertUndoSaved = false
269
290
  syncMode(state, "insert")
270
291
  return true
271
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
272
462
  }
273
463
 
274
464
  function clampNormalCursor(input: EditBufferLike) {
@@ -361,7 +551,7 @@ function tokenCtrl(token: string) {
361
551
  }
362
552
 
363
553
  function syncMode(state: VimState, mode: VimContext["mode"]) {
364
- state.setMode(mode === "insert" ? "insert" : "normal")
554
+ state.setMode(mode === "insert" || mode === "visual" || mode === "visual-line" ? mode : "normal")
365
555
  }
366
556
 
367
557
  function pendingDisplay(ctx: VimContext, keybindPending: boolean) {
package/src/plugin.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { createSignal } from "solid-js"
3
4
  import { PromptRoot } from "./prompt/root"
4
5
  import type { PromptModule } from "./prompt/types"
5
6
  import { createVimModule } from "./modules/vim"
@@ -7,6 +8,23 @@ import { createSnippetsModule } from "./modules/snippets"
7
8
  import { checkAutoUpdate } from "./update"
8
9
 
9
10
  const tui: TuiPlugin = async (api, options, meta) => {
11
+ const vimEnabledKey = `${meta.id}:enabled`
12
+ const [vimEnabled, setVimEnabled] = createSignal(api.kv.get(vimEnabledKey, true) === true)
13
+
14
+ api.command.register(() => [{
15
+ title: vimEnabled() ? "Disable Vim Mode" : "Enable Vim Mode",
16
+ value: "opencode-vim.toggle",
17
+ description: vimEnabled() ? "Turn off Vim key handling" : "Turn on Vim key handling",
18
+ category: "Vim",
19
+ slash: { name: "vim" },
20
+ onSelect() {
21
+ const next = !vimEnabled()
22
+ setVimEnabled(next)
23
+ api.kv.set(vimEnabledKey, next)
24
+ api.ui.toast({ variant: "info", message: `Vim mode ${next ? "enabled" : "disabled"}` })
25
+ },
26
+ }])
27
+
10
28
  if (readAutoUpdate(options)) {
11
29
  let timer: Timer | undefined
12
30
  api.lifecycle.onDispose(() => {
@@ -29,7 +47,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
29
47
 
30
48
  const modules: PromptModule[] = []
31
49
  if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule())
32
- modules.push(createVimModule(options))
50
+ modules.push(createVimModule(options, vimEnabled))
33
51
  moduleCache.set(key, modules)
34
52
  return modules
35
53
  }