1/** @jsxImportSource @alchemy.run/sigil */
2import { useEffect, useMemo, useState } from "@alchemy.run/sigil/react";
3import type { ReactNode } from "react";
4import type {
5 ConfirmOptions,
6 CycleSelectOptions,
7 AwaitExternalOptions,
8 MenuOptions,
9 MultiSelectOptions,
10 PasswordInputOptions,
11 Screen,
12 ScreenController,
13 SelectOptions,
14 TextInputOptions,
15} from "../types.ts";
16import { useGlyphs, useKeyGlyphs } from "../ui/Environment.tsx";
17import { Alert } from "../ui/Feedback.tsx";
18import {
19 ChoiceGroup,
20 filterChoices,
21 CycleList,
22 ExternalWait,
23 jumpSkippingDisabled,
24 Menu,
25 moveSkippingDisabled,
26 PromptFrame,
27 sanitizeTextInsert,
28 TextField,
29 useConfirmKeys,
30 useListNavigation,
31 useSelectedChoices,
32 useTerminalInput,
33 useTerminalPaste,
34 useTerminalSize,
35 useCycleNavigation,
36 type TerminalKey,
37} from "../ui/Interactive.tsx";
38import { Box } from "../ui/Layout.tsx";
39import { AnsweredPrompt } from "../ui/Transcript.tsx";
40import { Text } from "../ui/Typography.tsx";
41
42const errorMessage = (value: string | Error | undefined) =>
43 value instanceof Error ? value.message : value;
44
45const answerText = (value: string, maskGlyph?: string) => {
46 if (maskGlyph !== undefined) {
47 return maskGlyph.repeat(Math.min(value.length, 12));
48 }
49 return value || "(empty)";
50};
51
52/**
53 * Escape-to-cancel for standard prompts. Ctrl+C is handled centrally by the
54 * screen runner (SigilRuntime.run), so screens only wire Escape semantics.
55 */
56const useCancel = (cancel: () => void) =>
57 useTerminalInput((_input, key) => {
58 if (key.escape) cancel();
59 });
60
61/** One `Screen` factory shape shared by the non-generic prompt screens. */
62const screen =
63 <Options, Value>(
64 name: string,
65 Component: (
66 props: { readonly options: Options } & ScreenController<Value>,
67 ) => ReactNode,
68 ) =>
69 (options: Options): Screen<Value> => ({
70 name,
71 render: (controller) => <Component options={options} {...controller} />,
72 });
73
74/**
75 * Scaffolding shared by the select and multi-select prompts: cursor state
76 * clamped onto enabled rows, the navigation key ladder (arrows, `j`/`k` when
77 * not searchable, Home/End, PageUp/PageDown), Escape-clears-filter-then-
78 * cancels, filter editing, and bracketed paste into the filter. Callers run
79 * their own submit/toggle keys first and delegate the rest to `handleKey`.
80 */
81const useChoiceList = ({
82 disabled,
83 searchable,
84 visibleCount,
85 query,
86 setQuery,
87 onCancel,
88 initialCursor = 0,
89}: {
90 readonly disabled: ReadonlyArray<boolean>;
91 readonly searchable: boolean;
92 readonly visibleCount: number;
93 readonly query: string;
94 readonly setQuery: (query: string) => void;
95 readonly onCancel: () => void;
96 readonly initialCursor?: number;
97}) => {
98 const { cursor, setCursor } = useListNavigation(
99 disabled.length,
100 initialCursor,
101 );
102 useEffect(() => {
103 setCursor((current) => {
104 if (disabled.length === 0) return 0;
105 const clamped = Math.min(current, disabled.length - 1);
106 if (!disabled[clamped]) return clamped;
107 const enabled = disabled.findIndex((value) => !value);
108 return enabled === -1 ? clamped : enabled;
109 });
110 }, [disabled, setCursor]);
111 const page = Math.max(1, visibleCount);
112 const resetFilter = (next: string) => {
113 setQuery(next);
114 setCursor(0);
115 };
116 /** Returns true when the keystroke was consumed. */
117 const handleKey = (input: string, key: TerminalKey): boolean => {
118 const plain = !key.ctrl && !key.meta;
119 if (key.up || (!searchable && plain && input === "k"))
120 setCursor(moveSkippingDisabled(disabled, cursor, -1));
121 else if (key.down || (!searchable && plain && input === "j"))
122 setCursor(moveSkippingDisabled(disabled, cursor, 1));
123 else if (key.home) setCursor(jumpSkippingDisabled(disabled, cursor, 0));
124 else if (key.end)
125 setCursor(jumpSkippingDisabled(disabled, cursor, disabled.length - 1));
126 else if (key.pageUp)
127 setCursor(jumpSkippingDisabled(disabled, cursor, cursor - page));
128 else if (key.pageDown)
129 setCursor(jumpSkippingDisabled(disabled, cursor, cursor + page));
130 else if (key.escape) {
131 // An active filter absorbs the first Escape; the second cancels.
132 if (query !== "") resetFilter("");
133 else onCancel();
134 } else if (searchable && (key.backspace || key.delete))
135 resetFilter(query.slice(0, -1));
136 else if (searchable && plain && !key.tab) {
137 const typed = sanitizeTextInsert(input);
138 if (typed.length === 0) return false;
139 resetFilter(query + typed);
140 } else return false;
141 return true;
142 };
143 useTerminalPaste(
144 (pasted) => {
145 const typed = sanitizeTextInsert(pasted);
146 if (typed.length > 0) resetFilter(query + typed);
147 },
148 { active: searchable },
149 );
150 return { cursor, setCursor, handleKey } as const;
151};
152
153/**
154 * fzf-style query row for searchable lists: a `/` prefix, the typed text
155 * with a block cursor, and a right-hand annotation (match count, selection
156 * count). The row is always present so the list does not jump when the first
157 * character is typed.
158 */
159function FilterLine({
160 query,
161 annotation,
162}: {
163 readonly query: string;
164 readonly annotation: string;
165}) {
166 return (
167 <Box gap={2}>
168 <Text>
169 <Text tone="muted">/ </Text>
170 {query === "" ? (
171 <Text tone="muted">type to filter</Text>
172 ) : (
173 <>
174 {query}
175 <Text inverse> </Text>
176 </>
177 )}
178 </Text>
179 <Text tone="muted">{annotation}</Text>
180 </Box>
181 );
182}
183
184type TextPromptProps = {
185 readonly options: TextInputOptions | PasswordInputOptions;
186 readonly mask?: boolean;
187 readonly submit: (value: string, summary?: ReactNode) => void;
188 readonly cancel: () => void;
189};
190
191function TextPrompt({ options, mask, submit, cancel }: TextPromptProps) {
192 const glyphs = useGlyphs();
193 const keys = useKeyGlyphs();
194 const maskGlyph = mask ? glyphs.mask : undefined;
195 const [error, setError] = useState<string>();
196 useCancel(cancel);
197 const complete = (raw: string) => {
198 const value =
199 raw === "" &&
200 "defaultValue" in options &&
201 options.defaultValue !== undefined
202 ? options.defaultValue
203 : raw;
204 const problem = errorMessage(options.validate?.(value));
205 if (problem !== undefined) setError(problem);
206 else
207 submit(
208 value,
209 <AnsweredPrompt
210 message={options.message}
211 answer={answerText(value, maskGlyph)}
212 />,
213 );
214 };
215 return (
216 <PromptFrame
217 message={options.message}
218 description={options.description}
219 layout={options.layout ?? "inline"}
220 error={error}
221 keys={[
222 [keys.enter, "confirm"],
223 [keys.escape, "cancel"],
224 ]}
225 >
226 <TextField
227 // A default is the value Enter will submit, so show it in the empty
228 // field when the caller did not supply more specific hint text.
229 // Keep it a placeholder (rather than initialValue) so the first
230 // keystroke starts a replacement instead of appending to the default.
231 placeholder={
232 options.placeholder ??
233 ("defaultValue" in options ? options.defaultValue : undefined)
234 }
235 initialValue={
236 "initialValue" in options ? options.initialValue : undefined
237 }
238 mask={maskGlyph}
239 ariaLabel={options.message}
240 onChange={() => setError(undefined)}
241 onSubmit={complete}
242 />
243 </PromptFrame>
244 );
245}
246
247export const textScreen = screen<TextInputOptions, string>(
248 "text input",
249 TextPrompt,
250);
251
252export const passwordScreen = screen<PasswordInputOptions, string>(
253 "password input",
254 (props) => <TextPrompt {...props} mask />,
255);
256
257type SelectPromptProps<Value> = {
258 readonly options: SelectOptions<Value>;
259 readonly submit: (value: Value, summary?: ReactNode) => void;
260 readonly cancel: () => void;
261 readonly summary?: boolean;
262 readonly header?: ReactNode;
263 readonly footer?: ReactNode;
264 readonly escapeLabel?: string;
265};
266
267function SelectPrompt<Value>({
268 options,
269 submit,
270 cancel,
271 summary = true,
272 header,
273 footer,
274 escapeLabel = "cancel",
275}: SelectPromptProps<Value>) {
276 const keys = useKeyGlyphs();
277 const { rows } = useTerminalSize();
278 const visibleCount =
279 options.visibleCount ?? Math.max(3, Math.min(16, rows - 8));
280 const searchable = options.searchable === true;
281 const [query, setQuery] = useState("");
282 const filtered = useMemo(
283 () =>
284 searchable
285 ? filterChoices(options.options, query)
286 : options.options.map((choice, index) => ({ choice, index })),
287 [options.options, query, searchable],
288 );
289 const initialOriginalIndex = options.options.findIndex(
290 (choice) => choice.value === options.initialValue,
291 );
292 const initialIndex = filtered.findIndex(
293 ({ index }) => index === initialOriginalIndex,
294 );
295 const firstEnabled = filtered.findIndex(({ choice }) => !choice.disabled);
296 const disabled = useMemo(
297 () =>
298 filtered.map(
299 ({ choice }) =>
300 choice.disabled !== undefined && choice.disabled !== false,
301 ),
302 [filtered],
303 );
304 const { cursor, handleKey } = useChoiceList({
305 disabled,
306 searchable,
307 visibleCount,
308 query,
309 setQuery,
310 onCancel: cancel,
311 initialCursor:
312 initialIndex !== -1 && !filtered[initialIndex]?.choice.disabled
313 ? initialIndex
314 : Math.max(0, firstEnabled),
315 });
316 useTerminalInput((input, key) => {
317 if (key.enter) {
318 const choice = filtered[cursor]?.choice;
319 if (choice !== undefined && !choice.disabled) {
320 submit(
321 choice.value,
322 summary ? (
323 <AnsweredPrompt message={options.message} answer={choice.label} />
324 ) : undefined,
325 );
326 }
327 return;
328 }
329 handleKey(input, key);
330 });
331 return (
332 <Box flexDirection="column" gap={1}>
333 {header}
334 <PromptFrame
335 message={options.message}
336 keys={[
337 [keys.upDown, "navigate"],
338 [keys.enter, "select"],
339 [keys.escape, query === "" ? escapeLabel : "clear filter"],
340 ]}
341 >
342 <Box flexDirection="column">
343 {searchable ? (
344 <FilterLine
345 query={query}
346 annotation={
347 query === ""
348 ? `${options.options.length} choices`
349 : `${filtered.length} of ${options.options.length}`
350 }
351 />
352 ) : null}
353 <Menu
354 choices={filtered.map(({ choice }) => choice)}
355 cursor={cursor}
356 visibleCount={visibleCount}
357 empty="No matching choices."
358 descriptionPlacement={options.descriptionPlacement}
359 />
360 </Box>
361 </PromptFrame>
362 {footer}
363 </Box>
364 );
365}
366
367export const selectScreen = <Value,>(
368 options: SelectOptions<Value>,
369): Screen<Value> => ({
370 name: "selection",
371 render: ({ submit, cancel }) => (
372 <SelectPrompt options={options} submit={submit} cancel={cancel} />
373 ),
374});
375
376export const menuScreen = <Value,>(
377 options: MenuOptions<Value>,
378): Screen<Value> => {
379 const hasBack = Object.hasOwn(options, "back");
380 return {
381 name: "menu",
382 render: ({ submit, cancel }) => (
383 <SelectPrompt
384 options={options}
385 submit={submit}
386 cancel={() => (hasBack ? submit(options.back as Value) : cancel())}
387 summary={false}
388 header={options.header}
389 footer={options.footer}
390 escapeLabel={hasBack ? "back" : "exit"}
391 />
392 ),
393 };
394};
395
396type MultiSelectPromptProps<Value> = {
397 readonly options: MultiSelectOptions<Value>;
398 readonly submit: (value: ReadonlyArray<Value>, summary?: ReactNode) => void;
399 readonly cancel: () => void;
400};
401
402function MultiSelectPrompt<Value>({
403 options,
404 submit,
405 cancel,
406}: MultiSelectPromptProps<Value>) {
407 const keys = useKeyGlyphs();
408 const { rows } = useTerminalSize();
409 const visibleCount =
410 options.visibleCount ?? Math.max(3, Math.min(16, rows - 10));
411 const searchable = options.searchable === true;
412 const [query, setQuery] = useState("");
413 const filtered = useMemo(
414 () =>
415 searchable
416 ? filterChoices(options.options, query)
417 : options.options.map((choice, index) => ({ choice, index })),
418 [options.options, searchable, query],
419 );
420 const listRows = useMemo(() => {
421 const result: Array<
422 | {
423 readonly type: "group";
424 readonly label: string;
425 readonly indices: number[];
426 }
427 | {
428 readonly type: "choice";
429 readonly choice: (typeof filtered)[number]["choice"];
430 readonly index: number;
431 readonly nested: boolean;
432 }
433 > = [];
434 // Each contiguous run of a group gets its own header owning exactly the
435 // run's rows — a group split by ungrouped entries must not produce two
436 // headers that both toggle the whole group.
437 let currentGroup: string | undefined;
438 let currentHeader: { indices: number[] } | undefined;
439 for (const entry of filtered) {
440 const group = entry.choice.group;
441 if (group === undefined) {
442 currentHeader = undefined;
443 } else if (group !== currentGroup || currentHeader === undefined) {
444 const header = {
445 type: "group" as const,
446 label: group,
447 indices: [] as number[],
448 };
449 result.push(header);
450 currentHeader = header;
451 }
452 currentGroup = group;
453 if (group !== undefined && currentHeader !== undefined) {
454 currentHeader.indices.push(entry.index);
455 }
456 result.push({
457 type: "choice",
458 choice: entry.choice,
459 index: entry.index,
460 nested: group !== undefined,
461 });
462 }
463 return result;
464 }, [filtered]);
465 const [selected, setSelected] = useSelectedChoices(
466 options.options,
467 options.initialValues ?? [],
468 );
469 const [error, setError] = useState<string>();
470 const disabled = useMemo(
471 () =>
472 listRows.map((row) =>
473 row.type === "choice"
474 ? row.choice.disabled !== undefined && row.choice.disabled !== false
475 : row.indices.every((index) =>
476 Boolean(options.options[index]?.disabled),
477 ),
478 ),
479 [listRows, options.options],
480 );
481 const { cursor, handleKey } = useChoiceList({
482 disabled,
483 searchable,
484 visibleCount,
485 query,
486 setQuery,
487 onCancel: cancel,
488 });
489 useTerminalInput((input, key) => {
490 if (key.ctrl && input === "a") {
491 const enabledVisible = filtered.flatMap(({ choice, index }) =>
492 choice.disabled !== undefined && choice.disabled !== false
493 ? []
494 : [index],
495 );
496 if (enabledVisible.length === 0) return;
497 setSelected((current) => {
498 const next = new Set(current);
499 const allSelected = enabledVisible.every((index) => next.has(index));
500 for (const index of enabledVisible) {
501 if (allSelected) next.delete(index);
502 else next.add(index);
503 }
504 return next;
505 });
506 setError(undefined);
507 } else if (input === " " && !key.ctrl && !key.meta) {
508 const row = listRows[cursor];
509 if (row === undefined) return;
510 const indices =
511 row.type === "group"
512 ? row.indices.filter((index) => !options.options[index]?.disabled)
513 : row.choice.disabled
514 ? []
515 : [row.index];
516 if (indices.length === 0) return;
517 setSelected((current) => {
518 const next = new Set(current);
519 const allSelected = indices.every((index) => next.has(index));
520 for (const index of indices) {
521 if (allSelected) next.delete(index);
522 else next.add(index);
523 }
524 return next;
525 });
526 setError(undefined);
527 } else if (key.enter) {
528 if (options.required && selected.size === 0) {
529 setError("Select at least one option.");
530 return;
531 }
532 const values = options.options.flatMap((choice, index) =>
533 selected.has(index) ? [choice.value] : [],
534 );
535 const labels = options.options.flatMap((choice, index) =>
536 selected.has(index) ? [choice.label] : [],
537 );
538 submit(
539 values,
540 <AnsweredPrompt
541 message={options.message}
542 answer={labels.length === 0 ? "none" : labels.join(", ")}
543 />,
544 );
545 } else if (handleKey(input, key)) {
546 setError(undefined);
547 }
548 });
549 const visibleChoices = listRows.map((row) =>
550 row.type === "group"
551 ? {
552 value: row,
553 label: row.label,
554 sticky: true,
555 tone: "info" as const,
556 }
557 : {
558 ...row.choice,
559 value: row,
560 group: undefined,
561 indent: row.nested ? 2 : row.choice.indent,
562 },
563 );
564 const visibleSelected = new Set(
565 listRows.flatMap((row, visibleIndex) => {
566 if (row.type !== "group") {
567 return selected.has(row.index) ? [visibleIndex] : [];
568 }
569 // Match the toggle semantics: disabled children are excluded there, so
570 // they must not keep a fully-toggled group from rendering as selected.
571 const toggleable = row.indices.filter(
572 (index) => !options.options[index]?.disabled,
573 );
574 return toggleable.length > 0 &&
575 toggleable.every((index) => selected.has(index))
576 ? [visibleIndex]
577 : [];
578 }),
579 );
580 return (
581 <PromptFrame
582 message={options.message}
583 error={error}
584 keys={[
585 [keys.upDown, "navigate"],
586 [keys.space, "toggle"],
587 ["ctrl+a", "toggle all"],
588 [keys.enter, "confirm"],
589 [keys.escape, query === "" ? "cancel" : "clear filter"],
590 ]}
591 >
592 <Box flexDirection="column">
593 {searchable ? (
594 <FilterLine
595 query={query}
596 annotation={
597 query === ""
598 ? `${selected.size} selected`
599 : `${filtered.length} of ${options.options.length} · ${selected.size} selected`
600 }
601 />
602 ) : (
603 <Text tone="muted">{selected.size} selected</Text>
604 )}
605 <Menu<(typeof listRows)[number]>
606 choices={visibleChoices}
607 cursor={cursor}
608 selected={visibleSelected}
609 visibleCount={visibleCount}
610 empty="No matching choices."
611 descriptionPlacement={options.descriptionPlacement}
612 />
613 </Box>
614 </PromptFrame>
615 );
616}
617
618export const multiSelectScreen = <Value,>(
619 options: MultiSelectOptions<Value>,
620): Screen<ReadonlyArray<Value>> => ({
621 name: "multiple selection",
622 render: ({ submit, cancel }) => (
623 <MultiSelectPrompt options={options} submit={submit} cancel={cancel} />
624 ),
625});
626
627type CycleSelectPromptProps<State> = {
628 readonly options: CycleSelectOptions<State>;
629 readonly submit: (value: ReadonlyArray<State>, summary?: ReactNode) => void;
630 readonly cancel: () => void;
631};
632
633function CycleSelectPrompt<State>({
634 options,
635 submit,
636 cancel,
637}: CycleSelectPromptProps<State>) {
638 const keys = useKeyGlyphs();
639 const { rows } = useTerminalSize();
640 const visibleCount =
641 options.visibleCount ?? Math.max(3, Math.min(16, rows - 8));
642 const navigation = useCycleNavigation(
643 options.options.map((choice) => choice.states.length),
644 );
645 const [unchanged, setUnchanged] = useState(false);
646 useCancel(cancel);
647 const last = Math.max(0, options.options.length - 1);
648 const page = Math.max(1, visibleCount);
649 useTerminalInput((input, key) => {
650 const plain = !key.ctrl && !key.meta;
651 if (key.up || (plain && input === "k")) navigation.move(-1);
652 else if (key.down || (plain && input === "j")) navigation.move(1);
653 else if (key.home) navigation.setCursor(0);
654 else if (key.end) navigation.setCursor(last);
655 else if (key.pageUp)
656 navigation.setCursor(Math.max(0, navigation.cursor - page));
657 else if (key.pageDown)
658 navigation.setCursor(Math.min(last, navigation.cursor + page));
659 else if ((plain && input === " ") || key.right) {
660 navigation.cycle(1);
661 setUnchanged(false);
662 } else if (key.left) {
663 navigation.cycle(-1);
664 setUnchanged(false);
665 } else if (key.enter) {
666 if (
667 options.requireChange &&
668 navigation.indices.every((index) => index === 0)
669 ) {
670 setUnchanged(true);
671 return;
672 }
673 const values = options.options.flatMap((choice, index) => {
674 const state = choice.states[navigation.indices[index] ?? 0];
675 return state === undefined ? [] : [state.value];
676 });
677 const changed = options.options.flatMap((choice, index) => {
678 if ((navigation.indices[index] ?? 0) === 0) return [];
679 const state = choice.states[navigation.indices[index] ?? 0];
680 return state === undefined
681 ? []
682 : [`${choice.label}: ${state.label ?? "changed"}`];
683 });
684 submit(
685 values,
686 <AnsweredPrompt
687 message={options.message}
688 answer={changed.length === 0 ? "no changes" : changed.join(", ")}
689 />,
690 );
691 }
692 });
693 return (
694 <PromptFrame
695 message={options.message}
696 keys={[
697 [keys.upDown, "navigate"],
698 [keys.space, "change"],
699 [keys.enter, options.requireChange ? "apply" : "confirm"],
700 [keys.escape, options.requireChange ? "back" : "cancel"],
701 ]}
702 >
703 <Box flexDirection="column">
704 <CycleList
705 choices={options.options}
706 cursor={navigation.cursor}
707 indices={navigation.indices}
708 visibleCount={visibleCount}
709 />
710 {unchanged ? (
711 <Alert variant="warning" title="No changes to apply">
712 {options.unchangedMessage ??
713 "Press Space to change a selection, or Esc to go back."}
714 </Alert>
715 ) : null}
716 </Box>
717 </PromptFrame>
718 );
719}
720
721export const cycleSelectScreen = <State,>(
722 options: CycleSelectOptions<State>,
723): Screen<ReadonlyArray<State>> => ({
724 name: "cycle selection",
725 render: ({ submit, cancel }) => (
726 <CycleSelectPrompt options={options} submit={submit} cancel={cancel} />
727 ),
728});
729
730export const awaitExternalScreen = screen<AwaitExternalOptions, string>(
731 "external authorization",
732 ({ options, submit, cancel }) => (
733 <ExternalWait {...options} onSubmit={submit} onCancel={cancel} />
734 ),
735);
736
737type ConfirmPromptProps = {
738 readonly options: ConfirmOptions;
739 readonly submit: (value: boolean, summary?: ReactNode) => void;
740 readonly cancel: () => void;
741};
742
743function ConfirmPrompt({ options, submit, cancel }: ConfirmPromptProps) {
744 const keys = useKeyGlyphs();
745 const complete = (answer: boolean) =>
746 submit(
747 answer,
748 <AnsweredPrompt
749 message={options.message}
750 answer={
751 answer
752 ? (options.confirmLabel ?? "yes")
753 : (options.cancelLabel ?? "no")
754 }
755 />,
756 );
757 const value = useConfirmKeys({
758 // Default to "no": most CLI confirms gate destructive operations, so an
759 // absent-minded Enter must never approve one. Benign convenience prompts
760 // opt into default-yes with an explicit `initialValue: true`.
761 initialValue: options.initialValue ?? false,
762 onSubmit: complete,
763 onCancel: cancel,
764 });
765 return (
766 <PromptFrame
767 message={options.message}
768 keys={[
769 [
770 options.confirmLabel === undefined &&
771 options.cancelLabel === undefined
772 ? keys.yesNo
773 : keys.leftRight,
774 "choose",
775 ],
776 [keys.enter, "confirm"],
777 [keys.escape, "cancel"],
778 ]}
779 >
780 <ChoiceGroup
781 value={value}
782 choices={[
783 { value: true, label: options.confirmLabel ?? "Yes" },
784 { value: false, label: options.cancelLabel ?? "No" },
785 ]}
786 />
787 </PromptFrame>
788 );
789}
790
791export const confirmScreen = screen<ConfirmOptions, boolean>(
792 "confirmation",
793 ConfirmPrompt,
794);