1import { useRef, useState, type ReactNode } from "react";
2import {
3 Line,
4 sleep,
5 TermChrome,
6 useInViewLoop,
7 useSpinner,
8} from "./_terminal";
9
10/**
11 * Faithful emulation of the real `alchemy dev` TUI
12 * (packages/alchemy/src/Cli/components/view/SigilCli.tsx + PlanView.tsx):
13 *
14 * - static scrollback on top (`$ alchemy dev`, Vite HMR and Worker reloads)
15 * - the live plan widget below: a `Plan · <counts>` summary rule, TaskRows
16 * with braille spinners, per-status colors/glyphs from Util/Theme.ts, and
17 * the KeyBar footer (`Starting dev stack (n/m) │ p hide widget • Ctrl+C exit`)
18 * - on success in dev mode the widget flips to the `Output` view
19 * (`{ url: '…' }` via node inspect), exactly like `startApplySession`
20 * does when the stack output is ready.
21 */
22
23// Palette lifted verbatim from packages/alchemy/src/Util/Theme.ts — the
24// terminal body is always dark, so the raw hex values are correct here.
25const C = {
26 brand: "#e28a5b",
27 success: "#9acb69",
28 warning: "#efb85a",
29 info: "#b6c77a",
30 muted: "#8f887c",
31 accentBright: "#c5df8c",
32 emphasis: "#f5f0e6",
33};
34
35type Status =
36 | "pending"
37 | "creating"
38 | "created"
39 | "updating"
40 | "updated"
41 | "no change";
42
43interface RowState {
44 id: string;
45 type: string;
46 status: Status;
47 elapsed?: string;
48}
49
50const statusColor = (s: Status): string =>
51 s === "creating" || s === "created"
52 ? C.success
53 : s === "updating" || s === "updated"
54 ? C.warning
55 : C.muted;
56
57const inProgress = (s: Status) => s === "creating" || s === "updating";
58
59interface TuiState {
60 logs: ReactNode[];
61 rows: RowState[];
62 view: "plan" | "output";
63 label: string; // "Starting dev stack" | "Dev stack ready"
64 busy: boolean;
65 widget: boolean;
66}
67
68const BOOT: TuiState = {
69 logs: [],
70 rows: [],
71 view: "plan",
72 label: "Starting dev stack",
73 busy: true,
74 widget: false,
75};
76
77let _key = 0;
78
79export default function DevTuiTerminal({
80 title = "~/my-app",
81}: {
82 title?: string;
83}) {
84 const ref = useRef<HTMLDivElement>(null);
85 const [state, setState] = useState<TuiState>(BOOT);
86 const spinner = useSpinner(true);
87
88 useInViewLoop(ref, async (signal) => {
89 const set = (patch: Partial<TuiState>) =>
90 setState((s) => ({ ...s, ...patch }));
91 const log = (node: ReactNode) =>
92 setState((s) => ({
93 ...s,
94 logs: [...s.logs, <span key={`l${++_key}`}>{node}</span>].slice(-4),
95 }));
96 const row = (id: string, patch: Partial<RowState>) =>
97 setState((s) => ({
98 ...s,
99 rows: s.rows.map((r) => (r.id === id ? { ...r, ...patch } : r)),
100 }));
101
102 while (!signal.aborted) {
103 // ── boot: one command starts the Worker and the Vite app ────────
104 setState({ ...BOOT, logs: [] });
105 log(
106 <>
107 <span style={{ color: C.muted }}>$ </span>
108 <span style={{ color: C.emphasis }}>alchemy dev</span>
109 </>,
110 );
111 await sleep(500);
112 if (signal.aborted) return;
113 set({
114 widget: true,
115 rows: [
116 { id: "Api", type: "Cloudflare.Worker", status: "creating" },
117 { id: "Web", type: "Cloudflare.Website.Vite", status: "creating" },
118 ],
119 });
120 await sleep(900);
121 if (signal.aborted) return;
122 row("Api", { status: "created", elapsed: "0.6s" });
123 await sleep(700);
124 if (signal.aborted) return;
125 row("Web", { status: "created", elapsed: "1.3s" });
126 await sleep(500);
127 if (signal.aborted) return;
128 set({ view: "output", label: "Dev stack ready", busy: false });
129 await sleep(2200);
130 if (signal.aborted) return;
131
132 // ── edit the frontend: Vite HMR ─────────────────────────────────
133 log(
134 <>
135 <span style={{ color: C.muted }}>[web] </span>
136 <span style={{ color: C.info }}>hmr update </span>
137 <span style={{ color: C.emphasis }}>/src/App.tsx</span>
138 <span style={{ color: C.muted }}> (96ms)</span>
139 </>,
140 );
141 await sleep(1800);
142 if (signal.aborted) return;
143
144 // ── edit the backend: the Worker reloads in place ───────────────
145 log(
146 <>
147 <span style={{ color: C.muted }}>[api] </span>
148 <span style={{ color: C.info }}>reloaded </span>
149 <span style={{ color: C.emphasis }}>src/api.ts</span>
150 <span style={{ color: C.muted }}> (41ms)</span>
151 </>,
152 );
153 await sleep(1800);
154 if (signal.aborted) return;
155
156 // ── edit alchemy.run.ts: a new bucket, bound to the Worker ──────
157 log(
158 <>
159 <span style={{ color: C.warning }}>↻ </span>
160 <span style={{ color: C.muted }}>
161 alchemy.run.ts changed — reloading stack
162 </span>
163 </>,
164 );
165 await sleep(600);
166 if (signal.aborted) return;
167 set({
168 view: "plan",
169 label: "Starting dev stack",
170 busy: true,
171 rows: [
172 { id: "Uploads", type: "Cloudflare.R2.Bucket", status: "creating" },
173 { id: "Api", type: "Cloudflare.Worker", status: "pending" },
174 { id: "Web", type: "Cloudflare.Website.Vite", status: "no change" },
175 ],
176 });
177 await sleep(900);
178 if (signal.aborted) return;
179 row("Uploads", { status: "created", elapsed: "0.3s" });
180 row("Api", { status: "updating" });
181 await sleep(800);
182 if (signal.aborted) return;
183 row("Api", { status: "updated", elapsed: "0.4s" });
184 await sleep(500);
185 if (signal.aborted) return;
186 set({ view: "output", label: "Dev stack ready", busy: false });
187 await sleep(3500);
188 }
189 });
190
191 const done = state.rows.filter((r) => !inProgress(r.status)).length;
192 const summaryCounts = summarize(state.rows);
193
194 return (
195 <div ref={ref}>
196 <TermChrome
197 title={title}
198 badge="DEV"
199 badgeColor="var(--alc-accent-bright)"
200 maxLines={12}
201 >
202 {state.logs.map((l, i) => (
203 <Line key={i}>{l}</Line>
204 ))}
205 {state.widget && (
206 <>
207 {/* summary rule — PlanView renders `Plan · counts` (or `Output`) */}
208 <Rule>
209 {state.view === "output" ? (
210 <b style={{ color: C.brand }}>Output</b>
211 ) : (
212 <>
213 <b style={{ color: C.brand }}>Plan</b>
214 <span style={{ color: C.muted }}> · </span>
215 {summaryCounts.map((part, i) => (
216 <span key={part.label}>
217 {i > 0 && <span style={{ color: C.muted }}> · </span>}
218 <span style={{ color: part.color }}>{part.label}</span>
219 </span>
220 ))}
221 </>
222 )}
223 </Rule>
224 {state.view === "output" ? (
225 <>
226 <Line>
227 <span style={{ color: C.muted }}>{"{"}</span>
228 </Line>
229 <Line>
230 <span style={{ color: C.muted }}>{" "}web: </span>
231 <span style={{ color: C.accentBright }}>
232 'http://localhost:5173'
233 </span>
234 <span style={{ color: C.muted }}>,</span>
235 </Line>
236 <Line>
237 <span style={{ color: C.muted }}>{" "}api: </span>
238 <span style={{ color: C.accentBright }}>
239 'http://localhost:1337'
240 </span>
241 </Line>
242 <Line>
243 <span style={{ color: C.muted }}>{"}"}</span>
244 </Line>
245 </>
246 ) : (
247 state.rows.map((r) => (
248 <Line key={r.id}>
249 <span style={{ color: statusColor(r.status) }}>
250 {inProgress(r.status)
251 ? spinner
252 : r.status === "pending"
253 ? "·"
254 : "✓"}
255 </span>{" "}
256 <b style={{ color: C.emphasis }}>{r.id}</b>
257 <span style={{ color: C.muted }}> ({r.type}) </span>
258 <span style={{ color: statusColor(r.status) }}>
259 {r.status}
260 </span>
261 {r.elapsed && (
262 <span style={{ color: C.muted }}> ({r.elapsed})</span>
263 )}
264 </Line>
265 ))
266 )}
267 {/* keybar footer — spinner + label (+ progress) │ keys */}
268 <Rule>
269 {state.busy && <span style={{ color: C.brand }}>{spinner} </span>}
270 <b style={{ color: C.brand }}>{state.label}</b>
271 {state.busy && state.rows.length > 0 && (
272 <span style={{ color: C.muted }}>
273 {" "}
274 ({done}/{state.rows.length})
275 </span>
276 )}
277 <span style={{ color: C.muted }}> │ </span>
278 <Key k="p" label="hide widget" />
279 <span style={{ color: C.muted }}> • </span>
280 <Key k="Ctrl+C" label="exit" />
281 </Rule>
282 </>
283 )}
284 </TermChrome>
285 </div>
286 );
287}
288
289function Rule({ children }: { children: ReactNode }) {
290 return (
291 <Line
292 style={{
293 borderTop: "1px solid rgba(143, 136, 124, 0.35)",
294 marginTop: "0.45em",
295 paddingTop: "0.45em",
296 minHeight: "2em",
297 }}
298 >
299 {children}
300 </Line>
301 );
302}
303
304function Key({ k, label }: { k: string; label: string }) {
305 return (
306 <>
307 <b style={{ color: C.brand }}>{k}</b>
308 <span style={{ color: C.muted }}> {label}</span>
309 </>
310 );
311}
312
313function summarize(rows: RowState[]): { label: string; color: string }[] {
314 const counts = new Map<Status, number>();
315 for (const r of rows) counts.set(r.status, (counts.get(r.status) ?? 0) + 1);
316 const order: Status[] = [
317 "creating",
318 "updating",
319 "pending",
320 "created",
321 "updated",
322 "no change",
323 ];
324 return order
325 .filter((s) => counts.has(s))
326 .map((s) => ({
327 label: `${counts.get(s)} ${s}`,
328 color: statusColor(s),
329 }));
330}