@markjaquith/agency 3.6.0 → 3.6.1
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 +5 -0
- package/package.json +4 -1
- package/src/utils/interactive.js +977 -0
- package/src/utils/interactive.tsx +0 -937
|
@@ -1,937 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createCliRenderer,
|
|
5
|
-
type CliRenderer,
|
|
6
|
-
type CliRendererConfig,
|
|
7
|
-
type TextareaRenderable,
|
|
8
|
-
type TextNodeOptions,
|
|
9
|
-
} from "@opentui/core"
|
|
10
|
-
import {
|
|
11
|
-
render,
|
|
12
|
-
useKeyboard,
|
|
13
|
-
useTerminalDimensions,
|
|
14
|
-
type JSX,
|
|
15
|
-
} from "@opentui/solid"
|
|
16
|
-
import { createMemo, createSignal, For, Show } from "solid-js"
|
|
17
|
-
import type { ChoiceDetails, ChoiceSegment } from "./chooser"
|
|
18
|
-
import { macchiato } from "./theme"
|
|
19
|
-
|
|
20
|
-
export interface InteractiveChoice {
|
|
21
|
-
readonly key: string
|
|
22
|
-
readonly label: string
|
|
23
|
-
readonly depth?: number
|
|
24
|
-
readonly segments?: readonly ChoiceSegment[]
|
|
25
|
-
readonly details?: ChoiceDetails
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const interactiveRendererConfig = {
|
|
29
|
-
screenMode: "split-footer",
|
|
30
|
-
footerHeight: 5,
|
|
31
|
-
externalOutputMode: "capture-stdout",
|
|
32
|
-
consoleMode: "disabled",
|
|
33
|
-
clearOnShutdown: false,
|
|
34
|
-
exitOnCtrlC: false,
|
|
35
|
-
useMouse: false,
|
|
36
|
-
autoFocus: false,
|
|
37
|
-
openConsoleOnError: false,
|
|
38
|
-
} satisfies CliRendererConfig
|
|
39
|
-
|
|
40
|
-
export const interactiveSelectRendererConfig = {
|
|
41
|
-
...interactiveRendererConfig,
|
|
42
|
-
screenMode: "alternate-screen",
|
|
43
|
-
externalOutputMode: "passthrough",
|
|
44
|
-
} satisfies CliRendererConfig
|
|
45
|
-
|
|
46
|
-
interface PromptProps<T> {
|
|
47
|
-
readonly prompt: string
|
|
48
|
-
readonly onDone: (value: T | null) => void
|
|
49
|
-
readonly fullScreen?: boolean
|
|
50
|
-
readonly onQuit?: () => void
|
|
51
|
-
readonly embedded?: boolean
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const isCancel = (key: { name: string; ctrl: boolean }) =>
|
|
55
|
-
key.name === "escape" || (key.ctrl && key.name === "c")
|
|
56
|
-
|
|
57
|
-
interface PromptKey {
|
|
58
|
-
readonly name: string
|
|
59
|
-
readonly ctrl: boolean
|
|
60
|
-
preventDefault(): void
|
|
61
|
-
stopPropagation(): void
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const removedText = (before: string, after: string) => {
|
|
65
|
-
let start = 0
|
|
66
|
-
while (
|
|
67
|
-
start < before.length &&
|
|
68
|
-
start < after.length &&
|
|
69
|
-
before[start] === after[start]
|
|
70
|
-
) {
|
|
71
|
-
start++
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let end = 0
|
|
75
|
-
while (
|
|
76
|
-
before[before.length - end - 1] === after[after.length - end - 1] &&
|
|
77
|
-
end < before.length - start &&
|
|
78
|
-
end < after.length - start
|
|
79
|
-
) {
|
|
80
|
-
end++
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return before.slice(start, before.length - end)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const createReadlineEditing = (
|
|
87
|
-
getInput: () => TextareaRenderable | undefined,
|
|
88
|
-
onInput?: (value: string) => void,
|
|
89
|
-
) => {
|
|
90
|
-
let value = ""
|
|
91
|
-
let killBuffer = ""
|
|
92
|
-
let beforeKill: string | undefined
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
get value() {
|
|
96
|
-
return value
|
|
97
|
-
},
|
|
98
|
-
handleInput(next: string) {
|
|
99
|
-
if (beforeKill !== undefined) {
|
|
100
|
-
const killed = removedText(beforeKill, next)
|
|
101
|
-
if (killed) killBuffer = killed
|
|
102
|
-
beforeKill = undefined
|
|
103
|
-
}
|
|
104
|
-
value = next
|
|
105
|
-
onInput?.(next)
|
|
106
|
-
},
|
|
107
|
-
handleKey(key: PromptKey) {
|
|
108
|
-
const current = getInput()?.plainText
|
|
109
|
-
if (current !== undefined && current !== value) this.handleInput(current)
|
|
110
|
-
if (!key.ctrl) return false
|
|
111
|
-
if (key.name === "y") {
|
|
112
|
-
key.preventDefault()
|
|
113
|
-
key.stopPropagation()
|
|
114
|
-
if (killBuffer) getInput()?.insertText(killBuffer)
|
|
115
|
-
return true
|
|
116
|
-
}
|
|
117
|
-
if (key.name === "u" || key.name === "k" || key.name === "w") {
|
|
118
|
-
beforeKill = value
|
|
119
|
-
}
|
|
120
|
-
return false
|
|
121
|
-
},
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export const InteractiveTextPrompt = (props: PromptProps<string>) => {
|
|
126
|
-
let input: TextareaRenderable | undefined
|
|
127
|
-
const editing = createReadlineEditing(() => input)
|
|
128
|
-
useKeyboard((key) => {
|
|
129
|
-
if (key.propagationStopped) return
|
|
130
|
-
if (props.embedded && key.name === "escape" && editing.value) {
|
|
131
|
-
key.preventDefault()
|
|
132
|
-
key.stopPropagation()
|
|
133
|
-
input?.clear()
|
|
134
|
-
editing.handleInput("")
|
|
135
|
-
return
|
|
136
|
-
}
|
|
137
|
-
if (isCancel(key)) {
|
|
138
|
-
key.preventDefault()
|
|
139
|
-
key.stopPropagation()
|
|
140
|
-
if (key.ctrl && key.name === "c") props.onQuit?.()
|
|
141
|
-
props.onDone(null)
|
|
142
|
-
return
|
|
143
|
-
}
|
|
144
|
-
editing.handleKey(key)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
return (
|
|
148
|
-
<box
|
|
149
|
-
flexDirection="column"
|
|
150
|
-
width="100%"
|
|
151
|
-
height="100%"
|
|
152
|
-
backgroundColor={macchiato.base}
|
|
153
|
-
>
|
|
154
|
-
{props.fullScreen && !props.embedded && (
|
|
155
|
-
<>
|
|
156
|
-
<text fg={macchiato.blue}>{" Agency"}</text>
|
|
157
|
-
<box height={1} flexShrink={0} />
|
|
158
|
-
</>
|
|
159
|
-
)}
|
|
160
|
-
<text fg={macchiato.blue} flexShrink={0}>
|
|
161
|
-
{props.fullScreen ? ` ${props.prompt}` : props.prompt}
|
|
162
|
-
</text>
|
|
163
|
-
<textarea
|
|
164
|
-
focused
|
|
165
|
-
height={props.fullScreen ? 0 : 2}
|
|
166
|
-
flexGrow={props.fullScreen ? 1 : 0}
|
|
167
|
-
minHeight={1}
|
|
168
|
-
wrapMode="word"
|
|
169
|
-
backgroundColor={macchiato.mantle}
|
|
170
|
-
focusedBackgroundColor={macchiato.surface0}
|
|
171
|
-
textColor={macchiato.text}
|
|
172
|
-
focusedTextColor={macchiato.text}
|
|
173
|
-
cursorColor={macchiato.blue}
|
|
174
|
-
keyBindings={[
|
|
175
|
-
{ name: "return", action: "submit" },
|
|
176
|
-
{ name: "return", shift: true, action: "newline" },
|
|
177
|
-
]}
|
|
178
|
-
onSubmit={() => props.onDone(editing.value)}
|
|
179
|
-
onContentChange={() => {
|
|
180
|
-
editing.handleInput(input?.plainText ?? "")
|
|
181
|
-
}}
|
|
182
|
-
ref={(next) => {
|
|
183
|
-
input = next
|
|
184
|
-
}}
|
|
185
|
-
/>
|
|
186
|
-
<text fg={macchiato.overlay1} wrapMode="none" height={1} flexShrink={0}>
|
|
187
|
-
{props.embedded
|
|
188
|
-
? "enter submit | shift-enter newline | esc clear/back"
|
|
189
|
-
: "enter submit | shift-enter newline | esc cancel"}
|
|
190
|
-
</text>
|
|
191
|
-
</box>
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
interface SelectPromptProps extends PromptProps<string> {
|
|
196
|
-
readonly choices: readonly InteractiveChoice[]
|
|
197
|
-
readonly active?: boolean
|
|
198
|
-
readonly reservedRows?: number
|
|
199
|
-
readonly backgroundColor?: string
|
|
200
|
-
readonly emptyLabel?: string
|
|
201
|
-
readonly onTab?: () => void
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const isWordBoundary = (value: string, index: number) =>
|
|
205
|
-
index === 0 || /[\s/_.:-]/.test(value[index - 1]!)
|
|
206
|
-
|
|
207
|
-
const fuzzyScore = (value: string, query: string) => {
|
|
208
|
-
const candidate = value.toLowerCase()
|
|
209
|
-
const needle = query.toLowerCase()
|
|
210
|
-
let previous = new Float64Array(candidate.length)
|
|
211
|
-
let current = new Float64Array(candidate.length)
|
|
212
|
-
previous.fill(Number.NEGATIVE_INFINITY)
|
|
213
|
-
let bestScore = Number.NEGATIVE_INFINITY
|
|
214
|
-
|
|
215
|
-
for (let queryIndex = 0; queryIndex < needle.length; queryIndex++) {
|
|
216
|
-
current.fill(Number.NEGATIVE_INFINITY)
|
|
217
|
-
let bestEarlier = Number.NEGATIVE_INFINITY
|
|
218
|
-
bestScore = Number.NEGATIVE_INFINITY
|
|
219
|
-
for (let index = 0; index < candidate.length; index++) {
|
|
220
|
-
if (queryIndex > 0 && index > 0) {
|
|
221
|
-
bestEarlier = Math.max(bestEarlier, previous[index - 1]! + index - 1)
|
|
222
|
-
}
|
|
223
|
-
if (candidate[index] !== needle[queryIndex]) continue
|
|
224
|
-
|
|
225
|
-
const boundaryBonus = isWordBoundary(candidate, index) ? 8 : 0
|
|
226
|
-
if (queryIndex === 0) {
|
|
227
|
-
current[index] = 10 + boundaryBonus - index
|
|
228
|
-
bestScore = Math.max(bestScore, current[index]!)
|
|
229
|
-
continue
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const contiguous =
|
|
233
|
-
index > 0 ? previous[index - 1]! + 12 : Number.NEGATIVE_INFINITY
|
|
234
|
-
const gapped = bestEarlier - index + 1
|
|
235
|
-
current[index] = Math.max(contiguous, gapped) + 10 + boundaryBonus
|
|
236
|
-
bestScore = Math.max(bestScore, current[index]!)
|
|
237
|
-
}
|
|
238
|
-
if (!Number.isFinite(bestScore)) return null
|
|
239
|
-
const swap = previous
|
|
240
|
-
previous = current
|
|
241
|
-
current = swap
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return bestScore - candidate.length / 1000
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export const fuzzyChoices = (
|
|
248
|
-
choices: readonly InteractiveChoice[],
|
|
249
|
-
query: string,
|
|
250
|
-
) => {
|
|
251
|
-
if (!query) return choices
|
|
252
|
-
return choices
|
|
253
|
-
.map((choice, index) => ({
|
|
254
|
-
choice,
|
|
255
|
-
index,
|
|
256
|
-
score: fuzzyScore(choice.label, query),
|
|
257
|
-
}))
|
|
258
|
-
.filter(
|
|
259
|
-
(
|
|
260
|
-
match,
|
|
261
|
-
): match is typeof match & {
|
|
262
|
-
score: number
|
|
263
|
-
} => match.score !== null,
|
|
264
|
-
)
|
|
265
|
-
.sort((left, right) => right.score - left.score || left.index - right.index)
|
|
266
|
-
.map((match) => match.choice)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const choiceDepth = (choice: InteractiveChoice) => choice.depth ?? 0
|
|
270
|
-
|
|
271
|
-
const hasSibling = (
|
|
272
|
-
choices: readonly InteractiveChoice[],
|
|
273
|
-
index: number,
|
|
274
|
-
direction: -1 | 1,
|
|
275
|
-
) => {
|
|
276
|
-
const depth = choiceDepth(choices[index]!)
|
|
277
|
-
for (
|
|
278
|
-
let siblingIndex = index + direction;
|
|
279
|
-
siblingIndex >= 0 && siblingIndex < choices.length;
|
|
280
|
-
siblingIndex += direction
|
|
281
|
-
) {
|
|
282
|
-
const siblingDepth = choiceDepth(choices[siblingIndex]!)
|
|
283
|
-
if (siblingDepth < depth) return false
|
|
284
|
-
if (siblingDepth === depth) return true
|
|
285
|
-
}
|
|
286
|
-
return false
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
export const hierarchyPrefix = (
|
|
290
|
-
choices: readonly InteractiveChoice[],
|
|
291
|
-
index: number,
|
|
292
|
-
) => {
|
|
293
|
-
const choice = choices[index]
|
|
294
|
-
if (!choice || choice.depth === undefined) return ""
|
|
295
|
-
const depth = choiceDepth(choice)
|
|
296
|
-
let prefix = ""
|
|
297
|
-
let ancestorIndex = index
|
|
298
|
-
|
|
299
|
-
for (let ancestorDepth = depth - 1; ancestorDepth >= 0; ancestorDepth--) {
|
|
300
|
-
for (ancestorIndex--; ancestorIndex >= 0; ancestorIndex--) {
|
|
301
|
-
if (choiceDepth(choices[ancestorIndex]!) === ancestorDepth) break
|
|
302
|
-
}
|
|
303
|
-
prefix = `${ancestorIndex >= 0 && hasSibling(choices, ancestorIndex, 1) ? "│ " : " "}${prefix}`
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const hasPrevious = hasSibling(choices, index, -1)
|
|
307
|
-
const hasNext = hasSibling(choices, index, 1)
|
|
308
|
-
return `${prefix}${!hasPrevious && hasNext ? "╭" : hasNext ? "├" : "╰"}─ `
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
|
|
312
|
-
let input: TextareaRenderable | undefined
|
|
313
|
-
const dimensions = useTerminalDimensions()
|
|
314
|
-
const [query, setQuery] = createSignal("")
|
|
315
|
-
const editing = createReadlineEditing(() => input, setQuery)
|
|
316
|
-
const [selected, setSelected] = createSignal(0)
|
|
317
|
-
const choices = createMemo(() => fuzzyChoices(props.choices, query()))
|
|
318
|
-
const displaySegments = (choice: InteractiveChoice) =>
|
|
319
|
-
choice.segments ?? [{ text: choice.label }]
|
|
320
|
-
const move = (offset: -1 | 1) => {
|
|
321
|
-
const count = choices().length
|
|
322
|
-
if (count === 0) return
|
|
323
|
-
setSelected((current) => (current + offset + count) % count)
|
|
324
|
-
}
|
|
325
|
-
useKeyboard((key) => {
|
|
326
|
-
if (props.active === false || key.propagationStopped) return
|
|
327
|
-
if (key.name === "tab" && props.onTab) {
|
|
328
|
-
key.preventDefault()
|
|
329
|
-
key.stopPropagation()
|
|
330
|
-
props.onTab()
|
|
331
|
-
return
|
|
332
|
-
}
|
|
333
|
-
if (key.name === "escape" && query()) {
|
|
334
|
-
key.preventDefault()
|
|
335
|
-
key.stopPropagation()
|
|
336
|
-
input?.clear()
|
|
337
|
-
editing.handleInput("")
|
|
338
|
-
setQuery("")
|
|
339
|
-
setSelected(0)
|
|
340
|
-
return
|
|
341
|
-
}
|
|
342
|
-
if (isCancel(key)) {
|
|
343
|
-
key.preventDefault()
|
|
344
|
-
key.stopPropagation()
|
|
345
|
-
if (key.ctrl && key.name === "c") props.onQuit?.()
|
|
346
|
-
props.onDone(null)
|
|
347
|
-
return
|
|
348
|
-
}
|
|
349
|
-
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
|
350
|
-
key.preventDefault()
|
|
351
|
-
key.stopPropagation()
|
|
352
|
-
move(-1)
|
|
353
|
-
return
|
|
354
|
-
}
|
|
355
|
-
if (key.name === "down" || (key.ctrl && key.name === "n")) {
|
|
356
|
-
key.preventDefault()
|
|
357
|
-
key.stopPropagation()
|
|
358
|
-
move(1)
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
editing.handleKey(key)
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
const height = () => dimensions().height - (props.reservedRows ?? 0)
|
|
365
|
-
const background = () => props.backgroundColor ?? macchiato.base
|
|
366
|
-
const brandHeight = () => (!props.embedded && height() > 4 ? 1 : 0)
|
|
367
|
-
const gapHeight = () => (height() > (props.embedded ? 3 : 5) ? 1 : 0)
|
|
368
|
-
const availableRows = () => height() - 2 - 2 * brandHeight() - gapHeight()
|
|
369
|
-
const rowHeight = () =>
|
|
370
|
-
props.choices.some((choice) => choice.details) && availableRows() >= 2
|
|
371
|
-
? 2
|
|
372
|
-
: 1
|
|
373
|
-
const contentWidth = createMemo(() =>
|
|
374
|
-
Math.max(
|
|
375
|
-
0,
|
|
376
|
-
...props.choices.map(({ details }) =>
|
|
377
|
-
details
|
|
378
|
-
? Math.max(
|
|
379
|
-
...[details.title, details.metadata].map((row) =>
|
|
380
|
-
Bun.stringWidth(row.map((segment) => segment.text).join("")),
|
|
381
|
-
),
|
|
382
|
-
)
|
|
383
|
-
: 0,
|
|
384
|
-
),
|
|
385
|
-
),
|
|
386
|
-
)
|
|
387
|
-
const detailWidth = () =>
|
|
388
|
-
Math.min(contentWidth(), Math.max(1, dimensions().width - 20))
|
|
389
|
-
const visible = () => {
|
|
390
|
-
const visibleCount = Math.max(Math.floor(availableRows() / rowHeight()), 1)
|
|
391
|
-
const start = Math.min(
|
|
392
|
-
Math.max(selected() - Math.floor(visibleCount / 2), 0),
|
|
393
|
-
Math.max(choices().length - visibleCount, 0),
|
|
394
|
-
)
|
|
395
|
-
return choices()
|
|
396
|
-
.slice(start, start + visibleCount)
|
|
397
|
-
.map((choice, offset) => ({
|
|
398
|
-
choice,
|
|
399
|
-
index: start + offset,
|
|
400
|
-
originalIndex: props.choices.indexOf(choice),
|
|
401
|
-
}))
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
return (
|
|
405
|
-
<box
|
|
406
|
-
flexDirection="column"
|
|
407
|
-
width="100%"
|
|
408
|
-
height="100%"
|
|
409
|
-
backgroundColor={background()}
|
|
410
|
-
>
|
|
411
|
-
{brandHeight() > 0 && (
|
|
412
|
-
<text fg={macchiato.blue} height={1} flexShrink={0} wrapMode="none">
|
|
413
|
-
{" Agency"}
|
|
414
|
-
</text>
|
|
415
|
-
)}
|
|
416
|
-
<box height={brandHeight()} flexShrink={0} />
|
|
417
|
-
<text fg={macchiato.blue} height={1} flexShrink={0} wrapMode="none">
|
|
418
|
-
{props.prompt}
|
|
419
|
-
</text>
|
|
420
|
-
<box flexDirection="row" width="100%" height={1} flexShrink={0}>
|
|
421
|
-
<text fg={macchiato.blue}>{" "}</text>
|
|
422
|
-
<box
|
|
423
|
-
flexDirection="row"
|
|
424
|
-
flexGrow={1}
|
|
425
|
-
minWidth={10}
|
|
426
|
-
height={1}
|
|
427
|
-
paddingLeft={1}
|
|
428
|
-
paddingRight={1}
|
|
429
|
-
backgroundColor={
|
|
430
|
-
props.active === false
|
|
431
|
-
? macchiato.mantle
|
|
432
|
-
: props.embedded
|
|
433
|
-
? macchiato.surface1
|
|
434
|
-
: macchiato.surface0
|
|
435
|
-
}
|
|
436
|
-
>
|
|
437
|
-
<textarea
|
|
438
|
-
focused={props.active !== false}
|
|
439
|
-
flexGrow={1}
|
|
440
|
-
minWidth={8}
|
|
441
|
-
height={1}
|
|
442
|
-
wrapMode="none"
|
|
443
|
-
placeholder="type to filter"
|
|
444
|
-
placeholderColor={macchiato.overlay0}
|
|
445
|
-
backgroundColor={macchiato.mantle}
|
|
446
|
-
focusedBackgroundColor={
|
|
447
|
-
props.embedded ? macchiato.surface1 : macchiato.surface0
|
|
448
|
-
}
|
|
449
|
-
textColor={macchiato.text}
|
|
450
|
-
focusedTextColor={macchiato.text}
|
|
451
|
-
cursorColor={macchiato.blue}
|
|
452
|
-
keyBindings={[{ name: "return", action: "submit" }]}
|
|
453
|
-
onSubmit={() => {
|
|
454
|
-
const choice = choices()[selected()]
|
|
455
|
-
if (choice) props.onDone(choice.key)
|
|
456
|
-
}}
|
|
457
|
-
onContentChange={() => {
|
|
458
|
-
editing.handleInput(input?.plainText ?? "")
|
|
459
|
-
setSelected(0)
|
|
460
|
-
}}
|
|
461
|
-
ref={(next) => {
|
|
462
|
-
input = next
|
|
463
|
-
}}
|
|
464
|
-
/>
|
|
465
|
-
</box>
|
|
466
|
-
</box>
|
|
467
|
-
<box height={gapHeight()} flexShrink={0} />
|
|
468
|
-
<box flexDirection="column" flexGrow={1}>
|
|
469
|
-
<For
|
|
470
|
-
each={visible()}
|
|
471
|
-
fallback={
|
|
472
|
-
<text fg={macchiato.overlay1}>
|
|
473
|
-
{query() ? "No matches" : (props.emptyLabel ?? "No matches")}
|
|
474
|
-
</text>
|
|
475
|
-
}
|
|
476
|
-
>
|
|
477
|
-
{({ choice, index, originalIndex }) => (
|
|
478
|
-
<box
|
|
479
|
-
width="100%"
|
|
480
|
-
height={rowHeight()}
|
|
481
|
-
flexDirection="row"
|
|
482
|
-
backgroundColor={
|
|
483
|
-
index === selected() ? macchiato.surface1 : background()
|
|
484
|
-
}
|
|
485
|
-
>
|
|
486
|
-
{choice.details && rowHeight() === 2 ? (
|
|
487
|
-
<>
|
|
488
|
-
<text width={2} height={2} fg={macchiato.blue}>
|
|
489
|
-
{index === selected() ? "▌\n▌" : ""}
|
|
490
|
-
</text>
|
|
491
|
-
<box
|
|
492
|
-
flexDirection="column"
|
|
493
|
-
width={detailWidth()}
|
|
494
|
-
height={2}
|
|
495
|
-
flexShrink={0}
|
|
496
|
-
>
|
|
497
|
-
<For each={[choice.details.title, choice.details.metadata]}>
|
|
498
|
-
{(row) => (
|
|
499
|
-
<text height={1} wrapMode="none">
|
|
500
|
-
<For each={row}>
|
|
501
|
-
{(segment) => (
|
|
502
|
-
<span
|
|
503
|
-
style={{ fg: segment.color } as TextNodeOptions}
|
|
504
|
-
>
|
|
505
|
-
{segment.text}
|
|
506
|
-
</span>
|
|
507
|
-
)}
|
|
508
|
-
</For>
|
|
509
|
-
</text>
|
|
510
|
-
)}
|
|
511
|
-
</For>
|
|
512
|
-
</box>
|
|
513
|
-
<box width={2} flexShrink={0} />
|
|
514
|
-
<text
|
|
515
|
-
flexGrow={1}
|
|
516
|
-
minWidth={0}
|
|
517
|
-
width={0}
|
|
518
|
-
height={2}
|
|
519
|
-
wrapMode="word"
|
|
520
|
-
fg={macchiato.subtext0}
|
|
521
|
-
>
|
|
522
|
-
{choice.details.description}
|
|
523
|
-
</text>
|
|
524
|
-
</>
|
|
525
|
-
) : (
|
|
526
|
-
<text
|
|
527
|
-
fg={
|
|
528
|
-
index === selected() ? macchiato.text : macchiato.subtext0
|
|
529
|
-
}
|
|
530
|
-
bg={index === selected() ? macchiato.surface1 : background()}
|
|
531
|
-
wrapMode="none"
|
|
532
|
-
>
|
|
533
|
-
<span
|
|
534
|
-
style={
|
|
535
|
-
{
|
|
536
|
-
fg: index === selected() ? macchiato.blue : undefined,
|
|
537
|
-
} as TextNodeOptions
|
|
538
|
-
}
|
|
539
|
-
>
|
|
540
|
-
{index === selected() ? "▌ " : " "}
|
|
541
|
-
</span>
|
|
542
|
-
<span style={{ fg: macchiato.overlay1 } as TextNodeOptions}>
|
|
543
|
-
{query()
|
|
544
|
-
? ""
|
|
545
|
-
: hierarchyPrefix(props.choices, originalIndex)}
|
|
546
|
-
</span>
|
|
547
|
-
<For each={displaySegments(choice)}>
|
|
548
|
-
{(segment) => (
|
|
549
|
-
<span style={{ fg: segment.color } as TextNodeOptions}>
|
|
550
|
-
{segment.text}
|
|
551
|
-
</span>
|
|
552
|
-
)}
|
|
553
|
-
</For>
|
|
554
|
-
</text>
|
|
555
|
-
)}
|
|
556
|
-
</box>
|
|
557
|
-
)}
|
|
558
|
-
</For>
|
|
559
|
-
</box>
|
|
560
|
-
</box>
|
|
561
|
-
)
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
export interface InteractiveTab {
|
|
565
|
-
readonly id: string
|
|
566
|
-
readonly label: string
|
|
567
|
-
readonly prompt: string
|
|
568
|
-
readonly choices: readonly InteractiveChoice[]
|
|
569
|
-
readonly emptyLabel?: string
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
export const InteractiveTabbedPrompt = (props: {
|
|
573
|
-
readonly tabs: readonly InteractiveTab[]
|
|
574
|
-
readonly onDone: (value: string | null) => void
|
|
575
|
-
readonly onQuit?: () => void
|
|
576
|
-
readonly notice?: string
|
|
577
|
-
readonly initialTab?: number
|
|
578
|
-
readonly onTabChange?: (index: number) => void
|
|
579
|
-
readonly content?: (reservedRows: () => number) => JSX.Element
|
|
580
|
-
}) => {
|
|
581
|
-
const dimensions = useTerminalDimensions()
|
|
582
|
-
const [active, setActive] = createSignal(props.initialTab ?? 0)
|
|
583
|
-
const showBrand = () =>
|
|
584
|
-
dimensions().width >=
|
|
585
|
-
props.tabs.reduce(
|
|
586
|
-
(width, tab) => width + Bun.stringWidth(tab.label) + 4,
|
|
587
|
-
12,
|
|
588
|
-
)
|
|
589
|
-
const reservedRows = () => 1 + (props.notice ? 1 : 0)
|
|
590
|
-
const cycle = () => {
|
|
591
|
-
if (!props.tabs.length) return
|
|
592
|
-
const next = (active() + 1) % props.tabs.length
|
|
593
|
-
setActive(next)
|
|
594
|
-
props.onTabChange?.(next)
|
|
595
|
-
}
|
|
596
|
-
useKeyboard((key) => {
|
|
597
|
-
if (!props.content || key.name !== "tab" || key.propagationStopped) return
|
|
598
|
-
key.preventDefault()
|
|
599
|
-
key.stopPropagation()
|
|
600
|
-
cycle()
|
|
601
|
-
})
|
|
602
|
-
return (
|
|
603
|
-
<box
|
|
604
|
-
flexDirection="column"
|
|
605
|
-
width="100%"
|
|
606
|
-
height="100%"
|
|
607
|
-
backgroundColor={macchiato.surface0}
|
|
608
|
-
>
|
|
609
|
-
<box
|
|
610
|
-
flexDirection="row"
|
|
611
|
-
height={1}
|
|
612
|
-
flexShrink={0}
|
|
613
|
-
paddingLeft={1}
|
|
614
|
-
paddingRight={1}
|
|
615
|
-
>
|
|
616
|
-
<For each={props.tabs}>
|
|
617
|
-
{(tab, index) => (
|
|
618
|
-
<text
|
|
619
|
-
fg={index() === active() ? macchiato.text : macchiato.overlay1}
|
|
620
|
-
bg={index() === active() ? macchiato.base : macchiato.surface0}
|
|
621
|
-
wrapMode="none"
|
|
622
|
-
>
|
|
623
|
-
<span style={{ fg: macchiato.blue } as TextNodeOptions}>
|
|
624
|
-
{index() === active() ? "▎" : " "}
|
|
625
|
-
</span>
|
|
626
|
-
{` ${tab.label} `}
|
|
627
|
-
</text>
|
|
628
|
-
)}
|
|
629
|
-
</For>
|
|
630
|
-
<box flexGrow={1} minWidth={1} />
|
|
631
|
-
<Show when={showBrand()}>
|
|
632
|
-
<text fg={macchiato.blue} flexShrink={0} wrapMode="none">
|
|
633
|
-
{" Agency"}
|
|
634
|
-
</text>
|
|
635
|
-
</Show>
|
|
636
|
-
</box>
|
|
637
|
-
<box
|
|
638
|
-
flexDirection="column"
|
|
639
|
-
flexGrow={1}
|
|
640
|
-
minHeight={0}
|
|
641
|
-
backgroundColor={macchiato.base}
|
|
642
|
-
paddingLeft={1}
|
|
643
|
-
paddingRight={1}
|
|
644
|
-
>
|
|
645
|
-
<Show when={props.notice}>
|
|
646
|
-
<text fg={macchiato.text} height={1} flexShrink={0} wrapMode="none">
|
|
647
|
-
{props.notice}
|
|
648
|
-
</text>
|
|
649
|
-
</Show>
|
|
650
|
-
{props.content ? (
|
|
651
|
-
props.content(reservedRows)
|
|
652
|
-
) : (
|
|
653
|
-
<For each={props.tabs}>
|
|
654
|
-
{(tab, index) => (
|
|
655
|
-
<box
|
|
656
|
-
visible={index() === active()}
|
|
657
|
-
width="100%"
|
|
658
|
-
flexGrow={1}
|
|
659
|
-
minHeight={0}
|
|
660
|
-
>
|
|
661
|
-
<InteractiveSelectPrompt
|
|
662
|
-
embedded
|
|
663
|
-
active={index() === active()}
|
|
664
|
-
reservedRows={reservedRows()}
|
|
665
|
-
backgroundColor={macchiato.base}
|
|
666
|
-
prompt={tab.prompt}
|
|
667
|
-
choices={tab.choices}
|
|
668
|
-
emptyLabel={tab.emptyLabel}
|
|
669
|
-
onTab={cycle}
|
|
670
|
-
onDone={props.onDone}
|
|
671
|
-
onQuit={props.onQuit}
|
|
672
|
-
/>
|
|
673
|
-
</box>
|
|
674
|
-
)}
|
|
675
|
-
</For>
|
|
676
|
-
)}
|
|
677
|
-
</box>
|
|
678
|
-
</box>
|
|
679
|
-
)
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
const shutdown = async (renderer: CliRenderer) => {
|
|
683
|
-
try {
|
|
684
|
-
await renderer.idle()
|
|
685
|
-
} finally {
|
|
686
|
-
// OpenTUI restores streams/terminal modes and disposes the Solid root.
|
|
687
|
-
renderer.destroy()
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
type SessionView =
|
|
692
|
-
| {
|
|
693
|
-
kind: "tabs"
|
|
694
|
-
tabs: readonly InteractiveTab[]
|
|
695
|
-
finish: (value: string | null) => void
|
|
696
|
-
}
|
|
697
|
-
| {
|
|
698
|
-
kind: "select"
|
|
699
|
-
prompt: string
|
|
700
|
-
choices: readonly InteractiveChoice[]
|
|
701
|
-
finish: (value: string | null) => void
|
|
702
|
-
}
|
|
703
|
-
| { kind: "text"; prompt: string; finish: (value: string | null) => void }
|
|
704
|
-
| { kind: "progress"; prompt: string }
|
|
705
|
-
|
|
706
|
-
/** One terminal owner for a complete guided flow, rather than one per prompt. */
|
|
707
|
-
export const createInteractiveSession = async (
|
|
708
|
-
onCancel: () => void = () => {},
|
|
709
|
-
initialTabs: readonly InteractiveTab[] = [],
|
|
710
|
-
) => {
|
|
711
|
-
const [view, setView] = createSignal<SessionView>({
|
|
712
|
-
kind: "progress",
|
|
713
|
-
prompt: "Preparing…",
|
|
714
|
-
})
|
|
715
|
-
let pending: ((value: string | null) => void) | undefined
|
|
716
|
-
let closed = false
|
|
717
|
-
let cancelled = false
|
|
718
|
-
let quitRequested = false
|
|
719
|
-
let activeTab = 0
|
|
720
|
-
let frameTabs = initialTabs
|
|
721
|
-
let navigationRequested = false
|
|
722
|
-
const navigate = (index: number) => {
|
|
723
|
-
activeTab = index
|
|
724
|
-
navigationRequested = true
|
|
725
|
-
if (pending) pending(null)
|
|
726
|
-
else {
|
|
727
|
-
cancelled = true
|
|
728
|
-
onCancel()
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
const [notice, setNotice] = createSignal("")
|
|
732
|
-
const quit = () => {
|
|
733
|
-
quitRequested = true
|
|
734
|
-
onCancel()
|
|
735
|
-
}
|
|
736
|
-
const Progress = (props: { prompt: string }) => {
|
|
737
|
-
useKeyboard((key) => {
|
|
738
|
-
if (key.propagationStopped) return
|
|
739
|
-
if (!isCancel(key)) return
|
|
740
|
-
key.preventDefault()
|
|
741
|
-
key.stopPropagation()
|
|
742
|
-
cancelled = true
|
|
743
|
-
if (key.ctrl && key.name === "c") quitRequested = true
|
|
744
|
-
onCancel()
|
|
745
|
-
})
|
|
746
|
-
return (
|
|
747
|
-
<box
|
|
748
|
-
flexDirection="column"
|
|
749
|
-
width="100%"
|
|
750
|
-
height="100%"
|
|
751
|
-
backgroundColor={macchiato.base}
|
|
752
|
-
>
|
|
753
|
-
<text fg={macchiato.text}>{props.prompt}</text>
|
|
754
|
-
</box>
|
|
755
|
-
)
|
|
756
|
-
}
|
|
757
|
-
const renderer = await createCliRenderer({
|
|
758
|
-
...interactiveSelectRendererConfig,
|
|
759
|
-
onDestroy: () => {
|
|
760
|
-
// An external shutdown ends the application; an owned close may be a
|
|
761
|
-
// temporary worker handoff and must allow the caller to reopen the UI.
|
|
762
|
-
const external = !closed
|
|
763
|
-
closed = true
|
|
764
|
-
if (external) quit()
|
|
765
|
-
pending?.(null)
|
|
766
|
-
},
|
|
767
|
-
})
|
|
768
|
-
try {
|
|
769
|
-
await render(
|
|
770
|
-
() => (
|
|
771
|
-
<Show when={view()} keyed>
|
|
772
|
-
{(current) =>
|
|
773
|
-
current.kind === "tabs" ? (
|
|
774
|
-
<InteractiveTabbedPrompt
|
|
775
|
-
tabs={current.tabs}
|
|
776
|
-
onDone={current.finish}
|
|
777
|
-
onQuit={quit}
|
|
778
|
-
notice={notice()}
|
|
779
|
-
initialTab={activeTab}
|
|
780
|
-
onTabChange={(index) => {
|
|
781
|
-
activeTab = index
|
|
782
|
-
}}
|
|
783
|
-
/>
|
|
784
|
-
) : (
|
|
785
|
-
<InteractiveTabbedPrompt
|
|
786
|
-
tabs={frameTabs}
|
|
787
|
-
initialTab={activeTab}
|
|
788
|
-
notice={notice()}
|
|
789
|
-
onDone={() => {}}
|
|
790
|
-
onTabChange={navigate}
|
|
791
|
-
content={(reservedRows) =>
|
|
792
|
-
current.kind === "select" ? (
|
|
793
|
-
<InteractiveSelectPrompt
|
|
794
|
-
embedded
|
|
795
|
-
reservedRows={reservedRows()}
|
|
796
|
-
prompt={current.prompt}
|
|
797
|
-
choices={current.choices}
|
|
798
|
-
onDone={current.finish}
|
|
799
|
-
onQuit={quit}
|
|
800
|
-
/>
|
|
801
|
-
) : current.kind === "text" ? (
|
|
802
|
-
<InteractiveTextPrompt
|
|
803
|
-
fullScreen
|
|
804
|
-
embedded
|
|
805
|
-
prompt={current.prompt}
|
|
806
|
-
onDone={current.finish}
|
|
807
|
-
onQuit={quit}
|
|
808
|
-
/>
|
|
809
|
-
) : (
|
|
810
|
-
<Progress prompt={current.prompt} />
|
|
811
|
-
)
|
|
812
|
-
}
|
|
813
|
-
/>
|
|
814
|
-
)
|
|
815
|
-
}
|
|
816
|
-
</Show>
|
|
817
|
-
),
|
|
818
|
-
renderer,
|
|
819
|
-
)
|
|
820
|
-
} catch (error) {
|
|
821
|
-
await shutdown(renderer)
|
|
822
|
-
throw error
|
|
823
|
-
}
|
|
824
|
-
const ask = (
|
|
825
|
-
prompt: string,
|
|
826
|
-
choices?: readonly InteractiveChoice[],
|
|
827
|
-
tabs?: readonly InteractiveTab[],
|
|
828
|
-
) => {
|
|
829
|
-
if (closed || cancelled) return Promise.resolve(null)
|
|
830
|
-
return new Promise<string | null>((resolve) => {
|
|
831
|
-
pending = (value) => {
|
|
832
|
-
pending = undefined
|
|
833
|
-
if (!closed)
|
|
834
|
-
setView({ kind: "progress", prompt: "Preparing next step…" })
|
|
835
|
-
resolve(value)
|
|
836
|
-
}
|
|
837
|
-
setView(
|
|
838
|
-
tabs
|
|
839
|
-
? { kind: "tabs", tabs, finish: pending }
|
|
840
|
-
: choices
|
|
841
|
-
? { kind: "select", prompt, choices, finish: pending }
|
|
842
|
-
: { kind: "text", prompt, finish: pending },
|
|
843
|
-
)
|
|
844
|
-
renderer.requestRender()
|
|
845
|
-
})
|
|
846
|
-
}
|
|
847
|
-
return {
|
|
848
|
-
activateTab: (id: string) => {
|
|
849
|
-
const index = frameTabs.findIndex((tab) => tab.id === id)
|
|
850
|
-
if (index >= 0) activeTab = index
|
|
851
|
-
},
|
|
852
|
-
takeNavigation: () => {
|
|
853
|
-
const requested = navigationRequested
|
|
854
|
-
navigationRequested = false
|
|
855
|
-
return requested
|
|
856
|
-
},
|
|
857
|
-
get navigationRequested() {
|
|
858
|
-
return navigationRequested
|
|
859
|
-
},
|
|
860
|
-
get quitRequested() {
|
|
861
|
-
return quitRequested
|
|
862
|
-
},
|
|
863
|
-
notice: (message: string) => {
|
|
864
|
-
cancelled = false
|
|
865
|
-
setNotice(message)
|
|
866
|
-
},
|
|
867
|
-
tabs: (tabs: readonly InteractiveTab[]) => {
|
|
868
|
-
frameTabs = tabs
|
|
869
|
-
return ask("", undefined, tabs)
|
|
870
|
-
},
|
|
871
|
-
text: (prompt: string) => ask(prompt),
|
|
872
|
-
select: (prompt: string, choices: readonly InteractiveChoice[]) =>
|
|
873
|
-
ask(prompt, choices),
|
|
874
|
-
show: (prompt: string) => {
|
|
875
|
-
setView({ kind: "progress", prompt })
|
|
876
|
-
renderer.requestRender()
|
|
877
|
-
},
|
|
878
|
-
close: async () => {
|
|
879
|
-
if (closed) return
|
|
880
|
-
closed = true
|
|
881
|
-
pending?.(null)
|
|
882
|
-
await shutdown(renderer)
|
|
883
|
-
},
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
async function runInteractive<T>(
|
|
888
|
-
view: (finish: (value: T | null) => void) => JSX.Element,
|
|
889
|
-
config: CliRendererConfig = interactiveRendererConfig,
|
|
890
|
-
) {
|
|
891
|
-
let finish!: (value: T | null) => void
|
|
892
|
-
let settled = false
|
|
893
|
-
const result = new Promise<T | null>((resolve) => {
|
|
894
|
-
finish = (value) => {
|
|
895
|
-
if (settled) return
|
|
896
|
-
settled = true
|
|
897
|
-
resolve(value)
|
|
898
|
-
}
|
|
899
|
-
})
|
|
900
|
-
let renderer: CliRenderer | undefined
|
|
901
|
-
try {
|
|
902
|
-
renderer = await createCliRenderer({
|
|
903
|
-
...config,
|
|
904
|
-
onDestroy: () => finish(null),
|
|
905
|
-
})
|
|
906
|
-
await render(() => view(finish), renderer)
|
|
907
|
-
renderer.requestRender()
|
|
908
|
-
return await result
|
|
909
|
-
} finally {
|
|
910
|
-
if (renderer) {
|
|
911
|
-
await shutdown(renderer)
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
export const promptText = async (prompt: string) => {
|
|
917
|
-
const result = await runInteractive<string>((finish) => (
|
|
918
|
-
<InteractiveTextPrompt prompt={prompt} onDone={finish} />
|
|
919
|
-
))
|
|
920
|
-
if (result === null) throw new Error("Interactive input cancelled")
|
|
921
|
-
return result
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
export const promptSelect = (
|
|
925
|
-
prompt: string,
|
|
926
|
-
choices: readonly InteractiveChoice[],
|
|
927
|
-
) =>
|
|
928
|
-
runInteractive<string>(
|
|
929
|
-
(finish) => (
|
|
930
|
-
<InteractiveSelectPrompt
|
|
931
|
-
prompt={prompt}
|
|
932
|
-
choices={choices}
|
|
933
|
-
onDone={finish}
|
|
934
|
-
/>
|
|
935
|
-
),
|
|
936
|
-
interactiveSelectRendererConfig,
|
|
937
|
-
)
|