opencode-vim 0.0.22 → 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 +25 -63
  2. package/assets/vim-in-motion-dialogs.gif +0 -0
  3. package/dist/tui.js +3326 -0
  4. package/docs/configuration.md +50 -400
  5. package/docs/keymap-actions.md +60 -188
  6. package/docs/vim-behavior.md +93 -0
  7. package/package.json +6 -6
  8. package/src/modules/snippets/index.tsx +0 -17
  9. package/src/modules/snippets/loader.ts +0 -147
  10. package/src/modules/snippets/search.ts +0 -118
  11. package/src/modules/snippets/skill-loader.ts +0 -81
  12. package/src/modules/snippets/state.ts +0 -40
  13. package/src/modules/snippets/trigger.ts +0 -68
  14. package/src/modules/snippets/types.ts +0 -50
  15. package/src/modules/snippets/view.tsx +0 -674
  16. package/src/modules/vim/actions.ts +0 -85
  17. package/src/modules/vim/config.ts +0 -125
  18. package/src/modules/vim/edit.test.ts +0 -51
  19. package/src/modules/vim/edit.ts +0 -33
  20. package/src/modules/vim/graphemes.ts +0 -38
  21. package/src/modules/vim/index.tsx +0 -200
  22. package/src/modules/vim/keys.ts +0 -38
  23. package/src/modules/vim/log.ts +0 -37
  24. package/src/modules/vim/map.test.ts +0 -45
  25. package/src/modules/vim/map.ts +0 -198
  26. package/src/modules/vim/state.test.ts +0 -18
  27. package/src/modules/vim/state.tsx +0 -46
  28. package/src/modules/vim/view.tsx +0 -130
  29. package/src/modules/vim/vimee.test.ts +0 -211
  30. package/src/modules/vim/vimee.ts +0 -1146
  31. package/src/plugin.tsx +0 -116
  32. package/src/prompt/host.tsx +0 -38
  33. package/src/prompt/modules.ts +0 -16
  34. package/src/prompt/root.tsx +0 -69
  35. package/src/prompt/types.ts +0 -42
  36. package/src/update.ts +0 -120
  37. package/tui.tsx +0 -275
  38. package/view.tsx +0 -29
@@ -1,213 +1,85 @@
1
- # Keymap Actions
2
-
3
- Each keymap entry maps a key sequence to an action in one Vim mode:
4
-
5
- ```jsonc
6
- "keymaps": {
7
- "normal": {
8
- "q": "command:session.new"
1
+ # Custom Keymaps
2
+
3
+ Each entry maps a key sequence to an action in one Vim mode. Put `keymaps` inside
4
+ `options.vim` in your plugin's `cli.json` entry; see [Configuration](./configuration.md).
5
+
6
+ ```json
7
+ {
8
+ "keymaps": {
9
+ "insert": {
10
+ "kj": "normal",
11
+ "<C-s>": "submit"
12
+ },
13
+ "normal": {
14
+ "Y": "y$",
15
+ "H": "0",
16
+ "L": "$",
17
+ "q": "command:session.new"
18
+ }
9
19
  }
10
20
  }
