1/** @jsxImportSource @alchemy.run/sigil */
2import { useMemo, useState } from "@alchemy.run/sigil/react";
3import type { JSX } from "react";
4import {
5 ChoiceGroup,
6 Box,
7 KeyBar,
8 Text,
9 useGlyphs,
10 useKeyGlyphs,
11 useTerminalInput,
12} from "../ui/index.ts";
13import { Screen, theme, type ScreenController } from "../../CliKit/index.ts";
14import type { Plan as AlchemyPlan } from "../../../Plan.ts";
15import { Plan, PlanTree } from "./PlanView.tsx";
16
17export interface ApprovePlanProps {
18 plan: AlchemyPlan;
19 detailed?: boolean;
20 controller: ScreenController<boolean>;
21}
22
23/**
24 * Plan approval prompt: the plan tree followed by an operation-specific
25 * action and Cancel. Escape cancels; Ctrl+C is handled centrally by the
26 * screen runner.
27 */
28export function ApprovePlan(props: ApprovePlanProps): JSX.Element {
29 const { plan, detailed = false, controller } = props;
30 // Destruction is the risky path: require an explicit move to Destroy before
31 // Enter can remove or orphan resources. Deploy approvals remain action-first.
32 const [approved, setApproved] = useState(!plan.destroy);
33 const action = plan.destroy ? "Destroy" : "Deploy";
34 const glyphs = useGlyphs();
35 const keys = useKeyGlyphs();
36 const tree = useMemo(
37 () =>
38 new PlanTree(plan, {
39 detailed,
40 mode: "review",
41 label: action,
42 }),
43 [plan, detailed, action],
44 );
45 const complete = (answer: boolean) => {
46 const verdict = (
47 <Text color={answer ? theme.color.success : theme.color.danger}>
48 {answer ? glyphs.success : glyphs.error} {answer ? action : "Cancelled"}
49 </Text>
50 );
51 // On approval the plan disappears — the apply progress that follows
52 // shows every row again. A declined plan stays on screen (complete,
53 // unscrolled) so the user can review what they just turned down.
54 if (!answer) tree.setViewport("full");
55 const summary = answer ? (
56 verdict
57 ) : (
58 <Box flexDirection="column">
59 <Plan tree={tree} />
60 {verdict}
61 </Box>
62 );
63 controller.submit(answer, summary);
64 };
65
66 useTerminalInput((input, key) => {
67 if (key.enter) complete(approved);
68 else if (key.escape) controller.cancel();
69 else if (key.ctrl || key.meta) return;
70 else if (input.toLowerCase() === "y") complete(true);
71 else if (input.toLowerCase() === "n") complete(false);
72 });
73
74 const prompt = (
75 <Box flexDirection="column">
76 <Text bold>{action}?</Text>
77 <ChoiceGroup
78 value={approved}
79 choices={[
80 { value: true, label: action },
81 { value: false, label: "Cancel" },
82 ]}
83 onChange={setApproved}
84 />
85 <KeyBar
86 keys={[
87 [keys.upDown, "scroll plan"],
88 [keys.leftRight, "choose"],
89 [keys.enter, "confirm"],
90 [keys.escape, "cancel"],
91 ]}
92 />
93 </Box>
94 );
95
96 return <Plan tree={tree} footer={prompt} />;
97}
98
99export const approvePlanScreen = (plan: AlchemyPlan, detailed = false) =>
100 Screen.make<boolean>("plan approval", (controller) => (
101 <ApprovePlan plan={plan} detailed={detailed} controller={controller} />
102 ));