catherd-cli 0.1.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catherd-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Herds coding agents: autopilot builds from your own Claude Code session.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catherd",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Herds coding agents: Claude plans and verifies, Codex and opencode workers write the code, and Jev picks the model and effort for each piece.",
5
5
  "author": {
6
6
  "name": "47vigen",
package/plugin/.mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "catherd": {
4
4
  "command": "bunx",
5
- "args": ["catherd-cli@0.1.0", "mcp"]
5
+ "args": ["catherd-cli@0.2.1", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -223,7 +223,7 @@ The brief is the `brief` text you pass to `dispatch` (catherd writes it to `R/ro
223
223
  4. For a worker:
224
224
  - "Read `<R>/lanes/Mx.Ly.md`": decisions, signatures, data shapes;
225
225
  - its fast check, to run until it passes;
226
- - "Run the full suite only if this brief says so", and "Wrap any full build or full test suite in `npx -y catherd lock -- <command>`": other lanes share the machine.
226
+ - "Run the full suite only if this brief says so", and "Wrap any full build or full test suite in `bunx catherd-cli@0.2.1 lock -- <command>`": other lanes share the machine.
227
227
  5. For a reviewer: the A-lines and the changed files, with new files read in full. It reports every finding as `BLOCKER|BUG|NIT file:line — problem — fix`, covering:
228
228
  - unmet A-lines and edge cases;
229
229
  - code or abstractions nobody needs;
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { z } from "zod";
5
- import { fastCheckOf } from "../core/lanes.ts";
5
+ import { fastCheckOf, ownedFilesOf } from "../core/lanes.ts";
6
6
  import { acquire, resolveSlots } from "../core/lock.ts";
7
7
  import { json } from "./out.ts";
8
8
  import { findRun } from "./runs.ts";
@@ -13,6 +13,7 @@ interface PreflightResult {
13
13
  lane: string;
14
14
  check: string;
15
15
  pass: boolean;
16
+ skipped?: boolean;
16
17
  tail: string[];
17
18
  }
18
19
 
@@ -57,6 +58,20 @@ export function registerPreflight(server: McpServer): void {
57
58
  results.push({ lane, check: "", pass: false, tail: [`lanes/${f} has no "Fast check:" line`] });
58
59
  continue;
59
60
  }
61
+ // A check on a file the lane itself creates cannot run on the base tree; that is expected.
62
+ const unborn = ownedFilesOf(text).filter(
63
+ (p) => check.includes(p) && !existsSync(join(run.meta.repo, p)),
64
+ );
65
+ if (unborn.length > 0) {
66
+ results.push({
67
+ lane,
68
+ check,
69
+ pass: true,
70
+ skipped: true,
71
+ tail: [`skipped: ${unborn.join(", ")} does not exist yet; the lane creates it`],
72
+ });
73
+ continue;
74
+ }
60
75
  const r = await runCheck(run.meta.repo, check);
61
76
  results.push({ lane, check, ...r });
62
77
  }
@@ -116,3 +116,15 @@ export function saveProfileAndAgents(
116
116
  saveProfile(p);
117
117
  return r;
118
118
  }
119
+
120
+ /** Read-only count of catherd's own symlinks in `claudeAgentsDir()`, for the dashboard's
121
+ * "Claude agents" status row — unlike `writeClaudeAgents`, this never touches the filesystem. */
122
+ export function countLinkedAgents(): number {
123
+ const target = claudeAgentsDir();
124
+ const ours = `${join(configDir(), "agents")}${sep}`;
125
+ if (!lstatOrNull(target)) return 0;
126
+ return readdirSync(target).filter((name) => {
127
+ const st = lstatOrNull(join(target, name));
128
+ return st?.isSymbolicLink() && readlinkSync(join(target, name)).startsWith(ours);
129
+ }).length;
130
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Role } from "../types.ts";
2
+ import { VERSION } from "../version.ts";
2
3
 
3
4
  const READ_ONLY = ["Write", "Edit", "NotebookEdit", "Agent"];
4
5
  const WRITES = ["Agent"];
@@ -66,7 +67,7 @@ const worker = [
66
67
  "",
67
68
  `Read your lane file first. ${RUN_FILES} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
68
69
  "",
69
- "Change only the files you own. Run your fast check until it passes. Run the full suite only if the brief says so, and wrap any full build or full test suite in: npx -y catherd lock -- <command>. Other lanes share this machine.",
70
+ `Change only the files you own. Run your fast check until it passes. Run the full suite only if the brief says so, and wrap any full build or full test suite in: bunx catherd-cli@${VERSION} lock -- <command>. Other lanes share this machine.`,
70
71
  "",
71
72
  REPLY,
72
73
  ].join("\n");
@@ -1,4 +1,5 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { createFetch } from "ofetch";
4
5
  import { z } from "zod";
@@ -89,16 +90,19 @@ const Reply = z.object({
89
90
 
90
91
  const credentialsFile = () => join(configDir(), "credentials.json");
91
92
 
93
+ /** The key file TypeSafe's own tools use, so a user who already has one never types it again. */
94
+ export const typesafeKeyFile = (): string =>
95
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "typesafe", "api_key");
96
+
92
97
  export function jevKey(): string | null {
93
98
  const env = process.env.TYPESAFE_API_KEY?.trim();
94
99
  if (env) return env;
95
- if (!existsSync(credentialsFile())) return null;
96
100
  try {
97
101
  const k: unknown = JSON.parse(readFileSync(credentialsFile(), "utf8")).typesafeApiKey;
98
- return typeof k === "string" && k.trim() ? k.trim() : null;
99
- } catch {
100
- return null;
101
- }
102
+ if (typeof k === "string" && k.trim()) return k.trim();
103
+ } catch {}
104
+ if (!existsSync(typesafeKeyFile())) return null;
105
+ return readFileSync(typesafeKeyFile(), "utf8").trim() || null;
102
106
  }
103
107
 
104
108
  export function saveJevKey(key: string): void {
@@ -2,7 +2,7 @@ import { createCliRenderer } from "@opentui/core";
2
2
  import { createRoot } from "@opentui/react";
3
3
  import { defineCommand } from "citty";
4
4
  import { createElement, type ReactNode } from "react";
5
- import { Editor } from "./editor.tsx";
5
+ import { Dashboard } from "./dashboard.tsx";
6
6
  import { Init } from "./init.tsx";
7
7
  import { detectUi } from "./theme.ts";
8
8
  import { Watch } from "./watch.tsx";
@@ -27,10 +27,12 @@ export async function mount(el: ReactNode, stdin: { isTTY?: boolean } = process.
27
27
  return Number(process.exitCode ?? 0);
28
28
  }
29
29
 
30
- /** The main command's run. citty runs it after any subcommand too, hence the isBare check. */
30
+ /** The main command's run. citty runs it after any subcommand too, hence the isBare check.
31
+ * Bare `catherd` opens the dashboard; Profile/Watch/Setup from there render Editor/Watch/Init
32
+ * inline, in the same mounted tree — see dashboard.tsx. */
31
33
  export async function editorRun({ rawArgs }: { rawArgs: string[] }): Promise<void> {
32
34
  if (!isBare(rawArgs)) return;
33
- process.exitCode = await mount(createElement(Editor, { ui: detectUi(rawArgs) }));
35
+ process.exitCode = await mount(createElement(Dashboard, { ui: detectUi(rawArgs) }));
34
36
  }
35
37
 
36
38
  export const initCommand = defineCommand({
@@ -0,0 +1,245 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { TextAttributes } from "@opentui/core";
3
+ import { useKeyboard, useRenderer } from "@opentui/react";
4
+ import { useEffect, useState } from "react";
5
+ import { countLinkedAgents } from "../profile/agents.ts";
6
+ import { activeProfileName } from "../profile/profile.ts";
7
+ import { loadCatalog } from "../routing/catalog.ts";
8
+ import { jevKey } from "../routing/jev.ts";
9
+ import type { Catalog } from "../types.ts";
10
+ import { type BackendStatus, detectBackends } from "./backends.ts";
11
+ import { Editor } from "./editor.tsx";
12
+ import { Init } from "./init.tsx";
13
+ import { dot, type DotState, dotTint, glyph, type Mood, tint, type Ui } from "./theme.ts";
14
+ import { DetailPane, Frame, ListLine, Wordmark } from "./ui.tsx";
15
+ import { Watch } from "./watch.tsx";
16
+
17
+ export interface DashboardDeps {
18
+ detectBackends: () => Promise<BackendStatus[]>;
19
+ jevKey: () => string | null;
20
+ loadCatalog: () => Catalog;
21
+ countLinkedAgents: () => number;
22
+ activeProfileName: () => string;
23
+ version: string;
24
+ }
25
+
26
+ function readVersion(): string {
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as {
29
+ version: string;
30
+ };
31
+ return pkg.version;
32
+ } catch {
33
+ return "0.0.0";
34
+ }
35
+ }
36
+
37
+ const dashboardDeps: DashboardDeps = {
38
+ detectBackends,
39
+ jevKey,
40
+ loadCatalog,
41
+ countLinkedAgents,
42
+ activeProfileName,
43
+ version: readVersion(),
44
+ };
45
+
46
+ type Screen = "dashboard" | "profile" | "watch" | "setup";
47
+
48
+ interface Action {
49
+ screen: Screen | "refresh" | "exit";
50
+ label: string;
51
+ subtitle: string;
52
+ description: string;
53
+ }
54
+
55
+ const ACTIONS: Action[] = [
56
+ {
57
+ screen: "profile",
58
+ label: "Profile",
59
+ subtitle: "roles, routing, budget",
60
+ description: "Edit which models fill each role, and how catherd escalates between them.",
61
+ },
62
+ {
63
+ screen: "watch",
64
+ label: "Watch runs",
65
+ subtitle: "live and recent runs",
66
+ description: "See what is running now, and how past runs went.",
67
+ },
68
+ {
69
+ screen: "setup",
70
+ label: "Setup",
71
+ subtitle: "Jev key & backends",
72
+ description: "Re-run first-time setup: the Jev key, Codex/opencode, and a starting profile.",
73
+ },
74
+ {
75
+ screen: "refresh",
76
+ label: "Refresh catalog",
77
+ subtitle: "reload models & scores",
78
+ description: "Re-read the model catalog from disk, in case it changed underneath catherd.",
79
+ },
80
+ {
81
+ screen: "exit",
82
+ label: "Exit",
83
+ subtitle: "quit catherd",
84
+ description: "Leave catherd without changing anything.",
85
+ },
86
+ ];
87
+
88
+ interface Status {
89
+ backends: BackendStatus[];
90
+ jev: boolean;
91
+ agents: number;
92
+ active: string;
93
+ }
94
+
95
+ function backendState(b: BackendStatus | undefined, plain: boolean): { state: DotState; text: string } {
96
+ if (!b || !b.installed) return { state: "missing", text: "not installed" };
97
+ if (!b.loggedIn) {
98
+ return {
99
+ state: "warn",
100
+ text: b.version ? `${b.version} ${glyph("dot", plain)} not logged in` : "not logged in",
101
+ };
102
+ }
103
+ return { state: "ready", text: b.version ?? "ready" };
104
+ }
105
+
106
+ function StatusRow({ ui, state, name, value }: { ui: Ui; state: DotState; name: string; value: string }) {
107
+ return (
108
+ <text wrapMode="none" truncate>
109
+ <span fg={dotTint(state, ui.depth)}>{dot(state, ui.plain)}</span> {name.padEnd(16)}
110
+ <span attributes={TextAttributes.DIM}>{value}</span>
111
+ </text>
112
+ );
113
+ }
114
+
115
+ function mood(status: Status | null): Mood {
116
+ if (!status) return "working";
117
+ const trouble = status.backends.some((b) => !b.installed || !b.loggedIn) || !status.jev;
118
+ return trouble ? "waiting" : "good";
119
+ }
120
+
121
+ /** The bare `catherd` screen: an actions menu on the left, and the selected action's detail
122
+ * plus a live status snapshot on the right. Selecting Profile/Watch/Setup swaps this same
123
+ * mounted tree for that screen, in place — no new process, no re-mount of the renderer. */
124
+ export function Dashboard({ ui, deps = {} }: { ui: Ui; deps?: Partial<DashboardDeps> }) {
125
+ const d = { ...dashboardDeps, ...deps };
126
+ const renderer = useRenderer();
127
+ const [cursor, setCursor] = useState(0);
128
+ const [status, setStatus] = useState<Status | null>(null);
129
+ const [screen, setScreen] = useState<Screen>("dashboard");
130
+ const [note, setNote] = useState("");
131
+
132
+ const refresh = () => {
133
+ d.detectBackends().then(
134
+ (backends) =>
135
+ setStatus({
136
+ backends,
137
+ jev: d.jevKey() !== null,
138
+ agents: d.countLinkedAgents(),
139
+ active: d.activeProfileName(),
140
+ }),
141
+ () => setStatus({ backends: [], jev: false, agents: 0, active: d.activeProfileName() }),
142
+ );
143
+ };
144
+
145
+ // spec: no new network calls on every keystroke — backends are probed once on mount, and
146
+ // again only when the user asks for it with `r`.
147
+ useEffect(refresh, []);
148
+
149
+ const activate = () => {
150
+ const a = ACTIONS[cursor];
151
+ if (!a) return;
152
+ if (a.screen === "exit") renderer.destroy();
153
+ else if (a.screen === "refresh") {
154
+ d.loadCatalog();
155
+ setNote("Catalog reloaded.");
156
+ } else setScreen(a.screen);
157
+ };
158
+
159
+ useKeyboard((key) => {
160
+ if (screen !== "dashboard") return;
161
+ if (key.name === "up") setCursor((v) => Math.max(0, v - 1));
162
+ else if (key.name === "down") setCursor((v) => Math.min(ACTIONS.length - 1, v + 1));
163
+ else if (key.name === "return") activate();
164
+ else if (key.sequence === "r") refresh();
165
+ else if (key.sequence === "q") renderer.destroy();
166
+ });
167
+
168
+ if (screen === "profile") return <Editor ui={ui} />;
169
+ if (screen === "watch") return <Watch ui={ui} />;
170
+ if (screen === "setup") return <Init ui={ui} />;
171
+
172
+ const a = ACTIONS[cursor] as Action;
173
+ const codex = status
174
+ ? backendState(
175
+ status.backends.find((b) => b.backend === "codex"),
176
+ ui.plain,
177
+ )
178
+ : null;
179
+ const opencode = status
180
+ ? backendState(
181
+ status.backends.find((b) => b.backend === "opencode"),
182
+ ui.plain,
183
+ )
184
+ : null;
185
+
186
+ return (
187
+ <Frame
188
+ ui={ui}
189
+ title="catherd"
190
+ hint={`${glyph("keys", ui.plain)} navigate Enter open r refresh q exit`}
191
+ above={<Wordmark ui={ui} mood={mood(status)} suffix={d.version} />}
192
+ >
193
+ <box style={{ flexDirection: "row" }}>
194
+ <box style={{ flexDirection: "column", width: 22 }}>
195
+ <text fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD}>
196
+ ACTIONS
197
+ </text>
198
+ {ACTIONS.map((act, i) => (
199
+ <ListLine key={act.label} ui={ui} selected={i === cursor} text={act.label} />
200
+ ))}
201
+ </box>
202
+ <DetailPane ui={ui} heading="SELECTED ACTION" title={a.label} subtitle={a.subtitle}>
203
+ <text attributes={TextAttributes.DIM} wrapMode="word">
204
+ {a.description}
205
+ </text>
206
+ <text> </text>
207
+ <text fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD}>
208
+ CURRENT SETUP
209
+ </text>
210
+ {status ? (
211
+ <>
212
+ <StatusRow
213
+ ui={ui}
214
+ state={status.jev ? "ready" : "missing"}
215
+ name="Jev key"
216
+ value={status.jev ? "set" : "missing"}
217
+ />
218
+ <StatusRow ui={ui} state={codex?.state ?? "missing"} name="Codex" value={codex?.text ?? ""} />
219
+ <StatusRow
220
+ ui={ui}
221
+ state={opencode?.state ?? "missing"}
222
+ name="opencode"
223
+ value={opencode?.text ?? ""}
224
+ />
225
+ <StatusRow
226
+ ui={ui}
227
+ state={status.agents > 0 ? "ready" : "warn"}
228
+ name="Claude agents"
229
+ value={`${status.agents} linked`}
230
+ />
231
+ <StatusRow ui={ui} state="ready" name="Active profile" value={status.active} />
232
+ </>
233
+ ) : (
234
+ <text attributes={TextAttributes.DIM}>checking…</text>
235
+ )}
236
+ {note ? (
237
+ <text fg={tint("pink", ui.depth)} wrapMode="none" truncate>
238
+ {note}
239
+ </text>
240
+ ) : null}
241
+ </DetailPane>
242
+ </box>
243
+ </Frame>
244
+ );
245
+ }
@@ -1,4 +1,3 @@
1
- import { TextAttributes } from "@opentui/core";
2
1
  import { useKeyboard, useRenderer } from "@opentui/react";
3
2
  import { useEffect, useState } from "react";
4
3
  import { type HarnessCost, harnessCosts } from "../core/harness.ts";
@@ -13,10 +12,10 @@ import {
13
12
  import { loadCatalog } from "../routing/catalog.ts";
14
13
  import type { Catalog, Profile } from "../types.ts";
15
14
  import { type BackendStatus, detectBackends } from "./backends.ts";
16
- import { Matrix } from "./matrix.tsx";
15
+ import { matrixHint, Matrix } from "./matrix.tsx";
17
16
  import { deleteProfile, nameError, saveAndActivate } from "./profiles.ts";
18
- import { face, glyph, type Mood, tint, type Ui } from "./theme.ts";
19
- import { CatSpinner, Header, Screen } from "./ui.tsx";
17
+ import { glyph, tint, type Ui } from "./theme.ts";
18
+ import { CatSpinner, Frame } from "./ui.tsx";
20
19
 
21
20
  export interface EditorDeps {
22
21
  loadCatalog: () => Catalog;
@@ -34,6 +33,9 @@ const editorDeps: EditorDeps = {
34
33
 
35
34
  type Ask = "new" | "copy" | "delete" | "quit" | null;
36
35
 
36
+ const extraHint = (plain: boolean) =>
37
+ `p next profile ${glyph("dot", plain)} n new ${glyph("dot", plain)} c copy ${glyph("dot", plain)} x delete`;
38
+
37
39
  function openBook(): { profiles: Profile[]; idx: number } {
38
40
  const names = listProfiles();
39
41
  const profiles = names.length > 0 ? names.map((n) => loadProfile(n)) : [defaultProfile()];
@@ -60,9 +62,9 @@ export function Editor({ ui, deps = {} }: { ui: Ui; deps?: Partial<EditorDeps> }
60
62
  const [errors, setErrors] = useState<string[]>([]);
61
63
  const [note, setNote] = useState("");
62
64
  const [ask, setAsk] = useState<Ask>(null);
65
+ const [showHelp, setShowHelp] = useState(false);
63
66
  const profile = book.profiles[book.idx] as Profile;
64
67
  const pink = tint("pink", ui.depth);
65
- const dot = ` ${glyph("dot", ui.plain)} `;
66
68
 
67
69
  useEffect(() => {
68
70
  d.detectBackends().then(setBackends, () => setBackends([]));
@@ -150,24 +152,18 @@ export function Editor({ ui, deps = {} }: { ui: Ui; deps?: Partial<EditorDeps> }
150
152
  }
151
153
  });
152
154
 
153
- const mood: Mood =
154
- backends === null
155
- ? "working"
156
- : errors.length > 0
157
- ? "failed"
158
- : dirty.has(profile.name)
159
- ? "waiting"
160
- : "good";
155
+ const sep = ` ${glyph("dot", ui.plain)} `;
156
+ const title = [
157
+ `profile ${profile.name}`,
158
+ `${book.idx + 1}/${book.profiles.length}`,
159
+ profile.name === active ? "active" : null,
160
+ dirty.has(profile.name) ? "unsaved" : null,
161
+ ]
162
+ .filter(Boolean)
163
+ .join(sep);
161
164
 
162
165
  return (
163
- <Screen>
164
- <Header ui={ui} mood={mood} title={`profiles${dot}${profile.name}`} />
165
- <text wrapMode="none" truncate>
166
- {`profile ${profile.name} ${book.idx + 1}/${book.profiles.length}${
167
- profile.name === active ? " (active)" : ""
168
- }${dirty.has(profile.name) ? " (unsaved)" : ""}`}
169
- <span attributes={TextAttributes.DIM}>{`${dot}p next${dot}n new${dot}c copy${dot}x delete`}</span>
170
- </text>
166
+ <Frame ui={ui} title={title} hint={matrixHint(ui, "save", showHelp, extraHint(ui.plain))}>
171
167
  {backends === null ? (
172
168
  <CatSpinner ui={ui} label="sniffing out codex and opencode" />
173
169
  ) : (
@@ -178,6 +174,7 @@ export function Editor({ ui, deps = {} }: { ui: Ui; deps?: Partial<EditorDeps> }
178
174
  catalog={catalog}
179
175
  backends={backends}
180
176
  costs={costs}
177
+ errors={errors}
181
178
  reloadCatalog={d.loadCatalog}
182
179
  onChange={put}
183
180
  onCatalog={setCatalog}
@@ -185,6 +182,7 @@ export function Editor({ ui, deps = {} }: { ui: Ui; deps?: Partial<EditorDeps> }
185
182
  onQuit={() => (dirty.size > 0 ? setAsk("quit") : exit())}
186
183
  onKey={onKey}
187
184
  active={ask === null}
185
+ onToggleHelp={() => setShowHelp((v) => !v)}
188
186
  />
189
187
  )}
190
188
  {ask === "new" || ask === "copy" ? (
@@ -206,16 +204,11 @@ export function Editor({ ui, deps = {} }: { ui: Ui; deps?: Partial<EditorDeps> }
206
204
  {`Unsaved changes in ${[...dirty].join(", ")}. q quits without saving, any other key stays.`}
207
205
  </text>
208
206
  ) : null}
209
- {errors.map((e) => (
210
- <text key={e} fg={pink} wrapMode="none" truncate>
211
- {`${face("failed", ui.plain)} ${e}`}
212
- </text>
213
- ))}
214
207
  {note ? (
215
208
  <text wrapMode="none" truncate>
216
209
  {note}
217
210
  </text>
218
211
  ) : null}
219
- </Screen>
212
+ </Frame>
220
213
  );
221
214
  }
package/src/tui/init.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TextAttributes } from "@opentui/core";
2
- import { useKeyboard, useRenderer } from "@opentui/react";
2
+ import { useKeyboard, usePaste, useRenderer } from "@opentui/react";
3
3
  import { useRef, useState } from "react";
4
4
  import { type HarnessCost, harnessCosts } from "../core/harness.ts";
5
5
  import { listRuns } from "../core/runstore.ts";
@@ -8,10 +8,10 @@ import { loadCatalog } from "../routing/catalog.ts";
8
8
  import { jevKey, saveJevKey, testJevKey } from "../routing/jev.ts";
9
9
  import type { Catalog, Profile } from "../types.ts";
10
10
  import { type BackendStatus, detectBackends } from "./backends.ts";
11
- import { Matrix } from "./matrix.tsx";
11
+ import { matrixHint, Matrix } from "./matrix.tsx";
12
12
  import { saveAndActivate } from "./profiles.ts";
13
- import { face, glyph, type Mood, tint, type Ui } from "./theme.ts";
14
- import { CatSpinner, Header, Intro, Screen } from "./ui.tsx";
13
+ import { dot, glyph, tint, type Ui } from "./theme.ts";
14
+ import { CatSpinner, Frame, Intro } from "./ui.tsx";
15
15
 
16
16
  export interface InitDeps {
17
17
  jevKey: () => string | null;
@@ -37,14 +37,17 @@ export const PLUGIN_STEPS = ["/plugin marketplace add 47vigen/catherd", "/plugin
37
37
 
38
38
  type Phase = "intro" | "key" | "testing" | "backends" | "matrix" | "done";
39
39
 
40
- const TITLES: Record<Phase, string> = {
41
- intro: "",
42
- key: "init 1/3: the Jev key",
43
- testing: "init 1/3: the Jev key",
44
- backends: "init 2/3: your backends",
45
- matrix: "init 3/3: your profile",
46
- done: "ready",
47
- };
40
+ function title(phase: Phase, plain: boolean): string {
41
+ const dotSep = glyph("dot", plain);
42
+ return {
43
+ intro: "",
44
+ key: `Setup 1/3 ${dotSep} Jev key`,
45
+ testing: `Setup 1/3 ${dotSep} Jev key`,
46
+ backends: `Setup 2/3 ${dotSep} Backends`,
47
+ matrix: `Setup 3/3 ${dotSep} Your profile`,
48
+ done: `Setup ${dotSep} ready`,
49
+ }[phase];
50
+ }
48
51
 
49
52
  function BackendLine({ ui, b }: { ui: Ui; b: BackendStatus }) {
50
53
  const ok = b.installed && b.loggedIn;
@@ -52,7 +55,7 @@ function BackendLine({ ui, b }: { ui: Ui; b: BackendStatus }) {
52
55
  const fix = b.fix ? ` ${glyph("dot", ui.plain)} fix: ${b.fix}` : "";
53
56
  return (
54
57
  <text wrapMode="none" truncate attributes={ok ? TextAttributes.NONE : TextAttributes.DIM}>
55
- {`${glyph(ok ? "on" : "off", ui.plain)} ${b.backend.padEnd(9)}${(b.version ?? "").padEnd(9)}${state}${fix}`}
58
+ {`${dot(ok ? "ready" : "missing", ui.plain)} ${b.backend.padEnd(9)}${(b.version ?? "").padEnd(9)}${state}${fix}`}
56
59
  </text>
57
60
  );
58
61
  }
@@ -74,6 +77,7 @@ function MaskedKeyInput({ onSubmit }: { onSubmit: (key: string) => void }) {
74
77
  if (ch && ch.length === 1) setValue((v) => v + ch);
75
78
  }
76
79
  });
80
+ usePaste((e) => setValue((v) => v + new TextDecoder().decode(e.bytes).replace(/\s+/g, "")));
77
81
  return <text>{"*".repeat(value.length)}</text>;
78
82
  }
79
83
 
@@ -88,6 +92,7 @@ export function Init({ ui, deps = {} }: { ui: Ui; deps?: Partial<InitDeps> }) {
88
92
  const [profile, setProfile] = useState<Profile>(defaultProfile);
89
93
  const [backends, setBackends] = useState<BackendStatus[]>([]);
90
94
  const [errors, setErrors] = useState<string[]>([]);
95
+ const [showHelp, setShowHelp] = useState(false);
91
96
  const pink = tint("pink", ui.depth);
92
97
 
93
98
  // Any key finishes, once the "done" message has actually had a chance to sit on screen:
@@ -133,27 +138,13 @@ export function Init({ ui, deps = {} }: { ui: Ui; deps?: Partial<InitDeps> }) {
133
138
  };
134
139
 
135
140
  if (phase === "intro") {
136
- return (
137
- <Screen>
138
- <Intro ui={ui} onDone={afterIntro} />
139
- </Screen>
140
- );
141
+ return <Intro ui={ui} onDone={afterIntro} />;
141
142
  }
142
143
 
143
- const mood: Mood =
144
- phase === "done"
145
- ? "landed"
146
- : errors.length > 0 || note
147
- ? "failed"
148
- : phase === "key"
149
- ? "waiting"
150
- : phase === "matrix"
151
- ? "good"
152
- : "working";
153
-
144
+ const hint =
145
+ phase === "matrix" ? matrixHint(ui, "accept", showHelp) : phase === "done" ? "press any key" : "";
154
146
  return (
155
- <Screen>
156
- <Header ui={ui} mood={mood} title={TITLES[phase]} />
147
+ <Frame ui={ui} title={title(phase, ui.plain)} hint={hint}>
157
148
  {phase === "key" ? (
158
149
  <box style={{ flexDirection: "column" }}>
159
150
  <text>Paste your TypeSafe API key. Jev uses it to pick each lane's model and effort.</text>
@@ -180,6 +171,7 @@ export function Init({ ui, deps = {} }: { ui: Ui; deps?: Partial<InitDeps> }) {
180
171
  catalog={catalog}
181
172
  backends={backends}
182
173
  costs={costs}
174
+ errors={errors}
183
175
  reloadCatalog={d.loadCatalog}
184
176
  onChange={(p) => {
185
177
  setProfile(p);
@@ -191,13 +183,8 @@ export function Init({ ui, deps = {} }: { ui: Ui; deps?: Partial<InitDeps> }) {
191
183
  process.exitCode = 1;
192
184
  renderer.destroy();
193
185
  }}
194
- submitLabel="accept"
186
+ onToggleHelp={() => setShowHelp((v) => !v)}
195
187
  />
196
- {errors.map((e) => (
197
- <text key={e} fg={pink} wrapMode="none" truncate>
198
- {`${face("failed", ui.plain)} ${e}`}
199
- </text>
200
- ))}
201
188
  </box>
202
189
  ) : null}
203
190
  {phase === "done" ? (
@@ -216,6 +203,6 @@ export function Init({ ui, deps = {} }: { ui: Ui; deps?: Partial<InitDeps> }) {
216
203
  {note}
217
204
  </text>
218
205
  ) : null}
219
- </Screen>
206
+ </Frame>
220
207
  );
221
208
  }