opencode-vim 0.0.2
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 +13 -0
- package/bun.lock +15 -0
- package/docs/configuration.md +403 -0
- package/package.json +12 -0
- package/src/modules/snippets/index.tsx +19 -0
- package/src/modules/snippets/loader.ts +147 -0
- package/src/modules/snippets/search.ts +118 -0
- package/src/modules/snippets/skill-loader.ts +81 -0
- package/src/modules/snippets/state.ts +40 -0
- package/src/modules/snippets/trigger.ts +60 -0
- package/src/modules/snippets/types.ts +49 -0
- package/src/modules/snippets/view.tsx +626 -0
- package/src/modules/vim/actions.ts +54 -0
- package/src/modules/vim/config.ts +119 -0
- package/src/modules/vim/index.tsx +96 -0
- package/src/modules/vim/keys.ts +34 -0
- package/src/modules/vim/log.ts +37 -0
- package/src/modules/vim/map.ts +159 -0
- package/src/modules/vim/state.ts +24 -0
- package/src/modules/vim/view.tsx +86 -0
- package/src/modules/vim/vimee.ts +364 -0
- package/src/plugin.tsx +69 -0
- package/src/prompt/host.tsx +38 -0
- package/src/prompt/modules.ts +16 -0
- package/src/prompt/ref.ts +17 -0
- package/src/prompt/root.tsx +56 -0
- package/src/prompt/types.ts +42 -0
- package/tsconfig.json +12 -0
- package/tui.json.example +37 -0
- package/tui.tsx +1 -0
|
@@ -0,0 +1,364 @@
|
|
|
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"
|
|
4
|
+
import type { PromptContext } from "../../prompt/types"
|
|
5
|
+
import { focusedInput, setInput, type EditBufferLike } from "./actions"
|
|
6
|
+
import type { VimConfig } from "./config"
|
|
7
|
+
import type { VimLog } from "./log"
|
|
8
|
+
import { createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
|
|
9
|
+
import type { createVimState } from "./state"
|
|
10
|
+
|
|
11
|
+
type VimState = ReturnType<typeof createVimState>
|
|
12
|
+
type HostAction = VimeeAction | { type: "submit" }
|
|
13
|
+
|
|
14
|
+
export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
|
|
15
|
+
|
|
16
|
+
export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog) {
|
|
17
|
+
let buffer = new TextBuffer("")
|
|
18
|
+
let activeMap = createPromptMap("")
|
|
19
|
+
const maps = new Map([[activeMap.vimText, activeMap]])
|
|
20
|
+
let vim = createInitialContext({ line: 0, col: 0 })
|
|
21
|
+
const keybinds = createKeybinds(config, log)
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
23
|
+
let pendingInsert = ""
|
|
24
|
+
let nativeInsertUndoSaved = false
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
handle(event: KeyEvent, key: string, ctx: PromptContext) {
|
|
28
|
+
const ref = ctx.prompt()
|
|
29
|
+
if (!ref) return false
|
|
30
|
+
|
|
31
|
+
const input = focusedInput(ctx)
|
|
32
|
+
const text = input?.plainText ?? ref.current.input
|
|
33
|
+
const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
|
|
34
|
+
const map = mapForHostText(text, input)
|
|
35
|
+
const cursor = hostPosition(map, offset)
|
|
36
|
+
const vimeeKey = keyForVimee(event, key)
|
|
37
|
+
if (!vimeeKey) return false
|
|
38
|
+
|
|
39
|
+
if (state.mode() === "normal" && key === "<CR>") {
|
|
40
|
+
ref.submit()
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (state.mode() === "insert" && key === "<CR>") {
|
|
45
|
+
cancelPendingInsert(ctx, offset)
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (state.mode() === "insert") {
|
|
50
|
+
const consumed = handleInsertMode(event, vimeeKey, ctx, map, cursor, offset)
|
|
51
|
+
return consumed ?? false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const wasPending = keybinds?.isPending() ?? false
|
|
55
|
+
const pendingBefore = pendingInsert
|
|
56
|
+
sync(map, cursor)
|
|
57
|
+
|
|
58
|
+
if (vimeeKey === "A" && appendVisualLine(input, map)) {
|
|
59
|
+
state.setPending("")
|
|
60
|
+
updateTimeout(ctx)
|
|
61
|
+
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
|
|
66
|
+
vim = result.newCtx
|
|
67
|
+
applyActions(result.actions as HostAction[], ctx, map)
|
|
68
|
+
syncMode(state, vim.mode)
|
|
69
|
+
const keybindPending = keybinds?.isPending() ?? false
|
|
70
|
+
if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, offset)
|
|
71
|
+
pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
|
|
72
|
+
state.setPending(pendingDisplay(vim, keybindPending))
|
|
73
|
+
updateTimeout(ctx)
|
|
74
|
+
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
|
|
75
|
+
return consumesKey(vimeeKey, result.actions, vim, keybindPending)
|
|
76
|
+
},
|
|
77
|
+
cleanup() {
|
|
78
|
+
if (timer) clearTimeout(timer)
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sync(map: PromptMap, cursor: CursorPosition) {
|
|
83
|
+
if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
|
|
84
|
+
vim = { ...vim, cursor, mode: state.mode() }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mapForHostText(text: string, input: ReturnType<typeof focusedInput>) {
|
|
88
|
+
if (activeMap.hostText === text) return activeMap
|
|
89
|
+
activeMap = createPromptMap(text, input)
|
|
90
|
+
rememberMap(activeMap)
|
|
91
|
+
if (state.mode() === "insert") {
|
|
92
|
+
if (!nativeInsertUndoSaved) {
|
|
93
|
+
buffer.saveUndoPoint(vim.cursor)
|
|
94
|
+
nativeInsertUndoSaved = true
|
|
95
|
+
}
|
|
96
|
+
buffer.replaceContent(activeMap.vimText)
|
|
97
|
+
} else {
|
|
98
|
+
buffer = new TextBuffer(activeMap.vimText)
|
|
99
|
+
nativeInsertUndoSaved = false
|
|
100
|
+
}
|
|
101
|
+
return activeMap
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rememberMap(map: PromptMap) {
|
|
105
|
+
maps.set(map.vimText, map)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function nextMap(map: PromptMap, vimText: string) {
|
|
109
|
+
const known = maps.get(vimText)
|
|
110
|
+
if (known) return known
|
|
111
|
+
const next = derivePromptMap(map, vimText)
|
|
112
|
+
rememberMap(next)
|
|
113
|
+
return next
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) {
|
|
117
|
+
const ref = ctx.prompt()
|
|
118
|
+
const input = focusedInput(ctx)
|
|
119
|
+
let currentMap = map
|
|
120
|
+
if (!ref) return
|
|
121
|
+
|
|
122
|
+
for (const action of actions) {
|
|
123
|
+
switch (action.type) {
|
|
124
|
+
case "content-change":
|
|
125
|
+
rememberMap(currentMap)
|
|
126
|
+
currentMap = nextMap(currentMap, action.content)
|
|
127
|
+
activeMap = currentMap
|
|
128
|
+
setInput(ref, currentMap.hostText)
|
|
129
|
+
buffer.replaceContent(action.content)
|
|
130
|
+
break
|
|
131
|
+
case "cursor-move":
|
|
132
|
+
setCursor(input, currentMap, action.position)
|
|
133
|
+
break
|
|
134
|
+
case "mode-change":
|
|
135
|
+
nativeInsertUndoSaved = false
|
|
136
|
+
syncMode(state, action.mode)
|
|
137
|
+
break
|
|
138
|
+
case "quit":
|
|
139
|
+
ref.blur()
|
|
140
|
+
break
|
|
141
|
+
case "submit":
|
|
142
|
+
ref.submit()
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setCursor(input, currentMap, vim.cursor)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleInsertMode(event: KeyEvent, key: string, ctx: PromptContext, map: PromptMap, cursor: CursorPosition, offset: number) {
|
|
151
|
+
if (key === "Escape") {
|
|
152
|
+
cancelPendingInsert(ctx, offset)
|
|
153
|
+
sync(map, cursor)
|
|
154
|
+
const result = processKeystroke(key, vim, buffer, event.ctrl, false)
|
|
155
|
+
vim = result.newCtx
|
|
156
|
+
applyActions(result.actions as HostAction[], ctx, map)
|
|
157
|
+
syncMode(state, vim.mode)
|
|
158
|
+
state.setPending("")
|
|
159
|
+
log("vimee.key", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return undefined
|
|
164
|
+
|
|
165
|
+
const wasPending = keybinds.isPending()
|
|
166
|
+
const pendingBefore = pendingInsert
|
|
167
|
+
const resolved = keybinds.resolve(key, "insert", event.ctrl)
|
|
168
|
+
|
|
169
|
+
switch (resolved.status) {
|
|
170
|
+
case "pending":
|
|
171
|
+
pendingInsert = plainPending(resolved.display)
|
|
172
|
+
state.setPending(resolved.display)
|
|
173
|
+
updateTimeout(ctx)
|
|
174
|
+
return true
|
|
175
|
+
case "matched": {
|
|
176
|
+
sync(map, cursor)
|
|
177
|
+
const actions = applyKeybind(resolved.definition, map)
|
|
178
|
+
applyActions(actions, ctx, map)
|
|
179
|
+
syncMode(state, vim.mode)
|
|
180
|
+
pendingInsert = ""
|
|
181
|
+
state.setPending("")
|
|
182
|
+
updateTimeout(ctx)
|
|
183
|
+
log("vimee.keybind", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
case "none":
|
|
187
|
+
if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore, offset)
|
|
188
|
+
pendingInsert = ""
|
|
189
|
+
state.setPending("")
|
|
190
|
+
updateTimeout(ctx)
|
|
191
|
+
return wasPending ? false : undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function applyKeybind(definition: KeybindDefinition, map: PromptMap) {
|
|
196
|
+
if ("execute" in definition) {
|
|
197
|
+
const actions = definition.execute(vim, buffer) as HostAction[]
|
|
198
|
+
vim = { ...vim, cursor: hostPosition(map, cursorOffset(map, vim.cursor)) }
|
|
199
|
+
return actions
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let actions: HostAction[] = []
|
|
203
|
+
for (const token of parseKeySequence(definition.keys)) {
|
|
204
|
+
const result = processKeystroke(keyToken(token), vim, buffer, tokenCtrl(token), false)
|
|
205
|
+
vim = result.newCtx
|
|
206
|
+
actions = [...actions, ...(result.actions as HostAction[])]
|
|
207
|
+
}
|
|
208
|
+
return actions
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function updateTimeout(ctx: PromptContext) {
|
|
212
|
+
if (timer) clearTimeout(timer)
|
|
213
|
+
timer = undefined
|
|
214
|
+
if (!keybinds?.isPending()) return
|
|
215
|
+
timer = setTimeout(() => {
|
|
216
|
+
keybinds.cancel()
|
|
217
|
+
flushPendingInsert(ctx, pendingInsert)
|
|
218
|
+
pendingInsert = ""
|
|
219
|
+
state.setPending("")
|
|
220
|
+
ctx.requestRender()
|
|
221
|
+
}, config.keymapTimeout)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cancelPendingInsert(ctx: PromptContext, offset?: number) {
|
|
225
|
+
if (!keybinds?.isPending()) return
|
|
226
|
+
if (timer) clearTimeout(timer)
|
|
227
|
+
timer = undefined
|
|
228
|
+
keybinds.cancel()
|
|
229
|
+
flushPendingInsert(ctx, pendingInsert, offset)
|
|
230
|
+
pendingInsert = ""
|
|
231
|
+
state.setPending("")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function flushPendingInsert(ctx: PromptContext, value: string, offset?: number) {
|
|
235
|
+
if (!value || state.mode() !== "insert") return
|
|
236
|
+
const ref = ctx.prompt()
|
|
237
|
+
if (!ref) return
|
|
238
|
+
const input = focusedInput(ctx)
|
|
239
|
+
const text = input?.plainText ?? ref.current.input
|
|
240
|
+
const insertAt = clamp(offset ?? input?.cursorOffset ?? text.length, 0, text.length)
|
|
241
|
+
const currentOffset = input?.cursorOffset ?? insertAt
|
|
242
|
+
const next = text.slice(0, insertAt) + value + text.slice(insertAt)
|
|
243
|
+
setInput(ref, next)
|
|
244
|
+
activeMap = createPromptMap(next, input)
|
|
245
|
+
rememberMap(activeMap)
|
|
246
|
+
buffer.replaceContent(activeMap.vimText)
|
|
247
|
+
const nextOffset = currentOffset >= insertAt ? currentOffset + value.length : currentOffset
|
|
248
|
+
if (input) input.cursorOffset = nextOffset
|
|
249
|
+
vim = { ...vim, cursor: hostPosition(activeMap, nextOffset) }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cursorOffset(map: PromptMap, position: CursorPosition) {
|
|
253
|
+
return hostOffset(map, position, vim.mode === "insert" ? "next" : "previous")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) {
|
|
257
|
+
if (!input) return
|
|
258
|
+
input.cursorOffset = cursorOffset(map, position)
|
|
259
|
+
if (vim.mode !== "insert") clampNormalCursor(input)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function appendVisualLine(input: EditBufferLike | undefined, map: PromptMap) {
|
|
263
|
+
if (!input?.gotoVisualLineEnd) return false
|
|
264
|
+
input.gotoVisualLineEnd()
|
|
265
|
+
vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? map.hostText.length), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
|
|
266
|
+
nativeInsertUndoSaved = false
|
|
267
|
+
syncMode(state, "insert")
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function clampNormalCursor(input: EditBufferLike) {
|
|
273
|
+
const cursor = input.visualCursor
|
|
274
|
+
const eol = input.editorView?.getVisualEOL?.()
|
|
275
|
+
const offset = input.cursorOffset
|
|
276
|
+
if (!cursor || !eol || offset === undefined) return
|
|
277
|
+
if (cursor.visualCol === 0) return
|
|
278
|
+
if (cursor.visualRow === eol.visualRow && (cursor.offset === eol.offset || offset === eol.offset)) input.cursorOffset = Math.max(0, offset - 1)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined {
|
|
282
|
+
const map = createKeybindMap()
|
|
283
|
+
let count = 0
|
|
284
|
+
|
|
285
|
+
for (const [mode, keymaps] of Object.entries(config.keymaps) as Array<[VimeeMode, Record<string, string> | undefined]>) {
|
|
286
|
+
if (!keymaps) continue
|
|
287
|
+
for (const [keys, action] of Object.entries(keymaps)) {
|
|
288
|
+
try {
|
|
289
|
+
map.addKeybind(mode, keys as ValidKeySequence<typeof keys>, keybindAction(action))
|
|
290
|
+
count++
|
|
291
|
+
} catch (error) {
|
|
292
|
+
log("vimee.keymap.invalid", { mode, keys, action, error: error instanceof Error ? error.message : String(error) })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return count > 0 ? map : undefined
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function keybindAction(action: string): KeybindDefinition {
|
|
301
|
+
switch (action) {
|
|
302
|
+
case "normal":
|
|
303
|
+
return { keys: "<Esc>" }
|
|
304
|
+
case "insert":
|
|
305
|
+
return { keys: "i" }
|
|
306
|
+
case "submit":
|
|
307
|
+
return { execute: () => [{ type: "submit" } as unknown as VimeeAction] }
|
|
308
|
+
default:
|
|
309
|
+
return { keys: action }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function keyForVimee(event: KeyEvent, key: string) {
|
|
314
|
+
if (event.ctrl) return event.name?.toLowerCase()
|
|
315
|
+
const token = keyToken(key)
|
|
316
|
+
return token.startsWith("<") ? undefined : token
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function keyToken(token: string) {
|
|
320
|
+
if (token === "<Esc>" || token === "<C-[>") return "Escape"
|
|
321
|
+
if (token === "<CR>") return "Enter"
|
|
322
|
+
if (token === "<Tab>") return "Tab"
|
|
323
|
+
if (token === "<BS>") return "Backspace"
|
|
324
|
+
if (token === "<Del>") return "Delete"
|
|
325
|
+
if (token === "<Space>") return " "
|
|
326
|
+
if (token === "<Up>") return "ArrowUp"
|
|
327
|
+
if (token === "<Down>") return "ArrowDown"
|
|
328
|
+
if (token === "<Left>") return "ArrowLeft"
|
|
329
|
+
if (token === "<Right>") return "ArrowRight"
|
|
330
|
+
if (token === "<Home>") return "Home"
|
|
331
|
+
if (token === "<End>") return "End"
|
|
332
|
+
if (token.startsWith("<C-") && token.endsWith(">")) return token.slice(3, -1).toLowerCase()
|
|
333
|
+
return token
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function tokenCtrl(token: string) {
|
|
337
|
+
return token.startsWith("<C-") && token !== "<C-[>"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function syncMode(state: VimState, mode: VimContext["mode"]) {
|
|
341
|
+
state.setMode(mode === "insert" ? "insert" : "normal")
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function pendingDisplay(ctx: VimContext, keybindPending: boolean) {
|
|
345
|
+
if (ctx.phase === "operator-pending") return ctx.operator ?? ""
|
|
346
|
+
if (ctx.phase === "text-object-pending") return ctx.textObjectModifier ?? ""
|
|
347
|
+
if (keybindPending) return ctx.statusMessage
|
|
348
|
+
return ""
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function plainPending(value: string) {
|
|
352
|
+
return value.includes("<") || value.includes(">") ? "" : value
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function consumesKey(key: string, actions: VimeeAction[], ctx: VimContext, keybindPending: boolean) {
|
|
356
|
+
if (actions.length > 0) return true
|
|
357
|
+
if (keybindPending) return true
|
|
358
|
+
if (ctx.phase !== "idle") return true
|
|
359
|
+
return ctx.mode !== "insert" || key === "Escape"
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function clamp(value: number, min: number, max: number) {
|
|
363
|
+
return Math.max(min, Math.min(max, value))
|
|
364
|
+
}
|
package/src/plugin.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { PromptRoot } from "./prompt/root"
|
|
4
|
+
import type { PromptModule } from "./prompt/types"
|
|
5
|
+
import { createVimModule } from "./modules/vim"
|
|
6
|
+
import { createSnippetsModule } from "./modules/snippets"
|
|
7
|
+
|
|
8
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
9
|
+
const moduleCache = new Map<string, PromptModule[]>()
|
|
10
|
+
const createModules = (key: string) => {
|
|
11
|
+
const cached = moduleCache.get(key)
|
|
12
|
+
if (cached) return cached
|
|
13
|
+
|
|
14
|
+
const modules: PromptModule[] = []
|
|
15
|
+
if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule())
|
|
16
|
+
modules.push(createVimModule(options))
|
|
17
|
+
moduleCache.set(key, modules)
|
|
18
|
+
return modules
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
api.slots.register({
|
|
22
|
+
order: 50,
|
|
23
|
+
slots: {
|
|
24
|
+
home_prompt(ctx, props) {
|
|
25
|
+
return <PromptRoot api={api} slot={ctx} kind="home" workspaceID={props.workspace_id} ref={props.ref} modules={createModules(`home:${props.workspace_id ?? ""}`)} />
|
|
26
|
+
},
|
|
27
|
+
session_prompt(ctx, props) {
|
|
28
|
+
return (
|
|
29
|
+
<PromptRoot
|
|
30
|
+
api={api}
|
|
31
|
+
slot={ctx}
|
|
32
|
+
kind="session"
|
|
33
|
+
sessionID={props.session_id}
|
|
34
|
+
visible={props.visible}
|
|
35
|
+
disabled={props.disabled}
|
|
36
|
+
onSubmit={props.on_submit}
|
|
37
|
+
ref={props.ref}
|
|
38
|
+
modules={createModules(`session:${props.session_id}`)}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hasSnippetsPlugin(api: TuiPluginApi) {
|
|
47
|
+
return configHasSnippets(api.state.config.plugin) || configHasSnippets(api.tuiConfig.plugin) || api.plugins.list().some(pluginHasSnippets)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function configHasSnippets(plugins: unknown) {
|
|
51
|
+
if (!Array.isArray(plugins)) return false
|
|
52
|
+
return plugins.some((plugin) => specHasSnippets(Array.isArray(plugin) ? plugin[0] : plugin))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pluginHasSnippets(plugin: ReturnType<TuiPluginApi["plugins"]["list"]>[number]) {
|
|
56
|
+
return plugin.enabled && (specHasSnippets(plugin.id) || specHasSnippets(plugin.spec))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function specHasSnippets(spec: unknown) {
|
|
60
|
+
if (typeof spec !== "string") return false
|
|
61
|
+
return spec === "opencode-snippets" || spec.startsWith("opencode-snippets@") || spec.includes("/opencode-snippets")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
65
|
+
id: "local.opencode-vim",
|
|
66
|
+
tui,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default plugin
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPromptRef } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import type { JSX } from "@opentui/solid"
|
|
4
|
+
import type { PromptContext, PromptModule } from "./types"
|
|
5
|
+
import { submitModules } from "./modules"
|
|
6
|
+
|
|
7
|
+
type HostPromptProps = {
|
|
8
|
+
ctx: PromptContext
|
|
9
|
+
modules: PromptModule[]
|
|
10
|
+
ref?: (ref: TuiPromptRef | undefined) => void
|
|
11
|
+
right?: JSX.Element
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function HostPrompt(props: HostPromptProps) {
|
|
15
|
+
const api = props.ctx.api
|
|
16
|
+
|
|
17
|
+
const setRef = (ref: TuiPromptRef | undefined) => {
|
|
18
|
+
props.ctx.setPromptRef(ref)
|
|
19
|
+
props.ref?.(ref)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const onSubmit = () => {
|
|
23
|
+
if (submitModules(props.modules, props.ctx)) return
|
|
24
|
+
props.ctx.submitHost()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<api.ui.Prompt
|
|
29
|
+
sessionID={props.ctx.sessionID}
|
|
30
|
+
workspaceID={props.ctx.workspaceID}
|
|
31
|
+
visible={props.ctx.visible}
|
|
32
|
+
disabled={props.ctx.disabled}
|
|
33
|
+
onSubmit={onSubmit}
|
|
34
|
+
ref={setRef}
|
|
35
|
+
right={props.right}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PromptContext, PromptModule } from "./types"
|
|
2
|
+
|
|
3
|
+
export function sortModules(modules: PromptModule[]) {
|
|
4
|
+
return [...modules].sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function submitModules(modules: PromptModule[], ctx: PromptContext) {
|
|
8
|
+
for (const module of modules) {
|
|
9
|
+
if (module.onSubmit?.(ctx) === true) return true
|
|
10
|
+
}
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function notifyPromptRef(modules: PromptModule[], ref: ReturnType<PromptContext["prompt"]>, ctx: PromptContext) {
|
|
15
|
+
for (const module of modules) module.onPromptRef?.(ref, ctx)
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TuiPromptRef } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import type { PromptContext, PromptModule } from "./types"
|
|
3
|
+
import { notifyPromptRef } from "./modules"
|
|
4
|
+
|
|
5
|
+
export function createPromptRef(modules: PromptModule[], ctx: Omit<PromptContext, "prompt" | "setPromptRef">) {
|
|
6
|
+
let current: TuiPromptRef | undefined
|
|
7
|
+
const context = {
|
|
8
|
+
...ctx,
|
|
9
|
+
prompt: () => current,
|
|
10
|
+
setPromptRef(ref: TuiPromptRef | undefined) {
|
|
11
|
+
current = ref
|
|
12
|
+
notifyPromptRef(modules, ref, context)
|
|
13
|
+
},
|
|
14
|
+
} satisfies PromptContext
|
|
15
|
+
|
|
16
|
+
return context
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { onCleanup } from "solid-js"
|
|
3
|
+
import type { JSX } from "@opentui/solid"
|
|
4
|
+
import { HostPrompt } from "./host"
|
|
5
|
+
import { sortModules } from "./modules"
|
|
6
|
+
import { createPromptRef } from "./ref"
|
|
7
|
+
import type { PromptRootProps } from "./types"
|
|
8
|
+
|
|
9
|
+
export function PromptRoot(props: PromptRootProps) {
|
|
10
|
+
const modules = sortModules(props.modules)
|
|
11
|
+
const api = props.api
|
|
12
|
+
const ctx = createPromptRef(modules, {
|
|
13
|
+
api,
|
|
14
|
+
slot: props.slot,
|
|
15
|
+
kind: props.kind,
|
|
16
|
+
sessionID: props.sessionID,
|
|
17
|
+
workspaceID: props.workspaceID,
|
|
18
|
+
visible: props.visible,
|
|
19
|
+
disabled: props.disabled,
|
|
20
|
+
submitHost: () => props.onSubmit?.(),
|
|
21
|
+
requestRender: () => api.renderer.requestRender(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const cleanups = modules.flatMap((module) => {
|
|
25
|
+
const cleanup = module.setup?.(ctx)
|
|
26
|
+
return typeof cleanup === "function" ? [cleanup] : []
|
|
27
|
+
})
|
|
28
|
+
onCleanup(() => {
|
|
29
|
+
for (const cleanup of cleanups) cleanup()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const moduleAbove = modules.map((module) => module.renderAbove?.(ctx)).filter(Boolean) as JSX.Element[]
|
|
33
|
+
const moduleBelow = modules.map((module) => module.renderBelow?.(ctx)).filter(Boolean) as JSX.Element[]
|
|
34
|
+
const moduleRight = modules.map((module) => module.renderRight?.(ctx)).filter(Boolean) as JSX.Element[]
|
|
35
|
+
const hostRight = renderHostRight(props)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<box>
|
|
39
|
+
{moduleAbove}
|
|
40
|
+
<HostPrompt ctx={ctx} modules={modules} ref={props.ref} right={<RightItems items={[...moduleRight, hostRight]} />} />
|
|
41
|
+
{moduleBelow}
|
|
42
|
+
</box>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderHostRight(props: PromptRootProps) {
|
|
47
|
+
const Slot = props.api.ui.Slot
|
|
48
|
+
if (props.kind === "home") return <Slot name="home_prompt_right" workspace_id={props.workspaceID} />
|
|
49
|
+
return <Slot name="session_prompt_right" session_id={props.sessionID ?? ""} />
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function RightItems(props: { items: JSX.Element[] }) {
|
|
53
|
+
const items = props.items.filter(Boolean)
|
|
54
|
+
if (items.length === 0) return undefined
|
|
55
|
+
return <box flexDirection="row">{items}</box>
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { JSX } from "@opentui/solid"
|
|
2
|
+
import type { TuiPluginApi, TuiPromptRef, TuiSlotContext } from "@opencode-ai/plugin/tui"
|
|
3
|
+
|
|
4
|
+
export type PromptKind = "home" | "session"
|
|
5
|
+
|
|
6
|
+
export type PromptContext = {
|
|
7
|
+
api: TuiPluginApi
|
|
8
|
+
slot: TuiSlotContext
|
|
9
|
+
kind: PromptKind
|
|
10
|
+
sessionID?: string
|
|
11
|
+
workspaceID?: string
|
|
12
|
+
visible?: boolean
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
prompt: () => TuiPromptRef | undefined
|
|
15
|
+
setPromptRef: (ref: TuiPromptRef | undefined) => void
|
|
16
|
+
submitHost: () => void
|
|
17
|
+
requestRender: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type PromptModule = {
|
|
21
|
+
id: string
|
|
22
|
+
order?: number
|
|
23
|
+
setup?: (ctx: PromptContext) => void | (() => void)
|
|
24
|
+
onPromptRef?: (ref: TuiPromptRef | undefined, ctx: PromptContext) => void
|
|
25
|
+
onSubmit?: (ctx: PromptContext) => boolean | void
|
|
26
|
+
renderAbove?: (ctx: PromptContext) => JSX.Element
|
|
27
|
+
renderBelow?: (ctx: PromptContext) => JSX.Element
|
|
28
|
+
renderRight?: (ctx: PromptContext) => JSX.Element
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type PromptRootProps = {
|
|
32
|
+
api: TuiPluginApi
|
|
33
|
+
slot: TuiSlotContext
|
|
34
|
+
kind: PromptKind
|
|
35
|
+
sessionID?: string
|
|
36
|
+
workspaceID?: string
|
|
37
|
+
visible?: boolean
|
|
38
|
+
disabled?: boolean
|
|
39
|
+
onSubmit?: () => void
|
|
40
|
+
ref?: (ref: TuiPromptRef | undefined) => void
|
|
41
|
+
modules: PromptModule[]
|
|
42
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"jsx": "preserve",
|
|
7
|
+
"jsxImportSource": "@opentui/solid",
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"types": ["bun-types"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["./tui.tsx", "./src/**/*.ts", "./src/**/*.tsx"]
|
|
12
|
+
}
|
package/tui.json.example
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
3
|
+
"plugin": [
|
|
4
|
+
[
|
|
5
|
+
"./plugin/opencode-vim",
|
|
6
|
+
{
|
|
7
|
+
"vim": {
|
|
8
|
+
"defaultMode": "insert",
|
|
9
|
+
"keymapTimeout": 500,
|
|
10
|
+
"pendingDisplayDelay": 120,
|
|
11
|
+
"cursorStyles": {
|
|
12
|
+
"insert": {
|
|
13
|
+
"style": "line",
|
|
14
|
+
"blinking": true
|
|
15
|
+
},
|
|
16
|
+
"normal": {
|
|
17
|
+
"style": "block",
|
|
18
|
+
"blinking": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"debug": false,
|
|
22
|
+
"debugPath": "/home/you/.cache/opencode/opencode-vim.log",
|
|
23
|
+
"keymaps": {
|
|
24
|
+
"insert": {
|
|
25
|
+
"kj": "normal"
|
|
26
|
+
},
|
|
27
|
+
"normal": {
|
|
28
|
+
"i": "insert",
|
|
29
|
+
"<CR>": "submit",
|
|
30
|
+
"Y": "y$"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
]
|
|
37
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/plugin"
|