opencode-vim 0.0.10 → 0.0.12

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Adds Vim-style insert and normal mode editing to the OpenCode prompt.
4
4
 
5
- ![Demo](./assets/demo.gif)
5
+ ![Demo](./assets/demo2.gif)
6
6
 
7
7
  ## Installation
8
8
 
@@ -12,4 +12,17 @@ Install from the CLI:
12
12
  opencode plugin opencode-vim@latest --global
13
13
  ```
14
14
 
15
+ ## Supported Keys
16
+
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 |
27
+
15
28
  See [docs/configuration.md](./docs/configuration.md) for configuration options and keymap examples.
package/assets/demo.gif CHANGED
Binary file
Binary file
package/bun.lock CHANGED
@@ -5,13 +5,14 @@
5
5
  "": {
6
6
  "name": "opencode-vim",
7
7
  "dependencies": {
8
- "@opentui/core": "^0.2.1",
9
- "@opentui/solid": "^0.2.1",
8
+ "@opentui/core": "^0.2.6",
9
+ "@opentui/keymap": "^0.2.6",
10
+ "@opentui/solid": "^0.2.6",
10
11
  "@vimee/core": "^0.3.0",
11
12
  "solid-js": "^1.9.12",
12
13
  },
13
14
  "devDependencies": {
14
- "@opencode-ai/plugin": "1.14.30",
15
+ "@opencode-ai/plugin": "1.14.44",
15
16
  },
16
17
  },
17
18
  },
@@ -94,25 +95,27 @@
94
95
 
95
96
  "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
96
97
 
97
- "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.30", "", { "dependencies": { "@opencode-ai/sdk": "1.14.30", "effect": "4.0.0-beta.57", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.105", "@opentui/solid": ">=0.1.105" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-O1y6qR349R5XJtB76Vx4TO/uvLkqNvdsgxtj2ZpWoygb3rtrkuVMiBsrcL2WfuyyaLJnPmbnkeigcdm42r09Hg=="],
98
+ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.44", "", { "dependencies": { "@opencode-ai/sdk": "1.14.44", "effect": "4.0.0-beta.59", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.6", "@opentui/keymap": ">=0.2.6", "@opentui/solid": ">=0.2.6" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-7uqSJWaDuP9GNvdUJ57E8AX0WNVP4C8PyCIJ2qvjtucjw6DFT/ErjXDXUrRiqwK3omImXRlTDb32xP9IMfk0dw=="],
98
99
 
99
- "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.30", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-OgPEDvALekHZIjByo/okJ699aLPn+XtsVxgZxUqE8TlzAG7TtskMGFl0fro8O0T2p+nkOT/LstnKGbECvc0+YA=="],
100
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.44", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-hpMCYhwEWk2B1b9THHYy5GRlrCJ9g67pvSyLt9O2b2pYj0HE6v8MeVOhg6ucn2T4QBIoVJ3ZDWnh7ApaeunNWg=="],
100
101
 
101
- "@opentui/core": ["@opentui/core@0.2.1", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.1", "@opentui/core-darwin-x64": "0.2.1", "@opentui/core-linux-arm64": "0.2.1", "@opentui/core-linux-x64": "0.2.1", "@opentui/core-win32-arm64": "0.2.1", "@opentui/core-win32-x64": "0.2.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-tXFD2NeznXzZa02yOsQ/D0o/06GBqVrPR7VdmgqI+ZChcS7NY/QgNQGgAZUditW2vezFcxlgTqoCgQaZFqRfGQ=="],
102
+ "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
102
103
 
103
- "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vT8rgeNdkzD84wCcS5zmrSVFgR/SjeVr+2FNM/icTjwKh9Jq3cY6UY7/EFck3PRjrmanNu4zw7W5lFKSpMxnPQ=="],
104
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
104
105
 
105
- "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HAH1KeTejs0Z+RChNsNImI1eOlYJabKeAEvLtnn1aiHxvJFpzdAi6d/VrG5WFcxAem6TVRtnKN46gXff26lkig=="],
106
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
106
107
 
107
- "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-447FI6xolsKbG1eH3hpNGQhQ/8M6FDJIgpiJhX9XXvZGO7vEyN6efyqqIzRF+quafqF69+rT3qK70S1svjwVKA=="],
108
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
108
109
 
109
- "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdds+HnAbFiylTdxyYhRxPFPnrHm75KTi7QCA1JHU3lb8Sfwrxv3nlYtFhEV798y4osafsghL20Nq5mqVPjizQ=="],
110
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
110
111
 
111
- "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-VEuqX9OSFpw960mibe4OXVmnE43V/VYwB61zoxD5uw7L3jgCiOfrf+W2TuFbcRAgtTD35MypJ53fYxmUcVZc8g=="],
112
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
112
113
 
113
- "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ywwRbyjPPEbNe3da+ez4NxIaJWQ79sA2gdepuHKUVdmdxsevdRpAxOHkEpJNtiVvphPc/On9EsP25Hmn5gjYxg=="],
114
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
114
115
 
115
- "@opentui/solid": ["@opentui/solid@0.2.1", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.1", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-/vlW9SmcqkarbMwB4JaIwwS3ovo42DSwAnyM04pjJVhG7DzKKwdUzozQMRgvEDZZ7oGYQRvHyVAfL8GCLh0AZw=="],
116
+ "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
117
+
118
+ "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
116
119
 
117
120
  "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
118
121
 
@@ -134,7 +137,7 @@
134
137
 
135
138
  "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
136
139
 
137
- "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
140
+ "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
138
141
 
139
142
  "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="],
140
143
 
@@ -150,7 +153,7 @@
150
153
 
151
154
  "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
152
155
 
153
- "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="],
156
+ "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
154
157
 
155
158
  "electron-to-chromium": ["electron-to-chromium@1.5.345", "", {}, "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg=="],
156
159
 
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "opencode-vim",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "exports": {
5
5
  "./tui": {
6
6
  "import": "./tui.tsx"
7
7
  }
8
8
  },
9
9
  "dependencies": {
10
- "@opentui/core": "^0.2.1",
11
- "@opentui/solid": "^0.2.1",
10
+ "@opentui/core": "^0.2.6",
11
+ "@opentui/keymap": "^0.2.6",
12
+ "@opentui/solid": "^0.2.6",
12
13
  "solid-js": "^1.9.12",
13
14
  "@vimee/core": "^0.3.0"
14
15
  },
15
16
  "devDependencies": {
16
- "@opencode-ai/plugin": "1.14.30"
17
+ "@opencode-ai/plugin": "1.14.44"
17
18
  }
18
19
  }
@@ -3,9 +3,7 @@ import type { PromptModule } from "../../prompt/types"
3
3
  import type { SnippetController } from "./types"
4
4
  import { SnippetAutocomplete } from "./view"
5
5
 
6
- export function createSnippetsModule(): PromptModule {
7
- const controller: SnippetController = {}
8
-
6
+ export function createSnippetsModule(controller: SnippetController): PromptModule {
9
7
  return {
10
8
  id: "snippets",
11
9
  order: -10,
@@ -46,4 +46,5 @@ export type SnippetController = {
46
46
  accept?: () => boolean
47
47
  reload?: () => void
48
48
  insertTrigger?: () => void
49
+ navigate?: (delta: number) => boolean
49
50
  }
@@ -100,6 +100,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
100
100
  props.controller.accept = undefined
101
101
  props.controller.reload = undefined
102
102
  props.controller.insertTrigger = undefined
103
+ props.controller.navigate = undefined
103
104
  })
104
105
 
105
106
  const dialogBlockingInput = () => dialogOpen() || dialogHandoffUntil() > Date.now()
@@ -156,6 +157,27 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
156
157
  return item ? chooseItem(item) : false
157
158
  }
158
159
 
160
+ const navigate = (delta: number) => {
161
+ const ref = props.ctx.prompt()
162
+ if (!ref || !ref.focused || dialogBlockingInput()) return false
163
+
164
+ const current = findTrailingHashtagTrigger(ref.current.input)
165
+ if (!current || dismissed() === current.token) return false
166
+
167
+ const value = current.query.trim()
168
+ const total = optionsForQuery(value).length
169
+ if (total === 0 && !canCreateForQuery(current.query)) return false
170
+
171
+ if (pendingPromptSync) clearTimeout(pendingPromptSync)
172
+ pendingPromptSync = undefined
173
+ setInput(ref.current.input)
174
+ setSyncingPrompt(false)
175
+ lockKeyboardSelection()
176
+ setSelected((current) => stepSelection(current, total || 1, delta))
177
+ props.ctx.requestRender()
178
+ return true
179
+ }
180
+
159
181
  const accept = () => {
160
182
  const ref = props.ctx.prompt()
161
183
  if (!ref) return false
@@ -205,6 +227,14 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
205
227
  return false
206
228
  }
207
229
 
230
+ const canAcceptSubmit = (ref: TuiPromptRef) => {
231
+ if (isReloadCommand(ref.current.input)) return true
232
+ if (dialogBlockingInput()) return true
233
+
234
+ const current = findTrailingHashtagTrigger(ref.current.input)
235
+ return !!current && dismissed() !== current.token
236
+ }
237
+
208
238
  props.controller.accept = accept
209
239
  props.controller.reload = () => {
210
240
  const ref = props.ctx.prompt()
@@ -216,6 +246,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
216
246
  syncPromptInput(ref, insertSnippetTrigger(ref.current.input))
217
247
  ref.focus()
218
248
  }
249
+ props.controller.navigate = navigate
219
250
 
220
251
  createEffect(() => {
221
252
  const ref = props.ctx.prompt()
@@ -229,40 +260,45 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
229
260
  commandTimer = undefined
230
261
  if (disposed) return
231
262
 
232
- commandDispose = props.ctx.api.command.register(() => [
233
- {
234
- title: "Reload snippets",
235
- value: "snippets.reload",
236
- description: "Reload snippet files from disk",
237
- category: "Prompt",
238
- slash: { name: "snippets:reload" },
239
- onSelect() {
240
- void executeReloadInPrompt(ref)
263
+ commandDispose = props.ctx.api.keymap.registerLayer({
264
+ commands: [
265
+ {
266
+ namespace: "palette",
267
+ name: "snippets.reload",
268
+ title: "Reload snippets",
269
+ desc: "Reload snippet files from disk",
270
+ category: "Prompt",
271
+ slashName: "snippets:reload",
272
+ run() {
273
+ void executeReloadInPrompt(ref)
274
+ },
241
275
  },
242
- },
243
- {
244
- title: "Insert snippet",
245
- value: "snippets.insert",
246
- description: "Insert a snippet trigger into the prompt",
247
- category: "Prompt",
248
- onSelect() {
249
- syncPromptInput(ref, insertSnippetTrigger(ref.current.input))
250
- ref.focus()
276
+ {
277
+ namespace: "palette",
278
+ name: "snippets.insert",
279
+ title: "Insert snippet",
280
+ desc: "Insert a snippet trigger into the prompt",
281
+ category: "Prompt",
282
+ run() {
283
+ syncPromptInput(ref, insertSnippetTrigger(ref.current.input))
284
+ ref.focus()
285
+ },
251
286
  },
252
- },
253
- {
254
- title: "Accept snippet autocomplete",
255
- value: "snippets.accept",
256
- keybind: "input_submit",
257
- category: "Prompt",
258
- hidden: true,
259
- enabled: ref.focused,
260
- onSelect() {
261
- if (accept()) return
262
- ref.submit()
287
+ {
288
+ namespace: "palette",
289
+ name: "snippets.accept",
290
+ title: "Accept snippet autocomplete",
291
+ category: "Prompt",
292
+ hidden: true,
293
+ enabled: () => ref.focused && canAcceptSubmit(ref),
294
+ run() {
295
+ if (accept()) return
296
+ ref.submit()
297
+ },
263
298
  },
264
- },
265
- ])
299
+ ],
300
+ bindings: acceptSnippetBindings(props.ctx.api),
301
+ })
266
302
  }, 0)
267
303
  })
268
304
 
@@ -280,12 +316,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
280
316
  if (dialogBlockingInput()) return
281
317
  if (!visible()) return
282
318
 
283
- const total = options().length
284
- const actionable = total > 0 || canCreate()
285
-
286
- if ((name === "up" || name === "down") && actionable) {
287
- lockKeyboardSelection()
288
- setSelected((current) => stepSelection(current, total || 1, name === "up" ? -1 : 1))
319
+ if ((name === "up" || name === "down") && navigate(name === "up" ? -1 : 1)) {
289
320
  event.preventDefault()
290
321
  event.stopPropagation()
291
322
  return
@@ -298,8 +329,8 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
298
329
  return
299
330
  }
300
331
 
301
- if (name === "tab" && actionable) {
302
- if (total > 0) choose()
332
+ if (name === "tab" && (options().length > 0 || canCreate())) {
333
+ if (options().length > 0) choose()
303
334
  else void createSnippetDraft()
304
335
  event.preventDefault()
305
336
  event.stopPropagation()
@@ -559,6 +590,21 @@ function syncPromptInput(prompt: TuiPromptRef, input: string) {
559
590
  prompt.set({ input, mode: prompt.current.mode, parts: [...prompt.current.parts] })
560
591
  }
561
592
 
593
+ function acceptSnippetBindings(api: TuiPluginApi) {
594
+ const command = "snippets.accept"
595
+ const title = "Accept snippet autocomplete"
596
+
597
+ if (!api.tuiConfig.keybinds.has("input.submit")) {
598
+ return [{ key: "input_submit", cmd: command, desc: title }]
599
+ }
600
+
601
+ return api.tuiConfig.keybinds.get("input.submit").map((binding) => ({
602
+ ...binding,
603
+ cmd: command,
604
+ desc: binding.desc ?? title,
605
+ }))
606
+ }
607
+
562
608
  function selectedText(theme: PromptContext["api"]["theme"]["current"]) {
563
609
  if (theme.background.a !== 0) return theme.background
564
610
  const { r, g, b } = theme.primary
@@ -4,6 +4,7 @@ import { useKeyboard } from "@opentui/solid"
4
4
  import { onCleanup } from "solid-js"
5
5
  import type { Accessor } from "solid-js"
6
6
  import type { PromptContext, PromptModule } from "../../prompt/types"
7
+ import type { SnippetController } from "../snippets/types"
7
8
  import { applyVimCursorStyle, focusedInput } from "./actions"
8
9
  import type { VimConfig } from "./config"
9
10
  import { createVimConfig } from "./config"
@@ -14,7 +15,7 @@ import { createVimState } from "./state"
14
15
  import { createVimeeAdapter } from "./vimee"
15
16
  import { VimStatus } from "./view"
16
17
 
17
- export function createVimModule(options?: unknown, enabled: Accessor<boolean> = () => true): PromptModule {
18
+ export function createVimModule(options?: unknown, enabled: Accessor<boolean> = () => true, snippets?: SnippetController): PromptModule {
18
19
  const config = createVimConfig(options)
19
20
  const log = createVimLog(config)
20
21
  const state = createVimState(config.defaultMode, log)
@@ -27,7 +28,7 @@ export function createVimModule(options?: unknown, enabled: Accessor<boolean> =
27
28
  log("module.setup", { kind: ctx.kind, sessionID: ctx.sessionID, workspaceID: ctx.workspaceID })
28
29
  },
29
30
  renderAbove(ctx) {
30
- return <VimKeyboard ctx={ctx} config={config} state={state} enabled={enabled} log={log} />
31
+ return <VimKeyboard ctx={ctx} config={config} state={state} snippets={snippets} enabled={enabled} log={log} />
31
32
  },
32
33
  renderRight(ctx) {
33
34
  return <VimStatus mode={state.mode} pending={() => readablePending(state.pending())} enabled={enabled} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} />
@@ -35,12 +36,19 @@ export function createVimModule(options?: unknown, enabled: Accessor<boolean> =
35
36
  }
36
37
  }
37
38
 
38
- function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; enabled: Accessor<boolean>; log: VimLog }) {
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)
42
+ const preparedEvents = new WeakSet<KeyEvent>()
41
43
  props.log("keyboard.mount", { kind: props.ctx.kind })
42
44
 
43
45
  const cursorStyleTimer = setInterval(syncCursorStyle, 50)
46
+ const offKeyIntercept = props.ctx.api.keymap.intercept("key", ({ event }) => {
47
+ if (!props.enabled() || props.ctx.disabled || props.ctx.visible === false) return
48
+ const key = keyNotation(event)
49
+ if (!key || !passThroughKey(event, key, props.state.mode())) return
50
+ if (preparePassThroughKey(props.ctx, key, props.state.mode())) preparedEvents.add(event)
51
+ }, { priority: 100 })
44
52
 
45
53
  useKeyboard((event) => {
46
54
  props.log("keyboard.event", {
@@ -66,13 +74,13 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
66
74
  }
67
75
 
68
76
  if (passThroughKey(event, key, props.state.mode())) {
69
- preparePassThroughKey(props.ctx, key, props.state.mode())
77
+ if (!preparedEvents.delete(event)) preparePassThroughKey(props.ctx, key, props.state.mode())
70
78
  syncCursorStyle(true)
71
79
  props.ctx.requestRender()
72
80
  return
73
81
  }
74
82
 
75
- if (sendNavigationKey(event, props.ctx, key, props.state.mode())) {
83
+ if (sendNavigationKey(event, props.ctx, key, props.state.mode(), props.snippets)) {
76
84
  syncCursorStyle(true)
77
85
  props.ctx.requestRender()
78
86
  return
@@ -89,6 +97,7 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
89
97
 
90
98
  onCleanup(() => {
91
99
  props.log("keyboard.cleanup", { kind: props.ctx.kind })
100
+ offKeyIntercept()
92
101
  vimee.cleanup()
93
102
  clearInterval(cursorStyleTimer)
94
103
  })
@@ -116,14 +125,22 @@ function passThroughKey(event: KeyEvent, key: string, mode: string) {
116
125
  }
117
126
 
118
127
  function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) {
119
- if (mode !== "normal" || key !== "<CR>") return
128
+ if (mode !== "normal" || key !== "<CR>") return false
120
129
  const input = focusedInput(ctx)
121
- if (!input?.plainText || input.cursorOffset === undefined) return
130
+ if (!input?.plainText || input.cursorOffset === undefined) return false
122
131
  input.cursorOffset = Math.min(input.cursorOffset + 1, input.plainText.length)
132
+ return true
123
133
  }
124
134
 
125
- function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string) {
135
+ function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) {
126
136
  if (mode !== "normal") return false
137
+ const delta = snippetNavigationDelta(key)
138
+ if (delta !== 0 && snippets?.navigate?.(delta)) {
139
+ event.preventDefault()
140
+ event.stopPropagation()
141
+ return true
142
+ }
143
+
127
144
  const forwarded = commandNavigationKey(ctx, key)
128
145
  if (!forwarded) return false
129
146
 
@@ -133,6 +150,12 @@ function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mod
133
150
  return true
134
151
  }
135
152
 
153
+ function snippetNavigationDelta(key: string) {
154
+ if (key === "j") return 1
155
+ if (key === "k") return -1
156
+ return 0
157
+ }
158
+
136
159
  function commandNavigationKey(ctx: PromptContext, key: string): ParsedKey | undefined {
137
160
  if (!isPromptFocused(ctx)) return navigationKey(key)
138
161
  if (!isSlashCommandToken(ctx)) return undefined
@@ -1,6 +1,6 @@
1
1
  import type { KeyEvent } from "@opentui/core"
2
- import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke } from "@vimee/core"
3
- import type { CursorPosition, KeybindDefinition, KeybindMap, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
2
+ import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext } from "@vimee/core"
3
+ import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
4
4
  import type { PromptContext } from "../../prompt/types"
5
5
  import { focusedInput, setInput, type EditBufferLike } from "./actions"
6
6
  import type { VimConfig } from "./config"
@@ -38,7 +38,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
38
38
  const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
39
39
  const map = mapForHostText(text, input)
40
40
  const cursor = hostPosition(map, offset)
41
- const vimeeKey = keyForVimee(event, key)
41
+ let vimeeKey = keyForVimee(event, key)
42
42
  if (!vimeeKey) return false
43
43
 
44
44
  if (state.mode() === "normal" && key === "<CR>") {
@@ -70,6 +70,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
70
70
  const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined
71
71
  const shouldFlashYank = shouldFlashYankFor(vimeeKey)
72
72
  const visualYankRange = visualYankRangeFor(map)
73
+ vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
74
+ const textObjectHandled = handleTextObject(vimeeKey, ctx, map)
75
+ if (textObjectHandled !== undefined) return textObjectHandled
73
76
  const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
74
77
  vim = result.newCtx
75
78
  if (hostEnd !== undefined && result.actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
@@ -230,6 +233,39 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
230
233
  return actions
231
234
  }
232
235
 
236
+ function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
237
+ if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined
238
+ const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer)
239
+ if (!range) return undefined
240
+
241
+ const flashRange = vim.operator === "y" ? motionHostRange(map, range) : undefined
242
+
243
+ if (!vim.operator) {
244
+ vim = { ...resetContext(vim), visualAnchor: range.start, cursor: range.end }
245
+ applyActions([{ type: "cursor-move", position: range.end }], ctx, map)
246
+ state.setPending("")
247
+ updateTimeout(ctx)
248
+ log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["cursor-move"] })
249
+ return true
250
+ }
251
+ if (!textObjectOperator(vim.operator)) return undefined
252
+
253
+ const originalCursor = vim.cursor
254
+ const result = executeTextObject(vim.operator, range, buffer, vim)
255
+ if (vim.operator === "y") {
256
+ result.context = { ...result.context, cursor: originalCursor }
257
+ result.actions = result.actions.map((action) => (action.type === "cursor-move" ? { ...action, position: originalCursor } : action))
258
+ }
259
+ vim = result.context
260
+ applyActions(result.actions, ctx, map)
261
+ if (flashRange) flashYank(ctx, activeMap, { type: "yank", text: result.yankedText }, flashRange)
262
+ syncMode(state, vim.mode)
263
+ state.setPending("")
264
+ updateTimeout(ctx)
265
+ log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
266
+ return true
267
+ }
268
+
233
269
  function updateTimeout(ctx: PromptContext) {
234
270
  if (timer) clearTimeout(timer)
235
271
  timer = undefined
@@ -370,6 +406,13 @@ function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string):
370
406
  return vimOffsetRange(map, start, start + text.length - 1)
371
407
  }
372
408
 
409
+ function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined {
410
+ if (range.linewise) return visualLineRange(map, range.start, range.end)
411
+ const start = vimOffsetFromPosition(map.vimText, range.start)
412
+ const end = vimOffsetFromPosition(map.vimText, range.end)
413
+ return vimOffsetRange(map, start, end)
414
+ }
415
+
373
416
  function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
374
417
  const start = hostFromVimOffset(map, Math.min(left, right), "next")
375
418
  const end = hostFromVimOffset(map, Math.max(left, right), "previous")
@@ -572,6 +615,206 @@ function consumesKey(key: string, actions: VimeeAction[], ctx: VimContext, keybi
572
615
  return ctx.mode !== "insert" || key === "Escape"
573
616
  }
574
617
 
618
+ function textObjectAlias(key: string, ctx: VimContext) {
619
+ if (ctx.phase !== "text-object-pending") return undefined
620
+ if (key === "b") return "("
621
+ if (key === "B") return "{"
622
+ return undefined
623
+ }
624
+
625
+ function textObjectOperator(operator: Operator): operator is "y" | "d" | "c" {
626
+ return operator === "y" || operator === "d" || operator === "c"
627
+ }
628
+
629
+ function resolvePromptTextObject(modifier: "i" | "a", key: string, cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
630
+ if (key === "q") return quoteRange(modifier, cursor, buffer)
631
+ if (key !== "p") return null
632
+ return paragraphRange(modifier, cursor, buffer)
633
+ }
634
+
635
+ function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
636
+ const pair = quoteObjectPair(cursor, buffer)
637
+ if (!pair) return null
638
+ const start = modifier === "i" ? pair.open + 1 : pair.open
639
+ const end = modifier === "i" ? pair.close - 1 : pair.close
640
+ return {
641
+ start: { line: cursor.line, col: start },
642
+ end: { line: cursor.line, col: Math.max(start, end) },
643
+ linewise: false,
644
+ inclusive: true,
645
+ }
646
+ }
647
+
648
+ function paragraphRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
649
+ const count = buffer.getLineCount()
650
+ if (count === 0) return null
651
+
652
+ let start = clamp(cursor.line, 0, count - 1)
653
+ while (start < count && blankLine(buffer.getLine(start))) start++
654
+ if (start >= count) {
655
+ start = clamp(cursor.line, 0, count - 1)
656
+ while (start >= 0 && blankLine(buffer.getLine(start))) start--
657
+ }
658
+ if (start < 0 || start >= count) return null
659
+
660
+ let end = start
661
+ while (start > 0 && !blankLine(buffer.getLine(start - 1))) start--
662
+ while (end < count - 1 && !blankLine(buffer.getLine(end + 1))) end++
663
+
664
+ if (modifier === "a") {
665
+ if (end < count - 1 && blankLine(buffer.getLine(end + 1))) {
666
+ end++
667
+ while (end < count - 1 && blankLine(buffer.getLine(end + 1))) end++
668
+ } else {
669
+ while (start > 0 && blankLine(buffer.getLine(start - 1))) start--
670
+ }
671
+ }
672
+
673
+ return {
674
+ start: { line: start, col: 0 },
675
+ end: { line: end, col: Math.max(0, buffer.getLineLength(end) - 1) },
676
+ linewise: true,
677
+ inclusive: true,
678
+ }
679
+ }
680
+
681
+ function quoteObjectPair(cursor: CursorPosition, buffer: TextBuffer) {
682
+ let best: { open: number; close: number; distance: number } | undefined
683
+ for (const quote of ['"', "'", "`"] as const) {
684
+ const pair = quotePair(cursor, buffer, quote)
685
+ if (!pair) continue
686
+ if (!best || pair.distance < best.distance) best = pair
687
+ }
688
+ return best
689
+ }
690
+
691
+ function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
692
+ const line = buffer.getLine(cursor.line)
693
+ let open = -1
694
+ let close = -1
695
+ let inQuote = false
696
+ let quoteStart = -1
697
+
698
+ for (let index = 0; index < line.length; index++) {
699
+ if (line[index] !== quote || escaped(line, index)) continue
700
+ if (!inQuote) {
701
+ quoteStart = index
702
+ inQuote = true
703
+ continue
704
+ }
705
+ if (cursor.col >= quoteStart && cursor.col <= index) {
706
+ return { open: quoteStart, close: index, distance: 0 }
707
+ }
708
+ inQuote = false
709
+ }
710
+
711
+ for (let index = cursor.col; index < line.length; index++) {
712
+ if (line[index] !== quote || escaped(line, index)) continue
713
+ if (open === -1) open = index
714
+ else {
715
+ close = index
716
+ break
717
+ }
718
+ }
719
+ if (open !== -1 && close !== -1) return { open, close, distance: open - cursor.col }
720
+
721
+ close = -1
722
+ open = -1
723
+ for (let index = cursor.col; index >= 0; index--) {
724
+ if (line[index] !== quote || escaped(line, index)) continue
725
+ if (close === -1) close = index
726
+ else {
727
+ open = index
728
+ break
729
+ }
730
+ }
731
+ if (open !== -1 && close !== -1) return { open, close, distance: cursor.col - close }
732
+ return undefined
733
+ }
734
+
735
+ function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
736
+ buffer.saveUndoPoint(ctx.cursor)
737
+ const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer)
738
+ const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
739
+ const context = {
740
+ ...resetContext(ctx),
741
+ mode: result.mode,
742
+ cursor: result.cursor,
743
+ register: result.yankedText,
744
+ registers,
745
+ statusMessage: result.statusMessage,
746
+ }
747
+ const actions: VimeeAction[] = [{ type: "yank", text: result.yankedText }, ...result.actions, { type: "mode-change", mode: result.mode }, { type: "cursor-move", position: result.cursor }]
748
+ return { actions, context, yankedText: result.yankedText }
749
+ }
750
+
751
+ function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
752
+ const startLine = Math.min(range.start.line, range.end.line)
753
+ const endLine = Math.max(range.start.line, range.end.line)
754
+ const lineCount = endLine - startLine + 1
755
+ const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n"
756
+ if (operator === "y") {
757
+ return { actions: [] as VimeeAction[], cursor: { line: startLine, col: 0 }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" }
758
+ }
759
+
760
+ buffer.deleteLines(startLine, lineCount)
761
+ if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
762
+ const line = Math.min(startLine, buffer.getLineCount() - 1)
763
+ if (operator === "c") buffer.insertLine(line, "")
764
+ return {
765
+ actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
766
+ cursor: { line, col: 0 },
767
+ mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
768
+ yankedText,
769
+ statusMessage: lineCount >= 2 ? `${lineCount} fewer lines` : "",
770
+ }
771
+ }
772
+
773
+ function executeCharwiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
774
+ const ordered = orderedRange(range)
775
+ const endCol = range.inclusive ? ordered.end.col + 1 : ordered.end.col
776
+ const yankedText = textInRange(buffer, ordered.start, { line: ordered.end.line, col: endCol })
777
+ if (operator === "y") {
778
+ return { actions: [] as VimeeAction[], cursor: ordered.start, mode: "normal" as VimeeMode, yankedText, statusMessage: yankedText.split("\n").length >= 2 ? `${yankedText.split("\n").length} lines yanked` : "" }
779
+ }
780
+
781
+ const linesBefore = buffer.getLineCount()
782
+ buffer.deleteRange(ordered.start.line, ordered.start.col, ordered.end.line, endCol)
783
+ const linesRemoved = linesBefore - buffer.getLineCount()
784
+ return {
785
+ actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
786
+ cursor: { line: ordered.start.line, col: operator === "c" ? ordered.start.col : Math.min(ordered.start.col, Math.max(0, buffer.getLineLength(ordered.start.line) - 1)) },
787
+ mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
788
+ yankedText,
789
+ statusMessage: linesRemoved >= 2 ? `${linesRemoved} fewer lines` : "",
790
+ }
791
+ }
792
+
793
+ function orderedRange(range: MotionRange) {
794
+ if (range.start.line > range.end.line || (range.start.line === range.end.line && range.start.col > range.end.col)) {
795
+ return { start: range.end, end: range.start }
796
+ }
797
+ return { start: range.start, end: range.end }
798
+ }
799
+
800
+ function textInRange(buffer: TextBuffer, start: CursorPosition, end: CursorPosition) {
801
+ if (start.line === end.line) return buffer.getLine(start.line).slice(start.col, end.col)
802
+ const lines = [buffer.getLine(start.line).slice(start.col)]
803
+ for (let line = start.line + 1; line < end.line; line++) lines.push(buffer.getLine(line))
804
+ lines.push(buffer.getLine(end.line).slice(0, end.col))
805
+ return lines.join("\n")
806
+ }
807
+
808
+ function blankLine(line: string) {
809
+ return line.trim().length === 0
810
+ }
811
+
812
+ function escaped(line: string, index: number) {
813
+ let slashCount = 0
814
+ for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor--) slashCount++
815
+ return slashCount % 2 === 1
816
+ }
817
+
575
818
  function clamp(value: number, min: number, max: number) {
576
819
  return Math.max(min, Math.min(max, value))
577
820
  }
