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,626 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
|
4
|
+
import { useKeyboard } from "@opentui/solid"
|
|
5
|
+
import { RGBA, type ScrollBoxRenderable } from "@opentui/core"
|
|
6
|
+
import type { TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui"
|
|
7
|
+
import type { PromptContext } from "../../prompt/types"
|
|
8
|
+
import { ensureSnippetDraft, loadSnippets } from "./loader"
|
|
9
|
+
import { loadSkills } from "./skill-loader"
|
|
10
|
+
import { addPendingDraft, markSnippetReloadRequested } from "./state"
|
|
11
|
+
import { describeSkill, describeSnippet, filterSkills, filterSnippets, highlightMatches, matchedAliases } from "./search"
|
|
12
|
+
import { findTrailingHashtagTrigger, insertSkillLoad, insertSnippetTag, insertSnippetTrigger, isReloadCommand, preferredSnippetTag, stepSelection } from "./trigger"
|
|
13
|
+
import type { AutocompleteItem, SnippetController, SnippetInfo } from "./types"
|
|
14
|
+
|
|
15
|
+
const PROMPT_SYNC_MS = 50
|
|
16
|
+
const MENU_MAX_HEIGHT = 10
|
|
17
|
+
const MOUSE_HOVER_SUPPRESS_MS = 150
|
|
18
|
+
const INLINE_BORDER = {
|
|
19
|
+
border: ["left", "right"] as Array<"left" | "right">,
|
|
20
|
+
customBorderChars: {
|
|
21
|
+
topLeft: "",
|
|
22
|
+
bottomLeft: "",
|
|
23
|
+
vertical: "┃",
|
|
24
|
+
topRight: "",
|
|
25
|
+
bottomRight: "",
|
|
26
|
+
horizontal: " ",
|
|
27
|
+
bottomT: "",
|
|
28
|
+
topT: "",
|
|
29
|
+
cross: "",
|
|
30
|
+
leftT: "",
|
|
31
|
+
rightT: "",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SnippetAutocompleteProps = {
|
|
36
|
+
ctx: PromptContext
|
|
37
|
+
controller: SnippetController
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type InputMode = "keyboard" | "mouse"
|
|
41
|
+
|
|
42
|
+
export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
|
|
43
|
+
const [snippets, setSnippets] = createSignal<SnippetInfo[]>([])
|
|
44
|
+
const [skills, setSkills] = createSignal<Awaited<ReturnType<typeof loadSkills>>>([])
|
|
45
|
+
const [loading, setLoading] = createSignal(true)
|
|
46
|
+
const [input, setInput] = createSignal("")
|
|
47
|
+
const [syncingPrompt, setSyncingPrompt] = createSignal(false)
|
|
48
|
+
const [menuEpoch, setMenuEpoch] = createSignal(0)
|
|
49
|
+
const [selected, setSelected] = createSignal(0)
|
|
50
|
+
const [dismissed, setDismissed] = createSignal<string>()
|
|
51
|
+
const [inputMode, setInputMode] = createSignal<InputMode>("keyboard")
|
|
52
|
+
const [ignoreMouseUntil, setIgnoreMouseUntil] = createSignal(0)
|
|
53
|
+
const [lastMousePos, setLastMousePos] = createSignal<{ x: number; y: number }>()
|
|
54
|
+
const [creating, setCreating] = createSignal(false)
|
|
55
|
+
const [dialogOpen, setDialogOpen] = createSignal(false)
|
|
56
|
+
const [dialogHandoffUntil, setDialogHandoffUntil] = createSignal(0)
|
|
57
|
+
|
|
58
|
+
let syncInterval: ReturnType<typeof setInterval> | undefined
|
|
59
|
+
let pendingPromptSync: ReturnType<typeof setTimeout> | undefined
|
|
60
|
+
let pendingPromptFocus: ReturnType<typeof setTimeout> | undefined
|
|
61
|
+
let pendingDialogHandoff: ReturnType<typeof setTimeout> | undefined
|
|
62
|
+
let commandTimer: ReturnType<typeof setTimeout> | undefined
|
|
63
|
+
let commandDispose: (() => void) | undefined
|
|
64
|
+
let disposed = false
|
|
65
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
66
|
+
|
|
67
|
+
const refresh = async () => {
|
|
68
|
+
setLoading(true)
|
|
69
|
+
try {
|
|
70
|
+
const [nextSnippets, nextSkills] = await Promise.all([loadSnippets(props.ctx.api.state.path.directory), loadSkills(props.ctx.api.state.path.directory)])
|
|
71
|
+
if (!disposed) {
|
|
72
|
+
setSnippets(nextSnippets)
|
|
73
|
+
setSkills(nextSkills)
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
if (!disposed) setLoading(false)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
void refresh()
|
|
81
|
+
|
|
82
|
+
syncInterval = setInterval(() => {
|
|
83
|
+
const ref = props.ctx.prompt()
|
|
84
|
+
if (!ref) {
|
|
85
|
+
setInput("")
|
|
86
|
+
setSyncingPrompt(false)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
setInput(ref.current.input ?? "")
|
|
90
|
+
}, PROMPT_SYNC_MS)
|
|
91
|
+
|
|
92
|
+
onCleanup(() => {
|
|
93
|
+
disposed = true
|
|
94
|
+
if (syncInterval) clearInterval(syncInterval)
|
|
95
|
+
if (pendingPromptSync) clearTimeout(pendingPromptSync)
|
|
96
|
+
if (pendingPromptFocus) clearTimeout(pendingPromptFocus)
|
|
97
|
+
if (pendingDialogHandoff) clearTimeout(pendingDialogHandoff)
|
|
98
|
+
if (commandTimer) clearTimeout(commandTimer)
|
|
99
|
+
commandDispose?.()
|
|
100
|
+
props.controller.accept = undefined
|
|
101
|
+
props.controller.reload = undefined
|
|
102
|
+
props.controller.insertTrigger = undefined
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const dialogBlockingInput = () => dialogOpen() || dialogHandoffUntil() > Date.now()
|
|
106
|
+
const match = createMemo(() => {
|
|
107
|
+
if (props.ctx.disabled || props.ctx.visible === false) return undefined
|
|
108
|
+
return findTrailingHashtagTrigger(input())
|
|
109
|
+
})
|
|
110
|
+
const query = createMemo(() => match()?.query.trim() ?? "")
|
|
111
|
+
const options = createMemo(() => (match() ? optionsForQuery(query()) : []))
|
|
112
|
+
const draftName = createMemo(() => normalizeSnippetName(query()))
|
|
113
|
+
const canCreate = createMemo(() => !loading() && options().length === 0 && query().length > 0 && draftName().length > 0)
|
|
114
|
+
const visible = createMemo(() => !!match() && !syncingPrompt() && dismissed() !== match()?.token)
|
|
115
|
+
const menuVisible = createMemo(() => visible() && (options().length > 0 || canCreate()))
|
|
116
|
+
const menuHeight = createMemo(() => Math.min(MENU_MAX_HEIGHT, Math.max(1, options().length || 1)))
|
|
117
|
+
const selectedFg = createMemo(() => selectedText(props.ctx.api.theme.current))
|
|
118
|
+
const activeRowId = createMemo(() => options()[selected()]?.id ?? (canCreate() ? "create-snippet" : undefined))
|
|
119
|
+
const optionKey = createMemo(() => options().map((option) => option.id).join("\n"))
|
|
120
|
+
|
|
121
|
+
createEffect(() => {
|
|
122
|
+
menuEpoch()
|
|
123
|
+
if (visible()) scroll = undefined
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
createEffect(() => {
|
|
127
|
+
match()?.token
|
|
128
|
+
optionKey()
|
|
129
|
+
setSelected(0)
|
|
130
|
+
lockKeyboardSelection()
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
scroll?.scrollTo(0)
|
|
133
|
+
const first = activeRowId()
|
|
134
|
+
if (first) scroll?.scrollChildIntoView(first)
|
|
135
|
+
}, 0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
createEffect(() => {
|
|
139
|
+
const row = activeRowId()
|
|
140
|
+
if (row) scroll?.scrollChildIntoView(row)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const chooseItem = (item: AutocompleteItem) => {
|
|
144
|
+
const ref = props.ctx.prompt()
|
|
145
|
+
if (!ref) return false
|
|
146
|
+
const next = item.kind === "skill" ? insertSkillLoad(ref.current.input, item.skill.name) : insertSnippetTag(ref.current.input, preferredSnippetTag(ref.current.input, item.snippet))
|
|
147
|
+
syncPromptInput(ref, next)
|
|
148
|
+
setDismissed(undefined)
|
|
149
|
+
ref.focus()
|
|
150
|
+
props.ctx.requestRender()
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const choose = (index = selected()) => {
|
|
155
|
+
const item = options()[index]
|
|
156
|
+
return item ? chooseItem(item) : false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const accept = () => {
|
|
160
|
+
const ref = props.ctx.prompt()
|
|
161
|
+
if (!ref) return false
|
|
162
|
+
|
|
163
|
+
if (isReloadCommand(ref.current.input)) {
|
|
164
|
+
void executeReloadInPrompt(ref)
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (dialogBlockingInput()) return true
|
|
169
|
+
|
|
170
|
+
const current = findTrailingHashtagTrigger(ref.current.input)
|
|
171
|
+
if (!current || dismissed() === current.token) return false
|
|
172
|
+
|
|
173
|
+
const live = optionsForQuery(current.query.trim())
|
|
174
|
+
const liveIndex = Math.min(selected(), Math.max(live.length - 1, 0))
|
|
175
|
+
|
|
176
|
+
if (syncingPrompt()) {
|
|
177
|
+
if (live.length > 0) {
|
|
178
|
+
chooseItem(live[liveIndex] ?? live[0])
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
if (canCreateForQuery(current.query)) {
|
|
182
|
+
void createSnippetDraft(current.query)
|
|
183
|
+
return true
|
|
184
|
+
}
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (visible() && options().length > 0) {
|
|
189
|
+
choose(Math.min(selected(), options().length - 1))
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (loading()) return true
|
|
194
|
+
|
|
195
|
+
if (live.length > 0) {
|
|
196
|
+
chooseItem(live[liveIndex] ?? live[0])
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (canCreateForQuery(current.query)) {
|
|
201
|
+
void createSnippetDraft(current.query)
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
props.controller.accept = accept
|
|
209
|
+
props.controller.reload = () => {
|
|
210
|
+
const ref = props.ctx.prompt()
|
|
211
|
+
if (ref) void executeReloadInPrompt(ref)
|
|
212
|
+
}
|
|
213
|
+
props.controller.insertTrigger = () => {
|
|
214
|
+
const ref = props.ctx.prompt()
|
|
215
|
+
if (!ref) return
|
|
216
|
+
syncPromptInput(ref, insertSnippetTrigger(ref.current.input))
|
|
217
|
+
ref.focus()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
createEffect(() => {
|
|
221
|
+
const ref = props.ctx.prompt()
|
|
222
|
+
if (!ref) return
|
|
223
|
+
|
|
224
|
+
if (commandTimer) clearTimeout(commandTimer)
|
|
225
|
+
commandDispose?.()
|
|
226
|
+
commandDispose = undefined
|
|
227
|
+
|
|
228
|
+
commandTimer = setTimeout(() => {
|
|
229
|
+
commandTimer = undefined
|
|
230
|
+
if (disposed) return
|
|
231
|
+
|
|
232
|
+
commandDispose = props.ctx.api.command.register(() => [
|
|
233
|
+
{
|
|
234
|
+
title: "Reload snippets",
|
|
235
|
+
value: "snippets.reload",
|
|
236
|
+
description: "Reload snippet files from disk",
|
|
237
|
+
category: "Prompt",
|
|
238
|
+
slash: { name: "snippets:reload" },
|
|
239
|
+
onSelect() {
|
|
240
|
+
void executeReloadInPrompt(ref)
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
title: "Insert snippet",
|
|
245
|
+
value: "snippets.insert",
|
|
246
|
+
description: "Insert a snippet trigger into the prompt",
|
|
247
|
+
category: "Prompt",
|
|
248
|
+
onSelect() {
|
|
249
|
+
syncPromptInput(ref, insertSnippetTrigger(ref.current.input))
|
|
250
|
+
ref.focus()
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
title: "Accept snippet autocomplete",
|
|
255
|
+
value: "snippets.accept",
|
|
256
|
+
keybind: "input_submit",
|
|
257
|
+
category: "Prompt",
|
|
258
|
+
hidden: true,
|
|
259
|
+
enabled: ref.focused,
|
|
260
|
+
onSelect() {
|
|
261
|
+
if (accept()) return
|
|
262
|
+
ref.submit()
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
])
|
|
266
|
+
}, 0)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
useKeyboard((event) => {
|
|
270
|
+
const ref = props.ctx.prompt()
|
|
271
|
+
const name = event.name?.toLowerCase()
|
|
272
|
+
|
|
273
|
+
if (ref && isReloadCommand(ref.current.input) && (name === "return" || name === "enter")) {
|
|
274
|
+
void executeReloadInPrompt(ref)
|
|
275
|
+
event.preventDefault()
|
|
276
|
+
event.stopPropagation()
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (dialogBlockingInput()) return
|
|
281
|
+
if (!visible()) return
|
|
282
|
+
|
|
283
|
+
const total = options().length
|
|
284
|
+
const actionable = total > 0 || canCreate()
|
|
285
|
+
|
|
286
|
+
if ((name === "up" || name === "down") && actionable) {
|
|
287
|
+
lockKeyboardSelection()
|
|
288
|
+
setSelected((current) => stepSelection(current, total || 1, name === "up" ? -1 : 1))
|
|
289
|
+
event.preventDefault()
|
|
290
|
+
event.stopPropagation()
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (name === "escape") {
|
|
295
|
+
setDismissed(match()?.token)
|
|
296
|
+
event.preventDefault()
|
|
297
|
+
event.stopPropagation()
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (name === "tab" && actionable) {
|
|
302
|
+
if (total > 0) choose()
|
|
303
|
+
else void createSnippetDraft()
|
|
304
|
+
event.preventDefault()
|
|
305
|
+
event.stopPropagation()
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
schedulePromptSync()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<Show when={visible()}>
|
|
314
|
+
<box position="absolute" top={-menuHeight()} left={0} right={0} zIndex={100} borderColor={props.ctx.api.theme.current.border} {...INLINE_BORDER}>
|
|
315
|
+
<scrollbox ref={(ref: ScrollBoxRenderable) => (scroll = ref)} backgroundColor={props.ctx.api.theme.current.backgroundMenu} height={menuHeight()} scrollbarOptions={{ visible: false }}>
|
|
316
|
+
<For each={options()} fallback={<FallbackRow ctx={props.ctx} canCreate={canCreate()} creating={creating()} label={fallbackLabel()} draftName={draftName()} onCreate={() => void createSnippetDraft()} />}>
|
|
317
|
+
{(item, index) => (
|
|
318
|
+
<OptionRow
|
|
319
|
+
ctx={props.ctx}
|
|
320
|
+
item={item}
|
|
321
|
+
query={query()}
|
|
322
|
+
selected={index() === selected()}
|
|
323
|
+
selectedFg={selectedFg()}
|
|
324
|
+
onMouseMove={(x, y) => {
|
|
325
|
+
if (!allowMouseHover()) return
|
|
326
|
+
if (!recordMouseMove(x, y)) return
|
|
327
|
+
setInputMode("mouse")
|
|
328
|
+
}}
|
|
329
|
+
onMouseOver={() => {
|
|
330
|
+
if (inputMode() === "mouse") setSelected(index())
|
|
331
|
+
}}
|
|
332
|
+
onMouseDown={() => {
|
|
333
|
+
setInputMode("mouse")
|
|
334
|
+
setLastMousePos(undefined)
|
|
335
|
+
setSelected(index())
|
|
336
|
+
}}
|
|
337
|
+
onMouseUp={() => choose(index())}
|
|
338
|
+
/>
|
|
339
|
+
)}
|
|
340
|
+
</For>
|
|
341
|
+
</scrollbox>
|
|
342
|
+
</box>
|
|
343
|
+
</Show>
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
function optionsForQuery(value: string): AutocompleteItem[] {
|
|
347
|
+
const snippetOptions: AutocompleteItem[] = filterSnippets(snippets(), value).map((snippet) => ({
|
|
348
|
+
kind: "snippet",
|
|
349
|
+
id: `snippet:${snippet.name}`,
|
|
350
|
+
label: `#${snippet.name}`,
|
|
351
|
+
description: describeSnippet(snippet),
|
|
352
|
+
aliases: matchedAliases(snippet, value),
|
|
353
|
+
snippet,
|
|
354
|
+
}))
|
|
355
|
+
const skillOptions: AutocompleteItem[] = filterSkills(skills(), value).map((skill) => ({
|
|
356
|
+
kind: "skill",
|
|
357
|
+
id: `skill:${skill.name}`,
|
|
358
|
+
label: `#skill(${skill.name})`,
|
|
359
|
+
description: describeSkill(skill),
|
|
360
|
+
aliases: [],
|
|
361
|
+
skill,
|
|
362
|
+
}))
|
|
363
|
+
return [...snippetOptions, ...skillOptions]
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function canCreateForQuery(value: string) {
|
|
367
|
+
if (loading()) return false
|
|
368
|
+
const name = normalizeSnippetName(value)
|
|
369
|
+
return name.length > 0 && optionsForQuery(value).length === 0
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function fallbackLabel() {
|
|
373
|
+
if (loading()) return "Loading snippets and skills..."
|
|
374
|
+
if (snippets().length === 0 && skills().length === 0) return "No snippets or skills found"
|
|
375
|
+
return "No matching snippets or skills"
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function lockKeyboardSelection() {
|
|
379
|
+
setInputMode("keyboard")
|
|
380
|
+
setIgnoreMouseUntil(Date.now() + MOUSE_HOVER_SUPPRESS_MS)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function allowMouseHover() {
|
|
384
|
+
return Date.now() >= ignoreMouseUntil()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function recordMouseMove(x: number, y: number) {
|
|
388
|
+
const last = lastMousePos()
|
|
389
|
+
if (last?.x === x && last.y === y) return false
|
|
390
|
+
setLastMousePos({ x, y })
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function beginDialogHandoff() {
|
|
395
|
+
const until = Date.now() + MOUSE_HOVER_SUPPRESS_MS
|
|
396
|
+
setDialogHandoffUntil(until)
|
|
397
|
+
if (pendingDialogHandoff) clearTimeout(pendingDialogHandoff)
|
|
398
|
+
pendingDialogHandoff = setTimeout(() => {
|
|
399
|
+
if (dialogHandoffUntil() === until) setDialogHandoffUntil(0)
|
|
400
|
+
props.ctx.requestRender()
|
|
401
|
+
}, MOUSE_HOVER_SUPPRESS_MS + 25)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function restorePromptFocus(ref: TuiPromptRef) {
|
|
405
|
+
if (pendingPromptFocus) clearTimeout(pendingPromptFocus)
|
|
406
|
+
pendingPromptFocus = setTimeout(() => ref.focus(), 175)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function schedulePromptSync() {
|
|
410
|
+
const ref = props.ctx.prompt()
|
|
411
|
+
if (!ref || dialogBlockingInput()) return
|
|
412
|
+
const previous = input()
|
|
413
|
+
setSyncingPrompt(true)
|
|
414
|
+
setMenuEpoch((current) => current + 1)
|
|
415
|
+
if (pendingPromptSync) clearTimeout(pendingPromptSync)
|
|
416
|
+
pendingPromptSync = setTimeout(() => {
|
|
417
|
+
const next = ref.current.input ?? ""
|
|
418
|
+
if (next !== previous) setInput(next)
|
|
419
|
+
setSyncingPrompt(false)
|
|
420
|
+
props.ctx.requestRender()
|
|
421
|
+
}, 0)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function executeReloadInPrompt(ref: TuiPromptRef) {
|
|
425
|
+
const count = await reloadSnippetsInTui(props.ctx.api, refresh)
|
|
426
|
+
syncPromptInput(ref, "")
|
|
427
|
+
setDismissed(undefined)
|
|
428
|
+
ref.focus()
|
|
429
|
+
props.ctx.requestRender()
|
|
430
|
+
setTimeout(() => {
|
|
431
|
+
props.ctx.api.ui.toast({
|
|
432
|
+
title: "Snippets reloaded",
|
|
433
|
+
message: `Reloaded ${count} snippet${count === 1 ? "" : "s"}.`,
|
|
434
|
+
duration: 3000,
|
|
435
|
+
})
|
|
436
|
+
props.ctx.requestRender()
|
|
437
|
+
}, 0)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function createSnippetDraft(rawQuery?: string) {
|
|
441
|
+
const ref = props.ctx.prompt()
|
|
442
|
+
const name = normalizeSnippetName(rawQuery ?? query())
|
|
443
|
+
if (!ref || !name || creating()) return
|
|
444
|
+
const current = findTrailingHashtagTrigger(ref.current.input)
|
|
445
|
+
const nextInput = current ? `${ref.current.input.slice(0, current.start)}#${name}` : `#${name}`
|
|
446
|
+
const dismissedToken = `#${name}`
|
|
447
|
+
const editor = resolveExternalEditor()
|
|
448
|
+
|
|
449
|
+
if (!editor) {
|
|
450
|
+
props.ctx.api.ui.toast({ variant: "warning", message: "Set VISUAL or EDITOR to create snippets from the TUI." })
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
props.ctx.api.ui.dialog.setSize("medium")
|
|
455
|
+
setDialogOpen(true)
|
|
456
|
+
props.ctx.api.ui.dialog.replace(() => (
|
|
457
|
+
<props.ctx.api.ui.DialogConfirm
|
|
458
|
+
title={`Create snippet #${name}?`}
|
|
459
|
+
message={`This will create the snippet draft and open it in $${editor.env} (${editor.command}).`}
|
|
460
|
+
onCancel={() => {
|
|
461
|
+
setDialogOpen(false)
|
|
462
|
+
beginDialogHandoff()
|
|
463
|
+
props.ctx.api.ui.dialog.clear()
|
|
464
|
+
restorePromptFocus(ref)
|
|
465
|
+
}}
|
|
466
|
+
onConfirm={() => {
|
|
467
|
+
setDialogOpen(false)
|
|
468
|
+
beginDialogHandoff()
|
|
469
|
+
props.ctx.api.ui.dialog.clear()
|
|
470
|
+
void (async () => {
|
|
471
|
+
setCreating(true)
|
|
472
|
+
try {
|
|
473
|
+
syncPromptInput(ref, nextInput)
|
|
474
|
+
const filePath = await ensureSnippetDraft(name, props.ctx.api.state.path.directory)
|
|
475
|
+
await addPendingDraft(props.ctx.api.state.path.directory, name)
|
|
476
|
+
setDismissed(dismissedToken)
|
|
477
|
+
setCreating(false)
|
|
478
|
+
await openExternalEditor(props.ctx.api, filePath, editor)
|
|
479
|
+
} catch (error) {
|
|
480
|
+
props.ctx.api.ui.toast({ variant: "error", message: `Failed to create snippet: ${error instanceof Error ? error.message : String(error)}` })
|
|
481
|
+
syncPromptInput(ref, nextInput)
|
|
482
|
+
setDismissed(undefined)
|
|
483
|
+
} finally {
|
|
484
|
+
setCreating(false)
|
|
485
|
+
restorePromptFocus(ref)
|
|
486
|
+
}
|
|
487
|
+
})()
|
|
488
|
+
}}
|
|
489
|
+
/>
|
|
490
|
+
))
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function OptionRow(props: {
|
|
495
|
+
ctx: PromptContext
|
|
496
|
+
item: AutocompleteItem
|
|
497
|
+
query: string
|
|
498
|
+
selected: boolean
|
|
499
|
+
selectedFg: RGBA
|
|
500
|
+
onMouseMove: (x: number, y: number) => void
|
|
501
|
+
onMouseOver: () => void
|
|
502
|
+
onMouseDown: () => void
|
|
503
|
+
onMouseUp: () => void
|
|
504
|
+
}) {
|
|
505
|
+
const fg = () => (props.selected ? props.selectedFg : props.ctx.api.theme.current.text)
|
|
506
|
+
const mutedFg = () => (props.selected ? props.selectedFg : props.ctx.api.theme.current.textMuted)
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<box
|
|
510
|
+
id={props.item.id}
|
|
511
|
+
flexDirection="row"
|
|
512
|
+
backgroundColor={props.selected ? props.ctx.api.theme.current.primary : undefined}
|
|
513
|
+
paddingLeft={1}
|
|
514
|
+
paddingRight={1}
|
|
515
|
+
onMouseMove={(event: { x: number; y: number }) => props.onMouseMove(event.x, event.y)}
|
|
516
|
+
onMouseOver={props.onMouseOver}
|
|
517
|
+
onMouseDown={props.onMouseDown}
|
|
518
|
+
onMouseUp={props.onMouseUp}
|
|
519
|
+
>
|
|
520
|
+
<text fg={fg()} flexShrink={0} wrapMode="none">
|
|
521
|
+
<Highlighted text={props.item.label} query={props.query} fg={fg()} />
|
|
522
|
+
</text>
|
|
523
|
+
<Show when={props.item.aliases.length > 0}>
|
|
524
|
+
<text fg={mutedFg()} flexShrink={0} wrapMode="none">
|
|
525
|
+
<Highlighted text={` ${props.item.aliases.length === 1 ? "alias" : "aliases"}: ${props.item.aliases.join(", ")}`} query={props.query} fg={mutedFg()} />
|
|
526
|
+
</text>
|
|
527
|
+
</Show>
|
|
528
|
+
<Show when={props.item.description}>
|
|
529
|
+
<text fg={mutedFg()} wrapMode="none">
|
|
530
|
+
<Highlighted text={` ${props.item.description}`} query={props.query} fg={mutedFg()} />
|
|
531
|
+
</text>
|
|
532
|
+
</Show>
|
|
533
|
+
</box>
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function FallbackRow(props: { ctx: PromptContext; canCreate: boolean; creating: boolean; label: string; draftName: string; onCreate: () => void }) {
|
|
538
|
+
return (
|
|
539
|
+
<Show
|
|
540
|
+
when={props.canCreate}
|
|
541
|
+
fallback={
|
|
542
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
543
|
+
<text fg={props.ctx.api.theme.current.textMuted}>{props.label}</text>
|
|
544
|
+
</box>
|
|
545
|
+
}
|
|
546
|
+
>
|
|
547
|
+
<box id="create-snippet" paddingLeft={1} paddingRight={1} backgroundColor={props.ctx.api.theme.current.primary} onMouseUp={props.onCreate}>
|
|
548
|
+
<text fg={selectedText(props.ctx.api.theme.current)}>{props.creating ? "Creating snippet..." : `Add new Snippet: #${props.draftName}`}</text>
|
|
549
|
+
</box>
|
|
550
|
+
</Show>
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function Highlighted(props: { text: string; query: string; fg: RGBA }) {
|
|
555
|
+
return <For each={highlightMatches(props.text, props.query)}>{(part) => (part.match ? <span style={{ fg: props.fg, underline: true }}>{part.text}</span> : part.text)}</For>
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function syncPromptInput(prompt: TuiPromptRef, input: string) {
|
|
559
|
+
prompt.set({ input, mode: prompt.current.mode, parts: [...prompt.current.parts] })
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function selectedText(theme: PromptContext["api"]["theme"]["current"]) {
|
|
563
|
+
if (theme.background.a !== 0) return theme.background
|
|
564
|
+
const { r, g, b } = theme.primary
|
|
565
|
+
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
|
566
|
+
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function normalizeSnippetName(input: string) {
|
|
570
|
+
return input
|
|
571
|
+
.trim()
|
|
572
|
+
.toLowerCase()
|
|
573
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
574
|
+
.replace(/-{2,}/g, "-")
|
|
575
|
+
.replace(/^-+|-+$/g, "")
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function reloadSnippetsInTui(api: TuiPluginApi, refresh: () => Promise<void>) {
|
|
579
|
+
await refresh()
|
|
580
|
+
await markSnippetReloadRequested(api.state.path.directory)
|
|
581
|
+
return loadSnippets(api.state.path.directory).then((items) => items.length)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
type Editor = { command: string; env: "VISUAL" | "EDITOR" }
|
|
585
|
+
|
|
586
|
+
function resolveExternalEditor(): Editor | undefined {
|
|
587
|
+
const visual = Bun.env.VISUAL?.trim()
|
|
588
|
+
if (visual) return { command: visual, env: "VISUAL" }
|
|
589
|
+
const editor = Bun.env.EDITOR?.trim()
|
|
590
|
+
if (editor) return { command: editor, env: "EDITOR" }
|
|
591
|
+
return undefined
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function editorBinary(editor: Editor) {
|
|
595
|
+
return editor.command.split(/\s+/)[0] ?? editor.command
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function usesTerminalUi(editor: Editor) {
|
|
599
|
+
return !/^(code|cursor|windsurf|subl|zed|open|idea|webstorm|phpstorm|pycharm|rubymine|goland|clion|rider|datagrip)$/i.test(editorBinary(editor))
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function openExternalEditor(api: TuiPluginApi, filePath: string, editor: Editor) {
|
|
603
|
+
const args = editor.command.split(/\s+/).filter(Boolean)
|
|
604
|
+
const command = args.shift()
|
|
605
|
+
if (!command) return false
|
|
606
|
+
|
|
607
|
+
if (usesTerminalUi(editor)) api.renderer.suspend()
|
|
608
|
+
try {
|
|
609
|
+
await new Promise<void>((resolve, reject) => {
|
|
610
|
+
const child = spawn(command, [...args, filePath], { stdio: usesTerminalUi(editor) ? "inherit" : "ignore", detached: !usesTerminalUi(editor) })
|
|
611
|
+
child.on("error", reject)
|
|
612
|
+
child.on("exit", () => resolve())
|
|
613
|
+
if (!usesTerminalUi(editor)) {
|
|
614
|
+
child.unref()
|
|
615
|
+
resolve()
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
return true
|
|
619
|
+
} finally {
|
|
620
|
+
if (usesTerminalUi(editor)) {
|
|
621
|
+
api.renderer.resume()
|
|
622
|
+
api.renderer.console.clear()
|
|
623
|
+
api.renderer.requestRender()
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TuiPromptInfo, TuiPromptRef } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import type { PromptContext } from "../../prompt/types"
|
|
3
|
+
import type { VimCursorStyle } from "./config"
|
|
4
|
+
|
|
5
|
+
export type EditBufferLike = {
|
|
6
|
+
cursorOffset?: number
|
|
7
|
+
plainText?: string
|
|
8
|
+
visualCursor?: VisualCursorLike
|
|
9
|
+
editorView?: { getVisualEOL?: () => VisualCursorLike | undefined }
|
|
10
|
+
cursorStyle?: VimCursorStyle
|
|
11
|
+
moveCursorLeft?: () => boolean
|
|
12
|
+
moveCursorRight?: () => boolean
|
|
13
|
+
moveCursorUp?: () => boolean
|
|
14
|
+
moveCursorDown?: () => boolean
|
|
15
|
+
gotoVisualLineEnd?: () => boolean
|
|
16
|
+
gotoLineEnd?: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type VisualCursorLike = {
|
|
20
|
+
visualRow?: number
|
|
21
|
+
visualCol?: number
|
|
22
|
+
logicalRow?: number
|
|
23
|
+
logicalCol?: number
|
|
24
|
+
offset?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyVimCursorStyle(ctx: PromptContext, style: VimCursorStyle) {
|
|
28
|
+
const input = focusedInput(ctx)
|
|
29
|
+
if (!input) return false
|
|
30
|
+
input.cursorStyle = style
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function focusedInput(ctx: PromptContext): EditBufferLike | undefined {
|
|
35
|
+
const focused = ctx.api.renderer.currentFocusedRenderable as EditBufferLike | null | undefined
|
|
36
|
+
if (!focused || !hasEditBufferMethods(focused)) return undefined
|
|
37
|
+
return focused
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setInput(ref: TuiPromptRef, input: string) {
|
|
41
|
+
ref.set(toPromptInfo(ref, input))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasEditBufferMethods(input: EditBufferLike) {
|
|
45
|
+
return typeof input.moveCursorLeft === "function" || typeof input.moveCursorRight === "function" || typeof input.moveCursorUp === "function" || typeof input.moveCursorDown === "function" || typeof input.gotoLineEnd === "function"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toPromptInfo(ref: TuiPromptRef, input: string): TuiPromptInfo {
|
|
49
|
+
return {
|
|
50
|
+
input,
|
|
51
|
+
mode: ref.current.mode,
|
|
52
|
+
parts: [...ref.current.parts],
|
|
53
|
+
}
|
|
54
|
+
}
|