agentvibes 5.10.1 → 5.11.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.
Files changed (160) hide show
  1. package/.agentvibes/config.json +15 -1
  2. package/.agentvibes/install-manifest.json +342 -0
  3. package/.claude/audio/ui/CREDITS.txt +16 -0
  4. package/.claude/audio/ui/bling-success.wav +0 -0
  5. package/.claude/commands/agent-vibes/receiver.md +64 -0
  6. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  7. package/.claude/commands/agent-vibes-rdp.md +24 -24
  8. package/.claude/config/audio-effects.cfg +5 -4
  9. package/.claude/config/audio-effects.cfg.sample +52 -52
  10. package/.claude/config/background-music-enabled.txt +1 -1
  11. package/.claude/config/background-music-position.txt +27 -0
  12. package/.claude/config/background-music-volume.txt +1 -0
  13. package/.claude/config/background-music.cfg +1 -0
  14. package/.claude/config/background-music.txt +1 -0
  15. package/.claude/config/language.txt +1 -0
  16. package/.claude/config/personality.txt +1 -0
  17. package/.claude/config/reverb-level.txt +1 -0
  18. package/.claude/config/tts-speech-rate.txt +1 -0
  19. package/.claude/config/tts-verbosity.txt +1 -0
  20. package/.claude/docs/TERMUX_SETUP.md +408 -408
  21. package/.claude/github-star-reminder.txt +1 -1
  22. package/.claude/hooks/audio-cache-utils.sh +0 -0
  23. package/.claude/hooks/audio-processor.sh +57 -18
  24. package/.claude/hooks/background-music-manager.sh +0 -0
  25. package/.claude/hooks/bmad-party-speak.sh +0 -0
  26. package/.claude/hooks/bmad-speak-enhanced.sh +0 -0
  27. package/.claude/hooks/bmad-speak.sh +0 -0
  28. package/.claude/hooks/bmad-tts-injector.sh +0 -0
  29. package/.claude/hooks/bmad-voice-manager.sh +0 -0
  30. package/.claude/hooks/clawdbot-receiver-SECURE.sh +0 -0
  31. package/.claude/hooks/clawdbot-receiver.sh +0 -0
  32. package/.claude/hooks/clean-audio-cache.sh +0 -0
  33. package/.claude/hooks/cleanup-cache.sh +0 -0
  34. package/.claude/hooks/configure-rdp-mode.sh +0 -0
  35. package/.claude/hooks/download-extra-voices.sh +0 -0
  36. package/.claude/hooks/effects-manager.sh +0 -0
  37. package/.claude/hooks/github-star-reminder.sh +0 -0
  38. package/.claude/hooks/kokoro-installer.sh +117 -0
  39. package/.claude/hooks/kokoro-server.py +219 -0
  40. package/.claude/hooks/kokoro-tts.py +141 -0
  41. package/.claude/hooks/language-manager.sh +0 -0
  42. package/.claude/hooks/learn-manager.sh +0 -0
  43. package/.claude/hooks/macos-voice-manager.sh +0 -0
  44. package/.claude/hooks/migrate-background-music.sh +0 -0
  45. package/.claude/hooks/migrate-to-agentvibes.sh +0 -0
  46. package/.claude/hooks/optimize-background-music.sh +0 -0
  47. package/.claude/hooks/path-resolver.sh +0 -0
  48. package/.claude/hooks/personality-manager.sh +0 -0
  49. package/.claude/hooks/piper-download-voices.sh +39 -16
  50. package/.claude/hooks/piper-installer.sh +11 -6
  51. package/.claude/hooks/piper-multispeaker-registry.sh +0 -0
  52. package/.claude/hooks/piper-voice-manager.sh +6 -6
  53. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
  54. package/.claude/hooks/play-tts-agentvibes-receiver.sh +1 -0
  55. package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
  56. package/.claude/hooks/play-tts-enhanced.sh +0 -0
  57. package/.claude/hooks/play-tts-kokoro.sh +127 -0
  58. package/.claude/hooks/play-tts-macos.sh +0 -0
  59. package/.claude/hooks/play-tts-piper.sh +0 -0
  60. package/.claude/hooks/play-tts-soprano.sh +0 -0
  61. package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
  62. package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
  63. package/.claude/hooks/play-tts-windows-receiver.sh +0 -0
  64. package/.claude/hooks/play-tts.sh +78 -2
  65. package/.claude/hooks/prepare-release.sh +0 -0
  66. package/.claude/hooks/provider-commands.sh +71 -22
  67. package/.claude/hooks/provider-manager.sh +15 -7
  68. package/.claude/hooks/replay-target-audio.sh +0 -0
  69. package/.claude/hooks/requirements.txt +6 -6
  70. package/.claude/hooks/sentiment-manager.sh +0 -0
  71. package/.claude/hooks/session-start-tts.sh +0 -0
  72. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  73. package/.claude/hooks/speed-manager.sh +0 -0
  74. package/.claude/hooks/stop-tts.sh +0 -0
  75. package/.claude/hooks/termux-installer.sh +0 -0
  76. package/.claude/hooks/translate-manager.sh +0 -0
  77. package/.claude/hooks/translator.py +237 -237
  78. package/.claude/hooks/tts-queue-worker.sh +0 -0
  79. package/.claude/hooks/tts-queue.sh +0 -0
  80. package/.claude/hooks/verbosity-manager.sh +0 -0
  81. package/.claude/hooks/voice-manager.sh +0 -0
  82. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -0
  83. package/.claude/hooks-windows/kokoro-server.py +219 -0
  84. package/.claude/hooks-windows/kokoro-tts.py +107 -0
  85. package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
  86. package/.claude/hooks-windows/play-tts.ps1 +29 -1
  87. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  88. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
  89. package/.claude/piper-voices-dir.txt +1 -0
  90. package/.claude/verbosity.txt +1 -1
  91. package/.clawdbot/README.md +105 -105
  92. package/.mcp.json +6 -19
  93. package/README.md +12 -86
  94. package/RELEASE_NOTES.md +60 -0
  95. package/WINDOWS-SETUP.md +208 -208
  96. package/bin/agent-vibes +39 -39
  97. package/bin/agentvibes-voice-browser.js +0 -0
  98. package/bin/agentvibes.js +0 -0
  99. package/bin/mcp-server.js +121 -121
  100. package/bin/mcp-server.sh +0 -0
  101. package/bin/test-bmad-pr +78 -78
  102. package/mcp-server/QUICK_START.md +203 -203
  103. package/mcp-server/README.md +345 -345
  104. package/mcp-server/WINDOWS_SETUP.md +0 -0
  105. package/mcp-server/examples/claude_desktop_config.json +11 -11
  106. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  107. package/mcp-server/examples/custom_instructions.md +169 -169
  108. package/mcp-server/install-deps.js +0 -0
  109. package/mcp-server/server.py +1807 -1807
  110. package/mcp-server/test_server.py +0 -0
  111. package/package.json +4 -3
  112. package/src/cli/list-personalities.js +110 -110
  113. package/src/cli/list-voices.js +114 -114
  114. package/src/commands/bmad-voices.js +394 -394
  115. package/src/commands/install-mcp.js +3 -3
  116. package/src/console/app.js +25 -9
  117. package/src/console/audio-env.js +85 -1
  118. package/src/console/brand-colors.js +13 -13
  119. package/src/console/constants/personalities.js +44 -44
  120. package/src/console/navigation.js +4 -0
  121. package/src/console/tabs/agents-tab.js +12 -6
  122. package/src/console/tabs/help-tab.js +314 -314
  123. package/src/console/tabs/music-tab.js +7 -25
  124. package/src/console/tabs/readme-tab.js +272 -272
  125. package/src/console/tabs/setup-tab.js +1704 -120
  126. package/src/console/tabs/voices-tab.js +72 -88
  127. package/src/console/widgets/destroy-list.js +25 -25
  128. package/src/console/widgets/format-utils.js +14 -2
  129. package/src/console/widgets/help-bar.js +55 -0
  130. package/src/console/widgets/notice.js +55 -55
  131. package/src/console/widgets/reverb-picker.js +429 -41
  132. package/src/console/widgets/track-picker.js +60 -51
  133. package/src/i18n/de.js +2 -1
  134. package/src/i18n/en.js +1 -0
  135. package/src/i18n/es.js +2 -1
  136. package/src/i18n/fr.js +2 -1
  137. package/src/i18n/hi.js +2 -1
  138. package/src/i18n/ja.js +2 -1
  139. package/src/i18n/ko.js +2 -1
  140. package/src/i18n/pt.js +2 -1
  141. package/src/i18n/strings.js +9 -9
  142. package/src/i18n/zh-CN.js +2 -1
  143. package/src/installer/language-screen.js +31 -31
  144. package/src/installer/music-file-input.js +304 -304
  145. package/src/installer.js +95 -7
  146. package/src/services/config-service.js +264 -264
  147. package/src/services/language-service.js +47 -47
  148. package/src/services/provider-service.js +35 -0
  149. package/src/services/provider-voice-catalog.js +126 -0
  150. package/src/services/tts-engine-service.js +51 -2
  151. package/src/utils/audio-duration-validator.js +53 -10
  152. package/src/utils/audio-format-validator.js +277 -277
  153. package/src/utils/dependency-checker.js +469 -469
  154. package/src/utils/file-ownership-verifier.js +358 -358
  155. package/src/utils/music-file-validator.js +285 -285
  156. package/src/utils/preview-list-prompt.js +144 -136
  157. package/src/utils/provider-validator.js +70 -0
  158. package/src/utils/secure-music-storage.js +412 -412
  159. package/templates/agentvibes-receiver.sh +235 -231
  160. package/templates/audio/welcome-music.mp3 +0 -0
