catherd-cli 0.2.0 → 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 -25
  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 -69
  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
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
- }