@polderlabs/bizar 4.4.13 → 4.5.1

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.
Files changed (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -1,31 +1,20 @@
1
- // src/views/Skills.tsx — v3.1.0 skills registry: installed + search.
1
+ // src/views/Skills.tsx — v4.0.0 skills browser: scans all local SKILL.md sources.
2
2
  import { useEffect, useMemo, useState } from 'react';
3
3
  import {
4
4
  Sparkles,
5
5
  Search,
6
6
  RefreshCw,
7
- Download,
8
- Power,
9
- PowerOff,
10
- Code,
11
- Layers,
12
- Wrench,
13
- FlaskConical,
14
- Palette,
15
- Brain,
16
- Map as MapIcon,
17
- GitBranch,
18
- Book,
19
- CheckCircle2,
20
- XCircle,
21
- Tag as TagIcon,
22
7
  ExternalLink,
8
+ XCircle,
9
+ BookOpen,
10
+ Folder,
11
+ Users,
12
+ Package,
23
13
  } from 'lucide-react';
24
14
  import { Button } from '../components/Button';
25
- import { Card, CardTitle, CardMeta } from '../components/Card';
15
+ import { Card } from '../components/Card';
26
16
  import { EmptyState } from '../components/EmptyState';
27
17
  import { Spinner } from '../components/Spinner';
28
- import { StatusBadge } from '../components/StatusBadge';
29
18
  import { useToast } from '../components/Toast';
30
19
  import { useModal } from '../components/Modal';
31
20
  import { api } from '../lib/api';
@@ -41,55 +30,51 @@ type Props = {
41
30
  };
42
31
 
43
32
  type Skill = {
44
- id: string;
45
33
  name: string;
46
34
  description: string;
47
- category: string;
48
- tags: string[];
49
- version: string;
50
- source: string;
51
- path?: string | null;
52
- installed?: boolean;
53
- enabled?: boolean;
54
- mock?: boolean;
55
- installCmd?: string;
35
+ source: 'shipped' | 'user' | 'project';
36
+ path: string;
37
+ body?: string;
38
+ };
39
+
40
+ type Tab = { id: 'all' | 'shipped' | 'user' | 'project'; label: string; icon: typeof Sparkles };
41
+
42
+ const TABS: Tab[] = [
43
+ { id: 'all', label: 'All', icon: Sparkles },
44
+ { id: 'shipped', label: 'Shipped', icon: Package },
45
+ { id: 'user', label: 'User', icon: Users },
46
+ { id: 'project', label: 'Project', icon: Folder },
47
+ ];
48
+
49
+ const SOURCE_LABEL: Record<string, string> = {
50
+ shipped: 'Shipped',
51
+ user: 'User',
52
+ project: 'Project',
56
53
  };
57
54
 
58
- const ICONS: Record<string, typeof Sparkles> = {
59
- all: Sparkles,
60
- languages: Code,
61
- frameworks: Layers,
62
- tools: Wrench,
63
- testing: FlaskConical,
64
- design: Palette,
65
- reasoning: Brain,
66
- planning: MapIcon,
67
- gitops: GitBranch,
68
- docs: Book,
55
+ const SOURCE_ICON: Record<string, typeof Package> = {
56
+ shipped: Package,
57
+ user: Users,
58
+ project: Folder,
69
59
  };
70
60
 
71
61
  export function Skills({ snapshot, refreshSnapshot }: Props) {
72
62
  const toast = useToast();
73
63
  const modal = useModal();
74
- const [skills, setSkills] = useState<Skill[]>([]);
75
- const [categories, setCategories] = useState<{ id: string; label: string; icon: string }[]>([]);
76
- const [loading, setLoading] = useState(true);
77
- const [activeCategory, setActiveCategory] = useState('all');
78
- const [searchQ, setSearchQ] = useState('');
64
+ const [skills, setSkills] = useState<Skill[]>([]);
65
+ const [counts, setCounts] = useState({ shipped: 0, user: 0, project: 0, all: 0 });
66
+ const [loading, setLoading] = useState(true);
67
+ const [activeTab, setActiveTab] = useState<'all' | 'shipped' | 'user' | 'project'>('all');
68
+ const [searchQ, setSearchQ] = useState('');
79
69
  const [searchResults, setSearchResults] = useState<Skill[] | null>(null);
80
70
  const [searching, setSearching] = useState(false);
81
- const [installing, setInstalling] = useState<string | null>(null);
82
- // v3.3.1 — tracks which category <details> elements are open. Default: first 3.
83
- const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
84
- new Set(['all', 'languages', 'frameworks']),
85
- );
86
71
 
87
72
  const reload = async () => {
88
73
  setLoading(true);
89
74
  try {
90
- const d = await api.get<{ skills: Skill[]; categories: { id: string; label: string; icon: string }[] }>('/skills');
75
+ const d = await api.get<{ skills: Skill[]; counts: Record<string, number> }>('/skills');
91
76
  setSkills(d.skills || []);
92
- setCategories(d.categories || []);
77
+ setCounts({ shipped: d.counts?.shipped ?? 0, user: d.counts?.user ?? 0, project: d.counts?.project ?? 0, all: (d.skills || []).length });
93
78
  } catch (err) {
94
79
  toast.error(`Skills load failed: ${(err as Error).message}`);
95
80
  } finally {
@@ -97,11 +82,9 @@ export function Skills({ snapshot, refreshSnapshot }: Props) {
97
82
  }
98
83
  };
99
84
 
100
- useEffect(() => {
101
- reload();
102
- }, []);
85
+ useEffect(() => { reload(); }, []);
103
86
 
104
- // Debounce search.
87
+ // Debounced search
105
88
  useEffect(() => {
106
89
  if (!searchQ.trim()) {
107
90
  setSearchResults(null);
@@ -110,7 +93,9 @@ export function Skills({ snapshot, refreshSnapshot }: Props) {
110
93
  setSearching(true);
111
94
  const id = setTimeout(async () => {
112
95
  try {
113
- const d = await api.get<{ results: Skill[] }>(`/skills/search?q=${encodeURIComponent(searchQ.trim())}`);
96
+ const d = await api.get<{ results: Skill[] }>(
97
+ `/skills/search?q=${encodeURIComponent(searchQ.trim())}`,
98
+ );
114
99
  setSearchResults(d.results || []);
115
100
  } catch (err) {
116
101
  toast.error(`Search failed: ${(err as Error).message}`);
@@ -119,98 +104,89 @@ export function Skills({ snapshot, refreshSnapshot }: Props) {
119
104
  }
120
105
  }, 280);
121
106
  return () => clearTimeout(id);
122
- }, [searchQ, toast]);
107
+ }, [searchQ]);
123
108
 
124
- const filteredInstalled = useMemo(() => {
125
- let out = skills;
126
- if (activeCategory !== 'all') {
127
- out = out.filter((s) => (s.category || 'tools') === activeCategory);
128
- }
129
- return out;
130
- }, [skills, activeCategory]);
109
+ const displayed = useMemo<Skill[]>(() => {
110
+ if (searchResults !== null) return searchResults;
111
+ if (activeTab === 'all') return skills;
112
+ return skills.filter((s) => s.source === activeTab);
113
+ }, [skills, activeTab, searchResults]);
131
114
 
132
- const onInstall = async (s: Skill) => {
133
- if (!confirm(`Install skill "${s.name}"?\n\n${s.installCmd || `skills add ${s.id}`}`)) return;
134
- setInstalling(s.id);
115
+ const onRefresh = async () => {
135
116
  try {
136
- await api.post('/skills/install', { name: s.name, source: s.id });
137
- toast.success(`Installed ${s.name}.`);
138
- setSearchResults((cur) => cur?.filter((x) => x.id !== s.id) || cur);
117
+ await api.post('/skills/refresh', {});
118
+ toast.success('Skills refreshed.');
139
119
  await reload();
140
120
  } catch (err) {
141
- toast.error(`Install failed: ${(err as Error).message}`);
142
- } finally {
143
- setInstalling(null);
121
+ toast.error(`Refresh failed: ${(err as Error).message}`);
144
122
  }
145
123
  };
146
124
 
147
- const onToggle = async (s: Skill) => {
125
+ const onShowDetail = async (skill: Skill) => {
148
126
  try {
149
- if (s.enabled !== false) {
150
- await api.post(`/skills/${encodeURIComponent(s.id)}/disable`);
151
- toast.success(`Disabled ${s.name}.`);
152
- } else {
153
- await api.post(`/skills/${encodeURIComponent(s.id)}/enable`);
154
- toast.success(`Enabled ${s.name}.`);
155
- }
156
- await reload();
157
- } catch (err) {
158
- toast.error(`Toggle failed: ${(err as Error).message}`);
159
- }
160
- };
161
-
162
- const onShowDetails = (s: Skill) => {
163
- let content: React.ReactNode;
164
- content = (
165
- <div className="skill-detail">
166
- <p className="muted text-sm mono skill-detail-source">{s.source}</p>
167
- <p className="skill-detail-desc">{s.description || 'No description.'}</p>
168
- <div className="skill-detail-meta">
169
- <span><strong>Category:</strong> {s.category}</span>
170
- <span><strong>Version:</strong> <code>{s.version}</code></span>
171
- {s.path && <span><strong>Path:</strong> <code>{s.path}</code></span>}
172
- </div>
173
- {s.tags && s.tags.length > 0 && (
174
- <div className="skill-detail-tags">
175
- {s.tags.map((t) => (
176
- <span key={t} className="tag">{t}</span>
177
- ))}
127
+ const full = await api.get<Skill>(
128
+ `/skills/${encodeURIComponent(skill.source)}/${encodeURIComponent(skill.name)}`,
129
+ );
130
+ modal.open({
131
+ title: full.name,
132
+ width: 640,
133
+ children: (
134
+ <div className="skill-detail">
135
+ <div className="skill-detail-meta">
136
+ <span className="skill-source-badge" data-source={full.source}>
137
+ {SOURCE_LABEL[full.source] || full.source}
138
+ </span>
139
+ <code className="mono text-sm muted">{full.path}</code>
140
+ </div>
141
+ {full.description && (
142
+ <p className="skill-detail-desc">{full.description}</p>
143
+ )}
144
+ {full.body && (
145
+ <div className="skill-detail-body">
146
+ <pre className="skill-body-pre">{full.body}</pre>
147
+ </div>
148
+ )}
178
149
  </div>
179
- )}
180
- {s.installCmd && (
181
- <div>
182
- <label className="field-label">Install command</label>
183
- <pre className="skill-detail-cmd mono">{s.installCmd}</pre>
150
+ ),
151
+ footer: (
152
+ <Button variant="ghost" onClick={() => modal.close()}>Close</Button>
153
+ ),
154
+ });
155
+ } catch {
156
+ // Fallback: show what we already have
157
+ modal.open({
158
+ title: skill.name,
159
+ width: 560,
160
+ children: (
161
+ <div className="skill-detail">
162
+ <div className="skill-detail-meta">
163
+ <span className="skill-source-badge" data-source={skill.source}>
164
+ {SOURCE_LABEL[skill.source] || skill.source}
165
+ </span>
166
+ <code className="mono text-sm muted">{skill.path}</code>
167
+ </div>
168
+ {skill.description && (
169
+ <p className="skill-detail-desc">{skill.description}</p>
170
+ )}
184
171
  </div>
185
- )}
186
- </div>
187
- );
188
- modal.open({
189
- title: s.name,
190
- width: 560,
191
- children: content,
192
- footer: (
193
- <div className="modal-footer-actions">
172
+ ),
173
+ footer: (
194
174
  <Button variant="ghost" onClick={() => modal.close()}>Close</Button>
195
- {!s.installed && (
196
- <Button variant="primary" onClick={() => { modal.close(); onInstall(s); }} loading={installing === s.id}>
197
- <Download size={12} /> Install
198
- </Button>
199
- )}
200
- </div>
201
- ),
202
- });
175
+ ),
176
+ });
177
+ }
203
178
  };
204
179
 
205
180
  return (
206
181
  <div className="view view-skills">
182
+ {/* ── Header ─────────────────────────────────────────────────────── */}
207
183
  <header className="view-header">
208
184
  <div className="view-header-text">
209
185
  <h2 className="view-title">
210
186
  <Sparkles size={18} /> Skills
211
187
  </h2>
212
188
  <p className="view-subtitle">
213
- Installed skills + browse the registry. Powered by the <code>skills</code> CLI.
189
+ Browse all available skills shipped, user, and project-local.
214
190
  </p>
215
191
  </div>
216
192
  <div className="view-actions">
@@ -219,7 +195,7 @@ export function Skills({ snapshot, refreshSnapshot }: Props) {
219
195
  <input
220
196
  className="input"
221
197
  type="text"
222
- placeholder="Browse new skills…"
198
+ placeholder="Search skills…"
223
199
  value={searchQ}
224
200
  onChange={(e) => setSearchQ(e.target.value)}
225
201
  />
@@ -229,19 +205,42 @@ export function Skills({ snapshot, refreshSnapshot }: Props) {
229
205
  className="icon-btn"
230
206
  aria-label="Clear search"
231
207
  onClick={() => setSearchQ('')}
232
- title="Clear"
233
208
  >
234
209
  <XCircle size={12} />
235
210
  </button>
236
211
  )}
237
212
  </div>
238
- <Button variant="secondary" size="sm" onClick={reload}>
213
+ <Button variant="secondary" size="sm" onClick={onRefresh}>
239
214
  <RefreshCw size={14} /> Refresh
240
215
  </Button>
241
216
  </div>
242
217
  </header>
243
218
 
244
- {/* v3.3.1 Search results take priority over category view */}
219
+ {/* ── Source tabs ────────────────────────────────────────────────── */}
220
+ {!searchQ.trim() && (
221
+ <div className="skills-tabs">
222
+ {TABS.map((tab) => {
223
+ const Icon = tab.icon;
224
+ const count = tab.id === 'all'
225
+ ? counts.all
226
+ : counts[tab.id as keyof typeof counts] ?? 0;
227
+ return (
228
+ <button
229
+ key={tab.id}
230
+ type="button"
231
+ className={cn('skills-tab', activeTab === tab.id && 'skills-tab-active')}
232
+ onClick={() => setActiveTab(tab.id)}
233
+ >
234
+ <Icon size={14} />
235
+ <span>{tab.label}</span>
236
+ <span className="skills-tab-count">{count}</span>
237
+ </button>
238
+ );
239
+ })}
240
+ </div>
241
+ )}
242
+
243
+ {/* ── Search results ───────────────────────────────────────────── */}
245
244
  {searchQ.trim() && (
246
245
  <section className="skills-section">
247
246
  <h3 className="skills-section-title">
@@ -262,14 +261,12 @@ export function Skills({ snapshot, refreshSnapshot }: Props) {
262
261
  />
263
262
  ) : (
264
263
  <div className="skills-grid">
265
- {(searchResults || []).map((s) => (
264
+ {(searchResults || []).map((s, i) => (
266
265
  <SkillCard
267
- key={`search-${s.id}`}
266
+ key={`search-${i}`}
268
267
  skill={s}
269
- onShow={() => onShowDetails(s)}
270
- onInstall={() => onInstall(s)}
271
- installing={installing === s.id}
272
- installed={!!skills.find((x) => x.id === s.id)}
268
+ onShow={() => onShowDetail(s)}
269
+ searchQ={searchQ}
273
270
  />
274
271
  ))}
275
272
  </div>
@@ -277,155 +274,106 @@ export function Skills({ snapshot, refreshSnapshot }: Props) {
277
274
  </section>
278
275
  )}
279
276
 
280
- {/* v3.3.1 Collapsible category sections */}
277
+ {/* ── Skills grid (tab view) ───────────────────────────────────── */}
281
278
  {!searchQ.trim() && (
282
- <>
283
- {/* Quick category pills */}
284
- <div className="skills-categories">
285
- {categories.map((c) => {
286
- const Icon = ICONS[c.id] || Sparkles;
287
- const count = skills.filter((s) => (s.category || 'tools') === c.id).length;
288
- return (
289
- <button
290
- key={c.id}
291
- type="button"
292
- className={cn('skill-category', activeCategory === c.id && 'skill-category-active')}
293
- onClick={() => setActiveCategory(c.id)}
294
- >
295
- <Icon size={14} />
296
- <span>{c.label}</span>
297
- {count > 0 && <span className="skill-category-count">{count}</span>}
298
- </button>
299
- );
300
- })}
301
- </div>
279
+ <section className="skills-section">
280
+ {loading ? (
281
+ <div className="view-loading"><Spinner size="lg" /></div>
282
+ ) : displayed.length === 0 ? (
283
+ <EmptyState
284
+ icon={<BookOpen size={28} />}
285
+ title="No skills here"
286
+ message={
287
+ activeTab === 'all'
288
+ ? 'No skills found. Install skills with the skills CLI or add SKILL.md files to a skills directory.'
289
+ : `No skills in the ${SOURCE_LABEL[activeTab]} tab.`
290
+ }
291
+ />
292
+ ) : (
293
+ <div className="skills-grid">
294
+ {displayed.map((s, i) => (
295
+ <SkillCard
296
+ key={`${s.source}-${s.name}-${i}`}
297
+ skill={s}
298
+ onShow={() => onShowDetail(s)}
299
+ />
300
+ ))}
301
+ </div>
302
+ )}
303
+ </section>
304
+ )}
302
305
 
303
- {/* Installed skills grouped by category with <details> */}
304
- <section className="skills-section">
305
- <h3 className="skills-section-title">
306
- <CheckCircle2 size={14} /> Installed
307
- </h3>
308
- {loading ? (
309
- <div className="view-loading"><Spinner size="lg" /></div>
310
- ) : (
311
- <div className="skills-categories-list">
312
- {categories.map((cat) => {
313
- const catSkills = filteredInstalled.filter((s) => (s.category || 'tools') === cat.id);
314
- if (catSkills.length === 0) return null;
315
- const isOpen = expandedCategories.has(cat.id);
316
- return (
317
- <details
318
- key={cat.id}
319
- className="skills-category"
320
- open={isOpen}
321
- onToggle={(e) => {
322
- const tgt = e.currentTarget as HTMLDetailsElement;
323
- setExpandedCategories((prev) => {
324
- const next = new Set(prev);
325
- if (tgt.open) next.add(cat.id);
326
- else next.delete(cat.id);
327
- return next;
328
- });
329
- }}
330
- >
331
- <summary className="skills-category-summary">
332
- <span className="skills-category-name">
333
- {ICONS[cat.id] && (() => { const Icon = ICONS[cat.id]; return <Icon size={14} />; })()}
334
- {cat.label}
335
- </span>
336
- <span className="skill-category-count">{catSkills.length}</span>
337
- </summary>
338
- <div className="skills-grid">
339
- {catSkills.map((s) => (
340
- <SkillCard
341
- key={s.id}
342
- skill={s}
343
- onShow={() => onShowDetails(s)}
344
- onToggle={() => onToggle(s)}
345
- />
346
- ))}
347
- </div>
348
- </details>
349
- );
350
- })}
351
- </div>
352
- )}
353
- </section>
354
- </>
306
+ {/* ── Footer ────────────────────────────────────────────────────── */}
307
+ {!searchQ.trim() && (
308
+ <footer className="view-footer">
309
+ <span className="text-sm muted">
310
+ Skills are discovered from{' '}
311
+ <code>~/.opencode/skills/</code>,{' '}
312
+ <code>~/.agents/skills/</code>,{' '}
313
+ <code>bizar-dash/skills/</code>, and project-local directories.
314
+ </span>
315
+ </footer>
355
316
  )}
356
317
  </div>
357
318
  );
358
319
  }
359
320
 
321
+ // ── SkillCard ─────────────────────────────────────────────────────────────────
322
+
360
323
  function SkillCard({
361
324
  skill,
362
325
  onShow,
363
- onInstall,
364
- onToggle,
365
- installing,
366
- installed,
326
+ searchQ = '',
367
327
  }: {
368
328
  skill: Skill;
369
329
  onShow: () => void;
370
- onInstall?: () => void;
371
- onToggle?: () => void;
372
- installing?: boolean;
373
- installed?: boolean;
330
+ searchQ?: string;
374
331
  }) {
375
- const Icon = ICONS[skill.category] || Sparkles;
376
- const enabled = skill.enabled !== false;
332
+ const Icon = SOURCE_ICON[skill.source] || Package;
333
+ const desc = skill.description
334
+ ? skill.description.length > 200
335
+ ? skill.description.slice(0, 200) + '…'
336
+ : skill.description
337
+ : 'No description.';
338
+
377
339
  return (
378
- <Card variant="elevated" interactive className={cn('skill-card', !enabled && 'skill-card-disabled')}>
340
+ <Card variant="elevated" interactive className="skill-card">
379
341
  <div className="skill-card-head">
380
- <div className="skill-card-icon" style={{ background: `color-mix(in srgb, var(--accent) 12%, transparent)` }}>
342
+ <div
343
+ className="skill-card-icon"
344
+ style={{ background: `color-mix(in srgb, var(--accent) 12%, transparent)` }}
345
+ >
381
346
  <Icon size={18} />
382
347
  </div>
383
348
  <div className="skill-card-title-area">
384
- <div className="skill-card-title">{skill.name}</div>
385
- <div className="skill-card-source mono">{skill.source}</div>
349
+ <div className="skill-card-title">
350
+ {highlightMatch(skill.name, searchQ)}
351
+ </div>
352
+ <span className="skill-source-badge" data-source={skill.source}>
353
+ {SOURCE_LABEL[skill.source] || skill.source}
354
+ </span>
386
355
  </div>
387
- {installed ? (
388
- <StatusBadge kind={enabled ? 'success' : 'neutral'}>
389
- {enabled ? 'on' : 'off'}
390
- </StatusBadge>
391
- ) : (
392
- <StatusBadge kind="accent">new</StatusBadge>
393
- )}
394
- </div>
395
- <p className="skill-card-desc">{skill.description || 'No description.'}</p>
396
- <div className="skill-card-meta">
397
- <span className="mono text-sm">v{skill.version}</span>
398
- {skill.category && <span className="text-sm muted">{skill.category}</span>}
399
- {skill.tags && skill.tags.length > 0 && skill.tags.slice(0, 3).map((t) => (
400
- <span key={t} className="skill-card-tag">{t}</span>
401
- ))}
402
356
  </div>
357
+ <p className="skill-card-desc">{desc}</p>
403
358
  <div className="skill-card-actions">
404
359
  <Button variant="ghost" size="sm" onClick={onShow}>
405
- <ExternalLink size={12} /> Details
360
+ <ExternalLink size={12} /> View
406
361
  </Button>
407
- {installed && onToggle && (
408
- <Button
409
- variant={enabled ? 'ghost' : 'secondary'}
410
- size="sm"
411
- onClick={onToggle}
412
- title={enabled ? 'Disable' : 'Enable'}
413
- >
414
- {enabled ? <PowerOff size={12} /> : <Power size={12} />}
415
- {enabled ? 'Disable' : 'Enable'}
416
- </Button>
417
- )}
418
- {!installed && onInstall && (
419
- <Button
420
- variant="primary"
421
- size="sm"
422
- onClick={onInstall}
423
- loading={installing}
424
- >
425
- <Download size={12} /> Install
426
- </Button>
427
- )}
428
362
  </div>
429
363
  </Card>
430
364
  );
431
365
  }
366
+
367
+ /** Simple substring highlight — wraps matched term in <mark>. */
368
+ function highlightMatch(text: string, q: string): React.ReactNode {
369
+ if (!q.trim()) return text;
370
+ const idx = text.toLowerCase().indexOf(q.toLowerCase());
371
+ if (idx < 0) return text;
372
+ return (
373
+ <>
374
+ {text.slice(0, idx)}
375
+ <mark className="skill-highlight">{text.slice(idx, idx + q.length)}</mark>
376
+ {text.slice(idx + q.length)}
377
+ </>
378
+ );
379
+ }