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,162 @@
1
+ import type { Clock } from "@opentui/core";
2
+ import { useKeymap } from "@opentui/keymap/react";
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useReducer,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import type { ProfileDoc } from "../../../domain/profile.ts";
14
+ import type { Effects } from "../effects.ts";
15
+ import { type Action, type AppState, type Purpose, reduce } from "../state.ts";
16
+ import { setModal } from "./keymap.tsx";
17
+ import { type Toast, useToasts } from "./toast.tsx";
18
+
19
+ export type BackKind = "input" | "view";
20
+
21
+ /** What an answer carries besides its value. */
22
+ interface AnswerExtra {
23
+ /** the save dialog's: the profile file as its last preview read it, the only one the save may write over */
24
+ shown?: ProfileDoc;
25
+ /** the save dialog's: reads the file again and shows the new preview with the changed-on-disk notice */
26
+ changed?: () => void;
27
+ }
28
+
29
+ export type DialogHandler = (purpose: Purpose, value: string, extra: AnswerExtra) => void | Promise<void>;
30
+
31
+ export interface AppApi {
32
+ /** the state this render drew */
33
+ state: AppState;
34
+ /** the state now, after every dispatch so far: what a command handler must read */
35
+ getState(): AppState;
36
+ /** runs the reducer now, so the keymap's mode follows the dialog stack before the next key */
37
+ dispatch(a: Action): void;
38
+ effects: Effects;
39
+ clock: Clock;
40
+ toast(t: Toast): void;
41
+ /** lines printed to stdout once the TUI has exited: what the user must keep (spec §9.4); a line held already is dropped */
42
+ keep(lines: string[]): void;
43
+ exit(code: number): void;
44
+ /** copies to the clipboard (OSC 52); false when the terminal cannot */
45
+ copy(text: string): boolean;
46
+ /** answers the top dialog: runs the handler registered for its purpose */
47
+ answer(value: string, extra?: AnswerExtra): void;
48
+ onDialog(type: Purpose["type"], fn: DialogHandler): () => void;
49
+ /**
50
+ * esc runs the newest back handler (clear a filter, leave a run); ctrl+c runs only the newest one that
51
+ * clears a text input.
52
+ */
53
+ onBack(fn: () => void, kind: BackKind): () => void;
54
+ back(kind?: BackKind): boolean;
55
+ }
56
+
57
+ const AppContext = createContext<AppApi | null>(null);
58
+
59
+ export function useApp(): AppApi {
60
+ const app = useContext(AppContext);
61
+ if (!app) throw new Error("useApp must be used inside AppProvider");
62
+ return app;
63
+ }
64
+
65
+ export function AppProvider(props: {
66
+ effects: Effects;
67
+ clock: Clock;
68
+ initial: AppState;
69
+ copy: (text: string) => boolean;
70
+ onExit: (code: number, kept: string[]) => void;
71
+ children: ReactNode;
72
+ }) {
73
+ const keymap = useKeymap();
74
+ const toasts = useToasts();
75
+ const [state, setState] = useState(props.initial);
76
+ const stateRef = useRef(props.initial);
77
+ const kept = useRef<string[]>([]);
78
+ const handlers = useRef(new Map<string, DialogHandler[]>());
79
+ const backs = useRef<{ fn: () => void; kind: BackKind }[]>([]);
80
+ const propsRef = useRef(props);
81
+ propsRef.current = props;
82
+ const api = useMemo<Omit<AppApi, "state">>(
83
+ () => ({
84
+ getState: () => stateRef.current,
85
+ dispatch(a) {
86
+ const next = reduce(stateRef.current, a);
87
+ if (next === stateRef.current) return;
88
+ stateRef.current = next;
89
+ setModal(keymap, next.dialogs.length > 0);
90
+ setState(next);
91
+ },
92
+ effects: props.effects,
93
+ clock: props.clock,
94
+ toast: (t) => toasts.show(t),
95
+ keep(lines) {
96
+ // a line already kept (an agent linked by both Save and Make active) prints once
97
+ for (const l of lines) if (!kept.current.includes(l)) kept.current.push(l);
98
+ },
99
+ exit: (code) => propsRef.current.onExit(code, kept.current),
100
+ copy: (text) => propsRef.current.copy(text),
101
+ answer(value, extra = {}) {
102
+ const top = stateRef.current.dialogs.at(-1);
103
+ if (!top) return;
104
+ const fn = handlers.current.get(top.purpose.type)?.at(-1);
105
+ if (fn) void fn(top.purpose, value, extra);
106
+ },
107
+ onDialog(type, fn) {
108
+ const list = handlers.current.get(type) ?? [];
109
+ handlers.current.set(type, [...list, fn]);
110
+ return () =>
111
+ handlers.current.set(
112
+ type,
113
+ (handlers.current.get(type) ?? []).filter((f) => f !== fn),
114
+ );
115
+ },
116
+ onBack(fn, kind) {
117
+ const entry = { fn, kind };
118
+ backs.current.push(entry);
119
+ return () => {
120
+ backs.current = backs.current.filter((b) => b !== entry);
121
+ };
122
+ },
123
+ back(kind) {
124
+ const b = backs.current.findLast((x) => kind === undefined || x.kind === kind);
125
+ if (!b) return false;
126
+ b.fn();
127
+ return true;
128
+ },
129
+ }),
130
+ // the toast api and the keymap are stable for the app's life
131
+ [keymap, props.effects, props.clock, toasts.show],
132
+ );
133
+ const value = useMemo(() => ({ ...api, state }), [api, state]);
134
+ return <AppContext.Provider value={value}>{props.children}</AppContext.Provider>;
135
+ }
136
+
137
+ /** Registers the handler for a dialog purpose while the caller is mounted. */
138
+ export function useDialogHandler(type: Purpose["type"], fn: DialogHandler): void {
139
+ const app = useApp();
140
+ const ref = useRef(fn);
141
+ ref.current = fn;
142
+ useEffect(() => app.onDialog(type, (p, v, x) => ref.current(p, v, x)), [app.onDialog, type]);
143
+ }
144
+
145
+ /** Registers a back handler while `active`: `input` clears a text input, `view` leaves a view. */
146
+ export function useBack(active: boolean, kind: BackKind, fn: () => void): void {
147
+ const app = useApp();
148
+ const ref = useRef(fn);
149
+ ref.current = fn;
150
+ useEffect(() => (active ? app.onBack(() => ref.current(), kind) : undefined), [active, kind, app.onBack]);
151
+ }
152
+
153
+ /** The clock's time, re-read every `everyMs` (for "updated 3s ago"). */
154
+ export function useNow(everyMs: number): number {
155
+ const { clock } = useApp();
156
+ const [, tick] = useReducer((n: number) => n + 1, 0);
157
+ useEffect(() => {
158
+ const h = clock.setInterval(tick, everyMs);
159
+ return () => clock.clearInterval(h);
160
+ }, [clock, everyMs]);
161
+ return clock.now();
162
+ }
@@ -0,0 +1,57 @@
1
+ import { useTerminalDimensions } from "@opentui/react";
2
+ import { Component, type ReactNode, useEffect } from "react";
3
+ import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
4
+ import { wrap } from "../text.ts";
5
+ import { Line } from "../widgets/line.tsx";
6
+ import { useApp } from "./app.tsx";
7
+ import { setModal, useCommandLayer, useKeymap } from "./keymap.tsx";
8
+
9
+ /**
10
+ * What shows when a view throws while drawing: the error, its fix and a way out. Everything under the
11
+ * boundary is gone, with its keys, so this registers its own quit: q or ctrl+c exits 1 and prints the
12
+ * error after the terminal is given back.
13
+ */
14
+ function Crashed(props: { error: unknown }) {
15
+ const app = useApp();
16
+ const keymap = useKeymap();
17
+ const dims = useTerminalDimensions();
18
+ const e = props.error;
19
+ const message = errorMessage(e);
20
+ const fix = isCatherdError(e) ? e.fix : undefined;
21
+ // a dialog open when it broke is gone with the rest: its modal mode would keep q from reaching quit
22
+ useEffect(() => setModal(keymap, false), [keymap]);
23
+ const quit = () => {
24
+ app.keep([`catherd: ${message}`, ...(fix ? [`fix: ${fix}`] : [])]);
25
+ app.exit(1);
26
+ };
27
+ useCommandLayer("global", { "app.interrupt": quit });
28
+ useCommandLayer("app", { "app.quit": quit });
29
+ const w = Math.max(10, dims.width - 2);
30
+ const lines: { text: string; tone?: "error" | "muted" }[] = [
31
+ { text: "" },
32
+ ...wrap(`catherd stopped: ${message}`, w).map((text) => ({ text, tone: "error" as const })),
33
+ ...(fix ? wrap(`fix: ${fix}`, w).map((text) => ({ text, tone: "muted" as const })) : []),
34
+ { text: "" },
35
+ { text: "press q or ctrl+c to quit; unsaved changes are lost", tone: "muted" },
36
+ ];
37
+ return (
38
+ <box flexDirection="column" width={dims.width} height={dims.height}>
39
+ {lines.map((l, i) => (
40
+ <Line key={i} width={dims.width} parts={[{ text: ` ${l.text}`, tone: l.tone }]} />
41
+ ))}
42
+ </box>
43
+ );
44
+ }
45
+
46
+ /** Catches an error thrown while drawing, so the user keeps a way out of the alternate screen. */
47
+ export class AppErrorBoundary extends Component<{ children: ReactNode }, { error: unknown }> {
48
+ override state: { error: unknown } = { error: null };
49
+
50
+ static getDerivedStateFromError(error: unknown): { error: unknown } {
51
+ return { error: error ?? new Error("unknown error") };
52
+ }
53
+
54
+ override render(): ReactNode {
55
+ return this.state.error !== null ? <Crashed error={this.state.error} /> : this.props.children;
56
+ }
57
+ }
@@ -0,0 +1,160 @@
1
+ import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
3
+ import type { DoctorReport } from "../../../services/doctor.ts";
4
+ import type { Effects, RunRow, SessionRow } from "../effects.ts";
5
+ import { useApp } from "./app.tsx";
6
+
7
+ export interface Polled<T> {
8
+ value: T | null;
9
+ error: string | null;
10
+ /** what to do about `error`, when the failure says (a CatherdError's fix) */
11
+ fix: string | null;
12
+ /** clock time of the last good read, null before the first */
13
+ at: number | null;
14
+ refresh(): void;
15
+ }
16
+
17
+ /**
18
+ * Reads `read` now and every `everyMs` while not paused, off the render path (spec §9.4: runs data is
19
+ * polled off the render path): a read runs in a timer callback, never while React renders. It reads at once
20
+ * when it mounts, when `key` changes, on `refresh`, when it resumes and when it is turned back on; a new
21
+ * cadence or a pause only reschedules. `off`: no read at all (the data is not shown).
22
+ */
23
+ export function usePoll<T>(
24
+ read: () => T,
25
+ everyMs: number,
26
+ o: { paused?: boolean; key?: string; off?: boolean } = {},
27
+ ): Polled<T> {
28
+ const { clock } = useApp();
29
+ const [state, setState] = useState<{
30
+ value: T | null;
31
+ error: string | null;
32
+ fix: string | null;
33
+ at: number | null;
34
+ }>({ value: null, error: null, fix: null, at: null });
35
+ const readRef = useRef(read);
36
+ readRef.current = read;
37
+ const [nonce, setNonce] = useState(0);
38
+ // what the last effect run read for: a read at once is due only when this moves on
39
+ const last = useRef<{ clock: unknown; key: string | undefined; nonce: number; paused: boolean } | null>(
40
+ null,
41
+ );
42
+ const paused = o.paused ?? false;
43
+ useEffect(() => {
44
+ if (o.off) {
45
+ last.current = null;
46
+ return;
47
+ }
48
+ const was = last.current;
49
+ last.current = { clock, key: o.key, nonce, paused };
50
+ const now =
51
+ was === null ||
52
+ was.clock !== clock ||
53
+ was.key !== o.key ||
54
+ was.nonce !== nonce ||
55
+ (was.paused && !paused);
56
+ const run = () => {
57
+ try {
58
+ setState({ value: readRef.current(), error: null, fix: null, at: clock.now() });
59
+ } catch (e) {
60
+ // `at` stays the time of the last good read: old rows must not read as fresh
61
+ setState((s) => ({
62
+ ...s,
63
+ error: errorMessage(e),
64
+ fix: isCatherdError(e) && e.fix ? e.fix : null,
65
+ }));
66
+ }
67
+ };
68
+ const first = now ? clock.setTimeout(run, 0) : null;
69
+ const every = paused ? null : clock.setInterval(run, everyMs);
70
+ return () => {
71
+ if (first !== null) clock.clearTimeout(first);
72
+ if (every !== null) clock.clearInterval(every);
73
+ };
74
+ }, [clock, everyMs, paused, o.key, o.off, nonce]);
75
+ return { ...state, refresh: () => setNonce((n) => n + 1) };
76
+ }
77
+
78
+ /** `read` once, off the render path, and again whenever `key` changes or `refresh` is called. */
79
+ export function useLoad<T>(read: () => T, key = ""): Polled<T> {
80
+ return usePoll(read, 0, { paused: true, key });
81
+ }
82
+
83
+ /** How often the run list is read again, unless paused (spec §9.1 Runs: "updated Ns ago"; `p` pauses). */
84
+ export const RUNS_EVERY_MS = 2_000;
85
+
86
+ export interface Data {
87
+ report: DoctorReport | null;
88
+ checking: boolean;
89
+ checkedAt: number | null;
90
+ checkError: string | null;
91
+ recheck(): void;
92
+ runs: Polled<{ rows: RunRow[]; warnings: string[] }>;
93
+ /** spec §4: the Runs tab's sessions, read on the runs' cadence while their list is shown */
94
+ sessions: Polled<{ rows: SessionRow[]; warnings: string[] }>;
95
+ /** the profile names and the active one; read on the runs' cadence and after every profile write */
96
+ profiles: Polled<ReturnType<Effects["profiles"]>>;
97
+ }
98
+
99
+ const DataContext = createContext<Data | null>(null);
100
+
101
+ export function useData(): Data {
102
+ const d = useContext(DataContext);
103
+ if (!d) throw new Error("useData must be used inside DataProvider");
104
+ return d;
105
+ }
106
+
107
+ /** Spec §9.4 providers: the data every tab and the header share, read off the render path. */
108
+ export function DataProvider(props: { children: ReactNode }) {
109
+ const app = useApp();
110
+ const [report, setReport] = useState<DoctorReport | null>(null);
111
+ const [checking, setChecking] = useState(false);
112
+ const [checkedAt, setCheckedAt] = useState<number | null>(null);
113
+ const [checkError, setCheckError] = useState<string | null>(null);
114
+ const [nonce, setNonce] = useState(0);
115
+ useEffect(() => {
116
+ let live = true;
117
+ setChecking(true);
118
+ const h = app.clock.setTimeout(() => {
119
+ app.effects
120
+ .doctor()
121
+ .then((r) => {
122
+ if (!live) return;
123
+ setReport(r);
124
+ setCheckError(null);
125
+ })
126
+ .catch((e: unknown) => live && setCheckError(errorMessage(e)))
127
+ .finally(() => {
128
+ if (!live) return;
129
+ setChecking(false);
130
+ setCheckedAt(app.clock.now());
131
+ });
132
+ }, 0);
133
+ return () => {
134
+ live = false;
135
+ app.clock.clearTimeout(h);
136
+ };
137
+ }, [app.effects, app.clock, nonce]);
138
+ const runs = usePoll(() => app.effects.runs(), RUNS_EVERY_MS, { paused: app.state.paused });
139
+ // spec §9.4: reading the sessions scans every run, so only while their list is on screen
140
+ const sessions = usePoll(() => app.effects.sessions(), RUNS_EVERY_MS, {
141
+ paused: app.state.paused,
142
+ off: app.state.tab !== "runs" || app.state.session !== null,
143
+ });
144
+ // cheap (a directory listing and two small reads), and another terminal may change them at any time
145
+ const profiles = usePoll(() => app.effects.profiles(), RUNS_EVERY_MS);
146
+ const value = useMemo(
147
+ () => ({
148
+ report,
149
+ checking,
150
+ checkedAt,
151
+ checkError,
152
+ recheck: () => setNonce((n) => n + 1),
153
+ runs,
154
+ sessions,
155
+ profiles,
156
+ }),
157
+ [report, checking, checkedAt, checkError, runs, sessions, profiles],
158
+ );
159
+ return <DataContext.Provider value={value}>{props.children}</DataContext.Provider>;
160
+ }
@@ -0,0 +1,166 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/context/keymap.tsx (MIT, © 2025 opencode);
2
+ // see THIRD_PARTY_NOTICES.md.
3
+ import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core";
4
+ import type { Keymap } from "@opentui/keymap";
5
+ import {
6
+ registerBackspacePopsPendingSequence,
7
+ registerCommaBindings,
8
+ registerEscapeClearsPendingSequence,
9
+ registerTimedLeader,
10
+ } from "@opentui/keymap/addons/opentui";
11
+ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui";
12
+ import { KeymapProvider, useBindings, useKeymap } from "@opentui/keymap/react";
13
+ import { flushSync, useRenderer } from "@opentui/react";
14
+ import { createContext, type ReactNode, useContext, useEffect, useMemo, useReducer, useRef } from "react";
15
+ import {
16
+ type CommandDef,
17
+ type CommandId,
18
+ commandsIn,
19
+ DEFAULT_KEYS,
20
+ isGuarded,
21
+ isPrintable,
22
+ type Keybinds,
23
+ LEADER,
24
+ LEADER_TIMEOUT_MS,
25
+ type Scope,
26
+ } from "../commands.ts";
27
+
28
+ export type AppKeymap = Keymap<Renderable, KeyEvent>;
29
+ export type Handlers = Partial<Record<CommandId, () => void>>;
30
+
31
+ /** The keymap data key that holds the mode: `base`, or `modal` while a dialog is open (spec §9.2). */
32
+ export const MODE = "catherd.mode";
33
+
34
+ /** opencode's keymap setup: comma lists, esc and backspace for pending sequences, the timed leader, modes. */
35
+ export function createAppKeymap(renderer: CliRenderer): AppKeymap {
36
+ const k = createDefaultOpenTuiKeymap(renderer);
37
+ registerCommaBindings(k);
38
+ registerEscapeClearsPendingSequence(k);
39
+ registerBackspacePopsPendingSequence(k);
40
+ registerTimedLeader(k, { trigger: LEADER, name: "leader", timeoutMs: LEADER_TIMEOUT_MS });
41
+ k.setData(MODE, "base");
42
+ // a layer names its mode; "global" layers are live in every mode
43
+ k.registerLayerFields({
44
+ mode(value, ctx) {
45
+ if (value !== "global") ctx.require(MODE, value);
46
+ },
47
+ });
48
+ const attr = (name: string) => (value: unknown, ctx: { attr(n: string, v: unknown): void }) =>
49
+ ctx.attr(name, value);
50
+ k.registerCommandFields({
51
+ palette: attr("palette"),
52
+ suggested: attr("suggested"),
53
+ hint: attr("hint"),
54
+ short: attr("short"),
55
+ scope: attr("scope"),
56
+ cli: attr("cli"),
57
+ });
58
+ return k;
59
+ }
60
+
61
+ /** Puts the keymap in modal mode while a dialog is open; synchronous, so a key right after sees it. */
62
+ export const setModal = (k: AppKeymap, modal: boolean): void => k.setData(MODE, modal ? "modal" : "base");
63
+
64
+ const KeybindsContext = createContext<Keybinds>(DEFAULT_KEYS);
65
+ export const useKeybinds = (): Keybinds => useContext(KeybindsContext);
66
+
67
+ /** The keymap for the renderer, and the keys in force. */
68
+ export function AppKeymapProvider(props: { keybinds: Keybinds; keymap?: AppKeymap; children: ReactNode }) {
69
+ const renderer = useRenderer();
70
+ const keymap = useMemo(() => props.keymap ?? createAppKeymap(renderer), [props.keymap, renderer]);
71
+ return (
72
+ <KeymapProvider keymap={keymap}>
73
+ <KeybindsContext.Provider value={props.keybinds}>{props.children}</KeybindsContext.Provider>
74
+ </KeymapProvider>
75
+ );
76
+ }
77
+
78
+ const modeOf = (scope: Scope): "global" | "modal" | "base" =>
79
+ scope === "global" ? "global" : scope === "dialog" ? "modal" : "base";
80
+
81
+ /**
82
+ * Registers the commands of `scope` that have a handler, bound to their keys, while the calling component
83
+ * is mounted. Printable keys of guarded scopes, and every key of a `row.*` scope, go quiet while a text
84
+ * input has focus, so the input gets them as text; a `filter` layer is live only then.
85
+ */
86
+ export function useCommandLayer(scope: Scope, handlers: Handlers): void {
87
+ const renderer = useRenderer();
88
+ const keys = useKeybinds();
89
+ const ref = useRef(handlers);
90
+ ref.current = handlers;
91
+ const ids = commandsIn(scope)
92
+ .filter((c) => handlers[c.id as CommandId])
93
+ .map((c) => c.id as CommandId);
94
+ const idsKey = ids.join(" ");
95
+ const mode = modeOf(scope);
96
+ const typing = () => renderer.currentFocusedEditor !== null;
97
+ const layerEnabled = scope === "filter" ? typing : scope.startsWith("row.") ? () => !typing() : () => true;
98
+ const command = (c: CommandDef) => ({
99
+ name: c.id,
100
+ title: c.title,
101
+ group: c.group,
102
+ scope: c.scope,
103
+ ...(c.palette ? { palette: true } : {}),
104
+ ...(c.suggested ? { suggested: true } : {}),
105
+ ...(c.hint !== undefined ? { hint: c.hint } : {}),
106
+ ...(c.short ? { short: c.short } : {}),
107
+ ...(c.cli ? { cli: c.cli } : {}),
108
+ // a key's update is drawn before the next key of the same chunk is read: a dialog or a filter it
109
+ // opens has its input mounted and focused by then, so no letter typed right after it is lost
110
+ run: () => {
111
+ flushSync(() => ref.current[c.id as CommandId]?.());
112
+ },
113
+ });
114
+ const defs = commandsIn(scope).filter((c) => ids.includes(c.id as CommandId));
115
+ const guarded = isGuarded(scope);
116
+ const bindingsFor = (printable: boolean) =>
117
+ defs.flatMap((c) =>
118
+ keys[c.id as CommandId]
119
+ .filter((k) => (guarded ? isPrintable(k) === printable : !printable))
120
+ .map((k) => ({ key: k, cmd: c.id })),
121
+ );
122
+ useBindings(
123
+ () => ({ mode, enabled: layerEnabled, commands: defs.map(command), bindings: bindingsFor(false) }),
124
+ [scope, idsKey, keys],
125
+ );
126
+ // printable keys of a guarded scope: their own layer, quiet while a text input has focus
127
+ useBindings(
128
+ () => ({ mode, enabled: () => !typing(), bindings: guarded ? bindingsFor(true) : [] }),
129
+ [scope, idsKey, keys],
130
+ );
131
+ }
132
+
133
+ export interface CommandEntry {
134
+ id: CommandId;
135
+ title: string;
136
+ group: string;
137
+ palette: boolean;
138
+ suggested: boolean;
139
+ hint: number | null;
140
+ short: string | null;
141
+ cli: string | null;
142
+ }
143
+
144
+ /** The commands a key could run right now, or that `dispatch` could; what the palette and help list. */
145
+ export function reachableCommands(k: AppKeymap): CommandEntry[] {
146
+ return k.getCommandEntries({ visibility: "reachable" }).map(({ command: c }) => ({
147
+ id: c.name as CommandId,
148
+ title: String(c.title ?? c.name),
149
+ group: String(c.group ?? ""),
150
+ palette: c.palette === true,
151
+ suggested: c.suggested === true,
152
+ hint: typeof c.hint === "number" ? c.hint : null,
153
+ short: typeof c.short === "string" ? c.short : null,
154
+ cli: typeof c.cli === "string" ? c.cli : null,
155
+ }));
156
+ }
157
+
158
+ /** Re-renders the caller whenever the keymap's live bindings change (a layer, a mode, focus). */
159
+ export function useKeymapVersion(): number {
160
+ const k = useKeymap();
161
+ const [v, bump] = useReducer((n: number) => n + 1, 0);
162
+ useEffect(() => k.on("state", () => bump()), [k]);
163
+ return v;
164
+ }
165
+
166
+ export { useKeymap };
@@ -0,0 +1,45 @@
1
+ import type { Clock } from "@opentui/core";
2
+ import type { ReactNode } from "react";
3
+ import type { Keybinds } from "../commands.ts";
4
+ import type { Effects } from "../effects.ts";
5
+ import type { AppState } from "../state.ts";
6
+ import type { Ui } from "../theme.ts";
7
+ import { AppProvider } from "./app.tsx";
8
+ import { AppErrorBoundary } from "./boundary.tsx";
9
+ import { type AppKeymap, AppKeymapProvider } from "./keymap.tsx";
10
+ import { ThemeProvider } from "./theme.tsx";
11
+ import { ToastProvider } from "./toast.tsx";
12
+
13
+ /**
14
+ * Every provider, in dependency order: theme → keymap → toasts → app state (dialogs, handlers), and
15
+ * under them the boundary that keeps a way out when a view throws.
16
+ */
17
+ export function Providers(props: {
18
+ ui: Ui;
19
+ keybinds: Keybinds;
20
+ keymap?: AppKeymap;
21
+ effects: Effects;
22
+ clock: Clock;
23
+ initial: AppState;
24
+ copy: (text: string) => boolean;
25
+ onExit: (code: number, kept: string[]) => void;
26
+ children: ReactNode;
27
+ }) {
28
+ return (
29
+ <ThemeProvider ui={props.ui}>
30
+ <AppKeymapProvider keybinds={props.keybinds} keymap={props.keymap}>
31
+ <ToastProvider clock={props.clock}>
32
+ <AppProvider
33
+ effects={props.effects}
34
+ clock={props.clock}
35
+ initial={props.initial}
36
+ copy={props.copy}
37
+ onExit={props.onExit}
38
+ >
39
+ <AppErrorBoundary>{props.children}</AppErrorBoundary>
40
+ </AppProvider>
41
+ </ToastProvider>
42
+ </AppKeymapProvider>
43
+ </ThemeProvider>
44
+ );
45
+ }
@@ -0,0 +1,17 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+ import { paint, type Token, type Ui } from "../theme.ts";
3
+
4
+ const ThemeContext = createContext<Ui>({ plain: false, color: false, reducedMotion: true, mode: "dark" });
5
+
6
+ /** Spec §9.4 providers: the theme, one per concern. */
7
+ export function ThemeProvider(props: { ui: Ui; children: ReactNode }) {
8
+ return <ThemeContext.Provider value={props.ui}>{props.children}</ThemeContext.Provider>;
9
+ }
10
+
11
+ export const useUi = (): Ui => useContext(ThemeContext);
12
+
13
+ /** A token's colour for this terminal: undefined under NO_COLOR and --plain. */
14
+ export function useTone(): (t: Token) => string | undefined {
15
+ const ui = useUi();
16
+ return (t) => paint(ui, t);
17
+ }
@@ -0,0 +1,77 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/ui/toast.tsx (MIT, © 2025 opencode);
2
+ // see THIRD_PARTY_NOTICES.md.
3
+ import type { Clock } from "@opentui/core";
4
+ import { useRenderer } from "@opentui/react";
5
+ import {
6
+ createContext,
7
+ type ReactNode,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ } from "react";
15
+ import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
16
+
17
+ type ToastVariant = "info" | "success" | "warning" | "error";
18
+ export interface Toast {
19
+ variant: ToastVariant;
20
+ message: string;
21
+ /** the command that fixes it, shown in full on its own lines; the toast stays until the next key */
22
+ fix?: string;
23
+ }
24
+
25
+ /** How long a toast stays before the next one in the queue shows (one with a fix waits for a key). */
26
+ export const TOAST_MS = 4_000;
27
+
28
+ /** An error as a toast: its message, and its fix command when it has one. */
29
+ export const errorToast = (e: unknown): Toast => ({
30
+ variant: "error",
31
+ message: errorMessage(e),
32
+ ...(isCatherdError(e) && e.fix ? { fix: e.fix } : {}),
33
+ });
34
+
35
+ interface ToastApi {
36
+ current: Toast | null;
37
+ queued: number;
38
+ show(t: Toast): void;
39
+ }
40
+
41
+ const ToastContext = createContext<ToastApi>({ current: null, queued: 0, show: () => {} });
42
+
43
+ /**
44
+ * Spec §9.3: toasts queue at the top right; one shows at a time, for TOAST_MS each. A toast with a fix
45
+ * command stays until the next key, so the command the user must act on is not gone before it is read.
46
+ */
47
+ export function ToastProvider(props: { clock: Clock; children: ReactNode }) {
48
+ const renderer = useRenderer();
49
+ const [queue, setQueue] = useState<Toast[]>([]);
50
+ const queueRef = useRef(queue);
51
+ queueRef.current = queue;
52
+ const head = queue[0] ?? null;
53
+ useEffect(() => {
54
+ if (!head || head.fix) return;
55
+ const h = props.clock.setTimeout(() => setQueue((q) => q.slice(1)), TOAST_MS);
56
+ return () => props.clock.clearTimeout(h);
57
+ }, [head, props.clock]);
58
+ useEffect(() => {
59
+ // first in line, before the keymap: a toast the same key shows is not the one it dismisses
60
+ const onKey = () => {
61
+ const shown = queueRef.current[0];
62
+ if (shown?.fix) setQueue((q) => (q[0] === shown ? q.slice(1) : q));
63
+ };
64
+ renderer.keyInput.prependListener("keypress", onKey);
65
+ return () => {
66
+ renderer.keyInput.off("keypress", onKey);
67
+ };
68
+ }, [renderer]);
69
+ const show = useCallback((t: Toast) => setQueue((q) => [...q, t]), []);
70
+ const api = useMemo(
71
+ () => ({ current: head, queued: Math.max(0, queue.length - 1), show }),
72
+ [head, queue.length, show],
73
+ );
74
+ return <ToastContext.Provider value={api}>{props.children}</ToastContext.Provider>;
75
+ }
76
+
77
+ export const useToasts = (): ToastApi => useContext(ToastContext);