opencode-vim 0.0.19 → 0.0.20
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 +14 -2
- package/docs/configuration.md +13 -3
- package/docs/keymap-actions.md +213 -0
- package/package.json +1 -1
- package/src/modules/vim/index.tsx +15 -27
- package/src/modules/vim/map.test.ts +45 -0
- package/src/modules/vim/map.ts +39 -6
- 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 +140 -46
package/README.md
CHANGED
|
@@ -22,7 +22,19 @@ opencode plugin opencode-vim@latest --global
|
|
|
22
22
|
| `x`, `d`, `c`, `y`, `p`, `u`, `<C-r>` | Edit, yank, paste, undo, redo |
|
|
23
23
|
| `v`, `V` | Visual and visual-line selection |
|
|
24
24
|
| `3w`, `diw`, `ci"`, `yiq`, `dip`, `yib` | Counts and text objects |
|
|
25
|
-
|
|
|
25
|
+
| `k`, `j` on an empty prompt | Browse previous and next prompts |
|
|
26
|
+
| `<CR>` in normal mode | Submit the prompt (configurable) |
|
|
26
27
|
| `/vim` | Toggle Vim mode on or off |
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
## Custom Keymaps
|
|
30
|
+
|
|
31
|
+
Keymaps can change modes, submit the prompt, run Vim key sequences, or dispatch active OpenCode commands with the `command:` prefix.
|
|
32
|
+
|
|
33
|
+
```jsonc
|
|
34
|
+
"normal": {
|
|
35
|
+
"Y": "y$",
|
|
36
|
+
"q": "command:session.new"
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
See [Keymap Actions](./docs/keymap-actions.md) for available actions and commands, or [Configuration](./docs/configuration.md) for all options.
|
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
|
@@ -7,6 +7,7 @@ import type { SnippetController } from "../snippets/types"
|
|
|
7
7
|
import { applyVimCursorStyle, focusedInput } from "./actions"
|
|
8
8
|
import type { VimConfig } from "./config"
|
|
9
9
|
import { createVimConfig } from "./config"
|
|
10
|
+
import { displayToChar } from "./map"
|
|
10
11
|
import { keyNotation } from "./keys"
|
|
11
12
|
import { createVimLog } from "./log"
|
|
12
13
|
import type { VimLog } from "./log"
|
|
@@ -38,17 +39,9 @@ export function createVimModule(options?: unknown, enabled: Accessor<boolean> =
|
|
|
38
39
|
function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; snippets?: SnippetController; enabled: Accessor<boolean>; log: VimLog }) {
|
|
39
40
|
let cursorStyleMode = ""
|
|
40
41
|
const vimee = createVimeeAdapter(props.state, props.config, props.log)
|
|
41
|
-
const preparedEvents = new WeakSet<KeyEvent>()
|
|
42
42
|
props.log("keyboard.mount", { kind: props.ctx.kind })
|
|
43
43
|
|
|
44
44
|
const cursorStyleTimer = setInterval(syncCursorStyle, 50)
|
|
45
|
-
const offKeyIntercept = props.ctx.api.keymap.intercept("key", ({ event }) => {
|
|
46
|
-
if (!canHandleKeys(props)) return
|
|
47
|
-
const key = keyNotation(event)
|
|
48
|
-
if (!key || !passThroughKey(event, key, props.state.mode())) return
|
|
49
|
-
if (preparePassThroughKey(props.ctx, key, props.state.mode())) preparedEvents.add(event)
|
|
50
|
-
}, { priority: 100 })
|
|
51
|
-
|
|
52
45
|
const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
|
|
53
46
|
props.log("keyboard.event", {
|
|
54
47
|
name: event.name,
|
|
@@ -72,10 +65,7 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
|
|
|
72
65
|
return
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
if (passThroughKey(event, key, props.state.mode()))
|
|
76
|
-
if (!preparedEvents.delete(event)) preparePassThroughKey(props.ctx, key, props.state.mode())
|
|
77
|
-
return
|
|
78
|
-
}
|
|
68
|
+
if (passThroughKey(event, key, props.state.mode())) return
|
|
79
69
|
|
|
80
70
|
if (key === "<Esc>" && props.state.mode() === "normal" && !props.state.pending()) {
|
|
81
71
|
props.log("keyboard.fallthrough", { key, mode: props.state.mode() })
|
|
@@ -88,10 +78,17 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
|
|
|
88
78
|
return
|
|
89
79
|
}
|
|
90
80
|
|
|
91
|
-
const
|
|
81
|
+
const modeBefore = props.state.mode()
|
|
82
|
+
let consumed = false
|
|
83
|
+
try {
|
|
84
|
+
consumed = vimee.handle(event, key, props.ctx)
|
|
85
|
+
} finally {
|
|
86
|
+
if (consumed || props.state.mode() !== modeBefore) {
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
event.stopPropagation()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
92
91
|
if (consumed) {
|
|
93
|
-
event.preventDefault()
|
|
94
|
-
event.stopPropagation()
|
|
95
92
|
syncCursorStyle(true)
|
|
96
93
|
props.ctx.requestRender()
|
|
97
94
|
}
|
|
@@ -99,7 +96,6 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
|
|
|
99
96
|
|
|
100
97
|
onCleanup(() => {
|
|
101
98
|
props.log("keyboard.cleanup", { kind: props.ctx.kind })
|
|
102
|
-
offKeyIntercept()
|
|
103
99
|
offKeyboard()
|
|
104
100
|
vimee.cleanup()
|
|
105
101
|
clearInterval(cursorStyleTimer)
|
|
@@ -128,15 +124,7 @@ function readablePending(sequence: string) {
|
|
|
128
124
|
|
|
129
125
|
function passThroughKey(event: KeyEvent, key: string, mode: string) {
|
|
130
126
|
if (mode !== "normal") return false
|
|
131
|
-
return event.super === true || isArrowKey(key) || key === "<
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) {
|
|
135
|
-
if (mode !== "normal" || key !== "<CR>") return false
|
|
136
|
-
const input = focusedInput(ctx)
|
|
137
|
-
if (!input?.plainText || input.cursorOffset === undefined) return false
|
|
138
|
-
input.cursorOffset = Math.min(input.cursorOffset + 1, input.plainText.length)
|
|
139
|
-
return true
|
|
127
|
+
return event.super === true || isArrowKey(key) || key === "<C-c>"
|
|
140
128
|
}
|
|
141
129
|
|
|
142
130
|
function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) {
|
|
@@ -178,8 +166,8 @@ function isPromptFocused(ctx: PromptContext) {
|
|
|
178
166
|
function isNativeCompletionToken(ctx: PromptContext) {
|
|
179
167
|
const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? ""
|
|
180
168
|
const input = focusedInput(ctx)
|
|
181
|
-
const
|
|
182
|
-
const beforeCursor = text.slice(0, Math.min(
|
|
169
|
+
const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
|
|
170
|
+
const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length))
|
|
183
171
|
return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor)
|
|
184
172
|
}
|
|
185
173
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { charToDisplay, displayToChar, displayWidth } from "./map"
|
|
3
|
+
|
|
4
|
+
describe("vim display offsets", () => {
|
|
5
|
+
test("maps ASCII and CJK offsets", () => {
|
|
6
|
+
const text = "a中b"
|
|
7
|
+
|
|
8
|
+
expect(displayWidth(text)).toBe(4)
|
|
9
|
+
expect(charToDisplay(text, 2)).toBe(3)
|
|
10
|
+
expect(displayToChar(text, 2)).toBe(1)
|
|
11
|
+
expect(displayToChar(text, 3)).toBe(2)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("does not split supplementary characters", () => {
|
|
15
|
+
const text = "𠀀x"
|
|
16
|
+
|
|
17
|
+
expect(displayWidth(text)).toBe(3)
|
|
18
|
+
expect(charToDisplay(text, 1)).toBe(0)
|
|
19
|
+
expect(charToDisplay(text, 2)).toBe(2)
|
|
20
|
+
expect(displayToChar(text, 1)).toBe(0)
|
|
21
|
+
expect(displayToChar(text, 2)).toBe(2)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("does not split grapheme clusters", () => {
|
|
25
|
+
const emoji = "👨👩👧👦"
|
|
26
|
+
const accent = "e\u0301"
|
|
27
|
+
|
|
28
|
+
expect(displayWidth(emoji)).toBe(2)
|
|
29
|
+
expect(charToDisplay(emoji, 1)).toBe(0)
|
|
30
|
+
expect(displayToChar(emoji, 1)).toBe(0)
|
|
31
|
+
expect(displayToChar(emoji, 2)).toBe(emoji.length)
|
|
32
|
+
expect(displayWidth(accent)).toBe(1)
|
|
33
|
+
expect(charToDisplay(accent, 1)).toBe(0)
|
|
34
|
+
expect(displayToChar(accent, 1)).toBe(accent.length)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("counts newlines as textarea offsets", () => {
|
|
38
|
+
const text = "中\nx"
|
|
39
|
+
|
|
40
|
+
expect(displayWidth(text)).toBe(4)
|
|
41
|
+
expect(displayToChar(text, 2)).toBe(1)
|
|
42
|
+
expect(displayToChar(text, 3)).toBe(2)
|
|
43
|
+
expect(charToDisplay(text, 2)).toBe(3)
|
|
44
|
+
})
|
|
45
|
+
})
|
package/src/modules/vim/map.ts
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
import type { CursorPosition } from "@vimee/core"
|
|
2
2
|
import type { EditBufferLike } from "./actions"
|
|
3
3
|
|
|
4
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
5
|
+
|
|
6
|
+
function graphemeWidth(value: string) {
|
|
7
|
+
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
|
8
|
+
return value === "\n" ? 1 : Bun.stringWidth(value)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function charToDisplay(text: string, charIndex: number): number {
|
|
12
|
+
let width = 0
|
|
13
|
+
const limit = Math.max(0, Math.min(charIndex, text.length))
|
|
14
|
+
for (const part of graphemes.segment(text)) {
|
|
15
|
+
if (part.index + part.segment.length > limit) break
|
|
16
|
+
width += graphemeWidth(part.segment)
|
|
17
|
+
}
|
|
18
|
+
return width
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function displayToChar(text: string, displayOffset: number): number {
|
|
22
|
+
let width = 0
|
|
23
|
+
for (const part of graphemes.segment(text)) {
|
|
24
|
+
const next = width + graphemeWidth(part.segment)
|
|
25
|
+
if (next > displayOffset) return part.index
|
|
26
|
+
width = next
|
|
27
|
+
}
|
|
28
|
+
return text.length
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function displayWidth(text: string): number {
|
|
32
|
+
return charToDisplay(text, text.length)
|
|
33
|
+
}
|
|
34
|
+
|
|
4
35
|
export type PromptMap = {
|
|
5
36
|
hostText: string
|
|
6
37
|
vimText: string
|
|
@@ -37,12 +68,14 @@ function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vi
|
|
|
37
68
|
return vimOffset !== prefix - 1 && vimOffset !== vimLength - suffix
|
|
38
69
|
}
|
|
39
70
|
|
|
40
|
-
export function hostPosition(map: PromptMap,
|
|
41
|
-
|
|
71
|
+
export function hostPosition(map: PromptMap, hostDisplayOffset: number): CursorPosition {
|
|
72
|
+
const charIdx = displayToChar(map.hostText, hostDisplayOffset)
|
|
73
|
+
return positionFromOffset(map.vimText, map.hostToVim[clamp(charIdx, 0, map.hostText.length)] ?? 0)
|
|
42
74
|
}
|
|
43
75
|
|
|
44
76
|
export function hostOffset(map: PromptMap, position: CursorPosition, bias: "previous" | "next" = "next") {
|
|
45
|
-
|
|
77
|
+
const charIdx = hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
|
|
78
|
+
return charToDisplay(map.hostText, charIdx)
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
|
|
@@ -99,14 +132,14 @@ function visualWrapOffsets(input: EditBufferLike, text: string) {
|
|
|
99
132
|
const wraps: number[] = []
|
|
100
133
|
let previousRow: number | undefined
|
|
101
134
|
|
|
102
|
-
for (let
|
|
103
|
-
input.cursorOffset =
|
|
135
|
+
for (let charIdx = 0; charIdx <= text.length; charIdx++) {
|
|
136
|
+
input.cursorOffset = charToDisplay(text, charIdx)
|
|
104
137
|
const row = input.visualCursor?.visualRow
|
|
105
138
|
if (row === undefined) {
|
|
106
139
|
wraps.length = 0
|
|
107
140
|
break
|
|
108
141
|
}
|
|
109
|
-
if (previousRow !== undefined && row > previousRow && text[
|
|
142
|
+
if (previousRow !== undefined && row > previousRow && text[charIdx - 1] !== "\n") wraps.push(charIdx)
|
|
110
143
|
previousRow = row
|
|
111
144
|
}
|
|
112
145
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createVimState } from "./state"
|
|
3
|
+
|
|
4
|
+
describe("vim state", () => {
|
|
5
|
+
test("isolates listener errors", () => {
|
|
6
|
+
const events: string[] = []
|
|
7
|
+
const state = createVimState("normal", (event) => events.push(event))
|
|
8
|
+
state.subscribe(() => {
|
|
9
|
+
throw new Error("stale renderable")
|
|
10
|
+
})
|
|
11
|
+
state.subscribe(() => events.push("listener.complete"))
|
|
12
|
+
|
|
13
|
+
expect(() => state.setMode("insert")).not.toThrow()
|
|
14
|
+
expect(state.mode()).toBe("insert")
|
|
15
|
+
expect(events).toContain("state.listener.error")
|
|
16
|
+
expect(events).toContain("listener.complete")
|
|
17
|
+
})
|
|
18
|
+
})
|
package/src/modules/vim/state.ts
CHANGED
|
@@ -33,6 +33,12 @@ export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function notify() {
|
|
36
|
-
for (const listener of listeners)
|
|
36
|
+
for (const listener of listeners) {
|
|
37
|
+
try {
|
|
38
|
+
listener()
|
|
39
|
+
} catch (error) {
|
|
40
|
+
log("state.listener.error", { error: error instanceof Error ? error.message : String(error) })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
37
43
|
}
|
|
38
44
|
}
|
package/src/modules/vim/view.tsx
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { KeyEvent } from "@opentui/core"
|
|
3
|
+
import type { PromptContext } from "../../prompt/types"
|
|
4
|
+
import { createVimConfig } from "./config"
|
|
5
|
+
import type { VimLog } from "./log"
|
|
6
|
+
import { createVimState, type VimMode } from "./state"
|
|
7
|
+
import { createVimeeAdapter } from "./vimee"
|
|
8
|
+
|
|
9
|
+
describe("vim command keymaps", () => {
|
|
10
|
+
test("does not restore stale cursor state after a normal command", () => {
|
|
11
|
+
const fixture = createFixture("normal", "command:test.run")
|
|
12
|
+
fixture.dispatch = () => {
|
|
13
|
+
fixture.input.plainText = "updated"
|
|
14
|
+
fixture.input.cursorOffset = 7
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
expect(fixture.handle()).toBe(true)
|
|
18
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
19
|
+
expect(fixture.input.cursorOffset).toBe(7)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("uses the host history boundary and restores display width", () => {
|
|
23
|
+
const fixture = createFixture("normal", "command:prompt.history.next", "中")
|
|
24
|
+
let boundary = -1
|
|
25
|
+
fixture.dispatch = () => {
|
|
26
|
+
boundary = fixture.input.cursorOffset
|
|
27
|
+
fixture.input.plainText = "中中"
|
|
28
|
+
fixture.input.cursorOffset = 2
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
expect(fixture.handle()).toBe(true)
|
|
32
|
+
expect(boundary).toBe(1)
|
|
33
|
+
expect(fixture.input.cursorOffset).toBe(4)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("dispatches commands in insert mode", () => {
|
|
37
|
+
const fixture = createFixture("insert", "command:test.run")
|
|
38
|
+
|
|
39
|
+
expect(fixture.handle()).toBe(true)
|
|
40
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("rejects an empty command name", () => {
|
|
44
|
+
const logs: Array<[string, unknown]> = []
|
|
45
|
+
createFixture("normal", "command: ", "", (event, data) => logs.push([event, data]))
|
|
46
|
+
|
|
47
|
+
expect(logs.some(([event]) => event === "vimee.keymap.invalid")).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe("vim enter keymaps", () => {
|
|
52
|
+
test("submits unmapped Enter in normal mode without moving the cursor", () => {
|
|
53
|
+
const fixture = createFixture("normal", undefined, "中")
|
|
54
|
+
fixture.input.cursorOffset = 2
|
|
55
|
+
|
|
56
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
57
|
+
expect(fixture.submissions).toHaveLength(1)
|
|
58
|
+
expect(fixture.input.cursorOffset).toBe(2)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("passes unmapped Enter through in insert mode", () => {
|
|
62
|
+
const fixture = createFixture("insert", undefined)
|
|
63
|
+
|
|
64
|
+
expect(fixture.handle("<CR>")).toBe(false)
|
|
65
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("does not submit a matched mapping with no immediate actions", () => {
|
|
69
|
+
const fixture = createFixture("normal", "d", "text", () => {}, "<CR>")
|
|
70
|
+
|
|
71
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
72
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("runs an Enter command mapping", () => {
|
|
76
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "<CR>")
|
|
77
|
+
|
|
78
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
79
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
80
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("applies mode and submit mappings", () => {
|
|
84
|
+
const normal = createFixture("normal", "insert", "", () => {}, "<CR>")
|
|
85
|
+
const insert = createFixture("insert", "submit", "", () => {}, "<CR>")
|
|
86
|
+
|
|
87
|
+
expect(normal.handle("<CR>")).toBe(true)
|
|
88
|
+
expect(normal.handle("a")).toBe(false)
|
|
89
|
+
expect(insert.handle("<CR>")).toBe(true)
|
|
90
|
+
expect(insert.submissions).toHaveLength(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("allows Enter to finish a multi-key mapping", () => {
|
|
94
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "g<CR>")
|
|
95
|
+
|
|
96
|
+
expect(fixture.handle("g")).toBe(true)
|
|
97
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
98
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
99
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("rejects mappings that defer Enter", () => {
|
|
103
|
+
const logs: Array<[string, unknown]> = []
|
|
104
|
+
const fixture = createFixture("normal", "command:test.run", "", (event, data) => logs.push([event, data]), "<CR>x")
|
|
105
|
+
|
|
106
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
107
|
+
expect(fixture.submissions).toHaveLength(1)
|
|
108
|
+
expect(logs.some(([event]) => event === "vimee.keymap.invalid")).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("vim prompt history", () => {
|
|
113
|
+
test("starts from an empty prompt and continues through history", () => {
|
|
114
|
+
const fixture = createFixture("normal", undefined, "")
|
|
115
|
+
const entries = ["newest", "older", ""]
|
|
116
|
+
fixture.dispatch = (command) => {
|
|
117
|
+
fixture.input.plainText = entries.shift() ?? ""
|
|
118
|
+
fixture.input.cursorOffset = command === "prompt.history.previous" ? 0 : fixture.input.plainText.length
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
expect(fixture.handle("k")).toBe(true)
|
|
122
|
+
expect(fixture.handle("k")).toBe(true)
|
|
123
|
+
expect(fixture.handle("j")).toBe(true)
|
|
124
|
+
expect(fixture.commands).toEqual([
|
|
125
|
+
"prompt.history.previous",
|
|
126
|
+
"prompt.history.previous",
|
|
127
|
+
"prompt.history.next",
|
|
128
|
+
])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("keeps normal movement for a nonempty prompt", () => {
|
|
132
|
+
const fixture = createFixture("normal", undefined, "text")
|
|
133
|
+
|
|
134
|
+
expect(fixture.handle("k")).toBe(true)
|
|
135
|
+
expect(fixture.handle("j")).toBe(true)
|
|
136
|
+
expect(fixture.commands).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("stops history navigation after another normal key", () => {
|
|
140
|
+
const fixture = createFixture("normal", undefined, "")
|
|
141
|
+
fixture.dispatch = () => {
|
|
142
|
+
fixture.input.plainText = "recalled"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
expect(fixture.handle("k")).toBe(true)
|
|
146
|
+
expect(fixture.handle("h")).toBe(true)
|
|
147
|
+
expect(fixture.handle("k")).toBe(true)
|
|
148
|
+
expect(fixture.commands).toEqual(["prompt.history.previous"])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("prefers configured keymaps", () => {
|
|
152
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "k")
|
|
153
|
+
|
|
154
|
+
expect(fixture.handle()).toBe(true)
|
|
155
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("completes a pending keymap before history navigation", () => {
|
|
159
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "gk")
|
|
160
|
+
|
|
161
|
+
expect(fixture.handle("g")).toBe(true)
|
|
162
|
+
expect(fixture.handle("k")).toBe(true)
|
|
163
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
function createFixture(mode: VimMode, action: string | undefined, text = "text", log: VimLog = () => {}, mappedKey = "Q") {
|
|
168
|
+
const input = {
|
|
169
|
+
plainText: text,
|
|
170
|
+
cursorOffset: 0,
|
|
171
|
+
visualCursor: { visualRow: 0, visualCol: 0, offset: 0 },
|
|
172
|
+
moveCursorLeft: () => false,
|
|
173
|
+
}
|
|
174
|
+
const commands: string[] = []
|
|
175
|
+
const submissions: true[] = []
|
|
176
|
+
const prompt = {
|
|
177
|
+
current: { input: text, mode: "normal", parts: [] },
|
|
178
|
+
set() {},
|
|
179
|
+
submit() {
|
|
180
|
+
submissions.push(true)
|
|
181
|
+
},
|
|
182
|
+
blur() {},
|
|
183
|
+
}
|
|
184
|
+
const fixture = {
|
|
185
|
+
input,
|
|
186
|
+
commands,
|
|
187
|
+
submissions,
|
|
188
|
+
dispatch: (_command: string) => {},
|
|
189
|
+
handle: (key = mappedKey) => adapter.handle({ name: key } as KeyEvent, key, ctx),
|
|
190
|
+
}
|
|
191
|
+
const ctx = {
|
|
192
|
+
api: {
|
|
193
|
+
renderer: { currentFocusedRenderable: input },
|
|
194
|
+
keymap: {
|
|
195
|
+
dispatchCommand(command: string) {
|
|
196
|
+
commands.push(command)
|
|
197
|
+
fixture.dispatch(command)
|
|
198
|
+
return { ok: true as const }
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
prompt: () => prompt,
|
|
203
|
+
requestRender() {},
|
|
204
|
+
} as unknown as PromptContext
|
|
205
|
+
const config = createVimConfig({
|
|
206
|
+
defaultMode: mode,
|
|
207
|
+
keymaps: action ? { [mode]: { [mappedKey]: action } } : undefined,
|
|
208
|
+
})
|
|
209
|
+
const adapter = createVimeeAdapter(createVimState(mode), config, log)
|
|
210
|
+
return fixture
|
|
211
|
+
}
|
package/src/modules/vim/vimee.ts
CHANGED
|
@@ -5,13 +5,13 @@ import type { PromptContext } from "../../prompt/types"
|
|
|
5
5
|
import { focusedInput, setInput, type EditBufferLike } from "./actions"
|
|
6
6
|
import type { VimConfig } from "./config"
|
|
7
7
|
import type { VimLog } from "./log"
|
|
8
|
-
import { createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
|
|
8
|
+
import { charToDisplay, displayToChar, displayWidth, createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
|
|
9
9
|
import type { createVimState } from "./state"
|
|
10
10
|
|
|
11
11
|
type VimState = ReturnType<typeof createVimState>
|
|
12
|
-
type HostAction = VimeeAction | { type: "submit" }
|
|
13
|
-
type HostKeybindAction = "normal" | "submit"
|
|
14
|
-
type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction }
|
|
12
|
+
type HostAction = VimeeAction | { type: "submit" } | { type: "command"; command: string }
|
|
13
|
+
type HostKeybindAction = "normal" | "submit" | "command"
|
|
14
|
+
type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction; command?: string }
|
|
15
15
|
type HostRange = { start: number; end: number }
|
|
16
16
|
|
|
17
17
|
const YANK_FLASH_MS = 250
|
|
@@ -29,6 +29,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
29
29
|
let yankFlashActive = false
|
|
30
30
|
let pendingInsert = ""
|
|
31
31
|
let nativeInsertUndoSaved = false
|
|
32
|
+
let historyText: string | undefined
|
|
33
|
+
const defaultHistoryKeys = {
|
|
34
|
+
k: !hasNormalKeyPrefix(config, "k"),
|
|
35
|
+
j: !hasNormalKeyPrefix(config, "j"),
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
return {
|
|
34
39
|
handle(event: KeyEvent, key: string, ctx: PromptContext) {
|
|
@@ -38,16 +43,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
38
43
|
let vimeeKey = keyForVimee(event, key)
|
|
39
44
|
if (!vimeeKey) return false
|
|
40
45
|
|
|
41
|
-
if (state.mode() === "normal" && key === "<CR>") {
|
|
42
|
-
ref.submit()
|
|
43
|
-
return true
|
|
44
|
-
}
|
|
45
|
-
|
|
46
46
|
if (state.mode() === "insert") {
|
|
47
|
-
if (key === "<CR>") {
|
|
48
|
-
cancelPendingInsert(ctx)
|
|
49
|
-
return false
|
|
50
|
-
}
|
|
51
47
|
if (vimeeKey === "Escape") {
|
|
52
48
|
cancelPendingInsert(ctx, undefined, false)
|
|
53
49
|
enterNormal(ctx)
|
|
@@ -61,10 +57,24 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
61
57
|
|
|
62
58
|
const input = focusedInput(ctx)
|
|
63
59
|
const text = input?.plainText ?? ref.current.input
|
|
64
|
-
const
|
|
60
|
+
const dw = displayWidth(text)
|
|
61
|
+
const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
|
|
62
|
+
const charOff = displayToChar(text, displayOff)
|
|
63
|
+
|
|
64
|
+
const canBrowseHistory = vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
|
|
65
|
+
const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined
|
|
66
|
+
if (historyCommand) {
|
|
67
|
+
const result = dispatchCommand(historyCommand, ctx)
|
|
68
|
+
historyText = result.ok ? (focusedInput(ctx)?.plainText ?? ref.current.input) : undefined
|
|
69
|
+
state.setPending("")
|
|
70
|
+
updateTimeout(ctx)
|
|
71
|
+
log("vimee.history", { key, command: historyCommand, handled: result.ok })
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
historyText = undefined
|
|
65
75
|
|
|
66
76
|
const map = mapForHostText(text, input)
|
|
67
|
-
const cursor = hostPosition(map,
|
|
77
|
+
const cursor = hostPosition(map, displayOff)
|
|
68
78
|
|
|
69
79
|
const wasPending = keybinds?.isPending() ?? false
|
|
70
80
|
const pendingBefore = pendingInsert
|
|
@@ -77,35 +87,60 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
77
87
|
return true
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText,
|
|
90
|
+
const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, charOff, vim.count || 1) : undefined
|
|
81
91
|
const shouldFlashYank = shouldFlashYankFor(vimeeKey)
|
|
82
92
|
const visualYankRange = visualYankRangeFor(map)
|
|
83
93
|
vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
|
|
84
94
|
const textObjectHandled = handleTextObject(vimeeKey, ctx, map)
|
|
85
95
|
if (textObjectHandled !== undefined) return textObjectHandled
|
|
86
|
-
const
|
|
96
|
+
const resolved = keybinds?.resolve(vimeeKey, vim.mode, event.ctrl)
|
|
97
|
+
if (resolved?.status === "pending") {
|
|
98
|
+
vim = { ...vim, statusMessage: resolved.display }
|
|
99
|
+
state.setPending(resolved.display)
|
|
100
|
+
updateTimeout(ctx)
|
|
101
|
+
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: [] })
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
if (vimeeKey === "Enter" && state.mode() === "normal" && vim.phase === "idle" && !wasPending && resolved?.status !== "matched") {
|
|
105
|
+
ref.submit()
|
|
106
|
+
state.setPending("")
|
|
107
|
+
updateTimeout(ctx)
|
|
108
|
+
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["submit"] })
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let result
|
|
113
|
+
if (resolved?.status === "matched") {
|
|
114
|
+
const actions = applyKeybind(resolved.definition, map)
|
|
115
|
+
result = { newCtx: vim, actions }
|
|
116
|
+
} else {
|
|
117
|
+
result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false)
|
|
118
|
+
}
|
|
87
119
|
vim = result.newCtx
|
|
88
|
-
|
|
120
|
+
const actions = result.actions as HostAction[]
|
|
121
|
+
if (hostEnd !== undefined && actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
|
|
89
122
|
let clampFinalCursor = true
|
|
90
|
-
const content =
|
|
123
|
+
const content = actions.find((action) => action.type === "content-change")?.content
|
|
91
124
|
if (vimeeKey === "x" && content !== undefined) {
|
|
92
125
|
const next = nextMap(map, content)
|
|
93
|
-
const
|
|
94
|
-
|
|
126
|
+
const nextDW = displayWidth(next.hostText)
|
|
127
|
+
const target = clamp(displayOff, 0, Math.max(0, nextDW - 1))
|
|
128
|
+
if (charOff < map.hostText.length - 1 && map.hostText[charOff + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
|
|
95
129
|
vim = { ...vim, cursor: hostPosition(next, target) }
|
|
96
130
|
clampFinalCursor = false
|
|
97
131
|
}
|
|
98
132
|
}
|
|
99
|
-
applyActions(
|
|
100
|
-
if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(
|
|
133
|
+
applyActions(actions, ctx, map, clampFinalCursor)
|
|
134
|
+
if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange)
|
|
101
135
|
syncMode(state, vim.mode)
|
|
136
|
+
|
|
102
137
|
const keybindPending = keybinds?.isPending() ?? false
|
|
103
|
-
if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore,
|
|
138
|
+
if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
|
|
104
139
|
pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
|
|
105
140
|
state.setPending(pendingDisplay(vim, keybindPending))
|
|
106
141
|
updateTimeout(ctx)
|
|
107
|
-
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions:
|
|
108
|
-
return consumesKey(vimeeKey,
|
|
142
|
+
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
|
|
143
|
+
return consumesKey(vimeeKey, actions, vim, keybindPending)
|
|
109
144
|
},
|
|
110
145
|
cleanup() {
|
|
111
146
|
if (timer) clearTimeout(timer)
|
|
@@ -175,6 +210,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
175
210
|
case "submit":
|
|
176
211
|
ref.submit()
|
|
177
212
|
break
|
|
213
|
+
case "command":
|
|
214
|
+
dispatchCommand(action.command, ctx)
|
|
215
|
+
return
|
|
178
216
|
}
|
|
179
217
|
}
|
|
180
218
|
|
|
@@ -228,6 +266,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
228
266
|
case "submit":
|
|
229
267
|
ctx.prompt()?.submit()
|
|
230
268
|
return true
|
|
269
|
+
case "command":
|
|
270
|
+
dispatchCommand((definition as HostKeybindDefinition).command!, ctx)
|
|
271
|
+
return true
|
|
231
272
|
default:
|
|
232
273
|
return false
|
|
233
274
|
}
|
|
@@ -237,9 +278,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
237
278
|
const ref = ctx.prompt()
|
|
238
279
|
const input = focusedInput(ctx)
|
|
239
280
|
const text = input?.plainText ?? ref?.current.input ?? ""
|
|
240
|
-
const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
|
|
241
281
|
|
|
242
282
|
if (input && text.length > 0) {
|
|
283
|
+
const dw = displayWidth(text)
|
|
284
|
+
const offset = clamp(input?.cursorOffset ?? dw, 0, dw)
|
|
243
285
|
input.cursorOffset = Math.max(0, offset - 1)
|
|
244
286
|
clampNormalCursor(input)
|
|
245
287
|
}
|
|
@@ -329,18 +371,19 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
329
371
|
state.setPending("")
|
|
330
372
|
}
|
|
331
373
|
|
|
332
|
-
function flushPendingInsert(ctx: PromptContext, value: string,
|
|
374
|
+
function flushPendingInsert(ctx: PromptContext, value: string, charOffset?: number) {
|
|
333
375
|
if (!value || state.mode() !== "insert") return
|
|
334
376
|
const ref = ctx.prompt()
|
|
335
377
|
if (!ref) return
|
|
336
378
|
const input = focusedInput(ctx)
|
|
337
379
|
const text = input?.plainText ?? ref.current.input
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
const
|
|
380
|
+
const currentDisplayOff = input?.cursorOffset ?? 0
|
|
381
|
+
const currentCharOff = displayToChar(text, currentDisplayOff)
|
|
382
|
+
const insertAtChar = charOffset ?? currentCharOff
|
|
383
|
+
const next = text.slice(0, insertAtChar) + value + text.slice(insertAtChar)
|
|
341
384
|
setInput(ref, next)
|
|
342
|
-
const
|
|
343
|
-
if (input) input.cursorOffset =
|
|
385
|
+
const nextCharOff = currentCharOff >= insertAtChar ? currentCharOff + value.length : currentCharOff
|
|
386
|
+
if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff))
|
|
344
387
|
}
|
|
345
388
|
|
|
346
389
|
function cursorOffset(map: PromptMap, position: CursorPosition) {
|
|
@@ -357,7 +400,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
357
400
|
if (!input?.gotoVisualLineEnd) return false
|
|
358
401
|
clearVisualSelection(input)
|
|
359
402
|
input.gotoVisualLineEnd()
|
|
360
|
-
vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? map.hostText
|
|
403
|
+
vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? displayWidth(map.hostText)), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
|
|
361
404
|
nativeInsertUndoSaved = false
|
|
362
405
|
syncMode(state, "insert")
|
|
363
406
|
return true
|
|
@@ -457,14 +500,15 @@ function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange
|
|
|
457
500
|
|
|
458
501
|
function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined {
|
|
459
502
|
if (!map.hostText) return undefined
|
|
460
|
-
const
|
|
461
|
-
const
|
|
503
|
+
const dw = displayWidth(map.hostText)
|
|
504
|
+
const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1))
|
|
505
|
+
const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1))
|
|
462
506
|
return { start, end }
|
|
463
507
|
}
|
|
464
508
|
|
|
465
509
|
type YankAction = Extract<VimeeAction, { type: "yank" }>
|
|
466
510
|
|
|
467
|
-
function yankAction(actions:
|
|
511
|
+
function yankAction(actions: HostAction[]): YankAction | undefined {
|
|
468
512
|
return actions.find((action): action is YankAction => action.type === "yank")
|
|
469
513
|
}
|
|
470
514
|
|
|
@@ -521,32 +565,71 @@ function vimOffsetFromPosition(text: string, position: CursorPosition) {
|
|
|
521
565
|
|
|
522
566
|
function hostFromVimOffset(map: PromptMap, offset: number, bias: "previous" | "next") {
|
|
523
567
|
const current = clamp(offset, 0, map.vimText.length)
|
|
524
|
-
if (current === map.vimText.length) return map.hostText
|
|
568
|
+
if (current === map.vimText.length) return displayWidth(map.hostText)
|
|
525
569
|
|
|
526
570
|
const host = map.vimToHost[current]
|
|
527
|
-
if (host !== undefined) return host
|
|
571
|
+
if (host !== undefined) return charToDisplay(map.hostText, host)
|
|
528
572
|
|
|
529
573
|
if (bias === "previous") {
|
|
530
574
|
for (let previous = current - 1; previous >= 0; previous--) {
|
|
531
575
|
const previousHost = map.vimToHost[previous]
|
|
532
|
-
if (previousHost !== undefined) return previousHost
|
|
576
|
+
if (previousHost !== undefined) return charToDisplay(map.hostText, previousHost)
|
|
533
577
|
}
|
|
534
578
|
}
|
|
535
579
|
|
|
536
580
|
for (let next = current + 1; next < map.vimToHost.length; next++) {
|
|
537
581
|
const nextHost = map.vimToHost[next]
|
|
538
|
-
if (nextHost !== undefined) return nextHost
|
|
582
|
+
if (nextHost !== undefined) return charToDisplay(map.hostText, nextHost)
|
|
539
583
|
}
|
|
540
|
-
return map.hostText
|
|
584
|
+
return displayWidth(map.hostText)
|
|
541
585
|
}
|
|
542
586
|
|
|
543
587
|
function clampNormalCursor(input: EditBufferLike) {
|
|
544
588
|
const cursor = input.visualCursor
|
|
545
|
-
const eol = input.editorView?.getVisualEOL?.()
|
|
546
589
|
const offset = input.cursorOffset
|
|
547
|
-
|
|
590
|
+
const text = input.plainText
|
|
591
|
+
if (!cursor || offset === undefined || text === undefined) return
|
|
548
592
|
if (cursor.visualCol === 0) return
|
|
549
|
-
|
|
593
|
+
const dw = displayWidth(text)
|
|
594
|
+
if (offset >= dw) {
|
|
595
|
+
input.cursorOffset = Math.max(0, dw - 1)
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
const charIdx = displayToChar(text, offset)
|
|
599
|
+
if (charIdx < text.length && text[charIdx] === '\n') {
|
|
600
|
+
input.cursorOffset = Math.max(0, offset - 1)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function dispatchCommand(command: string, ctx: PromptContext) {
|
|
605
|
+
const input = focusedInput(ctx)
|
|
606
|
+
if (command === "prompt.history.previous" && input) input.cursorOffset = 0
|
|
607
|
+
if (command === "prompt.history.next" && input) input.cursorOffset = input.plainText?.length ?? 0
|
|
608
|
+
|
|
609
|
+
const result = ctx.api.keymap.dispatchCommand(command)
|
|
610
|
+
|
|
611
|
+
// The host currently checks history-next using character length, then leaves
|
|
612
|
+
// the cursor in that unit. Restore the display-width offset after dispatch.
|
|
613
|
+
if (command !== "prompt.history.next" || !result.ok) return result
|
|
614
|
+
const nextInput = focusedInput(ctx)
|
|
615
|
+
if (nextInput?.plainText !== undefined) nextInput.cursorOffset = displayWidth(nextInput.plainText)
|
|
616
|
+
return result
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function defaultHistoryCommand(key: string, text: string, historyText: string | undefined, enabled: Record<"j" | "k", boolean>) {
|
|
620
|
+
if (key !== "j" && key !== "k") return
|
|
621
|
+
if (!enabled[key]) return
|
|
622
|
+
if (text.length > 0 && text !== historyText) return
|
|
623
|
+
return key === "k" ? "prompt.history.previous" : "prompt.history.next"
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function hasNormalKeyPrefix(config: VimConfig, key: "j" | "k") {
|
|
627
|
+
for (const sequence of Object.keys(config.keymaps.normal ?? {})) {
|
|
628
|
+
try {
|
|
629
|
+
if (keyToken(parseKeySequence(sequence)[0] ?? "") === key) return true
|
|
630
|
+
} catch {}
|
|
631
|
+
}
|
|
632
|
+
return false
|
|
550
633
|
}
|
|
551
634
|
|
|
552
635
|
function endMotionOffset(text: string, offset: number, count: number) {
|
|
@@ -578,6 +661,8 @@ function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined
|
|
|
578
661
|
if (!keymaps) continue
|
|
579
662
|
for (const [keys, action] of Object.entries(keymaps)) {
|
|
580
663
|
try {
|
|
664
|
+
const tokens = parseKeySequence(keys)
|
|
665
|
+
if (tokens.length > 1 && tokens[0] === "<CR>") throw new Error("<CR> cannot start a multi-key mapping")
|
|
581
666
|
map.addKeybind(mode, keys as ValidKeySequence<typeof keys>, keybindAction(action))
|
|
582
667
|
count++
|
|
583
668
|
} catch (error) {
|
|
@@ -590,6 +675,15 @@ function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined
|
|
|
590
675
|
}
|
|
591
676
|
|
|
592
677
|
function keybindAction(action: string): HostKeybindDefinition {
|
|
678
|
+
if (action.startsWith("command:")) {
|
|
679
|
+
const command = action.slice(8).trim()
|
|
680
|
+
if (!command) throw new Error("Command name is required")
|
|
681
|
+
return {
|
|
682
|
+
execute: () => [{ type: "command", command } as unknown as VimeeAction],
|
|
683
|
+
hostAction: "command",
|
|
684
|
+
command,
|
|
685
|
+
}
|
|
686
|
+
}
|
|
593
687
|
switch (action) {
|
|
594
688
|
case "normal":
|
|
595
689
|
return { keys: "<Esc>", hostAction: "normal" }
|
|
@@ -644,7 +738,7 @@ function plainPending(value: string) {
|
|
|
644
738
|
return value.includes("<") || value.includes(">") ? "" : value
|
|
645
739
|
}
|
|
646
740
|
|
|
647
|
-
function consumesKey(key: string, actions:
|
|
741
|
+
function consumesKey(key: string, actions: HostAction[], ctx: VimContext, keybindPending: boolean) {
|
|
648
742
|
if (actions.length > 0) return true
|
|
649
743
|
if (keybindPending) return true
|
|
650
744
|
if (ctx.phase !== "idle") return true
|