magus-cli 7.3.1 → 7.4.1

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 (127) 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/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  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__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -2,13 +2,18 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import React, { useEffect, useCallback, useState, useRef } from "react";
4
4
  import { ScreenLayout } from "../components/layout/index.js";
5
- import { useKeyboard } from "../hooks/useKeyboard.js";
5
+ import { useScreenKeys } from "../input/KeyRouter.js";
6
+ import { moveDirection } from "../keymap.js";
6
7
  import { useApp, useModal } from "../state/AppContext.js";
7
8
  import { useDimensions } from "../state/DimensionsContext.js";
8
9
 
9
10
  import { cliTools } from "../../data/cli-tools.js";
10
11
  import { loadActiveClosure } from "../../services/active-closure.js";
11
- import { cliToolUninstall } from "../../services/cli-tool-commands.js";
12
+ import {
13
+ type InstallMethod,
14
+ cliToolUninstall,
15
+ uninstallBlocker,
16
+ } from "../../services/cli-tool-commands.js";
12
17
  import {
13
18
  type InstallInfo,
14
19
  PROBE_CONCURRENCY,
@@ -19,11 +24,12 @@ import {
19
24
  } from "../../services/tool-probe.js";
20
25
  import type { Command } from "../../utils/command-utils.js";
21
26
  import { formatCommand } from "../../utils/command-utils.js";
27
+ import { createRequestGate } from "../../utils/request-gate.js";
22
28
  import {
23
29
  type CliToolRow,
24
30
  buildToolRow,
31
+ cliToolEnterAction,
25
32
  countToolRows,
26
- isInstalled,
27
33
  } from "../adapters/cliToolsAdapter.js";
28
34
  import { ScrollableList } from "../components/ScrollableList.js";
29
35
  import {
@@ -43,8 +49,8 @@ const execFileAsync = promisify(execFile);
43
49
 
44
50
  let cachedRows: CliToolRow[] | null = null;
45
51
  let cacheInitialized = false;
46
- /** Bumped by every fetch, so a refresh discards the answers of the one before. */
47
- let fetchGeneration = 0;
52
+ /** Every fetch takes a token, so a refresh discards the answers of the one before. */
53
+ const fetchGate = createRequestGate();
48
54
 
49
55
  function clearCliToolsCache(): void {
50
56
  cachedRows = null;
@@ -90,6 +96,8 @@ export function CliToolsScreen() {
90
96
  const [rows, setRows] = useState<CliToolRow[]>(
91
97
  () => cachedRows || initialRows(),
92
98
  );
99
+ /** Why the last check could not run, shown under the header; null when it ran. */
100
+ const [probeError, setProbeError] = useState<string | null>(null);
93
101
 
94
102
  const rowsRef = useRef(rows);
95
103
  rowsRef.current = rows;
@@ -109,49 +117,59 @@ export function CliToolsScreen() {
109
117
  * Detection first — it decides which installer each tool is probed through
110
118
  * (§4.5 of the design: the closure's spec, else the detected installer, else
111
119
  * the catalogue) — then one shared probe, rows filling in as tools settle.
120
+ *
121
+ * Never rejects. It runs fire-and-forget on mount and on `r`, and a rejection
122
+ * nobody catches ends the app; a failed probe is said under the header
123
+ * instead, and `r` tries again.
112
124
  */
113
125
  const fetchVersionInfo = useCallback(async () => {
114
- const generation = ++fetchGeneration;
115
- const projectPath = state.projectPath || process.cwd();
116
- const [active, installs] = await Promise.all([
117
- // Full resolution, not `resolveBins: false`: a catalogue tool may be
118
- // declared by a PLUGIN's requires.bin, and parity needs the spec the
119
- // CLI plans with. `resolveBins: false` would still keep the profile's
120
- // own cliTools, but lose every plugin-declared bin and its pin.
121
- loadActiveClosure(projectPath),
122
- mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
123
- detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
124
- ),
125
- ]);
126
- if (generation !== fetchGeneration) return;
127
-
128
- const closureBins = active?.closure.bins ?? null;
129
- const planned = cliTools.map((tool, i) => ({
130
- tool,
131
- install: installs[i],
132
- spec: specForCatalogTool(tool, closureBins, installs[i]),
133
- }));
134
- setAndCache(() =>
135
- planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
136
- );
126
+ const token = fetchGate.begin();
127
+ setProbeError(null);
128
+ try {
129
+ const projectPath = state.projectPath || process.cwd();
130
+ const [active, installs] = await Promise.all([
131
+ // Full resolution, not `resolveBins: false`: a catalogue tool may be
132
+ // declared by a PLUGIN's requires.bin, and parity needs the spec the
133
+ // CLI plans with. `resolveBins: false` would still keep the profile's
134
+ // own cliTools, but lose every plugin-declared bin and its pin.
135
+ loadActiveClosure(projectPath),
136
+ mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
137
+ detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
138
+ ),
139
+ ]);
140
+ if (!fetchGate.isCurrent(token)) return;
141
+
142
+ const closureBins = active?.closure.bins ?? null;
143
+ const planned = cliTools.map((tool, i) => ({
144
+ tool,
145
+ install: installs[i],
146
+ spec: specForCatalogTool(tool, closureBins, installs[i]),
147
+ }));
148
+ setAndCache(() =>
149
+ planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
150
+ );
137
151
 
138
- await probeTools(
139
- planned.map((p) => p.spec),
140
- REAL_TOOL_PROBE_IO,
141
- {
142
- onResult: (facts, { index }) => {
143
- if (generation !== fetchGeneration) return;
144
- const p = planned[index];
145
- if (!p) return;
146
- const row = buildToolRow(p.tool, p.spec, facts, p.install);
147
- setAndCache((prev) => {
148
- const next = [...prev];
149
- next[index] = row;
150
- return next;
151
- });
152
+ await probeTools(
153
+ planned.map((p) => p.spec),
154
+ REAL_TOOL_PROBE_IO,
155
+ {
156
+ onResult: (facts, { index }) => {
157
+ if (!fetchGate.isCurrent(token)) return;
158
+ const p = planned[index];
159
+ if (!p) return;
160
+ const row = buildToolRow(p.tool, p.spec, facts, p.install);
161
+ setAndCache((prev) => {
162
+ const next = [...prev];
163
+ next[index] = row;
164
+ return next;
165
+ });
166
+ },
152
167
  },
153
- },
154
- );
168
+ );
169
+ } catch (error) {
170
+ if (!fetchGate.isCurrent(token)) return;
171
+ setProbeError(error instanceof Error ? error.message : String(error));
172
+ }
155
173
  }, [setAndCache, state.projectPath]);
156
174
 
157
175
  useEffect(() => {
@@ -162,27 +180,24 @@ export function CliToolsScreen() {
162
180
  }
163
181
  }, [fetchVersionInfo, rows]);
164
182
 
165
- useKeyboard((event) => {
166
- if (state.isSearching || state.modal) return;
167
-
168
- if (event.name === "up" || event.name === "k") {
169
- const newIndex = Math.max(0, cliToolsState.selectedIndex - 1);
170
- dispatch({ type: "CLITOOLS_SELECT", index: newIndex });
171
- } else if (event.name === "down" || event.name === "j") {
172
- const newIndex = Math.min(
173
- rows.length - 1,
174
- cliToolsState.selectedIndex + 1,
175
- );
176
- dispatch({ type: "CLITOOLS_SELECT", index: newIndex });
177
- } else if (event.name === "r") {
178
- handleRefresh();
179
- } else if (event.name === "a") {
180
- handleUpdateAll();
181
- } else if (event.name === "c") {
182
- handleResolveConflict();
183
- } else if (event.name === "enter" || event.name === "return") {
184
- handleInstall();
185
- }
183
+ useScreenKeys("cli-tools", {
184
+ install: () => {
185
+ void handleInstall();
186
+ },
187
+ "update-all": () => {
188
+ void handleUpdateAll();
189
+ },
190
+ conflict: () => {
191
+ void handleResolveConflict();
192
+ },
193
+ refresh: () => handleRefresh(),
194
+ move: (key) => {
195
+ const index = cliToolsState.selectedIndex + moveDirection(key);
196
+ dispatch({
197
+ type: "CLITOOLS_SELECT",
198
+ index: Math.max(0, Math.min(rows.length - 1, index)),
199
+ });
200
+ },
186
201
  });
187
202
 
188
203
  const handleRefresh = () => {
@@ -215,21 +230,11 @@ export function CliToolsScreen() {
215
230
 
216
231
  const handleInstall = async () => {
217
232
  const row = rows[cliToolsState.selectedIndex];
218
- if (!row || row.checking) return;
219
-
220
- const { tool, status, updateCommand, installCommand } = row;
221
- const installed = isInstalled(status);
222
- // Enter on an `unknown` row still offers the detected-method command:
223
- // an explicit user action, not a plan. The detail panel shows why the
224
- // row is unverified beside it.
225
- const action = !installed
226
- ? "Installing"
227
- : status?.state === "upgrade" || status?.state === "unknown"
228
- ? "Updating"
229
- : "Reinstalling";
230
- // Both sides come from the row, which built them from the SAME rule its
231
- // state came from — a declared tool's pin included.
232
- const command = installed && updateCommand ? updateCommand : installCommand;
233
+ const enter = row ? cliToolEnterAction(row) : null;
234
+ if (!row || !enter) return;
235
+
236
+ const { tool } = row;
237
+ const { command, progress: action } = enter;
233
238
 
234
239
  modal.loading(`${action} ${tool.displayName}...`);
235
240
  const result = await runCommand(command);
@@ -246,6 +251,20 @@ export function CliToolsScreen() {
246
251
  }
247
252
  };
248
253
 
254
+ /**
255
+ * Keep one install of a tool installed more than one way.
256
+ *
257
+ * Two rules, both from R9.10. An install magus cannot remove is never
258
+ * offered as something to remove: a choice reading "Keep npm, remove others"
259
+ * used to include Claude Code's native copy in "others", find no uninstall
260
+ * command for it, `continue` past it and report done — leaving the conflict
261
+ * exactly as it was, with the row still showing "!". And whatever is left
262
+ * behind is named afterwards, rather than silently skipped.
263
+ *
264
+ * Which installs can be removed comes from `cliToolUninstall` itself
265
+ * (through `uninstallBlocker`), so the list of choices and the commands that
266
+ * run cannot disagree about a method.
267
+ */
249
268
  const handleResolveConflict = async () => {
250
269
  const row = rows[cliToolsState.selectedIndex];
251
270
  const install: InstallInfo | undefined = row?.install;
@@ -253,38 +272,81 @@ export function CliToolsScreen() {
253
272
 
254
273
  const { tool } = row;
255
274
  const allMethods = install.all;
256
-
257
- // Let user pick which install to keep
258
- const options = allMethods.map((method) => ({
259
- label: `Keep ${method}${method === install.primary ? " (active)" : ""}, remove others`,
260
- value: method,
261
- }));
275
+ const blockers = new Map(
276
+ allMethods.map((m) => [
277
+ m,
278
+ uninstallBlocker(tool, m, install.brewFormula),
279
+ ]),
280
+ );
281
+ const removable = allMethods.filter((m) => blockers.get(m) === null);
282
+ const stuck = allMethods.filter((m) => blockers.get(m) !== null);
283
+ const stuckList = (methods: readonly InstallMethod[]): string =>
284
+ methods
285
+ .map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
286
+ .join("\n");
287
+
288
+ // One choice per install that can be KEPT — but only where keeping it
289
+ // removes something. "Keep npm" beside an unremovable native copy would
290
+ // promise a removal magus cannot perform.
291
+ const options = allMethods
292
+ .map((keep) => ({ keep, remove: removable.filter((m) => m !== keep) }))
293
+ .filter(({ remove }) => remove.length > 0)
294
+ .map(({ keep, remove }) => ({
295
+ label: `Keep ${keep}${keep === install.primary ? " (active)" : ""}, remove ${remove.join(" + ")}`,
296
+ value: keep,
297
+ }));
298
+
299
+ if (options.length === 0) {
300
+ await modal.message(
301
+ `${tool.displayName} conflict`,
302
+ `Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
303
+ "error",
304
+ );
305
+ return;
306
+ }
262
307
 
263
308
  const keep = await modal.select(
264
309
  `Resolve ${tool.displayName} conflict`,
265
- `Installed via ${allMethods.join(" + ")}. Which to keep?`,
310
+ stuck.length > 0
311
+ ? `Installed via ${allMethods.join(" + ")}. Which to keep?\n\n${stuckList(stuck)}`
312
+ : `Installed via ${allMethods.join(" + ")}. Which to keep?`,
266
313
  options,
267
314
  );
268
315
  if (keep === null) return;
269
316
 
270
- const toRemove = allMethods.filter((m) => m !== keep);
317
+ const toRemove = removable.filter((m) => m !== keep);
318
+ const leftBehind = stuck.filter((m) => m !== keep);
271
319
  modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
272
320
 
273
321
  const errors: string[] = [];
274
322
  for (const method of toRemove) {
275
323
  const cmd = cliToolUninstall(tool, method, install.brewFormula);
276
- if (!cmd) continue;
324
+ // Unreachable: `removable` was built from this same call. Reported
325
+ // rather than skipped, because a silent skip is the defect above.
326
+ if (!cmd) {
327
+ errors.push(`${method}: no uninstall command`);
328
+ continue;
329
+ }
277
330
  const result = await runCommand(cmd);
278
331
  if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
279
332
  }
280
333
 
281
334
  modal.hideModal();
282
335
 
283
- if (errors.length > 0) {
336
+ const report = [
337
+ errors.length > 0
338
+ ? `These removals failed. Run them by hand:\n${errors.join("\n")}`
339
+ : "",
340
+ leftBehind.length > 0
341
+ ? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
342
+ : "",
343
+ ].filter((part) => part !== "");
344
+
345
+ if (report.length > 0) {
284
346
  await modal.message(
285
- "Partial",
286
- `Some removals failed. Try manually:\n\n${errors.join("\n")}`,
287
- "error",
347
+ errors.length > 0 ? "Partly removed" : "One install stays",
348
+ report.join("\n\n"),
349
+ errors.length > 0 ? "error" : "info",
288
350
  );
289
351
  }
290
352
 
@@ -332,24 +394,32 @@ export function CliToolsScreen() {
332
394
  )}
333
395
  {counts.unverified > 0 && (
334
396
  <>
335
- <span fg={theme.colors.muted}> │ Unverified: </span>
397
+ <span fg={theme.colors.muted}> │ Not checked: </span>
336
398
  <span fg={theme.colors.warning}>{counts.unverified}</span>
337
399
  </>
338
400
  )}
339
401
  </text>
340
402
  );
341
403
 
404
+ // The footer names what Enter does on THIS row, from the same function the
405
+ // detail pane and the handler read; while the row is checked it does nothing.
406
+ const enter = selectedRow ? cliToolEnterAction(selectedRow) : null;
407
+
342
408
  return (
343
409
  <ScreenLayout
344
410
  title="magus CLI Tools"
345
411
  currentScreen="cli-tools"
412
+ footer={
413
+ enter ? { labels: { install: enter.label } } : { hide: ["install"] }
414
+ }
346
415
  statusLine={statusContent}
347
- footerHints={[
348
- { keys: ["Enter"], label: "install" },
349
- { keys: ["a"], label: "update all" },
350
- { keys: ["c"], label: "fix conflict" },
351
- { keys: ["r"], label: "refresh" },
352
- ]}
416
+ notice={
417
+ probeError ? (
418
+ <text fg={theme.colors.danger}>
419
+ Could not check the tools: {probeError} — r to retry
420
+ </text>
421
+ ) : undefined
422
+ }
353
423
  listPanel={
354
424
  <ScrollableList
355
425
  items={rows}
@@ -359,7 +429,9 @@ export function CliToolsScreen() {
359
429
  getKey={(row) => row.tool.name}
360
430
  />
361
431
  }
362
- detailPanel={renderCliToolDetail(selectedRow)}
432
+ detailPanel={(detailWidth) =>
433
+ renderCliToolDetail(selectedRow, detailWidth)
434
+ }
363
435
  />
364
436
  );
365
437
  }
@@ -11,7 +11,9 @@ import {
11
11
  } from "../adapters/settingsAdapter.js";
12
12
  import { ScrollableList } from "../components/ScrollableList.js";
13
13
  import { ScreenLayout } from "../components/layout/index.js";
14
- import { useKeyboard } from "../hooks/useKeyboard.js";
14
+ import { ErrorNotice } from "../components/primitives/index.js";
15
+ import { useScreenKeys } from "../input/KeyRouter.js";
16
+ import { moveDirection } from "../keymap.js";
15
17
  import {
16
18
  renderSettingDetail,
17
19
  renderSettingRow,
@@ -138,25 +140,20 @@ export function SettingsScreen() {
138
140
  }
139
141
  };
140
142
 
141
- useKeyboard((event) => {
142
- if (state.isSearching || state.modal) return;
143
-
144
- if (event.name === "up" || event.name === "k") {
145
- const newIndex = Math.max(0, settings.selectedIndex - 1);
146
- dispatch({ type: "SETTINGS_SELECT", index: newIndex });
147
- } else if (event.name === "down" || event.name === "j") {
148
- const newIndex = Math.min(
149
- Math.max(0, listItems.length - 1),
150
- settings.selectedIndex + 1,
151
- );
152
- dispatch({ type: "SETTINGS_SELECT", index: newIndex });
153
- } else if (event.name === "u") {
154
- handleScopeChange("user");
155
- } else if (event.name === "p") {
156
- handleScopeChange("project");
157
- } else if (event.name === "enter") {
158
- handleScopeChange("project");
159
- }
143
+ useScreenKeys("settings", {
144
+ user: () => {
145
+ void handleScopeChange("user");
146
+ },
147
+ project: () => {
148
+ void handleScopeChange("project");
149
+ },
150
+ move: (key) => {
151
+ const index = settings.selectedIndex + moveDirection(key);
152
+ dispatch({
153
+ type: "SETTINGS_SELECT",
154
+ index: Math.max(0, Math.min(listItems.length - 1, index)),
155
+ });
156
+ },
160
157
  });
161
158
 
162
159
  const selectedItem = listItems[settings.selectedIndex];
@@ -165,9 +162,8 @@ export function SettingsScreen() {
165
162
  if (settings.values.status === "loading") {
166
163
  return <text fg={theme.colors.muted}>Loading settings...</text>;
167
164
  }
168
- if (settings.values.status === "error") {
169
- return <text fg={theme.colors.danger}>Failed to load settings</text>;
170
- }
165
+ // The error itself is in the list panel, where it replaces the rows.
166
+ if (settings.values.status === "error") return null;
171
167
  return renderSettingDetail(selectedItem);
172
168
  };
173
169
 
@@ -178,10 +174,16 @@ export function SettingsScreen() {
178
174
  ).length
179
175
  : 0;
180
176
 
177
+ // A settings file that failed to read is not "0 configured" — that count
178
+ // would invite the user to configure, over a file that still holds it all.
181
179
  const statusContent = (
182
180
  <text fg={theme.colors.text}>
183
181
  <span fg={theme.colors.muted}>Settings: </span>
184
- <span fg={theme.colors.info}>{totalSet} configured</span>
182
+ {settings.values.status === "error" ? (
183
+ <span fg={theme.colors.danger}>unreadable</span>
184
+ ) : (
185
+ <span fg={theme.colors.info}>{totalSet} configured</span>
186
+ )}
185
187
  <span fg={theme.colors.muted}> │ u:user p:project</span>
186
188
  </text>
187
189
  );
@@ -191,26 +193,26 @@ export function SettingsScreen() {
191
193
  title="magus Settings"
192
194
  currentScreen="settings"
193
195
  statusLine={statusContent}
194
- footerHints={[
195
- { keys: ["↑", "↓"], label: "nav" },
196
- { keys: ["u"], label: "user scope" },
197
- { keys: ["p"], label: "project scope" },
198
- { keys: ["Enter"], label: "project" },
199
- ]}
200
196
  listPanel={
201
- settings.values.status !== "success" ? (
202
- <text fg={theme.colors.muted}>
203
- {settings.values.status === "loading"
204
- ? "Loading..."
205
- : "Error loading settings"}
206
- </text>
207
- ) : (
208
- <ScrollableList
209
- items={listItems}
210
- selectedIndex={settings.selectedIndex}
211
- renderItem={renderSettingRow}
212
- maxHeight={dimensions.listPanelHeight}
197
+ settings.values.status === "error" ? (
198
+ <ErrorNotice
199
+ title="Could not load settings."
200
+ detail={settings.values.error.message}
213
201
  />
202
+ ) : settings.values.status !== "success" ? (
203
+ <text fg={theme.colors.muted}>Loading...</text>
204
+ ) : (
205
+ // A function of the panel width: rows fit label and value to it.
206
+ (width: number) => (
207
+ <ScrollableList
208
+ items={listItems}
209
+ selectedIndex={settings.selectedIndex}
210
+ renderItem={(item, _index, isSelected) =>
211
+ renderSettingRow(item, isSelected, width)
212
+ }
213
+ maxHeight={dimensions.listPanelHeight}
214
+ />
215
+ )
214
216
  )
215
217
  }
216
218
  detailPanel={renderDetail()}
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "node:child_process";
2
- import { appendFileSync } from "node:fs";
3
2
  import { useRenderer } from "@opentui/react";
4
3
  import type React from "react";
5
4
  import { useCallback, useEffect, useMemo, useState } from "react";
@@ -14,7 +13,8 @@ import {
14
13
  } from "../../services/gitignore-service.js";
15
14
  import type { ResolvedRule, Tier, Violation } from "../../types/gitignore.js";
16
15
  import { ScreenLayout } from "../components/layout/index.js";
17
- import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
16
+ import { useScreenKeys } from "../input/KeyRouter.js";
17
+ import { moveDirection } from "../keymap.js";
18
18
  import {
19
19
  type RuleRow,
20
20
  renderRuleRow,
@@ -243,12 +243,29 @@ export function GitignoreScreen() {
243
243
  await reload();
244
244
  }, [cwd, modal, reload]);
245
245
 
246
+ /**
247
+ * Open the project manifest in $EDITOR, creating it first if needed.
248
+ *
249
+ * Every await sits inside the try: this runs fire-and-forget from a key, and a
250
+ * rejection nobody catches ends the app. A manifest that cannot be read or
251
+ * created is said in a dialog on this screen instead.
252
+ */
246
253
  const handleEditManifest = useCallback(async () => {
247
- const current = data ?? (await loadGitignoreState(cwd));
248
- const projectManifest = await ensureProjectManifestForEdit(
249
- cwd,
250
- current.resolved,
251
- );
254
+ let projectManifest: string;
255
+ try {
256
+ const current = data ?? (await loadGitignoreState(cwd));
257
+ projectManifest = await ensureProjectManifestForEdit(
258
+ cwd,
259
+ current.resolved,
260
+ );
261
+ } catch (err) {
262
+ await modal.message(
263
+ "Cannot open the manifest",
264
+ err instanceof Error ? err.message : String(err),
265
+ "error",
266
+ );
267
+ return;
268
+ }
252
269
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
253
270
  renderer.suspend();
254
271
  try {
@@ -262,48 +279,30 @@ export function GitignoreScreen() {
262
279
  }
263
280
  await reload();
264
281
  renderer.requestRender();
265
- }, [cwd, data, reload, renderer]);
266
-
267
- useKeyboardHandler((input, key) => {
268
- if (process.env.MAGUS_DEBUG_KEYS === "1") {
269
- try {
270
- appendFileSync(
271
- "/tmp/magus-keys.log",
272
- `${new Date().toISOString()} screen=gitignore input=${JSON.stringify(input)} name=${JSON.stringify(key.name)} shift=${key.shift} ctrl=${key.ctrl} meta=${key.meta}\n`,
273
- );
274
- } catch {
275
- // best-effort logging — never break the UI
276
- }
277
- }
278
- if (state.modal) return;
279
- if (input === "j" || key.downArrow) {
280
- setSelectedIdx((i) => Math.min(i + 1, Math.max(0, rows.length - 1)));
281
- return;
282
- }
283
- if (input === "k" || key.upArrow) {
284
- setSelectedIdx((i) => Math.max(i - 1, 0));
285
- return;
286
- }
287
- // F = fix everything out of sync; f = fix just the selected row.
288
- if (input === "F" || (input === "f" && key.shift)) {
289
- void handleFixAll();
290
- return;
291
- }
292
- if (input === "f") {
282
+ }, [cwd, data, modal, reload, renderer]);
283
+
284
+ useScreenKeys("gitignore", {
285
+ fix: () => {
293
286
  void handleFixOne();
294
- return;
295
- }
296
- if (input === "t") {
297
- void handleApplyTemplate();
298
- return;
299
- }
300
- if (input === "e") {
287
+ },
288
+ "fix-all": () => {
289
+ void handleFixAll();
290
+ },
291
+ edit: () => {
301
292
  void handleEditManifest();
302
- return;
303
- }
304
- if (input === "r") {
293
+ },
294
+ template: () => {
295
+ void handleApplyTemplate();
296
+ },
297
+ reload: () => {
305
298
  void reload();
306
- }
299
+ },
300
+ move: (key) => {
301
+ const delta = moveDirection(key);
302
+ setSelectedIdx((i) =>
303
+ Math.max(0, Math.min(Math.max(0, rows.length - 1), i + delta)),
304
+ );
305
+ },
307
306
  });
308
307
 
309
308
  // ── Rendering ─────────────────────────────────────────────────────────
@@ -368,14 +367,6 @@ export function GitignoreScreen() {
368
367
  title="Managed Git State"
369
368
  subtitle={subtitle}
370
369
  currentScreen="gitignore"
371
- footerHints={[
372
- { keys: ["↑", "↓"], label: "move" },
373
- { keys: ["f"], label: "fix selected" },
374
- { keys: ["F"], label: "fix all" },
375
- { keys: ["t"], label: "template" },
376
- { keys: ["e"], label: "edit" },
377
- { keys: ["r"], label: "reload" },
378
- ]}
379
370
  listPanel={listPanel}
380
371
  detailPanel={detailPanel}
381
372
  />
@@ -496,7 +487,11 @@ function renderDetail(
496
487
  >
497
488
  <text fg={theme.colors.text}>{v.path}</text>
498
489
  <text fg={theme.colors.muted}>{describeKind(v.kind)}</text>
499
- <text fg={v.severity === "safe" ? "cyan" : "red"}>
490
+ {/* A safe fix is a fact about the key; one that rewrites what git
491
+ tracks is destructive, and says so in `danger`. */}
492
+ <text
493
+ fg={v.severity === "safe" ? theme.colors.info : theme.colors.danger}
494
+ >
500
495
  {actionHint(v.kind)}
501
496
  </text>
502
497
  </box>