projectinator 0.3.0 → 0.4.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/README.md +30 -11
- package/bin/projectinator.mjs +56 -11
- package/dist/bakeoff.js +163 -0
- package/dist/bakeoff.js.map +1 -0
- package/dist/build-state.js +26 -0
- package/dist/build-state.js.map +1 -0
- package/dist/burndown.js +19 -0
- package/dist/burndown.js.map +1 -0
- package/dist/calibration.js +86 -0
- package/dist/calibration.js.map +1 -0
- package/dist/cli.js +289 -0
- package/dist/cli.js.map +1 -0
- package/dist/cost.js +35 -0
- package/dist/cost.js.map +1 -0
- package/dist/council.js +147 -0
- package/dist/council.js.map +1 -0
- package/dist/demo.js +93 -0
- package/dist/demo.js.map +1 -0
- package/dist/estimate.js +98 -0
- package/dist/estimate.js.map +1 -0
- package/dist/executor.js +118 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.js +82 -0
- package/dist/git.js.map +1 -0
- package/dist/intake.js +98 -0
- package/dist/intake.js.map +1 -0
- package/dist/models.js +171 -0
- package/dist/models.js.map +1 -0
- package/dist/narrate.js +76 -0
- package/dist/narrate.js.map +1 -0
- package/dist/openrouter.js +112 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/pm.js +245 -0
- package/dist/pm.js.map +1 -0
- package/dist/preview.js +194 -0
- package/dist/preview.js.map +1 -0
- package/dist/registry-store.js +38 -0
- package/dist/registry-store.js.map +1 -0
- package/dist/registry.js +115 -0
- package/dist/registry.js.map +1 -0
- package/dist/research.js +98 -0
- package/dist/research.js.map +1 -0
- package/dist/retro.js +83 -0
- package/dist/retro.js.map +1 -0
- package/dist/roles.js +357 -0
- package/dist/roles.js.map +1 -0
- package/dist/router.js +95 -0
- package/dist/router.js.map +1 -0
- package/dist/run-bakeoff.js +68 -0
- package/dist/run-bakeoff.js.map +1 -0
- package/dist/run-build.js +193 -0
- package/dist/run-build.js.map +1 -0
- package/{src/run-dev.ts → dist/run-dev.js} +23 -39
- package/dist/run-dev.js.map +1 -0
- package/{src/run-pm.ts → dist/run-pm.js} +34 -46
- package/dist/run-pm.js.map +1 -0
- package/{src/run-research.ts → dist/run-research.js} +19 -30
- package/dist/run-research.js.map +1 -0
- package/{src/run-scout.ts → dist/run-scout.js} +17 -27
- package/dist/run-scout.js.map +1 -0
- package/dist/run-web.js +93 -0
- package/dist/run-web.js.map +1 -0
- package/dist/scout.js +81 -0
- package/dist/scout.js.map +1 -0
- package/dist/session-cost.js +15 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/stack.js +34 -0
- package/dist/stack.js.map +1 -0
- package/dist/stuck.js +21 -0
- package/dist/stuck.js.map +1 -0
- package/dist/tui/App.js +1269 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/BakeOff.js +95 -0
- package/dist/tui/BakeOff.js.map +1 -0
- package/dist/tui/BoardEditor.js +183 -0
- package/dist/tui/BoardEditor.js.map +1 -0
- package/dist/tui/EditableBoard.js +140 -0
- package/dist/tui/EditableBoard.js.map +1 -0
- package/dist/tui/Frame.js +78 -0
- package/dist/tui/Frame.js.map +1 -0
- package/dist/tui/Intake.js +59 -0
- package/dist/tui/Intake.js.map +1 -0
- package/dist/tui/Kanban.js +75 -0
- package/dist/tui/Kanban.js.map +1 -0
- package/dist/tui/Settings.js +299 -0
- package/dist/tui/Settings.js.map +1 -0
- package/dist/tui/StackPick.js +44 -0
- package/dist/tui/StackPick.js.map +1 -0
- package/dist/tui/WebAccounts.js +131 -0
- package/dist/tui/WebAccounts.js.map +1 -0
- package/dist/tui/components.js +178 -0
- package/dist/tui/components.js.map +1 -0
- package/dist/tui/config.js +129 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/deploy.js +114 -0
- package/dist/tui/deploy.js.map +1 -0
- package/dist/tui/engine.js +710 -0
- package/dist/tui/engine.js.map +1 -0
- package/dist/tui/notify.js +47 -0
- package/dist/tui/notify.js.map +1 -0
- package/dist/tui/panels.js +42 -0
- package/dist/tui/panels.js.map +1 -0
- package/dist/tui/templates.js +106 -0
- package/dist/tui/templates.js.map +1 -0
- package/dist/tui/theme.js +42 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/validate.js +51 -0
- package/dist/tui/validate.js.map +1 -0
- package/{src/tui.tsx → dist/tui.js} +23 -33
- package/dist/tui.js.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/web/oauth-anthropic.js +179 -0
- package/dist/web/oauth-anthropic.js.map +1 -0
- package/dist/web/session.js +274 -0
- package/dist/web/session.js.map +1 -0
- package/package.json +5 -4
- package/src/bakeoff.ts +0 -214
- package/src/build-state.ts +0 -47
- package/src/burndown.ts +0 -35
- package/src/calibration.ts +0 -88
- package/src/cost.ts +0 -43
- package/src/council.ts +0 -175
- package/src/demo.ts +0 -106
- package/src/estimate.ts +0 -111
- package/src/executor.ts +0 -169
- package/src/git.ts +0 -72
- package/src/intake.ts +0 -125
- package/src/models.ts +0 -175
- package/src/narrate.ts +0 -87
- package/src/openrouter.ts +0 -119
- package/src/orchestrator.ts +0 -310
- package/src/pm.ts +0 -302
- package/src/preview.ts +0 -206
- package/src/registry-store.ts +0 -41
- package/src/registry.ts +0 -132
- package/src/research.ts +0 -123
- package/src/retro.ts +0 -99
- package/src/roles.ts +0 -401
- package/src/router.ts +0 -120
- package/src/run-bakeoff.ts +0 -77
- package/src/run-build.ts +0 -203
- package/src/run-web.ts +0 -87
- package/src/scout.ts +0 -121
- package/src/session-cost.ts +0 -17
- package/src/stack.ts +0 -46
- package/src/tui/App.tsx +0 -1760
- package/src/tui/BakeOff.tsx +0 -190
- package/src/tui/BoardEditor.tsx +0 -260
- package/src/tui/EditableBoard.tsx +0 -179
- package/src/tui/Frame.tsx +0 -142
- package/src/tui/Intake.tsx +0 -111
- package/src/tui/Kanban.tsx +0 -158
- package/src/tui/Settings.tsx +0 -513
- package/src/tui/StackPick.tsx +0 -79
- package/src/tui/WebAccounts.tsx +0 -197
- package/src/tui/components.tsx +0 -340
- package/src/tui/config.ts +0 -150
- package/src/tui/deploy.ts +0 -137
- package/src/tui/engine.ts +0 -749
- package/src/tui/notify.ts +0 -21
- package/src/tui/panels.tsx +0 -89
- package/src/tui/templates.ts +0 -119
- package/src/tui/theme.ts +0 -44
- package/src/tui/validate.ts +0 -53
- package/src/types.ts +0 -208
- package/src/web/oauth-anthropic.ts +0 -206
- package/src/web/session.ts +0 -299
package/src/tui/WebAccounts.tsx
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
// Connect accounts — sign in to your paid Claude / ChatGPT / Gemini web
|
|
2
|
-
// subscriptions once; the app then drives one background browser per provider to
|
|
3
|
-
// get ~free completions. Brittle + against provider ToS (account-ban risk).
|
|
4
|
-
|
|
5
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
-
import { Box, Text } from "ink";
|
|
7
|
-
import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
8
|
-
import { C, Panel, Menu as SelectInput } from "./components.js";
|
|
9
|
-
import {
|
|
10
|
-
PROVIDERS,
|
|
11
|
-
isConnected,
|
|
12
|
-
disconnect,
|
|
13
|
-
webLoginBegin,
|
|
14
|
-
webComplete,
|
|
15
|
-
type WebProvider,
|
|
16
|
-
} from "../web/session.js";
|
|
17
|
-
|
|
18
|
-
const ORDER: WebProvider[] = ["claude", "chatgpt", "gemini"];
|
|
19
|
-
|
|
20
|
-
type View =
|
|
21
|
-
| { kind: "list" }
|
|
22
|
-
| { kind: "actions"; provider: WebProvider }
|
|
23
|
-
| { kind: "connecting"; provider: WebProvider }
|
|
24
|
-
| { kind: "testing"; provider: WebProvider }
|
|
25
|
-
| { kind: "result"; provider: WebProvider; ok: boolean; text: string };
|
|
26
|
-
|
|
27
|
-
export function WebAccounts({ onExit }: { onExit: () => void }): React.ReactElement {
|
|
28
|
-
const [view, setView] = useState<View>({ kind: "list" });
|
|
29
|
-
const [notice, setNotice] = useState("");
|
|
30
|
-
const [, force] = useState(0);
|
|
31
|
-
const refresh = () => force((n) => n + 1);
|
|
32
|
-
// Handle for the open login browser (webLoginBegin), kept across renders.
|
|
33
|
-
const loginRef = useRef<{ finish: () => Promise<void>; cancel: () => Promise<void> } | null>(null);
|
|
34
|
-
const openingRef = useRef(false);
|
|
35
|
-
|
|
36
|
-
// ---- open the login window when we enter "connecting" ----
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (view.kind !== "connecting" || openingRef.current) return;
|
|
39
|
-
openingRef.current = true;
|
|
40
|
-
let alive = true;
|
|
41
|
-
void webLoginBegin(view.provider)
|
|
42
|
-
.then((h) => {
|
|
43
|
-
// If the user already left "connecting", close the just-opened browser
|
|
44
|
-
// instead of orphaning it with no UI path to finish/cancel.
|
|
45
|
-
if (!alive) { void h.cancel(); return; }
|
|
46
|
-
loginRef.current = h;
|
|
47
|
-
})
|
|
48
|
-
.catch(() => {
|
|
49
|
-
if (!alive) return;
|
|
50
|
-
setNotice("Could not open the browser.");
|
|
51
|
-
setView({ kind: "list" });
|
|
52
|
-
})
|
|
53
|
-
.finally(() => { openingRef.current = false; });
|
|
54
|
-
return () => { alive = false; };
|
|
55
|
-
}, [view]);
|
|
56
|
-
|
|
57
|
-
// ---- run a test completion when we enter "testing" ----
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (view.kind !== "testing") return;
|
|
60
|
-
const provider = view.provider;
|
|
61
|
-
let alive = true;
|
|
62
|
-
void webComplete(provider, "Reply with exactly one word: hello")
|
|
63
|
-
.then((text) => { if (alive) setView({ kind: "result", provider, ok: true, text: text.trim() || "(empty)" }); })
|
|
64
|
-
.catch((e) => { if (alive) setView({ kind: "result", provider, ok: false, text: e instanceof Error ? e.message : String(e) }); });
|
|
65
|
-
return () => { alive = false; };
|
|
66
|
-
}, [view]);
|
|
67
|
-
|
|
68
|
-
// ---------- list ----------
|
|
69
|
-
if (view.kind === "list") {
|
|
70
|
-
return (
|
|
71
|
-
<Box flexDirection="column">
|
|
72
|
-
{notice ? <Box marginBottom={1}><StatusMessage variant="success">{notice}</StatusMessage></Box> : null}
|
|
73
|
-
<Panel title="Connect accounts">
|
|
74
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
75
|
-
<Text color={C.dim}>Sign in once to your paid web subscriptions. The app reuses one</Text>
|
|
76
|
-
<Text color={C.dim}>background browser per provider — no window pops up per task.</Text>
|
|
77
|
-
<Text color={C.warn}>Uses the web UI (not the API): fragile, and against provider ToS.</Text>
|
|
78
|
-
</Box>
|
|
79
|
-
<SelectInput
|
|
80
|
-
items={[
|
|
81
|
-
...ORDER.map((p) => ({
|
|
82
|
-
label: `${isConnected(p) ? "🟢" : "⚪"} ${PROVIDERS[p].label} ${isConnected(p) ? "(connected)" : "(not connected)"}`,
|
|
83
|
-
value: p,
|
|
84
|
-
})),
|
|
85
|
-
{ label: "Back", value: "__back" },
|
|
86
|
-
]}
|
|
87
|
-
onSelect={(i) => {
|
|
88
|
-
setNotice("");
|
|
89
|
-
if (i.value === "__back") onExit();
|
|
90
|
-
else setView({ kind: "actions", provider: i.value as WebProvider });
|
|
91
|
-
}}
|
|
92
|
-
/>
|
|
93
|
-
</Panel>
|
|
94
|
-
</Box>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ---------- per-provider actions ----------
|
|
99
|
-
if (view.kind === "actions") {
|
|
100
|
-
const p = view.provider;
|
|
101
|
-
const connected = isConnected(p);
|
|
102
|
-
return (
|
|
103
|
-
<Box flexDirection="column">
|
|
104
|
-
<Text bold>{PROVIDERS[p].label}</Text>
|
|
105
|
-
<Text color={C.dim}>{connected ? "Connected. You can test or disconnect it." : "Not connected yet."}</Text>
|
|
106
|
-
<Box marginTop={1}>
|
|
107
|
-
<SelectInput
|
|
108
|
-
items={[
|
|
109
|
-
{ label: connected ? "Reconnect (open login again)" : "Connect (open login window)", value: "connect" },
|
|
110
|
-
...(connected ? [{ label: "Test — send a quick 'hello'", value: "test" }] : []),
|
|
111
|
-
...(connected ? [{ label: "Disconnect (log out, wipe session)", value: "disconnect" }] : []),
|
|
112
|
-
{ label: "Back", value: "__back" },
|
|
113
|
-
]}
|
|
114
|
-
onSelect={(i) => {
|
|
115
|
-
if (i.value === "__back") setView({ kind: "list" });
|
|
116
|
-
else if (i.value === "connect") setView({ kind: "connecting", provider: p });
|
|
117
|
-
else if (i.value === "test") setView({ kind: "testing", provider: p });
|
|
118
|
-
else if (i.value === "disconnect") {
|
|
119
|
-
disconnect(p);
|
|
120
|
-
setNotice(`Disconnected ${PROVIDERS[p].label}.`);
|
|
121
|
-
refresh();
|
|
122
|
-
setView({ kind: "list" });
|
|
123
|
-
}
|
|
124
|
-
}}
|
|
125
|
-
/>
|
|
126
|
-
</Box>
|
|
127
|
-
</Box>
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------- connecting (login window open) ----------
|
|
132
|
-
if (view.kind === "connecting") {
|
|
133
|
-
const p = view.provider;
|
|
134
|
-
return (
|
|
135
|
-
<Box flexDirection="column">
|
|
136
|
-
<Text bold>Connecting {PROVIDERS[p].label}</Text>
|
|
137
|
-
<Box marginTop={1} flexDirection="column">
|
|
138
|
-
<Text>A browser window opened. In it:</Text>
|
|
139
|
-
<Text color={C.dim}> 1. Log in to your {PROVIDERS[p].label} account (solve any captcha).</Text>
|
|
140
|
-
<Text color={C.dim}> 2. Wait until the chat screen loads.</Text>
|
|
141
|
-
<Text color={C.dim}> 3. Come back here and choose “I'm logged in”.</Text>
|
|
142
|
-
</Box>
|
|
143
|
-
<Box marginTop={1}>
|
|
144
|
-
<SelectInput
|
|
145
|
-
items={[
|
|
146
|
-
{ label: "I'm logged in — save the session", value: "done" },
|
|
147
|
-
{ label: "Cancel", value: "cancel" },
|
|
148
|
-
]}
|
|
149
|
-
onSelect={(i) => {
|
|
150
|
-
const h = loginRef.current;
|
|
151
|
-
loginRef.current = null;
|
|
152
|
-
if (i.value === "done") {
|
|
153
|
-
void (h?.finish() ?? Promise.resolve()).then(() => {
|
|
154
|
-
setNotice(`Connected ${PROVIDERS[p].label}.`);
|
|
155
|
-
refresh();
|
|
156
|
-
setView({ kind: "list" });
|
|
157
|
-
});
|
|
158
|
-
} else {
|
|
159
|
-
void (h?.cancel() ?? Promise.resolve()).then(() => setView({ kind: "list" }));
|
|
160
|
-
}
|
|
161
|
-
}}
|
|
162
|
-
/>
|
|
163
|
-
</Box>
|
|
164
|
-
</Box>
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ---------- testing ----------
|
|
169
|
-
if (view.kind === "testing") {
|
|
170
|
-
return (
|
|
171
|
-
<Box flexDirection="column">
|
|
172
|
-
<Text bold>Testing {PROVIDERS[view.provider].label}</Text>
|
|
173
|
-
<Spinner label="Sending a prompt through your web session… (first run launches the browser)" />
|
|
174
|
-
</Box>
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ---------- result ----------
|
|
179
|
-
if (view.kind === "result") {
|
|
180
|
-
const p = view.provider;
|
|
181
|
-
return (
|
|
182
|
-
<Box flexDirection="column">
|
|
183
|
-
<Text bold>{PROVIDERS[p].label} — test {view.ok ? "passed" : "failed"}</Text>
|
|
184
|
-
<Box marginTop={1}>
|
|
185
|
-
{view.ok
|
|
186
|
-
? <StatusMessage variant="success">Reply: {view.text}</StatusMessage>
|
|
187
|
-
: <StatusMessage variant="error">{view.text}</StatusMessage>}
|
|
188
|
-
</Box>
|
|
189
|
-
<Box marginTop={1}>
|
|
190
|
-
<SelectInput items={[{ label: "Back", value: "back" }]} onSelect={() => setView({ kind: "list" })} />
|
|
191
|
-
</Box>
|
|
192
|
-
</Box>
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return <Text>…</Text>;
|
|
197
|
-
}
|
package/src/tui/components.tsx
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
// Shared TUI pieces + theme. Kept small and presentational.
|
|
2
|
-
|
|
3
|
-
import React, { useState, useEffect } from "react";
|
|
4
|
-
import { Box, Text, useInput, useStdout } from "ink";
|
|
5
|
-
import Spinner from "ink-spinner";
|
|
6
|
-
import { Select, TextInput as UITextInput, PasswordInput, ProgressBar } from "@inkjs/ui";
|
|
7
|
-
import type { Capability } from "../types.js";
|
|
8
|
-
|
|
9
|
-
// ---- adapters: keep our existing call-site prop shape over @inkjs/ui ----
|
|
10
|
-
|
|
11
|
-
export interface MenuItem {
|
|
12
|
-
label: string;
|
|
13
|
-
value: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface MenuGroup {
|
|
17
|
-
title: string;
|
|
18
|
-
items: MenuItem[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Current terminal size (rows), updated on resize. */
|
|
22
|
-
export function useTermRows(): number {
|
|
23
|
-
const { stdout } = useStdout();
|
|
24
|
-
const [rows, setRows] = useState(stdout?.rows ?? 24);
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
if (!stdout) return;
|
|
27
|
-
const onResize = () => setRows(stdout.rows ?? 24);
|
|
28
|
-
stdout.on("resize", onResize);
|
|
29
|
-
return () => { stdout.off("resize", onResize); };
|
|
30
|
-
}, [stdout]);
|
|
31
|
-
return rows;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
type MenuLine =
|
|
35
|
-
| { kind: "gap" }
|
|
36
|
-
| { kind: "header"; text: string }
|
|
37
|
-
| { kind: "item"; text: string; idx: number };
|
|
38
|
-
|
|
39
|
-
/** A vertical menu with dim, non-selectable section headers. Arrow keys move
|
|
40
|
-
* through the selectable items only (headers are skipped); Enter selects.
|
|
41
|
-
* When `maxRows` is smaller than the content, it scrolls, keeping the
|
|
42
|
-
* selection in view, with ↑/↓ hints. */
|
|
43
|
-
export function GroupedMenu({
|
|
44
|
-
groups,
|
|
45
|
-
onSelect,
|
|
46
|
-
maxRows,
|
|
47
|
-
}: {
|
|
48
|
-
groups: MenuGroup[];
|
|
49
|
-
onSelect: (item: MenuItem) => void;
|
|
50
|
-
maxRows?: number;
|
|
51
|
-
}): React.ReactElement {
|
|
52
|
-
const flat = groups.flatMap((g) => g.items);
|
|
53
|
-
const [idx, setIdx] = useState(0);
|
|
54
|
-
const cur = Math.min(idx, Math.max(0, flat.length - 1));
|
|
55
|
-
|
|
56
|
-
useInput((_input, key) => {
|
|
57
|
-
if (key.upArrow) setIdx((i) => (i - 1 + flat.length) % flat.length);
|
|
58
|
-
else if (key.downArrow) setIdx((i) => (i + 1) % flat.length);
|
|
59
|
-
else if (key.return) { const it = flat[cur]; if (it) onSelect(it); }
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// Flatten to display lines (gaps + headers + items) so scrolling is line-exact.
|
|
63
|
-
const lines: MenuLine[] = [];
|
|
64
|
-
let fi = 0;
|
|
65
|
-
groups.forEach((g, gi) => {
|
|
66
|
-
if (gi) lines.push({ kind: "gap" });
|
|
67
|
-
if (g.title) lines.push({ kind: "header", text: g.title.toUpperCase() });
|
|
68
|
-
g.items.forEach((it) => { lines.push({ kind: "item", text: it.label, idx: fi }); fi++; });
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
const budget = maxRows && maxRows < lines.length ? Math.max(3, maxRows) : lines.length;
|
|
72
|
-
let start = 0;
|
|
73
|
-
let end = lines.length;
|
|
74
|
-
let clipTop = false;
|
|
75
|
-
let clipBot = false;
|
|
76
|
-
if (budget < lines.length) {
|
|
77
|
-
const selLine = lines.findIndex((l) => l.kind === "item" && l.idx === cur);
|
|
78
|
-
start = Math.max(0, Math.min(selLine - Math.floor(budget / 2), lines.length - budget));
|
|
79
|
-
end = start + budget;
|
|
80
|
-
clipTop = start > 0;
|
|
81
|
-
clipBot = end < lines.length;
|
|
82
|
-
}
|
|
83
|
-
const visible = lines.slice(clipTop ? start + 1 : start, clipBot ? end - 1 : end);
|
|
84
|
-
|
|
85
|
-
return (
|
|
86
|
-
<Box flexDirection="column">
|
|
87
|
-
{clipTop ? <Text color={C.dim}> ↑ more</Text> : null}
|
|
88
|
-
{visible.map((l, i) => {
|
|
89
|
-
if (l.kind === "gap") return <Text key={`g${i}`}> </Text>;
|
|
90
|
-
if (l.kind === "header") return <Text key={`h${i}`} color={C.dim} bold>{l.text}</Text>;
|
|
91
|
-
const sel = l.idx === cur;
|
|
92
|
-
return (
|
|
93
|
-
<Text key={`i${l.idx}`} color={sel ? C.accent : C.text} wrap="truncate-end">
|
|
94
|
-
{sel ? "❯ " : " "}{l.text}
|
|
95
|
-
</Text>
|
|
96
|
-
);
|
|
97
|
-
})}
|
|
98
|
-
{clipBot ? <Text color={C.dim}> ↓ more</Text> : null}
|
|
99
|
-
</Box>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** SelectInput-shaped wrapper over @inkjs/ui Select (items/onSelect). */
|
|
104
|
-
export function Menu({
|
|
105
|
-
items,
|
|
106
|
-
onSelect,
|
|
107
|
-
limit,
|
|
108
|
-
}: {
|
|
109
|
-
items: MenuItem[];
|
|
110
|
-
onSelect: (item: MenuItem) => void;
|
|
111
|
-
limit?: number;
|
|
112
|
-
}): React.ReactElement {
|
|
113
|
-
return (
|
|
114
|
-
<Select
|
|
115
|
-
options={items}
|
|
116
|
-
visibleOptionCount={limit ?? Math.min(items.length, 16)}
|
|
117
|
-
onChange={(value) => {
|
|
118
|
-
const it = items.find((i) => i.value === value);
|
|
119
|
-
if (it) onSelect(it);
|
|
120
|
-
}}
|
|
121
|
-
/>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Controlled-shaped wrapper over @inkjs/ui TextInput (value/onChange/onSubmit). */
|
|
126
|
-
export function TextField({
|
|
127
|
-
value,
|
|
128
|
-
onChange,
|
|
129
|
-
onSubmit,
|
|
130
|
-
placeholder,
|
|
131
|
-
}: {
|
|
132
|
-
value: string;
|
|
133
|
-
onChange: (v: string) => void;
|
|
134
|
-
onSubmit: () => void;
|
|
135
|
-
placeholder?: string;
|
|
136
|
-
}): React.ReactElement {
|
|
137
|
-
return <UITextInput defaultValue={value} placeholder={placeholder} onChange={onChange} onSubmit={() => onSubmit()} />;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Masked input for secrets. */
|
|
141
|
-
export function Password({
|
|
142
|
-
onSubmit,
|
|
143
|
-
placeholder,
|
|
144
|
-
}: {
|
|
145
|
-
onSubmit: (v: string) => void;
|
|
146
|
-
placeholder?: string;
|
|
147
|
-
}): React.ReactElement {
|
|
148
|
-
return <PasswordInput placeholder={placeholder} onSubmit={onSubmit} />;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Semantic theme tokens (OpenCode-style roles). Old keys (accent/dim/good/warn/
|
|
152
|
-
// bad/text) are kept as aliases so every existing call site still works; new
|
|
153
|
-
// screens should prefer the richer roles (textMuted, border, borderActive, …).
|
|
154
|
-
export const C = {
|
|
155
|
-
// brand / primary
|
|
156
|
-
accent: "#e0a72d", // signal amber
|
|
157
|
-
primary: "#e0a72d",
|
|
158
|
-
accentMuted: "#a67c1f", // dimmed amber — secondary emphasis
|
|
159
|
-
// text
|
|
160
|
-
text: "white",
|
|
161
|
-
textMuted: "#9aa0a6", // secondary text
|
|
162
|
-
textSubtle: "#6b7178", // faint / metadata
|
|
163
|
-
dim: "#9aa0a6", // legacy alias — same gray as textMuted so all metadata matches
|
|
164
|
-
// surfaces (for panels / bars)
|
|
165
|
-
bgPanel: "#1b1b1b",
|
|
166
|
-
bgElement: "#242424",
|
|
167
|
-
// borders
|
|
168
|
-
border: "#3a3a3a",
|
|
169
|
-
borderSubtle: "#2a2a2a",
|
|
170
|
-
borderActive: "#e0a72d",
|
|
171
|
-
// status
|
|
172
|
-
good: "green",
|
|
173
|
-
warn: "yellow",
|
|
174
|
-
bad: "red",
|
|
175
|
-
info: "cyan",
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
/** Current terminal width (columns), updated on resize. */
|
|
179
|
-
export function useTermCols(): number {
|
|
180
|
-
const { stdout } = useStdout();
|
|
181
|
-
const [cols, setCols] = useState(stdout?.columns ?? 80);
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
if (!stdout) return;
|
|
184
|
-
const onResize = () => setCols(stdout.columns ?? 80);
|
|
185
|
-
stdout.on("resize", onResize);
|
|
186
|
-
return () => { stdout.off("resize", onResize); };
|
|
187
|
-
}, [stdout]);
|
|
188
|
-
return cols;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export interface Hint {
|
|
192
|
-
/** The key(s), e.g. "Enter", "Esc", "↑↓", "Ctrl+C". */
|
|
193
|
-
keys: string;
|
|
194
|
-
/** What it does, e.g. "save", "go back". */
|
|
195
|
-
label: string;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** The one place we render keyboard shortcuts, styled after inkui's KeyHint:
|
|
199
|
-
* each key sits in a small bordered keycap (amber) with its dim action label
|
|
200
|
-
* beside it, keycaps laid out in a wrapping row. Use this everywhere instead
|
|
201
|
-
* of ad-hoc "Enter to save · Esc to back" prose so hints look identical across
|
|
202
|
-
* every screen. */
|
|
203
|
-
export function KeyHint({ hints }: { hints: Hint[] }): React.ReactElement {
|
|
204
|
-
return (
|
|
205
|
-
<Box flexWrap="wrap">
|
|
206
|
-
{hints.map((h, i) => (
|
|
207
|
-
<Box key={i} marginRight={2} alignItems="center">
|
|
208
|
-
<Box borderStyle="round" borderColor={C.dim} paddingX={1}>
|
|
209
|
-
<Text color={C.accent}>{h.keys}</Text>
|
|
210
|
-
</Box>
|
|
211
|
-
<Text color={C.dim}>{` ${h.label}`}</Text>
|
|
212
|
-
</Box>
|
|
213
|
-
))}
|
|
214
|
-
</Box>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** A rounded-border panel with an optional amber title. Frames content sections. */
|
|
219
|
-
export function Panel({
|
|
220
|
-
title,
|
|
221
|
-
borderColor,
|
|
222
|
-
children,
|
|
223
|
-
}: {
|
|
224
|
-
title?: string;
|
|
225
|
-
borderColor?: string;
|
|
226
|
-
children: React.ReactNode;
|
|
227
|
-
}): React.ReactElement {
|
|
228
|
-
return (
|
|
229
|
-
<Box flexDirection="column" borderStyle="round" borderColor={borderColor ?? C.border} paddingX={2} paddingY={1} alignSelf="flex-start">
|
|
230
|
-
{title ? <Box marginBottom={1}><Text bold color={C.accent}>{title}</Text></Box> : null}
|
|
231
|
-
{children}
|
|
232
|
-
</Box>
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** A quiet, bordered pill — a small status dot + muted label. Replaces loud
|
|
237
|
-
* filled badges for things like connected providers. */
|
|
238
|
-
export function Chip({
|
|
239
|
-
label,
|
|
240
|
-
dotColor,
|
|
241
|
-
active,
|
|
242
|
-
}: {
|
|
243
|
-
label: string;
|
|
244
|
-
dotColor?: string;
|
|
245
|
-
active?: boolean;
|
|
246
|
-
}): React.ReactElement {
|
|
247
|
-
return (
|
|
248
|
-
<Box borderStyle="round" borderColor={active ? C.borderActive : C.borderSubtle} paddingX={1}>
|
|
249
|
-
{dotColor ? <Text color={dotColor}>● </Text> : null}
|
|
250
|
-
<Text color={active ? C.text : C.textMuted}>{label}</Text>
|
|
251
|
-
</Box>
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export type TaskStatus = "pending" | "running" | "done" | "failed" | "skipped";
|
|
256
|
-
|
|
257
|
-
export interface TaskView {
|
|
258
|
-
id: string;
|
|
259
|
-
capability: Capability;
|
|
260
|
-
status: TaskStatus;
|
|
261
|
-
title: string;
|
|
262
|
-
cost?: number;
|
|
263
|
-
model?: string;
|
|
264
|
-
/** "PASS*" = passed, but the tester never ran the app (no Chromium). */
|
|
265
|
-
verdict?: "PASS" | "PASS*" | "FAIL";
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const CAP_LABEL: Record<Capability, string> = {
|
|
269
|
-
plan: "plan", design: "design", code: "code", review: "review", test: "test", ops: "ops",
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
/** The AI team: each capability is a role with a friendly name + icon. */
|
|
273
|
-
export const ROLE_META: Record<Capability, { emoji: string; label: string }> = {
|
|
274
|
-
plan: { emoji: "🧭", label: "Project manager" },
|
|
275
|
-
design: { emoji: "🎨", label: "Designer" },
|
|
276
|
-
code: { emoji: "🧠", label: "Developer" },
|
|
277
|
-
review: { emoji: "🔍", label: "Reviewer" },
|
|
278
|
-
test: { emoji: "🔎", label: "Tester" },
|
|
279
|
-
ops: { emoji: "🚀", label: "Runner" },
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
function statusMark(s: TaskStatus): React.ReactElement {
|
|
283
|
-
switch (s) {
|
|
284
|
-
case "running":
|
|
285
|
-
return (
|
|
286
|
-
<Text color="cyan">
|
|
287
|
-
<Spinner type="dots" />
|
|
288
|
-
</Text>
|
|
289
|
-
);
|
|
290
|
-
case "done":
|
|
291
|
-
return <Text color={C.good}>✓</Text>;
|
|
292
|
-
case "failed":
|
|
293
|
-
return <Text color={C.bad}>✗</Text>;
|
|
294
|
-
case "skipped":
|
|
295
|
-
return <Text color={C.dim}>·</Text>;
|
|
296
|
-
default:
|
|
297
|
-
return <Text color={C.dim}>○</Text>;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function TaskRow({ t }: { t: TaskView }): React.ReactElement {
|
|
302
|
-
const dim = t.status === "pending" || t.status === "skipped";
|
|
303
|
-
return (
|
|
304
|
-
<Box>
|
|
305
|
-
<Box width={2}>{statusMark(t.status)}</Box>
|
|
306
|
-
<Box width={7}>
|
|
307
|
-
<Text color={dim ? C.dim : C.accent}>{CAP_LABEL[t.capability]}</Text>
|
|
308
|
-
</Box>
|
|
309
|
-
<Box flexGrow={1}>
|
|
310
|
-
<Text color={dim ? C.dim : C.text} wrap="truncate-end">
|
|
311
|
-
{t.title}
|
|
312
|
-
</Text>
|
|
313
|
-
</Box>
|
|
314
|
-
<Box width={10} justifyContent="flex-end">
|
|
315
|
-
{t.verdict ? (
|
|
316
|
-
<Text color={t.verdict === "FAIL" ? C.bad : t.verdict === "PASS*" ? C.warn : C.good}>{t.verdict}</Text>
|
|
317
|
-
) : t.cost !== undefined ? (
|
|
318
|
-
<Text color={C.dim}>${t.cost.toFixed(2)}</Text>
|
|
319
|
-
) : (
|
|
320
|
-
<Text> </Text>
|
|
321
|
-
)}
|
|
322
|
-
</Box>
|
|
323
|
-
</Box>
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export function BudgetBar({ spent, cap }: { spent: number; cap: number }): React.ReactElement {
|
|
328
|
-
const frac = Math.min(1, cap > 0 ? spent / cap : 0);
|
|
329
|
-
const color = frac > 0.9 ? C.bad : frac > 0.66 ? C.warn : C.good;
|
|
330
|
-
return (
|
|
331
|
-
<Box>
|
|
332
|
-
<Text color={C.dim}>spent </Text>
|
|
333
|
-
<Text color={color} bold>${spent.toFixed(2)}</Text>
|
|
334
|
-
<Text color={C.dim}> / ${cap.toFixed(0)} </Text>
|
|
335
|
-
<Box width={30}>
|
|
336
|
-
<ProgressBar value={frac * 100} />
|
|
337
|
-
</Box>
|
|
338
|
-
</Box>
|
|
339
|
-
);
|
|
340
|
-
}
|
package/src/tui/config.ts
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
// Persistent app config — API keys + preferences — so the TUI is self-contained
|
|
2
|
-
// (no ~/.zshenv editing). Stored at ~/.projectinator/config.json with 0600 perms.
|
|
3
|
-
// Keys are applied to process.env on launch so Pi's auth picks them up.
|
|
4
|
-
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import type { Provider } from "../types.js";
|
|
9
|
-
|
|
10
|
-
export type WorkflowMode = "auto" | "approval";
|
|
11
|
-
|
|
12
|
-
export interface AppConfig {
|
|
13
|
-
keys: Partial<Record<Provider, string>>;
|
|
14
|
-
budgetCapUSD?: number;
|
|
15
|
-
concurrency?: number;
|
|
16
|
-
/** Warn (not halt) once spend crosses this % of the cap. Default 80. */
|
|
17
|
-
budgetAlertPct?: number;
|
|
18
|
-
/** Per-task limits; 0 = unlimited. Defaults: 10 min, $3. */
|
|
19
|
-
taskTimeoutMin?: number;
|
|
20
|
-
taskCostCapUSD?: number;
|
|
21
|
-
/** If set, always route to this provider (when it has a key), ignoring the others. */
|
|
22
|
-
preferredProvider?: Provider;
|
|
23
|
-
/** Default workflow for new builds: auto-run, or require PM approval before building. */
|
|
24
|
-
defaultMode?: WorkflowMode;
|
|
25
|
-
/** Desktop notification + sound when a build finishes. Default on. */
|
|
26
|
-
notify?: boolean;
|
|
27
|
-
/** Default target stack for new web builds; "ask" prompts each time. */
|
|
28
|
-
preferredStack?: "ask" | "vanilla" | "react" | "ai";
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const ENV_VAR: Record<Provider, string> = {
|
|
32
|
-
anthropic: "ANTHROPIC_API_KEY",
|
|
33
|
-
openai: "OPENAI_API_KEY",
|
|
34
|
-
google: "GEMINI_API_KEY",
|
|
35
|
-
openrouter: "OPENROUTER_API_KEY",
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
function dir(): string {
|
|
39
|
-
return join(homedir(), ".projectinator");
|
|
40
|
-
}
|
|
41
|
-
export function configPath(): string {
|
|
42
|
-
return join(dir(), "config.json");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function loadConfig(): AppConfig {
|
|
46
|
-
try {
|
|
47
|
-
if (!existsSync(configPath())) return { keys: {} };
|
|
48
|
-
const parsed = JSON.parse(readFileSync(configPath(), "utf-8")) as AppConfig;
|
|
49
|
-
return {
|
|
50
|
-
keys: parsed.keys ?? {},
|
|
51
|
-
budgetCapUSD: parsed.budgetCapUSD,
|
|
52
|
-
concurrency: parsed.concurrency,
|
|
53
|
-
budgetAlertPct: parsed.budgetAlertPct,
|
|
54
|
-
preferredProvider: parsed.preferredProvider,
|
|
55
|
-
defaultMode: parsed.defaultMode,
|
|
56
|
-
notify: parsed.notify,
|
|
57
|
-
preferredStack: parsed.preferredStack,
|
|
58
|
-
};
|
|
59
|
-
} catch {
|
|
60
|
-
return { keys: {} };
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function getNotify(): boolean {
|
|
65
|
-
return loadConfig().notify ?? true;
|
|
66
|
-
}
|
|
67
|
-
export function setNotify(v: boolean): void {
|
|
68
|
-
const cfg = loadConfig();
|
|
69
|
-
cfg.notify = v;
|
|
70
|
-
saveConfig(cfg);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function getPreferredStack(): "ask" | "vanilla" | "react" | "ai" {
|
|
74
|
-
return loadConfig().preferredStack ?? "ask";
|
|
75
|
-
}
|
|
76
|
-
export function setPreferredStack(v: "ask" | "vanilla" | "react" | "ai"): void {
|
|
77
|
-
const cfg = loadConfig();
|
|
78
|
-
cfg.preferredStack = v;
|
|
79
|
-
saveConfig(cfg);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function setPreferredProvider(p: Provider | undefined): void {
|
|
83
|
-
const cfg = loadConfig();
|
|
84
|
-
cfg.preferredProvider = p;
|
|
85
|
-
saveConfig(cfg);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function getDefaultMode(): WorkflowMode {
|
|
89
|
-
return loadConfig().defaultMode ?? "auto";
|
|
90
|
-
}
|
|
91
|
-
export function setDefaultMode(m: WorkflowMode): void {
|
|
92
|
-
const cfg = loadConfig();
|
|
93
|
-
cfg.defaultMode = m;
|
|
94
|
-
saveConfig(cfg);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function saveConfig(cfg: AppConfig): void {
|
|
98
|
-
mkdirSync(dir(), { recursive: true });
|
|
99
|
-
writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
100
|
-
try {
|
|
101
|
-
chmodSync(configPath(), 0o600); // keys are secrets — owner-only
|
|
102
|
-
} catch {
|
|
103
|
-
/* best effort */
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Put stored keys into process.env (without clobbering ones already set in the shell). */
|
|
108
|
-
export function applyKeysToEnv(cfg: AppConfig = loadConfig()): void {
|
|
109
|
-
for (const p of Object.keys(cfg.keys) as Provider[]) {
|
|
110
|
-
const v = cfg.keys[p];
|
|
111
|
-
if (v && !process.env[ENV_VAR[p]]) process.env[ENV_VAR[p]] = v;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Save a key both to disk and live env so availableProviders() updates immediately. */
|
|
116
|
-
export function setKey(provider: Provider, key: string): void {
|
|
117
|
-
const cfg = loadConfig();
|
|
118
|
-
cfg.keys[provider] = key;
|
|
119
|
-
saveConfig(cfg);
|
|
120
|
-
process.env[ENV_VAR[provider]] = key;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface Prefs {
|
|
124
|
-
budgetCapUSD: number;
|
|
125
|
-
concurrency: number;
|
|
126
|
-
budgetAlertPct: number;
|
|
127
|
-
taskTimeoutMin: number;
|
|
128
|
-
taskCostCapUSD: number;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function getPrefs(): Prefs {
|
|
132
|
-
const cfg = loadConfig();
|
|
133
|
-
return {
|
|
134
|
-
budgetCapUSD: cfg.budgetCapUSD ?? 25,
|
|
135
|
-
concurrency: cfg.concurrency ?? 3,
|
|
136
|
-
budgetAlertPct: cfg.budgetAlertPct ?? 80,
|
|
137
|
-
taskTimeoutMin: cfg.taskTimeoutMin ?? 10,
|
|
138
|
-
taskCostCapUSD: cfg.taskCostCapUSD ?? 3,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function setPrefs(prefs: Partial<Prefs>): void {
|
|
143
|
-
const cfg = loadConfig();
|
|
144
|
-
if (prefs.budgetCapUSD !== undefined) cfg.budgetCapUSD = prefs.budgetCapUSD;
|
|
145
|
-
if (prefs.concurrency !== undefined) cfg.concurrency = prefs.concurrency;
|
|
146
|
-
if (prefs.budgetAlertPct !== undefined) cfg.budgetAlertPct = prefs.budgetAlertPct;
|
|
147
|
-
if (prefs.taskTimeoutMin !== undefined) cfg.taskTimeoutMin = prefs.taskTimeoutMin;
|
|
148
|
-
if (prefs.taskCostCapUSD !== undefined) cfg.taskCostCapUSD = prefs.taskCostCapUSD;
|
|
149
|
-
saveConfig(cfg);
|
|
150
|
-
}
|