catherd-cli 0.2.1 → 1.1.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 (207) hide show
  1. package/README.md +141 -33
  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 +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,332 @@
1
+ import { useRenderer, useTerminalDimensions } from "@opentui/react";
2
+ import { type ReactNode, useEffect, useRef, useState } from "react";
3
+ import { errorMessage } from "../../../domain/errors.ts";
4
+ import {
5
+ applyPatch,
6
+ type Change,
7
+ diffProfiles,
8
+ type ProfileDoc,
9
+ patchBetween,
10
+ resolveProfile,
11
+ } from "../../../domain/profile.ts";
12
+ import type { Validation } from "../../../domain/profile-rules.ts";
13
+ import type { Effects } from "../effects.ts";
14
+ import { useApp } from "../providers/app.tsx";
15
+ import { useData, useLoad } from "../providers/data.tsx";
16
+ import { useCommandLayer } from "../providers/keymap.tsx";
17
+ import { useUi } from "../providers/theme.tsx";
18
+ import { withStaged } from "../profile-tree.ts";
19
+ import { type Dialog as DialogState, type Draft, isSaving } from "../state.ts";
20
+ import { wrap } from "../text.ts";
21
+ import { glyph } from "../theme.ts";
22
+ import { Buttons } from "../widgets/dialog-confirm.tsx";
23
+ import { Dialog, dialogRows } from "../widgets/dialog.tsx";
24
+ import { Line, type Part } from "../widgets/line.tsx";
25
+
26
+ type Save = Extract<DialogState, { kind: "save" }>;
27
+
28
+ /** Said when a Save found the file changed since the dialog showed what saving would do. */
29
+ const CHANGED_ON_DISK = "the profile changed on disk — check the changes and choose again";
30
+
31
+ const show = (v: unknown): string =>
32
+ v === null || v === undefined ? "none" : Array.isArray(v) ? v.join(", ") : String(v);
33
+
34
+ /** What saving a draft would do: the changed fields, staged treat-likes, issues, and agent changes. */
35
+ export interface SavePreview {
36
+ changes: Change[];
37
+ treatLikes: [string, string][];
38
+ validation: Validation;
39
+ agentsAdded: string[];
40
+ agentsRemoved: string[];
41
+ }
42
+
43
+ /**
44
+ * The save is a patch over the file as it is when it is written (Ruling 7), so the preview applies the
45
+ * draft's patch to the file as it is now, not to the copy the draft began from.
46
+ */
47
+ function previewSave(
48
+ d: Draft,
49
+ fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile">,
50
+ now: ProfileDoc = fx.readProfile(d.name),
51
+ ): SavePreview {
52
+ const before = resolveProfile(now, d.name);
53
+ const after = resolveProfile(applyPatch(now, patchBetween(d.base, d.doc)), d.name);
54
+ const { catalog } = fx.catalog(after.billing);
55
+ const a = new Set(fx.agents(before));
56
+ const b = new Set(fx.agents(after));
57
+ return {
58
+ changes: diffProfiles(before, after),
59
+ treatLikes: Object.entries(d.treatLikes),
60
+ validation: fx.validate(after, withStaged(catalog, d.treatLikes)),
61
+ agentsAdded: [...b].filter((x) => !a.has(x)),
62
+ agentsRemoved: [...a].filter((x) => !b.has(x)),
63
+ };
64
+ }
65
+
66
+ /** A block of the save dialog: heading lines, then items of one or more lines each. */
67
+ interface Section {
68
+ head: Part[][];
69
+ items: Part[][][];
70
+ /**
71
+ * How long the section holds on when rows run short (spec §9.2, Ruling 8):
72
+ * "filler" (changes, warnings, agent files) loses items first, then goes whole;
73
+ * "note" (what applies when) goes whole, never in part;
74
+ * "error" (validation errors) loses items only after both, and keeps its first;
75
+ * "failure" (why the last save failed) is never cut.
76
+ */
77
+ hold: "filler" | "note" | "error" | "failure";
78
+ /** the line that ends a cut section; "… and N more" when unset */
79
+ more?: (n: number) => string;
80
+ }
81
+
82
+ const HOLD = { filler: 0, note: 1, error: 2, failure: 3 } as const;
83
+
84
+ /**
85
+ * The sections' lines in at most `rows` lines. Empty sections are left out. Over budget, in turn:
86
+ * the longest filler section loses its last item (a cut section ends with one muted "… and N more"
87
+ * line); filler and note sections go whole, the least-held first; error sections lose items down
88
+ * to their first; last, lines come off the end of every section but the failure, then the failure.
89
+ */
90
+ export function fitSections(sections: Section[], rows: number): Part[][] {
91
+ const shown = sections.filter((s) => s.items.length > 0);
92
+ const keep = shown.map((s) => s.items.length);
93
+ const gone = shown.map(() => false);
94
+ const size = (i: number) => {
95
+ if (gone[i]) return 0;
96
+ const s = shown[i]!;
97
+ const body = s.items.slice(0, keep[i]).reduce((n, it) => n + it.length, 0);
98
+ return s.head.length + body + (keep[i]! < s.items.length ? 1 : 0);
99
+ };
100
+ const total = () => shown.reduce((n, _, i) => n + size(i), 0);
101
+ /** the largest live section `ok` accepts, or -1 */
102
+ const largest = (ok: (s: Section, i: number) => boolean) => {
103
+ let at = -1;
104
+ for (const [i, s] of shown.entries()) if (!gone[i] && ok(s, i) && (at < 0 || size(i) > size(at))) at = i;
105
+ return at;
106
+ };
107
+ for (let at; total() > rows && (at = largest((s, i) => s.hold === "filler" && keep[i]! > 0)) >= 0;)
108
+ keep[at]! -= 1;
109
+ for (const hold of ["filler", "note"] as const)
110
+ for (let at; total() > rows && (at = largest((s) => s.hold === hold)) >= 0;) gone[at] = true;
111
+ for (let at; total() > rows && (at = largest((s, i) => s.hold === "error" && keep[i]! > 1)) >= 0;)
112
+ keep[at]! -= 1;
113
+ const lines = shown.map((s, i) => {
114
+ if (gone[i]) return [];
115
+ const more = s.items.length - keep[i]!;
116
+ const tail = s.more?.(more) ?? `… and ${more} more`;
117
+ return [
118
+ ...s.head,
119
+ ...s.items.slice(0, keep[i]).flat(),
120
+ ...(more > 0 ? [[{ text: tail, tone: "muted" as const }]] : []),
121
+ ];
122
+ });
123
+ // still over: the blank lines between sections go, then lines come off the end of the least-held
124
+ // sections, the failure last
125
+ let over = lines.reduce((n, l) => n + l.length, 0) - Math.max(0, rows);
126
+ const order = shown.map((_, i) => i).sort((x, y) => HOLD[shown[x]!.hold] - HOLD[shown[y]!.hold]);
127
+ for (const i of order)
128
+ while (over > 0 && lines[i]![0]?.length === 0 && shown[i]!.head.length > 0 && !gone[i]) {
129
+ lines[i]!.shift();
130
+ over -= 1;
131
+ }
132
+ for (const i of order) {
133
+ const off = Math.min(over, lines[i]!.length);
134
+ lines[i]!.splice(lines[i]!.length - off, off);
135
+ over -= off;
136
+ }
137
+ return lines.flat();
138
+ }
139
+
140
+ /**
141
+ * Spec §9.2: ctrl+s opens this, never enter. It shows the diff, the agent-file changes and what applies
142
+ * when, with Save, Save & make active and Cancel; errors leave only Cancel.
143
+ */
144
+ export function SaveDialog(props: { dialog: Save }) {
145
+ const app = useApp();
146
+ const data = useData();
147
+ const ui = useUi();
148
+ const dims = useTerminalDimensions();
149
+ const renderer = useRenderer();
150
+ const name = props.dialog.purpose.name;
151
+ const draft = app.state.drafts[name];
152
+ // the preview reads the catalog and agent files: off the render path, so a read that throws is shown
153
+ // here as the reason nothing can be saved, not as a broken screen
154
+ const key = draft ? JSON.stringify([draft.doc, draft.treatLikes]) : "";
155
+ const loaded = useLoad(() => (draft ? previewSave(draft, app.effects) : null), key);
156
+ // a Save re-reads the file: when that changed what saving would do, the dialog shows the new preview
157
+ // in place of the loaded one and asks again (another process may have written the profile meanwhile)
158
+ const [fresh, setFresh] = useState<{ key: string; value: SavePreview | null; error: string | null } | null>(
159
+ null,
160
+ );
161
+ const shown = fresh?.key === key ? fresh : loaded;
162
+ const preview = shown.value;
163
+ const error = shown.error;
164
+ // what the dialog shows now, for keys in one burst
165
+ const shownRef = useRef<SavePreview | null>(preview);
166
+ shownRef.current = preview;
167
+ // set when a Save replaces the preview, cleared by the first frame drawn to the terminal after the
168
+ // commit that holds the new one: a Save between the two (a later key in the same burst) would answer a
169
+ // preview nobody has seen. React may commit between two keys of one burst, so a commit is not enough.
170
+ const unseenRef = useRef(false);
171
+ useEffect(() => {
172
+ if (!unseenRef.current) return;
173
+ const seen = () => {
174
+ unseenRef.current = false;
175
+ };
176
+ renderer.once("frame", seen);
177
+ renderer.requestRender();
178
+ return () => {
179
+ renderer.off("frame", seen);
180
+ };
181
+ }, [fresh, renderer]);
182
+ const [changed, setChanged] = useState(false);
183
+ /** reads the file again; a preview other than the shown one replaces it and asks again (false) */
184
+ const recheck = (): { same: boolean; now: ProfileDoc | null } => {
185
+ const d = app.getState().drafts[name];
186
+ let doc: ProfileDoc | null = null;
187
+ let now: SavePreview | null = null;
188
+ let failed: string | null = null;
189
+ try {
190
+ doc = d ? app.effects.readProfile(name) : null;
191
+ now = d && doc ? previewSave(d, app.effects, doc) : null;
192
+ } catch (e) {
193
+ failed = errorMessage(e);
194
+ }
195
+ if (failed === null && JSON.stringify(now) === JSON.stringify(shownRef.current))
196
+ return { same: true, now: doc };
197
+ shownRef.current = now;
198
+ unseenRef.current = true;
199
+ setFresh({ key, value: now, error: failed });
200
+ setChanged(true);
201
+ return { same: false, now: doc };
202
+ };
203
+ const blocked = error !== null || (preview?.validation.errors.length ?? 0) > 0;
204
+ // spec §9.2's order: Save / Save & make active / Cancel; errors leave only Cancel
205
+ const labels = blocked ? ["Cancel"] : ["Save", "Save & make active", "Cancel"];
206
+ const [focused, setFocusedState] = useState(0);
207
+ // keys in one burst read the focus as it is now, not as it was drawn
208
+ const focusedRef = useRef(0);
209
+ const setFocused = (f: number) => {
210
+ focusedRef.current = f;
211
+ setFocusedState(f);
212
+ };
213
+ useCommandLayer("dialog", {
214
+ "dialog.left": () => setFocused(Math.max(0, focusedRef.current - 1)),
215
+ "dialog.right": () => setFocused(Math.min(labels.length - 1, focusedRef.current + 1)),
216
+ "dialog.submit": () => {
217
+ // a save that is writing takes no answer
218
+ if (isSaving(app.getState())) return;
219
+ const label = labels[Math.min(focusedRef.current, labels.length - 1)];
220
+ // nothing is saved before its diff has been shown
221
+ if (label !== "Cancel" && (!shownRef.current || unseenRef.current)) return;
222
+ if (label === "Cancel") return app.dispatch({ type: "close" });
223
+ // only the save the dialog shows is made: the file is read again now, and a different preview
224
+ // replaces the shown one and asks again instead of saving
225
+ const { same, now } = recheck();
226
+ if (!same || !now) return;
227
+ // the save writes only over the file this preview read: one changed while it writes (another
228
+ // process, while a treat-like waits for the catalog lock) is refused, and the dialog asks again
229
+ const extra = {
230
+ shown: now,
231
+ // the notice shows even when the change is one this preview does not show (another field)
232
+ changed: () => {
233
+ if (recheck().same) setChanged(true);
234
+ },
235
+ };
236
+ app.answer(label === "Save" ? "save" : "activate", extra);
237
+ },
238
+ });
239
+ const rows = (inner: number): ReactNode[] => {
240
+ const out: ReactNode[] = [];
241
+ const line = (key: string, parts: Part[]) => out.push(<Line key={key} width={inner} parts={parts} />);
242
+ if (error !== null) {
243
+ wrap(`Cannot show what saving would do: ${error}`, inner).forEach((t, i) =>
244
+ line(`fail${i}`, [{ text: t, tone: "error" }]),
245
+ );
246
+ line("gap", []);
247
+ out.push(<Buttons key="buttons" labels={labels} focused={0} width={inner} />);
248
+ return out;
249
+ }
250
+ if (!preview) {
251
+ line("gone", [{ text: draft ? "reading…" : "Nothing to save.", tone: "muted" }]);
252
+ return out;
253
+ }
254
+ const repo = data.profiles.value?.repo;
255
+ const text = (t: string, tone: Part["tone"]): Part[][] => wrap(t, inner).map((l) => [{ text: l, tone }]);
256
+ const sections: Section[] = [
257
+ {
258
+ head: [],
259
+ hold: "filler",
260
+ items: [
261
+ ...preview.changes.map((c): Part[][] => [
262
+ [
263
+ { text: `${c.path} ` },
264
+ { text: show(c.before), tone: "diffDel" },
265
+ { text: ` ${glyph("arrow", ui.plain)} ` },
266
+ { text: show(c.after), tone: "diffAdd" },
267
+ ],
268
+ ]),
269
+ ...preview.treatLikes.map(([rung, like]): Part[][] => [
270
+ [{ text: `treat ${rung} like ` }, { text: like, tone: "diffAdd" }],
271
+ ]),
272
+ ],
273
+ },
274
+ {
275
+ head: [[]],
276
+ hold: "error",
277
+ more: (n) => `… and ${n} more ${n === 1 ? "error" : "errors"}`,
278
+ items: preview.validation.errors.map((e) =>
279
+ text(`${glyph("fail", ui.plain)} ${e.path}: ${e.message}`, "error"),
280
+ ),
281
+ },
282
+ {
283
+ // warnings follow the errors in one block
284
+ head: preview.validation.errors.length ? [] : [[]],
285
+ hold: "filler",
286
+ more: (n) => `… and ${n} more ${n === 1 ? "warning" : "warnings"}`,
287
+ items: preview.validation.warnings.map((w) =>
288
+ text(`${glyph("warn", ui.plain)} ${w.path}: ${w.message}`, "warning"),
289
+ ),
290
+ },
291
+ {
292
+ head: [[], [{ text: "Agent files", bold: true }]],
293
+ hold: "filler",
294
+ items: [
295
+ ...preview.agentsAdded.map((a): Part[][] => [[{ text: `+ ${a}`, tone: "diffAdd" }]]),
296
+ ...preview.agentsRemoved.map((a): Part[][] => [[{ text: `- ${a}`, tone: "diffDel" }]]),
297
+ ],
298
+ },
299
+ {
300
+ head: [[]],
301
+ hold: "note",
302
+ items: [
303
+ text(
304
+ "Applies to: native Claude agents in new Claude Code sessions; codex, claude-code and opencode from the next dispatch.",
305
+ "muted",
306
+ ),
307
+ // inside a bound repo, making a profile active binds the repo to it (the button keeps §9.2's label)
308
+ ...(repo ? [text(`Save & make active binds ${repo} to ${name}.`, "muted")] : []),
309
+ ],
310
+ },
311
+ { head: [[]], hold: "failure", items: props.dialog.error ? [text(props.dialog.error, "error")] : [] },
312
+ ];
313
+ // the gap and the buttons always fit: every section shares what is left of the panel
314
+ const notice =
315
+ changed && !props.dialog.saving ? wrap(`${glyph("warn", ui.plain)} ${CHANGED_ON_DISK}`, inner) : [];
316
+ fitSections(sections, dialogRows(dims.height) - 2 - notice.length).forEach((p, i) => line(`r${i}`, p));
317
+ line("gap5", []);
318
+ notice.forEach((t, i) => line(`changed${i}`, [{ text: t, tone: "warning" }]));
319
+ if (props.dialog.saving) line("saving", [{ text: "saving…", tone: "muted" }]);
320
+ else
321
+ out.push(
322
+ <Buttons
323
+ key="buttons"
324
+ labels={labels}
325
+ focused={Math.min(focused, labels.length - 1)}
326
+ width={inner}
327
+ />,
328
+ );
329
+ return out;
330
+ };
331
+ return <Dialog title={`Save profile ${name}`} size="large" rows={rows} />;
332
+ }
@@ -0,0 +1,209 @@
1
+ import type { Check } from "../../../services/doctor-checks.ts";
2
+ import { useApp, useNow } from "../providers/app.tsx";
3
+ import { useData } from "../providers/data.tsx";
4
+ import { useCommandLayer } from "../providers/keymap.tsx";
5
+ import { useUi } from "../providers/theme.tsx";
6
+ import { ago, plural, wrap } from "../text.ts";
7
+ import { glyph, STATE_TOKEN } from "../theme.ts";
8
+ import { Line, type Part } from "../widgets/line.tsx";
9
+ import { List, type ListItem, useSelected } from "../widgets/list.tsx";
10
+ import { hereWord, type RunRow } from "../effects.ts";
11
+ import { showProfile } from "./profile-actions.ts";
12
+ import { staleLines } from "./profiles.tsx";
13
+
14
+ /** `✓ ready` / `! not logged in` / `✗ missing`: state is always glyph and word (spec §9.3). */
15
+ function stateParts(state: Check["state"], word: string, plain: boolean): Part[] {
16
+ return [{ text: `${glyph(state, plain)} ${word}`.padEnd(16), tone: STATE_TOKEN[state] }];
17
+ }
18
+
19
+ /** A run in one line: its state word first, then title, repo and progress (research C4). */
20
+ export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
21
+ const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
22
+ if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
23
+ return [
24
+ r.live
25
+ ? { text: `${glyph("live", plain)} live `, tone: "info" }
26
+ : { text: `${glyph("dot", plain)} idle `, tone: "muted" },
27
+ { text: `${r.title} `, bold: true },
28
+ { text: `${r.repo} `, tone: "muted" },
29
+ { text: `${r.live ? `${r.live} live · ` : ""}${bits.join(" · ")} `, tone: "muted" },
30
+ { text: ago(now - Date.parse(r.createdAt)), tone: "muted" },
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * Spec §9.1 tab 1: each check as `glyph word — detail` with its full fix command (y copies it), the
36
+ * active profile, and the last five runs. enter opens the profile or a run.
37
+ */
38
+ export function StatusView(props: { width: number; height: number }) {
39
+ const app = useApp();
40
+ const data = useData();
41
+ const ui = useUi();
42
+ const now = useNow(1_000);
43
+ const { selected, select: setSelected, current: selectedNow } = useSelected();
44
+ const checks = data.report?.checks ?? [];
45
+ const profiles = data.profiles.value ?? { names: [], active: "…", here: "…", repo: null };
46
+ const runs = (data.runs.value?.rows ?? []).slice(0, 5);
47
+ const items: ListItem[] = [];
48
+ const heading = (key: string, text: string, right = "") =>
49
+ items.push({
50
+ key,
51
+ selectable: false,
52
+ render: (_s, w) => (
53
+ <Line
54
+ width={w}
55
+ parts={[
56
+ { text, bold: true },
57
+ { text: " ".repeat(Math.max(1, w - text.length - right.length - 1)) },
58
+ { text: right, tone: "muted" },
59
+ ]}
60
+ />
61
+ ),
62
+ });
63
+ const checked = data.checking
64
+ ? "checking…"
65
+ : data.checkedAt !== null
66
+ ? `checked ${ago(now - data.checkedAt)}`
67
+ : "";
68
+ heading("h:setup", " SETUP", checked);
69
+ if (data.checkError)
70
+ items.push({
71
+ key: "error",
72
+ selectable: false,
73
+ render: (_s, w) => (
74
+ <Line
75
+ width={w}
76
+ parts={[{ text: ` ${glyph("fail", ui.plain)} ${data.checkError}`, tone: "error" }]}
77
+ />
78
+ ),
79
+ });
80
+ for (const c of checks) {
81
+ // the detail wraps under the label instead of being cut: it is often a path (spec §9.3)
82
+ const said = wrap(`${c.label}${c.detail ? ` — ${c.detail}` : ""}`, Math.max(20, props.width - 19));
83
+ const first = said[0] ?? "";
84
+ const label = first.startsWith(c.label) ? c.label : "";
85
+ items.push({
86
+ key: `check:${c.id}`,
87
+ selectable: true,
88
+ render: (sel, w) => (
89
+ <Line
90
+ width={w}
91
+ selected={sel}
92
+ parts={[
93
+ { text: " " },
94
+ ...stateParts(c.state, c.word, ui.plain),
95
+ { text: label, bold: true },
96
+ { text: first.slice(label.length), tone: "muted" },
97
+ ]}
98
+ />
99
+ ),
100
+ });
101
+ said.slice(1).forEach((line, i) =>
102
+ items.push({
103
+ key: `detail:${c.id}:${i}`,
104
+ selectable: false,
105
+ render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(19)}${line}`, tone: "muted" }]} />,
106
+ }),
107
+ );
108
+ if (c.fix)
109
+ wrap(`fix: ${c.fix}`, Math.max(20, props.width - 22)).forEach((line, i) =>
110
+ items.push({
111
+ key: `fix:${c.id}:${i}`,
112
+ selectable: false,
113
+ render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(20)}${line}`, tone: "muted" }]} />,
114
+ }),
115
+ );
116
+ }
117
+ heading("h:profile", " PROFILE");
118
+ // until the first check is in, nothing is selectable, so the cursor starts on the first check
119
+ const ready = data.report !== null || data.checkError !== null;
120
+ items.push({
121
+ key: "profile",
122
+ selectable: ready,
123
+ render: (sel, w) => (
124
+ <Line
125
+ width={w}
126
+ selected={sel}
127
+ parts={[
128
+ { text: ` ${profiles.here}`, bold: true },
129
+ {
130
+ // "…" until the first read: never "0 profiles" on the first frame (spec 1.1 §13)
131
+ text: ` ${hereWord(profiles)} · ${data.profiles.value ? plural(profiles.names.length, "profile") : "…"}`,
132
+ tone: "muted",
133
+ },
134
+ ]}
135
+ />
136
+ ),
137
+ });
138
+ // a later read that failed: the row above is the last good read, not the profile now
139
+ for (const [i, p] of staleLines(data, ui.plain, { fix: false }).entries())
140
+ items.push({
141
+ key: `profile:stale:${i}`,
142
+ selectable: false,
143
+ render: (_s, w) => <Line width={w} parts={[{ ...p, text: ` ${p.text}` }]} />,
144
+ });
145
+ heading("h:runs", " RECENT RUNS");
146
+ if (runs.length === 0)
147
+ items.push({
148
+ key: "runs:none",
149
+ selectable: false,
150
+ render: (_s, w) => <Line width={w} parts={[{ text: " no runs yet", tone: "muted" }]} />,
151
+ });
152
+ for (const r of runs)
153
+ items.push({
154
+ key: `run:${r.id}`,
155
+ selectable: ready,
156
+ render: (sel, w) => (
157
+ <Line width={w} selected={sel} parts={[{ text: " " }, ...runParts(r, now, ui.plain)]} />
158
+ ),
159
+ });
160
+
161
+ const checkAt = (key: string | null) => checks.find((c) => key === `check:${c.id}`);
162
+ useCommandLayer("tab.status", {
163
+ "status.recheck": () => data.recheck(),
164
+ "status.copy": () => {
165
+ const selectedCheck = checkAt(selectedNow());
166
+ if (!selectedCheck?.fix) return app.toast({ variant: "info", message: "This row has no fix command" });
167
+ const ok = app.copy(selectedCheck.fix);
168
+ app.toast(
169
+ ok
170
+ ? { variant: "success", message: "Copied the fix command" }
171
+ : { variant: "warning", message: "This terminal cannot copy; the command is shown in full" },
172
+ );
173
+ },
174
+ });
175
+ useCommandLayer("row.status", {
176
+ "status.open": () => {
177
+ const selected = selectedNow();
178
+ const selectedCheck = checkAt(selected);
179
+ // the PROFILE row is what this directory runs on; doctor's `profile` row is the global active one,
180
+ // and its `profile:<name>` rows are the repo-bound ones
181
+ const name =
182
+ selected === "profile"
183
+ ? profiles.here
184
+ : selectedCheck?.id === "profile"
185
+ ? profiles.active
186
+ : selectedCheck?.id.startsWith("profile:")
187
+ ? selectedCheck.id.slice("profile:".length)
188
+ : null;
189
+ if (name !== null) {
190
+ showProfile(app, name);
191
+ } else if (selected?.startsWith("run:")) {
192
+ // spec §4: a run opens in its session's screen, which shows it with the session's other runs
193
+ const row = runs.find((r) => `run:${r.id}` === selected);
194
+ app.dispatch({ type: "tab", tab: "runs" });
195
+ if (row) app.dispatch({ type: "session", key: row.session });
196
+ }
197
+ },
198
+ });
199
+ return (
200
+ <List
201
+ items={items}
202
+ selected={selected}
203
+ onSelect={setSelected}
204
+ width={props.width}
205
+ height={props.height}
206
+ empty="checking…"
207
+ />
208
+ );
209
+ }
@@ -0,0 +1,114 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { usePendingSequence } from "@opentui/keymap/react";
3
+ import { type CommandId, formatKeys, LEADER } from "../commands.ts";
4
+ import { MODE, reachableCommands, useKeybinds, useKeymap, useKeymapVersion } from "../providers/keymap.tsx";
5
+ import { useTone, useUi } from "../providers/theme.tsx";
6
+ import { FACES, type Mood } from "../theme.ts";
7
+ import { TABS, type Tab } from "../state.ts";
8
+ import { width as widthOf } from "../text.ts";
9
+ import { Line, type Part } from "./line.tsx";
10
+
11
+ const TAB_TITLE: Record<Tab, string> = { status: "Status", profiles: "Profiles", runs: "Runs" };
12
+
13
+ /** Spec §9.3 header: one line with the version, the profile, the unsaved count and the mood face. */
14
+ export function Header(props: {
15
+ version: string;
16
+ profile: string | null;
17
+ active: boolean;
18
+ /** the word after the profile: "active", or "this repo" in a bound repo */
19
+ label?: string;
20
+ dirty: number;
21
+ mood: Mood;
22
+ width: number;
23
+ }) {
24
+ const pending = usePendingSequence();
25
+ const left: Part[] = [{ text: ` catherd ${props.version}`, bold: true }];
26
+ if (props.profile) {
27
+ left.push({ text: ` profile ${props.profile}` });
28
+ if (props.active) left.push({ text: ` (${props.label ?? "active"})`, tone: "muted" });
29
+ }
30
+ if (props.dirty > 0) left.push({ text: ` · ${props.dirty} unsaved`, tone: "warning" });
31
+ const right: Part[] = [];
32
+ if (pending.length)
33
+ right.push({
34
+ text: `${pending.map((p) => (p.tokenName === "leader" ? LEADER : p.display)).join(" ")} … `,
35
+ tone: "accent",
36
+ });
37
+ right.push({ text: `${FACES[props.mood]} `, tone: props.mood === "failed" ? "error" : "muted" });
38
+ const used = [...left, ...right].reduce((n, p) => n + widthOf(p.text), 0);
39
+ const gap = Math.max(1, props.width - used);
40
+ return <Line width={props.width} parts={[...left, { text: " ".repeat(gap) }, ...right]} />;
41
+ }
42
+
43
+ /** The tab strip: ` 1 Status 2 Profiles 3 Runs`, the active one bold in the accent. */
44
+ export function Tabs(props: { tab: Tab; width: number }) {
45
+ const ui = useUi();
46
+ const tone = useTone();
47
+ return (
48
+ <box flexDirection="row" width={props.width} height={1}>
49
+ {TABS.map((t, i) => {
50
+ const on = t === props.tab;
51
+ const text = ` ${i + 1} ${TAB_TITLE[t]} `;
52
+ return (
53
+ <text
54
+ key={t}
55
+ wrapMode="none"
56
+ fg={on ? tone(ui.color ? "accent" : "text") : tone("muted")}
57
+ attributes={on ? TextAttributes.BOLD | (ui.color ? 0 : TextAttributes.INVERSE) : 0}
58
+ >
59
+ {i === 0 ? text : ` ${text}`}
60
+ </text>
61
+ );
62
+ })}
63
+ </box>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Spec §9.2: footer hints are generated from the commands a key would reach now, in their `hint` order,
69
+ * as `key label` (the key as the user bound it). Hints drop from the end to fit, but the palette and
70
+ * help hints stay.
71
+ */
72
+ export function Footer(props: { width: number }) {
73
+ useKeymapVersion();
74
+ const keymap = useKeymap();
75
+ const keys = useKeybinds();
76
+ const ui = useUi();
77
+ const modal = keymap.getData(MODE) === "modal";
78
+ const hints: { id: string; text: string }[] = [];
79
+ let last: { order: number; label: string; keys: string[] } | null = null;
80
+ for (const c of reachableCommands(keymap)
81
+ .filter((c) => c.hint !== null && keys[c.id].length > 0 && (c.id !== "app.back" || modal))
82
+ .sort((a, b) => (a.hint as number) - (b.hint as number))) {
83
+ const key = formatKeys(keys[c.id as CommandId].slice(0, 1), ui.plain);
84
+ const label = c.short ?? c.title;
85
+ // commands with the same order and label read as one hint: `↑↓ move`
86
+ if (last && last.order === c.hint && last.label === label) {
87
+ last.keys.push(key);
88
+ const joined = last.keys.join(ui.plain ? "/" : "");
89
+ (hints.at(-1) as { text: string }).text = `${joined} ${label}`;
90
+ continue;
91
+ }
92
+ last = { order: c.hint as number, label, keys: [key] };
93
+ hints.push({ id: c.id, text: `${key} ${label}` });
94
+ }
95
+ const fixed = hints.filter((h) => h.id === "app.palette" || h.id === "app.help");
96
+ const rest = hints.filter((h) => !fixed.includes(h));
97
+ const sep = " ";
98
+ const room = props.width - 1 - fixed.reduce((n, h) => n + widthOf(h.text) + sep.length, 0);
99
+ const kept: string[] = [];
100
+ let used = 0;
101
+ for (const h of rest) {
102
+ if (used + widthOf(h.text) + sep.length > room) break;
103
+ kept.push(h.text);
104
+ used += widthOf(h.text) + sep.length;
105
+ }
106
+ const all = [...kept, ...fixed.map((h) => h.text)];
107
+ const parts: Part[] = [{ text: " " }];
108
+ all.forEach((t, i) => {
109
+ const at = t.indexOf(" ");
110
+ parts.push({ text: t.slice(0, at), bold: true }, { text: t.slice(at), tone: "muted" });
111
+ if (i < all.length - 1) parts.push({ text: sep });
112
+ });
113
+ return <Line width={props.width} parts={parts} />;
114
+ }