projectinator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/bin/projectinator.mjs +21 -0
  4. package/package.json +73 -0
  5. package/src/bakeoff.ts +220 -0
  6. package/src/build-state.ts +46 -0
  7. package/src/burndown.ts +35 -0
  8. package/src/calibration.ts +88 -0
  9. package/src/cost.ts +45 -0
  10. package/src/council.ts +180 -0
  11. package/src/demo.ts +106 -0
  12. package/src/estimate.ts +104 -0
  13. package/src/executor.ts +168 -0
  14. package/src/git.ts +72 -0
  15. package/src/intake.ts +129 -0
  16. package/src/models.ts +92 -0
  17. package/src/narrate.ts +91 -0
  18. package/src/orchestrator.ts +271 -0
  19. package/src/pm.ts +301 -0
  20. package/src/preview.ts +192 -0
  21. package/src/registry-store.ts +41 -0
  22. package/src/registry.ts +118 -0
  23. package/src/research.ts +127 -0
  24. package/src/retro.ts +99 -0
  25. package/src/roles.ts +357 -0
  26. package/src/router.ts +123 -0
  27. package/src/run-bakeoff.ts +77 -0
  28. package/src/run-build.ts +190 -0
  29. package/src/run-dev.ts +83 -0
  30. package/src/run-pm.ts +92 -0
  31. package/src/run-research.ts +74 -0
  32. package/src/run-scout.ts +68 -0
  33. package/src/run-web.ts +87 -0
  34. package/src/scout.ts +121 -0
  35. package/src/session-cost.ts +17 -0
  36. package/src/stack.ts +46 -0
  37. package/src/tui/App.tsx +1739 -0
  38. package/src/tui/BakeOff.tsx +190 -0
  39. package/src/tui/BoardEditor.tsx +248 -0
  40. package/src/tui/EditableBoard.tsx +169 -0
  41. package/src/tui/Frame.tsx +142 -0
  42. package/src/tui/Intake.tsx +111 -0
  43. package/src/tui/Kanban.tsx +155 -0
  44. package/src/tui/Settings.tsx +419 -0
  45. package/src/tui/StackPick.tsx +79 -0
  46. package/src/tui/WebAccounts.tsx +197 -0
  47. package/src/tui/components.tsx +338 -0
  48. package/src/tui/config.ts +134 -0
  49. package/src/tui/deploy.ts +137 -0
  50. package/src/tui/engine.ts +742 -0
  51. package/src/tui/notify.ts +21 -0
  52. package/src/tui/panels.tsx +89 -0
  53. package/src/tui/templates.ts +119 -0
  54. package/src/tui/theme.ts +44 -0
  55. package/src/tui/validate.ts +48 -0
  56. package/src/tui.tsx +63 -0
  57. package/src/types.ts +175 -0
  58. package/src/web/oauth-anthropic.ts +206 -0
  59. package/src/web/session.ts +299 -0