@@ -1,14 +1,18 @@
1
1
  /**
2
- * AgentVibes TUI — Shared Widget: Reverb Preset Picker
2
+ * AgentVibes TUI — Shared Widget: Audio Effects Picker
3
3
  *
4
- * Inline modal list for selecting reverb presets.
5
- * Extracted from settings-tab.js for reuse across tabs.
4
+ * Multi-select modal: pick reverb, echo, and chorus independently (combinable).
5
+ * Space = preview highlighted effect on a test tone (sox required).
6
+ * Enter = toggle selection for that category, c = apply, Esc = cancel.
6
7
  */
7
8
 
8
9
  import path from 'node:path';
9
- import { spawnSync } from 'node:child_process';
10
+ import os from 'node:os';
11
+ import fs from 'node:fs';
12
+ import crypto from 'node:crypto';
13
+ import { spawnSync, spawn } from 'node:child_process';
10
14
  import { destroyList } from './destroy-list.js';
11
- import { BRAND_PINK } from '../brand-colors.js';
15
+ import { renderHelpBar, selectorTitle } from './help-bar.js';
12
16
 
13
17
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
14
18
  let blessed;
@@ -17,81 +21,465 @@ if (!IS_TEST) {
17
21
  blessed = b;
18
22
  }
19
23
 
