opencode-vim 0.0.19 → 0.0.21
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 +70 -18
- package/docs/configuration.md +13 -3
- package/docs/keymap-actions.md +213 -0
- package/package.json +18 -16
- package/src/modules/vim/actions.ts +32 -7
- package/src/modules/vim/edit.test.ts +51 -0
- package/src/modules/vim/edit.ts +33 -0
- package/src/modules/vim/graphemes.ts +38 -0
- package/src/modules/vim/index.tsx +15 -27
- package/src/modules/vim/keys.ts +5 -1
- package/src/modules/vim/map.test.ts +45 -0
- package/src/modules/vim/map.ts +62 -29
- package/src/modules/vim/state.test.ts +18 -0
- package/src/modules/vim/state.ts +7 -1
- package/src/modules/vim/view.tsx +2 -0
- package/src/modules/vim/vimee.test.ts +211 -0
- package/src/modules/vim/vimee.ts +372 -82
- package/tui.tsx +281 -1
- package/view.tsx +46 -0
- package/tui.json.example +0 -37
package/README.md
CHANGED
|
@@ -1,28 +1,80 @@
|
|
|
1
1
|
# opencode-vim
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Vim-style editing for the OpenCode 2 prompt and dialog search fields.
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Configure the local plugin in OpenCode 2's `cli.json`:
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"plugins": [
|
|
14
|
+
"opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Options use the V2 object form:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"package": "opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim",
|
|
24
|
+
"options": {
|
|
25
|
+
"vim": {
|
|
26
|
+
"defaultMode": "insert",
|
|
27
|
+
"keymapTimeout": 500,
|
|
28
|
+
"keymaps": {
|
|
29
|
+
"insert": { "kj": "normal" },
|
|
30
|
+
"normal": { "Y": "y$" }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
OpenCode 2 does not yet expose its prompt ref or low-level key interceptor to
|
|
38
|
+
plugins. This adapter uses the public renderer in the V2 plugin context to
|
|
39
|
+
bridge those capabilities to the existing Vim engine.
|
|
40
|
+
|
|
41
|
+
## Dialogs
|
|
10
42
|
|
|
11
|
-
|
|
12
|
-
|
|
43
|
+
Searchable dialogs such as `/models`, the `Ctrl+P` command palette, and Settings
|
|
44
|
+
support Vim editing in their search fields. Each new dialog starts in your
|
|
45
|
+
configured `defaultMode`.
|
|
46
|
+
|
|
47
|
+
- In insert mode, type to filter. `Esc` (or your `kj` mapping) enters normal mode.
|
|
48
|
+
- In normal mode, `j`/`k` move the selected list item. `h`/`l`, `x`, `dw`, `u`,
|
|
49
|
+
and other editing commands act on the search text. `i` returns to insert mode.
|
|
50
|
+
- `Enter` selects the highlighted item; `Esc` in idle normal mode closes or goes
|
|
51
|
+
back. Arrows, Tab, Home/End, and Page Up/Down keep their dialog behavior.
|
|
52
|
+
- Dialog editing has separate mode and undo state from the main prompt.
|
|
53
|
+
|
|
54
|
+
Normal-mode custom mappings take precedence over the dialog `j`/`k` defaults.
|
|
55
|
+
|
|
56
|
+
## Development and testing
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm install
|
|
60
|
+
npm test
|
|
61
|
+
npm run typecheck
|
|
62
|
+
npm run bench
|
|
13
63
|
```
|
|
14
64
|
|
|
15
|
-
|
|
65
|
+
Tests use Bun and OpenTUI's native headless renderer: real textarea input,
|
|
66
|
+
cursor movement, selection, wrapping, attachment extmarks, and the mounted
|
|
67
|
+
plugin footer. They do not contact an OpenCode server or a model. When `nvim`
|
|
68
|
+
is installed, additional tests compare text and cursor results against headless
|
|
69
|
+
Neovim with user configuration disabled.
|
|
70
|
+
|
|
71
|
+
With an OpenCode source checkout and its dependencies installed, you can also
|
|
72
|
+
test against its real dialog component and keyboard routing:
|
|
16
73
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
| `<CR>` in normal mode | Submit the prompt |
|
|
26
|
-
| `/vim` | Toggle Vim mode on or off |
|
|
74
|
+
```sh
|
|
75
|
+
OPENCODE_SOURCE=/path/to/opencode npm test -- test/opencode-dialog.test.tsx
|
|
76
|
+
```
|
|
27
77
|
|
|
28
|
-
|
|
78
|
+
The benchmark measures wrap mapping and normal-mode navigation on prompts of
|
|
79
|
+
1,000, 5,000, and 10,000 characters. Wrapping follows the textarea's visual
|
|
80
|
+
lines; those cases are tested separately from Neovim's logical-line behavior.
|
package/docs/configuration.md
CHANGED
|
@@ -246,12 +246,14 @@ Use an absolute path in config. `~` is not expanded inside `debugPath`.
|
|
|
246
246
|
|
|
247
247
|
### `keymaps`
|
|
248
248
|
|
|
249
|
-
Custom keymaps for
|
|
249
|
+
Custom keymaps for each Vim mode.
|
|
250
250
|
|
|
251
251
|
Allowed modes:
|
|
252
252
|
|
|
253
253
|
- `"insert"`
|
|
254
254
|
- `"normal"`
|
|
255
|
+
- `"visual"`
|
|
256
|
+
- `"visual-line"`
|
|
255
257
|
|
|
256
258
|
Each keymap entry maps a key sequence to an action:
|
|
257
259
|
|
|
@@ -272,6 +274,8 @@ Supported built-in actions:
|
|
|
272
274
|
- `"insert"` enters insert mode.
|
|
273
275
|
- `"submit"` submits the OpenCode prompt.
|
|
274
276
|
|
|
277
|
+
Use `"command:<name>"` to dispatch an active OpenCode command. See [Keymap Actions](./keymap-actions.md) for the full action and command reference.
|
|
278
|
+
|
|
275
279
|
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:
|
|
276
280
|
|
|
277
281
|
```jsonc
|
|
@@ -282,6 +286,10 @@ Any other action string is treated as a Vim key sequence. For example, this maps
|
|
|
282
286
|
}
|
|
283
287
|
```
|
|
284
288
|
|
|
289
|
+
Unlike other keys, `<CR>` in normal mode defaults to `"submit"` when no mapping is configured. A mode-specific mapping overrides that default. In insert mode, an unmapped `<CR>` passes through to OpenCode's `input_submit` and `input_newline` keybinds.
|
|
290
|
+
|
|
291
|
+
When `input_submit` is not `"return"`, map `input_newline` to `"return"` for insert-mode newlines.
|
|
292
|
+
|
|
285
293
|
## Keymap Syntax
|
|
286
294
|
|
|
287
295
|
Key sequences can contain printable ASCII characters, except literal spaces. Use `<Space>` for the space key.
|
|
@@ -307,6 +315,8 @@ Supported special keys:
|
|
|
307
315
|
|
|
308
316
|
Ctrl key names must be lowercase. Use `<C-s>`, not `<C-S>`.
|
|
309
317
|
|
|
318
|
+
`<CR>` can be mapped directly or end a sequence, but cannot start a multi-key sequence.
|
|
319
|
+
|
|
310
320
|
Unsupported examples:
|
|
311
321
|
|
|
312
322
|
```jsonc
|
|
@@ -331,7 +341,7 @@ Use `kj` or `jk` to leave insert mode:
|
|
|
331
341
|
}
|
|
332
342
|
```
|
|
333
343
|
|
|
334
|
-
Submit
|
|
344
|
+
Submit with `<CR>` in normal mode is the default, so this keymap is optional:
|
|
335
345
|
|
|
336
346
|
```jsonc
|
|
337
347
|
"keymaps": {
|
|
@@ -407,7 +417,7 @@ Use a leader-style sequence:
|
|
|
407
417
|
|
|
408
418
|
If a keymap does not work, check these first:
|
|
409
419
|
|
|
410
|
-
- The mode is
|
|
420
|
+
- The mode is `insert`, `normal`, `visual`, or `visual-line`.
|
|
411
421
|
- The key sequence does not contain a literal space.
|
|
412
422
|
- Special keys use one of the supported names exactly.
|
|
413
423
|
- Ctrl keys use lowercase letters, such as `<C-s>`.
|
|
@@ -0,0 +1,213 @@
|
|
|
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"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
```
|
|
12
|
+
|
|
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
|
+
|
|
24
|
+
## Actions
|
|
25
|
+
|
|
26
|
+
| Action | Behavior |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `normal` | Enter normal mode |
|
|
29
|
+
| `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
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
## OpenCode Commands
|
|
37
|
+
|
|
38
|
+
Command mappings use the command ID, not a slash command. For example, use `command:session.new`, not `command:/new`.
|
|
39
|
+
|
|
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.
|
|
41
|
+
|
|
42
|
+
### Application
|
|
43
|
+
|
|
44
|
+
| Command | Behavior |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `command.palette.show` | Open the command palette |
|
|
47
|
+
| `session.list` | Open the session list |
|
|
48
|
+
| `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 |
|
|
52
|
+
| `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
|
+
| `prompt.history.previous` | Load the previous prompt |
|
|
102
|
+
| `prompt.history.next` | Load the next prompt |
|
|
103
|
+
|
|
104
|
+
### Autocomplete
|
|
105
|
+
|
|
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 |
|
|
115
|
+
|
|
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.
|
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.
|
|
4
|
+
"version": "0.0.21",
|
|
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
|
-
|
|
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
|
|
18
|
-
"@opentui/core": "
|
|
19
|
-
"@opentui/
|
|
20
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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.
|
|
37
|
-
"
|
|
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?:
|
|
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:
|
|
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:
|
|
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>
|