agentvibes 5.10.1 → 5.11.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 (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 +127 -2116
  94. package/RELEASE_NOTES.md +73 -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
@@ -20,6 +20,7 @@ import { promisify } from 'node:util';
20
20
  import fs from 'node:fs';
21
21
  import { promises as _fsP } from 'node:fs';
22
22
  import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
23
+ import { ELEVENLABS_VOICES, KOKORO_VOICE_IDS } from '../../services/provider-voice-catalog.js';
23
24
  import {
24
25
  PROVIDERS,
25
26
  checkClaudeInstalled, checkCopilotInstalled, checkCodexInstalled,
@@ -37,14 +38,15 @@ import {
37
38
  import {
38
39
  getAvailableEngines, getEngineStatuses, checkEngineInstalled,
39
40
  } from '../../services/tts-engine-service.js';
40
- import { openReverbPicker, REVERB_PRESETS } from '../widgets/reverb-picker.js';
41
+ import { openReverbPicker, formatEffectLabel } from '../widgets/reverb-picker.js';
41
42
  import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
43
+ import { renderHelpBar, selectorTitle } from '../widgets/help-bar.js';
42
44
  import { formatTrackName } from '../widgets/format-utils.js';
43
45
  import { destroyList } from '../widgets/destroy-list.js';
44
- import { scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
46
+ import { scanInstalledVoices, getVoiceMeta, genderIconTag, formatVoiceRow, voiceRowHeader, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
45
47
  import { attachBtnBlink } from './agents-tab.js';
46
48
  import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
47
- import { spawn } from 'node:child_process';
49
+ import { spawn, spawnSync } from 'node:child_process';
48
50
  import os from 'node:os';
49
51
  import crypto from 'node:crypto';
50
52
  import net from 'node:net';
@@ -53,6 +55,74 @@ const _execFileAsync = promisify(execFile);
53
55
 
54
56
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
55
57
 
58
+ // True while the automated test suite is running. The voice/cue previews below
59
+ // synthesize audio by spawning players DIRECTLY (SAPI, piper, say, the
60
+ // ElevenLabs hook, kokoro playback) — bypassing play-tts.sh's mute guard. The
61
+ // coverage suites fire every key handler (including Space = preview) on real
62
+ // widgets, so without this guard they would speak aloud on the developer's
63
+ // machine. We honor an explicit env flag (set by scripts/run-tests.sh) and the
64
+ // shared "tests running" marker file that play-tts.sh already respects.
65
+ // NOTE: distinct from AGENTVIBES_TEST_MODE, which stubs the whole tab — these
66
+ // previews must keep running their bodies (for coverage); only the final audio
67
+ // process is replaced with a silent no-op.
68
+ function _suppressAudio() {
69
+ if (process.env.AGENTVIBES_SUPPRESS_AUDIO === 'true') return true;
70
+ try { return fs.existsSync(path.join(os.homedir(), '.agentvibes-tests-running')); } catch { return false; }
71
+ }
72
+
73
+ // A child_process-like stub that emits a clean exit on the next tick, so the
74
+ // surrounding preview lifecycle code (label updates, exit/error handlers) still
75
+ // runs and stays covered — without launching a real audio process. Not routed
76
+ // through the imported spawn(), so it never pollutes a test's spawn tracking.
77
+ function _silentProc() {
78
+ const proc = {
79
+ _cbs: {},
80
+ killed: false,
81
+ on(ev, cb) { (this._cbs[ev] ||= []).push(cb); return this; },
82
+ once(ev, cb) { return this.on(ev, cb); },
83
+ emit(ev, ...a) { (this._cbs[ev] || []).forEach((f) => f(...a)); return true; },
84
+ kill() { this.killed = true; },
85
+ unref() {},
86
+ stdin: { write() {}, end() {} },
87
+ stdout: { on() {}, },
88
+ stderr: { on() {}, },
89
+ };
90
+ setImmediate(() => proc.emit('exit', 0));
91
+ return proc;
92
+ }
93
+
94
+ // Spawn an audio player, or a silent no-op while tests are running.
95
+ function _spawnAudio(cmd, args, opts) {
96
+ if (_suppressAudio()) return _silentProc();
97
+ return spawn(cmd, args, opts);
98
+ }
99
+
100
+ // Exported under stable aliases purely so the audio-suppression behavior can be
101
+ // unit-tested without standing up the blessed TUI.
102
+ export { _suppressAudio as __suppressAudio, _silentProc as __silentProc, _spawnAudio as __spawnAudio };
103
+
104
+ // Resolve a real Unix bash for spawning .sh hooks. On Windows a bare
105
+ // spawn('bash', …) resolves to C:\Windows\System32\bash.exe — that's WSL, which
106
+ // has a different HOME (no AgentVibes key/config), doesn't inherit the Windows
107
+ // env, and can't execute a Windows-path script. Prefer git-bash / msys2 bash.
108
+ let _cachedBash = null;
109
+ function resolveBash() {
110
+ if (_cachedBash) return _cachedBash;
111
+ if (process.platform !== 'win32') { _cachedBash = 'bash'; return _cachedBash; }
112
+ const candidates = [
113
+ process.env.AGENTVIBES_BASH,
114
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
115
+ 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
116
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
117
+ 'C:\\msys64\\usr\\bin\\bash.exe',
118
+ ].filter(Boolean);
119
+ for (const c of candidates) {
120
+ try { if (fs.existsSync(c)) { _cachedBash = c; return _cachedBash; } } catch { /* keep trying */ }
121
+ }
122
+ _cachedBash = 'bash'; // last resort
123
+ return _cachedBash;
124
+ }
125
+
56
126
  let blessed;
57
127
  if (!IS_TEST) {
58
128
  const { default: b } = await import('blessed');
@@ -91,11 +161,33 @@ const FOOTER_TEXT = '[Enter] Continue [Esc] Back [Tab] Next Tab [Q] Quit';
91
161
  // Maps non-Piper engine IDs to their canonical voice ID and display label.
92
162
  // Used by the voice picker, _buildFields display, and auto-save logic.
93
163
  const NATIVE_ENGINE_VOICES = {
94
- soprano: { id: 'soprano', label: 'Soprano' },
95
- sapi: { id: 'sapi', label: 'Windows SAPI' },
96
- 'macos-say': { id: 'macos-say', label: 'macOS Say' },
164
+ soprano: { id: 'soprano', label: 'Soprano' },
165
+ sapi: { id: 'sapi', label: 'Windows SAPI' },
166
+ 'macos-say': { id: 'macos-say', label: 'macOS Say' },
167
+ // elevenlabs is handled by its own multi-voice picker (see ELEVENLABS_VOICES);
168
+ // the id here is the default voice assigned when the engine is first selected.
169
+ elevenlabs: { id: 'EXAVITQu4vr4xnSDxMaL', label: 'ElevenLabs' },
97
170
  };
98
171
 
172
+ // Static built-in ElevenLabs premade voices — universal to every ElevenLabs
173
+ // account. The stored config value is the raw ElevenLabs voice_id; the TTS hook
174
+ // accepts raw IDs directly (its ^[a-zA-Z0-9]{10,40}$ path), so there is no
175
+ // name→id map to keep in sync. Custom/cloned account voices are NOT listed here
176
+ // (that needs a live API fetch); add them on elevenlabs.io to use their IDs.
177
+ const ELEVENLABS_DEFAULT_VOICE_ID = 'EXAVITQu4vr4xnSDxMaL'; // Sarah
178
+ // ELEVENLABS_VOICES is imported from services/provider-voice-catalog.js (single source of truth).
179
+
180
+ /** Returns the friendly name for an ElevenLabs voice_id, or null if unknown. */
181
+ const elevenLabsVoiceName = (id) => ELEVENLABS_VOICES.find(v => v.id === id)?.name || null;
182
+
183
+ /** Formats a stored voice value for display in the Voice field. */
184
+ function formatVoiceLabel(voice, globalVoice) {
185
+ if (NATIVE_ENGINE_VOICES[voice]) return NATIVE_ENGINE_VOICES[voice].label;
186
+ const el = elevenLabsVoiceName(voice);
187
+ if (el) return `ElevenLabs (${el})`;
188
+ return voice || `(global: ${globalVoice})`;
189
+ }
190
+
99
191
  // ---------------------------------------------------------------------------
100
192
  // Soprano WebUI auto-start helpers
101
193
 
@@ -166,6 +258,47 @@ export function formatGreeting(introText, projectName) {
166
258
  return `${name} is ready! Welcome to AgentVibes. Love AgentVibes? We'd really appreciate it if you could give us a star on GitHub.`;
167
259
  }
168
260
 
261
+ /**
262
+ * Build `python` args that check whether modules are importable WITHOUT
263
+ * importing them. `import kokoro` drags in torch (40s+ cold-start on Windows)
264
+ * and blows short spawn timeouts; find_spec only resolves the module spec and
265
+ * is ~instant. Pure/testable — caller runs it via spawnSync.
266
+ * @param {string[]} mods - top-level module names (e.g. ['kokoro','soundfile'])
267
+ * @returns {string[]} args array for `python <args>`; exit 0 = all present
268
+ */
269
+ export function buildPyModuleCheckArgs(mods) {
270
+ const expr = mods.map(m => `u.find_spec('${m}')`).join(' and ');
271
+ return ['-c', `import importlib.util as u, sys; sys.exit(0 if ${expr} else 1)`];
272
+ }
273
+
274
+ /**
275
+ * Build the fire-and-forget "preview ready" cue command for a platform. Pure
276
+ * (no spawning here, so it is unit-testable). Plays the bundled CC0 wav when
277
+ * present, else falls back to a system sound (Windows) / freedesktop cue or
278
+ * terminal bell (POSIX). stdio is ignored by the caller, so the POSIX bell is
279
+ * redirected to /dev/tty rather than the discarded stdout.
280
+ * @param {string} platform - process.platform value
281
+ * @param {string} wavPath - absolute path to the bling wav
282
+ * @param {boolean} haveWav - whether wavPath exists on disk
283
+ * @returns {{command: string, args: string[]}}
284
+ */
285
+ export function buildBlingCommand(platform, wavPath, haveWav) {
286
+ if (platform === 'win32') {
287
+ const ps = haveWav
288
+ ? `Add-Type -AssemblyName System.Windows.Forms; (New-Object System.Media.SoundPlayer('${wavPath.replace(/'/g, "''")}')).PlaySync()`
289
+ : '[System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 700';
290
+ return { command: 'powershell', args: ['-NoProfile', '-Command', ps] };
291
+ }
292
+ if (haveWav) {
293
+ // Pass wavPath as a positional arg ($1) so the path is never interpolated
294
+ // into the shell string (prevents injection / breakage on special chars).
295
+ const sh = 'paplay "$1" 2>/dev/null || aplay -q "$1" 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
296
+ return { command: 'bash', args: ['-c', sh, '--', wavPath] };
297
+ }
298
+ const sh = 'paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
299
+ return { command: 'bash', args: ['-c', sh] };
300
+ }
301
+
169
302
  // ---------------------------------------------------------------------------
170
303
  // Dependency detection helpers
171
304
 
@@ -204,7 +337,26 @@ async function _checkDependenciesAsync() {
204
337
  }
205
338
  }
206
339
 
207
- return { node, npm, piper, soprano: sopranoTts || sopranoWebui, ffmpeg };
340
+ // Kokoro: check python import (non-fatal if python3 unavailable)
341
+ // Use find_spec instead of import to avoid slow torch load (which causes ETIMEDOUT on sync checks)
342
+ const kokoro = await new Promise(resolve => {
343
+ try {
344
+ const proc = spawn('python3', ['-c', "import importlib.util; exit(0 if importlib.util.find_spec('kokoro') else 1)"], { stdio: 'ignore' }); // NOSONAR
345
+ const timer = setTimeout(() => { proc.kill(); resolve(false); }, 5000);
346
+ proc.on('close', code => { clearTimeout(timer); resolve(code === 0); });
347
+ proc.on('error', () => { clearTimeout(timer); resolve(false); });
348
+ } catch { resolve(false); }
349
+ });
350
+
351
+ // ElevenLabs: check env var or key file (sync — just a file read)
352
+ const elevenlabs = Boolean(process.env.ELEVENLABS_API_KEY) || (() => {
353
+ try {
354
+ const kf = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
355
+ return fs.existsSync(kf) && fs.readFileSync(kf, 'utf8').trim().length > 0;
356
+ } catch { return false; }
357
+ })();
358
+
359
+ return { node, npm, piper, soprano: sopranoTts || sopranoWebui, kokoro, elevenlabs, ffmpeg };
208
360
  }
209
361
 
210
362
  // ---------------------------------------------------------------------------
@@ -427,8 +579,19 @@ export function createSetupTab(screen, services) {
427
579
 
428
580
  function _cycleTtsFocus(dir) {
429
581
  const items = _ttsFocusableItems.filter(b => !b.hidden);
430
- if (!items.length) return;
431
- _ttsFocusIndex = (_ttsFocusIndex + dir + items.length) % items.length;
582
+ if (!items.length) {
583
+ _s2ContinueBtn.focus();
584
+ screen.render();
585
+ return;
586
+ }
587
+ const nextIdx = _ttsFocusIndex + dir;
588
+ if (dir > 0 && nextIdx >= items.length) {
589
+ // Tab past last install button → land on Continue
590
+ _s2ContinueBtn.focus();
591
+ screen.render();
592
+ return;
593
+ }
594
+ _ttsFocusIndex = (nextIdx + items.length) % items.length;
432
595
  items[_ttsFocusIndex].focus();
433
596
  screen.render();
434
597
  }
@@ -460,21 +623,158 @@ export function createSetupTab(screen, services) {
460
623
  }
461
624
  }
462
625
 
626
+ function _openApiKeyInput(engine, row) {
627
+ const keyFile = path.join(os.homedir(), '.agentvibes', `${engine.id}-key.txt`);
628
+ const dlg = blessed.box({
629
+ parent: screen, top: 'center', left: 'center',
630
+ width: 70, height: 11,
631
+ border: { type: 'line' }, tags: true,
632
+ label: ` {bold}{cyan-fg} ${engine.name} — Enter API Key {/cyan-fg}{/bold} `,
633
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
634
+ });
635
+ dlg.setFront();
636
+
637
+ blessed.text({
638
+ parent: dlg, top: 1, left: 2, tags: true,
639
+ content: `{white-fg}Paste your ${engine.name} API key (saved to ${keyFile}):{/white-fg}`,
640
+ style: { bg: COLORS.contentBg },
641
+ });
642
+
643
+ const inputBox = blessed.textbox({
644
+ parent: dlg, top: 3, left: 2, right: 2, height: 3,
645
+ border: { type: 'line' },
646
+ style: { fg: 'white', bg: 'black', border: { fg: 'blue' }, focus: { border: { fg: 'cyan' } } },
647
+ });
648
+
649
+ blessed.text({
650
+ parent: dlg, top: 7, left: 2, tags: true,
651
+ content: '{#9e9e9e-fg}[Enter] Save [Esc] Cancel{/#9e9e9e-fg}',
652
+ style: { bg: COLORS.contentBg },
653
+ });
654
+
655
+ let _closed = false;
656
+ const _prevGrabKeys = screen.grabKeys;
657
+
658
+ function _close(save) {
659
+ if (_closed) return;
660
+ _closed = true;
661
+ inputBox.removeAllListeners('keypress');
662
+ screen.grabKeys = _prevGrabKeys;
663
+ screen.program.hideCursor();
664
+ // Balance the openModal() below so the global Escape/nav handlers see the
665
+ // correct modal depth once this dialog is gone.
666
+ navigationService?.closeModal();
667
+ dlg.destroy();
668
+ if (save) {
669
+ const key = (inputBox.value || '').trim();
670
+ if (key.length > 0) {
671
+ try {
672
+ fs.mkdirSync(path.join(os.homedir(), '.agentvibes'), { recursive: true });
673
+ fs.writeFileSync(keyFile, key + '\n', { mode: 0o600 });
674
+ if (row) {
675
+ row.statusLabel.setContent('{green-fg}[Installed]{/green-fg}');
676
+ row.installBtn.hide();
677
+ }
678
+ } catch (e) {
679
+ if (row) row.statusLabel.setContent(`{red-fg}[Save failed: ${e.message}]{/red-fg}`);
680
+ }
681
+ }
682
+ }
683
+ // Return focus to the TTS engine list so arrow/Tab/Escape work again.
684
+ const _visible = _ttsFocusableItems.filter(b => !b.hidden);
685
+ const _target = (row && row.installBtn && !row.installBtn.hidden) ? row.installBtn : _visible[0];
686
+ _target?.focus();
687
+ screen.render();
688
+ }
689
+
690
+ // Register as a modal so the global Escape handlers (setup-tab line ~4610 and
691
+ // navigation.js) recognise this dialog. Without this, isModalOpen() stays
692
+ // false and Escape navigates the wizard back a screen while leaving this box
693
+ // orphaned with grabKeys stuck on — trapping the user. forceCloseAll() will
694
+ // invoke this close callback to dismiss the dialog cleanly.
695
+ navigationService?.openModal(null, () => _close(false));
696
+
697
+ let _cursor = 0;
698
+ inputBox.value = '';
699
+
700
+ function _renderInput() {
701
+ const val = inputBox.value;
702
+ const lpos = inputBox._getCoords();
703
+ if (!lpos) { screen.render(); return; }
704
+ const contentWidth = Math.max(1, (lpos.xl - lpos.xi) - inputBox.iwidth);
705
+ const start = _cursor > contentWidth - 1 ? _cursor - contentWidth + 1 : 0;
706
+ // Mask the API key on screen — render bullets while inputBox.value keeps
707
+ // the real key. This is the API-key dialog (engine.requiresApiKey).
708
+ inputBox.setContent('•'.repeat(val.length).slice(start));
709
+ screen.render();
710
+ screen.program.cup(lpos.yi + inputBox.itop, lpos.xi + inputBox.ileft + (_cursor - start));
711
+ }
712
+
713
+ screen.grabKeys = true;
714
+ inputBox.on('keypress', (ch, key) => {
715
+ if (key.name === 'enter') { _close(true); return; }
716
+ if (key.name === 'escape') { _close(false); return; }
717
+ if (key.name === 'backspace') {
718
+ if (_cursor > 0) { inputBox.value = inputBox.value.slice(0, _cursor - 1) + inputBox.value.slice(_cursor); _cursor--; }
719
+ } else if (key.name === 'delete') {
720
+ if (_cursor < inputBox.value.length) { inputBox.value = inputBox.value.slice(0, _cursor) + inputBox.value.slice(_cursor + 1); }
721
+ } else if (key.name === 'left') {
722
+ if (_cursor > 0) _cursor--;
723
+ } else if (key.name === 'right') {
724
+ if (_cursor < inputBox.value.length) _cursor++;
725
+ } else if (key.name === 'home') {
726
+ _cursor = 0;
727
+ } else if (key.name === 'end') {
728
+ _cursor = inputBox.value.length;
729
+ } else if (ch && !key.ctrl && !key.meta) {
730
+ // Strip CR/LF from pasted text and advance the cursor by the full
731
+ // inserted length (a multi-char paste otherwise desyncs the cursor).
732
+ const ins = ch.replace(/[\r\n]/g, '');
733
+ inputBox.value = inputBox.value.slice(0, _cursor) + ins + inputBox.value.slice(_cursor);
734
+ _cursor += ins.length;
735
+ }
736
+ _renderInput();
737
+ });
738
+
739
+ dlg.once('destroy', () => {
740
+ if (!_closed) { _closed = true; inputBox.removeAllListeners('keypress'); screen.grabKeys = _prevGrabKeys; screen.program.hideCursor(); navigationService?.closeModal(); }
741
+ });
742
+
743
+ inputBox.focus();
744
+ screen.program.showCursor();
745
+ _renderInput();
746
+ }
747
+
463
748
  let _ttsInstalling = false;
464
749
  async function _handleTtsInstall(engine) {
465
750
  if (!engine.installCmd || _ttsInstalling) return;
751
+
752
+ // ElevenLabs requires an API key — show a key-entry dialog instead of running a shell command
753
+ if (engine.requiresApiKey) {
754
+ const row = _ttsEngineRows.find(r => r.engine.id === engine.id);
755
+ _openApiKeyInput(engine, row);
756
+ return;
757
+ }
758
+
466
759
  _ttsInstalling = true;
467
760
 
468
- // Show installing status
469
761
  const row = _ttsEngineRows.find(r => r.engine.id === engine.id);
470
- if (row) {
471
- row.statusLabel.setContent('{yellow-fg}[Installing...]{/yellow-fg}');
472
- screen.render();
473
- }
762
+ const spinFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
763
+ let spinIdx = 0;
764
+ const spinTimer = setInterval(() => {
765
+ if (row) {
766
+ row.statusLabel.setContent(`{yellow-fg}${spinFrames[spinIdx % spinFrames.length]} Installing...{/yellow-fg}`);
767
+ screen.render();
768
+ }
769
+ spinIdx++;
770
+ }, 100);
474
771
 
475
772
  try {
476
- const opts = { stdio: 'pipe', timeout: 120000 };
477
- if (process.platform === 'win32') {
773
+ const opts = { stdio: 'pipe', timeout: 1800000 };
774
+ if (engine.installSpec) {
775
+ // Structured form — space-safe, no shell (FIX: installCmd.split(' ') corrupted paths with spaces)
776
+ await _execFileAsync(engine.installSpec.cmd, engine.installSpec.args, opts);
777
+ } else if (process.platform === 'win32') {
478
778
  opts.shell = true;
479
779
  await _execFileAsync(engine.installCmd, [], opts);
480
780
  } else {
@@ -482,7 +782,7 @@ export function createSetupTab(screen, services) {
482
782
  await _execFileAsync(parts[0], parts.slice(1), opts);
483
783
  }
484
784
 
485
- // Re-check and update status
785
+ clearInterval(spinTimer);
486
786
  const installed = checkEngineInstalled(engine.id);
487
787
  if (row) {
488
788
  row.statusLabel.setContent(installed
@@ -491,6 +791,7 @@ export function createSetupTab(screen, services) {
491
791
  if (installed) row.installBtn.hide();
492
792
  }
493
793
  } catch (err) {
794
+ clearInterval(spinTimer);
494
795
  if (row) {
495
796
  row.statusLabel.setContent(`{red-fg}[Failed]{/red-fg}`);
496
797
  }
@@ -504,6 +805,15 @@ export function createSetupTab(screen, services) {
504
805
  if (_screen < 3) { _screen++; _showCurrentScreen(); }
505
806
  });
506
807
  _s2ContinueBtn.hidden = true;
808
+ _s2ContinueBtn.key(['right', 'enter'], () => { if (_screen < 3) { _screen++; _showCurrentScreen(); } });
809
+ _s2ContinueBtn.key(['S-tab', 'up'], () => {
810
+ const items = _ttsFocusableItems.filter(b => !b.hidden);
811
+ if (items.length) {
812
+ _ttsFocusIndex = items.length - 1;
813
+ items[_ttsFocusIndex].focus();
814
+ screen.render();
815
+ }
816
+ });
507
817
 
508
818
  // =========================================================================
509
819
  // SCREEN 3: LLM Providers (new — from llm-providers-tab)
@@ -862,12 +1172,9 @@ export function createSetupTab(screen, services) {
862
1172
  function _buildFields() {
863
1173
  const base = [
864
1174
  { key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || `(global: ${globalEngine})` },
865
- { key: 'voice', label: 'Voice', getValue: () => NATIVE_ENGINE_VOICES[draft.voice]?.label ?? (draft.voice || `(global: ${globalVoice})`) },
1175
+ { key: 'voice', label: 'Voice', getValue: () => formatVoiceLabel(draft.voice, globalVoice) },
866
1176
  { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
867
- { key: 'reverb', label: 'Reverb', getValue: () => {
868
- const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
869
- return p ? p.label : draft.reverbPreset || 'Off';
870
- }},
1177
+ { key: 'audioEffects', label: 'Audio Effects', getValue: () => formatEffectLabel(draft.reverbPreset) },
871
1178
  { key: 'bgTrack', label: 'Music Track', getValue: () => formatTrackName(draft.bgTrack) || '(default)' },
872
1179
  { key: 'bgVolume', label: 'Music Vol', getValue: () => `${Math.round(parseFloat(draft.bgVolume) * 100)}%` },
873
1180
  { key: 'destination', label: 'Destination', getValue: () => draft.mode === 'remote' ? '🌐 Remote (SSH)' : '🏠 Local' },
@@ -881,8 +1188,8 @@ export function createSetupTab(screen, services) {
881
1188
  return `✏ ${h}${p}${k}`;
882
1189
  }});
883
1190
  if (draft.connType === 'manual') {
884
- base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{gray-fg}(optional){/gray-fg}' });
885
- base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{gray-fg}(default: 22){/gray-fg}' });
1191
+ base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{#9e9e9e-fg}(optional){/#9e9e9e-fg}' });
1192
+ base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{#9e9e9e-fg}(default: 22){/#9e9e9e-fg}' });
886
1193
  }
887
1194
  }
888
1195
  return base;
@@ -979,7 +1286,15 @@ export function createSetupTab(screen, services) {
979
1286
  const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
980
1287
  const sampleText = 'This is how your Hermes audio settings sound right now.';
981
1288
  let cmd, args;
982
- if (isWin) {
1289
+ if (draft.ttsEngine === 'elevenlabs') {
1290
+ // ElevenLabs has no Windows provider; route through the bash orchestrator
1291
+ // (play-tts.sh) so pretext, effects, and background music all apply. It then
1292
+ // dispatches to the ElevenLabs provider for synthesis.
1293
+ const script = path.join(packageDir, '.claude', 'hooks', 'play-tts.sh');
1294
+ cmd = resolveBash();
1295
+ args = [script, sampleText, draft.voice || ELEVENLABS_DEFAULT_VOICE_ID,
1296
+ '--llm', 'hermes', '--project-dir', targetDir];
1297
+ } else if (isWin) {
983
1298
  const script = path.join(targetDir, '.claude', hooksSubdir, 'play-tts.ps1');
984
1299
  cmd = 'powershell';
985
1300
  args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText, '', '-llm', 'hermes'];
@@ -988,9 +1303,12 @@ export function createSetupTab(screen, services) {
988
1303
  cmd = 'bash';
989
1304
  args = [script, sampleText, '', '--llm', 'hermes'];
990
1305
  }
991
- const proc = spawn(cmd, args, {
1306
+ const proc = _spawnAudio(cmd, args, {
992
1307
  stdio: 'ignore', windowsHide: true,
993
- env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
1308
+ env: {
1309
+ ...process.env, CLAUDE_PROJECT_DIR: targetDir,
1310
+ ...(draft.ttsEngine === 'elevenlabs' ? { AGENTVIBES_FORCE_PROVIDER: 'elevenlabs' } : {}),
1311
+ },
994
1312
  });
995
1313
  _previewModalProc = proc;
996
1314
  proc.on('exit', (code) => {
@@ -1063,8 +1381,16 @@ export function createSetupTab(screen, services) {
1063
1381
  case 'ttsEngine': _openTtsEnginePicker(draft, _refreshField); break;
1064
1382
  case 'voice': _openVoicePickerForLlm(draft, _refreshField); break;
1065
1383
  case 'pretext': _openPretextEditor(modal, draft, _refreshField); break;
1066
- case 'reverb':
1067
- openReverbPicker(screen, draft.reverbPreset, (val) => { draft.reverbPreset = val; _refreshField(); }, _cancelField, { applyToEffectsManager: false });
1384
+ case 'audioEffects':
1385
+ openReverbPicker(screen, draft.reverbPreset, (val) => { draft.reverbPreset = val; _refreshField(); }, _cancelField, {
1386
+ applyToEffectsManager: false,
1387
+ previewHooksDir: path.join(packageDir, '.claude', 'hooks'),
1388
+ previewLlmKey: 'hermes',
1389
+ previewTargetDir: targetDir,
1390
+ previewVoice: draft.voice,
1391
+ previewBashBin: resolveBash(),
1392
+ previewForceProvider: draft.ttsEngine === 'elevenlabs' ? 'elevenlabs' : undefined,
1393
+ });
1068
1394
  break;
1069
1395
  case 'bgTrack':
1070
1396
  openTrackPicker(screen, draft.bgTrack, Math.round(parseFloat(draft.bgVolume) * 100), (track) => { draft.bgTrack = track; _refreshField(); }, _cancelField, { skipVolume: true });
@@ -1316,7 +1642,7 @@ export function createSetupTab(screen, services) {
1316
1642
  const known = _getKnownHosts().filter(h => !aliases.includes(h)); // avoid duplicates
1317
1643
 
1318
1644
  // Build display items — aliases first (they carry key/port in ~/.ssh/config)
1319
- const aliasItems = aliases.map(a => ` 📋 ${a} {gray-fg}(SSH alias){/gray-fg}`);
1645
+ const aliasItems = aliases.map(a => ` 📋 ${a} {#9e9e9e-fg}(SSH alias){/#9e9e9e-fg}`);
1320
1646
  const knownItems = known.map(h => ` ${h}`);
1321
1647
  const allItems = [...aliasItems, ...knownItems, ' ✏ Enter manually…'];
1322
1648
 
@@ -1389,7 +1715,7 @@ export function createSetupTab(screen, services) {
1389
1715
  border: { type: 'line' }, tags: true,
1390
1716
  label: ' {bold}{cyan-fg} SSH Connection {/cyan-fg}{/bold} ',
1391
1717
  items: [
1392
- ` 📋 SSH Alias — pick from ~/.ssh/config${hasAliases ? '' : ' {gray-fg}(none found){/gray-fg}'}`,
1718
+ ` 📋 SSH Alias — pick from ~/.ssh/config${hasAliases ? '' : ' {#9e9e9e-fg}(none found){/#9e9e9e-fg}'}`,
1393
1719
  ' ✏ Manual — enter host, key, and port',
1394
1720
  ],
1395
1721
  style: {
@@ -1517,8 +1843,8 @@ export function createSetupTab(screen, services) {
1517
1843
  return `✏ ${h}${p}${k}`;
1518
1844
  }}];
1519
1845
  if (draft.connType === 'manual') {
1520
- base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{gray-fg}(optional){/gray-fg}' });
1521
- base.push({ key: 'port', label: 'Port', getValue: () => draft.port || provider.defaultPort || '{gray-fg}(default){/gray-fg}' });
1846
+ base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{#9e9e9e-fg}(optional){/#9e9e9e-fg}' });
1847
+ base.push({ key: 'port', label: 'Port', getValue: () => draft.port || provider.defaultPort || '{#9e9e9e-fg}(default){/#9e9e9e-fg}' });
1522
1848
  }
1523
1849
  return base;
1524
1850
  }
@@ -1586,7 +1912,7 @@ export function createSetupTab(screen, services) {
1586
1912
  // Refresh status text below provider name
1587
1913
  const row = transportRows.find(r => r.id === provider.id);
1588
1914
  if (row && draft.host) {
1589
- row.statusText.setContent(`{gray-fg}→ ${draft.host}:${draft.port}{/gray-fg}`);
1915
+ row.statusText.setContent(`{#9e9e9e-fg}→ ${draft.host}:${draft.port}{/#9e9e9e-fg}`);
1590
1916
  screen.render();
1591
1917
  }
1592
1918
  }).catch(() => {});
@@ -1608,10 +1934,10 @@ export function createSetupTab(screen, services) {
1608
1934
  const sampleText = 'This is how your audio settings sound right now.';
1609
1935
  const script = path.join(targetDir, '.claude', hooksSubdir, isWin ? 'play-tts.ps1' : 'play-tts.sh');
1610
1936
  const proc = isWin
1611
- ? spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText], { // NOSONAR
1937
+ ? _spawnAudio('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText], { // NOSONAR
1612
1938
  stdio: 'ignore', windowsHide: true, env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
1613
1939
  })
1614
- : spawn('bash', [script, sampleText], { // NOSONAR
1940
+ : _spawnAudio(resolveBash(), [script, sampleText], { // NOSONAR
1615
1941
  stdio: 'ignore', env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
1616
1942
  });
1617
1943
  _previewProc = proc;
@@ -1742,12 +2068,9 @@ export function createSetupTab(screen, services) {
1742
2068
  function _buildFields() {
1743
2069
  const base = [
1744
2070
  { key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || `(global: ${globalEngine})` },
1745
- { key: 'voice', label: 'Voice', getValue: () => NATIVE_ENGINE_VOICES[draft.voice]?.label ?? (draft.voice || `(global: ${globalVoice})`) },
2071
+ { key: 'voice', label: 'Voice', getValue: () => formatVoiceLabel(draft.voice, globalVoice) },
1746
2072
  { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
1747
- { key: 'reverb', label: 'Reverb', getValue: () => {
1748
- const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
1749
- return p ? p.label : draft.reverbPreset || 'Off';
1750
- }},
2073
+ { key: 'audioEffects', label: 'Audio Effects', getValue: () => formatEffectLabel(draft.reverbPreset) },
1751
2074
  { key: 'bgTrack', label: 'Music Track', getValue: () => formatTrackName(draft.bgTrack) || '(default)' },
1752
2075
  { key: 'bgVolume', label: 'Music Vol', getValue: () => `${Math.round(parseFloat(draft.bgVolume) * 100)}%` },
1753
2076
  { key: 'destination', label: 'Destination', getValue: () => draft.mode === 'remote' ? '🌐 Remote (SSH)' : '🏠 Local' },
@@ -1761,8 +2084,8 @@ export function createSetupTab(screen, services) {
1761
2084
  return `✏ ${h}${p}${k}`;
1762
2085
  }});
1763
2086
  if (draft.connType === 'manual') {
1764
- base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{gray-fg}(optional){/gray-fg}' });
1765
- base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{gray-fg}(default: 22){/gray-fg}' });
2087
+ base.push({ key: 'sshKey', label: 'SSH Key', getValue: () => draft.sshKey || '{#9e9e9e-fg}(optional){/#9e9e9e-fg}' });
2088
+ base.push({ key: 'port', label: 'Port', getValue: () => draft.port || '{#9e9e9e-fg}(default: 22){/#9e9e9e-fg}' });
1766
2089
  }
1767
2090
  }
1768
2091
  return base;
@@ -1899,7 +2222,15 @@ export function createSetupTab(screen, services) {
1899
2222
 
1900
2223
  function _doSpawnPreview() {
1901
2224
  let cmd, args;
1902
- if (isWin) {
2225
+ if (draft.ttsEngine === 'elevenlabs') {
2226
+ // ElevenLabs has no Windows provider; route through the bash orchestrator
2227
+ // (play-tts.sh) so pretext, effects, and background music all apply. It then
2228
+ // dispatches to the ElevenLabs provider for synthesis.
2229
+ const script = path.join(_hooksBase, '.claude', 'hooks', 'play-tts.sh');
2230
+ cmd = resolveBash();
2231
+ args = [script, sampleText, draft.voice || ELEVENLABS_DEFAULT_VOICE_ID,
2232
+ '--llm', llmKey, '--project-dir', targetDir];
2233
+ } else if (isWin) {
1903
2234
  const script = path.join(_hooksBase, '.claude', hooksSubdir, 'play-tts.ps1');
1904
2235
  cmd = 'powershell';
1905
2236
  args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText, '', '-llm', llmKey];
@@ -1908,10 +2239,13 @@ export function createSetupTab(screen, services) {
1908
2239
  cmd = 'bash';
1909
2240
  args = [script, sampleText, '', '--llm', llmKey];
1910
2241
  }
1911
- const proc = spawn(cmd, args, {
2242
+ const proc = _spawnAudio(cmd, args, {
1912
2243
  stdio: 'ignore',
1913
2244
  windowsHide: true,
1914
- env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_LLM_KEY: `llm:${llmKey}` },
2245
+ env: {
2246
+ ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_LLM_KEY: `llm:${llmKey}`,
2247
+ ...(draft.ttsEngine === 'elevenlabs' ? { AGENTVIBES_FORCE_PROVIDER: 'elevenlabs' } : {}),
2248
+ },
1915
2249
  });
1916
2250
  _previewModalProc = proc;
1917
2251
 
@@ -2058,11 +2392,19 @@ export function createSetupTab(screen, services) {
2058
2392
  _openPretextEditor(modal, draft, _refreshField);
2059
2393
  break;
2060
2394
 
2061
- case 'reverb':
2395
+ case 'audioEffects':
2062
2396
  openReverbPicker(screen, draft.reverbPreset, (val) => {
2063
2397
  draft.reverbPreset = val;
2064
2398
  _refreshField();
2065
- }, _cancelField, { applyToEffectsManager: false });
2399
+ }, _cancelField, {
2400
+ applyToEffectsManager: false,
2401
+ previewHooksDir: path.join(packageDir, '.claude', 'hooks'),
2402
+ previewLlmKey: llmKey,
2403
+ previewTargetDir: targetDir,
2404
+ previewVoice: draft.voice,
2405
+ previewBashBin: resolveBash(),
2406
+ previewForceProvider: draft.ttsEngine === 'elevenlabs' ? 'elevenlabs' : undefined,
2407
+ });
2066
2408
  break;
2067
2409
 
2068
2410
  case 'bgTrack':
@@ -2150,7 +2492,7 @@ export function createSetupTab(screen, services) {
2150
2492
  function _openTtsEnginePicker(draft, onDone) {
2151
2493
  function _closePicker() {
2152
2494
  navigationService?.closeModal();
2153
- destroyList(picker, screen);
2495
+ destroyList(box, screen);
2154
2496
  onDone();
2155
2497
  }
2156
2498
  navigationService?.openModal(null, _closePicker);
@@ -2163,36 +2505,81 @@ export function createSetupTab(screen, services) {
2163
2505
  // Add "(global default)" option at top
2164
2506
  items.unshift(' (global default)');
2165
2507
 
2166
- const picker = blessed.list({
2508
+ // Standardized chrome: titled box + pinned top help bar; selecting an engine
2509
+ // is not audible, so no preview hint.
2510
+ const ENGINE_TITLE = selectorTitle('Text To Speech (TTS) Engine');
2511
+ const box = blessed.box({
2167
2512
  parent: screen,
2168
2513
  top: 'center',
2169
2514
  left: 'center',
2170
2515
  width: 70,
2171
- height: Math.min(items.length + 4, 16),
2516
+ height: Math.min(items.length + 5, 17),
2172
2517
  border: { type: 'line' },
2173
2518
  tags: true,
2174
- label: ' {bold}{cyan-fg} Select TTS Engine {/cyan-fg}{/bold} ',
2519
+ label: ENGINE_TITLE,
2520
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
2521
+ });
2522
+ box.setFront();
2523
+ blessed.text({
2524
+ parent: box, top: 0, left: 1, right: 1, height: 1, tags: true,
2525
+ content: renderHelpBar([{ key: 'Enter', label: 'select' }, { key: 'Esc', label: 'cancel' }]),
2526
+ style: { bg: COLORS.contentBg },
2527
+ });
2528
+
2529
+ const picker = blessed.list({
2530
+ parent: box,
2531
+ top: 1,
2532
+ left: 0,
2533
+ right: 0,
2534
+ bottom: 0,
2535
+ tags: true,
2536
+ items,
2175
2537
  keys: true,
2176
2538
  vi: false,
2177
2539
  mouse: true,
2178
2540
  style: {
2179
2541
  fg: COLORS.labelFg,
2180
2542
  bg: COLORS.contentBg,
2181
- border: { fg: 'cyan' },
2182
2543
  selected: { bg: 'blue', fg: 'yellow' },
2183
2544
  item: { fg: COLORS.labelFg },
2184
2545
  },
2185
2546
  });
2186
- picker.setFront();
2187
- picker.setItems(items);
2188
2547
 
2189
2548
  picker.key(['enter'], () => {
2190
2549
  const idx = picker.selected;
2191
- const selectedEngine = idx === 0 ? '' : engines[idx - 1].id;
2550
+ // idx 0 = "(global default)"; idx 1..N map to engines[idx-1]. Guard the
2551
+ // bounds so a stray/out-of-range selection can never read .id of undefined.
2552
+ const engine = idx > 0 ? engines[idx - 1] : null;
2553
+ if (idx > 0 && !engine) { _closePicker(); return; }
2554
+ const selectedEngine = engine ? engine.id : '';
2555
+ // Guard: block selection of non-installed optional engines
2556
+ if (selectedEngine) {
2557
+ const engineStatus = engines.find(e => e.id === selectedEngine);
2558
+ if (engineStatus && !engineStatus.installed && !engineStatus.native) {
2559
+ box.setLabel(` {red-fg} ${engineStatus.name} is not installed — go to Setup > TTS Engines to install {/red-fg} `);
2560
+ screen.render();
2561
+ setTimeout(() => {
2562
+ if (!box.destroyed) {
2563
+ box.setLabel(ENGINE_TITLE);
2564
+ screen.render();
2565
+ }
2566
+ }, 3000);
2567
+ return;
2568
+ }
2569
+ }
2192
2570
  draft.ttsEngine = selectedEngine;
2193
2571
  // Auto-set voice to native engine canonical ID so the Voice field updates
2194
2572
  // immediately. For piper or empty engine, clear to '' (shows global default).
2195
- draft.voice = NATIVE_ENGINE_VOICES[selectedEngine]?.id || '';
2573
+ if (selectedEngine === 'elevenlabs') {
2574
+ // Keep an already-chosen ElevenLabs voice — a known built-in OR any raw
2575
+ // 20-char ElevenLabs voice_id (a custom/library voice the user configured
2576
+ // that isn't in our static list). Otherwise assign the default.
2577
+ const looksLikeElId = /^[A-Za-z0-9]{10,40}$/.test(draft.voice || '');
2578
+ draft.voice = (elevenLabsVoiceName(draft.voice) || looksLikeElId)
2579
+ ? draft.voice : ELEVENLABS_DEFAULT_VOICE_ID;
2580
+ } else {
2581
+ draft.voice = NATIVE_ENGINE_VOICES[selectedEngine]?.id || '';
2582
+ }
2196
2583
  _closePicker();
2197
2584
  });
2198
2585
 
@@ -2202,8 +2589,1015 @@ export function createSetupTab(screen, services) {
2202
2589
  screen.render();
2203
2590
  }
2204
2591
 
2592
+ // ── API Key Warning Popup ─────────────────────────────────────────────────
2593
+ // Non-blocking warning when a cloud TTS provider needs an API key.
2594
+ // Shows on top of whatever is currently open; calls onDismiss() when closed.
2595
+ function _showApiKeyWarning(serviceName, envVarName, keyFilePath, onDismiss) {
2596
+ const warningBox = blessed.box({
2597
+ parent: screen,
2598
+ top: 'center',
2599
+ left: 'center',
2600
+ width: 66,
2601
+ height: 11,
2602
+ border: { type: 'line' },
2603
+ tags: true,
2604
+ label: ` {bold}{yellow-fg} ${serviceName} — API Key Not Detected {/yellow-fg}{/bold} `,
2605
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'yellow' } },
2606
+ });
2607
+ warningBox.setFront();
2608
+
2609
+ blessed.text({
2610
+ parent: warningBox, top: 1, left: 2, right: 2, tags: true,
2611
+ content: `{yellow-fg}No API key found for ${serviceName}.{/yellow-fg}`,
2612
+ style: { bg: COLORS.contentBg },
2613
+ });
2614
+ blessed.text({
2615
+ parent: warningBox, top: 3, left: 2, right: 2, tags: true,
2616
+ content: `Set it in your shell:\n {cyan-fg}export ${envVarName}=your_key_here{/cyan-fg}`,
2617
+ style: { bg: COLORS.contentBg },
2618
+ });
2619
+ blessed.text({
2620
+ parent: warningBox, top: 6, left: 2, right: 2, tags: true,
2621
+ content: `Or write the key to:\n {cyan-fg}${keyFilePath}{/cyan-fg}`,
2622
+ style: { bg: COLORS.contentBg },
2623
+ });
2624
+ blessed.text({
2625
+ parent: warningBox, bottom: 1, left: 2, right: 2, tags: true,
2626
+ content: '{#9e9e9e-fg}[Enter] or [Esc] to dismiss{/#9e9e9e-fg}',
2627
+ style: { bg: COLORS.contentBg },
2628
+ });
2629
+
2630
+ let _warnClosed = false;
2631
+ function _closeWarning() {
2632
+ if (_warnClosed) return;
2633
+ _warnClosed = true;
2634
+ // Balance the openModal() below so a tab-switch forceCloseAll() that
2635
+ // invokes this callback tears the warning down cleanly instead of
2636
+ // orphaning it over the destroyed picker.
2637
+ navigationService?.closeModal();
2638
+ destroyList(warningBox, screen);
2639
+ onDismiss();
2640
+ }
2641
+
2642
+ warningBox.key(['enter', 'escape', 'space', 'q', 'Q'], _closeWarning);
2643
+ warningBox.on('click', _closeWarning);
2644
+ // Register as a modal so forceCloseAll() dismisses this dialog rather than
2645
+ // leaving it orphaned when the underlying picker is destroyed.
2646
+ navigationService?.openModal(null, _closeWarning);
2647
+ warningBox.focus();
2648
+ screen.render();
2649
+ }
2650
+
2205
2651
  // ── Voice picker for LLM config (matches agents-tab pattern) ──────────────
2206
2652
 
2653
+ // ── Kokoro voice scanner ─────────────────────────────────────────────────
2654
+ // Full static list for Kokoro v0.9.x. Voices not yet cached are downloaded
2655
+ // automatically by the library on first use via huggingface-hub.
2656
+ const _KOKORO_ALL_VOICES = KOKORO_VOICE_IDS;
2657
+
2658
+ function _scanKokoroVoices() {
2659
+ // Determine which voices are already cached locally
2660
+ const cached = new Set();
2661
+ try {
2662
+ const snapshotsDir = path.join(
2663
+ os.homedir(), '.cache', 'huggingface', 'hub',
2664
+ 'models--hexgrad--Kokoro-82M', 'snapshots'
2665
+ );
2666
+ for (const snap of fs.readdirSync(snapshotsDir)) {
2667
+ const voicesDir = path.join(snapshotsDir, snap, 'voices');
2668
+ if (!fs.existsSync(voicesDir)) continue;
2669
+ for (const f of fs.readdirSync(voicesDir)) {
2670
+ if (f.endsWith('.pt')) cached.add(f.replace('.pt', ''));
2671
+ }
2672
+ }
2673
+ } catch {}
2674
+ return { voices: _KOKORO_ALL_VOICES, cached };
2675
+ }
2676
+
2677
+ // Kokoro voice-id prefix → { language, gender } so they render as SEPARATE,
2678
+ // identically-styled columns (gender colored) like the other providers.
2679
+ const KOKORO_LANG = {
2680
+ af: { lang: 'en-US', gender: 'Female' }, am: { lang: 'en-US', gender: 'Male' },
2681
+ bf: { lang: 'en-GB', gender: 'Female' }, bm: { lang: 'en-GB', gender: 'Male' },
2682
+ jf: { lang: 'ja', gender: 'Female' }, jm: { lang: 'ja', gender: 'Male' },
2683
+ zf: { lang: 'zh', gender: 'Female' }, zm: { lang: 'zh', gender: 'Male' },
2684
+ ef: { lang: 'es', gender: 'Female' }, em: { lang: 'es', gender: 'Male' },
2685
+ ff: { lang: 'fr', gender: 'Female' }, fm: { lang: 'fr', gender: 'Male' },
2686
+ hf: { lang: 'hi', gender: 'Female' }, hm: { lang: 'hi', gender: 'Male' },
2687
+ pf: { lang: 'pt', gender: 'Female' }, pm: { lang: 'pt', gender: 'Male' },
2688
+ kf: { lang: 'ko', gender: 'Female' }, km: { lang: 'ko', gender: 'Male' },
2689
+ if: { lang: 'it', gender: 'Female' }, im: { lang: 'it', gender: 'Male' },
2690
+ };
2691
+ function _kokoroVoiceMeta(id) {
2692
+ const m = KOKORO_LANG[id.slice(0, 2)] || { lang: id.slice(0, 2), gender: '' };
2693
+ const name = id.slice(3);
2694
+ return { displayName: name.charAt(0).toUpperCase() + name.slice(1), lang: m.lang, gender: m.gender };
2695
+ }
2696
+
2697
+ function _openKokoroVoicePicker(draft, onDone, llmKey = '') {
2698
+ const { voices, cached } = _scanKokoroVoices();
2699
+
2700
+ // Detect the correct Python command for this platform (Windows: python / py, Unix: python3)
2701
+ let _pythonCmd = 'python3';
2702
+ for (const cmd of process.platform === 'win32' ? ['python', 'python3', 'py'] : ['python3', 'python']) {
2703
+ try {
2704
+ if (spawnSync(cmd, ['--version'], { stdio: 'ignore', timeout: 2000 }).status === 0) { // NOSONAR
2705
+ _pythonCmd = cmd;
2706
+ break;
2707
+ }
2708
+ } catch {}
2709
+ }
2710
+
2711
+ // Check Python modules are importable WITHOUT importing them. `import kokoro`
2712
+ // drags in torch, which on Windows cold-start takes 40s+ and blows any short
2713
+ // spawnSync timeout — yielding a false "not installed" and a pointless pip run
2714
+ // that sits at 0% ("Requirement already satisfied"). find_spec is ~instant and
2715
+ // only resolves the module spec, so detection stays reliable. (see line ~209)
2716
+ const _pyHasModules = (mods) =>
2717
+ spawnSync(_pythonCmd, buildPyModuleCheckArgs(mods), // NOSONAR
2718
+ { stdio: 'ignore', timeout: 5000 }).status === 0;
2719
+
2720
+ // Load favorites from ~/.agentvibes/kokoro-favorites.json
2721
+ let _kokoroFavorites = new Set();
2722
+ try {
2723
+ const favPath = path.join(os.homedir(), '.agentvibes', 'kokoro-favorites.json');
2724
+ _kokoroFavorites = new Set(JSON.parse(fs.readFileSync(favPath, 'utf8')));
2725
+ } catch {}
2726
+
2727
+ let _kClosed = false;
2728
+ let _kPreviewProc = null;
2729
+ let _kTmpWav = null;
2730
+ let _kAnimInterval = null;
2731
+ // Track all spawned download procs (Download-All + CJK/synth fallbacks) so
2732
+ // _killKPreview() can terminate them on close and their exit handlers never
2733
+ // touch a destroyed picker (use-after-destroy crash guard).
2734
+ const _kDlProcs = [];
2735
+
2736
+ // Read SSH config so previews route to the receiver
2737
+ let _sshHost = '', _sshKey = '', _sshPort = '';
2738
+ try {
2739
+ const tcPath = path.join(os.homedir(), '.agentvibes', 'transport-config.json');
2740
+ const tc = JSON.parse(fs.readFileSync(tcPath, 'utf8'));
2741
+
2742
+ // Priority 1: per-LLM remote config (e.g. tc['claude-code'].mode === 'remote')
2743
+ if (llmKey && tc[llmKey]?.mode === 'remote') {
2744
+ _sshHost = tc[llmKey].host || '';
2745
+ _sshKey = tc[llmKey].sshKey || '';
2746
+ _sshPort = String(tc[llmKey].port || '');
2747
+ }
2748
+
2749
+ // Priority 2: global provider is explicitly ssh-remote/agentvibes-receiver
2750
+ if (!_sshHost) {
2751
+ const provFilePath = path.join(os.homedir(), '.claude', 'tts-provider.txt');
2752
+ const globalProv = fs.readFileSync(provFilePath, 'utf8').trim();
2753
+ if (globalProv === 'ssh-remote' || globalProv === 'agentvibes-receiver') {
2754
+ _sshHost = tc[globalProv]?.host || '';
2755
+ _sshKey = tc[globalProv]?.sshKey || '';
2756
+ _sshPort = String(tc[globalProv]?.port || '');
2757
+ }
2758
+ }
2759
+
2760
+ // Priority 3: scan all transport-config entries for any mode=remote entry
2761
+ // (mirrors play-tts-ssh-remote.sh Priority 2b — handles the case where
2762
+ // tts-provider.txt says 'piper' but a per-LLM remote route is configured)
2763
+ if (!_sshHost) {
2764
+ for (const val of Object.values(tc)) {
2765
+ if (val?.mode === 'remote' && val?.host) {
2766
+ _sshHost = val.host;
2767
+ _sshKey = val.sshKey || '';
2768
+ _sshPort = String(val.port || '');
2769
+ break;
2770
+ }
2771
+ }
2772
+ }
2773
+ } catch {}
2774
+
2775
+ // Pre-validate SSH config once so the space handler can branch without re-checking
2776
+ const _validSshHost = Boolean(_sshHost && /^[a-zA-Z0-9][a-zA-Z0-9._@:-]*$/.test(_sshHost));
2777
+ const _validSshKey = Boolean(_sshKey && /^\//.test(_sshKey) && fs.existsSync(_sshKey));
2778
+ // Empty port => omit AGENTVIBES_SSH_PORT so the sender never forces
2779
+ // "-p 22" and an ~/.ssh/config Host alias keeps its real port (defer to ~/.ssh/config).
2780
+ const _validSshPort = Boolean(_sshPort && /^\d+$/.test(_sshPort));
2781
+
2782
+ const IDLE_LABEL = selectorTitle('Voice');
2783
+
2784
+ function _killKPreview() {
2785
+ _stopKSpinner();
2786
+ if (_kAnimInterval) { clearInterval(_kAnimInterval); _kAnimInterval = null; }
2787
+ if (_kPreviewProc) { try { _kPreviewProc.kill(); } catch {} _kPreviewProc = null; }
2788
+ // Kill any in-flight download procs (Download-All + fallbacks) so their
2789
+ // exit handlers don't fire against a destroyed picker.
2790
+ if (_dlAllProc) { try { _dlAllProc.kill(); } catch {} _dlAllProc = null; }
2791
+ while (_kDlProcs.length) {
2792
+ const p = _kDlProcs.pop();
2793
+ if (p) { try { p.kill(); } catch {} }
2794
+ }
2795
+ if (_kTmpWav) { try { fs.unlinkSync(_kTmpWav); } catch {} _kTmpWav = null; }
2796
+ }
2797
+ function _closeKP() {
2798
+ if (_kClosed) return;
2799
+ _kClosed = true;
2800
+ _killKPreview();
2801
+ // _dlAllProc and _dlAllActive are closed via _kClosed check in the dl loop
2802
+ navigationService?.closeModal();
2803
+ destroyList(kBox, screen, onDone);
2804
+ }
2805
+ navigationService?.openModal(null, _closeKP);
2806
+
2807
+ // ✓ = cached locally, ☁ = downloadable, ! = CJK (misaki[lang] missing), ★ = favorited
2808
+ function _isCjkVoice(id) {
2809
+ return ['jf','jm','zf','zm','kf','km'].includes(id.slice(0, 2));
2810
+ }
2811
+ // Returns the misaki extra and the import check for each CJK language group.
2812
+ // ja: misaki[ja] → pyopenjtalk; ko: misaki[ko] → jamo; zh: misaki[zh] → pypinyin
2813
+ function _cjkDeps(id) {
2814
+ const pfx = id.slice(0, 2);
2815
+ if (pfx === 'jf' || pfx === 'jm') return { pkg: 'misaki[ja]', check: 'pyopenjtalk', label: 'Japanese language support' };
2816
+ if (pfx === 'kf' || pfx === 'km') return { pkg: 'misaki[ko]', check: 'jamo', label: 'Korean language support' };
2817
+ if (pfx === 'zf' || pfx === 'zm') return { pkg: 'misaki[zh]', check: 'pypinyin', label: 'Chinese language support' };
2818
+ return { pkg: 'misaki[ja]', check: 'pyopenjtalk', label: 'Japanese language support' };
2819
+ }
2820
+
2821
+ // Check which CJK language groups already have their misaki extras installed.
2822
+ // Runs three quick spawnSync checks at picker-open time so icons are accurate.
2823
+ // Keys are the 2-char voice prefixes (jf/jm, kf/km, zf/zm) so _kokoroItem lookup is direct.
2824
+ const _cjkInstalled = new Set();
2825
+ for (const [pfxes, check] of [
2826
+ [['jf', 'jm'], 'pyopenjtalk'],
2827
+ [['kf', 'km'], 'jamo'],
2828
+ [['zf', 'zm'], 'pypinyin'],
2829
+ ]) {
2830
+ if (_pyHasModules([check])) {
2831
+ pfxes.forEach(p => _cjkInstalled.add(p));
2832
+ }
2833
+ }
2834
+
2835
+ // Check if kokoro + soundfile are installed (both required for synthesis)
2836
+ let _kokoroInstalled = _pyHasModules(['kokoro', 'soundfile']);
2837
+
2838
+ function _kokoroItem(id) {
2839
+ const fav = _kokoroFavorites.has(id) ? '{#FFD700-fg}★{/#FFD700-fg}' : ' ';
2840
+ let mark;
2841
+ if (_isCjkVoice(id) && !_cjkInstalled.has(id.slice(0, 2))) mark = '{yellow-fg}!{/yellow-fg}';
2842
+ else if (!_kokoroInstalled) mark = '{yellow-fg}!{/yellow-fg}';
2843
+ else if (cached.has(id)) mark = '{green-fg}✓{/green-fg}';
2844
+ else mark = '{cyan-fg}☁{/cyan-fg}';
2845
+ const m = _kokoroVoiceMeta(id);
2846
+ return formatVoiceRow({ status: `${fav} ${mark}`, name: m.displayName, gender: m.gender, lang: m.lang, detail: id });
2847
+ }
2848
+ const items = voices.map(_kokoroItem);
2849
+
2850
+ // Spinner state for Space-preview animation on the item row
2851
+ const _K_SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
2852
+ let _kSpinInterval = null;
2853
+ let _kSpinFrame = 0;
2854
+ let _kSpinningIdx = -1;
2855
+
2856
+ function _startKSpinner(listIdx) {
2857
+ _stopKSpinner();
2858
+ _kSpinningIdx = listIdx;
2859
+ _kSpinFrame = 0;
2860
+ _kSpinInterval = setInterval(() => {
2861
+ if (_kClosed) { _stopKSpinner(); return; }
2862
+ const spin = `{cyan-fg}${_K_SPIN[_kSpinFrame++ % _K_SPIN.length]}{/cyan-fg}`;
2863
+ kPicker.setItem(listIdx, `${_kokoroItem(voices[listIdx])} ${spin}`);
2864
+ screen.render();
2865
+ }, 80);
2866
+ }
2867
+
2868
+ function _stopKSpinner() {
2869
+ if (_kSpinInterval) { clearInterval(_kSpinInterval); _kSpinInterval = null; }
2870
+ if (_kSpinningIdx >= 0 && !_kClosed) {
2871
+ kPicker.setItem(_kSpinningIdx, _kokoroItem(voices[_kSpinningIdx]));
2872
+ screen.render();
2873
+ }
2874
+ _kSpinningIdx = -1;
2875
+ }
2876
+
2877
+ const LEGEND_H = 3;
2878
+ const pickerH = Math.min(voices.length + LEGEND_H + 3, 24);
2879
+
2880
+ // Outer box (border + label) — legend is a fixed child, not part of the scroll list
2881
+ const kBox = blessed.box({
2882
+ parent: screen,
2883
+ top: 'center', left: 'center',
2884
+ width: 78, height: pickerH,
2885
+ border: { type: 'line' }, tags: true,
2886
+ label: IDLE_LABEL,
2887
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
2888
+ });
2889
+ kBox.setFront();
2890
+
2891
+ // Fixed legend rows — pinned at top so they never scroll away
2892
+ blessed.text({
2893
+ parent: kBox, top: 0, left: 0, right: 0, height: LEGEND_H, tags: true,
2894
+ // Controls split across two rows so they don't wrap in narrow tmux panes;
2895
+ // third row is the symbol legend.
2896
+ content: renderHelpBar([
2897
+ { key: 'Space', label: 'preview' },
2898
+ { key: 'Enter', label: 'select' },
2899
+ { key: 'Esc', label: 'cancel' },
2900
+ ]) + '\n' + renderHelpBar([
2901
+ { key: '*', label: 'favorite' },
2902
+ { key: 'D', label: 'download all' },
2903
+ ]) + '\n{green-fg}✓{/green-fg}{#9e9e9e-fg}=cached {/#9e9e9e-fg}{#FFD700-fg}★{/#FFD700-fg}{#9e9e9e-fg}=fav {/#9e9e9e-fg}{cyan-fg}☁{/cyan-fg}{#9e9e9e-fg}=download {/#9e9e9e-fg}{yellow-fg}!{/yellow-fg}{#9e9e9e-fg}=needs pip install (kokoro or misaki[lang])',
2904
+ style: { bg: COLORS.contentBg },
2905
+ });
2906
+
2907
+ // Scrollable voice list — starts below the legend row
2908
+ const kPicker = blessed.list({
2909
+ parent: kBox,
2910
+ top: LEGEND_H, left: 0, right: 0, bottom: 0,
2911
+ keys: true, vi: true, mouse: true, tags: true,
2912
+ items,
2913
+ scrollable: true,
2914
+ scrollbar: { ch: '|', style: { fg: 'cyan' } },
2915
+ style: {
2916
+ fg: COLORS.labelFg, bg: COLORS.contentBg,
2917
+ selected: { bg: 'green', fg: 'white', bold: true },
2918
+ item: { fg: COLORS.labelFg },
2919
+ },
2920
+ });
2921
+
2922
+ const curIdx = voices.indexOf(draft.voice);
2923
+ if (curIdx >= 0) kPicker.select(curIdx);
2924
+
2925
+ kPicker.key(['enter'], () => {
2926
+ if (voices.length) draft.voice = voices[kPicker.selected];
2927
+ _closeKP();
2928
+ });
2929
+
2930
+ // Generic pip-install dialog + progress modal. onSuccess() is called after a clean exit
2931
+ // to let callers update icons/state; onCancel() is called when the user dismisses.
2932
+ function _promptInstallPkg(pkg, label, voiceId, onSuccess, onCancel) {
2933
+ const dlg = blessed.box({
2934
+ parent: screen,
2935
+ top: 'center', left: 'center',
2936
+ width: 64, height: 11,
2937
+ border: { type: 'line' }, tags: true,
2938
+ label: ` {yellow-fg}⚠ Missing: ${label}{/yellow-fg} `,
2939
+ content: [
2940
+ '',
2941
+ ` {white-fg}${voiceId}{/white-fg}{#9e9e9e-fg} needs {/#9e9e9e-fg}{cyan-fg}${pkg}{/cyan-fg}{#9e9e9e-fg} to speak.{/#9e9e9e-fg}`,
2942
+ ` {#9e9e9e-fg}Install it now with:{/#9e9e9e-fg}`,
2943
+ ` {cyan-fg}pip install ${pkg}{/cyan-fg}`,
2944
+ '',
2945
+ ` {#9e9e9e-fg}If using SSH receiver, run pip install there too.{/#9e9e9e-fg}`,
2946
+ '',
2947
+ ` {green-fg}[I]{/green-fg}{#9e9e9e-fg}=Install now {/#9e9e9e-fg}{red-fg}[Esc]{/red-fg}{#9e9e9e-fg}=Cancel`,
2948
+ ].join('\n'),
2949
+ style: { border: { fg: 'yellow' }, bg: '#1a1a2e' },
2950
+ });
2951
+ dlg.focus();
2952
+ navigationService?.openModal(null, () => { closeDlg(); });
2953
+ screen.render();
2954
+
2955
+ const closeDlg = () => { navigationService?.closeModal(); dlg.destroy(); kPicker.focus(); screen.render(); };
2956
+
2957
+ dlg.key(['escape', 'n', 'N'], () => { closeDlg(); onCancel?.(); });
2958
+ dlg.key(['i', 'I', 'y', 'Y', 'enter'], () => {
2959
+ navigationService?.closeModal();
2960
+ dlg.destroy();
2961
+
2962
+ // ── Single persistent install modal: shows progress during install,
2963
+ // updates to success/failure when done. Enter=OK only after done. ──
2964
+ const _PIP_BAR_W = 22;
2965
+ let _pipPct = 0;
2966
+ let _collecting = 0;
2967
+ let _pipBuf = '';
2968
+ let _pipErrBuf = '';
2969
+ let _mClosed = false;
2970
+ let _installProc = null;
2971
+
2972
+ function _bar(p) {
2973
+ const f = Math.round(p * _PIP_BAR_W / 100);
2974
+ return '{yellow-fg}' + '█'.repeat(f) + '{/yellow-fg}'
2975
+ + '{#555555-fg}' + '░'.repeat(_PIP_BAR_W - f) + '{/#555555-fg}';
2976
+ }
2977
+
2978
+ const iMod = blessed.box({
2979
+ parent: screen,
2980
+ top: 'center', left: 'center',
2981
+ width: 66, height: 9,
2982
+ border: { type: 'line' }, tags: true,
2983
+ label: ` {yellow-fg}⬇ Installing ${pkg}{/yellow-fg} `,
2984
+ style: { border: { fg: 'yellow' }, bg: '#1a1a2e' },
2985
+ });
2986
+
2987
+ function _setContent(barPct, phase, extra, isDone) {
2988
+ const barStr = ` {yellow-fg}⬇{/yellow-fg} [${_bar(barPct)}] {white-fg}${barPct}%{/white-fg} {#9e9e9e-fg}${phase}{/#9e9e9e-fg}`;
2989
+ const footer = isDone
2990
+ ? ` {green-fg}[Enter]{/green-fg}{#9e9e9e-fg} = OK{/#9e9e9e-fg}`
2991
+ : ` {red-fg}[Esc]{/red-fg}{#9e9e9e-fg} = Cancel{/#9e9e9e-fg}`;
2992
+ iMod.setContent([ '', barStr, '', extra, '', footer ].join('\n'));
2993
+ screen.render();
2994
+ }
2995
+
2996
+ _setContent(0, 'starting...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
2997
+ iMod.focus();
2998
+
2999
+ const cancelInstall = () => {
3000
+ if (_mClosed) return;
3001
+ _mClosed = true;
3002
+ if (_installProc && !_installProc.killed) { try { _installProc.kill(); } catch {} }
3003
+ navigationService?.closeModal();
3004
+ iMod.destroy();
3005
+ kBox.setLabel(IDLE_LABEL);
3006
+ setTimeout(() => { kPicker.focus(); screen.render(); }, 0);
3007
+ };
3008
+ navigationService?.openModal(null, cancelInstall);
3009
+ iMod.key(['escape'], cancelInstall);
3010
+ screen.render();
3011
+
3012
+ _installProc = spawn(_pythonCmd, ['-m', 'pip', 'install', '--progress-bar', 'on', ...pkg.split(/\s+/)], { // NOSONAR
3013
+ stdio: ['ignore', 'pipe', 'pipe'],
3014
+ });
3015
+
3016
+ _installProc.stdout.on('data', (chunk) => {
3017
+ if (_mClosed) return;
3018
+ _pipBuf += chunk.toString('utf8');
3019
+ const parts = _pipBuf.split(/[\r\n]/);
3020
+ _pipBuf = parts.pop() ?? '';
3021
+ for (const rawLine of parts) {
3022
+ // Strip ANSI escape codes — pip on Windows emits them even on non-TTY
3023
+ const line = rawLine.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); // NOSONAR
3024
+ const dlMatch = line.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*(kB|MB|GB)/i);
3025
+ if (dlMatch) {
3026
+ const done = parseFloat(dlMatch[1]), total = parseFloat(dlMatch[2]);
3027
+ if (total > 0) _pipPct = Math.max(_pipPct, Math.round(25 + (done / total) * 55));
3028
+ _setContent(_pipPct, 'downloading...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
3029
+ continue;
3030
+ }
3031
+ const t = line.trim();
3032
+ if (/^Collecting /i.test(t)) {
3033
+ _collecting++;
3034
+ _pipPct = Math.max(_pipPct, Math.min(20, _collecting * 5));
3035
+ _setContent(_pipPct, 'collecting...', ` {cyan-fg}${t}{/cyan-fg}`, false);
3036
+ } else if (/^Requirement already satisfied/i.test(t)) {
3037
+ // Everything already installed — pip emits no Collecting/Downloading,
3038
+ // so without this the bar would freeze at 0% until exit. Show motion.
3039
+ _pipPct = Math.max(_pipPct, 90);
3040
+ _setContent(_pipPct, 'verifying...', ` {#9e9e9e-fg}already satisfied — verifying{/#9e9e9e-fg}`, false);
3041
+ } else if (/Downloading.*\.whl/i.test(t)) {
3042
+ _pipPct = Math.max(_pipPct, 25);
3043
+ _setContent(_pipPct, 'downloading...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
3044
+ } else if (/Building|Running setup|running build/i.test(t)) {
3045
+ _pipPct = Math.max(_pipPct, 75);
3046
+ _setContent(_pipPct, 'building...', ` {cyan-fg}compiling ${pkg} from source{/cyan-fg}`, false);
3047
+ } else if (/Installing collected/i.test(t)) {
3048
+ _pipPct = Math.max(_pipPct, 88);
3049
+ _setContent(_pipPct, 'installing...', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
3050
+ } else if (/Successfully installed/i.test(t)) {
3051
+ _pipPct = 100;
3052
+ _setContent(100, 'done', ` {cyan-fg}pip install ${pkg}{/cyan-fg}`, false);
3053
+ }
3054
+ }
3055
+ });
3056
+
3057
+ _installProc.stderr.on('data', (chunk) => { _pipErrBuf += chunk.toString('utf8'); });
3058
+
3059
+ _installProc.on('exit', (code) => {
3060
+ if (_mClosed) return;
3061
+ const closeOk = () => {
3062
+ if (_mClosed) return;
3063
+ _mClosed = true;
3064
+ navigationService?.closeModal();
3065
+ iMod.destroy();
3066
+ kBox.setLabel(IDLE_LABEL);
3067
+ setTimeout(() => { kPicker.focus(); screen.render(); }, 0);
3068
+ };
3069
+ if (code === 0) {
3070
+ onSuccess?.();
3071
+ iMod.setLabel(` {green-fg}✓ Installed{/green-fg} `);
3072
+ iMod.style.border.fg = 'green';
3073
+ _setContent(100, 'done', ` {green-fg}${pkg}{/green-fg}{#9e9e9e-fg} installed — press Space on {/#9e9e9e-fg}{white-fg}${voiceId}{/white-fg}{#9e9e9e-fg} to preview{/#9e9e9e-fg}`, true);
3074
+ } else {
3075
+ const allOut = _pipBuf + _pipErrBuf;
3076
+ const needsDev = /Python\.h|python3-dev/i.test(allOut);
3077
+ const needsCmake = /cmake not found|cmake.*required/i.test(allOut);
3078
+ let hint;
3079
+ if (needsDev) hint = ` {yellow-fg}Fix:{/yellow-fg} {cyan-fg}sudo apt install python3-dev build-essential{/cyan-fg}`;
3080
+ else if (needsCmake) hint = ` {yellow-fg}Fix:{/yellow-fg} {cyan-fg}sudo apt install cmake{/cyan-fg}`;
3081
+ else hint = ` {#9e9e9e-fg}Run {/#9e9e9e-fg}{cyan-fg}pip install ${pkg}{/cyan-fg}{#9e9e9e-fg} in a terminal to see the error.{/#9e9e9e-fg}`;
3082
+ iMod.setLabel(` {red-fg}✗ Install Failed{/red-fg} `);
3083
+ iMod.style.border.fg = 'red';
3084
+ _setContent(_pipPct, `failed (exit ${code})`, hint, true);
3085
+ }
3086
+ iMod.key(['enter', 'o', 'O'], closeOk);
3087
+ iMod.focus();
3088
+ screen.render();
3089
+ });
3090
+
3091
+ _installProc.on('error', () => {
3092
+ if (_mClosed) return;
3093
+ iMod.setLabel(` {red-fg}✗ pip Not Found{/red-fg} `);
3094
+ iMod.style.border.fg = 'red';
3095
+ _setContent(0, 'error', ` {red-fg}Python not in PATH — install Python 3 first{/red-fg}`, true);
3096
+ const closeOk = () => {
3097
+ if (_mClosed) return;
3098
+ _mClosed = true;
3099
+ navigationService?.closeModal();
3100
+ iMod.destroy();
3101
+ kBox.setLabel(IDLE_LABEL);
3102
+ setTimeout(() => { kPicker.focus(); screen.render(); }, 0);
3103
+ };
3104
+ iMod.key(['enter', 'o', 'O'], closeOk);
3105
+ iMod.focus();
3106
+ screen.render();
3107
+ });
3108
+ });
3109
+ }
3110
+
3111
+ function _promptInstallMisakiLang(voiceId, onCancel) {
3112
+ const { pkg, label } = _cjkDeps(voiceId);
3113
+ _promptInstallPkg(pkg, label, voiceId, () => {
3114
+ const _LANG_PAIRS = { jf: 'jm', jm: 'jf', kf: 'km', km: 'kf', zf: 'zm', zm: 'zf' };
3115
+ const langPfx = voiceId.slice(0, 2);
3116
+ _cjkInstalled.add(langPfx);
3117
+ _cjkInstalled.add(_LANG_PAIRS[langPfx] ?? langPfx);
3118
+ voices.forEach((v, i) => {
3119
+ if (v.slice(0, 2) === langPfx || v.slice(0, 2) === (_LANG_PAIRS[langPfx] ?? langPfx)) {
3120
+ kPicker.setItem(i, _kokoroItem(v));
3121
+ }
3122
+ });
3123
+ }, onCancel);
3124
+ }
3125
+
3126
+ // Instant "bling" the moment a preview starts. Kokoro synthesis (and the SSH
3127
+ // round-trip to a remote receiver) can take ~20s, and silence reads as a hang
3128
+ // — this confirms the preview is on its way. Fire-and-forget: detached,
3129
+ // unref'd, errors swallowed, so it never blocks the UI or fails the preview.
3130
+ // Mirrors the chime heard on incoming remote messages.
3131
+ // Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
3132
+ // #648212). See .claude/audio/ui/CREDITS.txt. Falls back to a system sound
3133
+ // if the bundled asset is ever missing.
3134
+ const _blingWav = path.join(packageDir, '.claude', 'audio', 'ui', 'bling-success.wav');
3135
+ function _playReadyCue() {
3136
+ try {
3137
+ const { command, args } = buildBlingCommand(process.platform, _blingWav, fs.existsSync(_blingWav));
3138
+ const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
3139
+ cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
3140
+ cue.unref();
3141
+ } catch { /* the readiness cue is purely cosmetic — never break the preview */ }
3142
+ }
3143
+
3144
+ kPicker.key(['space'], () => {
3145
+ if (!voices.length) return;
3146
+ const voiceId = voices[kPicker.selected];
3147
+ if (_kPreviewProc) {
3148
+ _killKPreview();
3149
+ kBox.setLabel(IDLE_LABEL);
3150
+ screen.render();
3151
+ return;
3152
+ }
3153
+
3154
+ // kokoro package itself must be installed for any voice
3155
+ if (!_kokoroInstalled) {
3156
+ _promptInstallPkg('kokoro soundfile numpy', 'Kokoro TTS engine', voiceId, () => {
3157
+ _kokoroInstalled = true;
3158
+ voices.forEach((v, i) => kPicker.setItem(i, _kokoroItem(v)));
3159
+ });
3160
+ return;
3161
+ }
3162
+
3163
+ // CJK voices need language-specific misaki extras — prompt to install if missing
3164
+ if (_isCjkVoice(voiceId)) {
3165
+ const { check: importCheck } = _cjkDeps(voiceId);
3166
+ if (!_pyHasModules([importCheck])) {
3167
+ _promptInstallMisakiLang(voiceId);
3168
+ return;
3169
+ }
3170
+ }
3171
+
3172
+ // CJK voices require native-language text — English produces silence
3173
+ const _pfx2 = voiceId.slice(0, 2);
3174
+ const phrase = _pfx2 === 'zf' || _pfx2 === 'zm'
3175
+ ? '你好,这是 Kokoro 中文语音预览。'
3176
+ : _pfx2 === 'kf' || _pfx2 === 'km'
3177
+ ? '안녕하세요, 코코로 한국어 음성 미리보기입니다.'
3178
+ : _pfx2 === 'jf' || _pfx2 === 'jm'
3179
+ ? 'こんにちは、これはKokoroの日本語音声プレビューです。'
3180
+ : `Hi, I am the ${voiceId.slice(3)} Kokoro voice.`;
3181
+
3182
+ // Bling now — a real preview is committed (past the install-prompt and
3183
+ // toggle-off early returns). Fires for both local and remote paths.
3184
+ _playReadyCue();
3185
+
3186
+ // ── Remote preview: route through SSH pipeline so receiver plays it ──
3187
+ if (_validSshHost) {
3188
+ _startKSpinner(kPicker.selected);
3189
+ const hookDir = path.join(packageDir, '.claude', 'hooks');
3190
+ const remoteEnv = { ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_SSH_HOST: _sshHost };
3191
+ if (_validSshKey) remoteEnv.AGENTVIBES_SSH_KEY = _sshKey;
3192
+ if (_validSshPort) remoteEnv.AGENTVIBES_SSH_PORT = _sshPort;
3193
+ let remoteProc;
3194
+ let _remoteStderr = '';
3195
+ try {
3196
+ remoteProc = spawn(resolveBash(), [path.join(hookDir, 'play-tts-ssh-remote.sh'), phrase, voiceId], { // NOSONAR
3197
+ stdio: ['ignore', 'ignore', 'pipe'],
3198
+ env: remoteEnv,
3199
+ });
3200
+ } catch {
3201
+ _stopKSpinner();
3202
+ if (!_kClosed) {
3203
+ kBox.setLabel(` {red-fg}Remote preview failed{/red-fg} `);
3204
+ screen.render();
3205
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3206
+ }
3207
+ return;
3208
+ }
3209
+ remoteProc.stderr.on('data', (d) => { _remoteStderr += d.toString(); });
3210
+ _kPreviewProc = remoteProc;
3211
+ remoteProc.on('exit', (code) => {
3212
+ _kPreviewProc = null;
3213
+ _stopKSpinner();
3214
+ if (_kClosed) return;
3215
+ if (code !== 0 && _isCjkVoice(voiceId)) {
3216
+ // CJK voice failed on receiver — needs the language-specific misaki extra there.
3217
+ // Still try to download the .pt file locally so the picker shows ✓.
3218
+ const { pkg: cjkPkg } = _cjkDeps(voiceId);
3219
+ const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
3220
+ const dlProc = spawn(_pythonCmd, [pyScript, '--download-only', voiceId], { // NOSONAR
3221
+ stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env },
3222
+ });
3223
+ _kDlProcs.push(dlProc);
3224
+ let dlOut = '';
3225
+ dlProc.stdout.on('data', d => { dlOut += d.toString(); });
3226
+ dlProc.on('exit', (dlCode) => {
3227
+ const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1);
3228
+ if (_kClosed) return;
3229
+ if (dlCode === 0 && dlOut.trim()) {
3230
+ cached.add(voiceId);
3231
+ const li = voices.indexOf(voiceId);
3232
+ if (li >= 0) { kPicker.setItem(li, _kokoroItem(voiceId)); }
3233
+ }
3234
+ if (!_kClosed) {
3235
+ kBox.setLabel(` {yellow-fg}⚠ ${voiceId}: receiver needs pip install ${cjkPkg}{/yellow-fg} `);
3236
+ screen.render();
3237
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
3238
+ }
3239
+ });
3240
+ dlProc.on('error', () => {
3241
+ const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1);
3242
+ if (!_kClosed) {
3243
+ kBox.setLabel(` {yellow-fg}⚠ ${voiceId}: receiver needs pip install ${cjkPkg}{/yellow-fg} `);
3244
+ screen.render();
3245
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
3246
+ }
3247
+ });
3248
+ } else {
3249
+ const errLine = _remoteStderr.split('\n').find(l => l.includes('[ERROR]') || l.includes('kex_') || l.includes('Connection'));
3250
+ const label = errLine
3251
+ ? ` {red-fg}SSH: ${errLine.slice(0, 50)}{/red-fg} `
3252
+ : code !== 0
3253
+ ? ` {red-fg}SSH exit ${code}{/red-fg} `
3254
+ : ` {green-fg}✓ sent→${_sshHost}{/green-fg} `;
3255
+ kBox.setLabel(label);
3256
+ screen.render();
3257
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3258
+ }
3259
+ });
3260
+ remoteProc.on('error', () => {
3261
+ _kPreviewProc = null;
3262
+ _stopKSpinner();
3263
+ if (!_kClosed) {
3264
+ kBox.setLabel(` {red-fg}Remote preview failed{/red-fg} `);
3265
+ screen.render();
3266
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3267
+ }
3268
+ });
3269
+ return;
3270
+ }
3271
+
3272
+ // ── Local preview: synthesize WAV then play ──────────────────────────
3273
+ const isDownloaded = cached.has(voiceId);
3274
+
3275
+ // ── Download progress bar helpers ──────────────────────────────────────
3276
+ const DL_BAR_W = 22;
3277
+ const DL_SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
3278
+ let _dlSpinIdx = 0;
3279
+ let _dlRealPct = -1; // -1 = no real % received yet
3280
+
3281
+ function _dlBarLabel(pct) {
3282
+ const filled = Math.round(pct * DL_BAR_W / 100);
3283
+ const bar = '{yellow-fg}' + '█'.repeat(filled) + '{/yellow-fg}'
3284
+ + '{#555555-fg}' + '░'.repeat(DL_BAR_W - filled) + '{/#555555-fg}';
3285
+ return ` {yellow-fg}☁{/yellow-fg} [${bar}{#9e9e9e-fg}]{/#9e9e9e-fg} {white-fg}${pct}%{/white-fg} {#9e9e9e-fg}${voiceId}{/#9e9e9e-fg} `;
3286
+ }
3287
+
3288
+ function _startDlAnim() {
3289
+ kBox.setLabel(_dlBarLabel(0));
3290
+ screen.render();
3291
+ _kAnimInterval = setInterval(() => {
3292
+ if (_kClosed) { clearInterval(_kAnimInterval); _kAnimInterval = null; return; }
3293
+ if (_dlRealPct >= 0) {
3294
+ // Real progress received — update bar, keep interval running for smooth render
3295
+ kBox.setLabel(_dlBarLabel(_dlRealPct));
3296
+ } else {
3297
+ // No real data yet — spin
3298
+ _dlSpinIdx = (_dlSpinIdx + 1) % DL_SPIN.length;
3299
+ kBox.setLabel(` {yellow-fg}${DL_SPIN[_dlSpinIdx]} Downloading ${voiceId}...{/yellow-fg} `);
3300
+ }
3301
+ screen.render();
3302
+ }, 120);
3303
+ }
3304
+
3305
+ function _stopDlAnim() {
3306
+ if (_kAnimInterval) { clearInterval(_kAnimInterval); _kAnimInterval = null; }
3307
+ }
3308
+ // ──────────────────────────────────────────────────────────────────────
3309
+
3310
+ if (isDownloaded) {
3311
+ _startKSpinner(kPicker.selected);
3312
+ } else {
3313
+ _startDlAnim();
3314
+ }
3315
+ screen.render();
3316
+
3317
+ // Phase 1: synthesize WAV locally with kokoro-tts.py
3318
+ // stderr is captured so we can parse HuggingFace tqdm download progress
3319
+ const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
3320
+ const tmpWav = _secureTempWav('kokoro-preview');
3321
+ _kTmpWav = tmpWav;
3322
+
3323
+ let synthProc;
3324
+ try {
3325
+ synthProc = spawn(_pythonCmd, [pyScript, phrase, voiceId, tmpWav, '1.0'], { // NOSONAR
3326
+ stdio: ['ignore', 'ignore', 'pipe'],
3327
+ env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
3328
+ });
3329
+ } catch {
3330
+ _stopDlAnim();
3331
+ _kTmpWav = null;
3332
+ try { fs.unlinkSync(tmpWav); } catch {}
3333
+ if (!_kClosed) {
3334
+ kBox.setLabel(` {red-fg}Preview failed — is Kokoro installed?{/red-fg} `);
3335
+ screen.render();
3336
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3337
+ }
3338
+ return;
3339
+ }
3340
+ _kPreviewProc = synthProc;
3341
+
3342
+ // Always drain stderr to prevent pipe-buffer deadlock from PyTorch warnings.
3343
+ // For uncached voices, also parse tqdm download progress lines.
3344
+ let _stderrBuf = '';
3345
+ synthProc.stderr.on('data', (chunk) => {
3346
+ if (_kClosed) return;
3347
+ if (isDownloaded) return; // just drain — no UI update needed
3348
+ _stderrBuf += chunk.toString();
3349
+ // tqdm writes \r-terminated progress on same line; split on \r or \n
3350
+ const parts = _stderrBuf.split(/[\r\n]/);
3351
+ _stderrBuf = parts.pop() ?? '';
3352
+ for (const line of parts) {
3353
+ const m = line.match(/\b(\d{1,3})%\s*\|/);
3354
+ if (m) {
3355
+ _dlRealPct = Math.min(100, parseInt(m[1], 10));
3356
+ }
3357
+ }
3358
+ });
3359
+
3360
+ synthProc.on('exit', (synthCode) => {
3361
+ _stopDlAnim();
3362
+ _stopKSpinner();
3363
+ _kPreviewProc = null;
3364
+ _kTmpWav = null;
3365
+ if (_kClosed) { try { fs.unlinkSync(tmpWav); } catch {} return; }
3366
+ if (synthCode !== 0) {
3367
+ try { fs.unlinkSync(tmpWav); } catch {}
3368
+ kBox.setLabel(` {red-fg}Synthesis failed — run: pip install kokoro{/red-fg} `);
3369
+ screen.render();
3370
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
3371
+ // For voices where synthesis fails due to missing language deps (exit 3),
3372
+ // try a direct .pt file download so the voice can still be cached (shows ✓)
3373
+ if (synthCode === 3) {
3374
+ const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
3375
+ const dlProc = spawn(_pythonCmd, [pyScript, '--download-only', voiceId], { // NOSONAR
3376
+ stdio: ['ignore', 'pipe', 'ignore'],
3377
+ env: { ...process.env },
3378
+ });
3379
+ _kDlProcs.push(dlProc);
3380
+ let dlOut = '';
3381
+ dlProc.stdout.on('data', d => { dlOut += d.toString(); });
3382
+ dlProc.on('exit', (dlCode) => {
3383
+ const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1);
3384
+ if (_kClosed) return;
3385
+ if (dlCode === 0 && dlOut.trim()) {
3386
+ cached.add(voiceId);
3387
+ kPicker.setItems(voices.map(_kokoroItem));
3388
+ if (!_kClosed) { kBox.setLabel(` {green-fg}✓ ${voiceId} cached (synthesis requires extra deps){/green-fg} `); screen.render(); }
3389
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3390
+ }
3391
+ });
3392
+ dlProc.on('error', () => { const _i = _kDlProcs.indexOf(dlProc); if (_i >= 0) _kDlProcs.splice(_i, 1); });
3393
+ }
3394
+ return;
3395
+ }
3396
+ // Update icon to ✓ for newly-downloaded voice
3397
+ if (!isDownloaded) {
3398
+ cached.add(voiceId);
3399
+ kPicker.setItem(kPicker.selected, _kokoroItem(voiceId));
3400
+ }
3401
+
3402
+ // Phase 2: play WAV locally (remote destinations handled before synthesis via SSH pipeline)
3403
+ let playProc;
3404
+ try {
3405
+ if (process.platform === 'win32') {
3406
+ // SoundPlayer needs the Windows Forms assembly loaded and native backslash paths
3407
+ const psPath = tmpWav.replace(/'/g, "''");
3408
+ playProc = spawn('powershell', ['-NoProfile', '-Command', // NOSONAR
3409
+ `Add-Type -AssemblyName System.Windows.Forms; ` +
3410
+ `(New-Object System.Media.SoundPlayer('${psPath}')).PlaySync()`,
3411
+ ], { stdio: 'pipe', env: { ...process.env } });
3412
+ } else {
3413
+ playProc = spawn(resolveBash(), ['-c', `aplay -q "$1" 2>/dev/null || paplay "$1" 2>/dev/null || true`, '--', tmpWav], { // NOSONAR
3414
+ stdio: 'ignore',
3415
+ env: { ...process.env },
3416
+ });
3417
+ }
3418
+ } catch {
3419
+ try { fs.unlinkSync(tmpWav); } catch {}
3420
+ if (!_kClosed) {
3421
+ kBox.setLabel(` {red-fg}Playback failed{/red-fg} `);
3422
+ screen.render();
3423
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3424
+ }
3425
+ return;
3426
+ }
3427
+ _kPreviewProc = playProc;
3428
+ _kTmpWav = tmpWav;
3429
+ _startKSpinner(kPicker.selected);
3430
+ screen.render();
3431
+ playProc.on('exit', (code) => {
3432
+ _kPreviewProc = null;
3433
+ _stopKSpinner();
3434
+ if (_kTmpWav) { try { fs.unlinkSync(_kTmpWav); } catch {} _kTmpWav = null; }
3435
+ if (code !== 0 && !_kClosed) {
3436
+ kBox.setLabel(` {red-fg}Playback failed (exit ${code}){/red-fg} `);
3437
+ screen.render();
3438
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 4000);
3439
+ }
3440
+ });
3441
+ playProc.on('error', () => {
3442
+ _kPreviewProc = null;
3443
+ _stopKSpinner();
3444
+ if (_kTmpWav) { try { fs.unlinkSync(_kTmpWav); } catch {} _kTmpWav = null; }
3445
+ if (!_kClosed) { kBox.setLabel(` {red-fg}Playback failed{/red-fg} `); screen.render(); }
3446
+ });
3447
+ });
3448
+
3449
+ synthProc.on('error', () => {
3450
+ _stopDlAnim();
3451
+ _stopKSpinner();
3452
+ _kPreviewProc = null;
3453
+ _kTmpWav = null;
3454
+ try { fs.unlinkSync(tmpWav); } catch {}
3455
+ if (!_kClosed) {
3456
+ kBox.setLabel(` {red-fg}Python not found — run: pip install kokoro{/red-fg} `);
3457
+ screen.render();
3458
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 5000);
3459
+ }
3460
+ });
3461
+ });
3462
+
3463
+ // Toggle favorite (use '*' for consistency with every other picker)
3464
+ kPicker.key(['*', '+'], () => {
3465
+ const voiceId = voices[kPicker.selected];
3466
+ if (!voiceId) return;
3467
+ const favPath = path.join(os.homedir(), '.agentvibes', 'kokoro-favorites.json');
3468
+ if (_kokoroFavorites.has(voiceId)) {
3469
+ _kokoroFavorites.delete(voiceId);
3470
+ } else {
3471
+ _kokoroFavorites.add(voiceId);
3472
+ }
3473
+ try {
3474
+ fs.mkdirSync(path.join(os.homedir(), '.agentvibes'), { recursive: true });
3475
+ fs.writeFileSync(favPath, JSON.stringify([..._kokoroFavorites], null, 2));
3476
+ } catch {}
3477
+ // Rebuild picker items
3478
+ const newItems = voices.map(id => {
3479
+ return _kokoroItem(id);
3480
+ });
3481
+ kPicker.setItems(newItems);
3482
+ screen.render();
3483
+ });
3484
+
3485
+ // Download All — sequentially downloads every uncached voice
3486
+ let _dlAllActive = false;
3487
+ let _dlAllProc = null;
3488
+ kPicker.key(['d', 'D'], () => {
3489
+ if (_kPreviewProc || _dlAllActive) return;
3490
+ const toDownload = voices.filter(v => !cached.has(v));
3491
+ if (!toDownload.length) {
3492
+ kBox.setLabel(` {green-fg}✓ All ${voices.length} voices already cached{/green-fg} `);
3493
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 2500);
3494
+ screen.render();
3495
+ return;
3496
+ }
3497
+ _dlAllActive = true;
3498
+ let dlIdx = 0;
3499
+
3500
+ function _dlNext() {
3501
+ if (_kClosed || dlIdx >= toDownload.length) {
3502
+ _dlAllActive = false;
3503
+ _dlAllProc = null;
3504
+ if (!_kClosed) {
3505
+ const total = voices.filter(v => cached.has(v)).length;
3506
+ kBox.setLabel(` {green-fg}✓ Download complete — ${total}/${voices.length} cached{/green-fg} `);
3507
+ setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3508
+ screen.render();
3509
+ }
3510
+ return;
3511
+ }
3512
+ const voiceId = toDownload[dlIdx];
3513
+ const n = dlIdx + 1;
3514
+ kBox.setLabel(` {yellow-fg}☁ ${n}/${toDownload.length}: ${voiceId}...{/yellow-fg} `);
3515
+ screen.render();
3516
+
3517
+ const pyScript = path.join(packageDir, '.claude', 'hooks', 'kokoro-tts.py');
3518
+ let dlProc;
3519
+
3520
+ if (_isCjkVoice(voiceId)) {
3521
+ // CJK voices: skip synthesis (needs misaki[lang]), just download the .pt file
3522
+ try {
3523
+ dlProc = spawn(_pythonCmd, [pyScript, '--download-only', voiceId], { // NOSONAR
3524
+ stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env },
3525
+ });
3526
+ } catch { dlIdx++; _dlNext(); return; }
3527
+ _dlAllProc = dlProc;
3528
+ let dlOut = '';
3529
+ dlProc.stdout.on('data', d => { dlOut += d.toString(); });
3530
+ dlProc.on('exit', (code) => {
3531
+ _dlAllProc = null;
3532
+ if (_kClosed) return;
3533
+ if (code === 0 && dlOut.trim()) {
3534
+ cached.add(voiceId);
3535
+ const listIdx = voices.indexOf(voiceId);
3536
+ if (listIdx >= 0) kPicker.setItem(listIdx, _kokoroItem(voiceId));
3537
+ }
3538
+ dlIdx++; _dlNext();
3539
+ });
3540
+ dlProc.on('error', () => { _dlAllProc = null; dlIdx++; _dlNext(); });
3541
+ } else {
3542
+ const tmpWav = _secureTempWav('kokoro-dl');
3543
+ try {
3544
+ dlProc = spawn(_pythonCmd, [pyScript, 'hello', voiceId, tmpWav, '1.0'], { // NOSONAR
3545
+ stdio: ['ignore', 'ignore', 'pipe'],
3546
+ env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
3547
+ });
3548
+ } catch {
3549
+ dlIdx++;
3550
+ _dlNext();
3551
+ return;
3552
+ }
3553
+ _dlAllProc = dlProc;
3554
+
3555
+ let _buf = '', _dlPct = -1;
3556
+ const DL_W = 18;
3557
+ dlProc.stderr.on('data', (chunk) => {
3558
+ if (_kClosed) return;
3559
+ _buf += chunk.toString();
3560
+ const parts = _buf.split(/[\r\n]/);
3561
+ _buf = parts.pop() ?? '';
3562
+ for (const line of parts) {
3563
+ const m = line.match(/\b(\d{1,3})%\s*\|/);
3564
+ if (m) {
3565
+ const pct = Math.min(100, parseInt(m[1], 10));
3566
+ if (pct !== _dlPct) {
3567
+ _dlPct = pct;
3568
+ const filled = Math.round(pct * DL_W / 100);
3569
+ const bar = '{yellow-fg}' + '█'.repeat(filled) + '{/yellow-fg}'
3570
+ + '{#555555-fg}' + '░'.repeat(DL_W - filled) + '{/#555555-fg}';
3571
+ kBox.setLabel(` {yellow-fg}☁ ${n}/${toDownload.length} [${bar}] ${pct}% ${voiceId}{/yellow-fg} `);
3572
+ screen.render();
3573
+ }
3574
+ }
3575
+ }
3576
+ });
3577
+
3578
+ dlProc.on('exit', (code) => {
3579
+ _dlAllProc = null;
3580
+ try { fs.unlinkSync(tmpWav); } catch {}
3581
+ if (_kClosed) return;
3582
+ if (code === 0) {
3583
+ cached.add(voiceId);
3584
+ const listIdx = voices.indexOf(voiceId);
3585
+ if (listIdx >= 0) kPicker.setItem(listIdx, _kokoroItem(voiceId));
3586
+ }
3587
+ dlIdx++;
3588
+ _dlNext();
3589
+ });
3590
+ dlProc.on('error', () => { _dlAllProc = null; try { fs.unlinkSync(tmpWav); } catch {} dlIdx++; _dlNext(); });
3591
+ }
3592
+ }
3593
+ _dlNext();
3594
+ });
3595
+
3596
+ kPicker.key(['escape', 'q', 'Q'], _closeKP);
3597
+ kPicker.focus();
3598
+ screen.render();
3599
+ }
3600
+
2207
3601
  function _secureTempWav(prefix) {
2208
3602
  const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
2209
3603
  const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
@@ -2212,7 +3606,152 @@ export function createSetupTab(screen, services) {
2212
3606
  return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
2213
3607
  }
2214
3608
 
3609
+ // ElevenLabs voice picker — lists the static built-in premade voices.
3610
+ // Space previews the highlighted voice (real API call via the hook), Enter
3611
+ // selects it (stores the raw voice_id), Escape cancels.
3612
+ function _openElevenLabsVoicePicker(draft, onDone) {
3613
+ let _elClosed = false;
3614
+ let _elPreviewProc = null;
3615
+
3616
+ function _killElPreview() {
3617
+ if (_elPreviewProc) { try { _elPreviewProc.kill(); } catch {} _elPreviewProc = null; }
3618
+ }
3619
+ function _closeEl() {
3620
+ if (_elClosed) return;
3621
+ _elClosed = true;
3622
+ _killElPreview();
3623
+ navigationService?.closeModal();
3624
+ destroyList(elBox, screen, onDone);
3625
+ }
3626
+
3627
+ const _items = ELEVENLABS_VOICES.map(v => formatVoiceRow({ name: v.name, gender: v.gender, lang: v.lang, detail: v.desc }));
3628
+ const _height = Math.min(ELEVENLABS_VOICES.length + 5, Math.max(13, (screen.height || 24) - 4));
3629
+
3630
+ // Standardized chrome: titled box + pinned top help bar; transient
3631
+ // preview/error status shows in the title area.
3632
+ const _defaultHint = selectorTitle('Voice');
3633
+ const elBox = blessed.box({
3634
+ parent: screen,
3635
+ top: 'center',
3636
+ left: 'center',
3637
+ width: 56,
3638
+ height: _height,
3639
+ border: { type: 'line' },
3640
+ tags: true,
3641
+ label: _defaultHint,
3642
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
3643
+ });
3644
+ elBox.setFront();
3645
+ blessed.text({
3646
+ parent: elBox, top: 0, left: 1, right: 1, height: 1, tags: true,
3647
+ content: renderHelpBar([
3648
+ { key: 'Space', label: 'preview' },
3649
+ { key: 'Enter', label: 'select' },
3650
+ { key: 'Esc', label: 'cancel' },
3651
+ ]),
3652
+ style: { bg: COLORS.contentBg },
3653
+ });
3654
+
3655
+ const elPicker = blessed.list({
3656
+ parent: elBox,
3657
+ top: 1,
3658
+ left: 0,
3659
+ right: 0,
3660
+ bottom: 0,
3661
+ tags: true,
3662
+ items: _items,
3663
+ keys: true,
3664
+ vi: false,
3665
+ mouse: true,
3666
+ scrollbar: { ch: '|', style: { fg: 'cyan' } },
3667
+ style: {
3668
+ fg: COLORS.labelFg,
3669
+ bg: COLORS.contentBg,
3670
+ selected: { bg: 'green', fg: 'white', bold: true },
3671
+ item: { fg: COLORS.labelFg },
3672
+ },
3673
+ });
3674
+
3675
+ // Start on the currently-selected voice if it is one of ours.
3676
+ const _curIdx = ELEVENLABS_VOICES.findIndex(v => v.id === draft.voice);
3677
+ elPicker.select(_curIdx >= 0 ? _curIdx : 0);
3678
+
3679
+ function _setHint(text) { elBox.setLabel(text || _defaultHint); screen.render(); }
3680
+ _setHint(_defaultHint);
3681
+
3682
+ function _previewEl() {
3683
+ if (_elPreviewProc) { // toggle off
3684
+ _killElPreview();
3685
+ _setHint(_defaultHint);
3686
+ return;
3687
+ }
3688
+ const v = ELEVENLABS_VOICES[elPicker.selected];
3689
+ if (!v) return;
3690
+ const elScript = path.join(packageDir, '.claude', 'hooks', 'play-tts-elevenlabs.sh');
3691
+ let proc;
3692
+ try {
3693
+ proc = _spawnAudio(resolveBash(), [elScript, `Hi, I am ${v.name}.`, v.id], { // NOSONAR — local hook on user's PATH
3694
+ stdio: 'ignore',
3695
+ env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
3696
+ });
3697
+ } catch (e) {
3698
+ _setHint(' {red-fg}Preview failed to start{/red-fg} ');
3699
+ return;
3700
+ }
3701
+ _elPreviewProc = proc;
3702
+ _setHint(` {cyan-fg}♪ ${v.name}... (Space=stop){/cyan-fg} `);
3703
+ proc.on('exit', (code) => {
3704
+ _elPreviewProc = null;
3705
+ if (_elClosed) return;
3706
+ if (code && code !== 0) {
3707
+ _setHint(' {red-fg}Preview failed — check API key / plan{/red-fg} ');
3708
+ setTimeout(() => { if (!_elClosed) _setHint(_defaultHint); }, 3000);
3709
+ } else {
3710
+ _setHint(_defaultHint);
3711
+ }
3712
+ });
3713
+ proc.on('error', () => { _elPreviewProc = null; if (!_elClosed) _setHint(' {red-fg}Preview failed{/red-fg} '); });
3714
+ }
3715
+
3716
+ elPicker.key(['enter'], () => {
3717
+ const v = ELEVENLABS_VOICES[elPicker.selected];
3718
+ if (v) draft.voice = v.id;
3719
+ _closeEl();
3720
+ });
3721
+ elPicker.key(['space'], _previewEl);
3722
+ elPicker.key(['escape', 'q', 'Q'], _closeEl);
3723
+
3724
+ navigationService?.openModal(null, _closeEl);
3725
+ elPicker.focus();
3726
+ screen.render();
3727
+
3728
+ // Warn (non-blocking) if no API key is configured yet.
3729
+ const _elKeySet = Boolean(process.env.ELEVENLABS_API_KEY) || (() => {
3730
+ try {
3731
+ const kf = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
3732
+ return fs.existsSync(kf) && fs.readFileSync(kf, 'utf8').trim().length > 0;
3733
+ } catch { return false; }
3734
+ })();
3735
+ if (!_elKeySet) {
3736
+ _showApiKeyWarning('ElevenLabs', 'ELEVENLABS_API_KEY',
3737
+ path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt'),
3738
+ () => { if (!_elClosed) { elPicker.focus(); screen.render(); } });
3739
+ }
3740
+ }
3741
+
2215
3742
  function _openVoicePickerForLlm(draft, onDone, llmKey = '') {
3743
+ // Kokoro has its own multi-voice picker (voices scanned from HF cache)
3744
+ if (draft.ttsEngine === 'kokoro') {
3745
+ _openKokoroVoicePicker(draft, onDone, llmKey);
3746
+ return;
3747
+ }
3748
+
3749
+ // ElevenLabs has its own multi-voice picker (static built-in premade list)
3750
+ if (draft.ttsEngine === 'elevenlabs') {
3751
+ _openElevenLabsVoicePicker(draft, onDone);
3752
+ return;
3753
+ }
3754
+
2216
3755
  navigationService?.openModal(null, _closeVP);
2217
3756
 
2218
3757
  let _allVoices = [];
@@ -2271,37 +3810,47 @@ export function createSetupTab(screen, services) {
2271
3810
  _killNvPreview();
2272
3811
  if (_nvEnsureAbort) { _nvEnsureAbort.abort(); _nvEnsureAbort = null; }
2273
3812
  navigationService?.closeModal();
2274
- destroyList(nvPicker, screen, onDone);
3813
+ destroyList(nvBox, screen, onDone);
2275
3814
  }
2276
3815
 
2277
- const nvPicker = blessed.list({
3816
+ const NV_TITLE = selectorTitle('Voice');
3817
+ const NV_HELP = renderHelpBar([{ key: 'Space', label: 'preview' }, { key: 'Enter', label: 'select' }, { key: 'Esc', label: 'cancel' }]);
3818
+ // Standardized chrome: titled box + pinned top help bar; the single voice
3819
+ // row sits below. Status messages show in the title area.
3820
+ const nvBox = blessed.box({
2278
3821
  parent: screen,
2279
- top: 'center',
2280
- left: 'center',
2281
- width: 52,
2282
- height: 7,
2283
- border: { type: 'line' },
3822
+ top: 'center', left: 'center',
3823
+ width: 52, height: 7,
3824
+ border: { type: 'line' }, tags: true,
3825
+ label: NV_TITLE,
3826
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
3827
+ });
3828
+ nvBox.setFront();
3829
+ blessed.text({
3830
+ parent: nvBox, top: 0, left: 1, right: 1, height: 1, tags: true,
3831
+ content: NV_HELP, style: { bg: COLORS.contentBg },
3832
+ });
3833
+ const nvPicker = blessed.list({
3834
+ parent: nvBox,
3835
+ top: 1, left: 0, right: 0, bottom: 0,
2284
3836
  tags: true,
2285
- label: ' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ',
2286
3837
  keys: true,
2287
3838
  vi: false,
2288
3839
  mouse: true,
2289
3840
  style: {
2290
3841
  fg: COLORS.labelFg,
2291
3842
  bg: COLORS.contentBg,
2292
- border: { fg: 'cyan' },
2293
3843
  selected: { bg: 'green', fg: 'white', bold: true },
2294
3844
  item: { fg: COLORS.labelFg },
2295
3845
  },
2296
3846
  });
2297
- nvPicker.setFront();
2298
- nvPicker.setItems([` ${nativeVoice.label} {gray-fg}[Space] preview [Enter] select{/gray-fg}`]);
3847
+ nvPicker.setItems([` ${nativeVoice.label}`]);
2299
3848
  nvPicker.select(0);
2300
3849
 
2301
3850
  function _previewNativeVoice() {
2302
3851
  if (_nvPreviewProc) {
2303
3852
  _killNvPreview();
2304
- nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ');
3853
+ nvBox.setLabel(NV_TITLE);
2305
3854
  screen.render();
2306
3855
  return;
2307
3856
  }
@@ -2310,44 +3859,44 @@ export function createSetupTab(screen, services) {
2310
3859
 
2311
3860
  function _spawnAndTrack(cmd, args, opts) {
2312
3861
  let proc;
2313
- try { proc = spawn(cmd, args, opts); } catch (e) {
3862
+ try { proc = _spawnAudio(cmd, args, opts); } catch (e) {
2314
3863
  process.stderr.write(`[AgentVibes] preview spawn failed: ${e.message}\n`);
2315
- if (!_nvClosed) { nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
3864
+ if (!_nvClosed) { nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
2316
3865
  return;
2317
3866
  }
2318
3867
  _nvPreviewProc = proc;
2319
- nvPicker.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
3868
+ nvBox.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
2320
3869
  screen.render();
2321
3870
  proc.on('exit', (code) => {
2322
3871
  _nvPreviewProc = null;
2323
3872
  if (!_nvClosed) {
2324
3873
  if (code !== 0 && code !== null) {
2325
- nvPicker.setLabel(` {red-fg}Preview failed (exit ${code}){/red-fg} `);
2326
- setTimeout(() => { if (!_nvClosed) { nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} '); screen.render(); } }, 3000);
3874
+ nvBox.setLabel(` {red-fg}Preview failed (exit ${code}){/red-fg} `);
3875
+ setTimeout(() => { if (!_nvClosed) { nvBox.setLabel(NV_TITLE); screen.render(); } }, 3000);
2327
3876
  } else {
2328
- nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ');
3877
+ nvBox.setLabel(NV_TITLE);
2329
3878
  }
2330
3879
  screen.render();
2331
3880
  }
2332
3881
  });
2333
3882
  proc.on('error', () => {
2334
3883
  _nvPreviewProc = null;
2335
- if (!_nvClosed) { nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
3884
+ if (!_nvClosed) { nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
2336
3885
  });
2337
3886
  }
2338
3887
 
2339
3888
  if (engine === 'soprano' && process.platform === 'win32' && !process.env.WSL_DISTRO_NAME) {
2340
3889
  // Ensure soprano WebUI is running before preview; start it if not.
2341
- nvPicker.setLabel(' {cyan-fg}Checking Soprano...{/cyan-fg} ');
3890
+ nvBox.setLabel(' {cyan-fg}Checking Soprano...{/cyan-fg} ');
2342
3891
  screen.render();
2343
3892
  _nvEnsureAbort = new AbortController();
2344
3893
  _ensureSopranoWebUI((msg) => {
2345
- if (!_nvClosed) { nvPicker.setLabel(` {cyan-fg}${msg}{/cyan-fg} `); screen.render(); }
3894
+ if (!_nvClosed) { nvBox.setLabel(` {cyan-fg}${msg}{/cyan-fg} `); screen.render(); }
2346
3895
  }, _nvEnsureAbort.signal).then((ready) => {
2347
3896
  _nvEnsureAbort = null;
2348
3897
  if (_nvClosed) return;
2349
3898
  if (!ready) {
2350
- nvPicker.setLabel(' {red-fg}Soprano WebUI failed to start{/red-fg} ');
3899
+ nvBox.setLabel(' {red-fg}Soprano WebUI failed to start{/red-fg} ');
2351
3900
  screen.render();
2352
3901
  return;
2353
3902
  }
@@ -2360,30 +3909,36 @@ export function createSetupTab(screen, services) {
2360
3909
  let proc = null;
2361
3910
  try {
2362
3911
  if (engine === 'soprano') {
2363
- proc = spawn('soprano', [phrase], { stdio: 'ignore' }); // NOSONAR
3912
+ proc = _spawnAudio('soprano', [phrase], { stdio: 'ignore' }); // NOSONAR
2364
3913
  } else if (engine === 'sapi') {
2365
3914
  const safePhrase = phrase.replace(/'/g, "''");
2366
3915
  const sapiScript = `Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${safePhrase}')`;
2367
- proc = spawn('powershell', ['-NoProfile', '-Command', sapiScript], { stdio: 'ignore', windowsHide: true }); // NOSONAR
3916
+ proc = _spawnAudio('powershell', ['-NoProfile', '-Command', sapiScript], { stdio: 'ignore', windowsHide: true }); // NOSONAR
2368
3917
  } else if (engine === 'macos-say') {
2369
- proc = spawn('say', [phrase], { stdio: 'ignore' }); // NOSONAR
3918
+ proc = _spawnAudio('say', [phrase], { stdio: 'ignore' }); // NOSONAR
3919
+ } else if (engine.startsWith('elevenlabs')) {
3920
+ const elScript = path.join(packageDir, '.claude', 'hooks', 'play-tts-elevenlabs.sh');
3921
+ proc = _spawnAudio(resolveBash(), [elScript, phrase, 'Rachel'], { // NOSONAR
3922
+ stdio: 'ignore',
3923
+ env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
3924
+ });
2370
3925
  }
2371
3926
  } catch {}
2372
3927
  if (!proc) {
2373
- nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} ');
3928
+ nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} ');
2374
3929
  screen.render();
2375
3930
  return;
2376
3931
  }
2377
3932
  _nvPreviewProc = proc;
2378
- nvPicker.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
3933
+ nvBox.setLabel(` {cyan-fg}♪ ${nativeVoice.label}... (Space=stop){/cyan-fg} `);
2379
3934
  screen.render();
2380
3935
  proc.on('exit', () => {
2381
3936
  _nvPreviewProc = null;
2382
- if (!_nvClosed) { nvPicker.setLabel(' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} '); screen.render(); }
3937
+ if (!_nvClosed) { nvBox.setLabel(NV_TITLE); screen.render(); }
2383
3938
  });
2384
3939
  proc.on('error', () => {
2385
3940
  _nvPreviewProc = null;
2386
- if (!_nvClosed) { nvPicker.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
3941
+ if (!_nvClosed) { nvBox.setLabel(' {red-fg}Engine not installed{/red-fg} '); screen.render(); }
2387
3942
  });
2388
3943
  }
2389
3944
 
@@ -2392,6 +3947,25 @@ export function createSetupTab(screen, services) {
2392
3947
  nvPicker.key(['escape', 'q', 'Q'], _closeNV);
2393
3948
  nvPicker.focus();
2394
3949
  screen.render();
3950
+
3951
+ // ElevenLabs: if no API key is set, show a warning on top of the picker.
3952
+ // The picker stays open; user dismisses the warning and can still select/preview.
3953
+ if (draft.ttsEngine === 'elevenlabs') {
3954
+ const _elKeySet = Boolean(process.env.ELEVENLABS_API_KEY) || (() => {
3955
+ try {
3956
+ const kf = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
3957
+ return fs.existsSync(kf) && fs.readFileSync(kf, 'utf8').trim().length > 0;
3958
+ } catch { return false; }
3959
+ })();
3960
+ if (!_elKeySet) {
3961
+ _showApiKeyWarning(
3962
+ 'ElevenLabs',
3963
+ 'ELEVENLABS_API_KEY',
3964
+ path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt'),
3965
+ () => { if (!_nvClosed) { nvPicker.focus(); screen.render(); } }
3966
+ );
3967
+ }
3968
+ }
2395
3969
  return;
2396
3970
  }
2397
3971
 
@@ -2403,22 +3977,33 @@ export function createSetupTab(screen, services) {
2403
3977
  height: '88%',
2404
3978
  border: { type: 'line' },
2405
3979
  tags: true,
2406
- label: ' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ',
3980
+ label: selectorTitle('Voice'),
2407
3981
  style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
2408
3982
  });
2409
3983
  vpModal.setFront();
2410
3984
 
2411
- // Column header
2412
- const COL_N = 30;
2413
- const COL_G = 4;
3985
+ // Standardized help bar, pinned at the top (under the title).
3986
+ blessed.text({
3987
+ parent: vpModal, top: 0, left: 2, right: 2, height: 1, tags: true,
3988
+ content: renderHelpBar([
3989
+ { key: 'Space', label: 'preview' },
3990
+ { key: 'Enter', label: 'select' },
3991
+ { key: '+', label: 'like' },
3992
+ { key: '-', label: 'dislike' },
3993
+ { key: 'Esc', label: 'cancel' },
3994
+ ]),
3995
+ style: { bg: COLORS.contentBg },
3996
+ });
3997
+
3998
+ // Column header — matches the shared formatVoiceRow column layout.
2414
3999
  blessed.text({
2415
- parent: vpModal, top: 1, left: 6, tags: true,
2416
- content: `{cyan-fg}${'Name'.padEnd(COL_N)}{/cyan-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {cyan-fg}Provider{/cyan-fg}`,
4000
+ parent: vpModal, top: 2, left: 3, tags: true,
4001
+ content: voiceRowHeader(),
2417
4002
  style: { bg: COLORS.contentBg },
2418
4003
  });
2419
4004
 
2420
4005
  const vpList = blessed.list({
2421
- parent: vpModal, top: 2, left: 2, right: 2, bottom: 5,
4006
+ parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
2422
4007
  keys: true, vi: true, mouse: true,
2423
4008
  border: { type: 'line' },
2424
4009
  scrollbar: { ch: '|', style: { fg: 'cyan' } },
@@ -2436,15 +4021,15 @@ export function createSetupTab(screen, services) {
2436
4021
  content: ' ', style: { fg: 'cyan', bg: COLORS.contentBg },
2437
4022
  });
2438
4023
 
2439
- // Footer split into two fixed-height lines so wrapping never covers vpPreviewLine
2440
- blessed.text({
2441
- parent: vpModal, bottom: 3, left: 2, right: 2, height: 1, tags: true,
2442
- content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [a-z] Jump{/white-fg}',
2443
- style: { bg: COLORS.contentBg },
2444
- });
4024
+ // Movement hints at the bottom (primary actions live in the top help bar, so
4025
+ // they are not duplicated here). Standardized [key] = label formatting.
2445
4026
  blessed.text({
2446
4027
  parent: vpModal, bottom: 2, left: 2, right: 2, height: 1, tags: true,
2447
- content: '{white-fg}[Enter] Select [Space] Preview [+] 👍 [-] 👎 [Esc] Cancel{/white-fg}',
4028
+ content: renderHelpBar([
4029
+ { key: '↑↓', label: 'move' },
4030
+ { key: 'PgUp/PgDn', label: 'page' },
4031
+ { key: 'a-z', label: 'jump' },
4032
+ ]),
2448
4033
  style: { bg: COLORS.contentBg },
2449
4034
  });
2450
4035
 
@@ -2459,11 +4044,10 @@ export function createSetupTab(screen, services) {
2459
4044
  const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
2460
4045
  const star = isUp ? '{green-fg}👍{/green-fg}' : (isDown ? '{red-fg}👎{/red-fg}' : ' ');
2461
4046
  const meta = getVoiceMeta(v);
2462
- const name = meta.displayName.length > COL_N
2463
- ? meta.displayName.slice(0, COL_N - 1) + '…'
2464
- : meta.displayName.padEnd(COL_N);
2465
- // genderIconTag has invisible color tags pad with literal spaces (1 visible char + 3 spaces = 4)
2466
- return ` ${dot}${star} ${name}${genderIconTag(meta.gender)} ${meta.provider}`;
4047
+ // Derive the language tag from the Piper voice id prefix (e.g. en_US-… → en-US).
4048
+ const _lm = v.match(/^([a-z]{2})_([A-Z]{2})/);
4049
+ const lang = _lm ? `${_lm[1]}-${_lm[2]}` : '';
4050
+ return formatVoiceRow({ status: `${dot}${star}`, name: meta.displayName, gender: meta.gender, lang, detail: meta.provider });
2467
4051
  });
2468
4052
  }
2469
4053
 
@@ -2525,7 +4109,7 @@ export function createSetupTab(screen, services) {
2525
4109
  } else {
2526
4110
  const _playTts = path.join(_hooksBase, '.claude', 'hooks', 'play-tts.sh');
2527
4111
  const _llmArgs = llmKey ? ['--llm', llmKey] : [];
2528
- rProc = spawn('bash', [_playTts, phrase, voiceId, ..._llmArgs], { // NOSONAR
4112
+ rProc = spawn(resolveBash(), [_playTts, phrase, voiceId, ..._llmArgs], { // NOSONAR
2529
4113
  stdio: 'ignore', detached: true, env: _rEnv, cwd: targetDir,
2530
4114
  });
2531
4115
  }
@@ -2562,7 +4146,7 @@ export function createSetupTab(screen, services) {
2562
4146
 
2563
4147
  const args = ['--model', voicePath, '--output_file', tempWav];
2564
4148
  if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
2565
- const piper = spawn(_piperBin, args, {
4149
+ const piper = _spawnAudio(_piperBin, args, {
2566
4150
  stdio: ['pipe', 'ignore', 'ignore'],
2567
4151
  detached: !_isWin,
2568
4152
  windowsHide: true,
@@ -3175,17 +4759,19 @@ export function createSetupTab(screen, services) {
3175
4759
  const ok = () => `{green-fg}OK ${t(_getLang(), 'installed')}{/green-fg}`;
3176
4760
  const bad = () => `{red-fg}X ${t(_getLang(), 'notFound')}{/red-fg}`;
3177
4761
 
3178
- const ttsOk = _deps.piper || _deps.soprano;
4762
+ const ttsOk = _deps.piper || _deps.soprano || _deps.kokoro || _deps.elevenlabs;
3179
4763
  contentBox.setContent(_c([
3180
4764
  _HDR('', t(_getLang(), 'dependencyCheck')),
3181
4765
  '',
3182
- ` {white-fg}${'Dependency'.padEnd(14)}${'Status'}{/white-fg}`,
4766
+ ` {white-fg}${'Dependency'.padEnd(16)}${'Status'}{/white-fg}`,
3183
4767
  ` {white-fg}${'---'.repeat(26)}{/white-fg}`,
3184
- ` {white-fg}${'Node.js'.padEnd(14)}{/white-fg}${_deps.node ? ok() : bad()}`,
3185
- ` {white-fg}${'npm'.padEnd(14)}{/white-fg}${_deps.npm ? ok() : bad()}`,
3186
- ` {white-fg}${'Piper TTS'.padEnd(14)}{/white-fg}${_deps.piper ? ok() : bad()}`,
3187
- ` {white-fg}${'Soprano TTS'.padEnd(14)}{/white-fg}${_deps.soprano ? ok() : bad()}`,
3188
- ` {white-fg}${'ffmpeg'.padEnd(14)}{/white-fg}${_deps.ffmpeg ? ok() : `{red-fg}! ${t(_getLang(), 'ffmpegMissing')}{/red-fg}`}`,
4768
+ ` {white-fg}${'Node.js'.padEnd(16)}{/white-fg}${_deps.node ? ok() : bad()}`,
4769
+ ` {white-fg}${'npm'.padEnd(16)}{/white-fg}${_deps.npm ? ok() : bad()}`,
4770
+ ` {white-fg}${'Piper TTS'.padEnd(16)}{/white-fg}${_deps.piper ? ok() : bad()}`,
4771
+ ` {white-fg}${'Kokoro TTS'.padEnd(16)}{/white-fg}${_deps.kokoro ? ok() : `{#546e7a-fg}- optional{/#546e7a-fg}`}`,
4772
+ ` {white-fg}${'ElevenLabs'.padEnd(16)}{/white-fg}${_deps.elevenlabs ? ok() : `{#546e7a-fg}- needs API key{/#546e7a-fg}`}`,
4773
+ ` {white-fg}${'Soprano TTS'.padEnd(16)}{/white-fg}${_deps.soprano ? ok() : `{#546e7a-fg}- optional{/#546e7a-fg}`}`,
4774
+ ` {white-fg}${'ffmpeg'.padEnd(16)}{/white-fg}${_deps.ffmpeg ? ok() : `{red-fg}! ${t(_getLang(), 'ffmpegMissing')}{/red-fg}`}`,
3189
4775
  '',
3190
4776
  ttsOk
3191
4777
  ? ` {green-fg}OK ${t(_getLang(), 'ttsDetected')}{/green-fg}`
@@ -3193,11 +4779,9 @@ export function createSetupTab(screen, services) {
3193
4779
  '',
3194
4780
  '',
3195
4781
  ]));
3196
- if (ttsOk) {
3197
- _s1ContinueBtn.setContent(_tl('continueArrowBtn'));
3198
- _s1ContinueBtn.show();
3199
- _s1ContinueBtn.focus();
3200
- }
4782
+ _s1ContinueBtn.setContent(ttsOk ? _tl('continueArrowBtn') : ' Install TTS ->');
4783
+ _s1ContinueBtn.show();
4784
+ _s1ContinueBtn.focus();
3201
4785
  screen.render();
3202
4786
  }
3203
4787
 
@@ -3270,7 +4854,7 @@ export function createSetupTab(screen, services) {
3270
4854
 
3271
4855
  // Show provider rows instead of contentBox
3272
4856
  contentBox.hide();
3273
- hintLine.setContent(' Screen 3: LLM Providers | [Enter] Action | [Tab] Next button | [Esc] Tab bar');
4857
+ hintLine.setContent(' Screen 3: LLM Providers | [Enter] Action | [Tab] Next button | [Esc] TTS Engines');
3274
4858
  showAllProviderRows();
3275
4859
  refreshInstalledState().then(() => {
3276
4860
  if (providerFocusableItems.length) {