20
- const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
24
+ // ── Effect category definitions ───────────────────────────────────────────────
25
+
26
+ const EFFECT_CATEGORIES = [
27
+ {
28
+ id: 'reverb',
29
+ label: 'Reverb',
30
+ defaultValue: 'off-reverb',
31
+ items: [
32
+ { label: 'Off (no reverb)', value: 'off-reverb' },
33
+ { label: 'Light Reverb (small room)', value: 'light' },
34
+ { label: 'Medium Reverb (conference)', value: 'medium' },
35
+ { label: 'Heavy Reverb (large hall)', value: 'heavy' },
36
+ { label: 'Cathedral (epic space)', value: 'cathedral' },
37
+ ],
38
+ },
39
+ {
40
+ id: 'echo',
41
+ label: 'Echo',
42
+ defaultValue: 'off-echo',
43
+ items: [
44
+ { label: 'Off (no echo)', value: 'off-echo' },
45
+ { label: 'Echo (short delay)', value: 'echo-short' },
46
+ { label: 'Cave Echo (long)', value: 'echo-long' },
47
+ ],
48
+ },
49
+ {
50
+ id: 'chorus',
51
+ label: 'Chorus',
52
+ defaultValue: 'off-chorus',
53
+ items: [
54
+ { label: 'Off (no chorus)', value: 'off-chorus' },
55
+ { label: 'Light Chorus', value: 'chorus-light' },
56
+ { label: 'Deep Chorus', value: 'chorus-deep' },
57
+ ],
58
+ },
59
+ ];
60
+
61
+ // Character presets are compound — selecting one replaces all category selections
62
+ const CHARACTER_PRESETS = [
63
+ { label: 'Warm (reverb + bass)', value: 'warm' },
64
+ { label: 'Radio (EQ + overdrive)', value: 'radio' },
65
+ ];
66
+
67
+ // ── REVERB_PRESETS (backward-compat flat export) ──────────────────────────────
21
68
 
