agentcraft 0.0.4 → 0.0.5

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,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcraft",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Assign sounds to AI coding agent lifecycle events. Works with Claude Code and OpenCode.",
5
5
  "author": { "name": "rohenaz" },
6
6
  "keywords": ["sounds", "hooks", "audio", "productivity", "opencode", "claude-code"]
package/TODO.md ADDED
@@ -0,0 +1,29 @@
1
+ # AgentCraft TODO
2
+
3
+ ## Features
4
+
5
+ ### Push-to-Talk / Dictation Mute Key
6
+ Assign a configurable hotkey (default: Fn key) that suppresses sound playback while held.
7
+ Use case: PTT dictation apps (Whisper, SuperWhisper, etc.) use a held key to record. Sounds
8
+ firing during active recording interrupt or bleed into the mic capture.
9
+
10
+ **Design notes:**
11
+ - Settings UI: "MUTE WHILE HELD" key picker in the dashboard (default `Fn`, allow any key)
12
+ - Stored in `assignments.json` under `settings.muteKey` (e.g. `"Fn"`, `"CapsLock"`, `"F13"`)
13
+ - Detection: the hook script can't read keyboard state directly. Options:
14
+ 1. **Lock file approach** — dashboard JS writes `/tmp/agentcraft-muted.lock` on `keydown`
15
+ and removes it on `keyup`. Hook script checks for file existence before playing.
16
+ Requires the dashboard to be open (reasonable — it's a config tool).
17
+ 2. **Daemon approach** — small background process (bun script) listens to keyboard events
18
+ via IOKit/CGEvent on macOS and manages the lock file. Survives without dashboard open.
19
+ 3. **Passive detection** — read `/proc/bus/input` on Linux or `hidutil` output on macOS
20
+ to poll key state at hook execution time (complex, likely too slow).
21
+ - Recommended: start with option 1 (lock file via dashboard), add option 2 as optional daemon.
22
+ - The `play-sound.sh` check would be: `[ -f "/tmp/agentcraft-muted.lock" ] && exit 0`
23
+ - Dashboard global keydown/keyup listeners write/remove the lock file via a small API route
24
+ (`POST /api/mute { active: boolean }`)
25
+
26
+ ### Sound Browser SOURCE Dropdown (multi-pack filter)
27
+ Gemini Option A: compact SOURCE dropdown above group tabs, invisible at 1 pack, appears at 2+.
28
+ Filters the sound browser to a specific pack or shows all with pack badges on cards.
29
+ See: conversation history / Gemini recommendation for full spec.
package/bin/agentcraft.js CHANGED
@@ -2,7 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const { execSync, spawnSync } = require('child_process');
5
- const { existsSync, readdirSync, rmSync, statSync, mkdirSync, readFileSync, copyFileSync, realpathSync } = require('fs');
5
+ const { existsSync, readdirSync, rmSync, statSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, realpathSync } = require('fs');
6
6
  const { join, dirname } = require('path');
7
7
  const { homedir } = require('os');
8
8
 
@@ -139,6 +139,105 @@ function packInit() {
139
139
  console.log(` Browse packs: ${c.cyan('agentcraft list')}`);
140
140
  }
141
141
 
142
+ function createPack(name) {
143
+ if (!name) {
144
+ console.error(c.red('Usage: agentcraft create-pack <name>'));
145
+ console.error(c.dim(' Example: agentcraft create-pack my-sounds'));
146
+ process.exit(1);
147
+ }
148
+ if (existsSync(name)) {
149
+ console.error(c.red(`✗ Directory already exists: ${name}`));
150
+ process.exit(1);
151
+ }
152
+
153
+ // Try to detect GitHub username from git config
154
+ let publisher = 'your-github-username';
155
+ try {
156
+ const gitUser = execSync('git config --global user.name 2>/dev/null', { encoding: 'utf-8' }).trim();
157
+ const gitEmail = execSync('git config --global github.user 2>/dev/null', { encoding: 'utf-8' }).trim();
158
+ if (gitEmail) publisher = gitEmail;
159
+ else if (gitUser) publisher = gitUser.toLowerCase().replace(/\s+/g, '-');
160
+ } catch { /* use default */ }
161
+
162
+ console.log(`→ Creating pack ${c.cyan(name)} ...`);
163
+
164
+ // Directory structure
165
+ ensureDir(join(name, 'sounds', 'session-start'));
166
+ ensureDir(join(name, 'sounds', 'task-complete'));
167
+ ensureDir(join(name, 'sounds', 'error'));
168
+ ensureDir(join(name, 'ui', 'my-theme'));
169
+
170
+ // pack.json
171
+ writeFileSync(join(name, 'pack.json'), JSON.stringify({
172
+ name,
173
+ publisher,
174
+ version: '1.0.0',
175
+ description: 'My AgentCraft sound pack',
176
+ types: ['sounds'],
177
+ }, null, 2) + '\n');
178
+
179
+ // .gitignore
180
+ writeFileSync(join(name, '.gitignore'), '.DS_Store\nThumbs.db\n');
181
+
182
+ // README.md
183
+ writeFileSync(join(name, 'README.md'), `# ${name}
184
+
185
+ An [AgentCraft](https://github.com/rohenaz/agentcraft) sound pack.
186
+
187
+ ## Install
188
+
189
+ \`\`\`bash
190
+ agentcraft add ${publisher}/${name}
191
+ \`\`\`
192
+
193
+ Or manually:
194
+
195
+ \`\`\`bash
196
+ git clone https://github.com/${publisher}/${name} ~/.agentcraft/packs/${publisher}/${name}
197
+ \`\`\`
198
+
199
+ ## Structure
200
+
201
+ Drop \`.mp3\`, \`.wav\`, \`.ogg\`, or \`.m4a\` files into directories. The dashboard auto-discovers them:
202
+
203
+ \`\`\`
204
+ sounds/
205
+ session-start/ ← sounds here appear under "sounds > session start" group
206
+ task-complete/
207
+ error/
208
+ ui/
209
+ my-theme/ ← optional: UI sounds for the dashboard itself
210
+ click.mp3
211
+ hover.mp3
212
+ confirm.mp3
213
+ error.mp3
214
+ pageChange.mp3
215
+ \`\`\`
216
+
217
+ Top-level directories become group tabs. Nested directories become sub-tabs and subcategories.
218
+ Any layout works — organise however makes sense for your sounds.
219
+
220
+ ## Publishing
221
+
222
+ 1. Push this repo to GitHub as \`${publisher}/${name}\`
223
+ 2. Go to **Settings → Topics** and add the topic \`agentcraft-pack\`
224
+ 3. The community registry picks it up within 6 hours
225
+
226
+ Users can then find and install it from the AgentCraft dashboard PACKS tab.
227
+ `);
228
+
229
+ console.log(c.green(`✓ Created ${name}/`));
230
+ console.log('');
231
+ console.log('Next steps:');
232
+ console.log(` 1. Drop ${c.cyan('.mp3/.wav/.ogg')} files into the subdirectories`);
233
+ console.log(` 2. Push to GitHub as ${c.cyan(`${publisher}/${name}`)}`);
234
+ console.log(` 3. Add topic ${c.cyan('agentcraft-pack')} in GitHub Settings → Topics`);
235
+ console.log('');
236
+ console.log('Test locally before publishing:');
237
+ console.log(c.dim(` git clone . ~/.agentcraft/packs/${publisher}/${name}`));
238
+ console.log(c.dim(' agentcraft start'));
239
+ }
240
+
142
241
  function getWebDir() {
143
242
  if (process.env.CLAUDE_PLUGIN_ROOT) {
144
243
  return join(process.env.CLAUDE_PLUGIN_ROOT, 'web');
@@ -157,12 +256,13 @@ function showHelp() {
157
256
  ${c.bold('AgentCraft')} — assign sounds to AI agent lifecycle events
158
257
 
159
258
  ${c.cyan('Usage:')}
160
- agentcraft init Set up AgentCraft (install pack + config)
161
- agentcraft add <publisher/name> Install a sound pack from GitHub
162
- agentcraft remove <publisher/name> Remove an installed pack
163
- agentcraft update [publisher/name] Update a pack, or all packs if no arg given
164
- agentcraft list List installed packs
165
- agentcraft start Launch the dashboard (port 4040)
259
+ agentcraft init Set up AgentCraft (install pack + config)
260
+ agentcraft add <publisher/name> Install a sound pack from GitHub
261
+ agentcraft remove <publisher/name> Remove an installed pack
262
+ agentcraft update [publisher/name] Update a pack, or all packs if no arg given
263
+ agentcraft list List installed packs
264
+ agentcraft start Launch the dashboard (port 4040)
265
+ agentcraft create-pack <name> Scaffold a new sound pack repo
166
266
 
167
267
  ${c.cyan('Examples:')}
168
268
  agentcraft init
@@ -170,6 +270,7 @@ ${c.cyan('Examples:')}
170
270
  agentcraft add publisher/custom-pack
171
271
  agentcraft update
172
272
  agentcraft list
273
+ agentcraft create-pack my-sounds
173
274
 
174
275
  ${c.dim('Packs are stored at: ~/.agentcraft/packs/<publisher>/<name>/')}
175
276
  ${c.dim('Any git repo cloned there is automatically discovered by the dashboard.')}
@@ -208,6 +309,8 @@ if (cmd === 'init') {
208
309
  execSync(`cd "${webDir}" && bun install --silent && bun dev --port 4040`, { stdio: 'inherit' });
209
310
  } else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
210
311
  showHelp();
312
+ } else if (cmd === 'create-pack') {
313
+ createPack(sub);
211
314
  } else if (cmd === 'pack') {
212
315
  // Legacy shim — print migration hint and route through
213
316
  const newCmd = sub === 'install' ? 'add' : sub;
@@ -12,15 +12,9 @@ kill $(lsof -ti:4040) 2>/dev/null
12
12
  echo "AgentCraft server stopped."
13
13
  ```
14
14
 
15
- Check if the official sound pack is installed:
15
+ Run first-time setup (installs official pack + creates assignments.json if missing):
16
16
  ```bash
17
- ls ~/.agentcraft/packs/rohenaz/agentcraft-sounds 2>/dev/null | head -1
18
- ```
19
-
20
- If that returned nothing (empty or missing), install it:
21
- ```bash
22
- agentcraft pack install rohenaz/agentcraft-sounds 2>/dev/null || \
23
- git clone https://github.com/rohenaz/agentcraft-sounds ~/.agentcraft/packs/rohenaz/agentcraft-sounds
17
+ agentcraft init
24
18
  ```
25
19
 
26
20
  Check if server is already running:
@@ -17,6 +17,8 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
17
17
  ENABLED=$(jq -r '.settings.enabled // true' "$CONFIG")
18
18
  [ "$ENABLED" = "false" ] && exit 0
19
19
 
20
+ VOLUME=$(jq -r '.settings.masterVolume // 1' "$CONFIG")
21
+
20
22
  # Deduplicate: prevent the same event from firing within 3 seconds.
21
23
  # Claude Code fires SessionStart twice on resume (process init + session restore).
22
24
  LOCKFILE="/tmp/agentcraft-${EVENT}.lock"
@@ -70,9 +72,10 @@ fi
70
72
  [ ! -f "$FULL" ] && exit 0
71
73
 
72
74
  if [[ "$OSTYPE" == "darwin"* ]]; then
73
- afplay "$FULL" &
75
+ afplay -v "$VOLUME" "$FULL" &
74
76
  elif command -v paplay &>/dev/null; then
75
- paplay "$FULL" &
77
+ VOLUME_PAPLAY=$(awk "BEGIN{printf \"%d\", $VOLUME * 65536}")
78
+ paplay --volume="$VOLUME_PAPLAY" "$FULL" &
76
79
  elif command -v aplay &>/dev/null; then
77
80
  aplay "$FULL" &
78
81
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcraft",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Assign sounds to AI coding agent lifecycle events. CLI for managing sound packs.",
5
5
  "license": "MIT",
6
6
  "author": "rohenaz",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agentcraft-packs
3
- description: This skill should be used when the user asks to "install a sound pack", "add a pack", "find sound packs", "publish a pack", "create a pack", "share my sounds", "agentcraft pack install", "browse packs", "remove a pack", "update packs", or wants to know how the AgentCraft pack system works.
3
+ description: This skill should be used when the user asks to "install a sound pack", "add a pack", "find sound packs", "publish a pack", "create a pack", "create-pack", "scaffold a pack", "share my sounds", "agentcraft pack install", "browse packs", "remove a pack", "update packs", or wants to know how the AgentCraft pack system works.
4
4
  ---
5
5
 
6
6
  # AgentCraft Sound Packs
@@ -19,6 +19,7 @@ agentcraft list # show installed packs
19
19
  agentcraft update rohenaz/agentcraft-sounds # git pull one pack
20
20
  agentcraft update # update all packs
21
21
  agentcraft remove publisher/repo-name # uninstall
22
+ agentcraft create-pack my-sounds # scaffold a new pack repo
22
23
  ```
23
24
 
24
25
  `agentcraft pack install publisher/repo-name` resolves to `https://github.com/publisher/repo-name` and clones into `~/.agentcraft/packs/publisher/repo-name/`.
@@ -71,6 +72,14 @@ The hook script resolves this to the absolute path at runtime:
71
72
 
72
73
  Any GitHub repo with audio files (`.mp3`, `.wav`, `.ogg`, `.m4a`) is a valid pack. No manifest required — directory structure is the organization.
73
74
 
75
+ ### Step 0: Scaffold with the CLI (fastest path)
76
+
77
+ ```bash
78
+ agentcraft create-pack my-sounds
79
+ ```
80
+
81
+ This generates a ready-to-use pack directory with `pack.json`, `README.md`, example directories, and publishing instructions. Then drop your audio files in and push.
82
+
74
83
  ### Step 1: Organize the repo
75
84
 
76
85
  Recommended structure — group sounds into directories by game, theme, or purpose:
@@ -122,7 +131,7 @@ The registry GitHub Action runs every 6 hours and automatically picks up newly t
122
131
  ### Step 4: Share the install command
123
132
 
124
133
  ```bash
125
- agentcraft pack install your-username/your-repo-name
134
+ agentcraft add your-username/your-repo-name
126
135
  ```
127
136
 
128
137
  That's the entire publish workflow — push to GitHub, tag it, done.
@@ -4,13 +4,14 @@ import { resolvePackPath } from '@/lib/packs';
4
4
 
5
5
  export async function POST(req: NextRequest) {
6
6
  try {
7
- const { path } = await req.json();
7
+ const { path, volume } = await req.json();
8
8
  if (!path || typeof path !== 'string') {
9
9
  return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
10
10
  }
11
11
  const fullPath = resolvePackPath(path);
12
12
  if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
13
- spawn('afplay', [fullPath], { detached: true, stdio: 'ignore' }).unref();
13
+ const vol = typeof volume === 'number' && volume >= 0 && volume <= 1 ? volume : 1.0;
14
+ spawn('afplay', ['-v', String(vol), fullPath], { detached: true, stdio: 'ignore' }).unref();
14
15
  return NextResponse.json({ ok: true });
15
16
  } catch {
16
17
  return NextResponse.json({ error: 'Playback failed' }, { status: 500 });
package/web/app/page.tsx CHANGED
@@ -79,9 +79,9 @@ export default function Page() {
79
79
  await fetch('/api/preview', {
80
80
  method: 'POST',
81
81
  headers: { 'Content-Type': 'application/json' },
82
- body: JSON.stringify({ path }),
82
+ body: JSON.stringify({ path, volume: assignments.settings.masterVolume }),
83
83
  });
84
- }, []);
84
+ }, [assignments.settings.masterVolume]);
85
85
 
86
86
  const handleSave = useCallback(async () => {
87
87
  await fetch('/api/assignments', {
@@ -100,6 +100,13 @@ export default function Page() {
100
100
  });
101
101
  }, [assignments, handleAssignmentChange]);
102
102
 
103
+ const handleVolumeChange = useCallback((volume: number) => {
104
+ handleAssignmentChange({
105
+ ...assignments,
106
+ settings: { ...assignments.settings, masterVolume: volume },
107
+ });
108
+ }, [assignments, handleAssignmentChange]);
109
+
103
110
  const handleUiThemeChange = useCallback((theme: UITheme) => {
104
111
  setUITheme(theme, assignments.settings?.uiSounds?.[theme]);
105
112
  handleAssignmentChange({
@@ -227,7 +234,7 @@ export default function Page() {
227
234
  return (
228
235
  <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
229
236
  <div className="h-screen w-screen overflow-hidden flex flex-col" style={{ backgroundColor: 'var(--sf-bg)' }}>
230
- <HudHeader enabled={assignments.settings.enabled} onToggle={handleToggleEnabled} uiTheme={assignments.settings.uiTheme ?? 'sc2'} onUiThemeChange={handleUiThemeChange} onConfigureUISounds={() => setShowUISoundsModal(true)} />
237
+ <HudHeader enabled={assignments.settings.enabled} onToggle={handleToggleEnabled} uiTheme={assignments.settings.uiTheme ?? 'sc2'} onUiThemeChange={handleUiThemeChange} onConfigureUISounds={() => setShowUISoundsModal(true)} masterVolume={assignments.settings.masterVolume ?? 1.0} onVolumeChange={handleVolumeChange} />
231
238
  <div className="flex-1 grid overflow-hidden" style={{ gridTemplateColumns: '288px 1fr 320px' }}>
232
239
  <AgentRosterPanel
233
240
  assignments={assignments}
@@ -9,6 +9,8 @@ interface HudHeaderProps {
9
9
  uiTheme: UITheme;
10
10
  onUiThemeChange: (theme: UITheme) => void;
11
11
  onConfigureUISounds: () => void;
12
+ masterVolume: number;
13
+ onVolumeChange: (v: number) => void;
12
14
  }
13
15
 
14
16
  const UI_THEMES: { value: UITheme; label: string }[] = [
@@ -19,7 +21,7 @@ const UI_THEMES: { value: UITheme; label: string }[] = [
19
21
  { value: 'off', label: 'OFF' },
20
22
  ];
21
23
 
22
- export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds }: HudHeaderProps) {
24
+ export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds, masterVolume, onVolumeChange }: HudHeaderProps) {
23
25
  const [showDropdown, setShowDropdown] = useState(false);
24
26
  const activeLabel = UI_THEMES.find((t) => t.value === uiTheme)?.label ?? uiTheme.toUpperCase();
25
27
 
@@ -116,6 +118,26 @@ export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfi
116
118
 
117
119
  <div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
118
120
 
121
+ {/* Master volume */}
122
+ <div className="flex items-center gap-1.5">
123
+ <span className="text-[10px] tracking-widest uppercase opacity-40">VOL</span>
124
+ <input
125
+ type="range"
126
+ min={0}
127
+ max={100}
128
+ value={Math.round(masterVolume * 100)}
129
+ onChange={(e) => onVolumeChange(Number(e.target.value) / 100)}
130
+ data-no-ui-sound
131
+ className="w-16"
132
+ style={{ cursor: 'pointer', accentColor: 'var(--sf-cyan)', verticalAlign: 'middle' }}
133
+ />
134
+ <span className="text-[10px] w-7 text-right tabular-nums" style={{ color: 'var(--sf-cyan)', opacity: 0.7 }}>
135
+ {Math.round(masterVolume * 100)}%
136
+ </span>
137
+ </div>
138
+
139
+ <div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
140
+
119
141
  {/* Master enable/disable */}
120
142
  <button
121
143
  onClick={onToggle}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo, useCallback } from 'react';
3
+ import { useState, useMemo, useCallback, useEffect } from 'react';
4
4
  import { SoundUnit } from './sound-unit';
5
5
  import { groupSoundsByCategory, getGroupLabel, getSubTabLabel } from '@/lib/utils';
6
6
  import { playUISound } from '@/lib/ui-audio';
@@ -21,30 +21,53 @@ function internalCat(category: string): string {
21
21
  return idx === -1 ? category : category.slice(idx + 1);
22
22
  }
23
23
 
24
+ // Extract pack ID: "publisher/name:sc2/terran" → "publisher/name"
25
+ function packOfCat(category: string): string {
26
+ const idx = category.indexOf(':');
27
+ return idx === -1 ? '' : category.slice(0, idx);
28
+ }
29
+
30
+ // "publisher/name" → "name"
31
+ function packShortName(packId: string): string {
32
+ return packId.split('/')[1] ?? packId;
33
+ }
34
+
24
35
  export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode, onSelectModeAssign, onClearSelectMode }: SoundBrowserPanelProps) {
36
+ const [activePack, setActivePack] = useState<string | null>(null);
25
37
  const [activeGroup, setActiveGroup] = useState<string>('sc2');
26
38
  const [activeCategory, setActiveCategory] = useState<string>('sc2/terran');
27
39
  const [search, setSearch] = useState('');
28
40
 
29
- const handleGroupChange = useCallback((group: string) => {
30
- setActiveGroup(group);
31
- playUISound('pageChange', 0.4);
32
- }, []);
41
+ // All unique pack IDs present in the library
42
+ const allPacks = useMemo(() => {
43
+ return [...new Set(sounds.map((s) => packOfCat(s.category)))].filter(Boolean).sort();
44
+ }, [sounds]);
33
45
 
34
- const handleCategoryChange = useCallback((cat: string) => {
35
- setActiveCategory(cat);
36
- playUISound('pageChange', 0.35);
37
- }, []);
46
+ const showPackSelector = allPacks.length > 1;
47
+
48
+ // Sounds visible given the current pack filter
49
+ const visibleSounds = useMemo(() => {
50
+ if (!activePack) return sounds;
51
+ const prefix = activePack + ':';
52
+ return sounds.filter((s) => s.category.startsWith(prefix));
53
+ }, [sounds, activePack]);
38
54
 
39
55
  const allGroups = useMemo(() => {
40
- return [...new Set(sounds.map((s) => internalCat(s.category).split('/')[0]))].sort();
41
- }, [sounds]);
56
+ return [...new Set(visibleSounds.map((s) => internalCat(s.category).split('/')[0]))].sort();
57
+ }, [visibleSounds]);
58
+
59
+ // If activeGroup disappears after a pack switch, reset to the first available group
60
+ useEffect(() => {
61
+ if (allGroups.length > 0 && !allGroups.includes(activeGroup)) {
62
+ setActiveGroup(allGroups[0]);
63
+ }
64
+ }, [allGroups, activeGroup]);
42
65
 
43
66
  const groupCategories = useMemo(() => {
44
67
  return [...new Set(
45
- sounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
68
+ visibleSounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
46
69
  )].sort();
47
- }, [sounds, activeGroup]);
70
+ }, [visibleSounds, activeGroup]);
48
71
 
49
72
  // If activeCategory doesn't belong to current group, use first category of the group
50
73
  const effectiveCategory = groupCategories.includes(activeCategory)
@@ -64,21 +87,36 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
64
87
 
65
88
  const filteredSounds = useMemo(() => {
66
89
  if (isSearching) {
67
- // Global search across all sounds
68
90
  const q = search.toLowerCase();
69
- return sounds.filter((s) =>
91
+ return visibleSounds.filter((s) =>
70
92
  s.filename.toLowerCase().includes(q) ||
71
93
  s.category.toLowerCase().includes(q) ||
72
94
  s.subcategory.toLowerCase().includes(q)
73
95
  );
74
96
  }
75
- // Normal tab-filtered view
76
- return sounds.filter((s) => s.category === effectiveCategory);
77
- }, [sounds, effectiveCategory, search, isSearching]);
97
+ return visibleSounds.filter((s) => s.category === effectiveCategory);
98
+ }, [visibleSounds, effectiveCategory, search, isSearching]);
78
99
 
79
100
  const grouped = useMemo(() => groupSoundsByCategory(filteredSounds), [filteredSounds]);
80
101
 
81
102
  const showSubTabs = !isSearching && groupCategories.length > 1;
103
+ // Show pack label on cards when browsing all packs and multiple packs are installed
104
+ const showPackBadge = showPackSelector && !activePack;
105
+
106
+ const handleGroupChange = useCallback((group: string) => {
107
+ setActiveGroup(group);
108
+ playUISound('pageChange', 0.4);
109
+ }, []);
110
+
111
+ const handleCategoryChange = useCallback((cat: string) => {
112
+ setActiveCategory(cat);
113
+ playUISound('pageChange', 0.35);
114
+ }, []);
115
+
116
+ const handlePackChange = useCallback((pack: string | null) => {
117
+ setActivePack(pack);
118
+ playUISound('pageChange', 0.4);
119
+ }, []);
82
120
 
83
121
  return (
84
122
  <div className="flex flex-col overflow-hidden" style={{ borderLeft: '1px solid var(--sf-border)', borderRight: '1px solid var(--sf-border)' }}>
@@ -103,6 +141,45 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
103
141
  }}
104
142
  />
105
143
 
144
+ {/* SOURCE pack selector — only shown when 2+ packs installed */}
145
+ {showPackSelector && (
146
+ <div
147
+ className="flex items-center gap-1 mt-2 overflow-x-auto"
148
+ style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}
149
+ >
150
+ <span className="text-[9px] tracking-widest uppercase shrink-0" style={{ color: 'var(--sf-gold)', opacity: 0.6 }}>
151
+ SOURCE
152
+ </span>
153
+ <button
154
+ data-sf-hover
155
+ onClick={() => handlePackChange(null)}
156
+ className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-semibold uppercase tracking-wider transition-all"
157
+ style={{
158
+ border: `1px solid ${!activePack ? 'var(--sf-gold)' : 'rgba(255,192,0,0.2)'}`,
159
+ color: !activePack ? 'var(--sf-gold)' : 'rgba(255,192,0,0.45)',
160
+ backgroundColor: !activePack ? 'rgba(255,192,0,0.08)' : 'transparent',
161
+ }}
162
+ >
163
+ ALL
164
+ </button>
165
+ {allPacks.map((id) => (
166
+ <button
167
+ key={id}
168
+ data-sf-hover
169
+ onClick={() => handlePackChange(id)}
170
+ className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-semibold uppercase tracking-wider transition-all"
171
+ style={{
172
+ border: `1px solid ${activePack === id ? 'var(--sf-gold)' : 'rgba(255,192,0,0.2)'}`,
173
+ color: activePack === id ? 'var(--sf-gold)' : 'rgba(255,192,0,0.45)',
174
+ backgroundColor: activePack === id ? 'rgba(255,192,0,0.08)' : 'transparent',
175
+ }}
176
+ >
177
+ {packShortName(id)}
178
+ </button>
179
+ ))}
180
+ </div>
181
+ )}
182
+
106
183
  {/* Group tabs */}
