claudeup 3.9.0 → 3.11.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.
@@ -1,17 +1,19 @@
1
- import React, { useEffect, useCallback, useMemo } from "react";
1
+ import React, { useEffect, useCallback, useMemo, useState, useRef } from "react";
2
2
  import { useApp, useModal } from "../state/AppContext.js";
3
3
  import { useDimensions } from "../state/DimensionsContext.js";
4
4
  import { useKeyboard } from "../hooks/useKeyboard.js";
5
5
  import { ScreenLayout } from "../components/layout/index.js";
6
6
  import { ScrollableList } from "../components/ScrollableList.js";
7
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
7
8
  import {
8
9
  fetchAvailableSkills,
9
10
  fetchSkillFrontmatter,
10
11
  installSkill,
11
12
  uninstallSkill,
12
13
  } from "../../services/skills-manager.js";
14
+ import { searchSkills } from "../../services/skillsmp-client.js";
13
15
  import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS } from "../../data/skill-repos.js";
14
- import type { SkillInfo } from "../../types/index.js";
16
+ import type { SkillInfo, SkillSource } from "../../types/index.js";
15
17
 
16
18
  interface SkillListItem {
17
19
  id: string;
@@ -21,19 +23,13 @@ interface SkillListItem {
21
23
  categoryKey?: string;
22
24
  }
23
25
 
24
- const CATEGORY_COLORS: Record<string, string> = {
25
- recommended: "#2e7d32",
26
- frontend: "#1565c0",
27
- design: "#6a1b9a",
28
- media: "#e65100",
29
- security: "#b71c1c",
30
- debugging: "#00838f",
31
- database: "#4527a0",
32
- search: "#4e342e",
33
- general: "#333333",
34
- };
35
-
36
- const RECOMMENDED_NAMES = new Set(RECOMMENDED_SKILLS.map((r) => r.name));
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
+ }
37
33
 
38
34
  export function SkillsScreen() {
39
35
  const { state, dispatch } = useApp();
@@ -67,77 +63,184 @@ export function SkillsScreen() {
67
63
  fetchData();
68
64
  }, [fetchData, state.dataRefreshVersion]);
69
65
 
70
- // Build flat list: recommended first (as their own category), then by repo
71
- const allItems = useMemo((): SkillListItem[] => {
72
- if (skillsState.skills.status !== "success") return [];
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
+ }
73
79
 
74
- const skills = skillsState.skills.data;
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[] => {
75
160
  const query = skillsState.searchQuery.toLowerCase();
161
+ const items: SkillListItem[] = [];
76
162
 
77
- const filtered = query
78
- ? skills.filter(
163
+ // ── RECOMMENDED: always shown, filtered when searching ──
164
+ const filteredRec = query
165
+ ? mergedRecommended.filter(
79
166
  (s) =>
80
167
  s.name.toLowerCase().includes(query) ||
81
- s.source.repo.toLowerCase().includes(query) ||
82
- s.frontmatter?.description?.toLowerCase().includes(query),
168
+ (s.description || "").toLowerCase().includes(query),
83
169
  )
84
- : skills;
85
-
86
- const items: SkillListItem[] = [];
87
-
88
- // Recommended section
89
- const recommendedSkills = filtered.filter((s) =>
90
- RECOMMENDED_NAMES.has(
91
- s.name.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
92
- ) || RECOMMENDED_SKILLS.some((r) => r.skillPath === s.name),
93
- );
94
-
95
- if (recommendedSkills.length > 0) {
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) {
96
179
  items.push({
97
- id: "cat:recommended",
98
- type: "category",
99
- label: "Recommended",
100
- categoryKey: "recommended",
180
+ id: `skill:${skill.id}`,
181
+ type: "skill",
182
+ label: skill.name,
183
+ skill,
101
184
  });
102
- for (const skill of recommendedSkills) {
103
- items.push({
104
- id: `skill:${skill.id}`,
105
- type: "skill",
106
- label: skill.name,
107
- skill,
108
- });
109
- }
110
185
  }
111
186
 
112
- // Group remaining skills by repo
113
- const repoMap = new Map<string, SkillInfo[]>();
114
- for (const skill of filtered) {
115
- const isRec = recommendedSkills.includes(skill);
116
- if (isRec) continue;
117
- const existing = repoMap.get(skill.source.repo) || [];
118
- existing.push(skill);
119
- repoMap.set(skill.source.repo, existing);
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;
120
214
  }
121
215
 
122
- for (const [repo, repoSkills] of repoMap) {
123
- items.push({
124
- id: `cat:${repo}`,
125
- type: "category",
126
- label: `${repo} (${repoSkills.length})`,
127
- categoryKey: repo,
128
- });
129
- for (const skill of repoSkills) {
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) {
130
225
  items.push({
131
- id: `skill:${skill.id}`,
132
- type: "skill",
133
- label: skill.name,
134
- skill,
226
+ id: "cat:popular",
227
+ type: "category",
228
+ label: "Popular",
229
+ categoryKey: "popular",
135
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
+ }
136
239
  }
137
240
  }
138
241
 
139
242
  return items;
140
- }, [skillsState.skills, skillsState.searchQuery]);
243
+ }, [skillsState.skills, skillsState.searchQuery, searchResults, isSearchLoading, mergedRecommended]);
141
244
 
