claudeup 4.35.0 → 4.36.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 (63) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/git-worktree.test.ts +108 -0
  3. package/src/__tests__/scope-squares.test.tsx +165 -0
  4. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  5. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  6. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  7. package/src/main.tsx +21 -5
  8. package/src/opentui.d.ts +21 -12
  9. package/src/services/claude-settings.ts +37 -7
  10. package/src/services/git-worktree.ts +129 -0
  11. package/src/services/plugin-manager.ts +10 -1
  12. package/src/ui/App.tsx +19 -12
  13. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  14. package/src/ui/adapters/settingsAdapter.ts +119 -116
  15. package/src/ui/adapters/skillsAdapter.ts +203 -196
  16. package/src/ui/components/CategoryHeader.tsx +9 -8
  17. package/src/ui/components/EmptyFilterState.tsx +10 -5
  18. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  19. package/src/ui/components/ScopeIndicator.tsx +10 -6
  20. package/src/ui/components/ScrollableList.tsx +3 -2
  21. package/src/ui/components/SearchInput.tsx +2 -1
  22. package/src/ui/components/StyledText.tsx +5 -4
  23. package/src/ui/components/TabBar.tsx +4 -3
  24. package/src/ui/components/layout/FooterHints.tsx +37 -30
  25. package/src/ui/components/layout/Panel.tsx +6 -5
  26. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  27. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  28. package/src/ui/components/layout/ScreenLayout.tsx +27 -24
  29. package/src/ui/components/layout/index.ts +3 -3
  30. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  31. package/src/ui/components/modals/InputModal.tsx +14 -6
  32. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  33. package/src/ui/components/modals/MessageModal.tsx +9 -8
  34. package/src/ui/components/modals/SelectModal.tsx +11 -7
  35. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  36. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  37. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  38. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  39. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  40. package/src/ui/components/primitives/MetaText.tsx +3 -3
  41. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  42. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  43. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  44. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  45. package/src/ui/registry.ts +11 -11
  46. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  47. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  48. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  49. package/src/ui/renderers/pluginRenderers.tsx +528 -471
  50. package/src/ui/renderers/profileRenderers.tsx +346 -300
  51. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  52. package/src/ui/renderers/skillRenderers.tsx +410 -326
  53. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  54. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  55. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  56. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  57. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  58. package/src/ui/screens/McpScreen.tsx +12 -3
  59. package/src/ui/screens/PluginsScreen.tsx +17 -7
  60. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  61. package/src/ui/screens/SkillsScreen.tsx +832 -688
  62. package/src/ui/theme-mode.ts +73 -0
  63. package/src/ui/theme.ts +147 -53
@@ -1,4 +1,10 @@
1
- import React, { useEffect, useCallback, useMemo, useState, useRef } from "react";
1
+ import React, {
2
+ useEffect,
3
+ useCallback,
4
+ useMemo,
5
+ useState,
6
+ useRef,
7
+ } from "react";
2
8
  import os from "node:os";
3
9
  import path from "node:path";
4
10
  import fs from "fs-extra";
@@ -9,699 +15,837 @@ import { ScreenLayout } from "../components/layout/index.js";
9
15
 
10
16
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
11
17
  import {
12
- fetchAvailableSkills,
13
- fetchSkillFrontmatter,
14
- fetchSkillSetSkills,
15
- installSkill,
16
- uninstallSkill,
18
+ fetchAvailableSkills,
19
+ fetchSkillFrontmatter,
20
+ fetchSkillSetSkills,
21
+ installSkill,
22
+ uninstallSkill,
17
23
  } from "../../services/skills-manager.js";
18
- import { fetchCuratedSkillsCatalog, searchSkills } from "../../services/skillsmp-client.js";
19
- import { DEFAULT_SKILL_REPOS, classifyStarReliability, type RecommendedSkill } from "../../data/skill-repos.js";
20
- import type { SkillInfo, SkillSetInfo, SkillSource } from "../../types/index.js";
24
+ import {
25
+ fetchCuratedSkillsCatalog,
26
+ searchSkills,
27
+ } from "../../services/skillsmp-client.js";
28
+ import {
29
+ DEFAULT_SKILL_REPOS,
30
+ classifyStarReliability,
31
+ type RecommendedSkill,
32
+ } from "../../data/skill-repos.js";
33
+ import type {
34
+ SkillInfo,
35
+ SkillSetInfo,
36
+ SkillSource,
37
+ } from "../../types/index.js";
21
38
  import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
22
39
  import type { SkillBrowserItem } from "../adapters/skillsAdapter.js";
23
- import { renderSkillRow, renderSkillDetail } from "../renderers/skillRenderers.js";
40
+ import {
41
+ renderSkillRow,
42
+ renderSkillDetail,
43
+ } from "../renderers/skillRenderers.js";
24
44
  import { ScrollableList } from "../components/ScrollableList.js";
45
+ import { theme } from "../theme.js";
25
46
 