package/src/plugin.tsx CHANGED
@@ -5,25 +5,31 @@ import { PromptRoot } from "./prompt/root"
5
5
  import type { PromptModule } from "./prompt/types"
6
6
  import { createVimModule } from "./modules/vim"
7
7
  import { createSnippetsModule } from "./modules/snippets"
8
+ import type { SnippetController } from "./modules/snippets/types"
8
9
  import { checkAutoUpdate } from "./update"
9
10
 
10
11
  const tui: TuiPlugin = async (api, options, meta) => {
11
12
  const vimEnabledKey = `${meta.id}:enabled`
12
13
  const [vimEnabled, setVimEnabled] = createSignal(api.kv.get(vimEnabledKey, true) === true)
13
14
 
14
- api.command.register(() => [{
15
- title: vimEnabled() ? "Disable Vim Mode" : "Enable Vim Mode",
16
- value: "opencode-vim.toggle",
17
- description: vimEnabled() ? "Turn off Vim key handling" : "Turn on Vim key handling",
18
- category: "Vim",
19
- slash: { name: "vim" },
20
- onSelect() {
21
- const next = !vimEnabled()
22
- setVimEnabled(next)
23
- api.kv.set(vimEnabledKey, next)
24
- api.ui.toast({ variant: "info", message: `Vim mode ${next ? "enabled" : "disabled"}` })
25
- },
26
- }])
15
+ api.keymap.registerLayer({
16
+ commands: [
17
+ {
18
+ namespace: "palette",
19
+ name: "opencode-vim.toggle",
20
+ title: "Toggle Vim Mode",
21
+ desc: "Enable or disable Vim key handling",
22
+ category: "Vim",
23
+ slashName: "vim",
24
+ run() {
25
+ const next = !vimEnabled()
26
+ setVimEnabled(next)
27
+ api.kv.set(vimEnabledKey, next)
28
+ api.ui.toast({ variant: "info", message: `Vim mode ${next ? "enabled" : "disabled"}` })
29
+ },
30
+ },
31
+ ],
32
+ })
27
33
 
28
34
  if (readAutoUpdate(options)) {
29
35
  let timer: Timer | undefined
@@ -46,8 +52,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
46
52
  if (cached) return cached
47
53
 
48
54
  const modules: PromptModule[] = []
49
- if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule())
50
- modules.push(createVimModule(options, vimEnabled))
55
+ const snippets: SnippetController = {}
56
+ if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule(snippets))
57
+ modules.push(createVimModule(options, vimEnabled, snippets))
51
58
  moduleCache.set(key, modules)
52
59
  return modules
53
60
  }