107
184
  <div className="flex gap-1 mt-2 overflow-x-auto" style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}>
108
185
  {allGroups.map((group) => (
@@ -196,6 +273,7 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
196
273
  isAssigned={assignedPaths.has(sound.path)}
197
274
  onPreview={onPreview}
198
275
  onSelectAssign={selectMode ? () => onSelectModeAssign(sound.path) : undefined}
276
+ packLabel={showPackBadge ? packShortName(packOfCat(sound.category)) : undefined}
199
277
  />
200
278
  ))}
201
279
  </div>
@@ -11,11 +11,12 @@ interface SoundUnitProps {
11
11
  onPreview: (path: string) => void;
12
12
  isOverlay?: boolean;
13
13
  onSelectAssign?: () => void; // if set, card click assigns instead of plays
14
+ packLabel?: string; // shown when browsing multiple packs simultaneously
14
15
  }
15
16
 
16
17
  const BARS = 16;
17
18
 
18
- export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign }: SoundUnitProps) {
19
+ export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign, packLabel }: SoundUnitProps) {
19
20
  const [isPlaying, setIsPlaying] = useState(false);
20
21
  const [isHovered, setIsHovered] = useState(false);
21
22
  const [bars, setBars] = useState<number[]>(sound.waveform.map(h => h / 10));
@@ -198,6 +199,11 @@ export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAss
198
199
  <span className="shrink-0 text-[9px] animate-pulse" style={{ color: 'var(--sf-cyan)' }}>♪</span>
199
200
  )}
