@markjaquith/agency 3.5.2 → 3.6.0

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.
@@ -13,8 +13,8 @@ import {
13
13
  useTerminalDimensions,
14
14
  type JSX,
15
15
  } from "@opentui/solid"
16
- import { createMemo, createSignal, For } from "solid-js"
17
- import type { ChoiceSegment } from "./chooser"
16
+ import { createMemo, createSignal, For, Show } from "solid-js"
17
+ import type { ChoiceDetails, ChoiceSegment } from "./chooser"
18
18
  import { macchiato } from "./theme"
19
19
 
20
20
  export interface InteractiveChoice {
@@ -22,6 +22,7 @@ export interface InteractiveChoice {
22
22
  readonly label: string
23
23
  readonly depth?: number
24
24
  readonly segments?: readonly ChoiceSegment[]
25
+ readonly details?: ChoiceDetails
25
26
  }
26
27
 
27
28
  export const interactiveRendererConfig = {
@@ -45,6 +46,9 @@ export const interactiveSelectRendererConfig = {
45
46
  interface PromptProps<T> {
46
47
  readonly prompt: string
47
48
  readonly onDone: (value: T | null) => void
49
+ readonly fullScreen?: boolean
50
+ readonly onQuit?: () => void
51
+ readonly embedded?: boolean
48
52
  }
49
53
 
50
54
  const isCancel = (key: { name: string; ctrl: boolean }) =>
@@ -122,17 +126,22 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
122
126
  let input: TextareaRenderable | undefined
123
127
  const editing = createReadlineEditing(() => input)
124
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
+ }
125
137
  if (isCancel(key)) {
126
138
  key.preventDefault()
127
139
  key.stopPropagation()
140
+ if (key.ctrl && key.name === "c") props.onQuit?.()
128
141
  props.onDone(null)
129
142
  return
130
143
  }
131
- if (editing.handleKey(key)) return
132
- if (key.name !== "return") return
133
- key.preventDefault()
134
- key.stopPropagation()
135
- props.onDone(editing.value)
144
+ editing.handleKey(key)
136
145
  })
137
146
 
138
147
  return (
@@ -142,29 +151,42 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
142
151
  height="100%"
143
152
  backgroundColor={macchiato.base}
144
153
  >
145
- <text fg={macchiato.blue}>{props.prompt}</text>
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>
146
163
  <textarea
147
164
  focused
148
- height={2}
165
+ height={props.fullScreen ? 0 : 2}
166
+ flexGrow={props.fullScreen ? 1 : 0}
167
+ minHeight={1}
149
168
  wrapMode="word"
150
169
  backgroundColor={macchiato.mantle}
151
170
  focusedBackgroundColor={macchiato.surface0}
152
171
  textColor={macchiato.text}
153
172
  focusedTextColor={macchiato.text}
154
- cursorColor={macchiato.rosewater}
155
- keyBindings={[{ name: "return", action: "submit" }]}
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)}
156
179
  onContentChange={() => {
157
180
  editing.handleInput(input?.plainText ?? "")
158
181
  }}
159
182
  ref={(next) => {
160
183
  input = next
161
- queueMicrotask(() => {
162
- if (input && !input.isDestroyed) input.focus()
163
- })
164
184
  }}
165
185
  />
166
- <text fg={macchiato.overlay1} wrapMode="none">
167
- enter submit | esc cancel | ctrl-y yank
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"}
168
190
  </text>
169
191
  </box>
170
192
  )
@@ -172,6 +194,11 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
172
194
 
173
195
  interface SelectPromptProps extends PromptProps<string> {
174
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
175
202
  }
176
203
 
177
204
  const isWordBoundary = (value: string, index: number) =>
@@ -296,6 +323,13 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
296
323
  setSelected((current) => (current + offset + count) % count)
297
324
  }
298
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
+ }
299
333
  if (key.name === "escape" && query()) {
300
334
  key.preventDefault()
301
335
  key.stopPropagation()
@@ -308,6 +342,7 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
308
342
  if (isCancel(key)) {
309
343
  key.preventDefault()
310
344
  key.stopPropagation()
345
+ if (key.ctrl && key.name === "c") props.onQuit?.()
311
346
  props.onDone(null)
312
347
  return
313
348
  }
@@ -323,16 +358,36 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
323
358
  move(1)
324
359
  return
325
360
  }
326
- if (editing.handleKey(key)) return
327
- if (key.name !== "return") return
328
- key.preventDefault()
329
- key.stopPropagation()
330
- const choice = choices()[selected()]
331
- if (choice) props.onDone(choice.key)
361
+ editing.handleKey(key)
332
362
  })
