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
@@ -0,0 +1,206 @@
1
+ import type { Check } from "../../../services/doctor-checks.ts";
2
+ import { useApp, useNow } from "../providers/app.tsx";
3
+ import { useData } from "../providers/data.tsx";
4
+ import { useCommandLayer } from "../providers/keymap.tsx";
5
+ import { useUi } from "../providers/theme.tsx";
6
+ import { ago, plural, wrap } from "../text.ts";
7
+ import { glyph, STATE_TOKEN } from "../theme.ts";
8
+ import { Line, type Part } from "../widgets/line.tsx";
9
+ import { List, type ListItem, useSelected } from "../widgets/list.tsx";
10
+ import { hereWord, type RunRow } from "../effects.ts";
11
+ import { showProfile } from "./profile-actions.ts";
12
+ import { staleLines } from "./profiles.tsx";
13
+
14
+ /** `✓ ready` / `! not logged in` / `✗ missing`: state is always glyph and word (spec §9.3). */
15
+ function stateParts(state: Check["state"], word: string, plain: boolean): Part[] {
16
+ return [{ text: `${glyph(state, plain)} ${word}`.padEnd(16), tone: STATE_TOKEN[state] }];
17
+ }
18
+
19
+ /** A run in one line: its state word first, then title, repo and progress (research C4). */
20
+ export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
21
+ const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
22
+ if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
23
+ return [
24
+ r.live
25
+ ? { text: `${glyph("live", plain)} live `, tone: "info" }
26
+ : { text: `${glyph("dot", plain)} idle `, tone: "muted" },
27
+ { text: `${r.title} `, bold: true },
28
+ { text: `${r.repo} `, tone: "muted" },
29
+ { text: `${r.live ? `${r.live} live · ` : ""}${bits.join(" · ")} `, tone: "muted" },
30
+ { text: ago(now - Date.parse(r.createdAt)), tone: "muted" },
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * Spec §9.1 tab 1: each check as `glyph word — detail` with its full fix command (y copies it), the
36
+ * active profile, and the last five runs. enter opens the profile or a run.
37
+ */
38
+ export function StatusView(props: { width: number; height: number }) {
39
+ const app = useApp();
40
+ const data = useData();
41
+ const ui = useUi();
42
+ const now = useNow(1_000);
43
+ const { selected, select: setSelected, current: selectedNow } = useSelected();
44
+ const checks = data.report?.checks ?? [];
45
+ const profiles = data.profiles.value ?? { names: [], active: "…", here: "…", repo: null };
46
+ const runs = (data.runs.value?.rows ?? []).slice(0, 5);
47
+ const items: ListItem[] = [];
48
+ const heading = (key: string, text: string, right = "") =>
49
+ items.push({
50
+ key,
51
+ selectable: false,
52
+ render: (_s, w) => (
53
+ <Line
54
+ width={w}
55
+ parts={[
56
+ { text, bold: true },
57
+ { text: " ".repeat(Math.max(1, w - text.length - right.length - 1)) },
58
+ { text: right, tone: "muted" },
59
+ ]}
60
+ />
61
+ ),
62
+ });
63
+ const checked = data.checking
64
+ ? "checking…"
65
+ : data.checkedAt !== null
66
+ ? `checked ${ago(now - data.checkedAt)}`
67
+ : "";
68
+ heading("h:setup", " SETUP", checked);
69
+ if (data.checkError)
70
+ items.push({
71
+ key: "error",
72
+ selectable: false,
73
+ render: (_s, w) => (
74
+ <Line
75
+ width={w}
76
+ parts={[{ text: ` ${glyph("fail", ui.plain)} ${data.checkError}`, tone: "error" }]}
77
+ />
78
+ ),
79
+ });
80
+ for (const c of checks) {
81
+ // the detail wraps under the label instead of being cut: it is often a path (spec §9.3)
82
+ const said = wrap(`${c.label}${c.detail ? ` — ${c.detail}` : ""}`, Math.max(20, props.width - 19));
83
+ const first = said[0] ?? "";
84
+ const label = first.startsWith(c.label) ? c.label : "";
85
+ items.push({
86
+ key: `check:${c.id}`,
87
+ selectable: true,
88
+ render: (sel, w) => (
89
+ <Line
90
+ width={w}
91
+ selected={sel}
92
+ parts={[
93
+ { text: " " },
94
+ ...stateParts(c.state, c.word, ui.plain),
95
+ { text: label, bold: true },
96
+ { text: first.slice(label.length), tone: "muted" },
97
+ ]}
98
+ />
99
+ ),
100
+ });
101
+ said.slice(1).forEach((line, i) =>
102
+ items.push({
103
+ key: `detail:${c.id}:${i}`,
104
+ selectable: false,
105
+ render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(19)}${line}`, tone: "muted" }]} />,
106
+ }),
107
+ );
108
+ if (c.fix)
109
+ wrap(`fix: ${c.fix}`, Math.max(20, props.width - 22)).forEach((line, i) =>
110
+ items.push({
111
+ key: `fix:${c.id}:${i}`,
112
+ selectable: false,
113
+ render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(20)}${line}`, tone: "muted" }]} />,
114
+ }),
115
+ );
116
+ }
117
+ heading("h:profile", " PROFILE");
118
+ // until the first check is in, nothing is selectable, so the cursor starts on the first check
119
+ const ready = data.report !== null || data.checkError !== null;
120
+ items.push({
121
+ key: "profile",
122
+ selectable: ready,
123
+ render: (sel, w) => (
124
+ <Line
125
+ width={w}
126
+ selected={sel}
127
+ parts={[
128
+ { text: ` ${profiles.here}`, bold: true },
129
+ {
130
+ text: ` ${hereWord(profiles)} · ${plural(profiles.names.length, "profile")}`,
131
+ tone: "muted",
132
+ },
133
+ ]}
134
+ />
135
+ ),
136
+ });
137
+ // a later read that failed: the row above is the last good read, not the profile now
138
+ for (const [i, p] of staleLines(data, ui.plain, { fix: false }).entries())
139
+ items.push({
140
+ key: `profile:stale:${i}`,
141
+ selectable: false,
142
+ render: (_s, w) => <Line width={w} parts={[{ ...p, text: ` ${p.text}` }]} />,
143
+ });
144
+ heading("h:runs", " RECENT RUNS");
145
+ if (runs.length === 0)
146
+ items.push({
147
+ key: "runs:none",
148
+ selectable: false,
149
+ render: (_s, w) => <Line width={w} parts={[{ text: " no runs yet", tone: "muted" }]} />,
150
+ });
151
+ for (const r of runs)
152
+ items.push({
153
+ key: `run:${r.id}`,
154
+ selectable: ready,
155
+ render: (sel, w) => (
156
+ <Line width={w} selected={sel} parts={[{ text: " " }, ...runParts(r, now, ui.plain)]} />
157
+ ),
158
+ });
159
+
160
+ const checkAt = (key: string | null) => checks.find((c) => key === `check:${c.id}`);
161
+ useCommandLayer("tab.status", {
162
+ "status.recheck": () => data.recheck(),
163
+ "status.copy": () => {
164
+ const selectedCheck = checkAt(selectedNow());
165
+ if (!selectedCheck?.fix) return app.toast({ variant: "info", message: "This row has no fix command" });
166
+ const ok = app.copy(selectedCheck.fix);
167
+ app.toast(
168
+ ok
169
+ ? { variant: "success", message: "Copied the fix command" }
170
+ : { variant: "warning", message: "This terminal cannot copy; the command is shown in full" },
171
+ );
172
+ },
173
+ });
174
+ useCommandLayer("row.status", {
175
+ "status.open": () => {
176
+ const selected = selectedNow();
177
+ const selectedCheck = checkAt(selected);
178
+ // the PROFILE row is what this directory runs on; doctor's `profile` row is the global active one,
179
+ // and its `profile:<name>` rows are the repo-bound ones
180
+ const name =
181
+ selected === "profile"
182
+ ? profiles.here
183
+ : selectedCheck?.id === "profile"
184
+ ? profiles.active
185
+ : selectedCheck?.id.startsWith("profile:")
186
+ ? selectedCheck.id.slice("profile:".length)
187
+ : null;
188
+ if (name !== null) {
189
+ showProfile(app, name);
190
+ } else if (selected?.startsWith("run:")) {
191
+ app.dispatch({ type: "tab", tab: "runs" });
192
+ app.dispatch({ type: "run", id: selected.slice("run:".length) });
193
+ }
194
+ },
195
+ });
196
+ return (
197
+ <List
198
+ items={items}
199
+ selected={selected}
200
+ onSelect={setSelected}
201
+ width={props.width}
202
+ height={props.height}
203
+ empty="checking…"
204
+ />
205
+ );
206
+ }
@@ -0,0 +1,114 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { usePendingSequence } from "@opentui/keymap/react";
3
+ import { type CommandId, formatKeys, LEADER } from "../commands.ts";
4
+ import { MODE, reachableCommands, useKeybinds, useKeymap, useKeymapVersion } from "../providers/keymap.tsx";
5
+ import { useTone, useUi } from "../providers/theme.tsx";
6
+ import { FACES, type Mood } from "../theme.ts";
7
+ import { TABS, type Tab } from "../state.ts";
8
+ import { width as widthOf } from "../text.ts";
9
+ import { Line, type Part } from "./line.tsx";
10
+
11
+ const TAB_TITLE: Record<Tab, string> = { status: "Status", profiles: "Profiles", runs: "Runs" };
12
+
13
+ /** Spec §9.3 header: one line with the version, the profile, the unsaved count and the mood face. */
14
+ export function Header(props: {
15
+ version: string;
16
+ profile: string | null;
17
+ active: boolean;
18
+ /** the word after the profile: "active", or "this repo" in a bound repo */
19
+ label?: string;
20
+ dirty: number;
21
+ mood: Mood;
22
+ width: number;
23
+ }) {
24
+ const pending = usePendingSequence();
25
+ const left: Part[] = [{ text: ` catherd ${props.version}`, bold: true }];
26
+ if (props.profile) {
27
+ left.push({ text: ` profile ${props.profile}` });
28
+ if (props.active) left.push({ text: ` (${props.label ?? "active"})`, tone: "muted" });
29
+ }
30
+ if (props.dirty > 0) left.push({ text: ` · ${props.dirty} unsaved`, tone: "warning" });
31
+ const right: Part[] = [];
32
+ if (pending.length)
33
+ right.push({
34
+ text: `${pending.map((p) => (p.tokenName === "leader" ? LEADER : p.display)).join(" ")} … `,
35
+ tone: "accent",
36
+ });
37
+ right.push({ text: `${FACES[props.mood]} `, tone: props.mood === "failed" ? "error" : "muted" });
38
+ const used = [...left, ...right].reduce((n, p) => n + widthOf(p.text), 0);
39
+ const gap = Math.max(1, props.width - used);
40
+ return <Line width={props.width} parts={[...left, { text: " ".repeat(gap) }, ...right]} />;
41
+ }
42
+
43
+ /** The tab strip: ` 1 Status 2 Profiles 3 Runs`, the active one bold in the accent. */
44
+ export function Tabs(props: { tab: Tab; width: number }) {
45
+ const ui = useUi();
46
+ const tone = useTone();
47
+ return (
48
+ <box flexDirection="row" width={props.width} height={1}>
49
+ {TABS.map((t, i) => {
50
+ const on = t === props.tab;
51
+ const text = ` ${i + 1} ${TAB_TITLE[t]} `;
52
+ return (
53
+ <text
54
+ key={t}
55
+ wrapMode="none"
56
+ fg={on ? tone(ui.color ? "accent" : "text") : tone("muted")}
57
+ attributes={on ? TextAttributes.BOLD | (ui.color ? 0 : TextAttributes.INVERSE) : 0}
58
+ >
59
+ {i === 0 ? text : ` ${text}`}
60
+ </text>
61
+ );
62
+ })}
63
+ </box>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Spec §9.2: footer hints are generated from the commands a key would reach now, in their `hint` order,
69
+ * as `key label` (the key as the user bound it). Hints drop from the end to fit, but the palette and
70
+ * help hints stay.
71
+ */
72
+ export function Footer(props: { width: number }) {
73
+ useKeymapVersion();
74
+ const keymap = useKeymap();
75
+ const keys = useKeybinds();
76
+ const ui = useUi();
77
+ const modal = keymap.getData(MODE) === "modal";
78
+ const hints: { id: string; text: string }[] = [];
79
+ let last: { order: number; label: string; keys: string[] } | null = null;
80
+ for (const c of reachableCommands(keymap)
81
+ .filter((c) => c.hint !== null && keys[c.id].length > 0 && (c.id !== "app.back" || modal))
82
+ .sort((a, b) => (a.hint as number) - (b.hint as number))) {
83
+ const key = formatKeys(keys[c.id as CommandId].slice(0, 1), ui.plain);
84
+ const label = c.short ?? c.title;
85
+ // commands with the same order and label read as one hint: `↑↓ move`
86
+ if (last && last.order === c.hint && last.label === label) {
87
+ last.keys.push(key);
88
+ const joined = last.keys.join(ui.plain ? "/" : "");
89
+ (hints.at(-1) as { text: string }).text = `${joined} ${label}`;
90
+ continue;
91
+ }
92
+ last = { order: c.hint as number, label, keys: [key] };
93
+ hints.push({ id: c.id, text: `${key} ${label}` });
94
+ }
95
+ const fixed = hints.filter((h) => h.id === "app.palette" || h.id === "app.help");
96
+ const rest = hints.filter((h) => !fixed.includes(h));
97
+ const sep = " ";
98
+ const room = props.width - 1 - fixed.reduce((n, h) => n + widthOf(h.text) + sep.length, 0);
99
+ const kept: string[] = [];
100
+ let used = 0;
101
+ for (const h of rest) {
102
+ if (used + widthOf(h.text) + sep.length > room) break;
103
+ kept.push(h.text);
104
+ used += widthOf(h.text) + sep.length;
105
+ }
106
+ const all = [...kept, ...fixed.map((h) => h.text)];
107
+ const parts: Part[] = [{ text: " " }];
108
+ all.forEach((t, i) => {
109
+ const at = t.indexOf(" ");
110
+ parts.push({ text: t.slice(0, at), bold: true }, { text: t.slice(at), tone: "muted" });
111
+ if (i < all.length - 1) parts.push({ text: sep });
112
+ });
113
+ return <Line width={props.width} parts={parts} />;
114
+ }
@@ -0,0 +1,58 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/ui/dialog-confirm.tsx (MIT, © 2025 opencode);
2
+ // see THIRD_PARTY_NOTICES.md.
3
+ import { useRef, useState } from "react";
4
+ import { useApp } from "../providers/app.tsx";
5
+ import { useCommandLayer } from "../providers/keymap.tsx";
6
+ import type { Dialog as DialogState } from "../state.ts";
7
+ import { wrap } from "../text.ts";
8
+ import { Dialog } from "./dialog.tsx";
9
+ import { Line } from "./line.tsx";
10
+
11
+ type Confirm = Extract<DialogState, { kind: "confirm" }>;
12
+
13
+ /** A row of buttons: ←/→ or tab move, enter chooses (spec §9.2 confirmations; never "any other key"). */
14
+ export function Buttons(props: { labels: string[]; focused: number; width: number }) {
15
+ const cells = props.labels.map((l) => `[ ${l} ]`);
16
+ const used = cells.reduce((n, c) => n + Bun.stringWidth(c) + 2, 0);
17
+ return (
18
+ <box flexDirection="row" width={props.width} height={1}>
19
+ {cells.map((c, i) => (
20
+ <box key={c} flexDirection="row">
21
+ <Line width={Bun.stringWidth(c)} parts={[{ text: c }]} selected={i === props.focused} />
22
+ <text>{" "}</text>
23
+ </box>
24
+ ))}
25
+ <text>{" ".repeat(Math.max(0, props.width - used))}</text>
26
+ </box>
27
+ );
28
+ }
29
+
30
+ /** A real confirmation: the message, then [no] [yes]; a destructive one starts on "no". */
31
+ export function DialogConfirm(props: { dialog: Confirm }) {
32
+ const app = useApp();
33
+ const d = props.dialog;
34
+ const [focused, setFocusedState] = useState(d.destructive ? 0 : 1);
35
+ // keys in one burst read the focus as it is now, not as it was drawn
36
+ const focusedRef = useRef(focused);
37
+ const setFocused = (f: number) => {
38
+ focusedRef.current = f;
39
+ setFocusedState(f);
40
+ };
41
+ useCommandLayer("dialog", {
42
+ "dialog.left": () => setFocused(0),
43
+ "dialog.right": () => setFocused(1),
44
+ "dialog.submit": () => (focusedRef.current === 1 ? app.answer("yes") : app.dispatch({ type: "close" })),
45
+ });
46
+ return (
47
+ <Dialog
48
+ title={d.title}
49
+ rows={(inner) => [
50
+ ...d.message.flatMap((m, i) =>
51
+ wrap(m, inner).map((l, j) => <Line key={`m${i}-${j}`} width={inner} parts={[{ text: l }]} />),
52
+ ),
53
+ <Line key="gap" width={inner} parts={[]} />,
54
+ <Buttons key="buttons" labels={[d.no, d.yes]} focused={focused} width={inner} />,
55
+ ]}
56
+ />
57
+ );
58
+ }
@@ -0,0 +1,52 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/ui/dialog-prompt.tsx (MIT, © 2025 opencode);
2
+ // see THIRD_PARTY_NOTICES.md.
3
+ import type { InputRenderable } from "@opentui/core";
4
+ import { useEffect, useRef } from "react";
5
+ import { useApp } from "../providers/app.tsx";
6
+ import { useCommandLayer } from "../providers/keymap.tsx";
7
+ import { useTone } from "../providers/theme.tsx";
8
+ import type { Dialog as DialogState } from "../state.ts";
9
+ import { Dialog } from "./dialog.tsx";
10
+ import { Line } from "./line.tsx";
11
+
12
+ type Prompt = Extract<DialogState, { kind: "prompt" }>;
13
+
14
+ /**
15
+ * A value editor: one input that owns every printable key, enter to submit, esc to cancel. A refused
16
+ * value keeps what was typed and shows why under it (research K13).
17
+ */
18
+ export function DialogPrompt(props: { dialog: Prompt }) {
19
+ const app = useApp();
20
+ const tone = useTone();
21
+ const input = useRef<InputRenderable>(null);
22
+ const d = props.dialog;
23
+ useEffect(() => {
24
+ input.current?.focus();
25
+ }, []);
26
+ // the value typed so far, not the one drawn: text typed in the same tick as enter has not drawn yet
27
+ useCommandLayer("dialog", {
28
+ "dialog.submit": () => {
29
+ const top = app.getState().dialogs.at(-1);
30
+ app.answer(top?.kind === "prompt" ? top.value : d.value);
31
+ },
32
+ });
33
+ return (
34
+ <Dialog
35
+ title={d.title}
36
+ rows={(inner) => [
37
+ <Line key="label" width={inner} parts={[{ text: d.label, tone: "muted" }]} />,
38
+ <box key="input" flexDirection="row" width={inner} height={1}>
39
+ <text fg={tone("accent")}>{"> "}</text>
40
+ <input
41
+ ref={input}
42
+ value={d.value}
43
+ onInput={(v: string) => app.dispatch({ type: "input", value: v })}
44
+ width={inner - 2}
45
+ />
46
+ </box>,
47
+ <Line key="error" width={inner} parts={d.error ? [{ text: d.error, tone: "error" }] : []} />,
48
+ <Line key="hint" width={inner} parts={[{ text: "enter to save, esc to cancel", tone: "muted" }]} />,
49
+ ]}
50
+ />
51
+ );
52
+ }
@@ -0,0 +1,212 @@
1
+ // Adapted from anomalyco/opencode packages/tui/src/ui/dialog-select.tsx (MIT, © 2025 opencode);
2
+ // see THIRD_PARTY_NOTICES.md.
3
+ import type { InputRenderable } from "@opentui/core";
4
+ import { useTerminalDimensions } from "@opentui/react";
5
+ import { type ReactNode, useEffect, useRef, useState } from "react";
6
+ import { useApp } from "../providers/app.tsx";
7
+ import { useCommandLayer } from "../providers/keymap.tsx";
8
+ import { useTone, useUi } from "../providers/theme.tsx";
9
+ import { type Dialog as DialogState, isArmed, type SelectOption } from "../state.ts";
10
+ import { glyph } from "../theme.ts";
11
+ import { Dialog, dialogRows } from "./dialog.tsx";
12
+ import { Line, type Part } from "./line.tsx";
13
+ import { windowOf } from "./list.tsx";
14
+
15
+ type Select = Extract<DialogState, { kind: "select" }>;
16
+
17
+ interface Entry {
18
+ key: string;
19
+ heading: string | null;
20
+ option: SelectOption | null;
21
+ }
22
+
23
+ /** Options matching every word of `text` in their title, group or detail. */
24
+ export function matchOptions(options: SelectOption[], text: string): SelectOption[] {
25
+ const words = text.toLowerCase().split(/\s+/).filter(Boolean);
26
+ return options.filter((o) => {
27
+ const hay = `${o.title} ${o.group ?? ""} ${o.detail ?? ""} ${o.cli ?? ""}`.toLowerCase();
28
+ return words.every((w) => hay.includes(w));
29
+ });
30
+ }
31
+
32
+ /** Grouped lines for the options: a heading before each group, Suggested first while not filtering. */
33
+ export function entriesOf(d: Select, text: string): Entry[] {
34
+ const shown = matchOptions(d.options, text);
35
+ const out: Entry[] = [];
36
+ if (!text.trim() && d.suggested?.length) {
37
+ out.push({ key: "h:Suggested", heading: "Suggested", option: null });
38
+ for (const v of d.suggested) {
39
+ const o = d.options.find((x) => x.value === v);
40
+ if (o) out.push({ key: `s:${v}`, heading: null, option: o });
41
+ }
42
+ }
43
+ let group: string | undefined;
44
+ for (const o of shown) {
45
+ if (o.group !== undefined && o.group !== group)
46
+ out.push({ key: `h:${o.group}`, heading: o.group, option: null });
47
+ group = o.group;
48
+ out.push({ key: `o:${o.value}`, heading: null, option: o });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * The picker every list dialog uses (palette, help, profile list, treat-like, failover, default rung):
55
+ * a filter that owns the letters, ↑/↓ or ctrl+p/ctrl+n to move, enter to choose, esc to cancel; ctrl+d
56
+ * twice deletes where the dialog offers it (spec §9.2).
57
+ */
58
+ export function DialogSelect(props: { dialog: Select }) {
59
+ const app = useApp();
60
+ const ui = useUi();
61
+ const tone = useTone();
62
+ const dims = useTerminalDimensions();
63
+ const input = useRef<InputRenderable>(null);
64
+ const [text, setText] = useState("");
65
+ const [index, setIndex] = useState(0);
66
+ // what a key handler reads: the filter and cursor as they are now, not as drawn (keys in one tick)
67
+ const textRef = useRef("");
68
+ const indexRef = useRef(0);
69
+ const [top, setTop] = useState(0);
70
+ useEffect(() => {
71
+ input.current?.focus();
72
+ }, []);
73
+ const pick = (t: string, i: number) => {
74
+ const entries = entriesOf(props.dialog, t);
75
+ const selectable = entries.flatMap((e, n) => (e.option ? [n] : []));
76
+ const cur = selectable[Math.max(0, Math.min(i, selectable.length - 1))];
77
+ return { entries, selectable, cur, chosen: cur === undefined ? null : (entries[cur]?.option ?? null) };
78
+ };
79
+ const { entries, cur, chosen } = pick(text, index);
80
+ const chosenNow = () => pick(textRef.current, indexRef.current).chosen;
81
+ // every row the panel has above the rule and footer, less the filter and the gap under it
82
+ const height = Math.max(3, Math.min(entries.length, dialogRows(dims.height) - 4));
83
+ const w = windowOf(entries.length, cur ?? 0, height, top);
84
+ useEffect(() => {
85
+ if (w.top !== top) setTop(w.top);
86
+ }, [w.top, top]);
87
+ // moving the cursor takes back a first ctrl+d (opencode's session list does the same)
88
+ const move = (by: number) => {
89
+ if (app.getState().armed) app.dispatch({ type: "disarm" });
90
+ const n = pick(textRef.current, indexRef.current).selectable.length;
91
+ indexRef.current = Math.max(0, Math.min(n - 1, indexRef.current + by));
92
+ setIndex(indexRef.current);
93
+ };
94
+ const armed = chosen ? isArmed(app.state, "delete", chosen.value, app.clock.now()) : false;
95
+ useCommandLayer("dialog", {
96
+ "dialog.up": () => move(-1),
97
+ "dialog.down": () => move(1),
98
+ "dialog.pageUp": () => move(-height),
99
+ "dialog.pageDown": () => move(height),
100
+ "dialog.submit": () => {
101
+ const c = chosenNow();
102
+ if (c) app.answer(c.value);
103
+ },
104
+ ...(props.dialog.deletable
105
+ ? {
106
+ "dialog.delete": () => {
107
+ const c = chosenNow();
108
+ if (!c) return;
109
+ if (isArmed(app.getState(), "delete", c.value, app.clock.now())) {
110
+ app.dispatch({ type: "disarm" });
111
+ app.answer(`delete:${c.value}`);
112
+ } else app.dispatch({ type: "arm", what: "delete", target: c.value, at: app.clock.now() });
113
+ },
114
+ }
115
+ : {}),
116
+ });
117
+ const rows = (inner: number): ReactNode[] => {
118
+ // columns: the title, the detail and the CLI twin, each padded to the widest in the whole list, so
119
+ // they line up as the list scrolls; the CLI column goes when it does not fit, the detail column
120
+ // falls back to the right edge when even it does not
121
+ const wide = (f: (o: SelectOption) => string | undefined) =>
122
+ Math.max(0, ...props.dialog.options.map((o) => Bun.stringWidth(f(o) ?? "")));
123
+ const tw = wide((o) => o.title);
124
+ const dw = wide((o) => o.detail);
125
+ const cw = wide((o) => o.cli);
126
+ const columns = 2 + tw + 2 + dw <= inner;
127
+ const withCli = columns && cw > 0 && 2 + tw + 2 + dw + 2 + cw <= inner;
128
+ const out: ReactNode[] = [
129
+ <box key="filter" flexDirection="row" width={inner} height={1}>
130
+ <text fg={tone("accent")}>{"> "}</text>
131
+ <input
132
+ ref={input}
133
+ value={text}
134
+ placeholder="type to filter"
135
+ onInput={(v: string) => {
136
+ textRef.current = v;
137
+ indexRef.current = 0;
138
+ setText(v);
139
+ setIndex(0);
140
+ if (app.getState().armed) app.dispatch({ type: "disarm" });
141
+ }}
142
+ width={inner - 2}
143
+ />
144
+ </box>,
145
+ <Line key="gap" width={inner} parts={[]} />,
146
+ ];
147
+ if (entries.length === 0) {
148
+ out.push(
149
+ <Line
150
+ key="none"
151
+ width={inner}
152
+ parts={[{ text: props.dialog.options.length ? "No match" : props.dialog.empty, tone: "muted" }]}
153
+ />,
154
+ );
155
+ return out;
156
+ }
157
+ entries.slice(w.top, w.top + height).forEach((e, i) => {
158
+ const at = w.top + i;
159
+ if (i === 0 && w.above > 0)
160
+ return out.push(
161
+ <Line
162
+ key="up"
163
+ width={inner}
164
+ parts={[{ text: `${glyph("up", ui.plain)} ${w.above + 1} more`, tone: "muted" }]}
165
+ />,
166
+ );
167
+ if (i === height - 1 && w.below > 0)
168
+ return out.push(
169
+ <Line
170
+ key="down"
171
+ width={inner}
172
+ parts={[{ text: `${glyph("down", ui.plain)} ${w.below + 1} more`, tone: "muted" }]}
173
+ />,
174
+ );
175
+ if (!e.option)
176
+ return out.push(
177
+ <Line key={e.key} width={inner} parts={[{ text: e.heading ?? "", tone: "accent", bold: true }]} />,
178
+ );
179
+ const o = e.option;
180
+ const selected = at === cur;
181
+ const label = selected && armed ? "press ctrl+d again to delete" : o.title;
182
+ const detail = o.detail ?? "";
183
+ const left: Part[] = [
184
+ { text: `${o.current ? glyph("current", ui.plain) : " "} ` },
185
+ { text: label, tone: selected && armed ? "warning" : undefined },
186
+ ];
187
+ const pad = (t: string, n: number) => " ".repeat(Math.max(0, n - Bun.stringWidth(t)));
188
+ const right: Part[] = columns
189
+ ? [
190
+ { text: `${pad(label, tw)} ` },
191
+ { text: withCli ? `${detail}${pad(detail, dw)} ` : detail, tone: "muted" },
192
+ { text: withCli ? (o.cli ?? "") : "", tone: "muted" },
193
+ ]
194
+ : [
195
+ {
196
+ text: " ".repeat(Math.max(1, inner - Bun.stringWidth(` ${label}`) - Bun.stringWidth(detail))),
197
+ },
198
+ { text: detail, tone: "muted" },
199
+ ];
200
+ out.push(<Line key={e.key} width={inner} selected={selected} parts={[...left, ...right]} />);
201
+ });
202
+ return out;
203
+ };
204
+ return (
205
+ <Dialog
206
+ title={props.dialog.title}
207
+ // the palette's CLI column needs the wide panel
208
+ size={props.dialog.options.length > 40 || props.dialog.options.some((o) => o.cli) ? "large" : "medium"}
209
+ rows={rows}
210
+ />
211
+ );
212
+ }