200
201
  </div>
202
+ {packLabel && (
203
+ <span className="text-[8px] truncate leading-none" style={{ color: 'rgba(255,192,0,0.45)' }}>
204
+ {packLabel}
205
+ </span>
206
+ )}
201
207
  </div>
202
208
  );
203
209
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import { previewUISound } from '@/lib/ui-audio';
5
5
  import type { UITheme, UISlotMap } from '@/lib/types';
6
6
 
@@ -12,6 +12,8 @@ interface UISound {
12
12
 
13
13
  type SlotName = 'click' | 'hover' | 'error' | 'pageChange' | 'toggle' | 'confirm';
14
14
 
15
+ const CANONICAL_SLOTS = new Set(['click', 'hover', 'error', 'pageChange', 'toggle', 'confirm']);
16
+
15
17
  const SLOTS: { name: SlotName; label: string; desc: string }[] = [
16
18
  { name: 'hover', label: 'HOVER', desc: 'Mouse over interactive element' },
17
19
  { name: 'click', label: 'CLICK', desc: 'Button or card clicked' },
@@ -30,8 +32,8 @@ interface Props {
30
32
 
31
33
  export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
32
34
  const [sounds, setSounds] = useState<UISound[]>([]);
33
- const [activeTheme, setActiveTheme] = useState<UITheme>(uiTheme === 'off' ? 'sc2' : uiTheme);
34
- const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? 'sc2' : uiTheme] ?? {});
35
+ const [activeTheme, setActiveTheme] = useState<string>(uiTheme === 'off' ? '' : uiTheme);
36
+ const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? '' : uiTheme] ?? {});
35
37
  const [activeSlot, setActiveSlot] = useState<SlotName>('hover');
