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.
- package/README.md +25 -63
- package/assets/vim-in-motion-dialogs.gif +0 -0
- package/dist/tui.js +3326 -0
- package/docs/configuration.md +50 -400
- package/docs/keymap-actions.md +60 -188
- package/docs/vim-behavior.md +93 -0
- package/package.json +6 -6
- package/src/modules/snippets/index.tsx +0 -17
- package/src/modules/snippets/loader.ts +0 -147
- package/src/modules/snippets/search.ts +0 -118
- package/src/modules/snippets/skill-loader.ts +0 -81
- package/src/modules/snippets/state.ts +0 -40
- package/src/modules/snippets/trigger.ts +0 -68
- package/src/modules/snippets/types.ts +0 -50
- package/src/modules/snippets/view.tsx +0 -674
- package/src/modules/vim/actions.ts +0 -85
- package/src/modules/vim/config.ts +0 -125
- package/src/modules/vim/edit.test.ts +0 -51
- package/src/modules/vim/edit.ts +0 -33
- package/src/modules/vim/graphemes.ts +0 -38
- package/src/modules/vim/index.tsx +0 -200
- package/src/modules/vim/keys.ts +0 -38
- package/src/modules/vim/log.ts +0 -37
- package/src/modules/vim/map.test.ts +0 -45
- package/src/modules/vim/map.ts +0 -198
- package/src/modules/vim/state.test.ts +0 -18
- package/src/modules/vim/state.tsx +0 -46
- package/src/modules/vim/view.tsx +0 -130
- package/src/modules/vim/vimee.test.ts +0 -211
- package/src/modules/vim/vimee.ts +0 -1146
- package/src/plugin.tsx +0 -116
- package/src/prompt/host.tsx +0 -38
- package/src/prompt/modules.ts +0 -16
- package/src/prompt/root.tsx +0 -69
- package/src/prompt/types.ts +0 -42
- package/src/update.ts +0 -120
- package/tui.tsx +0 -275
- package/view.tsx +0 -29
package/docs/keymap-actions.md
CHANGED
|
@@ -1,213 +1,85 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
Each
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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:<
|
|
32
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
## OpenCode commands
|
|
41
62
|
|
|
42
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
}
|