catherd-cli 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,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,336 @@
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
+ /** the run the Runs tab has open; null shows the list */
124
+ run: string | null;
125
+ paused: boolean;
126
+ armed: Armed | null;
127
+ }
128
+
129
+ export type Action =
130
+ | { type: "tab"; tab: Tab }
131
+ | { type: "show"; name: string; doc: ProfileDoc }
132
+ | { type: "edit"; patch: ProfilePatch }
133
+ /** `patch` (ticking the rung) lands in the same undo step as the treat-like */
134
+ | { type: "treatLike"; rung: string; like: string | null; patch?: ProfilePatch }
135
+ | { type: "undo" }
136
+ | { type: "redo" }
137
+ /** `doc`: the profile as it is on disk now, the new base (else the draft's own base) */
138
+ | { type: "revert"; name: string; doc?: ProfileDoc }
139
+ /** `from` is the draft as the save began; edits made while it wrote stay staged over `doc` */
140
+ | { type: "saved"; name: string; doc: ProfileDoc; from?: Snapshot }
141
+ | { type: "forget"; name: string }
142
+ | { type: "open"; dialog: Dialog }
143
+ | { type: "replace"; dialog: Dialog }
144
+ | { type: "close" }
145
+ | { type: "saving"; name: string; on: boolean }
146
+ | { type: "input"; value: string }
147
+ | { type: "invalid"; error: string | null }
148
+ | { type: "run"; id: string | null }
149
+ | { type: "pause" }
150
+ | { type: "arm"; what: Armed["what"]; target: string; at: number }
151
+ | { type: "disarm" };
152
+
153
+ export const initialState = (tab: Tab = "status"): AppState => ({
154
+ tab,
155
+ profile: null,
156
+ drafts: {},
157
+ dialogs: [],
158
+ run: null,
159
+ paused: false,
160
+ armed: null,
161
+ });
162
+
163
+ const fresh = (name: string, doc: ProfileDoc): Draft => ({
164
+ name,
165
+ base: doc,
166
+ doc,
167
+ treatLikes: {},
168
+ past: [],
169
+ future: [],
170
+ });
171
+
172
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
173
+
174
+ /** The draft the Profiles tab shows, if any. */
175
+ export const currentDraft = (s: AppState): Draft | null => (s.profile ? (s.drafts[s.profile] ?? null) : null);
176
+
177
+ /** Changes staged in a draft: each changed field of the profile, and each staged treat-like. */
178
+ const isStaged = (d: Draft | undefined): boolean => d !== undefined && dirtyCount(d) > 0;
179
+
180
+ export function dirtyCount(d: Draft): number {
181
+ const fields = diffProfiles(resolveProfile(d.base, d.name), resolveProfile(d.doc, d.name)).length;
182
+ return fields + Object.keys(d.treatLikes).length;
183
+ }
184
+
185
+ export const totalDirty = (s: AppState): number =>
186
+ Object.values(s.drafts).reduce((n, d) => n + dirtyCount(d), 0);
187
+
188
+ /** Whether a save is writing: its dialog stays open, and the TUI does not quit, until it settles. */
189
+ export const isSaving = (s: AppState): boolean =>
190
+ s.dialogs.some((d) => d.kind === "save" && d.saving === true);
191
+
192
+ /** Whether the second press of `what` on `target` at `now` completes the double press. */
193
+ export const isArmed = (s: AppState, what: Armed["what"], target: string, now: number): boolean =>
194
+ s.armed !== null && s.armed.what === what && s.armed.target === target && now - s.armed.at <= ARM_MS[what];
195
+
196
+ /** A draft moved to `next`, the old state kept for undo; a change to nothing is no change. */
197
+ function step(d: Draft, next: Snapshot): Draft {
198
+ if (same(next.doc, d.doc) && same(next.treatLikes, d.treatLikes)) return d;
199
+ return {
200
+ ...d,
201
+ ...next,
202
+ past: [...d.past, { doc: d.doc, treatLikes: d.treatLikes }].slice(-HISTORY_LIMIT),
203
+ future: [],
204
+ };
205
+ }
206
+
207
+ function withDraft(s: AppState, name: string | null, f: (d: Draft) => Draft): AppState {
208
+ const d = name ? s.drafts[name] : undefined;
209
+ if (!d) return s;
210
+ const next = f(d);
211
+ return next === d ? s : { ...s, drafts: { ...s.drafts, [d.name]: next } };
212
+ }
213
+
214
+ /** Spec §9.4 `state.ts`: the pure reducer for tabs, drafts and their history, dialogs and armed keys. */
215
+ export function reduce(s: AppState, a: Action): AppState {
216
+ switch (a.type) {
217
+ case "tab":
218
+ return { ...s, tab: a.tab, armed: null };
219
+ case "show":
220
+ return {
221
+ ...s,
222
+ tab: "profiles",
223
+ profile: a.name,
224
+ // Keep only a draft with staged changes; a clean one restarts from the file just read.
225
+ drafts: isStaged(s.drafts[a.name]) ? s.drafts : { ...s.drafts, [a.name]: fresh(a.name, a.doc) },
226
+ };
227
+ case "edit":
228
+ return withDraft(s, s.profile, (d) =>
229
+ step(d, { doc: applyPatch(d.doc, a.patch), treatLikes: d.treatLikes }),
230
+ );
231
+ case "treatLike":
232
+ return withDraft(s, s.profile, (d) => {
233
+ const treatLikes = { ...d.treatLikes };
234
+ if (a.like === null) delete treatLikes[a.rung];
235
+ else treatLikes[a.rung] = a.like;
236
+ return step(d, { doc: a.patch ? applyPatch(d.doc, a.patch) : d.doc, treatLikes });
237
+ });
238
+ case "undo":
239
+ return withDraft(s, s.profile, (d) => {
240
+ const prev = d.past.at(-1);
241
+ if (!prev) return d;
242
+ return {
243
+ ...d,
244
+ ...prev,
245
+ past: d.past.slice(0, -1),
246
+ future: [{ doc: d.doc, treatLikes: d.treatLikes }, ...d.future],
247
+ };
248
+ });
249
+ case "redo":
250
+ return withDraft(s, s.profile, (d) => {
251
+ const next = d.future[0];
252
+ if (!next) return d;
253
+ return {
254
+ ...d,
255
+ ...next,
256
+ past: [...d.past, { doc: d.doc, treatLikes: d.treatLikes }],
257
+ future: d.future.slice(1),
258
+ };
259
+ });
260
+ case "revert":
261
+ return withDraft(s, a.name, (d) => {
262
+ const base = a.doc ?? d.base;
263
+ if (same(base, d.base)) return step(d, { doc: d.base, treatLikes: {} });
264
+ // another process saved the profile since the draft opened: what is saved now is the new base, and
265
+ // the history (with this revert's undo step) keeps each staged change, now over that base. A step
266
+ // that cannot be carried over (a hand-written rung no patch accepts) is dropped: a reducer that
267
+ // throws from a key handler would end the TUI
268
+ const moved = (x: Snapshot): Snapshot[] => {
269
+ try {
270
+ return [{ ...x, doc: applyPatch(base, patchBetween(d.base, x.doc)) }];
271
+ } catch {
272
+ return [];
273
+ }
274
+ };
275
+ return {
276
+ ...d,
277
+ base,
278
+ doc: base,
279
+ treatLikes: {},
280
+ past: [...d.past, { doc: d.doc, treatLikes: d.treatLikes }].flatMap(moved).slice(-HISTORY_LIMIT),
281
+ future: [],
282
+ };
283
+ });
284
+ case "saved": {
285
+ const d = s.drafts[a.name];
286
+ const from = a.from;
287
+ if (!from || !d || (same(d.doc, from.doc) && same(d.treatLikes, from.treatLikes)))
288
+ return { ...s, drafts: { ...s.drafts, [a.name]: fresh(a.name, a.doc) } };
289
+ // edited while the save wrote: the saved file is the new base, and the newer edits stay staged over it
290
+ const treatLikes = Object.fromEntries(
291
+ Object.entries(d.treatLikes).filter(([rung, like]) => from.treatLikes[rung] !== like),
292
+ );
293
+ const doc = applyPatch(a.doc, patchBetween(from.doc, d.doc));
294
+ return { ...s, drafts: { ...s.drafts, [a.name]: { ...fresh(a.name, a.doc), doc, treatLikes } } };
295
+ }
296
+ case "forget": {
297
+ const { [a.name]: _gone, ...drafts } = s.drafts;
298
+ return { ...s, drafts, profile: s.profile === a.name ? null : s.profile };
299
+ }
300
+ case "open":
301
+ return { ...s, dialogs: [...s.dialogs, a.dialog], armed: null };
302
+ case "replace":
303
+ return { ...s, dialogs: [...s.dialogs.slice(0, -1), a.dialog], armed: null };
304
+ case "close": {
305
+ const top = s.dialogs.at(-1);
306
+ if (top?.kind === "save" && top.saving) return s;
307
+ return { ...s, dialogs: s.dialogs.slice(0, -1), armed: null };
308
+ }
309
+ case "saving": {
310
+ const at = s.dialogs.findIndex((d) => d.kind === "save" && d.purpose.name === a.name);
311
+ const d = s.dialogs[at];
312
+ if (d?.kind !== "save" || (d.saving === true) === a.on) return s;
313
+ const dialogs = [...s.dialogs];
314
+ dialogs[at] = { ...d, saving: a.on };
315
+ return { ...s, dialogs };
316
+ }
317
+ case "input":
318
+ case "invalid": {
319
+ const top = s.dialogs.at(-1);
320
+ if (!top) return s;
321
+ let next: Dialog = top;
322
+ if (top.kind === "prompt")
323
+ next = a.type === "input" ? { ...top, value: a.value, error: null } : { ...top, error: a.error };
324
+ else if (top.kind === "save" && a.type === "invalid") next = { ...top, error: a.error };
325
+ return next === top ? s : { ...s, dialogs: [...s.dialogs.slice(0, -1), next] };
326
+ }
327
+ case "run":
328
+ return { ...s, run: a.id, armed: null };
329
+ case "pause":
330
+ return { ...s, paused: !s.paused };
331
+ case "arm":
332
+ return { ...s, armed: { what: a.what, target: a.target, at: a.at } };
333
+ case "disarm":
334
+ return s.armed ? { ...s, armed: null } : s;
335
+ }
336
+ }
@@ -0,0 +1,75 @@
1
+ import type { ProfilePatch } from "../../domain/profile.ts";
2
+ import type { CommandId } from "./commands.ts";
3
+ import type { AppApi } from "./providers/app.tsx";
4
+ import type { AppKeymap } from "./providers/keymap.tsx";
5
+ import type { Action } from "./state.ts";
6
+
7
+ /** One step towards a screen: a command as a key would run it, a reducer action, or a profile shown. */
8
+ export type Step = { command: CommandId } | { action: Action } | { show: string } | { edit: ProfilePatch };
9
+
10
+ export interface Story {
11
+ name: string;
12
+ title: string;
13
+ steps: Step[];
14
+ }
15
+
16
+ const SHOWN: Step[] = [{ show: "default" }];
17
+ const DIRTY: Step[] = [
18
+ ...SHOWN,
19
+ { edit: { budget: { usd: 5 }, roles: { verifier: { access: "read-only" } } } },
20
+ ];
21
+
22
+ /**
23
+ * Every view and widget in a state worth seeing (spec §9.4 `CATHERD_STORY=1`): the storybook lists them
24
+ * and the frame snapshots render them at three sizes, in Unicode and plain.
25
+ */
26
+ export const STORIES: Story[] = [
27
+ {
28
+ name: "status",
29
+ title: "Status: checks with fixes, profile, recent runs",
30
+ steps: [{ command: "tab.status" }],
31
+ },
32
+ { name: "profiles", title: "Profiles: the tree", steps: SHOWN },
33
+ { name: "profiles-dirty", title: "Profiles: two unsaved changes", steps: DIRTY },
34
+ {
35
+ name: "save",
36
+ title: "Save dialog: diff, agents, what applies when",
37
+ steps: [...DIRTY, { command: "profile.save" }],
38
+ },
39
+ { name: "quit", title: "Quit with unsaved changes", steps: [...DIRTY, { command: "app.quit" }] },
40
+ { name: "new-profile", title: "New profile prompt", steps: [{ command: "profile.new" }] },
41
+ { name: "profile-list", title: "Profile list", steps: [{ command: "profile.list" }] },
42
+ {
43
+ name: "palette",
44
+ title: "Command palette",
45
+ steps: [{ command: "tab.status" }, { command: "app.palette" }],
46
+ },
47
+ { name: "help", title: "Keyboard shortcuts", steps: [{ command: "tab.status" }, { command: "app.help" }] },
48
+ { name: "runs", title: "Runs: the list", steps: [{ command: "tab.runs" }] },
49
+ {
50
+ name: "run",
51
+ title: "Runs: one live run",
52
+ steps: [{ command: "tab.runs" }, { action: { type: "run", id: "20260926-114800-jobs-screen" } }],
53
+ },
54
+ ];
55
+
56
+ /** One step, as a key or the reducer would take it. */
57
+ export function applyStep(s: Step, app: AppApi, keymap: AppKeymap): void {
58
+ if ("command" in s) keymap.dispatchCommand(s.command);
59
+ else if ("action" in s) app.dispatch(s.action);
60
+ else if ("show" in s) app.dispatch({ type: "show", name: s.show, doc: app.effects.readProfile(s.show) });
61
+ else app.dispatch({ type: "edit", patch: s.edit });
62
+ }
63
+
64
+ /** Plays a story's steps, letting the app draw between them (a step may need the view the last one opened). */
65
+ export async function playStory(
66
+ story: Story,
67
+ app: AppApi,
68
+ keymap: AppKeymap,
69
+ wait: () => Promise<void>,
70
+ ): Promise<void> {
71
+ for (const s of story.steps) {
72
+ applyStep(s, app, keymap);
73
+ await wait();
74
+ }
75
+ }
@@ -0,0 +1,118 @@
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
+ /** `04:12`, or `1:02:03` past an hour. */
61
+ export function clock(secs: number): string {
62
+ const s = Math.max(0, Math.floor(secs));
63
+ const h = Math.floor(s / 3600);
64
+ const mm = String(Math.floor(s / 60) % 60).padStart(2, "0");
65
+ const ss = String(s % 60).padStart(2, "0");
66
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
67
+ }
68
+
69
+ /** "just now", "12s ago", "4m ago", "2h ago", "3d ago". */
70
+ export function ago(ms: number): string {
71
+ const s = Math.max(0, Math.floor(ms / 1000));
72
+ if (s < 2) return "just now";
73
+ if (s < 60) return `${s}s ago`;
74
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
75
+ if (s < 86_400) return `${Math.floor(s / 3600)}h ago`;
76
+ return `${Math.floor(s / 86_400)}d ago`;
77
+ }
78
+
79
+ /** `gpt-6-sol#medium` from `codex:gpt-6-sol#medium`: the backend goes, and a provider path keeps its last part. */
80
+ export function shortRung(rung: string): string {
81
+ const noBackend = rung.slice(rung.indexOf(":") + 1);
82
+ return noBackend.slice(noBackend.lastIndexOf("/") + 1);
83
+ }
84
+
85
+ const ASCII: Record<string, string> = {
86
+ "—": "-",
87
+ "–": "-",
88
+ "·": "-",
89
+ "…": "...",
90
+ "⋯": "...",
91
+ "→": "->",
92
+ "←": "<-",
93
+ "↑": "^",
94
+ "↓": "v",
95
+ "●": "*",
96
+ "•": "*",
97
+ "◌": ".",
98
+ "✓": "+",
99
+ "✗": "x",
100
+ "▸": ">",
101
+ "▾": "v",
102
+ "┃": "|",
103
+ "─": "-",
104
+ "█": "#",
105
+ "░": ".",
106
+ "×": "x",
107
+ ω: "w",
108
+ };
109
+
110
+ /** `s` with every character outside printable ASCII replaced, for --plain (spec §9.3). */
111
+ export function ascii(s: string): string {
112
+ let out = "";
113
+ for (const ch of s) {
114
+ const c = ch.codePointAt(0) ?? 0;
115
+ out += c >= 0x20 && c <= 0x7e ? ch : (ASCII[ch] ?? "?");
116
+ }
117
+ return out;
118
+ }