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
package/src/tui.tsx CHANGED
@@ -10,6 +10,7 @@
10
10
  import { RGBA, type ThemeMode, createCliRenderer } from "@opentui/core";
11
11
  import { createRoot } from "@opentui/react";
12
12
  import { App } from "./ui/App.js";
13
+ import { installShutdown } from "./ui/runtime/shutdown.js";
13
14
  import { setThemeMode } from "./ui/theme-mode.js";
14
15
  import { type ThemeEnv, resolveTheme } from "./ui/theme-resolve.js";
15
16
 
@@ -57,6 +58,49 @@ export async function startTui({
57
58
  backgroundColor: RGBA.defaultBackground(),
58
59
  useMouse: true,
59
60
  enableMouseMovement: false,
61
+ // BOTH, so the renderer registers no signal handler and never quits on
62
+ // Ctrl+C by itself: `installShutdown` below is the ONE teardown owner.
63
+ // Left at their defaults the renderer is a second owner — it destroys
64
+ // itself first on a signal and the React tree is torn down after the
65
+ // terminal was already restored (measured; see ui/runtime/shutdown.ts).
66
+ exitSignals: [],
67
+ exitOnCtrlC: false,
68
+ // OFF, so a click never gives a renderable keyboard focus. With it on, one
69
+ // click in a scrollbox made that box a second key receiver: OpenTUI hands
70
+ // every key to the focused element as well as to the KeyRouter, so arrows
71
+ // and PgUp/PgDn acted twice (measured on the test renderer: scrollTop 0 → 2
72
+ // after a click, 0 → 0 with this off). The router is the one key owner;
73
+ // InputModal focuses its own <input>, which is the only focus magus needs.
74
+ autoFocus: false,
75
+ });
76
+
77
+ // Wired immediately, before anything that can wait: theme resolution below
78
+ // can probe the terminal for 250ms, and a signal or a throw landing in that
79
+ // window must restore the terminal too.
80
+ //
81
+ // SIGINT/SIGTERM/SIGHUP/… restore the terminal and exit 128 + signum; an
82
+ // uncaught exception or unhandled rejection restores it, prints the error,
83
+ // and exits 1. `shutdown(0)` is the clean quit.
84
+ const root = createRoot(renderer);
85
+ const shutdown = installShutdown(renderer, root);
86
+ const quit = (): void => {
87
+ void shutdown(0);
88
+ };
89
+
90
+ // Ctrl+C is a KEYPRESS here, not a signal: raw mode clears ISIG, so the tty
91
+ // delivers 0x03 and SIGINT is never raised. Without this listener
92
+ // (`exitOnCtrlC: false` above) Ctrl+C would do nothing at all.
93
+ //
94
+ // It is registered on the renderer before React mounts, so it runs ahead of
95
+ // every `useKeyboard` handler — and stopping propagation means no screen or
96
+ // modal ever sees the key. That is what makes Ctrl+C quit from under any
97
+ // modal, including the undismissable profile gate, and from a screen whose
98
+ // render crashed.
99
+ renderer.keyInput.on("keypress", (key) => {
100
+ if (key.ctrl && key.name === "c") {
101
+ key.stopPropagation();
102
+ quit();
103
+ }
60
104
  });
61
105
 
62
106
  // Mouse selection → system clipboard, on release. OSC 52 survives SSH;
@@ -86,27 +130,10 @@ export async function startTui({
86
130
  });
87
131
  setThemeMode(mode);
88
132
 
89
- const root = createRoot(renderer);
90
-
91
- // Cleanup function to restore terminal
92
- const cleanup = () => {
93
- root.unmount();
94
- renderer.destroy(); // CRITICAL: Never use process.exit() directly
95
- };
96
-
97
- // Handle cleanup on exit signals
98
- const handleExit = () => {
99
- cleanup();
100
- // Exit after cleanup completes
101
- process.exit(0);
102
- };
103
-
104
- process.on("SIGINT", handleExit);
105
- process.on("SIGTERM", handleExit);
106
-
107
- // Render the OpenTUI app with exit handler
108
- root.render(<App onExit={handleExit} />);
133
+ // `q`, Escape on the home tab and the profile gate's quit all end here.
134
+ root.render(<App onExit={quit} />);
109
135
 
