agentvibes 4.6.8 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.agentvibes/bmad-voice-map.json +104 -0
  2. package/.agentvibes/config.json +13 -12
  3. package/.agentvibes/copilot-sessions.log +4 -0
  4. package/.claude/audio/tracks/Drifting Down the Hall.mp3 +0 -0
  5. package/.claude/audio/tracks/Late Night Hip Hop Groove.mp3 +0 -0
  6. package/.claude/audio/tracks/Midnight Charleston Stomp.mp3 +0 -0
  7. package/.claude/audio/tracks/README.md +51 -52
  8. package/.claude/config/audio-effects-bmad.cfg +50 -0
  9. package/.claude/config/audio-effects.cfg +4 -4
  10. package/.claude/config/background-music-enabled.txt +1 -0
  11. package/.claude/config/personality.txt +1 -0
  12. package/.claude/hooks/play-tts-piper.sh +3 -1
  13. package/.claude/hooks/play-tts.sh +380 -301
  14. package/.claude/hooks/session-start-tts.sh +81 -81
  15. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  16. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  17. package/.claude/hooks-windows/play-tts.ps1 +28 -6
  18. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  19. package/README.md +112 -6
  20. package/RELEASE_NOTES.md +83 -0
  21. package/bin/bmad-speak.js +16 -8
  22. package/mcp-server/server.py +15 -8
  23. package/package.json +1 -1
  24. package/src/console/app.js +899 -897
  25. package/src/console/footer-config.js +50 -50
  26. package/src/console/navigation.js +65 -65
  27. package/src/console/tabs/agents-tab.js +1899 -1886
  28. package/src/console/tabs/music-tab.js +1076 -1039
  29. package/src/console/tabs/placeholder-tab.js +81 -80
  30. package/src/console/tabs/settings-tab.js +941 -3988
  31. package/src/console/tabs/setup-tab.js +2071 -0
  32. package/src/console/tabs/voices-tab.js +1843 -1714
  33. package/src/console/widgets/format-utils.js +92 -89
  34. package/src/console/widgets/track-picker.js +325 -322
  35. package/src/installer.js +6147 -6092
  36. package/src/services/llm-provider-service.js +486 -0
  37. package/src/services/navigation-service.js +123 -123
  38. package/src/services/tts-engine-service.js +69 -0
  39. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  40. package/src/console/tabs/install-tab.js +0 -1081
