opencode-vim 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,80 @@
1
1
  # opencode-vim
2
2
 
3
- Adds Vim-style insert and normal mode editing to the OpenCode prompt.
3
+ Vim-style editing for the OpenCode 2 prompt and dialog search fields.
4
4
 
5
- ![Demo](./assets/demo2.gif)
5
+ This branch supports normal, insert, visual, and visual-line editing, custom
6
+ keymaps, prompt history, command dispatch, cursor styles, and `/vim` toggling.
7
+ The mode indicator appears before the working directory in the prompt footer.
6
8
 
7
- ## Installation
9
+ Configure the local plugin in OpenCode 2's `cli.json`:
8
10
 
9
- Install from the CLI:
11
+ ```json
12
+ {
13
+ "plugins": [
14
+ "opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim"
15
+ ]
16
+ }
17
+ ```
18
+
19
+ Options use the V2 object form:
20
+
21
+ ```json
22
+ {
23
+ "package": "opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim",
24
+ "options": {
25
+ "vim": {
26
+ "defaultMode": "insert",
27
+ "keymapTimeout": 500,
28
+ "keymaps": {
29
+ "insert": { "kj": "normal" },
30
+ "normal": { "Y": "y$" }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ OpenCode 2 does not yet expose its prompt ref or low-level key interceptor to
38
+ plugins. This adapter uses the public renderer in the V2 plugin context to
39
+ bridge those capabilities to the existing Vim engine.
40
+
41
+ ## Dialogs
10
42
 
11
- ```bash
12
- opencode plugin opencode-vim@latest --global
43
+ Searchable dialogs such as `/models`, the `Ctrl+P` command palette, and Settings
44
+ support Vim editing in their search fields. Each new dialog starts in your
45
+ configured `defaultMode`.
46
+
47
+ - In insert mode, type to filter. `Esc` (or your `kj` mapping) enters normal mode.
48
+ - In normal mode, `j`/`k` move the selected list item. `h`/`l`, `x`, `dw`, `u`,
49
+ and other editing commands act on the search text. `i` returns to insert mode.
50
+ - `Enter` selects the highlighted item; `Esc` in idle normal mode closes or goes
51
+ back. Arrows, Tab, Home/End, and Page Up/Down keep their dialog behavior.
52
+ - Dialog editing has separate mode and undo state from the main prompt.
53
+
54
+ Normal-mode custom mappings take precedence over the dialog `j`/`k` defaults.
55
+
56
+ ## Development and testing
57
+
58
+ ```sh
59
+ npm install
60
+ npm test
61
+ npm run typecheck
62
+ npm run bench
13
63
  ```
14
64
 
15
- ## Supported Keys
65
+ Tests use Bun and OpenTUI's native headless renderer: real textarea input,
66
+ cursor movement, selection, wrapping, attachment extmarks, and the mounted
67
+ plugin footer. They do not contact an OpenCode server or a model. When `nvim`
68
+ is installed, additional tests compare text and cursor results against headless
69
+ Neovim with user configuration disabled.
70
+
71
+ With an OpenCode source checkout and its dependencies installed, you can also
72
+ test against its real dialog component and keyboard routing:
16
73
 
17
- | Key | Behavior |
18
- | --- | --- |
19
- | `<Esc>`, `<C-[>` | Enter normal mode |
20
- | `i`, `a`, `A`, `o`, `O` | Return to insert mode |
21
- | `h`, `j`, `k`, `l`, `w`, `b`, `e`, `$`, `0` | Move through the prompt |
22
- | `x`, `d`, `c`, `y`, `p`, `u`, `<C-r>` | Edit, yank, paste, undo, redo |
23
- | `v`, `V` | Visual and visual-line selection |
24
- | `3w`, `diw`, `ci"`, `yiq`, `dip`, `yib` | Counts and text objects |
25
- | `<CR>` in normal mode | Submit the prompt |
26
- | `/vim` | Toggle Vim mode on or off |
74
+ ```sh
75
+ OPENCODE_SOURCE=/path/to/opencode npm test -- test/opencode-dialog.test.tsx
76
+ ```
27
77
 
28
- See [docs/configuration.md](./docs/configuration.md) for configuration options and keymap examples.
78
+ The benchmark measures wrap mapping and normal-mode navigation on prompts of
79
+ 1,000, 5,000, and 10,000 characters. Wrapping follows the textarea's visual
80
+ lines; those cases are tested separately from Neovim's logical-line behavior.
@@ -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,39 +1,41 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package.json",
2
3
  "name": "opencode-vim",
3
- "version": "0.0.19",
4
+ "version": "0.0.21",
5
+ "type": "module",
4
6
  "repository": {
5
7
  "type": "git",
6
8
  "url": "https://github.com/Tarquinen/opencode-vim"
7
9
  },
