opencode-vim 0.0.2

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 ADDED
@@ -0,0 +1,13 @@
1
+ # opencode-vim
2
+
3
+ Adds Vim-style insert and normal mode editing to the OpenCode prompt.
4
+
5
+ ## Installation
6
+
7
+ Install from the CLI:
8
+
9
+ ```bash
10
+ opencode plugin opencode-vim@latest --global
11
+ ```
12
+
13
+ See [docs/configuration.md](./docs/configuration.md) for configuration options and keymap examples.
package/bun.lock ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "opencode-vim",
7
+ "dependencies": {
8
+ "@vimee/core": "^0.3.0",
9
+ },
10
+ },
11
+ },
12
+ "packages": {
13
+ "@vimee/core": ["@vimee/core@0.3.0", "", {}, "sha512-aC44fw6HhmjrbJMafAk3oUZIDpFbgNebbDsWwMIFD0WwnOUZPETgzU3HYQetmwz0e38JY3I/g8CJXRQjspgJZw=="],
14
+ }
15
+ }
@@ -0,0 +1,403 @@
1
+ # Configuration Guide
2
+
3
+ This guide shows how to enable `opencode-vim` in OpenCode and configure its Vim prompt behavior.
4
+
5
+ ## Basic Setup
6
+
7
+ Add `opencode-vim` to the `plugin` array in your OpenCode `tui.jsonc` file:
8
+
9
+ ```jsonc
10
+ {
11
+ "$schema": "https://opencode.ai/tui.json",
12
+ "plugin": [
13
+ [
14
+ "./plugin/opencode-vim",
15
+ {
16
+ "vim": {
17
+ "defaultMode": "insert"
18
+ }
19
+ }
20
+ ]
21
+ ]
22
+ }
23
+ ```
24
+
25
+ If your plugin is installed somewhere else, change the plugin path to match your setup.
26
+
27
+ ## Full Example
28
+
29
+ ```jsonc
30
+ {
31
+ "$schema": "https://opencode.ai/tui.json",
32
+ "plugin": [
33
+ [
34
+ "./plugin/opencode-vim",
35
+ {
36
+ "vim": {
37
+ "defaultMode": "insert",
38
+ "keymapTimeout": 500,
39
+ "pendingDisplayDelay": 120,
40
+ "cursorStyles": {
41
+ "insert": {
42
+ "style": "line",
43
+ "blinking": true
44
+ },
45
+ "normal": {
46
+ "style": "block",
47
+ "blinking": true
48
+ }
49
+ },
50
+ "debug": false,
51
+ "debugPath": "/home/you/.cache/opencode/opencode-vim.log",
52
+ "keymaps": {
53
+ "insert": {
54
+ "kj": "normal"
55
+ },
56
+ "normal": {
57
+ "<CR>": "submit",
58
+ "Y": "y$"
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ]
64
+ ]
65
+ }
66
+ ```
67
+
68
+ ## Options
69
+
70
+ ### `defaultMode`
71
+
72
+ The mode the prompt starts in.
73
+
74
+ Allowed values:
75
+
76
+ - `"insert"`
77
+ - `"normal"`
78
+
79
+ Default:
80
+
81
+ ```jsonc
82
+ "defaultMode": "insert"
83
+ ```
84
+
85
+ Example:
86
+
87
+ ```jsonc
88
+ "defaultMode": "normal"
89
+ ```
90
+
91
+ ### `keymapTimeout`
92
+
93
+ How long, in milliseconds, the prompt waits for the next key when a configured keymap has only been partially typed.
94
+
95
+ Default:
96
+
97
+ ```jsonc
98
+ "keymapTimeout": 500
99
+ ```
100
+
101
+ Examples:
102
+
103
+ ```jsonc
104
+ "keymapTimeout": 250
105
+ ```
106
+
107
+ ```jsonc
108
+ "keymapTimeout": 1000
109
+ ```
110
+
111
+ Use a shorter timeout for faster fallback after partial mappings. Use a longer timeout if you type multi-key mappings slowly.
112
+
113
+ ### `pendingDisplayDelay`
114
+
115
+ How long, in milliseconds, the prompt waits before showing a pending key sequence in the status area.
116
+
117
+ Default:
118
+
119
+ ```jsonc
120
+ "pendingDisplayDelay": 120
121
+ ```
122
+
123
+ Examples:
124
+
125
+ ```jsonc
126
+ "pendingDisplayDelay": 0
127
+ ```
128
+
129
+ ```jsonc
130
+ "pendingDisplayDelay": 300
131
+ ```
132
+
133
+ This only affects display. It does not change how long keymaps wait for more input.
134
+
135
+ ### `cursorStyles`
136
+
137
+ The cursor style to use in each mode.
138
+
139
+ Allowed styles:
140
+
141
+ - `"block"`
142
+ - `"line"`
143
+ - `"underline"`
144
+ - `"default"`
145
+
146
+ Default:
147
+
148
+ ```jsonc
149
+ "cursorStyles": {
150
+ "insert": {
151
+ "style": "line",
152
+ "blinking": true
153
+ },
154
+ "normal": {
155
+ "style": "block",
156
+ "blinking": true
157
+ }
158
+ }
159
+ ```
160
+
161
+ Examples:
162
+
163
+ ```jsonc
164
+ "cursorStyles": {
165
+ "insert": {
166
+ "style": "line",
167
+ "blinking": false
168
+ },
169
+ "normal": {
170
+ "style": "block",
171
+ "blinking": false
172
+ }
173
+ }
174
+ ```
175
+
176
+ ```jsonc
177
+ "cursorStyles": {
178
+ "insert": {
179
+ "style": "underline"
180
+ },
181
+ "normal": {
182
+ "style": "default"
183
+ }
184
+ }
185
+ ```
186
+
187
+ You can configure only one mode if you want. Any omitted values use the defaults.
188
+
189
+ ### `debug`
190
+
191
+ Enables debug logging.
192
+
193
+ Default:
194
+
195
+ ```jsonc
196
+ "debug": false
197
+ ```
198
+
199
+ Example:
200
+
201
+ ```jsonc
202
+ "debug": true
203
+ ```
204
+
205
+ You can also enable debug logging with this environment variable:
206
+
207
+ ```bash
208
+ VIM_PROMPT_DEBUG=1
209
+ ```
210
+
211
+ ### `debugPath`
212
+
213
+ The file path used for debug logs when debug logging is enabled.
214
+
215
+ Default:
216
+
217
+ ```txt
218
+ ~/.cache/opencode/opencode-vim.log
219
+ ```
220
+
221
+ Example:
222
+
223
+ ```jsonc
224
+ "debugPath": "/tmp/opencode-vim.log"
225
+ ```
226
+
227
+ Use an absolute path in config. `~` is not expanded inside `debugPath`.
228
+
229
+ ### `keymaps`
230
+
231
+ Custom keymaps for insert mode and normal mode.
232
+
233
+ Allowed modes:
234
+
235
+ - `"insert"`
236
+ - `"normal"`
237
+
238
+ Each keymap entry maps a key sequence to an action:
239
+
240
+ ```jsonc
241
+ "keymaps": {
242
+ "insert": {
243
+ "kj": "normal"
244
+ },
245
+ "normal": {
246
+ "<CR>": "submit"
247
+ }
248
+ }
249
+ ```
250
+
251
+ Supported built-in actions:
252
+
253
+ - `"normal"` exits insert mode and enters normal mode.
254
+ - `"insert"` enters insert mode.
255
+ - `"submit"` submits the OpenCode prompt.
256
+
257
+ Any other action string is treated as a Vim key sequence. For example, this maps `Y` to yank from the cursor to the end of the line:
258
+
259
+ ```jsonc
260
+ "keymaps": {
261
+ "normal": {
262
+ "Y": "y$"
263
+ }
264
+ }
265
+ ```
266
+
267
+ ## Keymap Syntax
268
+
269
+ Key sequences can contain printable ASCII characters, except literal spaces. Use `<Space>` for the space key.
270
+
271
+ Examples:
272
+
273
+ ```jsonc
274
+ "x": "d"
275
+ "gg": "0"
276
+ "Y": "y$"
277
+ "\\r": "<C-r>"
278
+ ```
279
+
280
+ Supported special keys:
281
+
282
+ - `<Esc>`
283
+ - `<CR>`
284
+ - `<Tab>`
285
+ - `<BS>`
286
+ - `<Del>`
287
+ - `<Space>`
288
+ - `<C-a>` through `<C-z>`
289
+
290
+ Ctrl key names must be lowercase. Use `<C-s>`, not `<C-S>`.
291
+
292
+ Unsupported examples:
293
+
294
+ ```jsonc
295
+ "<C-S>": "submit"
296
+ "<C-1>": "submit"
297
+ "<Up>": "k"
298
+ "a b": "normal"
299
+ ```
300
+
301
+ Invalid keymaps are skipped. Enable debug logging if you need to troubleshoot keymap registration.
302
+
303
+ ## Keymap Examples
304
+
305
+ Use `kj` or `jk` to leave insert mode:
306
+
307
+ ```jsonc
308
+ "keymaps": {
309
+ "insert": {
310
+ "kj": "normal",
311
+ "jk": "normal"
312
+ }
313
+ }
314
+ ```
315
+
316
+ Submit the prompt with Enter in normal mode:
317
+
318
+ ```jsonc
319
+ "keymaps": {
320
+ "normal": {
321
+ "<CR>": "submit"
322
+ }
323
+ }
324
+ ```
325
+
326
+ Submit the prompt with Ctrl-S in insert mode:
327
+
328
+ ```jsonc
329
+ "keymaps": {
330
+ "insert": {
331
+ "<C-s>": "submit"
332
+ }
333
+ }
334
+ ```
335
+
336
+ Make `Y` yank to the end of the line:
337
+
338
+ ```jsonc
339
+ "keymaps": {
340
+ "normal": {
341
+ "Y": "y$"
342
+ }
343
+ }
344
+ ```
345
+
346
+ Make `D` delete to the beginning of the line:
347
+
348
+ ```jsonc
349
+ "keymaps": {
350
+ "normal": {
351
+ "D": "d0"
352
+ }
353
+ }
354
+ ```
355
+
356
+ Make `H` move to the beginning and `L` move to the end:
357
+
358
+ ```jsonc
359
+ "keymaps": {
360
+ "normal": {
361
+ "H": "0",
362
+ "L": "$"
363
+ }
364
+ }
365
+ ```
366
+
367
+ Use `q` to enter insert mode from normal mode:
368
+
369
+ ```jsonc
370
+ "keymaps": {
371
+ "normal": {
372
+ "q": "insert"
373
+ }
374
+ }
375
+ ```
376
+
377
+ Use a leader-style sequence:
378
+
379
+ ```jsonc
380
+ "keymaps": {
381
+ "normal": {
382
+ "\\s": "submit",
383
+ "\\r": "<C-r>"
384
+ }
385
+ }
386
+ ```
387
+
388
+ ## Troubleshooting
389
+
390
+ If a keymap does not work, check these first:
391
+
392
+ - The mode is either `insert` or `normal`.
393
+ - The key sequence does not contain a literal space.
394
+ - Special keys use one of the supported names exactly.
395
+ - Ctrl keys use lowercase letters, such as `<C-s>`.
396
+ - The action string is not empty.
397
+
398
+ To debug configuration problems, enable logging:
399
+
400
+ ```jsonc
401
+ "debug": true,
402
+ "debugPath": "/tmp/opencode-vim.log"
403
+ ```
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "opencode-vim",
3
+ "version": "0.0.2",
4
+ "exports": {
5
+ "./tui": {
6
+ "import": "./tui.tsx"
7
+ }
8
+ },
9
+ "dependencies": {
10
+ "@vimee/core": "^0.3.0"
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { PromptModule } from "../../prompt/types"
3
+ import type { SnippetController } from "./types"
4
+ import { SnippetAutocomplete } from "./view"
5
+
6
+ export function createSnippetsModule(): PromptModule {
7
+ const controller: SnippetController = {}
8
+
9
+ return {
10
+ id: "snippets",
11
+ order: -10,
12
+ onSubmit() {
13
+ return controller.accept?.() === true
14
+ },
15
+ renderAbove(ctx) {
16
+ return <SnippetAutocomplete ctx={ctx} controller={controller} />
17
+ },
18
+ }
19
+ }
@@ -0,0 +1,147 @@
1
+ import { basename, join } from "node:path"
2
+ import { mkdir, writeFile } from "node:fs/promises"
3
+ import type { SnippetInfo, SnippetSource } from "./types"
4
+
5
+ const SNIPPET_EXT = ".md"
6
+ const EMPTY_SNIPPET = `---
7
+ description: ""
8
+ ---
9
+
10
+ `
11
+
12
+ export async function loadSnippets(projectDir?: string) {
13
+ const registry = new Map<string, SnippetInfo>()
14
+ const home = process.env.HOME
15
+
16
+ if (home) {
17
+ await loadDir(registry, join(home, ".config/opencode/snippets"), "global")
18
+ await loadDir(registry, join(home, ".config/opencode/snippet"), "global")
19
+ }
20
+
21
+ if (projectDir) {
22
+ await loadDir(registry, join(projectDir, ".opencode/snippets"), "project")
23
+ await loadDir(registry, join(projectDir, ".opencode/snippet"), "project")
24
+ }
25
+
26
+ return listSnippets(registry).sort((left, right) => sourceRank(left) - sourceRank(right) || left.name.localeCompare(right.name))
27
+ }
28
+
29
+ async function loadDir(registry: Map<string, SnippetInfo>, dir: string, source: SnippetSource) {
30
+ let entries: string[]
31
+ try {
32
+ entries = await Array.fromAsync(new Bun.Glob(`*${SNIPPET_EXT}`).scan({ cwd: dir, onlyFiles: true }))
33
+ } catch {
34
+ return
35
+ }
36
+
37
+ for (const entry of entries) {
38
+ const snippet = await loadFile(join(dir, entry), source)
39
+ if (snippet) registerSnippet(registry, snippet)
40
+ }
41
+ }
42
+
43
+ async function loadFile(filePath: string, source: SnippetSource) {
44
+ let raw: string
45
+ try {
46
+ raw = await Bun.file(filePath).text()
47
+ } catch {
48
+ return undefined
49
+ }
50
+
51
+ const parsed = parseFrontmatter(raw)
52
+ return {
53
+ name: basename(filePath, SNIPPET_EXT),
54
+ content: parsed.content.trim(),
55
+ aliases: normalizeAliases(parsed.data.aliases ?? parsed.data.alias),
56
+ description: typeof parsed.data.description === "string" ? parsed.data.description : undefined,
57
+ filePath,
58
+ source,
59
+ } satisfies SnippetInfo
60
+ }
61
+
62
+ export async function ensureSnippetDraft(name: string, projectDir?: string) {
63
+ const dir = await ensureSnippetsDir(projectDir)
64
+ const filePath = join(dir, `${name}${SNIPPET_EXT}`)
65
+ if (!(await Bun.file(filePath).exists())) await writeFile(filePath, EMPTY_SNIPPET)
66
+ return filePath
67
+ }
68
+
69
+ async function ensureSnippetsDir(projectDir?: string) {
70
+ const home = process.env.HOME
71
+ const dir = projectDir ? join(projectDir, ".opencode/snippet") : home ? join(home, ".config/opencode/snippet") : undefined
72
+ if (!dir) throw new Error("Unable to resolve snippets directory")
73
+ await mkdir(dir, { recursive: true })
74
+ return dir
75
+ }
76
+
77
+ export function parseFrontmatter(raw: string) {
78
+ if (!raw.startsWith("---\n")) return { data: {} as Record<string, unknown>, content: raw }
79
+
80
+ const end = raw.indexOf("\n---", 4)
81
+ if (end < 0) return { data: {} as Record<string, unknown>, content: raw }
82
+
83
+ return {
84
+ data: parseYamlish(raw.slice(4, end)),
85
+ content: raw.slice(end + 4).replace(/^\r?\n/, ""),
86
+ }
87
+ }
88
+
89
+ function parseYamlish(input: string) {
90
+ const data: Record<string, unknown> = {}
91
+ const lines = input.split(/\r?\n/)
92
+ let currentList: string | undefined
93
+
94
+ for (const line of lines) {
95
+ const listItem = /^\s*-\s*(.+)$/.exec(line)
96
+ if (listItem && currentList) {
97
+ const value = String(listItem[1]).trim()
98
+ data[currentList] = [...asArray(data[currentList]), unquote(value)]
99
+ continue
100
+ }
101
+
102
+ const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
103
+ if (!pair) continue
104
+
105
+ currentList = undefined
106
+ const key = pair[1]
107
+ const value = pair[2].trim()
108
+ if (!value) {
109
+ data[key] = []
110
+ currentList = key
111
+ } else {
112
+ data[key] = unquote(value)
113
+ }
114
+ }
115
+
116
+ return data
117
+ }
118
+
119
+ export function normalizeAliases(value: unknown) {
120
+ return asArray(value).filter((entry) => typeof entry === "string" && entry.length > 0) as string[]
121
+ }
122
+
123
+ function asArray(value: unknown) {
124
+ return Array.isArray(value) ? value : typeof value === "string" ? [value] : []
125
+ }
126
+
127
+ export function unquote(value: string) {
128
+ return value.replace(/^['"]|['"]$/g, "")
129
+ }
130
+
131
+ function registerSnippet(registry: Map<string, SnippetInfo>, snippet: SnippetInfo) {
132
+ const old = registry.get(snippet.name.toLowerCase())
133
+ if (old) {
134
+ for (const alias of old.aliases) registry.delete(alias.toLowerCase())
135
+ }
136
+
137
+ registry.set(snippet.name.toLowerCase(), snippet)
138
+ for (const alias of snippet.aliases) registry.set(alias.toLowerCase(), snippet)
139
+ }
140
+
141
+ function listSnippets(registry: Map<string, SnippetInfo>) {
142
+ return [...new Map([...registry.values()].map((snippet) => [snippet.name, snippet])).values()]
143
+ }
144
+
145
+ function sourceRank(snippet: SnippetInfo) {
146
+ return snippet.source === "project" ? 0 : 1
147
+ }