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
@@ -9,7 +9,8 @@ import React, {
9
9
  useRef,
10
10
  } from "react";
11
11
  import { ScreenLayout } from "../components/layout/index.js";
12
- import { useKeyboard } from "../hooks/useKeyboard.js";
12
+ import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
13
+ import { moveDirection } from "../keymap.js";
13
14
  import { useApp, useModal } from "../state/AppContext.js";
14
15
  import { useDimensions } from "../state/DimensionsContext.js";
15
16
 
@@ -23,6 +24,7 @@ import {
23
24
  fetchSkillFrontmatter,
24
25
  fetchSkillSetSkills,
25
26
  installSkill,
27
+ readLocalSkillFrontmatter,
26
28
  uninstallSkill,
27
29
  } from "../../services/skills-manager.js";
28
30
  import {
@@ -30,11 +32,15 @@ import {
30
32
  searchSkills,
31
33
  } from "../../services/skillsmp-client.js";
32
34
  import type {
35
+ SkillFrontmatter,
33
36
  SkillInfo,
34
37
  SkillSetInfo,
35
38
  SkillSource,
36
39
  } from "../../types/index.js";
37
- import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
40
+ import {
41
+ buildSkillBrowserItems,
42
+ installedGroup,
43
+ } from "../adapters/skillsAdapter.js";
38
44
  import type { SkillBrowserItem } from "../adapters/skillsAdapter.js";
39
45
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
40
46
  import { ScrollableList } from "../components/ScrollableList.js";
@@ -54,7 +60,7 @@ export function SkillsScreen() {
54
60
  const [expandedSets, setExpandedSets] = useState<Set<string>>(new Set());
55
61
 
56
62
  const isSearchActive =
57
- state.isSearching && state.currentRoute.screen === "skills" && !state.modal;
63
+ state.isSearching && state.currentRoute.screen === "skills";
58
64
 
59
65
  // ── Data fetching ─────────────────────────────────────────────────────────
60
66
 
@@ -175,16 +181,46 @@ export function SkillsScreen() {
175
181
  };
176
182
  }, [skillsState.searchQuery]);
177
183
 
184
+ // Status bar message (auto-clears)
185
+ const [statusMsg, setStatusMsg] = useState<{
186
+ text: string;
187
+ tone: "success" | "error";
188
+ } | null>(null);
189
+ const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
+ const showStatus = useCallback(
191
+ (text: string, tone: "success" | "error" = "success") => {
192
+ setStatusMsg({ text, tone });
193
+ if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
194
+ statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
195
+ },
196
+ [],
197
+ );
198
+
178
199
  // ── Disk scan for installed skills ────────────────────────────────────────
179
200
 
201
+ /**
202
+ * Skills on disk, per scope, each with the frontmatter of its own SKILL.md —
203
+ * read here, once, so a local skill's detail pane renders at once from local
204
+ * data. `scanned` is false until the first scan finishes, so the header can
205
+ * say "reading" rather than a false "0 installed".
206
+ */
180
207
  const [installedFromDisk, setInstalledFromDisk] = useState<{
181
- user: Set<string>;
182
- project: Set<string>;
183
- }>({ user: new Set(), project: new Set() });
184
-
208
+ user: Map<string, SkillFrontmatter | null>;
209
+ project: Map<string, SkillFrontmatter | null>;
210
+ scanned: boolean;
211
+ }>({ user: new Map(), project: new Map(), scanned: false });
212
+
213
+ /**
214
+ * Which skills are on disk, per scope. Never rejects: it runs fire-and-forget
215
+ * from effects and after every install, and a rejection there is an
216
+ * unhandled one, which ends the app. A directory that exists but cannot be
217
+ * read is said on the status line — silently treating it as "no skills
218
+ * installed" would misreport the installed rows.
219
+ */
185
220
  const scanDisk = useCallback(async () => {
186
- const user = new Set<string>();
187
- const project = new Set<string>();
221
+ const user = new Map<string, SkillFrontmatter | null>();
222
+ const project = new Map<string, SkillFrontmatter | null>();
223
+ const unreadable: string[] = [];
188
224
  const userDir = path.join(os.homedir(), ".claude", "skills");
189
225
  const projDir = path.join(
190
226
  state.projectPath || process.cwd(),
@@ -199,19 +235,27 @@ export function SkillsScreen() {
199
235
  if (await fs.pathExists(dir)) {
200
236
  const entries = await fs.readdir(dir);
201
237
  for (const e of entries) {
202
- if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
238
+ const skillMd = path.join(dir, e, "SKILL.md");
239
+ if (await fs.pathExists(skillMd)) {
240
+ set.set(e, await readLocalSkillFrontmatter(skillMd, e));
241
+ }
203
242
  }
204
243
  }
205
- } catch {
206
- /* ignore */
244
+ } catch (error) {
245
+ unreadable.push(
246
+ `${dir} (${error instanceof Error ? error.message : String(error)})`,
247
+ );
207
248
  }
208
249
  }
209
- setInstalledFromDisk({ user, project });
210
- }, [state.projectPath]);
250
+ setInstalledFromDisk({ user, project, scanned: true });
251
+ if (unreadable.length > 0) {
252
+ showStatus(`Could not read ${unreadable.join("; ")}`, "error");
253
+ }
254
+ }, [state.projectPath, showStatus]);
211
255
 