8
10
  "exports": {
9
- "./tui": {
10
- "import": "./tui.tsx"
11
- }
11
+ "./tui": "./tui.tsx"
12
+ },
13
+ "scripts": {
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "bun test --conditions=browser --preload @opentui/solid/preload",
16
+ "bench": "bun test/benchmark.ts"
12
17
  },
13
18
  "dependencies": {
14
19
  "@vimee/core": "^0.3.0"
15
20
  },
16
21
  "peerDependencies": {
17
- "@opencode-ai/plugin": ">=1.4.3",
18
- "@opentui/core": "^0.4.2",
19
- "@opentui/keymap": "^0.4.2",
20
- "@opentui/solid": "^0.4.2",
21
- "solid-js": "^1.9.12"
22
+ "@opencode/plugin": "2.0.10",
23
+ "@opentui/core": ">=0.5.10",
24
+ "@opentui/solid": ">=0.5.10",
25
+ "solid-js": ">=1.9.0"
22
26
  },
23
27
  "devDependencies": {
24
- "@opencode-ai/plugin": "1.14.44",
25
- "@opentui/core": "^0.4.2",
26
- "@opentui/keymap": "^0.4.2",
27
- "@opentui/solid": "^0.4.2",
28
+ "@opencode/theme": "2.0.10",
28
29
  "@types/bun": "^1.3.14",
29
- "solid-js": "^1.9.12"
30
+ "@types/node": "^24.0.0",
31
+ "typescript": "^5.9.0"
30
32
  },
31
33
  "files": [
32
34
  "assets/",
33
35
  "docs/",
34
36
  "src/",
35
37
  "README.md",
36
- "tui.json.example",
37
- "tui.tsx"
38
+ "tui.tsx",
39
+ "view.tsx"
38
40
  ]
39
41
  }
@@ -1,14 +1,38 @@
1
- import type { RGBA } from "@opentui/core"
2
- import type { TuiPromptInfo, TuiPromptRef } from "@opencode-ai/plugin/tui"
3
- import type { PromptContext } from "../../prompt/types"
1
+ import type { CursorStyleOptions, LineInfo, RGBA } from "@opentui/core"
4
2
  import type { VimCursorStyle } from "./config"
5
3
 