@@ -0,0 +1,419 @@
1
+ // Settings — API keys, model-per-role assignments, preferences, web-login (soon).
2
+ // Self-contained: manages its own sub-navigation; calls onExit when done.
3
+
4
+ import React, { useState } from "react";
5
+ import { Box, Text } from "ink";
6
+ import { Spinner, StatusMessage } from "@inkjs/ui";
7
+ import type { Capability, Provider, Tier } from "../types.js";
8
+ import { C, Panel, Menu as SelectInput, GroupedMenu, KeyHint, TextField as TextInput, Password, type MenuGroup } from "./components.js";
9
+ import { WebAccounts } from "./WebAccounts.js";
10
+ import { connectedProviders } from "../web/session.js";
11
+ import { estimateAccuracy } from "../estimate.js";
12
+ import { availableProviders, effectiveRoster, allModels, setRoleModel, PROVIDER_LABEL } from "./engine.js";
13
+ import { setKey, getPrefs, setPrefs, loadConfig, setPreferredProvider, getDefaultMode, setDefaultMode, getNotify, setNotify, getPreferredStack, setPreferredStack, ENV_VAR } from "./config.js";
14
+ import { validateKey } from "./validate.js";
15
+
16
+ type Sub = "menu" | "keys" | "keyEntry" | "models" | "modelPick" | "prefs" | "provider" | "workflow" | "weblogin" | "accuracy" | "stack";
17
+
18
+ export function Settings({ onExit }: { onExit: () => void }): React.ReactElement {
19
+ const [sub, setSub] = useState<Sub>("menu");
20
+ const [keyProvider, setKeyProvider] = useState<Provider>("anthropic");
21
+ const [keyDraft, setKeyDraft] = useState("");
22
+ const [checking, setChecking] = useState(false);
23
+ const [keyError, setKeyError] = useState("");
24
+ const [role, setRole] = useState<{ capability: Capability; tier: Tier; label: string } | null>(null);
25
+ const [notice, setNotice] = useState("");
26
+ const [, force] = useState(0);
27
+ const refresh = () => force((n) => n + 1);
28
+
29
+ // ---------- menu ----------
30
+ if (sub === "menu") {
31
+ const groups: MenuGroup[] = [
32
+ { title: "Models & providers", items: [
33
+ { label: "API keys", value: "keys" },
34
+ { label: "Preferred provider", value: "provider" },
35
+ { label: "Model assignments", value: "models" },
36
+ { label: "Estimate accuracy", value: "accuracy" },
37
+ ] },
38
+ { title: "Build defaults", items: [
39
+ { label: "Default workflow", value: "workflow" },
40
+ { label: `Default stack: ${getPreferredStack()}`, value: "stack" },
41
+ { label: "Budget, speed & alerts", value: "prefs" },
42
+ { label: `Notify on done: ${getNotify() ? "On" : "Off"}`, value: "notify" },
43
+ ] },
44
+ // Web-login (browser automation / OAuth) is parked — vendors closed
45
+ // third-party subscription auth in 2026. Hidden unless PROJECTINATOR_WEB=1.
46
+ ...(process.env.PROJECTINATOR_WEB === "1"
47
+ ? [{ title: "Experimental", items: [{ label: `Connect accounts${connectedProviders().length ? ` (${connectedProviders().length})` : ""}`, value: "weblogin" }] }]
48
+ : []),
49
+ { title: "", items: [{ label: "Back", value: "back" }] },
50
+ ];
51
+ return (
52
+ <Box flexDirection="column">
53
+ {notice ? <Box marginBottom={1}><StatusMessage variant="success">{notice}</StatusMessage></Box> : null}
54
+ <Panel title="Settings">
55
+ <GroupedMenu
56
+ groups={groups}
57
+ onSelect={(i) => {
58
+ setNotice("");
59
+ if (i.value === "back") onExit();
60
+ else if (i.value === "notify") {
61
+ const next = !getNotify();
62
+ setNotify(next);
63
+ setNotice(`Notifications ${next ? "on" : "off"}.`);
64
+ } else setSub(i.value as Sub);
65
+ }}
66
+ />
67
+ </Panel>
68
+ </Box>
69
+ );
70
+ }
71
+
72
+ // ---------- API keys ----------
73
+ if (sub === "keys") {
74
+ const have = new Set(availableProviders());
75
+ const providers: Provider[] = ["anthropic", "openai", "google"];
76
+ return (
77
+ <Box flexDirection="column">
78
+ <Panel title="API keys">
79
+ <Text color={C.textMuted}>Select a provider to add or replace its key. Saved to ~/.projectinator (0600).</Text>
80
+ <Box marginTop={1}>
81
+ <SelectInput
82
+ items={[
83
+ ...providers.map((p) => ({
84
+ label: `${have.has(p) ? "✓" : "·"} ${PROVIDER_LABEL[p]} ${have.has(p) ? "(set)" : "(not set)"}`,
85
+ value: p,
86
+ })),
87
+ { label: "Back", value: "__back" },
88
+ ]}
89
+ onSelect={(i) => {
90
+ if (i.value === "__back") setSub("menu");
91
+ else {
92
+ setKeyProvider(i.value as Provider);
93
+ setKeyDraft("");
94
+ setKeyError("");
95
+ setChecking(false);
96
+ setSub("keyEntry");
97
+ }
98
+ }}
99
+ />
100
+ </Box>
101
+ </Panel>
102
+ </Box>
103
+ );
104
+ }
105
+
106
+ if (sub === "keyEntry") {
107
+ if (checking) {
108
+ return (
109
+ <Box flexDirection="column">
110
+ <Panel title={`Enter key for ${PROVIDER_LABEL[keyProvider]}`}>
111
+ <Spinner label={`Verifying key with ${PROVIDER_LABEL[keyProvider]}…`} />
112
+ </Panel>
113
+ </Box>
114
+ );
115
+ }
116
+ return (
117
+ <Box flexDirection="column">
118
+ <Panel title={`Enter key for ${PROVIDER_LABEL[keyProvider]}`}>
119
+ <Text color={C.textMuted}>Sets {ENV_VAR[keyProvider]}. Paste and press Enter — it's verified before saving. (hidden)</Text>
120
+ {keyError ? <Box marginTop={1}><StatusMessage variant="error">{keyError}</StatusMessage></Box> : null}
121
+ <Box marginTop={1}>
122
+ <Password
123
+ placeholder="paste your key…"
124
+ onSubmit={(k) => {
125
+ const trimmed = k.trim();
126
+ if (!trimmed) {
127
+ setSub("menu");
128
+ return;
129
+ }
130
+ setKeyError("");
131
+ setChecking(true);
132
+ void validateKey(keyProvider, trimmed).then((res) => {
133
+ setChecking(false);
134
+ if (res.ok) {
135
+ setKey(keyProvider, trimmed);
136
+ setNotice(`Saved ${PROVIDER_LABEL[keyProvider]} key (verified ✓).`);
137
+ setSub("menu");
138
+ } else {
139
+ setKeyError(res.error ?? "Key rejected.");
140
+ }
141
+ });
142
+ }}
143
+ />
144
+ </Box>
145
+ </Panel>
146
+ </Box>
147
+ );
148
+ }
149
+
150
+ // ---------- model assignments ----------
151
+ if (sub === "models") {
152
+ const rows = effectiveRoster();
153
+ const lock = loadConfig().preferredProvider;
154
+ return (
155
+ <Box flexDirection="column">
156
+ <Panel title="Model assignments">
157
+ <Text color={C.textMuted}>Which model plays each role. {lock ? `Pinned to ${PROVIDER_LABEL[lock]} — pick from its models.` : "Saved as overrides."}</Text>
158
+ <Box marginTop={1}>
159
+ <SelectInput
160
+ items={[
161
+ ...rows.map((r) => ({
162
+ label: `${r.label.padEnd(16)} ${r.model ?? "—"}`,
163
+ value: `${r.capability}:${r.tier}`,
164
+ })),
165
+ { label: "Back", value: "__back" },
166
+ ]}
167
+ onSelect={(i) => {
168
+ if (i.value === "__back") setSub("menu");
169
+ else {
170
+ const [capability, tier] = i.value.split(":") as [Capability, Tier];
171
+ const r = rows.find((x) => x.capability === capability && x.tier === tier)!;
172
+ setRole({ capability, tier, label: r.label });
173
+ setSub("modelPick");
174
+ }
175
+ }}
176
+ />
177
+ </Box>
178
+ </Panel>
179
+ </Box>
180
+ );
181
+ }
182
+
183
+ if (sub === "modelPick" && role) {
184
+ const lock = loadConfig().preferredProvider;
185
+ const models = lock ? allModels().filter((m) => m.provider === lock) : allModels();
186
+ return (
187
+ <Box flexDirection="column">
188
+ <Panel title={`Pick a model for ${role.label}`}>
189
+ {lock ? <Text color={C.textMuted}>Showing {PROVIDER_LABEL[lock]} models (you've pinned this provider).</Text> : null}
190
+ <Box marginTop={lock ? 1 : 0}>
191
+ <SelectInput
192
+ limit={10}
193
+ items={[
194
+ ...models.map((m) => ({ label: `${m.name} (${m.provider})`, value: m.id })),
195
+ { label: "Back", value: "__back" },
196
+ ]}
197
+ onSelect={(i) => {
198
+ if (i.value !== "__back") {
199
+ setRoleModel(role.capability, role.tier, i.value);
200
+ setNotice(`${role.label} → ${i.value}`);
201
+ refresh();
202
+ }
203
+ setSub("models");
204
+ }}
205
+ />
206
+ </Box>
207
+ </Panel>
208
+ </Box>
209
+ );
210
+ }
211
+
212
+ // ---------- preferred provider ----------
213
+ if (sub === "provider") {
214
+ const have = new Set(availableProviders());
215
+ const current = loadConfig().preferredProvider;
216
+ const providers: Provider[] = ["anthropic", "openai", "google"];
217
+ return (
218
+ <Box flexDirection="column">
219
+ <Panel title="Preferred provider">
220
+ <Text color={C.textMuted}>Pin one provider for every role, or Auto to use the best available. Current: {current ?? "Auto"}.</Text>
221
+ <Box marginTop={1}>
222
+ <SelectInput
223
+ items={[
224
+ { label: `Auto (best available)${!current ? " ✓" : ""}`, value: "__auto" },
225
+ ...providers.map((p) => ({
226
+ label: `${PROVIDER_LABEL[p]}${have.has(p) ? "" : " (no key)"}${current === p ? " ✓" : ""}`,
227
+ value: p,
228
+ })),
229
+ { label: "Back", value: "__back" },
230
+ ]}
231
+ onSelect={(i) => {
232
+ if (i.value === "__back") {
233
+ setSub("menu");
234
+ return;
235
+ }
236
+ const choice = i.value === "__auto" ? undefined : (i.value as Provider);
237
+ setPreferredProvider(choice);
238
+ setNotice(`Preferred provider: ${choice ?? "Auto"}.`);
239
+ setSub("menu");
240
+ }}
241
+ />
242
+ </Box>
243
+ </Panel>
244
+ </Box>
245
+ );
246
+ }
247
+
248
+ // ---------- default workflow ----------
249
+ if (sub === "workflow") {
250
+ const current = getDefaultMode();
251
+ return (
252
+ <Box flexDirection="column">
253
+ <Panel title="Default workflow for new builds">
254
+ <Text color={C.textMuted}>Current: {current === "approval" ? "Approval-gated" : "Auto-run"}.</Text>
255
+ <Box marginTop={1}>
256
+ <SelectInput
257
+ items={[
258
+ { label: `Auto-run — confirm cost, then build${current === "auto" ? " ✓" : ""}`, value: "auto" },
259
+ { label: `Approval-gated — you approve the backlog first${current === "approval" ? " ✓" : ""}`, value: "approval" },
260
+ { label: "Back", value: "__back" },
261
+ ]}
262
+ onSelect={(i) => {
263
+ if (i.value !== "__back") {
264
+ setDefaultMode(i.value as "auto" | "approval");
265
+ setNotice(`Default workflow: ${i.value === "approval" ? "Approval-gated" : "Auto-run"}.`);
266
+ }
267
+ setSub("menu");
268
+ }}
269
+ />
270
+ </Box>
271
+ </Panel>
272
+ </Box>
273
+ );
274
+ }
275
+
276
+ // ---------- preferences ----------
277
+ if (sub === "prefs") {
278
+ const prefs = getPrefs();
279
+ return <PrefsEditor initial={prefs} onDone={(p) => { setPrefs(p); setNotice("Preferences saved."); setSub("menu"); }} onCancel={() => setSub("menu")} />;
280
+ }
281
+
282
+ // ---------- estimate accuracy (calibration vs baseline) ----------
283
+ if (sub === "accuracy") {
284
+ const rows = estimateAccuracy();
285
+ return (
286
+ <Box flexDirection="column">
287
+ <Panel title="Estimate accuracy">
288
+ <Text color={C.textMuted}>Measured output tokens vs the static baseline, per role/difficulty. Self-calibration</Text>
289
+ <Text color={C.textMuted}>replaces the baseline once a bucket has ≥2 samples (✓ active).</Text>
290
+ <Box marginTop={1} flexDirection="column">
291
+ {rows.length === 0 ? (
292
+ <Text color={C.dim}>No data yet — run some builds and this fills in.</Text>
293
+ ) : (
294
+ <>
295
+ <Text color={C.dim}>{"role/diff".padEnd(16)}{"base".padEnd(8)}{"actual".padEnd(8)}{"Δ".padEnd(8)}{"n".padEnd(4)}live</Text>
296
+ {rows.map((r) => {
297
+ const delta = r.baseOutput > 0 ? Math.round(((r.actualOutput - r.baseOutput) / r.baseOutput) * 100) : 0;
298
+ return (
299
+ <Text key={`${r.capability}/${r.difficulty}`}>
300
+ {`${r.capability}/${r.difficulty}`.padEnd(16)}
301
+ {String(r.baseOutput).padEnd(8)}
302
+ <Text color={C.accent}>{String(r.actualOutput).padEnd(8)}</Text>
303
+ <Text color={Math.abs(delta) > 40 ? C.warn : C.dim}>{`${delta >= 0 ? "+" : ""}${delta}%`.padEnd(8)}</Text>
304
+ {String(r.n).padEnd(4)}
305
+ {r.active ? <Text color={C.good}>✓</Text> : <Text color={C.dim}>·</Text>}
306
+ </Text>
307
+ );
308
+ })}
309
+ </>
310
+ )}
311
+ </Box>
312
+ <Box marginTop={1}>
313
+ <SelectInput items={[{ label: "Back", value: "back" }]} onSelect={() => setSub("menu")} />
314
+ </Box>
315
+ </Panel>
316
+ </Box>
317
+ );
318
+ }
319
+
320
+ // ---------- default stack ----------
321
+ if (sub === "stack") {
322
+ const current = getPreferredStack();
323
+ return (
324
+ <Box flexDirection="column">
325
+ <Panel title="Default stack for new web builds">
326
+ <Text color={C.textMuted}>“Ask each time” shows the picker; anything else skips it. Current: {current}.</Text>
327
+ <Box marginTop={1}>
328
+ <SelectInput
329
+ items={[
330
+ { label: `Ask each time${current === "ask" ? " ✓" : ""}`, value: "ask" },
331
+ { label: `Vanilla HTML/CSS/JS${current === "vanilla" ? " ✓" : ""}`, value: "vanilla" },
332
+ { label: `React (CDN, no build)${current === "react" ? " ✓" : ""}`, value: "react" },
333
+ { label: `Let the AI decide${current === "ai" ? " ✓" : ""}`, value: "ai" },
334
+ { label: "Back", value: "__back" },
335
+ ]}
336
+ onSelect={(i) => {
337
+ if (i.value !== "__back") {
338
+ setPreferredStack(i.value as "ask" | "vanilla" | "react" | "ai");
339
+ setNotice(`Default stack: ${i.value}.`);
340
+ }
341
+ setSub("menu");
342
+ }}
343
+ />
344
+ </Box>
345
+ </Panel>
346
+ </Box>
347
+ );
348
+ }
349
+
350
+ // ---------- connect accounts (web subscriptions) ----------
351
+ if (sub === "weblogin") {
352
+ return <WebAccounts onExit={() => setSub("menu")} />;
353
+ }
354
+
355
+ return <Text>…</Text>;
356
+ }
357
+
358
+ function PrefsEditor({
359
+ initial,
360
+ onDone,
361
+ onCancel,
362
+ }: {
363
+ initial: { budgetCapUSD: number; concurrency: number; budgetAlertPct: number };
364
+ onDone: (p: { budgetCapUSD: number; concurrency: number; budgetAlertPct: number }) => void;
365
+ onCancel: () => void;
366
+ }): React.ReactElement {
367
+ const [cap, setCap] = useState(String(initial.budgetCapUSD));
368
+ const [conc, setConc] = useState(String(initial.concurrency));
369
+ const [pct, setPct] = useState(String(initial.budgetAlertPct));
370
+ const [field, setField] = useState<"cap" | "conc" | "pct">("cap");
371
+
372
+ const commit = () => {
373
+ const b = Math.max(1, parseFloat(cap) || initial.budgetCapUSD);
374
+ const c = Math.max(1, Math.floor(parseFloat(conc) || initial.concurrency));
375
+ const p = Math.min(99, Math.max(1, Math.round(parseFloat(pct) || initial.budgetAlertPct)));
376
+ onDone({ budgetCapUSD: b, concurrency: c, budgetAlertPct: p });
377
+ };
378
+
379
+ return (
380
+ <Box flexDirection="column">
381
+ <Panel title="Budget, speed & alerts">
382
+ <Box flexDirection="column">
383
+ <Box>
384
+ <Box width={22}><Text color={field === "cap" ? C.accent : C.text}>Budget cap (USD)</Text></Box>
385
+ {field === "cap" ? (
386
+ <TextInput value={cap} onChange={setCap} onSubmit={() => setField("conc")} />
387
+ ) : (
388
+ <Text>{cap}</Text>
389
+ )}
390
+ </Box>
391
+ <Box>
392
+ <Box width={22}><Text color={field === "conc" ? C.accent : C.text}>Tasks at once</Text></Box>
393
+ {field === "conc" ? (
394
+ <TextInput
395
+ value={conc}
396
+ onChange={setConc}
397
+ onSubmit={() => setField("pct")}
398
+ />
399
+ ) : (
400
+ <Text>{conc}</Text>
401
+ )}
402
+ </Box>
403
+ <Box>
404
+ <Box width={22}><Text color={field === "pct" ? C.accent : C.text}>Alert at % of cap</Text></Box>
405
+ {field === "pct" ? (
406
+ <TextInput value={pct} onChange={setPct} onSubmit={commit} />
407
+ ) : (
408
+ <Text>{pct}%</Text>
409
+ )}
410
+ </Box>
411
+ </Box>
412
+ <Box flexDirection="column" marginTop={1}>
413
+ <Text color={C.textSubtle}>Enter moves to the next field, then saves.</Text>
414
+ <KeyHint hints={[{ keys: "Enter", label: "next / save" }, { keys: "Ctrl+C", label: "cancel" }]} />
415
+ </Box>
416
+ </Panel>
417
+ </Box>
418
+ );
419
+ }
@@ -0,0 +1,79 @@
1
+ // Stack picker — platform first, then (for web) the framework. Non-web platforms
2
+ // fall back to a web build for now. Returns the chosen StackChoice.
3
+
4
+ import React, { useState } from "react";
5
+ import { Box, Text } from "ink";
6
+ import { C, Panel, Menu as SelectInput, KeyHint, TextField as TextInput } from "./components.js";
7
+ import { WEB_FRAMEWORKS, type Platform, type StackChoice } from "../stack.js";
8
+
9
+ const OTHER = "__other__";
10
+
11
+ export function StackPick({ onDone }: { onDone: (choice: StackChoice) => void }): React.ReactElement {
12
+ const [platform, setPlatform] = useState<Platform | null>(null);
13
+ const [typing, setTyping] = useState(false);
14
+ const [draft, setDraft] = useState("");
15
+
16
+ // step 1 — platform
17
+ if (!platform) {
18
+ return (
19
+ <Box flexDirection="column">
20
+ <Panel title="Target platform">
21
+ <SelectInput
22
+ items={[
23
+ { label: "Web", value: "web" },
24
+ { label: "Mobile (builds as a web app for now)", value: "mobile" },
25
+ { label: "Desktop (builds as a web app for now)", value: "desktop" },
26
+ ]}
27
+ onSelect={(i) => {
28
+ const p = i.value as Platform;
29
+ if (p === "web") setPlatform("web");
30
+ else onDone({ platform: p, framework: "ai" }); // web fallback, let PM decide
31
+ }}
32
+ />
33
+ </Panel>
34
+ <KeyHint hints={[{ keys: "Esc", label: "cancel the build" }]} />
35
+ </Box>
36
+ );
37
+ }
38
+
39
+ // step 2 — framework (web only)
40
+ if (typing) {
41
+ return (
42
+ <Box flexDirection="column">
43
+ <Text bold>Which framework?</Text>
44
+ <Box marginTop={1}>
45
+ <Text color={C.accent}>{"› "}</Text>
46
+ <TextInput
47
+ value={draft}
48
+ onChange={setDraft}
49
+ onSubmit={() => onDone({ platform: "web", framework: draft.trim() || "ai" })}
50
+ />
51
+ </Box>
52
+ <Box flexDirection="column" marginTop={1}>
53
+ <Text color={C.dim}>Name it — must run with no build step.</Text>
54
+ <KeyHint hints={[{ keys: "Enter", label: "continue" }]} />
55
+ </Box>
56
+ </Box>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <Box flexDirection="column">
62
+ <Panel title="Web framework">
63
+ <SelectInput
64
+ items={[
65
+ ...WEB_FRAMEWORKS.map((f) => ({ label: f.label, value: String(f.id) })),
66
+ { label: "Something else…", value: OTHER },
67
+ { label: "Platform", value: "__platform" },
68
+ ]}
69
+ onSelect={(i) => {
70
+ if (i.value === "__platform") setPlatform(null);
71
+ else if (i.value === OTHER) setTyping(true);
72
+ else onDone({ platform: "web", framework: i.value });
73
+ }}
74
+ />
75
+ </Panel>
76
+ <Text color={C.dim}>Esc cancels the build.</Text>
77
+ </Box>
78
+ );
79
+ }