22
69
  export const REVERB_PRESETS = Object.freeze([
23
- { label: 'Off (Dry, no reverb)', value: 'off' },
24
- { label: 'Light (Small room)', value: 'light' },
25
- { label: 'Medium (Conference room)', value: 'medium' },
26
- { label: 'Heavy (Large hall)', value: 'heavy' },
27
- { label: 'Cathedral (Epic space)', value: 'cathedral' },
70
+ { label: 'Off (no effects)', value: 'off' },
71
+ { label: 'Off (no reverb)', value: 'off-reverb' },
72
+ { label: 'Off (no echo)', value: 'off-echo' },
73
+ { label: 'Off (no chorus)', value: 'off-chorus' },
74
+ ...EFFECT_CATEGORIES.flatMap(cat => cat.items.filter(i => !i.value.startsWith('off-'))),
75
+ ...CHARACTER_PRESETS,
28
76
  ]);
29
77
 
78
+ export const AUDIO_EFFECT_PRESETS = REVERB_PRESETS;
79
+
80
+ // ── Label helper ──────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Format a stored effect value for display.
84
+ * Handles single values ('light') and combined values ('light+echo-short').
85
+ */
86
+ export function formatEffectLabel(value) {
87
+ if (!value || value === 'off') return 'Off';
88
+ return value.split('+')
89
+ .map(v => v.trim())
90
+ .filter(Boolean)
91
+ .map(v => {
92
+ const p = REVERB_PRESETS.find(r => r.value === v);
93
+ return p ? p.label.replace(/\s{2,}/g, ' ').trim() : v;
94
+ })
95
+ .join(' + ');
96
+ }
97
+
98
+ // ── Parse stored value → selections Map ──────────────────────────────────────
99
+
100
+ function parseEffectValue(value) {
101
+ const sel = new Map();
102
+ for (const cat of EFFECT_CATEGORIES) sel.set(cat.id, cat.defaultValue);
103
+ sel.set('character', null);
104
+
105
+ if (!value || value === 'off') return sel;
106
+
107
+ for (const part of value.split('+').map(s => s.trim()).filter(Boolean)) {
108
+ if (CHARACTER_PRESETS.find(p => p.value === part)) {
109
+ for (const cat of EFFECT_CATEGORIES) sel.set(cat.id, cat.defaultValue);
110
+ sel.set('character', part);
111
+ return sel;
112
+ }
113
+ for (const cat of EFFECT_CATEGORIES) {
114
+ if (cat.items.find(item => item.value === part)) {
115
+ sel.set(cat.id, part);
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ return sel;
121
+ }
122
+
123
+ // ── Serialize selections Map → stored value ───────────────────────────────────
124
+
125
+ function serializeSelections(sel) {
126
+ const charVal = sel.get('character');
127
+ if (charVal) return charVal;
128
+ const parts = [];
129
+ for (const cat of EFFECT_CATEGORIES) {
130
+ const v = sel.get(cat.id);
131
+ if (v && !v.startsWith('off-')) parts.push(v);
132
+ }
133
+ return parts.length ? parts.join('+') : 'off';
134
+ }
135
+
136
+ // ── TTS + effect preview ──────────────────────────────────────────────────────
137
+
138
+ function previewEffectWithVoice(effectValue, phrase, opts, onDone) {
139
+ if (IS_TEST) return;
140
+ stopPreview();
141
+ const playTts = path.join(opts.previewHooksDir, 'play-tts.sh');
142
+ if (!fs.existsSync(playTts)) { previewEffect(effectValue); onDone?.(); return; }
143
+
144
+ const env = { ...process.env, AGENTVIBES_REVERB_OVERRIDE: effectValue, AGENTVIBES_EFFECTS_PREVIEW: '1' };
145
+ if (opts.previewTargetDir) env.CLAUDE_PROJECT_DIR = opts.previewTargetDir;
146
+ // Force a specific synth provider when the caller knows it (e.g. ElevenLabs),
147
+ // so the orchestrator doesn't fall back to the global provider.
148
+ if (opts.previewForceProvider) env.AGENTVIBES_FORCE_PROVIDER = opts.previewForceProvider;
149
+
150
+ // On Windows, a bare 'bash' resolves to WSL (no audio/key access); the caller
151
+ // passes the real git-bash path via previewBashBin.
152
+ const bashBin = opts.previewBashBin || 'bash';
153
+ const args = [bashBin, playTts, phrase];
154
+ if (opts.previewLlmKey) args.push('--llm', opts.previewLlmKey, '--project-dir', opts.previewTargetDir || '');
155
+ // Pass voice explicitly so the preview uses the current voice, not the LLM row's default
156
+ if (opts.previewVoice) args.push(opts.previewVoice);
157
+
158
+ _previewProc = spawn(args[0], args.slice(1), { stdio: 'ignore', env });
159
+ if (_previewProc) {
160
+ _previewProc.on('close', () => { _previewProc = null; onDone?.(); });
161
+ _previewProc.on('error', () => { _previewProc = null; onDone?.(); });
162
+ }
163
+ }
164
+
165
+ // ── Sox effect preview ────────────────────────────────────────────────────────
166
+
167
+ const SOX_EFFECT_MAP = {
168
+ 'off': '', 'off-reverb': '', 'off-echo': '', 'off-chorus': '',
169
+ 'light': 'reverb 20 50 50',
170
+ 'medium': 'reverb 40 50 70',
171
+ 'heavy': 'reverb 70 50 100',
172
+ 'cathedral': 'reverb 90 30 100',
173
+ 'chorus-light': 'chorus 0.7 0.9 55 0.4 0.25 2 -t',
174
+ 'chorus-deep': 'chorus 0.8 0.9 55 0.4 0.25 2 -t chorus 0.8 0.9 44 0.4 0.2 2.3 -t',
175
+ 'echo-short': 'echo 0.8 0.6 60 0.4',
176
+ 'echo-long': 'echo 0.8 0.7 100 0.5 200 0.3',
177
+ 'warm': 'bass 5 reverb 30 50 60',
178
+ 'radio': 'highpass 300 treble 5 gain -3 overdrive 10 gain -3',
179
+ };
180
+
181
+ let _previewProc = null;
182
+
183
+ /**
184
+ * Create a secure temp .wav path: per-uid subdir under XDG_RUNTIME_DIR (fallback
185
+ * os.tmpdir()) with an unpredictable crypto.randomUUID() filename. Mirrors the
186
+ * private _secureTempWav helper used in the tabs (not importable without a larger
187
+ * refactor), so it is replicated here.
188
+ */
189
+ function _secureTempWav(prefix) {
190
+ const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
191
+ const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
192
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
193
+ try { fs.chmodSync(dir, 0o700); } catch {}
194
+ return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
195
+ }
196
+
197
+ function stopPreview() {
198
+ if (_previewProc && !_previewProc.killed) {
199
+ try { _previewProc.kill(); } catch {}
200
+ }
201
+ _previewProc = null;
202
+ }
203
+
204
+ function previewEffect(effectValue) {
205
+ if (IS_TEST || (process.platform === 'win32' && !process.env.WSL_DISTRO_NAME)) return;
206
+
207
+ stopPreview();
208
+
209
+ const soxFx = SOX_EFFECT_MAP[effectValue] ?? '';
210
+ const tonePath = _secureTempWav('av-tone');
211
+ const prevPath = _secureTempWav('av-prev');
212
+
213
+ const cleanup = () => {
214
+ try { if (fs.existsSync(tonePath)) fs.unlinkSync(tonePath); } catch {}
215
+ try { if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath); } catch {}
216
+ };
217
+
218
+ const doPlay = (file) => {
219
+ // S4036: 'play'/'sox' are standard SoX tools on the user's local PATH; absolute
220
+ // paths aren't portable across distros/Homebrew. Risk accepted.
221
+ _previewProc = spawn('play', [file], { stdio: 'ignore' }); // NOSONAR
222
+ _previewProc.on('error', cleanup);
223
+ _previewProc.on('close', () => { _previewProc = null; cleanup(); });
224
+ };
225
+
226
+ // Generate a two-octave harmonic tone (~1s) — richer than pure sine for hearing effects
227
+ const gen = spawn('sox', [ // NOSONAR — see S4036 note above
228
+ '-n', '-r', '44100', '-c', '1', tonePath,
229
+ 'synth', '1.0', 'sine', '220', 'sine', '440',
230
+ 'gain', '-n', '-6',
231
+ ], { stdio: 'ignore' });
232
+
233
+ _previewProc = gen;
234
+ gen.on('error', cleanup);
235
+ gen.on('close', (code) => {
236
+ if (code !== 0 || !fs.existsSync(tonePath)) { cleanup(); return; }
237
+ if (!soxFx) { doPlay(tonePath); return; }
238
+
239
+ const fxProc = spawn('sox', [tonePath, prevPath, ...soxFx.split(/\s+/).filter(Boolean)], { stdio: 'ignore' }); // NOSONAR — see S4036 note above
240
+ _previewProc = fxProc;
241
+ fxProc.on('error', () => doPlay(tonePath));
242
+ fxProc.on('close', (c) => {
243
+ if (c === 0 && fs.existsSync(prevPath)) doPlay(prevPath);
244
+ else doPlay(tonePath);
245
+ });
246
+ });
247
+ }
248
+
249
+ // ── Build flat items list for the blessed list widget ─────────────────────────
250
+
251
+ function buildFlatItems() {
252
+ const items = [];
253
+ items.push({ isHelp: true, label: renderHelpBar([
254
+ { key: 'Space', label: 'test' },
255
+ { key: 'Enter', label: 'toggle' },
256
+ { key: 'A', label: 'apply' },
257
+ { key: 'Esc', label: 'cancel' },
258
+ ]) });
259
+ items.push({ isSpacer: true, label: '' });
260
+ for (const cat of EFFECT_CATEGORIES) {
261
+ items.push({ isHeader: true, label: `── ${cat.label} ` });
262
+ for (const item of cat.items) items.push({ category: cat.id, ...item });
263
+ }
264
+ items.push({ isHeader: true, label: '── Character (compound presets) ' });
265
+ for (const item of CHARACTER_PRESETS) items.push({ category: 'character', ...item });
266
+ // Top padding so the Apply button isn't hunched against the last option. The
267
+ // button is the LAST row so pressing Down to the bottom always lands on it.
268
+ items.push({ isSpacer: true, label: '' });
269
+ items.push({ isApply: true, label: '✓ Apply effects' });
270
+ return items;
271
+ }
272
+
273
+ const FLAT_ITEMS = buildFlatItems();
274
+
275
+ // ── Main export ───────────────────────────────────────────────────────────────
276
+
30
277
  /**
31
- * Open the reverb preset picker modal.
278
+ * Open the multi-select audio effects picker.
279
+ * API-compatible with the old single-select openReverbPicker.
280
+ * currentPreset and onSelect(value) now support combined values like 'light+echo-short'.
32
281
  *
33
282
  * @param {object} screen - blessed screen
34
- * @param {string} currentPreset - current reverb preset value
35
- * @param {Function} onSelect - called with selected preset value
283
+ * @param {string} currentPreset - current effect value (single or '+'-combined)
284
+ * @param {Function} onSelect - called with the new combined effect value
36
285
  * @param {Function} [onClose] - called after modal closes
37
- * @param {object} [opts] - options
38
- * @param {boolean} [opts.applyToEffectsManager=true] - whether to apply via effects-manager.sh
286
+ * @param {object} [opts]
287
+ * @param {boolean} [opts.applyToEffectsManager=true]
39
288
  */
40
289
  export function openReverbPicker(screen, currentPreset, onSelect, onClose, opts = {}) {
41
290
  const applyToEffectsManager = opts.applyToEffectsManager !== false;
42
- const currentIdx = Math.max(0, REVERB_PRESETS.findIndex(p => p.value === currentPreset));
291
+ const selections = parseEffectValue(currentPreset);
292
+ const WIDTH = 52;
293
+ const HEIGHT = Math.min(FLAT_ITEMS.length + 4, Math.max(20, (screen.height || 24) - 2));
43
294
 
44
- const COLORS = {
45
- btnFocus: '#2e7d32',
46
- btnFocusFg: '#ffffff',
47
- };
295
+ let _previewIdx = -1;
296
+ let _spinFrame = 0;
297
+ const _SPIN = ['','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
298
+ // Tracked selection index so the Apply button (whose solid bg overrides
299
+ // blessed's row highlight) can render a distinct focused state.
300
+ let _selIdx = 0;
301
+ const _applyIdx = FLAT_ITEMS.findIndex(it => it.isApply);
302
+
303
+ const renderItems = () => FLAT_ITEMS.map((item, idx) => {
304
+ if (item.isSpacer) return '';
305
+ if (item.isHelp) return ` ${item.label}`;
306
+ if (item.isHeader) {
307
+ const fill = Math.max(0, WIDTH - 6 - item.label.length);
308
+ return `{#90a4ae-fg}${item.label}${'─'.repeat(fill)}{/#90a4ae-fg}`;
309
+ }
310
+ if (item.isLegend) return `{#90a4ae-fg}${item.label}{/#90a4ae-fg}`;
311
+ if (item.isApply) {
312
+ const btn = ' ✓ Apply effects ';
313
+ const pad = ' '.repeat(Math.max(0, Math.floor((WIDTH - 4 - btn.length) / 2)));
314
+ // Bright + arrows when focused, dim when not — so navigating to it is obvious.
315
+ return idx === _selIdx
316
+ ? `${pad}{#43a047-bg}{white-fg}{bold}▸${btn}◂{/bold}{/white-fg}{/#43a047-bg}`
317
+ : `${pad}{#1b3a1f-bg}{#81c784-fg} ${btn} {/#81c784-fg}{/#1b3a1f-bg}`;
318
+ }
319
+ const isSel = item.category === 'character'
320
+ ? selections.get('character') === item.value
321
+ : selections.get(item.category) === item.value;
322
+ const dot = isSel ? '{green-fg}●{/green-fg}' : '{#546e7a-fg}○{/#546e7a-fg}';
323
+ const spin = idx === _previewIdx ? ` {cyan-fg}${_SPIN[_spinFrame % _SPIN.length]}{/cyan-fg}` : '';
324
+ return ` ${dot} ${item.label}${spin}`;
325
+ });
48
326
 
49
327
  const list = blessed.list({
50
328
  parent: screen,
51
329
  top: 'center',
52
330
  left: 'center',
53
- width: 40,
54
- height: REVERB_PRESETS.length + 4,
331
+ width: WIDTH,
332
+ height: HEIGHT,
55
333
  border: { type: 'line' },
56
334
  tags: true,
57
- label: _modalTitle('Select Reverb Preset'),
58
- items: REVERB_PRESETS.map((p, i) => (i === currentIdx ? `● ${p.label}` : ` ${p.label}`)),
59
- keys: true,
335
+ label: selectorTitle('Audio Effects'),
336
+ items: renderItems(),
337
+ keys: false,
60
338
  vi: false,
61
339
  mouse: true,
340
+ scrollable: true,
341
+ scrollbar: { ch: '▐', style: { fg: '#2e7d32' } },
62
342
  style: {
63
- border: { fg: COLORS.btnFocus },
64
- selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
343
+ border: { fg: '#2e7d32' },
344
+ selected: { bg: '#1b3a1f', fg: '#e3f2fd' },
65
345
  item: { fg: '#e3f2fd' },
66
346
  },
67
347
  });
68
348
 
69
- list.select(currentIdx);
349
+ // Position cursor at first active (non-off) selection, or first selectable item
350
+ let initialIdx = FLAT_ITEMS.findIndex(item =>
351
+ item.category &&
352
+ (item.category === 'character'
353
+ ? selections.get('character') === item.value
354
+ : selections.get(item.category) === item.value && !item.value.startsWith('off-'))
355
+ );
356
+ if (initialIdx < 0) initialIdx = FLAT_ITEMS.findIndex(item => item.category);
357
+
358
+ _selIdx = Math.max(0, initialIdx);
359
+ list.select(_selIdx);
70
360
  list.focus();
71
361
  screen.render();
72
362
 
73
- list.key(['enter', 'space'], () => {
74
- const selected = REVERB_PRESETS[list.selected];
75
- if (!selected) return;
363
+ const _refresh = () => {
364
+ const cur = list.selected;
365
+ _selIdx = cur;
366
+ list.setItems(renderItems());
367
+ list.select(cur);
368
+ screen.render();
369
+ };
370
+
371
+ let _spinInterval = null;
76
372
 
373
+ const _stopSpinner = () => {
374
+ if (_spinInterval) { clearInterval(_spinInterval); _spinInterval = null; }
375
+ if (_previewIdx >= 0) {
376
+ const prevIdx = _previewIdx;
377
+ _previewIdx = -1;
378
+ list.setItem(prevIdx, renderItems()[prevIdx]);
379
+ screen.render();
380
+ }
381
+ _previewIdx = -1;
382
+ };
383
+
384
+ const _startSpinner = (idx) => {
385
+ _stopSpinner();
386
+ _previewIdx = idx;
387
+ _spinFrame = 0;
388
+ _spinInterval = setInterval(() => {
389
+ _spinFrame++;
390
+ // Update only the spinning item — avoids full-list redraw flashing
391
+ list.setItem(_previewIdx, renderItems()[_previewIdx]);
392
+ screen.render();
393
+ }, 80);
394
+ };
395
+
396
+ const _confirm = () => {
397
+ _stopSpinner();
398
+ const val = serializeSelections(selections);
77
399
  if (applyToEffectsManager) {
78
- const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
79
- if (!_isWin) {
400
+ const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
401
+ if (!isWin) {
80
402
  const effectsScript = path.join(process.cwd(), '.claude', 'hooks', 'effects-manager.sh');
81
- spawnSync('bash', [effectsScript, 'set-reverb', selected.value, 'default'], { // NOSONAR
82
- stdio: 'ignore',
83
- timeout: 5000,
84
- env: { ...process.env },
403
+ spawnSync('bash', [effectsScript, 'set-reverb', val, 'default'], { // NOSONAR
404
+ stdio: 'ignore', timeout: 5000, env: { ...process.env },
85
405
  });
86
406
  }
87
407
  }
88
-
89
- // Call onSelect before destroying to avoid stale-state re-renders
90
- onSelect(selected.value);
408
+ stopPreview();
409
+ onSelect(val);
91
410
  destroyList(list, screen, onClose);
411
+ };
412
+
413
+ list.key('enter', () => {
414
+ const item = FLAT_ITEMS[list.selected];
415
+ if (!item || item.isHeader || item.isLegend || item.isHelp || item.isSpacer) return;
416
+ if (item.isApply) { _confirm(); return; }
417
+
418
+ if (item.category === 'character') {
419
+ // Toggle: selecting same character preset again turns it off
420
+ if (selections.get('character') === item.value) {
421
+ selections.set('character', null);
422
+ } else {
423
+ // Character presets clear individual category selections
424
+ for (const cat of EFFECT_CATEGORIES) selections.set(cat.id, cat.defaultValue);
425
+ selections.set('character', item.value);
426
+ }
427
+ } else {
428
+ // Selecting a category item clears any active character preset
429
+ selections.set('character', null);
430
+ selections.set(item.category, item.value);
431
+ }
432
+ _refresh();
92
433
  });
93
434
 
435
+ list.key('space', () => {
436
+ const idx = list.selected;
437
+ const item = FLAT_ITEMS[idx];
438
+ if (!item || item.isHeader || item.isLegend || item.isApply || item.isHelp || item.isSpacer) return;
439
+ if (opts.previewHooksDir) {
440
+ const name = item.label.replace(/\s{2,}\(.*?\)\s*$/, '').trim();
441
+ _startSpinner(idx);
442
+ previewEffectWithVoice(item.value, `Testing ${name}.`, opts, _stopSpinner);
443
+ } else {
444
+ previewEffect(item.value);
445
+ }
446
+ });
447
+
448
+ list.key(['a', 'A', 'c', 'C'], _confirm);
449
+
450
+ // Navigation skips decoration (headers, spacers, help) so arrow keys move only
451
+ // between real options and the Apply button — Down to the bottom always lands
452
+ // on Apply. (keys:false above disables blessed's built-in nav so we own it.)
453
+ const _isSelectable = (i) => {
454
+ const it = FLAT_ITEMS[i];
455
+ return Boolean(it && (it.category || it.isApply));
456
+ };
457
+ // Select row i and repaint the Apply button so its focused/dim state follows.
458
+ const _selectRow = (i) => {
459
+ _selIdx = i;
460
+ list.select(i);
461
+ if (_applyIdx >= 0) list.setItem(_applyIdx, renderItems()[_applyIdx]);
462
+ screen.render();
463
+ };
464
+ const _move = (dir) => {
465
+ let i = list.selected;
466
+ do { i += dir; } while (i >= 0 && i < FLAT_ITEMS.length && !_isSelectable(i));
467
+ if (i >= 0 && i < FLAT_ITEMS.length && _isSelectable(i)) _selectRow(i);
468
+ };
469
+ const _jump = (dir) => {
470
+ const idx = dir > 0
471
+ ? FLAT_ITEMS.map((_, i) => i).reverse().find(_isSelectable)
472
+ : FLAT_ITEMS.findIndex((_, i) => _isSelectable(i));
473
+ if (idx >= 0) _selectRow(idx);
474
+ };
475
+ list.key(['down', 'j'], () => _move(1));
476
+ list.key(['up', 'k'], () => _move(-1));
477
+ list.key(['home'], () => _jump(-1));
478
+ list.key(['end'], () => _jump(1)); // jump straight to Apply
479
+
94
480
  list.key(['escape', 'q', 'Q'], () => {
481
+ _stopSpinner();
482
+ stopPreview();
95
483
  destroyList(list, screen, onClose);
96
484
  });
97
485
  }