claudeup 3.13.0 → 3.14.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -901,8 +901,7 @@ export function PluginsScreen() {
901
901
  const matches = item._matches;
902
902
  const segments = matches ? highlightMatches(plugin.name, matches) : null;
903
903
  if (isSelected) {
904
- const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}${hasLocal ? "l" : "."}]`;
905
- return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", scopeStr, " ", plugin.name, versionStr, " "] }));
904
+ return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", _jsx("span", { children: hasUser ? "" : "" }), _jsx("span", { children: hasProject ? "" : "" }), _jsx("span", { children: hasLocal ? "" : "□" }), " ", plugin.name, versionStr, " "] }));
906
905
  }
907
906
  const displayName = segments
908
907
  ? segments.map((seg) => seg.text).join("")
@@ -910,9 +909,9 @@ export function PluginsScreen() {
910
909
  if (plugin.isOrphaned) {
911
910
  const ver = plugin.installedVersion && plugin.installedVersion !== "0.0.0"
912
911
  ? ` v${plugin.installedVersion}` : "";
913
- return (_jsxs("text", { children: [_jsx("span", { fg: "red", children: " [x..] " }), _jsx("span", { fg: "gray", children: displayName }), ver && _jsx("span", { fg: "yellow", children: ver }), _jsx("span", { fg: "red", children: " deprecated" })] }));
912
+ return (_jsxs("text", { children: [_jsx("span", { fg: "red", children: " \u25A0\u25A0\u25A0 " }), _jsx("span", { fg: "gray", children: displayName }), ver && _jsx("span", { fg: "yellow", children: ver }), _jsx("span", { fg: "red", children: " deprecated" })] }));
914
913
  }
915
- return (_jsxs("text", { children: [_jsx("span", { fg: "#555555", children: " [" }), _jsx("span", { fg: hasUser ? "cyan" : "#555555", children: hasUser ? "u" : "." }), _jsx("span", { fg: hasProject ? "green" : "#555555", children: hasProject ? "p" : "." }), _jsx("span", { fg: hasLocal ? "yellow" : "#555555", children: hasLocal ? "l" : "." }), _jsx("span", { fg: "#555555", children: "]" }), _jsx("span", { children: " " }), _jsx("span", { fg: hasAnyScope ? "white" : "gray", children: displayName }), _jsx("span", { fg: plugin.hasUpdate ? "yellow" : "gray", children: versionStr })] }));
914
+ return (_jsxs("text", { children: [_jsx("span", { children: " " }), _jsx("span", { fg: hasUser ? "cyan" : "#333333", children: "\u25A0" }), _jsx("span", { fg: hasProject ? "green" : "#333333", children: "\u25A0" }), _jsx("span", { fg: hasLocal ? "yellow" : "#333333", children: "\u25A0" }), _jsx("span", { children: " " }), _jsx("span", { fg: hasAnyScope ? "white" : "gray", children: displayName }), _jsx("span", { fg: plugin.hasUpdate ? "yellow" : "gray", children: versionStr })] }));
916
915
  }
917
916
  return _jsx("text", { fg: "gray", children: item.label });
918
917
  };
@@ -1162,10 +1162,13 @@ export function PluginsScreen() {
1162
1162
  const segments = matches ? highlightMatches(plugin.name, matches) : null;
1163
1163
 
1164
1164
  if (isSelected) {
1165
- const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}${hasLocal ? "l" : "."}]`;
1166
1165
  return (
1167
1166
  <text bg="magenta" fg="white">
1168
- {" "}{scopeStr} {plugin.name}{versionStr}{" "}
1167
+ {" "}
1168
+ <span>{hasUser ? "■" : "□"}</span>
1169
+ <span>{hasProject ? "■" : "□"}</span>
1170
+ <span>{hasLocal ? "■" : "□"}</span>
1171
+ {" "}{plugin.name}{versionStr}{" "}
1169
1172
  </text>
1170
1173
  );
1171
1174
  }
