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
@@ -12,6 +12,7 @@ import {
12
12
  uninstallPlugin as cliUninstallPlugin,
13
13
  } from "../../services/claude-cli.js";
14
14
  import {
15
+ SettingsParseError,
15
16
  getMcpEnvVars,
16
17
  readSettings,
17
18
  setMcpEnvVar,
@@ -68,6 +69,7 @@ import {
68
69
  saveSeenVersions,
69
70
  } from "../../services/version-snapshot.js";
70
71
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
72
+ import { createRequestGate } from "../../utils/request-gate.js";
71
73
  import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
72
74
  import {
73
75
  type PluginBrowserItem,
@@ -80,7 +82,8 @@ import {
80
82
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
81
83
  import { ScrollableList } from "../components/ScrollableList.js";
82
84
  import { ScreenLayout } from "../components/layout/index.js";
83
- import { useKeyboard } from "../hooks/useKeyboard.js";
85
+ import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
86
+ import { type BindingId, moveDirection, tabKey } from "../keymap.js";
84
87
  import {
85
88
  renderPluginDetail,
86
89
  renderPluginRow,
@@ -90,6 +93,17 @@ import { useDimensions } from "../state/DimensionsContext.js";
90
93
  import { asyncValue } from "../state/types.js";
91
94
  import { theme } from "../theme.js";
92
95
 
96
+ /**
97
+ * The latest Plugins load wins.
98
+ *
99
+ * A load starts on mount and again on every `DATA_REFRESH_COMPLETE` — at
100
+ * startup that is two loads racing, and whichever finished LAST used to be the
101
+ * one on screen, often the older. Module scope rather than a ref, because the
102
+ * router remounts this screen on every tab switch and a load begun by the
103
+ * previous mount still has to lose to the new one.
104
+ */
105
+ const pluginsLoadGate = createRequestGate();
106
+
93
107
  /** Wraps the notice text in the warning-coloured row ScreenLayout renders. */
94
108
  function buildCatalogNotice(
95
109
  input: Parameters<typeof buildCatalogNoticeText>[0],
@@ -107,9 +121,7 @@ export function PluginsScreen() {
107
121
  const dimensions = useDimensions();
108
122
 
109
123
  const isSearchActive =
110
- state.isSearching &&
111
- state.currentRoute.screen === "plugins" &&
112
- !state.modal;
124
+ state.isSearching && state.currentRoute.screen === "plugins";
113
125
 
114
126
  /**
115
127
  * Catalogs that could not be read from any authoritative source.
@@ -136,8 +148,11 @@ export function PluginsScreen() {
136
148
  Readonly<Record<string, string>> | null | undefined
137
149
  >(undefined);
138
150
 
139
- // Fetch data (always fetches all scopes)
151
+ // Fetch data (always fetches all scopes). Only the most recently started
152
+ // load may touch the screen — or the seen-versions baseline, which a stale
153
+ // load would rewind.
140
154
  const fetchData = useCallback(async () => {
155
+ const token = pluginsLoadGate.begin();
141
156
  dispatch({ type: "PLUGINS_DATA_LOADING" });
142
157
  try {
143
158
  const localMarketplaces = await getLocalMarketplacesInfo();
@@ -150,6 +165,7 @@ export function PluginsScreen() {
150
165
  resolveBins: false,
151
166
  }),
152
167
  ]);
168
+ if (!pluginsLoadGate.isCurrent(token)) return;
153
169
  setPins(active?.closure.plugins ?? null);
154
170
 
155
171
  // Which catalogs answered, and which only appeared to. Read straight
@@ -178,6 +194,7 @@ export function PluginsScreen() {
178
194
  }
179
195
  const projectPath = state.projectPath || process.cwd();
180
196
  const previous = await loadSeenVersions(projectPath);
197
+ if (!pluginsLoadGate.isCurrent(token)) return;
181
198
  for (const change of diffVersions(previous, current)) {
182
199
  const target = pluginData.find((p) => p.id === change.pluginId);
183
200
  if (!target) continue;
@@ -191,6 +208,7 @@ export function PluginsScreen() {
191
208
  // refresh or a post-action refetch could load the baseline before the
192
209
  // previous save landed and re-report the same change.
193
210
  await saveSeenVersions(projectPath, current);
211
+ if (!pluginsLoadGate.isCurrent(token)) return;
194
212
 
195
213
  dispatch({
196
214
  type: "PLUGINS_DATA_SUCCESS",
@@ -198,6 +216,7 @@ export function PluginsScreen() {
198
216
  plugins: pluginData,
199
217
  });
200
218
  } catch (error) {
219
+ if (!pluginsLoadGate.isCurrent(token)) return;
201
220
  dispatch({
202
221
  type: "PLUGINS_DATA_ERROR",
203
222
  error: error instanceof Error ? error : new Error(String(error)),
@@ -441,119 +460,79 @@ export function PluginsScreen() {
441
460
  [dispatch, pluginsState.pluginContents, shownMarketplaces],
442
461
  );
443
462
 
444
- // Keyboard handling
445
- useKeyboard((event) => {
446
- if (state.modal) return;
447
-
448
- const hasQuery = pluginsState.searchQuery.length > 0;
449
-
450
- if (event.name === "escape") {
451
- if (hasQuery || isSearchActive) {
452
- dispatch({ type: "PLUGINS_SET_SEARCH", query: "" });
453
- dispatch({ type: "SET_SEARCHING", isSearching: false });
454
- dispatch({ type: "PLUGINS_SELECT", index: 0 });
455
- }
456
- return;
457
- }
463
+ // ── Keys ──────────────────────────────────────────────────────────────────
458
464
 
459
- if (event.name === "backspace" || event.name === "delete") {
460
- if (hasQuery) {
461
- const newQuery = pluginsState.searchQuery.slice(0, -1);
462
- dispatch({ type: "PLUGINS_SET_SEARCH", query: newQuery });
463
- dispatch({ type: "PLUGINS_SELECT", index: 0 });
464
- if (newQuery.length === 0) {
465
- dispatch({ type: "SET_SEARCHING", isSearching: false });
466
- }
467
- }
468
- return;
469
- }
470
-
471
- if (event.name === "up" || event.name === "k") {
472
- if (isSearchActive)
473
- dispatch({ type: "SET_SEARCHING", isSearching: false });
474
- dispatch({
475
- type: "PLUGINS_SELECT",
476
- index: Math.max(0, pluginsState.selectedIndex - 1),
477
- });
478
- return;
479
- }
480
- if (event.name === "down" || event.name === "j") {
481
- if (isSearchActive)
482
- dispatch({ type: "SET_SEARCHING", isSearching: false });
483
- dispatch({
484
- type: "PLUGINS_SELECT",
485
- index: Math.min(
486
- selectableItems.length - 1,
487
- pluginsState.selectedIndex + 1,
488
- ),
489
- });
490
- return;
491
- }
465
+ const setQuery = (query: string) => {
466
+ dispatch({ type: "PLUGINS_SET_SEARCH", query });
467
+ dispatch({ type: "PLUGINS_SELECT", index: 0 });
468
+ };
492
469
 
493
- if (event.name === "enter" || event.name === "return") {
494
- if (isSearchActive) {
495
- dispatch({ type: "SET_SEARCHING", isSearching: false });
496
- return;
497
- }
498
- handleSelect();
499
- return;
500
- }
470
+ // The search box, while it has focus: digits, j/k, q and ? are typed here.
471
+ useSearchInput({
472
+ focused: isSearchActive,
473
+ query: pluginsState.searchQuery,
474
+ append: (char) => setQuery(pluginsState.searchQuery + char),
475
+ erase: () => setQuery(pluginsState.searchQuery.slice(0, -1)),
476
+ clear: () => setQuery(""),
477
+ blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
478
+ });
501
479
 
502
- if (
503
- event.name === "left" ||
504
- event.name === "right" ||
505
- event.name === "<" ||
506
- event.name === ">"
507
- ) {
480
+ useScreenKeys("plugins", {
481
+ // Three keys, one binding: the footer reads "u/p/l toggle".
482
+ scope: (key) => {
483
+ void handleScopeToggle(
484
+ key.name === "u" ? "user" : key.name === "p" ? "project" : "local",
485
+ );
486
+ },
487
+ update: () => {
488
+ void handleUpdate();
489
+ },
490
+ "update-all": () => {
491
+ void handleUpdateAll();
492
+ },
493
+ open: () => {
494
+ void handleSelect();
495
+ },
496
+ expand: () => {
508
497
  const item = selectableItems[pluginsState.selectedIndex];
509
498
  if (item?.kind === "category") {
510
499
  dispatch({
511
500
  type: "PLUGINS_TOGGLE_MARKETPLACE",
512
501
  name: item.marketplace.name,
513
502
  });
514
- return;
515
- }
516
- if (item?.kind === "plugin" && item.isExpandable) {
503
+ } else if (item?.kind === "plugin" && item.isExpandable) {
517
504
  togglePluginContents(item);
518
- return;
519
505
  }
520
- }
521
-
522
- if (isSearchActive) {
523
- if (
524
- event.name.length === 1 &&
525
- !event.ctrl &&
526
- !event.meta &&
527
- !/[0-9]/.test(event.name)
528
- ) {
529
- dispatch({
530
- type: "PLUGINS_SET_SEARCH",
531
- query: pluginsState.searchQuery + event.name,
532
- });
533
- dispatch({ type: "PLUGINS_SELECT", index: 0 });
534
- }
535
- return;
536
- }
537
-
538
- if (event.name === "/") {
539
- dispatch({ type: "SET_SEARCHING", isSearching: true });
540
- return;
541
- }
542
-
543
- if (event.name === "r") handleRefresh();
544
- else if (event.name === "n") handleShowAddMarketplaceInstructions();
545
- else if (event.name === "t") handleShowTeamConfigHelp();
546
- // `name` is ALWAYS the unshifted key — Shift+U parses as
547
- // {name:"u", shift:true, sequence:"U"}, so `name === "U"` can never be
548
- // true. It sat below the plain "u" branch, so pressing Shift+U silently
549
- // installed at user scope instead of updating.
550
- else if (event.name === "u" && event.shift) handleUpdate();
551
- else if (event.name === "u") handleScopeToggle("user");
552
- else if (event.name === "p") handleScopeToggle("project");
553
- else if (event.name === "l") handleScopeToggle("local");
554
- else if (event.name === "d") handleRemoveDeprecated();
555
- else if (event.name === "a") handleUpdateAll();
556
- else if (event.name === "s") handleSaveAsProfile();
506
+ },
507
+ "save-profile": () => {
508
+ void handleSaveAsProfile();
509
+ },
510
+ remove: () => {
511
+ void handleRemoveDeprecated();
512
+ },
513
+ "add-marketplace": () => {
514
+ void handleShowAddMarketplaceInstructions();
515
+ },
516
+ "team-help": () => {
517
+ void handleShowTeamConfigHelp();
518
+ },
519
+ refresh: () => {
520
+ void handleRefresh();
521
+ },
522
+ search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
523
+ // Only while a committed filter is set; otherwise Esc is the global back.
524
+ "clear-filter": () => {
525
+ if (!pluginsState.searchQuery) return false;
526
+ setQuery("");
527
+ return true;
528
+ },
529
+ move: (key) => {
530
+ const index = pluginsState.selectedIndex + moveDirection(key);
531
+ dispatch({
532
+ type: "PLUGINS_SELECT",
533
+ index: Math.max(0, Math.min(selectableItems.length - 1, index)),
534
+ });
535
+ },
557
536
  });
558
537
 
559
538
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -713,7 +692,7 @@ export function PluginsScreen() {
713
692
  if (!wantToConfigure) {
714
693
  await modal.message(
715
694
  "Skipped Configuration",
716
- "You can configure these variables later in the Environment Variables screen (press 4).",
695
+ `You can configure these variables later in the Settings tab (press ${tabKey("settings")}).`,
717
696
  "info",
718
697
  );
719
698
  return true;
@@ -741,7 +720,7 @@ export function PluginsScreen() {
741
720
  if (value === null) {
742
721
  await modal.message(
743
722
  "Configuration Incomplete",
744
- "Skipped remaining configuration.\nYou can configure these later in Environment Variables (press 4).",
723
+ `Skipped remaining configuration.\nYou can configure these later in the Settings tab (press ${tabKey("settings")}).`,
745
724
  "info",
746
725
  );
747
726
  return true;
@@ -754,7 +733,18 @@ export function PluginsScreen() {
754
733
 
755
734
  return true;
756
735
  } catch (error) {
757
- console.error("Error collecting plugin env vars:", error);
736
+ // The plugin itself is installed either way. But a settings.local.json
737
+ // that does not parse is something the user has to fix by hand before
738
+ // any variable can be saved, so it is said, not logged out of sight.
739
+ if (error instanceof SettingsParseError) {
740
+ await modal.message(
741
+ "Environment variables not saved",
742
+ error.message,
743
+ "error",
744
+ );
745
+ } else {
746
+ console.error("Error collecting plugin env vars:", error);
747
+ }
758
748
  return true;
759
749
  }
760
750
  };
@@ -1251,8 +1241,20 @@ export function PluginsScreen() {
1251
1241
  };
1252
1242
 
1253
1243
  const handleSaveAsProfile = async () => {
1254
- const settings = await readSettings(state.projectPath);
1255
- const enabledPlugins = settings.enabledPlugins ?? {};
1244
+ // Inside a catch: this runs fire-and-forget from a keypress, so a rejected
1245
+ // read would be an unhandled rejection — which ends the app.
1246
+ let enabledPlugins: Record<string, boolean>;
1247
+ try {
1248
+ enabledPlugins =
1249
+ (await readSettings(state.projectPath)).enabledPlugins ?? {};
1250
+ } catch (error) {
1251
+ await modal.message(
1252
+ "Error",
1253
+ `Cannot save a profile: ${error instanceof Error ? error.message : String(error)}`,
1254
+ "error",
1255
+ );
1256
+ return;
1257
+ }
1256
1258
 
1257
1259
  const name = await modal.input("Save Profile", "Profile name:");
1258
1260
  if (name === null || !name.trim()) return;
@@ -1281,7 +1283,7 @@ export function PluginsScreen() {
1281
1283
  modal.hideModal();
1282
1284
  await modal.message(
1283
1285
  "Saved",
1284
- `Profile "${name.trim()}" saved.\nPress 6 to manage profiles.`,
1286
+ `Profile "${name.trim()}" saved.\nPress ${tabKey("profiles")} to manage profiles.`,
1285
1287
  "success",
1286
1288
  );
1287
1289
  } catch (error) {
@@ -1339,19 +1341,13 @@ export function PluginsScreen() {
1339
1341
 
1340
1342
  const selectedItem = selectableItems[pluginsState.selectedIndex];
1341
1343
 
1342
- const footerHints = isSearchActive
1343
- ? [
1344
- { keys: ["type"], label: "filter" },
1345
- { keys: ["Enter"], label: "done" },
1346
- { keys: ["Esc"], label: "clear" },
1347
- ]
1348
- : [
1349
- { keys: ["u", "/", "p", "/", "l"], label: "toggle" },
1350
- { keys: ["U"], label: "update" },
1351
- { keys: ["a"], label: "all" },
1352
- { keys: ["s"], label: "profile" },
1353
- { keys: ["/"], label: "search" },
1354
- ];
1344
+ // Advertised only where they act; they stay bound, and help lists them.
1345
+ const hiddenHints: BindingId<"plugins">[] = [
1346
+ ...(pluginsState.searchQuery ? [] : ["clear-filter" as const]),
1347
+ ...(selectedItem?.kind === "plugin" && selectedItem.plugin.isOrphaned
1348
+ ? []
1349
+ : ["remove" as const]),
1350
+ ];
1355
1351
 
1356
1352
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1357
1353
  const plugins: PluginInfo[] = shownPlugins ?? [];
@@ -1417,7 +1413,7 @@ export function PluginsScreen() {
1417
1413
  query: pluginsState.searchQuery,
1418
1414
  placeholder: searchPlaceholder,
1419
1415
  }}
1420
- footerHints={footerHints}
1416
+ footer={{ hide: hiddenHints }}
1421
1417
  listPanel={
1422
1418
  <box flexDirection="column">
1423
1419
  <ScrollableList
@@ -1,6 +1,7 @@
1
1
  import React, { useEffect, useCallback } from "react";
2
2
  import { ScreenLayout } from "../components/layout/index.js";
3
- import { useKeyboard } from "../hooks/useKeyboard.js";
3
+ import { useScreenKeys } from "../input/KeyRouter.js";
4
+ import { moveDirection } from "../keymap.js";
4
5
  import { useApp, useModal } from "../state/AppContext.js";
5
6
  import { useDimensions } from "../state/DimensionsContext.js";
6
7
 
@@ -8,7 +9,11 @@ import {
8
9
  PREDEFINED_PROFILES,
9
10
  type PredefinedProfile,
10
11
  } from "../../data/predefined-profiles.js";
11
- import { readSettings, writeSettings } from "../../services/claude-settings.js";
12
+ import {
13
+ getSettingsPath,
14
+ readSettings,
15
+ writeSettings,
16
+ } from "../../services/claude-settings.js";
12
17
  import {
13
18
  applyProfile,
14
19
  deleteProfile,
@@ -17,6 +22,7 @@ import {
17
22
  listProfiles,
18
23
  renameProfile,
19
24
  } from "../../services/profiles.js";
25
+ import { writableMap } from "../../services/settings-file.js";
20
26
  import {
21
27
  ClipboardUnavailableError,
22
28
  readClipboard,
@@ -67,43 +73,38 @@ export function ProfilesScreen() {
67
73
  const isNavigable = (item: ProfileListItem | undefined) =>
68
74
  item !== undefined && item.kind !== "header";
69
75
 
70
- useKeyboard((event) => {
71
- if (state.isSearching || state.modal) return;
72
-
73
- if (event.name === "up" || event.name === "k") {
74
- let newIndex = profilesState.selectedIndex - 1;
75
- while (newIndex > 0 && !isNavigable(allItems[newIndex])) {
76
- newIndex--;
77
- }
78
- if (newIndex >= 0 && isNavigable(allItems[newIndex])) {
79
- dispatch({ type: "PROFILES_SELECT", index: newIndex });
80
- }
81
- } else if (event.name === "down" || event.name === "j") {
82
- let newIndex = profilesState.selectedIndex + 1;
83
- while (
84
- newIndex < allItems.length - 1 &&
85
- !isNavigable(allItems[newIndex])
86
- ) {
87
- newIndex++;
88
- }
89
- if (newIndex < allItems.length && isNavigable(allItems[newIndex])) {
90
- dispatch({ type: "PROFILES_SELECT", index: newIndex });
91
- }
92
- } else if (event.name === "enter" || event.name === "a") {
76
+ useScreenKeys("profiles", {
77
+ apply: () => {
93
78
  if (selectedItem?.kind === "predefined") {
94
79
  void handleApplyPredefined(selectedItem.profile);
95
80
  } else if (selectedItem?.kind === "saved") {
96
81
  void handleApply();
97
82
  }
98
- } else if (event.name === "r") {
83
+ },
84
+ rename: () => {
99
85
  if (selectedItem?.kind === "saved") void handleRename();
100
- } else if (event.name === "d") {
86
+ },
87
+ delete: () => {
101
88
  if (selectedItem?.kind === "saved") void handleDelete();
102
- } else if (event.name === "c") {
89
+ },
90
+ copy: () => {
103
91
  if (selectedItem?.kind === "saved") void handleCopy();
104
- } else if (event.name === "i") {
92
+ },
93
+ import: () => {
105
94
  void handleImport();
106
- }
95
+ },
96
+ // Section headers are labels, not rows: step over them.
97
+ move: (key) => {
98
+ const delta = moveDirection(key);
99
+ let index = profilesState.selectedIndex + delta;
100
+ while (index >= 0 && index < allItems.length) {
101
+ if (isNavigable(allItems[index])) {
102
+ dispatch({ type: "PROFILES_SELECT", index });
103
+ return;
104
+ }
105
+ index += delta;
106
+ }
107
+ },
107
108
  });
108
109
 
109
110
  // ─── Actions ──────────────────────────────────────────────────────────────
@@ -124,18 +125,23 @@ export function ProfilesScreen() {
124
125
  modal.loading(`Applying "${profile.name}"...`);
125
126
  try {
126
127
  const settings = await readSettings(state.projectPath);
127
- settings.enabledPlugins = settings.enabledPlugins ?? {};
128
+ const settingsPath = getSettingsPath(state.projectPath);
129
+ // `writableMap` refuses a map that is present with the wrong shape; a
130
+ // bare `?? {}` kept an array, and the entries set on it were never saved.
131
+ const enabledPlugins =
132
+ writableMap(settings, "enabledPlugins", settingsPath) ?? {};
133
+ settings.enabledPlugins = enabledPlugins;
128
134
  for (const plugin of allPlugins) {
129
- if (!settings.enabledPlugins[plugin]) {
130
- settings.enabledPlugins[plugin] = true;
135
+ if (!enabledPlugins[plugin]) {
136
+ enabledPlugins[plugin] = true;
131
137
  }
132
138
  }
133
139
  for (const [key, value] of Object.entries(profile.settings)) {
134
140
  if (key === "env") {
135
141
  const envMap = value as Record<string, string>;
136
- const existing = settings as Record<string, unknown>;
137
- const existingEnv =
138
- (existing.env as Record<string, string> | undefined) ?? {};
142
+ // `ClaudeSettings` does not declare `env`; this view types the one key read.
143
+ const envView = settings as { env?: Record<string, string> };
144
+ const existingEnv = writableMap(envView, "env", settingsPath) ?? {};
139
145
  for (const [envKey, envVal] of Object.entries(envMap)) {
140
146
  if (!existingEnv[envKey]) {
141
147
  existingEnv[envKey] = envVal;
@@ -381,11 +387,11 @@ export function ProfilesScreen() {
381
387
 
382
388
  const statusContent = (
383
389
  <text fg={theme.colors.text}>
384
- <span fg={theme.colors.link}>{PREDEFINED_PROFILES.length} presets</span>
390
+ <span fg={theme.colors.info}>{PREDEFINED_PROFILES.length} presets</span>
385
391
  <span fg={theme.colors.muted}> + </span>
386
392
  <span fg={theme.colors.info}>{userCount} user</span>
387
393
  <span fg={theme.colors.muted}> + </span>
388
- <span fg={theme.colors.success}>{projCount} project</span>
394
+ <span fg={theme.colors.info}>{projCount} project</span>
389
395
  <span fg={theme.colors.muted}> = </span>
390
396
  <span fg={theme.colors.text}>
391
397
  {PREDEFINED_PROFILES.length + profileCount} total
@@ -410,14 +416,11 @@ export function ProfilesScreen() {
410
416
  title="magus Plugin Profiles"
411
417
  currentScreen="profiles"
412
418
  statusLine={statusContent}
413
- footerHints={[
414
- { keys: ["↑", "↓"], label: "nav" },
415
- { keys: ["Enter", "/", "a"], label: "apply" },
416
- { keys: ["r"], label: "rename" },
417
- { keys: ["d"], label: "delete" },
418
- { keys: ["c"], label: "copy" },
419
- { keys: ["i"], label: "import" },
420
- ]}
419
+ footer={{
420
+ // rename / delete / copy act on a saved profile only.
421
+ hide:
422
+ selectedItem?.kind === "saved" ? [] : ["rename", "delete", "copy"],
423
+ }}
421
424
  listPanel={
422
425
  <ScrollableList
423
426
  items={allItems}