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/src/tui/matrix-model.ts
CHANGED
|
@@ -16,21 +16,51 @@ import type { BackendStatus } from "./backends.ts";
|
|
|
16
16
|
|
|
17
17
|
export const NOTIFY: NotifyMoment[] = ["milestone", "finish", "blocked"];
|
|
18
18
|
const BACKENDS: Backend[] = ["claude", "codex", "opencode"];
|
|
19
|
+
const HARNESS_BACKENDS: Array<"codex" | "opencode"> = ["codex", "opencode"];
|
|
19
20
|
|
|
20
21
|
export type BudgetField = "minutes" | "tokens" | "usd";
|
|
21
22
|
export const BUDGET_FIELDS: BudgetField[] = ["minutes", "tokens", "usd"];
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/** The left pane's rows (ROLES/ROUTING/HARNESS/BUDGET/FAILOVER/NOTIFY): a fixed skeleton,
|
|
25
|
+
* independent of what is open or filtered. Everything else ("detail" rows) belongs to the
|
|
26
|
+
* right pane, and only exists for whichever top row is currently selected. */
|
|
27
|
+
export type TopRow =
|
|
24
28
|
| { kind: "role"; role: Role }
|
|
29
|
+
| { kind: "objective" }
|
|
30
|
+
| { kind: "lock" }
|
|
31
|
+
| { kind: "harness"; backend: "codex" | "opencode" }
|
|
32
|
+
| { kind: "budget-summary" }
|
|
33
|
+
| { kind: "failover-summary" }
|
|
34
|
+
| { kind: "notify"; moment: NotifyMoment };
|
|
35
|
+
|
|
36
|
+
export type DetailRow =
|
|
25
37
|
| { kind: "backend"; role: Role; backend: Backend }
|
|
26
|
-
| { kind: "harness"; role: Role; backend: "codex" | "opencode" }
|
|
27
38
|
| { kind: "model"; role: Role; model: CatalogModel }
|
|
28
39
|
| { kind: "effort"; role: Role; model: CatalogModel; effort: string }
|
|
29
|
-
| { kind: "objective" }
|
|
30
|
-
| { kind: "lock" }
|
|
31
40
|
| { kind: "budget"; field: BudgetField }
|
|
32
|
-
| { kind: "failover"; rung: RungId }
|
|
33
|
-
|
|
41
|
+
| { kind: "failover"; rung: RungId };
|
|
42
|
+
|
|
43
|
+
export type Row = TopRow | DetailRow;
|
|
44
|
+
|
|
45
|
+
export type Section = "ROLES" | "ROUTING" | "HARNESS" | "BUDGET" | "FAILOVER" | "NOTIFY";
|
|
46
|
+
|
|
47
|
+
export function sectionOf(r: TopRow): Section {
|
|
48
|
+
switch (r.kind) {
|
|
49
|
+
case "role":
|
|
50
|
+
return "ROLES";
|
|
51
|
+
case "objective":
|
|
52
|
+
case "lock":
|
|
53
|
+
return "ROUTING";
|
|
54
|
+
case "harness":
|
|
55
|
+
return "HARNESS";
|
|
56
|
+
case "budget-summary":
|
|
57
|
+
return "BUDGET";
|
|
58
|
+
case "failover-summary":
|
|
59
|
+
return "FAILOVER";
|
|
60
|
+
case "notify":
|
|
61
|
+
return "NOTIFY";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
34
64
|
|
|
35
65
|
export type Toggled = { profile: Profile } | { needsTreatLike: RungId } | { refused: string };
|
|
36
66
|
|
|
@@ -39,8 +69,9 @@ export function rowId(r: Row): string {
|
|
|
39
69
|
case "role":
|
|
40
70
|
return `role:${r.role}`;
|
|
41
71
|
case "backend":
|
|
72
|
+
return `backend:${r.role}:${r.backend}`;
|
|
42
73
|
case "harness":
|
|
43
|
-
return
|
|
74
|
+
return `harness:${r.backend}`;
|
|
44
75
|
case "model":
|
|
45
76
|
return `model:${r.role}:${r.model.id}`;
|
|
46
77
|
case "effort":
|
|
@@ -56,6 +87,8 @@ export function rowId(r: Row): string {
|
|
|
56
87
|
}
|
|
57
88
|
}
|
|
58
89
|
|
|
90
|
+
/** The `open` key a role/model row expands under `space`/`→`. Only these two kinds fold —
|
|
91
|
+
* budget and failover always show their whole detail once selected, and harness has none. */
|
|
59
92
|
export function openKey(r: Row): string | null {
|
|
60
93
|
if (r.kind === "role") return `role:${r.role}`;
|
|
61
94
|
if (r.kind === "model" || r.kind === "effort") return `model:${r.role}:${r.model.id}`;
|
|
@@ -69,35 +102,52 @@ export function readySet(backends: BackendStatus[]): Set<Backend> {
|
|
|
69
102
|
]);
|
|
70
103
|
}
|
|
71
104
|
|
|
72
|
-
|
|
105
|
+
/** The left pane: always these 17 rows, in this order, regardless of `open` or a filter. */
|
|
106
|
+
export function topLevelRows(): TopRow[] {
|
|
107
|
+
return [
|
|
108
|
+
...ROLES.map((role): TopRow => ({ kind: "role", role })),
|
|
109
|
+
{ kind: "objective" },
|
|
110
|
+
{ kind: "lock" },
|
|
111
|
+
...HARNESS_BACKENDS.map((backend): TopRow => ({ kind: "harness", backend })),
|
|
112
|
+
{ kind: "budget-summary" },
|
|
113
|
+
{ kind: "failover-summary" },
|
|
114
|
+
...NOTIFY.map((moment): TopRow => ({ kind: "notify", moment })),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The right pane for a selected role: its capable models grouped by backend, open to their
|
|
119
|
+
* effort ladder once expanded — the harness toggle no longer lives here, it is its own top row. */
|
|
120
|
+
export function roleDetailRows(
|
|
121
|
+
p: Profile,
|
|
122
|
+
c: Catalog,
|
|
123
|
+
role: Role,
|
|
124
|
+
open: ReadonlySet<string>,
|
|
125
|
+
filter = "",
|
|
126
|
+
): DetailRow[] {
|
|
73
127
|
const f = filter.toLowerCase();
|
|
74
|
-
const out:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
for (const model of group) {
|
|
85
|
-
out.push({ kind: "model", role, model });
|
|
86
|
-
if (!open.has(`model:${role}:${model.id}`)) continue;
|
|
87
|
-
for (const effort of model.efforts) out.push({ kind: "effort", role, model, effort });
|
|
88
|
-
}
|
|
128
|
+
const out: DetailRow[] = [];
|
|
129
|
+
const models = c.models.filter((m) => capableFor(role, m) && m.id.toLowerCase().includes(f));
|
|
130
|
+
for (const backend of BACKENDS) {
|
|
131
|
+
const group = models.filter((m) => m.backend === backend);
|
|
132
|
+
if (group.length === 0) continue;
|
|
133
|
+
out.push({ kind: "backend", role, backend });
|
|
134
|
+
for (const model of group) {
|
|
135
|
+
out.push({ kind: "model", role, model });
|
|
136
|
+
if (!open.has(`model:${role}:${model.id}`)) continue;
|
|
137
|
+
for (const effort of model.efforts) out.push({ kind: "effort", role, model, effort });
|
|
89
138
|
}
|
|
90
139
|
}
|
|
91
|
-
out.push(
|
|
92
|
-
{ kind: "objective" },
|
|
93
|
-
{ kind: "lock" },
|
|
94
|
-
...BUDGET_FIELDS.map((field): Row => ({ kind: "budget", field })),
|
|
95
|
-
...enabledFailoverRungs(p, c).map((rung): Row => ({ kind: "failover", rung })),
|
|
96
|
-
...NOTIFY.map((moment): Row => ({ kind: "notify", moment })),
|
|
97
|
-
);
|
|
98
140
|
return out;
|
|
99
141
|
}
|
|
100
142
|
|
|
143
|
+
export function budgetDetailRows(): DetailRow[] {
|
|
144
|
+
return BUDGET_FIELDS.map((field) => ({ kind: "budget", field }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function failoverDetailRows(p: Profile, c: Catalog): DetailRow[] {
|
|
148
|
+
return enabledFailoverRungs(p, c).map((rung) => ({ kind: "failover", rung }));
|
|
149
|
+
}
|
|
150
|
+
|
|
101
151
|
/** spec §11b: every codex/opencode rung ticked by a currently-enabled role gets a failover row. */
|
|
102
152
|
export function enabledFailoverRungs(p: Profile, c: Catalog): RungId[] {
|
|
103
153
|
const set = new Set<RungId>();
|
|
@@ -124,6 +174,25 @@ export function failoverOptions(c: Catalog, rung: RungId): RungId[] {
|
|
|
124
174
|
.sort();
|
|
125
175
|
}
|
|
126
176
|
|
|
177
|
+
/** "no cap" or e.g. "30 min · $5" — the BUDGET row's one-line left-pane summary. `sep` is the
|
|
178
|
+
* caller's glyph("dot", ui.plain) — this module stays presentation-neutral, so it takes the
|
|
179
|
+
* separator rather than importing theme.ts's plain/unicode split itself. */
|
|
180
|
+
export function budgetSummary(p: Profile, sep = " · "): string {
|
|
181
|
+
const b = p.budget;
|
|
182
|
+
const parts = [
|
|
183
|
+
b?.minutes !== undefined ? `${b.minutes} min` : null,
|
|
184
|
+
b?.tokens !== undefined ? `${Math.round(b.tokens / 1000)}k tok` : null,
|
|
185
|
+
b?.usd !== undefined ? `$${b.usd}` : null,
|
|
186
|
+
].filter((x): x is string => x !== null);
|
|
187
|
+
return parts.length > 0 ? parts.join(sep) : "no cap";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** "none" or "N set" — the FAILOVER row's one-line left-pane summary. */
|
|
191
|
+
export function failoverSummary(p: Profile): string {
|
|
192
|
+
const n = Object.keys(p.failover ?? {}).length;
|
|
193
|
+
return n === 0 ? "none" : `${n} set`;
|
|
194
|
+
}
|
|
195
|
+
|
|
127
196
|
export function isTicked(p: Profile, r: Row): boolean {
|
|
128
197
|
switch (r.kind) {
|
|
129
198
|
case "role":
|
|
@@ -134,9 +203,6 @@ export function isTicked(p: Profile, r: Row): boolean {
|
|
|
134
203
|
return (p.roles[r.role].models[r.model.id]?.length ?? 0) > 0;
|
|
135
204
|
case "effort":
|
|
136
205
|
return p.roles[r.role].models[r.model.id]?.includes(r.effort) ?? false;
|
|
137
|
-
case "budget":
|
|
138
|
-
case "failover":
|
|
139
|
-
return false;
|
|
140
206
|
case "notify":
|
|
141
207
|
return p.notify.includes(r.moment);
|
|
142
208
|
default:
|
|
@@ -205,7 +271,9 @@ export function toggle(p: Profile, c: Catalog, r: Row, ready: ReadonlySet<Backen
|
|
|
205
271
|
return { profile: p };
|
|
206
272
|
case "budget":
|
|
207
273
|
case "failover":
|
|
208
|
-
|
|
274
|
+
case "budget-summary":
|
|
275
|
+
case "failover-summary":
|
|
276
|
+
// budget/failover open their own editor/picker in the Matrix component instead of toggling.
|
|
209
277
|
return { profile: p };
|
|
210
278
|
}
|
|
211
279
|
}
|