1/** @jsxImportSource @alchemy.run/sigil */
2import {
3 measureElement,
4 useCursor,
5 useInput,
6 usePaste,
7 useStdout,
8 type DOMElement,
9} from "@alchemy.run/sigil";
10import {
11 useEffect,
12 useLayoutEffect,
13 useRef,
14 useState,
15} from "@alchemy.run/sigil/react";
16import type { JSX, ReactNode } from "react";
17import { stringWidth } from "@alchemy.run/sigil/ansi";
18import type { AwaitExternalOptions, Choice, CycleChoice } from "../types.ts";
19import { theme } from "../../../Util/Theme.ts";
20import { copyToClipboard, truncate } from "../../../Util/Terminal.ts";
21import { useCliEnvironment, useGlyphs, useKeyGlyphs } from "./Environment.tsx";
22import { KeyBar, Spinner } from "./Feedback.tsx";
23import { Box, overflowListWindow } from "./Layout.tsx";
24import { Link, Text } from "./Typography.tsx";
25
26export interface TerminalKey {
27 readonly up: boolean;
28 readonly down: boolean;
29 readonly left: boolean;
30 readonly right: boolean;
31 readonly home: boolean;
32 readonly end: boolean;
33 readonly pageUp: boolean;
34 readonly pageDown: boolean;
35 readonly enter: boolean;
36 readonly escape: boolean;
37 readonly backspace: boolean;
38 readonly delete: boolean;
39 readonly tab: boolean;
40 readonly shift: boolean;
41 readonly ctrl: boolean;
42 readonly meta: boolean;
43}
44
45/** Backend facade for custom screens; callers never need to import Sigil. */
46export const useTerminalInput = (
47 handler: (input: string, key: TerminalKey) => void,
48 options?: { readonly active?: boolean },
49) => {
50 return useInput(
51 (input, key) =>
52 handler(input, {
53 up: key.upArrow,
54 down: key.downArrow,
55 left: key.leftArrow,
56 right: key.rightArrow,
57 home: key.home,
58 end: key.end,
59 pageUp: key.pageUp,
60 pageDown: key.pageDown,
61 enter: key.return,
62 escape: key.escape,
63 backspace: key.backspace,
64 delete: key.delete,
65 tab: key.tab,
66 shift: key.shift,
67 ctrl: key.ctrl,
68 meta: key.meta,
69 }),
70 { isActive: options?.active ?? true },
71 );
72};
73
74/** Bracketed-paste input scoped the same way as {@link useTerminalInput}. */
75export const useTerminalPaste = (
76 handler: (text: string) => void,
77 options?: { readonly active?: boolean },
78) => {
79 usePaste(handler, { isActive: options?.active ?? true });
80};
81
82export const useTerminalSize = () => {
83 const { columns, rows } = useCliEnvironment();
84 return { columns, rows };
85};
86
87export const useListNavigation = (length: number, initialIndex = 0) => {
88 const [cursor, setCursor] = useState(initialIndex);
89 const clamped = Math.max(0, Math.min(cursor, Math.max(0, length - 1)));
90 const move = (delta: number) =>
91 setCursor((current) => {
92 if (length === 0) return 0;
93 // Clamp before wrapping: after a filter shrinks the list the raw state
94 // can point past the end, and wrapping from there jumps arbitrarily.
95 const base = Math.max(0, Math.min(current, length - 1));
96 return (((base + delta) % length) + length) % length;
97 });
98 return { cursor: clamped, move, setCursor } as const;
99};
100
101/** Step `delta` rows from `cursor`, wrapping and skipping disabled rows. */
102export const moveSkippingDisabled = (
103 disabled: ReadonlyArray<boolean>,
104 cursor: number,
105 delta: number,
106): number => {
107 const length = disabled.length;
108 if (length === 0) return 0;
109 for (let offset = 1; offset <= length; offset++) {
110 const next = (((cursor + delta * offset) % length) + length) % length;
111 if (!disabled[next]) return next;
112 }
113 return cursor;
114};
115
116/**
117 * Jump to `target` (clamped, no wrap), settling on the nearest enabled row in
118 * the jump direction and falling back to the other direction. Used for
119 * Home/End and PageUp/PageDown in list prompts.
120 */
121export const jumpSkippingDisabled = (
122 disabled: ReadonlyArray<boolean>,
123 cursor: number,
124 target: number,
125): number => {
126 const clamped = Math.max(0, Math.min(disabled.length - 1, target));
127 if (!disabled[clamped]) return clamped;
128 const direction = clamped >= cursor ? 1 : -1;
129 for (
130 let next = clamped + direction;
131 next >= 0 && next < disabled.length;
132 next += direction
133 ) {
134 if (!disabled[next]) return next;
135 }
136 for (
137 let next = clamped - direction;
138 next >= 0 && next < disabled.length;
139 next -= direction
140 ) {
141 if (!disabled[next]) return next;
142 }
143 return cursor;
144};
145
146/** "↑ N more" / "↓ N more" indicator row shared by windowed prompt lists. */
147function OverflowRow({
148 direction,
149 count,
150}: {
151 readonly direction: "up" | "down";
152 readonly count: number;
153}) {
154 const glyphs = useGlyphs();
155 if (count <= 0) return null;
156 return (
157 <Text tone="muted">
158 {" "}
159 {direction === "up" ? glyphs.overflowUp : glyphs.overflowDown} {count}{" "}
160 more
161 </Text>
162 );
163}
164
165/**
166 * List-cursor column shared by every focusable row (prompt lists, the
167 * edit-accounts list, the profile dashboard): the focused row carries the
168 * pointer glyph, every other row a same-width blank so labels stay aligned.
169 * Focus is a shape, not a tint, so it survives ASCII and no-colour terminals.
170 */
171export function Pointer({ focused }: { readonly focused: boolean }) {
172 const glyphs = useGlyphs();
173 return (
174 <Text color={theme.paint.focus} bold>
175 {focused ? glyphs.pointer : " "}
176 </Text>
177 );
178}
179
180/**
181 * Focused rows paint their label in the same brand colour as the pointer so
182 * the cursor reads as one block; unfocused labels stay the plain foreground,
183 * leaving green to status glyphs alone.
184 */
185const focusedLabelColor = (focused: boolean): string | undefined =>
186 focused ? theme.paint.focus : undefined;
187
188/**
189 * Width of the label column when descriptions sit beside labels: the widest
190 * label plus one cell, so every description starts in the same column
191 * (labels and descriptions read as two columns rather than a ragged sentence).
192 * Sticky/heading rows are excluded — they never carry a description.
193 */
194const labelColumnWidth = (
195 labels: ReadonlyArray<{ readonly label: string; readonly sticky?: boolean }>,
196): number =>
197 Math.max(
198 0,
199 ...labels.map(({ label, sticky }) => (sticky ? 0 : stringWidth(label))),
200 ) + 1;
201
202export interface MenuProps<Value> {
203 readonly choices: ReadonlyArray<Choice<Value>>;
204 readonly cursor: number;
205 readonly selected?: ReadonlySet<number>;
206 readonly visibleCount?: number;
207 readonly empty?: string;
208 readonly descriptionPlacement?: "below" | "inline";
209}
210
211/** Pure menu presentation used by select prompts and custom applications. */
212export function Menu<Value>({
213 choices,
214 cursor,
215 selected,
216 visibleCount = 12,
217 empty = "No choices.",
218 descriptionPlacement = "inline",
219}: MenuProps<Value>): JSX.Element {
220 const glyphs = useGlyphs();
221 if (choices.length === 0) return <Text tone="muted">{empty}</Text>;
222 // A sticky heading renders outside the window, in addition to the overflow
223 // rows the shared windowing already reserves.
224 const { start, end } = overflowListWindow(
225 choices.length,
226 cursor,
227 visibleCount,
228 );
229 const stickyIndex =
230 start === 0
231 ? -1
232 : choices.findLastIndex(
233 (choice, index) => index < start && choice.sticky,
234 );
235 const visible = [
236 ...(stickyIndex === -1 ? [] : [stickyIndex]),
237 ...Array.from({ length: end - start }, (_, offset) => start + offset),
238 ];
239 const alignDescriptions =
240 descriptionPlacement === "inline" &&
241 choices.some((choice) => choice.description !== undefined);
242 const labelWidth = alignDescriptions ? labelColumnWidth(choices) : undefined;
243 return (
244 <Box
245 flexDirection="column"
246 aria-role="listbox"
247 aria-state={{ multiselectable: selected !== undefined }}
248 >
249 <OverflowRow direction="up" count={start} />
250 {visible.map((index) => {
251 const choice = choices[index];
252 if (choice === undefined) return null;
253 const focused = index === cursor;
254 const checked = selected?.has(index);
255 const disabled =
256 choice.disabled !== undefined && choice.disabled !== false;
257 const previous = choices[index - 1];
258 const showGroup =
259 choice.group !== undefined &&
260 (index === start || previous?.group !== choice.group);
261 return (
262 <Box
263 key={index}
264 flexDirection="column"
265 marginTop={showGroup && index !== visible[0] ? 1 : 0}
266 >
267 {showGroup ? (
268 <Box paddingLeft={theme.space.indent}>
269 <Text bold tone="muted">
270 {choice.group.toUpperCase()}
271 </Text>
272 </Box>
273 ) : null}
274 <Box
275 gap={1}
276 paddingRight={1}
277 paddingLeft={choice.indent ?? 0}
278 aria-role="option"
279 aria-label={choice.label}
280 aria-state={{
281 disabled,
282 selected: selected === undefined ? focused : checked,
283 }}
284 >
285 <Pointer focused={focused} />
286 {selected === undefined ? null : (
287 <Text
288 color={checked ? theme.color.success : theme.color.muted}
289 dimColor={disabled}
290 >
291 {checked ? glyphs.checked : glyphs.unchecked}
292 </Text>
293 )}
294 <Box
295 flexDirection={
296 descriptionPlacement === "inline" ? "row" : "column"
297 }
298 flexGrow={1}
299 gap={descriptionPlacement === "inline" ? 1 : 0}
300 >
301 <Box
302 width={choice.sticky ? undefined : labelWidth}
303 flexShrink={0}
304 >
305 <Text
306 bold={focused || choice.sticky || checked}
307 color={
308 focusedLabelColor(focused) ??
309 (choice.tone === "info" ? theme.color.info : undefined)
310 }
311 dimColor={disabled}
312 >
313 {choice.label}
314 </Text>
315 </Box>
316 {choice.description === undefined ? null : (
317 <Text tone="muted" wrap="truncate-end">
318 {choice.description}
319 </Text>
320 )}
321 </Box>
322 {typeof choice.disabled === "string" ? (
323 <Text tone="muted">{choice.disabled}</Text>
324 ) : null}
325 </Box>
326 </Box>
327 );
328 })}
329 <OverflowRow direction="down" count={choices.length - end} />
330 </Box>
331 );
332}
333
334export interface TextFieldProps {
335 readonly placeholder?: string;
336 readonly initialValue?: string;
337 readonly value?: string;
338 readonly mask?: string;
339 readonly onSubmit: (value: string) => void;
340 readonly onChange?: (value: string) => void;
341 readonly onCancel?: () => void;
342 readonly active?: boolean;
343 readonly ariaLabel?: string;
344}
345
346const graphemeSegmenter = new Intl.Segmenter();
347
348/**
349 * Split into user-perceived characters so cursor movement and deletion never
350 * land inside a surrogate pair or emoji cluster.
351 */
352const toGraphemes = (value: string): ReadonlyArray<string> =>
353 Array.from(graphemeSegmenter.segment(value), (segment) => segment.segment);
354
355/**
356 * Normalize text before inserting it into a single-line field. Sigil reports
357 * bracketed paste separately, but pasted content can still contain newlines,
358 * tabs or other control characters that do not belong in the value.
359 */
360export const sanitizeTextInsert = (input: string): string =>
361 // eslint-disable-next-line no-control-regex
362 input.replace(/[\u0000-\u001f\u007f]/g, "");
363
364/** Readline-style whitespace word boundary to the left of `cursor`. */
365const wordBoundaryLeft = (
366 chars: ReadonlyArray<string>,
367 cursor: number,
368): number => {
369 let index = cursor;
370 while (index > 0 && chars[index - 1] === " ") index--;
371 while (index > 0 && chars[index - 1] !== " ") index--;
372 return index;
373};
374
375/** Readline-style whitespace word boundary to the right of `cursor`. */
376const wordBoundaryRight = (
377 chars: ReadonlyArray<string>,
378 cursor: number,
379): number => {
380 let index = cursor;
381 while (index < chars.length && chars[index] === " ") index++;
382 while (index < chars.length && chars[index] !== " ") index++;
383 return index;
384};
385
386/**
387 * Horizontal scroll window for a single-line field: which grapheme range fits
388 * in `avail` display cells while keeping the cursor visible. One cell is
389 * reserved for the cursor sitting past the last character.
390 */
391const visibleWindow = (
392 chars: ReadonlyArray<string>,
393 cursor: number,
394 avail: number,
395): { readonly start: number; readonly end: number } => {
396 const budget = Math.max(1, avail - 1);
397 const widths = chars.map((char) => stringWidth(char));
398 let before = 0;
399 for (let index = 0; index < cursor; index++) before += widths[index] ?? 0;
400 let start = 0;
401 if (before > budget) {
402 // Scrolled: pin the cursor near the right edge of the window.
403 let used = 0;
404 while (start < cursor && before - used > budget) {
405 used += widths[start] ?? 0;
406 start++;
407 }
408 }
409 let end = start;
410 let total = 0;
411 while (end < chars.length && total + (widths[end] ?? 0) <= budget) {
412 total += widths[end] ?? 0;
413 end++;
414 }
415 return { start, end };
416};
417
418/**
419 * Single-line editor with insertion, deletion, home/end, word-wise movement
420 * and the common readline kill bindings (Ctrl+U/K/W, Alt+Backspace). Long
421 * values scroll horizontally instead of wrapping, so the terminal cursor
422 * always sits on the field's own row.
423 */
424export function TextField({
425 placeholder,
426 initialValue = "",
427 value: controlledValue,
428 mask,
429 onSubmit,
430 onChange,
431 onCancel,
432 active,
433 ariaLabel,
434}: TextFieldProps) {
435 // Prompts are serialized by the CliKit runtime, so an enabled field owns
436 // the input stream outright — no Sigil focus negotiation. (Coupling this to
437 // useFocus({autoFocus}) silently disabled the field whenever any other
438 // focusable was already mounted.)
439 const inputActive = active ?? true;
440 const [internalValue, setInternalValue] = useState(initialValue);
441 const value = controlledValue ?? internalValue;
442 const chars = toGraphemes(value);
443 const [cursor, setCursor] = useState(
444 () => toGraphemes(controlledValue ?? initialValue).length,
445 );
446 const fieldRef = useRef<DOMElement>(null);
447 const [metrics, setMetrics] = useState({ x: 0, y: 0, measured: false });
448 const { setCursorPosition } = useCursor();
449 const { columns } = useCliEnvironment();
450 useEffect(() => {
451 setCursor((current) => Math.min(current, toGraphemes(value).length));
452 }, [value]);
453 const update = (nextChars: ReadonlyArray<string>, nextCursor: number) => {
454 const next = nextChars.join("");
455 if (controlledValue === undefined) setInternalValue(next);
456 setCursor(Math.max(0, Math.min(nextChars.length, nextCursor)));
457 onChange?.(next);
458 };
459 useTerminalInput(
460 (input, key) => {
461 if (key.enter) onSubmit(value);
462 else if (key.escape) onCancel?.();
463 else if (key.left)
464 setCursor(
465 key.ctrl || key.meta
466 ? wordBoundaryLeft(chars, cursor)
467 : Math.max(0, cursor - 1),
468 );
469 else if (key.right)
470 setCursor(
471 key.ctrl || key.meta
472 ? wordBoundaryRight(chars, cursor)
473 : Math.min(chars.length, cursor + 1),
474 );
475 // Sigil distinguishes physical Backspace from Delete.
476 else if (key.backspace && cursor > 0) {
477 const target = key.meta ? wordBoundaryLeft(chars, cursor) : cursor - 1;
478 update([...chars.slice(0, target), ...chars.slice(cursor)], target);
479 } else if (key.delete && cursor < chars.length)
480 update([...chars.slice(0, cursor), ...chars.slice(cursor + 1)], cursor);
481 else if (key.ctrl && input === "d" && cursor < chars.length)
482 update([...chars.slice(0, cursor), ...chars.slice(cursor + 1)], cursor);
483 else if (key.ctrl && input === "w" && cursor > 0) {
484 const target = wordBoundaryLeft(chars, cursor);
485 update([...chars.slice(0, target), ...chars.slice(cursor)], target);
486 } else if (key.ctrl && input === "u") update(chars.slice(cursor), 0);
487 else if (key.ctrl && input === "k")
488 update(chars.slice(0, cursor), cursor);
489 else if (key.home || (key.ctrl && input === "a")) setCursor(0);
490 else if (key.end || (key.ctrl && input === "e")) setCursor(chars.length);
491 else if (key.meta && input === "b")
492 setCursor(wordBoundaryLeft(chars, cursor));
493 else if (key.meta && input === "f")
494 setCursor(wordBoundaryRight(chars, cursor));
495 else if (!key.ctrl && !key.meta && !key.tab) {
496 const inserted = toGraphemes(sanitizeTextInsert(input));
497 if (inserted.length > 0) {
498 update(
499 [...chars.slice(0, cursor), ...inserted, ...chars.slice(cursor)],
500 cursor + inserted.length,
501 );
502 }
503 }
504 },
505 { active: inputActive },
506 );
507 useTerminalPaste(
508 (pasted) => {
509 const inserted = toGraphemes(sanitizeTextInsert(pasted));
510 if (inserted.length === 0) return;
511 update(
512 [...chars.slice(0, cursor), ...inserted, ...chars.slice(cursor)],
513 cursor + inserted.length,
514 );
515 },
516 { active: inputActive },
517 );
518 const shownChars = mask === undefined ? chars : chars.map(() => mask);
519 // Available width = terminal width minus the field's left offset. The box's
520 // own measured width is useless here: it shrinks to its content (flexGrow
521 // only affects height in a column parent), so using it collapses the scroll
522 // window to whatever is currently typed.
523 const avail = Math.max(4, columns - (metrics.measured ? metrics.x : 0) - 1);
524 const { start, end } = visibleWindow(shownChars, cursor, avail);
525 const display =
526 shownChars.length === 0
527 ? (placeholder ?? " ")
528 : `${shownChars.slice(start, end).join("")}${cursor === shownChars.length ? " " : ""}`;
529 // Cursor placement must happen DURING render: useCursor only records the
530 // position in a ref and propagates it to the renderer from an insertion
531 // effect, which runs BEFORE layout effects in the same commit. Setting the
532 // position from a layout effect therefore paints every frame with the
533 // previous keystroke's cursor (off by one while typing and erasing). The
534 // ref write is render-safe — propagation is commit-gated, so abandoned
535 // concurrent renders never reach the terminal.
536 setCursorPosition(
537 inputActive && metrics.measured
538 ? {
539 x: metrics.x + stringWidth(shownChars.slice(start, cursor).join("")),
540 y: metrics.y,
541 }
542 : undefined,
543 );
544 // Measuring stays post-layout: the Box ref only exists after mutation.
545 // A metrics change re-renders, which re-runs the render-time cursor
546 // placement above with the fresh offsets.
547 useLayoutEffect(() => {
548 if (fieldRef.current === null) return;
549 const { x, y } = measureElement(fieldRef.current);
550 setMetrics((current) =>
551 current.measured && current.x === x && current.y === y
552 ? current
553 : { x, y, measured: true },
554 );
555 });
556 return (
557 <Box ref={fieldRef} aria-role="textbox" aria-label={ariaLabel}>
558 <Text
559 tone={shownChars.length === 0 ? "muted" : "default"}
560 wrap="truncate-end"
561 >
562 {display}
563 </Text>
564 </Box>
565 );
566}
567
568export interface CycleListProps<State> {
569 readonly choices: ReadonlyArray<CycleChoice<State>>;
570 readonly cursor: number;
571 readonly indices: ReadonlyArray<number>;
572 readonly visibleCount?: number;
573}
574
575const stateColor = (
576 variant: CycleChoice<unknown>["states"][number]["variant"],
577) =>
578 variant === undefined || variant === "neutral"
579 ? undefined
580 : theme.color[variant === "error" ? "danger" : variant];
581
582export function CycleList<State>({
583 choices,
584 cursor,
585 indices,
586 visibleCount = 12,
587}: CycleListProps<State>) {
588 const glyphs = useGlyphs();
589 const { start, end } = overflowListWindow(
590 choices.length,
591 cursor,
592 visibleCount,
593 );
594 const labelWidth = labelColumnWidth(choices);
595 // The state word ("add", "remove", …) gets its own column too, so the
596 // descriptions line up whether or not a row's current state carries one.
597 const stateWidth = Math.max(
598 0,
599 ...choices.flatMap((choice) =>
600 choice.states.map((state) =>
601 state.label === undefined ? 0 : stringWidth(state.label),
602 ),
603 ),
604 );
605 return (
606 <Box flexDirection="column">
607 <OverflowRow direction="up" count={start} />
608 {choices.slice(start, end).map((choice, offset) => {
609 const index = start + offset;
610 const state = choice.states[indices[index] ?? 0];
611 const focused = index === cursor;
612 const color = stateColor(state?.variant);
613 return (
614 <Box key={index} gap={1} paddingRight={1}>
615 <Pointer focused={focused} />
616 <Text color={color} dimColor={color === undefined}>
617 {state?.icon ?? glyphs.bullet}
618 </Text>
619 <Box width={labelWidth} flexShrink={0}>
620 <Text bold color={focusedLabelColor(focused)}>
621 {choice.label}
622 </Text>
623 </Box>
624 {stateWidth === 0 ? null : (
625 <Box width={stateWidth} flexShrink={0}>
626 {state?.label === undefined ? null : (
627 <Text color={color}>{state.label}</Text>
628 )}
629 </Box>
630 )}
631 {choice.description === undefined ? null : (
632 <Text tone="muted">{choice.description}</Text>
633 )}
634 </Box>
635 );
636 })}
637 <OverflowRow direction="down" count={choices.length - end} />
638 </Box>
639 );
640}
641
642export const useCycleNavigation = (stateCounts: ReadonlyArray<number>) => {
643 const { cursor, move, setCursor } = useListNavigation(stateCounts.length);
644 const [indices, setIndices] = useState<ReadonlyArray<number>>(() =>
645 stateCounts.map(() => 0),
646 );
647 const cycle = (delta: number) =>
648 setIndices((current) =>
649 current.map((value, index) => {
650 if (index !== cursor) return value;
651 const count = stateCounts[index] ?? 0;
652 return count <= 0 ? 0 : (value + delta + count) % count;
653 }),
654 );
655 return { cursor, indices, move, setCursor, cycle } as const;
656};
657
658export interface ExternalWaitProps extends AwaitExternalOptions {
659 readonly onSubmit: (value: string) => void;
660 readonly onCancel: () => void;
661}
662
663/**
664 * Yes/no keyboard handling shared by `InlineConfirm` and the confirm prompt
665 * screen: arrows/tab toggle, Enter commits, y/n answer directly.
666 */
667export const useConfirmKeys = ({
668 initialValue = false,
669 active,
670 onSubmit,
671 onCancel,
672}: {
673 readonly initialValue?: boolean;
674 readonly active?: boolean;
675 readonly onSubmit: (value: boolean) => void;
676 readonly onCancel?: () => void;
677}): boolean => {
678 const [value, setValue] = useState(initialValue);
679 useTerminalInput(
680 (input, key) => {
681 if (key.escape) onCancel?.();
682 else if (key.left || key.right || key.tab || key.up || key.down)
683 setValue((current) => !current);
684 else if (key.enter) onSubmit(value);
685 else if (key.ctrl || key.meta) return;
686 else if (input.toLowerCase() === "y") onSubmit(true);
687 else if (input.toLowerCase() === "n") onSubmit(false);
688 },
689 { active: active ?? true },
690 );
691 return value;
692};
693
694type InlineConfirmProps = {
695 readonly message: string;
696 readonly initialValue?: boolean;
697 readonly confirmLabel?: string;
698 readonly cancelLabel?: string;
699 readonly active?: boolean;
700 readonly onSubmit: (value: boolean) => void;
701 readonly onCancel?: () => void;
702};
703
704export function InlineConfirm({
705 message,
706 initialValue = false,
707 confirmLabel = "Yes",
708 cancelLabel = "No",
709 active,
710 onSubmit,
711 onCancel,
712}: InlineConfirmProps) {
713 const keys = useKeyGlyphs();
714 const value = useConfirmKeys({ initialValue, active, onSubmit, onCancel });
715 return (
716 <Box flexDirection="column" gap={1}>
717 <Text bold color={theme.color.brand}>
718 {message}
719 </Text>
720 <ChoiceGroup
721 value={value}
722 choices={[
723 { value: true, label: confirmLabel },
724 { value: false, label: cancelLabel },
725 ]}
726 />
727 <KeyBar
728 marginTop={0}
729 keys={[
730 [
731 confirmLabel === "Yes" && cancelLabel === "No"
732 ? keys.yesNo
733 : keys.leftRight,
734 "choose",
735 ],
736 [keys.enter, "confirm"],
737 [keys.escape, "cancel"],
738 ]}
739 />
740 </Box>
741 );
742}
743
744export interface ChoiceGroupProps<Value> {
745 readonly choices: ReadonlyArray<{
746 readonly value: Value;
747 readonly label: string;
748 readonly description?: string;
749 }>;
750 readonly value: Value;
751 readonly orientation?: "horizontal" | "vertical";
752 readonly active?: boolean;
753 readonly onChange?: (value: Value) => void;
754}
755
756export function ChoiceGroup<Value>({
757 choices,
758 value,
759 orientation = "horizontal",
760 active = true,
761 onChange,
762}: ChoiceGroupProps<Value>) {
763 const selected = Math.max(
764 0,
765 choices.findIndex((choice) => choice.value === value),
766 );
767 useTerminalInput(
768 (_input, key) => {
769 if (onChange === undefined || choices.length === 0) return;
770 const previous =
771 key.tab && key.shift
772 ? true
773 : orientation === "horizontal"
774 ? key.left
775 : key.up;
776 const next =
777 key.tab || (orientation === "horizontal" ? key.right : key.down);
778 if (!previous && !next) return;
779 const delta = previous ? -1 : 1;
780 const index = (selected + delta + choices.length) % choices.length;
781 onChange(choices[index]!.value);
782 },
783 { active: active && onChange !== undefined },
784 );
785 return (
786 <Box
787 gap={orientation === "horizontal" ? 1 : 0}
788 paddingLeft={theme.space.indent}
789 flexDirection={orientation === "horizontal" ? "row" : "column"}
790 aria-role="radiogroup"
791 >
792 {choices.map((choice) => {
793 const selected = choice.value === value;
794 return (
795 <Box
796 key={choice.label}
797 paddingX={1}
798 backgroundColor={selected ? theme.paint.interactive : undefined}
799 aria-role="radio"
800 aria-label={choice.label}
801 aria-state={{ checked: selected }}
802 >
803 <Text
804 bold={selected}
805 color={selected ? theme.color.onAccent : undefined}
806 >
807 {choice.label}
808 </Text>
809 {choice.description === undefined ? null : (
810 <Text tone="muted"> · {choice.description}</Text>
811 )}
812 </Box>
813 );
814 })}
815 </Box>
816 );
817}
818
819/** Browser/OAuth waiting screen with URL controls and manual-entry fallback. */
820export function ExternalWait({
821 message,
822 waitingLabel,
823 url,
824 code,
825 openFailed = false,
826 onOpen,
827 allowManualInput = true,
828 inputLabel,
829 placeholder,
830 validate,
831 onSubmit,
832 onCancel,
833}: ExternalWaitProps) {
834 const { stdout } = useStdout();
835 const glyphs = useGlyphs();
836 const keyGlyphs = useKeyGlyphs();
837 const [manual, setManual] = useState(false);
838 const [showFull, setShowFull] = useState(false);
839 const [copied, setCopied] = useState(false);
840 const [browserFailed, setBrowserFailed] = useState(openFailed);
841 const [error, setError] = useState<string>();
842 const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
843 undefined,
844 );
845 useEffect(
846 () => () => {
847 clearTimeout(copiedTimer.current);
848 },
849 [],
850 );
851 const { columns } = useTerminalSize();
852 // Ctrl+C is handled centrally by the screen runner (SigilRuntime.run).
853 useTerminalInput((input, key) => {
854 if (manual) {
855 if (key.escape) {
856 setError(undefined);
857 setManual(false);
858 }
859 return;
860 }
861 const shortcut = input.toLowerCase();
862 if (key.enter && allowManualInput) setManual(true);
863 else if (key.escape) onCancel();
864 // Ctrl+C must fall through to the runner's cancel guard, not copy the URL.
865 else if (key.ctrl || key.meta) return;
866 else if (shortcut === "u") setShowFull((current) => !current);
867 else if (shortcut === "o" && onOpen !== undefined) {
868 setBrowserFailed(false);
869 void onOpen().catch(() => setBrowserFailed(true));
870 } else if (shortcut === "c" && (code ?? url) !== undefined) {
871 copyToClipboard(code ?? url!, stdout ?? process.stdout);
872 setCopied(true);
873 clearTimeout(copiedTimer.current);
874 copiedTimer.current = setTimeout(() => setCopied(false), 2000);
875 }
876 });
877 if (manual) {
878 const manualInputLabel =
879 inputLabel ?? "Paste the authorization code or callback URL";
880 return (
881 <PromptFrame
882 message={manualInputLabel}
883 layout="inline"
884 error={error}
885 keys={[
886 [keyGlyphs.enter, "confirm"],
887 [keyGlyphs.escape, "back to waiting"],
888 ]}
889 >
890 <TextField
891 placeholder={placeholder}
892 ariaLabel={manualInputLabel}
893 onCancel={() => {
894 setError(undefined);
895 setManual(false);
896 }}
897 onChange={() => setError(undefined)}
898 onSubmit={(value) => {
899 const problem = validate?.(value);
900 if (problem !== undefined) {
901 setError(problem instanceof Error ? problem.message : problem);
902 } else {
903 onSubmit(value);
904 }
905 }}
906 />
907 </PromptFrame>
908 );
909 }
910 return (
911 <PromptFrame
912 message={message}
913 keys={[
914 ...(allowManualInput
915 ? ([[keyGlyphs.enter, "enter code manually"]] as const)
916 : []),
917 ...(url === undefined
918 ? []
919 : ([
920 ...(onOpen === undefined
921 ? []
922 : ([["o", "open browser"]] as const)),
923 [
924 "c",
925 copied
926 ? `${glyphs.success} copied`
927 : code === undefined
928 ? "copy URL"
929 : "copy code",
930 ],
931 ["u", showFull ? "collapse URL" : "full URL"],
932 ] as const)),
933 [keyGlyphs.escape, "cancel"],
934 ]}
935 >
936 <Box flexDirection="column" gap={1}>
937 <Spinner label={waitingLabel} />
938 {code === undefined ? null : (
939 <Box gap={1}>
940 <Text tone="muted">Code</Text>
941 <Text bold color={theme.color.accentBright}>
942 {code}
943 </Text>
944 </Box>
945 )}
946 {browserFailed ? (
947 <Text tone="warning">
948 {glyphs.warning} Could not open the browser. Copy and open the URL
949 manually.
950 </Text>
951 ) : null}
952 {url === undefined ? null : (
953 <Link href={url}>
954 {showFull ? url : truncate(url, Math.max(24, columns - 8))}
955 </Link>
956 )}
957 </Box>
958 </PromptFrame>
959 );
960}
961
962export type PromptFrameProps = {
963 readonly message: string;
964 readonly description?: ReactNode;
965 readonly children: JSX.Element;
966 readonly layout?: "inline" | "stacked";
967 readonly error?: string;
968 readonly keys?: ReadonlyArray<readonly [string, string]>;
969};
970
971export function PromptFrame({
972 message,
973 description,
974 children,
975 layout = "stacked",
976 error,
977 keys,
978}: PromptFrameProps) {
979 const glyphs = useGlyphs();
980 // The active-step glyph shares the heading's brand colour: `◆ question` is
981 // the step being answered, `✓ question` (AnsweredPrompt) one that is done.
982 const heading = (suffix = "") => (
983 <Text>
984 <Text bold color={theme.color.brand}>
985 {glyphs.active} {message}
986 {suffix}
987 </Text>
988 </Text>
989 );
990 return (
991 <Box flexDirection="column">
992 {layout === "inline" ? (
993 <Box flexDirection="column">
994 <Box gap={1}>
995 {heading(":")}
996 <Box flexGrow={1}>{children}</Box>
997 </Box>
998 {description === undefined ? null : (
999 <Box paddingLeft={theme.space.indent}>
1000 <Text tone="muted">{description}</Text>
1001 </Box>
1002 )}
1003 </Box>
1004 ) : (
1005 <>
1006 {heading()}
1007 {description === undefined ? null : (
1008 <Box paddingLeft={theme.space.indent}>
1009 <Text tone="muted">{description}</Text>
1010 </Box>
1011 )}
1012 {/* Children sit directly under the heading, indented like the
1013 answered lines' text; grouping is carried by indentation, not
1014 by a rail. */}
1015 <Box paddingLeft={theme.space.indent} flexDirection="column">
1016 {children}
1017 </Box>
1018 </>
1019 )}
1020 {error === undefined ? null : (
1021 <Box marginTop={1} paddingLeft={2}>
1022 <Text color={theme.color.danger}>
1023 {glyphs.error} {error}
1024 </Text>
1025 </Box>
1026 )}
1027 {keys === undefined ? null : <KeyBar keys={keys} />}
1028 </Box>
1029 );
1030}
1031
1032export const filterChoices = <Value,>(
1033 choices: ReadonlyArray<Choice<Value>>,
1034 query: string,
1035) => {
1036 const normalized = query.trim().toLowerCase();
1037 const indexed = choices.map((choice, index) => ({ choice, index }));
1038 return normalized === ""
1039 ? indexed
1040 : indexed.filter(({ choice }) =>
1041 `${choice.group ?? ""} ${choice.label} ${choice.description ?? ""}`
1042 .toLowerCase()
1043 .includes(normalized),
1044 );
1045};
1046
1047/** Stable selected-index set for multiselect widgets. */
1048export const useSelectedChoices = <Value,>(
1049 choices: ReadonlyArray<Choice<Value>>,
1050 initialValues: ReadonlyArray<Value>,
1051) => {
1052 return useState<ReadonlySet<number>>(
1053 () =>
1054 new Set(
1055 initialValues.flatMap((value) => {
1056 const index = choices.findIndex((choice) => choice.value === value);
1057 return index === -1 || choices[index]?.disabled ? [] : [index];
1058 }),
1059 ),
1060 );
1061};