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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/projectinator.mjs +21 -0
- package/package.json +73 -0
- package/src/bakeoff.ts +220 -0
- package/src/build-state.ts +46 -0
- package/src/burndown.ts +35 -0
- package/src/calibration.ts +88 -0
- package/src/cost.ts +45 -0
- package/src/council.ts +180 -0
- package/src/demo.ts +106 -0
- package/src/estimate.ts +104 -0
- package/src/executor.ts +168 -0
- package/src/git.ts +72 -0
- package/src/intake.ts +129 -0
- package/src/models.ts +92 -0
- package/src/narrate.ts +91 -0
- package/src/orchestrator.ts +271 -0
- package/src/pm.ts +301 -0
- package/src/preview.ts +192 -0
- package/src/registry-store.ts +41 -0
- package/src/registry.ts +118 -0
- package/src/research.ts +127 -0
- package/src/retro.ts +99 -0
- package/src/roles.ts +357 -0
- package/src/router.ts +123 -0
- package/src/run-bakeoff.ts +77 -0
- package/src/run-build.ts +190 -0
- package/src/run-dev.ts +83 -0
- package/src/run-pm.ts +92 -0
- package/src/run-research.ts +74 -0
- package/src/run-scout.ts +68 -0
- package/src/run-web.ts +87 -0
- package/src/scout.ts +121 -0
- package/src/session-cost.ts +17 -0
- package/src/stack.ts +46 -0
- package/src/tui/App.tsx +1739 -0
- package/src/tui/BakeOff.tsx +190 -0
- package/src/tui/BoardEditor.tsx +248 -0
- package/src/tui/EditableBoard.tsx +169 -0
- package/src/tui/Frame.tsx +142 -0
- package/src/tui/Intake.tsx +111 -0
- package/src/tui/Kanban.tsx +155 -0
- package/src/tui/Settings.tsx +419 -0
- package/src/tui/StackPick.tsx +79 -0
- package/src/tui/WebAccounts.tsx +197 -0
- package/src/tui/components.tsx +338 -0
- package/src/tui/config.ts +134 -0
- package/src/tui/deploy.ts +137 -0
- package/src/tui/engine.ts +742 -0
- package/src/tui/notify.ts +21 -0
- package/src/tui/panels.tsx +89 -0
- package/src/tui/templates.ts +119 -0
- package/src/tui/theme.ts +44 -0
- package/src/tui/validate.ts +48 -0
- package/src/tui.tsx +63 -0
- package/src/types.ts +175 -0
- package/src/web/oauth-anthropic.ts +206 -0
- 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
|
+
}
|