@markjaquith/agency 3.5.1 → 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.
- package/README.md +167 -18
- package/cli-main.ts +13 -2
- package/docs/opencode-auto-start.md +67 -0
- package/package.json +10 -5
- package/src/act-schema.ts +62 -0
- package/src/cli-parser.ts +13 -3
- package/src/commands/act-actions.ts +734 -0
- package/src/commands/act-prompts.ts +294 -0
- package/src/commands/act.ts +602 -436
- package/src/commands/work.ts +16 -2
- package/src/utils/chooser.ts +7 -0
- package/src/utils/exec.ts +21 -6
- package/src/utils/interactive.tsx +528 -87
- package/src/workbase/AGENTS.md +49 -0
- package/src/workbase/opencode-launch.ts +120 -0
- package/src/workbase/work-target.ts +6 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
155
|
-
keyBindings={[
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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={
|
|
409
|
+
backgroundColor={background()}
|
|
355
410
|
>
|
|
356
|
-
|
|
357
|
-
<text fg={macchiato.blue}
|
|
358
|
-
{
|
|
411
|
+
{brandHeight() > 0 && (
|
|
412
|
+
<text fg={macchiato.blue} height={1} flexShrink={0} wrapMode="none">
|
|
413
|
+
{" Agency"}
|
|
359
414
|
</text>
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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={
|
|
365
|
-
height={
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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={
|
|
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={
|
|
480
|
+
height={rowHeight()}
|
|
481
|
+
flexDirection="row"
|
|
396
482
|
backgroundColor={
|
|
397
|
-
index === selected() ? macchiato.surface1 :
|
|
483
|
+
index === selected() ? macchiato.surface1 : background()
|
|
398
484
|
}
|
|
399
485
|
>
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
{
|
|
408
|
-
|
|
409
|
-
}
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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>(
|