opencode-vim 0.0.11 → 0.0.13

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/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.11",
3
+ "version": "0.0.13",
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,
@@ -1,16 +1,24 @@
1
1
  import type { HashtagTriggerMatch, SnippetInfo } from "./types"
2
2
 
3
- const HASHTAG_TRIGGER = /(^|\s)#([^\s#]*)$/
4
-
5
3
  export function findTrailingHashtagTrigger(input: string): HashtagTriggerMatch | undefined {
6
- const match = HASHTAG_TRIGGER.exec(input)
7
- if (!match) return undefined
4
+ let start = -1
5
+ for (let i = input.length - 1; i >= 0; i--) {
6
+ const char = input[i]
7
+ if (char === "#") {
8
+ start = i
9
+ break
10
+ }
11
+ if (/\s/.test(char)) return undefined
12
+ }
13
+
14
+ if (start < 0) return undefined
15
+ if (start > 0 && !/\s/.test(input[start - 1])) return undefined
8
16
 
9
- const query = match[2] ?? ""
17
+ const query = input.slice(start + 1)
10
18
  const token = `#${query}`
11
19
 
12
20
  return {
13
- start: input.length - token.length,
21
+ start,
14
22
  end: input.length,
15
23
  query,
16
24
  token,
@@ -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
  }
@@ -82,11 +82,12 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
82
82
  syncInterval = setInterval(() => {
83
83
  const ref = props.ctx.prompt()
84
84
  if (!ref) {
85
- setInput("")
85
+ if (input()) setInput("")
86
86
  setSyncingPrompt(false)
87
87
  return
88
88
  }
89
- setInput(ref.current.input ?? "")
89
+ const next = ref.current.input ?? ""
90
+ if (next !== input()) setInput(next)
90
91
  }, PROMPT_SYNC_MS)
91
92
 
92
93
  onCleanup(() => {
@@ -100,6 +101,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
100
101
  props.controller.accept = undefined
101
102
  props.controller.reload = undefined
102
103
  props.controller.insertTrigger = undefined
104
+ props.controller.navigate = undefined
103
105
  })
104
106
 
105
107
  const dialogBlockingInput = () => dialogOpen() || dialogHandoffUntil() > Date.now()
@@ -156,6 +158,27 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
156
158
  return item ? chooseItem(item) : false
157
159
  }
158
160
 
161
+ const navigate = (delta: number) => {
162
+ const ref = props.ctx.prompt()
163
+ if (!ref || !ref.focused || dialogBlockingInput()) return false
164
+
165
+ const current = findTrailingHashtagTrigger(ref.current.input)
166
+ if (!current || dismissed() === current.token) return false
167
+
168
+ const value = current.query.trim()
169
+ const total = optionsForQuery(value).length
170
+ if (total === 0 && !canCreateForQuery(current.query)) return false
171
+
172
+ if (pendingPromptSync) clearTimeout(pendingPromptSync)
173
+ pendingPromptSync = undefined
174
+ setInput(ref.current.input)
175
+ setSyncingPrompt(false)
176
+ lockKeyboardSelection()
177
+ setSelected((current) => stepSelection(current, total || 1, delta))
178
+ props.ctx.requestRender()
179
+ return true
180
+ }
181
+
159
182
  const accept = () => {
160
183
  const ref = props.ctx.prompt()
161
184
  if (!ref) return false
@@ -205,6 +228,14 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
205
228
  return false
206
229
  }
207
230
 
231
+ const canAcceptSubmit = (ref: TuiPromptRef) => {
232
+ if (isReloadCommand(ref.current.input)) return true
233
+ if (dialogBlockingInput()) return true
234
+
235
+ const current = findTrailingHashtagTrigger(ref.current.input)
236
+ return !!current && dismissed() !== current.token
237
+ }
238
+
208
239
  props.controller.accept = accept
209
240
  props.controller.reload = () => {
210
241
  const ref = props.ctx.prompt()
@@ -216,6 +247,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
216
247
  syncPromptInput(ref, insertSnippetTrigger(ref.current.input))
217
248
  ref.focus()
218
249
  }
250
+ props.controller.navigate = navigate
219
251
 
