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.
@@ -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: typeof raw.stars === "number" ? raw.stars : undefined,
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: typeof raw.stars === "number" ? raw.stars : undefined,
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
  });
@@ -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: always shown, filtered when searching ──
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: filteredRec.length,
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
- const popularWithStars = popular.filter((s) => (s.stars ?? 0) >= 5);
86
- if (popularWithStars.length > 0) {
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: popularWithStars.length,
141
+ count: popularDeduped.length,
94
142
  tone: "teal",
95
143
  });
96
- for (const skill of popularWithStars) {
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 type SkillBrowserItem = SkillCategoryItem | SkillSkillItem;
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: always shown, filtered when searching ──
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: filteredRec.length,
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
- const popularWithStars = popular.filter((s) => (s.stars ?? 0) >= 5);
139
- if (popularWithStars.length > 0) {
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: popularWithStars.length,
208
+ count: popularDeduped.length,
147
209
  tone: "teal",
148
210
  });
149
- for (const skill of popularWithStars) {
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
- import { useKeyboardHandler } from "../hooks/useKeyboardHandler";
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
- // Calculate visible items - strictly limited to effectiveMaxHeight
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) - REQUIRED for proper rendering */
10
+ /** Maximum visible height (number of lines) */
12
11
  maxHeight: number;
13
12
  /** Show scroll indicators */
14
13
  showScrollIndicators?: boolean;
15
- /** Called when selection changes (arrow keys) */
16
- onSelect?: (index: number) => void;
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
- onSelect,
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
- // Calculate visible items - strictly limited to effectiveMaxHeight
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 key={originalIndex} width="100%" overflow="hidden">
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
  )}