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,98 @@
1
+ import { type CliRenderer, createCliRenderer, SystemClock } from "@opentui/core";
2
+ import { createRoot } from "@opentui/react";
3
+ import { gitToplevel } from "../../infra/git.ts";
4
+ import { readConfig } from "../../services/profile-store.ts";
5
+ import { CatherdError } from "../../domain/errors.ts";
6
+ import { EXIT, printError } from "../cli-kit.ts";
7
+ import { type Keybinds, resolveKeybinds } from "./commands.ts";
8
+ import type { Effects } from "./effects.ts";
9
+ import { liveEffects } from "./effects.ts";
10
+ import { fixtureEffects } from "./fixtures.ts";
11
+ import { createAppKeymap } from "./providers/keymap.tsx";
12
+ import { Providers } from "./providers/root.tsx";
13
+ import { initialState, type Tab } from "./state.ts";
14
+ import { detectUi, type ThemeMode } from "./theme.ts";
15
+ import { App } from "./views/app.tsx";
16
+
17
+ export interface TuiOptions {
18
+ rawArgs: string[];
19
+ tab?: Tab;
20
+ env?: Record<string, string | undefined>;
21
+ /** Whether stdin and stdout are a terminal; tests pass it so none reads the real one. */
22
+ tty?: boolean;
23
+ }
24
+
25
+ /** The keys in force: the defaults under config.json's `keybinds` (E_CONFIG_KEYBIND before anything draws). */
26
+ export const keybindsFromConfig = (): Keybinds =>
27
+ resolveKeybinds((readConfig() as { keybinds?: unknown }).keybinds);
28
+
29
+ /** The terminal's own background decides dark or light, else dark (spec §9.3). */
30
+ async function modeOf(renderer: CliRenderer): Promise<ThemeMode> {
31
+ return renderer.themeMode ?? (await renderer.waitForThemeMode(1000)) ?? "dark";
32
+ }
33
+
34
+ /**
35
+ * Spec §9.4's renderer: no mouse capture, `exitOnCtrlC: false` (ctrl+c is the app's to layer), the
36
+ * alternate screen for the app only; the lines the user must keep print to stdout once it is gone.
37
+ * Resolves to the exit code: 0, or 130 when unsaved changes were discarded with ctrl+c.
38
+ */
39
+ export async function openTui(o: TuiOptions): Promise<number> {
40
+ const env = o.env ?? process.env;
41
+ const tty = o.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
42
+ if (!tty) {
43
+ printError(
44
+ new CatherdError("E_INPUT_INVALID", "the dashboard needs an interactive terminal", {
45
+ fix: "in a script, run catherd status, catherd doctor or catherd watch --once",
46
+ }),
47
+ );
48
+ return EXIT.usage;
49
+ }
50
+ const keybinds = keybindsFromConfig();
51
+ const story = Boolean(env.CATHERD_STORY);
52
+ // the repo this directory is in, resolved once at start (the repo ruling)
53
+ const effects: Effects = story ? fixtureEffects() : liveEffects(await gitToplevel(process.cwd()));
54
+ const renderer = await createCliRenderer({
55
+ exitOnCtrlC: false,
56
+ useMouse: false,
57
+ autoFocus: false,
58
+ screenMode: "alternate-screen",
59
+ // the kitty protocol tells esc from alt+key at once; CATHERD_NO_KITTY=1 turns it off where it misbehaves
60
+ useKittyKeyboard: env.CATHERD_NO_KITTY ? null : {},
61
+ targetFps: 30,
62
+ });
63
+ let result = { code: EXIT.ok as number, kept: [] as string[] };
64
+ const done = new Promise<void>((resolve) => renderer.once("destroy", () => resolve()));
65
+ // anything that throws once the renderer exists gives the terminal back before the error surfaces
66
+ try {
67
+ const ui = { ...detectUi(o.rawArgs, env), mode: await modeOf(renderer) };
68
+ renderer.setTerminalTitle(story ? "catherd stories" : "catherd");
69
+ createRoot(renderer).render(
70
+ <Providers
71
+ ui={ui}
72
+ keybinds={keybinds}
73
+ keymap={createAppKeymap(renderer)}
74
+ effects={effects}
75
+ clock={new SystemClock()}
76
+ initial={initialState(o.tab ?? "status")}
77
+ copy={(text) => renderer.copyToClipboardOSC52(text)}
78
+ onExit={(code, kept) => {
79
+ result = { code, kept };
80
+ renderer.destroy();
81
+ }}
82
+ >
83
+ <App story={story} />
84
+ </Providers>,
85
+ );
86
+ } catch (e) {
87
+ renderer.destroy();
88
+ throw e;
89
+ }
90
+ await done;
91
+ for (const line of result.kept) console.log(line);
92
+ return result.code;
93
+ }
94
+
95
+ /** Bare `catherd`: `cli.ts` imports this only when no subcommand was named, so no subcommand loads OpenTUI. */
96
+ export async function tuiRun(ctx: { rawArgs: string[] }): Promise<void> {
97
+ process.exitCode = await openTui({ rawArgs: ctx.rawArgs });
98
+ }
@@ -0,0 +1,356 @@
1
+ import {
2
+ applyPatch,
3
+ diffProfiles,
4
+ patchBetween,
5
+ type ProfileDoc,
6
+ type ProfilePatch,
7
+ resolveProfile,
8
+ } from "../../domain/profile.ts";
9
+ import type { Role } from "../../domain/roles.ts";
10
+
11
+ /** Spec §9.1: one app, three tabs. */
12
+ export const TABS = ["status", "profiles", "runs"] as const;
13
+ export type Tab = (typeof TABS)[number];
14
+
15
+ /** What a draft can undo to: the edited document and the treat-likes staged with it. */
16
+ interface Snapshot {
17
+ doc: ProfileDoc;
18
+ /** rung → the scored rung it is treated like; written to catalog.override.json on save */
19
+ treatLikes: Record<string, string>;
20
+ }
21
+
22
+ /** Spec §9.2: edits are staged per profile and touch nothing on disk until a save. */
23
+ export interface Draft extends Snapshot {
24
+ name: string;
25
+ /** the document as it was read, which a save diffs against */
26
+ base: ProfileDoc;
27
+ past: Snapshot[];
28
+ future: Snapshot[];
29
+ }
30
+
31
+ /** The number fields a value editor sets. */
32
+ const NUMBER_PATHS = [
33
+ "budget.minutes",
34
+ "budget.tokens",
35
+ "budget.usd",
36
+ "timeouts.idleMin",
37
+ "timeouts.wallMin",
38
+ ] as const;
39
+ export type NumberPath = (typeof NUMBER_PATHS)[number];
40
+
41
+ /** Why a dialog is open, so the one handler for that purpose runs when it is answered. */
42
+ export type Purpose =
43
+ | { type: "palette" }
44
+ | { type: "help" }
45
+ | { type: "quit" }
46
+ | { type: "save"; name: string }
47
+ /** `repo`: the scope the prompt shows, the bound repo or null for the global profile */
48
+ | { type: "activate"; name: string; repo: string | null }
49
+ | { type: "revert"; name: string }
50
+ | { type: "profiles" }
51
+ | { type: "new" }
52
+ | { type: "copy"; from: string }
53
+ | { type: "number"; path: NumberPath }
54
+ | { type: "start"; role: Role }
55
+ | { type: "treatLike"; rung: string; role: Role | null }
56
+ | { type: "failover"; rung: string }
57
+ | { type: "stories" };
58
+
59
+ export interface SelectOption {
60
+ value: string;
61
+ title: string;
62
+ /** options with a group are shown under its heading, in the order the groups first appear */
63
+ group?: string;
64
+ /** muted text after the title, in a column of its own: a key, a mark */
65
+ detail?: string;
66
+ /** a second muted column after the detail (the command's CLI twin); dropped when it does not fit */
67
+ cli?: string;
68
+ /** the value in force now, marked `●` */
69
+ current?: boolean;
70
+ }
71
+
72
+ export type Dialog =
73
+ | {
74
+ kind: "select";
75
+ purpose: Purpose;
76
+ title: string;
77
+ options: SelectOption[];
78
+ /** Suggested options, shown first while the filter is empty */
79
+ suggested?: string[];
80
+ /** offers ctrl+d, pressed twice, to delete the selected option */
81
+ deletable?: boolean;
82
+ /** the one muted line shown when there is nothing to choose */
83
+ empty: string;
84
+ }
85
+ | {
86
+ kind: "confirm";
87
+ purpose: Purpose;
88
+ title: string;
89
+ message: string[];
90
+ yes: string;
91
+ no: string;
92
+ /** a destructive confirm starts on its "no" button */
93
+ destructive: boolean;
94
+ }
95
+ | { kind: "prompt"; purpose: Purpose; title: string; label: string; value: string; error: string | null }
96
+ | {
97
+ kind: "save";
98
+ purpose: { type: "save"; name: string };
99
+ error: string | null;
100
+ /** a save is writing: the dialog shows "saving…", takes no answer and cannot be closed */
101
+ saving?: boolean;
102
+ };
103
+
104
+ /** A double press waiting for its second key (spec §9.2: ctrl+c when dirty, ctrl+d to delete or cancel). */
105
+ export interface Armed {
106
+ what: "interrupt" | "delete" | "cancel";
107
+ target: string;
108
+ at: number;
109
+ }
110
+
111
+ /** How long the second press of each double press may wait. */
112
+ export const ARM_MS: Record<Armed["what"], number> = { interrupt: 1_500, delete: 5_000, cancel: 5_000 };
113
+
114
+ export const HISTORY_LIMIT = 100;
115
+
116
+ export interface AppState {
117
+ tab: Tab;
118
+ /** the profile the Profiles tab shows; null until one is read */
119
+ profile: string | null;
120
+ drafts: Record<string, Draft>;
121
+ /** the open dialogs; only the top one is drawn and takes keys */
122
+ dialogs: Dialog[];
123
+ /** spec §4: the session the Runs tab has open (`key` null: "earlier runs"); null shows the sessions */
124
+ session: { key: string | null } | null;
125
+ /** the role the open session shows, by its dispatch */
126
+ role: { run: string; dispatchId: string } | null;
127
+ /** spec 1.1 §10: the milestone the open session shows, its digest */
128
+ milestone: { run: string; name: string } | null;
129
+ paused: boolean;
130
+ armed: Armed | null;
131
+ }
132
+
133
+ export type Action =
134
+ | { type: "tab"; tab: Tab }
135
+ | { type: "show"; name: string; doc: ProfileDoc }
136
+ | { type: "edit"; patch: ProfilePatch }
137
+ /** `patch` (ticking the rung) lands in the same undo step as the treat-like */
138
+ | { type: "treatLike"; rung: string; like: string | null; patch?: ProfilePatch }
139
+ | { type: "undo" }
140
+ | { type: "redo" }
141
+ /** `doc`: the profile as it is on disk now, the new base (else the draft's own base) */
142
+ | { type: "revert"; name: string; doc?: ProfileDoc }
143
+ /** `from` is the draft as the save began; edits made while it wrote stay staged over `doc` */
144
+ | { type: "saved"; name: string; doc: ProfileDoc; from?: Snapshot }
145
+ | { type: "forget"; name: string }
146
+ | { type: "open"; dialog: Dialog }
147
+ | { type: "replace"; dialog: Dialog }
148
+ | { type: "close" }
149
+ | { type: "saving"; name: string; on: boolean }
150
+ | { type: "input"; value: string }
151
+ | { type: "invalid"; error: string | null }
152
+ /** opens a session of the Runs tab (`key` null: "earlier runs") */
153
+ | { type: "session"; key: string | null }
154
+ /** opens one role of the open session */
155
+ | { type: "role"; run: string; dispatchId: string }
156
+ /** opens one milestone of the open session, on its digest */
157
+ | { type: "milestone"; run: string; name: string }
158
+ /** the Runs tab goes back one level: a milestone or a role to its session, a session to the list */
159
+ | { type: "up" }
160
+ | { type: "pause" }
161
+ | { type: "arm"; what: Armed["what"]; target: string; at: number }
162
+ | { type: "disarm" };
163
+
164
+ export const initialState = (tab: Tab = "status"): AppState => ({
165
+ tab,
166
+ profile: null,
167
+ drafts: {},
168
+ dialogs: [],
169
+ session: null,
170
+ role: null,
171
+ milestone: null,
172
+ paused: false,
173
+ armed: null,
174
+ });
175
+
176
+ const fresh = (name: string, doc: ProfileDoc): Draft => ({
177
+ name,
178
+ base: doc,
179
+ doc,
180
+ treatLikes: {},
181
+ past: [],
182
+ future: [],
183
+ });
184
+
185
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
186
+
187
+ /** The draft the Profiles tab shows, if any. */
188
+ export const currentDraft = (s: AppState): Draft | null => (s.profile ? (s.drafts[s.profile] ?? null) : null);
189
+
190
+ /** Changes staged in a draft: each changed field of the profile, and each staged treat-like. */
191
+ const isStaged = (d: Draft | undefined): boolean => d !== undefined && dirtyCount(d) > 0;
192
+
193
+ export function dirtyCount(d: Draft): number {
194
+ const fields = diffProfiles(resolveProfile(d.base, d.name), resolveProfile(d.doc, d.name)).length;
195
+ return fields + Object.keys(d.treatLikes).length;
196
+ }
197
+
198
+ export const totalDirty = (s: AppState): number =>
199
+ Object.values(s.drafts).reduce((n, d) => n + dirtyCount(d), 0);
200
+
201
+ /** Whether a save is writing: its dialog stays open, and the TUI does not quit, until it settles. */
202
+ export const isSaving = (s: AppState): boolean =>
203
+ s.dialogs.some((d) => d.kind === "save" && d.saving === true);
204
+
205
+ /** Whether the second press of `what` on `target` at `now` completes the double press. */
206
+ export const isArmed = (s: AppState, what: Armed["what"], target: string, now: number): boolean =>
207
+ s.armed !== null && s.armed.what === what && s.armed.target === target && now - s.armed.at <= ARM_MS[what];
208
+
209
+ /** A draft moved to `next`, the old state kept for undo; a change to nothing is no change. */
210
+ function step(d: Draft, next: Snapshot): Draft {
211
+ if (same(next.doc, d.doc) && same(next.treatLikes, d.treatLikes)) return d;
212
+ return {
213
+ ...d,
214
+ ...next,
215
+ past: [...d.past, { doc: d.doc, treatLikes: d.treatLikes }].slice(-HISTORY_LIMIT),
216
+ future: [],
217
+ };
218
+ }
219
+
220
+ function withDraft(s: AppState, name: string | null, f: (d: Draft) => Draft): AppState {
221
+ const d = name ? s.drafts[name] : undefined;
222
+ if (!d) return s;
223
+ const next = f(d);
224
+ return next === d ? s : { ...s, drafts: { ...s.drafts, [d.name]: next } };
225
+ }
226
+
227
+ /** Spec §9.4 `state.ts`: the pure reducer for tabs, drafts and their history, dialogs and armed keys. */
228
+ export function reduce(s: AppState, a: Action): AppState {
229
+ switch (a.type) {
230
+ case "tab":
231
+ return { ...s, tab: a.tab, armed: null };
232
+ case "show":
233
+ return {
234
+ ...s,
235
+ tab: "profiles",
236
+ profile: a.name,
237
+ // Keep only a draft with staged changes; a clean one restarts from the file just read.
238
+ drafts: isStaged(s.drafts[a.name]) ? s.drafts : { ...s.drafts, [a.name]: fresh(a.name, a.doc) },
239
+ };
240
+ case "edit":
241
+ return withDraft(s, s.profile, (d) =>
242
+ step(d, { doc: applyPatch(d.doc, a.patch), treatLikes: d.treatLikes }),
243
+ );
244
+ case "treatLike":
245
+ return withDraft(s, s.profile, (d) => {
246
+ const treatLikes = { ...d.treatLikes };
247
+ if (a.like === null) delete treatLikes[a.rung];
248
+ else treatLikes[a.rung] = a.like;
249
+ return step(d, { doc: a.patch ? applyPatch(d.doc, a.patch) : d.doc, treatLikes });
250
+ });
251
+ case "undo":
252
+ return withDraft(s, s.profile, (d) => {
253
+ const prev = d.past.at(-1);
254
+ if (!prev) return d;
255
+ return {
256
+ ...d,
257
+ ...prev,
258
+ past: d.past.slice(0, -1),
259
+ future: [{ doc: d.doc, treatLikes: d.treatLikes }, ...d.future],
260
+ };
261
+ });
262
+ case "redo":
263
+ return withDraft(s, s.profile, (d) => {
264
+ const next = d.future[0];
265
+ if (!next) return d;
266
+ return {
267
+ ...d,
268
+ ...next,
269
+ past: [...d.past, { doc: d.doc, treatLikes: d.treatLikes }],
270
+ future: d.future.slice(1),
271
+ };
272
+ });
273
+ case "revert":
274
+ return withDraft(s, a.name, (d) => {
275
+ const base = a.doc ?? d.base;
276
+ if (same(base, d.base)) return step(d, { doc: d.base, treatLikes: {} });
277
+ // another process saved the profile since the draft opened: what is saved now is the new base, and
278
+ // the history (with this revert's undo step) keeps each staged change, now over that base. A step
279
+ // that cannot be carried over (a hand-written rung no patch accepts) is dropped: a reducer that
280
+ // throws from a key handler would end the TUI
281
+ const moved = (x: Snapshot): Snapshot[] => {
282
+ try {
283
+ return [{ ...x, doc: applyPatch(base, patchBetween(d.base, x.doc)) }];
284
+ } catch {
285
+ return [];
286
+ }
287
+ };
288
+ return {
289
+ ...d,
290
+ base,
291
+ doc: base,
292
+ treatLikes: {},
293
+ past: [...d.past, { doc: d.doc, treatLikes: d.treatLikes }].flatMap(moved).slice(-HISTORY_LIMIT),
294
+ future: [],
295
+ };
296
+ });
297
+ case "saved": {
298
+ const d = s.drafts[a.name];
299
+ const from = a.from;
300
+ if (!from || !d || (same(d.doc, from.doc) && same(d.treatLikes, from.treatLikes)))
301
+ return { ...s, drafts: { ...s.drafts, [a.name]: fresh(a.name, a.doc) } };
302
+ // edited while the save wrote: the saved file is the new base, and the newer edits stay staged over it
303
+ const treatLikes = Object.fromEntries(
304
+ Object.entries(d.treatLikes).filter(([rung, like]) => from.treatLikes[rung] !== like),
305
+ );
306
+ const doc = applyPatch(a.doc, patchBetween(from.doc, d.doc));
307
+ return { ...s, drafts: { ...s.drafts, [a.name]: { ...fresh(a.name, a.doc), doc, treatLikes } } };
308
+ }
309
+ case "forget": {
310
+ const { [a.name]: _gone, ...drafts } = s.drafts;
311
+ return { ...s, drafts, profile: s.profile === a.name ? null : s.profile };
312
+ }
313
+ case "open":
314
+ return { ...s, dialogs: [...s.dialogs, a.dialog], armed: null };
315
+ case "replace":
316
+ return { ...s, dialogs: [...s.dialogs.slice(0, -1), a.dialog], armed: null };
317
+ case "close": {
318
+ const top = s.dialogs.at(-1);
319
+ if (top?.kind === "save" && top.saving) return s;
320
+ return { ...s, dialogs: s.dialogs.slice(0, -1), armed: null };
321
+ }
322
+ case "saving": {
323
+ const at = s.dialogs.findIndex((d) => d.kind === "save" && d.purpose.name === a.name);
324
+ const d = s.dialogs[at];
325
+ if (d?.kind !== "save" || (d.saving === true) === a.on) return s;
326
+ const dialogs = [...s.dialogs];
327
+ dialogs[at] = { ...d, saving: a.on };
328
+ return { ...s, dialogs };
329
+ }
330
+ case "input":
331
+ case "invalid": {
332
+ const top = s.dialogs.at(-1);
333
+ if (!top) return s;
334
+ let next: Dialog = top;
335
+ if (top.kind === "prompt")
336
+ next = a.type === "input" ? { ...top, value: a.value, error: null } : { ...top, error: a.error };
337
+ else if (top.kind === "save" && a.type === "invalid") next = { ...top, error: a.error };
338
+ return next === top ? s : { ...s, dialogs: [...s.dialogs.slice(0, -1), next] };
339
+ }
340
+ case "session":
341
+ return { ...s, session: { key: a.key }, role: null, milestone: null, armed: null };
342
+ case "role":
343
+ return { ...s, role: { run: a.run, dispatchId: a.dispatchId }, milestone: null, armed: null };
344
+ case "milestone":
345
+ return { ...s, milestone: { run: a.run, name: a.name }, role: null, armed: null };
346
+ case "up":
347
+ if (s.milestone) return { ...s, milestone: null, armed: null };
348
+ return s.role ? { ...s, role: null, armed: null } : { ...s, session: null, armed: null };
349
+ case "pause":
350
+ return { ...s, paused: !s.paused };
351
+ case "arm":
352
+ return { ...s, armed: { what: a.what, target: a.target, at: a.at } };
353
+ case "disarm":
354
+ return s.armed ? { ...s, armed: null } : s;
355
+ }
356
+ }
@@ -0,0 +1,107 @@
1
+ import type { ProfilePatch } from "../../domain/profile.ts";
2
+ import type { CommandId } from "./commands.ts";
3
+ import type { FixtureOptions } from "./fixtures.ts";
4
+ import type { AppApi } from "./providers/app.tsx";
5
+ import type { AppKeymap } from "./providers/keymap.tsx";
6
+ import type { Action } from "./state.ts";
7
+
8
+ /** One step towards a screen: a command as a key would run it, a reducer action, or a profile shown. */
9
+ export type Step = { command: CommandId } | { action: Action } | { show: string } | { edit: ProfilePatch };
10
+
11
+ export interface Story {
12
+ name: string;
13
+ title: string;
14
+ steps: Step[];
15
+ /** the fixtures the frame snapshots render it on (the storybook shows it on its one set) */
16
+ fixtures?: FixtureOptions;
17
+ }
18
+
19
+ const SHOWN: Step[] = [{ show: "default" }];
20
+ const DIRTY: Step[] = [
21
+ ...SHOWN,
22
+ { edit: { budget: { usd: 5 }, roles: { verifier: { access: "read-only" } } } },
23
+ ];
24
+
25
+ /**
26
+ * Every view and widget in a state worth seeing (spec §9.4 `CATHERD_STORY=1`): the storybook lists them
27
+ * and the frame snapshots render them at three sizes, in Unicode and plain.
28
+ */
29
+ export const STORIES: Story[] = [
30
+ {
31
+ name: "status",
32
+ title: "Status: checks with fixes, profile, recent runs",
33
+ steps: [{ command: "tab.status" }],
34
+ },
35
+ { name: "profiles", title: "Profiles: the tree", steps: SHOWN },
36
+ { name: "profiles-dirty", title: "Profiles: two unsaved changes", steps: DIRTY },
37
+ {
38
+ name: "save",
39
+ title: "Save dialog: diff, agents, what applies when",
40
+ steps: [...DIRTY, { command: "profile.save" }],
41
+ },
42
+ { name: "quit", title: "Quit with unsaved changes", steps: [...DIRTY, { command: "app.quit" }] },
43
+ { name: "new-profile", title: "New profile prompt", steps: [{ command: "profile.new" }] },
44
+ { name: "profile-list", title: "Profile list", steps: [{ command: "profile.list" }] },
45
+ {
46
+ name: "palette",
47
+ title: "Command palette",
48
+ steps: [{ command: "tab.status" }, { command: "app.palette" }],
49
+ },
50
+ { name: "help", title: "Keyboard shortcuts", steps: [{ command: "tab.status" }, { command: "app.help" }] },
51
+ {
52
+ name: "runs-empty",
53
+ title: "Runs: no runs yet",
54
+ steps: [{ command: "tab.runs" }],
55
+ fixtures: { runs: [], sessions: [] },
56
+ },
57
+ { name: "runs", title: "Runs: the sessions", steps: [{ command: "tab.runs" }] },
58
+ {
59
+ name: "session",
60
+ title: "Runs: one session with two runs, one continued elsewhere",
61
+ steps: [{ command: "tab.runs" }, { action: { type: "session", key: "s-auth" } }],
62
+ },
63
+ {
64
+ name: "session-live",
65
+ title: "Runs: a live session with three live roles",
66
+ steps: [{ command: "tab.runs" }, { action: { type: "session", key: "s-jobs" } }],
67
+ },
68
+ {
69
+ name: "role",
70
+ title: "Runs: a role opened",
71
+ steps: [
72
+ { command: "tab.runs" },
73
+ { action: { type: "session", key: "s-jobs" } },
74
+ { action: { type: "role", run: "20260926-114800-jobs-screen", dispatchId: "d1" } },
75
+ ],
76
+ },
77
+ {
78
+ name: "milestone",
79
+ title: "Runs: a milestone opened on its digest",
80
+ steps: [
81
+ { command: "tab.runs" },
82
+ { action: { type: "session", key: "s-jobs" } },
83
+ { action: { type: "milestone", run: "20260926-114800-jobs-screen", name: "M0" } },
84
+ ],
85
+ },
86
+ ];
87
+
88
+ /** One step, as a key or the reducer would take it. */
89
+ export function applyStep(s: Step, app: AppApi, keymap: AppKeymap): void {
90
+ if ("command" in s) keymap.dispatchCommand(s.command);
91
+ else if ("action" in s) app.dispatch(s.action);
92
+ else if ("show" in s) app.dispatch({ type: "show", name: s.show, doc: app.effects.readProfile(s.show) });
93
+ else app.dispatch({ type: "edit", patch: s.edit });
94
+ }
95
+
96
+ /** Plays a story's steps, letting the app draw between them (a step may need the view the last one opened). */
97
+ export async function playStory(
98
+ story: Story,
99
+ app: AppApi,
100
+ keymap: AppKeymap,
101
+ wait: () => Promise<void>,
102
+ ): Promise<void> {
103
+ for (const s of story.steps) {
104
+ applyStep(s, app, keymap);
105
+ await wait();
106
+ }
107
+ }
@@ -0,0 +1,130 @@
1
+ /** Terminal columns a string takes (wide and zero-width characters counted as the terminal does). */
2
+ export const width = (s: string): number => Bun.stringWidth(s);
3
+
4
+ /** Cuts `s` at the end to `max` columns with an ellipsis; never in the middle (spec §9.3). */
5
+ export function truncateEnd(s: string, max: number, plain = false): string {
6
+ if (max <= 0) return "";
7
+ if (width(s) <= max) return s;
8
+ const more = plain ? "..." : "…";
9
+ const room = max - width(more);
10
+ if (room <= 0) return more.slice(0, max);
11
+ let out = "";
12
+ for (const ch of s) {
13
+ if (width(out + ch) > room) break;
14
+ out += ch;
15
+ }
16
+ return out + more;
17
+ }
18
+
19
+ /** `1 change`, `2 changes`: a count with its noun, never `change(s)`. */
20
+ export const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? "" : "s"}`;
21
+
22
+ /** Pads with spaces to exactly `w` columns (truncating nothing: callers truncate first). */
23
+ export const padEnd = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - width(s)));
24
+
25
+ /**
26
+ * Wraps to `max` columns without losing a character: breaks at spaces, and inside a word only when the
27
+ * word alone is wider than a line (ids, paths and commands wrap; spec §9.3).
28
+ */
29
+ export function wrap(s: string, max: number): string[] {
30
+ if (max <= 0) return [s];
31
+ const lines: string[] = [];
32
+ let line = "";
33
+ const push = () => {
34
+ lines.push(line);
35
+ line = "";
36
+ };
37
+ for (const word of s.split(" ")) {
38
+ const candidate = line ? `${line} ${word}` : word;
39
+ if (width(candidate) <= max) {
40
+ line = candidate;
41
+ continue;
42
+ }
43
+ if (line) push();
44
+ let rest = word;
45
+ while (width(rest) > max) {
46
+ let head = "";
47
+ for (const ch of rest) {
48
+ if (head && width(head + ch) > max) break;
49
+ head += ch;
50
+ }
51
+ lines.push(head);
52
+ rest = rest.slice(head.length);
53
+ }
54
+ line = rest;
55
+ }
56
+ if (line || lines.length === 0) push();
57
+ return lines;
58
+ }
59
+
60
+ /**
61
+ * `wrap` with a hanging indent: a line's leading spaces and list marker (`- `, `* `, `1. `) are kept on its first
62
+ * line, and its continuation lines are indented to match, so a wrapped bullet does not read as a new line.
63
+ */
64
+ export function wrapHanging(s: string, max: number): string[] {
65
+ const lead = /^\s*(?:[-*+] |\d+[.)] )?/.exec(s)?.[0] ?? "";
66
+ const hang = width(lead);
67
+ if (!lead || max - hang < 1) return wrap(s, max);
68
+ const pad = " ".repeat(hang);
69
+ return wrap(s.slice(lead.length), max - hang).map((l, i) => (i === 0 ? lead : pad) + l);
70
+ }
71
+
72
+ /** `04:12`, or `1:02:03` past an hour. */
73
+ export function clock(secs: number): string {
74
+ const s = Math.max(0, Math.floor(secs));
75
+ const h = Math.floor(s / 3600);
76
+ const mm = String(Math.floor(s / 60) % 60).padStart(2, "0");
77
+ const ss = String(s % 60).padStart(2, "0");
78
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
79
+ }
80
+
81
+ /** "just now", "12s ago", "4m ago", "2h ago", "3d ago". */
82
+ export function ago(ms: number): string {
83
+ const s = Math.max(0, Math.floor(ms / 1000));
84
+ if (s < 2) return "just now";
85
+ if (s < 60) return `${s}s ago`;
86
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
87
+ if (s < 86_400) return `${Math.floor(s / 3600)}h ago`;
88
+ return `${Math.floor(s / 86_400)}d ago`;
89
+ }
90
+
91
+ /** `gpt-6-sol#medium` from `codex:gpt-6-sol#medium`: the backend goes, and a provider path keeps its last part. */
92
+ export function shortRung(rung: string): string {
93
+ const noBackend = rung.slice(rung.indexOf(":") + 1);
94
+ return noBackend.slice(noBackend.lastIndexOf("/") + 1);
95
+ }
96
+
97
+ const ASCII: Record<string, string> = {
98
+ "—": "-",
99
+ "–": "-",
100
+ "·": "-",
101
+ "…": "...",
102
+ "⋯": "...",
103
+ "→": "->",
104
+ "←": "<-",
105
+ "↑": "^",
106
+ "↓": "v",
107
+ "●": "*",
108
+ "•": "*",
109
+ "◌": ".",
110
+ "✓": "+",
111
+ "✗": "x",
112
+ "▸": ">",
113
+ "▾": "v",
114
+ "┃": "|",
115
+ "─": "-",
116
+ "█": "#",
117
+ "░": ".",
118
+ "×": "x",
119
+ ω: "w",
120
+ };
121
+
122
+ /** `s` with every character outside printable ASCII replaced, for --plain (spec §9.3). */
123
+ export function ascii(s: string): string {
124
+ let out = "";
125
+ for (const ch of s) {
126
+ const c = ch.codePointAt(0) ?? 0;
127
+ out += c >= 0x20 && c <= 0x7e ? ch : (ASCII[ch] ?? "?");
128
+ }
129
+ return out;
130
+ }