magus-cli 7.3.1 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -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,20 +148,28 @@ 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();
144
159
  const allMarketplaces = getAllMarketplaces(localMarketplaces);
145
- // Read-only, never throws. `resolveBins: false`: this screen needs only
146
- // the plugin pins, not every declared plugin's binary requirements.
160
+ // Read-only, and it throws for exactly one thing: a settings file or an
161
+ // installed_plugins.json that exists and cannot be read. That lands in
162
+ // the catch below and is shown on this screen, which is the point —
163
+ // "nothing installed" would be a lie about a file nobody could read.
164
+ // `resolveBins: false`: this screen needs only the plugin pins, not
165
+ // every declared plugin's binary requirements.
147
166
  const [pluginData, active] = await Promise.all([
148
167
  getAvailablePlugins(state.projectPath),
149
168
  loadActiveClosure(state.projectPath || process.cwd(), {
150
169
  resolveBins: false,
151
170
  }),
152
171
  ]);
172
+ if (!pluginsLoadGate.isCurrent(token)) return;
153
173
  setPins(active?.closure.plugins ?? null);
154
174
 
155
175
  // Which catalogs answered, and which only appeared to. Read straight
@@ -178,6 +198,7 @@ export function PluginsScreen() {
178
198
  }
179
199
  const projectPath = state.projectPath || process.cwd();
180
200
  const previous = await loadSeenVersions(projectPath);
