1/** @jsxImportSource @alchemy.run/sigil */
2import { stripVTControlCharacters } from "node:util";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Semaphore from "effect/Semaphore";
8import type * as Scope from "effect/Scope";
9import {
10 AnsiText,
11 Box,
12 render,
13 renderToString,
14 Static,
15 useTitle,
16} from "@alchemy.run/sigil";
17import { useSyncExternalStore } from "@alchemy.run/sigil/react";
18import type { ReactNode } from "react";
19import { Spinner, Status } from "../ui/Feedback.tsx";
20import { CliEnvironment } from "../ui/Environment.tsx";
21import { useTerminalInput } from "../ui/Interactive.tsx";
22import { LiveStore, useLiveStore } from "../ui/Live.tsx";
23import { CancelledPrompt } from "../ui/Transcript.tsx";
24import { Text } from "../ui/Typography.tsx";
25import {
26 NonInteractiveTerminal,
27 TerminalCancelled,
28} from "../../../Interaction.ts";
29import {
30 confirmScreen,
31 cycleSelectScreen,
32 awaitExternalScreen,
33 menuScreen,
34 multiSelectScreen,
35 passwordScreen,
36 selectScreen,
37 textScreen,
38} from "./Prompts.tsx";
39import { ApplicationPresentation, CliKit } from "../../CliKit/CliKit.ts";
40import { setNativeProgress } from "../../../Util/Terminal.ts";
41import type {
42 ProgressHandle,
43 ProgressOptions,
44 RenderOptions,
45 Screen,
46 MenuOptions,
47 CliKitCapabilities,
48 CliKitOptions,
49 InteractionError,
50 LiveViewHandle,
51 LiveViewOptions,
52 View,
53} from "../types.ts";
54
55const InApplication = Context.Reference<boolean>(
56 "Alchemy::CliKit/InApplication",
57 {
58 defaultValue: () => false,
59 },
60);
61
62/**
63 * One rendered block. Views are immutable once mounted — dynamic content
64 * flows through caller-owned stores (`LiveStore` + `useLiveStore`) that the
65 * view component subscribes to, never through the runtime.
66 */
67interface Item {
68 readonly key: number;
69 readonly view: View;
70 readonly placement?: LiveViewOptions["placement"];
71}
72
73const normalizeView = (view: View): View => {
74 if (
75 typeof view === "string" ||
76 typeof view === "number" ||
77 typeof view === "bigint"
78 ) {
79 return <Text>{String(view)}</Text>;
80 }
81 // Contract: an array of primitives renders as ONE Text line with the
82 // elements concatenated (no separator) — matching how React renders
83 // adjacent text children. Callers wanting separators must join themselves.
84 if (
85 Array.isArray(view) &&
86 view.every(
87 (item) =>
88 item === null ||
89 item === undefined ||
90 typeof item === "boolean" ||
91 typeof item === "string" ||
92 typeof item === "number" ||
93 typeof item === "bigint",
94 )
95 ) {
96 return (
97 <Text>
98 {view
99 .filter(
100 (item) =>
101 item !== null && item !== undefined && typeof item !== "boolean",
102 )
103 .map(String)
104 .join("")}
105 </Text>
106 );
107 }
108 return view;
109};
110
111const formatStaticView = (
112 view: ReactNode,
113 options: RenderOptions,
114 capabilities: CliKitCapabilities,
115): string => {
116 const colors = options.colors ?? capabilities.colors;
117 // The renderer and the components must agree on the width: components size
118 // themselves from the environment's `columns`, so an explicit render width
119 // has to flow into the capabilities too, not only into renderToString.
120 const columns = options.columns ?? capabilities.columns;
121 const output = renderToString(
122 <CliEnvironment capabilities={{ ...capabilities, colors, columns }}>
123 {view}
124 </CliEnvironment>,
125 { columns, colorProfile: colors ? "truecolor" : "none" },
126 ).replace(/[\s\n]+$/, "");
127 return colors ? output : stripVTControlCharacters(output);
128};
129
130interface StoreState {
131 readonly staticItems: Item[];
132 readonly transcript: ReadonlyArray<Item>;
133 readonly live: ReadonlyArray<Item>;
134 readonly active?: Item;
135}
136
137/**
138 * The single external store the React root syncs from. It only tracks WHICH
139 * blocks are mounted and in what region (static scrollback, in-application
140 * transcript, live region, active prompt) — block content never changes
141 * after mount, so there is no update surface here.
142 */
143class TerminalStore {
144 private state: StoreState = { staticItems: [], transcript: [], live: [] };
145 private readonly listeners = new Set<() => void>();
146 private nextKey = 0;
147
148 readonly subscribe = (listener: () => void) => {
149 this.listeners.add(listener);
150 return () => this.listeners.delete(listener);
151 };
152 readonly snapshot = () => this.state;
153
154 private commit(state: StoreState) {
155 this.state = state;
156 for (const listener of this.listeners) listener();
157 }
158
159 alloc() {
160 return this.nextKey++;
161 }
162
163 append(view: View) {
164 this.commit({
165 ...this.state,
166 transcript: [...this.state.transcript, { key: this.alloc(), view }],
167 });
168 }
169
170 appendStatic(view: View) {
171 this.commit({
172 ...this.state,
173 staticItems: [...this.state.staticItems, { key: this.alloc(), view }],
174 });
175 }
176
177 addLive(
178 key: number,
179 view: View,
180 placement: LiveViewOptions["placement"] = "afterTranscript",
181 ) {
182 this.commit({
183 ...this.state,
184 live: [...this.state.live, { key, view, placement }],
185 });
186 }
187
188 removeLive(key: number) {
189 this.commit({
190 ...this.state,
191 live: this.state.live.filter((entry) => entry.key !== key),
192 });
193 }
194
195 /**
196 * Move a live block into scrollback (its component renders one final time
197 * from its store's final state). No-op when the row was already cleared.
198 */
199 completeLive(key: number, destination: "staticItems" | "transcript") {
200 const item = this.state.live.find((entry) => entry.key === key);
201 if (item === undefined) return;
202 this.commit({
203 ...this.state,
204 [destination]: [
205 ...this.state[destination],
206 { key: this.alloc(), view: item.view },
207 ],
208 live: this.state.live.filter((entry) => entry.key !== key),
209 });
210 }
211
212 activate(view: View) {
213 this.commit({ ...this.state, active: { key: this.alloc(), view } });
214 }
215
216 deactivate() {
217 if (this.state.active !== undefined)
218 this.commit({ ...this.state, active: undefined });
219 }
220
221 clear() {
222 this.commit({ ...this.state, transcript: [], live: [] });
223 }
224
225 clearStatic() {
226 if (this.state.staticItems.length > 0) {
227 this.commit({ ...this.state, staticItems: [] });
228 }
229 }
230
231 clearTranscript() {
232 if (this.state.transcript.length > 0) {
233 this.commit({ ...this.state, transcript: [] });
234 }
235 }
236
237 get idle() {
238 return this.state.active === undefined && this.state.live.length === 0;
239 }
240}
241
242/**
243 * Always-on Ctrl+C handler wrapped around every screen by `run`. Screens no
244 * longer need their own Ctrl+C wiring (a screen that forgot it used to make
245 * the CLI unkillable, since Sigil runs with `exitOnCtrlC: false` in raw mode);
246 * they only handle Escape, whose semantics differ per screen.
247 */
248type ScreenCancelGuardProps = {
249 readonly onCancel: () => void;
250 readonly children?: ReactNode;
251};
252
253function ScreenCancelGuard({ onCancel, children }: ScreenCancelGuardProps) {
254 useTerminalInput(
255 (input, key) => {
256 if (key.ctrl && input === "c") onCancel();
257 },
258 { active: true },
259 );
260 return <>{children}</>;
261}
262
263type ItemViewProps = { readonly item: Item };
264
265function ItemView({ item }: ItemViewProps) {
266 return <Box flexDirection="column">{item.view}</Box>;
267}
268
269type TerminalRootProps = {
270 readonly store: TerminalStore;
271};
272
273function TerminalRoot({ store }: TerminalRootProps) {
274 const state = useSyncExternalStore(store.subscribe, store.snapshot);
275 useTerminalInput(
276 (input, key) => {
277 if (!key.ctrl || input !== "c") return;
278 process.kill(process.pid, "SIGINT");
279 },
280 { active: state.active === undefined },
281 );
282 const beforeTranscript = state.live.filter(
283 (item) => item.placement === "beforeTranscript",
284 );
285 const afterTranscript = state.live.filter(
286 (item) => item.placement !== "beforeTranscript",
287 );
288 return (
289 <Box flexDirection="column">
290 <Static items={state.staticItems}>
291 {(item) => <ItemView key={item.key} item={item} />}
292 </Static>
293 {beforeTranscript.map((item) => (
294 <ItemView key={item.key} item={item} />
295 ))}
296 {state.transcript.map((item) => (
297 <ItemView key={item.key} item={item} />
298 ))}
299 {afterTranscript.map((item) => (
300 <ItemView key={item.key} item={item} />
301 ))}
302 {state.active === undefined ? null : (
303 <Box marginTop={state.transcript.length > 0 ? 1 : 0}>
304 <ItemView key={state.active.key} item={state.active} />
305 </Box>
306 )}
307 </Box>
308 );
309}
310
311/** Progress rows are ordinary live views over a runtime-owned store. */
312interface ProgressState {
313 readonly options: ProgressOptions;
314 readonly final?: {
315 readonly variant: "success" | "error";
316 readonly message?: string;
317 };
318}
319
320type ProgressViewProps = {
321 readonly store: LiveStore<ProgressState>;
322};
323
324function ProgressView({ store }: ProgressViewProps) {
325 const state = useLiveStore(store);
326 useTitle(state.options.title);
327 return state.final !== undefined ? (
328 <Status variant={state.final.variant}>
329 {state.final.message ?? state.options.label}
330 </Status>
331 ) : state.options.spinning === false ? (
332 <Status variant="info" detail={state.options.detail}>
333 {state.options.label}
334 </Status>
335 ) : (
336 <Spinner label={state.options.label} detail={state.options.detail} />
337 );
338}
339
340export interface CliKitRuntime {
341 readonly service: CliKit["Service"];
342 readonly dispose: () => Promise<void>;
343}
344
345export const makeRuntime = (
346 options: CliKitOptions,
347 capabilities: CliKitCapabilities,
348): CliKitRuntime => {
349 const stdin = options.stdin ?? process.stdin;
350 const stdout = options.stdout ?? process.stdout;
351 const stderr = options.stderr ?? process.stderr;
352 const store = new TerminalStore();
353 const rendererGate = Semaphore.makeUnsafe(1);
354 const promptGate = Semaphore.makeUnsafe(1);
355
356 interface Mounted {
357 readonly sigil: ReturnType<typeof render>;
358 readonly alternate: boolean;
359 /** Settles when the instance exits; render crashes are routed out, never rethrown. */
360 exit: Promise<void>;
361 /**
362 * Flush any partial captured line into the Static transcript — called at
363 * unmount so trailing output without a newline still lands. Lives on the
364 * instance because it closes over this mount's capture buffers and must
365 * die with it.
366 */
367 readonly flushDirectStdio?: () => void;
368 }
369 // Lifecycle state. `mounted` and `unmounting` deliberately overlap: during
370 // teardown the instance stays in `mounted` until re-validation passes, so
371 // prints/live views racing the drain still see a live renderer (and can
372 // abort the teardown). `applicationMounted` is an orthogonal axis — an
373 // `app()` region pins the renderer regardless of store idleness — and
374 // `disposed` is a terminal one-way latch.
375 let mounted: Mounted | undefined;
376 let unmounting: Promise<void> | undefined;
377 let applicationMounted = false;
378 let disposed = false;
379 /**
380 * Fails the active interaction when a screen component throws during
381 * render. Without this the prompt's callback would never resume and the
382 * CLI would hang with a dead renderer.
383 */
384 let failActive: ((error: unknown) => void) | undefined;
385 /**
386 * The single failure channel for renderer errors: route into the active
387 * interaction when a prompt is waiting, otherwise surface on stderr. Every
388 * async failure path (the exit promise, fire-and-forget mounts) funnels
389 * here so no rejection ever escapes unhandled.
390 */
391 const failRenderer = (error: unknown) => {
392 if (failActive !== undefined) failActive(error);
393 else stderr.write(`CliKit renderer error: ${String(error)}\n`);
394 };
395
396 const mount = async (alternateScreen = false): Promise<void> => {
397 // Never overlap a new Sigil root with one that is still tearing down —
398 // Sigil keys instances by stdout and two live roots corrupt the output.
399 while (unmounting !== undefined) await unmounting;
400 if (disposed || mounted !== undefined) return;
401 const captureDirectStdio = options.captureConsole !== false;
402 // Process output lives in the renderer's Static zone above every inline
403 // view. Adding a static row commits it to scrollback and naturally moves
404 // the live UI down, avoiding log-update's erase/restore path and its
405 // render artifacts. `patchConsole: "stdio"` captures console, Node
406 // warnings, and direct stream writes from tools such as Floci (the
407 // renderer's own frames go through passthrough facades and bypass the
408 // capture); `onCapturedOutput` takes ownership of each raw chunk so the
409 // Static transcript renders it instead of the built-in splicing. The
410 // renderer restores console and stream writes itself on unmount.
411 const buffers = { stdout: "", stderr: "" };
412 // A captured write that ends mid-line parks its tail here waiting for
413 // the rest of the line. A real terminal would display the partial
414 // immediately — so when no continuation arrives promptly, flush the
415 // tail as its own row instead of holding it until unmount. Without the
416 // deadline, a writer whose final chunk lacks a trailing newline (e.g.
417 // a pretty-printed cause written raw to stderr) has its last line
418 // appear only when the process exits. One timer PER STREAM: a chatty
419 // stdout must not keep resetting the deadline of a parked stderr tail.
420 const partialFlushTimers: {
421 [Stream in keyof typeof buffers]?: ReturnType<typeof setTimeout>;
422 } = {};
423 const clearPartialFlush = (stream: keyof typeof buffers) => {
424 const timer = partialFlushTimers[stream];
425 if (timer !== undefined) {
426 clearTimeout(timer);
427 partialFlushTimers[stream] = undefined;
428 }
429 };
430 const flushPartialLine = (stream: keyof typeof buffers) => {
431 partialFlushTimers[stream] = undefined;
432 if (buffers[stream] !== "") {
433 store.appendStatic(<AnsiText wrap="none">{buffers[stream]}</AnsiText>);
434 buffers[stream] = "";
435 }
436 };
437 const appendLines = (stream: keyof typeof buffers, data: string) => {
438 const parts = `${buffers[stream]}${data}`.split(/\r?\n/);
439 buffers[stream] = parts.pop() ?? "";
440 for (const line of parts) {
441 store.appendStatic(<AnsiText wrap="none">{line || " "}</AnsiText>);
442 }
443 clearPartialFlush(stream);
444 if (buffers[stream] !== "") {
445 const timer = setTimeout(() => flushPartialLine(stream), 50);
446 timer.unref?.();
447 partialFlushTimers[stream] = timer;
448 }
449 };
450 const sigil = render(
451 <CliEnvironment capabilities={capabilities} observeWindow>
452 <TerminalRoot store={store} />
453 </CliEnvironment>,
454 {
455 stdin,
456 stdout,
457 stderr,
458 exitOnCtrlC: false,
459 interactive: capabilities.input,
460 alternateScreen: alternateScreen,
461 colorProfile: capabilities.colors ? "truecolor" : "none",
462 ...(captureDirectStdio
463 ? {
464 patchConsole: "stdio" as const,
465 onCapturedOutput: (stream: "stdout" | "stderr", data: string) => {
466 appendLines(stream, data);
467 return true;
468 },
469 }
470 : { patchConsole: false as const }),
471 },
472 );
473 const current: Mounted = {
474 sigil,
475 alternate: alternateScreen,
476 exit: Promise.resolve(),
477 flushDirectStdio: captureDirectStdio
478 ? () => {
479 // AnsiText, matching the line and deadline paths: a trailing
480 // fragment must not render color-stripped just because teardown
481 // won the race against the 50ms deadline.
482 for (const stream of ["stdout", "stderr"] as const) {
483 clearPartialFlush(stream);
484 if (buffers[stream] !== "") {
485 store.appendStatic(
486 <AnsiText wrap="none">{buffers[stream]}</AnsiText>,
487 );
488 buffers[stream] = "";
489 }
490 }
491 }
492 : undefined,
493 };
494 // Capture the exit promise at mount time: Sigil registers its process
495 // `beforeExit` listener on the first `waitUntilExit()` call and `unmount`
496 // only removes a listener that already exists, so waiting only after
497 // unmount leaks one process listener per mount cycle. A rejection means a
498 // component threw during render — Sigil has already unmounted itself, so
499 // drop the dead instance and surface the error instead of hanging.
500 current.exit = sigil.waitUntilExit().then(
501 () => undefined,
502 (error: unknown) => {
503 // The crashed instance is dropped without the unmount path, so
504 // disarm any pending partial-flush timer here too.
505 current.flushDirectStdio?.();
506 if (mounted === current) mounted = undefined;
507 failRenderer(error);
508 },
509 );
510 mounted = current;
511 };
512
513 const ensureMounted = () => Effect.promise(() => mount());
514
515 const waitForRender = () =>
516 mounted?.sigil.waitUntilRenderFlush().catch(() => undefined) ??
517 Promise.resolve();
518
519 /**
520 * Tear down the renderer. Total (never rejects) and re-validated across its
521 * awaits: output printed or live views opened while the final frame flushes
522 * abort the teardown instead of being destroyed with it. `force` skips the
523 * re-validation for dispose and presentation switches.
524 */
525 const unmount = (force = false): Promise<void> => {
526 if (unmounting !== undefined) return unmounting;
527 const current = mounted;
528 if (current === undefined) return Promise.resolve();
529 unmounting = (async () => {
530 try {
531 current.flushDirectStdio?.();
532 // Drain: keep flushing until no new static output arrives between
533 // flushes, so a `print` racing the teardown reaches the terminal.
534 // Unbounded by design — the bound is the renderer's convergence: the
535 // loop exits as soon as producers go quiet for one full flush.
536 let staticCount;
537 do {
538 staticCount = store.snapshot().staticItems.length;
539 await current.sigil.waitUntilRenderFlush();
540 } while (store.snapshot().staticItems.length !== staticCount);
541 // Re-validate: a prompt or live view may have re-armed the renderer
542 // while the frame flushed. Their output would be destroyed by the
543 // teardown, so leave the instance mounted for them instead.
544 if (!force && (applicationMounted || !store.idle)) return;
545 // A captured chunk that arrived DURING the drain may have parked a
546 // partial line and armed a flush timer. Flush and disarm now —
547 // otherwise the timer fires after `clearStatic()` below and the
548 // stale fragment replays at the top of the next mount.
549 current.flushDirectStdio?.();
550 mounted = undefined;
551 // unmount restores the patched console and stream writes.
552 current.sigil.unmount();
553 await current.exit;
554 current.sigil.cleanup();
555 // Static output has been handed off to the terminal. Do not replay
556 // it when a later live session mounts a fresh Sigil root.
557 store.clearStatic();
558 } catch {
559 // Teardown must be total: a crashed instance still ends up unmounted
560 // with console/stream patches restored — and with no armed partial
561 // flush timer left to poke the store after clearStatic.
562 if (mounted === current) mounted = undefined;
563 try {
564 current.flushDirectStdio?.();
565 current.sigil.unmount();
566 } catch {
567 // Already unmounted (or unmount itself is what threw above).
568 }
569 }
570 })().finally(() => {
571 unmounting = undefined;
572 });
573 return unmounting;
574 };
575
576 const releaseIfIdle = () =>
577 Effect.suspend(() =>
578 applicationMounted || !store.idle
579 ? Effect.void
580 : Effect.promise(() => unmount()),
581 );
582
583 const formatView = (view: View, renderOptions: RenderOptions = {}) =>
584 formatStaticView(normalizeView(view), renderOptions, {
585 ...capabilities,
586 // Re-read the width at render time — the boot-time snapshot goes stale
587 // the moment the user resizes the terminal.
588 columns: stdout.columns ?? capabilities.columns,
589 });
590
591 const renderView = (view: View, renderOptions: RenderOptions = {}) =>
592 Effect.sync(() => formatView(view, renderOptions));
593
594 const print = (view: View, renderOptions: RenderOptions = {}) =>
595 Effect.gen(function* () {
596 const inApplication = yield* InApplication;
597 view = normalizeView(view);
598 if (inApplication) {
599 yield* Effect.sync(() => store.append(view));
600 } else if (mounted !== undefined) {
601 yield* Effect.sync(() => store.appendStatic(view));
602 } else {
603 const output = yield* renderView(view, renderOptions);
604 if (output !== "")
605 yield* Effect.sync(() => stdout.write(`${output}\n`));
606 }
607 });
608
609 const messageOptions = (
610 message: string | { message: string; detail?: string },
611 ) => (typeof message === "string" ? { message } : message);
612
613 const log =
614 (variant: "info" | "success" | "warning" | "error") =>
615 (message: string | { message: string; detail?: string }) => {
616 const options = messageOptions(message);
617 return print(
618 <Status variant={variant} detail={options.detail}>
619 {options.message}
620 </Status>,
621 );
622 };
623
624 const run = <Value,>(screen: Screen<Value>) => {
625 if (!capabilities.input) {
626 return Effect.fail(
627 new NonInteractiveTerminal({
628 operation: screen.name,
629 message: `Cannot run ${screen.name} without an interactive terminal. Provide the equivalent command flags instead.`,
630 }),
631 );
632 }
633 return Effect.gen(function* () {
634 const inApplication = yield* InApplication;
635 const interaction = promptGate.withPermits(1)(
636 Effect.gen(function* () {
637 let completedView: View | undefined;
638 return yield* Effect.callback<Value, TerminalCancelled>((resume) => {
639 let settled = false;
640 const finish = (
641 result: Effect.Effect<Value, TerminalCancelled>,
642 resultView?: View,
643 ) => {
644 if (settled) return;
645 settled = true;
646 failActive = undefined;
647 store.deactivate();
648 if (resultView !== undefined) {
649 if (inApplication) store.append(normalizeView(resultView));
650 else completedView = resultView;
651 }
652 resume(result);
653 };
654 const cancel = () =>
655 finish(
656 Effect.fail(new TerminalCancelled()),
657 <CancelledPrompt message={screen.name} />,
658 );
659 // A screen that throws during render unmounts Sigil with the error;
660 // without this hook the callback would never resume and the CLI
661 // would hang forever on a dead renderer.
662 failActive = (error) => finish(Effect.die(error));
663 store.activate(
664 <ScreenCancelGuard onCancel={cancel}>
665 {screen.render({
666 submit: (value, summary) =>
667 finish(Effect.succeed(value), summary),
668 cancel,
669 })}
670 </ScreenCancelGuard>,
671 );
672 // Not awaited (Effect.callback's register is synchronous), but
673 // never fire-and-forget: a mount failure — e.g. a synchronous
674 // throw from Sigil's render() — routes through `failRenderer`,
675 // which fails this very interaction via the `failActive` hook
676 // installed above instead of escaping as an unhandled rejection.
677 mount().catch(failRenderer);
678 // Interruption cleanup: deactivate the screen and let the caller's
679 // finalizers release the renderer.
680 return Effect.sync(() => {
681 if (!settled) {
682 settled = true;
683 failActive = undefined;
684 store.deactivate();
685 }
686 });
687 }).pipe(
688 Effect.ensuring(
689 Effect.suspend(() =>
690 inApplication
691 ? Effect.void
692 : releaseIfIdle().pipe(
693 Effect.andThen(
694 completedView === undefined
695 ? Effect.void
696 : print(completedView),
697 ),
698 ),
699 ),
700 ),
701 );
702 }),
703 );
704 return yield* inApplication
705 ? interaction
706 : rendererGate.withPermits(1)(interaction);
707 });
708 };
709
710 const menu = <Value,>(
711 options: MenuOptions<Value>,
712 ): Effect.Effect<Value, InteractionError> =>
713 Effect.gen(function* () {
714 if (yield* InApplication) yield* Effect.sync(() => store.clear());
715 return yield* run(menuScreen(options));
716 });
717
718 const app = <A, E, R>(
719 effect: Effect.Effect<A, E, R>,
720 ): Effect.Effect<A, E | NonInteractiveTerminal, R> =>
721 !capabilities.input
722 ? Effect.fail(
723 new NonInteractiveTerminal({
724 operation: "application",
725 message:
726 "Cannot run a CLI application without terminal input. Provide the equivalent command flags instead.",
727 }),
728 )
729 : Effect.gen(function* () {
730 if (yield* InApplication) return yield* effect;
731 const presentation = yield* ApplicationPresentation;
732 const alternate =
733 presentation === "alternate" && capabilities.alternateScreen;
734 return yield* rendererGate.withPermits(1)(
735 Effect.acquireUseRelease(
736 Effect.promise(async () => {
737 applicationMounted = true;
738 store.clear();
739 // Sigil cannot change `alternateScreen` on a live instance: if
740 // an inline renderer (say an earlier progress row) is still
741 // mounted with the wrong presentation, remount with the
742 // requested one instead of silently ignoring it.
743 if (mounted !== undefined && mounted.alternate !== alternate) {
744 await unmount(true);
745 }
746 await mount(alternate);
747 }),
748 () => effect.pipe(Effect.provideService(InApplication, true)),
749 () =>
750 Effect.promise(async () => {
751 applicationMounted = false;
752 store.clear();
753 // Let the empty frame replace the application, then erase
754 // that frame explicitly. Unmount can now restore terminal
755 // modes without leaving the empty frame's newline behind.
756 const current = mounted;
757 if (current !== undefined) {
758 await current.sigil.waitUntilRenderFlush();
759 current.sigil.clear();
760 }
761 await unmount();
762 }),
763 ),
764 );
765 });
766
767 // A wizard owns the renderer when invoked standalone and reuses the
768 // current application renderer when nested. Prompt serialization remains
769 // centralized in `run`, so nested profile/OAuth flows suspend cleanly.
770 const wizard = <A, E, R>(
771 effect: Effect.Effect<A, E, R>,
772 ): Effect.Effect<A, E | NonInteractiveTerminal, R> =>
773 Effect.gen(function* () {
774 if (!(yield* InApplication)) return yield* app(effect);
775 return yield* effect.pipe(
776 Effect.ensuring(Effect.sync(() => store.clearTranscript())),
777 );
778 });
779
780 const makeLive = (
781 initial: View,
782 options: LiveViewOptions = {},
783 ): Effect.Effect<LiveViewHandle> =>
784 Effect.gen(function* () {
785 if (!capabilities.input) {
786 yield* print(initial);
787 return { close: Effect.void };
788 }
789 const inApplication = yield* InApplication;
790 const key = store.alloc();
791 let closed = false;
792 yield* Effect.sync(() =>
793 store.addLive(key, normalizeView(initial), options.placement),
794 );
795 yield* ensureMounted();
796 return {
797 close: Effect.suspend(() => {
798 if (closed) return Effect.void;
799 closed = true;
800 // removeLive/completeLive no-op when the row was already cleared
801 // by a menu loop or application boundary.
802 if (options.persistOnClose)
803 store.completeLive(
804 key,
805 inApplication ? "transcript" : "staticItems",
806 );
807 else store.removeLive(key);
808 return Effect.promise(waitForRender).pipe(
809 Effect.andThen(releaseIfIdle()),
810 );
811 }),
812 };
813 });
814
815 /**
816 * Scope-bound: the enclosing scope closes the view as a backstop, so an
817 * interrupted caller can never leave an orphaned row keeping the renderer
818 * mounted (and the process alive) forever.
819 */
820 const live = (
821 initial: View,
822 options: LiveViewOptions = {},
823 ): Effect.Effect<LiveViewHandle, never, Scope.Scope> =>
824 Effect.acquireRelease(makeLive(initial, options), (handle) => handle.close);
825
826 /**
827 * A progress row is just a live view over a runtime-owned store — the
828 * handle mutates the store, React re-renders. Settling swaps the store to
829 * its final state and commits the block to scrollback.
830 */
831 const makeProgress = (
832 initial: ProgressOptions,
833 ): Effect.Effect<ProgressHandle> =>
834 Effect.gen(function* () {
835 const dynamic =
836 capabilities.input && ((yield* InApplication) || stdout.isTTY === true);
837 if (!dynamic) {
838 let current = initial;
839 let closed = false;
840 yield* print(
841 <Status variant="info" detail={initial.detail}>
842 {initial.label}
843 </Status>,
844 );
845 const settle = (variant: "success" | "error", message?: string) =>
846 Effect.suspend(() => {
847 if (closed) return Effect.void;
848 closed = true;
849 return print(
850 <Status variant={variant}>{message ?? current.label}</Status>,
851 );
852 });
853 return {
854 update: (next) =>
855 Effect.sync(() => {
856 if (!closed) current = next;
857 }),
858 succeed: (message) => settle("success", message),
859 fail: (message) => settle("error", message),
860 close: Effect.sync(() => {
861 closed = true;
862 }),
863 } satisfies ProgressHandle;
864 }
865 const inApplication = yield* InApplication;
866 const progressStore = new LiveStore<ProgressState>({ options: initial });
867 const key = store.alloc();
868 yield* Effect.sync(() =>
869 store.addLive(key, <ProgressView store={progressStore} />),
870 );
871 yield* ensureMounted();
872 let settled = false;
873 const finishRow = (persist: boolean) =>
874 Effect.suspend(() => {
875 if (settled) return Effect.void;
876 settled = true;
877 // completeLive/removeLive no-op when the row was already cleared
878 // by a menu loop or application boundary.
879 if (persist)
880 store.completeLive(
881 key,
882 inApplication ? "transcript" : "staticItems",
883 );
884 else store.removeLive(key);
885 return Effect.promise(waitForRender).pipe(
886 Effect.andThen(releaseIfIdle()),
887 );
888 });
889 const settle = (variant: "success" | "error", message?: string) =>
890 Effect.suspend(() => {
891 if (settled) return Effect.void;
892 progressStore.update((state) => ({
893 ...state,
894 final: { variant, message },
895 }));
896 return finishRow(true);
897 });
898 return {
899 update: (next) =>
900 Effect.sync(() => {
901 if (!settled) progressStore.set({ options: next });
902 }),
903 succeed: (message) => settle("success", message),
904 fail: (message) => settle("error", message),
905 // An unsettled close abandons the row rather than committing it.
906 close: finishRow(false),
907 } satisfies ProgressHandle;
908 });
909
910 const progress = (
911 initial: ProgressOptions,
912 ): Effect.Effect<ProgressHandle, never, Scope.Scope> =>
913 Effect.acquireRelease(makeProgress(initial), (handle) => handle.close);
914
915 const service: CliKit["Service"] = {
916 terminal: capabilities,
917 nativeProgress: {
918 set: (state, value) =>
919 Effect.sync(() => {
920 if (mounted !== undefined) mounted.sigil.setProgress(state, value);
921 else setNativeProgress(state, value, stdout);
922 }),
923 },
924 output: {
925 print,
926 format: formatView,
927 render: renderView,
928 info: log("info"),
929 success: log("success"),
930 warning: log("warning"),
931 error: log("error"),
932 },
933 prompt: {
934 text: (inputOptions) => run(textScreen(inputOptions)),
935 password: (inputOptions) => run(passwordScreen(inputOptions)),
936 confirm: (confirmOptions) => run(confirmScreen(confirmOptions)),
937 select: (selectOptions) => run(selectScreen(selectOptions)),
938 multiSelect: (selectOptions) => run(multiSelectScreen(selectOptions)),
939 cycle: (selectOptions) => run(cycleSelectScreen(selectOptions)),
940 awaitExternal: (externalOptions) =>
941 run(awaitExternalScreen(externalOptions)),
942 menu,
943 custom: run,
944 },
945 wizard,
946 application: app,
947 live: { progress, open: live },
948 task: (taskOptions, effect) =>
949 Effect.scoped(
950 Effect.gen(function* () {
951 const handle = yield* progress(taskOptions);
952 return yield* effect.pipe(
953 Effect.onExit((exit) =>
954 Exit.isSuccess(exit)
955 ? handle.succeed()
956 : // Interruption (Ctrl+C) is not a failure — remove the row
957 // instead of painting a red error status.
958 Cause.hasInterruptsOnly(exit.cause)
959 ? handle.close
960 : handle.fail(),
961 ),
962 );
963 }),
964 ),
965 };
966
967 return {
968 service,
969 dispose: async () => {
970 // After dispose the runtime must never mount again; force teardown even
971 // if stray live rows were leaked.
972 disposed = true;
973 await unmount(true);
974 },
975 };
976};