220
252
  createEffect(() => {
221
253
  const ref = props.ctx.prompt()
@@ -229,40 +261,45 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
229
261
  commandTimer = undefined
230
262
  if (disposed) return
231
263
 
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)
264
+ commandDispose = props.ctx.api.keymap.registerLayer({
265
+ commands: [
266
+ {
267
+ namespace: "palette",
268
+ name: "snippets.reload",
269
+ title: "Reload snippets",
270
+ desc: "Reload snippet files from disk",
271
+ category: "Prompt",
272
+ slashName: "snippets:reload",
273
+ run() {
274
+ void executeReloadInPrompt(ref)
275
+ },
241
276
  },
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()
277
+ {
278
+ namespace: "palette",
279
+ name: "snippets.insert",
280
+ title: "Insert snippet",
281
+ desc: "Insert a snippet trigger into the prompt",
282
+ category: "Prompt",
283
+ run() {
284
+ syncPromptInput(ref, insertSnippetTrigger(ref.current.input))
285
+ ref.focus()
286
+ },
251
287
  },
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()
288
+ {
289
+ namespace: "palette",
290
+ name: "snippets.accept",
291
+ title: "Accept snippet autocomplete",
292
+ category: "Prompt",
293
+ hidden: true,
294
+ enabled: () => ref.focused && canAcceptSubmit(ref),
295
+ run() {
296
+ if (accept()) return
297
+ ref.submit()
298
+ },
263
299
  },
264
- },
265
- ])
300
+ ],
301
+ bindings: acceptSnippetBindings(props.ctx.api),
302
+ })
266
303
  }, 0)
267
304
  })
268
305
 
@@ -280,12 +317,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
280
317
  if (dialogBlockingInput()) return
281
318
  if (!visible()) return
282
319
 
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))
320
+ if ((name === "up" || name === "down") && navigate(name === "up" ? -1 : 1)) {
289
321
  event.preventDefault()
290
322
  event.stopPropagation()
291
323
  return
@@ -298,8 +330,8 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
298
330
  return
299
331
  }
300
332
 
301
- if (name === "tab" && actionable) {
302
- if (total > 0) choose()
333
+ if (name === "tab" && (options().length > 0 || canCreate())) {
334
+ if (options().length > 0) choose()
303
335
  else void createSnippetDraft()
304
336
  event.preventDefault()
305
337
  event.stopPropagation()
@@ -559,6 +591,21 @@ function syncPromptInput(prompt: TuiPromptRef, input: string) {
559
591
  prompt.set({ input, mode: prompt.current.mode, parts: [...prompt.current.parts] })
560
592
  }
561
593
 
594
+ function acceptSnippetBindings(api: TuiPluginApi) {
595
+ const command = "snippets.accept"
596
+ const title = "Accept snippet autocomplete"
597
+
598
+ if (!api.tuiConfig.keybinds.has("input.submit")) {
599
+ return [{ key: "input_submit", cmd: command, desc: title }]
600
+ }
601
+
602
+ return api.tuiConfig.keybinds.get("input.submit").map((binding) => ({
603
+ ...binding,
604
+ cmd: command,
605
+ desc: binding.desc ?? title,
606
+ }))
607
+ }
608
+
562
609
  function selectedText(theme: PromptContext["api"]["theme"]["current"]) {
563
610
  if (theme.background.a !== 0) return theme.background
564
611
  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,11 @@ 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())
70
- syncCursorStyle(true)
71
- props.ctx.requestRender()
77
+ if (!preparedEvents.delete(event)) preparePassThroughKey(props.ctx, key, props.state.mode())
72
78
  return
73
79
  }
74
80
 
75
- if (sendNavigationKey(event, props.ctx, key, props.state.mode())) {
81
+ if (sendNavigationKey(event, props.ctx, key, props.state.mode(), props.snippets)) {
76
82
  syncCursorStyle(true)
77
83
  props.ctx.requestRender()
78
84
  return
@@ -82,13 +88,14 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
82
88
  if (consumed) {
83
89
  event.preventDefault()
84
90
  event.stopPropagation()
91
+ syncCursorStyle(true)
92
+ props.ctx.requestRender()
85
93
  }
86
- syncCursorStyle(true)
87
- props.ctx.requestRender()
88
94
  })
89
95
 
90
96
  onCleanup(() => {
91
97
  props.log("keyboard.cleanup", { kind: props.ctx.kind })
98
+ offKeyIntercept()
92
99
  vimee.cleanup()
93
100
  clearInterval(cursorStyleTimer)
94
101
  })
@@ -116,14 +123,22 @@ function passThroughKey(event: KeyEvent, key: string, mode: string) {
116
123
  }
117
124
 
118
125
  function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) {
119
- if (mode !== "normal" || key !== "<CR>") return
126
+ if (mode !== "normal" || key !== "<CR>") return false
120
127
  const input = focusedInput(ctx)
121
- if (!input?.plainText || input.cursorOffset === undefined) return
128
+ if (!input?.plainText || input.cursorOffset === undefined) return false
122
129
  input.cursorOffset = Math.min(input.cursorOffset + 1, input.plainText.length)
130
+ return true
123
131
  }
