catherd-cli 0.2.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,545 @@
1
+ // Command ids and default keys follow anomalyco/opencode packages/tui/src/config/keybind.ts
2
+ // (MIT, © 2025 opencode); see THIRD_PARTY_NOTICES.md.
3
+ import { z } from "zod";
4
+ import { CatherdError } from "../../domain/errors.ts";
5
+
6
+ /**
7
+ * Where a command's keys are live (spec §9.2's context stack global → tab → pane → dialog → input).
8
+ * - `global`: in every mode, dialogs included.
9
+ * - `app`, `tab.*`, `list`: the base mode (no dialog open); their printable keys only while no text input
10
+ * has focus.
11
+ * - `row.*`: the base mode, and only while no text input has focus (their keys act on the selected row).
12
+ * - `filter`: only while a list's filter input has focus.
13
+ * - `dialog`: only while a dialog is open (the keymap's modal mode).
14
+ */
15
+ export const SCOPES = [
16
+ "global",
17
+ "app",
18
+ "tab.status",
19
+ "tab.profiles",
20
+ "tab.runs",
21
+ "list",
22
+ "row.status",
23
+ "row.profiles",
24
+ "row.runs",
25
+ "filter",
26
+ "dialog",
27
+ ] as const;
28
+ export type Scope = (typeof SCOPES)[number];
29
+
30
+ type Group = "App" | "Tabs" | "Profiles" | "Edit" | "Status" | "Runs" | "Lists" | "Dialogs";
31
+
32
+ export interface CommandDef {
33
+ id: string;
34
+ title: string;
35
+ group: Group;
36
+ scope: Scope;
37
+ /** default keys, in the keymap's syntax: `ctrl+s`, `shift+g`, `<leader>n`, `space`, `return` */
38
+ keys: readonly string[];
39
+ /** listed in the command palette */
40
+ palette?: true;
41
+ /** listed under "Suggested" while the palette's filter is empty */
42
+ suggested?: true;
43
+ /** footer order; commands without one are never hinted */
44
+ hint?: number;
45
+ /** the footer's label, when the title is too long for it */
46
+ short?: string;
47
+ /** the CLI command that does the same, shown in the palette (research C3: the TUI teaches the CLI) */
48
+ cli?: string;
49
+ }
50
+
51
+ /** The leader key and its timeout (spec §9.2: `ctrl+x`, 2 s, as in opencode). */
52
+ export const LEADER = "ctrl+x";
53
+ export const LEADER_TIMEOUT_MS = 2_000;
54
+
55
+ /** Every action in the TUI. The palette, footer, help and keys all come from this one table. */
56
+ export const COMMANDS = [
57
+ // global: live in every mode
58
+ { id: "app.interrupt", title: "Cancel, or quit", group: "App", scope: "global", keys: ["ctrl+c"] },
59
+ // hinted only while a dialog is open (the Footer), where esc cancels it
60
+ {
61
+ id: "app.back",
62
+ title: "Back",
63
+ group: "App",
64
+ scope: "global",
65
+ keys: ["escape"],
66
+ hint: 80,
67
+ short: "cancel",
68
+ },
69
+
70
+ // app: any tab, no dialog open
71
+ {
72
+ id: "app.palette",
73
+ title: "Command palette",
74
+ group: "App",
75
+ scope: "app",
76
+ keys: ["ctrl+p", ":"],
77
+ hint: 90,
78
+ short: "commands",
79
+ },
80
+ {
81
+ id: "app.help",
82
+ title: "Keyboard shortcuts",
83
+ group: "App",
84
+ scope: "app",
85
+ keys: ["?"],
86
+ palette: true,
87
+ hint: 91,
88
+ short: "help",
89
+ },
90
+ { id: "app.quit", title: "Quit", group: "App", scope: "app", keys: ["q", "<leader>q"], palette: true },
91
+ {
92
+ id: "app.stories",
93
+ title: "Stories…",
94
+ group: "App",
95
+ scope: "app",
96
+ keys: [],
97
+ palette: true,
98
+ suggested: true,
99
+ },
100
+ {
101
+ id: "tab.status",
102
+ title: "Go to Status",
103
+ group: "Tabs",
104
+ scope: "app",
105
+ keys: ["1", "<leader>1"],
106
+ palette: true,
107
+ },
108
+ {
109
+ id: "tab.profiles",
110
+ title: "Go to Profiles",
111
+ group: "Tabs",
112
+ scope: "app",
113
+ keys: ["2", "<leader>2"],
114
+ palette: true,
115
+ },
116
+ {
117
+ id: "tab.runs",
118
+ title: "Go to Runs",
119
+ group: "Tabs",
120
+ scope: "app",
121
+ keys: ["3", "<leader>3"],
122
+ palette: true,
123
+ },
124
+ { id: "tab.next", title: "Next tab", group: "Tabs", scope: "app", keys: ["]"] },
125
+ { id: "tab.prev", title: "Previous tab", group: "Tabs", scope: "app", keys: ["["] },
126
+ {
127
+ id: "profile.new",
128
+ title: "New profile…",
129
+ group: "Profiles",
130
+ scope: "app",
131
+ keys: ["<leader>n"],
132
+ palette: true,
133
+ suggested: true,
134
+ cli: "catherd profile new <name>",
135
+ },
136
+ {
137
+ id: "profile.list",
138
+ title: "Switch profile…",
139
+ group: "Profiles",
140
+ scope: "app",
141
+ keys: ["<leader>l"],
142
+ palette: true,
143
+ suggested: true,
144
+ cli: "catherd profile list",
145
+ },
146
+
147
+ // tab.status
148
+ {
149
+ id: "status.recheck",
150
+ title: "Re-check setup",
151
+ group: "Status",
152
+ scope: "tab.status",
153
+ keys: ["r"],
154
+ palette: true,
155
+ suggested: true,
156
+ hint: 3,
157
+ short: "re-check",
158
+ cli: "catherd doctor",
159
+ },
160
+ {
161
+ id: "status.copy",
162
+ title: "Copy the fix command",
163
+ group: "Status",
164
+ scope: "tab.status",
165
+ keys: ["y"],
166
+ palette: true,
167
+ hint: 2,
168
+ short: "copy fix",
169
+ },
170
+
171
+ // tab.profiles
172
+ {
173
+ id: "profile.save",
174
+ title: "Save profile…",
175
+ group: "Profiles",
176
+ scope: "tab.profiles",
177
+ keys: ["ctrl+s"],
178
+ palette: true,
179
+ suggested: true,
180
+ hint: 3,
181
+ short: "save",
182
+ cli: "catherd profile set <path> <value>",
183
+ },
184
+ {
185
+ id: "profile.activate",
186
+ title: "Make this profile active…",
187
+ group: "Profiles",
188
+ scope: "tab.profiles",
189
+ keys: ["a"],
190
+ palette: true,
191
+ hint: 6,
192
+ short: "activate",
193
+ cli: "catherd profile use <name> [--repo]",
194
+ },
195
+ {
196
+ id: "profile.copy",
197
+ title: "Copy this profile…",
198
+ group: "Profiles",
199
+ scope: "tab.profiles",
200
+ keys: [],
201
+ palette: true,
202
+ cli: "catherd profile copy <from> <name>",
203
+ },
204
+ {
205
+ id: "profile.revert",
206
+ title: "Discard unsaved changes…",
207
+ group: "Profiles",
208
+ scope: "tab.profiles",
209
+ keys: [],
210
+ palette: true,
211
+ },
212
+ {
213
+ id: "catalog.refresh",
214
+ title: "Refresh the model catalog",
215
+ group: "Profiles",
216
+ scope: "tab.profiles",
217
+ keys: ["r"],
218
+ palette: true,
219
+ cli: "catherd catalog refresh",
220
+ },
221
+ {
222
+ id: "edit.undo",
223
+ title: "Undo",
224
+ group: "Edit",
225
+ scope: "tab.profiles",
226
+ keys: ["<leader>u"],
227
+ palette: true,
228
+ hint: 7,
229
+ short: "undo",
230
+ },
231
+ {
232
+ id: "edit.redo",
233
+ title: "Redo",
234
+ group: "Edit",
235
+ scope: "tab.profiles",
236
+ keys: ["<leader>r"],
237
+ palette: true,
238
+ },
239
+
240
+ // tab.runs
241
+ {
242
+ id: "runs.refresh",
243
+ title: "Refresh now",
244
+ group: "Runs",
245
+ scope: "tab.runs",
246
+ keys: ["r"],
247
+ palette: true,
248
+ hint: 3,
249
+ short: "refresh",
250
+ },
251
+ {
252
+ id: "runs.pause",
253
+ title: "Pause or resume updates",
254
+ group: "Runs",
255
+ scope: "tab.runs",
256
+ keys: ["p"],
257
+ palette: true,
258
+ hint: 4,
259
+ short: "pause",
260
+ },
261
+
262
+ // list: every list and tree
263
+ { id: "list.up", title: "Move up", group: "Lists", scope: "list", keys: ["up", "k"] },
264
+ { id: "list.down", title: "Move down", group: "Lists", scope: "list", keys: ["down", "j"] },
265
+ { id: "list.pageUp", title: "Page up", group: "Lists", scope: "list", keys: ["pageup"] },
266
+ { id: "list.pageDown", title: "Page down", group: "Lists", scope: "list", keys: ["pagedown"] },
267
+ { id: "list.first", title: "First row", group: "Lists", scope: "list", keys: ["home", "g"] },
268
+ { id: "list.last", title: "Last row", group: "Lists", scope: "list", keys: ["end", "shift+g"] },
269
+ {
270
+ id: "list.filter",
271
+ title: "Filter",
272
+ group: "Lists",
273
+ scope: "list",
274
+ keys: ["/"],
275
+ hint: 8,
276
+ short: "filter",
277
+ },
278
+
279
+ // row.*: act on the selected row
280
+ {
281
+ id: "status.open",
282
+ title: "Open",
283
+ group: "Status",
284
+ scope: "row.status",
285
+ keys: ["return"],
286
+ hint: 1,
287
+ short: "open",
288
+ },
289
+ {
290
+ id: "tree.toggle",
291
+ title: "Toggle",
292
+ group: "Profiles",
293
+ scope: "row.profiles",
294
+ keys: ["space"],
295
+ hint: 1,
296
+ short: "toggle",
297
+ },
298
+ {
299
+ id: "tree.open",
300
+ title: "Change or expand",
301
+ group: "Profiles",
302
+ scope: "row.profiles",
303
+ keys: ["return"],
304
+ hint: 2,
305
+ short: "change",
306
+ },
307
+ { id: "tree.expand", title: "Expand", group: "Profiles", scope: "row.profiles", keys: ["right", "l"] },
308
+ {
309
+ id: "tree.collapse",
310
+ title: "Collapse, or go to the parent",
311
+ group: "Profiles",
312
+ scope: "row.profiles",
313
+ keys: ["left", "h"],
314
+ },
315
+ {
316
+ id: "runs.open",
317
+ title: "Open the session, milestone or role",
318
+ group: "Runs",
319
+ scope: "row.runs",
320
+ keys: ["return"],
321
+ hint: 1,
322
+ short: "open",
323
+ },
324
+ {
325
+ id: "runs.cancel",
326
+ title: "Cancel the selected role (press twice)",
327
+ group: "Runs",
328
+ scope: "row.runs",
329
+ keys: ["ctrl+d"],
330
+ hint: 2,
331
+ short: "cancel",
332
+ cli: "catherd runs cancel <run> <name>",
333
+ },
334
+
335
+ // filter: a list's filter input has focus
336
+ {
337
+ id: "filter.accept",
338
+ title: "Keep the filter",
339
+ group: "Lists",
340
+ scope: "filter",
341
+ keys: ["return"],
342
+ hint: 1,
343
+ short: "done",
344
+ },
345
+
346
+ // dialog: a dialog is open
347
+ // hints with the same short label and order show as one: `↑↓ move`
348
+ {
349
+ id: "dialog.up",
350
+ title: "Previous",
351
+ group: "Dialogs",
352
+ scope: "dialog",
353
+ keys: ["up", "ctrl+p"],
354
+ hint: 0,
355
+ short: "move",
356
+ },
357
+ {
358
+ id: "dialog.down",
359
+ title: "Next",
360
+ group: "Dialogs",
361
+ scope: "dialog",
362
+ keys: ["down", "ctrl+n"],
363
+ hint: 0,
364
+ short: "move",
365
+ },
366
+ { id: "dialog.pageUp", title: "Page up", group: "Dialogs", scope: "dialog", keys: ["pageup"] },
367
+ { id: "dialog.pageDown", title: "Page down", group: "Dialogs", scope: "dialog", keys: ["pagedown"] },
368
+ {
369
+ id: "dialog.left",
370
+ title: "Previous button",
371
+ group: "Dialogs",
372
+ scope: "dialog",
373
+ keys: ["left", "shift+tab"],
374
+ hint: 0,
375
+ short: "button",
376
+ },
377
+ {
378
+ id: "dialog.right",
379
+ title: "Next button",
380
+ group: "Dialogs",
381
+ scope: "dialog",
382
+ keys: ["right", "tab"],
383
+ hint: 0,
384
+ short: "button",
385
+ },
386
+ {
387
+ id: "dialog.submit",
388
+ title: "Choose",
389
+ group: "Dialogs",
390
+ scope: "dialog",
391
+ keys: ["return"],
392
+ hint: 1,
393
+ short: "choose",
394
+ },
395
+ {
396
+ id: "dialog.delete",
397
+ title: "Delete (press twice)",
398
+ group: "Dialogs",
399
+ scope: "dialog",
400
+ keys: ["ctrl+d"],
401
+ hint: 2,
402
+ short: "delete",
403
+ },
404
+ ] as const satisfies readonly CommandDef[];
405
+
406
+ export type CommandId = (typeof COMMANDS)[number]["id"];
407
+ export type Keybinds = Record<CommandId, readonly string[]>;
408
+
409
+ const BY_ID = new Map<string, CommandDef>(COMMANDS.map((c) => [c.id, c]));
410
+ export const commandsIn = (scope: Scope): CommandDef[] => COMMANDS.filter((c) => c.scope === scope);
411
+
412
+ /** Keys a text input would take as typing: one character, `space`, or a shifted letter. */
413
+ export const isPrintable = (key: string): boolean =>
414
+ [...key].length === 1 || key === "space" || /^shift\+.$/.test(key);
415
+
416
+ /** Scopes whose printable keys go quiet while a text input has focus. */
417
+ const GUARDED: readonly Scope[] = ["app", "tab.status", "tab.profiles", "tab.runs", "list"];
418
+ export const isGuarded = (scope: Scope): boolean => GUARDED.includes(scope);
419
+
420
+ /** Commands only the ctrl+c layering may run; rebinding them could leave no way out. */
421
+ const RESERVED: readonly CommandId[] = ["app.interrupt", "app.back"];
422
+
423
+ export const DEFAULT_KEYS: Keybinds = Object.fromEntries(
424
+ COMMANDS.map((c): [string, readonly string[]] => [c.id, c.keys]),
425
+ ) as Keybinds;
426
+
427
+ /** Spec §9.2 `config.json` `keybinds`: `{ <command id>: string | string[] | "none" }`. */
428
+ const KeybindsSchema = z.record(z.string(), z.union([z.string().min(1), z.array(z.string().min(1))]));
429
+
430
+ /** The stacks of scopes that can be live at once; a key must mean one thing in each. */
431
+ export const CONTEXTS: readonly { tab: "status" | "profiles" | "runs" | null; typing: boolean }[] = [
432
+ ...(["status", "profiles", "runs"] as const).flatMap((tab) => [
433
+ { tab, typing: false },
434
+ { tab, typing: true },
435
+ ]),
436
+ { tab: null, typing: true },
437
+ ];
438
+
439
+ /** The keys live in one context, with the command each would run. */
440
+ export function keysIn(ctx: (typeof CONTEXTS)[number], keys: Keybinds): [string, CommandId][] {
441
+ const scopes: Scope[] =
442
+ ctx.tab === null
443
+ ? ["global", "dialog"]
444
+ : ["global", "app", `tab.${ctx.tab}`, "list", ctx.typing ? "filter" : `row.${ctx.tab}`];
445
+ const out: [string, CommandId][] = [];
446
+ for (const c of COMMANDS) {
447
+ if (!scopes.includes(c.scope)) continue;
448
+ for (const k of keys[c.id])
449
+ if (!(ctx.typing && isGuarded(c.scope) && isPrintable(k))) out.push([k, c.id]);
450
+ }
451
+ return out;
452
+ }
453
+
454
+ /** Every key that means two things somewhere: `"<key>: <id>, <id> (<context>)"`. */
455
+ export function duplicateKeys(keys: Keybinds): string[] {
456
+ const out: string[] = [];
457
+ for (const ctx of CONTEXTS) {
458
+ const seen = new Map<string, CommandId>();
459
+ for (const [k, id] of keysIn(ctx, keys)) {
460
+ const first = seen.get(k);
461
+ if (first && first !== id)
462
+ out.push(`${k}: ${first}, ${id} (${ctx.tab ?? "dialog"}${ctx.typing && ctx.tab ? ", typing" : ""})`);
463
+ else seen.set(k, id);
464
+ }
465
+ }
466
+ return [...new Set(out)];
467
+ }
468
+
469
+ /**
470
+ * The keys in force: the defaults with `config.json`'s `keybinds` over them. Unknown ids, reserved
471
+ * commands, the leader as a command's own key, a typed key on a command live while the user types, and
472
+ * overrides that make one key mean two things are refused with `E_CONFIG_KEYBIND`.
473
+ */
474
+ export function resolveKeybinds(raw: unknown): Keybinds {
475
+ const keys: Keybinds = { ...DEFAULT_KEYS };
476
+ if (raw === undefined) return keys;
477
+ const parsed = KeybindsSchema.safeParse(raw);
478
+ if (!parsed.success)
479
+ throw new CatherdError("E_CONFIG_KEYBIND", "config.json keybinds must map command ids to keys", {
480
+ fix: 'e.g. "keybinds": { "profile.save": "ctrl+w", "app.help": "none" }',
481
+ });
482
+ for (const [id, value] of Object.entries(parsed.data)) {
483
+ if (!BY_ID.has(id))
484
+ throw new CatherdError("E_CONFIG_KEYBIND", `config.json keybinds: no command "${id}"`, {
485
+ fix: "the command palette (ctrl+p) and catherd's README list the command ids",
486
+ });
487
+ if ((RESERVED as readonly string[]).includes(id))
488
+ throw new CatherdError("E_CONFIG_KEYBIND", `config.json keybinds: "${id}" cannot be rebound`, {
489
+ fix: `remove "${id}" from keybinds`,
490
+ });
491
+ // a comma list is several keys, as in opencode's config
492
+ const list = (Array.isArray(value) ? value : [value]).flatMap((v) => v.split(",").map((k) => k.trim()));
493
+ if (list.some((k) => k.split(" ")[0] === LEADER))
494
+ throw new CatherdError(
495
+ "E_CONFIG_KEYBIND",
496
+ `config.json keybinds: ${id} cannot take ${LEADER}, the leader`,
497
+ {
498
+ fix: `bind ${id} to another key, or to the leader and a key: "<leader>w"`,
499
+ },
500
+ );
501
+ // dialog and filter commands are live while the user types: a typed key would take letters from the text
502
+ const scope = (BY_ID.get(id) as CommandDef).scope;
503
+ const typed = list.find(isPrintable);
504
+ if ((scope === "dialog" || scope === "filter") && typed)
505
+ throw new CatherdError(
506
+ "E_CONFIG_KEYBIND",
507
+ `config.json keybinds: ${id} works while you type, so it cannot take "${typed}"`,
508
+ { fix: `bind ${id} to a key with ctrl or alt, or a named key such as "return"` },
509
+ );
510
+ keys[id as CommandId] = list.includes("none") ? [] : list.filter(Boolean);
511
+ }
512
+ const dup = duplicateKeys(keys);
513
+ if (dup.length)
514
+ throw new CatherdError(
515
+ "E_CONFIG_KEYBIND",
516
+ `config.json keybinds give one key two meanings: ${dup.join("; ")}`,
517
+ {
518
+ fix: 'bind one of them to another key, or to "none"',
519
+ },
520
+ );
521
+ return keys;
522
+ }
523
+
524
+ const WORDS: Record<string, string> = {
525
+ return: "enter",
526
+ escape: "esc",
527
+ pageup: "pgup",
528
+ pagedown: "pgdn",
529
+ up: "↑",
530
+ down: "↓",
531
+ left: "←",
532
+ right: "→",
533
+ };
534
+ const PLAIN_WORDS: Record<string, string> = { up: "up", down: "down", left: "left", right: "right" };
535
+
536
+ /** How a key reads in the footer, palette and help: `ctrl+x n`, `enter`, `G`, `↑`. */
537
+ export function formatKey(key: string, plain = false): string {
538
+ if (key.startsWith("<leader>")) return `${LEADER} ${formatKey(key.slice("<leader>".length), plain)}`;
539
+ if (/^shift\+[a-z]$/.test(key)) return key.slice(-1).toUpperCase();
540
+ return (plain ? PLAIN_WORDS[key] : undefined) ?? WORDS[key] ?? key;
541
+ }
542
+
543
+ /** All of a command's keys, `/`-joined, or "" when it has none. */
544
+ export const formatKeys = (keys: readonly string[], plain = false): string =>
545
+ keys.map((k) => formatKey(k, plain)).join("/");