magus-cli 7.3.0 → 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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Blind tests — contract C17 (requirement R5.9), with C14 (CliToolRow) and
3
+ * C5 (keymap) as the surrounding contracts.
4
+ *
5
+ * Written from requirements.md and architecture.md only.
6
+ *
7
+ * R5.9: a key whose action depends on the selected row names that action in
8
+ * the footer, and the footer, the detail pane and the handler all take it from
9
+ * one function. CLI Tools, Enter:
10
+ * not installed → "install"
11
+ * update available → "update" (detail "Update to vX" when a target is known)
12
+ * cannot tell (unknown)→ "update anyway"
13
+ * current → "reinstall"
14
+ * still being checked → Enter does nothing; the footer does not advertise it.
15
+ * The footer label is the detail pane's action words, lowercased. Help keeps
16
+ * the binding's general description.
17
+ *
18
+ * AMENDED 2026-09-19: a row whose reason is `ahead-of-pin` (installed newer
19
+ * than its pin) has NO Enter action — cliToolEnterAction returns null and the
20
+ * footer does not advertise Enter (the screen passes no Enter label /
21
+ * `hide: ["install"]`). Every command there is wrong: the pin's command would
22
+ * downgrade, and latest moves further past the pin. Every other `unknown`
23
+ * reason keeps "update anyway".
24
+ *
25
+ * Pure: rows are built by hand to the CliToolRow contract. No IO. Modules are
26
+ * loaded dynamically so a missing export fails each test, not the whole file.
27
+ */
28
+ import { describe, expect, test } from "bun:test";
29
+ import { type CliTool, cliTools } from "../../data/cli-tools.js";
30
+ import type { ToolStatus } from "../../services/tool-probe.js";
31
+ import type { BinRequirement } from "../../types/index.js";
32
+ import type { CliToolRow } from "../../ui/adapters/cliToolsAdapter.js";
33
+
34
+ const loadAdapter = () => import("../../ui/adapters/cliToolsAdapter.js");
35
+ const loadKeymap = () => import("../../ui/keymap.js");
36
+
37
+ type Command = { cmd: string; args: string[] };
38
+ interface EnterAction {
39
+ kind: "install" | "update" | "update-anyway" | "reinstall";
40
+ label: string;
41
+ detail: string;
42
+ progress: string;
43
+ command: Command;
44
+ }
45
+ interface Binding {
46
+ id: string;
47
+ keys: readonly string[];
48
+ label: string;
49
+ description: string;
50
+ priority: number;
51
+ }
52
+ interface FooterOptions {
53
+ owner?: "screen" | "input" | "none";
54
+ hide?: readonly string[];
55
+ labels?: Readonly<Record<string, string>>;
56
+ }
57
+
58
+ /** Calls cliToolEnterAction through the module, failing clearly if it is absent. */
59
+ async function enterAction(row: CliToolRow): Promise<EnterAction | null> {
60
+ const mod = (await loadAdapter()) as unknown as {
61
+ cliToolEnterAction?: (r: CliToolRow) => EnterAction | null;
62
+ };
63
+ if (typeof mod.cliToolEnterAction !== "function") {
64
+ throw new Error("cliToolsAdapter does not export cliToolEnterAction");
65
+ }
66
+ return mod.cliToolEnterAction(row);
67
+ }
68
+
69
+ /** Calls footerHints with the C17 options argument. */
70
+ async function hintsFor(
71
+ width: number,
72
+ options?: FooterOptions,
73
+ ): Promise<Binding[]> {
74
+ const km = await loadKeymap();
75
+ const fn = km.footerHints as unknown as (
76
+ screen: string,
77
+ width: number,
78
+ options?: FooterOptions,
79
+ ) => Binding[];
80
+ return options === undefined
81
+ ? fn("cli-tools", width)
82
+ : fn("cli-tools", width, options);
83
+ }
84
+
85
+ const SCREEN = "cli-tools";
86
+ const ENTER_ID = "install";
87
+ const isEnter = (b: Binding) => b.keys.includes("Enter");
88
+ const sig = (b: Binding) => JSON.stringify([b.id, [...b.keys], b.label]);
89
+ const show = (hints: readonly Binding[]) =>
90
+ hints.map((b) => `${b.keys.join("/")} ${b.label}`);
91
+
92
+ /** The keymap's own CLI Tools Enter binding (id "install", keys ["Enter"]). */
93
+ async function keymapEnter(): Promise<Binding> {
94
+ const km = await loadKeymap();
95
+ const found = (km.SCREEN_BINDINGS[SCREEN] as readonly Binding[]).find(
96
+ (b) => b.id === ENTER_ID,
97
+ );
98
+ if (!found) throw new Error('SCREEN_BINDINGS["cli-tools"] has no "install"');
99
+ return found;
100
+ }
101
+
102
+ /**
103
+ * The detail pane's leading action words, lowercased: the words before the
104
+ * first "to", the first version-looking token, or the first token that does
105
+ * not start with a letter. "Update to v1.2.3" → "update";
106
+ * "Update anyway" → "update anyway"; "Reinstall v2.0.0" → "reinstall".
107
+ */
108
+ function actionWords(detail: string): string {
109
+ const words: string[] = [];
110
+ for (const token of detail.trim().split(/\s+/)) {
111
+ if (token.toLowerCase() === "to") break;
112
+ if (/^v?\d/i.test(token)) break;
113
+ if (!/^\p{L}/u.test(token)) break;
114
+ words.push(token);
115
+ }
116
+ return words.join(" ").toLowerCase();
117
+ }
118
+
119
+ describe("C17 — the test's own action-word reader", () => {
120
+ test("R5.9 control: action words stop at `to`, at a version, and at punctuation", () => {
121
+ expect(actionWords("Update to v1.2.3")).toBe("update");
122
+ expect(actionWords("Update anyway")).toBe("update anyway");
123
+ expect(actionWords("Install")).toBe("install");
124
+ expect(actionWords("Reinstall v2.0.0")).toBe("reinstall");
125
+ expect(actionWords("Reinstall (2.0.0)")).toBe("reinstall");
126
+ });
127
+ });
128
+
129
+ // ── rows ────────────────────────────────────────────────────────────────────
130
+
131
+ const TOOL: CliTool = cliTools[0] as CliTool;
132
+ const SPEC: BinRequirement = {
133
+ name: TOOL.name,
134
+ via: "npm",
135
+ package: "@example/x",
136
+ };
137
+ const INSTALL_CMD: Command = {
138
+ cmd: "npm",
139
+ args: ["install", "-g", "@example/x"],
140
+ };
141
+ const UPDATE_CMD: Command = {
142
+ cmd: "npm",
143
+ args: ["update", "-g", "@example/x"],
144
+ };
145
+
146
+ type StatusFields = Pick<
147
+ ToolStatus,
148
+ "state" | "reason" | "installed" | "latest" | "target"
149
+ > & { pin?: string };
150
+
151
+ function status(fields: StatusFields): ToolStatus {
152
+ return {
153
+ name: TOOL.name,
154
+ state: fields.state,
155
+ reason: fields.reason,
156
+ installed: fields.installed,
157
+ latest: fields.latest,
158
+ target: fields.target,
159
+ ...(fields.pin ? { pin: fields.pin } : {}),
160
+ };
161
+ }
162
+
163
+ function row(
164
+ st: ToolStatus | null,
165
+ opts: { checking?: boolean; withUpdate?: boolean } = {},
166
+ ): CliToolRow {
167
+ return {
168
+ tool: TOOL,
169
+ spec: SPEC,
170
+ checking: opts.checking ?? false,
171
+ status: st,
172
+ installCommand: INSTALL_CMD,
173
+ ...(opts.withUpdate === false ? {} : { updateCommand: UPDATE_CMD }),
174
+ };
175
+ }
176
+
177
+ interface Case {
178
+ name: string;
179
+ fields: StatusFields;
180
+ kind: EnterAction["kind"];
181
+ label: string;
182
+ }
183
+
184
+ /** Every state, each reason that can produce it, with realistic versions. */
185
+ const CASES: Case[] = [
186
+ {
187
+ name: "install / absent",
188
+ fields: {
189
+ state: "install",
190
+ reason: "absent",
191
+ installed: null,
192
+ latest: "2.0.0",
193
+ target: "2.0.0",
194
+ },
195
+ kind: "install",
196
+ label: "install",
197
+ },
198
+ {
199
+ name: "upgrade / behind",
200
+ fields: {
201
+ state: "upgrade",
202
+ reason: "behind",
203
+ installed: "1.0.0",
204
+ latest: "1.2.3",
205
+ target: "1.2.3",
206
+ },
207
+ kind: "update",
208
+ label: "update",
209
+ },
210
+ {
211
+ name: "upgrade / behind-pin",
212
+ fields: {
213
+ state: "upgrade",
214
+ reason: "behind-pin",
215
+ installed: "1.0.0",
216
+ latest: "3.0.0",
217
+ target: "1.5.0",
218
+ pin: "1.5.0",
219
+ },
220
+ kind: "update",
221
+ label: "update",
222
+ },
223
+ {
224
+ name: "current / up-to-date",
225
+ fields: {
226
+ state: "current",
227
+ reason: "up-to-date",
228
+ installed: "2.0.0",
229
+ latest: "2.0.0",
230
+ target: null,
231
+ },
232
+ kind: "reinstall",
233
+ label: "reinstall",
234
+ },
235
+ {
236
+ name: "current / at-pin",
237
+ fields: {
238
+ state: "current",
239
+ reason: "at-pin",
240
+ installed: "1.5.0",
241
+ latest: "3.0.0",
242
+ target: null,
243
+ pin: "1.5.0",
244
+ },
245
+ kind: "reinstall",
246
+ label: "reinstall",
247
+ },
248
+ // Every `unknown` reason except `ahead-of-pin`, which has no Enter action
249
+ // (R5.9 AMENDED) and is covered in its own block below.
250
+ ...(
251
+ [
252
+ ["version-unreadable", null, "2.0.0", undefined],
253
+ ["unorderable", "nightly-2026-09-01", "2.0.0", undefined],
254
+ ["not-managed", "1.2.3", "2.0.0", undefined],
255
+ ["held-back", "1.2.3", "2.0.0", "1.2.3"],
256
+ ["pin-not-installable", "1.2.3", "2.0.0", "9.9.9"],
257
+ ["latest-unavailable", "1.2.3", null, undefined],
258
+ ["not-probed", null, null, undefined],
259
+ ] as const
260
+ ).map(
261
+ ([reason, installed, latest, pin]): Case => ({
262
+ name: `unknown / ${reason}`,
263
+ fields: {
264
+ state: "unknown",
265
+ reason,
266
+ installed,
267
+ latest,
268
+ target: null,
269
+ ...(pin ? { pin } : {}),
270
+ },
271
+ kind: "update-anyway",
272
+ label: "update anyway",
273
+ }),
274
+ ),
275
+ ];
276
+
277
+ /** Fails with the row's name rather than a bare "expected object, got null". */
278
+ async function mustAct(
279
+ c: Case,
280
+ opts?: { withUpdate?: boolean },
281
+ ): Promise<EnterAction> {
282
+ const action = await enterAction(row(status(c.fields), opts));
283
+ if (action === null) throw new Error(`${c.name}: cliToolEnterAction → null`);
284
+ return action;
285
+ }
286
+
287
+ // ── cliToolEnterAction ──────────────────────────────────────────────────────
288
+
289
+ describe("C17 — cliToolEnterAction while the row is being checked", () => {
290
+ test("R5.9 a checking row with no status yet → null (Enter does nothing)", async () => {
291
+ expect(await enterAction(row(null, { checking: true }))).toBeNull();
292
+ });
293
+
294
+ test("R5.9 a checking row that still carries an earlier status → null", async () => {
295
+ const earlier = status({
296
+ state: "upgrade",
297
+ reason: "behind",
298
+ installed: "1.0.0",
299
+ latest: "1.2.3",
300
+ target: "1.2.3",
301
+ });
302
+ expect(await enterAction(row(earlier, { checking: true }))).toBeNull();
303
+ });
304
+
305
+ test("R5.9 a row that is not checking but has no status → null", async () => {
306
+ expect(await enterAction(row(null, { checking: false }))).toBeNull();
307
+ });
308
+ });
309
+
310
+ describe("C17 — cliToolEnterAction per state", () => {
311
+ for (const c of CASES) {
312
+ test(`R5.9 ${c.name}: kind "${c.kind}", footer label "${c.label}"`, async () => {
313
+ const a = await mustAct(c);
314
+ expect({ kind: a.kind, label: a.label }).toEqual({
315
+ kind: c.kind,
316
+ label: c.label,
317
+ });
318
+ });
319
+
320
+ test(`R5.9 ${c.name}: the label is the detail's action words, lowercased`, async () => {
321
+ const a = await mustAct(c);
322
+ // The detail rides along so a failure shows what the pane printed.
323
+ expect({ detail: a.detail, words: actionWords(a.detail) }).toEqual({
324
+ detail: a.detail,
325
+ words: a.label,
326
+ });
327
+ });
328
+
329
+ test(`R5.9 ${c.name}: detail and progress are real text (no null/undefined leaks)`, async () => {
330
+ const a = await mustAct(c);
331
+ for (const text of [a.detail, a.progress]) {
332
+ expect(typeof text).toBe("string");
333
+ expect(text.trim().length).toBeGreaterThan(0);
334
+ expect(text).not.toMatch(/null|undefined|NaN/);
335
+ }
336
+ });
337
+
338
+ const want = c.kind === "install" ? "installCommand" : "updateCommand";
339
+ test(`R5.9 ${c.name}: with both commands, the command is the ${want}`, async () => {
340
+ const a = await mustAct(c, { withUpdate: true });
341
+ expect(a.command).toEqual(
342
+ c.kind === "install" ? INSTALL_CMD : UPDATE_CMD,
343
+ );
344
+ });
345
+
346
+ test(`R5.9 ${c.name}: with no updateCommand, the command falls back to the installCommand`, async () => {
347
+ const a = await mustAct(c, { withUpdate: false });
348
+ expect(a.command).toEqual(INSTALL_CMD);
349
+ });
350
+ }
351
+ });
352
+
353
+ // ── ahead-of-pin: no Enter action (R5.9 AMENDED 2026-09-19) ─────────────────
354
+
355
+ /** Installed 2.1.0, pinned at 2.0.0 — the installed copy is newer than its pin. */
356
+ const PIN = "2.0.0";
357
+ const PINNED_SPEC: BinRequirement = { ...SPEC, version: PIN };
358
+ /** What an updater aimed at the pin would run: a downgrade to an older version. */
359
+ const DOWNGRADE_CMD: Command = {
360
+ cmd: "npm",
361
+ args: ["install", "-g", `@example/x@${PIN}`],
362
+ };
363
+
364
+ const AHEAD_VARIANTS = [
365
+ { name: "latest equals installed", installed: "2.1.0", latest: "2.1.0" },
366
+ { name: "latest past installed", installed: "2.1.0", latest: "3.0.0" },
367
+ ] as const;
368
+
369
+ function aheadRow(
370
+ v: (typeof AHEAD_VARIANTS)[number],
371
+ commands: { update?: Command; install: Command },
372
+ ): CliToolRow {
373
+ return {
374
+ tool: TOOL,
375
+ spec: PINNED_SPEC,
376
+ checking: false,
377
+ status: status({
378
+ state: "unknown",
379
+ reason: "ahead-of-pin",
380
+ installed: v.installed,
381
+ latest: v.latest,
382
+ target: null,
383
+ pin: PIN,
384
+ }),
385
+ installCommand: commands.install,
386
+ ...(commands.update ? { updateCommand: commands.update } : {}),
387
+ };
388
+ }
389
+
390
+ describe("C17 — cliToolEnterAction for ahead-of-pin rows (R5.9 AMENDED)", () => {
391
+ for (const v of AHEAD_VARIANTS) {
392
+ test(`R5.9 unknown / ahead-of-pin (${v.name}): no Enter action → null`, async () => {
393
+ const a = await enterAction(
394
+ aheadRow(v, { update: UPDATE_CMD, install: INSTALL_CMD }),
395
+ );
396
+ expect(a).toBeNull();
397
+ });
398
+
399
+ test(`R5.9 unknown / ahead-of-pin (${v.name}): an updateCommand aimed at the older pin AND an installCommand still yield null (no command can run)`, async () => {
400
+ const a = await enterAction(
401
+ aheadRow(v, { update: DOWNGRADE_CMD, install: INSTALL_CMD }),
402
+ );
403
+ // Surface the command that would have run, if any, on failure.
404
+ expect(
405
+ a === null ? null : { kind: a.kind, command: a.command },
406
+ ).toBeNull();
407
+ });
408
+
409
+ test(`R5.9 unknown / ahead-of-pin (${v.name}): with only an installCommand → null`, async () => {
410
+ const a = await enterAction(aheadRow(v, { install: INSTALL_CMD }));
411
+ expect(a).toBeNull();
412
+ });
413
+ }
414
+
415
+ test('R5.9 unknown / ahead-of-pin: the null action is shown as hide ["install"], and the footer advertises no Enter', async () => {
416
+ const a = await enterAction(
417
+ aheadRow(AHEAD_VARIANTS[0], {
418
+ update: DOWNGRADE_CMD,
419
+ install: INSTALL_CMD,
420
+ }),
421
+ );
422
+ expect(a).toBeNull();
423
+ // A null action: the screen passes no Enter label and hides the binding.
424
+ const hints = await hintsFor(200, { hide: [ENTER_ID] });
425
+ expect(show(hints.filter(isEnter))).toEqual([]);
426
+ expect(hints.filter((b) => b.id === ENTER_ID)).toEqual([]);
427
+ });
428
+
429
+ test("R5.9 control: the other unknown reasons still offer update anyway (ahead-of-pin is the only exception)", async () => {
430
+ const others = CASES.filter((c) => c.fields.state === "unknown");
431
+ expect(others.map((c) => c.fields.reason).sort()).toEqual(
432
+ [
433
+ "held-back",
434
+ "latest-unavailable",
435
+ "not-managed",
436
+ "not-probed",
437
+ "pin-not-installable",
438
+ "unorderable",
439
+ "version-unreadable",
440
+ ].sort(),
441
+ );
442
+ for (const c of others) {
443
+ const a = await mustAct(c);
444
+ expect({ reason: c.fields.reason, kind: a.kind, label: a.label }).toEqual(
445
+ {
446
+ reason: c.fields.reason,
447
+ kind: "update-anyway",
448
+ label: "update anyway",
449
+ },
450
+ );
451
+ }
452
+ });
453
+ });
454
+
455
+ describe("C17 — the update detail names its target", () => {
456
+ const behind = (target: string | null): Case => ({
457
+ name: `upgrade / behind → ${target}`,
458
+ fields: {
459
+ state: "upgrade",
460
+ reason: "behind",
461
+ installed: "1.0.0",
462
+ latest: "1.2.3",
463
+ target,
464
+ },
465
+ kind: "update",
466
+ label: "update",
467
+ });
468
+
469
+ test('R5.9 upgrade with target 1.2.3: detail is "Update to v1.2.3"', async () => {
470
+ const a = await mustAct(behind("1.2.3"));
471
+ expect(a.detail).toBe("Update to v1.2.3");
472
+ });
473
+
474
+ test("R5.9 upgrade with target 1.2.3: detail carries the version with one leading v", async () => {
475
+ const a = await mustAct(behind("1.2.3"));
476
+ expect(a.detail).toContain("v1.2.3");
477
+ expect(a.detail).not.toMatch(/vv\d/i);
478
+ });
479
+
480
+ test("R5.9 upgrade with a target already written v1.2.3: still one leading v", async () => {
481
+ const a = await mustAct(behind("v1.2.3"));
482
+ expect(a.detail).toContain("v1.2.3");
483
+ expect(a.detail).not.toMatch(/vv\d/i);
484
+ });
485
+
486
+ test("R5.9 upgrade with no known target: the label stays update and the detail names no version", async () => {
487
+ const a = await mustAct(behind(null));
488
+ expect(a.kind).toBe("update");
489
+ expect(a.label).toBe("update");
490
+ expect(actionWords(a.detail)).toBe("update");
491
+ expect(a.detail).not.toMatch(/null|undefined|\bv\b|to\s*$/);
492
+ });
493
+ });
494
+
495
+ // ── footerHints with labels / hide ──────────────────────────────────────────
496
+
497
+ describe("C17 — the CLI Tools Enter binding in the keymap", () => {
498
+ test('R5.9 SCREEN_BINDINGS["cli-tools"] has an Enter binding with id "install"', async () => {
499
+ const b = await keymapEnter();
500
+ expect([...b.keys]).toEqual(["Enter"]);
501
+ expect(b.label.trim().length).toBeGreaterThan(0);
502
+ expect(b.description.trim().length).toBeGreaterThan(0);
503
+ });
504
+ });
505
+
506
+ describe("C17 — footerHints(cli-tools) with a row label", () => {
507
+ test('R5.9 labels { install: "update" } @200: an Enter hint labelled "update", none labelled "install"', async () => {
508
+ const hints = await hintsFor(200, { labels: { install: "update" } });
509
+ const enter = hints.filter(isEnter);
510
+ expect(show(enter)).toEqual(["Enter update"]);
511
+ expect(enter[0]?.id).toBe(ENTER_ID);
512
+ expect(show(hints.filter((b) => b.label === "install"))).toEqual([]);
513
+ });
514
+
515
+ test("R5.9 no options @200: the Enter hint keeps the keymap's default label", async () => {
516
+ const def = await keymapEnter();
517
+ const hints = await hintsFor(200);
518
+ expect(show(hints.filter(isEnter))).toEqual([`Enter ${def.label}`]);
519
+ });
520
+
521
+ test('R5.9 hide ["install"] @200: no Enter hint (a row being checked)', async () => {
522
+ const hints = await hintsFor(200, { hide: [ENTER_ID] });
523
+ expect(show(hints.filter(isEnter))).toEqual([]);
524
+ expect(hints.filter((b) => b.id === ENTER_ID)).toEqual([]);
525
+ });
526
+
527
+ test('R5.9 hide ["install"] wins over a label for the same binding', async () => {
528
+ const hints = await hintsFor(200, {
529
+ hide: [ENTER_ID],
530
+ labels: { install: "update" },
531
+ });
532
+ expect(show(hints.filter(isEnter))).toEqual([]);
533
+ expect(show(hints.filter((b) => b.label === "update"))).toEqual([]);
534
+ });
535
+
536
+ test("R5.9 a label only renames the Enter hint; every other hint is unchanged", async () => {
537
+ const plain = await hintsFor(10_000);
538
+ const named = await hintsFor(10_000, { labels: { install: "update" } });
539
+ const others = (hs: Binding[]) =>
540
+ hs
541
+ .filter((b) => b.id !== ENTER_ID)
542
+ .map(sig)
543
+ .sort();
544
+ expect(others(named)).toEqual(others(plain));
545
+ });
546
+
547
+ test("R5.9 a label for an id the screen does not have changes nothing", async () => {
548
+ const plain = await hintsFor(200);
549
+ const named = await hintsFor(200, {
550
+ labels: { "no-such-binding": "zzz" },
551
+ });
552
+ expect(named.map(sig)).toEqual(plain.map(sig));
553
+ });
554
+
555
+ test("R5.9 an override does not leak into the keymap or into later calls", async () => {
556
+ const before = await keymapEnter();
557
+ const beforeLabel = before.label;
558
+ await hintsFor(200, { labels: { install: "update anyway" } });
559
+ const after = await keymapEnter();
560
+ expect(after.label).toBe(beforeLabel);
561
+ const later = await hintsFor(200);
562
+ expect(show(later.filter(isEnter))).toEqual([`Enter ${beforeLabel}`]);
563
+ });
564
+
565
+ for (const width of [40, 60, 80, 100] as const) {
566
+ test(`R5.9 labels { install: "update anyway" } @${width}: footerWidth(hints) <= ${width}`, async () => {
567
+ const km = await loadKeymap();
568
+ const hints = await hintsFor(width, {
569
+ labels: { install: "update anyway" },
570
+ });
571
+ expect(km.footerWidth(hints)).toBeLessThanOrEqual(width);
572
+ });
573
+
574
+ test(`R5.9 labels { install: "update anyway" } @${width}: any Enter hint shown carries the override`, async () => {
575
+ const hints = await hintsFor(width, {
576
+ labels: { install: "update anyway" },
577
+ });
578
+ for (const b of hints.filter(isEnter)) {
579
+ expect(b.label).toBe("update anyway");
580
+ }
581
+ });
582
+
583
+ test(`R5.9 a label too long for any footer @${width}: budget uses the replaced label, so it still fits`, async () => {
584
+ const km = await loadKeymap();
585
+ const huge = "update anyway ".repeat(12).trim();
586
+ const hints = await hintsFor(width, { labels: { install: huge } });
587
+ expect(km.footerWidth(hints)).toBeLessThanOrEqual(width);
588
+ expect(show(hints.filter(isEnter))).toEqual([]);
589
+ });
590
+ }
591
+ });
592
+
593
+ describe("C17 — one function feeds the footer (R5.9)", () => {
594
+ for (const c of CASES) {
595
+ test(`R5.9 ${c.name}: passing the action's label to footerHints shows "Enter ${c.label}"`, async () => {
596
+ const a = await mustAct(c);
597
+ const hints = await hintsFor(200, { labels: { install: a.label } });
598
+ expect(show(hints.filter(isEnter))).toEqual([`Enter ${a.label}`]);
599
+ });
600
+ }
601
+ });
602
+
603
+ // ── help ────────────────────────────────────────────────────────────────────
604
+
605
+ describe("C17 — help keeps the general description", () => {
606
+ for (const current of ["cli-tools", "plugins"] as const) {
607
+ test(`R5.9 helpSections("${current}") lists the Enter binding with its default label and general description, after footer overrides`, async () => {
608
+ const km = await loadKeymap();
609
+ const def = await keymapEnter();
610
+ // Footer options passed elsewhere must not reach help.
611
+ await hintsFor(200, { labels: { install: "update anyway" } });
612
+ await hintsFor(200, { hide: [ENTER_ID] });
613
+ const section = km.helpSections(current).find((s) => s.screen === SCREEN);
614
+ expect(section).toBeDefined();
615
+ const enter = (section?.bindings as readonly Binding[] | undefined)?.find(
616
+ (b) => b.id === ENTER_ID,
617
+ );
618
+ expect(enter).toBeDefined();
619
+ expect([...(enter?.keys ?? [])]).toEqual(["Enter"]);
620
+ expect(enter?.label).toBe(def.label);
621
+ expect(enter?.description).toBe(def.description);
622
+ expect(enter?.description.toLowerCase()).not.toBe("update anyway");
623
+ });
624
+ }
625
+ });