@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.
@@ -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
- )