opencode-vim 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,40 +1,81 @@
1
1
  # opencode-vim
2
2
 
3
- Adds Vim-style insert and normal mode editing to the OpenCode prompt.
3
+ Vim-style editing for the OpenCode 2 prompt and dialog search fields.
4
4
 
5
- ![Demo](./assets/demo2.gif)
5
+ This branch supports normal, insert, visual, and visual-line editing, custom
6
+ keymaps, prompt history, command dispatch, cursor styles, and `/vim` toggling.
7
+ The mode indicator appears before the working directory in the prompt footer.
8
+ Pending key sequences stay hidden so they do not shift the footer layout.
6
9
 
7
- ## Installation
10
+ Configure the local plugin in OpenCode 2's `cli.json`:
8
11
 
9
- Install from the CLI:
12
+ ```json
13
+ {
14
+ "plugins": [
15
+ "opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim"
16
+ ]
17
+ }
18
+ ```
19
+
20
+ Options use the V2 object form:
10
21
 
11
- ```bash
12
- opencode plugin opencode-vim@latest --global
22
+ ```json
23
+ {
24
+ "package": "opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim",
25
+ "options": {
26
+ "vim": {
27
+ "defaultMode": "insert",
28
+ "keymapTimeout": 500,
29
+ "keymaps": {
30
+ "insert": { "kj": "normal" },
31
+ "normal": { "Y": "y$" }
32
+ }
33
+ }
34
+ }
35
+ }
13
36
  ```
14
37
 
15
- ## Supported Keys
38
+ OpenCode 2 does not yet expose its prompt ref or low-level key interceptor to
39
+ plugins. This adapter uses the public renderer in the V2 plugin context to
40
+ bridge those capabilities to the existing Vim engine.
16
41
 
17
- | Key | Behavior |
18
- | --- | --- |
19
- | `<Esc>`, `<C-[>` | Enter normal mode |
20
- | `i`, `a`, `A`, `o`, `O` | Return to insert mode |
21
- | `h`, `j`, `k`, `l`, `w`, `b`, `e`, `$`, `0` | Move through the prompt |
22
- | `x`, `d`, `c`, `y`, `p`, `u`, `<C-r>` | Edit, yank, paste, undo, redo |
23
- | `v`, `V` | Visual and visual-line selection |
24
- | `3w`, `diw`, `ci"`, `yiq`, `dip`, `yib` | Counts and text objects |
25
- | `k`, `j` on an empty prompt | Browse previous and next prompts |
26
- | `<CR>` in normal mode | Submit the prompt (configurable) |
27
- | `/vim` | Toggle Vim mode on or off |
42
+ ## Dialogs
28
43
 
29
- ## Custom Keymaps
44
+ Searchable dialogs such as `/models`, the `Ctrl+P` command palette, and Settings
45
+ support Vim editing in their search fields. Each new dialog starts in your
46
+ configured `defaultMode`.
30
47
 
31
- Keymaps can change modes, submit the prompt, run Vim key sequences, or dispatch active OpenCode commands with the `command:` prefix.
48
+ - In insert mode, type to filter. `Esc` (or your `kj` mapping) enters normal mode.
49
+ - In normal mode, `j`/`k` move the selected list item. `h`/`l`, `x`, `dw`, `u`,
50
+ and other editing commands act on the search text. `i` returns to insert mode.
51
+ - `Enter` selects the highlighted item; `Esc` in idle normal mode closes or goes
52
+ back. Arrows, Tab, Home/End, and Page Up/Down keep their dialog behavior.
53
+ - Dialog editing has separate mode and undo state from the main prompt.
32
54
 
33
- ```jsonc
34
- "normal": {
35
- "Y": "y$",
36
- "q": "command:session.new"
37
- }
55
+ Normal-mode custom mappings take precedence over the dialog `j`/`k` defaults.
56
+
57
+ ## Development and testing
58
+
59
+ ```sh
60
+ npm install
61
+ npm test
62
+ npm run typecheck
63
+ npm run bench
64
+ ```
65
+
66
+ Tests use Bun and OpenTUI's native headless renderer: real textarea input,
67
+ cursor movement, selection, wrapping, attachment extmarks, and the mounted
68
+ plugin footer. They do not contact an OpenCode server or a model. When `nvim`
69
+ is installed, additional tests compare text and cursor results against headless
70
+ Neovim with user configuration disabled.
71
+
72
+ With an OpenCode source checkout and its dependencies installed, you can also
73
+ test against its real dialog component and keyboard routing:
74
+
75
+ ```sh
76
+ OPENCODE_SOURCE=/path/to/opencode npm test -- test/opencode-dialog.test.tsx
38
77
  ```
39
78
 
40
- See [Keymap Actions](./docs/keymap-actions.md) for available actions and commands, or [Configuration](./docs/configuration.md) for all options.
79
+ The benchmark measures wrap mapping and normal-mode navigation on prompts of
80
+ 1,000, 5,000, and 10,000 characters. Wrapping follows the textarea's visual
81
+ lines; those cases are tested separately from Neovim's logical-line behavior.
package/package.json CHANGED
@@ -1,39 +1,41 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package.json",
2
3
  "name": "opencode-vim",
3
- "version": "0.0.20",
4
+ "version": "0.0.22",
5
+ "type": "module",
4
6
  "repository": {
5
7
  "type": "git",
6
8
  "url": "https://github.com/Tarquinen/opencode-vim"
7
9
  },
8
10
  "exports": {
9
- "./tui": {
10
- "import": "./tui.tsx"
11
- }
11
+ "./tui": "./tui.tsx"
12
+ },
13
+ "scripts": {
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "bun test --conditions=browser --preload @opentui/solid/preload",
16
+ "bench": "bun test/benchmark.ts"
12
17
  },
13
18
  "dependencies": {
14
19
  "@vimee/core": "^0.3.0"
15
20
  },
16
21
  "peerDependencies": {
17
- "@opencode-ai/plugin": ">=1.4.3",
18
- "@opentui/core": "^0.4.2",
19
- "@opentui/keymap": "^0.4.2",
20
- "@opentui/solid": "^0.4.2",
21
- "solid-js": "^1.9.12"
22
+ "@opencode/plugin": "2.0.10",
23
+ "@opentui/core": ">=0.5.10",
24
+ "@opentui/solid": ">=0.5.10",
25
+ "solid-js": ">=1.9.0"
22
26
  },
23
27
  "devDependencies": {
24
- "@opencode-ai/plugin": "1.14.44",
25
- "@opentui/core": "^0.4.2",
26
- "@opentui/keymap": "^0.4.2",
27
- "@opentui/solid": "^0.4.2",
28
+ "@opencode/theme": "2.0.10",
28
29
  "@types/bun": "^1.3.14",
29
- "solid-js": "^1.9.12"
30
+ "@types/node": "^24.0.0",
31
+ "typescript": "^5.9.0"
30
32
  },
31
33
  "files": [
32
34
  "assets/",
33
35
  "docs/",
34
36
  "src/",
35
37
  "README.md",
36
- "tui.json.example",
37
- "tui.tsx"
38
+ "tui.tsx",
39
+ "view.tsx"
38
40
  ]
39
41
  }
@@ -1,14 +1,38 @@
1
- import type { RGBA } from "@opentui/core"
2
- import type { TuiPromptInfo, TuiPromptRef } from "@opencode-ai/plugin/tui"
3
- import type { PromptContext } from "../../prompt/types"
1
+ import type { CursorStyleOptions, LineInfo, RGBA } from "@opentui/core"
4
2
  import type { VimCursorStyle } from "./config"
5
3
 
4
+ type PromptInfo = {
5
+ input: string
6
+ mode: string
7
+ parts: unknown[]
8
+ }
9
+
10
+ type PromptRef = {
11
+ current: PromptInfo
12
+ set: (value: PromptInfo) => void
13
+ submit: () => void
14
+ blur: () => void
15
+ }
16
+
17
+ export type PromptContext = {
18
+ api: {
19
+ renderer: { currentFocusedRenderable?: unknown }
20
+ keymap: { dispatchCommand: (command: string) => { ok: boolean } }
21
+ theme: { current: { warning: RGBA; info: RGBA; background: RGBA } }
22
+ }
23
+ prompt: () => PromptRef | undefined
24
+ requestRender: () => void
25
+ }
26
+
6
27
  export type EditBufferLike = {
28
+ isDestroyed?: boolean
29
+ width?: number
30
+ wrapMode?: string
7
31
  cursorOffset?: number
8
32
  plainText?: string
9
33
  visualCursor?: VisualCursorLike
10
- editorView?: { getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
11
- cursorStyle?: VimCursorStyle
34
+ editorView?: { getLogicalLineInfo?: () => LineInfo; getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
35
+ cursorStyle?: CursorStyleOptions
12
36
  selectionBg?: RGBA
13
37
  selectionFg?: RGBA
14
38
  moveCursorLeft?: () => boolean
@@ -16,6 +40,7 @@ export type EditBufferLike = {
16
40
  moveCursorUp?: () => boolean
17
41
  moveCursorDown?: () => boolean
18
42
  setSelection?: (start: number, end: number) => void
43
+ insertText?: (text: string) => void
19
44
  setSelectionInclusive?: (start: number, end: number) => void
20
45
  clearSelection?: () => void
21
46
  gotoVisualLineEnd?: () => boolean
@@ -43,7 +68,7 @@ export function focusedInput(ctx: PromptContext): EditBufferLike | undefined {
43
68
  return focused
44
69
  }
45
70
 
46
- export function setInput(ref: TuiPromptRef, input: string) {
71
+ export function setInput(ref: PromptRef, input: string) {
47
72
  ref.set(toPromptInfo(ref, input))
48
73
  }
49
74
 
@@ -51,7 +76,7 @@ function hasEditBufferMethods(input: EditBufferLike) {
51
76
  return typeof input.moveCursorLeft === "function" || typeof input.moveCursorRight === "function" || typeof input.moveCursorUp === "function" || typeof input.moveCursorDown === "function" || typeof input.gotoLineEnd === "function"
52
77
  }
53
78
 
54
- function toPromptInfo(ref: TuiPromptRef, input: string): TuiPromptInfo {
79
+ function toPromptInfo(ref: PromptRef, input: string): PromptInfo {
55
80
  return {
56
81
  input,
57
82
  mode: ref.current.mode,
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { editInput } from "./edit"
3
+
4
+ describe("editInput", () => {
5
+ test("inserts without replacing unchanged placeholders", () => {
6
+ const fixture = createFixture("[Image 1] after")
7
+
8
+ editInput(fixture.input, "[Image 1] kafter")
9
+
10
+ expect(fixture.calls).toEqual([["cursor", 10], ["insert", "k"]])
11
+ })
12
+
13
+ test("uses display offsets for a minimal deletion", () => {
14
+ const fixture = createFixture("中 [Image 1] abc")
15
+
16
+ editInput(fixture.input, "中 [Image 1] ac")
17
+
18
+ expect(fixture.calls).toEqual([["selection", 14, 15], ["insert", ""]])
19
+ })
20
+
21
+ test("does not split graphemes", () => {
22
+ const fixture = createFixture("a👩‍💻b")
23
+
24
+ editInput(fixture.input, "axb")
25
+
26
+ expect(fixture.calls).toEqual([["selection", 1, 3], ["insert", "x"]])
27
+ })
28
+ })
29
+
30
+ function createFixture(plainText: string) {
31
+ const calls: Array<["cursor", number] | ["selection", number, number] | ["insert", string]> = []
32
+ let cursorOffset = 0
33
+ const input = {
34
+ plainText,
35
+ clearSelection() {},
36
+ get cursorOffset() {
37
+ return cursorOffset
38
+ },
39
+ set cursorOffset(value: number) {
40
+ cursorOffset = value
41
+ calls.push(["cursor", value])
42
+ },
43
+ setSelection(start: number, end: number) {
44
+ calls.push(["selection", start, end])
45
+ },
46
+ insertText(text: string) {
47
+ calls.push(["insert", text])
48
+ },
49
+ }
50
+ return { input, calls }
51
+ }
@@ -0,0 +1,33 @@
1
+ import { charToDisplay } from "./map"
2
+
3
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+
5
+ type Input = {
6
+ plainText: string
7
+ cursorOffset: number
8
+ setSelection: (start: number, end: number) => void
9
+ insertText: (text: string) => void
10
+ clearSelection: () => unknown
11
+ }
12
+
13
+ export function editInput(input: Input, value: string) {
14
+ if (input.plainText === value) return
15
+ input.clearSelection()
16
+
17
+ const before = [...graphemes.segment(input.plainText)]
18
+ const after = [...graphemes.segment(value)]
19
+ let prefix = 0
20
+ while (prefix < before.length && prefix < after.length && before[prefix].segment === after[prefix].segment) prefix++
21
+
22
+ let suffix = 0
23
+ while (suffix + prefix < before.length && suffix + prefix < after.length && before[before.length - suffix - 1].segment === after[after.length - suffix - 1].segment) suffix++
24
+
25
+ const start = before[prefix]?.index ?? input.plainText.length
26
+ const end = suffix ? before[before.length - suffix].index : input.plainText.length
27
+ const valueEnd = suffix ? after[after.length - suffix].index : value.length
28
+ const startOffset = charToDisplay(input.plainText, start)
29
+
30
+ if (start === end) input.cursorOffset = startOffset
31
+ else input.setSelection(startOffset, charToDisplay(input.plainText, end))
32
+ input.insertText(value.slice(start, valueEnd))
33
+ }
@@ -0,0 +1,38 @@
1
+ const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" })
2
+
3
+ // Vimee indexes JavaScript strings by UTF-16 code unit. Give each multi-unit
4
+ // grapheme one private-use character so motions, operators and registers cannot
5
+ // split it. The dictionary lives with the adapter, including its undo history.
6
+ export function createGraphemeCodec() {
7
+ const encoded = new Map<string, string>()
8
+ const decoded = new Map<string, string>()
9
+ let next = 0xe000
10
+ return {
11
+ encode(text: string) {
12
+ let result = ""
13
+ for (const { segment } of segments.segment(text)) {
14
+ const code = segment.charCodeAt(0)
15
+ if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) {
16
+ result += segment
17
+ continue
18
+ }
19
+ let token = encoded.get(segment)
20
+ if (!token) {
21
+ if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full")
22
+ token = String.fromCharCode(next++)
23
+ encoded.set(segment, token)
24
+ decoded.set(token, segment)
25
+ }
26
+ result += token
27
+ }
28
+ return result
29
+ },
30
+ decode(text: string) {
31
+ let result = ""
32
+ for (const token of text) result += decoded.get(token) ?? token
33
+ return result
34
+ },
35
+ }
36
+ }
37
+
38
+ export type GraphemeCodec = ReturnType<typeof createGraphemeCodec>
@@ -1,5 +1,7 @@
1
1
  import type { KeyEvent } from "@opentui/core"
2
2
 
3
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+
3
5
  const NAMED_KEYS: Record<string, string> = {
4
6
  escape: "<Esc>",
5
7
  esc: "<Esc>",
@@ -15,6 +17,8 @@ const NAMED_KEYS: Record<string, string> = {
15
17
  right: "<Right>",
16
18
  home: "<Home>",
17
19
  end: "<End>",
20
+ pageup: "<PageUp>",
21
+ pagedown: "<PageDown>",
18
22
  }
19
23
 
20
24
  export function keyNotation(event: KeyEvent) {
@@ -23,7 +27,7 @@ export function keyNotation(event: KeyEvent) {
23
27
 
24
28
  if (event.ctrl) return `<C-${ctrlKey(name)}>`
25
29
  if (event.meta) return `<M-${name}>`
26
- if (name.length === 1) return event.shift ? name.toUpperCase() : name
30
+ if ([...graphemes.segment(name)].length === 1) return event.shift ? name.toUpperCase() : name
27
31
  return NAMED_KEYS[name] ?? `<${name}>`
28
32
  }
29
33
 
@@ -1,10 +1,13 @@
1
1
  import type { CursorPosition } from "@vimee/core"
2
2
  import type { EditBufferLike } from "./actions"
3
+ import { createGraphemeCodec, type GraphemeCodec } from "./graphemes"
3
4
 
4
5
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
5
6
 
6
7
  function graphemeWidth(value: string) {
7
8
  // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
9
+ // OpenTUI's edit buffer gives tabs a fixed two-column width by default.
10
+ if (value === "\t") return 2
8
11
  return value === "\n" ? 1 : Bun.stringWidth(value)
9
12
  }
10
13
 
@@ -33,14 +36,15 @@ export function displayWidth(text: string): number {
33
36
  }
34
37
 
35
38
  export type PromptMap = {
39
+ codec: GraphemeCodec
36
40
  hostText: string
37
41
  vimText: string
38
42
  hostToVim: number[]
39
43
  vimToHost: Array<number | undefined>
40
44
  }
41
45
 
42
- export function createPromptMap(hostText: string, input?: EditBufferLike): PromptMap {
43
- return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [])
46
+ export function createPromptMap(hostText: string, input?: EditBufferLike, codec = createGraphemeCodec()): PromptMap {
47
+ return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [], codec)
44
48
  }
45
49
 
46
50
  export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
@@ -57,10 +61,10 @@ export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
57
61
  continue
58
62
  }
59
63
 
60
- hostText += vimText[vimOffset]
64
+ hostText += map.codec.decode(vimText[vimOffset])
61
65
  }
62
66
 
63
- return buildPromptMapFromSynthetic(hostText, vimText, synthetic)
67
+ return buildPromptMapFromSynthetic(hostText, vimText, synthetic, map.codec)
64
68
  }
65
69
 
66
70
  function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vimLength: number, inserted: string) {
@@ -78,30 +82,30 @@ export function hostOffset(map: PromptMap, position: CursorPosition, bias: "prev
78
82
  return charToDisplay(map.hostText, charIdx)
79
83
  }
80
84
 
81
- function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
85
+ function buildPromptMap(hostText: string, wraps: number[], codec: GraphemeCodec): PromptMap {
82
86
  const hostToVim: number[] = []
83
87
  const vimToHost: Array<number | undefined> = []
84
88
  const wrapOffsets = new Set(wraps.filter((offset) => offset > 0 && offset < hostText.length && hostText[offset - 1] !== "\n"))
85
89
  let vimText = ""
86
90
  let vimOffset = 0
87
91
 
88
- for (let hostOffset = 0; hostOffset < hostText.length; hostOffset++) {
92
+ for (const { index: hostOffset, segment } of graphemes.segment(hostText)) {
89
93
  if (wrapOffsets.has(hostOffset)) {
90
94
  vimText += "\n"
91
95
  vimToHost[vimOffset] = undefined
92
96
  vimOffset++
93
97
  }
94
- hostToVim[hostOffset] = vimOffset
95
- vimText += hostText[hostOffset]
98
+ for (let index = hostOffset; index < hostOffset + segment.length; index++) hostToVim[index] = vimOffset
99
+ vimText += codec.encode(segment)
96
100
  vimToHost[vimOffset] = hostOffset
97
101
  vimOffset++
98
102
  }
99
103
 
100
104
  hostToVim[hostText.length] = vimOffset
101
- return { hostText, vimText, hostToVim, vimToHost }
105
+ return { hostText, vimText, hostToVim, vimToHost, codec }
102
106
  }
103
107
 
104
- function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>): PromptMap {
108
+ function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>, codec: GraphemeCodec): PromptMap {
105
109
  const hostToVim: number[] = []
106
110
  const vimToHost: Array<number | undefined> = []
107
111
  let hostOffset = 0
@@ -112,13 +116,13 @@ function buildPromptMapFromSynthetic(hostText: string, vimText: string, syntheti
112
116
  continue
113
117
  }
114
118
 
115
- hostToVim[hostOffset] = vimOffset
116
119
  vimToHost[vimOffset] = hostOffset
117
- hostOffset++
120
+ const length = codec.decode(vimText[vimOffset]).length
121
+ for (let index = 0; index < length; index++) hostToVim[hostOffset++] = vimOffset
118
122
  }
119
123
 
120
124
  hostToVim[hostText.length] = vimText.length
121
- return { hostText, vimText, hostToVim, vimToHost }
125
+ return { hostText, vimText, hostToVim, vimToHost, codec }
122
126
  }
123
127
 
124
128
  function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, prefix: number, suffix: number) {
@@ -128,22 +132,18 @@ function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, pr
128
132
  }
129
133
 
130
134
  function visualWrapOffsets(input: EditBufferLike, text: string) {
131
- const original = input.cursorOffset
132
135
  const wraps: number[] = []
133
- let previousRow: number | undefined
134
-
135
- for (let charIdx = 0; charIdx <= text.length; charIdx++) {
136
- input.cursorOffset = charToDisplay(text, charIdx)
137
- const row = input.visualCursor?.visualRow
138
- if (row === undefined) {
139
- wraps.length = 0
140
- break
136
+ const lines = input.editorView?.getLogicalLineInfo?.()
137
+ if (!lines) return wraps
138
+ let line = 1
139
+ let offset = 0
140
+ for (const part of graphemes.segment(text)) {
141
+ while (line < lines.lineStartCols.length && lines.lineStartCols[line] <= offset) {
142
+ if (lines.lineWraps[line] > 0) wraps.push(part.index)
143
+ line++
141
144
  }
142
- if (previousRow !== undefined && row > previousRow && text[charIdx - 1] !== "\n") wraps.push(charIdx)
143
- previousRow = row
145
+ offset += graphemeWidth(part.segment)
144
146
  }
145
-
146
- input.cursorOffset = original
147
147
  return wraps
148
148
  }
149
149
 
@@ -1,3 +1,5 @@
1
+ // Keep this module as TSX so OpenCode's plugin loader rewrites its Solid import
2
+ // to the host runtime shared by the footer and cursor effects.
1
3
  import { createSignal } from "solid-js"
2
4
  import type { VimLog } from "./log"
3
5
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test"
2
2
  import type { KeyEvent } from "@opentui/core"
3
- import type { PromptContext } from "../../prompt/types"
3
+ import type { PromptContext } from "./actions"
4
4
  import { createVimConfig } from "./config"
5
5
  import type { VimLog } from "./log"
6
6
  import { createVimState, type VimMode } from "./state"