catherd-cli 0.1.0 → 0.2.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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/skills/catherd/SKILL.md +1 -1
- package/src/profile/agents.ts +12 -0
- package/src/profile/role-prompts.ts +1 -1
- package/src/routing/jev.ts +9 -5
- package/src/tui/commands.ts +5 -3
- package/src/tui/dashboard.tsx +245 -0
- package/src/tui/editor.tsx +20 -27
- package/src/tui/init.tsx +25 -38
- package/src/tui/matrix-model.ts +102 -34
- package/src/tui/matrix.tsx +288 -126
- package/src/tui/theme.ts +41 -4
- package/src/tui/ui.tsx +187 -24
- package/src/tui/watch.tsx +84 -106
package/package.json
CHANGED
package/plugin/.mcp.json
CHANGED
|
@@ -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 `
|
|
226
|
+
- "Run the full suite only if this brief says so", and "Wrap any full build or full test suite in `bunx catherd-cli 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;
|
package/src/profile/agents.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -66,7 +66,7 @@ const worker = [
|
|
|
66
66
|
"",
|
|
67
67
|
`Read your lane file first. ${RUN_FILES} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
|
|
68
68
|
"",
|
|
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:
|
|
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: bunx catherd-cli lock -- <command>. Other lanes share this machine.",
|
|
70
70
|
"",
|
|
71
71
|
REPLY,
|
|
72
72
|
].join("\n");
|
package/src/routing/jev.ts
CHANGED
|
@@ -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
|
-
|
|
99
|
-
} catch {
|
|
100
|
-
|
|
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 {
|
package/src/tui/commands.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
+
}
|
package/src/tui/editor.tsx
CHANGED
|
@@ -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 {
|
|
19
|
-
import { CatSpinner,
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 {
|
|
14
|
-
import { CatSpinner,
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
{`${
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
</
|
|
206
|
+
</Frame>
|
|
220
207
|
);
|
|
221
208
|
}
|