124
132
 
125
- function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string) {
133
+ function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) {
126
134
  if (mode !== "normal") return false
135
+ const delta = snippetNavigationDelta(key)
136
+ if (delta !== 0 && snippets?.navigate?.(delta)) {
137
+ event.preventDefault()
138
+ event.stopPropagation()
139
+ return true
140
+ }
141
+
127
142
  const forwarded = commandNavigationKey(ctx, key)
128
143
  if (!forwarded) return false
129
144
 
@@ -133,6 +148,12 @@ function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mod
133
148
  return true
134
149
  }
135
150
 
151
+ function snippetNavigationDelta(key: string) {
152
+ if (key === "j") return 1
153
+ if (key === "k") return -1
154
+ return 0
155
+ }
156
+
136
157
  function commandNavigationKey(ctx: PromptContext, key: string): ParsedKey | undefined {
137
158
  if (!isPromptFocused(ctx)) return navigationKey(key)
138
159
  if (!isSlashCommandToken(ctx)) return undefined
@@ -10,6 +10,8 @@ import type { createVimState } from "./state"
10
10
 
11
11
  type VimState = ReturnType<typeof createVimState>
12
12
  type HostAction = VimeeAction | { type: "submit" }
13
+ type HostKeybindAction = "normal" | "submit"
14
+ type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction }
13
15
  type HostRange = { start: number; end: number }
14
16
 
15
17
  const YANK_FLASH_MS = 250
@@ -33,11 +35,6 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
33
35
  const ref = ctx.prompt()
34
36
  if (!ref) return false
35
37
 
36
- const input = focusedInput(ctx)
37
- const text = input?.plainText ?? ref.current.input
38
- const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
39
- const map = mapForHostText(text, input)
40
- const cursor = hostPosition(map, offset)
41
38
  let vimeeKey = keyForVimee(event, key)
42
39
  if (!vimeeKey) return false
43
40
 
@@ -46,16 +43,29 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
46
43
  return true
47
44
  }
48
45
 
49
- if (state.mode() === "insert" && key === "<CR>") {
50
- cancelPendingInsert(ctx, offset)
51
- return false
52
- }
53
-
54
46
  if (state.mode() === "insert") {
55
- const consumed = handleInsertMode(event, vimeeKey, ctx, map, cursor, offset)
56
- return consumed ?? false
47
+ if (key === "<CR>") {
48
+ cancelPendingInsert(ctx)
49
+ return false
50
+ }
51
+ if (vimeeKey === "Escape") {
52
+ cancelPendingInsert(ctx, undefined, false)
53
+ enterNormal(ctx)
54
+ state.setPending("")
55
+ updateTimeout(ctx)
56
+ log("vimee.key", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
57
+ return true
58
+ }
59
+ return handleInsertKeybind(event, vimeeKey, ctx)
57
60
  }
58
61
 
62
+ const input = focusedInput(ctx)
63
+ const text = input?.plainText ?? ref.current.input
64
+ const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
65
+
66
+ const map = mapForHostText(text, input)
67
+ const cursor = hostPosition(map, offset)
68
+
59
69
  const wasPending = keybinds?.isPending() ?? false
60
70
  const pendingBefore = pendingInsert
61
71
  sync(map, cursor)
@@ -172,20 +182,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
172
182
  syncVisualSelection(input, currentMap, ctx)
173
183
  }
174
184
 
