claudeup 3.8.0 → 3.10.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.
@@ -0,0 +1,727 @@
1
+ import React, { useEffect, useCallback, useMemo, useState, useRef } from "react";
2
+ import { useApp, useModal } from "../state/AppContext.js";
3
+ import { useDimensions } from "../state/DimensionsContext.js";
4
+ import { useKeyboard } from "../hooks/useKeyboard.js";
5
+ import { ScreenLayout } from "../components/layout/index.js";
6
+ import { ScrollableList } from "../components/ScrollableList.js";
7
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
8
+ import {
9
+ fetchAvailableSkills,
10
+ fetchSkillFrontmatter,
11
+ installSkill,
12
+ uninstallSkill,
13
+ } from "../../services/skills-manager.js";
14
+ import { searchSkills } from "../../services/skillsmp-client.js";
15
+ import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS } from "../../data/skill-repos.js";
16
+ import type { SkillInfo, SkillSource } from "../../types/index.js";
17
+
18
+ interface SkillListItem {
19
+ id: string;
20
+ type: "category" | "skill";
21
+ label: string;
22
+ skill?: SkillInfo;
23
+ categoryKey?: string;
24
+ }
25
+
26
+ function formatStars(stars?: number): string {
27
+ if (!stars) return "";
28
+ if (stars >= 1000000) return `★ ${(stars / 1000000).toFixed(1)}M`;
29
+ if (stars >= 10000) return `★ ${Math.round(stars / 1000)}K`;
30
+ if (stars >= 1000) return `★ ${(stars / 1000).toFixed(1)}K`;
31
+ return `★ ${stars}`;
32
+ }
33
+
34
+ export function SkillsScreen() {
35
+ const { state, dispatch } = useApp();
36
+ const { skills: skillsState } = state;
37
+ const modal = useModal();
38
+ const dimensions = useDimensions();
39
+
40
+ const isSearchActive =
41
+ state.isSearching &&
42
+ state.currentRoute.screen === "skills" &&
43
+ !state.modal;
44
+
45
+ // Fetch data
46
+ const fetchData = useCallback(async () => {
47
+ dispatch({ type: "SKILLS_DATA_LOADING" });
48
+ try {
49
+ const skills = await fetchAvailableSkills(
50
+ DEFAULT_SKILL_REPOS,
51
+ state.projectPath,
52
+ );
53
+ dispatch({ type: "SKILLS_DATA_SUCCESS", skills });
54
+ } catch (error) {
55
+ dispatch({
56
+ type: "SKILLS_DATA_ERROR",
57
+ error: error instanceof Error ? error : new Error(String(error)),
58
+ });
59
+ }
60
+ }, [dispatch, state.projectPath]);
61
+
62
+ useEffect(() => {
63
+ fetchData();
64
+ }, [fetchData, state.dataRefreshVersion]);
65
+
66
+ // Remote search: query Firebase API when user types (debounced, cached)
67
+ const [searchResults, setSearchResults] = useState<SkillInfo[]>([]);
68
+ const [isSearchLoading, setIsSearchLoading] = useState(false);
69
+ const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
+ const searchCacheRef = useRef<Map<string, SkillInfo[]>>(new Map());
71
+
72
+ useEffect(() => {
73
+ const query = skillsState.searchQuery.trim();
74
+ if (query.length < 2) {
75
+ setSearchResults([]);
76
+ setIsSearchLoading(false);
77
+ return;
78
+ }
79
+
80
+ // Check cache first
81
+ const cached = searchCacheRef.current.get(query);
82
+ if (cached) {
83
+ setSearchResults(cached);
84
+ setIsSearchLoading(false);
85
+ return;
86
+ }
87
+
88
+ setIsSearchLoading(true);
89
+
90
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
91
+ searchTimerRef.current = setTimeout(async () => {
92
+ try {
93
+ const results = await searchSkills(query, { limit: 30 });
94
+ const mapped: SkillInfo[] = results.map((r) => {
95
+ const source: SkillSource = {
96
+ label: r.repo || "unknown",
97
+ repo: r.repo || "unknown",
98
+ skillsPath: "",
99
+ };
100
+ return {
101
+ id: `remote:${r.repo}/${r.skillPath}`,
102
+ name: r.name,
103
+ description: r.description || "",
104
+ source,
105
+ repoPath: r.skillPath ? `${r.skillPath}/SKILL.md` : "SKILL.md",
106
+ gitBlobSha: "",
107
+ frontmatter: null,
108
+ installed: false,
109
+ installedScope: null,
110
+ hasUpdate: false,
111
+ stars: r.stars,
112
+ };
113
+ });
114
+ searchCacheRef.current.set(query, mapped);
115
+ setSearchResults(mapped);
116
+ } catch {
117
+ setSearchResults([]);
118
+ }
119
+ setIsSearchLoading(false);
120
+ }, 400);
121
+
122
+ return () => {
123
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
124
+ };
125
+ }, [skillsState.searchQuery]);
126
+
127
+ // Static recommended skills — always available, no API needed
128
+ const staticRecommended = useMemo((): SkillInfo[] => {
129
+ return RECOMMENDED_SKILLS.map((r) => ({
130
+ id: `rec:${r.repo}/${r.skillPath}`,
131
+ name: r.name,
132
+ description: r.description,
133
+ source: { label: r.repo, repo: r.repo, skillsPath: "" },
134
+ repoPath: `${r.skillPath}/SKILL.md`,
135
+ gitBlobSha: "",
136
+ frontmatter: null,
137
+ installed: false,
138
+ installedScope: null,
139
+ hasUpdate: false,
140
+ isRecommended: true,
141
+ stars: undefined,
142
+ }));
143
+ }, []);
144
+
145
+ // Merge static recommended with fetched data (to get install status + stars)
146
+ const mergedRecommended = useMemo((): SkillInfo[] => {
147
+ if (skillsState.skills.status !== "success") return staticRecommended;
148
+ const fetched = skillsState.skills.data.filter((s) => s.isRecommended);
149
+ // Merge: keep fetched data (has stars + install status), fall back to static
150
+ return staticRecommended.map((staticSkill) => {
151
+ const match = fetched.find(
152
+ (f) => f.source.repo === staticSkill.source.repo && f.name === staticSkill.name,
153
+ );
154
+ return match || staticSkill;
155
+ });
156
+ }, [staticRecommended, skillsState.skills]);
157
+
158
+ // Build list: recommended always shown, then search results or popular
159
+ const allItems = useMemo((): SkillListItem[] => {
160
+ const query = skillsState.searchQuery.toLowerCase();
161
+ const items: SkillListItem[] = [];
162
+
163
+ // ── RECOMMENDED: always shown, filtered when searching ──
164
+ const filteredRec = query
165
+ ? mergedRecommended.filter(
166
+ (s) =>
167
+ s.name.toLowerCase().includes(query) ||
168
+ (s.description || "").toLowerCase().includes(query),
169
+ )
170
+ : mergedRecommended;
171
+
172
+ items.push({
173
+ id: "cat:recommended",
174
+ type: "category",
175
+ label: "Recommended",
176
+ categoryKey: "recommended",
177
+ });
178
+ for (const skill of filteredRec) {
179
+ items.push({
180
+ id: `skill:${skill.id}`,
181
+ type: "skill",
182
+ label: skill.name,
183
+ skill,
184
+ });
185
+ }
186
+
187
+ // ── SEARCH MODE ──
188
+ if (query.length >= 2) {
189
+ // Loading and no-results handled in listPanel, not as list items
190
+
191
+ if (!isSearchLoading && searchResults.length > 0) {
192
+ // Dedup against recommended
193
+ const recNames = new Set(mergedRecommended.map((s) => s.name));
194
+ const deduped = searchResults.filter((s) => !recNames.has(s.name));
195
+ if (deduped.length > 0) {
196
+ items.push({
197
+ id: "cat:search",
198
+ type: "category",
199
+ label: `Search (${deduped.length})`,
200
+ categoryKey: "popular",
201
+ });
202
+ for (const skill of deduped) {
203
+ items.push({
204
+ id: `skill:${skill.id}`,
205
+ type: "skill",
206
+ label: skill.name,
207
+ skill,
208
+ });
209
+ }
210
+ }
211
+ }
212
+ // No-results message handled in listPanel below, not as a list item
213
+ return items;
214
+ }
215
+
216
+ // ── POPULAR (default, no search query) ──
217
+ // Loading state handled in listPanel, not as category header
218
+
219
+ if (skillsState.skills.status === "success") {
220
+ const popularSkills = skillsState.skills.data
221
+ .filter((s) => !s.isRecommended)
222
+ .sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
223
+
224
+ if (popularSkills.length > 0) {
225
+ items.push({
226
+ id: "cat:popular",
227
+ type: "category",
228
+ label: "Popular",
229
+ categoryKey: "popular",
230
+ });
231
+ for (const skill of popularSkills) {
232
+ items.push({
233
+ id: `skill:${skill.id}`,
234
+ type: "skill",
235
+ label: skill.name,
236
+ skill,
237
+ });
238
+ }
239
+ }
240
+ }
241
+
242
+ return items;
243
+ }, [skillsState.skills, skillsState.searchQuery, searchResults, isSearchLoading, mergedRecommended]);
244
+
245
+ const selectableItems = useMemo(
246
+ () => allItems.filter((item) => item.type === "skill" || item.type === "category"),
247
+ [allItems],
248
+ );
249
+
250
+ const selectedItem = selectableItems[skillsState.selectedIndex];
251
+ const selectedSkill = selectedItem?.type === "skill" ? selectedItem.skill : undefined;
252
+
253
+ // Lazy-load frontmatter for selected skill
254
+ useEffect(() => {
255
+ if (!selectedSkill || selectedSkill.frontmatter) return;
256
+
257
+ fetchSkillFrontmatter(selectedSkill).then((fm) => {
258
+ dispatch({
259
+ type: "SKILLS_UPDATE_ITEM",
260
+ name: selectedSkill.name,
261
+ updates: { frontmatter: fm },
262
+ });
263
+ }).catch(() => {});
264
+ }, [selectedSkill?.id, dispatch]);
265
+
266
+ // Install handler
267
+ const handleInstall = useCallback(async (scope: "user" | "project") => {
268
+ if (!selectedSkill) return;
269
+
270
+ modal.loading(`Installing ${selectedSkill.name}...`);
271
+ try {
272
+ await installSkill(selectedSkill, scope, state.projectPath);
273
+ modal.hideModal();
274
+ dispatch({
275
+ type: "SKILLS_UPDATE_ITEM",
276
+ name: selectedSkill.name,
277
+ updates: {
278
+ installed: true,
279
+ installedScope: scope,
280
+ },
281
+ });
282
+ await modal.message(
283
+ "Installed",
284
+ `${selectedSkill.name} installed to ${scope === "user" ? "~/.claude/skills/" : ".claude/skills/"}`,
285
+ "success",
286
+ );
287
+ } catch (error) {
288
+ modal.hideModal();
289
+ await modal.message("Error", `Failed to install: ${error}`, "error");
290
+ }
291
+ }, [selectedSkill, state.projectPath, dispatch, modal]);
292
+
293
+ // Uninstall handler
294
+ const handleUninstall = useCallback(async () => {
295
+ if (!selectedSkill || !selectedSkill.installed) return;
296
+
297
+ const scope = selectedSkill.installedScope;
298
+ if (!scope) return;
299
+
300
+ const confirmed = await modal.confirm(
301
+ `Uninstall "${selectedSkill.name}"?`,
302
+ `This will remove it from the ${scope} scope.`,
303
+ );
304
+ if (!confirmed) return;
305
+
306
+ modal.loading(`Uninstalling ${selectedSkill.name}...`);
307
+ try {
308
+ await uninstallSkill(selectedSkill.name, scope, state.projectPath);
309
+ modal.hideModal();
310
+ dispatch({
311
+ type: "SKILLS_UPDATE_ITEM",
312
+ name: selectedSkill.name,
313
+ updates: {
314
+ installed: false,
315
+ installedScope: null,
316
+ },
317
+ });
318
+ await modal.message("Uninstalled", `${selectedSkill.name} removed.`, "success");
319
+ } catch (error) {
320
+ modal.hideModal();
321
+ await modal.message("Error", `Failed to uninstall: ${error}`, "error");
322
+ }
323
+ }, [selectedSkill, state.projectPath, dispatch, modal]);
324
+
325
+ // Keyboard handling — same pattern as PluginsScreen
326
+ useKeyboard((event) => {
327
+ if (state.modal) return;
328
+
329
+ const hasQuery = skillsState.searchQuery.length > 0;
330
+
331
+ // Escape: clear search
332
+ if (event.name === "escape") {
333
+ if (hasQuery || isSearchActive) {
334
+ dispatch({ type: "SKILLS_SET_SEARCH", query: "" });
335
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
336
+ dispatch({ type: "SKILLS_SELECT", index: 0 });
337
+ }
338
+ return;
339
+ }
340
+
341
+ // Backspace: remove last char
342
+ if (event.name === "backspace" || event.name === "delete") {
343
+ if (hasQuery) {
344
+ const newQuery = skillsState.searchQuery.slice(0, -1);
345
+ dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
346
+ dispatch({ type: "SKILLS_SELECT", index: 0 });
347
+ if (!newQuery) dispatch({ type: "SET_SEARCHING", isSearching: false });
348
+ }
349
+ return;
350
+ }
351
+
352
+ // Navigation — always works; exits search mode on navigate
353
+ if (event.name === "up" || event.name === "k") {
354
+ if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
355
+ const newIndex = Math.max(0, skillsState.selectedIndex - 1);
356
+ dispatch({ type: "SKILLS_SELECT", index: newIndex });
357
+ return;
358
+ }
359
+ if (event.name === "down" || event.name === "j") {
360
+ if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
361
+ const newIndex = Math.min(
362
+ Math.max(0, selectableItems.length - 1),
363
+ skillsState.selectedIndex + 1,
364
+ );
365
+ dispatch({ type: "SKILLS_SELECT", index: newIndex });
366
+ return;
367
+ }
368
+
369
+ // Enter — install (always works)
370
+ if (event.name === "return" || event.name === "enter") {
371
+ if (isSearchActive) {
372
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
373
+ return;
374
+ }
375
+ if (selectedSkill && !selectedSkill.installed) {
376
+ handleInstall("project");
377
+ }
378
+ return;
379
+ }
380
+
381
+ // When actively typing in search, letters go to the query
382
+ if (isSearchActive) {
383
+ if (event.name === "k" || event.name === "j") {
384
+ const delta = event.name === "k" ? -1 : 1;
385
+ const newIndex = Math.max(0, Math.min(selectableItems.length - 1, skillsState.selectedIndex + delta));
386
+ dispatch({ type: "SKILLS_SELECT", index: newIndex });
387
+ return;
388
+ }
389
+ if (event.name && event.name.length === 1 && !event.ctrl && !event.meta && !/[0-9]/.test(event.name)) {
390
+ dispatch({ type: "SKILLS_SET_SEARCH", query: skillsState.searchQuery + event.name });
391
+ dispatch({ type: "SKILLS_SELECT", index: 0 });
392
+ }
393
+ return;
394
+ }
395
+
396
+ // Action shortcuts (work when not actively typing, even with filter visible)
397
+ if (event.name === "u" && selectedSkill) {
398
+ if (selectedSkill.installed && selectedSkill.installedScope === "user") handleUninstall();
399
+ else handleInstall("user");
400
+ } else if (event.name === "p" && selectedSkill) {
401
+ if (selectedSkill.installed && selectedSkill.installedScope === "project") handleUninstall();
402
+ else handleInstall("project");
403
+ } else if (event.name === "d" && selectedSkill?.installed) {
404
+ handleUninstall();
405
+ } else if (event.name === "r") {
406
+ fetchData();
407
+ }
408
+ // "/" to enter search mode
409
+ else if (event.name === "/") {
410
+ dispatch({ type: "SET_SEARCHING", isSearching: true });
411
+ }
412
+ });
413
+
414
+ const renderListItem = (
415
+ item: SkillListItem,
416
+ _idx: number,
417
+ isSelected: boolean,
418
+ ) => {
419
+ if (item.type === "category") {
420
+ const isRec = item.categoryKey === "recommended";
421
+ const bgColor = isRec ? "green" : "cyan";
422
+ const star = isRec ? "★ " : "";
423
+
424
+ if (isSelected) {
425
+ return (
426
+ <text bg="magenta" fg="white">
427
+ <strong> {star}{item.label} </strong>
428
+ </text>
429
+ );
430
+ }
431
+ return (
432
+ <text bg={bgColor} fg="white">
433
+ <strong> {star}{item.label} </strong>
434
+ </text>
435
+ );
436
+ }
437
+
438
+ if (item.type === "skill" && item.skill) {
439
+ const skill = item.skill;
440
+ const indicator = skill.installed ? "●" : "○";
441
+ const indicatorColor = skill.installed ? "cyan" : "gray";
442
+ const scopeTag = skill.installedScope === "user" ? "u" : skill.installedScope === "project" ? "p" : "";
443
+ const starsStr = formatStars(skill.stars);
444
+
445
+ if (isSelected) {
446
+ return (
447
+ <text bg="magenta" fg="white">
448
+ {" "}{indicator} {skill.name}{skill.hasUpdate ? " ⬆" : ""}{scopeTag ? ` [${scopeTag}]` : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
449
+ </text>
450
+ );
451
+ }
452
+
453
+ return (
454
+ <text>
455
+ <span fg={indicatorColor}> {indicator} </span>
456
+ <span fg="white">{skill.name}</span>
457
+ {skill.hasUpdate && <span fg="yellow"> ⬆</span>}
458
+ {scopeTag && (
459
+ <span fg={scopeTag === "u" ? "cyan" : "green"}> [{scopeTag}]</span>
460
+ )}
461
+ {starsStr && (
462
+ <span fg="yellow">{" "}{starsStr}</span>
463
+ )}
464
+ </text>
465
+ );
466
+ }
467
+
468
+ return <text fg="gray">{item.label}</text>;
469
+ };
470
+
471
+ const renderDetail = () => {
472
+ if (skillsState.skills.status === "loading") {
473
+ return <text fg="gray">Loading skills...</text>;
474
+ }
475
+
476
+ if (skillsState.skills.status === "error") {
477
+ return (
478
+ <box flexDirection="column">
479
+ <text fg="red">Failed to load skills</text>
480
+ <box marginTop={1}>
481
+ <text fg="gray">{skillsState.skills.error.message}</text>
482
+ </box>
483
+ </box>
484
+ );
485
+ }
486
+
487
+ if (!selectedItem) {
488
+ return <text fg="gray">Select a skill to see details</text>;
489
+ }
490
+
491
+ if (selectedItem.type === "category") {
492
+ const isRec = selectedItem.categoryKey === "recommended";
493
+ const isNoResults = selectedItem.categoryKey === "no-results";
494
+
495
+ if (isNoResults) {
496
+ return (
497
+ <box flexDirection="column">
498
+ <text fg="yellow">
499
+ <strong>No skills found</strong>
500
+ </text>
501
+ <box marginTop={1}>
502
+ <text fg="gray">
503
+ Nothing matched "{skillsState.searchQuery}".
504
+ </text>
505
+ </box>
506
+ <box marginTop={1}>
507
+ <text fg="gray">
508
+ Try a different search term, or if you think this is a mistake, create an issue at:
509
+ </text>
510
+ </box>
511
+ <box marginTop={1}>
512
+ <text fg="#5c9aff">github.com/MadAppGang/magus/issues</text>
513
+ </box>
514
+ <box marginTop={2}>
515
+ <text fg="gray">Press Esc to clear the search.</text>
516
+ </box>
517
+ </box>
518
+ );
519
+ }
520
+
521
+ return (
522
+ <box flexDirection="column">
523
+ <text fg={isRec ? "green" : "cyan"}>
524
+ <strong>{isRec ? "★ " : ""}{selectedItem.label}</strong>
525
+ </text>
526
+ <box marginTop={1}>
527
+ <text fg="gray">
528
+ {isRec ? "Curated skills recommended for most projects" : "Popular skills sorted by stars"}
529
+ </text>
530
+ </box>
531
+ </box>
532
+ );
533
+ }
534
+
535
+ if (!selectedSkill) return null;
536
+
537
+ const fm = selectedSkill.frontmatter;
538
+ const description = fm?.description || selectedSkill.description || "Loading...";
539
+ const scopeColor = selectedSkill.installedScope === "user" ? "cyan" : "green";
540
+ const starsStr = formatStars(selectedSkill.stars);
541
+
542
+ return (
543
+ <box flexDirection="column">
544
+ <text fg="cyan">
545
+ <strong>{selectedSkill.name}</strong>
546
+ {starsStr && <span fg="yellow"> {starsStr}</span>}
547
+ </text>
548
+
549
+ <box marginTop={1}>
550
+ <text fg="white">
551
+ {description}
552
+ </text>
553
+ </box>
554
+
555
+ {fm?.category && (
556
+ <box marginTop={1}>
557
+ <text>
558
+ <span fg="gray">Category </span>
559
+ <span fg="cyan">{fm.category}</span>
560
+ </text>
561
+ </box>
562
+ )}
563
+
564
+ {fm?.author && (
565
+ <box>
566
+ <text>
567
+ <span fg="gray">Author </span>
568
+ <span>{fm.author}</span>
569
+ </text>
570
+ </box>
571
+ )}
572
+
573
+ {fm?.version && (
574
+ <box>
575
+ <text>
576
+ <span fg="gray">Version </span>
577
+ <span>{fm.version}</span>
578
+ </text>
579
+ </box>
580
+ )}
581
+
582
+ {fm?.tags && fm.tags.length > 0 && (
583
+ <box>
584
+ <text>
585
+ <span fg="gray">Tags </span>
586
+ <span>{fm.tags.join(", ")}</span>
587
+ </text>
588
+ </box>
589
+ )}
590
+
591
+ <box marginTop={1} flexDirection="column">
592
+ <text>
593
+ <span fg="gray">Source </span>
594
+ <span fg="#5c9aff">{selectedSkill.source.repo}</span>
595
+ </text>
596
+ <text>
597
+ <span fg="gray"> </span>
598
+ <span fg="gray">{selectedSkill.repoPath}</span>
599
+ </text>
600
+ </box>
601
+
602
+ {selectedSkill.installed && selectedSkill.installedScope && (
603
+ <box marginTop={1} flexDirection="column">
604
+ <text>{"─".repeat(24)}</text>
605
+ <text>
606
+ <span fg="gray">Installed </span>
607
+ <span fg={scopeColor}>
608
+ {selectedSkill.installedScope === "user"
609
+ ? "~/.claude/skills/"
610
+ : ".claude/skills/"}
611
+ {selectedSkill.name}/
612
+ </span>
613
+ </text>
614
+ </box>
615
+ )}
616
+
617
+ <box marginTop={1} flexDirection="column">
618
+ <text>{"─".repeat(24)}</text>
619
+ <text>
620
+ <strong>Install scope:</strong>
621
+ </text>
622
+ <box marginTop={1} flexDirection="column">
623
+ <text>
624
+ <span bg="cyan" fg="black"> u </span>
625
+ <span fg={selectedSkill.installedScope === "user" ? "cyan" : "gray"}>
626
+ {selectedSkill.installedScope === "user" ? " ● " : " ○ "}
627
+ </span>
628
+ <span fg="cyan">User</span>
629
+ <span fg="gray"> ~/.claude/skills/</span>
630
+ </text>
631
+ <text>
632
+ <span bg="green" fg="black"> p </span>
633
+ <span fg={selectedSkill.installedScope === "project" ? "green" : "gray"}>
634
+ {selectedSkill.installedScope === "project" ? " ● " : " ○ "}
635
+ </span>
636
+ <span fg="green">Project</span>
637
+ <span fg="gray"> .claude/skills/</span>
638
+ </text>
639
+ </box>
640
+ </box>
641
+
642
+ {selectedSkill.hasUpdate && (
643
+ <box marginTop={1}>
644
+ <text bg="yellow" fg="black"> UPDATE AVAILABLE </text>
645
+ </box>
646
+ )}
647
+
648
+ <box marginTop={1} flexDirection="column">
649
+ {!selectedSkill.installed && (
650
+ <text fg="gray">Press u/p to install in scope</text>
651
+ )}
652
+ {selectedSkill.installed && (
653
+ <text fg="gray">Press d to uninstall</text>
654
+ )}
655
+ </box>
656
+ </box>
657
+ );
658
+ };
659
+
660
+ const skills =
661
+ skillsState.skills.status === "success" ? skillsState.skills.data : [];
662
+ const installedCount = skills.filter((s) => s.installed).length;
663
+ const query = skillsState.searchQuery.trim();
664
+
665
+ const statusContent = (
666
+ <text>
667
+ <span fg="gray">Skills: </span>
668
+ <span fg="cyan">{installedCount} installed</span>
669
+ {query.length >= 2 && isSearchLoading && (
670
+ <span fg="yellow"> │ searching...</span>
671
+ )}
672
+ {query.length >= 2 && !isSearchLoading && searchResults.length > 0 && (
673
+ <span fg="green"> │ {searchResults.length} found</span>
674
+ )}
675
+ {!query && (
676
+ <span fg="gray"> │ 89K+ searchable</span>
677
+ )}
678
+ </text>
679
+ );
680
+
681
+ return (
682
+ <ScreenLayout
683
+ title="claudeup Skills"
684
+ currentScreen="skills"
685
+ statusLine={statusContent}
686
+ search={
687
+ skillsState.searchQuery || isSearchActive
688
+ ? {
689
+ isActive: isSearchActive,
690
+ query: skillsState.searchQuery,
691
+ placeholder: "type to search",
692
+ }
693
+ : undefined
694
+ }
695
+ footerHints={isSearchActive
696
+ ? "type to filter │ Enter:done │ Esc:clear"
697
+ : "u:user │ p:project │ d:uninstall │ /:search"
698
+ }
699
+ listPanel={
700
+ <box flexDirection="column">
701
+ <ScrollableList
702
+ items={selectableItems}
703
+ selectedIndex={skillsState.selectedIndex}
704
+ renderItem={renderListItem}
705
+ maxHeight={dimensions.listPanelHeight}
706
+ />
707
+ {!query && skillsState.skills.status === "loading" && (
708
+ <box marginTop={2} paddingLeft={2}>
709
+ <text fg="yellow">Loading popular skills...</text>
710
+ </box>
711
+ )}
712
+ {query.length >= 2 && isSearchLoading && (
713
+ <box marginTop={2} paddingLeft={2}>
714
+ <text fg="yellow">Searching for "{skillsState.searchQuery}"...</text>
715
+ </box>
716
+ )}
717
+ {query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (
718
+ <EmptyFilterState query={skillsState.searchQuery} entityName="skills" />
719
+ )}
720
+ </box>
721
+ }
722
+ detailPanel={renderDetail()}
723
+ />
724
+ );
725
+ }
726
+
727
+ export default SkillsScreen;