201
+ if (!pluginsLoadGate.isCurrent(token)) return;
181
202
  for (const change of diffVersions(previous, current)) {
182
203
  const target = pluginData.find((p) => p.id === change.pluginId);
183
204
  if (!target) continue;
@@ -191,6 +212,7 @@ export function PluginsScreen() {
191
212
  // refresh or a post-action refetch could load the baseline before the
192
213
  // previous save landed and re-report the same change.
193
214
  await saveSeenVersions(projectPath, current);
215
+ if (!pluginsLoadGate.isCurrent(token)) return;
194
216
 
195
217
  dispatch({
196
218
  type: "PLUGINS_DATA_SUCCESS",
@@ -198,6 +220,7 @@ export function PluginsScreen() {
198
220
  plugins: pluginData,
199
221
  });
200
222
  } catch (error) {
223
+ if (!pluginsLoadGate.isCurrent(token)) return;
201
224
  dispatch({
202
225
  type: "PLUGINS_DATA_ERROR",
203
226
  error: error instanceof Error ? error : new Error(String(error)),
@@ -441,119 +464,79 @@ export function PluginsScreen() {
441
464
  [dispatch, pluginsState.pluginContents, shownMarketplaces],
442
465
  );
443
466
 
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
- }
467
+ // ── Keys ──────────────────────────────────────────────────────────────────
458
468
 
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
- }
469
+ const setQuery = (query: string) => {
470
+ dispatch({ type: "PLUGINS_SET_SEARCH", query });
471
+ dispatch({ type: "PLUGINS_SELECT", index: 0 });
472
+ };
492
473
 
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
- }
474
+ // The search box, while it has focus: digits, j/k, q and ? are typed here.
475
+ useSearchInput({
476
+ focused: isSearchActive,
477
+ query: pluginsState.searchQuery,
478
+ append: (char) => setQuery(pluginsState.searchQuery + char),
479
+ erase: () => setQuery(pluginsState.searchQuery.slice(0, -1)),
480
+ clear: () => setQuery(""),
481
+ blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
482
+ });
501
483
 
502
- if (
503
- event.name === "left" ||
504
- event.name === "right" ||
505
- event.name === "<" ||
506
- event.name === ">"
507
- ) {
484
+ useScreenKeys("plugins", {
485
+ // Three keys, one binding: the footer reads "u/p/l toggle".
486
+ scope: (key) => {
487
+ void handleScopeToggle(
488
+ key.name === "u" ? "user" : key.name === "p" ? "project" : "local",
489
+ );
490
+ },
491
+ update: () => {
492
+ void handleUpdate();
493
+ },
494
+ "update-all": () => {
495
+ void handleUpdateAll();
496
+ },
497
+ open: () => {
498
+ void handleSelect();
499
+ },
500
+ expand: () => {
508
501
  const item = selectableItems[pluginsState.selectedIndex];
509
502
  if (item?.kind === "category") {
510
503
  dispatch({
511
504
  type: "PLUGINS_TOGGLE_MARKETPLACE",
512
505
  name: item.marketplace.name,
513
506
  });
514
- return;
515
- }
516
- if (item?.kind === "plugin" && item.isExpandable) {
507
+ } else if (item?.kind === "plugin" && item.isExpandable) {
517
508
  togglePluginContents(item);
518
- return;
519
509
  }
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();
510
+ },
511
+ "save-profile": () => {
512
+ void handleSaveAsProfile();
513
+ },
514
+ remove: () => {
515
+ void handleRemoveDeprecated();
516
+ },
517
+ "add-marketplace": () => {
518
+ void handleShowAddMarketplaceInstructions();
519
+ },
520
+ "team-help": () => {
521
+ void handleShowTeamConfigHelp();
522
+ },
523
+ refresh: () => {
524
+ void handleRefresh();
525
+ },
526
+ search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
527
+ // Only while a committed filter is set; otherwise Esc is the global back.
528
+ "clear-filter": () => {
529
+ if (!pluginsState.searchQuery) return false;
530
+ setQuery("");
531
+ return true;
532
+ },
533
+ move: (key) => {
534
+ const index = pluginsState.selectedIndex + moveDirection(key);
535
+ dispatch({
536
+ type: "PLUGINS_SELECT",
537
+ index: Math.max(0, Math.min(selectableItems.length - 1, index)),
538
+ });
539
+ },
557
540
  });
558
541
 
559
542
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -713,7 +696,7 @@ export function PluginsScreen() {
713
696
  if (!wantToConfigure) {
714
697
  await modal.message(
715
698
  "Skipped Configuration",
716
- "You can configure these variables later in the Environment Variables screen (press 4).",
699
+ `You can configure these variables later in the Settings tab (press ${tabKey("settings")}).`,
717
700
  "info",
718
701
  );
719
702
  return true;
@@ -741,7 +724,7 @@ export function PluginsScreen() {
741
724
  if (value === null) {
742
725
  await modal.message(
743
726
  "Configuration Incomplete",
744
- "Skipped remaining configuration.\nYou can configure these later in Environment Variables (press 4).",
727
+ `Skipped remaining configuration.\nYou can configure these later in the Settings tab (press ${tabKey("settings")}).`,
745
728
  "info",
746
729
  );
747
730
  return true;
@@ -754,7 +737,18 @@ export function PluginsScreen() {
754
737
 
755
738
  return true;
756
739
  } catch (error) {
757
- console.error("Error collecting plugin env vars:", error);
740
+ // The plugin itself is installed either way. But a settings.local.json
741
+ // that does not parse is something the user has to fix by hand before
742
+ // any variable can be saved, so it is said, not logged out of sight.
743
+ if (error instanceof SettingsParseError) {
744
+ await modal.message(
745
+ "Environment variables not saved",
746
+ error.message,
747
+ "error",
748
+ );
749
+ } else {
750
+ console.error("Error collecting plugin env vars:", error);
751
+ }
758
752
  return true;
759
753
  }
760
754
  };
@@ -1251,8 +1245,20 @@ export function PluginsScreen() {
1251
1245
  };
1252
1246
 
1253
1247
  const handleSaveAsProfile = async () => {
1254
- const settings = await readSettings(state.projectPath);
1255
- const enabledPlugins = settings.enabledPlugins ?? {};
1248
+ // Inside a catch: this runs fire-and-forget from a keypress, so a rejected
1249
+ // read would be an unhandled rejection — which ends the app.
1250
+ let enabledPlugins: Record<string, boolean>;
1251
+ try {
1252
+ enabledPlugins =
1253
+ (await readSettings(state.projectPath)).enabledPlugins ?? {};
1254
+ } catch (error) {
1255
+ await modal.message(
1256
+ "Error",
1257
+ `Cannot save a profile: ${error instanceof Error ? error.message : String(error)}`,
1258
+ "error",
1259
+ );
1260
+ return;
1261
+ }
1256
1262
 
1257
1263
  const name = await modal.input("Save Profile", "Profile name:");
1258
1264
  if (name === null || !name.trim()) return;
@@ -1281,7 +1287,7 @@ export function PluginsScreen() {
1281
1287
  modal.hideModal();
1282
1288
  await modal.message(
1283
1289
  "Saved",
1284
- `Profile "${name.trim()}" saved.\nPress 6 to manage profiles.`,
1290
+ `Profile "${name.trim()}" saved.\nPress ${tabKey("profiles")} to manage profiles.`,
1285
1291
  "success",
1286
1292
  );
1287
1293
  } catch (error) {
@@ -1339,19 +1345,13 @@ export function PluginsScreen() {
1339
1345
 
1340
1346
  const selectedItem = selectableItems[pluginsState.selectedIndex];
1341
1347
 
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
- ];
1348
+ // Advertised only where they act; they stay bound, and help lists them.
1349
+ const hiddenHints: BindingId<"plugins">[] = [
1350
+ ...(pluginsState.searchQuery ? [] : ["clear-filter" as const]),
1351
+ ...(selectedItem?.kind === "plugin" && selectedItem.plugin.isOrphaned
1352
+ ? []
1353
+ : ["remove" as const]),
1354
+ ];
1355
1355
 
1356
1356
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1357
1357
  const plugins: PluginInfo[] = shownPlugins ?? [];
@@ -1417,7 +1417,7 @@ export function PluginsScreen() {
1417
1417
  query: pluginsState.searchQuery,
1418
1418
  placeholder: searchPlaceholder,
1419
1419
  }}
1420
- footerHints={footerHints}
1420
+ footer={{ hide: hiddenHints }}
1421
1421
  listPanel={
1422
1422
  <box flexDirection="column">
1423
1423
  <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}