333
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))
334
389
  const visible = () => {
335
- const visibleCount = Math.max(dimensions().height - 3, 1)
390
+ const visibleCount = Math.max(Math.floor(availableRows() / rowHeight()), 1)
336
391
  const start = Math.min(
337
392
  Math.max(selected() - Math.floor(visibleCount / 2), 0),
338
393
  Math.max(choices().length - visibleCount, 0),
@@ -351,96 +406,482 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
351
406
  flexDirection="column"
352
407
  width="100%"
353
408
  height="100%"
354
- backgroundColor={macchiato.base}
409
+ backgroundColor={background()}
355
410
  >
356
- <box flexDirection="row" width="100%">
357
- <text fg={macchiato.blue} flexShrink={1} wrapMode="none">
358
- {props.prompt}
411
+ {brandHeight() > 0 && (
412
+ <text fg={macchiato.blue} height={1} flexShrink={0} wrapMode="none">
413
+ {" Agency"}
359
414
  </text>
360
- <text fg={macchiato.blue}>{" > "}</text>
361
- <textarea
362
- focused
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"
363
424
  flexGrow={1}
364
- minWidth={8}
365
- height={2}
366
- wrapMode="word"
367
- placeholder="filter"
368
- placeholderColor={macchiato.overlay0}
369
- backgroundColor={macchiato.mantle}
370
- focusedBackgroundColor={macchiato.surface0}
371
- textColor={macchiato.text}
372
- focusedTextColor={macchiato.text}
373
- cursorColor={macchiato.rosewater}
374
- keyBindings={[{ name: "return", action: "submit" }]}
375
- onContentChange={() => {
376
- editing.handleInput(input?.plainText ?? "")
377
- setSelected(0)
378
- }}
379
- ref={(next) => {
380
- input = next
381
- queueMicrotask(() => {
382
- if (input && !input.isDestroyed) input.focus()
383
- })
384
- }}
385
- />
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>
386
466
  </box>
467
+ <box height={gapHeight()} flexShrink={0} />
387
468
  <box flexDirection="column" flexGrow={1}>
388
469
  <For
389
470
  each={visible()}
390
- fallback={<text fg={macchiato.overlay1}>No matches</text>}
471
+ fallback={
472
+ <text fg={macchiato.overlay1}>
473
+ {query() ? "No matches" : (props.emptyLabel ?? "No matches")}
474
+ </text>
475
+ }
391
476
  >
392
477
  {({ choice, index, originalIndex }) => (
393
478
  <box
394
479
  width="100%"
395
- height={1}
480
+ height={rowHeight()}
481
+ flexDirection="row"
396
482
  backgroundColor={
397
- index === selected() ? macchiato.surface1 : macchiato.base
483
+ index === selected() ? macchiato.surface1 : background()
398
484
  }
399
485
  >
400
- <text
401
- fg={index === selected() ? macchiato.text : macchiato.subtext0}
402
- bg={index === selected() ? macchiato.surface1 : macchiato.base}
403
- wrapMode="none"
404
- >
405
- <span
406
- style={
407
- {
408
- fg: index === selected() ? macchiato.mauve : undefined,
409
- } as TextNodeOptions
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
410
529
  }
530
+ bg={index === selected() ? macchiato.surface1 : background()}
531
+ wrapMode="none"
411
532
  >
412
- {index === selected() ? "▌ " : " "}
413
- </span>
414
- <span style={{ fg: macchiato.overlay1 } as TextNodeOptions}>
415
- {query() ? "" : hierarchyPrefix(props.choices, originalIndex)}
416
- </span>
417
- <For each={displaySegments(choice)}>
418
- {(segment) => (
419
- <span style={{ fg: segment.color } as TextNodeOptions}>
420
- {segment.text}
421
- </span>
422
- )}
423
- </For>
424
- </text>
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
+ )}
425
556
  </box>
426
557
  )}
427
558
  </For>
428
559
  </box>
429
- <text fg={macchiato.overlay1} wrapMode="none">
430
- enter select | esc clear/cancel | arrows/ctrl-n/p | ctrl-y yank
431
- </text>
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>
432
678
  </box>
433
679
  )
434
680
  }
435
681
 
436
682
  const shutdown = async (renderer: CliRenderer) => {
437
- await renderer.idle().catch(() => undefined)
438
- if (renderer.externalOutputMode === "capture-stdout") {
439
- renderer.externalOutputMode = "passthrough"
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
+ },
440
884
  }
441
- if (renderer.screenMode === "split-footer")
442
- renderer.screenMode = "main-screen"
443
- if (!renderer.isDestroyed) renderer.destroy()
444
885
  }
445
886
 
446
887
  async function runInteractive<T>(