142
245
  const selectableItems = useMemo(
143
246
  () => allItems.filter((item) => item.type === "skill" || item.type === "category"),
@@ -219,75 +322,91 @@ export function SkillsScreen() {
219
322
  }
220
323
  }, [selectedSkill, state.projectPath, dispatch, modal]);
221
324
 
222
- // Keyboard handling
325
+ // Keyboard handling — same pattern as PluginsScreen
223
326
  useKeyboard((event) => {
224
327
  if (state.modal) return;
225
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
226
353
  if (event.name === "up" || event.name === "k") {
227
- if (state.isSearching) return;
354
+ if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
228
355
  const newIndex = Math.max(0, skillsState.selectedIndex - 1);
229
356
  dispatch({ type: "SKILLS_SELECT", index: newIndex });
230
- } else if (event.name === "down" || event.name === "j") {
231
- if (state.isSearching) return;
357
+ return;
358
+ }
359
+ if (event.name === "down" || event.name === "j") {
360
+ if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
232
361
  const newIndex = Math.min(
233
362
  Math.max(0, selectableItems.length - 1),
234
363
  skillsState.selectedIndex + 1,
235
364
  );
236
365
  dispatch({ type: "SKILLS_SELECT", index: newIndex });
237
- } else if (event.name === "u") {
238
- if (state.isSearching) return;
239
- if (selectedSkill) {
240
- if (selectedSkill.installed && selectedSkill.installedScope === "user") {
241
- handleUninstall();
242
- } else {
243
- handleInstall("user");
244
- }
245
- }
246
- } else if (event.name === "p") {
247
- if (state.isSearching) return;
248
- if (selectedSkill) {
249
- if (selectedSkill.installed && selectedSkill.installedScope === "project") {
250
- handleUninstall();
251
- } else {
252
- handleInstall("project");
253
- }
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;
254
374
  }
255
- } else if (event.name === "return" || event.name === "enter") {
256
- if (state.isSearching) return;
257
375
  if (selectedSkill && !selectedSkill.installed) {
258
376
  handleInstall("project");
259
377
  }
260
- } else if (event.name === "d") {
261
- if (state.isSearching) return;
262
- if (selectedSkill?.installed) {
263
- handleUninstall();
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;
264
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();
265
405
  } else if (event.name === "r") {
266
- if (state.isSearching) return;
267
406
  fetchData();
268
- } else if (event.name === "escape") {
269
- if (skillsState.searchQuery) {
270
- dispatch({ type: "SKILLS_SET_SEARCH", query: "" });
271
- dispatch({ type: "SET_SEARCHING", isSearching: false });
272
- }
273
- } else if (event.name === "backspace") {
274
- if (skillsState.searchQuery) {
275
- const newQuery = skillsState.searchQuery.slice(0, -1);
276
- dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
277
- if (!newQuery) {
278
- dispatch({ type: "SET_SEARCHING", isSearching: false });
279
- }
280
- }
281
- } else if (
282
- event.name &&
283
- event.name.length === 1 &&
284
- !/[0-9]/.test(event.name) &&
285
- !event.ctrl &&
286
- !event.meta
287
- ) {
288
- // Inline search: type to filter
289
- const newQuery = skillsState.searchQuery + event.name;
290
- dispatch({ type: "SKILLS_SET_SEARCH", query: newQuery });
407
+ }
408
+ // "/" to enter search mode
409
+ else if (event.name === "/") {
291
410
  dispatch({ type: "SET_SEARCHING", isSearching: true });
292
411
  }
293
412
  });
@@ -298,9 +417,8 @@ export function SkillsScreen() {
298
417
  isSelected: boolean,
299
418
  ) => {
300
419
  if (item.type === "category") {
301
- const catKey = item.categoryKey || "general";
302
- const isRec = catKey === "recommended";
303
- const bgColor = CATEGORY_COLORS[catKey] || CATEGORY_COLORS.general;
420
+ const isRec = item.categoryKey === "recommended";
421
+ const bgColor = isRec ? "green" : "cyan";
304
422
  const star = isRec ? "★ " : "";
305
423
 
306
424
  if (isSelected) {
@@ -321,18 +439,13 @@ export function SkillsScreen() {
321
439
  const skill = item.skill;
322
440
  const indicator = skill.installed ? "●" : "○";
323
441
  const indicatorColor = skill.installed ? "cyan" : "gray";
324
- const scopeLabel = skill.installedScope
325
- ? skill.installedScope === "user"
326
- ? "[u]"
327
- : "[p]"
328
- : " ";
329
- const updateBadge = skill.hasUpdate ? "[UPDT] " : "";
442
+ const scopeTag = skill.installedScope === "user" ? "u" : skill.installedScope === "project" ? "p" : "";
443
+ const starsStr = formatStars(skill.stars);
330
444
 
331
445
  if (isSelected) {
332
446
  return (
333
447
  <text bg="magenta" fg="white">
334
- {" "}
335
- {indicator} {scopeLabel} {updateBadge}{skill.name.padEnd(30)}{skill.source.repo}{" "}
448
+ {" "}{indicator} {skill.name}{skill.hasUpdate ? " ⬆" : ""}{scopeTag ? ` [${scopeTag}]` : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
336
449
  </text>
337
450
  );
338
451
  }
@@ -340,15 +453,14 @@ export function SkillsScreen() {
340
453
  return (
341
454
  <text>
342
455
  <span fg={indicatorColor}> {indicator} </span>
343
- <span fg={skill.installedScope === "user" ? "cyan" : skill.installedScope === "project" ? "green" : "gray"}>
344
- {scopeLabel}
345
- </span>
346
- <span> </span>
347
- {skill.hasUpdate && (
348
- <span bg="yellow" fg="black">{updateBadge}</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>
349
463
  )}
350
- <span fg="white">{skill.name.padEnd(30)}</span>
351
- <span fg="gray">{skill.source.repo}</span>
352
464
  </text>
353
465
  );
354
466
  }
@@ -368,9 +480,6 @@ export function SkillsScreen() {
368
480
  <box marginTop={1}>
369
481
  <text fg="gray">{skillsState.skills.error.message}</text>
370
482
  </box>
371
- <box marginTop={1}>
372
- <text fg="gray">Set GITHUB_TOKEN to increase rate limits.</text>
373
- </box>
374
483
  </box>
375
484
  );
376
485
  }
@@ -380,15 +489,44 @@ export function SkillsScreen() {
380
489
  }
381
490
 
382
491
  if (selectedItem.type === "category") {
383
- const catKey = selectedItem.categoryKey || "general";
384
- const color = CATEGORY_COLORS[catKey] ? "green" : "cyan";
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
+
385
521
  return (
386
522
  <box flexDirection="column">
387
- <text fg={color}>
388
- <strong>{selectedItem.label}</strong>
523
+ <text fg={isRec ? "green" : "cyan"}>
524
+ <strong>{isRec ? "★ " : ""}{selectedItem.label}</strong>
389
525
  </text>
390
526
  <box marginTop={1}>
391
- <text fg="gray">Skills in this category</text>
527
+ <text fg="gray">
528
+ {isRec ? "Curated skills recommended for most projects" : "Popular skills sorted by stars"}
529
+ </text>
392
530
  </box>
393
531
  </box>
394
532
  );
@@ -397,17 +535,20 @@ export function SkillsScreen() {
397
535
  if (!selectedSkill) return null;
398
536
 
399
537
  const fm = selectedSkill.frontmatter;
538
+ const description = fm?.description || selectedSkill.description || "Loading...";
400
539
  const scopeColor = selectedSkill.installedScope === "user" ? "cyan" : "green";
540
+ const starsStr = formatStars(selectedSkill.stars);
401
541
 
402
542
  return (
403
543
  <box flexDirection="column">
404
544
  <text fg="cyan">
405
545
  <strong>{selectedSkill.name}</strong>
546
+ {starsStr && <span fg="yellow"> {starsStr}</span>}
406
547
  </text>
407
548
 
408
549
  <box marginTop={1}>
409
550
  <text fg="white">
410
- {fm ? fm.description : "Loading..."}
551
+ {description}
411
552
  </text>
412
553
  </box>
413
554
 
@@ -519,16 +660,20 @@ export function SkillsScreen() {
519
660
  const skills =
520
661
  skillsState.skills.status === "success" ? skillsState.skills.data : [];
521
662
  const installedCount = skills.filter((s) => s.installed).length;
522
- const totalCount = skills.length;
523
- const updateCount = skills.filter((s) => s.hasUpdate).length;
663
+ const query = skillsState.searchQuery.trim();
524
664
 
525
665
  const statusContent = (
526
666
  <text>
527
667
  <span fg="gray">Skills: </span>
528
668
  <span fg="cyan">{installedCount} installed</span>
529
- <span fg="gray"> {totalCount} available</span>
530
- {updateCount > 0 && (
531
- <span fg="yellow"> │ {updateCount} updates</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>
532
677
  )}
533
678
  </text>
534
679
  );
@@ -547,24 +692,32 @@ export function SkillsScreen() {
547
692
  }
548
693
  : undefined
549
694
  }
550
- footerHints="↑↓:nav │ u:user scope │ p:project scope │ Enter:install │ d:uninstall │ type to search"
695
+ footerHints={isSearchActive
696
+ ? "type to filter │ Enter:done │ Esc:clear"
697
+ : "u:user │ p:project │ d:uninstall │ /:search"
698
+ }
551
699
  listPanel={
552
- skillsState.skills.status !== "success" ? (
553
- <text fg="gray">
554
- {skillsState.skills.status === "loading"
555
- ? "Loading skills..."
556
- : skillsState.skills.status === "error"
557
- ? "Error loading skills"
558
- : "Press r to load skills"}
559
- </text>
560
- ) : (
700
+ <box flexDirection="column">
561
701
  <ScrollableList
562
702
  items={selectableItems}
563
703
  selectedIndex={skillsState.selectedIndex}
564
704
  renderItem={renderListItem}
565
705
  maxHeight={dimensions.listPanelHeight}
566
706
  />
567
- )
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>
568
721
  }
569
722
  detailPanel={renderDetail()}
570
723
  />