1/** @jsxImportSource @alchemy.run/sigil */
2/**
3 * GUI-style dashboard behind bare `alchemy profile`. One Sigil app stays
4 * mounted for the whole session and screens replace each other in place:
5 *
6 * overview — chip tabs (default profile first), selected profile's
7 * provider details with an up/down focus cursor, keybind bar,
8 * inline rename/new/delete/remove
9 * edit — replaces the overview: per-provider cycle rows
10 * (keep / reconfigure / remove, add for unconnected)
11 *
12 * Pure store actions (create/rename/delete) round-trip through
13 * the hub via a request bridge WITHOUT unmounting — results come back as
14 * in-app notices. Only flows that must prompt in the transcript (provider
15 * configuration, credential refresh) resolve the session. The built-in
16 * `default` profile is pinned first and can be neither renamed nor deleted.
17 */
18import * as Effect from "effect/Effect";
19import * as Fiber from "effect/Fiber";
20import * as Scheduler from "effect/Scheduler";
21import { useEffect, useState } from "@alchemy.run/sigil/react";
22import type { JSX } from "react";
23import {
24 Alert,
25 Box,
26 CycleList,
27 Gutter,
28 InlineConfirm,
29 KeyBar,
30 LiveStore,
31 Pointer,
32 PromptFrame,
33 Spinner,
34 Stack,
35 Status,
36 Tabs,
37 Text,
38 TextField,
39 Toast,
40 useCycleNavigation,
41 useGlyphs,
42 useKeyGlyphs,
43 useLiveStore,
44 useTerminalInput,
45 useTerminalSize,
46 VirtualList,
47} from "../ui/index.ts";
48import {
49 CliKit,
50 theme,
51 type NonInteractiveTerminal,
52} from "../../CliKit/index.ts";
53import {
54 type EditState,
55 editStateStyle,
56 ProviderBlock,
57 providerBlockHeight,
58 providerColumnWidths,
59 type ProfileProviderDisplay,
60} from "./Profile.tsx";
61
62// --- data contracts ---------------------------------------------------------
63
64export interface DashboardEntry {
65 readonly name: string;
66 readonly isActive: boolean;
67 readonly isDefault: boolean;
68}
69
70export interface ProfileDetailsPayload {
71 readonly providers: ReadonlyArray<ProfileProviderDisplay>;
72 /** Registry providers not yet connected to this profile. */
73 readonly available: ReadonlyArray<string>;
74}
75
76export type PureAction =
77 | { kind: "create"; name: string }
78 | { kind: "rename"; name: string; newName: string }
79 | { kind: "delete"; name: string };
80
81export type FlowAction =
82 | {
83 kind: "edit-apply";
84 name: string;
85 add: string[];
86 reconfigure: string[];
87 remove: string[];
88 }
89 | { kind: "refresh"; name: string; provider?: string };
90
91export type ExternalAction = FlowAction | { kind: "exit" };
92
93export interface ExecuteResult {
94 readonly ok: boolean;
95 readonly message: string;
96 readonly entries: ReadonlyArray<DashboardEntry>;
97 /** Profile to focus after the action (e.g. the new name of a rename). */
98 readonly selected?: string;
99}
100
101// --- store ------------------------------------------------------------------
102
103export type Details =
104 | { state: "loading" }
105 | ({ state: "ready" } & ProfileDetailsPayload)
106 | { state: "failed"; message: string };
107
108interface Notice {
109 readonly ok: boolean;
110 readonly message: string;
111}
112
113interface Flow {
114 readonly kind: FlowAction["kind"];
115 readonly name: string;
116 readonly provider?: string;
117 /** Inline flows render within the overview (refresh spinner). */
118 readonly inline: boolean;
119}
120
121interface DashState {
122 readonly entries: ReadonlyArray<DashboardEntry>;
123 readonly details: ReadonlyMap<string, Details>;
124 readonly notice: Notice | undefined;
125 readonly busy: boolean;
126 /** One-shot tab focus request (e.g. follow a rename to its new name). */
127 readonly focus: string | undefined;
128 /** Active in-app flow; `inline` flows render within the overview. */
129 readonly flow: Flow | undefined;
130}
131
132/**
133 * Immutable-snapshot store on top of `LiveStore`, so every mutation notifies
134 * the mounted dashboard. The resolver bridge and the notice auto-dismiss
135 * timer live outside the snapshot — they carry no visual state.
136 */
137export class DashStore extends LiveStore<DashState> {
138 private resolver: ((action: PureAction | ExternalAction) => void) | null =
139 null;
140
141 constructor(entries: ReadonlyArray<DashboardEntry>) {
142 super({
143 entries,
144 details: new Map(),
145 notice: undefined,
146 busy: false,
147 focus: undefined,
148 flow: undefined,
149 });
150 }
151
152 detailsFor(name: string): Details {
153 return this.snapshot().details.get(name) ?? { state: "loading" };
154 }
155 detailNames(): ReadonlySet<string> {
156 return new Set(this.snapshot().details.keys());
157 }
158 setDetails(name: string, details: Details) {
159 this.update((state) => ({
160 ...state,
161 details: new Map(state.details).set(name, details),
162 }));
163 }
164 setFlow(flow: Flow | undefined) {
165 this.update((state) => ({ ...state, flow }));
166 }
167 clearFocus() {
168 this.update((state) => ({ ...state, focus: undefined }));
169 }
170 dispose() {
171 this.resolver = null;
172 }
173 clearNotice() {
174 this.update((state) => ({ ...state, notice: undefined }));
175 }
176 applyResult(result: ExecuteResult) {
177 this.update((state) => ({
178 ...state,
179 entries: result.entries,
180 notice: { ok: result.ok, message: result.message },
181 focus: result.ok ? result.selected : undefined,
182 busy: false,
183 }));
184 }
185 bindResolver(resolve: (action: PureAction | ExternalAction) => void) {
186 this.resolver = resolve;
187 }
188 readonly dispatch = (action: PureAction | ExternalAction) => {
189 if (this.snapshot().busy || this.resolver === null) return;
190 const resolve = this.resolver;
191 this.resolver = null;
192 this.update((state) => ({ ...state, busy: true, notice: undefined }));
193 resolve(action);
194 };
195}
196
197// --- building blocks --------------------------------------------------------
198
199type DetailsPaneProps = {
200 details: Details;
201 refreshingProvider?: string;
202 /** Index into `details.providers` the up/down cursor rests on. */
203 focusedIndex: number;
204};
205
206function DetailsPane({
207 details,
208 refreshingProvider,
209 focusedIndex,
210}: DetailsPaneProps): JSX.Element {
211 if (details.state === "loading") {
212 return <Spinner label="resolving credentials…" />;
213 }
214 if (details.state === "failed") {
215 return <Status variant="error">{details.message}</Status>;
216 }
217 if (details.providers.length === 0) {
218 return (
219 <Text tone="muted">No accounts connected — press e to add one.</Text>
220 );
221 }
222 const { providers } = details;
223 const { nameWidth, methodWidth } = providerColumnWidths(providers);
224 const focusedProvider = providers[focusedIndex]?.name;
225 const reauthHint = "press r to re-login";
226 // The same blocks `profile show` prints, windowed to the rows the terminal
227 // leaves the pane: the list shrinks to fit (see the root layout in
228 // `Dashboard`) and scrolls just enough to keep the focused provider in view.
229 // The profile-level slot (no focused provider) shows the list from the top.
230 return (
231 <Box flexDirection="column" minHeight={0}>
232 <VirtualList
233 items={providers}
234 getKey={(provider) => provider.name}
235 itemHeight={(provider, index) =>
236 providerBlockHeight(provider, index === 0)
237 }
238 focusedIndex={Math.max(0, focusedIndex)}
239 renderItem={(provider, index) => (
240 <ProviderBlock
241 provider={provider}
242 first={index === 0}
243 nameWidth={nameWidth}
244 methodWidth={methodWidth}
245 reauthHint={reauthHint}
246 refreshingProvider={refreshingProvider}
247 focusedProvider={focusedProvider}
248 focusColumn
249 />
250 )}
251 />
252 </Box>
253 );
254}
255
256// --- edit screen ------------------------------------------------------------
257
258interface EditRow {
259 readonly provider: string;
260 readonly method: string | undefined; // undefined = not connected
261 readonly states: ReadonlyArray<EditState>;
262}
263
264type EditScreenProps = {
265 profile: string;
266 rows: ReadonlyArray<EditRow>;
267 onApply: (choices: ReadonlyArray<EditState>) => void;
268 onBack: () => void;
269};
270
271function EditScreen({
272 profile,
273 rows,
274 onApply,
275 onBack,
276}: EditScreenProps): JSX.Element {
277 const { cursor, indices, move, cycle } = useCycleNavigation(
278 rows.map((row) => row.states.length),
279 );
280 const keys = useKeyGlyphs();
281 const glyphs = useGlyphs();
282 const [unchanged, setUnchanged] = useState(false);
283 useTerminalInput((input, key) => {
284 const plain = !key.ctrl && !key.meta;
285 if (key.escape) return onBack();
286 if (key.up || (plain && input === "k")) move(-1);
287 else if (key.down || (plain && input === "j")) move(1);
288 else if ((plain && input === " ") || key.right) {
289 cycle(1);
290 setUnchanged(false);
291 } else if (key.left) {
292 cycle(-1);
293 setUnchanged(false);
294 } else if (key.enter) {
295 if (indices.every((index) => index === 0)) setUnchanged(true);
296 else onApply(rows.map((row, i) => row.states[indices[i]]!));
297 }
298 });
299 if (rows.length === 0) {
300 return (
301 <Stack>
302 <Text>
303 <Text bold color={theme.color.accent}>
304 edit accounts
305 </Text>
306 <Text tone="muted"> · {profile}</Text>
307 </Text>
308 <Status>No providers are available for this profile.</Status>
309 <KeyBar keys={[[keys.escape, "back"]]} />
310 </Stack>
311 );
312 }
313 const choices = rows.map((row) => ({
314 label: row.provider,
315 description: row.method ?? "not connected",
316 states: row.states.map((state) => ({
317 value: state,
318 label: editStateStyle[state].label,
319 icon: glyphs[editStateStyle[state].icon],
320 variant: editStateStyle[state].variant,
321 })),
322 }));
323 return (
324 <Stack gap={1}>
325 <Text>
326 <Text bold color={theme.color.accent}>
327 edit accounts
328 </Text>
329 <Text tone="muted"> · {profile}</Text>
330 </Text>
331 <CycleList choices={choices} cursor={cursor} indices={indices} />
332 {unchanged ? (
333 <Alert variant="warning" title="No changes to apply">
334 Press Space to change an account, or Esc to go back.
335 </Alert>
336 ) : null}
337 <KeyBar
338 keys={[
339 [keys.upDown, "navigate"],
340 [keys.space, "change"],
341 [keys.enter, "apply"],
342 [keys.escape, "back"],
343 ]}
344 />
345 </Stack>
346 );
347}
348
349// --- main component ---------------------------------------------------------
350
351type Mode = "normal" | "rename" | "create" | "delete" | "remove";
352
353type DashboardControlsProps = {
354 readonly mode: Mode;
355 readonly busy: boolean;
356 readonly flow: Flow | undefined;
357 readonly entry: DashboardEntry | undefined;
358 readonly provider: ProfileProviderDisplay | undefined;
359 readonly keybinds: ReadonlyArray<readonly [string, string]>;
360 readonly keyGlyphs: ReturnType<typeof useKeyGlyphs>;
361 readonly store: DashStore;
362 readonly setMode: (mode: Mode) => void;
363};
364
365function DashboardControls({
366 mode,
367 busy,
368 flow,
369 entry,
370 provider,
371 keybinds,
372 keyGlyphs,
373 store,
374 setMode,
375}: DashboardControlsProps) {
376 if (flow?.inline !== true && (busy || flow !== undefined)) return null;
377 if (mode === "normal") return <KeyBar keys={keybinds} marginTop={0} />;
378 if (mode === "delete" && entry !== undefined) {
379 return (
380 <InlineConfirm
381 message={`Delete '${entry.name}' and all its stored credentials?`}
382 confirmLabel="Delete"
383 cancelLabel="Cancel"
384 onSubmit={(confirmed) => {
385 setMode("normal");
386 if (confirmed) store.dispatch({ kind: "delete", name: entry.name });
387 }}
388 onCancel={() => setMode("normal")}
389 />
390 );
391 }
392 if (mode === "remove" && entry !== undefined && provider !== undefined) {
393 return (
394 <InlineConfirm
395 message={`Remove '${provider.name}' from profile '${entry.name}'?`}
396 confirmLabel="Remove"
397 cancelLabel="Cancel"
398 onSubmit={(confirmed) => {
399 setMode("normal");
400 if (confirmed) {
401 store.dispatch({
402 kind: "edit-apply",
403 name: entry.name,
404 add: [],
405 reconfigure: [],
406 remove: [provider.name],
407 });
408 }
409 }}
410 onCancel={() => setMode("normal")}
411 />
412 );
413 }
414
415 const renaming = mode === "rename" && entry !== undefined;
416 return (
417 <PromptFrame
418 message={renaming ? `rename '${entry.name}' to` : "new profile name"}
419 layout="inline"
420 keys={[
421 [keyGlyphs.enter, "confirm"],
422 [keyGlyphs.escape, "cancel"],
423 ]}
424 >
425 <TextField
426 key={`${mode}-${entry?.name ?? ""}`}
427 placeholder={renaming ? `${entry.name}-new` : "my-profile"}
428 onSubmit={(value) => {
429 const name = value.trim();
430 if (name.length === 0) return;
431 setMode("normal");
432 store.dispatch(
433 renaming
434 ? { kind: "rename", name: entry.name, newName: name }
435 : { kind: "create", name },
436 );
437 }}
438 onCancel={() => setMode("normal")}
439 />
440 </PromptFrame>
441 );
442}
443
444type DashboardProps = {
445 store: DashStore;
446 initialSelected: number;
447};
448
449export function Dashboard({
450 store,
451 initialSelected,
452}: DashboardProps): JSX.Element {
453 const state = useLiveStore(store);
454 const keyGlyphs = useKeyGlyphs();
455 const { rows } = useTerminalSize();
456 const [selected, setSelected] = useState(initialSelected);
457 // -1 is the profile-level slot: no provider is focused and profile actions
458 // are shown. Up/down cycles through this slot and every connected provider.
459 const [focusedProvider, setFocusedProvider] = useState(-1);
460 const [mode, setMode] = useState<Mode>("normal");
461 const [screen, setScreen] = useState<"overview" | "edit">("overview");
462 const { entries, focus: requestedFocus, flow, busy, notice } = state;
463 useEffect(() => {
464 if (requestedFocus === undefined) return;
465 const focusIndex = entries.findIndex(
466 (entry) => entry.name === requestedFocus,
467 );
468 store.clearFocus();
469 if (focusIndex >= 0) {
470 setSelected(focusIndex);
471 }
472 }, [entries, requestedFocus, store]);
473 const index = Math.min(Math.max(selected, 0), entries.length - 1);
474 const entry = entries[index];
475 const details =
476 entry === undefined ? undefined : store.detailsFor(entry.name);
477 const providers = details?.state === "ready" ? details.providers : [];
478 const provider = providers[focusedProvider];
479 const moveProviderFocus = (delta: -1 | 1) =>
480 setFocusedProvider((current) => {
481 const slotCount = providers.length + 1;
482 const position = current + 1;
483 return ((position + delta + slotCount) % slotCount) - 1;
484 });
485 useEffect(() => setFocusedProvider(-1), [entry?.name]);
486
487 useTerminalInput((input, key) => {
488 // flow prompts, the edit screen, and the inline TextField/InlineConfirm
489 // modes own the keyboard
490 if (flow !== undefined || screen === "edit" || busy) return;
491 if (mode !== "normal") return;
492 if (input === "q" || key.escape || (key.ctrl && input === "c")) {
493 store.dispatch({ kind: "exit" });
494 } else if (key.ctrl || key.meta) {
495 return;
496 } else if (entry === undefined) {
497 if (input === "n") setMode("create");
498 } else if (key.left) {
499 setSelected((s) => (s + entries.length - 1) % entries.length);
500 setFocusedProvider(-1);
501 } else if (key.right) {
502 setSelected((s) => (s + 1) % entries.length);
503 setFocusedProvider(-1);
504 } else if (key.up && providers.length > 0) {
505 moveProviderFocus(-1);
506 } else if (key.down && providers.length > 0) {
507 moveProviderFocus(1);
508 } else if (provider === undefined && input === "R" && !entry.isDefault) {
509 setMode("rename");
510 } else if (provider === undefined && input === "d" && !entry.isDefault) {
511 setMode("delete");
512 } else if (
513 provider === undefined &&
514 input === "e" &&
515 details?.state === "ready"
516 ) {
517 setScreen("edit");
518 } else if (provider === undefined && input === "r") {
519 store.dispatch({ kind: "refresh", name: entry.name });
520 } else if (provider === undefined && input === "n") {
521 setMode("create");
522 } else if (input === "e" && provider !== undefined) {
523 store.dispatch({
524 kind: "edit-apply",
525 name: entry.name,
526 add: [],
527 reconfigure: [provider.name],
528 remove: [],
529 });
530 } else if (input === "r" && provider !== undefined) {
531 store.dispatch({
532 kind: "refresh",
533 name: entry.name,
534 provider: provider.name,
535 });
536 } else if (input === "d" && provider !== undefined) {
537 setMode("remove");
538 }
539 });
540
541 if (flow !== undefined && !flow.inline) {
542 return (
543 <Text>
544 <Text bold color={theme.color.accent}>
545 {flow.kind === "refresh" ? "refresh" : "edit accounts"}
546 </Text>
547 <Text tone="muted"> · {flow.name}</Text>
548 </Text>
549 );
550 }
551
552 if (
553 screen === "edit" &&
554 entry !== undefined &&
555 details !== undefined &&
556 details.state === "ready"
557 ) {
558 const rows: EditRow[] = [
559 ...details.providers.map((provider) => ({
560 provider: provider.name,
561 method: provider.method,
562 states: ["keep", "reconfigure", "remove"] as const,
563 })),
564 ...details.available.map((name) => ({
565 provider: name,
566 method: undefined,
567 states: ["skip", "add"] as const,
568 })),
569 ];
570 return (
571 <EditScreen
572 profile={entry.name}
573 rows={rows}
574 onBack={() => setScreen("overview")}
575 onApply={(choices) => {
576 const pick = (state: EditState) =>
577 rows.flatMap((row, i) =>
578 choices[i] === state ? [row.provider] : [],
579 );
580 const action: ExternalAction = {
581 kind: "edit-apply",
582 name: entry.name,
583 add: pick("add"),
584 reconfigure: pick("reconfigure"),
585 remove: pick("remove"),
586 };
587 setScreen("overview");
588 if (
589 action.add.length +
590 action.reconfigure.length +
591 action.remove.length ===
592 0
593 ) {
594 return;
595 }
596 store.dispatch(action);
597 }}
598 />
599 );
600 }
601
602 const annotation = entry !== undefined && entry.isActive ? "active" : "";
603
604 const keybinds: ReadonlyArray<readonly [string, string]> =
605 entry === undefined
606 ? [
607 ["n", "new"],
608 ["q", "quit"],
609 ]
610 : [
611 [keyGlyphs.leftRight, "switch profile"],
612 ...(providers.length === 0
613 ? []
614 : ([[keyGlyphs.upDown, "focus provider"]] as const)),
615 ...(provider !== undefined
616 ? ([
617 ["e", "reconfigure"],
618 ["r", "refresh"],
619 ["d", "remove"],
620 ] as const)
621 : ([
622 ...(details?.state === "ready"
623 ? ([
624 ["e", "edit"],
625 ["r", "refresh"],
626 ] as const)
627 : []),
628 ["n", "new"],
629 ...(entry.isDefault
630 ? []
631 : ([
632 ["R", "rename"],
633 ["d", "delete"],
634 ] as const)),
635 ] as ReadonlyArray<readonly [string, string]>)),
636 ["q", "quit"],
637 ];
638
639 // The frame never exceeds the terminal: the provider list is the only part
640 // allowed to shrink (a `VirtualList` windows it to whatever rows are left),
641 // so every other row of chrome opts out of shrinking. Short lists keep the
642 // compact layout because the cap is a maximum, not a fixed height.
643 return (
644 <Stack maxHeight={rows}>
645 <Stack flexShrink={0}>
646 <Tabs
647 tabs={entries.map((e) => ({
648 id: e.name,
649 label: e.name,
650 marked: e.isActive,
651 }))}
652 active={entry?.name ?? ""}
653 />
654 </Stack>
655 <Stack gap={1} minHeight={0}>
656 {entry === undefined ? (
657 <Text tone="muted">No profiles yet — press n to create one.</Text>
658 ) : (
659 <>
660 {/* The profile row is the first focus slot. It sits in the same
661 gutter as the provider rows and shares their cursor column, so
662 the pointer moves in a straight line as focus travels. */}
663 <Box flexShrink={0}>
664 <Gutter>
665 <Box flexDirection="row">
666 <Pointer focused={provider === undefined} />
667 <Text> </Text>
668 <Text
669 bold
670 color={
671 provider === undefined ? theme.paint.focus : undefined
672 }
673 >
674 {entry.name}
675 </Text>
676 {annotation === "" ? null : (
677 <Text tone="muted"> · {annotation}</Text>
678 )}
679 </Box>
680 </Gutter>
681 </Box>
682 <Box flexDirection="column" minHeight={0}>
683 <DetailsPane
684 details={details ?? { state: "loading" }}
685 focusedIndex={focusedProvider}
686 refreshingProvider={
687 flow?.kind === "refresh" ? flow.provider : undefined
688 }
689 />
690 </Box>
691 </>
692 )}
693 </Stack>
694 {/* Keep one stable status row so notices do not push the controls around.
695 It shares the gutter with the profile/provider rows above it. */}
696 <Box minHeight={1} flexShrink={0} paddingLeft={theme.space.indent}>
697 {busy && flow === undefined ? (
698 <Spinner label="working…" />
699 ) : notice !== undefined ? (
700 <Toast variant={notice.ok ? "info" : "error"}>{notice.message}</Toast>
701 ) : null}
702 </Box>
703 <Stack flexShrink={0}>
704 <DashboardControls
705 mode={mode}
706 busy={busy}
707 flow={flow}
708 entry={entry}
709 provider={provider}
710 keybinds={keybinds}
711 keyGlyphs={keyGlyphs}
712 store={store}
713 setMode={setMode}
714 />
715 </Stack>
716 </Stack>
717 );
718}
719
720// --- session driver ---------------------------------------------------------
721
722export interface DashboardSessionOptions<R> {
723 readonly entries: ReadonlyArray<DashboardEntry>;
724 readonly selected: string | undefined;
725 /**
726 * Resolves a profile's provider details. A typed failure renders as the
727 * detail pane's `failed` state, showing the error's message.
728 */
729 readonly loadDetails: (
730 name: string,
731 ) => Effect.Effect<ProfileDetailsPayload, { readonly message: string }, R>;
732 /** Executes a pure store action and returns the refreshed state. */
733 readonly execute: (
734 action: PureAction,
735 ) => Effect.Effect<ExecuteResult, never, R>;
736 /**
737 * Runs an edit/refresh flow. Its prompts render inside the dashboard via
738 * the embedded session; resolves with a toast outcome — `ok: false`
739 * renders as a persistent error notice instead of an auto-dismissing
740 * success toast.
741 */
742 readonly runFlow: (
743 action: FlowAction,
744 events: {
745 readonly onProviderStart: (provider: string) => Effect.Effect<void>;
746 },
747 ) => Effect.Effect<{ ok: boolean; message: string }, never, R>;
748 /** Re-reads entries after a flow (the active profile may have changed). */
749 readonly reloadEntries: Effect.Effect<
750 ReadonlyArray<DashboardEntry>,
751 never,
752 R
753 >;
754}
755
756/**
757 * Runs the dashboard inside CliKit's application renderer. Pure actions and
758 * edit/refresh flows share the same frame, and the application clears it on
759 * exit.
760 */
761export const runProfileDashboardSession = <R,>(
762 options: DashboardSessionOptions<R>,
763): Effect.Effect<void, NonInteractiveTerminal, R | CliKit> =>
764 Effect.flatMap(CliKit, (cli) =>
765 cli.application(
766 // live.open is Scope-bound; the session scope is its release backstop
767 // (the ensuring(live.close) below settles it on the normal path).
768 Effect.scoped(
769 Effect.gen(function* () {
770 const store = new DashStore(options.entries);
771 let noticeFiber: Fiber.Fiber<void, never> | undefined;
772
773 const applyResult = Effect.fn(function* (result: ExecuteResult) {
774 if (noticeFiber !== undefined) yield* Fiber.interrupt(noticeFiber);
775 store.applyResult(result);
776 if (result.ok) {
777 noticeFiber = yield* Effect.sleep("4 seconds").pipe(
778 Effect.andThen(Effect.sync(() => store.clearNotice())),
779 Effect.forkChild,
780 );
781 }
782 });
783
784 const loadInto = (name: string) =>
785 options.loadDetails(name).pipe(
786 Effect.flatMap((payload) =>
787 Effect.sync(() =>
788 store.setDetails(name, { state: "ready", ...payload }),
789 ),
790 ),
791 Effect.catch((error) =>
792 Effect.sync(() =>
793 store.setDetails(name, {
794 state: "failed",
795 message: error.message,
796 }),
797 ),
798 ),
799 Effect.catchDefect((defect) =>
800 Effect.sync(() =>
801 store.setDetails(name, {
802 state: "failed",
803 message: String(defect),
804 }),
805 ),
806 ),
807 // Provider discovery can build very large Layers. Yield often
808 // enough for Sigil's 80ms animation clock to keep painting while
809 // that CPU-heavy Effect graph is evaluated.
810 Effect.provideService(Scheduler.MaxOpsBeforeYield, 64),
811 );
812
813 const initialSelected = Math.max(
814 0,
815 options.entries.findIndex(
816 (entry) => entry.name === options.selected,
817 ),
818 );
819
820 const live = yield* cli.live.open(
821 <Dashboard store={store} initialSelected={initialSelected} />,
822 { placement: "beforeTranscript" },
823 );
824
825 // Mount the spinner before starting stack import/provider builds.
826 // Forking the loader first allowed synchronous module evaluation to
827 // delay the dashboard's first frame, making it look fully hung.
828 const loader = yield* Effect.forEach(
829 options.entries,
830 (entry) => loadInto(entry.name),
831 { concurrency: 2, discard: true },
832 ).pipe(Effect.delay("1 millis"), Effect.forkChild);
833
834 yield* Effect.gen(function* () {
835 while (true) {
836 const action = yield* Effect.callback<
837 PureAction | ExternalAction
838 >((resume) => {
839 store.bindResolver((action) => resume(Effect.succeed(action)));
840 });
841 switch (action.kind) {
842 case "exit":
843 return;
844 case "refresh":
845 case "edit-apply": {
846 store.setFlow({
847 kind: action.kind,
848 name: action.name,
849 provider:
850 action.kind === "refresh" ? action.provider : undefined,
851 // refresh keeps the overview on screen with a spinner; only
852 // account editing takes over the whole view
853 inline: action.kind === "refresh",
854 });
855 const flowEffect = options.runFlow(action, {
856 onProviderStart: (provider) =>
857 Effect.sync(() =>
858 store.setFlow({
859 kind: "refresh",
860 name: action.name,
861 provider,
862 inline: true,
863 }),
864 ),
865 });
866 // Provider login implementations emit useful progress in
867 // standalone commands. Inside the dashboard that progress
868 // belongs in the provider pane, not the transcript.
869 const quietRefreshCli = {
870 ...cli,
871 output: {
872 ...cli.output,
873 info: () => Effect.void,
874 success: () => Effect.void,
875 },
876 } satisfies CliKit["Service"];
877 const result = yield* cli.wizard(
878 action.kind === "refresh"
879 ? flowEffect.pipe(
880 Effect.provideService(CliKit, quietRefreshCli),
881 )
882 : flowEffect,
883 );
884 const entries = yield* options.reloadEntries;
885 if (action.kind === "refresh") {
886 // Keep the provider-level spinner visible until the new
887 // expiry/token details have replaced the old ones.
888 yield* loadInto(action.name);
889 } else {
890 store.setDetails(action.name, { state: "loading" });
891 yield* loadInto(action.name).pipe(Effect.forkChild);
892 }
893 store.setFlow(undefined);
894 yield* applyResult({
895 ok: result.ok,
896 message: result.message,
897 entries,
898 selected: action.name,
899 });
900 break;
901 }
902 default: {
903 const result = yield* options.execute(action);
904 // a renamed/created profile needs its details (re)resolved
905 const names = store.detailNames();
906 yield* applyResult(result);
907 yield* Effect.forEach(
908 result.entries.filter((e) => !names.has(e.name)),
909 (e) => loadInto(e.name),
910 { concurrency: 2, discard: true },
911 ).pipe(Effect.forkChild);
912 }
913 }
914 }
915 }).pipe(
916 Effect.ensuring(Effect.sync(() => store.dispose())),
917 Effect.ensuring(
918 Effect.suspend(() =>
919 noticeFiber === undefined
920 ? Effect.void
921 : Fiber.interrupt(noticeFiber),
922 ),
923 ),
924 Effect.ensuring(live.close),
925 Effect.ensuring(Fiber.interrupt(loader)),
926 );
927 }),
928 ),
929 ),
930 );