catherd-cli 1.5.0 → 2.0.0-beta.20261022113309

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 (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -1,344 +0,0 @@
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 { repairs, 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
- /** spec 1.2 §6.2: the save leaves errors but removes one of the stored profile's and adds none */
40
- repair: boolean;
41
- agentsAdded: string[];
42
- agentsRemoved: string[];
43
- }
44
-
45
- /**
46
- * The save is a patch over the file as it is when it is written (Ruling 7), so the preview applies the
47
- * draft's patch to the file as it is now, not to the copy the draft began from.
48
- */
49
- function previewSave(
50
- d: Draft,
51
- fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile" | "profiles">,
52
- now: ProfileDoc = fx.readProfile(d.name),
53
- ): SavePreview {
54
- const before = resolveProfile(now, d.name, d.host);
55
- const after = resolveProfile(applyPatch(now, patchBetween(d.base, d.doc)), d.name, d.host);
56
- const { catalog } = fx.catalog(after.billing);
57
- const a = new Set(fx.agents(before));
58
- const b = new Set(fx.agents(after));
59
- const staged = withStaged(catalog, d.treatLikes);
60
- const validation = fx.validate(after, staged);
61
- return {
62
- changes: diffProfiles(before, after),
63
- treatLikes: Object.entries(d.treatLikes),
64
- validation,
65
- // as the ProfileService rules: a profile with no stored file has no errors a save could repair (1.2 minor)
66
- repair:
67
- validation.errors.length > 0 &&
68
- fx.profiles().names.includes(d.name) &&
69
- repairs(fx.validate(before, staged), validation),
70
- agentsAdded: [...b].filter((x) => !a.has(x)),
71
- agentsRemoved: [...a].filter((x) => !b.has(x)),
72
- };
73
- }
74
-
75
- /** A block of the save dialog: heading lines, then items of one or more lines each. */
76
- interface Section {
77
- head: Part[][];
78
- items: Part[][][];
79
- /**
80
- * How long the section holds on when rows run short (spec §9.2, Ruling 8):
81
- * "filler" (changes, warnings, agent files) loses items first, then goes whole;
82
- * "note" (what applies when) goes whole, never in part;
83
- * "error" (validation errors) loses items only after both, and keeps its first;
84
- * "failure" (why the last save failed) is never cut.
85
- */
86
- hold: "filler" | "note" | "error" | "failure";
87
- /** the line that ends a cut section; "… and N more" when unset */
88
- more?: (n: number) => string;
89
- }
90
-
91
- const HOLD = { filler: 0, note: 1, error: 2, failure: 3 } as const;
92
-
93
- /**
94
- * The sections' lines in at most `rows` lines. Empty sections are left out. Over budget, in turn:
95
- * the longest filler section loses its last item (a cut section ends with one muted "… and N more"
96
- * line); filler and note sections go whole, the least-held first; error sections lose items down
97
- * to their first; last, lines come off the end of every section but the failure, then the failure.
98
- */
99
- export function fitSections(sections: Section[], rows: number): Part[][] {
100
- const shown = sections.filter((s) => s.items.length > 0);
101
- const keep = shown.map((s) => s.items.length);
102
- const gone = shown.map(() => false);
103
- const size = (i: number) => {
104
- if (gone[i]) return 0;
105
- const s = shown[i]!;
106
- const body = s.items.slice(0, keep[i]).reduce((n, it) => n + it.length, 0);
107
- return s.head.length + body + (keep[i]! < s.items.length ? 1 : 0);
108
- };
109
- const total = () => shown.reduce((n, _, i) => n + size(i), 0);
110
- /** the largest live section `ok` accepts, or -1 */
111
- const largest = (ok: (s: Section, i: number) => boolean) => {
112
- let at = -1;
113
- for (const [i, s] of shown.entries()) if (!gone[i] && ok(s, i) && (at < 0 || size(i) > size(at))) at = i;
114
- return at;
115
- };
116
- for (let at; total() > rows && (at = largest((s, i) => s.hold === "filler" && keep[i]! > 0)) >= 0;)
117
- keep[at]! -= 1;
118
- for (const hold of ["filler", "note"] as const)
119
- for (let at; total() > rows && (at = largest((s) => s.hold === hold)) >= 0;) gone[at] = true;
120
- for (let at; total() > rows && (at = largest((s, i) => s.hold === "error" && keep[i]! > 1)) >= 0;)
121
- keep[at]! -= 1;
122
- const lines = shown.map((s, i) => {
123
- if (gone[i]) return [];
124
- const more = s.items.length - keep[i]!;
125
- const tail = s.more?.(more) ?? `… and ${more} more`;
126
- return [
127
- ...s.head,
128
- ...s.items.slice(0, keep[i]).flat(),
129
- ...(more > 0 ? [[{ text: tail, tone: "muted" as const }]] : []),
130
- ];
131
- });
132
- // still over: the blank lines between sections go, then lines come off the end of the least-held
133
- // sections, the failure last
134
- let over = lines.reduce((n, l) => n + l.length, 0) - Math.max(0, rows);
135
- const order = shown.map((_, i) => i).sort((x, y) => HOLD[shown[x]!.hold] - HOLD[shown[y]!.hold]);
136
- for (const i of order)
137
- while (over > 0 && lines[i]![0]?.length === 0 && shown[i]!.head.length > 0 && !gone[i]) {
138
- lines[i]!.shift();
139
- over -= 1;
140
- }
141
- for (const i of order) {
142
- const off = Math.min(over, lines[i]!.length);
143
- lines[i]!.splice(lines[i]!.length - off, off);
144
- over -= off;
145
- }
146
- return lines.flat();
147
- }
148
-
149
- /**
150
- * Spec §9.2: ctrl+s opens this, never enter. It shows the diff, the agent-file changes and what applies
151
- * when, with Save, Save & make active and Cancel; errors leave only Cancel.
152
- */
153
- export function SaveDialog(props: { dialog: Save }) {
154
- const app = useApp();
155
- const data = useData();
156
- const ui = useUi();
157
- const dims = useTerminalDimensions();
158
- const renderer = useRenderer();
159
- const name = props.dialog.purpose.name;
160
- const draft = app.state.drafts[name];
161
- // the preview reads the catalog and agent files: off the render path, so a read that throws is shown
162
- // here as the reason nothing can be saved, not as a broken screen
163
- const key = draft ? JSON.stringify([draft.doc, draft.treatLikes]) : "";
164
- const loaded = useLoad(() => (draft ? previewSave(draft, app.effects) : null), key);
165
- // a Save re-reads the file: when that changed what saving would do, the dialog shows the new preview
166
- // in place of the loaded one and asks again (another process may have written the profile meanwhile)
167
- const [fresh, setFresh] = useState<{ key: string; value: SavePreview | null; error: string | null } | null>(
168
- null,
169
- );
170
- const shown = fresh?.key === key ? fresh : loaded;
171
- const preview = shown.value;
172
- const error = shown.error;
173
- // what the dialog shows now, for keys in one burst
174
- const shownRef = useRef<SavePreview | null>(preview);
175
- shownRef.current = preview;
176
- // set when a Save replaces the preview, cleared by the first frame drawn to the terminal after the
177
- // commit that holds the new one: a Save between the two (a later key in the same burst) would answer a
178
- // preview nobody has seen. React may commit between two keys of one burst, so a commit is not enough.
179
- const unseenRef = useRef(false);
180
- useEffect(() => {
181
- if (!unseenRef.current) return;
182
- const seen = () => {
183
- unseenRef.current = false;
184
- };
185
- renderer.once("frame", seen);
186
- renderer.requestRender();
187
- return () => {
188
- renderer.off("frame", seen);
189
- };
190
- }, [fresh, renderer]);
191
- const [changed, setChanged] = useState(false);
192
- /** reads the file again; a preview other than the shown one replaces it and asks again (false) */
193
- const recheck = (): { same: boolean; now: ProfileDoc | null } => {
194
- const d = app.getState().drafts[name];
195
- let doc: ProfileDoc | null = null;
196
- let now: SavePreview | null = null;
197
- let failed: string | null = null;
198
- try {
199
- doc = d ? app.effects.readProfile(name) : null;
200
- now = d && doc ? previewSave(d, app.effects, doc) : null;
201
- } catch (e) {
202
- failed = errorMessage(e);
203
- }
204
- if (failed === null && JSON.stringify(now) === JSON.stringify(shownRef.current))
205
- return { same: true, now: doc };
206
- shownRef.current = now;
207
- unseenRef.current = true;
208
- setFresh({ key, value: now, error: failed });
209
- setChanged(true);
210
- return { same: false, now: doc };
211
- };
212
- // spec 1.2 §6.2: a save that repairs part of an invalid profile is offered; any other error leaves Cancel
213
- const blocked = error !== null || ((preview?.validation.errors.length ?? 0) > 0 && !preview?.repair);
214
- // spec §9.2's order: Save / Save & make active / Cancel; errors leave only Cancel
215
- const labels = blocked ? ["Cancel"] : ["Save", "Save & make active", "Cancel"];
216
- const [focused, setFocusedState] = useState(0);
217
- // keys in one burst read the focus as it is now, not as it was drawn
218
- const focusedRef = useRef(0);
219
- const setFocused = (f: number) => {
220
- focusedRef.current = f;
221
- setFocusedState(f);
222
- };
223
- useCommandLayer("dialog", {
224
- "dialog.left": () => setFocused(Math.max(0, focusedRef.current - 1)),
225
- "dialog.right": () => setFocused(Math.min(labels.length - 1, focusedRef.current + 1)),
226
- "dialog.submit": () => {
227
- // a save that is writing takes no answer
228
- if (isSaving(app.getState())) return;
229
- const label = labels[Math.min(focusedRef.current, labels.length - 1)];
230
- // nothing is saved before its diff has been shown
231
- if (label !== "Cancel" && (!shownRef.current || unseenRef.current)) return;
232
- if (label === "Cancel") return app.dispatch({ type: "close" });
233
- // only the save the dialog shows is made: the file is read again now, and a different preview
234
- // replaces the shown one and asks again instead of saving
235
- const { same, now } = recheck();
236
- if (!same || !now) return;
237
- // the save writes only over the file this preview read: one changed while it writes (another
238
- // process, while a treat-like waits for the catalog lock) is refused, and the dialog asks again
239
- const extra = {
240
- shown: now,
241
- // the notice shows even when the change is one this preview does not show (another field)
242
- changed: () => {
243
- if (recheck().same) setChanged(true);
244
- },
245
- };
246
- app.answer(label === "Save" ? "save" : "activate", extra);
247
- },
248
- });
249
- const rows = (inner: number): ReactNode[] => {
250
- const out: ReactNode[] = [];
251
- const line = (key: string, parts: Part[]) => out.push(<Line key={key} width={inner} parts={parts} />);
252
- if (error !== null) {
253
- wrap(`Cannot show what saving would do: ${error}`, inner).forEach((t, i) =>
254
- line(`fail${i}`, [{ text: t, tone: "error" }]),
255
- );
256
- line("gap", []);
257
- out.push(<Buttons key="buttons" labels={labels} focused={0} width={inner} />);
258
- return out;
259
- }
260
- if (!preview) {
261
- line("gone", [{ text: draft ? "reading…" : "Nothing to save.", tone: "muted" }]);
262
- return out;
263
- }
264
- const repo = data.profiles.value?.repo;
265
- const text = (t: string, tone: Part["tone"]): Part[][] => wrap(t, inner).map((l) => [{ text: l, tone }]);
266
- const sections: Section[] = [
267
- {
268
- head: [],
269
- hold: "filler",
270
- items: [
271
- ...preview.changes.map((c): Part[][] => [
272
- [
273
- { text: `${c.path} ` },
274
- { text: show(c.before), tone: "diffDel" },
275
- { text: ` ${glyph("arrow", ui.plain)} ` },
276
- { text: show(c.after), tone: "diffAdd" },
277
- ],
278
- ]),
279
- ...preview.treatLikes.map(([rung, like]): Part[][] => [
280
- [{ text: `treat ${rung} like ` }, { text: like, tone: "diffAdd" }],
281
- ]),
282
- ],
283
- },
284
- {
285
- head: preview.repair
286
- ? [[], ...text("This save fixes an error and adds none; these stay open:", "warning")]
287
- : [[]],
288
- hold: "error",
289
- more: (n) => `… and ${n} more ${n === 1 ? "error" : "errors"}`,
290
- items: preview.validation.errors.map((e) =>
291
- text(`${glyph("fail", ui.plain)} ${e.path}: ${e.message}`, "error"),
292
- ),
293
- },
294
- {
295
- // warnings follow the errors in one block
296
- head: preview.validation.errors.length ? [] : [[]],
297
- hold: "filler",
298
- more: (n) => `… and ${n} more ${n === 1 ? "warning" : "warnings"}`,
299
- items: preview.validation.warnings.map((w) =>
300
- text(`${glyph("warn", ui.plain)} ${w.path}: ${w.message}`, "warning"),
301
- ),
302
- },
303
- {
304
- head: [[], [{ text: "Agent files", bold: true }]],
305
- hold: "filler",
306
- items: [
307
- ...preview.agentsAdded.map((a): Part[][] => [[{ text: `+ ${a}`, tone: "diffAdd" }]]),
308
- ...preview.agentsRemoved.map((a): Part[][] => [[{ text: `- ${a}`, tone: "diffDel" }]]),
309
- ],
310
- },
311
- {
312
- head: [[]],
313
- hold: "note",
314
- items: [
315
- text(
316
- "Applies to: native Claude agents in new Claude Code sessions; codex, claude-code and opencode from the next dispatch.",
317
- "muted",
318
- ),
319
- // inside a bound repo, making a profile active binds the repo to it (the button keeps §9.2's label)
320
- ...(repo ? [text(`Save & make active binds ${repo} to ${name}.`, "muted")] : []),
321
- ],
322
- },
323
- { head: [[]], hold: "failure", items: props.dialog.error ? [text(props.dialog.error, "error")] : [] },
324
- ];
325
- // the gap and the buttons always fit: every section shares what is left of the panel
326
- const notice =
327
- changed && !props.dialog.saving ? wrap(`${glyph("warn", ui.plain)} ${CHANGED_ON_DISK}`, inner) : [];
328
- fitSections(sections, dialogRows(dims.height) - 2 - notice.length).forEach((p, i) => line(`r${i}`, p));
329
- line("gap5", []);
330
- notice.forEach((t, i) => line(`changed${i}`, [{ text: t, tone: "warning" }]));
331
- if (props.dialog.saving) line("saving", [{ text: "saving…", tone: "muted" }]);
332
- else
333
- out.push(
334
- <Buttons
335
- key="buttons"
336
- labels={labels}
337
- focused={Math.min(focused, labels.length - 1)}
338
- width={inner}
339
- />,
340
- );
341
- return out;
342
- };
343
- return <Dialog title={`Save profile ${name}`} size="large" rows={rows} />;
344
- }
@@ -1,213 +0,0 @@
1
- import type { Check } from "../../../services/doctor-checks.ts";
2
- import { waitingLine } from "../../../services/orchestrator-wait.ts";
3
- import { useApp, useNow } from "../providers/app.tsx";
4
- import { useData } from "../providers/data.tsx";
5
- import { useCommandLayer } from "../providers/keymap.tsx";
6
- import { useUi } from "../providers/theme.tsx";
7
- import { ago, plural, wrap } from "../text.ts";
8
- import { glyph, STATE_TOKEN } from "../theme.ts";
9
- import { Line, type Part } from "../widgets/line.tsx";
10
- import { List, type ListItem, useSelected } from "../widgets/list.tsx";
11
- import { hereWord, type RunRow } from "../effects.ts";
12
- import { showProfile } from "./profile-actions.ts";
13
- import { staleLines } from "./profiles.tsx";
14
-
15
- /** `✓ ready` / `! not logged in` / `✗ missing`: state is always glyph and word (spec §9.3). */
16
- function stateParts(state: Check["state"], word: string, plain: boolean): Part[] {
17
- return [{ text: `${glyph(state, plain)} ${word}`.padEnd(16), tone: STATE_TOKEN[state] }];
18
- }
19
-
20
- /** A run in one line: its state word first, then title, repo and progress (research C4). */
21
- export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
22
- const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
23
- const waiting = waitingLine(r.waiting, now);
24
- if (waiting) bits.push(waiting);
25
- if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
26
- return [
27
- r.live
28
- ? { text: `${glyph("live", plain)} live `, tone: "info" }
29
- : { text: `${glyph("dot", plain)} idle `, tone: "muted" },
30
- { text: `${r.title} `, bold: true },
31
- { text: `${r.repo} `, tone: "muted" },
32
- { text: `${r.live ? `${r.live} live · ` : ""}${bits.join(" · ")} `, tone: "muted" },
33
- { text: ago(now - Date.parse(r.createdAt)), tone: "muted" },
34
- ];
35
- }
36
-
37
- /**
38
- * Spec §9.1 tab 1: each check as `glyph word — detail` with its full fix command (y copies it), the
39
- * active profile, and the last five runs. enter opens the profile or a run.
40
- */
41
- export function StatusView(props: { width: number; height: number }) {
42
- const app = useApp();
43
- const data = useData();
44
- const ui = useUi();
45
- const now = useNow(1_000);
46
- const { selected, select: setSelected, current: selectedNow } = useSelected();
47
- const checks = data.report?.checks ?? [];
48
- const profiles = data.profiles.value ?? { names: [], active: "…", here: "…", repo: null };
49
- const runs = (data.runs.value?.rows ?? []).slice(0, 5);
50
- const items: ListItem[] = [];
51
- const heading = (key: string, text: string, right = "") =>
52
- items.push({
53
- key,
54
- selectable: false,
55
- render: (_s, w) => (
56
- <Line
57
- width={w}
58
- parts={[
59
- { text, bold: true },
60
- { text: " ".repeat(Math.max(1, w - text.length - right.length - 1)) },
61
- { text: right, tone: "muted" },
62
- ]}
63
- />
64
- ),
65
- });
66
- const checked = data.checking
67
- ? "checking…"
68
- : data.checkedAt !== null
69
- ? `checked ${ago(now - data.checkedAt)}`
70
- : "";
71
- heading("h:setup", " SETUP", checked);
72
- if (data.checkError)
73
- items.push({
74
- key: "error",
75
- selectable: false,
76
- render: (_s, w) => (
77
- <Line
78
- width={w}
79
- parts={[{ text: ` ${glyph("fail", ui.plain)} ${data.checkError}`, tone: "error" }]}
80
- />
81
- ),
82
- });
83
- for (const c of checks) {
84
- // the detail wraps under the label instead of being cut: it is often a path (spec §9.3)
85
- const said = wrap(`${c.label}${c.detail ? ` — ${c.detail}` : ""}`, Math.max(20, props.width - 19));
86
- const first = said[0] ?? "";
87
- const label = first.startsWith(c.label) ? c.label : "";
88
- items.push({
89
- key: `check:${c.id}`,
90
- selectable: true,
91
- render: (sel, w) => (
92
- <Line
93
- width={w}
94
- selected={sel}
95
- parts={[
96
- { text: " " },
97
- ...stateParts(c.state, c.word, ui.plain),
98
- { text: label, bold: true },
99
- { text: first.slice(label.length), tone: "muted" },
100
- ]}
101
- />
102
- ),
103
- });
104
- said.slice(1).forEach((line, i) =>
105
- items.push({
106
- key: `detail:${c.id}:${i}`,
107
- selectable: false,
108
- render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(19)}${line}`, tone: "muted" }]} />,
109
- }),
110
- );
111
- if (c.fix)
112
- wrap(`fix: ${c.fix}`, Math.max(20, props.width - 22)).forEach((line, i) =>
113
- items.push({
114
- key: `fix:${c.id}:${i}`,
115
- selectable: false,
116
- render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(20)}${line}`, tone: "muted" }]} />,
117
- }),
118
- );
119
- }
120
- heading("h:profile", " PROFILE");
121
- // until the first check is in, nothing is selectable, so the cursor starts on the first check
122
- const ready = data.report !== null || data.checkError !== null;
123
- items.push({
124
- key: "profile",
125
- selectable: ready,
126
- render: (sel, w) => (
127
- <Line
128
- width={w}
129
- selected={sel}
130
- parts={[
131
- { text: ` ${profiles.here}`, bold: true },
132
- {
133
- // "…" until the first read: never "0 profiles" on the first frame (spec 1.1 §13)
134
- text: ` ${hereWord(profiles)} · ${data.profiles.value ? plural(profiles.names.length, "profile") : "…"}`,
135
- tone: "muted",
136
- },
137
- ]}
138
- />
139
- ),
140
- });
141
- // a later read that failed: the row above is the last good read, not the profile now
142
- for (const [i, p] of staleLines(data, ui.plain, { fix: false }).entries())
143
- items.push({
144
- key: `profile:stale:${i}`,
145
- selectable: false,
146
- render: (_s, w) => <Line width={w} parts={[{ ...p, text: ` ${p.text}` }]} />,
147
- });
148
- heading("h:runs", " RECENT RUNS");
149
- if (runs.length === 0)
150
- items.push({
151
- key: "runs:none",
152
- selectable: false,
153
- render: (_s, w) => <Line width={w} parts={[{ text: " no runs yet", tone: "muted" }]} />,
154
- });
155
- for (const r of runs)
156
- items.push({
157
- key: `run:${r.id}`,
158
- selectable: ready,
159
- render: (sel, w) => (
160
- <Line width={w} selected={sel} parts={[{ text: " " }, ...runParts(r, now, ui.plain)]} />
161
- ),
162
- });
163
-
164
- const checkAt = (key: string | null) => checks.find((c) => key === `check:${c.id}`);
165
- useCommandLayer("tab.status", {
166
- "status.recheck": () => data.recheck(),
167
- "status.copy": () => {
168
- const selectedCheck = checkAt(selectedNow());
169
- if (!selectedCheck?.fix) return app.toast({ variant: "info", message: "This row has no fix command" });
170
- const ok = app.copy(selectedCheck.fix);
171
- app.toast(
172
- ok
173
- ? { variant: "success", message: "Copied the fix command" }
174
- : { variant: "warning", message: "This terminal cannot copy; the command is shown in full" },
175
- );
176
- },
177
- });
178
- useCommandLayer("row.status", {
179
- "status.open": () => {
180
- const selected = selectedNow();
181
- const selectedCheck = checkAt(selected);
182
- // the PROFILE row is what this directory runs on; doctor's `profile` row is the global active one,
183
- // and its `profile:<name>` rows are the repo-bound ones
184
- const name =
185
- selected === "profile"
186
- ? profiles.here
187
- : selectedCheck?.id === "profile"
188
- ? profiles.active
189
- : selectedCheck?.id.startsWith("profile:")
190
- ? selectedCheck.id.slice("profile:".length)
191
- : null;
192
- if (name !== null) {
193
- showProfile(app, name);
194
- } else if (selected?.startsWith("run:")) {
195
- // spec §4: a run opens in its session's screen, which shows it with the session's other runs; the
196
- // cursor starts on that run
197
- const row = runs.find((r) => `run:${r.id}` === selected);
198
- app.dispatch({ type: "tab", tab: "runs" });
199
- if (row) app.dispatch({ type: "session", key: row.session, run: row.id });
200
- }
201
- },
202
- });
203
- return (
204
- <List
205
- items={items}
206
- selected={selected}
207
- onSelect={setSelected}
208
- width={props.width}
209
- height={props.height}
210
- empty="checking…"
211
- />
212
- );
213
- }
@@ -1,114 +0,0 @@
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
- }