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.
- package/package.json +4 -4
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- 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 {
|
|
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 {
|
|
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"
|
|
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:
|
|
182
|
-
project:
|
|
183
|
-
|
|
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
|
|
187
|
-
const project = new
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
648
|
+
useScreenKeys("skills", {
|
|
649
|
+
scope: (key) => {
|
|
650
|
+
const scope = key.name === "u" ? "user" : "project";
|
|
628
651
|
if (selectedSet) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
672
|
-
if (
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
724
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
778
|
-
|
|
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={
|
|
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.
|
|
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.
|
|
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
|
}
|