@@ -1,3988 +1,941 @@
1
- /**
2
- * AgentVibes TUI Console — Settings Tab
3
- * Stories 7.1 (Provider & Voice) + 7.2 (Audio Effects)
4
- *
5
- * Implements the Tab Component Contract:
6
- * createSettingsTab(screen, services) { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
- *
8
- * Groups 1-2 implemented. Groups 3-5 added in stories 7.3-7.5.
9
- * Button-level focus navigation (↑↓←→) implemented in story 7.6.
10
- */
11
-
12
- import fs from 'node:fs';
13
- import http from 'node:http';
14
- import path from 'node:path';
15
- import os from 'node:os';
16
- import { randomUUID } from 'node:crypto';
17
- import { spawn, spawnSync } from 'node:child_process';
18
- import {
19
- PIPER_VOICES_DIR, COL_NAME_W, COL_GENDER_W, SAMPLE_PHRASES,
20
- parseVoiceId, parseMultiSpeaker, scanInstalledVoices, getVoiceMeta, getFavorites, toggleFavorite,
21
- } from './voices-tab.js';
22
- import { LanguageService } from '../../services/language-service.js';
23
- import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
24
- import { formatTrackLabel, scanTracks, getMusicFavorites, toggleMusicFavorite, applyTrackToAudioEffects } from './music-tab.js';
25
- import { BRAND_PINK, BRAND_BLUE } from '../brand-colors.js';
26
- import { buildAudioEnv, detectMp3Player, detectWavPlayer } from '../audio-env.js';
27
- import { destroyList } from '../widgets/destroy-list.js';
28
- import { openReverbPicker } from '../widgets/reverb-picker.js';
29
- import { openPersonalityPicker } from '../widgets/personality-picker.js';
30
- import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
31
- import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
32
- import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
33
-
34
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
35
- const _IS_WINDOWS = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
36
-
37
- /** Resolve piper binary path — uses exe on Windows, 'piper' in PATH on Unix */
38
- function _resolvePiperBin() {
39
- if (_IS_WINDOWS) {
40
- const lad = process.env.LOCALAPPDATA ||
41
- (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
42
- if (lad) {
43
- // Standalone binary install
44
- const exe = path.join(lad, 'Programs', 'Piper', 'piper.exe');
45
- if (fs.existsSync(exe)) return exe;
46
- // pip-installed piper (Python Scripts directory)
47
- const pyScripts = path.join(lad, 'Programs', 'Python');
48
- try {
49
- const pyDirs = fs.readdirSync(pyScripts).filter(d => d.startsWith('Python'));
50
- for (const d of pyDirs) {
51
- const pipExe = path.join(pyScripts, d, 'Scripts', 'piper.exe');
52
- if (fs.existsSync(pipExe)) return pipExe;
53
- }
54
- } catch { /* no Python installs */ }
55
- }
56
- }
57
- return 'piper';
58
- }
59
-
60
- /** Build spawn options with Windows-safe defaults (no visible console, no detached) */
61
- function _spawnOpts(env, extraOpts = {}) {
62
- return { stdio: 'ignore', detached: !_IS_WINDOWS, windowsHide: true, env, ...extraOpts };
63
- }
64
-
65
- // Sanitize strings before passing as env vars to shell commands.
66
- // Removes characters that could cause shell injection when expanded inside sh -c.
67
- function _sanitizeForShell(str) {
68
- return str.replace(/[`$\\(){}!]/g, '');
69
- }
70
-
71
- // Lazy-load blessed only in non-test mode (avoids screen requirement in tests)
72
- let blessed;
73
- if (!IS_TEST) {
74
- const { default: b } = await import('blessed');
75
- blessed = b;
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // Brand colours (matches architecture.md + UX design plan)
80
-
81
- // Modal label helper — wraps text in BRAND_PINK for consistent modal titles
82
- const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
83
-
84
- const COLORS = {
85
- contentBg: '#0a0e1a', // Near-black content background
86
- sectionHdr: 'bright-cyan', // Matches header "Agent" color
87
- labelFg: '#e3f2fd', // Light blue text — labels
88
- valueFg: '#ffff00', // Yellow — current values
89
- btnDefault: '#37474f', // Dark slate — default button bg
90
- btnFocus: '#2e7d32', // Green focused/selected button bg
91
- btnFocusFg: '#ffffff', // White focused button text
92
- btnPress: '#ff00ff', // Magenta pressed button bg
93
- btnChange: '#37474f', // Dark slate — Change buttons
94
- btnTest: '#37474f', // Dark slate — Test buttons
95
- btnEdit: '#37474f', // Dark slate — Edit buttons
96
- btnEnableOn: '#37474f', // Dark slate — Enabled toggle
97
- btnEnableOff: '#37474f', // Dark slate — Disabled toggle
98
- borderFg: 'bright-cyan', // Matches section headers
99
- footerBg: '#2196f3', // Blue — settings footer
100
- noticeFg: '#90a4ae', // Gray — stub notice text
101
- };
102
-
103
- const FOOTER_TEXT =
104
- '[↑↓] Group [←→] Sibling/Sub-tab [Enter/Space] Activate [Tab] Switch Tab [Q] Quit';
105
- // (legacy constant kept for test stub; UI uses _getFooter() which reads current language)
106
-
107
- // Default effects — single source of truth (used by _getEffects, _setEffects, refreshDisplay)
108
- const EFFECTS_DEFAULTS = Object.freeze({ reverbPreset: 'light' });
109
-
110
- // Default background music config
111
- const MUSIC_DEFAULTS = Object.freeze({ enabled: false, track: 'agentvibes_soft_flamenco_loop.mp3', volume: 20 });
112
-
113
- // Verbosity display labels
114
- const VERBOSITY_LABELS = Object.freeze({ high: 'High', medium: 'Medium', low: 'Low', minimal: 'Minimal', custom: 'Custom' });
115
-
116
- // Personality emojis and names imported from src/console/constants/personalities.js
117
- // (via the import at the top of this file)
118
-
119
- // Human-readable track display names moved to shared widgets/format-utils.js
120
- // TRACK_NAMES constant removed (M1 dedup). Use formatTrackName() instead.
121
-
122
- // Built-in track list for the picker (fallback when tracks dir is missing)
123
- const BUILT_IN_TRACKS = [
124
- { label: '🎻 Soft Flamenco', file: 'agentvibes_soft_flamenco_loop.mp3' },
125
- { label: '🌸 Bossa Nova', file: 'agent_vibes_bossa_nova_v2_loop.mp3' },
126
- { label: '🌊 Chillwave', file: 'agent_vibes_chillwave_v2_loop.mp3' },
127
- { label: '🪘 Gnawa Ambient', file: 'agent_vibes_ganawa_ambient_v2_loop.mp3' },
128
- ];
129
-
130
- // ---------------------------------------------------------------------------
131
- // Exported format helpers (pure functions — used by tests and UI)
132
-
133
- /**
134
- * @param {string} preset - 'off' | 'light' | 'medium' | 'heavy' | 'cathedral'
135
- * @returns {string}
136
- */
137
- export const formatReverbState = _sharedFormatReverbState;
138
-
139
- /**
140
- * @param {boolean} enabled
141
- * @returns {string}
142
- */
143
- export function formatMusicState(enabled) {
144
- return enabled ? 'Enabled' : 'Disabled';
145
- }
146
-
147
- /**
148
- * @param {number} volume - integer 10–100
149
- * @returns {string}
150
- */
151
- export function formatVolume(volume) {
152
- const v = typeof volume === 'number' && !isNaN(volume) ? volume : MUSIC_DEFAULTS.volume;
153
- return `${Math.max(10, Math.min(100, v))}%`;
154
- }
155
-
156
- /**
157
- * @param {string} track - filename (e.g. 'agentvibes_soft_flamenco_loop.mp3')
158
- * @returns {string}
159
- */
160
- export const formatTrackName = _sharedFormatTrackName;
161
-
162
- /**
163
- * @param {string} verbosity - 'high' | 'medium' | 'low'
164
- * @returns {string}
165
- */
166
- export function formatVerbosity(verbosity) {
167
- return VERBOSITY_LABELS[verbosity] ?? 'High';
168
- }
169
-
170
- /**
171
- * @param {string} personality
172
- * @returns {string}
173
- */
174
- export function formatPersonality(personality) {
175
- const name = personality || 'none';
176
- const emoji = PERSONALITY_EMOJIS[name] ?? '✨';
177
- const label = name === 'none' ? 'None' : name.charAt(0).toUpperCase() + name.slice(1);
178
- return `${emoji} ${label}`;
179
- }
180
-
181
- /**
182
- * @param {string} pretext - intro text (max 50 chars from installer)
183
- * @returns {string}
184
- */
185
- export function formatIntroText(pretext) {
186
- if (!pretext) return '(none)';
187
- return pretext.length > 30 ? pretext.slice(0, 30) + '' : pretext;
188
- }
189
-
190
- // ---------------------------------------------------------------------------
191
- // Test stub — returned in AGENTVIBES_TEST_MODE to avoid blessed widgets
192
-
193
- function createTestStub() {
194
- return {
195
- box: {},
196
- show: () => {},
197
- hide: () => {},
198
- onFocus: () => {},
199
- onBlur: () => {},
200
- getFooterText: () => FOOTER_TEXT,
201
- getFooterColor: () => COLORS.footerBg,
202
- };
203
- }
204
-
205
- // ---------------------------------------------------------------------------
206
-
207
- /**
208
- * Create the Settings tab component.
209
- * Follows the Tab Component Contract defined in architecture.md.
210
- *
211
- * @param {object} screen - Blessed screen instance (or test stub)
212
- * @param {object} services
213
- * @param {import('../../services/config-service.js').ConfigService} services.configService
214
- * @param {import('../../services/provider-service.js').ProviderService} services.providerService
215
- * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
216
- */
217
- export function createSettingsTab(screen, services) {
218
- if (IS_TEST) return createTestStub();
219
-
220
- const { configService, providerService, navigationService, focusMainTabBar, focusFirstHeaderItem, focusLastHeaderItem, updateHeaderStatus } = services;
221
- const _getLang = () => services.languageService?.getLang() ?? 'en';
222
- const _t = (key) => t(_getLang(), key);
223
-
224
- // Re-render all labels immediately when language changes (live update, no tab switch needed)
225
- services.languageService?.onChange(() => {
226
- refreshLabels();
227
- screen.render();
228
- });
229
-
230
- // Playback state for the voice sample button
231
- let _sampleProcess = null;
232
- let _samplePlaying = false;
233
-
234
- // soprano-manager.sh wait-proc — tracked separately so killing it does NOT kill soprano-webui
235
- let _sopranoMgrProc = null;
236
-
237
- // Soprano WebUI status glyph — updated asynchronously when provider is soprano
238
- let _sopranoStatusGlyph = '';
239
- let _sopranoStatusProc = null;
240
-
241
- const _sampleEnv = buildAudioEnv();
242
-
243
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
244
-
245
- const SPINNER_PROCESSING_BG = '#00838f'; // teal — distinct from blue default and cyan focus
246
-
247
- // Single-button spinner (▶ Play, music Test)
248
- let _spinnerTimer = null;
249
- let _spinnerIdx = 0;
250
- let _spinnerBtn = null;
251
-
252
- function _startSpinner(btn, label) {
253
- _stopSpinner(); // Clear any running spinner so label updates don't leak intervals
254
- _spinnerBtn = btn;
255
- _spinnerIdx = 0;
256
- btn.style.bg = SPINNER_PROCESSING_BG;
257
- btn.setContent(`${SPINNER_FRAMES[0]} ${label}`);
258
- screen.render();
259
- _spinnerTimer = setInterval(() => {
260
- _spinnerIdx = (_spinnerIdx + 1) % SPINNER_FRAMES.length;
261
- btn.setContent(`${SPINNER_FRAMES[_spinnerIdx]} ${label}`);
262
- screen.render();
263
- }, 100);
264
- }
265
-
266
- function _stopSpinner() {
267
- if (_spinnerTimer) { clearInterval(_spinnerTimer); _spinnerTimer = null; }
268
- if (_spinnerBtn) { _spinnerBtn.style.bg = COLORS.btnDefault; _spinnerBtn = null; }
269
- }
270
-
271
- // Multi-button spinner for _testBtns (reverb Test + Full Preview — each keeps its own label)
272
- let _testSpinnerTimer = null;
273
- let _testSpinnerIdx = 0;
274
- // Populated alongside _testBtns so we know each button's rest label
275
- const _testBtnLabels = new Map();
276
-
277
- function _startTestSpinner() {
278
- _testSpinnerIdx = 0;
279
- for (const b of _testBtns) {
280
- b.style.bg = SPINNER_PROCESSING_BG;
281
- b.setContent(`${SPINNER_FRAMES[0]} ${_testBtnLabels.get(b) ?? _t('previewBtn')}`);
282
- }
283
- screen.render();
284
- _testSpinnerTimer = setInterval(() => {
285
- _testSpinnerIdx = (_testSpinnerIdx + 1) % SPINNER_FRAMES.length;
286
- for (const b of _testBtns) {
287
- b.setContent(`${SPINNER_FRAMES[_testSpinnerIdx]} ${_testBtnLabels.get(b) ?? _t('previewBtn')}`);
288
- }
289
- screen.render();
290
- }, 100);
291
- }
292
-
293
- function _stopTestSpinner() {
294
- if (_testSpinnerTimer) { clearInterval(_testSpinnerTimer); _testSpinnerTimer = null; }
295
- for (const b of _testBtns) { b.style.bg = COLORS.btnDefault; }
296
- }
297
-
298
- function _killSample() {
299
- _stopSpinner();
300
- // Kill manager wait-proc with direct SIGTERM (NOT process group) so soprano-webui keeps running
301
- if (_sopranoMgrProc) {
302
- try { _sopranoMgrProc.kill('SIGTERM'); } catch {}
303
- _sopranoMgrProc = null;
304
- }
305
- if (_sampleProcess) {
306
- if (_IS_WINDOWS) {
307
- try { _sampleProcess.kill(); } catch {}
308
- } else {
309
- try { process.kill(-_sampleProcess.pid, 'SIGTERM'); } catch {}
310
- }
311
- _sampleProcess = null;
312
- }
313
- _samplePlaying = false;
314
- }
315
-
316
- // Test button state (shared for reverb [Test])
317
- let _testActive = false;
318
- let _testMusicProc = null;
319
- let _testVoiceProc = null;
320
- let _testTimeout = null;
321
- let _testInitiatorBtn = null; // button that started the current test (restored on completion)
322
- const _testBtns = []; // populated after button creation
323
-
324
- // Music-only test state (background music [Test] — no voice synthesis)
325
- let _musicTestActive = false;
326
- let _musicTestProc = null;
327
-
328
- // Config Storage snapshot — taken when tab is shown, used by Cancel Changes
329
- let _snapshotGlobal = null;
330
- let _snapshotLocal = null;
331
-
332
- function _captureSnapshot() {
333
- try {
334
- _snapshotGlobal = JSON.parse(JSON.stringify(configService.getGlobalConfig()));
335
- const local = configService.getProjectConfig();
336
- _snapshotLocal = local ? JSON.parse(JSON.stringify(local)) : null;
337
- } catch {
338
- _snapshotGlobal = {};
339
- _snapshotLocal = null;
340
- }
341
- }
342
-
343
- const _testEnv = buildAudioEnv();
344
-
345
- function _killTest() {
346
- _stopTestSpinner();
347
- if (_testTimeout) { clearTimeout(_testTimeout); _testTimeout = null; }
348
- if (_testMusicProc) { try { _IS_WINDOWS ? _testMusicProc.kill() : process.kill(-_testMusicProc.pid, 'SIGTERM'); } catch {} _testMusicProc = null; }
349
- if (_testVoiceProc) { try { _IS_WINDOWS ? _testVoiceProc.kill() : process.kill(-_testVoiceProc.pid, 'SIGTERM'); } catch {} _testVoiceProc = null; }
350
- _testActive = false;
351
- _testInitiatorBtn = null;
352
- // Restore spinner labels to defaults (may have been overridden for soprano 'Loading model…')
353
- _testBtnLabels.set(reverbTestBtn, _t('previewBtn'));
354
- _testBtnLabels.set(personalityTestBtn, _t('previewBtn'));
355
- _testBtnLabels.set(fullPreviewBtn, _t('fullPreviewBtn'));
356
- }
357
-
358
- function _setTestBtnsLabel(label) {
359
- for (const b of _testBtns) { b.setContent(label); }
360
- screen.render();
361
- }
362
-
363
- // Restore each test button to its individual default label (from _testBtnLabels map).
364
- // Replaces the old _restoreTestBtnsLabels() pattern which stomped on non-'Test' labels.
365
- function _restoreTestBtnsLabels() {
366
- for (const b of _testBtns) b.setContent(_testBtnLabels.get(b) ?? _t('previewBtn'));
367
- screen.render();
368
- }
369
-
370
- // Read a random example response from the personality's .md file.
371
- // Returns null when no personality is set or the file can't be parsed.
372
- function _getPersonalityPhrase(personality) {
373
- if (!personality || personality === 'none' || personality === 'normal') return null;
374
- if (personality.includes('..') || personality.includes('/') || personality.includes('\\')) return null;
375
- try {
376
- const file = path.join(process.cwd(), '.claude', 'personalities', personality + '.md');
377
- const lines = fs.readFileSync(file, 'utf8').split('\n');
378
- const examples = lines
379
- .filter(l => /^\s*- "/.test(l))
380
- .map(l => l.trim().replace(/^- "/, '').replace(/"$/, '').trim())
381
- .filter(Boolean);
382
- return examples.length ? examples[Math.floor(Math.random() * examples.length)] : null;
383
- } catch { return null; }
384
- }
385
-
386
- const _TEST_GREETINGS = [
387
- 'Hey', 'Hi there', 'Hello', 'Hey there', 'Howdy', 'Greetings', 'What\'s up',
388
- ];
389
-
390
- function _testGreeting() {
391
- return _TEST_GREETINGS[Math.floor(Math.random() * _TEST_GREETINGS.length)];
392
- }
393
-
394
- function _buildPreviewPhrase() {
395
- const cfg = configService.getConfig();
396
- const provider = providerService.getActiveProvider();
397
- const _rawVoice = providerService.getActiveVoiceId() ?? 'unknown';
398
- const _msV = parseMultiSpeaker(_rawVoice);
399
- const voice = provider === 'soprano' ? 'Soprano' : (_msV.isMultiSpeaker ? _msV.speakerName : _rawVoice);
400
-
401
- const effects = cfg.effects ?? {};
402
- const reverbOn = effects.reverb !== false;
403
- const reverbPreset = effects.reverbPreset ?? 'light';
404
-
405
- const music = cfg.backgroundMusic ?? {};
406
- const musicOn = music.enabled !== false;
407
- const trackLabel = _stripLeadingEmoji(formatTrackName(music.track ?? ''));
408
-
409
- const personality = (cfg.personality ?? '').trim();
410
- const hasPersonality = personality && personality !== 'none' && personality !== 'normal';
411
-
412
- const parts = [];
413
- parts.push(`${_testGreeting()}.`);
414
- parts.push('Agent Vibes here.');
415
-
416
- let voicePart = `I am ${voice}`;
417
- if (hasPersonality) voicePart += `, with ${personality} personality`;
418
- voicePart += ',';
419
- parts.push(voicePart);
420
-
421
- if (reverbOn) parts.push(`reverb set at ${reverbPreset},`);
422
- else parts.push('reverb off,');
423
-
424
- if (musicOn) parts.push(`and background music set to ${trackLabel}.`);
425
- else parts.push('and background music off.');
426
-
427
- return parts.join(' ');
428
- }
429
-
430
- // Spawn background music loop for Full Preview; stores proc in _testMusicProc.
431
- function _startTestMusic(musicCfg) {
432
- const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
433
- const trackId = musicCfg.track ?? 'agentvibes_soft_flamenco_loop.mp3';
434
- const trackPath = path.resolve(tracksDir, trackId);
435
- const safeMusic = path.resolve(tracksDir);
436
- const trackExists = (trackPath.startsWith(safeMusic + path.sep) || trackPath === safeMusic)
437
- && (() => { try { fs.accessSync(trackPath); return true; } catch { return false; } })();
438
- if (!trackExists) return;
439
- const vol = musicCfg.volume ?? MUSIC_DEFAULTS.volume;
440
- const volFraction = (Math.max(10, Math.min(100, vol)) / 100).toFixed(2);
441
- if (_IS_WINDOWS) {
442
- const _mp3P = detectMp3Player(_testEnv);
443
- _testMusicProc = _mp3P
444
- ? spawn(_mp3P.bin, _mp3P.args(trackPath), _spawnOpts(_testEnv))
445
- : null;
446
- } else {
447
- const musicCmd = [
448
- `ffplay -nodisp -loop 0 -loglevel quiet -volume ${vol} "${trackPath}"`,
449
- `play "${trackPath}" repeat 9999 vol ${volFraction}`,
450
- `mpg123 -q --loop -1 "${trackPath}"`,
451
- ].join(' 2>/dev/null || ') + ' 2>/dev/null';
452
- _testMusicProc = spawn('sh', ['-c', musicCmd], _spawnOpts(_testEnv));
453
- }
454
- if (_testMusicProc) _testMusicProc.unref();
455
- }
456
-
457
- // Apply reverb preset to tempWav; returns { wavToPlay, processedWav }.
458
- // processedWav is non-null only when a processed file was created successfully.
459
- function _applyReverbToWav(tempWav, preset) {
460
- if (!preset || preset === 'off') return { wavToPlay: tempWav, processedWav: null };
461
- const processedWav = path.join(os.tmpdir(), `agentvibes-test-fx-${randomUUID()}.wav`);
462
- if (_IS_WINDOWS) {
463
- const FFMPEG_REVERB = {
464
- light: 'aecho=0.8:0.88:60:0.4',
465
- medium: 'aecho=0.8:0.88:60|120:0.4|0.3',
466
- heavy: 'aecho=0.8:0.88:60|120|180:0.4|0.3|0.2',
467
- cathedral: 'aecho=0.8:0.88:100|200|300|400:0.3|0.25|0.2|0.15',
468
- };
469
- const filter = FFMPEG_REVERB[preset];
470
- if (filter) spawnSync('ffmpeg', ['-y', '-i', tempWav, '-af', filter, processedWav], { stdio: 'ignore', timeout: 5000, env: _testEnv });
471
- } else {
472
- const SOX_REVERB = {
473
- light: 'reverb 20 50 50',
474
- medium: 'reverb 40 50 70',
475
- heavy: 'reverb 70 50 100',
476
- cathedral: 'reverb 90 30 100',
477
- };
478
- const soxFx = SOX_REVERB[preset];
479
- if (soxFx) spawnSync('sox', [tempWav, processedWav, ...soxFx.split(' ')], { stdio: 'ignore', timeout: 5000, env: _testEnv });
480
- }
481
- try {
482
- if (fs.statSync(processedWav).size > 0) return { wavToPlay: processedWav, processedWav };
483
- fs.unlinkSync(processedWav);
484
- } catch {}
485
- return { wavToPlay: tempWav, processedWav: null };
486
- }
487
-
488
- // withMusic=true → Full Preview (voice + reverb + background track)
489
- // withMusic=false → Reverb Test (voice + reverb only, no background music)
490
- // phraseOverride speak this text instead of the full _buildPreviewPhrase() summary
491
- function _runTest(withMusic = true, phraseOverride = null) {
492
- if (_testActive) { _killTest(); _restoreTestBtnsLabels(); return; }
493
-
494
- _testActive = true;
495
- _testInitiatorBtn = _buttons[_currentIdx];
496
- _startTestSpinner();
497
-
498
- // Prefer the settings-tab key (backgroundMusic) over the music-tab key (music)
499
- const musicCfg = configService.getConfig().backgroundMusic
500
- ?? configService.getConfig().music
501
- ?? {};
502
- // Start background music loop (Full Preview only — not reverb Test)
503
- if (withMusic) _startTestMusic(musicCfg);
504
-
505
- // Lead-in before voice synthesis.
506
- // Soprano CLI loads the neural model fresh each call (cold-start: 5–120s depending on hardware).
507
- // No artificial delay needed — music will be playing well before synthesis completes.
508
- // The spinner label is updated to "Loading model…" so the user knows it's working.
509
- const provider = providerService.getActiveProvider();
510
- const leadInMs = provider === 'soprano' ? 0 : 2000;
511
- if (provider === 'soprano') {
512
- // Use "Synthesizing…" when WebUI is already warm, "Loading model…" when cold.
513
- const sopranoLabel = _sopranoStatusGlyph === ' 🟢' ? 'Synthesizing…' : 'Loading model…';
514
- for (const b of _testBtns) _testBtnLabels.set(b, sopranoLabel);
515
- for (const b of _testBtns) b.setContent(`${SPINNER_FRAMES[0]} ${sopranoLabel}`);
516
- screen.render();
517
- }
518
- _testTimeout = setTimeout(() => {
519
- _testTimeout = null;
520
- if (!_testActive) return;
521
-
522
- const provider = providerService.getActiveProvider(); // re-read (may have changed)
523
- const tempWav = path.join(os.tmpdir(), `agentvibes-test-${randomUUID()}.wav`);
524
- const ttsInput = phraseOverride ?? _buildPreviewPhrase();
525
-
526
- let synthProc;
527
- if (provider === 'soprano') {
528
- const port = process.env.SOPRANO_PORT || '7860';
529
- const synther = path.resolve(new URL(import.meta.url).pathname,
530
- '..', '..', '..', '..', '.claude', 'hooks', 'soprano-gradio-synth.py');
531
- const sopranoEnv = {
532
- ...(_testEnv),
533
- _AV_PHRASE: _sanitizeForShell(ttsInput),
534
- _AV_WAV: tempWav,
535
- _AV_SYNTHER: synther,
536
- _AV_PORT: String(port),
537
- };
538
- // Mirror the Play button: try Gradio WebUI → OpenAI-compat API → CLI fallback.
539
- // This keeps the model warm instead of cold-loading it on every Test press.
540
- const cmd = [
541
- `python3 "$_AV_SYNTHER" "$_AV_PHRASE" "$_AV_WAV" "$_AV_PORT" 2>/dev/null`,
542
- `curl -sf --max-time 30 "http://127.0.0.1:$_AV_PORT/v1/audio/speech"` +
543
- ` -H "Content-Type: application/json"` +
544
- ` -d "{\\"input\\":\\"$_AV_PHRASE\\"}" --output "$_AV_WAV" 2>/dev/null`,
545
- `soprano "$_AV_PHRASE" -o "$_AV_WAV"`,
546
- ].join(' || ');
547
- synthProc = spawn('sh', ['-c', cmd], {
548
- stdio: 'ignore', detached: !_IS_WINDOWS, windowsHide: true, env: sopranoEnv,
549
- });
550
- } else {
551
- const voiceId = providerService.getActiveVoiceId();
552
- if (!voiceId) { _killTest(); _restoreTestBtnsLabels(); return; }
553
- const _ms = parseMultiSpeaker(voiceId);
554
- const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
555
- const safePiper = path.resolve(PIPER_VOICES_DIR);
556
- if (!voicePath.startsWith(safePiper + path.sep) && voicePath !== safePiper) {
557
- _killTest(); _restoreTestBtnsLabels(); return;
558
- }
559
- const _piperArgs = ['--model', voicePath, '--output_file', tempWav];
560
- if (_ms.speakerId != null) _piperArgs.push('--speaker', String(_ms.speakerId));
561
- synthProc = spawn(_resolvePiperBin(), _piperArgs, {
562
- stdio: ['pipe', 'ignore', 'ignore'], detached: !_IS_WINDOWS, windowsHide: true, env: _testEnv,
563
- });
564
- synthProc.stdin.write(ttsInput + '\n');
565
- synthProc.stdin.end();
566
- }
567
- synthProc.unref();
568
- _testVoiceProc = synthProc;
569
-
570
- synthProc.on('exit', (code) => {
571
- if (!_testActive || code !== 0) {
572
- _killTest(); _restoreTestBtnsLabels();
573
- try { fs.unlinkSync(tempWav); } catch {}
574
- return;
575
- }
576
-
577
- // Apply reverb based on current preset (read from config — no bash, works on Windows)
578
- const preset = configService.getConfig().effects?.reverbPreset ?? EFFECTS_DEFAULTS.reverbPreset;
579
- const { wavToPlay, processedWav } = _applyReverbToWav(tempWav, preset);
580
-
581
- _stopTestSpinner();
582
- _setTestBtnsLabel('■ Stop');
583
- const _wavPlayer1 = detectWavPlayer(_testEnv);
584
- const playProc = _wavPlayer1
585
- ? spawn(_wavPlayer1.bin, _wavPlayer1.args(wavToPlay), _spawnOpts(_testEnv))
586
- : null;
587
- if (!playProc) { _killTest(); _restoreTestBtnsLabels(); return; }
588
- _testVoiceProc = playProc;
589
- playProc.on('exit', () => {
590
- const btn = _testInitiatorBtn;
591
- _killTest(); _restoreTestBtnsLabels();
592
- try { fs.unlinkSync(tempWav); } catch {}
593
- if (processedWav) { try { fs.unlinkSync(processedWav); } catch {} }
594
- if (btn && !btn.hidden) setImmediate(() => _focusButton(btn));
595
- });
596
- playProc.on('error', () => {
597
- const btn = _testInitiatorBtn;
598
- _killTest(); _restoreTestBtnsLabels();
599
- try { fs.unlinkSync(tempWav); } catch {}
600
- if (processedWav) { try { fs.unlinkSync(processedWav); } catch {} }
601
- if (btn && !btn.hidden) setImmediate(() => _focusButton(btn));
602
- });
603
- });
604
-
605
- synthProc.on('error', () => {
606
- const btn = _testInitiatorBtn;
607
- _killTest(); _restoreTestBtnsLabels();
608
- if (btn && !btn.hidden) setImmediate(() => _focusButton(btn));
609
- });
610
- }, leadInMs);
611
- }
612
-
613
- function _killMusicTest() {
614
- if (_musicTestProc) {
615
- if (_IS_WINDOWS) {
616
- try { _musicTestProc.kill(); } catch {}
617
- } else {
618
- try { process.kill(-_musicTestProc.pid, 'SIGTERM'); } catch {}
619
- }
620
- _musicTestProc = null;
621
- }
622
- _musicTestActive = false;
623
- }
624
-
625
- function _runMusicTest() {
626
- if (_musicTestActive) {
627
- _killMusicTest();
628
- musicTestBtn.setContent('▶ Preview');
629
- screen.render();
630
- return;
631
- }
632
-
633
- const musicCfg = configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
634
- if (!musicCfg.enabled) {
635
- // Show a small popup offering to enable music on the spot
636
- const modal = blessed.box({
637
- parent: screen, top: 'center', left: 'center', width: 46, height: 7,
638
- border: { type: 'line' },
639
- tags: true,
640
- label: _modalTitle('Background Music'),
641
- style: { bg: COLORS.contentBg, border: { fg: COLORS.btnFocus } },
642
- });
643
- blessed.text({
644
- parent: modal, top: 1, left: 2, tags: true,
645
- content: '{#e3f2fd-fg}Music is disabled. Enable it now?{/#e3f2fd-fg}',
646
- style: { bg: COLORS.contentBg },
647
- });
648
- function _closeConfirm() {
649
- modal.destroy();
650
- try {
651
- for (let r = 0; r < screen.height; r++)
652
- for (let c = 0; c < screen.width; c++)
653
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
654
- } catch {}
655
- _restoreFocus();
656
- screen.render();
657
- }
658
- const enableBtn = _createButton(modal, screen, 'Enable', COLORS, () => {
659
- _closeConfirm();
660
- _setMusic(configService, { enabled: true });
661
- refreshDisplay();
662
- _runMusicTest();
663
- });
664
- enableBtn.top = 4; enableBtn.left = 4;
665
- const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeConfirm);
666
- cancelBtn.top = 4; cancelBtn.left = 16;
667
- modal.key(['escape', 'q'], _closeConfirm);
668
- enableBtn.key(['right', 'tab'], () => { cancelBtn.focus(); screen.render(); });
669
- cancelBtn.key(['left', 'S-tab'], () => { enableBtn.focus(); screen.render(); });
670
- modal.setFront();
671
- enableBtn.focus();
672
- screen.render();
673
- return;
674
- }
675
- const trackId = musicCfg.track ?? MUSIC_DEFAULTS.track;
676
- const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
677
- const trackPath = path.resolve(tracksDir, trackId);
678
- const safeBase = path.resolve(tracksDir);
679
- if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
680
-
681
- _musicTestActive = true;
682
-
683
- // Apply volume: ffplay 0-100, sox vol 0.0-1.0
684
- const vol = musicCfg.volume ?? MUSIC_DEFAULTS.volume;
685
- const volFraction = (Math.max(10, Math.min(100, vol)) / 100).toFixed(2);
686
-
687
- // Play up to 10 seconds of the track (music-only, no voice)
688
- const cmd = [
689
- `ffplay -nodisp -t 10 -loglevel quiet -volume ${vol} "${trackPath}"`,
690
- `play "${trackPath}" trim 0 10 vol ${volFraction}`,
691
- `mpg123 -q "${trackPath}"`,
692
- ].join(' 2>/dev/null || ') + ' 2>/dev/null';
693
-
694
- if (_IS_WINDOWS) {
695
- const _mp3P2 = detectMp3Player(_testEnv);
696
- _musicTestProc = _mp3P2
697
- ? spawn(_mp3P2.bin, _mp3P2.args(trackPath), _spawnOpts(_testEnv))
698
- : null;
699
- } else {
700
- _musicTestProc = spawn('sh', ['-c', cmd], _spawnOpts(_testEnv));
701
- }
702
- if (!_musicTestProc) {
703
- _musicTestActive = false;
704
- musicTestBtn.setContent(_t('previewBtn'));
705
- screen.render();
706
- return;
707
- }
708
- _musicTestProc.unref();
709
- musicTestBtn.setContent('■ Stop');
710
- screen.render();
711
-
712
- _musicTestProc.on('exit', () => {
713
- if (_musicTestActive) {
714
- _killMusicTest();
715
- musicTestBtn.setContent(_t('previewBtn'));
716
- _focusButton(musicTestBtn);
717
- }
718
- });
719
- _musicTestProc.on('error', () => {
720
- _killMusicTest();
721
- musicTestBtn.setContent(_t('previewBtn'));
722
- _focusButton(musicTestBtn);
723
- });
724
- }
725
-
726
- // -------------------------------------------------------------------------
727
- // Audio destination helpers
728
-
729
- function _detectSshAliases() {
730
- try {
731
- const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
732
- const raw = fs.readFileSync(sshConfigPath, 'utf8');
733
- const aliases = [];
734
- for (const line of raw.split('\n')) {
735
- const m = line.match(/^Host\s+(\S+)/i);
736
- if (m && !m[1].includes('*') && !m[1].includes('?')) aliases.push(m[1]);
737
- }
738
- return aliases;
739
- } catch {
740
- return [];
741
- }
742
- }
743
-
744
- function formatAudioDst(dst, alias) {
745
- if (dst === 'remote') return `Remote → ${alias || '(no alias set)'}`;
746
- return 'Local Speakers';
747
- }
748
-
749
- // -------------------------------------------------------------------------
750
- // Container box fills content area, hidden until activated
751
-
752
- const box = blessed.box({
753
- parent: screen,
754
- top: 5, // Below header (rows 0-3) + tab bar (row 4)
755
- left: 0,
756
- width: '100%',
757
- bottom: 2, // Above context footer + GitHub footer
758
- hidden: true,
759
- scrollable: true,
760
- alwaysScroll: true,
761
- mouse: true,
762
- scrollbar: { ch: '│', track: { bg: '#1e2a3a' }, style: { fg: COLORS.borderFg } },
763
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.borderFg } },
764
- border: { type: 'line' },
765
- });
766
-
767
-
768
- // -------------------------------------------------------------------------
769
- // Sub-tab bar Voice | Effects | Personality | Output
770
-
771
- const SUB_TABS = ['voice', 'effects', 'personality', 'output', 'language'];
772
- // Fixed-width labels (English) — widths used for layout at creation time
773
- const SUB_TAB_LABELS = {
774
- voice: ' [V] Voice ',
775
- effects: ' [E] Effects ',
776
- personality: ' [P] Personality ',
777
- output: ' [O] Output ',
778
- language: ' [L] Language ',
779
- };
780
- // Keys map for i18n lookup
781
- const SUB_TAB_KEYS = {
782
- voice: 'subTabVoice', effects: 'subTabEffects', personality: 'subTabPersonality',
783
- output: 'subTabOutput', language: 'subTabLanguage',
784
- };
785
- let _activeSubTab = 'voice';
786
-
787
- const _subTabBar = blessed.box({
788
- parent: box, top: 1, left: 1, height: 1,
789
- style: { bg: COLORS.contentBg },
790
- });
791
-
792
- blessed.text({
793
- parent: box, top: 2, left: 1, right: 1,
794
- content: '─'.repeat(80),
795
- style: { fg: '#37474f', bg: COLORS.contentBg },
796
- });
797
-
798
- const _subTabItemsMap = {};
799
- let _xOff = 0;
800
- for (const id of SUB_TABS) {
801
- const lbl = SUB_TAB_LABELS[id];
802
- const item = blessed.box({
803
- parent: _subTabBar,
804
- content: lbl, width: lbl.length, height: 1,
805
- top: 0, left: _xOff,
806
- keys: true, focusable: true,
807
- style: { fg: 'bright-cyan', bg: '#263238' },
808
- });
809
- _subTabItemsMap[id] = item;
810
- _xOff += lbl.length;
811
- }
812
-
813
- function _updateSubTabBar() {
814
- for (const id of SUB_TABS) {
815
- const item = _subTabItemsMap[id];
816
- if (id === _activeSubTab) {
817
- item.style.fg = 'white';
818
- item.style.bg = '#0288d1'; // light blue — active tab
819
- item.style.bold = true;
820
- } else {
821
- item.style.fg = 'bright-cyan';
822
- item.style.bg = '#263238';
823
- item.style.bold = false;
824
- }
825
- }
826
- }
827
-
828
- // Focused sub-tab item turns purple + blinking █; blur restores active/inactive colours
829
- for (const id of SUB_TABS) {
830
- const item = _subTabItemsMap[id];
831
- // Always read current content so post-init language changes are preserved
832
- const _getStBase = () => item.content.replace(/█$/, ' ');
833
- let _stInterval = null;
834
- item.on('focus', () => {
835
- item.style.fg = 'white';
836
- item.style.bg = '#9c27b0';
837
- item.style.bold = true;
838
- let _stOn = true;
839
- const base = _getStBase();
840
- item.setContent(base.slice(0, -1) + '█');
841
- screen.render();
842
- _stInterval = setInterval(() => {
843
- _stOn = !_stOn;
844
- const b = _getStBase();
845
- item.setContent(_stOn ? b.slice(0, -1) + '█' : b);
846
- screen.render();
847
- }, 500);
848
- });
849
- item.on('blur', () => {
850
- if (_stInterval) { clearInterval(_stInterval); _stInterval = null; }
851
- item.setContent(_getStBase());
852
- _updateSubTabBar();
853
- screen.render();
854
- });
855
- }
856
-
857
- // -------------------------------------------------------------------------
858
- // Section header: ── Provider & Voice ──
859
-
860
- const providerVoiceHeader = blessed.text({
861
- parent: box,
862
- top: 3,
863
- left: 1,
864
- content: '{bright-cyan-fg} 🎤 Provider & Voice {/bright-cyan-fg}',
865
- tags: true,
866
- style: { bg: COLORS.contentBg },
867
- });
868
-
869
- // -------------------------------------------------------------------------
870
- // Provider row: label + value + [Switch] button
871
-
872
- const providerLabel = blessed.text({
873
- parent: box,
874
- top: 5,
875
- left: 6,
876
- content: 'Provider:',
877
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
878
- });
879
-
880
- const providerValue = blessed.text({
881
- parent: box,
882
- top: 5,
883
- left: 22,
884
- width: 26, // truncate before [Switch] at left:40
885
- wrap: false,
886
- content: '', // populated by refreshDisplay()
887
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
888
- });
889
-
890
- const switchBtn = _createButton(box, screen, 'Switch', COLORS, () => {
891
- _openProviderPicker(screen, providerService, (selected) => {
892
- providerService.setActiveProvider(selected);
893
- refreshDisplay();
894
- _buttons[_currentIdx].focus();
895
- screen.render();
896
- }, _restoreFocus);
897
- });
898
- switchBtn.top = 5;
899
- switchBtn.left = 52;
900
-
901
- // -------------------------------------------------------------------------
902
- // Voice row: label + value + [Change] button (stub for story 7-8)
903
-
904
- const voiceLabel = blessed.text({
905
- parent: box,
906
- top: 7,
907
- left: 6,
908
- content: 'Current Voice:',
909
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
910
- });
911
-
912
- const voiceValue = blessed.text({
913
- parent: box,
914
- top: 7,
915
- left: 22,
916
- width: 26, // truncate before [Change] at left:40
917
- wrap: false,
918
- content: '', // populated by refreshDisplay()
919
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
920
- });
921
-
922
- const changeBtn = _createButton(box, screen, 'Change', COLORS, () => {
923
- if (changeBtn.hidden) return; // Guard: non-piper providers hide this button
924
- _openVoiceBrowserModal(screen, providerService, configService, navigationService, () => {
925
- refreshDisplay();
926
- _buttons[_currentIdx].focus();
927
- screen.render();
928
- }, _restoreFocus);
929
- }, { bg: COLORS.btnChange });
930
- changeBtn.top = 7;
931
- changeBtn.left = 52;
932
-
933
- const playBtn = _createButton(box, screen, '▶ Play', COLORS, () => {
934
- if (_samplePlaying) {
935
- _killSample();
936
- playBtn.setContent('▶ Play');
937
- screen.render();
938
- return;
939
- }
940
-
941
- const provider = providerService.getActiveProvider();
942
- const _activePers = (configService.getConfig().personality ?? '').trim();
943
- const _hasPersonality = _activePers && _activePers !== 'none' && _activePers !== 'normal';
944
- const _rawPlay = providerService.getActiveVoiceId() ?? 'this voice';
945
- const _msPlay = parseMultiSpeaker(_rawPlay);
946
- let phrase = `${_testGreeting()}. Agent Vibes here. I am ${provider === 'soprano' ? 'Soprano' : (_msPlay.isMultiSpeaker ? _msPlay.speakerName : _rawPlay)}`;
947
- if (_hasPersonality) phrase += `, with ${_activePers} personality`;
948
- phrase += '.';
949
- const tempWav = path.join(os.tmpdir(), `agentvibes-sample-${randomUUID()}.wav`);
950
-
951
- _samplePlaying = true;
952
-
953
- const _onSynthDone = (code) => {
954
- _stopSpinner();
955
- if (!_samplePlaying) { try { fs.unlinkSync(tempWav); } catch {} return; }
956
- if (code !== 0) {
957
- _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn);
958
- _showNotice(screen, 'Voice synthesis failed — check voice model');
959
- try { fs.unlinkSync(tempWav); } catch {}
960
- return;
961
- }
962
- playBtn.setContent('■ Stop');
963
- screen.render();
964
- const _wavPlayer2 = detectWavPlayer(_sampleEnv);
965
- if (!_wavPlayer2) {
966
- _stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
967
- _showNotice(screen, 'No audio player found — install ffplay, sox, or mpv');
968
- screen.render(); return;
969
- }
970
- const playProc = spawn(_wavPlayer2.bin, _wavPlayer2.args(tempWav), _spawnOpts(_sampleEnv));
971
- _sampleProcess = playProc;
972
- const _done = () => { _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn); try { fs.unlinkSync(tempWav); } catch {} };
973
- playProc.on('exit', _done);
974
- playProc.on('error', _done);
975
- };
976
-
977
- if (provider === 'soprano') {
978
- const port = process.env.SOPRANO_PORT || '7860';
979
- const synther = path.resolve(new URL(import.meta.url).pathname,
980
- '..', '..', '..', '..', '.claude', 'hooks', 'soprano-gradio-synth.py');
981
- const managerPath = path.resolve(new URL(import.meta.url).pathname,
982
- '..', '..', '..', '..', '.claude', 'hooks', 'soprano-manager.sh');
983
-
984
- // Fast-path: cached green status glyph confirms WebUI is healthy — skip manager wait.
985
- // @why soprano-manager does an HTTP health check (up to 2s) even when already running;
986
- // if _refreshSopranoStatus() already confirmed 🟢 we can synthesize immediately.
987
- if (_sopranoStatusGlyph === ' 🟢') {
988
- _doSopranoSynth(true);
989
- } else {
990
- // Ask soprano-manager to ensure the WebUI is running and wait until healthy.
991
- // If already running: exits in <200ms. If cold-starting: blocks up to 60s.
992
- // Progressive label updates tell the user how long it's been waiting.
993
- _startSpinner(playBtn, 'Starting Soprano…');
994
-
995
- let _startSecs = 0;
996
- const _startLabelTimer = setInterval(() => {
997
- if (!_samplePlaying) { clearInterval(_startLabelTimer); return; }
998
- _startSecs += 10;
999
- // Re-call _startSpinner to replace the label; it stops the old interval first.
1000
- _startSpinner(playBtn, `Starting Soprano… (${_startSecs}s)`);
1001
- }, 10000);
1002
-
1003
- if (fs.existsSync(managerPath)) {
1004
- const mgrProc = spawn('bash', [managerPath, 'start', '--wait'], {
1005
- stdio: ['ignore', 'ignore', 'pipe'],
1006
- env: { ...process.env, SOPRANO_PORT: port },
1007
- });
1008
- _sopranoMgrProc = mgrProc; // tracked separately — kill() won't cascade to soprano-webui
1009
-
1010
- mgrProc.on('exit', (code) => {
1011
- clearInterval(_startLabelTimer);
1012
- _sopranoMgrProc = null;
1013
- if (!_samplePlaying) return;
1014
- if (code === 5) {
1015
- // soprano-webui binary not installed
1016
- _stopSpinner();
1017
- _killSample();
1018
- playBtn.setContent('▶ Play');
1019
- _showNotice(screen, 'Soprano not installed — run: pip install soprano-tts');
1020
- _refreshSopranoStatus();
1021
- _focusButton(playBtn);
1022
- return;
1023
- }
1024
- // code 0 = WebUI ready (Synthesizing…); any other = timed out/error (Loading model…)
1025
- _doSopranoSynth(code === 0);
1026
- });
1027
-
1028
- mgrProc.on('error', () => {
1029
- clearInterval(_startLabelTimer);
1030
- _sopranoMgrProc = null;
1031
- if (_samplePlaying) _doSopranoSynth(false);
1032
- });
1033
- } else {
1034
- // soprano-manager.sh not present — fall back to direct 2s HTTP check
1035
- clearInterval(_startLabelTimer);
1036
- const checkReq = http.get(
1037
- `http://127.0.0.1:${port}/gradio_api/info`,
1038
- { timeout: 2000 },
1039
- (res) => { res.resume(); _doSopranoSynth(true); },
1040
- );
1041
- checkReq.on('error', () => _doSopranoSynth(false));
1042
- checkReq.on('timeout', () => { checkReq.destroy(); _doSopranoSynth(false); });
1043
- }
1044
- }
1045
-
1046
- function _doSopranoSynth(webUIUp) {
1047
- if (!_samplePlaying) return;
1048
- _startSpinner(playBtn, webUIUp ? 'Synthesizing…' : 'Loading model…');
1049
-
1050
- // Pass phrase and output path via env vars — avoids all shell-escaping issues.
1051
- // Mode chain: WebUI (Gradio, model warm) → API server (OpenAI-compat) → CLI (slow cold-load).
1052
- const sopranoEnv = {
1053
- ...(_sampleEnv),
1054
- _AV_PHRASE: _sanitizeForShell(phrase),
1055
- _AV_WAV: tempWav,
1056
- _AV_SYNTHER: synther,
1057
- _AV_PORT: String(port),
1058
- };
1059
- const cmd = [
1060
- // Mode 1: Gradio WebUI
1061
- `python3 "$_AV_SYNTHER" "$_AV_PHRASE" "$_AV_WAV" "$_AV_PORT" 2>/dev/null`,
1062
- // Mode 2: OpenAI-compatible API server (curl writes WAV directly)
1063
- `curl -sf --max-time 30 "http://127.0.0.1:$_AV_PORT/v1/audio/speech"` +
1064
- ` -H "Content-Type: application/json"` +
1065
- ` -d "{\\"input\\":\\"$_AV_PHRASE\\"}" --output "$_AV_WAV" 2>/dev/null`,
1066
- // Mode 3: CLI — reloads neural model each call (~15-30s)
1067
- `soprano "$_AV_PHRASE" -o "$_AV_WAV"`,
1068
- ].join(' || ');
1069
-
1070
- const soprano = spawn('sh', ['-c', cmd], {
1071
- stdio: 'ignore', detached: !_IS_WINDOWS, windowsHide: true, env: sopranoEnv,
1072
- });
1073
- _sampleProcess = soprano;
1074
- soprano.on('exit', (code) => {
1075
- _onSynthDone(code);
1076
- _refreshSopranoStatus(); // Update status glyph after synthesis completes
1077
- });
1078
- soprano.on('error', () => { _stopSpinner(); _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn); });
1079
- }
1080
- } else {
1081
- // Piper (default): pipe text via stdin
1082
- _startSpinner(playBtn, 'Synthesizing…');
1083
- const voiceId = providerService.getActiveVoiceId();
1084
- if (!voiceId) {
1085
- _stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
1086
- _showNotice(screen, 'No voice selected — choose a voice first');
1087
- screen.render(); return;
1088
- }
1089
- const _ms2 = parseMultiSpeaker(voiceId);
1090
- const voicePath = path.resolve(PIPER_VOICES_DIR, _ms2.model + '.onnx');
1091
- const safeBase = path.resolve(PIPER_VOICES_DIR);
1092
- if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
1093
- _stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
1094
- _showNotice(screen, 'Invalid voice path');
1095
- screen.render(); return;
1096
- }
1097
- const piperBin2 = _resolvePiperBin();
1098
- if (piperBin2 === 'piper') {
1099
- // Bare command — verify it exists in PATH before spawning
1100
- const whichCmd = _IS_WINDOWS ? 'where' : 'which';
1101
- const whichResult = spawnSync(whichCmd, [_IS_WINDOWS ? 'piper.exe' : 'piper'], { stdio: 'pipe', env: _sampleEnv });
1102
- if (whichResult.status !== 0) {
1103
- _stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
1104
- _showNotice(screen, 'Piper not installed — run the installer or: pip install piper-tts');
1105
- _focusButton(playBtn); screen.render(); return;
1106
- }
1107
- }
1108
- const _piperArgs2 = ['--model', voicePath, '--output_file', tempWav];
1109
- if (_ms2.speakerId != null) _piperArgs2.push('--speaker', String(_ms2.speakerId));
1110
- const piper = spawn(piperBin2, _piperArgs2, {
1111
- stdio: ['pipe', 'ignore', 'pipe'], detached: !_IS_WINDOWS, windowsHide: true, env: _sampleEnv,
1112
- });
1113
- let _piperStderr = '';
1114
- piper.stderr.on('data', (d) => { _piperStderr += d.toString(); });
1115
- piper.stdin.write(phrase + '\n');
1116
- piper.stdin.end();
1117
- _sampleProcess = piper;
1118
- piper.on('exit', (code) => {
1119
- if (code !== 0 && _piperStderr) {
1120
- // Python tracebacks: actual error is the LAST non-empty line
1121
- const lines = _piperStderr.split('\n').map(l => l.trim()).filter(Boolean);
1122
- const errLine = lines[lines.length - 1] || lines[0] || 'unknown error';
1123
- _stopSpinner();
1124
- if (!_samplePlaying) { try { fs.unlinkSync(tempWav); } catch {} return; }
1125
- _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn);
1126
- _showNotice(screen, errLine.length > 100 ? errLine.substring(0, 97) + '…' : errLine);
1127
- try { fs.unlinkSync(tempWav); } catch {}
1128
- return;
1129
- }
1130
- _onSynthDone(code);
1131
- });
1132
- piper.on('error', (e) => {
1133
- _stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
1134
- _showNotice(screen, `Piper failed: ${e.message}`);
1135
- _focusButton(playBtn);
1136
- });
1137
- }
1138
- });
1139
- playBtn.top = 7;
1140
- playBtn.left = 64;
1141
-
1142
- const voiceFileText = blessed.text({
1143
- parent: box,
1144
- top: 8,
1145
- left: 22,
1146
- right: 2,
1147
- wrap: false,
1148
- content: '.claude/tts-voice.txt',
1149
- style: { fg: '#546e7a', bg: COLORS.contentBg },
1150
- });
1151
-
1152
- // -------------------------------------------------------------------------
1153
- // Section header: ── Audio Effects ──
1154
-
1155
- const audioEffectsHeader = blessed.text({
1156
- parent: box,
1157
- top: 3,
1158
- left: 1,
1159
- content: '{bright-cyan-fg} ⚡ Audio Effects {/bright-cyan-fg}',
1160
- tags: true,
1161
- style: { bg: COLORS.contentBg },
1162
- });
1163
-
1164
- // -------------------------------------------------------------------------
1165
- // Reverb row: label + value + [Toggle] + [Adjust] buttons
1166
-
1167
- const reverbLabel = blessed.text({
1168
- parent: box,
1169
- top: 5,
1170
- left: 6,
1171
- content: 'Reverb:',
1172
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1173
- });
1174
-
1175
- const reverbValue = blessed.text({
1176
- parent: box,
1177
- top: 5,
1178
- left: 22,
1179
- width: 26, // truncate before [Change] at left:40
1180
- wrap: false,
1181
- content: '', // populated by refreshDisplay()
1182
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1183
- });
1184
-
1185
- const reverbChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1186
- openReverbPicker(screen, configService.getConfig().effects?.reverbPreset ?? 'light', (preset) => {
1187
- _setEffects(configService, { reverbPreset: preset });
1188
- // On Windows, sync reverb-level.txt so play-tts.ps1 picks it up
1189
- if (_IS_WINDOWS) {
1190
- const _validPresets = new Set(['off', 'light', 'medium', 'heavy', 'cathedral']);
1191
- if (_validPresets.has(preset)) {
1192
- const _cwdMgr = path.join(process.cwd(), '.claude', 'hooks-windows', 'effects-manager.ps1');
1193
- const _homeMgr = path.join(os.homedir(), '.claude', 'hooks-windows', 'effects-manager.ps1');
1194
- const effectsMgr = fs.existsSync(_cwdMgr) ? _cwdMgr : _homeMgr;
1195
- spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', effectsMgr, 'set-reverb', preset, 'default'], {
1196
- stdio: 'ignore', timeout: 5000,
1197
- });
1198
- }
1199
- }
1200
- refreshDisplay();
1201
- }, _restoreFocus);
1202
- }, { bg: COLORS.btnChange });
1203
- reverbChangeBtn.top = 5;
1204
- reverbChangeBtn.left = 52;
1205
-
1206
- const reverbTestBtn = _createButton(box, screen, '▶ Preview', COLORS, () => _runTest(false), { bg: COLORS.btnTest });
1207
- reverbTestBtn.top = 5;
1208
- reverbTestBtn.left = 64;
1209
-
1210
- const reverbPathText = blessed.text({
1211
- parent: box,
1212
- top: 6,
1213
- left: 22,
1214
- right: 2,
1215
- wrap: false,
1216
- content: '.agentvibes/config.json',
1217
- style: { fg: '#546e7a', bg: COLORS.contentBg },
1218
- });
1219
-
1220
- // -------------------------------------------------------------------------
1221
- // Section header: ── Background Music ──
1222
-
1223
- const bgMusicHeader = blessed.text({
1224
- parent: box,
1225
- top: 7,
1226
- left: 1,
1227
- content: '{bright-cyan-fg} 🎸 Background Music {/bright-cyan-fg}',
1228
- tags: true,
1229
- style: { bg: COLORS.contentBg },
1230
- });
1231
-
1232
- // -------------------------------------------------------------------------
1233
- // Music row (single): Track value + [Change] + [Enabled/Disabled] + [Test]
1234
-
1235
- const trackLabel = blessed.text({
1236
- parent: box,
1237
- top: 9,
1238
- left: 6,
1239
- content: 'Track:',
1240
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1241
- });
1242
-
1243
- const trackValue = blessed.text({
1244
- parent: box,
1245
- top: 9,
1246
- left: 22,
1247
- width: 26, // truncate before [Change] at left:40
1248
- wrap: false,
1249
- content: '', // populated by refreshDisplay()
1250
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1251
- });
1252
-
1253
- const trackChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1254
- _openMusicBrowserModal(screen, configService, navigationService, () => {
1255
- refreshDisplay();
1256
- _buttons[_currentIdx].focus();
1257
- screen.render();
1258
- }, _restoreFocus);
1259
- }, { bg: COLORS.btnChange });
1260
- trackChangeBtn.top = 9;
1261
- trackChangeBtn.left = 52;
1262
-
1263
- const musicToggleBtn = _createButton(box, screen, 'Disabled', COLORS, () => {
1264
- const music = _getMusic(configService);
1265
- _setMusic(configService, { enabled: !music.enabled });
1266
- refreshDisplay();
1267
- }, {
1268
- bg: COLORS.btnEnableOff,
1269
- getDynamicBg: () => _getMusic(configService).enabled ? COLORS.btnEnableOn : COLORS.btnEnableOff,
1270
- });
1271
- musicToggleBtn.top = 9;
1272
- musicToggleBtn.left = 64;
1273
-
1274
- const musicTestBtn = _createButton(box, screen, '▶ Preview', COLORS, _runMusicTest, { bg: COLORS.btnTest });
1275
- musicTestBtn.top = 9;
1276
- musicTestBtn.left = 78;
1277
-
1278
- const trackPathText = blessed.text({
1279
- parent: box,
1280
- top: 10,
1281
- left: 22,
1282
- right: 2,
1283
- wrap: false,
1284
- content: '.agentvibes/config.json',
1285
- style: { fg: '#546e7a', bg: COLORS.contentBg },
1286
- });
1287
-
1288
- // -------------------------------------------------------------------------
1289
- // Volume row: label + value + [Change] button
1290
-
1291
- const volumeLabel = blessed.text({
1292
- parent: box,
1293
- top: 11,
1294
- left: 6,
1295
- content: 'Volume:',
1296
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1297
- });
1298
-
1299
- const volumeValue = blessed.text({
1300
- parent: box,
1301
- top: 11,
1302
- left: 22,
1303
- width: 26,
1304
- wrap: false,
1305
- content: '', // populated by refreshDisplay()
1306
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1307
- });
1308
-
1309
- const volumeChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1310
- _openVolumePicker(screen, configService, (vol) => {
1311
- _setMusic(configService, { volume: vol });
1312
- // If music test is active, restart it at the new volume
1313
- if (_musicTestActive) {
1314
- _killMusicTest();
1315
- _runMusicTest();
1316
- }
1317
- refreshDisplay();
1318
- }, _restoreFocus);
1319
- }, { bg: COLORS.btnChange });
1320
- volumeChangeBtn.top = 11;
1321
- volumeChangeBtn.left = 52;
1322
-
1323
- // -------------------------------------------------------------------------
1324
- // Section header: ── Style ──
1325
-
1326
- const styleHeader = blessed.text({
1327
- parent: box,
1328
- top: 3,
1329
- left: 1,
1330
- content: '{bright-cyan-fg} 🎭 Style {/bright-cyan-fg}',
1331
- tags: true,
1332
- style: { bg: COLORS.contentBg },
1333
- });
1334
-
1335
- // -------------------------------------------------------------------------
1336
- // Verbosity row: label + value + [Change] button
1337
-
1338
- const verbosityLabel = blessed.text({
1339
- parent: box,
1340
- top: 5,
1341
- left: 6,
1342
- content: 'Verbosity:',
1343
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1344
- });
1345
-
1346
- const verbosityValue = blessed.text({
1347
- parent: box,
1348
- top: 5,
1349
- left: 22,
1350
- width: 26, // truncate before [Change] at left:40
1351
- wrap: false,
1352
- content: '', // populated by refreshDisplay()
1353
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1354
- });
1355
-
1356
- const verbosityChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1357
- _openVerbosityPicker(screen, configService, () => refreshDisplay(), _restoreFocus);
1358
- }, { bg: COLORS.btnChange });
1359
- verbosityChangeBtn.top = 5;
1360
- verbosityChangeBtn.left = 52;
1361
-
1362
- const verbosityPathText = blessed.text({
1363
- parent: box,
1364
- top: 6,
1365
- left: 22,
1366
- right: 2,
1367
- wrap: false,
1368
- content: '.claude/tts-verbosity.txt',
1369
- style: { fg: '#546e7a', bg: COLORS.contentBg },
1370
- });
1371
-
1372
- // -------------------------------------------------------------------------
1373
- // Personality row: label + value + [Change] button
1374
-
1375
- const personalityLabel = blessed.text({
1376
- parent: box,
1377
- top: 7,
1378
- left: 6,
1379
- content: 'Personality:',
1380
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1381
- });
1382
-
1383
- const personalityValue = blessed.text({
1384
- parent: box,
1385
- top: 7,
1386
- left: 22,
1387
- width: 26, // truncate before [Change] at left:40
1388
- wrap: false,
1389
- content: '', // populated by refreshDisplay()
1390
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1391
- });
1392
-
1393
- const personalityChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1394
- openPersonalityPicker(screen, configService.getConfig().personality ?? 'none', (name) => {
1395
- configService.set('personality', name);
1396
- refreshDisplay();
1397
- }, _restoreFocus);
1398
- }, { bg: COLORS.btnChange });
1399
- personalityChangeBtn.top = 7;
1400
- personalityChangeBtn.left = 52;
1401
-
1402
- const personalityTestBtn = _createButton(box, screen, '▶ Preview', COLORS, () => {
1403
- const personality = (configService.getConfig().personality ?? '').trim();
1404
- const example = _getPersonalityPhrase(personality);
1405
- const phrase = example
1406
- ? `${_testGreeting()}. Agent Vibes here. ${example}`
1407
- : _buildPreviewPhrase();
1408
- _runTest(false, phrase);
1409
- }, { bg: COLORS.btnTest });
1410
- personalityTestBtn.top = 7;
1411
- personalityTestBtn.left = 64;
1412
-
1413
- const personalityFileText = blessed.text({
1414
- parent: box,
1415
- top: 8,
1416
- left: 22,
1417
- right: 2,
1418
- wrap: false,
1419
- tags: true,
1420
- content: '', // populated by refreshDisplay()
1421
- style: { fg: '#546e7a', bg: COLORS.contentBg },
1422
- });
1423
-
1424
- // -------------------------------------------------------------------------
1425
- // Section header: ── Intro Text ──
1426
-
1427
- const introTextHeader = blessed.text({
1428
- parent: box,
1429
- top: 10,
1430
- left: 1,
1431
- content: '{bright-cyan-fg} ✍️ Intro Text {/bright-cyan-fg}',
1432
- tags: true,
1433
- style: { bg: COLORS.contentBg },
1434
- });
1435
-
1436
- // -------------------------------------------------------------------------
1437
- // Intro Text row: label + value + [Edit] + [Clear] buttons
1438
-
1439
- const introTextLabel = blessed.text({
1440
- parent: box,
1441
- top: 12,
1442
- left: 6,
1443
- content: 'Intro Text:',
1444
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1445
- });
1446
-
1447
- const introTextValue = blessed.text({
1448
- parent: box,
1449
- top: 12,
1450
- left: 22,
1451
- width: 26, // truncate before [Edit] at left:40
1452
- wrap: false,
1453
- content: '', // populated by refreshDisplay()
1454
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1455
- });
1456
-
1457
- const introEditBtn = _createButton(box, screen, 'Edit', COLORS, () => {
1458
- _openIntroTextEditor(screen, configService, () => { refreshDisplay(); }, _restoreFocus);
1459
- }, { bg: COLORS.btnEdit });
1460
- introEditBtn.top = 12;
1461
- introEditBtn.left = 52;
1462
-
1463
- const introClearBtn = _createButton(box, screen, 'Clear', COLORS, () => {
1464
- configService.set('pretext', '');
1465
- refreshDisplay();
1466
- }, { bg: '#c62828' });
1467
- introClearBtn.top = 12;
1468
- introClearBtn.left = 64;
1469
-
1470
- const introPathText = blessed.text({
1471
- parent: box,
1472
- top: 13,
1473
- left: 22,
1474
- right: 2,
1475
- wrap: false,
1476
- content: '.agentvibes/config.json',
1477
- style: { fg: '#546e7a', bg: COLORS.contentBg },
1478
- });
1479
-
1480
- // Full Preview button — voice + reverb + background track combined
1481
- const fullPreviewBtn = _createButton(box, screen, '▶ Full Preview', COLORS, () => _runTest(true));
1482
- fullPreviewBtn.bottom = 0;
1483
- fullPreviewBtn.left = 2;
1484
-
1485
- // -------------------------------------------------------------------------
1486
- // Section header: 📡 Audio Destination
1487
-
1488
- const audioDstHeader = blessed.text({
1489
- parent: box,
1490
- top: 3,
1491
- left: 2,
1492
- content: '{bright-cyan-fg} 📡 Audio Destination {/bright-cyan-fg}',
1493
- tags: true,
1494
- style: { bg: COLORS.contentBg },
1495
- });
1496
-
1497
- // -------------------------------------------------------------------------
1498
- // Destination row: label + value + [Change] button
1499
-
1500
- const audioDstLabel = blessed.text({
1501
- parent: box,
1502
- top: 5,
1503
- left: 6,
1504
- content: 'Destination:',
1505
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1506
- });
1507
-
1508
- const audioDstValue = blessed.text({
1509
- parent: box,
1510
- top: 5,
1511
- left: 22,
1512
- width: 26,
1513
- wrap: false,
1514
- content: '', // populated by refreshDisplay()
1515
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1516
- });
1517
-
1518
- const audioDstChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1519
- const aliases = _detectSshAliases();
1520
- const current = configService.getConfig().audio_destination ?? 'local';
1521
- const choices = ['local', 'remote'];
1522
- const nextIdx = (choices.indexOf(current) + 1) % choices.length;
1523
- const next = choices[nextIdx];
1524
- configService.set('audio_destination', next);
1525
- if (next === 'remote' && !(configService.getConfig().audio_ssh_alias)) {
1526
- // Prompt for alias immediately if switching to remote with no alias set
1527
- const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
1528
- const prompt = blessed.prompt({
1529
- parent: screen,
1530
- top: 'center', left: 'center',
1531
- height: 'shrink', width: '60%',
1532
- border: 'line', tags: true,
1533
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
1534
- });
1535
- prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
1536
- aliases[0] ?? '',
1537
- (err, val) => {
1538
- prompt.destroy();
1539
- if (!err && val && val.trim()) {
1540
- const trimmed = val.trim();
1541
- if (/[;&|`$(){}\\<>]/.test(trimmed)) {
1542
- _showNotice(screen, 'Invalid alias — special characters not allowed');
1543
- } else {
1544
- configService.set('audio_ssh_alias', trimmed);
1545
- }
1546
- }
1547
- refreshDisplay();
1548
- screen.render();
1549
- });
1550
- screen.render();
1551
- return;
1552
- }
1553
- refreshDisplay();
1554
- }, { bg: COLORS.btnChange });
1555
- audioDstChangeBtn.top = 5;
1556
- audioDstChangeBtn.left = 52;
1557
-
1558
- // -------------------------------------------------------------------------
1559
- // SSH Alias row: label + value + [Edit] + [stream mode toggle] buttons
1560
- // Hidden when destination is Local — shown/hidden by refreshDisplay()
1561
-
1562
- const audioSshLabel = blessed.text({
1563
- parent: box,
1564
- top: 7,
1565
- left: 6,
1566
- hidden: true,
1567
- content: 'SSH Alias:',
1568
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1569
- });
1570
-
1571
- const audioSshValue = blessed.text({
1572
- parent: box,
1573
- top: 7,
1574
- left: 22,
1575
- width: 26,
1576
- wrap: false,
1577
- hidden: true,
1578
- content: '', // populated by refreshDisplay()
1579
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1580
- });
1581
-
1582
- const audioSshEditBtn = _createButton(box, screen, 'Edit', COLORS, () => {
1583
- const aliases = _detectSshAliases();
1584
- const current = configService.getConfig().audio_ssh_alias ?? '';
1585
- const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
1586
- const prompt = blessed.prompt({
1587
- parent: screen,
1588
- top: 'center', left: 'center',
1589
- height: 'shrink', width: '60%',
1590
- border: 'line', tags: true,
1591
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
1592
- });
1593
- prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
1594
- current || (aliases[0] ?? ''),
1595
- (err, val) => {
1596
- prompt.destroy();
1597
- if (!err && val !== null) {
1598
- const trimmed = val.trim();
1599
- if (/[;&|`$(){}\\<>]/.test(trimmed)) {
1600
- _showNotice(screen, 'Invalid alias — special characters not allowed');
1601
- } else {
1602
- configService.set('audio_ssh_alias', trimmed);
1603
- }
1604
- }
1605
- refreshDisplay();
1606
- screen.render();
1607
- });
1608
- screen.render();
1609
- }, { bg: COLORS.btnEdit });
1610
- audioSshEditBtn.top = 7;
1611
- audioSshEditBtn.left = 52;
1612
- audioSshEditBtn.hide();
1613
-
1614
- // Stream mode toggle
1615
- // Streaming Text Only = send TTS text to remote AgentVibes Receiver which speaks locally (no audio data transfer)
1616
- // Streaming Pulse Audio = stream audio file over SSH/PulseAudio tunnel
1617
- const audioStreamModeBtn = _createButton(box, screen, 'Streaming Text Only ✓', COLORS, () => {
1618
- const current = configService.getConfig().audio_stream_mode ?? 'text';
1619
- configService.set('audio_stream_mode', current === 'text' ? 'pulse' : 'text');
1620
- refreshDisplay();
1621
- }, { bg: '#1565c0' }); // blue — distinct from green focus
1622
- audioStreamModeBtn.top = 7;
1623
- audioStreamModeBtn.left = 64;
1624
- audioStreamModeBtn.hide();
1625
-
1626
- // Explanation note
1627
- const audioExplanationNote = blessed.text({
1628
- parent: box,
1629
- top: 9,
1630
- left: 6,
1631
- right: 2,
1632
- wrap: false,
1633
- tags: true,
1634
- content: `{#546e7a-fg}Remote: sends TTS over SSH. Text Only = remote speaks (no audio transfer). Pulse = streams audio.{/#546e7a-fg}`,
1635
- style: { bg: COLORS.contentBg },
1636
- });
1637
-
1638
- // -------------------------------------------------------------------------
1639
- // Section header: 💾 Config Storage
1640
-
1641
- const configStorageHeader = blessed.text({
1642
- parent: box,
1643
- top: 11,
1644
- left: 2,
1645
- content: '{bright-cyan-fg} 💾 Config Storage {/bright-cyan-fg}',
1646
- tags: true,
1647
- style: { bg: COLORS.contentBg },
1648
- });
1649
-
1650
- // Info row 1: global config path
1651
- const configGlobalLabel = blessed.text({
1652
- parent: box,
1653
- top: 12,
1654
- left: 6,
1655
- content: 'Global:',
1656
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1657
- });
1658
-
1659
- const configGlobalValue = blessed.text({
1660
- parent: box,
1661
- top: 12,
1662
- left: 22,
1663
- right: 2,
1664
- wrap: false,
1665
- content: '', // populated by refreshConfigDisplay()
1666
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1667
- });
1668
-
1669
- // Info row 2: local config path (or "None")
1670
- const configLocalLabel = blessed.text({
1671
- parent: box,
1672
- top: 13,
1673
- left: 6,
1674
- content: 'Local:',
1675
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1676
- });
1677
-
1678
- const configLocalValue = blessed.text({
1679
- parent: box,
1680
- top: 13,
1681
- left: 22,
1682
- right: 2,
1683
- wrap: false,
1684
- content: '', // populated by refreshConfigDisplay()
1685
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1686
- });
1687
-
1688
- // Action buttons row — right column, row 17
1689
- const saveGloballyBtn = _createButton(box, screen, 'Save Globally', COLORS, () => {
1690
- const data = configService.getConfig();
1691
- const configPath = configService.getGlobalConfigPath();
1692
- _showSavePreview(screen, configPath, data, () => {
1693
- configService.saveAllToGlobal(data);
1694
- applyTrackToAudioEffects(data.backgroundMusic?.track);
1695
- refreshConfigDisplay();
1696
- _showNotice(screen, 'Settings Saved');
1697
- }, () => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); });
1698
- }, { bg: '#7b1fa2' }); // purple
1699
- saveGloballyBtn.bottom = 0;
1700
- saveGloballyBtn.left = 24;
1701
-
1702
- const saveLocallyBtn = _createButton(box, screen, 'Save Locally', COLORS, () => {
1703
- const data = configService.getConfig();
1704
- const configPath = configService.getLocalConfigPath();
1705
- _showSavePreview(screen, configPath, data, () => {
1706
- configService.saveAllToLocal(data);
1707
- applyTrackToAudioEffects(data.backgroundMusic?.track);
1708
- refreshConfigDisplay();
1709
- _showNotice(screen, 'Settings Saved');
1710
- }, () => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); });
1711
- }, { bg: '#1565c0' }); // blue — distinct from green focus
1712
- saveLocallyBtn.bottom = 0;
1713
- saveLocallyBtn.left = 46;
1714
-
1715
- const cancelChangesBtn = _createButton(box, screen, 'Cancel Changes', COLORS, () => {
1716
- // Restore global config to snapshot taken at tab open
1717
- if (_snapshotGlobal !== null) configService.saveAllToGlobal(_snapshotGlobal);
1718
- // Restore (or remove) local config
1719
- if (_snapshotLocal !== null) {
1720
- configService.saveAllToLocal(_snapshotLocal);
1721
- } else {
1722
- // Local didn't exist at tab open — remove it if created during this session
1723
- const localPath = configService.getLocalConfigPath();
1724
- try { if (fs.existsSync(localPath)) fs.unlinkSync(localPath); } catch {}
1725
- }
1726
- refreshDisplay();
1727
- refreshConfigDisplay();
1728
- _showNotice(screen, 'Changes reverted');
1729
- }, { bg: '#c62828' }); // red
1730
- cancelChangesBtn.bottom = 0;
1731
- cancelChangesBtn.left = 66;
1732
-
1733
- // -------------------------------------------------------------------------
1734
- // Section: 🌐 Language
1735
-
1736
- const languageSectionHeader = blessed.text({
1737
- parent: box,
1738
- top: 3,
1739
- left: 1,
1740
- content: '{bright-cyan-fg} 🌐 Language {/bright-cyan-fg}',
1741
- tags: true,
1742
- style: { bg: COLORS.contentBg },
1743
- });
1744
-
1745
- const languageCurrentLabel = blessed.text({
1746
- parent: box,
1747
- top: 5,
1748
- left: 6,
1749
- content: 'Language:',
1750
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1751
- });
1752
-
1753
- const languageCurrentValue = blessed.text({
1754
- parent: box,
1755
- top: 5,
1756
- left: 22,
1757
- width: 30,
1758
- wrap: false,
1759
- content: '',
1760
- style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
1761
- });
1762
-
1763
- // Visible list height — cap at 10 rows (fits standard 24-row terminals with headers)
1764
- const LANG_LIST_HEIGHT = Math.min(SUPPORTED_LANGUAGES.length, 10);
1765
-
1766
- // blessed.list: natively focusable, handles selection highlight, scrolling.
1767
- // keys:true needed so keypress events propagate to the element (keyable=true).
1768
- // We immediately removeAllListeners('keypress') to strip blessed's built-in up/down nav —
1769
- // our manual .key() handlers (registered on 'key down'/'key up') are the sole navigators.
1770
- const languageList = blessed.list({
1771
- parent: box,
1772
- top: 7,
1773
- left: 4,
1774
- width: 44,
1775
- height: LANG_LIST_HEIGHT + 2, // +2 for border
1776
- keys: true,
1777
- mouse: true,
1778
- tags: false,
1779
- items: SUPPORTED_LANGUAGES.map(l => l.name),
1780
- style: {
1781
- selected: { bg: 'green', fg: 'white', bold: true },
1782
- item: { fg: 'white' },
1783
- border: { fg: 'cyan' },
1784
- focus: { border: { fg: 'yellow' } },
1785
- },
1786
- border: { type: 'line' },
1787
- scrollable: true,
1788
- scrollbar: { style: { bg: 'blue' } },
1789
- });
1790
- // Strip blessed's built-in keypress nav (up/down/j/k/etc.) — our .key() handlers take over.
1791
- // .key() registers on 'key <name>' events (not 'keypress'), so they survive this removal.
1792
- languageList.removeAllListeners('keypress');
1793
-
1794
- // Hint shown below the list
1795
- const langHint = blessed.text({
1796
- parent: box,
1797
- top: 7 + LANG_LIST_HEIGHT + 3,
1798
- left: 4,
1799
- content: '{gray-fg}↑↓ navigate · Enter to apply{/gray-fg}',
1800
- tags: true,
1801
- style: { bg: COLORS.contentBg },
1802
- });
1803
-
1804
- // Apply button — kept for mouse users; keyboard users just press Enter on the list
1805
- const langApplyBtn = _createButton(box, screen, '✓ Apply Language', COLORS, () => {
1806
- const selected = SUPPORTED_LANGUAGES[languageList.selected ?? 0];
1807
- if (selected && services.languageService) {
1808
- services.languageService.setLang(selected.value);
1809
- refreshLanguageDisplay();
1810
- _showNotice(screen, `Language: ${selected.name}`);
1811
- }
1812
- }, { bg: '#2e7d32' });
1813
- langApplyBtn.top = 7 + LANG_LIST_HEIGHT + 5;
1814
- langApplyBtn.left = 4;
1815
-
1816
- function refreshLanguageDisplay() {
1817
- const currentLang = services.languageService?.getLang() ?? 'en';
1818
- const found = SUPPORTED_LANGUAGES.find(l => l.value === currentLang);
1819
- languageCurrentValue.setContent(found ? found.name : currentLang);
1820
- const idx = SUPPORTED_LANGUAGES.findIndex(l => l.value === currentLang);
1821
- if (idx >= 0) languageList.select(idx);
1822
- screen.render();
1823
- }
1824
-
1825
- // Key navigation wired after _navigateRow is defined (see below)
1826
-
1827
- // -------------------------------------------------------------------------
1828
- // Display state + button-level focus navigation (story 7.6)
1829
-
1830
- // Widget groups for each sub-tab (used by _showSubTab to show/hide)
1831
- const _subTabWidgets = {
1832
- voice: [
1833
- providerVoiceHeader,
1834
- providerLabel, providerValue, switchBtn,
1835
- voiceLabel, voiceValue, changeBtn, playBtn, voiceFileText,
1836
- ],
1837
- effects: [
1838
- audioEffectsHeader,
1839
- reverbLabel, reverbValue, reverbChangeBtn, reverbTestBtn, reverbPathText,
1840
- bgMusicHeader,
1841
- trackLabel, trackValue, trackChangeBtn, musicToggleBtn, musicTestBtn, trackPathText,
1842
- volumeLabel, volumeValue, volumeChangeBtn,
1843
- ],
1844
- personality: [
1845
- styleHeader,
1846
- verbosityLabel, verbosityValue, verbosityChangeBtn, verbosityPathText,
1847
- personalityLabel, personalityValue, personalityChangeBtn, personalityTestBtn, personalityFileText,
1848
- introTextHeader,
1849
- introTextLabel, introTextValue, introEditBtn, introClearBtn, introPathText,
1850
- ],
1851
- output: [
1852
- audioDstHeader,
1853
- audioDstLabel, audioDstValue, audioDstChangeBtn,
1854
- audioSshLabel, audioSshValue, audioSshEditBtn, audioStreamModeBtn, audioExplanationNote,
1855
- configStorageHeader,
1856
- configGlobalLabel, configGlobalValue,
1857
- configLocalLabel, configLocalValue,
1858
- ],
1859
- language: [
1860
- languageSectionHeader,
1861
- languageCurrentLabel, languageCurrentValue,
1862
- languageList, langHint, langApplyBtn,
1863
- ],
1864
- };
1865
-
1866
- // Row groups per sub-tab for ↑↓ navigation
1867
- const _rowsBySubTab = {
1868
- voice: [[switchBtn], [changeBtn, playBtn]],
1869
- effects: [[reverbChangeBtn, reverbTestBtn], [trackChangeBtn, musicToggleBtn, musicTestBtn], [volumeChangeBtn]],
1870
- personality: [[verbosityChangeBtn], [personalityChangeBtn, personalityTestBtn], [introEditBtn, introClearBtn]],
1871
- output: [[audioDstChangeBtn], [audioSshEditBtn, audioStreamModeBtn]],
1872
- language: [[languageList], [langApplyBtn]],
1873
- };
1874
-
1875
- const _subTabItemsArray = SUB_TABS.map(id => _subTabItemsMap[id]);
1876
-
1877
- function _showSubTab(name, keepFocusOnBar = false) {
1878
- _activeSubTab = name;
1879
-
1880
- // Hide all section widgets; clear any active blink intervals on hidden buttons
1881
- for (const widgets of Object.values(_subTabWidgets)) {
1882
- for (const w of widgets) {
1883
- if (w._btnBlinkInterval) { clearInterval(w._btnBlinkInterval); w._btnBlinkInterval = null; }
1884
- w.hide();
1885
- }
1886
- }
1887
-
1888
- // Show active section widgets (SSH row controlled by refreshDisplay, not here)
1889
- const sshSpecific = [audioSshLabel, audioSshValue, audioSshEditBtn, audioStreamModeBtn];
1890
- for (const w of _subTabWidgets[name]) {
1891
- if (sshSpecific.includes(w)) continue;
1892
- w.show();
1893
- }
1894
-
1895
- // If showing output tab, let refreshDisplay control SSH row visibility
1896
- if (name === 'output') refreshDisplay();
1897
-
1898
- // Rebuild _rows: [subTabRow, ...contentRows, fullPreview, save, cancel]
1899
- _rows.length = 0;
1900
- _rows.push(_subTabItemsArray);
1901
- for (const row of _rowsBySubTab[name]) _rows.push(row);
1902
- _rows.push([fullPreviewBtn]);
1903
- _rows.push([saveGloballyBtn, saveLocallyBtn, cancelChangesBtn]);
1904
-
1905
- _updateSubTabBar();
1906
-
1907
- if (!keepFocusOnBar) {
1908
- const firstRow = _rowsBySubTab[name].find(row => !row[0].hidden);
1909
- if (firstRow) {
1910
- _currentIdx = _buttons.indexOf(firstRow[0]);
1911
- _focusButton(firstRow[0]);
1912
- }
1913
- }
1914
-
1915
- screen.render();
1916
- }
1917
-
1918
- const _buttons = [
1919
- _subTabItemsMap.voice, _subTabItemsMap.effects,
1920
- _subTabItemsMap.personality, _subTabItemsMap.output, _subTabItemsMap.language,
1921
- switchBtn, changeBtn, playBtn,
1922
- reverbChangeBtn, reverbTestBtn,
1923
- trackChangeBtn, musicToggleBtn, musicTestBtn,
1924
- volumeChangeBtn,
1925
- verbosityChangeBtn, personalityChangeBtn, personalityTestBtn,
1926
- introEditBtn, introClearBtn,
1927
- audioDstChangeBtn, audioSshEditBtn, audioStreamModeBtn,
1928
- languageList, langApplyBtn,
1929
- fullPreviewBtn,
1930
- saveGloballyBtn, saveLocallyBtn, cancelChangesBtn,
1931
- ];
1932
-
1933
- // Restore focus to the active settings button after any modal closes.
1934
- const _restoreFocus = () => _focusButton(_buttons[_currentIdx]);
1935
-
1936
- // Register test buttons for label sync (reverb + full preview share state)
1937
- _testBtns.push(reverbTestBtn, personalityTestBtn, fullPreviewBtn);
1938
- _testBtnLabels.set(reverbTestBtn, '▶ Preview');
1939
- _testBtnLabels.set(personalityTestBtn, '▶ Preview');
1940
- _testBtnLabels.set(fullPreviewBtn, '▶ Full Preview');
1941
-
1942
- let _currentIdx = 0;
1943
-
1944
- // Map each button to its row label + value widgets for focus-highlight
1945
- const _buttonToLabel = new Map([
1946
- [switchBtn, providerLabel],
1947
- [changeBtn, voiceLabel],
1948
- [playBtn, voiceLabel],
1949
- [reverbChangeBtn, reverbLabel],
1950
- [reverbTestBtn, reverbLabel],
1951
- [trackChangeBtn, trackLabel],
1952
- [musicToggleBtn, trackLabel],
1953
- [musicTestBtn, trackLabel],
1954
- [volumeChangeBtn, volumeLabel],
1955
- [verbosityChangeBtn, verbosityLabel],
1956
- [personalityChangeBtn, personalityLabel],
1957
- [personalityTestBtn, personalityLabel],
1958
- [introEditBtn, introTextLabel],
1959
- [introClearBtn, introTextLabel],
1960
- [audioDstChangeBtn, audioDstLabel],
1961
- [audioSshEditBtn, audioSshLabel],
1962
- [audioStreamModeBtn, audioDstLabel],
1963
- ]);
1964
-
1965
- const _buttonToValue = new Map([
1966
- [switchBtn, providerValue],
1967
- [changeBtn, voiceValue],
1968
- [playBtn, voiceValue],
1969
- [reverbChangeBtn, reverbValue],
1970
- [reverbTestBtn, reverbValue],
1971
- [trackChangeBtn, trackValue],
1972
- [musicToggleBtn, trackValue],
1973
- [musicTestBtn, trackValue],
1974
- [volumeChangeBtn, volumeValue],
1975
- [verbosityChangeBtn, verbosityValue],
1976
- [personalityChangeBtn, personalityValue],
1977
- [personalityTestBtn, personalityValue],
1978
- [introEditBtn, introTextValue],
1979
- [introClearBtn, introTextValue],
1980
- [audioDstChangeBtn, audioDstValue],
1981
- [audioSshEditBtn, audioSshValue],
1982
- [audioStreamModeBtn, audioDstValue],
1983
- ]);
1984
-
1985
- // Sync _currentIdx; highlight label (cyan) + value (bright blue + underline) on focus
1986
- for (const [i, btn] of _buttons.entries()) {
1987
- btn.on('focus', () => {
1988
- _currentIdx = i;
1989
- const lbl = _buttonToLabel.get(btn);
1990
- if (lbl) lbl.style.fg = COLORS.btnFocus;
1991
- const val = _buttonToValue.get(btn);
1992
- if (val) { val.style.fg = COLORS.btnFocus; val.style.underline = true; }
1993
- });
1994
- btn.on('blur', () => {
1995
- const lbl = _buttonToLabel.get(btn);
1996
- if (lbl) lbl.style.fg = COLORS.labelFg;
1997
- const val = _buttonToValue.get(btn);
1998
- if (val) { val.style.fg = COLORS.valueFg; val.style.underline = false; }
1999
- });
2000
- }
2001
-
2002
- // Shared focus helper — suppresses intermediate renders, force-invalidates olines.
2003
- // Prevents the olines desync artifact where setContent() updates lines[] but
2004
- // olines[] stays stale, causing draw() to skip repainting those cells.
2005
- function _focusButton(btn) {
2006
- const _orig = screen.render.bind(screen);
2007
- screen.render = () => {};
2008
- try { btn.focus(); } finally { screen.render = _orig; }
2009
-
2010
- screen.clearRegion(0, screen.cols, 4, screen.rows - 2);
2011
- for (let r = 4; r < screen.rows - 2; r++) {
2012
- const orow = screen.olines[r];
2013
- if (!orow) continue;
2014
- for (let c = 0; c < screen.cols; c++) {
2015
- if (orow[c]) orow[c][0] = -1;
2016
- }
2017
- orow.dirty = true;
2018
- }
2019
- screen.render();
2020
- }
2021
-
2022
- // ↓ / ↑ → navigate between row groups (skips siblings; use ←/→ for those)
2023
-
2024
- // Returns the first non-hidden button in a row, or the first button if all are hidden.
2025
- // Needed because some rows have a hidden first button (e.g. [changeBtn, playBtn] when
2026
- // provider is not piper — changeBtn is hidden but playBtn is still reachable).
2027
- function _firstVisibleBtn(row) {
2028
- return row.find(b => !b.hidden) ?? row[0];
2029
- }
2030
-
2031
- function _isRowVisible(row) {
2032
- return row.some(b => !b.hidden);
2033
- }
2034
-
2035
- function _navigateRow(delta) {
2036
- const focused = _buttons[_currentIdx];
2037
- let rowIdx = _rows.findIndex(row => row.includes(focused));
2038
- if (rowIdx === -1) rowIdx = 0;
2039
- // At the sub-tab bar (row 0): pressing ↑ moves focus to the main header tab bar
2040
- if (rowIdx === 0 && delta < 0) {
2041
- if (typeof focusMainTabBar === 'function') focusMainTabBar();
2042
- return;
2043
- }
2044
-
2045
- // Skip rows where ALL buttons are hidden (e.g. SSH alias row when destination is local).
2046
- // Use _firstVisibleBtn so we land on the first visible button in a mixed row.
2047
- let attempts = 0;
2048
- do {
2049
- rowIdx = (rowIdx + delta + _rows.length) % _rows.length;
2050
- attempts++;
2051
- } while (!_isRowVisible(_rows[rowIdx]) && attempts < _rows.length);
2052
- // When landing on the sub-tab bar (row 0), focus the ACTIVE sub-tab item, not the first one
2053
- const btn = rowIdx === 0
2054
- ? (_subTabItemsMap[_activeSubTab] ?? _firstVisibleBtn(_rows[0]))
2055
- : _firstVisibleBtn(_rows[rowIdx]);
2056
- _currentIdx = _buttons.indexOf(btn);
2057
- _focusButton(btn);
2058
- }
2059
-
2060
- for (const btn of _buttons) {
2061
- btn.key(['down'], () => {
2062
- if (btn === languageList) return; // languageList has its own boundary-aware down handler
2063
- _navigateRow(1);
2064
- });
2065
- btn.key(['up'], () => {
2066
- if (btn === languageList) return; // languageList has its own boundary-aware up handler
2067
- _navigateRow(-1);
2068
- });
2069
- btn.key(['escape'], () => { if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0); });
2070
- }
2071
-
2072
- // Language list — fully manual navigation (keys:false on the list disables blessed's built-in
2073
- // so only our handlers run, giving us clean boundary detection without double-move issues).
2074
- languageList.key(['down'], () => {
2075
- const cur = languageList.selected ?? 0;
2076
- if (cur >= SUPPORTED_LANGUAGES.length - 1) {
2077
- _navigateRow(1); // past last item → Apply button
2078
- } else {
2079
- languageList.select(cur + 1);
2080
- screen.render();
2081
- }
2082
- });
2083
- languageList.key(['up'], () => {
2084
- const cur = languageList.selected ?? 0;
2085
- if (cur <= 0) {
2086
- _navigateRow(-1); // past first item → sub-tab bar
2087
- } else {
2088
- languageList.select(cur - 1);
2089
- screen.render();
2090
- }
2091
- });
2092
- languageList.key(['enter', 'return', 'space'], () => {
2093
- const selected = SUPPORTED_LANGUAGES[languageList.selected ?? 0];
2094
- if (selected && services.languageService) {
2095
- services.languageService.setLang(selected.value);
2096
- refreshLanguageDisplay();
2097
- _showNotice(screen, `Language: ${selected.name}`);
2098
- }
2099
- });
2100
- languageList.key(['escape'], () => { if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0); });
2101
-
2102
- // ← / → within content rows — uses _buttonGroups (static); sub-tab bar has its own wiring
2103
- const _rows = []; // populated dynamically by _showSubTab()
2104
-
2105
- const _buttonGroups = [
2106
- [switchBtn], [changeBtn, playBtn],
2107
- [reverbChangeBtn, reverbTestBtn],
2108
- [trackChangeBtn, musicToggleBtn, musicTestBtn],
2109
- [volumeChangeBtn],
2110
- [verbosityChangeBtn],
2111
- [personalityChangeBtn, personalityTestBtn],
2112
- [introEditBtn, introClearBtn],
2113
- [audioDstChangeBtn],
2114
- [audioSshEditBtn, audioStreamModeBtn],
2115
- [languageList],
2116
- [langApplyBtn],
2117
- [fullPreviewBtn, saveGloballyBtn, saveLocallyBtn, cancelChangesBtn],
2118
- ];
2119
-
2120
- for (const row of _buttonGroups) {
2121
- for (let i = 0; i < row.length; i++) {
2122
- if (i < row.length - 1) {
2123
- row[i].key(['right'], () => {
2124
- // Skip hidden siblings (e.g. SSH/stream mode when destination is local)
2125
- let next = i + 1;
2126
- while (next < row.length && row[next].hidden) next++;
2127
- if (next < row.length) { _currentIdx = _buttons.indexOf(row[next]); _focusButton(row[next]); }
2128
- });
2129
- }
2130
- if (i > 0) {
2131
- row[i].key(['left'], () => {
2132
- let prev = i - 1;
2133
- while (prev >= 0 && row[prev].hidden) prev--;
2134
- if (prev >= 0) { _currentIdx = _buttons.indexOf(row[prev]); _focusButton(row[prev]); }
2135
- });
2136
- }
2137
- }
2138
- }
2139
-
2140
- // Tab/S-tab cycle: bottom buttons ↔ main header tab bar
2141
- // Debounce prevents key-repeat from firing on the newly-focused button in the same stroke.
2142
- let _tabBusy = false;
2143
- const _withTabDebounce = (fn) => () => {
2144
- if (_tabBusy) return;
2145
- _tabBusy = true;
2146
- setTimeout(() => { _tabBusy = false; }, 120);
2147
- fn();
2148
- };
2149
-
2150
- fullPreviewBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); }));
2151
- saveGloballyBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); }));
2152
- saveLocallyBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(cancelChangesBtn); _focusButton(cancelChangesBtn); }));
2153
- cancelChangesBtn.key(['tab'], _withTabDebounce(() => { if (typeof focusFirstHeaderItem === 'function') focusFirstHeaderItem(); }));
2154
-
2155
- fullPreviewBtn.key(['S-tab'], _withTabDebounce(() => { if (typeof focusLastHeaderItem === 'function') focusLastHeaderItem(); }));
2156
- saveGloballyBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(fullPreviewBtn); _focusButton(fullPreviewBtn); }));
2157
- saveLocallyBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); }));
2158
- cancelChangesBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); }));
2159
-
2160
- // Wire sub-tab ←/→ and Tab/S-tab to switch sub-tabs
2161
- for (let i = 0; i < SUB_TABS.length; i++) {
2162
- const item = _subTabItemsMap[SUB_TABS[i]];
2163
- if (i < SUB_TABS.length - 1) {
2164
- item.key(['right'], () => {
2165
- _showSubTab(SUB_TABS[i + 1], true);
2166
- _focusButton(_subTabItemsArray[i + 1]);
2167
- });
2168
- }
2169
- if (i > 0) {
2170
- item.key(['left'], () => {
2171
- _showSubTab(SUB_TABS[i - 1], true);
2172
- _focusButton(_subTabItemsArray[i - 1]);
2173
- });
2174
- }
2175
- // Tab/S-tab wrap-cycle through sub-tab bar (independent of global header Tab cycle)
2176
- item.key(['tab'], () => {
2177
- const next = (i + 1) % SUB_TABS.length;
2178
- _showSubTab(SUB_TABS[next], true);
2179
- _focusButton(_subTabItemsArray[next]);
2180
- });
2181
- item.key(['S-tab'], () => {
2182
- const prev = (i - 1 + SUB_TABS.length) % SUB_TABS.length;
2183
- _showSubTab(SUB_TABS[prev], true);
2184
- _focusButton(_subTabItemsArray[prev]);
2185
- });
2186
- }
2187
-
2188
- // Initialize with Voice sub-tab active
2189
- _showSubTab('voice');
2190
- _currentIdx = _buttons.indexOf(switchBtn);
2191
-
2192
- // Keyboard shortcuts for direct sub-tab access
2193
- box.key(['l', 'L'], () => { _showSubTab('language'); });
2194
-
2195
- // @function _refreshSopranoStatus
2196
- // @intent Update the provider status glyph (🟢/🟡/🔴) without blocking the render loop
2197
- // @why soprano-manager status does an HTTP health-check (up to 2s) — must be async
2198
- function _refreshSopranoStatus() {
2199
- if (_sopranoStatusProc) {
2200
- try { _sopranoStatusProc.kill(); } catch {}
2201
- _sopranoStatusProc = null;
2202
- }
2203
- const managerPath = path.resolve(new URL(import.meta.url).pathname,
2204
- '..', '..', '..', '..', '.claude', 'hooks', 'soprano-manager.sh');
2205
- if (!fs.existsSync(managerPath)) return;
2206
-
2207
- const proc = spawn('bash', [managerPath, 'status'], {
2208
- stdio: ['ignore', 'ignore', 'ignore'],
2209
- });
2210
- _sopranoStatusProc = proc;
2211
-
2212
- proc.on('exit', (code) => {
2213
- _sopranoStatusProc = null;
2214
- // 0=running(🟢) 1=starting(🟡) 2=stopped(🔴) 3=conflict(🔴)
2215
- _sopranoStatusGlyph = code === 0 ? ' 🟢' : code === 1 ? ' 🟡' : ' 🔴';
2216
- if (providerService.getActiveProvider() === 'soprano') {
2217
- const name = _ALL_PROVIDERS.find(p => p.id === 'soprano')?.name ?? 'Soprano';
2218
- providerValue.setContent(name + _sopranoStatusGlyph);
2219
- screen.render();
2220
- }
2221
- });
2222
-
2223
- proc.on('error', () => { _sopranoStatusProc = null; });
2224
- }
2225
-
2226
- function refreshDisplay() {
2227
- const activeProvider = providerService.getActiveProvider();
2228
- const activeVoice = providerService.getActiveVoiceId();
2229
- const provName = _ALL_PROVIDERS.find(p => p.id === activeProvider)?.name ?? activeProvider;
2230
- if (activeProvider === 'soprano') {
2231
- // Show cached glyph immediately, kick off async refresh for updated status
2232
- providerValue.setContent(provName + _sopranoStatusGlyph);
2233
- _refreshSopranoStatus();
2234
- } else {
2235
- providerValue.setContent(provName);
2236
- // Cancel any pending status check and clear glyph when leaving soprano
2237
- _sopranoStatusGlyph = '';
2238
- if (_sopranoStatusProc) { try { _sopranoStatusProc.kill(); } catch {} _sopranoStatusProc = null; }
2239
- }
2240
- // Single-voice providers: show the provider name instead of voice ID
2241
- // For multi-speaker voices, show speaker name (e.g., "Kristin_Hughes" not "16Speakers::Kristin_Hughes")
2242
- const _msDisplay = parseMultiSpeaker(activeVoice);
2243
- voiceValue.setContent(activeProvider === 'soprano' ? 'Soprano' : (_msDisplay.isMultiSpeaker ? _msDisplay.speakerName : activeVoice));
2244
- // Only Piper supports multiple installed voices — hide Change for single-voice providers
2245
- if (activeProvider === 'piper') { changeBtn.show(); playBtn.left = 64; voiceFileText.setContent('.claude/tts-voice.txt'); }
2246
- else { changeBtn.hide(); playBtn.left = 52; voiceFileText.setContent(''); }
2247
-
2248
- // Group 2: Audio Effects
2249
- const effects = configService.getConfig().effects ?? EFFECTS_DEFAULTS;
2250
- reverbValue.setContent(formatReverbState(effects.reverbPreset ?? 'light'));
2251
-
2252
- // Group 3: Background Music
2253
- const music = configService.getConfig().backgroundMusic ?? configService.getConfig().music ?? MUSIC_DEFAULTS;
2254
- // Strip leading emoji so double-width chars don't misalign buttons on the same row
2255
- trackValue.setContent(_stripLeadingEmoji(formatTrackName(music.track)));
2256
- const musicEnabled = music.enabled ?? false;
2257
- musicToggleBtn.setContent(musicEnabled ? 'Enabled' : 'Disabled');
2258
- musicToggleBtn.style.bg = musicEnabled ? COLORS.btnEnableOn : COLORS.btnEnableOff;
2259
- volumeValue.setContent(formatVolume(music.volume));
2260
-
2261
- // Group 4: Personality & Verbosity
2262
- const cfg = configService.getConfig();
2263
- verbosityValue.setContent(formatVerbosity(cfg.verbosity));
2264
- personalityValue.setContent(_stripLeadingEmoji(formatPersonality(cfg.personality)));
2265
- const _pers = (cfg.personality ?? '').trim();
2266
- personalityFileText.setContent(
2267
- (_pers && _pers !== 'none' && _pers !== 'normal')
2268
- ? `.claude/personalities/${_pers}.md`
2269
- : '',
2270
- );
2271
-
2272
- // Group 5: Intro Text
2273
- introTextValue.setContent(formatIntroText(cfg.pretext));
2274
-
2275
- // Group 6: Audio Destination
2276
- const audioDst = cfg.audio_destination ?? 'local';
2277
- const audioAlias = cfg.audio_ssh_alias ?? '';
2278
- audioDstValue.setContent(formatAudioDst(audioDst, audioAlias));
2279
- // Show/hide SSH Alias row and stream mode toggle based on destination
2280
- if (audioDst === 'remote') {
2281
- audioSshLabel.show();
2282
- audioSshValue.show();
2283
- audioSshEditBtn.show();
2284
- audioStreamModeBtn.show();
2285
- audioSshValue.setContent(audioAlias || '(none)');
2286
- const streamMode = cfg.audio_stream_mode ?? 'text';
2287
- audioStreamModeBtn.setContent(streamMode === 'pulse' ? 'Streaming Pulse Audio' : 'Streaming Text Only ✓');
2288
- audioStreamModeBtn.style.bg = streamMode === 'text' ? '#1565c0' : COLORS.btnChange;
2289
- } else {
2290
- audioSshLabel.hide();
2291
- audioSshValue.hide();
2292
- audioSshEditBtn.hide();
2293
- audioStreamModeBtn.hide();
2294
- }
2295
-
2296
- if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
2297
- screen.render();
2298
- }
2299
-
2300
- function refreshConfigDisplay() {
2301
- const globalPath = configService.getGlobalConfigPath();
2302
- const localPath = configService.getLocalConfigPath();
2303
- const hasLocal = configService.hasLocalConfig();
2304
- // Abbreviate home dir with ~ for readability
2305
- const home = os.homedir();
2306
- const abbrev = (p) => p.startsWith(home) ? '~' + p.slice(home.length) : p;
2307
- configGlobalValue.setContent(abbrev(globalPath));
2308
- // Local path shown in full (not abbreviated) so the user sees the real location
2309
- configLocalValue.setContent(
2310
- hasLocal ? localPath : 'None (settings saved to global)',
2311
- );
2312
- screen.render();
2313
- }
2314
-
2315
- // -------------------------------------------------------------------------
2316
- // refreshLabels — update all static label/header/button strings to current language
2317
- // Called from show() so strings update whenever the user returns to this tab after a lang change.
2318
-
2319
- function refreshLabels() {
2320
- // Sub-tab bar labels (content only — widths fixed at creation)
2321
- for (const id of SUB_TABS) {
2322
- _subTabItemsMap[id].setContent(_t(SUB_TAB_KEYS[id]));
2323
- }
2324
- // Section headers
2325
- providerVoiceHeader.setContent(`{bright-cyan-fg}${_t('sectionProviderVoice')}{/bright-cyan-fg}`);
2326
- audioEffectsHeader.setContent(`{bright-cyan-fg}${_t('sectionAudioEffects')}{/bright-cyan-fg}`);
2327
- bgMusicHeader.setContent(`{bright-cyan-fg}${_t('sectionBgMusic')}{/bright-cyan-fg}`);
2328
- styleHeader.setContent(`{bright-cyan-fg}${_t('sectionStyle')}{/bright-cyan-fg}`);
2329
- introTextHeader.setContent(`{bright-cyan-fg}${_t('sectionIntroText')}{/bright-cyan-fg}`);
2330
- audioDstHeader.setContent(`{bright-cyan-fg}${_t('sectionAudioDest')}{/bright-cyan-fg}`);
2331
- configStorageHeader.setContent(`{bright-cyan-fg}${_t('sectionConfigStorage')}{/bright-cyan-fg}`);
2332
- languageSectionHeader.setContent(`{bright-cyan-fg}${_t('sectionLanguage')}{/bright-cyan-fg}`);
2333
- // Row labels
2334
- providerLabel.setContent(_t('providerRowLabel'));
2335
- voiceLabel.setContent(_t('currentVoiceLabel'));
2336
- reverbLabel.setContent(_t('reverbLabel'));
2337
- trackLabel.setContent(_t('trackLabel'));
2338
- volumeLabel.setContent(_t('volumeLabel'));
2339
- verbosityLabel.setContent(_t('verbosityLabel'));
2340
- personalityLabel.setContent(_t('personalityLabel'));
2341
- introTextLabel.setContent(_t('introTextRowLabel'));
2342
- audioDstLabel.setContent(_t('destinationLabel'));
2343
- audioSshLabel.setContent(_t('sshAliasLabel'));
2344
- configGlobalLabel.setContent(_t('globalLabel'));
2345
- configLocalLabel.setContent(_t('localLabel'));
2346
- languageCurrentLabel.setContent(_t('languageLabel'));
2347
- // Buttons (only the ones with fixed labels — not dynamic state buttons)
2348
- switchBtn.setContent(_t('switchBtn'));
2349
- changeBtn.setContent(_t('changeBtn'));
2350
- reverbChangeBtn.setContent(_t('changeBtn'));
2351
- trackChangeBtn.setContent(_t('changeBtn'));
2352
- volumeChangeBtn.setContent(_t('changeBtn'));
2353
- verbosityChangeBtn.setContent(_t('changeBtn'));
2354
- personalityChangeBtn.setContent(_t('changeBtn'));
2355
- audioDstChangeBtn.setContent(_t('changeBtn'));
2356
- playBtn.setContent(_t('playBtn'));
2357
- reverbTestBtn.setContent(_t('previewBtn'));
2358
- _testBtnLabels.set(reverbTestBtn, _t('previewBtn'));
2359
- personalityTestBtn.setContent(_t('previewBtn'));
2360
- _testBtnLabels.set(personalityTestBtn, _t('previewBtn'));
2361
- fullPreviewBtn.setContent(_t('fullPreviewBtn'));
2362
- _testBtnLabels.set(fullPreviewBtn, _t('fullPreviewBtn'));
2363
- musicTestBtn.setContent(_t('previewBtn'));
2364
- saveGloballyBtn.setContent(_t('saveGloballyBtn'));
2365
- saveLocallyBtn.setContent(_t('saveLocallyBtn'));
2366
- cancelChangesBtn.setContent(_t('cancelChangesBtn'));
2367
- introEditBtn.setContent(_t('editBtn'));
2368
- introClearBtn.setContent(_t('clearBtn'));
2369
- audioSshEditBtn.setContent(_t('editBtn'));
2370
- langApplyBtn.setContent(_t('applyLanguageBtn'));
2371
- }
2372
-
2373
- // -------------------------------------------------------------------------
2374
- // Tab Component Contract implementation
2375
-
2376
- return {
2377
- box,
2378
-
2379
- show() {
2380
- _captureSnapshot();
2381
- box.show();
2382
- refreshLabels();
2383
- refreshDisplay();
2384
- refreshConfigDisplay();
2385
- refreshLanguageDisplay();
2386
- // Force full olines invalidation — prevents ghost rows when the tab becomes visible
2387
- try {
2388
- for (let r = 0; r < screen.height; r++)
2389
- for (let c = 0; c < screen.width; c++)
2390
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
2391
- } catch {}
2392
- screen.render();
2393
- },
2394
-
2395
- hide() {
2396
- _killSample();
2397
- playBtn.setContent('▶ Play');
2398
- _killTest();
2399
- _restoreTestBtnsLabels();
2400
- _killMusicTest();
2401
- musicTestBtn.setContent('▶ Preview');
2402
- // Kill any pending soprano status check
2403
- if (_sopranoStatusProc) {
2404
- try { _sopranoStatusProc.kill(); } catch {}
2405
- _sopranoStatusProc = null;
2406
- }
2407
- box.hide();
2408
- screen.render();
2409
- },
2410
-
2411
- onFocus() {
2412
- // Land on the active sub-tab bar item so the user can ↑↓ from there.
2413
- // Use _focusButton (not raw .focus()) so olines get invalidated before render,
2414
- // preventing the ghost-duplicate-row artifact on initial tab activation.
2415
- const activeSubTabItem = _subTabItemsMap[_activeSubTab];
2416
- _currentIdx = _buttons.indexOf(activeSubTabItem);
2417
- _focusButton(activeSubTabItem);
2418
- },
2419
-
2420
- onBlur() {
2421
- _killSample();
2422
- playBtn.setContent('▶ Play');
2423
- _killTest();
2424
- _restoreTestBtnsLabels();
2425
- _killMusicTest();
2426
- musicTestBtn.setContent('▶ Preview');
2427
- },
2428
-
2429
- getFooterText() {
2430
- return _t('settingsFooter');
2431
- },
2432
-
2433
- getFooterColor() {
2434
- return COLORS.footerBg;
2435
- },
2436
-
2437
- focusBottomRow() {
2438
- _currentIdx = _buttons.indexOf(fullPreviewBtn);
2439
- _focusButton(fullPreviewBtn);
2440
- },
2441
-
2442
- focusLastBottomRow() {
2443
- _currentIdx = _buttons.indexOf(cancelChangesBtn);
2444
- _focusButton(cancelChangesBtn);
2445
- },
2446
- };
2447
- }
2448
-
2449
- // ---------------------------------------------------------------------------
2450
- // Private: Create a styled focusable button
2451
-
2452
- function _createButton(parent, screen, label, COLORS, onClick, opts = {}) {
2453
- const baseBg = opts.bg ?? COLORS.btnDefault;
2454
- const getDynamicBg = opts.getDynamicBg ?? null;
2455
- const btn = blessed.button({
2456
- parent,
2457
- content: label,
2458
- mouse: true,
2459
- keys: true,
2460
- shrink: true,
2461
- padding: { left: 1, right: 1 },
2462
- style: {
2463
- bg: baseBg,
2464
- fg: 'white',
2465
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
2466
- },
2467
- });
2468
-
2469
- // Focus indicators: ►label◄ with blinking █ cursor
2470
- // Store interval on the button so it can be cleared when the button is hidden.
2471
- btn._btnBlinkInterval = null;
2472
- btn.on('focus', () => {
2473
- btn.style.bg = COLORS.btnFocus;
2474
- btn.style.fg = COLORS.btnFocusFg;
2475
- const raw = btn.content.replace(/[►◄█]/g, '').trim();
2476
- btn.setContent(`►${raw}◄ █`);
2477
- let _on = true;
2478
- screen.render();
2479
- btn._btnBlinkInterval = setInterval(() => {
2480
- _on = !_on;
2481
- // Skip if spinner has overridden the content (no ► means spinner is active)
2482
- if (!btn.content.includes('►')) return;
2483
- const r = btn.content.replace(/[►◄█]/g, '').trim();
2484
- btn.setContent(_on ? `►${r}◄ █` : `►${r}◄`);
2485
- screen.render();
2486
- }, 500);
2487
- });
2488
- btn.on('blur', () => {
2489
- if (btn._btnBlinkInterval) { clearInterval(btn._btnBlinkInterval); btn._btnBlinkInterval = null; }
2490
- btn.style.bg = getDynamicBg ? getDynamicBg() : baseBg;
2491
- btn.style.fg = 'white';
2492
- const raw = btn.content.replace(/[►◄█]/g, '').trim();
2493
- btn.setContent(raw);
2494
- screen.render();
2495
- });
2496
-
2497
- // Keyboard activation with magenta flash
2498
- btn.key(['enter', 'space'], () => {
2499
- btn.style.bg = COLORS.btnPress;
2500
- btn.style.fg = 'white';
2501
- screen.render();
2502
- setTimeout(() => {
2503
- btn.style.bg = getDynamicBg ? getDynamicBg() : baseBg;
2504
- btn.style.fg = 'white';
2505
- screen.render();
2506
- onClick();
2507
- }, 150);
2508
- });
2509
-
2510
- // Mouse click only — no mouseover so hover never causes render artifacts
2511
- btn.on('click', () => btn.press());
2512
-
2513
- return btn;
2514
- }
2515
-
2516
- // ---------------------------------------------------------------------------
2517
- // Private: Provider picker modal — all providers, install status, instructions
2518
-
2519
- const _ALL_PROVIDERS = [
2520
- { id: 'piper', name: 'Piper TTS', platforms: ['linux', 'darwin', 'win32'], desc: 'High-quality local neural TTS' },
2521
- { id: 'soprano', name: 'Soprano', platforms: ['linux', 'darwin'], desc: 'Ultra-fast neural TTS (single voice)' },
2522
- { id: 'sapi', name: 'Windows SAPI', platforms: ['win32'], desc: 'Windows built-in text-to-speech' },
2523
- { id: 'macos', name: 'Mac Say', platforms: ['darwin'], desc: 'macOS built-in text-to-speech' },
2524
- ];
2525
-
2526
- const _INSTALL_CMDS = {
2527
- piper: ['pip install piper-tts', 'OR: pipx install piper-tts', '', 'Voices are downloaded separately:', 'Run: agentvibes install (then choose Piper)'],
2528
- soprano: ['pip install soprano-tts', 'OR: pipx install soprano-tts', '', 'Keep model loaded for fast synthesis:', 'soprano-webui'],
2529
- sapi: ['Built-in on Windows — no install required.', 'Only works in a native Windows shell,', 'not inside WSL. Use piper or soprano in WSL.'],
2530
- macos: ['Built-in on macOS — no install required.', 'The say command ships with every Mac.'],
2531
- };
2532
-
2533
- function _detectEnvLabel() {
2534
- if (process.platform === 'win32') return { label: 'Windows', platform: 'win32' };
2535
- if (process.platform === 'darwin') return { label: 'macOS', platform: 'darwin' };
2536
- try {
2537
- const v = fs.readFileSync('/proc/version', 'utf8');
2538
- if (v.toLowerCase().includes('microsoft')) return { label: 'WSL (Linux/Microsoft)', platform: 'linux' };
2539
- } catch {}
2540
- return { label: 'Linux', platform: 'linux' };
2541
- }
2542
-
2543
- function _openProviderPicker(screen, providerService, onSelect, onClose) {
2544
- const { label: envLabel, platform } = _detectEnvLabel();
2545
- const installed = new Set(providerService.getInstalledProviders());
2546
- const current = providerService.getActiveProvider();
2547
-
2548
- const modal = blessed.box({
2549
- parent: screen,
2550
- top: 'center',
2551
- left: 'center',
2552
- width: 70,
2553
- height: 24,
2554
- border: { type: 'line' },
2555
- tags: true,
2556
- label: _modalTitle('Select Provider'),
2557
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
2558
- });
2559
-
2560
- function _close() {
2561
- modal.destroy();
2562
- try {
2563
- for (let r = 0; r < screen.height; r++)
2564
- for (let c = 0; c < screen.width; c++)
2565
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
2566
- } catch {}
2567
- onClose?.();
2568
- screen.render();
2569
- }
2570
-
2571
- // Environment header
2572
- blessed.text({
2573
- parent: modal, top: 0, left: 1, tags: true,
2574
- content: `{bright-cyan-fg}🖥 Environment:{/bright-cyan-fg} {bold}${envLabel}{/bold}`,
2575
- style: { bg: COLORS.contentBg },
2576
- });
2577
- blessed.text({
2578
- parent: modal, top: 1, left: 0,
2579
- content: ' ' + '─'.repeat(66),
2580
- style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg },
2581
- });
2582
-
2583
- // Provider rows (top 2–5)
2584
- const actionBtns = [];
2585
- let focusIdx = 0;
2586
-
2587
- _ALL_PROVIDERS.forEach((prov, i) => {
2588
- const rowTop = 2 + (i * 2); // 2 rows per provider: name row + description row
2589
- const isSupported = prov.platforms.includes(platform);
2590
- const isInstalled = installed.has(prov.id);
2591
- const isCurrent = prov.id === current;
2592
-
2593
- if (!isSupported) {
2594
- const osMap = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' };
2595
- const forOs = prov.platforms.map(p => osMap[p] ?? p).join('/');
2596
- blessed.text({
2597
- parent: modal, top: rowTop, left: 1, width: 66, tags: true,
2598
- content: `{#546e7a-fg}✗ ${prov.name} — only on: ${forOs}{/#546e7a-fg}`,
2599
- style: { bg: COLORS.contentBg },
2600
- });
2601
- blessed.text({
2602
- parent: modal, top: rowTop + 1, left: 5, width: 62, tags: true,
2603
- content: `{#455a64-fg}${prov.desc}{/#455a64-fg}`,
2604
- style: { bg: COLORS.contentBg },
2605
- });
2606
- return;
2607
- }
2608
-
2609
- const icon = isInstalled ? '{green-fg}✓{/green-fg}' : '{#ef9a9a-fg}✗{/#ef9a9a-fg}';
2610
- const name = isInstalled ? `{bold}${prov.name}{/bold}` : prov.name;
2611
- const active = isCurrent ? ' {yellow-fg}[active]{/yellow-fg}' : '';
2612
- const status = isInstalled ? '{green-fg}Installed{/green-fg}' : '{#ef9a9a-fg}Not found{/#ef9a9a-fg}';
2613
-
2614
- blessed.text({ parent: modal, top: rowTop, left: 1, width: 30, tags: true, content: `${icon} ${name}${active}`, style: { bg: COLORS.contentBg } });
2615
- blessed.text({ parent: modal, top: rowTop, left: 44, width: 12, tags: true, content: status, style: { bg: COLORS.contentBg } });
2616
- blessed.text({ parent: modal, top: rowTop + 1, left: 5, width: 60, tags: true,
2617
- content: `{#90a4ae-fg}${prov.desc}{/#90a4ae-fg}`, style: { bg: COLORS.contentBg } });
2618
-
2619
- const btn = _createButton(modal, screen, isInstalled ? 'Select' : 'Install', COLORS, () => {
2620
- if (isInstalled) {
2621
- _close(); onSelect(prov.id);
2622
- } else {
2623
- const lines = _INSTALL_CMDS[prov.id] ?? ['No instructions available.'];
2624
- instrTitle.setContent(`{bright-cyan-fg}Install — ${prov.name}:{/bright-cyan-fg}`);
2625
- instrContent.setContent(lines.map(l => l ? `{bright-cyan-fg}${l}{/bright-cyan-fg}` : '').join('\n'));
2626
- screen.render();
2627
- }
2628
- });
2629
- btn.top = rowTop; btn.left = 57;
2630
- if (isCurrent) focusIdx = actionBtns.length;
2631
- actionBtns.push(btn);
2632
- });
2633
-
2634
- // Separator + instructions panel (shifted down 4 rows due to 2-row provider layout)
2635
- blessed.text({ parent: modal, top: 10, left: 0, content: ' ' + '─'.repeat(66), style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg } });
2636
-
2637
- const instrTitle = blessed.text({
2638
- parent: modal, top: 11, left: 1, width: 66, tags: true,
2639
- content: '{bright-cyan-fg}Install instructions — click Install beside a provider:{/bright-cyan-fg}',
2640
- style: { bg: COLORS.contentBg },
2641
- });
2642
- const instrContent = blessed.text({
2643
- parent: modal, top: 12, left: 3, width: 64, height: 5, tags: true,
2644
- content: '{#546e7a-fg}(click Install beside a provider to see commands){/#546e7a-fg}',
2645
- style: { bg: COLORS.contentBg },
2646
- });
2647
-
2648
- // Bottom separator + Cancel
2649
- blessed.text({ parent: modal, top: 18, left: 0, content: ' ' + '─'.repeat(66), style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg } });
2650
-
2651
- const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close);
2652
- cancelBtn.top = 19; cancelBtn.left = 'center';
2653
- actionBtns.push(cancelBtn);
2654
-
2655
- // Keyboard navigation
2656
- for (let i = 0; i < actionBtns.length; i++) {
2657
- actionBtns[i].key(['down', 'tab'], () => {
2658
- const cur = actionBtns.findIndex(b => b === screen.focused);
2659
- actionBtns[(cur + 1) % actionBtns.length].focus();
2660
- });
2661
- actionBtns[i].key(['up', 'S-tab'], () => {
2662
- const cur = actionBtns.findIndex(b => b === screen.focused);
2663
- actionBtns[(cur - 1 + actionBtns.length) % actionBtns.length].focus();
2664
- });
2665
- }
2666
- modal.key(['escape', 'q'], _close);
2667
-
2668
- (actionBtns[focusIdx] ?? actionBtns[0])?.focus();
2669
- screen.render();
2670
- }
2671
-
2672
- // ---------------------------------------------------------------------------
2673
- // Private: Destroy helper — now imported from shared widgets/destroy-list.js
2674
- // (kept as comment for git blame traceability)
2675
-
2676
- // NOTE: The following line was the old _destroyList definition, now using shared import:
2677
- // import { destroyList } from '../widgets/destroy-list.js';
2678
- //
2679
- // Old code removed to eliminate duplication (M1 fix).
2680
- // The shared destroyList has identical behavior.
2681
-
2682
- // ---------------------------------------------------------------------------
2683
- // Private: Show a temporary stub notice text
2684
-
2685
- // Strip a leading emoji character (code points > U+2500 cover emoji ranges)
2686
- // while preserving punctuation like en-dash (U+2013) and em-dash (U+2014).
2687
- function _stripLeadingEmoji(s) {
2688
- if (!s) return s;
2689
- const cp = s.codePointAt(0);
2690
- return cp > 0x2500 ? s.slice(String.fromCodePoint(cp).length).trimStart() : s;
2691
- }
2692
-
2693
- /**
2694
- * Show a "Save Preview" confirmation modal.
2695
- * Displays the destination path and all key-value pairs that will be saved.
2696
- * User must press [OK — Save] to confirm or [Cancel] to abort.
2697
- *
2698
- * @param {object} screen - blessed screen
2699
- * @param {string} filePath - absolute destination path
2700
- * @param {object} data - config object to be saved
2701
- * @param {Function} onConfirm - called only if user presses OK
2702
- */
2703
- function _showSavePreview(screen, filePath, data, onConfirm, onClose) {
2704
- // Flatten nested objects one level deep
2705
- const rawLines = [];
2706
- for (const [k, v] of Object.entries(data)) {
2707
- if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
2708
- for (const [sk, sv] of Object.entries(v)) {
2709
- rawLines.push([`${k}.${sk}`, String(sv ?? '')]);
2710
- }
2711
- } else {
2712
- rawLines.push([k, String(v ?? '')]);
2713
- }
2714
- }
2715
-
2716
- const keyWidth = rawLines.length ? Math.max(...rawLines.map(([k]) => k.length)) : 0;
2717
- const pathLine = ` Path: ${filePath}`;
2718
- const kvMaxW = rawLines.length ? Math.max(...rawLines.map(([k, v]) => 2 + keyWidth + 2 + v.length)) : 0;
2719
- const innerW = Math.max(52, pathLine.length + 2, kvMaxW + 4);
2720
- const width = Math.min(innerW + 4, screen.width - 4);
2721
- const sep = '─'.repeat(Math.max(0, Math.min(innerW - 2, width - 6)));
2722
-
2723
- const taggedKV = rawLines.map(([k, v]) =>
2724
- ` {#90a4ae-fg}${k.padEnd(keyWidth)}:{/#90a4ae-fg} {#ffff00-fg}${v}{/#ffff00-fg}`
2725
- );
2726
-
2727
- // Content rows (all text rendered via box.content; buttons are child widgets)
2728
- const contentLines = [
2729
- ` {#90a4ae-fg}Path:{/#90a4ae-fg} ${filePath}`,
2730
- ` ${sep}`,
2731
- ...taggedKV,
2732
- ` ${sep}`,
2733
- '', // blank row — buttons sit here as child widgets
2734
- ];
2735
-
2736
- const height = contentLines.length + 2; // +2 for top/bottom border
2737
-
2738
- const modal = blessed.box({
2739
- parent: screen,
2740
- top: 'center',
2741
- left: 'center',
2742
- width,
2743
- height,
2744
- label: _modalTitle('Save Preview'),
2745
- border: { type: 'line' },
2746
- tags: true,
2747
- content: contentLines.join('\n'),
2748
- style: {
2749
- fg: '#e3f2fd',
2750
- bg: COLORS.contentBg,
2751
- border: { fg: 'bright-cyan' },
2752
- },
2753
- });
2754
-
2755
- function _close() { destroyList(modal, screen, onClose); }
2756
-
2757
- modal.key(['escape'], _close);
2758
-
2759
- // Buttons are children of the modal box; top is relative to box content area
2760
- const btnRow = contentLines.length - 1; // last content line (the blank row)
2761
- const midX = Math.floor((width - 2) / 2);
2762
-
2763
- const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close, { bg: '#c62828' });
2764
- cancelBtn.top = btnRow;
2765
- cancelBtn.left = midX - 14;
2766
-
2767
- const okBtn = _createButton(modal, screen, 'OK — Save', COLORS, () => {
2768
- _close();
2769
- onConfirm();
2770
- }, { bg: '#1565c0' });
2771
- okBtn.top = btnRow;
2772
- okBtn.left = midX + 2;
2773
-
2774
- // Keyboard navigation between OK and Cancel buttons
2775
- okBtn.key(['tab', 'left', 'right'], () => { cancelBtn.focus(); screen.render(); });
2776
- cancelBtn.key(['tab', 'left', 'right'], () => { okBtn.focus(); screen.render(); });
2777
-
2778
- screen.render();
2779
- okBtn.focus();
2780
- }
2781
-
2782
- function _showNotice(screen, message) {
2783
- _showNoticeWidget(screen, message, { bg: COLORS.contentBg });
2784
- }
2785
-
2786
- // ---------------------------------------------------------------------------
2787
- // Private: Effects config read/write helpers
2788
-
2789
- function _getEffects(configService) {
2790
- return configService.getConfig().effects ?? EFFECTS_DEFAULTS;
2791
- }
2792
-
2793
- function _setEffects(configService, partial) {
2794
- const current = configService.getConfig().effects ?? EFFECTS_DEFAULTS;
2795
- const merged = { ...current, ...partial };
2796
- configService.set('effects', merged);
2797
- }
2798
-
2799
- // ---------------------------------------------------------------------------
2800
- // Private: _openReverbPicker removed — now using shared import:
2801
- // import { openReverbPicker } from '../widgets/reverb-picker.js';
2802
-
2803
- // ---------------------------------------------------------------------------
2804
- // Private: Background music config read/write helpers
2805
-
2806
- function _getMusic(configService) {
2807
- return configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
2808
- }
2809
-
2810
- function _setMusic(configService, partial) {
2811
- const current = configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
2812
- const merged = { ...current, ...partial };
2813
- configService.set('backgroundMusic', merged);
2814
- }
2815
-
2816
- // ---------------------------------------------------------------------------
2817
- // Private: Inline track picker
2818
-
2819
- function _openTrackPicker(screen, configService, onSelect, onClose) {
2820
- // Scan .claude/audio/tracks/ dynamically; fall back to BUILT_IN_TRACKS if missing.
2821
- const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
2822
- let tracks;
2823
- try {
2824
- const files = fs.readdirSync(tracksDir);
2825
- tracks = files
2826
- .filter(f => /\.mp3$/i.test(f))
2827
- .sort()
2828
- .map(f => ({ file: f, label: formatTrackName(f) }));
2829
- } catch {
2830
- tracks = BUILT_IN_TRACKS;
2831
- }
2832
-
2833
- const ADD_SENTINEL = '__ADD_CUSTOM_TRACK__';
2834
- const allItems = [...tracks, { file: ADD_SENTINEL, label: '+ Add Custom Track' }];
2835
-
2836
- const currentTrack = (configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track);
2837
- const items = allItems.map(t =>
2838
- t.file === ADD_SENTINEL
2839
- ? ` {bright-cyan-fg}+ Add Custom Track{/bright-cyan-fg}`
2840
- : (t.file === currentTrack ? `● ${t.label}` : ` ${t.label}`)
2841
- );
2842
- const currentIdx = tracks.findIndex(t => t.file === currentTrack);
2843
-
2844
- const listHeight = Math.min(allItems.length + 4, Math.floor(screen.rows * 0.7));
2845
- const list = blessed.list({
2846
- parent: screen,
2847
- top: 'center',
2848
- left: 'center',
2849
- width: 50,
2850
- height: listHeight,
2851
- border: { type: 'line' },
2852
- tags: true,
2853
- label: _modalTitle('Select Track'),
2854
- items,
2855
- keys: true,
2856
- vi: false,
2857
- mouse: true,
2858
- scrollable: true,
2859
- scrollbar: { ch: '│', track: { bg: '#1e2a3a' }, style: { fg: COLORS.btnFocus } },
2860
- style: {
2861
- border: { fg: COLORS.btnFocus },
2862
- selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
2863
- item: { fg: '#e3f2fd' },
2864
- },
2865
- });
2866
-
2867
- if (currentIdx >= 0) list.select(currentIdx);
2868
- list.focus();
2869
- screen.render();
2870
-
2871
- list.key(['enter', 'space'], () => {
2872
- const selected = allItems[list.selected];
2873
- if (!selected) return;
2874
- if (selected.file === ADD_SENTINEL) {
2875
- // Destroy list first, then open path-input dialog
2876
- destroyList(list, screen);
2877
- _openCustomTrackInput(screen, tracksDir, (newFile) => {
2878
- onSelect(newFile);
2879
- }, onClose);
2880
- return;
2881
- }
2882
- destroyList(list, screen, onClose);
2883
- onSelect(selected.file);
2884
- });
2885
-
2886
- list.key(['escape', 'q'], () => {
2887
- destroyList(list, screen, onClose);
2888
- });
2889
- }
2890
-
2891
- // ---------------------------------------------------------------------------
2892
- // Private: Custom track path-input dialog — copies an MP3 into tracks dir
2893
-
2894
- function _openCustomTrackInput(screen, tracksDir, onDone, onClose) {
2895
- let _closed = false;
2896
-
2897
- const modal = blessed.box({
2898
- parent: screen,
2899
- top: 'center',
2900
- left: 'center',
2901
- width: 64,
2902
- height: 11,
2903
- border: { type: 'line' },
2904
- tags: true,
2905
- label: _modalTitle('Add Custom Track'),
2906
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.btnFocus } },
2907
- });
2908
-
2909
- blessed.text({
2910
- parent: modal, top: 1, left: 2,
2911
- content: 'Enter full path to an MP3 file:',
2912
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
2913
- });
2914
-
2915
- const inputBox = blessed.textbox({
2916
- parent: modal, top: 3, left: 2, right: 2, height: 3,
2917
- border: { type: 'line' },
2918
- inputOnFocus: true,
2919
- style: {
2920
- fg: COLORS.valueFg, bg: '#0d1b35',
2921
- border: { fg: COLORS.borderFg },
2922
- focus: { border: { fg: COLORS.btnFocus } },
2923
- },
2924
- });
2925
-
2926
- const errText = blessed.text({
2927
- parent: modal, top: 7, left: 2, width: 58,
2928
- tags: true, content: '',
2929
- style: { bg: COLORS.contentBg },
2930
- });
2931
-
2932
- function _close() {
2933
- if (_closed) return;
2934
- _closed = true;
2935
- modal.destroy();
2936
- try {
2937
- for (let r = 0; r < screen.height; r++)
2938
- for (let c = 0; c < screen.width; c++)
2939
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
2940
- } catch {}
2941
- onClose?.();
2942
- screen.render();
2943
- }
2944
-
2945
- function _addTrack() {
2946
- const raw = inputBox.getValue().trim();
2947
- if (!raw) return;
2948
- const src = path.resolve(raw);
2949
-
2950
- // Validate: must be a readable .mp3 file owned by the current user
2951
- if (!/\.mp3$/i.test(src)) {
2952
- errText.setContent('{red-fg}File must be an MP3 (.mp3){/red-fg}');
2953
- screen.render(); return;
2954
- }
2955
- try {
2956
- const stat = fs.statSync(src);
2957
- if (!stat.isFile()) throw new Error('not a file');
2958
- if (stat.uid !== undefined && stat.uid !== process.getuid?.()) {
2959
- errText.setContent('{red-fg}File not owned by current user{/red-fg}');
2960
- screen.render(); return;
2961
- }
2962
- } catch {
2963
- errText.setContent('{red-fg}File not found or not accessible{/red-fg}');
2964
- screen.render(); return;
2965
- }
2966
-
2967
- const dest = path.join(tracksDir, path.basename(src));
2968
- try {
2969
- fs.mkdirSync(tracksDir, { recursive: true });
2970
- fs.copyFileSync(src, dest);
2971
- } catch {
2972
- errText.setContent('{red-fg}Could not copy file to tracks directory{/red-fg}');
2973
- screen.render(); return;
2974
- }
2975
-
2976
- _closed = true;
2977
- modal.destroy();
2978
- try {
2979
- for (let r = 0; r < screen.height; r++)
2980
- for (let c = 0; c < screen.width; c++)
2981
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
2982
- } catch {}
2983
- screen.render();
2984
- onDone(path.basename(src));
2985
- }
2986
-
2987
- inputBox.key(['enter'], _addTrack);
2988
- inputBox.key(['escape'], _close);
2989
-
2990
- const addBtn = _createButton(modal, screen, 'Add Track', COLORS, _addTrack);
2991
- addBtn.bottom = 1; addBtn.left = 4;
2992
-
2993
- const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close);
2994
- cancelBtn.bottom = 1; cancelBtn.left = 18;
2995
-
2996
- addBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
2997
- cancelBtn.key(['tab'], () => { inputBox.focus(); screen.render(); });
2998
-
2999
- modal.setFront();
3000
- inputBox.focus();
3001
- screen.render();
3002
- }
3003
-
3004
- // ---------------------------------------------------------------------------
3005
- // Private: Inline volume picker (10% steps: 10–100)
3006
-
3007
- function _openVolumePicker(screen, configService, onSelect, onClose) {
3008
- const VOLUMES = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
3009
- const currentVol = configService.getConfig().backgroundMusic?.volume ?? MUSIC_DEFAULTS.volume;
3010
- const currentIdx = Math.max(0, VOLUMES.indexOf(currentVol));
3011
-
3012
- // Preview state
3013
- let _previewProcess = null;
3014
- let _previewVol = null;
3015
-
3016
- const _previewEnv = buildAudioEnv();
3017
-
3018
- function _killPreview() {
3019
- if (_previewProcess) {
3020
- if (_IS_WINDOWS) {
3021
- try { _previewProcess.kill(); } catch {}
3022
- } else {
3023
- try { process.kill(-_previewProcess.pid, 'SIGTERM'); } catch {}
3024
- }
3025
- _previewProcess = null;
3026
- }
3027
- _previewVol = null;
3028
- }
3029
-
3030
- function _buildItems() {
3031
- return VOLUMES.map((v, i) => {
3032
- const mark = (v === _previewVol) ? '♪' : (i === currentIdx ? '●' : ' ');
3033
- const hint = (v === _previewVol) ? ' (Space to stop) ' : ' (Space to test) ';
3034
- return ` ${mark} ${String(v).padStart(3)}%${hint}`;
3035
- });
3036
- }
3037
-
3038
- function _refreshList() {
3039
- const sel = list.selected;
3040
- list.setItems(_buildItems());
3041
- list.select(sel);
3042
- screen.render();
3043
- }
3044
-
3045
- function _close() {
3046
- _killPreview();
3047
- list.destroy();
3048
- // Force-invalidate olines so blessed redraws every cell the modal covered
3049
- try {
3050
- for (let r = 0; r < screen.height; r++)
3051
- for (let c = 0; c < screen.width; c++)
3052
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
3053
- } catch {}
3054
- onClose?.();
3055
- screen.render();
3056
- }
3057
-
3058
- function _previewVolume(vol) {
3059
- const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
3060
- const trackId = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
3061
- const trackPath = path.resolve(tracksDir, trackId);
3062
- const safeBase = path.resolve(tracksDir);
3063
- if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
3064
-
3065
- // Toggle: pressing Space on the currently playing volume stops it
3066
- if (_previewVol === vol) {
3067
- _killPreview();
3068
- _refreshList();
3069
- return;
3070
- }
3071
-
3072
- _killPreview();
3073
- _previewVol = vol;
3074
-
3075
- const volFraction = (Math.max(10, Math.min(100, vol)) / 100).toFixed(2);
3076
- const cmd = [
3077
- `ffplay -nodisp -t 10 -loglevel quiet -volume ${vol} "${trackPath}"`,
3078
- `play "${trackPath}" trim 0 10 vol ${volFraction}`,
3079
- `mpg123 -q "${trackPath}"`,
3080
- ].join(' 2>/dev/null || ') + ' 2>/dev/null';
3081
-
3082
- if (_IS_WINDOWS) {
3083
- const _mp3P3 = detectMp3Player(_previewEnv);
3084
- _previewProcess = _mp3P3
3085
- ? spawn(_mp3P3.bin, _mp3P3.args(trackPath), _spawnOpts(_previewEnv))
3086
- : null;
3087
- if (_previewProcess) {
3088
- _previewProcess.on('error', () => { _previewProcess = null; _previewVol = null; _refreshList(); });
3089
- }
3090
- } else {
3091
- _previewProcess = spawn('sh', ['-c', cmd], _spawnOpts(_previewEnv));
3092
- }
3093
- if (!_previewProcess) { _previewVol = null; return; }
3094
- _previewProcess.unref();
3095
- _refreshList();
3096
-
3097
- _previewProcess.on('exit', () => {
3098
- if (_previewVol === vol) { _killPreview(); _refreshList(); }
3099
- });
3100
- _previewProcess.on('error', () => {
3101
- if (_previewVol === vol) { _killPreview(); _refreshList(); }
3102
- });
3103
- }
3104
-
3105
- const list = blessed.list({
3106
- parent: screen,
3107
- top: 'center',
3108
- left: 'center',
3109
- width: 28,
3110
- height: VOLUMES.length + 4,
3111
- border: { type: 'line' },
3112
- tags: true,
3113
- label: _modalTitle('Volume'),
3114
- items: _buildItems(),
3115
- keys: true,
3116
- vi: false,
3117
- mouse: true,
3118
- style: {
3119
- border: { fg: COLORS.btnFocus },
3120
- selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
3121
- item: { fg: '#e3f2fd' },
3122
- },
3123
- });
3124
-
3125
- list.select(currentIdx);
3126
- list.focus();
3127
- screen.render();
3128
-
3129
- // Space → preview audio at selected volume (toggle stop/play)
3130
- list.key(['space'], () => {
3131
- const vol = VOLUMES[list.selected];
3132
- if (vol !== undefined) _previewVolume(vol);
3133
- });
3134
-
3135
- // Enter → accept selected volume and close
3136
- list.key(['enter'], () => {
3137
- const vol = VOLUMES[list.selected];
3138
- if (vol === undefined) return;
3139
- _close();
3140
- onSelect(vol);
3141
- });
3142
-
3143
- list.key(['escape', 'q'], () => {
3144
- _close();
3145
- });
3146
- }
3147
-
3148
- // ---------------------------------------------------------------------------
3149
- // Private: Full music browser modal — rich track selection with favorites + preview
3150
-
3151
- function _openMusicBrowserModal(screen, configService, navigationService, onDone, onClose) {
3152
- let _allTracks = [];
3153
- let _showFavoritesOnly = false;
3154
- let _previewProcess = null;
3155
- let _previewTrackId = null;
3156
- let _closed = false;
3157
-
3158
- // Block global Tab-to-cycle-tab while modal is open
3159
- navigationService?.openModal();
3160
-
3161
- const _modalEnv = buildAudioEnv();
3162
-
3163
- function _killPreview() {
3164
- if (_previewProcess) {
3165
- if (_IS_WINDOWS) {
3166
- try { _previewProcess.kill(); } catch {}
3167
- } else {
3168
- try { process.kill(-_previewProcess.pid, 'SIGTERM'); } catch {}
3169
- }
3170
- _previewProcess = null;
3171
- }
3172
- _previewTrackId = null;
3173
- }
3174
-
3175
- function _closeModal() {
3176
- if (_closed) return;
3177
- _closed = true;
3178
- navigationService?.closeModal();
3179
- _killPreview();
3180
- modal.destroy();
3181
-
3182
- // Force-invalidate olines so draw() rewrites every cell the modal covered
3183
- screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
3184
- for (let r = 2; r < screen.rows - 2; r++) {
3185
- const orow = screen.olines[r];
3186
- if (!orow) continue;
3187
- for (let c = 0; c < screen.cols; c++) {
3188
- if (orow[c]) orow[c][0] = -1;
3189
- }
3190
- orow.dirty = true;
3191
- }
3192
-
3193
- onClose?.();
3194
- screen.render();
3195
- onDone();
3196
- }
3197
-
3198
- // ---- Modal overlay ----
3199
- const modal = blessed.box({
3200
- parent: screen,
3201
- top: '5%',
3202
- left: '3%',
3203
- width: '94%',
3204
- height: '90%',
3205
- border: { type: 'line' },
3206
- tags: true,
3207
- label: _modalTitle('🎵 Select Music Track'),
3208
- style: {
3209
- fg: COLORS.labelFg,
3210
- bg: COLORS.contentBg,
3211
- border: { fg: COLORS.btnFocus },
3212
- label: { fg: COLORS.btnFocus },
3213
- },
3214
- });
3215
- modal.setFront();
3216
-
3217
- // ---- Track list ----
3218
- const modalTrackList = blessed.list({
3219
- parent: modal,
3220
- top: 1,
3221
- left: 2,
3222
- right: 2,
3223
- bottom: 6,
3224
- keys: true,
3225
- vi: true,
3226
- mouse: true,
3227
- border: { type: 'line' },
3228
- scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
3229
- style: {
3230
- fg: COLORS.labelFg,
3231
- bg: COLORS.contentBg,
3232
- border: { fg: COLORS.borderFg },
3233
- selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
3234
- item: { fg: COLORS.labelFg },
3235
- },
3236
- });
3237
-
3238
- // ---- Preview status line ----
3239
- const modalPreviewLine = blessed.text({
3240
- parent: modal,
3241
- bottom: 5,
3242
- left: 2,
3243
- right: 2,
3244
- tags: true,
3245
- content: '',
3246
- style: { fg: 'bright-cyan', bg: COLORS.contentBg },
3247
- });
3248
-
3249
- // ---- File location hint ----
3250
- blessed.text({
3251
- parent: modal,
3252
- bottom: 4,
3253
- left: 2,
3254
- right: 2,
3255
- tags: true,
3256
- content: `{#455a64-fg}Add MP3 files to: .claude/audio/tracks/ • Supports ffplay / mpg123 / play{/#455a64-fg}`,
3257
- style: { bg: COLORS.contentBg },
3258
- });
3259
-
3260
- // ---- Key hint bar ----
3261
- blessed.text({
3262
- parent: modal,
3263
- bottom: 3,
3264
- left: 2,
3265
- right: 2,
3266
- content: '{#455a64-fg}[\u2191\u2193] Navigate [Enter] Select [Space] Preview [F] Favorite [/] Favorites only [Esc] Cancel{/#455a64-fg}',
3267
- tags: true,
3268
- style: { bg: COLORS.contentBg },
3269
- });
3270
-
3271
- // ---- Buttons ----
3272
- const selectTrackBtn = _createButton(modal, screen, 'Select Track', COLORS, () => {
3273
- const visible = _getVisibleTracks();
3274
- const selected = visible[modalTrackList.selected];
3275
- if (selected) {
3276
- try {
3277
- const current = configService.getConfig().backgroundMusic ?? {};
3278
- configService.set('backgroundMusic', { ...current, track: selected.id });
3279
- } catch {}
3280
- _closeModal();
3281
- }
3282
- });
3283
- selectTrackBtn.bottom = 1;
3284
- selectTrackBtn.left = 4;
3285
-
3286
- const cancelModalBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeModal);
3287
- cancelModalBtn.bottom = 1;
3288
- cancelModalBtn.left = 22;
3289
-
3290
- // ---- Helper functions ----
3291
-
3292
- function _getVisibleTracks() {
3293
- if (!_showFavoritesOnly) return _allTracks;
3294
- const favs = getMusicFavorites(configService);
3295
- return _allTracks.filter(t => favs.includes(t.id));
3296
- }
3297
-
3298
- function _buildListItems(tracks) {
3299
- const currentTrack = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
3300
- const favs = getMusicFavorites(configService);
3301
- return tracks.map(t => {
3302
- const isActive = t.id === currentTrack;
3303
- const isFav = favs.includes(t.id);
3304
- const isPrev = t.id === _previewTrackId;
3305
- const activeMark = isPrev ? '\u266A' : (isActive ? '\u25B6' : ' ');
3306
- const favMark = isFav ? '\u2605' : ' ';
3307
- return ` ${activeMark} ${favMark} ${formatTrackName(t.id) || t.label}`;
3308
- });
3309
- }
3310
-
3311
- function _refreshList() {
3312
- if (_closed) return;
3313
- const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
3314
- const scanned = scanTracks();
3315
- _allTracks = scanned;
3316
- const visible = _getVisibleTracks();
3317
- const items = _buildListItems(visible);
3318
- modalTrackList.setItems(items.length > 0 ? items : [' (no tracks found)']);
3319
- screen.render();
3320
- }
3321
-
3322
- function _previewTrack(trackId) {
3323
- const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
3324
- const trackPath = path.resolve(tracksDir, trackId);
3325
- const safeBase = path.resolve(tracksDir);
3326
- if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
3327
-
3328
- // Toggle: second press on same track → stop
3329
- if (_previewTrackId === trackId) {
3330
- _killPreview();
3331
- if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
3332
- _refreshList();
3333
- return;
3334
- }
3335
-
3336
- _killPreview();
3337
-
3338
- const _mp3Player = detectMp3Player(_modalEnv);
3339
- if (!_mp3Player) return;
3340
- _previewProcess = spawn(_mp3Player.bin, _mp3Player.args(trackPath), {
3341
- stdio: 'ignore', detached: !_IS_WINDOWS, windowsHide: true, env: _modalEnv,
3342
- });
3343
- _previewProcess.unref();
3344
- _previewTrackId = trackId;
3345
-
3346
- const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
3347
- if (!_closed) {
3348
- modalPreviewLine.setContent(`{bright-cyan-fg}\u266A Previewing: ${label} (Space to stop){/bright-cyan-fg}`);
3349
- screen.render();
3350
- }
3351
-
3352
- _previewProcess.on('exit', () => {
3353
- if (_previewTrackId === trackId) {
3354
- _previewTrackId = null;
3355
- _previewProcess = null;
3356
- if (!_closed) { modalPreviewLine.setContent(''); _refreshList(); }
3357
- }
3358
- });
3359
-
3360
- _previewProcess.on('error', () => {
3361
- _previewTrackId = null;
3362
- _previewProcess = null;
3363
- if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
3364
- });
3365
- }
3366
-
3367
- // ---- Key bindings ----
3368
-
3369
- modalTrackList.key(['enter'], () => {
3370
- const visible = _getVisibleTracks();
3371
- const sel = visible[modalTrackList.selected];
3372
- if (sel) {
3373
- try {
3374
- const current = configService.getConfig().backgroundMusic ?? {};
3375
- configService.set('backgroundMusic', { ...current, track: sel.id });
3376
- } catch {}
3377
- _closeModal();
3378
- }
3379
- });
3380
-
3381
- modalTrackList.key(['space'], () => {
3382
- const visible = _getVisibleTracks();
3383
- const sel = visible[modalTrackList.selected];
3384
- if (sel) { _previewTrack(sel.id); }
3385
- });
3386
-
3387
- modalTrackList.key(['f', 'F'], () => {
3388
- const visible = _getVisibleTracks();
3389
- const sel = visible[modalTrackList.selected];
3390
- if (sel) {
3391
- toggleMusicFavorite(configService, sel.id);
3392
- _refreshList();
3393
- }
3394
- });
3395
-
3396
- modalTrackList.key(['/'], () => {
3397
- _showFavoritesOnly = !_showFavoritesOnly;
3398
- _refreshList();
3399
- });
3400
-
3401
- modalTrackList.key(['escape', 'q'], _closeModal);
3402
-
3403
- // Tab: list → [Select Track] → [Cancel] → list
3404
- modalTrackList.key(['tab'], () => { selectTrackBtn.focus(); screen.render(); });
3405
- selectTrackBtn.key(['tab'], () => { cancelModalBtn.focus(); screen.render(); });
3406
- cancelModalBtn.key(['tab'], () => { modalTrackList.focus(); screen.render(); });
3407
- selectTrackBtn.key(['escape'], _closeModal);
3408
- cancelModalBtn.key(['escape'], _closeModal);
3409
-
3410
- // ---- Initial load ----
3411
- _refreshList();
3412
-
3413
- // Scroll to active track on open
3414
- const currentTrack = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
3415
- const activeIdx = _getVisibleTracks().findIndex(t => t.id === currentTrack);
3416
- if (activeIdx >= 0) modalTrackList.select(activeIdx);
3417
-
3418
- modalTrackList.focus();
3419
- screen.render();
3420
- }
3421
-
3422
- // ---------------------------------------------------------------------------
3423
- // Private: Inline verbosity picker
3424
-
3425
- function _openVerbosityPicker(screen, configService, onDone, onClose) {
3426
- const levels = ['Minimal', 'Low', 'Medium', 'High', 'Custom'];
3427
- const current = configService.getConfig().verbosity ?? 'high';
3428
- const currentIdx = Math.max(0, levels.findIndex(l => l.toLowerCase() === current));
3429
-
3430
- const list = blessed.list({
3431
- parent: screen,
3432
- top: 'center',
3433
- left: 'center',
3434
- width: 28,
3435
- height: levels.length + 4,
3436
- border: { type: 'line' },
3437
- tags: true,
3438
- label: _modalTitle('Verbosity Level'),
3439
- items: levels.map((l, i) => (i === currentIdx ? `● ${l}` : ` ${l}`)),
3440
- keys: true,
3441
- vi: false,
3442
- mouse: true,
3443
- style: {
3444
- border: { fg: COLORS.btnFocus },
3445
- selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
3446
- item: { fg: '#e3f2fd' },
3447
- },
3448
- });
3449
-
3450
- list.select(currentIdx);
3451
- list.focus();
3452
- screen.render();
3453
-
3454
- list.key(['enter', 'space'], () => {
3455
- const selected = levels[list.selected];
3456
- if (!selected) return;
3457
- destroyList(list, screen, onClose);
3458
- configService.set('verbosity', selected.toLowerCase());
3459
- onDone();
3460
- });
3461
-
3462
- list.key(['escape', 'q'], () => {
3463
- destroyList(list, screen, onClose);
3464
- });
3465
- }
3466
-
3467
- // ---------------------------------------------------------------------------
3468
- // Private: Inline intro text editor
3469
-
3470
- function _openIntroTextEditor(screen, configService, onDone, onClose) {
3471
- const current = configService.getConfig().pretext ?? '';
3472
- let _closed = false;
3473
-
3474
- const modal = blessed.box({
3475
- parent: screen,
3476
- top: 'center',
3477
- left: 'center',
3478
- width: 62,
3479
- height: 11,
3480
- border: { type: 'line' },
3481
- tags: true,
3482
- label: _modalTitle('Edit Intro Text'),
3483
- style: {
3484
- fg: COLORS.labelFg,
3485
- bg: COLORS.contentBg,
3486
- border: { fg: COLORS.btnFocus },
3487
- },
3488
- });
3489
-
3490
- blessed.text({
3491
- parent: modal,
3492
- top: 1,
3493
- left: 2,
3494
- content: 'Enter intro text (max 50 chars, prepended before TTS):',
3495
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
3496
- });
3497
-
3498
- const inputBox = blessed.textbox({
3499
- parent: modal,
3500
- top: 3,
3501
- left: 2,
3502
- right: 2,
3503
- height: 3,
3504
- border: { type: 'line' },
3505
- inputOnFocus: true,
3506
- style: {
3507
- fg: COLORS.valueFg,
3508
- bg: '#0d1b35',
3509
- border: { fg: COLORS.borderFg },
3510
- focus: { border: { fg: COLORS.btnFocus } },
3511
- },
3512
- });
3513
- inputBox.setValue(current);
3514
-
3515
- blessed.text({
3516
- parent: modal,
3517
- bottom: 1,
3518
- left: 2,
3519
- content: '{#455a64-fg}[Enter] Save [Esc] Cancel{/#455a64-fg}',
3520
- tags: true,
3521
- style: { bg: COLORS.contentBg },
3522
- });
3523
-
3524
- function _close() {
3525
- if (_closed) return;
3526
- _closed = true;
3527
- modal.destroy();
3528
- screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
3529
- for (let r = 2; r < screen.rows - 2; r++) {
3530
- const orow = screen.olines[r];
3531
- if (!orow) continue;
3532
- for (let c = 0; c < screen.cols; c++) { if (orow[c]) orow[c][0] = -1; }
3533
- orow.dirty = true;
3534
- }
3535
- onClose?.();
3536
- screen.render();
3537
- }
3538
-
3539
- inputBox.key(['enter'], () => {
3540
- const value = inputBox.getValue().replace(/\n/g, ' ').trim().slice(0, 50);
3541
- try { configService.set('pretext', value); } catch {}
3542
- _close();
3543
- onDone();
3544
- });
3545
-
3546
- inputBox.key(['escape'], () => {
3547
- _close();
3548
- });
3549
-
3550
- modal.setFront();
3551
- inputBox.focus();
3552
- screen.render();
3553
- }
3554
-
3555
- // ---------------------------------------------------------------------------
3556
- // Private: Full voice browser modal — replicates the Voices tab UX
3557
-
3558
- function _openVoiceBrowserModal(screen, providerService, configService, navigationService, onDone, onClose) {
3559
- let _allVoices = [];
3560
- let _filterText = '';
3561
- let _playingProcess = null;
3562
- let _playingVoiceId = null;
3563
- let _closed = false;
3564
-
3565
- // Block global Tab-to-cycle-tab while modal is open
3566
- navigationService?.openModal();
3567
-
3568
- const _spawnEnv = buildAudioEnv();
3569
-
3570
- function _killPreview() {
3571
- if (_playingProcess) {
3572
- if (_IS_WINDOWS) {
3573
- try { _playingProcess.kill(); } catch {}
3574
- } else {
3575
- try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
3576
- }
3577
- _playingProcess = null;
3578
- }
3579
- _playingVoiceId = null;
3580
- }
3581
-
3582
- function _closeModal() {
3583
- if (_closed) return;
3584
- _closed = true;
3585
- navigationService?.closeModal();
3586
- _killPreview();
3587
- modal.destroy();
3588
-
3589
- // Force-invalidate olines so draw() rewrites every cell the modal covered.
3590
- // modal.destroy() removes the widget from lines[] but leaves olines[] stale,
3591
- // so draw() skips repainting cells where lines==olines — terminal retains
3592
- // modal content. Setting attr=-1 is impossible for any real cell, so draw()
3593
- // is forced to physically rewrite each cell on the next render.
3594
- screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
3595
- for (let r = 2; r < screen.rows - 2; r++) {
3596
- const orow = screen.olines[r];
3597
- if (!orow) continue;
3598
- for (let c = 0; c < screen.cols; c++) {
3599
- if (orow[c]) orow[c][0] = -1;
3600
- }
3601
- orow.dirty = true;
3602
- }
3603
-
3604
- onClose?.();
3605
- screen.render();
3606
- onDone();
3607
- }
3608
-
3609
- // ---- Modal overlay ----
3610
- const modal = blessed.box({
3611
- parent: screen,
3612
- top: '8%',
3613
- left: '4%',
3614
- width: '92%',
3615
- height: '84%',
3616
- border: { type: 'line' },
3617
- tags: true,
3618
- label: _modalTitle('Change Voice'),
3619
- style: {
3620
- fg: COLORS.labelFg,
3621
- bg: COLORS.contentBg,
3622
- border: { fg: COLORS.btnFocus },
3623
- label: { fg: COLORS.btnFocus },
3624
- },
3625
- });
3626
- modal.setFront();
3627
-
3628
- // ---- Search ----
3629
- blessed.text({
3630
- parent: modal,
3631
- top: 1,
3632
- left: 2,
3633
- content: 'Search:',
3634
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
3635
- });
3636
-
3637
- const modalSearch = blessed.textbox({
3638
- parent: modal,
3639
- top: 1,
3640
- left: 11,
3641
- width: 40,
3642
- height: 1,
3643
- inputOnFocus: true,
3644
- keys: true,
3645
- style: {
3646
- fg: COLORS.valueFg,
3647
- bg: '#1a3a5c',
3648
- focus: { bg: '#283593' },
3649
- },
3650
- });
3651
-
3652
- // ---- Column header ----
3653
- blessed.text({
3654
- parent: modal,
3655
- top: 2,
3656
- left: 6,
3657
- content: `{bright-cyan-fg}${'Name'.padEnd(COL_NAME_W)}${'Gender'.padEnd(COL_GENDER_W)}Provider{/bright-cyan-fg}`,
3658
- tags: true,
3659
- style: { bg: COLORS.contentBg },
3660
- });
3661
-
3662
- // ---- Voice list ----
3663
- const modalVoiceList = blessed.list({
3664
- parent: modal,
3665
- top: 3,
3666
- left: 2,
3667
- right: 2,
3668
- bottom: 6,
3669
- keys: true,
3670
- vi: true,
3671
- mouse: true,
3672
- border: { type: 'line' },
3673
- scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
3674
- style: {
3675
- fg: COLORS.labelFg,
3676
- bg: COLORS.contentBg,
3677
- border: { fg: COLORS.borderFg },
3678
- selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
3679
- item: { fg: COLORS.labelFg },
3680
- },
3681
- });
3682
-
3683
- // ---- Info panel ----
3684
- blessed.text({
3685
- parent: modal,
3686
- bottom: 5,
3687
- left: 2,
3688
- content: `{bright-cyan-fg}── Voice Info ${'─'.repeat(50)}{/bright-cyan-fg}`,
3689
- tags: true,
3690
- style: { bg: COLORS.contentBg },
3691
- });
3692
-
3693
- const modalInfoLine = blessed.text({
3694
- parent: modal,
3695
- bottom: 4,
3696
- left: 2,
3697
- right: 2,
3698
- tags: true,
3699
- content: '',
3700
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
3701
- });
3702
-
3703
- const modalPreviewLine = blessed.text({
3704
- parent: modal,
3705
- bottom: 3,
3706
- left: 2,
3707
- right: 2,
3708
- tags: true,
3709
- content: '',
3710
- style: { fg: 'bright-cyan', bg: COLORS.contentBg },
3711
- });
3712
-
3713
- // ---- Key hint bar ----
3714
- blessed.text({
3715
- parent: modal,
3716
- bottom: 2,
3717
- left: 2,
3718
- right: 2,
3719
- content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [F] Favorite [/] Search [Esc] Cancel{/#455a64-fg}',
3720
- tags: true,
3721
- style: { bg: COLORS.contentBg },
3722
- });
3723
-
3724
- // ---- Buttons ----
3725
- const selectBtn = _createButton(modal, screen, 'Select Voice', COLORS, () => {
3726
- const voices = _getFiltered();
3727
- const selected = voices[modalVoiceList.selected];
3728
- if (selected) {
3729
- providerService.setActiveVoice(selected);
3730
- _closeModal();
3731
- }
3732
- });
3733
- selectBtn.bottom = 1;
3734
- selectBtn.left = 4;
3735
-
3736
- const favBtn = _createButton(modal, screen, '★ Fav', COLORS, () => {
3737
- const filtered = _getFiltered();
3738
- const sel = filtered[modalVoiceList.selected];
3739
- if (sel) { toggleFavorite(configService, sel); _refreshList(); }
3740
- });
3741
- favBtn.bottom = 1;
3742
- favBtn.left = 22;
3743
-
3744
- const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeModal);
3745
- cancelBtn.bottom = 1;
3746
- cancelBtn.left = 33;
3747
-
3748
- // ---- Helper functions ----
3749
-
3750
- function _getFiltered() {
3751
- if (!_filterText) return _allVoices;
3752
- const f = _filterText.toLowerCase();
3753
- return _allVoices.filter(v => v.toLowerCase().includes(f));
3754
- }
3755
-
3756
- function _buildItems(voices) {
3757
- const active = providerService.getActiveVoiceId();
3758
- const favs = getFavorites(configService);
3759
- return voices.map(v => {
3760
- const isFav = favs.includes(v);
3761
- const isActive = v === active;
3762
- const isPrev = v === _playingVoiceId;
3763
- const star = isFav ? '★' : ' ';
3764
- const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
3765
- const { displayName, gender, provider } = getVoiceMeta(v);
3766
- const name = displayName.length > COL_NAME_W
3767
- ? displayName.slice(0, COL_NAME_W - 1) + '…'
3768
- : displayName.padEnd(COL_NAME_W);
3769
- return ` ${star}${dot} ${name}${gender.padEnd(COL_GENDER_W)}${provider}`;
3770
- });
3771
- }
3772
-
3773
- function _formatInfo(voiceId) {
3774
- const { lang, name, quality } = parseVoiceId(voiceId);
3775
- const Y = COLORS.valueFg;
3776
- if (lang === 'unknown') {
3777
- return `{${Y}-fg}Voice:{/${Y}-fg} ${voiceId} {${Y}-fg}Provider:{/${Y}-fg} Piper`;
3778
- }
3779
- return `{${Y}-fg}Voice:{/${Y}-fg} ${name} ` +
3780
- `{${Y}-fg}Language:{/${Y}-fg} ${lang} ` +
3781
- `{${Y}-fg}Quality:{/${Y}-fg} ${quality} ` +
3782
- `{${Y}-fg}Provider:{/${Y}-fg} Piper ` +
3783
- `{${Y}-fg}ID:{/${Y}-fg} ${voiceId}`;
3784
- }
3785
-
3786
- function _refreshList() {
3787
- if (_closed) return;
3788
- _allVoices = scanInstalledVoices();
3789
- const filtered = _getFiltered();
3790
- const items = _buildItems(filtered);
3791
- modalVoiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
3792
- const active = providerService.getActiveVoiceId();
3793
- const sel = filtered[modalVoiceList.selected] ?? active ?? '';
3794
- if (sel) modalInfoLine.setContent(` ${_formatInfo(sel)}`);
3795
- screen.render();
3796
- }
3797
-
3798
- function _previewVoice(voiceId) {
3799
- if (_playingVoiceId === voiceId) {
3800
- _killPreview();
3801
- if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
3802
- return;
3803
- }
3804
- _killPreview();
3805
-
3806
- // Path traversal guard
3807
- const _ms3 = parseMultiSpeaker(voiceId);
3808
- const voicePath = path.resolve(PIPER_VOICES_DIR, _ms3.model + '.onnx');
3809
- const safeBase = path.resolve(PIPER_VOICES_DIR);
3810
- if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
3811
-
3812
- const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${randomUUID()}.wav`);
3813
- const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
3814
-
3815
- const _IS_WINDOWS = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
3816
- let _piperBin3 = 'piper';
3817
- if (_IS_WINDOWS) {
3818
- const _lad = process.env.LOCALAPPDATA ||
3819
- (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
3820
- if (_lad) {
3821
- const _exe = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
3822
- if (fs.existsSync(_exe)) _piperBin3 = _exe;
3823
- }
3824
- }
3825
- const _piperArgs3 = ['--model', voicePath, '--output_file', tempWav];
3826
- if (_ms3.speakerId != null) _piperArgs3.push('--speaker', String(_ms3.speakerId));
3827
- const piper = spawn(_piperBin3, _piperArgs3, {
3828
- stdio: ['pipe', 'ignore', 'ignore'],
3829
- detached: !_IS_WINDOWS,
3830
- windowsHide: true,
3831
- env: _spawnEnv,
3832
- });
3833
- piper.stdin.write(phrase + '\n');
3834
- piper.stdin.end();
3835
-
3836
- _playingProcess = piper;
3837
- _playingVoiceId = voiceId;
3838
- if (!_closed) {
3839
- modalPreviewLine.setContent(`{bright-cyan-fg}♪ Synthesizing: ${voiceId}…{/bright-cyan-fg}`);
3840
- screen.render();
3841
- }
3842
-
3843
- piper.on('exit', (code) => {
3844
- if (_playingVoiceId !== voiceId) {
3845
- try { fs.unlinkSync(tempWav); } catch {}
3846
- return;
3847
- }
3848
- if (code !== 0) {
3849
- _playingVoiceId = null;
3850
- _playingProcess = null;
3851
- if (!_closed) {
3852
- modalPreviewLine.setContent('{bright-cyan-fg}♪ Preview failed (piper error — is piper installed?){/bright-cyan-fg}');
3853
- screen.render();
3854
- setTimeout(() => { if (!_closed) { modalPreviewLine.setContent(''); screen.render(); } }, 4000);
3855
- }
3856
- return;
3857
- }
3858
-
3859
- const _wavPlayer3 = detectWavPlayer(_spawnEnv);
3860
- if (!_wavPlayer3) return;
3861
- const playProc = spawn(_wavPlayer3.bin, _wavPlayer3.args(tempWav), {
3862
- stdio: 'ignore',
3863
- detached: !_IS_WINDOWS,
3864
- windowsHide: true,
3865
- env: _spawnEnv,
3866
- });
3867
- _playingProcess = playProc;
3868
-
3869
- if (!_closed) {
3870
- modalPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId} (Space to stop){/bright-cyan-fg}`);
3871
- screen.render();
3872
- }
3873
-
3874
- playProc.on('exit', () => {
3875
- if (_playingVoiceId === voiceId) {
3876
- _playingVoiceId = null;
3877
- _playingProcess = null;
3878
- if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
3879
- }
3880
- try { fs.unlinkSync(tempWav); } catch {}
3881
- });
3882
-
3883
- playProc.on('error', () => {
3884
- _playingVoiceId = null;
3885
- _playingProcess = null;
3886
- if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
3887
- try { fs.unlinkSync(tempWav); } catch {}
3888
- });
3889
- });
3890
-
3891
- piper.on('error', () => {
3892
- _playingVoiceId = null;
3893
- _playingProcess = null;
3894
- if (!_closed) {
3895
- modalPreviewLine.setContent('{bright-cyan-fg}♪ Cannot find piper — install with: pipx install piper-tts{/bright-cyan-fg}');
3896
- screen.render();
3897
- setTimeout(() => { if (!_closed) { modalPreviewLine.setContent(''); screen.render(); } }, 4000);
3898
- }
3899
- });
3900
- }
3901
-
3902
- // ---- Key bindings ----
3903
-
3904
- // Search: update filter on keypress
3905
- modalSearch.on('keypress', () => {
3906
- setTimeout(() => {
3907
- _filterText = modalSearch.getValue().trim();
3908
- _refreshList();
3909
- }, 0);
3910
- });
3911
-
3912
- // Escape in search → back to list (not close)
3913
- modalSearch.key(['escape'], () => {
3914
- modalVoiceList.focus();
3915
- screen.render();
3916
- });
3917
-
3918
- // Tab out of search → select button
3919
- modalSearch.key(['tab'], () => { selectBtn.focus(); screen.render(); });
3920
-
3921
- // / in list → open search
3922
- modalVoiceList.key(['/'], () => {
3923
- modalSearch.clearValue();
3924
- modalSearch.focus();
3925
- screen.render();
3926
- });
3927
-
3928
- // f → toggle favorite
3929
- modalVoiceList.key(['f'], () => {
3930
- const filtered = _getFiltered();
3931
- const sel = filtered[modalVoiceList.selected];
3932
- if (sel) { toggleFavorite(configService, sel); _refreshList(); }
3933
- });
3934
-
3935
- // Enter → select voice (set active + close modal)
3936
- modalVoiceList.key(['enter'], () => {
3937
- const filtered = _getFiltered();
3938
- const sel = filtered[modalVoiceList.selected];
3939
- if (sel) {
3940
- providerService.setActiveVoice(sel);
3941
- _closeModal();
3942
- }
3943
- });
3944
-
3945
- // Space → preview voice (toggle)
3946
- modalVoiceList.key(['space'], () => {
3947
- const filtered = _getFiltered();
3948
- const sel = filtered[modalVoiceList.selected];
3949
- if (sel) { _previewVoice(sel); _refreshList(); }
3950
- });
3951
-
3952
- // Update info panel on selection change
3953
- modalVoiceList.on('select item', () => {
3954
- const filtered = _getFiltered();
3955
- const sel = filtered[modalVoiceList.selected] ?? '';
3956
- if (sel && !_closed) {
3957
- modalInfoLine.setContent(` ${_formatInfo(sel)}`);
3958
- screen.render();
3959
- }
3960
- });
3961
-
3962
- // Tab navigation: list → [Select] → [★ Fav] → [Cancel] → list
3963
- modalVoiceList.key(['tab'], () => { selectBtn.focus(); screen.render(); });
3964
- selectBtn.key(['tab'], () => { favBtn.focus(); screen.render(); });
3965
- favBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
3966
- cancelBtn.key(['tab'], () => { modalVoiceList.focus(); screen.render(); });
3967
-
3968
- // Escape / q closes modal
3969
- modalVoiceList.key(['escape', 'q'], _closeModal);
3970
- selectBtn.key(['escape'], _closeModal);
3971
- favBtn.key(['escape'], _closeModal);
3972
- cancelBtn.key(['escape'], _closeModal);
3973
-
3974
- // ---- Initial load ----
3975
- _refreshList();
3976
-
3977
- // Scroll to active voice on open
3978
- const activeVoiceId = providerService.getActiveVoiceId();
3979
- const activeIdx = _getFiltered().indexOf(activeVoiceId);
3980
- if (activeIdx >= 0) modalVoiceList.select(activeIdx);
3981
-
3982
- modalVoiceList.focus();
3983
- screen.render();
3984
- }
3985
-
3986
- // ---------------------------------------------------------------------------
3987
- // Private: _openPersonalityPicker removed — now using shared import:
3988
- // import { openPersonalityPicker } from '../widgets/personality-picker.js';
1
+ /**
2
+ * AgentVibes TUI Console — Settings Tab (Redesigned)
3
+ *
4
+ * Simplified flat settings list matching the mockup:
5
+ * 1. Interface Language
6
+ * 2. Default TTS Engine
7
+ * 3. Default Voice
8
+ * 4. Verbosity
9
+ * 5. Audio Destination
10
+ * 6. Config Storage (read-only)
11
+ * 7. Re-run Setup Wizard
12
+ *
13
+ * Implements the Tab Component Contract:
14
+ * createSettingsTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import crypto from 'node:crypto';
21
+ import { spawn } from 'node:child_process';
22
+ import {
23
+ scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker,
24
+ } from './voices-tab.js';
25
+ import { LanguageService } from '../../services/language-service.js';
26
+ import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
27
+ import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
28
+ import { destroyList } from '../widgets/destroy-list.js';
29
+ import { openReverbPicker } from '../widgets/reverb-picker.js';
30
+ import { openPersonalityPicker } from '../widgets/personality-picker.js';
31
+ import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
32
+ import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
33
+ import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
34
+ import {
35
+ getAvailableEngines, checkEngineInstalled,
36
+ } from '../../services/tts-engine-service.js';
37
+
38
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
39
+
40
+ let blessed;
41
+ if (!IS_TEST) {
42
+ const { default: b } = await import('blessed');
43
+ blessed = b;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Named ANSI colors only — hex renders as white on Paul's terminal
48
+
49
+ const COLORS = {
50
+ contentBg: 'black',
51
+ sectionHdr: 'bright-cyan',
52
+ labelFg: 'white',
53
+ valueFg: 'yellow',
54
+ btnDefault: 'blue',
55
+ btnFocus: 'green',
56
+ btnFocusFg: 'white',
57
+ btnPress: 'magenta',
58
+ borderFg: 'bright-cyan',
59
+ footerBg: '#2196f3',
60
+ noticeFg: 'white',
61
+ };
62
+
63
+ const FOOTER_TEXT =
64
+ '[↑↓] Navigate [Enter] Edit [Esc] Tab Bar';
65
+
66
+ const MUSIC_DEFAULTS = Object.freeze({ enabled: false, track: 'agentvibes_soft_flamenco_loop.mp3', volume: 20 });
67
+ const VERBOSITY_LABELS = Object.freeze({ high: 'High', medium: 'Medium', low: 'Low', minimal: 'Minimal', custom: 'Custom' });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Exported format helpers (pure functions — used by tests and UI)
71
+
72
+ export const formatReverbState = _sharedFormatReverbState;
73
+
74
+ export function formatMusicState(enabled) {
75
+ return enabled ? 'Enabled' : 'Disabled';
76
+ }
77
+
78
+ export function formatVolume(volume) {
79
+ const v = typeof volume === 'number' && !isNaN(volume) ? volume : MUSIC_DEFAULTS.volume;
80
+ return `${Math.max(10, Math.min(100, v))}%`;
81
+ }
82
+
83
+ export const formatTrackName = _sharedFormatTrackName;
84
+
85
+ export function formatVerbosity(verbosity) {
86
+ return VERBOSITY_LABELS[verbosity] ?? 'High';
87
+ }
88
+
89
+ export function formatPersonality(personality) {
90
+ const name = personality || 'none';
91
+ const emoji = PERSONALITY_EMOJIS[name] ?? '✨';
92
+ const label = name === 'none' ? 'None' : name.charAt(0).toUpperCase() + name.slice(1);
93
+ return `${emoji} ${label}`;
94
+ }
95
+
96
+ export function formatIntroText(pretext) {
97
+ if (!pretext) return '(none)';
98
+ return pretext.length > 30 ? pretext.slice(0, 30) + '' : pretext;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Test stub
103
+
104
+ function createTestStub() {
105
+ return {
106
+ box: {},
107
+ show: () => {},
108
+ hide: () => {},
109
+ onFocus: () => {},
110
+ onBlur: () => {},
111
+ getFooterText: () => FOOTER_TEXT,
112
+ getFooterColor: () => COLORS.footerBg,
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Create the Settings tab component (redesigned flat list).
120
+ *
121
+ * @param {object} screen - Blessed screen instance (or test stub)
122
+ * @param {object} services
123
+ * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
124
+ */
125
+ export function createSettingsTab(screen, services) {
126
+ if (IS_TEST) return createTestStub();
127
+
128
+ const { configService, providerService, navigationService, focusMainTabBar, languageService } = services;
129
+
130
+ // ── Container ────────────────────────────────────────────────────────────
131
+
132
+ const box = blessed.box({
133
+ parent: screen,
134
+ top: 5,
135
+ left: 0,
136
+ width: '100%',
137
+ bottom: 2,
138
+ tags: true,
139
+ keys: true,
140
+ scrollable: false,
141
+ hidden: true,
142
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
143
+ });
144
+
145
+ // ── Settings items definition ────────────────────────────────────────────
146
+
147
+ const SETTINGS = [
148
+ {
149
+ key: 'language',
150
+ label: 'Interface Language',
151
+ getValue: () => {
152
+ const lang = languageService?.getLang() ?? configService?.getConfig?.()?.language ?? 'en';
153
+ const entry = SUPPORTED_LANGUAGES.find(l => l.value === lang);
154
+ return entry ? entry.name : lang;
155
+ },
156
+ desc: 'Press Enter to change — also accessible during first-run Setup wizard',
157
+ },
158
+ {
159
+ key: 'ttsEngine',
160
+ label: 'Default TTS Engine',
161
+ getValue: () => {
162
+ const engine = configService?.getConfig?.()?.ttsEngine ?? '';
163
+ if (!engine) {
164
+ const engines = getAvailableEngines();
165
+ const installed = engines.find(e => checkEngineInstalled(e.id));
166
+ return installed ? installed.name : '(none)';
167
+ }
168
+ const match = getAvailableEngines().find(e => e.id === engine);
169
+ return match ? match.name : engine;
170
+ },
171
+ desc: 'Global default individual providers can override in Setup → Configure',
172
+ },
173
+ {
174
+ key: 'voice',
175
+ label: 'Default Voice',
176
+ getValue: () => {
177
+ const voice = providerService?.getActiveVoiceId() ?? configService?.getConfig?.()?.voice ?? '';
178
+ if (!voice) return '(none)';
179
+ const meta = getVoiceMeta(voice);
180
+ return meta.displayName || voice;
181
+ },
182
+ desc: 'Global default voice providers can override',
183
+ },
184
+ {
185
+ key: 'verbosity',
186
+ label: 'Verbosity',
187
+ getValue: () => formatVerbosity(configService?.getConfig?.()?.verbosity ?? 'high'),
188
+ desc: null,
189
+ },
190
+ {
191
+ key: 'audioDst',
192
+ label: 'Audio Destination',
193
+ getValue: () => {
194
+ const dst = configService?.getConfig?.()?.audio_destination ?? 'local';
195
+ if (dst === 'remote') {
196
+ const alias = configService?.getConfig?.()?.audio_ssh_alias ?? '';
197
+ const mode = configService?.getConfig?.()?.audio_stream_mode ?? 'text';
198
+ return `Remote (${alias || 'no alias'}) ${mode === 'pulse' ? 'PulseAudio' : 'Text Only'}`;
199
+ }
200
+ return 'Local';
201
+ },
202
+ desc: 'Play audio locally or stream to a remote host via SSH',
203
+ },
204
+ {
205
+ key: 'configStorage',
206
+ label: 'Config Storage',
207
+ getValue: () => {
208
+ const home = os.homedir();
209
+ const globalPath = path.join(home, '.claude', 'config', 'audio-effects.cfg');
210
+ const localPath = path.join('.claude', 'config', 'audio-effects.cfg');
211
+ return `Global: ${globalPath} | Local: ${localPath}`;
212
+ },
213
+ desc: null,
214
+ readOnly: true,
215
+ },
216
+ {
217
+ key: 'rerunWizard',
218
+ label: 'Re-run Setup Wizard',
219
+ getValue: () => '',
220
+ desc: 'Press Enter to re-run the first-time setup (Language Deps → TTS → Providers)',
221
+ isAction: true,
222
+ },
223
+ ];
224
+
225
+ // ── Build UI ─────────────────────────────────────────────────────────────
226
+
227
+ const headerText = blessed.text({
228
+ parent: box,
229
+ top: 0,
230
+ left: 2,
231
+ tags: true,
232
+ content: '{bold}{cyan-fg}Settings{/cyan-fg}{/bold}',
233
+ style: { bg: COLORS.contentBg },
234
+ });
235
+
236
+ // Settings rows — each is a bordered section
237
+ const _settingWidgets = [];
238
+ let yPos = 2;
239
+
240
+ for (let i = 0; i < SETTINGS.length; i++) {
241
+ const setting = SETTINGS[i];
242
+ const rowHeight = setting.desc ? 3 : 2;
243
+
244
+ const rowBox = blessed.box({
245
+ parent: box,
246
+ top: yPos,
247
+ left: 2,
248
+ right: 2,
249
+ height: rowHeight,
250
+ border: { type: 'line' },
251
+ tags: true,
252
+ style: {
253
+ fg: COLORS.labelFg,
254
+ bg: COLORS.contentBg,
255
+ border: { fg: 'blue' },
256
+ },
257
+ });
258
+
259
+ const labelWidget = blessed.text({
260
+ parent: rowBox,
261
+ top: 0,
262
+ left: 1,
263
+ tags: true,
264
+ content: `{bold}{cyan-fg}${setting.label}{/cyan-fg}{/bold}`,
265
+ style: { bg: COLORS.contentBg },
266
+ });
267
+
268
+ const valueWidget = blessed.text({
269
+ parent: rowBox,
270
+ top: 0,
271
+ left: setting.label.length + 4,
272
+ right: 1,
273
+ tags: true,
274
+ wrap: false,
275
+ content: '',
276
+ style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
277
+ });
278
+
279
+ let descWidget = null;
280
+ if (setting.desc) {
281
+ descWidget = blessed.text({
282
+ parent: rowBox,
283
+ top: 1,
284
+ left: 1,
285
+ right: 1,
286
+ tags: true,
287
+ wrap: false,
288
+ content: `{white-fg}${setting.desc}{/white-fg}`,
289
+ style: { bg: COLORS.contentBg },
290
+ });
291
+ }
292
+
293
+ _settingWidgets.push({ setting, rowBox, labelWidget, valueWidget, descWidget });
294
+ yPos += rowHeight + 1;
295
+ }
296
+
297
+ // Footer hint
298
+ const hintLine = blessed.text({
299
+ parent: box,
300
+ bottom: 0,
301
+ left: 2,
302
+ right: 2,
303
+ tags: true,
304
+ content: `{white-fg}${FOOTER_TEXT}{/white-fg}`,
305
+ style: { bg: COLORS.contentBg },
306
+ });
307
+
308
+ // ── Selection state ──────────────────────────────────────────────────────
309
+
310
+ let _selectedIdx = 0;
311
+
312
+ function _highlightRow(idx) {
313
+ for (let i = 0; i < _settingWidgets.length; i++) {
314
+ const w = _settingWidgets[i];
315
+ if (i === idx) {
316
+ w.rowBox.style.bg = 'magenta';
317
+ w.rowBox.style.border.fg = 'magenta';
318
+ w.labelWidget.style.bg = 'magenta';
319
+ w.labelWidget.style.fg = 'white';
320
+ w.valueWidget.style.bg = 'magenta';
321
+ if (w.descWidget) w.descWidget.style.bg = 'magenta';
322
+ } else {
323
+ w.rowBox.style.bg = COLORS.contentBg;
324
+ w.rowBox.style.border.fg = 'blue';
325
+ w.labelWidget.style.bg = COLORS.contentBg;
326
+ w.labelWidget.style.fg = 'cyan';
327
+ w.valueWidget.style.bg = COLORS.contentBg;
328
+ if (w.descWidget) w.descWidget.style.bg = COLORS.contentBg;
329
+ }
330
+ }
331
+ screen.render();
332
+ }
333
+
334
+ function _refreshValues() {
335
+ for (const w of _settingWidgets) {
336
+ const val = w.setting.getValue();
337
+ if (w.setting.isAction) {
338
+ w.valueWidget.setContent('');
339
+ } else {
340
+ w.valueWidget.setContent(`{yellow-fg}${val}{/yellow-fg}`);
341
+ }
342
+ }
343
+ }
344
+
345
+ // ── Key navigation ───────────────────────────────────────────────────────
346
+
347
+ box.key(['down', 'j'], () => {
348
+ _selectedIdx = Math.min(_selectedIdx + 1, SETTINGS.length - 1);
349
+ _highlightRow(_selectedIdx);
350
+ });
351
+
352
+ box.key(['up', 'k'], () => {
353
+ if (_selectedIdx === 0) {
354
+ _selectedIdx = -1;
355
+ _highlightRow(-1);
356
+ if (typeof focusMainTabBar === 'function') {
357
+ focusMainTabBar();
358
+ screen.render();
359
+ }
360
+ return;
361
+ }
362
+ _selectedIdx = _selectedIdx - 1;
363
+ _highlightRow(_selectedIdx);
364
+ });
365
+
366
+ box.key(['escape'], () => {
367
+ if (typeof focusMainTabBar === 'function') {
368
+ focusMainTabBar();
369
+ screen.render();
370
+ }
371
+ });
372
+
373
+ box.key(['enter', 'space'], () => {
374
+ const setting = SETTINGS[_selectedIdx];
375
+ if (setting.readOnly) return;
376
+ _handleEdit(setting);
377
+ });
378
+
379
+ // ── Edit handlers ────────────────────────────────────────────────────────
380
+
381
+ function _handleEdit(setting) {
382
+ switch (setting.key) {
383
+ case 'language':
384
+ _editLanguage();
385
+ break;
386
+ case 'ttsEngine':
387
+ _editTtsEngine();
388
+ break;
389
+ case 'voice':
390
+ _editVoice();
391
+ break;
392
+ case 'verbosity':
393
+ _editVerbosity();
394
+ break;
395
+ case 'audioDst':
396
+ _editAudioDst();
397
+ break;
398
+ case 'rerunWizard':
399
+ _rerunWizard();
400
+ break;
401
+ }
402
+ }
403
+
404
+ // ── Language editor ──────────────────────────────────────────────────────
405
+
406
+ function _editLanguage() {
407
+ navigationService?.openModal();
408
+
409
+ const modal = blessed.list({
410
+ parent: screen,
411
+ top: 'center',
412
+ left: 'center',
413
+ width: 40,
414
+ height: SUPPORTED_LANGUAGES.length + 4,
415
+ border: { type: 'line' },
416
+ tags: true,
417
+ label: ' {bold}{cyan-fg} Select Language {/cyan-fg}{/bold} ',
418
+ keys: true,
419
+ vi: true,
420
+ mouse: true,
421
+ style: {
422
+ fg: COLORS.labelFg,
423
+ bg: COLORS.contentBg,
424
+ border: { fg: 'cyan' },
425
+ selected: { bg: 'blue', fg: 'yellow' },
426
+ item: { fg: COLORS.labelFg },
427
+ },
428
+ });
429
+ modal.setFront();
430
+
431
+ const items = SUPPORTED_LANGUAGES.map(l => ` ${l.name}`);
432
+ modal.setItems(items);
433
+
434
+ const currentLang = languageService?.getLang() ?? 'en';
435
+ const currentIdx = SUPPORTED_LANGUAGES.findIndex(l => l.value === currentLang);
436
+ if (currentIdx >= 0) modal.select(currentIdx);
437
+
438
+ modal.key(['enter'], () => {
439
+ const sel = SUPPORTED_LANGUAGES[modal.selected];
440
+ if (sel) {
441
+ configService.set('language', sel.value);
442
+ if (languageService) languageService.setLang(sel.value);
443
+ }
444
+ _closeModal();
445
+ });
446
+ modal.key(['escape', 'q'], _closeModal);
447
+
448
+ function _closeModal() {
449
+ navigationService?.closeModal();
450
+ destroyList(modal, screen);
451
+ _refreshValues();
452
+ box.focus();
453
+ screen.render();
454
+ }
455
+
456
+ modal.focus();
457
+ screen.render();
458
+ }
459
+
460
+ // ── TTS Engine editor ────────────────────────────────────────────────────
461
+
462
+ function _editTtsEngine() {
463
+ navigationService?.openModal();
464
+
465
+ const engines = getAvailableEngines();
466
+ const modal = blessed.list({
467
+ parent: screen,
468
+ top: 'center',
469
+ left: 'center',
470
+ width: 50,
471
+ height: engines.length + 4,
472
+ border: { type: 'line' },
473
+ tags: true,
474
+ label: ' {bold}{cyan-fg} Default TTS Engine {/cyan-fg}{/bold} ',
475
+ keys: true,
476
+ vi: true,
477
+ mouse: true,
478
+ style: {
479
+ fg: COLORS.labelFg,
480
+ bg: COLORS.contentBg,
481
+ border: { fg: 'cyan' },
482
+ selected: { bg: 'blue', fg: 'yellow' },
483
+ item: { fg: COLORS.labelFg },
484
+ },
485
+ });
486
+ modal.setFront();
487
+
488
+ const items = engines.map(e => {
489
+ const installed = checkEngineInstalled(e.id);
490
+ const status = installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[N/A]{/yellow-fg}';
491
+ return ` ${e.name} ${status}`;
492
+ });
493
+ modal.setItems(items);
494
+
495
+ modal.key(['enter'], () => {
496
+ const sel = engines[modal.selected];
497
+ if (sel) configService.set('ttsEngine', sel.id);
498
+ _closeModal();
499
+ });
500
+ modal.key(['escape', 'q'], _closeModal);
501
+
502
+ function _closeModal() {
503
+ navigationService?.closeModal();
504
+ destroyList(modal, screen);
505
+ _refreshValues();
506
+ box.focus();
507
+ screen.render();
508
+ }
509
+
510
+ modal.focus();
511
+ screen.render();
512
+ }
513
+
514
+ // ── Voice editor (with space bar preview matches agents-tab pattern) ──
515
+
516
+ function _secureTempWav(prefix) {
517
+ const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
518
+ const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
519
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
520
+ try { fs.chmodSync(dir, 0o700); } catch {}
521
+ return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
522
+ }
523
+
524
+ function _editVoice() {
525
+ navigationService?.openModal();
526
+
527
+ let _allVoices = [];
528
+ let _previewProc = null;
529
+ let _previewVoiceId = null;
530
+ let _vpClosed = false;
531
+
532
+ const _spawnEnv = buildAudioEnv();
533
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
534
+
535
+ function _killVP() {
536
+ if (_previewProc) {
537
+ try {
538
+ if (_isWin) { _previewProc.kill(); } else { process.kill(-_previewProc.pid, 'SIGTERM'); }
539
+ } catch {}
540
+ _previewProc = null;
541
+ }
542
+ _previewVoiceId = null;
543
+ }
544
+
545
+ function _closeVP() {
546
+ if (_vpClosed) return;
547
+ _vpClosed = true;
548
+ _killVP();
549
+ navigationService?.closeModal();
550
+ destroyList(vpModal, screen);
551
+ _refreshValues();
552
+ box.focus();
553
+ screen.render();
554
+ }
555
+
556
+ const vpModal = blessed.box({
557
+ parent: screen,
558
+ top: '6%',
559
+ left: '3%',
560
+ width: '94%',
561
+ height: '88%',
562
+ border: { type: 'line' },
563
+ tags: true,
564
+ label: ' {bold}{cyan-fg} Select Default Voice {/cyan-fg}{/bold} ',
565
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
566
+ });
567
+ vpModal.setFront();
568
+
569
+ const COL_N = 30;
570
+ const COL_G = 4;
571
+ blessed.text({
572
+ parent: vpModal, top: 1, left: 6, tags: true,
573
+ content: `{cyan-fg}${'Name'.padEnd(COL_N)}{/cyan-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {cyan-fg}Provider{/cyan-fg}`,
574
+ style: { bg: COLORS.contentBg },
575
+ });
576
+
577
+ const vpList = blessed.list({
578
+ parent: vpModal, top: 2, left: 2, right: 2, bottom: 5,
579
+ keys: true, vi: true, mouse: true,
580
+ border: { type: 'line' },
581
+ scrollbar: { ch: '|', style: { fg: 'cyan' } },
582
+ tags: true,
583
+ style: {
584
+ fg: COLORS.labelFg, bg: COLORS.contentBg,
585
+ border: { fg: 'blue' },
586
+ selected: { bg: 'green', fg: 'white', bold: true },
587
+ item: { fg: COLORS.labelFg },
588
+ },
589
+ });
590
+
591
+ const vpPreviewLine = blessed.text({
592
+ parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
593
+ content: '', style: { fg: 'cyan', bg: COLORS.contentBg },
594
+ });
595
+
596
+ blessed.text({
597
+ parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
598
+ content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [Home/End] [a-z] Jump [Enter] Select [Space] Preview [Esc] Cancel{/white-fg}',
599
+ style: { bg: COLORS.contentBg },
600
+ });
601
+
602
+ function _buildItems(voices) {
603
+ const currentVoice = providerService?.getActiveVoiceId() ?? '';
604
+ return voices.map(v => {
605
+ const isActive = v === currentVoice;
606
+ const isPrev = v === _previewVoiceId;
607
+ const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
608
+ const meta = getVoiceMeta(v);
609
+ const name = meta.displayName.length > COL_N
610
+ ? meta.displayName.slice(0, COL_N - 1) + '…'
611
+ : meta.displayName.padEnd(COL_N);
612
+ // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
613
+ return ` ${dot} ${name}${genderIconTag(meta.gender)} ${meta.provider}`;
614
+ });
615
+ }
616
+
617
+ function _refreshVP() {
618
+ if (_vpClosed) return;
619
+ const savedIdx = vpList.selected ?? 0;
620
+ const savedScroll = vpList.childBase ?? 0;
621
+ _allVoices = scanInstalledVoices();
622
+ // Sort by display name so the first-letter quick jump is intuitive
623
+ _allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
624
+ getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
625
+ const items = _buildItems(_allVoices);
626
+ vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
627
+ vpList.select(Math.min(savedIdx, items.length - 1));
628
+ vpList.childBase = Math.min(savedScroll, Math.max(0, items.length - (vpList.height - 2)));
629
+ screen.render();
630
+ }
631
+
632
+ function _previewVoice(voiceId) {
633
+ if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
634
+ _killVP();
635
+
636
+ const _ms = parseMultiSpeaker(voiceId);
637
+ const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
638
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
639
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
640
+
641
+ const tempWav = _secureTempWav('vp');
642
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
643
+
644
+ let _piperBin = 'piper';
645
+ if (_isWin) {
646
+ const _lad = process.env.LOCALAPPDATA ||
647
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
648
+ if (_lad) {
649
+ const _ep = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
650
+ if (fs.existsSync(_ep)) _piperBin = _ep;
651
+ }
652
+ }
653
+
654
+ const args = ['--model', voicePath, '--output_file', tempWav];
655
+ if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
656
+ const piper = spawn(_piperBin, args, {
657
+ stdio: ['pipe', 'ignore', 'ignore'],
658
+ detached: !_isWin,
659
+ windowsHide: true,
660
+ env: _spawnEnv,
661
+ });
662
+ piper.stdin.write(phrase + '\n');
663
+ piper.stdin.end();
664
+ _previewProc = piper;
665
+ _previewVoiceId = voiceId;
666
+
667
+ if (!_vpClosed) {
668
+ vpPreviewLine.setContent(`{cyan-fg}♪ Synthesizing: ${voiceId}...{/cyan-fg}`);
669
+ _refreshVP();
670
+ }
671
+
672
+ piper.on('exit', (code) => {
673
+ if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
674
+ if (code !== 0) { _previewProc = null; _previewVoiceId = null; try { fs.unlinkSync(tempWav); } catch {} return; }
675
+ const wp = detectWavPlayer(_spawnEnv);
676
+ if (!wp) return;
677
+ const pp = spawn(wp.bin, wp.args(tempWav), {
678
+ stdio: 'ignore',
679
+ detached: !_isWin,
680
+ windowsHide: true,
681
+ env: _spawnEnv,
682
+ });
683
+ _previewProc = pp;
684
+ if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}{/cyan-fg}`); screen.render(); }
685
+ pp.on('exit', () => {
686
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
687
+ try { fs.unlinkSync(tempWav); } catch {}
688
+ });
689
+ });
690
+ piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
691
+ }
692
+
693
+ vpList.key(['enter'], () => {
694
+ const sel = _allVoices[vpList.selected];
695
+ if (sel) {
696
+ if (providerService) providerService.setActiveVoice(sel);
697
+ else configService.set('voice', sel);
698
+ }
699
+ _closeVP();
700
+ });
701
+ vpList.key(['space'], () => {
702
+ const sel = _allVoices[vpList.selected];
703
+ if (sel) _previewVoice(sel);
704
+ });
705
+ vpList.key(['escape', 'q'], _closeVP);
706
+
707
+ // PageUp / PageDown / Home / End navigation
708
+ const _pageSize = () => Math.max(1, (vpList.height ?? 10) - 2);
709
+ vpList.key(['pageup'], () => { vpList.up(_pageSize()); screen.render(); });
710
+ vpList.key(['pagedown'], () => { vpList.down(_pageSize()); screen.render(); });
711
+ vpList.key(['home'], () => { vpList.select(0); screen.render(); });
712
+ vpList.key(['end'], () => { vpList.select(Math.max(0, _allVoices.length - 1)); screen.render(); });
713
+
714
+ // First-letter quick jump: typing 'a' jumps to the first voice starting
715
+ // with A. Block keys reserved by the list widget (vi nav, cancel) so
716
+ // they don't get swallowed: q (cancel), j/k/g/h/l (vi navigation).
717
+ const _vpJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'q']);
718
+ vpList.on('keypress', (ch, key) => {
719
+ if (!ch || key?.ctrl || key?.meta) return;
720
+ if (!/^[a-zA-Z]$/.test(ch)) return;
721
+ const target = ch.toLowerCase();
722
+ if (_vpJumpBlocked.has(target)) return;
723
+ const idx = _allVoices.findIndex(v => {
724
+ const name = getVoiceMeta(v).displayName.toLowerCase();
725
+ return name.startsWith(target);
726
+ });
727
+ if (idx >= 0) { vpList.select(idx); screen.render(); }
728
+ });
729
+
730
+ _refreshVP();
731
+ const currentVoice = providerService?.getActiveVoiceId() ?? '';
732
+ const activeIdx = _allVoices.indexOf(currentVoice);
733
+ if (activeIdx >= 0) vpList.select(activeIdx);
734
+ vpList.focus();
735
+ screen.render();
736
+ }
737
+
738
+ // ── Verbosity editor ─────────────────────────────────────────────────────
739
+
740
+ function _editVerbosity() {
741
+ navigationService?.openModal();
742
+
743
+ const levels = ['high', 'medium', 'low'];
744
+ const modal = blessed.list({
745
+ parent: screen,
746
+ top: 'center',
747
+ left: 'center',
748
+ width: 30,
749
+ height: levels.length + 4,
750
+ border: { type: 'line' },
751
+ tags: true,
752
+ label: ' {bold}{cyan-fg} Verbosity {/cyan-fg}{/bold} ',
753
+ keys: true,
754
+ vi: true,
755
+ mouse: true,
756
+ style: {
757
+ fg: COLORS.labelFg,
758
+ bg: COLORS.contentBg,
759
+ border: { fg: 'cyan' },
760
+ selected: { bg: 'blue', fg: 'yellow' },
761
+ item: { fg: COLORS.labelFg },
762
+ },
763
+ });
764
+ modal.setFront();
765
+
766
+ modal.setItems(levels.map(l => ` ${formatVerbosity(l)}`));
767
+ const current = configService?.getConfig?.()?.verbosity ?? 'high';
768
+ const idx = levels.indexOf(current);
769
+ if (idx >= 0) modal.select(idx);
770
+
771
+ modal.key(['enter'], () => {
772
+ const sel = levels[modal.selected];
773
+ if (sel) configService.set('verbosity', sel);
774
+ _closeModal();
775
+ });
776
+ modal.key(['escape', 'q'], _closeModal);
777
+
778
+ function _closeModal() {
779
+ navigationService?.closeModal();
780
+ destroyList(modal, screen);
781
+ _refreshValues();
782
+ box.focus();
783
+ screen.render();
784
+ }
785
+
786
+ modal.focus();
787
+ screen.render();
788
+ }
789
+
790
+ // ── Audio Destination editor ─────────────────────────────────────────────
791
+
792
+ function _detectSshAliases() {
793
+ try {
794
+ const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
795
+ const raw = fs.readFileSync(sshConfigPath, 'utf8');
796
+ const matches = raw.match(/^Host\s+(\S+)/gm);
797
+ if (!matches) return [];
798
+ return matches.map(m => m.replace(/^Host\s+/, '').trim()).filter(a => a !== '*');
799
+ } catch {
800
+ return [];
801
+ }
802
+ }
803
+
804
+ function _editAudioDst() {
805
+ navigationService?.openModal();
806
+
807
+ const choices = ['local', 'remote'];
808
+ const modal = blessed.list({
809
+ parent: screen,
810
+ top: 'center',
811
+ left: 'center',
812
+ width: 40,
813
+ height: choices.length + 4,
814
+ border: { type: 'line' },
815
+ tags: true,
816
+ label: ' {bold}{cyan-fg} Audio Destination {/cyan-fg}{/bold} ',
817
+ keys: true,
818
+ vi: true,
819
+ mouse: true,
820
+ style: {
821
+ fg: COLORS.labelFg,
822
+ bg: COLORS.contentBg,
823
+ border: { fg: 'cyan' },
824
+ selected: { bg: 'blue', fg: 'yellow' },
825
+ item: { fg: COLORS.labelFg },
826
+ },
827
+ });
828
+ modal.setFront();
829
+
830
+ modal.setItems(choices.map(c => ` ${c === 'local' ? 'Local' : 'Remote (SSH)'}`));
831
+ const current = configService?.getConfig?.()?.audio_destination ?? 'local';
832
+ const idx = choices.indexOf(current);
833
+ if (idx >= 0) modal.select(idx);
834
+
835
+ modal.key(['enter'], () => {
836
+ const sel = choices[modal.selected];
837
+ if (sel) {
838
+ configService.set('audio_destination', sel);
839
+ if (sel === 'remote' && !(configService.getConfig().audio_ssh_alias)) {
840
+ // Prompt for SSH alias
841
+ _closeModal();
842
+ _promptSshAlias();
843
+ return;
844
+ }
845
+ }
846
+ _closeModal();
847
+ });
848
+ modal.key(['escape', 'q'], _closeModal);
849
+
850
+ function _closeModal() {
851
+ navigationService?.closeModal();
852
+ destroyList(modal, screen);
853
+ _refreshValues();
854
+ box.focus();
855
+ screen.render();
856
+ }
857
+
858
+ modal.focus();
859
+ screen.render();
860
+ }
861
+
862
+ function _promptSshAlias() {
863
+ navigationService?.openModal();
864
+ const aliases = _detectSshAliases();
865
+ const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
866
+ const prompt = blessed.prompt({
867
+ parent: screen,
868
+ top: 'center', left: 'center',
869
+ height: 'shrink', width: '60%',
870
+ border: 'line', tags: true,
871
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
872
+ });
873
+ prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
874
+ aliases[0] ?? '',
875
+ (err, val) => {
876
+ prompt.destroy();
877
+ navigationService?.closeModal();
878
+ if (!err && val && val.trim()) {
879
+ const trimmed = val.trim();
880
+ if (/[;&|`$(){}\\<>]/.test(trimmed)) {
881
+ _showNoticeWidget(screen, 'Invalid alias — special characters not allowed');
882
+ } else {
883
+ configService.set('audio_ssh_alias', trimmed);
884
+ }
885
+ }
886
+ _refreshValues();
887
+ box.focus();
888
+ screen.render();
889
+ });
890
+ screen.render();
891
+ }
892
+
893
+ // ── Re-run wizard ────────────────────────────────────────────────────────
894
+
895
+ function _rerunWizard() {
896
+ // Clear setupCompleted flag so the Setup tab shows the wizard
897
+ configService.set('setupCompleted', false);
898
+ // Navigate to setup tab
899
+ if (navigationService) {
900
+ navigationService.switchTab('setup');
901
+ }
902
+ }
903
+
904
+ // ── Refresh display ──────────────────────────────────────────────────────
905
+
906
+ function refreshDisplay() {
907
+ _refreshValues();
908
+ _highlightRow(_selectedIdx);
909
+ }
910
+
911
+ // ── Tab Component Contract ───────────────────────────────────────────────
912
+
913
+ function show() {
914
+ refreshDisplay();
915
+ box.show();
916
+ box.focus();
917
+ screen.render();
918
+ }
919
+
920
+ function hide() {
921
+ box.hide();
922
+ }
923
+
924
+ function onFocus() {
925
+ if (_selectedIdx < 0) _selectedIdx = 0;
926
+ refreshDisplay();
927
+ box.focus();
928
+ }
929
+
930
+ function onBlur() {}
931
+
932
+ function getFooterText() {
933
+ return FOOTER_TEXT;
934
+ }
935
+
936
+ function getFooterColor() {
937
+ return COLORS.footerBg;
938
+ }
939
+
940
+ return { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor };
941
+ }