catherd-cli 0.2.0 → 1.0.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.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -25
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -69
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,440 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { type Profile, resolveProfile } from "../../../domain/profile.ts";
3
+ import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
4
+ import { hereWord } from "../effects.ts";
5
+ import { useApp, useDialogHandler } from "../providers/app.tsx";
6
+ import { type Data, useData, useLoad } from "../providers/data.tsx";
7
+ import { useCommandLayer } from "../providers/keymap.tsx";
8
+ import { errorToast } from "../providers/toast.tsx";
9
+ import { useUi } from "../providers/theme.tsx";
10
+ import {
11
+ failoverOptions,
12
+ numberPatch,
13
+ numberValue,
14
+ parseNumber,
15
+ patchFor,
16
+ startOptions,
17
+ treatLikeOptions,
18
+ } from "../profile-edits.ts";
19
+ import { buildRows, filterRows, firstMatch, type Row, type RowAction, withStaged } from "../profile-tree.ts";
20
+ import { currentDraft, dirtyCount } from "../state.ts";
21
+ import { wrap } from "../text.ts";
22
+ import { glyph, STATE_TOKEN } from "../theme.ts";
23
+ import { Line, type Part } from "../widgets/line.tsx";
24
+ import { List, type ListItem, useSelected } from "../widgets/list.tsx";
25
+ import { openActivate, openNewProfile, openRevert, openSave, showProfile } from "./profile-actions.ts";
26
+
27
+ /** What a row does, for the line under the tree when it has no issue. */
28
+ const ABOUT: Partial<Record<RowAction["type"], string>> = {
29
+ role: "space turns the role on or off; enter opens its access, default rung and models",
30
+ access:
31
+ "enter cycles read-only → workspace-write → full; enforced: the backend stops a write, advisory: it is only asked",
32
+ start: "enter picks where a lane starts without a kind and difficulty",
33
+ model: "enter opens its efforts; space never changes a rung",
34
+ rung: "space ticks this rung onto the role's ladder; routing orders the ladder by cost",
35
+ objective: "enter switches between cost and speed",
36
+ jev: "enter turns Jev on (auto: when a key exists) or off",
37
+ isolated: "space runs the backend with catherd's own config instead of yours",
38
+ number: "enter edits the value",
39
+ failover: "enter picks the stand-in on a usage limit; it runs on a fresh thread",
40
+ notify: "space turns this notification on or off",
41
+ };
42
+
43
+ /**
44
+ * The profiles poll failed after a good read (its error is newer than the value it keeps): one error line
45
+ * and, when the failure says, its fix; none once a read succeeds again.
46
+ */
47
+ export function staleLines(data: Data, plain: boolean, o: { fix?: boolean } = {}): Part[] {
48
+ const p = data.profiles;
49
+ if (p.error === null || p.value === null) return [];
50
+ const said: Part[] = [
51
+ {
52
+ text: ` ${glyph("fail", plain)} could not read the profiles again: ${p.error} · showing the last good read`,
53
+ tone: "error",
54
+ },
55
+ ];
56
+ if (p.fix && o.fix !== false) said.push({ text: ` fix: ${p.fix}`, tone: "muted" });
57
+ return said;
58
+ }
59
+
60
+ /** Only ticking an unscored effort asks for a treat-like; unticking a rung always just unticks it. */
61
+ const needsTreatLike = (p: Profile, a: RowAction): boolean =>
62
+ a.type === "rung" && !a.scored && !p.roles[a.role].rungs.includes(a.rung);
63
+
64
+ /**
65
+ * Spec §9.1 tab 2: the profile's line, then one tree (roles → access + backend → model → efforts, then
66
+ * routing, harness, budget, failover, timeouts, notify), then a line about the selected row. Every edit
67
+ * is staged in the draft; nothing is written before ctrl+s.
68
+ */
69
+ export function ProfilesView(props: { width: number; height: number }) {
70
+ const app = useApp();
71
+ const data = useData();
72
+ const ui = useUi();
73
+ const { selected, select: setSelected, current: selectedNow } = useSelected();
74
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["role:worker"]));
75
+ const [filter, setFilter] = useState<string | null>(null);
76
+ const draft = currentDraft(app.state);
77
+ const here = data.profiles.value?.here ?? null;
78
+ // the profile the tab opens on; a read of it that fails is shown in the tab (below), and read again
79
+ // on r and after every good poll of the profiles
80
+ const [openFailed, setOpenFailed] = useState<{ name: string; error: string; fix: string | null } | null>(
81
+ null,
82
+ );
83
+ const [openTries, setOpenTries] = useState(0);
84
+ const polledAt = data.profiles.at;
85
+ useEffect(() => {
86
+ if (app.state.profile || !here) return;
87
+ const e = showProfile(app, here, { quiet: true });
88
+ setOpenFailed(
89
+ e === null
90
+ ? null
91
+ : {
92
+ name: here,
93
+ error: errorMessage(e),
94
+ fix: isCatherdError(e) && e.fix ? e.fix : null,
95
+ },
96
+ );
97
+ }, [app.state.profile, here, app, polledAt, openTries]);
98
+ const profile = useMemo(() => (draft ? resolveProfile(draft.doc, draft.name) : null), [draft]);
99
+ const billingKey = JSON.stringify(profile?.billing ?? {});
100
+ // read again after every save too, by the identity of the base it reads back (the same bytes when only
101
+ // treat-likes changed): a save writes the staged treat-likes to the catalog override and clears them
102
+ // from the draft, so the catalog read before it would lack them
103
+ const reads = useRef(0);
104
+ const savedKey = useMemo(() => ++reads.current, [draft?.base]);
105
+ const loaded = useLoad(
106
+ () => (profile ? app.effects.catalog(profile.billing) : null),
107
+ `${billingKey}|${savedKey}`,
108
+ );
109
+ const catalog = useMemo(
110
+ () => (loaded.value && draft ? withStaged(loaded.value.catalog, draft.treatLikes) : null),
111
+ [loaded.value, draft],
112
+ );
113
+ const validation = useMemo(
114
+ () => (profile && catalog ? app.effects.validate(profile, catalog) : { errors: [], warnings: [] }),
115
+ [profile, catalog, app.effects],
116
+ );
117
+ const rows: Row[] = useMemo(() => {
118
+ if (!profile || !catalog || !loaded.value || !draft) return [];
119
+ const all = buildRows({
120
+ profile,
121
+ models: loaded.value.models,
122
+ catalog,
123
+ staged: draft.treatLikes,
124
+ expanded,
125
+ harnesses: app.effects.harnesses,
126
+ enforcement: app.effects.enforcement,
127
+ validation,
128
+ expandAll: Boolean(filter),
129
+ });
130
+ return filter ? filterRows(all, filter) : all;
131
+ }, [profile, catalog, loaded.value, draft, expanded, filter, validation, app.effects]);
132
+ // a read that failed is shown with its fix and a retry, not as one still pending: the profiles (no
133
+ // draft can open without them; the 2 s poll also reads them again) or, once a profile shows, the catalog
134
+ const failure =
135
+ !draft && data.profiles.error !== null
136
+ ? {
137
+ what: "the profiles",
138
+ error: data.profiles.error,
139
+ fix: data.profiles.fix,
140
+ retry: data.profiles.refresh,
141
+ polled: true,
142
+ }
143
+ : !draft && openFailed
144
+ ? {
145
+ what: `the profile ${openFailed.name}`,
146
+ error: openFailed.error,
147
+ fix: openFailed.fix,
148
+ retry: () => setOpenTries((n) => n + 1),
149
+ polled: true,
150
+ }
151
+ : profile && loaded.error !== null && !loaded.value
152
+ ? {
153
+ what: "the catalog",
154
+ error: loaded.error,
155
+ fix: loaded.fix,
156
+ retry: loaded.refresh,
157
+ polled: false,
158
+ }
159
+ : null;
160
+ // a later read that failed over a kept good one: said under the header until a read succeeds, like the
161
+ // Runs tab over its kept rows; the draft stays open and editable
162
+ const stale = draft ? staleLines(data, ui.plain) : [];
163
+ const row = rows.find((r) => r.key === selected) ?? null;
164
+ /** the row the cursor is on now, which a key earlier in the same tick may have moved */
165
+ const rowNow = () => rows.find((r) => r.key === selectedNow()) ?? null;
166
+ // typing a filter puts the cursor on the first row that matches it; an edit under a kept filter
167
+ // (new rows, same text) leaves the cursor where it is
168
+ const placedFor = useRef<string | null>(null);
169
+ useEffect(() => {
170
+ if (!filter) {
171
+ placedFor.current = null;
172
+ return;
173
+ }
174
+ if (filter === placedFor.current) return;
175
+ const m = firstMatch(rows, filter);
176
+ if (!m) return;
177
+ placedFor.current = filter;
178
+ setSelected(m.key);
179
+ }, [filter, rows]);
180
+ /** the draft as it is now: two keys in one tick must not both edit the profile this render drew */
181
+ const current = () => {
182
+ const d = currentDraft(app.getState());
183
+ return d ? resolveProfile(d.doc, d.name) : null;
184
+ };
185
+ const edit = (patch: ReturnType<typeof patchFor>) => patch && app.dispatch({ type: "edit", patch });
186
+ const toggleOpen = (r: Row, open?: boolean) =>
187
+ setExpanded((e) => {
188
+ const next = new Set(e);
189
+ if (open ?? !next.has(r.key)) next.add(r.key);
190
+ else next.delete(r.key);
191
+ return next;
192
+ });
193
+ const pick = (r: Row) => {
194
+ if (!profile || !loaded.value || !catalog) return;
195
+ const a = r.action;
196
+ if (a.type === "start")
197
+ app.dispatch({
198
+ type: "open",
199
+ dialog: {
200
+ kind: "select",
201
+ purpose: { type: "start", role: a.role },
202
+ title: `Default rung for ${a.role}`,
203
+ options: startOptions(profile, a.role),
204
+ empty: "no rungs",
205
+ },
206
+ });
207
+ else if (a.type === "failover")
208
+ app.dispatch({
209
+ type: "open",
210
+ dialog: {
211
+ kind: "select",
212
+ purpose: { type: "failover", rung: a.rung },
213
+ title: `Stand-in for ${a.rung}`,
214
+ options: failoverOptions(profile, loaded.value.models, catalog, a.rung),
215
+ empty: "no scored rung on another quota",
216
+ },
217
+ });
218
+ else if (a.type === "rung" && !a.scored)
219
+ app.dispatch({
220
+ type: "open",
221
+ dialog: {
222
+ kind: "select",
223
+ purpose: { type: "treatLike", rung: a.rung, role: a.role },
224
+ title: `Treat ${a.rung} like…`,
225
+ options: treatLikeOptions(catalog),
226
+ empty: "no scored rung",
227
+ },
228
+ });
229
+ else if (a.type === "number") {
230
+ const v = numberValue(profile, a.path);
231
+ app.dispatch({
232
+ type: "open",
233
+ dialog: {
234
+ kind: "prompt",
235
+ purpose: { type: "number", path: a.path },
236
+ title: `Set ${a.path}`,
237
+ label: a.path.startsWith("budget.") ? "a number above 0; empty for no cap" : "minutes, above 0",
238
+ value: v === undefined ? "" : String(v),
239
+ error: null,
240
+ },
241
+ });
242
+ }
243
+ };
244
+ const primary = (r: Row | null) => {
245
+ const p = current();
246
+ if (!r || !p) return;
247
+ const a = r.action;
248
+ if (a.type === "role" || a.type === "model") return toggleOpen(r);
249
+ if (a.type === "start" || a.type === "failover" || a.type === "number" || needsTreatLike(p, a))
250
+ return pick(r);
251
+ edit(patchFor(p, a));
252
+ };
253
+ const toggle = (r: Row | null) => {
254
+ const p = current();
255
+ if (!r || !p) return;
256
+ const a = r.action;
257
+ if (a.type === "model") return toggleOpen(r);
258
+ if (needsTreatLike(p, a)) return pick(r);
259
+ if (a.type === "role" || a.type === "rung" || a.type === "isolated" || a.type === "notify")
260
+ edit(patchFor(p, a));
261
+ };
262
+ useCommandLayer("tab.profiles", {
263
+ "profile.save": () => openSave(app),
264
+ "profile.activate": () => openActivate(app, data),
265
+ "profile.copy": () => draft && openNewProfile(app, draft.name),
266
+ "profile.revert": () => openRevert(app),
267
+ "catalog.refresh": () => {
268
+ // after a failed read, r reads it again (and only that)
269
+ if (failure) return failure.retry();
270
+ void app.effects.refreshCatalog().then(
271
+ () => {
272
+ loaded.refresh();
273
+ app.toast({ variant: "success", message: "Catalog refreshed" });
274
+ },
275
+ (e: unknown) => app.toast(errorToast(e)),
276
+ );
277
+ },
278
+ "edit.undo": () => app.dispatch({ type: "undo" }),
279
+ "edit.redo": () => app.dispatch({ type: "redo" }),
280
+ });
281
+ useCommandLayer("row.profiles", {
282
+ "tree.toggle": () => toggle(rowNow()),
283
+ "tree.open": () => primary(rowNow()),
284
+ "tree.expand": () => {
285
+ const row = rowNow();
286
+ if (row?.expandable) toggleOpen(row, true);
287
+ },
288
+ "tree.collapse": () => {
289
+ const row = rowNow();
290
+ if (!row) return;
291
+ if (row.expandable && row.expanded) return toggleOpen(row, false);
292
+ let p = rows.find((r) => r.key === row.parent);
293
+ while (p && !p.selectable) p = rows.find((r) => r.key === p?.parent);
294
+ if (p) setSelected(p.key);
295
+ },
296
+ });
297
+ useDialogHandler("start", (p, value) => {
298
+ app.dispatch({ type: "close" });
299
+ if (p.type === "start") edit({ roles: { [p.role]: { defaultRung: value || null } } });
300
+ });
301
+ useDialogHandler("failover", (p, value) => {
302
+ app.dispatch({ type: "close" });
303
+ if (p.type === "failover") edit({ failover: { [p.rung]: value || null } });
304
+ });
305
+ useDialogHandler("treatLike", (p, value) => {
306
+ app.dispatch({ type: "close" });
307
+ const prof = current();
308
+ if (p.type !== "treatLike" || !prof) return;
309
+ // one undo step: the treat-like and the tick it brings
310
+ const tick =
311
+ p.role && !prof.roles[p.role].rungs.includes(p.rung)
312
+ ? patchFor(prof, { type: "rung", role: p.role, rung: p.rung, scored: true })
313
+ : null;
314
+ app.dispatch({ type: "treatLike", rung: p.rung, like: value, ...(tick ? { patch: tick } : {}) });
315
+ });
316
+ useDialogHandler("number", (p, value) => {
317
+ if (p.type !== "number") return;
318
+ const r = parseNumber(p.path, value);
319
+ if ("error" in r) return app.dispatch({ type: "invalid", error: r.error });
320
+ app.dispatch({ type: "close" });
321
+ edit(numberPatch(p.path, r.value));
322
+ });
323
+
324
+ const unsaved = draft ? dirtyCount(draft) : 0;
325
+ const top: Part[] = draft
326
+ ? [
327
+ { text: " PROFILE ", bold: true },
328
+ { text: draft.name, bold: true },
329
+ {
330
+ text: draft.name === here ? ` (${hereWord(data.profiles.value ?? { repo: null })})` : "",
331
+ tone: "muted",
332
+ },
333
+ { text: unsaved ? ` · ${unsaved} unsaved` : "", tone: "warning" },
334
+ { text: ` ${glyph("dot", ui.plain)} ctrl+x l switch · ctrl+x n new`, tone: "muted" },
335
+ ]
336
+ : failure
337
+ ? [{ text: " PROFILE", bold: true }]
338
+ : [{ text: " reading the profile…", tone: "muted" }];
339
+ const about = row?.issue
340
+ ? [
341
+ {
342
+ text: ` ${glyph(row.issue.level === "error" ? "fail" : "warn", ui.plain)} ${row.issue.message}`,
343
+ tone: STATE_TOKEN[row.issue.level === "error" ? "fail" : "warn"],
344
+ },
345
+ ...(row.issue.fix ? [{ text: ` fix: ${row.issue.fix}`, tone: "muted" as const }] : []),
346
+ ]
347
+ : [{ text: ` ${(row && ABOUT[row.action.type]) ?? ""}`, tone: "muted" as const }];
348
+ const aboutLines = about
349
+ .flatMap((p) => wrap(p.text, props.width).map((text) => ({ ...p, text })))
350
+ .slice(0, 2);
351
+ // the value column of a group of rows starts after its longest label, so long model names line up
352
+ const widest = new Map<string | null, number>();
353
+ for (const r of rows)
354
+ if (r.selectable) widest.set(r.parent, Math.max(widest.get(r.parent) ?? 0, Bun.stringWidth(r.label)));
355
+ const items: ListItem[] = rows.map((r) => ({
356
+ key: r.key,
357
+ selectable: r.selectable,
358
+ render: (sel, w) => {
359
+ const indent = " ".repeat(r.depth);
360
+ const marker = r.expandable ? `${glyph(r.expanded ? "open" : "shut", ui.plain)} ` : " ";
361
+ const check = r.check === null ? "" : `${glyph(r.check ? "on" : "off", ui.plain)} `;
362
+ const issue = r.issue ? ` ${glyph(r.issue.level === "error" ? "fail" : "warn", ui.plain)}` : "";
363
+ if (!r.selectable)
364
+ return (
365
+ <Line
366
+ width={w}
367
+ parts={[
368
+ {
369
+ text: ` ${indent}${r.label}`,
370
+ bold: r.depth === 0,
371
+ tone: r.depth === 0 ? undefined : "muted",
372
+ },
373
+ { text: r.value ? ` ${r.value}` : "", tone: "muted" },
374
+ ]}
375
+ />
376
+ );
377
+ const labelWidth = Math.max(14, 30 - r.depth * 2, (widest.get(r.parent) ?? 0) + 2);
378
+ return (
379
+ <Line
380
+ width={w}
381
+ selected={sel}
382
+ dim={r.dim}
383
+ parts={[
384
+ { text: ` ${indent}${marker}${check}` },
385
+ {
386
+ text: r.label.length + 2 > labelWidth ? `${r.label} ` : r.label.padEnd(labelWidth),
387
+ bold: r.depth === 1,
388
+ },
389
+ { text: r.value, tone: "muted" },
390
+ { text: issue, tone: r.issue?.level === "error" ? "error" : "warning" },
391
+ ]}
392
+ />
393
+ );
394
+ },
395
+ }));
396
+ return (
397
+ <box flexDirection="column" width={props.width} height={props.height}>
398
+ <Line width={props.width} parts={top} />
399
+ {stale.map((p, i) => (
400
+ <Line key={`stale${i}`} width={props.width} parts={[p]} />
401
+ ))}
402
+ {failure ? (
403
+ <box flexDirection="column" width={props.width} height={props.height - 3}>
404
+ {[
405
+ ...wrap(
406
+ ` ${glyph("fail", ui.plain)} could not read ${failure.what}: ${failure.error}`,
407
+ props.width,
408
+ ).map((text): Part => ({ text, tone: "error" })),
409
+ ...(failure.fix ? wrap(` fix: ${failure.fix}`, props.width) : []).map((text): Part => ({
410
+ text,
411
+ tone: "muted",
412
+ })),
413
+ {
414
+ text: ` r retry${failure.polled ? " · also read again every 2 s" : ""}`,
415
+ tone: "muted",
416
+ } as Part,
417
+ ]
418
+ .slice(0, Math.max(0, props.height - 3))
419
+ .map((p, i) => (
420
+ <Line key={i} width={props.width} parts={[p]} />
421
+ ))}
422
+ </box>
423
+ ) : (
424
+ <List
425
+ items={items}
426
+ selected={selected}
427
+ onSelect={setSelected}
428
+ width={props.width}
429
+ height={props.height - 3 - stale.length}
430
+ filter={filter}
431
+ onFilter={setFilter}
432
+ empty="reading the catalog…"
433
+ />
434
+ )}
435
+ {[0, 1].map((i) => (
436
+ <Line key={i} width={props.width} parts={aboutLines[i] ? [aboutLines[i]] : []} />
437
+ ))}
438
+ </box>
439
+ );
440
+ }