26
47
  export function SkillsScreen() {
27
- const { state, dispatch } = useApp();
28
- const { skills: skillsState } = state;
29
- const modal = useModal();
30
- const dimensions = useDimensions();
31
- const [curatedSkills, setCuratedSkills] = useState<RecommendedSkill[]>([]);
32
- const [skillSets, setSkillSets] = useState<SkillSetInfo[]>([]);
33
- const [expandedSets, setExpandedSets] = useState<Set<string>>(new Set());
34
-
35
- const isSearchActive =
36
- state.isSearching &&
37
- state.currentRoute.screen === "skills" &&
38
- !state.modal;
39
-
40
- // ── Data fetching ─────────────────────────────────────────────────────────
41
-
42
- const fetchData = useCallback(async () => {
43
- dispatch({ type: "SKILLS_DATA_LOADING" });
44
- try {
45
- const catalog = await fetchCuratedSkillsCatalog();
46
- setCuratedSkills(catalog.skills);
47
- setSkillSets((previous) =>
48
- catalog.skillSets.map((skillSet) => {
49
- const existing = previous.find((item) => item.repo === skillSet.repo);
50
- return existing
51
- ? { ...existing, ...skillSet, id: skillSet.repo }
52
- : {
53
- id: skillSet.repo,
54
- name: skillSet.name,
55
- description: skillSet.description,
56
- repo: skillSet.repo,
57
- icon: skillSet.icon,
58
- stars: skillSet.stars,
59
- skills: [],
60
- loaded: false,
61
- loading: false,
62
- };
63
- }),
64
- );
65
- const skills = await fetchAvailableSkills(
66
- DEFAULT_SKILL_REPOS,
67
- state.projectPath,
68
- catalog.skills,
69
- );
70
- dispatch({ type: "SKILLS_DATA_SUCCESS", skills });
71
- } catch (error) {
72
- dispatch({
73
- type: "SKILLS_DATA_ERROR",
74
- error: error instanceof Error ? error : new Error(String(error)),
75
- });
76
- }
77
- }, [dispatch, state.projectPath]);
78
-
79
- useEffect(() => {
80
- fetchData();
81
- }, [fetchData, state.dataRefreshVersion]);
82
-
83
- // ── Remote search (debounced, cached) ─────────────────────────────────────
84
-
85
- const [searchResults, setSearchResults] = useState<SkillInfo[]>([]);
86
- const [isSearchLoading, setIsSearchLoading] = useState(false);
87
- const [searchError, setSearchError] = useState<string | null>(null);
88
- const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
89
- const searchCacheRef = useRef<Map<string, SkillInfo[]>>(new Map());
90
-
91
- useEffect(() => {
92
- const query = skillsState.searchQuery.trim();
93
- if (query.length < 2) {
94
- setSearchResults([]);
95
- setIsSearchLoading(false);
96
- setSearchError(null);
97
- return;
98
- }
99
-
100
- const cached = searchCacheRef.current.get(query);
101
- if (cached) {
102
- setSearchResults(cached);
103
- setIsSearchLoading(false);
104
- setSearchError(null);
105
- return;
106
- }
107
-
108
- setIsSearchLoading(true);
109
- setSearchError(null);
110
-
111
- if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
112
- searchTimerRef.current = setTimeout(async () => {
113
- try {
114
- const results = await searchSkills(query, { limit: 30 });
115
- const mapped: SkillInfo[] = results.map((r) => {
116
- const source: SkillSource = {
117
- label: r.repo || "unknown",
118
- repo: r.repo || "unknown",
119
- skillsPath: "",
120
- };
121
- return {
122
- id: `remote:${r.repo}/${r.skillPath}`,
123
- name: r.name,
124
- description: r.description || "",
125
- source,
126
- repoPath: r.skillPath ? `${r.skillPath}/SKILL.md` : "SKILL.md",
127
- gitBlobSha: "",
128
- frontmatter: null,
129
- installed: false,
130
- installedScope: null,
131
- hasUpdate: false,
132
- stars: r.stars,
133
- starReliability: classifyStarReliability(r.repo || "unknown", r.stars),
134
- };
135
- });
136
- searchCacheRef.current.set(query, mapped);
137
- setSearchResults(mapped);
138
- setSearchError(null);
139
- } catch (error) {
140
- // A thrown error means the search service is unreachable — distinct
141
- // from a successful search that returned zero matches.
142
- setSearchResults([]);
143
- setSearchError(
144
- error instanceof Error ? error.message : "Search service unavailable",
145
- );
146
- }
147
- setIsSearchLoading(false);
148
- }, 400);
149
-
150
- return () => {
151
- if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
152
- };
153
- }, [skillsState.searchQuery]);
154
-
155
- // ── Disk scan for installed skills ────────────────────────────────────────
156
-
157
- const [installedFromDisk, setInstalledFromDisk] = useState<{
158
- user: Set<string>;
159
- project: Set<string>;
160
- }>({ user: new Set(), project: new Set() });
161
-
162
- const scanDisk = useCallback(async () => {
163
- const user = new Set<string>();
164
- const project = new Set<string>();
165
- const userDir = path.join(os.homedir(), ".claude", "skills");
166
- const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
167
- for (const [dir, set] of [[userDir, user], [projDir, project]] as const) {
168
- try {
169
- if (await fs.pathExists(dir)) {
170
- const entries = await fs.readdir(dir);
171
- for (const e of entries) {
172
- if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
173
- }
174
- }
175
- } catch { /* ignore */ }
176
- }
177
- setInstalledFromDisk({ user, project });
178
- }, [state.projectPath]);
179
-
180
- useEffect(() => {
181
- scanDisk();
182
- }, [scanDisk, state.dataRefreshVersion]);
183
-
184
- // ── Skill Sets state ──────────────────────────────────────────────────────
185
-
186
-
187
- // Re-mark installed status on child skills when disk state changes
188
- useEffect(() => {
189
- setSkillSets((prev) =>
190
- prev.map((set) => {
191
- if (!set.loaded) return set;
192
- const updatedSkills = set.skills.map((skill) => {
193
- const slug = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
194
- const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(skill.name);
195
- const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(skill.name);
196
- return {
197
- ...skill,
198
- installed: isUser || isProj,
199
- installedScope: isProj ? "project" as const : isUser ? "user" as const : null,
200
- };
201
- });
202
- return { ...set, skills: updatedSkills };
203
- }),
204
- );
205
- }, [installedFromDisk]);
206
-
207
- const handleToggleSet = useCallback(
208
- async (setId: string) => {
209
- const isExpanded = expandedSets.has(setId);
210
- const newExpanded = new Set(expandedSets);
211
- if (isExpanded) {
212
- newExpanded.delete(setId);
213
- } else {
214
- newExpanded.add(setId);
215
- }
216
- setExpandedSets(newExpanded);
217
-
218
- // Fetch skills on first expand
219
- const set = skillSets.find((s) => s.id === setId);
220
- if (!isExpanded && set && !set.loaded && !set.loading) {
221
- setSkillSets((prev) =>
222
- prev.map((s) => (s.id === setId ? { ...s, loading: true } : s)),
223
- );
224
- try {
225
- const skills = await fetchSkillSetSkills(set.repo, state.projectPath);
226
- setSkillSets((prev) =>
227
- prev.map((s) =>
228
- s.id === setId
229
- ? { ...s, skills, loaded: true, loading: false, error: undefined }
230
- : s,
231
- ),
232
- );
233
- } catch (error) {
234
- setSkillSets((prev) =>
235
- prev.map((s) =>
236
- s.id === setId
237
- ? {
238
- ...s,
239
- loading: false,
240
- error: error instanceof Error ? error.message : String(error),
241
- }
242
- : s,
243
- ),
244
- );
245
- }
246
- }
247
- },
248
- [expandedSets, skillSets, state.projectPath],
249
- );
250
-
251
- // Status bar message (auto-clears)
252
- const [statusMsg, setStatusMsg] = useState<{ text: string; tone: "success" | "error" } | null>(null);
253
- const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
254
- const showStatus = useCallback((text: string, tone: "success" | "error" = "success") => {
255
- setStatusMsg({ text, tone });
256
- if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
257
- statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
258
- }, []);
259
-
260
- const handleInstallAllFromSet = useCallback(
261
- async (setId: string, scope: "user" | "project") => {
262
- const set = skillSets.find((s) => s.id === setId);
263
- if (!set || !set.loaded) return;
264
-
265
- const toInstall = set.skills.filter((s) => !s.installed);
266
- if (toInstall.length === 0) {
267
- showStatus(`All ${set.name} skills already installed`);
268
- return;
269
- }
270
-
271
- showStatus(`Installing ${toInstall.length} skills from ${set.name}...`);
272
- let installed = 0;
273
- let failed = 0;
274
- let degraded = 0;
275
- for (const skill of toInstall) {
276
- try {
277
- const result = await installSkill(skill, scope, state.projectPath);
278
- installed++;
279
- if (result.degraded) degraded++;
280
- } catch {
281
- failed++;
282
- }
283
- }
284
- await scanDisk();
285
- if (failed > 0) {
286
- showStatus(`Installed ${installed}/${toInstall.length} (${failed} failed)`, "error");
287
- } else if (degraded > 0) {
288
- // Incomplete is not the same as installed. Say so rather than let a
289
- // batch of half-written skills report a clean success.
290
- showStatus(
291
- `Installed ${installed} skills from ${set.name} to ${scope} — ${degraded} incomplete (missing supporting files)`,
292
- "error",
293
- );
294
- } else {
295
- showStatus(`Installed ${installed} skills from ${set.name} to ${scope}`);
296
- }
297
- },
298
- [skillSets, state.projectPath, scanDisk, showStatus],
299
- );
300
-
301
- // ── Derived data ──────────────────────────────────────────────────────────
302
-
303
- const staticRecommended = useMemo((): SkillInfo[] => {
304
- return curatedSkills.map((r) => {
305
- const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
306
- const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
307
- const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
308
- return {
309
- id: `rec:${r.repo}/${r.skillPath}`,
310
- name: r.name,
311
- description: r.description,
312
- source: { label: r.repo, repo: r.repo, skillsPath: "" },
313
- repoPath: `${r.skillPath}/SKILL.md`,
314
- gitBlobSha: "",
315
- frontmatter: null,
316
- installed: isUser || isProj,
317
- installedScope: isProj ? "project" : isUser ? "user" : null,
318
- hasUpdate: false,
319
- isRecommended: true,
320
- stars: r.stars,
321
- starReliability: classifyStarReliability(r.repo, r.stars),
322
- };
323
- });
324
- }, [curatedSkills, installedFromDisk]);
325
-
326
- const mergedRecommended = useMemo((): SkillInfo[] => {
327
- if (skillsState.skills.status !== "success") return staticRecommended;
328
- const fetched = skillsState.skills.data.filter((s) => s.isRecommended);
329
- return staticRecommended.map((staticSkill) => {
330
- const match = fetched.find(
331
- (f) => f.source.repo === staticSkill.source.repo && f.name === staticSkill.name,
332
- );
333
- if (!match) return staticSkill;
334
- // Merge: prefer fetched data for description/stars, but disk scan is
335
- // authoritative for installed state (staticSkill reflects current disk)
336
- return {
337
- ...staticSkill,
338
- ...match,
339
- installed: staticSkill.installed,
340
- installedScope: staticSkill.installedScope,
341
- stars: match.stars || staticSkill.stars,
342
- };
343
- });
344
- }, [staticRecommended, skillsState.skills]);
345
-
346
- const installedSkills = useMemo((): SkillInfo[] => {
347
- const all: SkillInfo[] = [];
348
- for (const [scope, names] of [
349
- ["user", installedFromDisk.user],
350
- ["project", installedFromDisk.project],
351
- ] as const) {
352
- for (const name of names) {
353
- if (all.some((s) => s.name === name)) continue;
354
- all.push({
355
- id: `installed:${scope}/${name}`,
356
- name,
357
- description: "",
358
- source: { label: "local", repo: "local", skillsPath: "" },
359
- repoPath: "",
360
- gitBlobSha: "",
361
- frontmatter: null,
362
- installed: true,
363
- installedScope: scope,
364
- hasUpdate: false,
365
- stars: undefined,
366
- });
367
- }
368
- }
369
- return all;
370
- }, [installedFromDisk]);
371
-
372
- const popularSkills = useMemo((): SkillInfo[] => {
373
- if (skillsState.skills.status !== "success") return [];
374
- return skillsState.skills.data
375
- .filter((s) => !s.isRecommended)
376
- .sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
377
- }, [skillsState.skills]);
378
-
379
- // ── List items (built by adapter) ─────────────────────────────────────────
380
-
381
- const allItems = useMemo(
382
- () =>
383
- buildSkillBrowserItems({
384
- recommended: mergedRecommended,
385
- popular: popularSkills,
386
- installed: installedSkills,
387
- searchResults,
388
- query: skillsState.searchQuery,
389
- isSearchLoading,
390
- skillSets,
391
- expandedSets,
392
- }),
393
- [
394
- mergedRecommended,
395
- popularSkills,
396
- installedSkills,
397
- searchResults,
398
- skillsState.searchQuery,
399
- isSearchLoading,
400
- skillSets,
401
- expandedSets,
402
- ],
403
- );
404
-
405
- // Auto-correct selection if it lands on a category header (e.g. initial load, reset)
406
- useEffect(() => {
407
- const item = allItems[skillsState.selectedIndex];
408
- if (item && item.kind === "category") {
409
- const firstSelectable = allItems.findIndex((i) => i.kind === "skill" || i.kind === "skillset");
410
- if (firstSelectable >= 0) dispatch({ type: "SKILLS_SELECT", index: firstSelectable });
411
- }
412
- }, [allItems, skillsState.selectedIndex, dispatch]);
413
-
414
- const selectedItem: SkillBrowserItem | undefined = allItems[skillsState.selectedIndex];
415
- const selectedSkill =
416
- selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
417
- const selectedSet =
418
- selectedItem?.kind === "skillset" ? selectedItem.skillSet : undefined;
419
-
420
- // ── Lazy-load frontmatter for selected skill ───────────────────────────────
421
-
422
- // Check if selected skill belongs to a skill set (lives in local state, not reducer)
423
- const isSkillSetChild = selectedSkill
424
- ? skillSets.some((s) => s.loaded && s.skills.some((sk) => sk.id === selectedSkill.id))
425
- : false;
426
-
427
- useEffect(() => {
428
- if (!selectedSkill || selectedSkill.frontmatter) return;
429
- fetchSkillFrontmatter(selectedSkill).then((fm) => {
430
- if (isSkillSetChild) {
431
- // Update the local skillSets state
432
- setSkillSets((prev) =>
433
- prev.map((set) => ({
434
- ...set,
435
- skills: set.skills.map((sk) =>
436
- sk.id === selectedSkill.id ? { ...sk, frontmatter: fm } : sk,
437
- ),
438
- })),
439
- );
440
- } else {
441
- dispatch({
442
- type: "SKILLS_UPDATE_ITEM",
443
- name: selectedSkill.name,
444
- updates: { frontmatter: fm },
445
- });
446
- }
447
- }).catch(() => {});
448
- }, [selectedSkill?.id, isSkillSetChild, dispatch]);
449
-
450
- // ── Action handlers ───────────────────────────────────────────────────────
451
-
452
- const handleInstall = useCallback(async (scope: "user" | "project") => {
453
- if (!selectedSkill) return;
454
- try {
455
- const result = await installSkill(selectedSkill, scope, state.projectPath);
456
- await scanDisk(); // Re-scan disk only no network re-fetch needed
457
- const files =
458
- result.fileCount === 1 ? "1 file" : `${result.fileCount} files`;
459
- // An install that silently dropped supporting files used to report plain
460
- // success, so a half-installed skill looked identical to a good one.
461
- if (result.degraded) {
462
- showStatus(
463
- `Installed ${selectedSkill.name} to ${scope} (${files}) — ${result.degraded}`,
464
- "error",
465
- );
466
- } else {
467
- showStatus(`Installed ${selectedSkill.name} to ${scope} (${files})`);
468
- }
469
- } catch (error) {
470
- showStatus(`Failed: ${error}`, "error");
471
- }
472
- }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
473
-
474
- const handleUninstall = useCallback(async () => {
475
- if (!selectedSkill || !selectedSkill.installed) return;
476
- const scope = selectedSkill.installedScope;
477
- if (!scope) return;
478
-
479
- try {
480
- await uninstallSkill(selectedSkill.name, scope, state.projectPath);
481
- await scanDisk(); // Re-scan disk only — no network re-fetch needed
482
- showStatus(`Removed ${selectedSkill.name} from ${scope}`);
483
- } catch (error) {
484
- showStatus(`Failed: ${error}`, "error");
485
- }
486
- }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
487
-
488
- // ── Keyboard handling ─────────────────────────────────────────────────────
489
-
490
- useKeyboard((event) => {
491
- if (state.modal) return;
492
-
493
- const hasQuery = skillsState.searchQuery.length > 0;
494
-
495
- if (event.name === "escape") {
496
- if (hasQuery || isSearchActive) {
497
- dispatch({ type: "SKILLS_SET_SEARCH", query: "" });
498
- dispatch({ type: "SET_SEARCHING", isSearching: false });
499
- dispatch({ type: "SKILLS_SELECT", index: 0 });
500
- }
501
- return;
502
- }
503
-
504
- if (event.name === "backspace" || event.name === "delete") {
505
- if (hasQuery) {
506
- const newQuery = skillsState.searchQuery.slice(0, -1);
507
- dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
508
- dispatch({ type: "SKILLS_SELECT", index: 0 });
509
- if (!newQuery) dispatch({ type: "SET_SEARCHING", isSearching: false });
510
- }
511
- return;
512
- }
513
-
514
- if (event.name === "up" || event.name === "k") {
515
- if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
516
- let newIndex = skillsState.selectedIndex - 1;
517
- // Skip category headers
518
- while (newIndex >= 0 && allItems[newIndex]?.kind === "category") newIndex--;
519
- if (newIndex >= 0) dispatch({ type: "SKILLS_SELECT", index: newIndex });
520
- return;
521
- }
522
- if (event.name === "down" || event.name === "j") {
523
- if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
524
- let newIndex = skillsState.selectedIndex + 1;
525
- // Skip category headers
526
- while (newIndex < allItems.length && allItems[newIndex]?.kind === "category") newIndex++;
527
- if (newIndex < allItems.length) dispatch({ type: "SKILLS_SELECT", index: newIndex });
528
- return;
529
- }
530
-
531
- if (event.name === "return" || event.name === "enter") {
532
- if (isSearchActive) {
533
- dispatch({ type: "SET_SEARCHING", isSearching: false });
534
- return;
535
- }
536
- if (selectedSet) {
537
- handleToggleSet(selectedSet.id);
538
- return;
539
- }
540
- if (selectedSkill && !selectedSkill.installed) {
541
- handleInstall("project");
542
- }
543
- return;
544
- }
545
-
546
- // Action keys only when NOT actively typing in search
547
- if (!isSearchActive) {
548
- if (event.name === "u") {
549
- if (selectedSet) {
550
- handleInstallAllFromSet(selectedSet.id, "user");
551
- return;
552
- }
553
- if (selectedSkill) {
554
- if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
555
- else handleInstall("user");
556
- return;
557
- }
558
- } else if (event.name === "p") {
559
- if (selectedSet) {
560
- handleInstallAllFromSet(selectedSet.id, "project");
561
- return;
562
- }
563
- if (selectedSkill) {
564
- if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
565
- else handleInstall("project");
566
- return;
567
- }
568
- }
569
- }
570
-
571
- if (isSearchActive) {
572
- if (event.name === "k" || event.name === "j") {
573
- const delta = event.name === "k" ? -1 : 1;
574
- const newIndex = Math.max(0, Math.min(allItems.length - 1, skillsState.selectedIndex + delta));
575
- dispatch({ type: "SKILLS_SELECT", index: newIndex });
576
- return;
577
- }
578
- if (event.name && event.name.length === 1 && !event.ctrl && !event.meta && !/[0-9]/.test(event.name)) {
579
- dispatch({ type: "SKILLS_SET_SEARCH", query: skillsState.searchQuery + event.name });
580
- dispatch({ type: "SKILLS_SELECT", index: 0 });
581
- }
582
- return;
583
- }
584
-
585
- if (event.name === "r") {
586
- fetchData();
587
- } else if (event.name === "o" && (selectedSkill || selectedSet)) {
588
- if (selectedSet) {
589
- const url = `https://github.com/${selectedSet.repo}`;
590
- import("node:child_process").then(({ execSync: exec }) => {
591
- try { exec(`open "${url}"`); } catch { /* ignore */ }
592
- });
593
- } else if (selectedSkill) {
594
- const repo = selectedSkill.source.repo;
595
- const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
596
- if (repo && repo !== "local") {
597
- const url = `https://github.com/${repo}/tree/main/${repoPath}`;
598
- import("node:child_process").then(({ execSync: exec }) => {
599
- try { exec(`open "${url}"`); } catch { /* ignore */ }
600
- });
601
- }
602
- }
603
- } else if (event.name === "/") {
604
- dispatch({ type: "SET_SEARCHING", isSearching: true });
605
- }
606
- });
607
-
608
- // ── Status line ───────────────────────────────────────────────────────────
609
-
610
- const skills =
611
- skillsState.skills.status === "success" ? skillsState.skills.data : [];
612
- const installedCount = skills.filter((s) => s.installed).length;
613
- const query = skillsState.searchQuery.trim();
614
-
615
- const statusContent = statusMsg ? (
616
- <text>
617
- <span fg={statusMsg.tone === "success" ? "green" : "red"}>{statusMsg.text}</span>
618
- </text>
619
- ) : (
620
- <text>
621
- <span fg="gray">Skills: </span>
622
- <span fg="cyan">{installedCount} installed</span>
623
- {query.length >= 2 && isSearchLoading && (
624
- <span fg="yellow"> │ searching...</span>
625
- )}
626
- {query.length >= 2 && !isSearchLoading && !searchError && searchResults.length > 0 && (
627
- <span fg="green"> │ {searchResults.length} found</span>
628
- )}
629
- {query.length >= 2 && !isSearchLoading && searchError && (
630
- <span fg="red"> │ search unavailable</span>
631
- )}
632
- {!query && <span fg="gray"> │ 89K+ searchable</span>}
633
- </text>
634
- );
635
-
636
- // ── Render ────────────────────────────────────────────────────────────────
637
-
638
- return (
639
- <ScreenLayout
640
- title="claudeup Skills"
641
- currentScreen="skills"
642
- statusLine={statusContent}
643
- search={
644
- skillsState.searchQuery || isSearchActive
645
- ? {
646
- isActive: isSearchActive,
647
- query: skillsState.searchQuery,
648
- placeholder: "type to search",
649
- }
650
- : undefined
651
- }
652
- footerHints={
653
- isSearchActive
654
- ? [
655
- { keys: ["type"], label: "filter" },
656
- { keys: ["Enter"], label: "done" },
657
- { keys: ["Esc"], label: "clear" },
658
- ]
659
- : [
660
- { keys: ["u"], label: "user" },
661
- { keys: ["p"], label: "project" },
662
- { keys: ["o"], label: "open" },
663
- { keys: ["/"], label: "search" },
664
- ]
665
- }
666
- listPanel={
667
- <box flexDirection="column">
668
- <ScrollableList
669
- items={allItems}
670
- selectedIndex={skillsState.selectedIndex}
671
- renderItem={renderSkillRow}
672
- maxHeight={dimensions.listPanelHeight}
673
- getKey={(item, index) => `${index}:${item.id}`}
674
- />
675
- {!query && skillsState.skills.status === "loading" && (
676
- <box marginTop={2} paddingLeft={2}>
677
- <text fg="yellow">Loading popular skills...</text>
678
- </box>
679
- )}
680
- {query.length >= 2 && isSearchLoading && (
681
- <box marginTop={2} paddingLeft={2}>
682
- <text fg="yellow">Searching for "{skillsState.searchQuery}"...</text>
683
- </box>
684
- )}
685
- {query.length >= 2 && !isSearchLoading && searchError && (
686
- <box flexDirection="column" marginTop={2} paddingLeft={2} paddingRight={2}>
687
- <text fg="red">Skill search is unavailable right now.</text>
688
- <box marginTop={1}>
689
- <text fg="gray">The search service could not be reached</text>
690
- </box>
691
- <text fg="gray">({searchError}). Check your connection and retry.</text>
692
- <box marginTop={1}>
693
- <text fg="gray">Press Esc to clear the search.</text>
694
- </box>
695
- </box>
696
- )}
697
- {query.length >= 2 && !isSearchLoading && !searchError && searchResults.length === 0 && (
698
- <EmptyFilterState query={skillsState.searchQuery} entityName="skills" />
699
- )}
700
- </box>
701
- }
702
- detailPanel={renderSkillDetail(selectedItem)}
703
- />
704
- );
48
+ const { state, dispatch } = useApp();
49
+ const { skills: skillsState } = state;
50
+ const modal = useModal();
51
+ const dimensions = useDimensions();
52
+ const [curatedSkills, setCuratedSkills] = useState<RecommendedSkill[]>([]);
53
+ const [skillSets, setSkillSets] = useState<SkillSetInfo[]>([]);
54
+ const [expandedSets, setExpandedSets] = useState<Set<string>>(new Set());
55
+
56
+ const isSearchActive =
57
+ state.isSearching && state.currentRoute.screen === "skills" && !state.modal;
58
+
59
+ // ── Data fetching ─────────────────────────────────────────────────────────
60
+
61
+ const fetchData = useCallback(async () => {
62
+ dispatch({ type: "SKILLS_DATA_LOADING" });
63
+ try {
64
+ const catalog = await fetchCuratedSkillsCatalog();
65
+ setCuratedSkills(catalog.skills);
66
+ setSkillSets((previous) =>
67
+ catalog.skillSets.map((skillSet) => {
68
+ const existing = previous.find((item) => item.repo === skillSet.repo);
69
+ return existing
70
+ ? { ...existing, ...skillSet, id: skillSet.repo }
71
+ : {
72
+ id: skillSet.repo,
73
+ name: skillSet.name,
74
+ description: skillSet.description,
75
+ repo: skillSet.repo,
76
+ icon: skillSet.icon,
77
+ stars: skillSet.stars,
78
+ skills: [],
79
+ loaded: false,
80
+ loading: false,
81
+ };
82
+ }),
83
+ );
84
+ const skills = await fetchAvailableSkills(
85
+ DEFAULT_SKILL_REPOS,
86
+ state.projectPath,
87
+ catalog.skills,
88
+ );
89
+ dispatch({ type: "SKILLS_DATA_SUCCESS", skills });
90
+ } catch (error) {
91
+ dispatch({
92
+ type: "SKILLS_DATA_ERROR",
93
+ error: error instanceof Error ? error : new Error(String(error)),
94
+ });
95
+ }
96
+ }, [dispatch, state.projectPath]);
97
+
98
+ useEffect(() => {
99
+ fetchData();
100
+ }, [fetchData, state.dataRefreshVersion]);
101
+
102
+ // ── Remote search (debounced, cached) ─────────────────────────────────────
103
+
104
+ const [searchResults, setSearchResults] = useState<SkillInfo[]>([]);
105
+ const [isSearchLoading, setIsSearchLoading] = useState(false);
106
+ const [searchError, setSearchError] = useState<string | null>(null);
107
+ const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
108
+ const searchCacheRef = useRef<Map<string, SkillInfo[]>>(new Map());
109
+
110
+ useEffect(() => {
111
+ const query = skillsState.searchQuery.trim();
112
+ if (query.length < 2) {
113
+ setSearchResults([]);
114
+ setIsSearchLoading(false);
115
+ setSearchError(null);
116
+ return;
117
+ }
118
+
119
+ const cached = searchCacheRef.current.get(query);
120
+ if (cached) {
121
+ setSearchResults(cached);
122
+ setIsSearchLoading(false);
123
+ setSearchError(null);
124
+ return;
125
+ }
126
+
127
+ setIsSearchLoading(true);
128
+ setSearchError(null);
129
+
130
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
131
+ searchTimerRef.current = setTimeout(async () => {
132
+ try {
133
+ const results = await searchSkills(query, { limit: 30 });
134
+ const mapped: SkillInfo[] = results.map((r) => {
135
+ const source: SkillSource = {
136
+ label: r.repo || "unknown",
137
+ repo: r.repo || "unknown",
138
+ skillsPath: "",
139
+ };
140
+ return {
141
+ id: `remote:${r.repo}/${r.skillPath}`,
142
+ name: r.name,
143
+ description: r.description || "",
144
+ source,
145
+ repoPath: r.skillPath ? `${r.skillPath}/SKILL.md` : "SKILL.md",
146
+ gitBlobSha: "",
147
+ frontmatter: null,
148
+ installed: false,
149
+ installedScope: null,
150
+ hasUpdate: false,
151
+ stars: r.stars,
152
+ starReliability: classifyStarReliability(
153
+ r.repo || "unknown",
154
+ r.stars,
155
+ ),
156
+ };
157
+ });
158
+ searchCacheRef.current.set(query, mapped);
159
+ setSearchResults(mapped);
160
+ setSearchError(null);
161
+ } catch (error) {
162
+ // A thrown error means the search service is unreachable — distinct
163
+ // from a successful search that returned zero matches.
164
+ setSearchResults([]);
165
+ setSearchError(
166
+ error instanceof Error ? error.message : "Search service unavailable",
167
+ );
168
+ }
169
+ setIsSearchLoading(false);
170
+ }, 400);
171
+
172
+ return () => {
173
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
174
+ };
175
+ }, [skillsState.searchQuery]);
176
+
177
+ // ── Disk scan for installed skills ────────────────────────────────────────
178
+
179
+ const [installedFromDisk, setInstalledFromDisk] = useState<{
180
+ user: Set<string>;
181
+ project: Set<string>;
182
+ }>({ user: new Set(), project: new Set() });
183
+
184
+ const scanDisk = useCallback(async () => {
185
+ const user = new Set<string>();
186
+ const project = new Set<string>();
187
+ const userDir = path.join(os.homedir(), ".claude", "skills");
188
+ const projDir = path.join(
189
+ state.projectPath || process.cwd(),
190
+ ".claude",
191
+ "skills",
192
+ );
193
+ for (const [dir, set] of [
194
+ [userDir, user],
195
+ [projDir, project],
196
+ ] as const) {
197
+ try {
198
+ if (await fs.pathExists(dir)) {
199
+ const entries = await fs.readdir(dir);
200
+ for (const e of entries) {
201
+ if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
202
+ }
203
+ }
204
+ } catch {
205
+ /* ignore */
206
+ }
207
+ }
208
+ setInstalledFromDisk({ user, project });
209
+ }, [state.projectPath]);
210
+
211
+ useEffect(() => {
212
+ scanDisk();
213
+ }, [scanDisk, state.dataRefreshVersion]);
214
+
215
+ // ── Skill Sets state ──────────────────────────────────────────────────────
216
+
217
+ // Re-mark installed status on child skills when disk state changes
218
+ useEffect(() => {
219
+ setSkillSets((prev) =>
220
+ prev.map((set) => {
221
+ if (!set.loaded) return set;
222
+ const updatedSkills = set.skills.map((skill) => {
223
+ const slug = skill.name
224
+ .toLowerCase()
225
+ .replace(/[^a-z0-9]+/g, "-")
226
+ .replace(/^-|-$/g, "");
227
+ const isUser =
228
+ installedFromDisk.user.has(slug) ||
229
+ installedFromDisk.user.has(skill.name);
230
+ const isProj =
231
+ installedFromDisk.project.has(slug) ||
232
+ installedFromDisk.project.has(skill.name);
233
+ return {
234
+ ...skill,
235
+ installed: isUser || isProj,
236
+ installedScope: isProj
237
+ ? ("project" as const)
238
+ : isUser
239
+ ? ("user" as const)
240
+ : null,
241
+ };
242
+ });
243
+ return { ...set, skills: updatedSkills };
244
+ }),
245
+ );
246
+ }, [installedFromDisk]);
247
+
248
+ const handleToggleSet = useCallback(
249
+ async (setId: string) => {
250
+ const isExpanded = expandedSets.has(setId);
251
+ const newExpanded = new Set(expandedSets);
252
+ if (isExpanded) {
253
+ newExpanded.delete(setId);
254
+ } else {
255
+ newExpanded.add(setId);
256
+ }
257
+ setExpandedSets(newExpanded);
258
+
259
+ // Fetch skills on first expand
260
+ const set = skillSets.find((s) => s.id === setId);
261
+ if (!isExpanded && set && !set.loaded && !set.loading) {
262
+ setSkillSets((prev) =>
263
+ prev.map((s) => (s.id === setId ? { ...s, loading: true } : s)),
264
+ );
265
+ try {
266
+ const skills = await fetchSkillSetSkills(set.repo, state.projectPath);
267
+ setSkillSets((prev) =>
268
+ prev.map((s) =>
269
+ s.id === setId
270
+ ? {
271
+ ...s,
272
+ skills,
273
+ loaded: true,
274
+ loading: false,
275
+ error: undefined,
276
+ }
277
+ : s,
278
+ ),
279
+ );
280
+ } catch (error) {
281
+ setSkillSets((prev) =>
282
+ prev.map((s) =>
283
+ s.id === setId
284
+ ? {
285
+ ...s,
286
+ loading: false,
287
+ error:
288
+ error instanceof Error ? error.message : String(error),
289
+ }
290
+ : s,
291
+ ),
292
+ );
293
+ }
294
+ }
295
+ },
296
+ [expandedSets, skillSets, state.projectPath],
297
+ );
298
+
299
+ // Status bar message (auto-clears)
300
+ const [statusMsg, setStatusMsg] = useState<{
301
+ text: string;
302
+ tone: "success" | "error";
303
+ } | null>(null);
304
+ const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
305
+ const showStatus = useCallback(
306
+ (text: string, tone: "success" | "error" = "success") => {
307
+ setStatusMsg({ text, tone });
308
+ if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
309
+ statusTimerRef.current = setTimeout(() => setStatusMsg(null), 3000);
310
+ },
311
+ [],
312
+ );
313
+
314
+ const handleInstallAllFromSet = useCallback(
315
+ async (setId: string, scope: "user" | "project") => {
316
+ const set = skillSets.find((s) => s.id === setId);
317
+ if (!set || !set.loaded) return;
318
+
319
+ const toInstall = set.skills.filter((s) => !s.installed);
320
+ if (toInstall.length === 0) {
321
+ showStatus(`All ${set.name} skills already installed`);
322
+ return;
323
+ }
324
+
325
+ showStatus(`Installing ${toInstall.length} skills from ${set.name}...`);
326
+ let installed = 0;
327
+ let failed = 0;
328
+ let degraded = 0;
329
+ for (const skill of toInstall) {
330
+ try {
331
+ const result = await installSkill(skill, scope, state.projectPath);
332
+ installed++;
333
+ if (result.degraded) degraded++;
334
+ } catch {
335
+ failed++;
336
+ }
337
+ }
338
+ await scanDisk();
339
+ if (failed > 0) {
340
+ showStatus(
341
+ `Installed ${installed}/${toInstall.length} (${failed} failed)`,
342
+ "error",
343
+ );
344
+ } else if (degraded > 0) {
345
+ // Incomplete is not the same as installed. Say so rather than let a
346
+ // batch of half-written skills report a clean success.
347
+ showStatus(
348
+ `Installed ${installed} skills from ${set.name} to ${scope} ${degraded} incomplete (missing supporting files)`,
349
+ "error",
350
+ );
351
+ } else {
352
+ showStatus(
353
+ `Installed ${installed} skills from ${set.name} to ${scope}`,
354
+ );
355
+ }
356
+ },
357
+ [skillSets, state.projectPath, scanDisk, showStatus],
358
+ );
359
+
360
+ // ── Derived data ──────────────────────────────────────────────────────────
361
+
362
+ const staticRecommended = useMemo((): SkillInfo[] => {
363
+ return curatedSkills.map((r) => {
364
+ const slug = r.name
365
+ .toLowerCase()
366
+ .replace(/[^a-z0-9]+/g, "-")
367
+ .replace(/^-|-$/g, "");
368
+ const isUser =
369
+ installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
370
+ const isProj =
371
+ installedFromDisk.project.has(slug) ||
372
+ installedFromDisk.project.has(r.name);
373
+ return {
374
+ id: `rec:${r.repo}/${r.skillPath}`,
375
+ name: r.name,
376
+ description: r.description,
377
+ source: { label: r.repo, repo: r.repo, skillsPath: "" },
378
+ repoPath: `${r.skillPath}/SKILL.md`,
379
+ gitBlobSha: "",
380
+ frontmatter: null,
381
+ installed: isUser || isProj,
382
+ installedScope: isProj ? "project" : isUser ? "user" : null,
383
+ hasUpdate: false,
384
+ isRecommended: true,
385
+ stars: r.stars,
386
+ starReliability: classifyStarReliability(r.repo, r.stars),
387
+ };
388
+ });
389
+ }, [curatedSkills, installedFromDisk]);
390
+
391
+ const mergedRecommended = useMemo((): SkillInfo[] => {
392
+ if (skillsState.skills.status !== "success") return staticRecommended;
393
+ const fetched = skillsState.skills.data.filter((s) => s.isRecommended);
394
+ return staticRecommended.map((staticSkill) => {
395
+ const match = fetched.find(
396
+ (f) =>
397
+ f.source.repo === staticSkill.source.repo &&
398
+ f.name === staticSkill.name,
399
+ );
400
+ if (!match) return staticSkill;
401
+ // Merge: prefer fetched data for description/stars, but disk scan is
402
+ // authoritative for installed state (staticSkill reflects current disk)
403
+ return {
404
+ ...staticSkill,
405
+ ...match,
406
+ installed: staticSkill.installed,
407
+ installedScope: staticSkill.installedScope,
408
+ stars: match.stars || staticSkill.stars,
409
+ };
410
+ });
411
+ }, [staticRecommended, skillsState.skills]);
412
+
413
+ const installedSkills = useMemo((): SkillInfo[] => {
414
+ const all: SkillInfo[] = [];
415
+ for (const [scope, names] of [
416
+ ["user", installedFromDisk.user],
417
+ ["project", installedFromDisk.project],
418
+ ] as const) {
419
+ for (const name of names) {
420
+ if (all.some((s) => s.name === name)) continue;
421
+ all.push({
422
+ id: `installed:${scope}/${name}`,
423
+ name,
424
+ description: "",
425
+ source: { label: "local", repo: "local", skillsPath: "" },
426
+ repoPath: "",
427
+ gitBlobSha: "",
428
+ frontmatter: null,
429
+ installed: true,
430
+ installedScope: scope,
431
+ hasUpdate: false,
432
+ stars: undefined,
433
+ });
434
+ }
435
+ }
436
+ return all;
437
+ }, [installedFromDisk]);
438
+
439
+ const popularSkills = useMemo((): SkillInfo[] => {
440
+ if (skillsState.skills.status !== "success") return [];
441
+ return skillsState.skills.data
442
+ .filter((s) => !s.isRecommended)
443
+ .sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
444
+ }, [skillsState.skills]);
445
+
446
+ // ── List items (built by adapter) ─────────────────────────────────────────
447
+
448
+ const allItems = useMemo(
449
+ () =>
450
+ buildSkillBrowserItems({
451
+ recommended: mergedRecommended,
452
+ popular: popularSkills,
453
+ installed: installedSkills,
454
+ searchResults,
455
+ query: skillsState.searchQuery,
456
+ isSearchLoading,
457
+ skillSets,
458
+ expandedSets,
459
+ }),
460
+ [
461
+ mergedRecommended,
462
+ popularSkills,
463
+ installedSkills,
464
+ searchResults,
465
+ skillsState.searchQuery,
466
+ isSearchLoading,
467
+ skillSets,
468
+ expandedSets,
469
+ ],
470
+ );
471
+
472
+ // Auto-correct selection if it lands on a category header (e.g. initial load, reset)
473
+ useEffect(() => {
474
+ const item = allItems[skillsState.selectedIndex];
475
+ if (item && item.kind === "category") {
476
+ const firstSelectable = allItems.findIndex(
477
+ (i) => i.kind === "skill" || i.kind === "skillset",
478
+ );
479
+ if (firstSelectable >= 0)
480
+ dispatch({ type: "SKILLS_SELECT", index: firstSelectable });
481
+ }
482
+ }, [allItems, skillsState.selectedIndex, dispatch]);
483
+
484
+ const selectedItem: SkillBrowserItem | undefined =
485
+ allItems[skillsState.selectedIndex];
486
+ const selectedSkill =
487
+ selectedItem?.kind === "skill" ? selectedItem.skill : undefined;
488
+ const selectedSet =
489
+ selectedItem?.kind === "skillset" ? selectedItem.skillSet : undefined;
490
+
491
+ // ── Lazy-load frontmatter for selected skill ───────────────────────────────
492
+
493
+ // Check if selected skill belongs to a skill set (lives in local state, not reducer)
494
+ const isSkillSetChild = selectedSkill
495
+ ? skillSets.some(
496
+ (s) => s.loaded && s.skills.some((sk) => sk.id === selectedSkill.id),
497
+ )
498
+ : false;
499
+
500
+ useEffect(() => {
501
+ if (!selectedSkill || selectedSkill.frontmatter) return;
502
+ fetchSkillFrontmatter(selectedSkill)
503
+ .then((fm) => {
504
+ if (isSkillSetChild) {
505
+ // Update the local skillSets state
506
+ setSkillSets((prev) =>
507
+ prev.map((set) => ({
508
+ ...set,
509
+ skills: set.skills.map((sk) =>
510
+ sk.id === selectedSkill.id ? { ...sk, frontmatter: fm } : sk,
511
+ ),
512
+ })),
513
+ );
514
+ } else {
515
+ dispatch({
516
+ type: "SKILLS_UPDATE_ITEM",
517
+ name: selectedSkill.name,
518
+ updates: { frontmatter: fm },
519
+ });
520
+ }
521
+ })
522
+ .catch(() => {});
523
+ }, [selectedSkill?.id, isSkillSetChild, dispatch]);
524
+
525
+ // ── Action handlers ───────────────────────────────────────────────────────
526
+
527
+ const handleInstall = useCallback(
528
+ async (scope: "user" | "project") => {
529
+ if (!selectedSkill) return;
530
+ try {
531
+ const result = await installSkill(
532
+ selectedSkill,
533
+ scope,
534
+ state.projectPath,
535
+ );
536
+ await scanDisk(); // Re-scan disk only no network re-fetch needed
537
+ const files =
538
+ result.fileCount === 1 ? "1 file" : `${result.fileCount} files`;
539
+ // An install that silently dropped supporting files used to report plain
540
+ // success, so a half-installed skill looked identical to a good one.
541
+ if (result.degraded) {
542
+ showStatus(
543
+ `Installed ${selectedSkill.name} to ${scope} (${files}) ${result.degraded}`,
544
+ "error",
545
+ );
546
+ } else {
547
+ showStatus(`Installed ${selectedSkill.name} to ${scope} (${files})`);
548
+ }
549
+ } catch (error) {
550
+ showStatus(`Failed: ${error}`, "error");
551
+ }
552
+ },
553
+ [selectedSkill, state.projectPath, scanDisk, showStatus],
554
+ );
555
+
556
+ const handleUninstall = useCallback(async () => {
557
+ if (!selectedSkill || !selectedSkill.installed) return;
558
+ const scope = selectedSkill.installedScope;
559
+ if (!scope) return;
560
+
561
+ try {
562
+ await uninstallSkill(selectedSkill.name, scope, state.projectPath);
563
+ await scanDisk(); // Re-scan disk only — no network re-fetch needed
564
+ showStatus(`Removed ${selectedSkill.name} from ${scope}`);
565
+ } catch (error) {
566
+ showStatus(`Failed: ${error}`, "error");
567
+ }
568
+ }, [selectedSkill, state.projectPath, scanDisk, showStatus]);
569
+
570
+ // ── Keyboard handling ─────────────────────────────────────────────────────
571
+
572
+ useKeyboard((event) => {
573
+ if (state.modal) return;
574
+
575
+ const hasQuery = skillsState.searchQuery.length > 0;
576
+
577
+ if (event.name === "escape") {
578
+ if (hasQuery || isSearchActive) {
579
+ dispatch({ type: "SKILLS_SET_SEARCH", query: "" });
580
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
581
+ dispatch({ type: "SKILLS_SELECT", index: 0 });
582
+ }
583
+ return;
584
+ }
585
+
586
+ if (event.name === "backspace" || event.name === "delete") {
587
+ if (hasQuery) {
588
+ const newQuery = skillsState.searchQuery.slice(0, -1);
589
+ dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
590
+ dispatch({ type: "SKILLS_SELECT", index: 0 });
591
+ if (!newQuery) dispatch({ type: "SET_SEARCHING", isSearching: false });
592
+ }
593
+ return;
594
+ }
595
+
596
+ if (event.name === "up" || event.name === "k") {
597
+ if (isSearchActive)
598
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
599
+ let newIndex = skillsState.selectedIndex - 1;
600
+ // Skip category headers
601
+ while (newIndex >= 0 && allItems[newIndex]?.kind === "category")
602
+ newIndex--;
603
+ if (newIndex >= 0) dispatch({ type: "SKILLS_SELECT", index: newIndex });
604
+ return;
605
+ }
606
+ if (event.name === "down" || event.name === "j") {
607
+ if (isSearchActive)
608
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
609
+ let newIndex = skillsState.selectedIndex + 1;
610
+ // Skip category headers
611
+ while (
612
+ newIndex < allItems.length &&
613
+ allItems[newIndex]?.kind === "category"
614
+ )
615
+ newIndex++;
616
+ if (newIndex < allItems.length)
617
+ dispatch({ type: "SKILLS_SELECT", index: newIndex });
618
+ return;
619
+ }
620
+
621
+ if (event.name === "return" || event.name === "enter") {
622
+ if (isSearchActive) {
623
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
624
+ return;
625
+ }
626
+ if (selectedSet) {
627
+ handleToggleSet(selectedSet.id);
628
+ return;
629
+ }
630
+ if (selectedSkill && !selectedSkill.installed) {
631
+ handleInstall("project");
632
+ }
633
+ return;
634
+ }
635
+
636
+ // Action keys only when NOT actively typing in search
637
+ if (!isSearchActive) {
638
+ if (event.name === "u") {
639
+ if (selectedSet) {
640
+ handleInstallAllFromSet(selectedSet.id, "user");
641
+ return;
642
+ }
643
+ if (selectedSkill) {
644
+ if (
645
+ selectedSkill.installed &&
646
+ selectedSkill.installedScope === "user"
647
+ )
648
+ handleUninstall();
649
+ else handleInstall("user");
650
+ return;
651
+ }
652
+ } else if (event.name === "p") {
653
+ if (selectedSet) {
654
+ handleInstallAllFromSet(selectedSet.id, "project");
655
+ return;
656
+ }
657
+ if (selectedSkill) {
658
+ if (
659
+ selectedSkill.installed &&
660
+ selectedSkill.installedScope === "project"
661
+ )
662
+ handleUninstall();
663
+ else handleInstall("project");
664
+ return;
665
+ }
666
+ }
667
+ }
668
+
669
+ if (isSearchActive) {
670
+ if (event.name === "k" || event.name === "j") {
671
+ const delta = event.name === "k" ? -1 : 1;
672
+ const newIndex = Math.max(
673
+ 0,
674
+ Math.min(allItems.length - 1, skillsState.selectedIndex + delta),
675
+ );
676
+ dispatch({ type: "SKILLS_SELECT", index: newIndex });
677
+ return;
678
+ }
679
+ if (
680
+ event.name &&
681
+ event.name.length === 1 &&
682
+ !event.ctrl &&
683
+ !event.meta &&
684
+ !/[0-9]/.test(event.name)
685
+ ) {
686
+ dispatch({
687
+ type: "SKILLS_SET_SEARCH",
688
+ query: skillsState.searchQuery + event.name,
689
+ });
690
+ dispatch({ type: "SKILLS_SELECT", index: 0 });
691
+ }
692
+ return;
693
+ }
694
+
695
+ if (event.name === "r") {
696
+ fetchData();
697
+ } else if (event.name === "o" && (selectedSkill || selectedSet)) {
698
+ if (selectedSet) {
699
+ const url = `https://github.com/${selectedSet.repo}`;
700
+ import("node:child_process").then(({ execSync: exec }) => {
701
+ try {
702
+ exec(`open "${url}"`);
703
+ } catch {
704
+ /* ignore */
705
+ }
706
+ });
707
+ } else if (selectedSkill) {
708
+ const repo = selectedSkill.source.repo;
709
+ const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
710
+ if (repo && repo !== "local") {
711
+ const url = `https://github.com/${repo}/tree/main/${repoPath}`;
712
+ import("node:child_process").then(({ execSync: exec }) => {
713
+ try {
714
+ exec(`open "${url}"`);
715
+ } catch {
716
+ /* ignore */
717
+ }
718
+ });
719
+ }
720
+ }
721
+ } else if (event.name === "/") {
722
+ dispatch({ type: "SET_SEARCHING", isSearching: true });
723
+ }
724
+ });
725
+
726
+ // ── Status line ───────────────────────────────────────────────────────────
727
+
728
+ const skills =
729
+ skillsState.skills.status === "success" ? skillsState.skills.data : [];
730
+ const installedCount = skills.filter((s) => s.installed).length;
731
+ const query = skillsState.searchQuery.trim();
732
+
733
+ const statusContent = statusMsg ? (
734
+ <text fg={theme.colors.text}>
735
+ <span fg={statusMsg.tone === "success" ? "green" : "red"}>
736
+ {statusMsg.text}
737
+ </span>
738
+ </text>
739
+ ) : (
740
+ <text fg={theme.colors.text}>
741
+ <span fg={theme.colors.muted}>Skills: </span>
742
+ <span fg={theme.colors.info}>{installedCount} installed</span>
743
+ {query.length >= 2 && isSearchLoading && (
744
+ <span fg={theme.colors.warning}> │ searching...</span>
745
+ )}
746
+ {query.length >= 2 &&
747
+ !isSearchLoading &&
748
+ !searchError &&
749
+ searchResults.length > 0 && (
750
+ <span fg={theme.colors.success}> │ {searchResults.length} found</span>
751
+ )}
752
+ {query.length >= 2 && !isSearchLoading && searchError && (
753
+ <span fg={theme.colors.danger}> │ search unavailable</span>
754
+ )}
755
+ {!query && <span fg={theme.colors.muted}> │ 89K+ searchable</span>}
756
+ </text>
757
+ );
758
+
759
+ // ── Render ────────────────────────────────────────────────────────────────
760
+
761
+ return (
762
+ <ScreenLayout
763
+ title="claudeup Skills"
764
+ currentScreen="skills"
765
+ statusLine={statusContent}
766
+ search={
767
+ skillsState.searchQuery || isSearchActive
768
+ ? {
769
+ isActive: isSearchActive,
770
+ query: skillsState.searchQuery,
771
+ placeholder: "type to search",
772
+ }
773
+ : undefined
774
+ }
775
+ footerHints={
776
+ isSearchActive
777
+ ? [
778
+ { keys: ["type"], label: "filter" },
779
+ { keys: ["Enter"], label: "done" },
780
+ { keys: ["Esc"], label: "clear" },
781
+ ]
782
+ : [
783
+ { keys: ["u"], label: "user" },
784
+ { keys: ["p"], label: "project" },
785
+ { keys: ["o"], label: "open" },
786
+ { keys: ["/"], label: "search" },
787
+ ]
788
+ }
789
+ listPanel={
790
+ <box flexDirection="column">
791
+ <ScrollableList
792
+ items={allItems}
793
+ selectedIndex={skillsState.selectedIndex}
794
+ renderItem={renderSkillRow}
795
+ maxHeight={dimensions.listPanelHeight}
796
+ getKey={(item, index) => `${index}:${item.id}`}
797
+ />
798
+ {!query && skillsState.skills.status === "loading" && (
799
+ <box marginTop={2} paddingLeft={2}>
800
+ <text fg={theme.colors.warning}>Loading popular skills...</text>
801
+ </box>
802
+ )}
803
+ {query.length >= 2 && isSearchLoading && (
804
+ <box marginTop={2} paddingLeft={2}>
805
+ <text fg={theme.colors.warning}>
806
+ Searching for "{skillsState.searchQuery}"...
807
+ </text>
808
+ </box>
809
+ )}
810
+ {query.length >= 2 && !isSearchLoading && searchError && (
811
+ <box
812
+ flexDirection="column"
813
+ marginTop={2}
814
+ paddingLeft={2}
815
+ paddingRight={2}
816
+ >
817
+ <text fg={theme.colors.danger}>
818
+ Skill search is unavailable right now.
819
+ </text>
820
+ <box marginTop={1}>
821
+ <text fg={theme.colors.muted}>
822
+ The search service could not be reached
823
+ </text>
824
+ </box>
825
+ <text fg={theme.colors.muted}>
826
+ ({searchError}). Check your connection and retry.
827
+ </text>
828
+ <box marginTop={1}>
829
+ <text fg={theme.colors.muted}>
830
+ Press Esc to clear the search.
831
+ </text>
832
+ </box>
833
+ </box>
834
+ )}
835
+ {query.length >= 2 &&
836
+ !isSearchLoading &&
837
+ !searchError &&
838
+ searchResults.length === 0 && (
839
+ <EmptyFilterState
840
+ query={skillsState.searchQuery}
841
+ entityName="skills"
842
+ />
843
+ )}
844
+ </box>
845
+ }
846
+ detailPanel={renderSkillDetail(selectedItem)}
847
+ />
848
+ );
705
849
  }
706
850
 
707
851
  export default SkillsScreen;