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 +14 -1
- package/assets/demo.gif +0 -0
- package/assets/demo2.gif +0 -0
- package/bun.lock +18 -15
- package/package.json +5 -4
- package/src/modules/snippets/index.tsx +1 -3
- package/src/modules/snippets/types.ts +1 -0
- package/src/modules/snippets/view.tsx +85 -39
- package/src/modules/vim/index.tsx +31 -8
- package/src/modules/vim/vimee.ts +246 -3
- package/src/plugin.tsx +22 -15
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
|
-

|
|
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
|
package/assets/demo2.gif
ADDED
|
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.
|
|
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.12",
|
|
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,
|
|
@@ -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.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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" &&
|
|
302
|
-
if (
|
|
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
|
package/src/modules/vim/vimee.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
}
|