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
package/src/tui/theme.ts DELETED
@@ -1,177 +0,0 @@
1
- import { WriteStream } from "node:tty";
2
- import type { HarnessCost } from "../core/harness.ts";
3
-
4
- export type Mood = "good" | "working" | "waiting" | "failed" | "landed";
5
- export type Tone = "ginger" | "cream" | "charcoal" | "pink" | "green" | "amber" | "red" | "surface" | "ink";
6
- /** Node's getColorDepth: 1 none, 4 sixteen, 8 256, 24 true colour. */
7
- export type Depth = 1 | 4 | 8 | 24;
8
-
9
- export interface Ui {
10
- plain: boolean;
11
- reducedMotion: boolean;
12
- depth: Depth;
13
- }
14
-
15
- // OpenTUI renders true-colour RGB only and downsamples itself for the terminal it finds, so
16
- // every depth above 1 gets the same hex; depth 1 (NO_COLOR) gets no colour at all.
17
- const TONES: Record<Tone, string> = {
18
- ginger: "#E8833A",
19
- cream: "#F5E6C8",
20
- charcoal: "#4A4340",
21
- pink: "#FF6FA5",
22
- green: "#8FBF6A",
23
- amber: "#E0A63E",
24
- red: "#D9636A",
25
- surface: "#211C20",
26
- ink: "#1A1417",
27
- };
28
-
29
- /** The one accent colour: headings and the selected-row bar. Everything else stays neutral or dim. */
30
- export const ACCENT: Tone = "pink";
31
-
32
- export const GRADIENT: string[] = [TONES.ginger, TONES.pink];
33
-
34
- export function tint(tone: Tone, depth: Depth): string | undefined {
35
- return depth === 1 ? undefined : TONES[tone];
36
- }
37
-
38
- export function detectUi(rawArgs: string[], env: NodeJS.ProcessEnv = process.env): Ui {
39
- return {
40
- // is-unicode-supported answers exactly this on macOS and Linux.
41
- plain: rawArgs.includes("--plain") || env.TERM === "linux",
42
- reducedMotion: rawArgs.includes("--reduced-motion"),
43
- depth: WriteStream.prototype.getColorDepth.call(process.stdout as WriteStream, env) as Depth,
44
- };
45
- }
46
-
47
- export const FACES: Record<Mood, string> = {
48
- good: "=^.^=",
49
- working: "=o.o=",
50
- waiting: "=-.-=",
51
- failed: "=x.x=",
52
- landed: "=^ω^=",
53
- };
54
-
55
- export function face(mood: Mood, plain: boolean): string {
56
- return plain ? FACES[mood].replace("ω", "w") : FACES[mood];
57
- }
58
-
59
- const GLYPHS = {
60
- on: ["●", "*"],
61
- off: ["○", "o"],
62
- cat: ["🐈", "*"],
63
- paw: ["🐾", ""],
64
- dot: ["·", "-"],
65
- more: ["…", "..."],
66
- open: ["▾", "v"],
67
- shut: ["▸", ">"],
68
- cursor: ["▶", ">"],
69
- arrow: ["→", "->"],
70
- keys: ["↑↓", "up/down"],
71
- swap: ["⇄", "<->"],
72
- } as const;
73
-
74
- export type Glyph = keyof typeof GLYPHS;
75
- export const GLYPH_NAMES = Object.keys(GLYPHS) as Glyph[];
76
-
77
- export function glyph(g: Glyph, plain: boolean): string {
78
- return GLYPHS[g][plain ? 1 : 0];
79
- }
80
-
81
- /** Ready/warn/missing status, painted with the same two shapes `on`/`off` toggles use — a
82
- * warning is still a "filled" dot (amber instead of green), missing is the hollow one (red).
83
- * ponytail: --plain collapses warn into the ready glyph (a star), the spec's ASCII table only
84
- * names two dot characters, star and o; upgrade to a three-way ASCII split if that ever matters. */
85
- export type DotState = "ready" | "warn" | "missing";
86
- const DOT_TONE: Record<DotState, Tone> = { ready: "green", warn: "amber", missing: "red" };
87
-
88
- export function dot(state: DotState, plain: boolean): string {
89
- return glyph(state === "missing" ? "off" : "on", plain);
90
- }
91
-
92
- export function dotTint(state: DotState, depth: Depth): string | undefined {
93
- return tint(DOT_TONE[state], depth);
94
- }
95
-
96
- /** The conductor: ears, a face that follows the mood, a raised baton, paws. */
97
- export function mascot(mood: Mood, plain: boolean): [string, string, string] {
98
- return [" /\\_/\\ .", `(${face(mood, plain)})/`, ' (")(")'].map((l) => l.padEnd(9)) as [
99
- string,
100
- string,
101
- string,
102
- ];
103
- }
104
-
105
- export const HERDING = [
106
- "herding",
107
- "counting whiskers",
108
- "negotiating with Luna",
109
- "coaxing Sol off the keyboard",
110
- "lining up the kittens",
111
- "untangling the yarn",
112
- ] as const;
113
-
114
- export function herdLine(i: number, plain: boolean): string {
115
- return `${HERDING[i % HERDING.length]}${glyph("more", plain)}`;
116
- }
117
-
118
- /** Kneading paws (m/n) and a flicking tail. ASCII already, so --plain needs no second set. */
119
- export const SPINNER = {
120
- frames: ["m=o.o=~", "n=o.o=-", "m=o.o=_", "n=o.o=-"],
121
- ms: 160,
122
- copyMs: 2400,
123
- } as const;
124
-
125
- /** The init intro: 6 frames of 200 ms, 1.2 s in all, inside the spec's 1.5 s. */
126
- export const INTRO = { steps: 6, ms: 200 } as const;
127
-
128
- export const HARNESS_HINT: Record<"codex" | "opencode", string> = {
129
- codex: "native keeps your Codex config, hooks, skills, AGENTS.md; isolated saves tokens",
130
- opencode: "native keeps your opencode config, agents and plugins; isolated saves tokens",
131
- };
132
-
133
- const kTokens = (n: number) => `~${Math.round(n / 1000)}k`;
134
-
135
- export function costNote(h: HarnessCost): string {
136
- if (h.extraPerRun !== null) return `customizations ${kTokens(h.extraPerRun)} tokens/run`;
137
- if (h.nativeMedian !== null) return `native ${kTokens(h.nativeMedian)} tokens/run`;
138
- return h.isolatedMedian !== null ? `isolated ${kTokens(h.isolatedMedian)} tokens/run` : "";
139
- }
140
-
141
- /** The profile editor's HARNESS detail: the native/isolated choice plus what it costs, falling
142
- * back to the static hint (`HARNESS_HINT`) until a run has recorded a real cost. */
143
- export function harnessLine(
144
- backend: "codex" | "opencode",
145
- isolated: boolean,
146
- cost: HarnessCost | undefined,
147
- plain: boolean,
148
- ): string {
149
- const mode = isolated ? "isolated" : "native";
150
- const note = (cost && costNote(cost)) || HARNESS_HINT[backend];
151
- return `${mode} ${glyph("dot", plain)} ${note}`;
152
- }
153
-
154
- export function clock(secs: number): string {
155
- const s = Math.max(0, Math.floor(secs));
156
- const h = Math.floor(s / 3600);
157
- const mm = String(Math.floor(s / 60) % 60).padStart(2, "0");
158
- const ss = String(s % 60).padStart(2, "0");
159
- return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
160
- }
161
-
162
- /** "gpt-6-sol#medium" -> "sol#medium"; provider paths drop to the last segment. */
163
- export function shortRung(rung: string): string {
164
- return rung.replace(/^.*\//, "").replace(/^gpt-\d+-/, "");
165
- }
166
-
167
- export function nick(rung: string): string {
168
- const model = shortRung(rung).split("#")[0] ?? "";
169
- return model.charAt(0).toUpperCase() + model.slice(1);
170
- }
171
-
172
- export function climbLine(name: string, from: string, to: string, plain: boolean): string {
173
- const a = nick(from);
174
- const b = nick(to);
175
- const story = a === b ? `${a} digs deeper` : `${a} gave up, ${b} takes over`;
176
- return `${story} ${glyph("dot", plain)} ${name} ${shortRung(from)} ${glyph("arrow", plain)} ${shortRung(to)}`;
177
- }
package/src/tui/ui.tsx DELETED
@@ -1,267 +0,0 @@
1
- import type { BorderCharacters } from "@opentui/core";
2
- import { TextAttributes } from "@opentui/core";
3
- import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
- import { type ReactNode, useEffect, useState } from "react";
5
- import { VERSION } from "../version.ts";
6
- import {
7
- ACCENT,
8
- GRADIENT,
9
- glyph,
10
- herdLine,
11
- INTRO,
12
- type Mood,
13
- mascot,
14
- SPINNER,
15
- tint,
16
- type Ui,
17
- } from "./theme.ts";
18
-
19
- /** --plain's border: no box-drawing characters, just `+ - |` (spec: "ASCII borders"). */
20
- const ASCII_BORDER: BorderCharacters = {
21
- topLeft: "+",
22
- topRight: "+",
23
- bottomLeft: "+",
24
- bottomRight: "+",
25
- horizontal: "-",
26
- vertical: "|",
27
- topT: "+",
28
- bottomT: "+",
29
- leftT: "+",
30
- rightT: "+",
31
- cross: "+",
32
- };
33
-
34
- /** Every screen's chrome: a bordered panel with its title set into the top border, and a short
35
- * key-hint line outside and below it (so the hint never competes with the border for space). */
36
- /** The widest the layout grows; wider terminals centre it with margins on both sides. */
37
- const MAX_WIDTH = 110;
38
-
39
- export function Frame({
40
- ui,
41
- title,
42
- hint,
43
- mood = "good",
44
- above,
45
- children,
46
- }: {
47
- ui: Ui;
48
- title: string;
49
- hint: string;
50
- mood?: Mood;
51
- above?: ReactNode;
52
- children: ReactNode;
53
- }) {
54
- const { width } = useTerminalDimensions();
55
- const term = Math.max(width || 80, 80);
56
- const w = Math.min(term - 4, MAX_WIDTH);
57
- const accent = tint(ACCENT, ui.depth);
58
- return (
59
- <box style={{ flexDirection: "column", width: term, alignItems: "center", paddingTop: 1 }}>
60
- <box style={{ flexDirection: "column", width: w }}>
61
- {above ?? <Wordmark ui={ui} mood={mood} suffix={VERSION} />}
62
- <text> </text>
63
- <box
64
- style={{
65
- flexDirection: "column",
66
- width: w,
67
- paddingLeft: 2,
68
- paddingRight: 2,
69
- paddingTop: 1,
70
- paddingBottom: 1,
71
- }}
72
- border
73
- borderStyle={ui.plain ? "single" : "rounded"}
74
- customBorderChars={ui.plain ? ASCII_BORDER : undefined}
75
- borderColor={accent}
76
- title={` ${title} `}
77
- titleColor={accent}
78
- titleAlignment="left"
79
- >
80
- {children}
81
- </box>
82
- <box style={{ paddingLeft: 2, paddingTop: 1 }}>
83
- <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
84
- {hint}
85
- </text>
86
- </box>
87
- </box>
88
- </box>
89
- );
90
- }
91
-
92
- /** The right pane: a slightly lighter surface than the panel, with an uppercase accent title,
93
- * a dim one-line subtitle, and whatever detail rows the caller has for the selected item. */
94
- export function DetailPane({
95
- ui,
96
- heading,
97
- title,
98
- subtitle,
99
- children,
100
- }: {
101
- ui: Ui;
102
- heading?: string;
103
- title: string;
104
- subtitle: string;
105
- children: ReactNode;
106
- }) {
107
- return (
108
- <box
109
- style={{
110
- flexDirection: "column",
111
- flexGrow: 1,
112
- marginLeft: 2,
113
- paddingLeft: 2,
114
- paddingRight: 2,
115
- paddingTop: 1,
116
- paddingBottom: 1,
117
- }}
118
- backgroundColor={ui.plain ? undefined : tint("surface", ui.depth)}
119
- >
120
- {heading && (
121
- <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
122
- {heading}
123
- </text>
124
- )}
125
- <text fg={tint(ACCENT, ui.depth)} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
126
- {title}
127
- </text>
128
- <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
129
- {subtitle}
130
- </text>
131
- <text> </text>
132
- {children}
133
- </box>
134
- );
135
- }
136
-
137
- /** One row of a left-pane list: a filled accent bar with a ▶ marker when selected, plain
138
- * otherwise; `tag` is a dim trailing value (e.g. a role's "off", a rung's rung id). */
139
- export function ListLine({
140
- ui,
141
- selected,
142
- dim,
143
- text,
144
- tag,
145
- }: {
146
- ui: Ui;
147
- selected: boolean;
148
- dim?: boolean;
149
- text: string;
150
- tag?: string | null;
151
- }) {
152
- const accent = tint(ACCENT, ui.depth);
153
- const filled = selected && !ui.plain;
154
- return (
155
- <box style={{ flexDirection: "row", width: "100%" }} backgroundColor={filled ? accent : undefined}>
156
- <text
157
- wrapMode="none"
158
- truncate
159
- fg={filled ? tint("ink", ui.depth) : undefined}
160
- attributes={(selected ? TextAttributes.BOLD : 0) | (dim && !selected ? TextAttributes.DIM : 0)}
161
- >
162
- {selected ? glyph("cursor", ui.plain) : " "} {text}
163
- {tag ? (
164
- <span attributes={filled ? TextAttributes.BOLD : TextAttributes.DIM}>{` ${tag}`}</span>
165
- ) : null}
166
- </text>
167
- </box>
168
- );
169
- }
170
-
171
- const hex = (h: string): [number, number, number] => [
172
- Number.parseInt(h.slice(1, 3), 16),
173
- Number.parseInt(h.slice(3, 5), 16),
174
- Number.parseInt(h.slice(5, 7), 16),
175
- ];
176
-
177
- /** One colour per letter, interpolated between GRADIENT's two hex stops. */
178
- export function gradientLetters(word: string): { ch: string; fg: string }[] {
179
- const [a, b] = [hex(GRADIENT[0] as string), hex(GRADIENT[1] as string)];
180
- return [...word].map((ch, i) => {
181
- const t = word.length > 1 ? i / (word.length - 1) : 0;
182
- const rgb = a.map((c, k) => Math.round(c + ((b[k] as number) - c) * t));
183
- return { ch, fg: `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}` };
184
- });
185
- }
186
-
187
- /** The cat, and the wordmark it carries: `word` gradient-lettered where colour is available,
188
- * an optional `suffix` on the same line (the dashboard's version number), and an optional
189
- * second `subtitle` line (the intro's herding copy). Only place the 🐾 glyph still appears. */
190
- export function Wordmark({
191
- ui,
192
- mood,
193
- word = "catherd",
194
- suffix,
195
- subtitle,
196
- }: {
197
- ui: Ui;
198
- mood: Mood;
199
- word?: string;
200
- suffix?: string;
201
- subtitle?: string;
202
- }) {
203
- const [ears, head, paws] = mascot(mood, ui.plain);
204
- const ginger = tint("ginger", ui.depth);
205
- return (
206
- <box style={{ flexDirection: "row", marginBottom: 1 }}>
207
- <box style={{ flexDirection: "column", marginRight: 2 }}>
208
- <text fg={ginger}>{ears}</text>
209
- <text fg={ginger}>{head}</text>
210
- <text fg={ginger}>{paws}</text>
211
- </box>
212
- <box style={{ flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
213
- <text wrapMode="none" truncate attributes={TextAttributes.BOLD}>
214
- {ui.depth >= 8 && word
215
- ? gradientLetters(word).map((s, i) => (
216
- // biome-ignore lint: index key is stable, the wordmark never reorders
217
- <span key={i} fg={s.fg}>
218
- {s.ch}
219
- </span>
220
- ))
221
- : (word ?? " ")}
222
- {suffix ? <span attributes={TextAttributes.DIM}>{` ${suffix}`}</span> : null}
223
- </text>
224
- {subtitle ? (
225
- <text fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
226
- {subtitle}
227
- </text>
228
- ) : null}
229
- </box>
230
- </box>
231
- );
232
- }
233
-
234
- export function CatSpinner({ ui, label }: { ui: Ui; label: string }) {
235
- const [n, setN] = useState(0);
236
- useEffect(() => {
237
- if (ui.reducedMotion) return;
238
- const t = setInterval(() => setN((x) => x + 1), SPINNER.ms);
239
- return () => clearInterval(t);
240
- }, [ui.reducedMotion]);
241
- const frame = SPINNER.frames[n % SPINNER.frames.length];
242
- const copy = herdLine(Math.floor((n * SPINNER.ms) / SPINNER.copyMs), ui.plain);
243
- return (
244
- <text wrapMode="none" truncate>
245
- <span fg={tint("ginger", ui.depth)}>{frame}</span> {label}{" "}
246
- <span attributes={TextAttributes.DIM}>
247
- {glyph("dot", ui.plain)} {copy}
248
- </span>
249
- </text>
250
- );
251
- }
252
-
253
- export function Intro({ ui, onDone }: { ui: Ui; onDone: () => void }) {
254
- const [step, setStep] = useState(ui.reducedMotion ? INTRO.steps : 0);
255
- useKeyboard(() => onDone());
256
- useEffect(() => {
257
- if (step >= INTRO.steps) {
258
- onDone();
259
- return;
260
- }
261
- const t = setTimeout(() => setStep(step + 1), INTRO.ms);
262
- return () => clearTimeout(t);
263
- }, [step]);
264
- const mood: Mood = step < 2 ? "waiting" : step < INTRO.steps ? "working" : "good";
265
- const word = "catherd".slice(0, Math.round((7 * step) / INTRO.steps));
266
- return <Wordmark ui={ui} mood={mood} word={word} subtitle={herdLine(step, ui.plain)} />;
267
- }
@@ -1,83 +0,0 @@
1
- import type { RunSummary } from "../core/status.ts";
2
- import type { RunRecord, RungId } from "../types.ts";
3
- import { climbLine, clock, face, glyph, type Mood, shortRung } from "./theme.ts";
4
-
5
- /**
6
- * The fields `watch` shows from one `jev.jsonl` row (`JevLogRow` in `src/routing/jev.ts`):
7
- * which questions were asked, the answer actually used, and whether Jev answered or the
8
- * profile default did. `ts`, `answers` and `why` are in the file but not shown here.
9
- */
10
- export interface JevLine {
11
- questions: string[];
12
- used: string;
13
- source: "jev" | "default";
14
- }
15
-
16
- export function runMood(s: RunSummary): Mood {
17
- if (s.live.length > 0) return "working";
18
- if (s.totals.notOk.length > 0) return "failed";
19
- return s.totals.runs > 0 ? "good" : "waiting";
20
- }
21
-
22
- export function runLine(s: RunSummary, plain: boolean): string {
23
- return [
24
- `${face(runMood(s), plain)} ${s.title}`,
25
- `${s.live.length} live`,
26
- `${s.totals.runs} runs`,
27
- `${s.milestones.length} landed`,
28
- ].join(` ${glyph("dot", plain)} `);
29
- }
30
-
31
- export function liveLine(l: RunSummary["live"][number], plain: boolean): string {
32
- return `${glyph("cat", plain)} ${l.name} ${shortRung(l.rung)} ${clock(l.secs)} ${face("working", plain)}`;
33
- }
34
-
35
- export function milestoneLine(m: string, plain: boolean): string {
36
- const id = /\bM\d+\b/.exec(m)?.[0];
37
- if (!id) return `${face("landed", plain)} ${m}`;
38
- const mins = /\b(\d+)m\b/.exec(m)?.[1];
39
- const sha = /\b[0-9a-f]{7,40}\b/.exec(m)?.[0]?.slice(0, 7);
40
- return [`${face("landed", plain)} ${id} landed`, mins ? `${mins}m` : null, sha ?? null]
41
- .filter((x): x is string => x !== null)
42
- .join(` ${glyph("dot", plain)} `);
43
- }
44
-
45
- /** Remembers each role's last rung across polls; a role that comes back on another rung has climbed. */
46
- export function climbs(seen: Map<string, RungId>, s: RunSummary, plain: boolean): string[] {
47
- const out: string[] = [];
48
- for (const l of s.live) {
49
- const key = `${s.id}/${l.name}`;
50
- const from = seen.get(key);
51
- if (from !== undefined && from !== l.rung) out.push(climbLine(l.name, from, l.rung, plain));
52
- seen.set(key, l.rung);
53
- }
54
- return out;
55
- }
56
-
57
- export function recordLine(r: RunRecord, plain: boolean): string {
58
- const tail = r.replyStatus ? ` ${glyph("dot", plain)} ${r.replyStatus}` : "";
59
- return `${r.name} ${shortRung(r.rung)} ${r.status} ${clock(r.secs)}${tail}`;
60
- }
61
-
62
- export function jevLine(e: JevLine, plain: boolean): string {
63
- const tail = e.source === "default" ? ` ${glyph("dot", plain)} fell back to the profile default` : "";
64
- return `${e.questions.join(", ")} ${glyph("arrow", plain)} ${e.used}${tail}`;
65
- }
66
-
67
- /** spec §11b: --plain's stand-in for the coloured budget bar, e.g. "[#####-----] 52%". */
68
- export function plainBudgetBar(fraction: number, width = 10): string {
69
- const f = Math.max(0, Math.min(1, fraction));
70
- const filled = Math.round(f * width);
71
- return `[${"#".repeat(filled)}${"-".repeat(width - filled)}] ${Math.round(f * 100)}%`;
72
- }
73
-
74
- /** spec §11b: the bar is the ginger-to-pink gradient below 80%, a solid warning tint from 80%,
75
- * and a solid error tint once the budget is spent. Only ginger/pink exist in the warm palette
76
- * (theme.ts), so "warning" and "error" reuse them rather than adding new tones. */
77
- export type BudgetTone = "gradient" | "warning" | "error";
78
-
79
- export function budgetTone(fraction: number): BudgetTone {
80
- if (fraction >= 1) return "error";
81
- if (fraction >= 0.8) return "warning";
82
- return "gradient";
83
- }