11
21
  ```
12
22
 
13
- ## Modes
14
-
15
- | Mode | Keymaps apply while |
16
- | --- | --- |
17
- | `insert` | Typing text |
18
- | `normal` | Navigating and editing |
19
- | `visual` | Selecting characters |
20
- | `visual-line` | Selecting lines |
21
-
22
- All action types below can be configured in any mode.
23
+ ## Modes and actions
23
24
 
24
- ## Actions
25
+ Mappings apply while editing the prompt or a search dialog, in `insert`, `normal`,
26
+ `visual`, or `visual-line` mode. They do not replace session-browsing or reader
27
+ bindings; use `sessionKey` to change the session toggle.
25
28
 
26
29
  | Action | Behavior |
27
30
  | --- | --- |
28
31
  | `normal` | Enter normal mode |
29
32
  | `insert` | Enter insert mode |
30
- | `submit` | Submit the prompt |
31
- | `command:<name>` | Dispatch an active OpenCode command |
32
- | Any other string | Run it as a Vim key sequence, such as `y$` |
33
+ | `submit` | Submit the prompt or confirm the search dialog |
34
+ | `command:<id>` | Dispatch an active OpenCode command |
35
+ | Vim key sequence, such as `y$` | Run those Vim keys |
36
+
37
+ Insert-mode mappings support only `normal`, `submit`, `command:<id>`, or Escape
38
+ (`"<Esc>"` / `"<C-[>"`). Other editing modes support all action types above.
39
+
40
+ Mapping sequences are literal: mapping `j` to `j` uses an actual line, while
41
+ mapping it to `gj` uses a wrapped row.
42
+
43
+ ## Key notation
44
+
45
+ Use printable ASCII characters; uppercase letters represent shifted keys. Use
46
+ `<Space>` instead of a literal space.
47
+
48
+ Special keys are `<Esc>`, `<CR>`, `<Tab>`, `<BS>`, `<Del>`, `<Space>`, and `<C-a>`
49
+ through `<C-z>`. Ctrl letters must be lowercase: `<C-s>`, not `<C-S>`.
33
50
 
34
- An unmapped `<CR>` submits in normal mode and passes through to OpenCode in insert mode. `<CR>` can be mapped directly or end a sequence, but cannot start a multi-key sequence.
51
+ Examples: `gg`, `kj`, `Y`, `<C-s>`, or `g<CR>`. In JSON, escape a backslash, as in
52
+ `"\\s"` for a backslash followed by `s`.
35
53
 
36
- ## OpenCode Commands
54
+ An unmapped `<CR>` submits in normal mode and passes through to OpenCode in insert
55
+ mode. It can be mapped directly or end a sequence, but cannot start a multi-key
56
+ mapping.
37
57
 
38
- Command mappings use the command ID, not a slash command. For example, use `command:session.new`, not `command:/new`.
58
+ `keymapTimeout` sets the wait between keys in a custom mapping; the default is
59
+ 500 ms. Unmatched or timed-out insert prefixes become ordinary text.
39
60
 
40
- Commands are provided by OpenCode and installed plugins. They may change between versions and only run when their UI context is active. The commands below are available from a focused prompt in the current OpenCode release.
61
+ ## OpenCode commands
41
62
 
42
- ### Application
63
+ Use a command ID, not a slash command: `command:session.new`, not `command:/new`.
64
+ Commands run only when available in the current UI context.
43
65
 
44
- | Command | Behavior |
66
+ | Command ID | Behavior |
45
67
  | --- | --- |
46
68
  | `command.palette.show` | Open the command palette |
47
- | `session.list` | Open the session list |
48
69
  | `session.new` | Start a new session |
49
- | `session.quick_switch.1` through `.9` | Switch to a numbered recent session |
50
- | `workspace.copy_path` | Copy the current worktree path |
51
- | `workspace.list` | Open workspace management |
70
+ | `session.list` | Open the session list |
52
71
  | `model.list` | Open model selection |
53
- | `model.cycle_recent` | Select the next recent model |
54
- | `model.cycle_recent_reverse` | Select the previous recent model |
55
- | `model.cycle_favorite` | Select the next favorite model |
56
- | `model.cycle_favorite_reverse` | Select the previous favorite model |
57
- | `agent.list` | Open agent selection |
58
- | `agent.cycle` | Select the next agent |
59
- | `agent.cycle.reverse` | Select the previous agent |
60
- | `mcp.list` | Open MCP management |
61
- | `variant.cycle` | Select the next model variant |
62
- | `variant.list` | Open variant selection |
63
- | `provider.connect` | Connect a provider |
64
- | `console.org.switch` | Switch organizations |
65
- | `opencode.status` | Open status information |
66
- | `opencode.debug` | Open debug information |
67
- | `theme.switch` | Select a theme |
68
- | `theme.switch_mode` | Toggle light and dark mode |
69
- | `theme.mode.lock` | Lock automatic theme changes |
70
- | `help.show` | Open help |
71
- | `docs.open` | Open OpenCode documentation |
72
- | `app.exit` | Exit OpenCode |
73
- | `app.debug` | Toggle the debug overlay |
74
- | `app.console` | Toggle the renderer console |
75
- | `app.heap_snapshot` | Write a heap snapshot |
76
- | `terminal.suspend` | Suspend OpenCode |
77
- | `terminal.title.toggle` | Toggle terminal title updates |
78
- | `app.toggle.animations` | Toggle TUI animations |
79
- | `app.toggle.file_context` | Toggle automatic file context |
80
- | `app.toggle.diffwrap` | Toggle diff wrapping |
81
- | `app.toggle.paste_summary` | Toggle pasted-text summaries |
82
- | `app.toggle.session_directory_filter` | Toggle session directory filtering |
83
- | `permission.mode` | Toggle permission auto-approval |
84
-
85
- ### Prompt
86
-
87
- | Command | Behavior |
88
- | --- | --- |
89
- | `prompt.clear` | Clear the prompt |
90
- | `prompt.submit` | Submit the prompt |
91
- | `prompt.editor_context.clear` | Remove attached editor context |
92
- | `prompt.paste` | Paste clipboard text or an image |
93
- | `session.interrupt` | Interrupt the active session |
94
- | `prompt.editor` | Edit the prompt in an external editor |
95
- | `prompt.skills` | Open skill selection |
96
- | `workspace.set` | Switch workspace |
97
- | `session.move` | Move the session to another project |
98
- | `prompt.stash` | Stash and clear the prompt |
99
- | `prompt.stash.pop` | Restore the latest prompt stash |
100
- | `prompt.stash.list` | Open prompt stash selection |
101
72
  | `prompt.history.previous` | Load the previous prompt |
102
73
  | `prompt.history.next` | Load the next prompt |
74
+ | `opencode-vim.toggle` | Toggle Vim mode |
103
75
 
104
- ### Autocomplete
76
+ See [OpenCode's command reference](https://opencode.ai/v2/docs/cli/keybinds) for
77
+ the full list. Installed plugins can register additional commands.
105
78
 
106
- These commands are active while prompt autocomplete is visible.
107
-
108
- | Command | Behavior |
109
- | --- | --- |
110
- | `prompt.autocomplete.prev` | Select the previous item |
111
- | `prompt.autocomplete.next` | Select the next item |
112
- | `prompt.autocomplete.hide` | Hide autocomplete |
113
- | `prompt.autocomplete.select` | Accept the selected item |
114
- | `prompt.autocomplete.complete` | Complete or expand the selected item |
79
+ ## Troubleshooting
115
80
 
116
- ### Input
117
-
118
- These commands act on the focused prompt input.
119
-
120
- | Command | Behavior |
121
- | --- | --- |
122
- | `input.move.left` | Move left |
123
- | `input.move.right` | Move right |
124
- | `input.move.up` | Move up |
125
- | `input.move.down` | Move down |
126
- | `input.select.left` | Select left |
127
- | `input.select.right` | Select right |
128
- | `input.select.up` | Select up |
129
- | `input.select.down` | Select down |
130
- | `input.line.home` | Move to logical line start |
131
- | `input.line.end` | Move to logical line end |
132
- | `input.select.line.home` | Select to logical line start |
133
- | `input.select.line.end` | Select to logical line end |
134
- | `input.visual.line.home` | Move to visual line start |
135
- | `input.visual.line.end` | Move to visual line end |
136
- | `input.select.visual.line.home` | Select to visual line start |
137
- | `input.select.visual.line.end` | Select to visual line end |
138
- | `input.buffer.home` | Move to the start of the prompt |
139
- | `input.buffer.end` | Move to the end of the prompt |
140
- | `input.select.buffer.home` | Select to the start of the prompt |
141
- | `input.select.buffer.end` | Select to the end of the prompt |
142
- | `input.delete.line` | Delete the current line |
143
- | `input.delete.to.line.end` | Delete to line end |
144
- | `input.delete.to.line.start` | Delete to line start |
145
- | `input.backspace` | Delete backward |
146
- | `input.delete` | Delete forward |
147
- | `input.newline` | Insert a newline |
148
- | `input.undo` | Undo an edit |
149
- | `input.redo` | Redo an edit |
150
- | `input.word.forward` | Move forward one word |
151
- | `input.word.backward` | Move backward one word |
152
- | `input.select.word.forward` | Select forward one word |
153
- | `input.select.word.backward` | Select backward one word |
154
- | `input.delete.word.forward` | Delete forward one word |
155
- | `input.delete.word.backward` | Delete backward one word |
156
- | `input.select.all` | Select the entire prompt |
157
- | `input.submit` | Submit the prompt input |
158
-
159
- ### Session
160
-
161
- These commands are active in a session.
162
-
163
- | Command | Behavior |
164
- | --- | --- |
165
- | `session.share` | Share the session or copy its URL |
166
- | `session.rename` | Rename the session |
167
- | `session.timeline` | Open the message timeline |
168
- | `session.fork` | Fork from the timeline |
169
- | `session.compact` | Summarize the session |
170
- | `session.unshare` | Stop sharing the session |
171
- | `session.undo` | Undo the latest user message |
172
- | `session.redo` | Redo an undone message |
173
- | `session.sidebar.toggle` | Toggle the session sidebar |
174
- | `session.toggle.conceal` | Toggle code concealment |
175
- | `session.toggle.timestamps` | Toggle message timestamps |
176
- | `session.toggle.thinking` | Toggle expanded thinking |
177
- | `session.toggle.actions` | Toggle tool action details |
178
- | `session.toggle.scrollbar` | Toggle the session scrollbar |
179
- | `session.toggle.generic_tool_output` | Toggle generic tool output |
180
- | `session.page.up` | Scroll up half a page |
181
- | `session.page.down` | Scroll down half a page |
182
- | `session.line.up` | Scroll up one line |
183
- | `session.line.down` | Scroll down one line |
184
- | `session.half.page.up` | Scroll up a quarter page |
185
- | `session.half.page.down` | Scroll down a quarter page |
186
- | `session.first` | Jump to the first message |
187
- | `session.last` | Jump to the last message |
188
- | `session.messages_last_user` | Jump to the latest user message |
189
- | `session.message.next` | Jump to the next message |
190
- | `session.message.previous` | Jump to the previous message |
191
- | `messages.copy` | Copy the latest assistant message |
192
- | `session.copy` | Copy the session transcript |
193
- | `session.export` | Export the session transcript |
194
- | `session.background` | Background active subagents |
195
- | `session.child.first` | Open the first child session |
196
- | `session.parent` | Open the parent session |
197
- | `session.child.next` | Open the next child session |
198
- | `session.child.previous` | Open the previous child session |
199
-
200
- ### Plugins
201
-
202
- | Command | Behavior | Requirement |
203
- | --- | --- | --- |
204
- | `opencode-vim.toggle` | Toggle Vim mode | Always available |
205
- | `snippets.reload` | Reload snippets | Snippets plugin installed |
206
- | `snippets.insert` | Open snippet selection | Snippets plugin installed |
207
- | `snippets.accept` | Accept the active snippet field | Snippets plugin installed |
208
- | `plugins.list` | Open plugin management | Built-in plugin manager enabled |
209
- | `plugins.install` | Open plugin installation | Built-in plugin manager enabled |
210
- | `diff.open` | Open the diff viewer | Built-in diff viewer enabled |
211
- | `tips.toggle` | Toggle home-screen tips | Home screen visible |
212
-
213
- Other plugins can register additional commands. A command that is unavailable in the current context is ignored.
81
+ - Check the mode and whether the action is supported in it.
82
+ - Use the exact key notation above; literal spaces and names such as `<C-S>` or
83
+ `<Up>` are not supported in custom mapping sequences.
84
+ - Command mappings need an active command in the current context.
85
+ - Enable `debug` in [Configuration](./configuration.md) to inspect rejected mappings.
@@ -0,0 +1,93 @@
1
+ # Keybindings and Modes
2
+
3
+ These are the default bindings. See [Configuration](./configuration.md) to change
4
+ the startup mode, session key, or editing mappings.
5
+
6
+ ## Prompt editing
7
+
8
+ Starts in insert mode. Press `Esc` to enter normal mode and `i` to type again.
9
+ Use `/vim` to toggle the plugin on or off.
10
+
11
+ | Key | Behavior |
12
+ | --- | --- |
13
+ | `Esc`, `Ctrl+[` | Enter normal mode |
14
+ | `i`, `a`, `A`, `o`, `O` | Enter insert mode |
15
+ | `h`, `l`, `w`, `b`, `e`, `$`, `0` | Move through the prompt |
16
+ | `j`, `k` | Move through wrapped rows; counts use actual lines |
17
+ | Up, Down | OpenCode's cursor/history navigation |
18
+ | `gj`, `gk`, `g0`, `g^`, `g$` | Move through wrapped screen rows |
19
+ | `x`, `d`, `c`, `y`, `p` | Delete, change, yank, and paste |
20
+ | `u`, `Ctrl+r`, `.` | Undo, redo, and repeat the last change |
21
+ | `v`, `V` | Visual and visual-line selection |
22
+ | `3w`, `diw`, `ci"`, `yiq`, `dip`, `yib` | Counts and text objects |
23
+ | `k`, `j` from an empty prompt | Browse previous and next prompts |
24
+ | `Enter` in normal mode | Submit the prompt |
25
+ | `s` in normal mode | Enter session mode |
26
+
27
+ Wrapping is visual only. Line commands such as `0`, `$`, `A`, `dd`, `yy`, and `V`
28
+ use actual newline-separated lines. Bare `j`/`k` follow wrapped rows, as in
29
+ LazyVim; counts such as `3j` and operator motions such as `dj` use actual lines.
30
+ `gj`/`gk` always follow wrapped rows, including with counts.
31
+
32
+ Text objects include words and quotes, plus `iq`/`aq` for the nearest quote pair,
33
+ `ip`/`ap` for paragraphs, and `ib`/`ab` or `iB`/`aB` for parentheses or braces.
34
+ Yanks and cuts (`y`, `d`, `c`, `x`) write to the system clipboard. `p` and `P`
35
+ read its current text, including text copied from another application. Counts,
36
+ linewise puts, undo, and dot repeat still use Vim's editing behavior.
37
+
38
+ Named registers such as `"ayiw` and `"ap` stay separate from the system clipboard.
39
+ When clipboard access is unavailable, puts use the last copied text shared by
40
+ the prompt, dialogs, and session reader. Over SSH, copying uses the terminal's
41
+ clipboard support; `p` uses this fallback because OpenTUI cannot read the remote
42
+ client's clipboard. Use your terminal's paste shortcut for external text there.
43
+ Use OpenCode's normal paste shortcut for images and attachment handling.
44
+
45
+ ## Session mode
46
+
47
+ From a session prompt, press `s` in normal mode to browse from the latest item.
48
+ A colored bar marks the selected text block, reasoning block, tool call, or group.
49
+ Visible items are highlighted in place; off-screen items scroll into view.
50
+
51
+ | Key | Behavior |
52
+ | --- | --- |
53
+ | `j`, `k`, Down, Up | Next / previous item; counts work too |
54
+ | `gg`, `G` | First / last item |
55
+ | `Enter` | Expand/collapse a group, or open an item in a read-only modal |
56
+ | `yy` | Copy selected text; groups copy their summary |
57
+ | `Ctrl+d`, `Ctrl+u` | Scroll down / up |
58
+ | `Ctrl+f`, `Ctrl+b`, Page Down, Page Up | Scroll by larger steps |
59
+ | `Esc`, `Ctrl+[` | Return to the prompt |
60
+ | `s` | Return to the prompt |
61
+
62
+ A collapsed group is one stop. Expand it to navigate its individual tools or
63
+ reasoning blocks. Tool items expose their displayed details; text items preserve
64
+ their Markdown.
65
+
66
+ Inside the modal, use Vim motions to move, `v` or `V` to select, and `y` to copy.
67
+ `Esc` cancels a selection or pending motion, then closes the modal. From its normal
68
+ mode, `s` returns directly to the prompt. The modal keeps a snapshot of the item
69
+ while you read it, even if the response is still streaming.
70
+
71
+ Session yanks go to both the clipboard and the shared fallback, so `p`
72
+ pastes into the prompt. Yanks briefly highlight the copied text. Your prompt text,
73
+ cursor, and undo history are preserved.
74
+
75
+ ## Dialogs
76
+
77
+ Search dialogs such as `/models` and `Ctrl+P` inherit the prompt's insert/normal
78
+ mode. Type to filter in insert mode; use `j`/`k` to choose items in normal mode.
79
+ `Enter` confirms, and `Esc` in normal mode goes back.
80
+
81
+ The footer follows the active editor's mode. Closing the dialog restores the
82
+ prompt's mode or the session footer. Prompt and dialog edits have separate undo
83
+ histories.
84
+
85
+ ## Vim compatibility
86
+
87
+ This is a Vim-style subset powered by `@vimee/core`, with OpenCode-specific
88
+ submission, history, dialogs, and session navigation. Insert-mode typing uses
89
+ OpenCode's native editor. The adapter maps graphemes and cursor positions without
90
+ adding newlines at soft wraps.
91
+
92
+ For standard Vim terminology, see [Vim's quick reference](https://vimhelp.org/quickref.txt.html)
93
+ and [motion documentation](https://vimhelp.org/motion.txt.html).
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-vim",
4
- "version": "0.0.22",
4
+ "version": "0.0.24",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/Tarquinen/opencode-vim"
9
9
  },
10
10
  "exports": {
11
- "./tui": "./tui.tsx"
11
+ "./tui": "./dist/tui.js"
12
12
  },
13
13
  "scripts": {
14
+ "build": "bun scripts/build.ts",
15
+ "prepack": "bun run build",
14
16
  "typecheck": "tsc --noEmit",
15
17
  "test": "bun test --conditions=browser --preload @opentui/solid/preload",
16
18
  "bench": "bun test/benchmark.ts"
@@ -33,9 +35,7 @@
33
35
  "files": [
34
36
  "assets/",
35
37
  "docs/",
36
- "src/",
37
- "README.md",
38
- "tui.tsx",
39
- "view.tsx"
38
+ "dist/",
39
+ "README.md"
40
40
  ]
41
41
  }
@@ -1,17 +0,0 @@
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(controller: SnippetController): PromptModule {
7
- return {
8
- id: "snippets",
9
- order: -10,
10
- onSubmit() {
11
- return controller.accept?.() === true
12
- },
13
- renderAbove(ctx) {
14
- return <SnippetAutocomplete ctx={ctx} controller={controller} />
15
- },
16
- }
17
- }
@@ -1,147 +0,0 @@
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
- }
@@ -1,118 +0,0 @@
1
- import type { SkillInfo, SnippetInfo } from "./types"
2
-
3
- export type HighlightPart = {
4
- text: string
5
- match: boolean
6
- }
7
-
8
- function normalizeSearchText(input: string) {
9
- return input.toLowerCase().replace(/[^a-z0-9]/g, "")
10
- }
11
-
12
- function scoreText(input: string, query: string) {
13
- const raw = input.toLowerCase()
14
- const compact = normalizeSearchText(input)
15
- const needle = query.toLowerCase().trim()
16
- const compactNeedle = normalizeSearchText(query)
17
-
18
- if (raw === needle) return 0
19
- if (compactNeedle && compact === compactNeedle) return 1
20
- if (raw.startsWith(needle)) return 2
21
- if (compactNeedle && compact.startsWith(compactNeedle)) return 3
22
- if (raw.includes(needle)) return 4
23
- if (compactNeedle && compact.includes(compactNeedle)) return 5
24
- return Number.POSITIVE_INFINITY
25
- }
26
-
27
- function snippetDescription(snippet: SnippetInfo) {
28
- return (snippet.description || snippet.content).replace(/\s+/g, " ").trim()
29
- }
30
-
31
- function scoreSnippet(snippet: SnippetInfo, query: string) {
32
- if (!query) return 0
33
-
34
- const nameScore = Math.min(scoreText(snippet.name, query), ...snippet.aliases.map((alias) => scoreText(alias, query)))
35
- if (Number.isFinite(nameScore)) return nameScore
36
-
37
- const description = (snippet.description || "").replace(/\s+/g, " ").trim().toLowerCase()
38
- const lowerQuery = query.toLowerCase()
39
- if (description.startsWith(lowerQuery)) return 6
40
- if (description.includes(lowerQuery)) return 7
41
- return Number.POSITIVE_INFINITY
42
- }
43
-
44
- function sourceRank(snippet: SnippetInfo) {
45
- return snippet.source === "project" ? 0 : 1
46
- }
47
-
48
- export function filterSnippets(snippets: SnippetInfo[], query: string) {
49
- const needle = query.trim()
50
- return snippets
51
- .map((snippet) => ({ snippet, score: scoreSnippet(snippet, needle) }))
52
- .filter((entry) => Number.isFinite(entry.score))
53
- .sort((left, right) => left.score - right.score || sourceRank(left.snippet) - sourceRank(right.snippet) || left.snippet.name.localeCompare(right.snippet.name))
54
- .map((entry) => entry.snippet)
55
- }
56
-
57
- export function matchedAliases(snippet: SnippetInfo, query: string) {
58
- const needle = query.trim()
59
- if (!needle) return []
60
- return snippet.aliases.filter((alias) => Number.isFinite(scoreText(alias, needle)))
61
- }
62
-
63
- export function describeSnippet(snippet: SnippetInfo) {
64
- return snippetDescription(snippet)
65
- }
66
-
67
- export function highlightMatches(input: string, query: string): HighlightPart[] {
68
- const needle = query.trim().toLowerCase()
69
- if (!needle) return [{ text: input, match: false }]
70
-
71
- const lower = input.toLowerCase()
72
- const parts: HighlightPart[] = []
73
- let cursor = 0
74
-
75
- while (cursor < input.length) {
76
- const index = lower.indexOf(needle, cursor)
77
- if (index < 0) break
78
- if (index > cursor) parts.push({ text: input.slice(cursor, index), match: false })
79
- parts.push({ text: input.slice(index, index + needle.length), match: true })
80
- cursor = index + needle.length
81
- }
82
-
83
- if (parts.length === 0) return [{ text: input, match: false }]
84
- if (cursor < input.length) parts.push({ text: input.slice(cursor), match: false })
85
- return parts
86
- }
87
-
88
- function skillTag(skill: SkillInfo) {
89
- return `skill(${skill.name})`
90
- }
91
-
92
- function scoreSkill(skill: SkillInfo, query: string) {
93
- if (!query) return 0
94
- const nameScore = Math.min(scoreText(skill.name, query), scoreText(skillTag(skill), query))
95
- if (Number.isFinite(nameScore)) return nameScore
96
- const description = (skill.description || "").replace(/\s+/g, " ").trim().toLowerCase()
97
- const lowerQuery = query.toLowerCase()
98
- if (description.startsWith(lowerQuery)) return 6
99
- if (description.includes(lowerQuery)) return 7
100
- return Number.POSITIVE_INFINITY
101
- }
102
-
103
- function skillSourceRank(skill: SkillInfo) {
104
- return skill.source === "project" ? 0 : 1
105
- }
106
-
107
- export function filterSkills(skills: SkillInfo[], query: string) {
108
- const needle = query.trim()
109
- return skills
110
- .map((skill) => ({ skill, score: scoreSkill(skill, needle) }))
111
- .filter((entry) => Number.isFinite(entry.score))
112
- .sort((left, right) => left.score - right.score || skillSourceRank(left.skill) - skillSourceRank(right.skill) || left.skill.name.localeCompare(right.skill.name))
113
- .map((entry) => entry.skill)
114
- }
115
-
116
- export function describeSkill(skill: SkillInfo) {
117
- return (skill.description || skill.content).replace(/\s+/g, " ").trim()
118
- }