212
256
  // biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-scan the disk even though the body never reads it
213
257
  useEffect(() => {
214
- scanDisk();
258
+ void scanDisk();
215
259
  }, [scanDisk, state.dataRefreshVersion]);
216
260
 
217
261
  // ── Skill Sets state ──────────────────────────────────────────────────────
@@ -298,21 +342,6 @@ export function SkillsScreen() {
298
342
  [expandedSets, skillSets, state.projectPath],
299
343
  );
300
344
 
301
- // Status bar message (auto-clears)
302
- const [statusMsg, setStatusMsg] = useState<{
303
- text: string;
304
- tone: "success" | "error";
305
- } | null>(null);
306
- const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
307
- const showStatus = useCallback(
308
- (text: string, tone: "success" | "error" = "success") => {
309
- setStatusMsg({ text, tone });
310
- if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
311
- statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
312
- },
313
- [],
314
- );
315
-
316
345
  const handleInstallAllFromSet = useCallback(
317
346
  async (setId: string, scope: "user" | "project") => {
318
347
  const set = skillSets.find((s) => s.id === setId);
@@ -418,16 +447,19 @@ export function SkillsScreen() {
418
447
  ["user", installedFromDisk.user],
419
448
  ["project", installedFromDisk.project],
420
449
  ] as const) {
421
- for (const name of names) {
450
+ for (const [name, frontmatter] of names) {
422
451
  if (all.some((s) => s.name === name)) continue;
423
452
  all.push({
424
453
  id: `installed:${scope}/${name}`,
425
454
  name,
426
- description: "",
455
+ description: frontmatter?.description ?? "",
427
456
  source: { label: "local", repo: "local", skillsPath: "" },
428
457
  repoPath: "",
429
458
  gitBlobSha: "",
430
- frontmatter: null,
459
+ // Read from disk by the scan. A SKILL.md that could not be read
460
+ // still gets an empty record: a local skill is never fetched, so
461
+ // null would read as "not loaded yet" for good.
462
+ frontmatter: frontmatter ?? { name, description: "" },
431
463
  installed: true,
432
464
  installedScope: scope,
433
465
  hasUpdate: false,
@@ -499,8 +531,22 @@ export function SkillsScreen() {
499
531
  )
500
532
  : false;
501
533
 
534
+ // Remote descriptions whose fetch failed, by skill id — so the pane can say
535
+ // so instead of loading for ever. A refresh (r) clears it and tries again.
536
+ const [failedDescriptions, setFailedDescriptions] = useState<Set<string>>(
537
+ new Set(),
538
+ );
539
+
502
540
  useEffect(() => {
503
- if (!selectedSkill || selectedSkill.frontmatter) return;
541
+ // A local skill's frontmatter came off disk with the scan; there is no
542
+ // remote to ask. A failed fetch is not retried until the user refreshes.
543
+ if (
544
+ !selectedSkill ||
545
+ selectedSkill.frontmatter ||
546
+ selectedSkill.source.repo === "local" ||
547
+ failedDescriptions.has(selectedSkill.id)
548
+ )
549
+ return;
504
550
  fetchSkillFrontmatter(selectedSkill)
505
551
  .then((fm) => {
506
552
  if (isSkillSetChild) {
@@ -521,8 +567,10 @@ export function SkillsScreen() {
521
567
  });
522
568
  }
523
569
  })
524
- .catch(() => {});
525
- }, [selectedSkill, isSkillSetChild, dispatch]);
570
+ .catch(() => {
571
+ setFailedDescriptions((prev) => new Set(prev).add(selectedSkill.id));
572
+ });
573
+ }, [selectedSkill, isSkillSetChild, dispatch, failedDescriptions]);
526
574
 
527
575
  // ── Action handlers ───────────────────────────────────────────────────────
528
576
 
@@ -569,187 +617,126 @@ export function SkillsScreen() {
569
617
  }
570
618
  }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
571
619
 
572
- // ── Keyboard handling ─────────────────────────────────────────────────────
573
-
574
- useKeyboard((event) => {
575
- if (state.modal) return;
576
-
577
- const hasQuery = skillsState.searchQuery.length > 0;
578
-
579
- if (event.name === "escape") {
580
- if (hasQuery || isSearchActive) {
581
- dispatch({ type: "SKILLS_SET_SEARCH", query: "" });
582
- dispatch({ type: "SET_SEARCHING", isSearching: false });
583
- dispatch({ type: "SKILLS_SELECT", index: 0 });
584
- }
585
- return;
586
- }
620
+ // ── Keys ──────────────────────────────────────────────────────────────────
621
+
622
+ const setQuery = (query: string) => {
623
+ dispatch({ type: "SKILLS_SET_SEARCH", query });
624
+ dispatch({ type: "SKILLS_SELECT", index: 0 });
625
+ };
626
+
627
+ // The search box, while it has focus: digits, j/k, q and ? are typed here.
628
+ useSearchInput({
629
+ focused: isSearchActive,
630
+ query: skillsState.searchQuery,
631
+ append: (char) => setQuery(skillsState.searchQuery + char),
632
+ erase: () => setQuery(skillsState.searchQuery.slice(0, -1)),
633
+ clear: () => setQuery(""),
634
+ blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
635
+ });
587
636
 
588
- if (event.name === "backspace" || event.name === "delete") {
589
- if (hasQuery) {
590
- const newQuery = skillsState.searchQuery.slice(0, -1);
591
- dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
592
- dispatch({ type: "SKILLS_SELECT", index: 0 });
593
- if (!newQuery) dispatch({ type: "SET_SEARCHING", isSearching: false });
637
+ /** Open `url` in the browser. Best effort: a failure changes nothing. */
638
+ const openUrl = (url: string) => {
639
+ import("node:child_process").then(({ execSync: exec }) => {
640
+ try {
641
+ exec(`open "${url}"`);
642
+ } catch {
643
+ /* ignore */
594
644
  }
595
- return;
596
- }
597
-
598
- if (event.name === "up" || event.name === "k") {
599
- if (isSearchActive)
600
- dispatch({ type: "SET_SEARCHING", isSearching: false });
601
- let newIndex = skillsState.selectedIndex - 1;
602
- // Skip category headers
603
- while (newIndex >= 0 && allItems[newIndex]?.kind === "category")
604
- newIndex--;
605
- if (newIndex >= 0) dispatch({ type: "SKILLS_SELECT", index: newIndex });
606
- return;
607
- }
608
- if (event.name === "down" || event.name === "j") {
609
- if (isSearchActive)
610
- dispatch({ type: "SET_SEARCHING", isSearching: false });
611
- let newIndex = skillsState.selectedIndex + 1;
612
- // Skip category headers
613
- while (
614
- newIndex < allItems.length &&
615
- allItems[newIndex]?.kind === "category"
616
- )
617
- newIndex++;
618
- if (newIndex < allItems.length)
619
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
620
- return;
621
- }
645
+ });
646
+ };
622
647
 
623
- if (event.name === "return" || event.name === "enter") {
624
- if (isSearchActive) {
625
- dispatch({ type: "SET_SEARCHING", isSearching: false });
626
- return;
627
- }
648
+ useScreenKeys("skills", {
649
+ scope: (key) => {
650
+ const scope = key.name === "u" ? "user" : "project";
628
651
  if (selectedSet) {
629
- handleToggleSet(selectedSet.id);
630
- return;
631
- }
632
- if (selectedSkill && !selectedSkill.installed) {
633
- handleInstall("project");
634
- }
635
- return;
636
- }
637
-
638
- // Action keys only when NOT actively typing in search
639
- if (!isSearchActive) {
640
- if (event.name === "u") {
641
- if (selectedSet) {
642
- handleInstallAllFromSet(selectedSet.id, "user");
643
- return;
644
- }
645
- if (selectedSkill) {
646
- if (
647
- selectedSkill.installed &&
648
- selectedSkill.installedScope === "user"
649
- )
650
- handleUninstall();
651
- else handleInstall("user");
652
- return;
653
- }
654
- } else if (event.name === "p") {
655
- if (selectedSet) {
656
- handleInstallAllFromSet(selectedSet.id, "project");
657
- return;
658
- }
659
- if (selectedSkill) {
660
- if (
661
- selectedSkill.installed &&
662
- selectedSkill.installedScope === "project"
663
- )
664
- handleUninstall();
665
- else handleInstall("project");
666
- return;
652
+ void handleInstallAllFromSet(selectedSet.id, scope);
653
+ } else if (selectedSkill) {
654
+ if (selectedSkill.installed && selectedSkill.installedScope === scope) {
655
+ void handleUninstall();
656
+ } else {
657
+ void handleInstall(scope);
667
658
  }
668
659
  }
669
- }
670
-
671
- if (isSearchActive) {
672
- if (event.name === "k" || event.name === "j") {
673
- const delta = event.name === "k" ? -1 : 1;
674
- const newIndex = Math.max(
675
- 0,
676
- Math.min(allItems.length - 1, skillsState.selectedIndex + delta),
677
- );
678
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
679
- return;
680
- }
681
- if (
682
- event.name &&
683
- event.name.length === 1 &&
684
- !event.ctrl &&
685
- !event.meta &&
686
- !/[0-9]/.test(event.name)
687
- ) {
688
- dispatch({
689
- type: "SKILLS_SET_SEARCH",
690
- query: skillsState.searchQuery + event.name,
691
- });
692
- dispatch({ type: "SKILLS_SELECT", index: 0 });
660
+ },
661
+ open: () => {
662
+ if (selectedSet) void handleToggleSet(selectedSet.id);
663
+ else if (selectedSkill && !selectedSkill.installed) {
664
+ void handleInstall("project");
693
665
  }
694
- return;
695
- }
696
-
697
- if (event.name === "r") {
698
- fetchData();
699
- } else if (event.name === "o" && (selectedSkill || selectedSet)) {
666
+ },
667
+ browse: () => {
700
668
  if (selectedSet) {
701
- const url = `https://github.com/${selectedSet.repo}`;
702
- import("node:child_process").then(({ execSync: exec }) => {
703
- try {
704
- exec(`open "${url}"`);
705
- } catch {
706
- /* ignore */
707
- }
708
- });
669
+ openUrl(`https://github.com/${selectedSet.repo}`);
709
670
  } else if (selectedSkill) {
710
671
  const repo = selectedSkill.source.repo;
711
672
  const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
712
673
  if (repo && repo !== "local") {
713
- const url = `https://github.com/${repo}/tree/main/${repoPath}`;
714
- import("node:child_process").then(({ execSync: exec }) => {
715
- try {
716
- exec(`open "${url}"`);
717
- } catch {
718
- /* ignore */
719
- }
720
- });
674
+ openUrl(`https://github.com/${repo}/tree/main/${repoPath}`);
721
675
  }
722
676
  }
723
- } else if (event.name === "/") {
724
- dispatch({ type: "SET_SEARCHING", isSearching: true });
725
- }
677
+ },
678
+ refresh: () => {
679
+ setFailedDescriptions(new Set());
680
+ void fetchData();
681
+ },
682
+ search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
683
+ // Only while a committed filter is set; otherwise Esc is the global back.
684
+ "clear-filter": () => {
685
+ if (!skillsState.searchQuery) return false;
686
+ setQuery("");
687
+ return true;
688
+ },
689
+ // Category headers are labels, not rows: step over them.
690
+ move: (key) => {
691
+ const delta = moveDirection(key);
692
+ let index = skillsState.selectedIndex + delta;
693
+ while (index >= 0 && index < allItems.length) {
694
+ if (allItems[index]?.kind !== "category") {
695
+ dispatch({ type: "SKILLS_SELECT", index });
696
+ return;
697
+ }
698
+ index += delta;
699
+ }
700
+ },
726
701
  });
727
702
 
728
703
  // ── Status line ───────────────────────────────────────────────────────────
729
704
 
730
- const skills =
731
- skillsState.skills.status === "success" ? skillsState.skills.data : [];
732
- const installedCount = skills.filter((s) => s.installed).length;
705
+ // The same selector the Installed group is built from — see `installedGroup`.
706
+ const installedCount = installedGroup(
707
+ installedSkills,
708
+ skillsState.searchQuery,
709
+ ).length;
733
710
  const query = skillsState.searchQuery.trim();
734
711
 
735
712
  const statusContent = statusMsg ? (
736
713
  <text fg={theme.colors.text}>
737
- <span fg={statusMsg.tone === "success" ? "green" : "red"}>
714
+ <span
715
+ fg={
716
+ statusMsg.tone === "success"
717
+ ? theme.colors.success
718
+ : theme.colors.danger
719
+ }
720
+ >
738
721
  {statusMsg.text}
739
722
  </span>
740
723
  </text>
741
724
  ) : (
742
725
  <text fg={theme.colors.text}>
743
726
  <span fg={theme.colors.muted}>Skills: </span>
744
- <span fg={theme.colors.info}>{installedCount} installed</span>
727
+ {installedFromDisk.scanned ? (
728
+ <span fg={theme.colors.info}>{installedCount} installed</span>
729
+ ) : (
730
+ <span fg={theme.colors.muted}>reading…</span>
731
+ )}
745
732
  {query.length >= 2 && isSearchLoading && (
746
- <span fg={theme.colors.warning}> │ searching...</span>
733
+ <span fg={theme.colors.muted}> │ searching…</span>
747
734
  )}
748
735
  {query.length >= 2 &&
749
736
  !isSearchLoading &&
750
737
  !searchError &&
751
738
  searchResults.length > 0 && (
752
- <span fg={theme.colors.success}> │ {searchResults.length} found</span>
739
+ <span fg={theme.colors.info}> │ {searchResults.length} found</span>
753
740
  )}
754
741
  {query.length >= 2 && !isSearchLoading && searchError && (
755
742
  <span fg={theme.colors.danger}> │ search unavailable</span>
@@ -774,38 +761,27 @@ export function SkillsScreen() {
774
761
  }
775
762
  : undefined
776
763
  }
777
- footerHints={
778
- isSearchActive
779
- ? [
780
- { keys: ["type"], label: "filter" },
781
- { keys: ["Enter"], label: "done" },
782
- { keys: ["Esc"], label: "clear" },
783
- ]
784
- : [
785
- { keys: ["u"], label: "user" },
786
- { keys: ["p"], label: "project" },
787
- { keys: ["o"], label: "open" },
788
- { keys: ["/"], label: "search" },
789
- ]
790
- }
791
- listPanel={
764
+ footer={{ hide: skillsState.searchQuery ? [] : ["clear-filter"] }}
765
+ listPanel={(width: number) => (
792
766
  <box flexDirection="column">
793
767
  <ScrollableList
794
768
  items={allItems}
795
769
  selectedIndex={skillsState.selectedIndex}
796
- renderItem={renderSkillRow}
770
+ renderItem={(item, _index, isSelected) =>
771
+ renderSkillRow(item, isSelected, width)
772
+ }
797
773
  maxHeight={dimensions.listPanelHeight}
798
774
  getKey={(item, index) => `${index}:${item.id}`}
799
775
  />
800
776
  {!query && skillsState.skills.status === "loading" && (
801
777
  <box marginTop={2} paddingLeft={2}>
802
- <text fg={theme.colors.warning}>Loading popular skills...</text>
778
+ <text fg={theme.colors.muted}>Loading popular skills…</text>
803
779
  </box>
804
780
  )}
805
781
  {query.length >= 2 && isSearchLoading && (
806
782
  <box marginTop={2} paddingLeft={2}>
807
- <text fg={theme.colors.warning}>
808
- Searching for "{skillsState.searchQuery}"...
783
+ <text fg={theme.colors.muted}>
784
+ Searching for "{skillsState.searchQuery}"…
809
785
  </text>
810
786
  </box>
811
787
  )}
@@ -844,8 +820,12 @@ export function SkillsScreen() {
844
820
  />
845
821
  )}
846
822
  </box>
847
- }
848
- detailPanel={renderSkillDetail(selectedItem)}
823
+ )}
824
+ detailPanel={renderSkillDetail(selectedItem, {
825
+ descriptionFailed:
826
+ selectedSkill !== undefined &&
827
+ failedDescriptions.has(selectedSkill.id),
828
+ })}
849
829
  />
850
830
  );
851
831
  }