4
+ type PromptInfo = {
5
+ input: string
6
+ mode: string
7
+ parts: unknown[]
8
+ }
9
+
10
+ type PromptRef = {
11
+ current: PromptInfo
12
+ set: (value: PromptInfo) => void
13
+ submit: () => void
14
+ blur: () => void
15
+ }
16
+
17
+ export type PromptContext = {
18
+ api: {
19
+ renderer: { currentFocusedRenderable?: unknown }
20
+ keymap: { dispatchCommand: (command: string) => { ok: boolean } }
21
+ theme: { current: { warning: RGBA; info: RGBA; background: RGBA } }
22
+ }
23
+ prompt: () => PromptRef | undefined
24
+ requestRender: () => void
25
+ }
26
+
6
27
  export type EditBufferLike = {
28
+ isDestroyed?: boolean
29
+ width?: number
30
+ wrapMode?: string
7
31
  cursorOffset?: number
8
32
  plainText?: string
9
33
  visualCursor?: VisualCursorLike
10
- editorView?: { getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
11
- cursorStyle?: VimCursorStyle
34
+ editorView?: { getLogicalLineInfo?: () => LineInfo; getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
35
+ cursorStyle?: CursorStyleOptions
12
36
  selectionBg?: RGBA
13
37
  selectionFg?: RGBA
14
38
  moveCursorLeft?: () => boolean
@@ -16,6 +40,7 @@ export type EditBufferLike = {
16
40
  moveCursorUp?: () => boolean
17
41
  moveCursorDown?: () => boolean
18
42
  setSelection?: (start: number, end: number) => void
43
+ insertText?: (text: string) => void
19
44
  setSelectionInclusive?: (start: number, end: number) => void
20
45
  clearSelection?: () => void
21
46
  gotoVisualLineEnd?: () => boolean
@@ -43,7 +68,7 @@ export function focusedInput(ctx: PromptContext): EditBufferLike | undefined {
43
68
  return focused
44
69
  }
45
70
 
46
- export function setInput(ref: TuiPromptRef, input: string) {
71
+ export function setInput(ref: PromptRef, input: string) {
47
72
  ref.set(toPromptInfo(ref, input))
48
73
  }
49
74
 
@@ -51,7 +76,7 @@ function hasEditBufferMethods(input: EditBufferLike) {
51
76
  return typeof input.moveCursorLeft === "function" || typeof input.moveCursorRight === "function" || typeof input.moveCursorUp === "function" || typeof input.moveCursorDown === "function" || typeof input.gotoLineEnd === "function"
52
77
  }
53
78
 
54
- function toPromptInfo(ref: TuiPromptRef, input: string): TuiPromptInfo {
79
+ function toPromptInfo(ref: PromptRef, input: string): PromptInfo {
55
80
  return {
56
81
  input,
57
82
  mode: ref.current.mode,
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { editInput } from "./edit"
3
+
4
+ describe("editInput", () => {
5
+ test("inserts without replacing unchanged placeholders", () => {
6
+ const fixture = createFixture("[Image 1] after")
7
+
8
+ editInput(fixture.input, "[Image 1] kafter")
9
+
10
+ expect(fixture.calls).toEqual([["cursor", 10], ["insert", "k"]])
11
+ })
12
+
13
+ test("uses display offsets for a minimal deletion", () => {
14
+ const fixture = createFixture("中 [Image 1] abc")
15
+
16
+ editInput(fixture.input, "中 [Image 1] ac")
17
+
18
+ expect(fixture.calls).toEqual([["selection", 14, 15], ["insert", ""]])
19
+ })
20
+
21
+ test("does not split graphemes", () => {
22
+ const fixture = createFixture("a👩‍💻b")
23
+
24
+ editInput(fixture.input, "axb")
25
+
26
+ expect(fixture.calls).toEqual([["selection", 1, 3], ["insert", "x"]])
27
+ })
28
+ })
29
+
30
+ function createFixture(plainText: string) {
31
+ const calls: Array<["cursor", number] | ["selection", number, number] | ["insert", string]> = []
32
+ let cursorOffset = 0
33
+ const input = {
34
+ plainText,
35
+ clearSelection() {},
36
+ get cursorOffset() {
37
+ return cursorOffset
38
+ },
39
+ set cursorOffset(value: number) {
40
+ cursorOffset = value
41
+ calls.push(["cursor", value])
42
+ },
43
+ setSelection(start: number, end: number) {
44
+ calls.push(["selection", start, end])
45
+ },
46
+ insertText(text: string) {
47
+ calls.push(["insert", text])
48
+ },
49
+ }
50
+ return { input, calls }
51
+ }
@@ -0,0 +1,33 @@
1
+ import { charToDisplay } from "./map"
2
+
3
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+
5
+ type Input = {
6
+ plainText: string
7
+ cursorOffset: number
8
+ setSelection: (start: number, end: number) => void
9
+ insertText: (text: string) => void
10
+ clearSelection: () => unknown
11
+ }
12
+
13
+ export function editInput(input: Input, value: string) {
14
+ if (input.plainText === value) return
15
+ input.clearSelection()
16
+
17
+ const before = [...graphemes.segment(input.plainText)]
18
+ const after = [...graphemes.segment(value)]
19
+ let prefix = 0
20
+ while (prefix < before.length && prefix < after.length && before[prefix].segment === after[prefix].segment) prefix++
21
+
22
+ let suffix = 0
23
+ while (suffix + prefix < before.length && suffix + prefix < after.length && before[before.length - suffix - 1].segment === after[after.length - suffix - 1].segment) suffix++
24
+
25
+ const start = before[prefix]?.index ?? input.plainText.length
26
+ const end = suffix ? before[before.length - suffix].index : input.plainText.length
27
+ const valueEnd = suffix ? after[after.length - suffix].index : value.length
28
+ const startOffset = charToDisplay(input.plainText, start)
29
+
30
+ if (start === end) input.cursorOffset = startOffset
31
+ else input.setSelection(startOffset, charToDisplay(input.plainText, end))
32
+ input.insertText(value.slice(start, valueEnd))
33
+ }
@@ -0,0 +1,38 @@
1
+ const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" })
2
+
3
+ // Vimee indexes JavaScript strings by UTF-16 code unit. Give each multi-unit
4
+ // grapheme one private-use character so motions, operators and registers cannot
5
+ // split it. The dictionary lives with the adapter, including its undo history.
6
+ export function createGraphemeCodec() {
7
+ const encoded = new Map<string, string>()
8
+ const decoded = new Map<string, string>()
9
+ let next = 0xe000
10
+ return {
11
+ encode(text: string) {
12
+ let result = ""
13
+ for (const { segment } of segments.segment(text)) {
14
+ const code = segment.charCodeAt(0)
15
+ if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) {
16
+ result += segment
17
+ continue
18
+ }
19
+ let token = encoded.get(segment)
20
+ if (!token) {
21
+ if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full")
22
+ token = String.fromCharCode(next++)
23
+ encoded.set(segment, token)
24
+ decoded.set(token, segment)
25
+ }
26
+ result += token
27
+ }
28
+ return result
29
+ },
30
+ decode(text: string) {
31
+ let result = ""
32
+ for (const token of text) result += decoded.get(token) ?? token
33
+ return result
34
+ },
35
+ }
36
+ }
37
+
38
+ export type GraphemeCodec = ReturnType<typeof createGraphemeCodec>