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.
- package/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -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 run",
|
|
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("/");
|