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 +18 -15
- package/package.json +5 -4
- package/src/modules/snippets/index.tsx +1 -3
- package/src/modules/snippets/trigger.ts +14 -6
- package/src/modules/snippets/types.ts +1 -0
- package/src/modules/snippets/view.tsx +88 -41
- package/src/modules/vim/index.tsx +33 -12
- package/src/modules/vim/vimee.ts +77 -41
- package/src/plugin.tsx +22 -15
package/bun.lock
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "opencode-vim",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@opentui/core": "^0.2.
|
|
9
|
-
"@opentui/
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
11
|
-
"@opentui/
|
|
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.
|
|
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
|
-
|
|
7
|
-
|
|
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 =
|
|
17
|
+
const query = input.slice(start + 1)
|
|
10
18
|
const token = `#${query}`
|
|
11
19
|
|
|
12
20
|
return {
|
|
13
|
-
start
|
|
21
|
+
start,
|
|
14
22
|
end: input.length,
|
|
15
23
|
query,
|
|
16
24
|
token,
|
|
@@ -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
|
-
|
|
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.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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" &&
|
|
302
|
-
if (
|
|
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
|
package/src/modules/vim/vimee.ts
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
|
176
|
-
if (
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
209
|
-
return
|
|
211
|
+
log("vimee.keybind.unsupported", { key })
|
|
212
|
+
return false
|
|
210
213
|
}
|
|
211
214
|
case "none":
|
|
212
|
-
if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore
|
|
215
|
+
if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore)
|
|
213
216
|
pendingInsert = ""
|
|
214
217
|
state.setPending("")
|
|
215
218
|
updateTimeout(ctx)
|
|
216
|
-
return
|
|
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):
|
|
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.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
50
|
-
modules.push(
|
|
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
|
}
|