110
- // Wait indefinitely (app runs until user exits)
136
+ // Wait indefinitely: every exit goes through `shutdown`, which ends the
137
+ // process itself.
111
138
  await new Promise(() => {});
112
139
  }
package/src/ui/App.tsx CHANGED
@@ -1,5 +1,3 @@
1
- import fs from "node:fs";
2
- import { useRenderer } from "@opentui/react";
3
1
  import React, { useEffect, useState } from "react";
4
2
  import { activePluginPins } from "../services/active-closure.js";
5
3
  import { isClaudeAvailable } from "../services/claude-cli.js";
@@ -26,15 +24,18 @@ import {
26
24
  checkForUpdates,
27
25
  getCurrentVersion,
28
26
  } from "../services/version-check.js";
27
+ import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
28
+ import { TABS } from "./components/TabBar.js";
29
29
  import { ProgressBar } from "./components/layout/ProgressBar.js";
30
30
  import { ModalContainer } from "./components/modals/index.js";
31
31
  import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
32
- import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
33
32
  import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
34
33
  import {
35
34
  staleLoadRepairSummary,
36
35
  useStaleLoadModal,
37
36
  } from "./hooks/useStaleLoadModal.js";
37
+ import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
38
+ import { type GlobalHandlers, runGlobalBindings } from "./keymap.js";
38
39
  import {
39
40
  AliasScreen,
40
41
  CliToolsScreen,
@@ -65,13 +66,22 @@ export const VERSION = getCurrentVersion();
65
66
 
66
67
  /**
67
68
  * Router Component
68
- * Renders the current screen based on route
69
+ * Renders the current screen based on route, inside a boundary that confines a
70
+ * render error to that screen — see ScreenErrorBoundary.
69
71
  */
70
72
  function Router() {
71
73
  const { state } = useApp();
72
- const { currentRoute } = state;
74
+ const { screen } = state.currentRoute;
73
75
 
74
- switch (currentRoute.screen) {
76
+ return (
77
+ <ScreenErrorBoundary screen={screen}>
78
+ <RouteScreen screen={screen} />
79
+ </ScreenErrorBoundary>
80
+ );
81
+ }
82
+
83
+ function RouteScreen({ screen }: { screen: Screen }) {
84
+ switch (screen) {
75
85
  case "plugins":
76
86
  return <PluginsScreen />;
77
87
  case "mcp":
@@ -100,181 +110,47 @@ function Router() {
100
110
  }
101
111
 
102
112
  /**
103
- * GlobalKeyHandler Component
104
- * Handles global keyboard shortcuts (1-8, Tab, Escape, q, Ctrl+C, ?, Shift+D)
105
- * Does not render anything (returns null)
113
+ * GlobalKeys Component
114
+ * The GLOBAL key layer — the lowest one: tab digits, Tab / Shift+Tab, `q`,
115
+ * `?` and Esc, from `GLOBAL_BINDINGS` in ui/keymap.ts. It only ever sees a key
116
+ * that the modal, a focused search box, the screen and the panel scroll all
117
+ * declined, so a digit typed into a search box never switches tab and an Esc
118
+ * that cleared a filter never also quits.
119
+ *
120
+ * Ctrl+C is NOT here. It is owned by the bootstrap (src/tui.tsx), which
121
+ * catches it on the renderer before any React handler, stops its propagation
122
+ * and runs the one shutdown routine — so it quits from any screen, from under
123
+ * any modal (the profile gate cannot be dismissed), and even after a render
124
+ * error has taken the React tree down.
106
125
  */
107
- function GlobalKeyHandler({
108
- onDebugToggle,
109
- onExit,
110
- }: { onDebugToggle: () => void; onExit: () => void }): null {
126
+ function GlobalKeys({ onExit }: { onExit: () => void }): null {
111
127
  const { state } = useApp();
112
128
  const { navigateToScreen } = useNavigation();
113
129
  const modal = useModal();
114
- const renderer = useRenderer();
115
-
116
- useKeyboardHandler((input, key) => {
117
- // Debug key (Shift+D) - always available
118
- if (input === "D" && key.shift) {
119
- onDebugToggle();
120
- // Write debug info to file
121
- const debugInfo = {
122
- timestamp: new Date().toISOString(),
123
- terminal: { rows: renderer.height, columns: renderer.width },
124
- state: {
125
- currentRoute: state.currentRoute,
126
- isSearching: state.isSearching,
127
- modal: state.modal ? { type: state.modal.type } : null,
128
- plugins: {
129
- scope: state.plugins.scope,
130
- selectedIndex: state.plugins.selectedIndex,
131
- searchQuery: state.plugins.searchQuery,
132
- marketplacesStatus: state.plugins.marketplaces.status,
133
- pluginsStatus: state.plugins.plugins.status,
134
- },
135
- },
136
- };
137
- fs.writeFileSync(
138
- "/tmp/magus-debug.json",
139
- JSON.stringify(debugInfo, null, 2),
140
- );
141
- return;
142
- }
143
-
144
- // Ctrl+C quits from ANYWHERE, including from under an open modal, and is
145
- // checked before the guard below rather than beside `q` further down.
146
- //
147
- // Every modal used to be dismissable, so Escape-then-Ctrl+C always worked
148
- // and this ordering never mattered. The profile gate cannot be dismissed —
149
- // by design, since there is nothing to carry on to — which left Ctrl+C
150
- // swallowed by the guard and no interrupt at all. A terminal app that
151
- // ignores Ctrl+C is indistinguishable from one that has hung.
152
- //
153
- // `q` deliberately does NOT get the same treatment: a modal may want it
154
- // (the select list's own cancel), and it is a printable character an input
155
- // modal has to receive.
156
- if (key.ctrl && input === "c") {
157
- onExit();
158
- return;
159
- }
160
-
161
- // Don't handle keys when modal is open or searching
162
- if (state.modal || state.isSearching) return;
163
-
164
- // Global navigation shortcuts (1-9, then 0) - include mcp-registry as it's
165
- // a sub-screen of mcp
166
- const isTopLevel = [
167
- "plugins",
168
- "mcp",
169
- "mcp-registry",
170
- "settings",
171
- "cli-tools",
172
- "profiles",
173
- "skills",
174
- "styles",
175
- "gitignore",
176
- "alias",
177
- "models",
178
- ].includes(state.currentRoute.screen);
179
-
180
- if (isTopLevel) {
181
- if (input === "1") navigateToScreen("plugins");
182
- else if (input === "2") navigateToScreen("skills");
183
- else if (input === "3") navigateToScreen("mcp");
184
- else if (input === "4") navigateToScreen("settings");
185
- else if (input === "5") navigateToScreen("profiles");
186
- else if (input === "6") navigateToScreen("cli-tools");
187
- else if (input === "7") navigateToScreen("gitignore");
188
- else if (input === "8") navigateToScreen("alias");
189
- else if (input === "9") navigateToScreen("styles");
190
- else if (input === "0") navigateToScreen("models");
191
-
192
- // Tab navigation cycling
193
- if (key.tab) {
194
- const screens: Screen[] = [
195
- "plugins",
196
- "skills",
197
- "mcp",
198
- "settings",
199
- "profiles",
200
- "cli-tools",
201
- "gitignore",
202
- "alias",
203
- "styles",
204
- "models",
205
- ];
206
- const currentIndex = screens.indexOf(
207
- state.currentRoute.screen as Screen,
208
- );
209
- if (currentIndex !== -1) {
210
- const nextIndex = key.shift
211
- ? (currentIndex - 1 + screens.length) % screens.length
212
- : (currentIndex + 1) % screens.length;
213
- navigateToScreen(screens[nextIndex]);
214
- }
215
- }
216
- }
217
-
218
- // q or Ctrl+C quits from anywhere — no need to bounce to home first.
219
- if (input === "q" || (key.ctrl && input === "c")) {
220
- onExit();
221
- return;
222
- }
223
-
224
- // Escape goes back: registry → mcp, any other tab → plugins home,
225
- // already-home → exit. But when a screen has a focused sub-view open
226
- // (e.g. the Alias flag-detail editor), that sub-view owns Escape and
227
- // backs out to itself — don't let the global handler jump to home.
228
- // Scoped to the Escape branch ONLY so q/Ctrl+C still quit from anywhere.
229
- if (key.escape) {
230
- if (state.detailActive) return;
231
- if (state.currentRoute.screen === "plugins") {
232
- onExit();
233
- } else if (state.currentRoute.screen === "mcp-registry") {
234
- navigateToScreen("mcp");
235
- } else {
236
- navigateToScreen("plugins");
237
- }
238
- }
239
-
240
- // ? for help
241
- if (input === "?") {
242
- modal.message(
243
- "magus Help",
244
- `Navigation
245
- ↑/↓ or j/k Move selection
246
- Enter Select / Toggle
247
- Escape Back (to home)
248
- q / Ctrl+C Quit (from anywhere)
249
- ? This help
250
-
251
- Quick Navigation
252
- 1 Plugins 4 Settings 7 Git State 0 Models
253
- 2 Skills 5 Profiles 8 Alias
254
- 3 MCP Servers 6 CLI Tools 9 Styles
255
-
256
- Plugin Actions
257
- u Update d Uninstall
258
- a Update All r Refresh
259
-
260
- MCP Servers
261
- / Search local + remote
262
- r Browse MCP registry
263
-
264
- Styles
265
- Space Tick / untick a style
266
- a Apply the selection to this project
267
- x Reset the selection to what is live
268
- c Clear the active output style
269
-
270
- Models
271
- a / Enter Route this project through the selected preset
272
- c Turn model tiers off
273
- r Re-read the config`,
274
- "info",
275
- );
276
- }
277
- });
130
+ const screen = state.currentRoute.screen;
131
+
132
+ const handlers: GlobalHandlers = {
133
+ help: () => modal.help(screen),
134
+ quit: () => onExit(),
135
+ "cycle-tab": (key) => {
136
+ // The registry is a sub-screen of MCP, so it cycles from MCP's place.
137
+ const from = screen === "mcp-registry" ? "mcp" : screen;
138
+ const index = TABS.findIndex((tab) => tab.screen === from);
139
+ if (index === -1) return;
140
+ const step = key.shift ? TABS.length - 1 : 1;
141
+ const next = TABS[(index + step) % TABS.length];
142
+ if (next) navigateToScreen(next.screen);
143
+ },
144
+ // Back: registry → MCP, any other tab → Plugins, Plugins → quit.
145
+ back: () => {
146
+ if (screen === "plugins") onExit();
147
+ else if (screen === "mcp-registry") navigateToScreen("mcp");
148
+ else navigateToScreen("plugins");
149
+ },
150
+ tab: (target) => navigateToScreen(target),
151
+ };
152
+
153
+ useKeyLayer("global", true, (key) => runGlobalBindings(handlers, key));
278
154
 
279
155
  return null;
280
156
  }
@@ -287,7 +163,7 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
287
163
  if (!result.updateAvailable) return null;
288
164
 
289
165
  return (
290
- <box paddingLeft={1} paddingRight={1}>
166
+ <box flexDirection="row" paddingLeft={1} paddingRight={1}>
291
167
  <text bg={theme.colors.warning} fg={theme.hints.fg}>
292
168
  <strong> UPDATE </strong>
293
169
  </text>
@@ -326,16 +202,12 @@ function ProgressIndicator({
326
202
  * Main app layout with all UI elements
327
203
  */
328
204
  interface AppContentInnerProps {
329
- showDebug: boolean;
330
- onDebugToggle: () => void;
331
205
  updateInfo: VersionCheckResult | null;
332
206
  onExit: () => void;
333
207
  recoveryReport: string | null;
334
208
  }
335
209
 
336
210
  function AppContentInner({
337
- showDebug,
338
- onDebugToggle,
339
211
  updateInfo,
340
212
  onExit,
341
213
  recoveryReport,
@@ -352,15 +224,6 @@ function AppContentInner({
352
224
  <text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
353
225
  </box>
354
226
  )}
355
- {showDebug && (
356
- <box paddingLeft={1} paddingRight={1}>
357
- <text fg={theme.colors.muted}>
358
- DEBUG: {dimensions.terminalWidth}x{dimensions.terminalHeight} |
359
- content={dimensions.contentHeight} | screen=
360
- {state.currentRoute.screen}
361
- </text>
362
- </box>
363
- )}
364
227
  {progress && <ProgressIndicator {...progress} />}
365
228
  <box
366
229
  flexDirection="column"
@@ -370,7 +233,7 @@ function AppContentInner({
370
233
  >
371
234
  <Router />
372
235
  </box>
373
- <GlobalKeyHandler onDebugToggle={onDebugToggle} onExit={onExit} />
236
+ <GlobalKeys onExit={onExit} />
374
237
  <ModalContainer />
375
238
  </box>
376
239
  );
@@ -387,7 +250,6 @@ interface AppContentProps {
387
250
  function AppContent({ onExit }: AppContentProps) {
388
251
  const { state, dispatch } = useApp();
389
252
  const { progress } = state;
390
- const [showDebug, setShowDebug] = useState(false);
391
253
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
392
254
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
393
255
  const [gitignoreData, setGitignoreData] = useState<{
@@ -623,13 +485,10 @@ function AppContent({ onExit }: AppContentProps) {
623
485
  return (
624
486
  <DimensionsProvider
625
487
  showProgress={!!progress}
626
- showDebug={showDebug}
627
488
  showUpdateBanner={!!updateInfo?.updateAvailable}
628
489
  transientBannerCount={transientBannerCount}
629
490
  >
630
491
  <AppContentInner
631
- showDebug={showDebug}
632
- onDebugToggle={() => setShowDebug((s) => !s)}
633
492
  updateInfo={updateInfo}
634
493
  onExit={onExit}
635
494
  recoveryReport={recoveryReport}
@@ -650,7 +509,9 @@ interface AppProps {
650
509
  export function App({ onExit }: AppProps) {
651
510
  return (
652
511
  <AppProvider>
653
- <AppContent onExit={onExit} />
512
+ <KeyRouter>
513
+ <AppContent onExit={onExit} />
514
+ </KeyRouter>
654
515
  </AppProvider>
655
516
  );
656
517
  }
@@ -8,9 +8,11 @@
8
8
  * `classifyTool(spec, facts)`, and for a tool the active profile declares the
9
9
  * spec IS the profile's (`specForCatalogTool`), so the two cannot differ.
10
10
  *
11
- * The same goes for the COMMANDS: a declared tool's row builds them with
12
- * `binCommandForState`, the rule `magus update`'s apply path uses, so the
13
- * row's target and the row's key can never aim at different versions.
11
+ * The same goes for the COMMANDS: the probe's own `upgradeCommand` when it
12
+ * built one (an npm prefix, Claude Code's updater) — the very argv `magus
13
+ * update` carries on its plan item — and otherwise `binCommandForState`, the
14
+ * rule the apply path uses, so the row's target and the row's key can never
15
+ * aim at different versions.
14
16
  *
15
17
  * Pure: no IO, no React. The screen gathers facts; this only assembles.
16
18
  */
@@ -23,6 +25,7 @@ import {
23
25
  import {
24
26
  type InstallInfo,
25
27
  type ToolFacts,
28
+ type ToolReason,
26
29
  type ToolStatus,
27
30
  classifyTool,
28
31
  } from "../../services/tool-probe.js";
@@ -80,7 +83,13 @@ export function buildToolRow(
80
83
  const base = { tool, spec, installCommand, ...(install ? { install } : {}) };
81
84
  if (!facts) return { ...base, checking: true, status: null };
82
85
  const status = classifyTool(spec, facts);
83
- const updateCommand = updateCommandFor(tool, declared, status, install);
86
+ const updateCommand = updateCommandFor(
87
+ tool,
88
+ declared,
89
+ status,
90
+ facts,
91
+ install,
92
+ );
84
93
  return {
85
94
  ...base,
86
95
  checking: false,
@@ -92,11 +101,16 @@ export function buildToolRow(
92
101
  /**
93
102
  * The command the row's key runs, from the same rule as the row's state.
94
103
  *
95
- * A declared tool goes through `binCommandForState` — the CLI apply path's own
96
- * builder over the PROFILE's requirement — so a row whose target is the pin
97
- * installs the pin. Building `@latest` here (what this used to do, always) let
98
- * the TUI overshoot a pin the CLI hits exactly, and the tool then classified
99
- * `unknown / ahead-of-pin` for good: magus never downgrades.
104
+ * First the probe's own `upgradeCommand`: set when the file PATH runs belongs
105
+ * to an owner the spec's installer would miss — another npm prefix, Claude
106
+ * Code's native installer — and already aimed at the pin when there is one.
107
+ * `magus update` runs the same value, so Enter and the CLI cannot differ.
108
+ *
109
+ * Otherwise a declared tool goes through `binCommandForState` — the CLI apply
110
+ * path's own builder over the PROFILE's requirement — so a row whose target is
111
+ * the pin installs the pin. Building `@latest` here (what this used to do,
112
+ * always) let the TUI overshoot a pin the CLI hits exactly, and the tool then
113
+ * classified `unknown / ahead-of-pin` for good: magus never downgrades.
100
114
  *
101
115
  * An undeclared catalogue tool keeps the screen's own rule, "follow how it IS
102
116
  * installed": there is no pin to honour, and `pnpm`/`yarn` have no
@@ -106,9 +120,11 @@ function updateCommandFor(
106
120
  tool: CliTool,
107
121
  declared: ResolvedBin | null,
108
122
  status: ToolStatus,
123
+ facts: ToolFacts,
109
124
  install: InstallInfo | undefined,
110
125
  ): Command | undefined {
111
126
  if (!isInstalled(status)) return undefined;
127
+ if (facts.upgradeCommand) return facts.upgradeCommand;
112
128
  if (declared) {
113
129
  return binCommandForState("upgrade", status.target, declared) ?? undefined;
114
130
  }
@@ -126,6 +142,78 @@ export function displayVersion(v: string): string {
126
142
  return `v${v.replace(/^v/i, "")}`;
127
143
  }
128
144
 
145
+ /** What Enter does on one row. */
146
+ export interface CliToolEnterAction {
147
+ kind: "install" | "update" | "update-anyway" | "reinstall";
148
+ /** The footer hint's label, e.g. "update". */
149
+ label: string;
150
+ /** The detail pane's words beside the Enter chip, e.g. "Update to v0.95.0". */
151
+ detail: string;
152
+ /** The progress word while it runs, e.g. "Updating". */
153
+ progress: string;
154
+ command: Command;
155
+ }
156
+
157
+ /**
158
+ * What Enter does on `row` — null while the row is still being checked, when
159
+ * Enter does nothing.
160
+ *
161
+ * The footer hint, the detail pane and the handler each used to decide this
162
+ * on their own, and the footer said "install" on every row while the detail
163
+ * pane beside it said "Update to v0.95.0". All three read this now.
164
+ *
165
+ * An `unknown` row still offers the detected method's command: an explicit
166
+ * user action, not a plan, and the detail pane shows why beside it.
167
+ */
168
+ export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
169
+ const { status, updateCommand, installCommand } = row;
170
+ if (row.checking || !status) return null;
171
+ // Installed NEWER than its pin: every command there is wrong. The pin's own
172
+ // command downgrades — for an npm prefix or Claude Code's native installer the
173
+ // probe aims its command AT the pin — and latest moves further past the pin
174
+ // the profile asked for. Enter does nothing; the row's note says why.
175
+ if (status.reason === "ahead-of-pin") return null;
176
+ if (!isInstalled(status)) {
177
+ return {
178
+ kind: "install",
179
+ label: "install",
180
+ detail: "Install",
181
+ progress: "Installing",
182
+ command: installCommand,
183
+ };
184
+ }
185
+ // Both commands come from the row, which built them from the SAME rule its
186
+ // state came from — a declared tool's pin included.
187
+ const command = updateCommand ?? installCommand;
188
+ if (status.state === "upgrade") {
189
+ return {
190
+ kind: "update",
191
+ label: "update",
192
+ detail: status.target
193
+ ? `Update to ${displayVersion(status.target)}`
194
+ : "Update",
195
+ progress: "Updating",
196
+ command,
197
+ };
198
+ }
199
+ if (status.state === "unknown") {
200
+ return {
201
+ kind: "update-anyway",
202
+ label: "update anyway",
203
+ detail: "Update anyway",
204
+ progress: "Updating",
205
+ command,
206
+ };
207
+ }
208
+ return {
209
+ kind: "reinstall",
210
+ label: "reinstall",
211
+ detail: "Reinstall",
212
+ progress: "Reinstalling",
213
+ command,
214
+ };
215
+ }
216
+
129
217
  export interface CliToolStatusText {
130
218
  /** Text after the version, with its leading space; "" for nothing to say. */
131
219
  suffix: string;
@@ -133,8 +221,49 @@ export interface CliToolStatusText {
133
221
  }
134
222
 
135
223
  /**
136
- * The row's status words. An `unknown` shows its note verbatim — the same
137
- * string `magus update` prints for that tool.
224
+ * The row's short word for WHY an `unknown` could not be checked.
225
+ *
226
+ * A label, never the note. The note is a sentence with a path in it ("on PATH
227
+ * at /Users/…, which npm … does not own — …"); a list row has room for a
228
+ * version, a method tag and a few words, so printing the note there cut it off
229
+ * mid-path on every machine. The full note stays in the detail pane's "Why"
230
+ * line, the same string `magus update` prints.
231
+ *
232
+ * Every label is at most 19 cells, so the row suffix (" ? " + label) stays
233
+ * within 24. Keyed by reason alone — plus the spec's installer, which is a
234
+ * word, never a path.
235
+ */
236
+ function unknownLabel(reason: ToolReason, via: BinRequirement["via"]): string {
237
+ switch (reason) {
238
+ case "not-managed":
239
+ return `not managed by ${via}`;
240
+ case "version-unreadable":
241
+ return "version unreadable";
242
+ case "latest-unavailable":
243
+ return "latest unavailable";
244
+ case "unorderable":
245
+ return "not comparable";
246
+ case "ahead-of-pin":
247
+ return "ahead of pin";
248
+ case "held-back":
249
+ return "held back";
250
+ case "pin-not-installable":
251
+ return "pin not installable";
252
+ case "not-probed":
253
+ return "not checked";
254
+ // Never `unknown`: these reasons plan work or report `current`.
255
+ case "absent":
256
+ case "up-to-date":
257
+ case "behind":
258
+ case "at-pin":
259
+ case "behind-pin":
260
+ return "not checked";
261
+ }
262
+ }
263
+
264
+ /**
265
+ * The row's status words. An `unknown` shows a short label for its reason
266
+ * ({@link unknownLabel}); the detail pane carries the full note.
138
267
  */
139
268
  export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
140
269
  const { status } = row;
@@ -142,7 +271,7 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
142
271
  switch (status.state) {
143
272
  case "unknown":
144
273
  return {
145
- suffix: ` ? ${status.note ?? "cannot tell whether it is current"}`,
274
+ suffix: ` ? ${unknownLabel(status.reason, row.spec.via)}`,
146
275
  tone: "warning",
147
276
  };
148
277
  case "upgrade":