@@ -1179,7 +1182,7 @@ export function PluginsScreen() {
1179
1182
  ? ` v${plugin.installedVersion}` : "";
1180
1183
  return (
1181
1184
  <text>
1182
- <span fg="red"> [x..] </span>
1185
+ <span fg="red"> ■■■ </span>
1183
1186
  <span fg="gray">{displayName}</span>
1184
1187
  {ver && <span fg="yellow">{ver}</span>}
1185
1188
  <span fg="red"> deprecated</span>
@@ -1189,11 +1192,10 @@ export function PluginsScreen() {
1189
1192
 
1190
1193
  return (
1191
1194
  <text>
1192
- <span fg="#555555"> [</span>
1193
- <span fg={hasUser ? "cyan" : "#555555"}>{hasUser ? "u" : "."}</span>
1194
- <span fg={hasProject ? "green" : "#555555"}>{hasProject ? "p" : "."}</span>
1195
- <span fg={hasLocal ? "yellow" : "#555555"}>{hasLocal ? "l" : "."}</span>
1196
- <span fg="#555555">]</span>
1195
+ <span> </span>
1196
+ <span fg={hasUser ? "cyan" : "#333333"}>■</span>
1197
+ <span fg={hasProject ? "green" : "#333333"}>■</span>
1198
+ <span fg={hasLocal ? "yellow" : "#333333"}>■</span>
1197
1199
  <span> </span>
1198
1200
  <span fg={hasAnyScope ? "white" : "gray"}>{displayName}</span>
1199
1201
  <span fg={plugin.hasUpdate ? "yellow" : "gray"}>{versionStr}</span>
@@ -1,5 +1,8 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
2
  import { useEffect, useCallback, useMemo, useState, useRef } from "react";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import fs from "fs-extra";
3
6
  import { useApp, useModal } from "../state/AppContext.js";
4
7
  import { useDimensions } from "../state/DimensionsContext.js";
5
8
  import { useKeyboard } from "../hooks/useKeyboard.js";
@@ -103,23 +106,53 @@ export function SkillsScreen() {
103
106
  clearTimeout(searchTimerRef.current);
104
107
  };
105
108
  }, [skillsState.searchQuery]);
109
+ // Scan installed skills from disk (instant, no API)
110
+ const [installedFromDisk, setInstalledFromDisk] = useState({ user: new Set(), project: new Set() });
111
+ useEffect(() => {
112
+ async function scanDisk() {
113
+ const user = new Set();
114
+ const project = new Set();
115
+ const userDir = path.join(os.homedir(), ".claude", "skills");
116
+ const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
117
+ for (const [dir, set] of [[userDir, user], [projDir, project]]) {
118
+ try {
119
+ if (await fs.pathExists(dir)) {
120
+ const entries = await fs.readdir(dir);
121
+ for (const e of entries) {
122
+ if (await fs.pathExists(path.join(dir, e, "SKILL.md")))
123
+ set.add(e);
124
+ }
125
+ }
126
+ }
127
+ catch { /* ignore */ }
128
+ }
129
+ setInstalledFromDisk({ user, project });
130
+ }
131
+ scanDisk();
132
+ }, [state.projectPath, state.dataRefreshVersion]);
106
133
  // Static recommended skills — always available, no API needed
134
+ // Enriched with disk-based install status immediately
107
135
  const staticRecommended = useMemo(() => {
108
- return RECOMMENDED_SKILLS.map((r) => ({
109
- id: `rec:${r.repo}/${r.skillPath}`,
110
- name: r.name,
111
- description: r.description,
112
- source: { label: r.repo, repo: r.repo, skillsPath: "" },
113
- repoPath: `${r.skillPath}/SKILL.md`,
114
- gitBlobSha: "",
115
- frontmatter: null,
116
- installed: false,
117
- installedScope: null,
118
- hasUpdate: false,
119
- isRecommended: true,
120
- stars: undefined,
121
- }));
122
- }, []);
136
+ return RECOMMENDED_SKILLS.map((r) => {
137
+ const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
138
+ const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
139
+ const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
140
+ return {
141
+ id: `rec:${r.repo}/${r.skillPath}`,
142
+ name: r.name,
143
+ description: r.description,
144
+ source: { label: r.repo, repo: r.repo, skillsPath: "" },
145
+ repoPath: `${r.skillPath}/SKILL.md`,
146
+ gitBlobSha: "",
147
+ frontmatter: null,
148
+ installed: isUser || isProj,
149
+ installedScope: isProj ? "project" : isUser ? "user" : null,
150
+ hasUpdate: false,
151
+ isRecommended: true,
152
+ stars: undefined,
153
+ };
154
+ });
155
+ }, [installedFromDisk]);
123
156
  // Merge static recommended with fetched data (to get install status + stars)
