opencode-vim 0.0.18 → 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 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
- | `<CR>` in normal mode | Submit the prompt |
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
- See [docs/configuration.md](./docs/configuration.md) for configuration options and keymap examples.
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.
@@ -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 insert mode and normal mode.
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 the prompt with Enter in normal mode:
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 either `insert` or `normal`.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vim",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Tarquinen/opencode-vim"
@@ -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,8 +65,10 @@ 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())
68
+ if (passThroughKey(event, key, props.state.mode())) return
69
+
70
+ if (key === "<Esc>" && props.state.mode() === "normal" && !props.state.pending()) {
71
+ props.log("keyboard.fallthrough", { key, mode: props.state.mode() })
77
72
  return
78
73
  }
79
74
 
@@ -83,10 +78,17 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
83
78
  return
84
79
  }
85
80
 
86
- const consumed = vimee.handle(event, key, props.ctx)
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
+ }
87
91
  if (consumed) {
88
- event.preventDefault()
89
- event.stopPropagation()
90
92
  syncCursorStyle(true)
91
93
  props.ctx.requestRender()
92
94
  }
@@ -94,7 +96,6 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
94
96
 
95
97
  onCleanup(() => {
96
98
  props.log("keyboard.cleanup", { kind: props.ctx.kind })
97
- offKeyIntercept()
98
99
  offKeyboard()
99
100
  vimee.cleanup()
100
101
  clearInterval(cursorStyleTimer)
@@ -123,15 +124,7 @@ function readablePending(sequence: string) {
123
124
 
124
125
  function passThroughKey(event: KeyEvent, key: string, mode: string) {
125
126
  if (mode !== "normal") return false
126
- return event.super === true || isArrowKey(key) || key === "<CR>" || key === "<C-c>"
127
- }
128
-
129
- function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) {
130
- if (mode !== "normal" || key !== "<CR>") return false
131
- const input = focusedInput(ctx)
132
- if (!input?.plainText || input.cursorOffset === undefined) return false
133
- input.cursorOffset = Math.min(input.cursorOffset + 1, input.plainText.length)
134
- return true
127
+ return event.super === true || isArrowKey(key) || key === "<C-c>"
135
128
  }
136
129
 
137
130
  function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) {
@@ -173,8 +166,8 @@ function isPromptFocused(ctx: PromptContext) {
173
166
  function isNativeCompletionToken(ctx: PromptContext) {
174
167
  const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? ""
175
168
  const input = focusedInput(ctx)
176
- const offset = Math.max(0, input?.cursorOffset ?? text.length)
177
- const beforeCursor = text.slice(0, Math.min(offset + 1, text.length))
169
+ const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
170
+ const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length))
178
171
  return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor)
179
172
  }
180
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
+ })
@@ -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, hostOffset: number): CursorPosition {
41
- return positionFromOffset(map.vimText, map.hostToVim[clamp(hostOffset, 0, map.hostText.length)] ?? 0)
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
- return hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
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 offset = 0; offset <= text.length; offset++) {
103
- input.cursorOffset = offset
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[offset - 1] !== "\n") wraps.push(offset)
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
+ })
@@ -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) listener()
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
  }
@@ -58,6 +58,8 @@ export function VimStatus(props: VimStatusProps) {
58
58
  unsubscribe()
59
59
  clearInterval(timer)
60
60
  if (pendingTimer) clearTimeout(pendingTimer)
61
+ modeText = undefined
62
+ pendingText = undefined
61
63
  })
62
64
 
63
65
  return (
@@ -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
+ }
@@ -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 offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
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, offset)
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, offset, vim.count || 1) : undefined
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 result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
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
- if (hostEnd !== undefined && result.actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
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 = result.actions.find((action) => action.type === "content-change")?.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 target = clamp(offset, 0, Math.max(0, next.hostText.length - 1))
94
- if (offset < map.hostText.length - 1 && map.hostText[offset + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
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(result.actions as HostAction[], ctx, map, clampFinalCursor)
100
- if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(result.actions), visualYankRange)
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, offset)
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: result.actions.map((action) => action.type) })
108
- return consumesKey(vimeeKey, result.actions, vim, keybindPending)
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, offset?: number) {
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 insertAt = clamp(offset ?? input?.cursorOffset ?? text.length, 0, text.length)
339
- const currentOffset = input?.cursorOffset ?? insertAt
340
- const next = text.slice(0, insertAt) + value + text.slice(insertAt)
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 nextOffset = currentOffset >= insertAt ? currentOffset + value.length : currentOffset
343
- if (input) input.cursorOffset = nextOffset
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.length), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
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 start = clamp(Math.min(left, right), 0, Math.max(0, map.hostText.length - 1))
461
- const end = clamp(Math.max(left, right), 0, Math.max(0, map.hostText.length - 1))
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: VimeeAction[]): YankAction | undefined {
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.length
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.length
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
- if (!cursor || !eol || offset === undefined) return
590
+ const text = input.plainText
591
+ if (!cursor || offset === undefined || text === undefined) return
548
592
  if (cursor.visualCol === 0) return
549
- if (cursor.visualRow === eol.visualRow && (cursor.offset === eol.offset || offset === eol.offset)) input.cursorOffset = Math.max(0, offset - 1)
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: VimeeAction[], ctx: VimContext, keybindPending: boolean) {
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