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.
@@ -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
- export type Row =
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
- | { kind: "notify"; moment: NotifyMoment };
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 `${r.kind}:${r.role}:${r.backend}`;
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
- export function rows(p: Profile, c: Catalog, open: ReadonlySet<string>, filter = ""): Row[] {
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: Row[] = [];
75
- for (const role of ROLES) {
76
- out.push({ kind: "role", role });
77
- if (!f && !open.has(`role:${role}`)) continue;
78
- const models = c.models.filter((m) => capableFor(role, m) && m.id.toLowerCase().includes(f));
79
- for (const backend of BACKENDS) {
80
- const group = models.filter((m) => m.backend === backend);
81
- if (group.length === 0) continue;
82
- out.push({ kind: "backend", role, backend });
83
- if (backend !== "claude") out.push({ kind: "harness", role, backend });
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
- // These open their own editor/picker in the Matrix component instead of toggling.
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
  }