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
@@ -2,7 +2,8 @@ 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
 
@@ -19,11 +20,12 @@ import {
19
20
  } from "../../services/tool-probe.js";
20
21
  import type { Command } from "../../utils/command-utils.js";
21
22
  import { formatCommand } from "../../utils/command-utils.js";
23
+ import { createRequestGate } from "../../utils/request-gate.js";
22
24
  import {
23
25
  type CliToolRow,
24
26
  buildToolRow,
27
+ cliToolEnterAction,
25
28
  countToolRows,
26
- isInstalled,
27
29
  } from "../adapters/cliToolsAdapter.js";
28
30
  import { ScrollableList } from "../components/ScrollableList.js";
29
31
  import {
@@ -43,8 +45,8 @@ const execFileAsync = promisify(execFile);
43
45
 
44
46
  let cachedRows: CliToolRow[] | null = null;
45
47
  let cacheInitialized = false;
46
- /** Bumped by every fetch, so a refresh discards the answers of the one before. */
47
- let fetchGeneration = 0;
48
+ /** Every fetch takes a token, so a refresh discards the answers of the one before. */
49
+ const fetchGate = createRequestGate();
48
50
 
49
51
  function clearCliToolsCache(): void {
50
52
  cachedRows = null;
@@ -90,6 +92,8 @@ export function CliToolsScreen() {
90
92
  const [rows, setRows] = useState<CliToolRow[]>(
91
93
  () => cachedRows || initialRows(),
92
94
  );
95
+ /** Why the last check could not run, shown under the header; null when it ran. */
96
+ const [probeError, setProbeError] = useState<string | null>(null);
93
97
 
94
98
  const rowsRef = useRef(rows);
95
99
  rowsRef.current = rows;
@@ -109,49 +113,59 @@ export function CliToolsScreen() {
109
113
  * Detection first — it decides which installer each tool is probed through
110
114
  * (§4.5 of the design: the closure's spec, else the detected installer, else
111
115
  * the catalogue) — then one shared probe, rows filling in as tools settle.
116
+ *
117
+ * Never rejects. It runs fire-and-forget on mount and on `r`, and a rejection
118
+ * nobody catches ends the app; a failed probe is said under the header
119
+ * instead, and `r` tries again.
112
120
  */
113
121
  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
- );
122
+ const token = fetchGate.begin();
123
+ setProbeError(null);
124
+ try {
125
+ const projectPath = state.projectPath || process.cwd();
126
+ const [active, installs] = await Promise.all([
127
+ // Full resolution, not `resolveBins: false`: a catalogue tool may be
128
+ // declared by a PLUGIN's requires.bin, and parity needs the spec the
129
+ // CLI plans with. `resolveBins: false` would still keep the profile's
130
+ // own cliTools, but lose every plugin-declared bin and its pin.
131
+ loadActiveClosure(projectPath),
132
+ mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
133
+ detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
134
+ ),
135
+ ]);
136
+ if (!fetchGate.isCurrent(token)) return;
137
+
138
+ const closureBins = active?.closure.bins ?? null;
139
+ const planned = cliTools.map((tool, i) => ({
140
+ tool,
141
+ install: installs[i],
142
+ spec: specForCatalogTool(tool, closureBins, installs[i]),
143
+ }));
144
+ setAndCache(() =>
145
+ planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
146
+ );
137
147
 
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
- });
148
+ await probeTools(
149
+ planned.map((p) => p.spec),
150
+ REAL_TOOL_PROBE_IO,
151
+ {
152
+ onResult: (facts, { index }) => {
153
+ if (!fetchGate.isCurrent(token)) return;
154
+ const p = planned[index];
155
+ if (!p) return;
156
+ const row = buildToolRow(p.tool, p.spec, facts, p.install);
157
+ setAndCache((prev) => {
158
+ const next = [...prev];
159
+ next[index] = row;
160
+ return next;
161
+ });
162
+ },
152
163
  },
153
- },
154
- );
164
+ );
165
+ } catch (error) {
166
+ if (!fetchGate.isCurrent(token)) return;
167
+ setProbeError(error instanceof Error ? error.message : String(error));
168
+ }
155
169
  }, [setAndCache, state.projectPath]);
156
170
 
157
171
  useEffect(() => {
@@ -162,27 +176,24 @@ export function CliToolsScreen() {
162
176
  }
163
177
  }, [fetchVersionInfo, rows]);
164
178
 
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
- }
179
+ useScreenKeys("cli-tools", {
180
+ install: () => {
181
+ void handleInstall();
182
+ },
183
+ "update-all": () => {
184
+ void handleUpdateAll();
185
+ },
186
+ conflict: () => {
187
+ void handleResolveConflict();
188
+ },
189
+ refresh: () => handleRefresh(),
190
+ move: (key) => {
191
+ const index = cliToolsState.selectedIndex + moveDirection(key);
192
+ dispatch({
193
+ type: "CLITOOLS_SELECT",
194
+ index: Math.max(0, Math.min(rows.length - 1, index)),
195
+ });
196
+ },
186
197
  });
187
198
 
188
199
  const handleRefresh = () => {
@@ -215,21 +226,11 @@ export function CliToolsScreen() {
215
226
 
216
227
  const handleInstall = async () => {
217
228
  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;
229
+ const enter = row ? cliToolEnterAction(row) : null;
230
+ if (!row || !enter) return;
231
+
232
+ const { tool } = row;
233
+ const { command, progress: action } = enter;
233
234
 
234
235
  modal.loading(`${action} ${tool.displayName}...`);
235
236
  const result = await runCommand(command);
@@ -332,24 +333,32 @@ export function CliToolsScreen() {
332
333
  )}
333
334
  {counts.unverified > 0 && (
334
335
  <>
335
- <span fg={theme.colors.muted}> │ Unverified: </span>
336
+ <span fg={theme.colors.muted}> │ Not checked: </span>
336
337
  <span fg={theme.colors.warning}>{counts.unverified}</span>
337
338
  </>
338
339
  )}
339
340
  </text>
340
341
  );
341
342
 
343
+ // The footer names what Enter does on THIS row, from the same function the
344
+ // detail pane and the handler read; while the row is checked it does nothing.
345
+ const enter = selectedRow ? cliToolEnterAction(selectedRow) : null;
346
+
342
347
  return (
343
348
  <ScreenLayout
344
349
  title="magus CLI Tools"
345
350
  currentScreen="cli-tools"
351
+ footer={
352
+ enter ? { labels: { install: enter.label } } : { hide: ["install"] }
353
+ }
346
354
  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
- ]}
355
+ notice={
356
+ probeError ? (
357
+ <text fg={theme.colors.danger}>
358
+ Could not check the tools: {probeError} — r to retry
359
+ </text>
360
+ ) : undefined
361
+ }
353
362
  listPanel={
354
363
  <ScrollableList
355
364
  items={rows}
@@ -359,7 +368,9 @@ export function CliToolsScreen() {
359
368
  getKey={(row) => row.tool.name}
360
369
  />
361
370
  }
362
- detailPanel={renderCliToolDetail(selectedRow)}
371
+ detailPanel={(detailWidth) =>
372
+ renderCliToolDetail(selectedRow, detailWidth)
373
+ }
363
374
  />
364
375
  );
365
376
  }
@@ -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>