magus-cli 7.3.1 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
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":
@@ -4,6 +4,7 @@ import type { ApplyReporter } from "../../services/update-engine.js";
4
4
  import type { PluginUpdateItem } from "../../services/update-plan.js";
5
5
  import type { Marketplace } from "../../types/index.js";
6
6
  import type { PluginContentsEntry } from "../state/types.js";
7
+ import type { BadgeTone } from "../theme.js";
7
8
 
8
9
  // Virtual marketplace name for the community sub-section of claude-plugins-official
9
10
  export const COMMUNITY_VIRTUAL_MARKETPLACE =
@@ -23,7 +24,7 @@ export interface PluginCategoryItem {
23
24
  isExpanded: boolean;
24
25
  isCommunitySection?: boolean;
25
26
  /** Visual tone for the category row */
26
- tone: "yellow" | "gray" | "green" | "red" | "purple" | "teal" | "byline";
27
+ tone: BadgeTone | "byline";
27
28
  /** Badge text shown on category row (e.g. "★ Official") */
28
29
  badge?: string;
29
30
  }
@@ -196,24 +197,26 @@ function categoryStyling(
196
197
  marketplaceEnabled: boolean,
197
198
  ): { tone: PluginCategoryItem["tone"]; badge?: string } {
198
199
  if (!marketplaceEnabled) {
199
- return { tone: "gray" };
200
+ return { tone: "neutral" };
200
201
  }
201
202
  if (isCommunitySection) {
202
- return { tone: "gray", badge: "3rd Party" };
203
+ return { tone: "neutral", badge: "3rd Party" };
203
204
  }
205
+ // `info`, not `warning`: "official" is a fact about the source, and amber
206
+ // means "needs a look". The ★ carries the distinction.
204
207
  if (mp.name === "claude-plugins-official") {
205
- return { tone: "yellow", badge: "★ Official" };
208
+ return { tone: "info", badge: "★ Official" };
206
209
  }
207
210
  if (mp.name === "claude-code-plugins") {
208
- return { tone: "red", badge: "⚠ Deprecated" };
211
+ return { tone: "danger", badge: "⚠ Deprecated" };
209
212
  }
210
213
  if (mp.official) {
211
- return { tone: "yellow", badge: "★ Official" };
214
+ return { tone: "info", badge: "★ Official" };
212
215
  }
213
216
  if (isMadAppGang(mp)) {
214
217
  return { tone: "byline", badge: "by MadAppGang" };
215
218
  }
216
- return { tone: "green", badge: "✓ Added" };
219
+ return { tone: "success", badge: "✓ Added" };
217
220
  }
218
221
 
219
222
  /**
@@ -4,7 +4,6 @@ import {
4
4
  type SettingDefinition,
5
5
  } from "../../data/settings-catalog.js";
6
6
  import type { ScopedSettingValues } from "../../services/settings-manager.js";
7
- import { type UiColor, theme } from "../theme.js";
8
7
 
9
8
  // ─── Item types ────────────────────────────────────────────────────────────────
10
9
 
@@ -51,26 +50,6 @@ export const CATEGORY_ORDER: SettingCategory[] = [
51
50
  "advanced",
52
51
  ];
53
52
 
54
- export const CATEGORY_BG: Record<SettingCategory, UiColor> = {
55
- recommended: theme.colors.success,
56
- agents: theme.colors.info,
57
- models: theme.colors.accent,
58
- workflow: theme.colors.link,
59
- terminal: theme.colors.warning,
60
- performance: theme.colors.accent,
61
- advanced: theme.colors.warning,
62
- };
63
-
64
- export const CATEGORY_COLOR: Record<SettingCategory, UiColor> = {
65
- recommended: theme.colors.success,
66
- agents: theme.colors.info,
67
- models: theme.colors.info,
68
- workflow: theme.colors.link,
69
- terminal: theme.colors.link,
70
- performance: "magentaBright",
71
- advanced: theme.colors.warning,
72
- };
73
-
74
53
  export const CATEGORY_DESCRIPTIONS: Record<SettingCategory, string> = {
75
54
  recommended: "Most impactful settings every user should know.",
76
55
  agents: "Agent teams, task lists, and subagent configuration.",