magus-cli 7.3.1 → 7.4.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 (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,898 @@
1
+ /**
2
+ * Every key magus binds, in one place.
3
+ *
4
+ * The footer hints, the help overlay AND the key handling all read this file.
5
+ * They used to be three hand-kept copies: the help text said `u` updated a
6
+ * plugin while the Plugins handler bound `u` to a scope toggle, the help
7
+ * covered four tabs of ten, and footers advertised keys the screen had renamed.
8
+ *
9
+ * A binding's `keys` are what the user sees and also what is matched: `"U"` is
10
+ * Shift+U, `"u/p/l"` is three keys in one hint, `"^U"` is Ctrl+U, and `"Enter"`,
11
+ * `"Space"`, `"Esc"`, `"Tab"`, `"⇧Tab"`, `"PgUp"`, `"PgDn"` and the arrows are
12
+ * named keys. A screen hands `useScreenKeys` one handler per binding id, typed
13
+ * as `ScreenHandlers<S>`, so a binding with no handler does not compile — and a
14
+ * key with no binding cannot be handled, because the handler is only ever
15
+ * reached through a binding.
16
+ *
17
+ * The rules the tests hold this file to are also checked when it loads (see
18
+ * `assertKeymap` at the bottom): within a screen no key is claimed twice, and no
19
+ * screen claims a digit, `q`, `?` or a scroll key.
20
+ */
21
+
22
+ import { TABS } from "./components/TabBar.js";
23
+ import type { KeyPress } from "./input/key-dispatch.js";
24
+ import type { Screen } from "./state/types.js";
25
+
26
+ // ─── Types ──────────────────────────────────────────────────────────────────
27
+
28
+ export interface KeyBinding {
29
+ /** Unique within its screen (or within the global / scroll lists). */
30
+ id: string;
31
+ /**
32
+ * As displayed: "u", "U", "Enter", "Space", "/", "?", "^U". An entry may
33
+ * group alternatives with a slash — "u/p/l", "PgUp/PgDn" — which reads as one
34
+ * hint and matches each key. The lone "/" is the slash key itself.
35
+ */
36
+ keys: readonly string[];
37
+ /** Short footer label, e.g. "update". */
38
+ label: string;
39
+ /** Help text: one line, what the key does and where. */
40
+ description: string;
41
+ /**
42
+ * Higher is kept first when the footer is narrow. 0 (`HELP_ONLY`) keeps the
43
+ * binding out of footers altogether — it is listed in help only.
44
+ */
45
+ priority: number;
46
+ }
47
+
48
+ export interface LegendEntry {
49
+ symbol: string;
50
+ meaning: string;
51
+ }
52
+
53
+ export interface HelpSection {
54
+ title: string;
55
+ /** Set on a screen's own section; global, scroll and search-box sections have none. */
56
+ screen?: Screen;
57
+ bindings: readonly KeyBinding[];
58
+ /** What the symbols on this screen mean. */
59
+ legend?: readonly LegendEntry[];
60
+ }
61
+
62
+ /**
63
+ * What a binding does when its key arrives. Return `false` to decline — the key
64
+ * then carries on to the layers below, as if the binding were not there (Esc
65
+ * clears a committed filter, and only when there is one; otherwise it goes on to
66
+ * the global "back"). Anything else, including nothing, consumes the key.
67
+ */
68
+ export type KeyHandler = (key: KeyPress) => unknown;
69
+
70
+ /** A binding whose id is known to the type system. */
71
+ type Bound<Id extends string> = KeyBinding & { readonly id: Id };
72
+
73
+ function bind<const Id extends string>(
74
+ id: Id,
75
+ keys: readonly string[],
76
+ label: string,
77
+ description: string,
78
+ priority: number,
79
+ ): Bound<Id> {
80
+ return { id, keys, label, description, priority };
81
+ }
82
+
83
+ /** Listed in help, never in a footer. */
84
+ const HELP_ONLY = 0;
85
+
86
+ // ─── Shared bindings ────────────────────────────────────────────────────────
87
+
88
+ const MOVE = bind(
89
+ "move",
90
+ ["↑", "↓", "j", "k"],
91
+ "move",
92
+ "Move the selection",
93
+ 20,
94
+ );
95
+
96
+ /** The direction a `move` key asks for: -1 for ↑ or k, +1 for ↓ or j. */
97
+ export function moveDirection(key: KeyPress): -1 | 1 {
98
+ return key.name === "up" || key.name === "k" ? -1 : 1;
99
+ }
100
+
101
+ const CLEAR_FILTER = bind(
102
+ "clear-filter",
103
+ ["Esc"],
104
+ "clear filter",
105
+ "Clear a filter kept with Enter (only while one is set)",
106
+ 95,
107
+ );
108
+
109
+ function search(description: string) {
110
+ return bind("search", ["/"], "search", description, 75);
111
+ }
112
+
113
+ function refresh(description: string) {
114
+ return bind("refresh", ["r"], "refresh", description, 40);
115
+ }
116
+
117
+ // ─── Per screen ─────────────────────────────────────────────────────────────
118
+
119
+ const PLUGINS = [
120
+ bind(
121
+ "scope",
122
+ ["u/p/l"],
123
+ "toggle",
124
+ "Install or uninstall at user (u), project (p) or local (l) scope",
125
+ 90,
126
+ ),
127
+ bind(
128
+ "update",
129
+ ["U"],
130
+ "update",
131
+ "Update the plugin, or repair it when stale or broken",
132
+ 85,
133
+ ),
134
+ bind(
135
+ "update-all",
136
+ ["a"],
137
+ "all",
138
+ "Update or repair every plugin that needs it",
139
+ 80,
140
+ ),
141
+ bind(
142
+ "open",
143
+ ["Enter"],
144
+ "scopes",
145
+ "Pick a scope to toggle; on a marketplace, expand or collapse it",
146
+ 55,
147
+ ),
148
+ bind(
149
+ "expand",
150
+ ["←", "→", "<", ">"],
151
+ "expand",
152
+ "Expand or collapse a marketplace, or a plugin's contents",
153
+ 25,
154
+ ),
155
+ bind(
156
+ "save-profile",
157
+ ["s"],
158
+ "profile",
159
+ "Save the enabled plugins as a profile",
160
+ 60,
161
+ ),
162
+ bind(
163
+ "remove",
164
+ ["d"],
165
+ "remove",
166
+ "Remove a deprecated plugin from every scope",
167
+ 30,
168
+ ),
169
+ bind("add-marketplace", ["n"], "add marketplace", "Add a marketplace", 30),
170
+ bind("team-help", ["t"], "team", "How to share marketplaces with a team", 10),
171
+ refresh("Refresh the local marketplace caches"),
172
+ search("Filter plugins by name"),
173
+ CLEAR_FILTER,
174
+ MOVE,
175
+ ] as const;
176
+
177
+ const SKILLS = [
178
+ bind(
179
+ "scope",
180
+ ["u/p"],
181
+ "toggle",
182
+ "Install or remove at user (u) or project (p) scope; on a set, install it all",
183
+ 90,
184
+ ),
185
+ bind(
186
+ "open",
187
+ ["Enter"],
188
+ "install",
189
+ "Install the skill in this project; on a set, expand or collapse it",
190
+ 60,
191
+ ),
192
+ bind("browse", ["o"], "open", "Open the skill or set on GitHub", 50),
193
+ refresh("Reload the skills catalog"),
194
+ search("Filter skills; two or more characters also search the registry"),
195
+ CLEAR_FILTER,
196
+ MOVE,
197
+ ] as const;
198
+
199
+ const STYLES = [
200
+ bind(
201
+ "toggle",
202
+ ["Space"],
203
+ "toggle",
204
+ "Tick or untick the style; on a registry entry, fetch it",
205
+ 90,
206
+ ),
207
+ bind("apply", ["a"], "apply", "Apply the ticked styles to this project", 88),
208
+ bind(
209
+ "fill",
210
+ ["t"],
211
+ "fill",
212
+ "Fill a template from the codebase (runs for minutes)",
213
+ 62,
214
+ ),
215
+ bind("clear", ["c"], "clear", "Turn the active output style off", 60),
216
+ bind(
217
+ "edit",
218
+ ["Enter", "e"],
219
+ "edit",
220
+ "Open the style's file in its default app",
221
+ 55,
222
+ ),
223
+ bind(
224
+ "fetch",
225
+ ["f"],
226
+ "fetch",
227
+ "Fetch a community style, or accept its downloaded update",
228
+ 54,
229
+ ),
230
+ bind(
231
+ "new",
232
+ ["n"],
233
+ "new",
234
+ "Create a team style in .claude/output-styles/",
235
+ 50,
236
+ ),
237
+ bind(
238
+ "check",
239
+ ["u"],
240
+ "check",
241
+ "Check the selected community style for an update",
242
+ 45,
243
+ ),
244
+ bind(
245
+ "check-all",
246
+ ["U"],
247
+ "check all",
248
+ "Check every fetched community style for updates",
249
+ 35,
250
+ ),
251
+ bind("reset", ["x"], "reset", "Reset the ticks to what is live", 30),
252
+ refresh("Re-read the styles"),
253
+ search("Filter styles"),
254
+ CLEAR_FILTER,
255
+ MOVE,
256
+ ] as const;
257
+
258
+ const MODELS = [
259
+ bind(
260
+ "apply",
261
+ ["Enter", "a"],
262
+ "apply",
263
+ "Route this project through the selected preset",
264
+ 90,
265
+ ),
266
+ bind("off", ["c"], "off", "Turn model tiers off", 70),
267
+ bind("reload", ["r"], "reload", "Re-read the model routing", 40),
268
+ search("Filter presets"),
269
+ CLEAR_FILTER,
270
+ MOVE,
271
+ ] as const;
272
+
273
+ const MCP = [
274
+ bind(
275
+ "toggle",
276
+ ["Enter"],
277
+ "add/remove",
278
+ "Add the server to this project, or remove it",
279
+ 90,
280
+ ),
281
+ search("Search the MCP registry"),
282
+ bind("registry", ["r"], "registry", "Browse the MCP registry", 60),
283
+ MOVE,
284
+ ] as const;
285
+
286
+ const MCP_REGISTRY = [
287
+ bind("install", ["Enter"], "install", "Add the server to this project", 90),
288
+ search("Search the registry; the list reloads as you type"),
289
+ bind("local", ["l"], "local", "Back to this project's MCP servers", 50),
290
+ bind("reload", ["R"], "reload", "Reload the results", 40),
291
+ CLEAR_FILTER,
292
+ MOVE,
293
+ ] as const;
294
+
295
+ const SETTINGS = [
296
+ bind(
297
+ "user",
298
+ ["u"],
299
+ "user",
300
+ "Change the setting at user scope (~/.claude/settings.json)",
301
+ 90,
302
+ ),
303
+ bind(
304
+ "project",
305
+ ["Enter", "p"],
306
+ "project",
307
+ "Change the setting at project scope (.claude/settings.json)",
308
+ 88,
309
+ ),
310
+ MOVE,
311
+ ] as const;
312
+
313
+ const CLI_TOOLS = [
314
+ bind(
315
+ "install",
316
+ ["Enter"],
317
+ "install",
318
+ "Install, update or reinstall the selected tool",
319
+ 90,
320
+ ),
321
+ bind(
322
+ "update-all",
323
+ ["a"],
324
+ "update all",
325
+ "Update every tool known to be behind",
326
+ 80,
327
+ ),
328
+ bind(
329
+ "conflict",
330
+ ["c"],
331
+ "fix conflict",
332
+ "Keep one install of a tool installed more than one way",
333
+ 60,
334
+ ),
335
+ refresh("Check every tool again"),
336
+ MOVE,
337
+ ] as const;
338
+
339
+ const PROFILES = [
340
+ bind(
341
+ "apply",
342
+ ["Enter", "a"],
343
+ "apply",
344
+ "Apply the selected profile to this project",
345
+ 90,
346
+ ),
347
+ bind("rename", ["r"], "rename", "Rename a saved profile", 60),
348
+ bind("delete", ["d"], "delete", "Delete a saved profile", 55),
349
+ bind("copy", ["c"], "copy", "Copy a saved profile to the clipboard", 50),
350
+ bind("import", ["i"], "import", "Import a profile from the clipboard", 45),
351
+ MOVE,
352
+ ] as const;
353
+
354
+ const GITIGNORE = [
355
+ bind("fix", ["f"], "fix", "Fix the selected row", 90),
356
+ bind("fix-all", ["F"], "fix all", "Fix every row that needs it", 85),
357
+ bind("edit", ["e"], "edit", "Open .claude/gitignore.json in $EDITOR", 55),
358
+ bind(
359
+ "template",
360
+ ["t"],
361
+ "template",
362
+ "Merge a built-in template into .claude/gitignore.json",
363
+ 50,
364
+ ),
365
+ bind("reload", ["r"], "reload", "Re-read the git state", 40),
366
+ MOVE,
367
+ ] as const;
368
+
369
+ const ALIAS = [
370
+ bind(
371
+ "toggle",
372
+ ["Space", "Enter"],
373
+ "toggle",
374
+ "Turn the flag on or off; in the flag editor, the row",
375
+ 90,
376
+ ),
377
+ bind("write", ["w"], "write", "Write the alias to your shell's rc file", 85),
378
+ bind("edit", ["e"], "edit", "Edit the flag's value", 80),
379
+ bind("reset", ["r"], "reset", "Reset the row to its default", 50),
380
+ bind("add", ["a"], "add", "Flag editor: add a value", 80),
381
+ bind("delete", ["d"], "delete", "Flag editor: remove the selected value", 70),
382
+ bind("back", ["h", "Esc"], "back", "Flag editor: back to the flag list", 75),
383
+ MOVE,
384
+ ] as const;
385
+
386
+ const SCREEN_KEYMAP = {
387
+ plugins: PLUGINS,
388
+ mcp: MCP,
389
+ "mcp-registry": MCP_REGISTRY,
390
+ settings: SETTINGS,
391
+ "cli-tools": CLI_TOOLS,
392
+ profiles: PROFILES,
393
+ skills: SKILLS,
394
+ styles: STYLES,
395
+ gitignore: GITIGNORE,
396
+ alias: ALIAS,
397
+ models: MODELS,
398
+ } as const satisfies Record<Screen, readonly KeyBinding[]>;
399
+
400
+ export const SCREEN_BINDINGS: Readonly<Record<Screen, readonly KeyBinding[]>> =
401
+ SCREEN_KEYMAP;
402
+
403
+ /** The binding ids one screen declares. */
404
+ export type BindingId<S extends Screen> =
405
+ (typeof SCREEN_KEYMAP)[S][number]["id"];
406
+
407
+ /** One handler per binding of screen `S` — none missing, none extra. */
408
+ export type ScreenHandlers<S extends Screen> = Record<BindingId<S>, KeyHandler>;
409
+
410
+ // ─── Global and scroll ──────────────────────────────────────────────────────
411
+
412
+ const HELP = bind("help", ["?"], "help", "Show every key (this list)", 100);
413
+
414
+ const FIXED_GLOBAL = [
415
+ HELP,
416
+ bind(
417
+ "quit",
418
+ ["q"],
419
+ "quit",
420
+ "Quit magus (Ctrl+C quits from anywhere, even a dialog)",
421
+ 30,
422
+ ),
423
+ bind(
424
+ "cycle-tab",
425
+ ["Tab", "⇧Tab"],
426
+ "tabs",
427
+ "Next tab; Shift+Tab goes to the previous one",
428
+ 15,
429
+ ),
430
+ bind(
431
+ "back",
432
+ ["Esc"],
433
+ "back",
434
+ "To Plugins (from the MCP registry, to MCP); on Plugins, quit",
435
+ 10,
436
+ ),
437
+ ] as const;
438
+
439
+ /** One help entry per tab, keyed from TABS so the digits are never retyped. */
440
+ const TAB_BINDINGS = TABS.map((tab) =>
441
+ bind(
442
+ `tab-${tab.screen}`,
443
+ [tab.key],
444
+ tab.label,
445
+ `Go to ${tab.label}`,
446
+ HELP_ONLY,
447
+ ),
448
+ );
449
+
450
+ const TAB_SCREEN_BY_BINDING = new Map<string, Screen>(
451
+ TABS.map((tab) => [`tab-${tab.screen}`, tab.screen]),
452
+ );
453
+
454
+ export const GLOBAL_BINDINGS: readonly KeyBinding[] = [
455
+ ...FIXED_GLOBAL,
456
+ ...TAB_BINDINGS,
457
+ ];
458
+
459
+ /**
460
+ * The global handlers: one per fixed binding, plus `tab`, which every digit
461
+ * binding calls with its screen.
462
+ */
463
+ export type GlobalHandlers = Record<
464
+ (typeof FIXED_GLOBAL)[number]["id"],
465
+ KeyHandler
466
+ > & { tab: (screen: Screen) => void };
467
+
468
+ const SCROLL = [
469
+ bind(
470
+ "scroll-half",
471
+ ["^U/^D"],
472
+ "scroll",
473
+ "Scroll the right-hand panel half a page up / down",
474
+ 70,
475
+ ),
476
+ bind(
477
+ "scroll-page",
478
+ ["PgUp/PgDn"],
479
+ "page",
480
+ "Scroll the right-hand panel a page up / down",
481
+ 15,
482
+ ),
483
+ ] as const;
484
+
485
+ export const SCROLL_BINDINGS: readonly KeyBinding[] = SCROLL;
486
+
487
+ export type ScrollHandlers = Record<(typeof SCROLL)[number]["id"], KeyHandler>;
488
+
489
+ /**
490
+ * What a focused search box does with keys. Not matched like the lists above —
491
+ * the input layer decides by `inputLayerConsumes` — but listed so its footer
492
+ * and its help section come from the same file as everything else.
493
+ */
494
+ export const INPUT_BINDINGS: readonly KeyBinding[] = [
495
+ bind(
496
+ "type",
497
+ ["type"],
498
+ "filter",
499
+ "Typed text goes into the box, digits, q and ? included",
500
+ 90,
501
+ ),
502
+ bind("done", ["Enter"], "done", "Keep the filter and leave the box", 85),
503
+ bind(
504
+ "clear",
505
+ ["Esc"],
506
+ "clear",
507
+ "Clear the text; on an empty box, leave it",
508
+ 80,
509
+ ),
510
+ bind(
511
+ "move",
512
+ ["↑", "↓"],
513
+ "move",
514
+ "Move the selection without leaving the box",
515
+ 60,
516
+ ),
517
+ bind("erase", ["Backspace"], "erase", "Delete the last character", HELP_ONLY),
518
+ ];
519
+
520
+ // ─── Matching ───────────────────────────────────────────────────────────────
521
+
522
+ type KeyMatcher = (key: KeyPress) => boolean;
523
+
524
+ const plain = (key: KeyPress) => !key.ctrl && !key.meta;
525
+
526
+ const NAMED_KEYS: Readonly<Record<string, KeyMatcher>> = {
527
+ Enter: (k) => plain(k) && (k.name === "return" || k.name === "enter"),
528
+ Space: (k) => plain(k) && k.name === "space",
529
+ Esc: (k) => plain(k) && k.name === "escape",
530
+ Tab: (k) => plain(k) && k.name === "tab" && !k.shift,
531
+ "⇧Tab": (k) => plain(k) && k.name === "tab" && k.shift,
532
+ PgUp: (k) => plain(k) && k.name === "pageup",
533
+ PgDn: (k) => plain(k) && k.name === "pagedown",
534
+ Backspace: (k) => plain(k) && k.name === "backspace",
535
+ "↑": (k) => plain(k) && k.name === "up",
536
+ "↓": (k) => plain(k) && k.name === "down",
537
+ "←": (k) => plain(k) && k.name === "left",
538
+ "→": (k) => plain(k) && k.name === "right",
539
+ };
540
+
541
+ /** The individual keys one displayed entry stands for: "u/p/l" → u, p, l. */
542
+ function tokensOf(entry: string): string[] {
543
+ return entry === "/" ? ["/"] : entry.split("/");
544
+ }
545
+
546
+ function parseToken(token: string): KeyMatcher {
547
+ const named = NAMED_KEYS[token];
548
+ if (named) return named;
549
+
550
+ const ctrl = /^\^([A-Z])$/.exec(token);
551
+ if (ctrl) {
552
+ const name = (ctrl[1] as string).toLowerCase();
553
+ return (k) => k.ctrl && !k.meta && k.name === name;
554
+ }
555
+
556
+ if ([...token].length === 1) {
557
+ // Letters arrive lowercase with `shift` separate, so "U" is Shift+u and
558
+ // "u" must NOT also fire on Shift+u.
559
+ if (/^[a-z]$/.test(token)) {
560
+ return (k) => plain(k) && k.name === token && !k.shift;
561
+ }
562
+ if (/^[A-Z]$/.test(token)) {
563
+ const name = token.toLowerCase();
564
+ return (k) => plain(k) && k.name === name && k.shift;
565
+ }
566
+ // Digits and punctuation: shift is how some of them are typed at all.
567
+ return (k) => plain(k) && (k.name === token || k.sequence === token);
568
+ }
569
+
570
+ throw new Error(`keymap: "${token}" is not a key this keymap can match`);
571
+ }
572
+
573
+ const MATCHERS = new WeakMap<KeyBinding, KeyMatcher[]>();
574
+
575
+ function matchersOf(binding: KeyBinding): KeyMatcher[] {
576
+ let matchers = MATCHERS.get(binding);
577
+ if (!matchers) {
578
+ matchers = binding.keys.flatMap(tokensOf).map(parseToken);
579
+ MATCHERS.set(binding, matchers);
580
+ }
581
+ return matchers;
582
+ }
583
+
584
+ /** The first binding in `bindings` whose keys include `key`. */
585
+ export function matchBinding(
586
+ bindings: readonly KeyBinding[],
587
+ key: KeyPress,
588
+ ): KeyBinding | undefined {
589
+ return bindings.find((binding) =>
590
+ matchersOf(binding).some((matches) => matches(key)),
591
+ );
592
+ }
593
+
594
+ /**
595
+ * Run the handler of the binding `key` belongs to. True when a binding matched
596
+ * and its handler did not decline.
597
+ */
598
+ export function runBindings(
599
+ bindings: readonly KeyBinding[],
600
+ handlers: Readonly<Record<string, KeyHandler>>,
601
+ key: KeyPress,
602
+ ): boolean {
603
+ const binding = matchBinding(bindings, key);
604
+ if (!binding) return false;
605
+ const handler = handlers[binding.id];
606
+ if (!handler) return false;
607
+ return handler(key) !== false;
608
+ }
609
+
610
+ /** The global layer's version of `runBindings`: digits go to `handlers.tab`. */
611
+ export function runGlobalBindings(
612
+ handlers: GlobalHandlers,
613
+ key: KeyPress,
614
+ ): boolean {
615
+ const binding = matchBinding(GLOBAL_BINDINGS, key);
616
+ if (!binding) return false;
617
+ const screen = TAB_SCREEN_BY_BINDING.get(binding.id);
618
+ if (screen) {
619
+ handlers.tab(screen);
620
+ return true;
621
+ }
622
+ const handler = (handlers as unknown as Record<string, KeyHandler>)[
623
+ binding.id
624
+ ];
625
+ return handler ? handler(key) !== false : false;
626
+ }
627
+
628
+ // ─── Tabs ───────────────────────────────────────────────────────────────────
629
+
630
+ /** The digit that opens `screen`, from TABS; null for the MCP registry. */
631
+ export function tabKey(screen: Screen): string | null {
632
+ return TABS.find((tab) => tab.screen === screen)?.key ?? null;
633
+ }
634
+
635
+ /** Every screen, in tab order, with the MCP registry after MCP. */
636
+ const ALL_SCREENS: readonly Screen[] = TABS.flatMap((tab): Screen[] =>
637
+ tab.screen === "mcp" ? ["mcp", "mcp-registry"] : [tab.screen],
638
+ );
639
+
640
+ function screenTitle(screen: Screen): string {
641
+ if (screen === "mcp-registry") return "MCP registry";
642
+ const tab = TABS.find((t) => t.screen === screen);
643
+ return tab ? `${tab.key} ${tab.label}` : screen;
644
+ }
645
+
646
+ // ─── Footer ─────────────────────────────────────────────────────────────────
647
+
648
+ /** The space between two footer hints. */
649
+ export const FOOTER_GAP = " ";
650
+
651
+ /** Terminal cells a string takes. Every glyph this keymap uses is one cell. */
652
+ function cells(text: string): number {
653
+ return [...text].length;
654
+ }
655
+
656
+ /**
657
+ * A key group as one chip's text.
658
+ *
659
+ * Single-character keys butt together, because `↑↓` is how an arrow pair is
660
+ * written everywhere. Anything longer needs a gap, or the names run into each
661
+ * other and read as one key that does not exist — MEASURED on screen:
662
+ * `["Enter", "a"]` rendered as `Entera`. A space rather than a slash, because
663
+ * an entry such as "u/p/l" or "PgUp/PgDn" already contains one.
664
+ */
665
+ export function joinKeys(keys: readonly string[]): string {
666
+ return keys.every((k) => cells(k) === 1) ? keys.join("") : keys.join(" ");
667
+ }
668
+
669
+ /**
670
+ * Cells one hint takes: the key chip and the label chip, each padded by a space
671
+ * on both sides — exactly what `FooterHints` draws.
672
+ */
673
+ function hintWidth(hint: Pick<KeyBinding, "keys" | "label">): number {
674
+ return cells(joinKeys(hint.keys)) + 2 + cells(hint.label) + 2;
675
+ }
676
+
677
+ /** Cells the footer row needs for `hints`, gaps included. */
678
+ export function footerWidth(
679
+ hints: readonly Pick<KeyBinding, "keys" | "label">[],
680
+ ): number {
681
+ if (hints.length === 0) return 0;
682
+ const chips = hints.reduce((sum, hint) => sum + hintWidth(hint), 0);
683
+ return chips + FOOTER_GAP.length * (hints.length - 1);
684
+ }
685
+
686
+ export interface FooterOptions<S extends Screen = Screen> {
687
+ /**
688
+ * Who owns the keys right now.
689
+ * - "screen" (default): the screen's bindings, then scroll and global keys.
690
+ * - "input": a focused search box. `?`, digits and `q` are typed, so no
691
+ * global hint is shown — advertising one would be a lie.
692
+ * - "none": the screen failed to draw; only scroll and global keys still work.
693
+ */
694
+ owner?: "screen" | "input" | "none";
695
+ /**
696
+ * Binding ids of screen `S` not worth advertising on the current row. They
697
+ * still work, and help still lists them. Typed per screen, so a misspelt id
698
+ * does not compile rather than silently hiding nothing.
699
+ */
700
+ hide?: readonly BindingId<S>[];
701
+ /**
702
+ * What a binding does on the current row, when that differs by row — Enter
703
+ * on CLI Tools is "install", "update" or "reinstall". Replaces the footer
704
+ * label only; help keeps the binding's own description, which covers every
705
+ * case. Typed per screen, like `hide`.
706
+ */
707
+ labels?: Readonly<Partial<Record<BindingId<S>, string>>>;
708
+ }
709
+
710
+ /**
711
+ * The hints that fit a footer `width` cells wide.
712
+ *
713
+ * `? help` is pinned whenever it fits. The rest are taken in descending
714
+ * priority and the first one that does not fit ends the list, so a narrower
715
+ * footer is always a subset of a wider one and never keeps a lower-priority
716
+ * hint while dropping a higher one. They are shown in declaration order —
717
+ * screen, scroll, global — with `? help` last.
718
+ */
719
+ export function footerHints(
720
+ screen: Screen,
721
+ width: number,
722
+ options: FooterOptions = {},
723
+ ): KeyBinding[] {
724
+ const owner = options.owner ?? "screen";
725
+ const hidden = new Set<string>(options.hide ?? []);
726
+ const labels: Readonly<Record<string, string | undefined>> =
727
+ options.labels ?? {};
728
+
729
+ const own =
730
+ owner === "screen"
731
+ ? SCREEN_BINDINGS[screen]
732
+ .filter((b) => !hidden.has(b.id))
733
+ // Relabelled BEFORE fitting, so the width budget measures the
734
+ // words actually drawn.
735
+ .map((b) => {
736
+ const label = labels[b.id];
737
+ return label ? { ...b, label } : b;
738
+ })
739
+ : owner === "input"
740
+ ? INPUT_BINDINGS
741
+ : [];
742
+ const global =
743
+ owner === "input" ? [] : GLOBAL_BINDINGS.filter((b) => b !== HELP);
744
+ const pool = [...own, ...SCROLL_BINDINGS, ...global].filter(
745
+ (b) => b.priority > HELP_ONLY,
746
+ );
747
+
748
+ const pinned: KeyBinding[] = owner === "input" ? [] : [HELP];
749
+ if (footerWidth(pinned) > width) return [];
750
+
751
+ const kept = new Set<KeyBinding>(pinned);
752
+ // A stable sort keeps declaration order among equal priorities.
753
+ for (const binding of [...pool].sort((a, b) => b.priority - a.priority)) {
754
+ if (footerWidth([...kept, binding]) > width) break;
755
+ kept.add(binding);
756
+ }
757
+ return [...pool.filter((b) => kept.has(b)), ...pinned];
758
+ }
759
+
760
+ // ─── Help ───────────────────────────────────────────────────────────────────
761
+
762
+ /**
763
+ * What the symbols on each screen mean — every glyph a row draws, in the words
764
+ * a reader needs, so a state never depends on telling two colours apart.
765
+ * `■`/`□` are `SCOPE_ON`/`SCOPE_OFF` (ScopeSquares), `▌` is `SELECTED_MARK`
766
+ * (SelectableRow), `[–]` the Styles template glyph — each drawn exactly as
767
+ * written here.
768
+ */
769
+ const LEGENDS: Partial<Record<Screen, readonly LegendEntry[]>> = {
770
+ plugins: [
771
+ {
772
+ symbol: "■",
773
+ meaning: "installed at that scope; the squares are user, project, local",
774
+ },
775
+ { symbol: "□", meaning: "not installed at that scope" },
776
+ { symbol: "✗", meaning: "listed by no marketplace magus read" },
777
+ { symbol: "●", meaning: "added / installed" },
778
+ { symbol: "○", meaning: "not added / not installed" },
779
+ { symbol: "★", meaning: "an official marketplace" },
780
+ { symbol: "▌", meaning: "the selected row" },
781
+ ],
782
+ skills: [
783
+ {
784
+ symbol: "■",
785
+ meaning: "installed at that scope; the squares are user, project",
786
+ },
787
+ { symbol: "□", meaning: "not installed at that scope" },
788
+ { symbol: "★", meaning: "GitHub stars of a repo about skills" },
789
+ {
790
+ symbol: "☆",
791
+ meaning: "stars of a large mixed repo; they say little about the skill",
792
+ },
793
+ { symbol: "●", meaning: "installed at that scope" },
794
+ { symbol: "○", meaning: "not installed at that scope" },
795
+ ],
796
+ styles: [
797
+ { symbol: "[x]", meaning: "ticked and live" },
798
+ { symbol: "[+]", meaning: "ticked; a to apply" },
799
+ { symbol: "[~]", meaning: "live but unticked; apply removes it" },
800
+ { symbol: "[ ]", meaning: "not ticked" },
801
+ {
802
+ symbol: "[–]",
803
+ meaning: "a template; fill it (t) before it can be ticked",
804
+ },
805
+ {
806
+ symbol: "[↓]",
807
+ meaning: "in the registry, not fetched yet; f fetches it",
808
+ },
809
+ ],
810
+ mcp: [
811
+ { symbol: "⚙", meaning: "needs configuration before it runs" },
812
+ { symbol: "●", meaning: "installed" },
813
+ { symbol: "○", meaning: "not installed" },
814
+ { symbol: "⬢", meaning: "an MCP server" },
815
+ ],
816
+ settings: [
817
+ { symbol: "★", meaning: "recommended settings" },
818
+ { symbol: "●", meaning: "set in a settings file" },
819
+ { symbol: "○", meaning: "not set; the default applies" },
820
+ ],
821
+ "cli-tools": [
822
+ { symbol: "●", meaning: "installed and current" },
823
+ { symbol: "○", meaning: "not installed" },
824
+ { symbol: "*", meaning: "an update is available" },
825
+ {
826
+ symbol: "?",
827
+ meaning: "installed; magus cannot tell whether it is current",
828
+ },
829
+ { symbol: "!", meaning: "installed more than one way; c fixes it" },
830
+ ],
831
+ };
832
+
833
+ /**
834
+ * The help overlay's contents: `current` first, then every other screen in tab
835
+ * order, then the search box, the global keys and the scroll keys.
836
+ */
837
+ export function helpSections(current: Screen): HelpSection[] {
838
+ const screens = [current, ...ALL_SCREENS.filter((s) => s !== current)];
839
+ return [
840
+ ...screens.map((screen): HelpSection => {
841
+ const legend = LEGENDS[screen];
842
+ return {
843
+ title: screenTitle(screen),
844
+ screen,
845
+ bindings: SCREEN_BINDINGS[screen],
846
+ ...(legend ? { legend } : {}),
847
+ };
848
+ }),
849
+ { title: "In a search box", bindings: INPUT_BINDINGS },
850
+ { title: "Everywhere", bindings: GLOBAL_BINDINGS },
851
+ { title: "Scrolling the right-hand panel", bindings: SCROLL_BINDINGS },
852
+ ];
853
+ }
854
+
855
+ // ─── Load-time checks ───────────────────────────────────────────────────────
856
+
857
+ /**
858
+ * Refuse a keymap that breaks its own rules. Runs once, when the module loads,
859
+ * so a bad edit fails the first test or launch rather than a keypress.
860
+ */
861
+ function assertKeymap(): void {
862
+ const scrollKeys = new Set(
863
+ SCROLL_BINDINGS.flatMap((b) => b.keys.flatMap(tokensOf)),
864
+ );
865
+ for (const binding of [...GLOBAL_BINDINGS, ...SCROLL_BINDINGS]) {
866
+ matchersOf(binding);
867
+ }
868
+ for (const screen of ALL_SCREENS) {
869
+ const seen = new Map<string, string>();
870
+ for (const binding of SCREEN_BINDINGS[screen]) {
871
+ matchersOf(binding);
872
+ for (const token of binding.keys.flatMap(tokensOf)) {
873
+ const owner = seen.get(token);
874
+ if (owner) {
875
+ throw new Error(
876
+ `keymap: ${screen} binds "${token}" twice (${owner}, ${binding.id})`,
877
+ );
878
+ }
879
+ seen.set(token, binding.id);
880
+ if (/^[0-9]$/.test(token) || token === "q" || token === "?") {
881
+ throw new Error(
882
+ `keymap: ${screen}.${binding.id} claims "${token}", a global key`,
883
+ );
884
+ }
885
+ if (scrollKeys.has(token)) {
886
+ throw new Error(
887
+ `keymap: ${screen}.${binding.id} claims "${token}", a scroll key`,
888
+ );
889
+ }
890
+ }
891
+ }
892
+ }
893
+ if (ALL_SCREENS.length !== Object.keys(SCREEN_BINDINGS).length) {
894
+ throw new Error("keymap: a screen has bindings but no place in TABS");
895
+ }
896
+ }
897
+
898
+ assertKeymap();