catherd-cli 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,68 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/ui/dialog.tsx (MIT, © 2025 opencode);
2
+ // see THIRD_PARTY_NOTICES.md.
3
+ import { useTerminalDimensions } from "@opentui/react";
4
+ import type { ReactNode } from "react";
5
+ import { useTone, useUi } from "../providers/theme.tsx";
6
+ import { DIALOG_WIDTH, type DialogSize } from "../theme.ts";
7
+ import { Line } from "./line.tsx";
8
+
9
+ /** The panel's inner width: its size, clamped to the terminal less a margin, less the padding. */
10
+ const dialogInner = (size: DialogSize, termWidth: number): number =>
11
+ Math.max(10, Math.min(DIALOG_WIDTH[size], termWidth - 2) - 4);
12
+
13
+ /** The rows a quarter down the screen leaves for `rows`: the height less the top padding, the
14
+ * blank line, the title, the gap under it and the blank line at the bottom. */
15
+ export const dialogRows = (termHeight: number): number =>
16
+ Math.max(1, termHeight - Math.floor(termHeight / 4) - 4);
17
+
18
+ /**
19
+ * Spec §9.3: a raised panel over a dimmed backdrop, a quarter down the screen, 60, 88 or 116 columns
20
+ * wide, with no border; the title bold on the left and a muted `esc` on the right. `rows` returns one
21
+ * node per line, each `inner` columns wide (Line pads them); the panel pads every line with spaces on
22
+ * both sides, so it hides what is beneath even without colour.
23
+ */
24
+ export function Dialog(props: { title: string; size?: DialogSize; rows: (inner: number) => ReactNode[] }) {
25
+ const dims = useTerminalDimensions();
26
+ const ui = useUi();
27
+ const tone = useTone();
28
+ const size = props.size ?? "medium";
29
+ const inner = dialogInner(size, dims.width);
30
+ const lines: ReactNode[] = [
31
+ <Line key="top" width={inner} parts={[]} />,
32
+ <Line
33
+ key="title"
34
+ width={inner}
35
+ parts={[
36
+ { text: props.title, bold: true },
37
+ { text: " ".repeat(Math.max(1, inner - Bun.stringWidth(props.title) - 3)) },
38
+ { text: "esc", tone: "muted" },
39
+ ]}
40
+ />,
41
+ <Line key="gap" width={inner} parts={[]} />,
42
+ ...props.rows(inner),
43
+ <Line key="bottom" width={inner} parts={[]} />,
44
+ ];
45
+ return (
46
+ <box
47
+ position="absolute"
48
+ top={0}
49
+ left={0}
50
+ zIndex={3000}
51
+ width={dims.width}
52
+ height={dims.height}
53
+ alignItems="center"
54
+ paddingTop={Math.floor(dims.height / 4)}
55
+ backgroundColor={ui.color ? `${tone("backdrop")}99` : undefined}
56
+ >
57
+ <box width={inner + 4} flexDirection="column" backgroundColor={tone("surfaceRaised")}>
58
+ {lines.map((line, i) => (
59
+ <box key={i} flexDirection="row" height={1}>
60
+ <text>{" "}</text>
61
+ {line}
62
+ <text>{" "}</text>
63
+ </box>
64
+ ))}
65
+ </box>
66
+ </box>
67
+ );
68
+ }
@@ -0,0 +1,92 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core";
2
+ import { useTone, useUi } from "../providers/theme.tsx";
3
+ import { ascii, truncateEnd, width as widthOf } from "../text.ts";
4
+ import type { Token } from "../theme.ts";
5
+
6
+ /** One run of text on a line, in a token's colour. */
7
+ export interface Part {
8
+ text: string;
9
+ tone?: Token;
10
+ bold?: boolean;
11
+ }
12
+
13
+ /** The parts cut to `max` columns at the end (an ellipsis on the last one kept). */
14
+ export function clip(parts: Part[], max: number, plain: boolean): Part[] {
15
+ const out: Part[] = [];
16
+ let used = 0;
17
+ for (const p of parts) {
18
+ const w = widthOf(p.text);
19
+ if (used + w <= max) {
20
+ out.push(p);
21
+ used += w;
22
+ continue;
23
+ }
24
+ const room = max - used;
25
+ if (room > 0) out.push({ ...p, text: truncateEnd(p.text, room, plain) });
26
+ break;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * The one line primitive every row is drawn with: exactly `width` columns. A selected line is filled
33
+ * with the accent and bold (spec §9.3), or drawn in reverse video without colour; under --plain every
34
+ * character is ASCII.
35
+ */
36
+ export function Line(props: {
37
+ parts: Part[];
38
+ width: number;
39
+ selected?: boolean;
40
+ dim?: boolean;
41
+ /**
42
+ * Paints every cell, spaces included, so nothing beneath shows through (a toast over the tabs): the
43
+ * raised surface with colour, reverse video without, since a plain space paints nothing.
44
+ */
45
+ opaque?: boolean;
46
+ }) {
47
+ const ui = useUi();
48
+ const tone = useTone();
49
+ const parts = clip(
50
+ ui.plain ? props.parts.map((p) => ({ ...p, text: ascii(p.text) })) : props.parts,
51
+ props.width,
52
+ ui.plain,
53
+ );
54
+ const used = parts.reduce((n, p) => n + widthOf(p.text), 0);
55
+ const pad = " ".repeat(Math.max(0, props.width - used));
56
+ const sel = props.selected === true;
57
+ const fg = (p: Part) =>
58
+ sel ? tone("selectionText") : props.dim ? tone("muted") : p.tone ? tone(p.tone) : undefined;
59
+ // without colour, the terminal's own default background (SGR 49, no colour) makes a space paint
60
+ const bg = sel
61
+ ? tone("selection")
62
+ : props.opaque
63
+ ? ui.color
64
+ ? tone("surfaceRaised")
65
+ : RGBA.defaultBackground()
66
+ : undefined;
67
+ const inverse = (sel || props.opaque === true) && !ui.color ? TextAttributes.INVERSE : 0;
68
+ const attrs = (p: Part) => (sel || p.bold ? TextAttributes.BOLD : 0) | inverse;
69
+ return (
70
+ <text wrapMode="none">
71
+ {parts.map((p, i) => (
72
+ <span key={i} fg={fg(p)} bg={bg} attributes={attrs(p)}>
73
+ {p.text}
74
+ </span>
75
+ ))}
76
+ <span bg={bg} attributes={inverse}>
77
+ {pad}
78
+ </span>
79
+ </text>
80
+ );
81
+ }
82
+
83
+ /** A full-width rule (`─`, or `-` in plain). */
84
+ export function Rule(props: { width: number }) {
85
+ const ui = useUi();
86
+ const tone = useTone();
87
+ return (
88
+ <text wrapMode="none" fg={tone("border")}>
89
+ {(ui.plain ? "-" : "─").repeat(Math.max(0, props.width))}
90
+ </text>
91
+ );
92
+ }
@@ -0,0 +1,196 @@
1
+ import type { InputRenderable } from "@opentui/core";
2
+ import { type ReactNode, useEffect, useRef, useState } from "react";
3
+ import { useBack } from "../providers/app.tsx";
4
+ import { useCommandLayer } from "../providers/keymap.tsx";
5
+ import { useTone, useUi } from "../providers/theme.tsx";
6
+ import { glyph } from "../theme.ts";
7
+ import { Line } from "./line.tsx";
8
+
9
+ export interface ListItem {
10
+ key: string;
11
+ /** headings and continuation lines are skipped by the cursor */
12
+ selectable: boolean;
13
+ render(selected: boolean, width: number): ReactNode;
14
+ }
15
+
16
+ export interface Window {
17
+ top: number;
18
+ /** rows hidden above and below the window */
19
+ above: number;
20
+ below: number;
21
+ }
22
+
23
+ const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
24
+
25
+ /**
26
+ * Which rows of `total` fit in `height` so that `index` shows, moving no more than needed from `top`.
27
+ * With 3 rows or more, a hint line (`↑ 3 more`) takes the first or the last row when rows are hidden there.
28
+ */
29
+ export function windowOf(total: number, index: number, height: number, top: number): Window {
30
+ if (total <= height) return { top: 0, above: 0, below: 0 };
31
+ const i = clamp(index, 0, total - 1);
32
+ const maxTop = total - height;
33
+ let t = clamp(top, 0, maxTop);
34
+ if (height >= 3) {
35
+ const first = t + (t > 0 ? 1 : 0);
36
+ const last = t + height - 1 - (t < maxTop ? 1 : 0);
37
+ if (i < first) t = i === 0 ? 0 : i - 1;
38
+ else if (i > last) t = i === total - 1 ? maxTop : i - height + 2;
39
+ } else if (i < t) t = i;
40
+ else if (i > t + height - 1) t = i - height + 1;
41
+ t = clamp(t, 0, maxTop);
42
+ return height >= 3 ? { top: t, above: t, below: maxTop - t } : { top: t, above: 0, below: 0 };
43
+ }
44
+
45
+ /**
46
+ * The selectable row `by` selectable rows away from row `from` (clamped to the ends). From a row that
47
+ * cannot be selected, the next selectable row is one step down and the previous one a step up; `by` 0
48
+ * snaps to the next one (else the last).
49
+ */
50
+ export function step(items: readonly { selectable: boolean }[], from: number, by: number): number {
51
+ const sel = items.flatMap((it, i) => (it.selectable ? [i] : []));
52
+ if (sel.length === 0) return -1;
53
+ const at = sel.indexOf(from);
54
+ if (at >= 0) return sel[clamp(at + by, 0, sel.length - 1)] as number;
55
+ const next = sel.findIndex((i) => i > from);
56
+ if (by === 0) return sel[next < 0 ? sel.length - 1 : next] as number;
57
+ const pos = by > 0 ? (next < 0 ? sel.length - 1 : next + by - 1) : (next < 0 ? sel.length : next) + by;
58
+ return sel[clamp(pos, 0, sel.length - 1)] as number;
59
+ }
60
+
61
+ /**
62
+ * A view's selected row: `selected` is what this render draws, `current()` what a key handler must read
63
+ * (a key in the same tick may have moved it already).
64
+ */
65
+ export function useSelected(): {
66
+ selected: string | null;
67
+ select(key: string): void;
68
+ current(): string | null;
69
+ } {
70
+ const [selected, setSelected] = useState<string | null>(null);
71
+ const ref = useRef<string | null>(null);
72
+ ref.current = selected;
73
+ return {
74
+ selected,
75
+ select(key) {
76
+ ref.current = key;
77
+ setSelected(key);
78
+ },
79
+ current: () => ref.current,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * A list or tree pane (spec §9.2 lists: arrows plus j/k, paging, Home/End, `/` filter). Controlled: the
85
+ * view owns the selected key and the filter text; the list moves them and draws the visible window with
86
+ * `↑ n more` / `↓ n more` hints instead of a scrollbar.
87
+ */
88
+ export function List(props: {
89
+ items: ListItem[];
90
+ selected: string | null;
91
+ onSelect(key: string): void;
92
+ width: number;
93
+ height: number;
94
+ /** the filter text, null when no filter is open */
95
+ filter?: string | null;
96
+ onFilter?(text: string | null): void;
97
+ /** the muted line shown when there are no rows */
98
+ empty: string;
99
+ }) {
100
+ const ui = useUi();
101
+ const tone = useTone();
102
+ const input = useRef<InputRenderable>(null);
103
+ const [top, setTop] = useState(0);
104
+ // the command that opens the filter is drawn at once (useCommandLayer), so this runs before the next key
105
+ const [focusTick, setFocusTick] = useState(0);
106
+ useEffect(() => {
107
+ if (focusTick > 0) input.current?.focus();
108
+ }, [focusTick]);
109
+ const filtering = props.filter !== undefined && props.filter !== null;
110
+ const bodyHeight = Math.max(1, props.height - (filtering ? 1 : 0));
111
+ const indexOf = (key: string | null) => {
112
+ const i = props.items.findIndex((it) => it.key === key);
113
+ return i < 0 || !props.items[i]?.selectable ? step(props.items, Math.max(0, i), 0) : i;
114
+ };
115
+ const index = indexOf(props.selected);
116
+ // the row a key handler moves from: the one the last key chose, which may not be drawn yet
117
+ const selectedRef = useRef(props.selected);
118
+ selectedRef.current = props.selected;
119
+ const choose = (i: number) => {
120
+ const it = props.items[i];
121
+ if (!it) return;
122
+ selectedRef.current = it.key;
123
+ props.onSelect(it.key);
124
+ };
125
+ const w = windowOf(props.items.length, Math.max(0, index), bodyHeight, top);
126
+ useEffect(() => {
127
+ if (w.top !== top) setTop(w.top);
128
+ }, [w.top, top]);
129
+ // the cursor snaps to a selectable row, judged on the selection now: this effect can run after a key
130
+ // already moved it (a key that lands between a draw and its effects), and must not take that back
131
+ useEffect(() => {
132
+ const it = props.items[indexOf(selectedRef.current)];
133
+ if (it && it.key !== selectedRef.current) choose(indexOf(selectedRef.current));
134
+ });
135
+ const move = (by: number) => choose(step(props.items, Math.max(0, indexOf(selectedRef.current)), by));
136
+ const page = Math.max(1, bodyHeight - 2);
137
+ useCommandLayer("list", {
138
+ "list.up": () => move(-1),
139
+ "list.down": () => move(1),
140
+ "list.pageUp": () => move(-page),
141
+ "list.pageDown": () => move(page),
142
+ "list.first": () => choose(step(props.items, 0, 0)),
143
+ "list.last": () => choose(step(props.items, props.items.length - 1, 0)),
144
+ ...(props.onFilter
145
+ ? {
146
+ "list.filter": () => {
147
+ props.onFilter?.(props.filter ?? "");
148
+ setFocusTick((n) => n + 1);
149
+ },
150
+ }
151
+ : {}),
152
+ });
153
+ useCommandLayer("filter", { "filter.accept": () => input.current?.blur() });
154
+ useBack(filtering, "input", () => {
155
+ input.current?.blur();
156
+ props.onFilter?.(null);
157
+ });
158
+ const rows: ReactNode[] = [];
159
+ const visible = props.items.slice(w.top, w.top + bodyHeight);
160
+ visible.forEach((it, i) => {
161
+ const hintAbove = i === 0 && w.above > 0;
162
+ const hintBelow = i === visible.length - 1 && w.below > 0;
163
+ if (hintAbove || hintBelow) {
164
+ const n = hintAbove ? w.above + 1 : w.below + 1;
165
+ rows.push(
166
+ <Line
167
+ key={`hint-${hintAbove ? "up" : "down"}`}
168
+ width={props.width}
169
+ parts={[{ text: ` ${glyph(hintAbove ? "up" : "down", ui.plain)} ${n} more`, tone: "muted" }]}
170
+ />,
171
+ );
172
+ return;
173
+ }
174
+ rows.push(<box key={it.key}>{it.render(w.top + i === index, props.width)}</box>);
175
+ });
176
+ if (props.items.length === 0)
177
+ rows.push(<Line key="empty" width={props.width} parts={[{ text: ` ${props.empty}`, tone: "muted" }]} />);
178
+ const matches = props.items.filter((it) => it.selectable).length;
179
+ return (
180
+ <box flexDirection="column" width={props.width} height={props.height}>
181
+ {filtering ? (
182
+ <box flexDirection="row" width={props.width} height={1}>
183
+ <text fg={tone("accent")}>{" / "}</text>
184
+ <input
185
+ ref={input}
186
+ value={props.filter ?? ""}
187
+ onInput={(v: string) => props.onFilter?.(v)}
188
+ width={Math.max(1, props.width - 18)}
189
+ />
190
+ <text fg={tone("muted")}>{` ${matches} ${matches === 1 ? "match" : "matches"}`.padEnd(15)}</text>
191
+ </box>
192
+ ) : null}
193
+ {rows}
194
+ </box>
195
+ );
196
+ }
@@ -0,0 +1,49 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/component/spinner.tsx and startup-loading.tsx
2
+ // (MIT, © 2025 opencode); see THIRD_PARTY_NOTICES.md.
3
+ import { useEffect, useState } from "react";
4
+ import { useApp } from "../providers/app.tsx";
5
+ import { useTone, useUi } from "../providers/theme.tsx";
6
+ import { ascii } from "../text.ts";
7
+ import { SPINNER } from "../theme.ts";
8
+
9
+ /**
10
+ * Spec §9.3 motion: true once `active` has lasted SPINNER.showAfterMs, and then for at least
11
+ * SPINNER.minShowMs, so a quick check never flickers.
12
+ */
13
+ export function useDelayedPresence(active: boolean): boolean {
14
+ const { clock } = useApp();
15
+ const [shownAt, setShownAt] = useState<number | null>(null);
16
+ useEffect(() => {
17
+ if (active && shownAt === null) {
18
+ const h = clock.setTimeout(() => setShownAt(clock.now()), SPINNER.showAfterMs);
19
+ return () => clock.clearTimeout(h);
20
+ }
21
+ if (!active && shownAt !== null) {
22
+ const left = SPINNER.minShowMs - (clock.now() - shownAt);
23
+ if (left <= 0) {
24
+ setShownAt(null);
25
+ return;
26
+ }
27
+ const h = clock.setTimeout(() => setShownAt(null), left);
28
+ return () => clock.clearTimeout(h);
29
+ }
30
+ return undefined;
31
+ }, [active, shownAt, clock]);
32
+ return shownAt !== null;
33
+ }
34
+
35
+ /** A braille spinner and what it waits on; a still `⋯` under reduced motion (spec §9.3). */
36
+ export function Spinner(props: { label: string }) {
37
+ const { clock } = useApp();
38
+ const ui = useUi();
39
+ const tone = useTone();
40
+ const [frame, setFrame] = useState(0);
41
+ useEffect(() => {
42
+ if (ui.reducedMotion) return;
43
+ const h = clock.setInterval(() => setFrame((f) => f + 1), SPINNER.ms);
44
+ return () => clock.clearInterval(h);
45
+ }, [clock, ui.reducedMotion]);
46
+ const frames = ui.plain ? SPINNER.plainFrames : SPINNER.frames;
47
+ const g = ui.reducedMotion ? SPINNER.still[ui.plain ? 1 : 0] : frames[frame % frames.length];
48
+ return <text fg={tone("muted")}>{`${g} ${ui.plain ? ascii(props.label) : props.label}`}</text>;
49
+ }
@@ -0,0 +1,62 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/ui/toast.tsx (MIT, © 2025 opencode);
2
+ // see THIRD_PARTY_NOTICES.md.
3
+ import { useTerminalDimensions } from "@opentui/react";
4
+ import { useToasts } from "../providers/toast.tsx";
5
+ import { useUi } from "../providers/theme.tsx";
6
+ import { truncateEnd, wrap } from "../text.ts";
7
+ import { glyph, type Token } from "../theme.ts";
8
+ import { Line } from "./line.tsx";
9
+
10
+ const TONE: Record<string, Token> = { info: "info", success: "success", warning: "warning", error: "error" };
11
+
12
+ /** Lines of a toast's message kept before the rest is cut; a fix command always shows in full. */
13
+ const MESSAGE_LINES = 3;
14
+
15
+ /**
16
+ * Spec §9.3: the current toast at the top right, with a `┃` bar in its colour and the queue's length.
17
+ * The message wraps to three lines; a fix command wraps in full on muted lines under it.
18
+ */
19
+ export function ToastHost() {
20
+ const { current, queued } = useToasts();
21
+ const ui = useUi();
22
+ const dims = useTerminalDimensions();
23
+ if (!current) return null;
24
+ const bar = glyph("bar", ui.plain);
25
+ const max = Math.max(1, Math.min(60, dims.width - 6) - 4);
26
+ const said = wrap(`${current.message}${queued ? ` +${queued} more` : ""}`, max);
27
+ const message = said.slice(0, MESSAGE_LINES);
28
+ if (said.length > MESSAGE_LINES)
29
+ message[MESSAGE_LINES - 1] = truncateEnd(
30
+ `${message[MESSAGE_LINES - 1]} ${said[MESSAGE_LINES]}`,
31
+ max,
32
+ ui.plain,
33
+ );
34
+ const fix = current.fix ? wrap(current.fix, max) : [];
35
+ const text = Math.max(...[...message, ...fix].map((l) => Bun.stringWidth(l)));
36
+ const w = text + 4;
37
+ const line = (t: string, muted: boolean, i: number) => (
38
+ <Line
39
+ key={i}
40
+ width={w}
41
+ opaque
42
+ parts={[
43
+ { text: `${bar} `, tone: TONE[current.variant] },
44
+ { text: t.padEnd(text + t.length - Bun.stringWidth(t)), tone: muted ? "muted" : undefined },
45
+ { text: ` ${bar}`, tone: TONE[current.variant] },
46
+ ]}
47
+ />
48
+ );
49
+ return (
50
+ <box
51
+ position="absolute"
52
+ top={1}
53
+ left={Math.max(0, dims.width - w - 2)}
54
+ zIndex={4000}
55
+ width={w}
56
+ height={message.length + fix.length}
57
+ flexDirection="column"
58
+ >
59
+ {[...message.map((t, i) => line(t, false, i)), ...fix.map((t, i) => line(t, true, message.length + i))]}
60
+ </box>
61
+ );
62
+ }
@@ -0,0 +1,4 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /** A file shipped in the package, such as `catalog/models.json`; catherd ships its sources unbuilt. */
4
+ export const assetPath = (rel: string): string => fileURLToPath(new URL(`../../${rel}`, import.meta.url));