36
38
  const [playing, setPlaying] = useState<string | null>(null);
37
39
 
@@ -39,8 +41,32 @@ export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
39
41
  fetch('/api/ui-sounds').then((r) => r.json()).then(setSounds).catch(console.error);
40
42
  }, []);
41
43
 
44
+ // Derive theme tabs: a ui/ subdir qualifies as a theme only if it contains
45
+ // at least one file whose basename matches a canonical slot name.
46
+ // Folders starting with '_' are always excluded (utility library escape hatch).
47
+ const themes = useMemo(() => {
48
+ const validGroups = new Set(
49
+ sounds
50
+ .filter((s) => {
51
+ const base = s.filename.replace(/\.(mp3|wav|ogg|m4a)$/i, '');
52
+ return CANONICAL_SLOTS.has(base);
53
+ })
54
+ .map((s) => s.group)
55
+ );
56
+ return [...validGroups].filter((t) => t && !t.startsWith('_')).sort();
57
+ }, [sounds]);
58
+
59
+ // When themes load, initialize activeTheme to first available if current isn't in list
60
+ useEffect(() => {
61
+ if (themes.length > 0 && activeTheme === '') {
62
+ const initial = themes[0];
63
+ setActiveTheme(initial);
64
+ setSlots(uiSounds[initial] ?? {});
65
+ }
66
+ }, [themes, activeTheme, uiSounds]);
67
+
42
68
  // When theme changes, load existing slot config for that theme