175
- function handleInsertMode(event: KeyEvent, key: string, ctx: PromptContext, map: PromptMap, cursor: CursorPosition, offset: number) {
176
- if (key === "Escape") {
177
- cancelPendingInsert(ctx, offset, false)
178
- sync(map, cursor)
179
- const result = processKeystroke(key, vim, buffer, event.ctrl, false)
180
- vim = result.newCtx
181
- applyActions(result.actions as HostAction[], ctx, map)
182
- syncMode(state, vim.mode)
183
- state.setPending("")
184
- log("vimee.key", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
185
- return true
186
- }
187
-
188
- if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return undefined
185
+ function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext) {
186
+ if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return false
189
187
 
190
188
  const wasPending = keybinds.isPending()
191
189
  const pendingBefore = pendingInsert
@@ -198,25 +196,67 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
198
196
  updateTimeout(ctx)
199
197
  return true
200
198
  case "matched": {
201
- sync(map, cursor)
202
- const actions = applyKeybind(resolved.definition, map)
203
- applyActions(actions, ctx, map)
204
- syncMode(state, vim.mode)
199
+ if (applyInsertKeybind(resolved.definition, ctx)) {
200
+ pendingInsert = ""
201
+ state.setPending("")
202
+ updateTimeout(ctx)
203
+ log("vimee.keybind", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
204
+ return true
205
+ }
206
+
207
+ if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore)
205
208
  pendingInsert = ""
206
209
  state.setPending("")
207
210
  updateTimeout(ctx)
208
- log("vimee.keybind", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
209
- return true
211
+ log("vimee.keybind.unsupported", { key })
212
+ return false
210
213
  }
211
214
  case "none":
212
- if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore, offset)
215
+ if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore)
213
216
  pendingInsert = ""
214
217
  state.setPending("")
215
218
  updateTimeout(ctx)
216
- return wasPending ? false : undefined
219
+ return false
220
+ }
221
+ }
222
+
223
+ function applyInsertKeybind(definition: KeybindDefinition, ctx: PromptContext) {
224
+ switch (insertHostAction(definition)) {
225
+ case "normal":
226
+ enterNormal(ctx)
227
+ return true
228
+ case "submit":
229
+ ctx.prompt()?.submit()
230
+ return true
231
+ default:
232
+ return false
217
233
  }
218
234
  }
219
235
 
236
+ function enterNormal(ctx: PromptContext) {
237
+ const ref = ctx.prompt()
238
+ const input = focusedInput(ctx)
239
+ const text = input?.plainText ?? ref?.current.input ?? ""
240
+ const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
241
+
242
+ if (input && text.length > 0) {
243
+ input.cursorOffset = Math.max(0, offset - 1)
244
+ clampNormalCursor(input)
245
+ }
246
+
247
+ vim = { ...resetContext(vim), mode: "normal", statusMessage: "" }
248
+ nativeInsertUndoSaved = false
249
+ syncMode(state, "normal")
250
+ }
251
+
252
+ function insertHostAction(definition: KeybindDefinition): HostKeybindAction | undefined {
253
+ const action = (definition as HostKeybindDefinition).hostAction
254
+ if (action) return action
255
+ if ("execute" in definition) return undefined
256
+ if (definition.keys === "<Esc>" || definition.keys === "<C-[>" || definition.keys === "Escape") return "normal"
257
+ return undefined
258
+ }
259
+
220
260
  function applyKeybind(definition: KeybindDefinition, map: PromptMap) {
221
261
  if ("execute" in definition) {
222
262
  const actions = definition.execute(vim, buffer) as HostAction[]
@@ -299,12 +339,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
299
339
  const currentOffset = input?.cursorOffset ?? insertAt
300
340
  const next = text.slice(0, insertAt) + value + text.slice(insertAt)
301
341
  setInput(ref, next)
302
- activeMap = createPromptMap(next, input)
303
- rememberMap(activeMap)
304
- buffer.replaceContent(activeMap.vimText)
305
342
  const nextOffset = currentOffset >= insertAt ? currentOffset + value.length : currentOffset
306
343
  if (input) input.cursorOffset = nextOffset
307
- vim = { ...vim, cursor: hostPosition(activeMap, nextOffset) }
308
344
  }
309
345
 
310
346
  function cursorOffset(map: PromptMap, position: CursorPosition) {
@@ -553,14 +589,14 @@ function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined
553
589
  return count > 0 ? map : undefined
554
590
  }
555
591
 
556
- function keybindAction(action: string): KeybindDefinition {
592
+ function keybindAction(action: string): HostKeybindDefinition {
557
593
  switch (action) {
558
594
  case "normal":
559
- return { keys: "<Esc>" }
595
+ return { keys: "<Esc>", hostAction: "normal" }
560
596
  case "insert":
561
597
  return { keys: "i" }
562
598
  case "submit":
563
- return { execute: () => [{ type: "submit" } as unknown as VimeeAction] }
599
+ return { execute: () => [{ type: "submit" } as unknown as VimeeAction], hostAction: "submit" }
564
600
  default:
565
601
  return { keys: action }
566
602
  }
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
  }