opencode-vim 0.0.23 → 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.
- package/README.md +11 -0
- package/dist/tui.js +3326 -0
- package/package.json +6 -6
- package/src/clipboard.tsx +0 -50
- package/src/modules/snippets/index.tsx +0 -17
- package/src/modules/snippets/loader.ts +0 -147
- package/src/modules/snippets/search.ts +0 -118
- package/src/modules/snippets/skill-loader.ts +0 -81
- package/src/modules/snippets/state.ts +0 -40
- package/src/modules/snippets/trigger.ts +0 -68
- package/src/modules/snippets/types.ts +0 -50
- package/src/modules/snippets/view.tsx +0 -674
- package/src/modules/vim/actions.ts +0 -82
- package/src/modules/vim/config.ts +0 -137
- package/src/modules/vim/edit.test.ts +0 -51
- package/src/modules/vim/edit.ts +0 -33
- package/src/modules/vim/graphemes.ts +0 -38
- package/src/modules/vim/index.tsx +0 -200
- package/src/modules/vim/keys.ts +0 -38
- package/src/modules/vim/log.ts +0 -37
- package/src/modules/vim/map.test.ts +0 -45
- package/src/modules/vim/map.ts +0 -90
- package/src/modules/vim/state.test.ts +0 -18
- package/src/modules/vim/state.tsx +0 -46
- package/src/modules/vim/view.tsx +0 -130
- package/src/modules/vim/vimee.test.ts +0 -211
- package/src/modules/vim/vimee.ts +0 -1211
- package/src/plugin.tsx +0 -116
- package/src/prompt/host.tsx +0 -38
- package/src/prompt/modules.ts +0 -16
- package/src/prompt/root.tsx +0 -69
- package/src/prompt/types.ts +0 -42
- package/src/session.tsx +0 -291
- package/src/transcript-items.tsx +0 -168
- package/src/transcript.tsx +0 -179
- package/src/update.ts +0 -120
- package/tui.tsx +0 -340
- 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
|
-
}
|