43
- const switchTheme = useCallback((theme: UITheme) => {
69
+ const switchTheme = useCallback((theme: string) => {
44
70
  setActiveTheme(theme);
45
71
  setSlots(uiSounds[theme] ?? {});
46
72
  }, [uiSounds]);
@@ -110,14 +136,14 @@ export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
110
136
  </div>
111
137
 
112
138
  {/* Theme selector */}
113
- <div className="flex items-center gap-2 px-5 py-2 shrink-0" style={{ borderBottom: '1px solid var(--sf-border)' }}>
114
- <span className="text-[10px] tracking-widest uppercase opacity-40">THEME</span>
115
- {(['sc2', 'wc3', 'ff7', 'ff9'] as const).map((t) => (
139
+ <div className="flex items-center gap-2 px-5 py-2 shrink-0 overflow-x-auto" style={{ borderBottom: '1px solid var(--sf-border)' }}>
140
+ <span className="text-[10px] tracking-widest uppercase opacity-40 shrink-0">THEME</span>
141
+ {themes.map((t) => (
116
142
  <button
117
143
  key={t}
118
144
  data-sf-hover
119
145
  onClick={() => switchTheme(t)}
120
- className="px-3 py-0.5 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
146
+ className="shrink-0 px-3 py-0.5 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
121
147
  style={{
122
148
  border: `1px solid ${activeTheme === t ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
123
149
  color: activeTheme === t ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.35)',
package/web/lib/types.ts CHANGED
@@ -21,7 +21,7 @@ export interface SkillConfig {
21
21
  hooks: Partial<Record<SkillHookEvent, string>>;
22
22
  }
23
23
 
24
- export type UITheme = 'sc2' | 'wc3' | 'ff7' | 'ff9' | 'off';
24
+ export type UITheme = string; // Dynamic: derives from installed pack ui/ directory names; 'off' disables UI sounds
25
25
 
26
26
  export interface SelectMode {
27
27
  scope: string; // 'global' | agent-name | 'skill/qualifiedName'