opencode-vim 0.0.22 → 0.0.24

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