claudeup 4.5.4 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/skill-repos.js +56 -0
- package/src/data/skill-repos.ts +70 -1
- package/src/prerunner/index.js +12 -1
- package/src/prerunner/index.ts +14 -0
- package/src/services/claude-settings.js +128 -10
- package/src/services/claude-settings.ts +149 -9
- package/src/services/skills-manager.js +50 -2
- package/src/services/skills-manager.ts +65 -2
- package/src/types/index.ts +29 -0
- package/src/ui/adapters/skillsAdapter.js +57 -9
- package/src/ui/adapters/skillsAdapter.ts +72 -10
- package/src/ui/components/ScrollableList.js +8 -20
- package/src/ui/components/ScrollableList.tsx +16 -29
- package/src/ui/renderers/skillRenderers.js +72 -9
- package/src/ui/renderers/skillRenderers.tsx +176 -11
- package/src/ui/screens/PluginsScreen.js +1 -1
- package/src/ui/screens/PluginsScreen.tsx +1 -0
- package/src/ui/screens/SkillsScreen.js +177 -39
- package/src/ui/screens/SkillsScreen.tsx +199 -34
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
-
import { RECOMMENDED_SKILLS } from "../data/skill-repos.js";
|
|
4
|
+
import { RECOMMENDED_SKILLS, classifyStarReliability } from "../data/skill-repos.js";
|
|
5
5
|
const SKILLS_API_BASE = "https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
6
6
|
const treeCache = new Map();
|
|
7
7
|
// ─── Path helpers ──────────────────────────────────────────────────────────────
|
|
@@ -48,6 +48,51 @@ async function fetchGitTree(repo) {
|
|
|
48
48
|
treeCache.set(repo, { etag, tree, fetchedAt: Date.now() });
|
|
49
49
|
return tree;
|
|
50
50
|
}
|
|
51
|
+
// ─── Skill Set fetching ──────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* Fetch all skills from a skill set repo using the GitHub Tree API.
|
|
54
|
+
* Returns SkillInfo[] with installed status marked via disk scan.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchSkillSetSkills(repo, projectPath) {
|
|
57
|
+
const tree = await fetchGitTree(repo);
|
|
58
|
+
// Find all SKILL.md blobs
|
|
59
|
+
const skillBlobs = tree.tree.filter((entry) => entry.type === "blob" && entry.path.endsWith("/SKILL.md"));
|
|
60
|
+
const userInstalled = await getInstalledSkillNames("user");
|
|
61
|
+
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
62
|
+
const slugify = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
63
|
+
const source = {
|
|
64
|
+
label: repo,
|
|
65
|
+
repo,
|
|
66
|
+
skillsPath: "",
|
|
67
|
+
};
|
|
68
|
+
return skillBlobs.map((blob) => {
|
|
69
|
+
// Derive skill name from parent directory of SKILL.md
|
|
70
|
+
const parts = blob.path.split("/");
|
|
71
|
+
const name = parts[parts.length - 2]; // e.g. "huggingface-datasets"
|
|
72
|
+
const repoPath = blob.path; // e.g. "skills/huggingface-datasets/SKILL.md"
|
|
73
|
+
const slug = slugify(name);
|
|
74
|
+
const isUser = userInstalled.has(slug) || userInstalled.has(name);
|
|
75
|
+
const isProj = projectInstalled.has(slug) || projectInstalled.has(name);
|
|
76
|
+
const installed = isUser || isProj;
|
|
77
|
+
const installedScope = isProj
|
|
78
|
+
? "project"
|
|
79
|
+
: isUser
|
|
80
|
+
? "user"
|
|
81
|
+
: null;
|
|
82
|
+
return {
|
|
83
|
+
id: `${repo}/${blob.path.replace("/SKILL.md", "")}`,
|
|
84
|
+
name,
|
|
85
|
+
source,
|
|
86
|
+
repoPath,
|
|
87
|
+
gitBlobSha: blob.sha,
|
|
88
|
+
frontmatter: null,
|
|
89
|
+
installed,
|
|
90
|
+
installedScope,
|
|
91
|
+
hasUpdate: false,
|
|
92
|
+
description: "",
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
51
96
|
// ─── Frontmatter parser ───────────────────────────────────────────────────────
|
|
52
97
|
function parseYamlFrontmatter(content) {
|
|
53
98
|
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
@@ -134,6 +179,7 @@ function mapApiSkillToSkillInfo(raw) {
|
|
|
134
179
|
repo,
|
|
135
180
|
skillsPath: "",
|
|
136
181
|
};
|
|
182
|
+
const stars = typeof raw.stars === "number" ? raw.stars : undefined;
|
|
137
183
|
return {
|
|
138
184
|
id: `${repo}/${skillPath}`,
|
|
139
185
|
name: raw.name || skillPath,
|
|
@@ -145,7 +191,8 @@ function mapApiSkillToSkillInfo(raw) {
|
|
|
145
191
|
installed: false,
|
|
146
192
|
installedScope: null,
|
|
147
193
|
hasUpdate: false,
|
|
148
|
-
stars
|
|
194
|
+
stars,
|
|
195
|
+
starReliability: classifyStarReliability(repo, stars),
|
|
149
196
|
};
|
|
150
197
|
}
|
|
151
198
|
export async function fetchPopularSkills(limit = 30) {
|
|
@@ -196,6 +243,7 @@ export async function fetchAvailableSkills(_repos, projectPath) {
|
|
|
196
243
|
installedScope: null,
|
|
197
244
|
hasUpdate: false,
|
|
198
245
|
isRecommended: true,
|
|
246
|
+
starReliability: classifyStarReliability(rec.repo, rec.stars),
|
|
199
247
|
};
|
|
200
248
|
return markInstalled(skill);
|
|
201
249
|
});
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
SkillFrontmatter,
|
|
8
8
|
GitTreeResponse,
|
|
9
9
|
} from "../types/index.js";
|
|
10
|
-
import { RECOMMENDED_SKILLS } from "../data/skill-repos.js";
|
|
10
|
+
import { RECOMMENDED_SKILLS, classifyStarReliability } from "../data/skill-repos.js";
|
|
11
11
|
|
|
12
12
|
const SKILLS_API_BASE =
|
|
13
13
|
"https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
@@ -85,6 +85,66 @@ async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
|
|
|
85
85
|
return tree;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ─── Skill Set fetching ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetch all skills from a skill set repo using the GitHub Tree API.
|
|
92
|
+
* Returns SkillInfo[] with installed status marked via disk scan.
|
|
93
|
+
*/
|
|
94
|
+
export async function fetchSkillSetSkills(
|
|
95
|
+
repo: string,
|
|
96
|
+
projectPath?: string,
|
|
97
|
+
): Promise<SkillInfo[]> {
|
|
98
|
+
const tree = await fetchGitTree(repo);
|
|
99
|
+
|
|
100
|
+
// Find all SKILL.md blobs
|
|
101
|
+
const skillBlobs = tree.tree.filter(
|
|
102
|
+
(entry) => entry.type === "blob" && entry.path.endsWith("/SKILL.md"),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const userInstalled = await getInstalledSkillNames("user");
|
|
106
|
+
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
107
|
+
|
|
108
|
+
const slugify = (name: string) =>
|
|
109
|
+
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
110
|
+
|
|
111
|
+
const source: SkillSource = {
|
|
112
|
+
label: repo,
|
|
113
|
+
repo,
|
|
114
|
+
skillsPath: "",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return skillBlobs.map((blob) => {
|
|
118
|
+
// Derive skill name from parent directory of SKILL.md
|
|
119
|
+
const parts = blob.path.split("/");
|
|
120
|
+
const name = parts[parts.length - 2]; // e.g. "huggingface-datasets"
|
|
121
|
+
const repoPath = blob.path; // e.g. "skills/huggingface-datasets/SKILL.md"
|
|
122
|
+
const slug = slugify(name);
|
|
123
|
+
|
|
124
|
+
const isUser = userInstalled.has(slug) || userInstalled.has(name);
|
|
125
|
+
const isProj = projectInstalled.has(slug) || projectInstalled.has(name);
|
|
126
|
+
const installed = isUser || isProj;
|
|
127
|
+
const installedScope: "user" | "project" | null = isProj
|
|
128
|
+
? "project"
|
|
129
|
+
: isUser
|
|
130
|
+
? "user"
|
|
131
|
+
: null;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
id: `${repo}/${blob.path.replace("/SKILL.md", "")}`,
|
|
135
|
+
name,
|
|
136
|
+
source,
|
|
137
|
+
repoPath,
|
|
138
|
+
gitBlobSha: blob.sha,
|
|
139
|
+
frontmatter: null,
|
|
140
|
+
installed,
|
|
141
|
+
installedScope,
|
|
142
|
+
hasUpdate: false,
|
|
143
|
+
description: "",
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
88
148
|
// ─── Frontmatter parser ───────────────────────────────────────────────────────
|
|
89
149
|
|
|
90
150
|
function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
@@ -190,6 +250,7 @@ function mapApiSkillToSkillInfo(raw: any): SkillInfo {
|
|
|
190
250
|
repo,
|
|
191
251
|
skillsPath: "",
|
|
192
252
|
};
|
|
253
|
+
const stars = typeof raw.stars === "number" ? raw.stars : undefined;
|
|
193
254
|
return {
|
|
194
255
|
id: `${repo}/${skillPath}`,
|
|
195
256
|
name: (raw.name as string) || skillPath,
|
|
@@ -201,7 +262,8 @@ function mapApiSkillToSkillInfo(raw: any): SkillInfo {
|
|
|
201
262
|
installed: false,
|
|
202
263
|
installedScope: null,
|
|
203
264
|
hasUpdate: false,
|
|
204
|
-
stars
|
|
265
|
+
stars,
|
|
266
|
+
starReliability: classifyStarReliability(repo, stars),
|
|
205
267
|
};
|
|
206
268
|
}
|
|
207
269
|
|
|
@@ -263,6 +325,7 @@ export async function fetchAvailableSkills(
|
|
|
263
325
|
installedScope: null,
|
|
264
326
|
hasUpdate: false,
|
|
265
327
|
isRecommended: true,
|
|
328
|
+
starReliability: classifyStarReliability(rec.repo, rec.stars),
|
|
266
329
|
};
|
|
267
330
|
return markInstalled(skill);
|
|
268
331
|
});
|
package/src/types/index.ts
CHANGED
|
@@ -168,6 +168,35 @@ export interface SkillInfo {
|
|
|
168
168
|
stars?: number;
|
|
169
169
|
/** Short description (from API, before frontmatter is loaded) */
|
|
170
170
|
description?: string;
|
|
171
|
+
/** How reliable the star count is as a quality signal for this skill */
|
|
172
|
+
starReliability?: "dedicated" | "mega-repo" | "skill-dump";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** How reliable the star count is as a quality signal for this skill */
|
|
176
|
+
export type StarReliability = "dedicated" | "mega-repo" | "skill-dump";
|
|
177
|
+
|
|
178
|
+
/** A skill set — a group of skills from a single repo */
|
|
179
|
+
export interface SkillSetInfo {
|
|
180
|
+
/** Unique key, e.g. "huggingface/skills" */
|
|
181
|
+
id: string;
|
|
182
|
+
/** Display name, e.g. "Hugging Face" */
|
|
183
|
+
name: string;
|
|
184
|
+
/** Repo description */
|
|
185
|
+
description: string;
|
|
186
|
+
/** GitHub owner/repo */
|
|
187
|
+
repo: string;
|
|
188
|
+
/** Icon/emoji for display */
|
|
189
|
+
icon: string;
|
|
190
|
+
/** GitHub star count */
|
|
191
|
+
stars?: number;
|
|
192
|
+
/** Individual skills within this set (fetched lazily via Tree API) */
|
|
193
|
+
skills: SkillInfo[];
|
|
194
|
+
/** Whether skills have been fetched yet */
|
|
195
|
+
loaded: boolean;
|
|
196
|
+
/** Loading state */
|
|
197
|
+
loading: boolean;
|
|
198
|
+
/** Error if fetch failed */
|
|
199
|
+
error?: string;
|
|
171
200
|
}
|
|
172
201
|
|
|
173
202
|
// ─── GitHub Tree API types ────────────────────────────────────────────────────
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Builds the flat list of items for the SkillsScreen list panel.
|
|
3
3
|
* Extracted from SkillsScreen so it can be tested independently.
|
|
4
4
|
*/
|
|
5
|
-
export function buildSkillBrowserItems({ recommended, popular, installed, searchResults, query, isSearchLoading, }) {
|
|
5
|
+
export function buildSkillBrowserItems({ recommended, popular, installed, searchResults, query, isSearchLoading, skillSets = [], expandedSets = new Set(), }) {
|
|
6
6
|
const lowerQuery = query.toLowerCase();
|
|
7
7
|
const items = [];
|
|
8
8
|
// ── INSTALLED: always shown at top (if any) ──
|
|
@@ -18,7 +18,7 @@ export function buildSkillBrowserItems({ recommended, popular, installed, search
|
|
|
18
18
|
categoryKey: "installed",
|
|
19
19
|
count: installedFiltered.length,
|
|
20
20
|
tone: "purple",
|
|
21
|
-
star: "
|
|
21
|
+
star: "\u25CF ",
|
|
22
22
|
});
|
|
23
23
|
for (const skill of installedFiltered) {
|
|
24
24
|
items.push({
|
|
@@ -29,21 +29,60 @@ export function buildSkillBrowserItems({ recommended, popular, installed, search
|
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
// ── RECOMMENDED:
|
|
32
|
+
// ── RECOMMENDED: skill sets + individual skills, all as first-class items ──
|
|
33
|
+
const filteredSets = lowerQuery
|
|
34
|
+
? skillSets.filter((s) => {
|
|
35
|
+
if (s.name.toLowerCase().includes(lowerQuery))
|
|
36
|
+
return true;
|
|
37
|
+
if (s.description.toLowerCase().includes(lowerQuery))
|
|
38
|
+
return true;
|
|
39
|
+
if (s.loaded && s.skills.some((sk) => sk.name.toLowerCase().includes(lowerQuery)))
|
|
40
|
+
return true;
|
|
41
|
+
return false;
|
|
42
|
+
})
|
|
43
|
+
: skillSets;
|
|
33
44
|
const filteredRec = lowerQuery
|
|
34
45
|
? recommended.filter((s) => s.name.toLowerCase().includes(lowerQuery) ||
|
|
35
46
|
(s.description || "").toLowerCase().includes(lowerQuery))
|
|
36
47
|
: recommended;
|
|
48
|
+
const recommendedCount = filteredSets.length + filteredRec.length;
|
|
37
49
|
items.push({
|
|
38
50
|
id: "cat:recommended",
|
|
39
51
|
kind: "category",
|
|
40
52
|
label: "Recommended",
|
|
41
53
|
title: "Recommended",
|
|
42
54
|
categoryKey: "recommended",
|
|
43
|
-
count:
|
|
55
|
+
count: recommendedCount,
|
|
44
56
|
tone: "green",
|
|
45
|
-
star: "
|
|
57
|
+
star: "\u2605 ",
|
|
46
58
|
});
|
|
59
|
+
// Skill sets first within recommended
|
|
60
|
+
for (const set of filteredSets) {
|
|
61
|
+
const isExpanded = expandedSets.has(set.id);
|
|
62
|
+
items.push({
|
|
63
|
+
id: `skillset:${set.id}`,
|
|
64
|
+
kind: "skillset",
|
|
65
|
+
label: set.name,
|
|
66
|
+
skillSet: set,
|
|
67
|
+
expanded: isExpanded,
|
|
68
|
+
});
|
|
69
|
+
// When expanded, show child skills as indented skill items
|
|
70
|
+
if (isExpanded && set.loaded) {
|
|
71
|
+
const childSkills = lowerQuery
|
|
72
|
+
? set.skills.filter((sk) => sk.name.toLowerCase().includes(lowerQuery))
|
|
73
|
+
: set.skills;
|
|
74
|
+
for (const skill of childSkills) {
|
|
75
|
+
items.push({
|
|
76
|
+
id: `skill:${skill.id}`,
|
|
77
|
+
kind: "skill",
|
|
78
|
+
label: skill.name,
|
|
79
|
+
skill,
|
|
80
|
+
indent: 2,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Then individual recommended skills
|
|
47
86
|
for (const skill of filteredRec) {
|
|
48
87
|
items.push({
|
|
49
88
|
id: `skill:${skill.id}`,
|
|
@@ -82,18 +121,27 @@ export function buildSkillBrowserItems({ recommended, popular, installed, search
|
|
|
82
121
|
return items;
|
|
83
122
|
}
|
|
84
123
|
// ── POPULAR (default, no search query) — only skills with meaningful stars ──
|
|
85
|
-
|
|
86
|
-
|
|
124
|
+
// Dedup by name — API can return same skill name from different repos
|
|
125
|
+
const seenPopular = new Set();
|
|
126
|
+
const popularDeduped = popular
|
|
127
|
+
.filter((s) => (s.stars ?? 0) >= 5)
|
|
128
|
+
.filter((s) => {
|
|
129
|
+
if (seenPopular.has(s.name))
|
|
130
|
+
return false;
|
|
131
|
+
seenPopular.add(s.name);
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
if (popularDeduped.length > 0) {
|
|
87
135
|
items.push({
|
|
88
136
|
id: "cat:popular",
|
|
89
137
|
kind: "category",
|
|
90
138
|
label: "Popular",
|
|
91
139
|
title: "Popular",
|
|
92
140
|
categoryKey: "popular",
|
|
93
|
-
count:
|
|
141
|
+
count: popularDeduped.length,
|
|
94
142
|
tone: "teal",
|
|
95
143
|
});
|
|
96
|
-
for (const skill of
|
|
144
|
+
for (const skill of popularDeduped) {
|
|
97
145
|
items.push({
|
|
98
146
|
id: `skill:${skill.id}`,
|
|
99
147
|
kind: "skill",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SkillInfo } from "../../types/index.js";
|
|
1
|
+
import type { SkillInfo, SkillSetInfo } from "../../types/index.js";
|
|
2
2
|
|
|
3
3
|
// ─── Item types ───────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
@@ -19,9 +19,19 @@ export interface SkillSkillItem {
|
|
|
19
19
|
kind: "skill";
|
|
20
20
|
label: string;
|
|
21
21
|
skill: SkillInfo;
|
|
22
|
+
/** Extra indent level for child skills inside an expanded skill set */
|
|
23
|
+
indent?: number;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
export
|
|
26
|
+
export interface SkillSetItem {
|
|
27
|
+
id: string;
|
|
28
|
+
kind: "skillset";
|
|
29
|
+
label: string;
|
|
30
|
+
skillSet: SkillSetInfo;
|
|
31
|
+
expanded: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SkillBrowserItem = SkillCategoryItem | SkillSkillItem | SkillSetItem;
|
|
25
35
|
|
|
26
36
|
// ─── Adapter ─────────────────────────────────────────────────────────────────
|
|
27
37
|
|
|
@@ -32,6 +42,8 @@ export interface BuildSkillBrowserItemsArgs {
|
|
|
32
42
|
searchResults: SkillInfo[];
|
|
33
43
|
query: string;
|
|
34
44
|
isSearchLoading: boolean;
|
|
45
|
+
skillSets?: SkillSetInfo[];
|
|
46
|
+
expandedSets?: Set<string>;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
/**
|
|
@@ -45,6 +57,8 @@ export function buildSkillBrowserItems({
|
|
|
45
57
|
searchResults,
|
|
46
58
|
query,
|
|
47
59
|
isSearchLoading,
|
|
60
|
+
skillSets = [],
|
|
61
|
+
expandedSets = new Set(),
|
|
48
62
|
}: BuildSkillBrowserItemsArgs): SkillBrowserItem[] {
|
|
49
63
|
const lowerQuery = query.toLowerCase();
|
|
50
64
|
const items: SkillBrowserItem[] = [];
|
|
@@ -63,7 +77,7 @@ export function buildSkillBrowserItems({
|
|
|
63
77
|
categoryKey: "installed",
|
|
64
78
|
count: installedFiltered.length,
|
|
65
79
|
tone: "purple",
|
|
66
|
-
star: "
|
|
80
|
+
star: "\u25CF ",
|
|
67
81
|
});
|
|
68
82
|
for (const skill of installedFiltered) {
|
|
69
83
|
items.push({
|
|
@@ -75,7 +89,16 @@ export function buildSkillBrowserItems({
|
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
91
|
|
|
78
|
-
// ── RECOMMENDED:
|
|
92
|
+
// ── RECOMMENDED: skill sets + individual skills, all as first-class items ──
|
|
93
|
+
const filteredSets = lowerQuery
|
|
94
|
+
? skillSets.filter((s) => {
|
|
95
|
+
if (s.name.toLowerCase().includes(lowerQuery)) return true;
|
|
96
|
+
if (s.description.toLowerCase().includes(lowerQuery)) return true;
|
|
97
|
+
if (s.loaded && s.skills.some((sk) => sk.name.toLowerCase().includes(lowerQuery))) return true;
|
|
98
|
+
return false;
|
|
99
|
+
})
|
|
100
|
+
: skillSets;
|
|
101
|
+
|
|
79
102
|
const filteredRec = lowerQuery
|
|
80
103
|
? recommended.filter(
|
|
81
104
|
(s) =>
|
|
@@ -84,16 +107,47 @@ export function buildSkillBrowserItems({
|
|
|
84
107
|
)
|
|
85
108
|
: recommended;
|
|
86
109
|
|
|
110
|
+
const recommendedCount = filteredSets.length + filteredRec.length;
|
|
111
|
+
|
|
87
112
|
items.push({
|
|
88
113
|
id: "cat:recommended",
|
|
89
114
|
kind: "category",
|
|
90
115
|
label: "Recommended",
|
|
91
116
|
title: "Recommended",
|
|
92
117
|
categoryKey: "recommended",
|
|
93
|
-
count:
|
|
118
|
+
count: recommendedCount,
|
|
94
119
|
tone: "green",
|
|
95
|
-
star: "
|
|
120
|
+
star: "\u2605 ",
|
|
96
121
|
});
|
|
122
|
+
|
|
123
|
+
// Skill sets first within recommended
|
|
124
|
+
for (const set of filteredSets) {
|
|
125
|
+
const isExpanded = expandedSets.has(set.id);
|
|
126
|
+
items.push({
|
|
127
|
+
id: `skillset:${set.id}`,
|
|
128
|
+
kind: "skillset",
|
|
129
|
+
label: set.name,
|
|
130
|
+
skillSet: set,
|
|
131
|
+
expanded: isExpanded,
|
|
132
|
+
});
|
|
133
|
+
// When expanded, show child skills as indented skill items
|
|
134
|
+
if (isExpanded && set.loaded) {
|
|
135
|
+
const childSkills = lowerQuery
|
|
136
|
+
? set.skills.filter((sk) => sk.name.toLowerCase().includes(lowerQuery))
|
|
137
|
+
: set.skills;
|
|
138
|
+
for (const skill of childSkills) {
|
|
139
|
+
items.push({
|
|
140
|
+
id: `skill:${skill.id}`,
|
|
141
|
+
kind: "skill",
|
|
142
|
+
label: skill.name,
|
|
143
|
+
skill,
|
|
144
|
+
indent: 2,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Then individual recommended skills
|
|
97
151
|
for (const skill of filteredRec) {
|
|
98
152
|
items.push({
|
|
99
153
|
id: `skill:${skill.id}`,
|
|
@@ -135,18 +189,26 @@ export function buildSkillBrowserItems({
|
|
|
135
189
|
}
|
|
136
190
|
|
|
137
191
|
// ── POPULAR (default, no search query) — only skills with meaningful stars ──
|
|
138
|
-
|
|
139
|
-
|
|
192
|
+
// Dedup by name — API can return same skill name from different repos
|
|
193
|
+
const seenPopular = new Set<string>();
|
|
194
|
+
const popularDeduped = popular
|
|
195
|
+
.filter((s) => (s.stars ?? 0) >= 5)
|
|
196
|
+
.filter((s) => {
|
|
197
|
+
if (seenPopular.has(s.name)) return false;
|
|
198
|
+
seenPopular.add(s.name);
|
|
199
|
+
return true;
|
|
200
|
+
});
|
|
201
|
+
if (popularDeduped.length > 0) {
|
|
140
202
|
items.push({
|
|
141
203
|
id: "cat:popular",
|
|
142
204
|
kind: "category",
|
|
143
205
|
label: "Popular",
|
|
144
206
|
title: "Popular",
|
|
145
207
|
categoryKey: "popular",
|
|
146
|
-
count:
|
|
208
|
+
count: popularDeduped.length,
|
|
147
209
|
tone: "teal",
|
|
148
210
|
});
|
|
149
|
-
for (const skill of
|
|
211
|
+
for (const skill of popularDeduped) {
|
|
150
212
|
items.push({
|
|
151
213
|
id: `skill:${skill.id}`,
|
|
152
214
|
kind: "skill",
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useMemo } from "react";
|
|
3
|
-
|
|
4
|
-
export function ScrollableList({ items, selectedIndex, renderItem, maxHeight, showScrollIndicators = true, onSelect, focused = false, }) {
|
|
3
|
+
export function ScrollableList({ items, selectedIndex, renderItem, maxHeight, showScrollIndicators = true, getKey, }) {
|
|
5
4
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
6
|
-
// Handle keyboard navigation
|
|
7
|
-
useKeyboardHandler((input, key) => {
|
|
8
|
-
if (!focused || !onSelect)
|
|
9
|
-
return;
|
|
10
|
-
if (key.upArrow || input === "k") {
|
|
11
|
-
const newIndex = Math.max(0, selectedIndex - 1);
|
|
12
|
-
onSelect(newIndex);
|
|
13
|
-
}
|
|
14
|
-
else if (key.downArrow || input === "j") {
|
|
15
|
-
const newIndex = Math.min(items.length - 1, selectedIndex + 1);
|
|
16
|
-
onSelect(newIndex);
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
// Account for scroll indicators in available space
|
|
20
5
|
const hasItemsAbove = scrollOffset > 0;
|
|
21
6
|
const hasItemsBelow = scrollOffset + maxHeight < items.length;
|
|
22
7
|
const indicatorLines = (showScrollIndicators && hasItemsAbove ? 1 : 0) +
|
|
@@ -25,15 +10,18 @@ export function ScrollableList({ items, selectedIndex, renderItem, maxHeight, sh
|
|
|
25
10
|
// Adjust scroll offset to keep selected item visible
|
|
26
11
|
useEffect(() => {
|
|
27
12
|
if (selectedIndex < scrollOffset) {
|
|
28
|
-
// Selected is above viewport - scroll up
|
|
29
13
|
setScrollOffset(selectedIndex);
|
|
30
14
|
}
|
|
31
15
|
else if (selectedIndex >= scrollOffset + effectiveMaxHeight) {
|
|
32
|
-
// Selected is below viewport - scroll down
|
|
33
16
|
setScrollOffset(selectedIndex - effectiveMaxHeight + 1);
|
|
34
17
|
}
|
|
35
18
|
}, [selectedIndex, effectiveMaxHeight, scrollOffset]);
|
|
36
|
-
//
|
|
19
|
+
// Reset scroll when items change drastically (e.g. search)
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (scrollOffset >= items.length) {
|
|
22
|
+
setScrollOffset(Math.max(0, items.length - effectiveMaxHeight));
|
|
23
|
+
}
|
|
24
|
+
}, [items.length, scrollOffset, effectiveMaxHeight]);
|
|
37
25
|
const visibleItems = useMemo(() => {
|
|
38
26
|
const start = scrollOffset;
|
|
39
27
|
const end = Math.min(scrollOffset + effectiveMaxHeight, items.length);
|
|
@@ -43,6 +31,6 @@ export function ScrollableList({ items, selectedIndex, renderItem, maxHeight, sh
|
|
|
43
31
|
}));
|
|
44
32
|
}, [items, scrollOffset, effectiveMaxHeight]);
|
|
45
33
|
const itemsBelow = items.length - scrollOffset - effectiveMaxHeight;
|
|
46
|
-
return (_jsxs("box", { flexDirection: "column", children: [showScrollIndicators && hasItemsAbove && (_jsxs("text", { fg: "cyan", children: ["\u2191 ", scrollOffset, " more"] })), visibleItems.map(({ item, originalIndex }) => (_jsx("box", { width: "100%", overflow: "hidden", children: renderItem(item, originalIndex, originalIndex === selectedIndex) }, originalIndex))), showScrollIndicators && hasItemsBelow && (_jsxs("text", { fg: "cyan", children: ["\u2193 ", itemsBelow, " more"] }))] }));
|
|
34
|
+
return (_jsxs("box", { flexDirection: "column", children: [showScrollIndicators && hasItemsAbove && (_jsxs("text", { fg: "cyan", children: ["\u2191 ", scrollOffset, " more"] })), visibleItems.map(({ item, originalIndex }) => (_jsx("box", { width: "100%", overflow: "hidden", children: renderItem(item, originalIndex, originalIndex === selectedIndex) }, getKey ? getKey(item, originalIndex) : `${originalIndex}`))), showScrollIndicators && hasItemsBelow && (_jsxs("text", { fg: "cyan", children: ["\u2193 ", itemsBelow, " more"] }))] }));
|
|
47
35
|
}
|
|
48
36
|
export default ScrollableList;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo } from "react";
|
|
2
|
-
import { useKeyboardHandler } from "../hooks/useKeyboardHandler";
|
|
3
2
|
|
|
4
3
|
interface ScrollableListProps<T> {
|
|
5
4
|
/** Array of items to display */
|
|
@@ -8,14 +7,12 @@ interface ScrollableListProps<T> {
|
|
|
8
7
|
selectedIndex: number;
|
|
9
8
|
/** Render function for each item */
|
|
10
9
|
renderItem: (item: T, index: number, isSelected: boolean) => React.ReactNode;
|
|
11
|
-
/** Maximum visible height (number of lines)
|
|
10
|
+
/** Maximum visible height (number of lines) */
|
|
12
11
|
maxHeight: number;
|
|
13
12
|
/** Show scroll indicators */
|
|
14
13
|
showScrollIndicators?: boolean;
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/** Whether this list should receive keyboard input */
|
|
18
|
-
focused?: boolean;
|
|
14
|
+
/** Item key extractor */
|
|
15
|
+
getKey?: (item: T, index: number) => string;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
export function ScrollableList<T>({
|
|
@@ -24,25 +21,10 @@ export function ScrollableList<T>({
|
|
|
24
21
|
renderItem,
|
|
25
22
|
maxHeight,
|
|
26
23
|
showScrollIndicators = true,
|
|
27
|
-
|
|
28
|
-
focused = false,
|
|
24
|
+
getKey,
|
|
29
25
|
}: ScrollableListProps<T>) {
|
|
30
26
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
31
27
|
|
|
32
|
-
// Handle keyboard navigation
|
|
33
|
-
useKeyboardHandler((input, key) => {
|
|
34
|
-
if (!focused || !onSelect) return;
|
|
35
|
-
|
|
36
|
-
if (key.upArrow || input === "k") {
|
|
37
|
-
const newIndex = Math.max(0, selectedIndex - 1);
|
|
38
|
-
onSelect(newIndex);
|
|
39
|
-
} else if (key.downArrow || input === "j") {
|
|
40
|
-
const newIndex = Math.min(items.length - 1, selectedIndex + 1);
|
|
41
|
-
onSelect(newIndex);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
// Account for scroll indicators in available space
|
|
46
28
|
const hasItemsAbove = scrollOffset > 0;
|
|
47
29
|
const hasItemsBelow = scrollOffset + maxHeight < items.length;
|
|
48
30
|
const indicatorLines =
|
|
@@ -53,15 +35,19 @@ export function ScrollableList<T>({
|
|
|
53
35
|
// Adjust scroll offset to keep selected item visible
|
|
54
36
|
useEffect(() => {
|
|
55
37
|
if (selectedIndex < scrollOffset) {
|
|
56
|
-
// Selected is above viewport - scroll up
|
|
57
38
|
setScrollOffset(selectedIndex);
|
|
58
39
|
} else if (selectedIndex >= scrollOffset + effectiveMaxHeight) {
|
|
59
|
-
// Selected is below viewport - scroll down
|
|
60
40
|
setScrollOffset(selectedIndex - effectiveMaxHeight + 1);
|
|
61
41
|
}
|
|
62
42
|
}, [selectedIndex, effectiveMaxHeight, scrollOffset]);
|
|
63
43
|
|
|
64
|
-
//
|
|
44
|
+
// Reset scroll when items change drastically (e.g. search)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (scrollOffset >= items.length) {
|
|
47
|
+
setScrollOffset(Math.max(0, items.length - effectiveMaxHeight));
|
|
48
|
+
}
|
|
49
|
+
}, [items.length, scrollOffset, effectiveMaxHeight]);
|
|
50
|
+
|
|
65
51
|
const visibleItems = useMemo(() => {
|
|
66
52
|
const start = scrollOffset;
|
|
67
53
|
const end = Math.min(scrollOffset + effectiveMaxHeight, items.length);
|
|
@@ -75,19 +61,20 @@ export function ScrollableList<T>({
|
|
|
75
61
|
|
|
76
62
|
return (
|
|
77
63
|
<box flexDirection="column">
|
|
78
|
-
{/* Scroll up indicator */}
|
|
79
64
|
{showScrollIndicators && hasItemsAbove && (
|
|
80
65
|
<text fg="cyan">↑ {scrollOffset} more</text>
|
|
81
66
|
)}
|
|
82
67
|
|
|
83
|
-
{/* Visible items - strictly limited */}
|
|
84
68
|
{visibleItems.map(({ item, originalIndex }) => (
|
|
85
|
-
<box
|
|
69
|
+
<box
|
|
70
|
+
key={getKey ? getKey(item, originalIndex) : `${originalIndex}`}
|
|
71
|
+
width="100%"
|
|
72
|
+
overflow="hidden"
|
|
73
|
+
>
|
|
86
74
|
{renderItem(item, originalIndex, originalIndex === selectedIndex)}
|
|
87
75
|
</box>
|
|
88
76
|
))}
|
|
89
77
|
|
|
90
|
-
{/* Scroll down indicator */}
|
|
91
78
|
{showScrollIndicators && hasItemsBelow && (
|
|
92
79
|
<text fg="cyan">↓ {itemsBelow} more</text>
|
|
93
80
|
)}
|