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.
@@ -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
+ }