124
157
  const mergedRecommended = useMemo(() => {
125
158
  if (skillsState.skills.status !== "success")
@@ -131,10 +164,58 @@ export function SkillsScreen() {
131
164
  return match || staticSkill;
132
165
  });
133
166
  }, [staticRecommended, skillsState.skills]);
134
- // Build list: recommended always shown, then search results or popular
167
+ // Build installed skills list from disk (always available)
168
+ const installedSkills = useMemo(() => {
169
+ const all = [];
170
+ for (const [scope, names] of [
171
+ ["user", installedFromDisk.user],
172
+ ["project", installedFromDisk.project],
173
+ ]) {
174
+ for (const name of names) {
175
+ // Skip if already in the list (avoid dupes)
176
+ if (all.some((s) => s.name === name))
177
+ continue;
178
+ all.push({
179
+ id: `installed:${scope}/${name}`,
180
+ name,
181
+ description: "",
182
+ source: { label: "local", repo: "local", skillsPath: "" },
183
+ repoPath: "",
184
+ gitBlobSha: "",
185
+ frontmatter: null,
186
+ installed: true,
187
+ installedScope: scope,
188
+ hasUpdate: false,
189
+ stars: undefined,
190
+ });
191
+ }
192
+ }
193
+ return all;
194
+ }, [installedFromDisk]);
195
+ // Build list: installed first, then recommended, then search/popular
135
196
  const allItems = useMemo(() => {
136
197
  const query = skillsState.searchQuery.toLowerCase();
137
198
  const items = [];
199
+ // ── INSTALLED: always shown at top (if any) ──
200
+ const installedFiltered = query
201
+ ? installedSkills.filter((s) => s.name.toLowerCase().includes(query))
202
+ : installedSkills;
203
+ if (installedFiltered.length > 0) {
204
+ items.push({
205
+ id: "cat:installed",
206
+ type: "category",
207
+ label: `Installed (${installedFiltered.length})`,
208
+ categoryKey: "installed",
209
+ });
210
+ for (const skill of installedFiltered) {
211
+ items.push({
212
+ id: `skill:${skill.id}`,
213
+ type: "skill",
214
+ label: skill.name,
215
+ skill,
216
+ });
217
+ }
218
+ }
138
219
  // ── RECOMMENDED: always shown, filtered when searching ──
139
220
  const filteredRec = query
140
221
  ? mergedRecommended.filter((s) => s.name.toLowerCase().includes(query) ||
@@ -345,6 +426,20 @@ export function SkillsScreen() {
345
426
  else if (event.name === "r") {
346
427
  fetchData();
347
428
  }
429
+ else if (event.name === "o" && selectedSkill) {
430
+ // Open in browser
431
+ const repo = selectedSkill.source.repo;
432
+ const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
433
+ if (repo && repo !== "local") {
434
+ const url = `https://github.com/${repo}/tree/main/${repoPath}`;
435
+ import("node:child_process").then(({ execSync: exec }) => {
436
+ try {
437
+ exec(`open "${url}"`);
438
+ }
439
+ catch { /* ignore */ }
440
+ });
441
+ }
442
+ }
348
443
  // "/" to enter search mode
349
444
  else if (event.name === "/") {
350
445
  dispatch({ type: "SET_SEARCHING", isSearching: true });
@@ -353,8 +448,9 @@ export function SkillsScreen() {
353
448
  const renderListItem = (item, _idx, isSelected) => {
354
449
  if (item.type === "category") {
355
450
  const isRec = item.categoryKey === "recommended";
356
- const bgColor = isRec ? "green" : "cyan";
357
- const star = isRec ? " " : "";
451
+ const isInstalled = item.categoryKey === "installed";
452
+ const bgColor = isInstalled ? "#7e57c2" : isRec ? "#2e7d32" : "#00695c";
453
+ const star = isRec ? "★ " : isInstalled ? "● " : "";
358
454
  if (isSelected) {
359
455
  return (_jsx("text", { bg: "magenta", fg: "white", children: _jsxs("strong", { children: [" ", star, item.label, " "] }) }));
360
456
  }
@@ -367,10 +463,9 @@ export function SkillsScreen() {
367
463
  const hasProject = skill.installedScope === "project";
368
464
  const nameColor = skill.installed ? "white" : "gray";
369
465
  if (isSelected) {
370
- const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}]`;
371
- return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", scopeStr, " ", skill.name, skill.hasUpdate ? " ⬆" : "", starsStr ? ` ${starsStr}` : "", " "] }));
466
+ return (_jsxs("text", { bg: "magenta", fg: "white", children: [" ", _jsx("span", { children: hasUser ? "" : "" }), _jsx("span", { children: hasProject ? "" : "□" }), " ", skill.name, skill.hasUpdate ? " ⬆" : "", starsStr ? ` ${starsStr}` : "", " "] }));
372
467
  }
373
- return (_jsxs("text", { children: [_jsx("span", { fg: "#555555", children: " [" }), _jsx("span", { fg: hasUser ? "cyan" : "#555555", children: hasUser ? "u" : "." }), _jsx("span", { fg: hasProject ? "green" : "#555555", children: hasProject ? "p" : "." }), _jsx("span", { fg: "#555555", children: "] " }), _jsx("span", { fg: nameColor, children: skill.name }), skill.hasUpdate && _jsx("span", { fg: "yellow", children: " \u2B06" }), starsStr && (_jsxs("span", { fg: "yellow", children: [" ", starsStr] }))] }));
468
+ return (_jsxs("text", { children: [_jsx("span", { children: " " }), _jsx("span", { fg: hasUser ? "cyan" : "#333333", children: "\u25A0" }), _jsx("span", { fg: hasProject ? "green" : "#333333", children: "\u25A0" }), _jsx("span", { children: " " }), _jsx("span", { fg: nameColor, children: skill.name }), skill.hasUpdate && _jsx("span", { fg: "yellow", children: " \u2B06" }), starsStr && (_jsxs("span", { fg: "yellow", children: [" ", starsStr] }))] }));
374
469
  }
375
470
  return _jsx("text", { fg: "gray", children: item.label });
376
471
  };
@@ -414,6 +509,6 @@ export function SkillsScreen() {
414
509
  }
415
510
  : undefined, footerHints: isSearchActive
416
511
  ? "type to filter │ Enter:done │ Esc:clear"
417
- : "u:user │ p:project │ d:uninstall │ /:search", listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: selectableItems, selectedIndex: skillsState.selectedIndex, renderItem: renderListItem, maxHeight: dimensions.listPanelHeight }), !query && skillsState.skills.status === "loading" && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsx("text", { fg: "yellow", children: "Loading popular skills..." }) })), query.length >= 2 && isSearchLoading && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsxs("text", { fg: "yellow", children: ["Searching for \"", skillsState.searchQuery, "\"..."] }) })), query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (_jsx(EmptyFilterState, { query: skillsState.searchQuery, entityName: "skills" }))] }), detailPanel: renderDetail() }));
512
+ : "u:user │ p:project │ o:open │ /:search", listPanel: _jsxs("box", { flexDirection: "column", children: [_jsx(ScrollableList, { items: selectableItems, selectedIndex: skillsState.selectedIndex, renderItem: renderListItem, maxHeight: dimensions.listPanelHeight }), !query && skillsState.skills.status === "loading" && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsx("text", { fg: "yellow", children: "Loading popular skills..." }) })), query.length >= 2 && isSearchLoading && (_jsx("box", { marginTop: 2, paddingLeft: 2, children: _jsxs("text", { fg: "yellow", children: ["Searching for \"", skillsState.searchQuery, "\"..."] }) })), query.length >= 2 && !isSearchLoading && searchResults.length === 0 && (_jsx(EmptyFilterState, { query: skillsState.searchQuery, entityName: "skills" }))] }), detailPanel: renderDetail() }));
418
513
  }
419
514
  export default SkillsScreen;
@@ -1,4 +1,7 @@
1
1
  import React, { useEffect, useCallback, useMemo, useState, useRef } from "react";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
2
5
  import { useApp, useModal } from "../state/AppContext.js";
3
6
  import { useDimensions } from "../state/DimensionsContext.js";
4
7
  import { useKeyboard } from "../hooks/useKeyboard.js";
@@ -125,23 +128,56 @@ export function SkillsScreen() {
125
128
  };
126
129
  }, [skillsState.searchQuery]);
127
130
 
131
+ // Scan installed skills from disk (instant, no API)
132
+ const [installedFromDisk, setInstalledFromDisk] = useState<{
133
+ user: Set<string>;
134
+ project: Set<string>;
135
+ }>({ user: new Set(), project: new Set() });
136
+
137
+ useEffect(() => {
138
+ async function scanDisk() {
139
+ const user = new Set<string>();
140
+ const project = new Set<string>();
141
+ const userDir = path.join(os.homedir(), ".claude", "skills");
142
+ const projDir = path.join(state.projectPath || process.cwd(), ".claude", "skills");
143
+ for (const [dir, set] of [[userDir, user], [projDir, project]] as const) {
144
+ try {
145
+ if (await fs.pathExists(dir)) {
146
+ const entries = await fs.readdir(dir);
147
+ for (const e of entries) {
148
+ if (await fs.pathExists(path.join(dir, e, "SKILL.md"))) set.add(e);
149
+ }
150
+ }
151
+ } catch { /* ignore */ }
152
+ }
153
+ setInstalledFromDisk({ user, project });
154
+ }
155
+ scanDisk();
156
+ }, [state.projectPath, state.dataRefreshVersion]);
157
+
128
158
  // Static recommended skills — always available, no API needed
159
+ // Enriched with disk-based install status immediately
129
160
  const staticRecommended = useMemo((): SkillInfo[] => {
130
- return RECOMMENDED_SKILLS.map((r) => ({
131
- id: `rec:${r.repo}/${r.skillPath}`,
132
- name: r.name,
133
- description: r.description,
134
- source: { label: r.repo, repo: r.repo, skillsPath: "" },
135
- repoPath: `${r.skillPath}/SKILL.md`,
136
- gitBlobSha: "",
137
- frontmatter: null,
138
- installed: false,
139
- installedScope: null,
140
- hasUpdate: false,
141
- isRecommended: true,
142
- stars: undefined,
143
- }));
144
- }, []);
161
+ return RECOMMENDED_SKILLS.map((r) => {
162
+ const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
163
+ const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
164
+ const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
165
+ return {
166
+ id: `rec:${r.repo}/${r.skillPath}`,
167
+ name: r.name,
168
+ description: r.description,
169
+ source: { label: r.repo, repo: r.repo, skillsPath: "" },
170
+ repoPath: `${r.skillPath}/SKILL.md`,
171
+ gitBlobSha: "",
172
+ frontmatter: null,
173
+ installed: isUser || isProj,
174
+ installedScope: isProj ? "project" : isUser ? "user" : null,
175
+ hasUpdate: false,
176
+ isRecommended: true,
177
+ stars: undefined,
178
+ };
179
+ });
180
+ }, [installedFromDisk]);
145
181
 
146
182
  // Merge static recommended with fetched data (to get install status + stars)
147
183
  const mergedRecommended = useMemo((): SkillInfo[] => {
@@ -156,11 +192,61 @@ export function SkillsScreen() {
156
192
  });
157
193
  }, [staticRecommended, skillsState.skills]);
158
194
 
159
- // Build list: recommended always shown, then search results or popular
195
+ // Build installed skills list from disk (always available)
196
+ const installedSkills = useMemo((): SkillInfo[] => {
197
+ const all: SkillInfo[] = [];
198
+ for (const [scope, names] of [
199
+ ["user", installedFromDisk.user],
200
+ ["project", installedFromDisk.project],
201
+ ] as const) {
202
+ for (const name of names) {
203
+ // Skip if already in the list (avoid dupes)
204
+ if (all.some((s) => s.name === name)) continue;
205
+ all.push({
206
+ id: `installed:${scope}/${name}`,
207
+ name,
208
+ description: "",
209
+ source: { label: "local", repo: "local", skillsPath: "" },
210
+ repoPath: "",
211
+ gitBlobSha: "",
212
+ frontmatter: null,
213
+ installed: true,
214
+ installedScope: scope,
215
+ hasUpdate: false,
216
+ stars: undefined,
217
+ });
218
+ }
219
+ }
220
+ return all;
221
+ }, [installedFromDisk]);
222
+
223
+ // Build list: installed first, then recommended, then search/popular
160
224
  const allItems = useMemo((): SkillListItem[] => {
161
225
  const query = skillsState.searchQuery.toLowerCase();
162
226
  const items: SkillListItem[] = [];
163
227
 
228
+ // ── INSTALLED: always shown at top (if any) ──
229
+ const installedFiltered = query
230
+ ? installedSkills.filter((s) => s.name.toLowerCase().includes(query))
231
+ : installedSkills;
232
+
233
+ if (installedFiltered.length > 0) {
234
+ items.push({
235
+ id: "cat:installed",
236
+ type: "category",
237
+ label: `Installed (${installedFiltered.length})`,
238
+ categoryKey: "installed",
239
+ });
240
+ for (const skill of installedFiltered) {
241
+ items.push({
242
+ id: `skill:${skill.id}`,
243
+ type: "skill",
244
+ label: skill.name,
245
+ skill,
246
+ });
247
+ }
248
+ }
249
+
164
250
  // ── RECOMMENDED: always shown, filtered when searching ──
165
251
  const filteredRec = query
166
252
  ? mergedRecommended.filter(
@@ -393,6 +479,16 @@ export function SkillsScreen() {
393
479
  handleUninstall();
394
480
  } else if (event.name === "r") {
395
481
  fetchData();
482
+ } else if (event.name === "o" && selectedSkill) {
483
+ // Open in browser
484
+ const repo = selectedSkill.source.repo;
485
+ const repoPath = selectedSkill.repoPath?.replace("/SKILL.md", "") || "";
486
+ if (repo && repo !== "local") {
487
+ const url = `https://github.com/${repo}/tree/main/${repoPath}`;
488
+ import("node:child_process").then(({ execSync: exec }) => {
489
+ try { exec(`open "${url}"`); } catch { /* ignore */ }
490
+ });
491
+ }
396
492
  }
397
493
  // "/" to enter search mode
398
494
  else if (event.name === "/") {
@@ -407,8 +503,9 @@ export function SkillsScreen() {
407
503
  ) => {
408
504
  if (item.type === "category") {
409
505
  const isRec = item.categoryKey === "recommended";
410
- const bgColor = isRec ? "green" : "cyan";
411
- const star = isRec ? " " : "";
506
+ const isInstalled = item.categoryKey === "installed";
507
+ const bgColor = isInstalled ? "#7e57c2" : isRec ? "#2e7d32" : "#00695c";
508
+ const star = isRec ? "★ " : isInstalled ? "● " : "";
412
509
 
413
510
  if (isSelected) {
414
511
  return (
@@ -432,20 +529,22 @@ export function SkillsScreen() {
432
529
  const nameColor = skill.installed ? "white" : "gray";
433
530
 
434
531
  if (isSelected) {
435
- const scopeStr = `[${hasUser ? "u" : "."}${hasProject ? "p" : "."}]`;
436
532
  return (
437
533
  <text bg="magenta" fg="white">
438
- {" "}{scopeStr} {skill.name}{skill.hasUpdate ? " ⬆" : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
534
+ {" "}
535
+ <span>{hasUser ? "■" : "□"}</span>
536
+ <span>{hasProject ? "■" : "□"}</span>
537
+ {" "}{skill.name}{skill.hasUpdate ? " ⬆" : ""}{starsStr ? ` ${starsStr}` : ""}{" "}
439
538
  </text>
440
539
  );
441
540
  }
442
541
 
443
542
  return (
444
543
  <text>
445
- <span fg="#555555"> [</span>
446
- <span fg={hasUser ? "cyan" : "#555555"}>{hasUser ? "u" : "."}</span>
447
- <span fg={hasProject ? "green" : "#555555"}>{hasProject ? "p" : "."}</span>
448
- <span fg="#555555">] </span>
544
+ <span> </span>
545
+ <span fg={hasUser ? "cyan" : "#333333"}>■</span>
546
+ <span fg={hasProject ? "green" : "#333333"}>■</span>
547
+ <span> </span>
449
548
  <span fg={nameColor}>{skill.name}</span>
450
549
  {skill.hasUpdate && <span fg="yellow"> ⬆</span>}
451
550
  {starsStr && (
@@ -684,7 +783,7 @@ export function SkillsScreen() {
684
783
  }
685
784
  footerHints={isSearchActive
686
785
  ? "type to filter │ Enter:done │ Esc:clear"
687
- : "u:user │ p:project │ d:uninstall │ /:search"
786
+ : "u:user │ p:project │ o:open │ /:search"
688
787
  }
689
788
  listPanel={
690
789
  <box flexDirection="column">