agentvibes 4.6.8 → 5.0.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 (35) 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/README.md +51 -52
  5. package/.claude/config/audio-effects-bmad.cfg +50 -0
  6. package/.claude/config/audio-effects.cfg +4 -4
  7. package/.claude/config/background-music-enabled.txt +1 -0
  8. package/.claude/config/personality.txt +1 -0
  9. package/.claude/hooks/play-tts-piper.sh +3 -1
  10. package/.claude/hooks/play-tts.sh +373 -301
  11. package/.claude/hooks/session-start-tts.sh +81 -81
  12. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  13. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  14. package/.claude/hooks-windows/play-tts.ps1 +101 -9
  15. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  16. package/README.md +98 -6
  17. package/RELEASE_NOTES.md +35 -0
  18. package/bin/bmad-speak.js +16 -8
  19. package/mcp-server/server.py +15 -8
  20. package/package.json +1 -1
  21. package/src/console/app.js +899 -897
  22. package/src/console/footer-config.js +50 -50
  23. package/src/console/navigation.js +65 -65
  24. package/src/console/tabs/agents-tab.js +1896 -1886
  25. package/src/console/tabs/music-tab.js +1046 -1039
  26. package/src/console/tabs/placeholder-tab.js +81 -80
  27. package/src/console/tabs/settings-tab.js +939 -3988
  28. package/src/console/tabs/setup-tab.js +1811 -0
  29. package/src/console/tabs/voices-tab.js +1720 -1714
  30. package/src/installer.js +6147 -6092
  31. package/src/services/llm-provider-service.js +407 -0
  32. package/src/services/navigation-service.js +123 -123
  33. package/src/services/tts-engine-service.js +69 -0
  34. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  35. package/src/console/tabs/install-tab.js +0 -1081
@@ -1,1886 +1,1896 @@
1
- /**
2
- * AgentVibes TUI Console — Agents Tab (BMAD Integration)
3
- *
4
- * Implements the Tab Component Contract:
5
- * createAgentsTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
6
- *
7
- * Two states:
8
- * 1. No BMAD detected → onboarding screen with description, links, install command
9
- * 2. BMAD detected → agent table with per-agent voice/pretext/reverb/personality/music customization
10
- */
11
-
12
- import { AgentVoiceStore, scanBmadAgents, isBmadDetected, isSingleVoiceProvider } from '../../services/agent-voice-store.js';
13
- import { openReverbPicker, REVERB_PRESETS } from '../widgets/reverb-picker.js';
14
- import { openPersonalityPicker, PERSONALITIES, PERSONALITY_EMOJIS } from '../widgets/personality-picker.js';
15
- import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
16
- import { formatReverbState, formatTrackName, formatVoiceName } from '../widgets/format-utils.js';
17
- import {
18
- PIPER_VOICES_DIR, SAMPLE_PHRASES,
19
- parseMultiSpeaker, scanInstalledVoices, getVoiceMeta,
20
- } from './voices-tab.js';
21
- import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
22
- import { destroyList } from '../widgets/destroy-list.js';
23
- import { BRAND_PINK } from '../brand-colors.js';
24
- import { t } from '../../i18n/strings.js';
25
- import crypto from 'node:crypto';
26
- import fs from 'node:fs';
27
- import os from 'node:os';
28
- import path from 'node:path';
29
- import { spawn } from 'node:child_process';
30
-
31
- // Max pretext length to prevent excessively long TTS utterances
32
- const MAX_PRETEXT_LENGTH = 200;
33
-
34
- /**
35
- * Attach a blinking █ cursor to a set of blessed buttons.
36
- * Works alongside existing focus/blur handlers (e.g. ►..◄ indicators).
37
- * While a spinner is active on a button, blink is paused for that button.
38
- * Returns { cleanup, startSpinner(btn, screen), stopSpinner(btn, screen) }.
39
- */
40
- export function attachBtnBlink(btns, screen) {
41
- let _interval = null;
42
- let _on = true;
43
- let _active = null;
44
- let _spinning = null; // button currently showing a spinner
45
-
46
- const _SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
47
- let _spinIdx = 0;
48
- let _spinInterval = null;
49
-
50
- // Store original label on each button at attach time — never derive from current content
51
- btns.forEach(btn => { btn._blinkBase = btn.content; });
52
-
53
- // Focused with indicator ch right after ► e.g. ►█Preview◄ / ► Preview◄ (same width)
54
- function _focused(base, ch) { return `►${ch}${base}◄`; }
55
-
56
- function _tick() {
57
- if (!_active || _active === _spinning) return;
58
- _on = !_on;
59
- _active.setContent(_focused(_active._blinkBase, _on ? '█' : ' '));
60
- screen.render();
61
- }
62
-
63
- btns.forEach(btn => {
64
- btn.on('focus', () => {
65
- _active = btn;
66
- _on = true;
67
- if (btn !== _spinning) {
68
- btn.setContent(_focused(btn._blinkBase, '█'));
69
- screen.render();
70
- }
71
- if (!_interval) _interval = setInterval(_tick, 500);
72
- });
73
- btn.on('blur', () => {
74
- if (_active !== btn) return;
75
- _active = null;
76
- if (_interval) { clearInterval(_interval); _interval = null; _on = true; }
77
- if (btn !== _spinning) {
78
- btn.setContent(btn._blinkBase);
79
- screen.render();
80
- }
81
- });
82
- });
83
-
84
- function startSpinner(btn) {
85
- _spinning = btn;
86
- _spinIdx = 0;
87
- if (_spinInterval) clearInterval(_spinInterval);
88
- _spinInterval = setInterval(() => {
89
- _spinIdx = (_spinIdx + 1) % _SPIN.length;
90
- btn.setContent(_active === btn
91
- ? _focused(btn._blinkBase, _SPIN[_spinIdx])
92
- : `${_SPIN[_spinIdx]}${btn._blinkBase}`);
93
- screen.render();
94
- }, 80);
95
- }
96
-
97
- function stopSpinner(btn) {
98
- if (_spinInterval) { clearInterval(_spinInterval); _spinInterval = null; }
99
- _spinning = null;
100
- btn.setContent(_active === btn ? _focused(btn._blinkBase, '█') : btn._blinkBase);
101
- screen.render();
102
- }
103
-
104
- function cleanup() {
105
- if (_interval) { clearInterval(_interval); _interval = null; }
106
- if (_spinInterval){ clearInterval(_spinInterval); _spinInterval = null; }
107
- }
108
-
109
- return { cleanup, startSpinner, stopSpinner };
110
- }
111
-
112
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
113
-
114
- let blessed;
115
- if (!IS_TEST) {
116
- const { default: b } = await import('blessed');
117
- blessed = b;
118
- }
119
-
120
- // ---------------------------------------------------------------------------
121
-
122
- const COLORS = {
123
- contentBg: '#0a0e1a',
124
- sectionHdr: '#7b1fa2',
125
- labelFg: '#e3f2fd',
126
- valueFg: '#ffff00',
127
-
128
- btnDefault: '#6a1b9a',
129
- btnFocus: '#2e7d32', // Green — focused/selected
130
- btnFocusFg: '#ffffff',
131
- btnPress: '#ff00ff',
132
- borderFg: '#9c27b0',
133
- footerBg: '#9c27b0',
134
- noticeFg: '#90a4ae',
135
- warnFg: '#ff9800',
136
- linkFg: 'bright-cyan',
137
- };
138
-
139
- const _FOOTER_BMAD_EN = '[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [X] Reset [Q] Quit';
140
- const _FOOTER_NOBMAD_EN = '[Tab] Switch Tab [Q] Quit';
141
-
142
- const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
143
-
144
- // Column widths for agent table
145
- const COL_ICON = 4;
146
- const COL_NAME = 16;
147
- const COL_VOICE = 12; // beautified names avg 5-11 chars
148
- const COL_GENDER = 8;
149
- const COL_PROVIDER = 12;
150
- const COL_PRETEXT = 14;
151
- const COL_REVERB = 10;
152
- const COL_MUSIC = 11;
153
- const COL_VOL = 5; // e.g. "70%" or "100%"
154
-
155
- // Inline hint appended to the selected row when list is focused
156
- const _ROW_HINT_BMAD = ` {bright-black-fg}[Space] Preview [Enter] Configure{/bright-black-fg}`;
157
-
158
- // ---------------------------------------------------------------------------
159
-
160
- function createTestStub() {
161
- return {
162
- box: {},
163
- show: () => {},
164
- hide: () => {},
165
- onFocus: () => {},
166
- onBlur: () => {},
167
- getFooterText: () => _FOOTER_BMAD_EN,
168
- getFooterColor: () => COLORS.footerBg,
169
- };
170
- }
171
-
172
- // ---------------------------------------------------------------------------
173
-
174
- /**
175
- * Create the Agents tab component.
176
- */
177
- export function createAgentsTab(screen, services) {
178
- if (IS_TEST) return createTestStub();
179
-
180
- const { configService, providerService, focusMainTabBar, navigationService, languageService } = services;
181
- const _tl = (key) => languageService ? languageService.t(key) : t('en', key);
182
-
183
- function _buildOnboardingText() {
184
- return `{bold}{#ce93d8-fg}${_tl('bmadTitle')}{/#ce93d8-fg}{/bold}
185
-
186
- {bold}${_tl('bmadWhatIsHeader')}{/bold}
187
-
188
- ${_tl('bmadDesc')}
189
-
190
-
191
- {bold}${_tl('bmadInstallHeader')}{/bold}
192
-
193
- {bright-cyan-fg}npx bmad-method install{/bright-cyan-fg}
194
-
195
-
196
- {bold}${_tl('bmadLearnMoreHeader')}{/bold}
197
-
198
- {bright-cyan-fg}https://docs.bmad-method.org/{/bright-cyan-fg}
199
- {bright-cyan-fg}https://github.com/bmad-code-org/BMAD-METHOD{/bright-cyan-fg}
200
-
201
-
202
- {#90a4ae-fg}${_tl('bmadInstalledNote')}{/#90a4ae-fg}`;
203
- }
204
- const voiceStore = new AgentVoiceStore();
205
-
206
- // Capture cwd once at construction (L1 fix)
207
- const _projectRoot = process.cwd();
208
-
209
- let _bmadDetected = false;
210
- let _agents = [];
211
- let _playingProcess = null;
212
- let _playGeneration = 0; // H4: generation counter to prevent orphaned processes
213
-
214
- /**
215
- * Create a secure temp file path using XDG_RUNTIME_DIR or user-specific dir (H3 fix).
216
- */
217
- function _secureTempWav(prefix) {
218
- const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
219
- const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
220
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
221
- try { fs.chmodSync(dir, 0o700); } catch {}
222
- return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
223
- }
224
-
225
- // -------------------------------------------------------------------------
226
- // Container
227
-
228
- const box = blessed.box({
229
- parent: screen,
230
- top: 5,
231
- left: 0,
232
- width: '100%',
233
- bottom: 2,
234
- hidden: true,
235
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
236
- border: { type: 'line' },
237
- borderStyle: { fg: COLORS.borderFg },
238
- });
239
-
240
- // -------------------------------------------------------------------------
241
- // Onboarding content (no-BMAD state)
242
-
243
- const onboardingBox = blessed.box({
244
- parent: box,
245
- top: 1,
246
- left: 3,
247
- right: 3,
248
- bottom: 1,
249
- hidden: true,
250
- tags: true,
251
- scrollable: true,
252
- keys: true,
253
- vi: true,
254
- mouse: true,
255
- content: _buildOnboardingText(),
256
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
257
- });
258
-
259
- onboardingBox.key(['escape'], () => {
260
- if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
261
- });
262
-
263
- // -------------------------------------------------------------------------
264
- // BMAD state — section header
265
-
266
- const sectionHeader = blessed.text({
267
- parent: box,
268
- top: 1,
269
- left: 2,
270
- hidden: true,
271
- content: `{#7b1fa2-fg}── BMAD Agents ${'─'.repeat(53)}{/#7b1fa2-fg}`,
272
- tags: true,
273
- style: { bg: COLORS.contentBg },
274
- });
275
-
276
- // Column header
277
- const columnHeader = blessed.text({
278
- parent: box,
279
- top: 2,
280
- left: 4,
281
- hidden: true,
282
- tags: true,
283
- content: `{#90a4ae-fg}${''.padEnd(COL_ICON)}${' Agent'.padEnd(COL_NAME)}${' Voice'.padEnd(COL_VOICE)}${' Gender'.padEnd(COL_GENDER)}${' Provider'.padEnd(COL_PROVIDER)}${' Reverb'.padEnd(COL_REVERB)}${' Music'.padEnd(COL_MUSIC)}${' Vol'.padEnd(COL_VOL)} Pretext{/#90a4ae-fg}`,
284
- style: { bg: COLORS.contentBg },
285
- });
286
-
287
- // -------------------------------------------------------------------------
288
- // Agent list
289
-
290
- const agentList = blessed.list({
291
- parent: box,
292
- top: 3,
293
- left: 2,
294
- width: '96%',
295
- height: '55%',
296
- hidden: true,
297
- keys: true,
298
- vi: true,
299
- mouse: true,
300
- tags: true,
301
- border: { type: 'line' },
302
- scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
303
- style: {
304
- fg: COLORS.labelFg,
305
- bg: COLORS.contentBg,
306
- border: { fg: COLORS.borderFg },
307
- selected: { bg: 'blue', fg: 'yellow' },
308
- item: { fg: COLORS.labelFg },
309
- },
310
- });
311
-
312
- // -------------------------------------------------------------------------
313
- // Status panel
314
-
315
- const statusDivider = blessed.text({
316
- parent: box,
317
- top: '64%',
318
- left: 2,
319
- hidden: true,
320
- content: `{#7b1fa2-fg}── Status ${'─'.repeat(58)}{/#7b1fa2-fg}`,
321
- tags: true,
322
- style: { bg: COLORS.contentBg },
323
- });
324
-
325
- const statusLine = blessed.text({
326
- parent: box,
327
- top: '69%',
328
- left: 2,
329
- hidden: true,
330
- tags: true,
331
- content: '',
332
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
333
- });
334
-
335
- const warningLine = blessed.text({
336
- parent: box,
337
- top: '74%',
338
- left: 2,
339
- hidden: true,
340
- tags: true,
341
- content: '',
342
- style: { fg: COLORS.warnFg, bg: COLORS.contentBg },
343
- });
344
-
345
- // Hint shown inline next to the action buttons at bottom of list
346
- const hintLine = blessed.text({
347
- parent: box,
348
- bottom: 5,
349
- left: 4,
350
- hidden: true,
351
- tags: true,
352
- content: '{#546e7a-fg}[Space] Preview [Enter] Configure [X] Reset [A] Auto-assign [B] Bulk Edit{/#546e7a-fg}',
353
- style: { bg: COLORS.contentBg },
354
- });
355
-
356
- // -------------------------------------------------------------------------
357
- // Buttons
358
-
359
- function _createBtn(label, onClick) {
360
- const btn = blessed.button({
361
- parent: box,
362
- content: label,
363
- mouse: true,
364
- keys: true,
365
- shrink: true,
366
- hidden: true,
367
- padding: { left: 1, right: 1 },
368
- style: {
369
- bg: COLORS.btnDefault,
370
- fg: 'white',
371
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
372
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
373
- },
374
- });
375
- btn.on('focus', () => {
376
- const raw = btn.content.replace(/[►◄]/g, '').trim();
377
- btn.setContent(`►${raw}◄`);
378
- screen.render();
379
- });
380
- btn.on('blur', () => {
381
- const raw = btn.content.replace(/[►◄]/g, '').trim();
382
- btn.setContent(raw);
383
- screen.render();
384
- });
385
- btn.key(['enter', 'space'], () => {
386
- btn.style.bg = COLORS.btnPress;
387
- screen.render();
388
- setTimeout(() => {
389
- btn.style.bg = COLORS.btnDefault;
390
- screen.render();
391
- onClick();
392
- }, 150);
393
- });
394
- btn.on('click', () => btn.press());
395
- btn.on('mouseover', () => btn.focus());
396
- return btn;
397
- }
398
-
399
- const resetBtn = _createBtn('[X] Reset', () => {
400
- const agent = _agents[agentList.selected ?? 0];
401
- if (agent) {
402
- voiceStore.resetAgentProfile(agent.id);
403
- refreshDisplay();
404
- }
405
- });
406
- resetBtn.bottom = 4;
407
- resetBtn.left = 4;
408
-
409
- const autoAssignBtn = _createBtn('[A] Auto-assign', () => _autoAssignAll());
410
- autoAssignBtn.bottom = 4;
411
- autoAssignBtn.left = 18;
412
-
413
- const bulkEditBtn = _createBtn('[B] Bulk Edit', () => _openBulkEditMenu());
414
- bulkEditBtn.bottom = 4;
415
- bulkEditBtn.left = 36;
416
-
417
- // -------------------------------------------------------------------------
418
- // Show/hide helpers for the two states
419
-
420
- const _bmadWidgets = [sectionHeader, columnHeader, agentList, hintLine, resetBtn, autoAssignBtn, bulkEditBtn];
421
-
422
- function _showBmadState() {
423
- onboardingBox.hide();
424
- for (const w of _bmadWidgets) w.show();
425
- }
426
-
427
- function _showOnboardingState() {
428
- for (const w of _bmadWidgets) w.hide();
429
- onboardingBox.show();
430
- }
431
-
432
- // -------------------------------------------------------------------------
433
- // Build table row items
434
-
435
- function _buildListItems(agents) {
436
- if (agents.length === 0) {
437
- return [' (no BMAD agents detected)'];
438
- }
439
- return agents.map(a => {
440
- const profile = voiceStore.getAgentProfile(a.id);
441
- // Strip variation selectors (e.g. U+FE0F on 🏗️) so padEnd uses visual width
442
- const rawIcon = (a.icon || '').replace(/\uFE0F/g, '');
443
- const icon = (rawIcon ? `${rawIcon} ` : ' ').padEnd(COL_ICON);
444
- const name = ` ${a.displayName}`.padEnd(COL_NAME).slice(0, COL_NAME);
445
- const voiceRaw = formatVoiceName(profile.voice);
446
- const voice = (' ' + voiceRaw).padEnd(COL_VOICE).slice(0, COL_VOICE);
447
- const meta = profile.voice ? getVoiceMeta(profile.voice) : { gender: '—', provider: '—' };
448
- const gender = (' ' + meta.gender).padEnd(COL_GENDER).slice(0, COL_GENDER);
449
- const provider = (' ' + meta.provider).padEnd(COL_PROVIDER).slice(0, COL_PROVIDER);
450
- const reverb = (' ' + (profile.reverbPreset || '(global)')).padEnd(COL_REVERB).slice(0, COL_REVERB);
451
- const music = (' ' + (profile.backgroundMusic?.track
452
- ? formatTrackName(profile.backgroundMusic.track)
453
- : '(global)')).padEnd(COL_MUSIC).slice(0, COL_MUSIC);
454
- const vol = profile.backgroundMusic?.enabled
455
- ? ` ${profile.backgroundMusic.volume ?? 20}%`.padEnd(COL_VOL)
456
- : ' — ';
457
- const pretext = ' ' + (profile.pretext || '(default)').slice(0, COL_PRETEXT - 1);
458
- return ` ${icon}${name}${voice}${gender}${provider}${reverb}${music}${vol} ${pretext}`;
459
- });
460
- }
461
-
462
- // -------------------------------------------------------------------------
463
- // Refresh display
464
-
465
- function refreshDisplay() {
466
- _bmadDetected = isBmadDetected(_projectRoot);
467
- _agents = scanBmadAgents(_projectRoot);
468
-
469
- if (!_bmadDetected) {
470
- _showOnboardingState();
471
- screen.render();
472
- return;
473
- }
474
-
475
- _showBmadState();
476
-
477
- const items = _buildListItems(_agents);
478
- agentList.setItems(items);
479
-
480
- if (_listFocused) {
481
- _hintIdx = -1;
482
- _hintBase = '';
483
- _updateHint(agentList.selected ?? 0);
484
- }
485
-
486
- screen.render();
487
- }
488
-
489
- // -------------------------------------------------------------------------
490
- // Temporary "Saved!" toast notification
491
-
492
- function _showSavedToast(agentName) {
493
- const toast = blessed.box({
494
- parent: screen,
495
- top: 'center',
496
- left: 'center',
497
- width: 34,
498
- height: 3,
499
- border: { type: 'line' },
500
- tags: true,
501
- content: ` {green-fg}{bold}✓ ${agentName} saved!{/bold}{/green-fg}`,
502
- style: { fg: '#e3f2fd', bg: '#1b5e20', border: { fg: '#4caf50' } },
503
- });
504
- toast.setFront();
505
- screen.render();
506
- setTimeout(() => {
507
- toast.destroy();
508
- try {
509
- for (let r = 0; r < screen.height; r++)
510
- for (let c = 0; c < screen.width; c++)
511
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
512
- } catch {}
513
- screen.render();
514
- }, 1500);
515
- }
516
-
517
- // -------------------------------------------------------------------------
518
- // Row spinner (animated braille while preview is playing)
519
-
520
- const _SPIN_PFX = '{bright-cyan-fg}';
521
- const _SPIN_SFX = '{/bright-cyan-fg}';
522
- const _SPIN_PFX_TOTAL_LEN = _SPIN_PFX.length + 1 + _SPIN_SFX.length; // tag + 1 frame char + close tag
523
- const _SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
524
- let _spinnerInterval = null;
525
- let _spinnerFrameIdx = 0;
526
- let _spinnerAgentIdx = -1;
527
-
528
- // Strip the spinner prefix (tag+frame+close or plain first char) to get the row tail.
529
- function _stripSpinnerPfx(c) {
530
- return c.startsWith(_SPIN_PFX) ? c.slice(_SPIN_PFX_TOTAL_LEN) : c.slice(1);
531
- }
532
-
533
- function _startSpinner(agentIdx) {
534
- _stopSpinner();
535
- _spinnerAgentIdx = agentIdx;
536
- _spinnerFrameIdx = 0;
537
- const items = agentList.items;
538
- const item = items[_spinnerAgentIdx];
539
- if (item) {
540
- item.setContent(`${_SPIN_PFX}${_SPIN_FRAMES[0]}${_SPIN_SFX}${_stripSpinnerPfx(item.content ?? ' ')}`);
541
- screen.render();
542
- }
543
- _spinnerInterval = setInterval(() => {
544
- _spinnerFrameIdx = (_spinnerFrameIdx + 1) % _SPIN_FRAMES.length;
545
- const it = agentList.items[_spinnerAgentIdx];
546
- if (!it) return;
547
- it.setContent(`${_SPIN_PFX}${_SPIN_FRAMES[_spinnerFrameIdx]}${_SPIN_SFX}${_stripSpinnerPfx(it.content ?? ' ')}`);
548
- screen.render();
549
- }, 80);
550
- }
551
-
552
- function _stopSpinner() {
553
- if (_spinnerInterval) { clearInterval(_spinnerInterval); _spinnerInterval = null; }
554
- if (_spinnerAgentIdx >= 0) {
555
- const item = agentList.items[_spinnerAgentIdx];
556
- if (item) item.setContent(' ' + _stripSpinnerPfx(item.content ?? ' '));
557
- _spinnerAgentIdx = -1;
558
- screen.render();
559
- }
560
- }
561
-
562
- // -------------------------------------------------------------------------
563
- // Kill any playing preview
564
-
565
- function _killPreview() {
566
- _stopSpinner();
567
- if (_playingProcess) {
568
- try {
569
- // On Windows, negative-PID process group kill is unsupported
570
- if (process.platform === 'win32') {
571
- _playingProcess.kill();
572
- } else {
573
- process.kill(-_playingProcess.pid, 'SIGTERM');
574
- }
575
- } catch {}
576
- _playingProcess = null;
577
- }
578
- }
579
-
580
- // -------------------------------------------------------------------------
581
- // Sample an agent with their full profile (voice + pretext + reverb + music)
582
- // Uses play-tts-enhanced.sh for the complete effects pipeline.
583
-
584
- function _sampleAgent(agent) {
585
- const profile = voiceStore.getAgentProfile(agent.id);
586
- const globalCfg = configService.getConfig();
587
- _sampleWithFullProfile(agent, {
588
- voice: profile.voice || globalCfg.voice || '',
589
- pretext: profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title),
590
- reverbPreset: profile.reverbPreset || globalCfg.effects?.reverbPreset || 'light',
591
- personality: profile.personality || globalCfg.personality || 'none',
592
- backgroundMusic: {
593
- track: profile.backgroundMusic?.track || globalCfg.backgroundMusic?.track || '',
594
- volume: profile.backgroundMusic?.volume ?? globalCfg.backgroundMusic?.volume ?? 20,
595
- enabled: profile.backgroundMusic?.enabled ?? globalCfg.backgroundMusic?.enabled ?? false,
596
- },
597
- });
598
- }
599
-
600
- // -------------------------------------------------------------------------
601
- // Agent detail panel (modal overlay)
602
-
603
- function _openAgentDetailPanel(agent) {
604
- const profile = voiceStore.getAgentProfile(agent.id);
605
- const globalCfg = configService.getConfig();
606
-
607
- // Working copy of the profile being edited
608
- const draft = {
609
- voice: profile.voice || globalCfg.voice || '',
610
- pretext: profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title),
611
- reverbPreset: profile.reverbPreset || globalCfg.effects?.reverbPreset || 'light',
612
- personality: profile.personality || globalCfg.personality || 'none',
613
- backgroundMusic: {
614
- track: profile.backgroundMusic?.track || globalCfg.backgroundMusic?.track || '',
615
- volume: profile.backgroundMusic?.volume ?? globalCfg.backgroundMusic?.volume ?? 20,
616
- enabled: profile.backgroundMusic?.enabled ?? globalCfg.backgroundMusic?.enabled ?? false,
617
- },
618
- };
619
-
620
- let _closed = false;
621
- navigationService?.openModal();
622
-
623
- const modal = blessed.box({
624
- parent: screen,
625
- top: 'center',
626
- left: 'center',
627
- width: 72,
628
- height: 19,
629
- border: { type: 'line' },
630
- tags: true,
631
- label: _modalTitle(`${agent.icon || '🧙'} ${agent.displayName} (${agent.title || 'Agent'})`),
632
- style: {
633
- fg: COLORS.labelFg,
634
- bg: COLORS.contentBg,
635
- border: { fg: COLORS.btnFocus },
636
- },
637
- });
638
- modal.setFront();
639
-
640
- // Field definitions
641
- const FIELDS = [
642
- { key: 'voice', label: 'Voice', getValue: () => draft.voice || '(global default)' },
643
- { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(default)' },
644
- { key: 'reverbPreset', label: 'Reverb', getValue: () => formatReverbState(draft.reverbPreset) },
645
- { key: 'personality', label: 'Personality', getValue: () => {
646
- const p = draft.personality;
647
- const emoji = PERSONALITY_EMOJIS[p] || '';
648
- return `${emoji} ${p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1)}`;
649
- }},
650
- { key: 'musicTrack', label: 'Music Track', getValue: () => {
651
- if (!draft.backgroundMusic.enabled) return '(disabled)';
652
- return formatTrackName(draft.backgroundMusic.track) || '(none)';
653
- }},
654
- { key: 'musicVol', label: 'Music Vol', getValue: () => {
655
- if (!draft.backgroundMusic.enabled) return '(disabled)';
656
- return `${draft.backgroundMusic.volume ?? 20}%`;
657
- }},
658
- ];
659
-
660
- // Build field list items
661
- function _fieldItems() {
662
- return FIELDS.map(f => {
663
- const label = f.label.padEnd(14);
664
- const val = f.getValue();
665
- return ` ${label} ${val}`;
666
- });
667
- }
668
-
669
- const fieldList = blessed.list({
670
- parent: modal,
671
- top: 1,
672
- left: 2,
673
- right: 2,
674
- height: FIELDS.length + 2,
675
- keys: true,
676
- vi: true,
677
- mouse: true,
678
- border: { type: 'line' },
679
- tags: true,
680
- style: {
681
- fg: COLORS.labelFg,
682
- bg: COLORS.contentBg,
683
- border: { fg: '#4a148c' },
684
- selected: { bg: 'blue', fg: 'yellow' },
685
- item: { fg: COLORS.labelFg },
686
- },
687
- });
688
- fieldList.setItems(_fieldItems());
689
-
690
- // Key hint
691
- blessed.text({
692
- parent: modal,
693
- bottom: 4,
694
- left: 2,
695
- right: 2,
696
- tags: true,
697
- content: '{white-fg}[↑↓] Navigate [Enter] Edit [Tab] → Preview/Save [Esc] Cancel{/white-fg}',
698
- style: { bg: COLORS.contentBg },
699
- });
700
-
701
- // Buttons
702
- function _modalBtn(label, leftPos, onClick) {
703
- const btn = blessed.button({
704
- parent: modal,
705
- content: label,
706
- bottom: 2,
707
- left: leftPos,
708
- mouse: true,
709
- keys: true,
710
- shrink: true,
711
- padding: { left: 1, right: 1 },
712
- style: {
713
- bg: COLORS.btnDefault,
714
- fg: 'white',
715
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
716
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
717
- },
718
- });
719
- // Focus indicator handled by attachBtnBlink
720
- btn.key(['enter', 'space'], () => onClick());
721
- btn.on('click', () => onClick());
722
- return btn;
723
- }
724
-
725
- const previewBtn = _modalBtn('Preview', 4, () => {
726
- _sampleAgentWithDraft({ ...agent }, draft, () => {
727
- btnBlink.stopSpinner(previewBtn);
728
- });
729
- btnBlink.startSpinner(previewBtn);
730
- });
731
-
732
- const saveBtn = _modalBtn('Save', 18, () => {
733
- // Only save fields that differ from global
734
- const toSave = {};
735
- if (draft.voice && draft.voice !== globalCfg.voice) toSave.voice = draft.voice;
736
- if (draft.pretext !== AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title)) toSave.pretext = draft.pretext;
737
- if (draft.reverbPreset !== (globalCfg.effects?.reverbPreset || 'light')) toSave.reverbPreset = draft.reverbPreset;
738
- if (draft.personality !== (globalCfg.personality || 'none')) toSave.personality = draft.personality;
739
- if (draft.backgroundMusic.track !== (globalCfg.backgroundMusic?.track || '') ||
740
- draft.backgroundMusic.volume !== (globalCfg.backgroundMusic?.volume ?? 20) ||
741
- draft.backgroundMusic.enabled !== (globalCfg.backgroundMusic?.enabled ?? false)) {
742
- toSave.backgroundMusic = draft.backgroundMusic;
743
- }
744
- voiceStore.setAgentProfile(agent.id, toSave);
745
- _closeModal();
746
- refreshDisplay();
747
- // Show temporary "Saved!" toast
748
- _showSavedToast(agent.displayName);
749
- });
750
-
751
- const resetAllBtn = _modalBtn('Reset to Defaults', 26, () => {
752
- voiceStore.resetAgentProfile(agent.id);
753
- _closeModal();
754
- refreshDisplay();
755
- });
756
-
757
- const cancelBtn = _modalBtn('Cancel', 50, _closeModal);
758
-
759
- // Blinking █ cursor + preview spinner — reusable across all modal buttons
760
- const btnBlink = attachBtnBlink([previewBtn, saveBtn, resetAllBtn, cancelBtn], screen);
761
-
762
- function _closeModal() {
763
- if (_closed) return;
764
- _closed = true;
765
- _killPreview();
766
- btnBlink.cleanup();
767
- navigationService?.closeModal();
768
- destroyList(modal, screen);
769
- agentList.focus();
770
- screen.render();
771
- }
772
-
773
- // Field editing via Enter
774
- fieldList.key(['enter'], () => {
775
- const idx = fieldList.selected;
776
- const field = FIELDS[idx];
777
- if (!field) return;
778
-
779
- switch (field.key) {
780
- case 'voice':
781
- _openVoicePickerForAgent(agent, draft, () => {
782
- fieldList.setItems(_fieldItems());
783
- fieldList.select(idx);
784
- fieldList.focus();
785
- screen.render();
786
- });
787
- break;
788
-
789
- case 'pretext':
790
- _openPretextEditor(modal, draft, () => {
791
- fieldList.setItems(_fieldItems());
792
- fieldList.select(idx);
793
- fieldList.focus();
794
- screen.render();
795
- });
796
- break;
797
-
798
- case 'reverbPreset':
799
- openReverbPicker(screen, draft.reverbPreset, (val) => {
800
- draft.reverbPreset = val;
801
- fieldList.setItems(_fieldItems());
802
- fieldList.select(idx);
803
- fieldList.focus();
804
- screen.render();
805
- }, () => {
806
- fieldList.focus();
807
- screen.render();
808
- }, { applyToEffectsManager: false });
809
- break;
810
-
811
- case 'personality':
812
- openPersonalityPicker(screen, draft.personality, (val) => {
813
- draft.personality = val;
814
- fieldList.setItems(_fieldItems());
815
- fieldList.select(idx);
816
- fieldList.focus();
817
- screen.render();
818
- }, () => {
819
- fieldList.focus();
820
- screen.render();
821
- });
822
- break;
823
-
824
- case 'musicTrack':
825
- openTrackPicker(screen, draft.backgroundMusic.track, draft.backgroundMusic.volume, (track) => {
826
- draft.backgroundMusic.track = track;
827
- draft.backgroundMusic.enabled = true;
828
- fieldList.setItems(_fieldItems());
829
- fieldList.select(idx);
830
- fieldList.focus();
831
- screen.render();
832
- }, () => {
833
- fieldList.focus();
834
- screen.render();
835
- }, { skipVolume: true });
836
- break;
837
-
838
- case 'musicVol':
839
- openVolumeInput(screen, draft.backgroundMusic.volume, (volume) => {
840
- draft.backgroundMusic.volume = volume;
841
- if (draft.backgroundMusic.track) draft.backgroundMusic.enabled = true;
842
- fieldList.setItems(_fieldItems());
843
- fieldList.select(idx);
844
- fieldList.focus();
845
- screen.render();
846
- }, () => {
847
- fieldList.focus();
848
- screen.render();
849
- });
850
- break;
851
- }
852
- });
853
-
854
- // Space = sample with current draft
855
- fieldList.key(['space'], () => {
856
- const draftAgent = { ...agent };
857
- // Temporarily set profile for sampling
858
- _sampleAgentWithDraft(draftAgent, draft);
859
- });
860
-
861
- // Escape = close
862
- fieldList.key(['escape', 'q'], _closeModal);
863
- previewBtn.key(['escape'], _closeModal);
864
- saveBtn.key(['escape'], _closeModal);
865
- resetAllBtn.key(['escape'], _closeModal);
866
- cancelBtn.key(['escape'], _closeModal);
867
-
868
- // Tab + arrow navigation within modal
869
- fieldList.key(['tab'], () => { previewBtn.focus(); screen.render(); });
870
-
871
- // Wrap: down on last field → focus first button; up on first field → focus first button
872
- // One extra arrow press at boundary moves to button row.
873
- // Track previous selection so arriving at boundary doesn't immediately jump.
874
- let _prevFieldSel = 0;
875
- fieldList.key(['down'], () => {
876
- const cur = fieldList.selected ?? 0;
877
- if (cur === FIELDS.length - 1 && _prevFieldSel === FIELDS.length - 1) {
878
- previewBtn.focus(); screen.render();
879
- }
880
- _prevFieldSel = cur;
881
- });
882
- fieldList.key(['up'], () => {
883
- const cur = fieldList.selected ?? 0;
884
- if (cur === 0 && _prevFieldSel === 0) {
885
- previewBtn.focus(); screen.render();
886
- }
887
- _prevFieldSel = cur;
888
- });
889
-
890
- // Wrap: up on buttons → back to field list (last/first field respectively)
891
- previewBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
892
- saveBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
893
- resetAllBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
894
- cancelBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
895
-
896
- previewBtn.key(['tab', 'right'], () => { saveBtn.focus(); screen.render(); });
897
- previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
898
-
899
- saveBtn.key(['tab', 'right'], () => { resetAllBtn.focus(); screen.render(); });
900
- saveBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
901
-
902
- resetAllBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
903
- resetAllBtn.key(['left'], () => { saveBtn.focus(); screen.render(); });
904
-
905
- cancelBtn.key(['tab', 'right'], () => { fieldList.focus(); screen.render(); });
906
- cancelBtn.key(['left'], () => { resetAllBtn.focus(); screen.render(); });
907
-
908
- fieldList.focus();
909
- screen.render();
910
- }
911
-
912
- // -------------------------------------------------------------------------
913
- // Voice picker for agent detail panel
914
-
915
- function _openVoicePickerForAgent(agent, draft, onDone) {
916
- let _allVoices = [];
917
- let _filterText = '';
918
- let _previewProc = null;
919
- let _previewVoiceId = null;
920
- let _vpClosed = false;
921
-
922
- const _spawnEnv = buildAudioEnv();
923
-
924
- function _killVP() {
925
- if (_previewProc) {
926
- try {
927
- if (process.platform === 'win32') {
928
- _previewProc.kill();
929
- } else {
930
- process.kill(-_previewProc.pid, 'SIGTERM');
931
- }
932
- } catch {}
933
- _previewProc = null;
934
- }
935
- _previewVoiceId = null;
936
- }
937
-
938
- function _closeVP() {
939
- if (_vpClosed) return;
940
- _vpClosed = true;
941
- _killVP();
942
- destroyList(vpModal, screen, onDone);
943
- }
944
-
945
- const vpModal = blessed.box({
946
- parent: screen,
947
- top: '6%',
948
- left: '3%',
949
- width: '94%',
950
- height: '88%',
951
- border: { type: 'line' },
952
- tags: true,
953
- label: _modalTitle(`Select Voice for ${agent.icon || ''} ${agent.displayName}`),
954
- style: {
955
- fg: COLORS.labelFg,
956
- bg: COLORS.contentBg,
957
- border: { fg: 'bright-cyan' },
958
- },
959
- });
960
- vpModal.setFront();
961
-
962
- // Search
963
- blessed.text({
964
- parent: vpModal, top: 1, left: 2,
965
- content: 'Search:', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
966
- });
967
- const vpSearch = blessed.textbox({
968
- parent: vpModal, top: 1, left: 11, width: 40, height: 1,
969
- inputOnFocus: true, keys: true,
970
- style: { fg: COLORS.valueFg, bg: '#1a3a5c', focus: { bg: '#245a80' } },
971
- });
972
-
973
- // Column header
974
- const COL_N = 28;
975
- const COL_G = 10;
976
- blessed.text({
977
- parent: vpModal, top: 2, left: 6, tags: true,
978
- content: `{bright-cyan-fg}${'Name'.padEnd(COL_N)}${'Gender'.padEnd(COL_G)}Provider{/bright-cyan-fg}`,
979
- style: { bg: COLORS.contentBg },
980
- });
981
-
982
- const vpList = blessed.list({
983
- parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
984
- keys: true, vi: true, mouse: true,
985
- border: { type: 'line' },
986
- scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
987
- style: {
988
- fg: COLORS.labelFg, bg: COLORS.contentBg,
989
- border: { fg: COLORS.borderFg },
990
- selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
991
- item: { fg: COLORS.labelFg },
992
- },
993
- });
994
-
995
- const vpInfoLine = blessed.text({
996
- parent: vpModal, bottom: 4, left: 2, right: 2, tags: true,
997
- content: '', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
998
- });
999
-
1000
- const vpPreviewLine = blessed.text({
1001
- parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
1002
- content: '', style: { fg: 'bright-cyan', bg: COLORS.contentBg },
1003
- });
1004
-
1005
- blessed.text({
1006
- parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
1007
- content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [/] Search [Esc] Cancel{/#455a64-fg}',
1008
- style: { bg: COLORS.contentBg },
1009
- });
1010
-
1011
- function _getFiltered() {
1012
- if (!_filterText) return _allVoices;
1013
- const f = _filterText.toLowerCase();
1014
- return _allVoices.filter(v => v.toLowerCase().includes(f));
1015
- }
1016
-
1017
- function _buildVoiceItems(voices) {
1018
- return voices.map(v => {
1019
- const isActive = v === draft.voice;
1020
- const isPrev = v === _previewVoiceId;
1021
- const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
1022
- const meta = getVoiceMeta(v);
1023
- const name = meta.displayName.length > COL_N
1024
- ? meta.displayName.slice(0, COL_N - 1) + '…'
1025
- : meta.displayName.padEnd(COL_N);
1026
- return ` ${dot} ${name}${meta.gender.padEnd(COL_G)}${meta.provider}`;
1027
- });
1028
- }
1029
-
1030
- function _refreshVP() {
1031
- if (_vpClosed) return;
1032
- _allVoices = scanInstalledVoices();
1033
- const filtered = _getFiltered();
1034
- const items = _buildVoiceItems(filtered);
1035
- vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
1036
- screen.render();
1037
- }
1038
-
1039
- function _previewVoice(voiceId) {
1040
- if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); screen.render(); return; }
1041
- _killVP();
1042
-
1043
- const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1044
-
1045
- const _ms = parseMultiSpeaker(voiceId);
1046
- const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
1047
- const safeBase = path.resolve(PIPER_VOICES_DIR);
1048
- if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
1049
-
1050
- const tempWav = _secureTempWav('vp');
1051
- const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1052
-
1053
- // Resolve piper binary (on Windows, find piper.exe)
1054
- let _piperBin = 'piper';
1055
- if (_isWin) {
1056
- const _lad = process.env.LOCALAPPDATA ||
1057
- (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
1058
- if (_lad) {
1059
- const _ep = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
1060
- if (fs.existsSync(_ep)) _piperBin = _ep;
1061
- }
1062
- }
1063
-
1064
- const args = ['--model', voicePath, '--output_file', tempWav];
1065
- if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
1066
- const piper = spawn(_piperBin, args, {
1067
- stdio: ['pipe', 'ignore', 'ignore'],
1068
- detached: !_isWin,
1069
- windowsHide: true,
1070
- env: _spawnEnv,
1071
- });
1072
- piper.stdin.write(phrase + '\n');
1073
- piper.stdin.end();
1074
- _previewProc = piper;
1075
- _previewVoiceId = voiceId;
1076
-
1077
- if (!_vpClosed) {
1078
- vpPreviewLine.setContent(`{bright-cyan-fg}♪ Synthesizing: ${voiceId}...{/bright-cyan-fg}`);
1079
- screen.render();
1080
- }
1081
-
1082
- piper.on('exit', (code) => {
1083
- if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
1084
- if (code !== 0) { _previewProc = null; _previewVoiceId = null; return; }
1085
- const wp = detectWavPlayer(_spawnEnv);
1086
- if (!wp) return;
1087
- const pp = spawn(wp.bin, wp.args(tempWav), {
1088
- stdio: 'ignore',
1089
- detached: !_isWin,
1090
- windowsHide: true,
1091
- env: _spawnEnv,
1092
- });
1093
- _previewProc = pp;
1094
- if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}{/bright-cyan-fg}`); screen.render(); }
1095
- pp.on('exit', () => {
1096
- if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); screen.render(); } }
1097
- try { fs.unlinkSync(tempWav); } catch {}
1098
- });
1099
- });
1100
- piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1101
- }
1102
-
1103
- vpSearch.on('keypress', () => {
1104
- setTimeout(() => { _filterText = vpSearch.getValue().trim(); _refreshVP(); }, 0);
1105
- });
1106
- vpSearch.key(['escape'], () => { vpList.focus(); screen.render(); });
1107
- vpList.key(['/'], () => { vpSearch.clearValue(); vpSearch.focus(); screen.render(); });
1108
- vpList.key(['enter'], () => {
1109
- const filtered = _getFiltered();
1110
- const sel = filtered[vpList.selected];
1111
- if (sel) { draft.voice = sel; _closeVP(); }
1112
- });
1113
- vpList.key(['space'], () => {
1114
- const filtered = _getFiltered();
1115
- const sel = filtered[vpList.selected];
1116
- if (sel) _previewVoice(sel);
1117
- });
1118
- vpList.key(['escape', 'q'], _closeVP);
1119
-
1120
- _refreshVP();
1121
- const activeIdx = _getFiltered().indexOf(draft.voice);
1122
- if (activeIdx >= 0) vpList.select(activeIdx);
1123
- vpList.focus();
1124
- screen.render();
1125
- }
1126
-
1127
- // -------------------------------------------------------------------------
1128
- // Pretext inline editor
1129
-
1130
- function _openPretextEditor(parentModal, draft, onDone) {
1131
- const editModal = blessed.box({
1132
- parent: screen,
1133
- top: 'center',
1134
- left: 'center',
1135
- width: 60,
1136
- height: 8,
1137
- border: { type: 'line' },
1138
- tags: true,
1139
- label: _modalTitle('Edit Pretext'),
1140
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'bright-cyan' } },
1141
- });
1142
- editModal.setFront();
1143
-
1144
- blessed.text({
1145
- parent: editModal, top: 1, left: 2,
1146
- content: 'Agent pretext (spoken before each TTS message):',
1147
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1148
- });
1149
-
1150
- const inputBox = blessed.textbox({
1151
- parent: editModal, top: 3, left: 2, right: 2, height: 3,
1152
- border: { type: 'line' },
1153
- inputOnFocus: true,
1154
- value: draft.pretext,
1155
- style: {
1156
- fg: COLORS.valueFg, bg: '#0d1b35',
1157
- border: { fg: COLORS.borderFg },
1158
- focus: { border: { fg: 'bright-cyan' } },
1159
- },
1160
- });
1161
-
1162
- let _editClosed = false;
1163
- function _closeEdit(save) {
1164
- if (_editClosed) return;
1165
- _editClosed = true;
1166
- if (save) {
1167
- const raw = inputBox.getValue().trim();
1168
- // M7: enforce max pretext length
1169
- draft.pretext = (raw || draft.pretext).slice(0, MAX_PRETEXT_LENGTH);
1170
- }
1171
- destroyList(editModal, screen, onDone);
1172
- }
1173
-
1174
- inputBox.key(['enter'], () => _closeEdit(true));
1175
- inputBox.key(['escape'], () => _closeEdit(false));
1176
-
1177
- inputBox.focus();
1178
- screen.render();
1179
- }
1180
-
1181
- // -------------------------------------------------------------------------
1182
- // Sample agent with a draft profile (no save) — same full pipeline
1183
-
1184
- function _sampleAgentWithDraft(agent, draft, onComplete) {
1185
- _sampleWithFullProfile(agent, draft, onComplete);
1186
- }
1187
-
1188
- // -------------------------------------------------------------------------
1189
- // Shared: sample with full profile via play-tts-enhanced.sh
1190
- // Writes a temp agent profile JSON, then calls the enhanced TTS pipeline
1191
- // which applies voice + reverb + background music.
1192
-
1193
- function _sampleWithFullProfile(agent, profile, onComplete) {
1194
- _killPreview();
1195
- const gen = ++_playGeneration;
1196
-
1197
- // Start spinner on the agent's row in the list
1198
- const agentIdx = _agents.findIndex(a => a.id === agent.id);
1199
- if (agentIdx >= 0) _startSpinner(agentIdx);
1200
-
1201
- const voiceId = profile.voice || '';
1202
- const pretext = profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title);
1203
- const phrase = `${pretext} ${SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]}`;
1204
-
1205
- const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1206
-
1207
- if (isWindows) {
1208
- // On Windows, call play-tts.ps1 via PowerShell with draft settings patched
1209
- // in so reverb, background music, and personality are applied.
1210
- _sampleWithFullEffectsWindows(gen, agent, profile, phrase, onComplete);
1211
- } else {
1212
- // On Linux/macOS/WSL, use play-tts.sh
1213
- const _spawnEnv = buildAudioEnv();
1214
- const scriptDir = path.join(_projectRoot, '.claude', 'hooks');
1215
- const plainScript = path.join(scriptDir, 'play-tts.sh');
1216
- const args = [plainScript, phrase];
1217
- if (voiceId) args.push(voiceId);
1218
-
1219
- const proc = spawn('bash', args, {
1220
- stdio: ['ignore', 'ignore', 'ignore'],
1221
- detached: true,
1222
- env: { ..._spawnEnv },
1223
- cwd: _projectRoot,
1224
- });
1225
- _playingProcess = proc;
1226
-
1227
- proc.on('exit', () => {
1228
- if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1229
- });
1230
- proc.on('error', () => {
1231
- if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1232
- });
1233
- }
1234
- }
1235
-
1236
- /** Windows-native sample: piper.exe → wav → detectWavPlayer */
1237
- function _sampleWithPiperDirect(gen, voiceId, phrase) {
1238
- const _spawnEnv = buildAudioEnv();
1239
-
1240
- // Resolve piper binary
1241
- let piperBin = 'piper';
1242
- const localAppData = process.env.LOCALAPPDATA ||
1243
- (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
1244
- if (localAppData) {
1245
- const exePath = path.join(localAppData, 'Programs', 'Piper', 'piper.exe');
1246
- if (fs.existsSync(exePath)) piperBin = exePath;
1247
- }
1248
-
1249
- // Resolve voice model path
1250
- const ms = parseMultiSpeaker(voiceId);
1251
- const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
1252
- const safeBase = path.resolve(PIPER_VOICES_DIR);
1253
- if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
1254
- _stopSpinner();
1255
- return;
1256
- }
1257
-
1258
- const tempWav = path.join(os.tmpdir(), `agentvibes-agent-preview-${Date.now()}.wav`);
1259
- const piperArgs = ['--model', voicePath, '--output_file', tempWav];
1260
- if (ms.speakerId != null) piperArgs.push('--speaker', String(ms.speakerId));
1261
-
1262
- const piper = spawn(piperBin, piperArgs, {
1263
- stdio: ['pipe', 'ignore', 'ignore'],
1264
- detached: false,
1265
- windowsHide: true,
1266
- env: _spawnEnv,
1267
- });
1268
- piper.stdin.write(phrase + '\n');
1269
- piper.stdin.end();
1270
- _playingProcess = piper;
1271
-
1272
- piper.on('exit', (code) => {
1273
- if (gen !== _playGeneration) {
1274
- try { fs.unlinkSync(tempWav); } catch {}
1275
- return;
1276
- }
1277
- if (code !== 0) {
1278
- _playingProcess = null;
1279
- _stopSpinner();
1280
- try { fs.unlinkSync(tempWav); } catch {}
1281
- return;
1282
- }
1283
-
1284
- // Play the synthesized wav
1285
- const wavPlayer = detectWavPlayer(_spawnEnv);
1286
- if (!wavPlayer) {
1287
- _playingProcess = null;
1288
- _stopSpinner();
1289
- try { fs.unlinkSync(tempWav); } catch {}
1290
- return;
1291
- }
1292
- const playProc = spawn(wavPlayer.bin, wavPlayer.args(tempWav), {
1293
- stdio: 'ignore',
1294
- detached: false,
1295
- windowsHide: true,
1296
- env: _spawnEnv,
1297
- });
1298
- _playingProcess = playProc;
1299
-
1300
- playProc.on('exit', () => {
1301
- if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1302
- try { fs.unlinkSync(tempWav); } catch {}
1303
- });
1304
- playProc.on('error', () => {
1305
- if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1306
- try { fs.unlinkSync(tempWav); } catch {}
1307
- });
1308
- });
1309
-
1310
- piper.on('error', () => {
1311
- if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1312
- });
1313
- }
1314
-
1315
- /** Windows full-effects preview: temporarily patches config files then calls play-tts.ps1 */
1316
- function _sampleWithFullEffectsWindows(gen, agent, profile, phrase, onComplete) {
1317
- const _spawnEnv = buildAudioEnv();
1318
- const homeDir = process.env.USERPROFILE || os.homedir();
1319
- // Use project-local .claude if it exists, else global ~/.claude
1320
- const claudeDir = fs.existsSync(path.join(_projectRoot, '.claude'))
1321
- ? path.join(_projectRoot, '.claude')
1322
- : path.join(homeDir, '.claude');
1323
- const configDir = path.join(claudeDir, 'config');
1324
- const hooksDir = path.join(claudeDir, 'hooks-windows');
1325
- const playTts = path.join(hooksDir, 'play-tts.ps1');
1326
- if (!fs.existsSync(playTts)) { _sampleWithPiperDirect(gen, profile.voice || '', phrase); return; }
1327
-
1328
- // Files to temporarily patch
1329
- const personalityFile = path.join(configDir, 'personality.txt');
1330
- const reverbFile = path.join(configDir, 'reverb-level.txt');
1331
- const bgEnabledFile = path.join(configDir, 'background-music-enabled.txt');
1332
- const audioEffectsCfg = path.join(configDir, 'audio-effects.cfg');
1333
-
1334
- // Save originals
1335
- const _read = f => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
1336
- const origPersonality = _read(personalityFile);
1337
- const origReverb = _read(reverbFile);
1338
- const origBgEnabled = _read(bgEnabledFile);
1339
- const origAudioEffects = _read(audioEffectsCfg);
1340
-
1341
- const bgMusic = profile.backgroundMusic;
1342
- let tempCfgLine = '';
1343
-
1344
- try {
1345
- if (profile.personality && profile.personality !== 'none')
1346
- fs.writeFileSync(personalityFile, profile.personality);
1347
- if (profile.reverbPreset)
1348
- fs.writeFileSync(reverbFile, profile.reverbPreset);
1349
- if (bgMusic?.enabled && bgMusic?.track) {
1350
- fs.writeFileSync(bgEnabledFile, 'true');
1351
- const vol = ((bgMusic.volume ?? 20) / 100).toFixed(2);
1352
- tempCfgLine = `${agent.id}||${bgMusic.track}|${vol}`;
1353
- fs.writeFileSync(audioEffectsCfg, `${tempCfgLine}\n${origAudioEffects || ''}`);
1354
- }
1355
- } catch { /* degrade gracefully */ }
1356
-
1357
- const voiceId = profile.voice || '';
1358
- const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase];
1359
- if (voiceId) psArgs.push(voiceId);
1360
-
1361
- const proc = spawn('powershell', psArgs, {
1362
- stdio: 'ignore', detached: false, windowsHide: true,
1363
- // CLAUDE_PROJECT_DIR tells play-tts.ps1 to use the project's .claude/config
1364
- // rather than falling back to ~/.claude where our patches don't exist.
1365
- env: { ..._spawnEnv, AGENTVIBES_AGENT_NAME: agent.id, CLAUDE_PROJECT_DIR: _projectRoot },
1366
- });
1367
- _playingProcess = proc;
1368
-
1369
- function _restore() {
1370
- try {
1371
- if (origPersonality !== null) fs.writeFileSync(personalityFile, origPersonality);
1372
- else try { fs.unlinkSync(personalityFile); } catch {}
1373
- if (origReverb !== null) fs.writeFileSync(reverbFile, origReverb);
1374
- if (bgMusic?.enabled && bgMusic?.track) {
1375
- if (origBgEnabled !== null) fs.writeFileSync(bgEnabledFile, origBgEnabled);
1376
- else try { fs.unlinkSync(bgEnabledFile); } catch {}
1377
- if (origAudioEffects !== null) fs.writeFileSync(audioEffectsCfg, origAudioEffects);
1378
- }
1379
- } catch {}
1380
- }
1381
-
1382
- proc.on('exit', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1383
- proc.on('error', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1384
- }
1385
-
1386
- // -------------------------------------------------------------------------
1387
- // Auto-assign helpers
1388
-
1389
- function _shuffleArray(arr) {
1390
- const a = [...arr];
1391
- for (let i = a.length - 1; i > 0; i--) {
1392
- const j = Math.floor(Math.random() * (i + 1));
1393
- [a[i], a[j]] = [a[j], a[i]];
1394
- }
1395
- return a;
1396
- }
1397
-
1398
- // Common first-name → gender map for gender-aware auto-assign.
1399
- // Only needs to cover names likely used as BMAD agent display names.
1400
- const _NAME_GENDER = {
1401
- // Female
1402
- amelia: 'Female', amy: 'Female', anna: 'Female', betty: 'Female',
1403
- claire: 'Female', dana: 'Female', emma: 'Female', faye: 'Female',
1404
- grace: 'Female', heather: 'Female', ivy: 'Female', jane: 'Female',
1405
- jenny: 'Female', julia: 'Female', kate: 'Female', laura: 'Female',
1406
- lily: 'Female', maria: 'Female', mary: 'Female', nina: 'Female',
1407
- olivia: 'Female', paige: 'Female', quinn: 'Female', rachel: 'Female',
1408
- sally: 'Female', sara: 'Female', sarah: 'Female', sophie: 'Female',
1409
- tina: 'Female', wendy: 'Female', zoe: 'Female',
1410
- freya: 'Female', saga: 'Female',
1411
- // Male
1412
- alan: 'Male', barry: 'Male', bob: 'Male', carl: 'Male',
1413
- charlie: 'Male', dan: 'Male', david: 'Male', eric: 'Male',
1414
- frank: 'Male', george: 'Male', hank: 'Male', jack: 'Male',
1415
- james: 'Male', joe: 'Male', john: 'Male', kevin: 'Male',
1416
- leo: 'Male', mark: 'Male', max: 'Male', murat: 'Male',
1417
- nick: 'Male', oscar: 'Male', paul: 'Male', ray: 'Male',
1418
- ryan: 'Male', saif: 'Male', sam: 'Male', steve: 'Male',
1419
- tom: 'Male', victor: 'Male', winston: 'Male', zach: 'Male',
1420
- };
1421
-
1422
- /** Infer agent gender from display name (first word). */
1423
- function _inferAgentGender(agent) {
1424
- const firstName = (agent.displayName || '').split(/[\s(]/)[0].toLowerCase();
1425
- return _NAME_GENDER[firstName] || null;
1426
- }
1427
-
1428
- function _autoAssignVoices() {
1429
- const installed = scanInstalledVoices();
1430
- if (installed.length === 0) return false;
1431
-
1432
- // Separate voices by gender
1433
- const femaleVoices = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Female'));
1434
- const maleVoices = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Male'));
1435
- const otherVoices = _shuffleArray(installed.filter(v => !['Male', 'Female'].includes(getVoiceMeta(v).gender)));
1436
-
1437
- // Separate agents by gender
1438
- const femaleAgents = _agents.filter(a => _inferAgentGender(a) === 'Female');
1439
- const maleAgents = _agents.filter(a => _inferAgentGender(a) === 'Male');
1440
- const otherAgents = _agents.filter(a => !_inferAgentGender(a));
1441
-
1442
- const usedVoices = new Set();
1443
-
1444
- // Assign matching-gender voices first, then fall back to any available
1445
- function assignGroup(agents, preferredPool, fallbackPools) {
1446
- const allPools = [preferredPool, ...fallbackPools];
1447
- agents.forEach(agent => {
1448
- let voice = null;
1449
- for (const pool of allPools) {
1450
- voice = pool.find(v => !usedVoices.has(v));
1451
- if (voice) break;
1452
- }
1453
- // If all unique voices exhausted, reuse from preferred pool
1454
- if (!voice && preferredPool.length > 0) {
1455
- voice = preferredPool[usedVoices.size % preferredPool.length];
1456
- }
1457
- if (voice) {
1458
- usedVoices.add(voice);
1459
- voiceStore.setAgentProfile(agent.id, { voice });
1460
- }
1461
- });
1462
- }
1463
-
1464
- assignGroup(femaleAgents, femaleVoices, [otherVoices, maleVoices]);
1465
- assignGroup(maleAgents, maleVoices, [otherVoices, femaleVoices]);
1466
- assignGroup(otherAgents, otherVoices, [maleVoices, femaleVoices]);
1467
-
1468
- return true;
1469
- }
1470
-
1471
- function _autoAssignMusic() {
1472
- const tracksDir = path.join(_projectRoot, '.claude', 'audio', 'tracks');
1473
- let tracks = [];
1474
- try {
1475
- tracks = fs.readdirSync(tracksDir).filter(f => /\.mp3$/i.test(f));
1476
- } catch { /* no tracks dir */ }
1477
- if (tracks.length === 0) return false;
1478
-
1479
- const shuffled = _shuffleArray(tracks);
1480
- _agents.forEach((agent, i) => {
1481
- const track = shuffled[i % shuffled.length];
1482
- const existing = voiceStore.getAgentProfile(agent.id);
1483
- voiceStore.setAgentProfile(agent.id, {
1484
- backgroundMusic: { track, volume: existing.backgroundMusic?.volume ?? 20, enabled: true },
1485
- });
1486
- });
1487
- return true;
1488
- }
1489
-
1490
- function _autoAssignAll() {
1491
- if (_agents.length === 0) return;
1492
- const voiceOk = _autoAssignVoices();
1493
- const musicOk = _autoAssignMusic();
1494
- refreshDisplay();
1495
- const msg = voiceOk && musicOk ? 'Voices and music auto-assigned'
1496
- : voiceOk ? 'Voices auto-assigned' : 'Auto-assign: no voices found';
1497
- _showSavedToast(msg);
1498
- }
1499
-
1500
- // -------------------------------------------------------------------------
1501
- // Bulk edit menu
1502
-
1503
- function _openBulkEditMenu() {
1504
- const BULK_ACTIONS = [
1505
- { label: ' Randomize Voices (gender-aware)', key: 'voices' },
1506
- { label: ' Randomize Music (unique per agent)', key: 'music' },
1507
- { label: ' Randomize Both', key: 'both' },
1508
- { label: ' Set Same Music for All Agents...', key: 'setMusic' },
1509
- { label: ' Set Same Volume for All Agents...', key: 'setVolume' },
1510
- { label: ' Set Same Pretext for All Agents...', key: 'setPretext' },
1511
- { label: ' Set Same Reverb for All Agents...', key: 'setReverb' },
1512
- { label: ' Reset All Agent Profiles', key: 'resetAll' },
1513
- ];
1514
-
1515
- const menuList = blessed.list({
1516
- parent: screen,
1517
- top: 'center',
1518
- left: 'center',
1519
- width: 52,
1520
- height: BULK_ACTIONS.length + 4,
1521
- border: { type: 'line' },
1522
- tags: true,
1523
- label: _modalTitle('Bulk Edit'),
1524
- keys: true,
1525
- vi: true,
1526
- mouse: true,
1527
- items: BULK_ACTIONS.map(a => a.label),
1528
- style: {
1529
- fg: COLORS.labelFg,
1530
- bg: COLORS.contentBg,
1531
- border: { fg: COLORS.btnFocus },
1532
- selected: { bg: 'blue', fg: 'yellow' },
1533
- item: { fg: COLORS.labelFg },
1534
- },
1535
- });
1536
-
1537
- blessed.text({
1538
- parent: menuList,
1539
- bottom: -1,
1540
- left: 1,
1541
- width: 48,
1542
- height: 1,
1543
- tags: true,
1544
- content: '{#455a64-fg}[Enter] Select [Esc] Cancel{/#455a64-fg}',
1545
- style: { bg: COLORS.contentBg },
1546
- });
1547
-
1548
- menuList.setFront();
1549
- menuList.focus();
1550
- screen.render();
1551
-
1552
- let _menuClosed = false;
1553
- function _closeMenu(callback) {
1554
- if (_menuClosed) return;
1555
- _menuClosed = true;
1556
- destroyList(menuList, screen, callback);
1557
- }
1558
-
1559
- menuList.key(['enter'], () => {
1560
- const action = BULK_ACTIONS[menuList.selected];
1561
- if (!action) return;
1562
-
1563
- switch (action.key) {
1564
- case 'voices':
1565
- _closeMenu(() => {
1566
- if (_autoAssignVoices()) { refreshDisplay(); _showSavedToast('Voices randomized'); }
1567
- });
1568
- break;
1569
-
1570
- case 'music':
1571
- _closeMenu(() => {
1572
- if (_autoAssignMusic()) { refreshDisplay(); _showSavedToast('Music randomized'); }
1573
- });
1574
- break;
1575
-
1576
- case 'both':
1577
- _closeMenu(() => { _autoAssignAll(); });
1578
- break;
1579
-
1580
- case 'setMusic':
1581
- _closeMenu(() => {
1582
- openTrackPicker(screen, '', 20, (track, volume) => {
1583
- _agents.forEach(agent => {
1584
- const p = voiceStore.getAgentProfile(agent.id);
1585
- voiceStore.setAgentProfile(agent.id, {
1586
- backgroundMusic: { track, volume, enabled: true },
1587
- });
1588
- });
1589
- refreshDisplay();
1590
- _showSavedToast('Music set for all agents');
1591
- agentList.focus();
1592
- }, () => { agentList.focus(); screen.render(); });
1593
- });
1594
- break;
1595
-
1596
- case 'setVolume':
1597
- _closeMenu(() => {
1598
- const curVol = voiceStore.getAgentProfile(_agents[0]?.id)?.backgroundMusic?.volume ?? 20;
1599
- openVolumeInput(screen, curVol, (volume) => {
1600
- _agents.forEach(agent => {
1601
- const p = voiceStore.getAgentProfile(agent.id);
1602
- const bm = p.backgroundMusic || {};
1603
- voiceStore.setAgentProfile(agent.id, {
1604
- backgroundMusic: { ...bm, volume },
1605
- });
1606
- });
1607
- refreshDisplay();
1608
- _showSavedToast(`Volume set to ${volume}% for all agents`);
1609
- agentList.focus();
1610
- }, () => { agentList.focus(); screen.render(); });
1611
- });
1612
- break;
1613
-
1614
- case 'setPretext':
1615
- _closeMenu(() => { _openBulkPretextEditor(); });
1616
- break;
1617
-
1618
- case 'setReverb':
1619
- _closeMenu(() => {
1620
- openReverbPicker(screen, '', (val) => {
1621
- _agents.forEach(agent => voiceStore.setAgentProfile(agent.id, { reverbPreset: val }));
1622
- refreshDisplay();
1623
- _showSavedToast('Reverb set for all agents');
1624
- agentList.focus();
1625
- }, () => { agentList.focus(); screen.render(); }, { applyToEffectsManager: false });
1626
- });
1627
- break;
1628
-
1629
- case 'resetAll':
1630
- _closeMenu(() => {
1631
- _agents.forEach(agent => voiceStore.resetAgentProfile(agent.id));
1632
- refreshDisplay();
1633
- _showSavedToast('All profiles reset');
1634
- });
1635
- break;
1636
- }
1637
- });
1638
-
1639
- menuList.key(['escape', 'q'], () => {
1640
- _closeMenu(() => { agentList.focus(); screen.render(); });
1641
- });
1642
- }
1643
-
1644
- function _openBulkPretextEditor() {
1645
- const editModal = blessed.box({
1646
- parent: screen,
1647
- top: 'center',
1648
- left: 'center',
1649
- width: 60,
1650
- height: 9,
1651
- border: { type: 'line' },
1652
- tags: true,
1653
- label: _modalTitle('Set Pretext for All Agents'),
1654
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'bright-cyan' } },
1655
- });
1656
- editModal.setFront();
1657
-
1658
- blessed.text({
1659
- parent: editModal, top: 1, left: 2,
1660
- content: 'Pretext to apply to all agents (leave empty to clear):',
1661
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1662
- });
1663
-
1664
- const inputBox = blessed.textbox({
1665
- parent: editModal, top: 3, left: 2, right: 2, height: 3,
1666
- border: { type: 'line' },
1667
- inputOnFocus: true,
1668
- style: {
1669
- fg: COLORS.valueFg, bg: '#0d1b35',
1670
- border: { fg: COLORS.borderFg },
1671
- focus: { border: { fg: 'bright-cyan' } },
1672
- },
1673
- });
1674
-
1675
- blessed.text({
1676
- parent: editModal, bottom: 1, left: 2, tags: true,
1677
- content: '{#455a64-fg}[Enter] Apply to all [Esc] Cancel{/#455a64-fg}',
1678
- style: { bg: COLORS.contentBg },
1679
- });
1680
-
1681
- let _closed = false;
1682
- function _close(save) {
1683
- if (_closed) return;
1684
- _closed = true;
1685
- if (save) {
1686
- const raw = inputBox.getValue().trim().slice(0, MAX_PRETEXT_LENGTH);
1687
- _agents.forEach(agent => {
1688
- if (raw) {
1689
- voiceStore.setAgentProfile(agent.id, { pretext: raw });
1690
- } else {
1691
- const p = voiceStore.getAgentProfile(agent.id);
1692
- const { pretext: _removed, ...rest } = p;
1693
- voiceStore.resetAgentProfile(agent.id);
1694
- if (Object.keys(rest).length > 0) voiceStore.setAgentProfile(agent.id, rest);
1695
- }
1696
- });
1697
- refreshDisplay();
1698
- _showSavedToast('Pretext set for all agents');
1699
- }
1700
- destroyList(editModal, screen, () => { agentList.focus(); screen.render(); });
1701
- }
1702
-
1703
- inputBox.key(['enter'], () => _close(true));
1704
- inputBox.key(['escape'], () => _close(false));
1705
- inputBox.focus();
1706
- screen.render();
1707
- }
1708
-
1709
- // -------------------------------------------------------------------------
1710
- // Key bindings
1711
-
1712
- agentList.key(['x', 'X'], () => {
1713
- const agent = _agents[agentList.selected ?? 0];
1714
- if (agent) {
1715
- voiceStore.resetAgentProfile(agent.id);
1716
- refreshDisplay();
1717
- }
1718
- });
1719
-
1720
-
1721
- agentList.key(['enter'], () => {
1722
- const agent = _agents[agentList.selected ?? 0];
1723
- if (agent) _openAgentDetailPanel(agent);
1724
- });
1725
-
1726
- agentList.key(['space'], () => {
1727
- const agent = _agents[agentList.selected ?? 0];
1728
- if (agent) _sampleAgent(agent);
1729
- });
1730
-
1731
- agentList.key(['a', 'A'], () => { _autoAssignAll(); });
1732
- agentList.key(['b', 'B'], () => { _openBulkEditMenu(); });
1733
-
1734
- // Type-to-jump
1735
- const _agentJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'x', 'a', 'b']);
1736
- agentList.on('keypress', (ch, key) => {
1737
- if (!ch || key.ctrl || key.meta) return;
1738
- const lower = ch.toLowerCase();
1739
- if (!/^[a-z]$/.test(lower)) return;
1740
- if (_agentJumpBlocked.has(lower)) return;
1741
- const count = _agents.length;
1742
- if (count === 0) return;
1743
- const start = agentList.selected ?? 0;
1744
- for (let i = 1; i <= count; i++) {
1745
- const idx = (start + i) % count;
1746
- const name = (_agents[idx]?.displayName ?? '').toLowerCase();
1747
- if (name.startsWith(lower)) {
1748
- agentList.select(idx);
1749
- screen.render();
1750
- break;
1751
- }
1752
- }
1753
- });
1754
-
1755
- // Inline row hint (appended to selected row while list is focused)
1756
- let _listFocused = false;
1757
- let _hintIdx = -1;
1758
- let _hintBase = ''; // row content before hint was appended (no hint, no █)
1759
-
1760
- function _updateHint(idx) {
1761
- const items = agentList.items;
1762
- if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
1763
- const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
1764
- items[_hintIdx].setContent(hadBlink ? _hintBase + ' █' : _hintBase);
1765
- }
1766
- if (idx >= 0 && items[idx]) {
1767
- let c = items[idx].content ?? '';
1768
- const hasBlink = c.endsWith(' █');
1769
- if (hasBlink) c = c.slice(0, -3);
1770
- _hintBase = c;
1771
- items[idx].setContent(c + _ROW_HINT_BMAD + (hasBlink ? ' █' : ''));
1772
- } else {
1773
- _hintBase = '';
1774
- }
1775
- _hintIdx = idx;
1776
- }
1777
-
1778
- // Blinking cursor
1779
- let _alBlink = { interval: null, on: false, sel: -1 };
1780
- function _alTick() {
1781
- _alBlink.on = !_alBlink.on;
1782
- const items = agentList.items;
1783
- const cur = agentList.selected ?? 0;
1784
- if (_alBlink.sel !== cur && _alBlink.sel >= 0 && items[_alBlink.sel]) {
1785
- items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '').replace(/ █$/, ''));
1786
- }
1787
- _alBlink.sel = cur;
1788
- if (items[cur]) {
1789
- const base = (items[cur].content ?? '').replace(/ █$/, '');
1790
- items[cur].setContent(_alBlink.on ? `${base} █` : base);
1791
- }
1792
- screen.render();
1793
- }
1794
- agentList.on('focus', () => {
1795
- _listFocused = true;
1796
- _alBlink.on = true;
1797
- _alBlink.sel = agentList.selected ?? 0;
1798
- _hintIdx = -1;
1799
- _hintBase = '';
1800
- _updateHint(_alBlink.sel);
1801
- const items = agentList.items;
1802
- if (items[_alBlink.sel]) items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '') + ' █');
1803
- screen.render();
1804
- _alBlink.interval = setInterval(_alTick, 500);
1805
- });
1806
- agentList.on('blur', () => {
1807
- _listFocused = false;
1808
- if (_alBlink.interval) { clearInterval(_alBlink.interval); _alBlink.interval = null; }
1809
- const items = agentList.items;
1810
- const sel = agentList.selected ?? 0;
1811
- if (items[sel]) {
1812
- items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1813
- }
1814
- if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1815
- items[_hintIdx].setContent(_hintBase);
1816
- }
1817
- _hintIdx = -1;
1818
- _hintBase = '';
1819
- screen.render();
1820
- });
1821
- agentList.on('select item', () => {
1822
- _updateHint(agentList.selected ?? 0);
1823
- if (_alBlink.interval) _alTick();
1824
- });
1825
-
1826
- // Navigation: up at top → tab bar, escape → tab bar
1827
- agentList.key(['up'], () => {
1828
- if (agentList.selected === 0 && typeof focusMainTabBar === 'function') {
1829
- focusMainTabBar();
1830
- setTimeout(() => { agentList.select(0); screen.render(); }, 0);
1831
- }
1832
- });
1833
- agentList.key(['escape'], () => {
1834
- if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1835
- });
1836
-
1837
- // -------------------------------------------------------------------------
1838
- // Language change handler
1839
-
1840
- if (languageService) {
1841
- languageService.onChange(() => {
1842
- onboardingBox.setContent(_buildOnboardingText());
1843
- screen.render();
1844
- });
1845
- }
1846
-
1847
- // -------------------------------------------------------------------------
1848
- // Tab Component Contract
1849
-
1850
- return {
1851
- box,
1852
-
1853
- show() {
1854
- box.show();
1855
- refreshDisplay();
1856
- screen.render();
1857
- },
1858
-
1859
- hide() {
1860
- _killPreview();
1861
- box.hide();
1862
- screen.render();
1863
- },
1864
-
1865
- onFocus() {
1866
- if (_bmadDetected) {
1867
- agentList.focus();
1868
- } else {
1869
- onboardingBox.focus();
1870
- }
1871
- screen.render();
1872
- },
1873
-
1874
- onBlur() {
1875
- _killPreview();
1876
- },
1877
-
1878
- getFooterText() {
1879
- return _bmadDetected ? _tl('bmadFooterBmad') : _tl('bmadFooterNobmad');
1880
- },
1881
-
1882
- getFooterColor() {
1883
- return COLORS.footerBg;
1884
- },
1885
- };
1886
- }
1
+ /**
2
+ * AgentVibes TUI Console — Agents Tab (BMAD Integration)
3
+ *
4
+ * Implements the Tab Component Contract:
5
+ * createAgentsTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
6
+ *
7
+ * Two states:
8
+ * 1. No BMAD detected → onboarding screen with description, links, install command
9
+ * 2. BMAD detected → agent table with per-agent voice/pretext/reverb/personality/music customization
10
+ */
11
+
12
+ import { AgentVoiceStore, scanBmadAgents, isBmadDetected, isSingleVoiceProvider } from '../../services/agent-voice-store.js';
13
+ import { openReverbPicker, REVERB_PRESETS } from '../widgets/reverb-picker.js';
14
+ import { openPersonalityPicker, PERSONALITIES, PERSONALITY_EMOJIS } from '../widgets/personality-picker.js';
15
+ import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
16
+ import { formatReverbState, formatTrackName, formatVoiceName } from '../widgets/format-utils.js';
17
+ import {
18
+ PIPER_VOICES_DIR, SAMPLE_PHRASES,
19
+ parseMultiSpeaker, scanInstalledVoices, getVoiceMeta,
20
+ } from './voices-tab.js';
21
+ import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
22
+ import { destroyList } from '../widgets/destroy-list.js';
23
+ import { BRAND_PINK } from '../brand-colors.js';
24
+ import { t } from '../../i18n/strings.js';
25
+ import crypto from 'node:crypto';
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { spawn } from 'node:child_process';
30
+
31
+ // Max pretext length to prevent excessively long TTS utterances
32
+ const MAX_PRETEXT_LENGTH = 200;
33
+
34
+ /**
35
+ * Attach a blinking █ cursor to a set of blessed buttons.
36
+ * Works alongside existing focus/blur handlers (e.g. ►..◄ indicators).
37
+ * While a spinner is active on a button, blink is paused for that button.
38
+ * Returns { cleanup, startSpinner(btn, screen), stopSpinner(btn, screen) }.
39
+ */
40
+ export function attachBtnBlink(btns, screen) {
41
+ let _interval = null;
42
+ let _on = true;
43
+ let _active = null;
44
+ let _spinning = null; // button currently showing a spinner
45
+
46
+ const _SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
47
+ let _spinIdx = 0;
48
+ let _spinInterval = null;
49
+
50
+ // Store original label on each button at attach time — never derive from current content
51
+ btns.forEach(btn => { btn._blinkBase = btn.content; });
52
+
53
+ // Focused with indicator ch right after ► e.g. ►█Preview◄ / ► Preview◄ (same width)
54
+ function _focused(base, ch) { return `►${ch}${base}◄`; }
55
+
56
+ function _tick() {
57
+ if (!_active || _active === _spinning) return;
58
+ _on = !_on;
59
+ _active.setContent(_focused(_active._blinkBase, _on ? '█' : ' '));
60
+ screen.render();
61
+ }
62
+
63
+ btns.forEach(btn => {
64
+ btn.on('focus', () => {
65
+ _active = btn;
66
+ _on = true;
67
+ if (btn !== _spinning) {
68
+ btn.setContent(_focused(btn._blinkBase, '█'));
69
+ screen.render();
70
+ }
71
+ if (!_interval) _interval = setInterval(_tick, 500);
72
+ });
73
+ btn.on('blur', () => {
74
+ if (_active !== btn) return;
75
+ _active = null;
76
+ if (_interval) { clearInterval(_interval); _interval = null; _on = true; }
77
+ if (btn !== _spinning) {
78
+ btn.setContent(btn._blinkBase);
79
+ screen.render();
80
+ }
81
+ });
82
+ });
83
+
84
+ function startSpinner(btn) {
85
+ _spinning = btn;
86
+ _spinIdx = 0;
87
+ if (_spinInterval) clearInterval(_spinInterval);
88
+ _spinInterval = setInterval(() => {
89
+ _spinIdx = (_spinIdx + 1) % _SPIN.length;
90
+ btn.setContent(_active === btn
91
+ ? _focused(btn._blinkBase, _SPIN[_spinIdx])
92
+ : `${_SPIN[_spinIdx]}${btn._blinkBase}`);
93
+ screen.render();
94
+ }, 80);
95
+ }
96
+
97
+ function stopSpinner(btn) {
98
+ if (_spinInterval) { clearInterval(_spinInterval); _spinInterval = null; }
99
+ _spinning = null;
100
+ btn.setContent(_active === btn ? _focused(btn._blinkBase, '█') : btn._blinkBase);
101
+ screen.render();
102
+ }
103
+
104
+ function cleanup() {
105
+ if (_interval) { clearInterval(_interval); _interval = null; }
106
+ if (_spinInterval){ clearInterval(_spinInterval); _spinInterval = null; }
107
+ }
108
+
109
+ return { cleanup, startSpinner, stopSpinner };
110
+ }
111
+
112
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
113
+
114
+ let blessed;
115
+ if (!IS_TEST) {
116
+ const { default: b } = await import('blessed');
117
+ blessed = b;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const COLORS = {
123
+ contentBg: '#0a0e1a',
124
+ sectionHdr: '#7b1fa2',
125
+ labelFg: '#e3f2fd',
126
+ valueFg: '#ffff00',
127
+
128
+ btnDefault: '#6a1b9a',
129
+ btnFocus: '#2e7d32', // Green — focused/selected
130
+ btnFocusFg: '#ffffff',
131
+ btnPress: '#ff00ff',
132
+ borderFg: '#9c27b0',
133
+ footerBg: '#9c27b0',
134
+ noticeFg: '#90a4ae',
135
+ warnFg: '#ff9800',
136
+ linkFg: 'bright-cyan',
137
+ };
138
+
139
+ const _FOOTER_BMAD_EN = '[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [X] Reset [Q] Quit';
140
+ const _FOOTER_NOBMAD_EN = '[Tab] Switch Tab [Q] Quit';
141
+
142
+ const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
143
+
144
+ // Column widths for agent table
145
+ const COL_ICON = 4;
146
+ const COL_NAME = 16;
147
+ const COL_VOICE = 12; // beautified names avg 5-11 chars
148
+ const COL_GENDER = 8;
149
+ const COL_PROVIDER = 12;
150
+ const COL_PRETEXT = 14;
151
+ const COL_REVERB = 10;
152
+ const COL_MUSIC = 11;
153
+ const COL_VOL = 5; // e.g. "70%" or "100%"
154
+
155
+ // Inline hint appended to the selected row when list is focused
156
+ const _ROW_HINT_BMAD = ` {bright-black-fg}[Space] Preview [Enter] Configure{/bright-black-fg}`;
157
+
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function createTestStub() {
161
+ return {
162
+ box: {},
163
+ show: () => {},
164
+ hide: () => {},
165
+ onFocus: () => {},
166
+ onBlur: () => {},
167
+ getFooterText: () => _FOOTER_BMAD_EN,
168
+ getFooterColor: () => COLORS.footerBg,
169
+ };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Create the Agents tab component.
176
+ */
177
+ export function createAgentsTab(screen, services) {
178
+ if (IS_TEST) return createTestStub();
179
+
180
+ const { configService, providerService, focusMainTabBar, navigationService, languageService } = services;
181
+ const _tl = (key) => languageService ? languageService.t(key) : t('en', key);
182
+
183
+ function _buildOnboardingText() {
184
+ return `{bold}{#ce93d8-fg}${_tl('bmadTitle')}{/#ce93d8-fg}{/bold}
185
+
186
+ {bold}${_tl('bmadWhatIsHeader')}{/bold}
187
+
188
+ ${_tl('bmadDesc')}
189
+
190
+
191
+ {bold}${_tl('bmadInstallHeader')}{/bold}
192
+
193
+ {bright-cyan-fg}npx bmad-method install{/bright-cyan-fg}
194
+
195
+
196
+ {bold}${_tl('bmadLearnMoreHeader')}{/bold}
197
+
198
+ {bright-cyan-fg}https://docs.bmad-method.org/{/bright-cyan-fg}
199
+ {bright-cyan-fg}https://github.com/bmad-code-org/BMAD-METHOD{/bright-cyan-fg}
200
+
201
+
202
+ {#90a4ae-fg}${_tl('bmadInstalledNote')}{/#90a4ae-fg}`;
203
+ }
204
+ const voiceStore = new AgentVoiceStore();
205
+
206
+ // Capture cwd once at construction (L1 fix)
207
+ const _projectRoot = process.cwd();
208
+
209
+ let _bmadDetected = false;
210
+ let _agents = [];
211
+ let _playingProcess = null;
212
+ let _playGeneration = 0; // H4: generation counter to prevent orphaned processes
213
+
214
+ /**
215
+ * Create a secure temp file path using XDG_RUNTIME_DIR or user-specific dir (H3 fix).
216
+ */
217
+ function _secureTempWav(prefix) {
218
+ const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
219
+ const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
220
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
221
+ try { fs.chmodSync(dir, 0o700); } catch {}
222
+ return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
223
+ }
224
+
225
+ // -------------------------------------------------------------------------
226
+ // Container
227
+
228
+ const box = blessed.box({
229
+ parent: screen,
230
+ top: 5,
231
+ left: 0,
232
+ width: '100%',
233
+ bottom: 2,
234
+ hidden: true,
235
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
236
+ border: { type: 'line' },
237
+ borderStyle: { fg: COLORS.borderFg },
238
+ });
239
+
240
+ // -------------------------------------------------------------------------
241
+ // Onboarding content (no-BMAD state)
242
+
243
+ const onboardingBox = blessed.box({
244
+ parent: box,
245
+ top: 1,
246
+ left: 3,
247
+ right: 3,
248
+ bottom: 1,
249
+ hidden: true,
250
+ tags: true,
251
+ scrollable: true,
252
+ keys: true,
253
+ vi: true,
254
+ mouse: true,
255
+ content: _buildOnboardingText(),
256
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
257
+ });
258
+
259
+ onboardingBox.key(['escape'], () => {
260
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
261
+ });
262
+
263
+ // -------------------------------------------------------------------------
264
+ // BMAD state — section header
265
+
266
+ const sectionHeader = blessed.text({
267
+ parent: box,
268
+ top: 1,
269
+ left: 2,
270
+ hidden: true,
271
+ content: `{#7b1fa2-fg}── BMAD Agents ${'─'.repeat(53)}{/#7b1fa2-fg}`,
272
+ tags: true,
273
+ style: { bg: COLORS.contentBg },
274
+ });
275
+
276
+ // Column header
277
+ const columnHeader = blessed.text({
278
+ parent: box,
279
+ top: 2,
280
+ left: 4,
281
+ hidden: true,
282
+ tags: true,
283
+ content: `{#90a4ae-fg}${''.padEnd(COL_ICON)}${' Agent'.padEnd(COL_NAME)}${' Voice'.padEnd(COL_VOICE)}${' Gender'.padEnd(COL_GENDER)}${' Provider'.padEnd(COL_PROVIDER)}${' Reverb'.padEnd(COL_REVERB)}${' Music'.padEnd(COL_MUSIC)}${' Vol'.padEnd(COL_VOL)} Pretext{/#90a4ae-fg}`,
284
+ style: { bg: COLORS.contentBg },
285
+ });
286
+
287
+ // -------------------------------------------------------------------------
288
+ // Agent list
289
+
290
+ const agentList = blessed.list({
291
+ parent: box,
292
+ top: 3,
293
+ left: 2,
294
+ width: '96%',
295
+ height: '55%',
296
+ hidden: true,
297
+ keys: true,
298
+ vi: true,
299
+ mouse: true,
300
+ tags: true,
301
+ border: { type: 'line' },
302
+ scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
303
+ style: {
304
+ fg: COLORS.labelFg,
305
+ bg: COLORS.contentBg,
306
+ border: { fg: COLORS.borderFg },
307
+ selected: { bg: 'blue', fg: 'yellow' },
308
+ item: { fg: COLORS.labelFg },
309
+ },
310
+ });
311
+
312
+ // -------------------------------------------------------------------------
313
+ // Status panel
314
+
315
+ const statusDivider = blessed.text({
316
+ parent: box,
317
+ top: '64%',
318
+ left: 2,
319
+ hidden: true,
320
+ content: `{#7b1fa2-fg}── Status ${'─'.repeat(58)}{/#7b1fa2-fg}`,
321
+ tags: true,
322
+ style: { bg: COLORS.contentBg },
323
+ });
324
+
325
+ const statusLine = blessed.text({
326
+ parent: box,
327
+ top: '69%',
328
+ left: 2,
329
+ hidden: true,
330
+ tags: true,
331
+ content: '',
332
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
333
+ });
334
+
335
+ const warningLine = blessed.text({
336
+ parent: box,
337
+ top: '74%',
338
+ left: 2,
339
+ hidden: true,
340
+ tags: true,
341
+ content: '',
342
+ style: { fg: COLORS.warnFg, bg: COLORS.contentBg },
343
+ });
344
+
345
+ // Hint shown inline next to the action buttons at bottom of list
346
+ const hintLine = blessed.text({
347
+ parent: box,
348
+ bottom: 5,
349
+ left: 4,
350
+ hidden: true,
351
+ tags: true,
352
+ content: '{#546e7a-fg}[Space] Preview [Enter] Configure [X] Reset [A] Auto-assign [B] Bulk Edit{/#546e7a-fg}',
353
+ style: { bg: COLORS.contentBg },
354
+ });
355
+
356
+ // -------------------------------------------------------------------------
357
+ // Buttons
358
+
359
+ function _createBtn(label, onClick) {
360
+ const btn = blessed.button({
361
+ parent: box,
362
+ content: label,
363
+ mouse: true,
364
+ keys: true,
365
+ shrink: true,
366
+ hidden: true,
367
+ padding: { left: 1, right: 1 },
368
+ style: {
369
+ bg: COLORS.btnDefault,
370
+ fg: 'white',
371
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
372
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
373
+ },
374
+ });
375
+ btn.on('focus', () => {
376
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
377
+ btn.setContent(`►${raw}◄`);
378
+ screen.render();
379
+ });
380
+ btn.on('blur', () => {
381
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
382
+ btn.setContent(raw);
383
+ screen.render();
384
+ });
385
+ btn.key(['enter', 'space'], () => {
386
+ btn.style.bg = COLORS.btnPress;
387
+ screen.render();
388
+ setTimeout(() => {
389
+ btn.style.bg = COLORS.btnDefault;
390
+ screen.render();
391
+ onClick();
392
+ }, 150);
393
+ });
394
+ btn.on('click', () => btn.press());
395
+ btn.on('mouseover', () => btn.focus());
396
+ return btn;
397
+ }
398
+
399
+ const resetBtn = _createBtn('[X] Reset', () => {
400
+ const agent = _agents[agentList.selected ?? 0];
401
+ if (agent) {
402
+ voiceStore.resetAgentProfile(agent.id);
403
+ refreshDisplay();
404
+ }
405
+ });
406
+ resetBtn.bottom = 4;
407
+ resetBtn.left = 4;
408
+
409
+ const autoAssignBtn = _createBtn('[A] Auto-assign', () => _autoAssignAll());
410
+ autoAssignBtn.bottom = 4;
411
+ autoAssignBtn.left = 18;
412
+
413
+ const bulkEditBtn = _createBtn('[B] Bulk Edit', () => _openBulkEditMenu());
414
+ bulkEditBtn.bottom = 4;
415
+ bulkEditBtn.left = 36;
416
+
417
+ // -------------------------------------------------------------------------
418
+ // Show/hide helpers for the two states
419
+
420
+ const _bmadWidgets = [sectionHeader, columnHeader, agentList, hintLine, resetBtn, autoAssignBtn, bulkEditBtn];
421
+
422
+ function _showBmadState() {
423
+ onboardingBox.hide();
424
+ for (const w of _bmadWidgets) w.show();
425
+ }
426
+
427
+ function _showOnboardingState() {
428
+ for (const w of _bmadWidgets) w.hide();
429
+ onboardingBox.show();
430
+ }
431
+
432
+ // -------------------------------------------------------------------------
433
+ // Build table row items
434
+
435
+ function _buildListItems(agents) {
436
+ if (agents.length === 0) {
437
+ return [' (no BMAD agents detected)'];
438
+ }
439
+ return agents.map(a => {
440
+ const profile = voiceStore.getAgentProfile(a.id);
441
+ // Strip variation selectors (e.g. U+FE0F on 🏗️) so padEnd uses visual width
442
+ const rawIcon = (a.icon || '').replace(/\uFE0F/g, '');
443
+ const icon = (rawIcon ? `${rawIcon} ` : ' ').padEnd(COL_ICON);
444
+ const name = ` ${a.displayName}`.padEnd(COL_NAME).slice(0, COL_NAME);
445
+ const voiceRaw = formatVoiceName(profile.voice);
446
+ const voice = (' ' + voiceRaw).padEnd(COL_VOICE).slice(0, COL_VOICE);
447
+ const meta = profile.voice ? getVoiceMeta(profile.voice) : { gender: '—', provider: '—' };
448
+ const gender = (' ' + meta.gender).padEnd(COL_GENDER).slice(0, COL_GENDER);
449
+ const provider = (' ' + meta.provider).padEnd(COL_PROVIDER).slice(0, COL_PROVIDER);
450
+ const reverb = (' ' + (profile.reverbPreset || '(global)')).padEnd(COL_REVERB).slice(0, COL_REVERB);
451
+ const music = (' ' + (profile.backgroundMusic?.track
452
+ ? formatTrackName(profile.backgroundMusic.track)
453
+ : '(global)')).padEnd(COL_MUSIC).slice(0, COL_MUSIC);
454
+ const vol = profile.backgroundMusic?.enabled
455
+ ? ` ${profile.backgroundMusic.volume ?? 20}%`.padEnd(COL_VOL)
456
+ : ' — ';
457
+ const pretext = ' ' + (profile.pretext || '(default)').slice(0, COL_PRETEXT - 1);
458
+ return ` ${icon}${name}${voice}${gender}${provider}${reverb}${music}${vol} ${pretext}`;
459
+ });
460
+ }
461
+
462
+ // -------------------------------------------------------------------------
463
+ // Refresh display
464
+
465
+ function refreshDisplay() {
466
+ _bmadDetected = isBmadDetected(_projectRoot);
467
+ _agents = scanBmadAgents(_projectRoot);
468
+
469
+ if (!_bmadDetected) {
470
+ _showOnboardingState();
471
+ screen.render();
472
+ return;
473
+ }
474
+
475
+ _showBmadState();
476
+
477
+ const items = _buildListItems(_agents);
478
+ agentList.setItems(items);
479
+
480
+ if (_listFocused) {
481
+ _hintIdx = -1;
482
+ _hintBase = '';
483
+ _updateHint(agentList.selected ?? 0);
484
+ }
485
+
486
+ screen.render();
487
+ }
488
+
489
+ // -------------------------------------------------------------------------
490
+ // Temporary "Saved!" toast notification
491
+
492
+ function _showSavedToast(agentName) {
493
+ const toast = blessed.box({
494
+ parent: screen,
495
+ top: 'center',
496
+ left: 'center',
497
+ width: 34,
498
+ height: 3,
499
+ border: { type: 'line' },
500
+ tags: true,
501
+ content: ` {green-fg}{bold}✓ ${agentName} saved!{/bold}{/green-fg}`,
502
+ style: { fg: '#e3f2fd', bg: '#1b5e20', border: { fg: '#4caf50' } },
503
+ });
504
+ toast.setFront();
505
+ screen.render();
506
+ setTimeout(() => {
507
+ toast.destroy();
508
+ try {
509
+ for (let r = 0; r < screen.height; r++)
510
+ for (let c = 0; c < screen.width; c++)
511
+ if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
512
+ } catch {}
513
+ screen.render();
514
+ }, 1500);
515
+ }
516
+
517
+ // -------------------------------------------------------------------------
518
+ // Row spinner (animated braille while preview is playing)
519
+
520
+ const _SPIN_PFX = '{bright-cyan-fg}';
521
+ const _SPIN_SFX = '{/bright-cyan-fg}';
522
+ const _SPIN_PFX_TOTAL_LEN = _SPIN_PFX.length + 1 + _SPIN_SFX.length; // tag + 1 frame char + close tag
523
+ const _SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
524
+ let _spinnerInterval = null;
525
+ let _spinnerFrameIdx = 0;
526
+ let _spinnerAgentIdx = -1;
527
+
528
+ // Strip the spinner prefix (tag+frame+close or plain first char) to get the row tail.
529
+ function _stripSpinnerPfx(c) {
530
+ return c.startsWith(_SPIN_PFX) ? c.slice(_SPIN_PFX_TOTAL_LEN) : c.slice(1);
531
+ }
532
+
533
+ function _startSpinner(agentIdx) {
534
+ _stopSpinner();
535
+ _spinnerAgentIdx = agentIdx;
536
+ _spinnerFrameIdx = 0;
537
+ const items = agentList.items;
538
+ const item = items[_spinnerAgentIdx];
539
+ if (item) {
540
+ item.setContent(`${_SPIN_PFX}${_SPIN_FRAMES[0]}${_SPIN_SFX}${_stripSpinnerPfx(item.content ?? ' ')}`);
541
+ screen.render();
542
+ }
543
+ _spinnerInterval = setInterval(() => {
544
+ _spinnerFrameIdx = (_spinnerFrameIdx + 1) % _SPIN_FRAMES.length;
545
+ const it = agentList.items[_spinnerAgentIdx];
546
+ if (!it) return;
547
+ it.setContent(`${_SPIN_PFX}${_SPIN_FRAMES[_spinnerFrameIdx]}${_SPIN_SFX}${_stripSpinnerPfx(it.content ?? ' ')}`);
548
+ screen.render();
549
+ }, 80);
550
+ }
551
+
552
+ function _stopSpinner() {
553
+ if (_spinnerInterval) { clearInterval(_spinnerInterval); _spinnerInterval = null; }
554
+ if (_spinnerAgentIdx >= 0) {
555
+ const item = agentList.items[_spinnerAgentIdx];
556
+ if (item) item.setContent(' ' + _stripSpinnerPfx(item.content ?? ' '));
557
+ _spinnerAgentIdx = -1;
558
+ screen.render();
559
+ }
560
+ }
561
+
562
+ // -------------------------------------------------------------------------
563
+ // Kill any playing preview
564
+
565
+ function _killPreview() {
566
+ _stopSpinner();
567
+ if (_playingProcess) {
568
+ try {
569
+ // On Windows, negative-PID process group kill is unsupported
570
+ if (process.platform === 'win32') {
571
+ _playingProcess.kill();
572
+ } else {
573
+ process.kill(-_playingProcess.pid, 'SIGTERM');
574
+ }
575
+ } catch {}
576
+ _playingProcess = null;
577
+ }
578
+ }
579
+
580
+ // -------------------------------------------------------------------------
581
+ // Sample an agent with their full profile (voice + pretext + reverb + music)
582
+ // Uses play-tts-enhanced.sh for the complete effects pipeline.
583
+
584
+ function _sampleAgent(agent) {
585
+ const profile = voiceStore.getAgentProfile(agent.id);
586
+ const globalCfg = configService.getConfig();
587
+ _sampleWithFullProfile(agent, {
588
+ voice: profile.voice || globalCfg.voice || '',
589
+ pretext: profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title),
590
+ reverbPreset: profile.reverbPreset || globalCfg.effects?.reverbPreset || 'light',
591
+ personality: profile.personality || globalCfg.personality || 'none',
592
+ backgroundMusic: {
593
+ track: profile.backgroundMusic?.track || globalCfg.backgroundMusic?.track || '',
594
+ volume: profile.backgroundMusic?.volume ?? globalCfg.backgroundMusic?.volume ?? 20,
595
+ enabled: profile.backgroundMusic?.enabled ?? globalCfg.backgroundMusic?.enabled ?? false,
596
+ },
597
+ });
598
+ }
599
+
600
+ // -------------------------------------------------------------------------
601
+ // Agent detail panel (modal overlay)
602
+
603
+ function _openAgentDetailPanel(agent) {
604
+ const profile = voiceStore.getAgentProfile(agent.id);
605
+ const globalCfg = configService.getConfig();
606
+
607
+ // Working copy of the profile being edited
608
+ const draft = {
609
+ voice: profile.voice || globalCfg.voice || '',
610
+ pretext: profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title),
611
+ reverbPreset: profile.reverbPreset || globalCfg.effects?.reverbPreset || 'light',
612
+ personality: profile.personality || globalCfg.personality || 'none',
613
+ backgroundMusic: {
614
+ track: profile.backgroundMusic?.track || globalCfg.backgroundMusic?.track || '',
615
+ volume: profile.backgroundMusic?.volume ?? globalCfg.backgroundMusic?.volume ?? 20,
616
+ enabled: profile.backgroundMusic?.enabled ?? globalCfg.backgroundMusic?.enabled ?? false,
617
+ },
618
+ };
619
+
620
+ let _closed = false;
621
+ navigationService?.openModal();
622
+
623
+ const modal = blessed.box({
624
+ parent: screen,
625
+ top: 'center',
626
+ left: 'center',
627
+ width: 72,
628
+ height: 19,
629
+ border: { type: 'line' },
630
+ tags: true,
631
+ label: _modalTitle(`${agent.icon || '🧙'} ${agent.displayName} (${agent.title || 'Agent'})`),
632
+ style: {
633
+ fg: COLORS.labelFg,
634
+ bg: COLORS.contentBg,
635
+ border: { fg: COLORS.btnFocus },
636
+ },
637
+ });
638
+ modal.setFront();
639
+
640
+ // Field definitions
641
+ const FIELDS = [
642
+ { key: 'voice', label: 'Voice', getValue: () => draft.voice || '(global default)' },
643
+ { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(default)' },
644
+ { key: 'reverbPreset', label: 'Reverb', getValue: () => formatReverbState(draft.reverbPreset) },
645
+ { key: 'personality', label: 'Personality', getValue: () => {
646
+ const p = draft.personality;
647
+ const emoji = PERSONALITY_EMOJIS[p] || '';
648
+ return `${emoji} ${p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1)}`;
649
+ }},
650
+ { key: 'musicTrack', label: 'Music Track', getValue: () => {
651
+ if (!draft.backgroundMusic.enabled) return '(disabled)';
652
+ return formatTrackName(draft.backgroundMusic.track) || '(none)';
653
+ }},
654
+ { key: 'musicVol', label: 'Music Vol', getValue: () => {
655
+ if (!draft.backgroundMusic.enabled) return '(disabled)';
656
+ return `${draft.backgroundMusic.volume ?? 20}%`;
657
+ }},
658
+ ];
659
+
660
+ // Build field list items
661
+ function _fieldItems() {
662
+ return FIELDS.map(f => {
663
+ const label = f.label.padEnd(14);
664
+ const val = f.getValue();
665
+ return ` ${label} ${val}`;
666
+ });
667
+ }
668
+
669
+ const fieldList = blessed.list({
670
+ parent: modal,
671
+ top: 1,
672
+ left: 2,
673
+ right: 2,
674
+ height: FIELDS.length + 2,
675
+ keys: true,
676
+ vi: true,
677
+ mouse: true,
678
+ border: { type: 'line' },
679
+ tags: true,
680
+ style: {
681
+ fg: COLORS.labelFg,
682
+ bg: COLORS.contentBg,
683
+ border: { fg: '#4a148c' },
684
+ selected: { bg: 'blue', fg: 'yellow' },
685
+ item: { fg: COLORS.labelFg },
686
+ },
687
+ });
688
+ fieldList.setItems(_fieldItems());
689
+
690
+ // Key hint
691
+ blessed.text({
692
+ parent: modal,
693
+ bottom: 4,
694
+ left: 2,
695
+ right: 2,
696
+ tags: true,
697
+ content: '{white-fg}[↑↓] Navigate [Enter] Edit [Tab] → Preview/Save [Esc] Cancel{/white-fg}',
698
+ style: { bg: COLORS.contentBg },
699
+ });
700
+
701
+ // Buttons
702
+ function _modalBtn(label, leftPos, onClick) {
703
+ const btn = blessed.button({
704
+ parent: modal,
705
+ content: label,
706
+ bottom: 2,
707
+ left: leftPos,
708
+ mouse: true,
709
+ keys: true,
710
+ shrink: true,
711
+ padding: { left: 1, right: 1 },
712
+ style: {
713
+ bg: COLORS.btnDefault,
714
+ fg: 'white',
715
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
716
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
717
+ },
718
+ });
719
+ // Focus indicator handled by attachBtnBlink
720
+ btn.key(['enter', 'space'], () => onClick());
721
+ btn.on('click', () => onClick());
722
+ return btn;
723
+ }
724
+
725
+ const previewBtn = _modalBtn('Preview', 4, () => {
726
+ _sampleAgentWithDraft({ ...agent }, draft, () => {
727
+ btnBlink.stopSpinner(previewBtn);
728
+ });
729
+ btnBlink.startSpinner(previewBtn);
730
+ });
731
+
732
+ const saveBtn = _modalBtn('Save', 18, () => {
733
+ // Only save fields that differ from global
734
+ const toSave = {};
735
+ if (draft.voice && draft.voice !== globalCfg.voice) toSave.voice = draft.voice;
736
+ if (draft.pretext !== AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title)) toSave.pretext = draft.pretext;
737
+ if (draft.reverbPreset !== (globalCfg.effects?.reverbPreset || 'light')) toSave.reverbPreset = draft.reverbPreset;
738
+ if (draft.personality !== (globalCfg.personality || 'none')) toSave.personality = draft.personality;
739
+ if (draft.backgroundMusic.track !== (globalCfg.backgroundMusic?.track || '') ||
740
+ draft.backgroundMusic.volume !== (globalCfg.backgroundMusic?.volume ?? 20) ||
741
+ draft.backgroundMusic.enabled !== (globalCfg.backgroundMusic?.enabled ?? false)) {
742
+ toSave.backgroundMusic = draft.backgroundMusic;
743
+ }
744
+ voiceStore.setAgentProfile(agent.id, toSave);
745
+ _closeModal();
746
+ refreshDisplay();
747
+ // Show temporary "Saved!" toast
748
+ _showSavedToast(agent.displayName);
749
+ });
750
+
751
+ const resetAllBtn = _modalBtn('Reset to Defaults', 26, () => {
752
+ voiceStore.resetAgentProfile(agent.id);
753
+ _closeModal();
754
+ refreshDisplay();
755
+ });
756
+
757
+ const cancelBtn = _modalBtn('Cancel', 50, _closeModal);
758
+
759
+ // Blinking █ cursor + preview spinner — reusable across all modal buttons
760
+ const btnBlink = attachBtnBlink([previewBtn, saveBtn, resetAllBtn, cancelBtn], screen);
761
+
762
+ function _closeModal() {
763
+ if (_closed) return;
764
+ _closed = true;
765
+ _killPreview();
766
+ btnBlink.cleanup();
767
+ navigationService?.closeModal();
768
+ destroyList(modal, screen);
769
+ agentList.focus();
770
+ screen.render();
771
+ }
772
+
773
+ // Field editing via Enter
774
+ fieldList.key(['enter'], () => {
775
+ const idx = fieldList.selected;
776
+ const field = FIELDS[idx];
777
+ if (!field) return;
778
+
779
+ switch (field.key) {
780
+ case 'voice':
781
+ _openVoicePickerForAgent(agent, draft, () => {
782
+ fieldList.setItems(_fieldItems());
783
+ fieldList.select(idx);
784
+ fieldList.focus();
785
+ screen.render();
786
+ });
787
+ break;
788
+
789
+ case 'pretext':
790
+ _openPretextEditor(modal, draft, () => {
791
+ fieldList.setItems(_fieldItems());
792
+ fieldList.select(idx);
793
+ fieldList.focus();
794
+ screen.render();
795
+ });
796
+ break;
797
+
798
+ case 'reverbPreset':
799
+ openReverbPicker(screen, draft.reverbPreset, (val) => {
800
+ draft.reverbPreset = val;
801
+ fieldList.setItems(_fieldItems());
802
+ fieldList.select(idx);
803
+ fieldList.focus();
804
+ screen.render();
805
+ }, () => {
806
+ fieldList.focus();
807
+ screen.render();
808
+ }, { applyToEffectsManager: false });
809
+ break;
810
+
811
+ case 'personality':
812
+ openPersonalityPicker(screen, draft.personality, (val) => {
813
+ draft.personality = val;
814
+ fieldList.setItems(_fieldItems());
815
+ fieldList.select(idx);
816
+ fieldList.focus();
817
+ screen.render();
818
+ }, () => {
819
+ fieldList.focus();
820
+ screen.render();
821
+ });
822
+ break;
823
+
824
+ case 'musicTrack':
825
+ openTrackPicker(screen, draft.backgroundMusic.track, draft.backgroundMusic.volume, (track) => {
826
+ draft.backgroundMusic.track = track;
827
+ draft.backgroundMusic.enabled = true;
828
+ fieldList.setItems(_fieldItems());
829
+ fieldList.select(idx);
830
+ fieldList.focus();
831
+ screen.render();
832
+ }, () => {
833
+ fieldList.focus();
834
+ screen.render();
835
+ }, { skipVolume: true });
836
+ break;
837
+
838
+ case 'musicVol':
839
+ openVolumeInput(screen, draft.backgroundMusic.volume, (volume) => {
840
+ draft.backgroundMusic.volume = volume;
841
+ if (draft.backgroundMusic.track) draft.backgroundMusic.enabled = true;
842
+ fieldList.setItems(_fieldItems());
843
+ fieldList.select(idx);
844
+ fieldList.focus();
845
+ screen.render();
846
+ }, () => {
847
+ fieldList.focus();
848
+ screen.render();
849
+ });
850
+ break;
851
+ }
852
+ });
853
+
854
+ // Space = sample with current draft
855
+ fieldList.key(['space'], () => {
856
+ const draftAgent = { ...agent };
857
+ // Temporarily set profile for sampling
858
+ _sampleAgentWithDraft(draftAgent, draft);
859
+ });
860
+
861
+ // Escape = close
862
+ fieldList.key(['escape', 'q'], _closeModal);
863
+ previewBtn.key(['escape'], _closeModal);
864
+ saveBtn.key(['escape'], _closeModal);
865
+ resetAllBtn.key(['escape'], _closeModal);
866
+ cancelBtn.key(['escape'], _closeModal);
867
+
868
+ // Tab + arrow navigation within modal
869
+ fieldList.key(['tab'], () => { previewBtn.focus(); screen.render(); });
870
+
871
+ // Wrap: down on last field → focus first button; up on first field → focus first button
872
+ // One extra arrow press at boundary moves to button row.
873
+ // Track previous selection so arriving at boundary doesn't immediately jump.
874
+ let _prevFieldSel = 0;
875
+ fieldList.key(['down'], () => {
876
+ const cur = fieldList.selected ?? 0;
877
+ if (cur === FIELDS.length - 1 && _prevFieldSel === FIELDS.length - 1) {
878
+ previewBtn.focus(); screen.render();
879
+ }
880
+ _prevFieldSel = cur;
881
+ });
882
+ fieldList.key(['up'], () => {
883
+ const cur = fieldList.selected ?? 0;
884
+ if (cur === 0 && _prevFieldSel === 0) {
885
+ previewBtn.focus(); screen.render();
886
+ }
887
+ _prevFieldSel = cur;
888
+ });
889
+
890
+ // Wrap: up on buttons → back to field list (last/first field respectively)
891
+ previewBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
892
+ saveBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
893
+ resetAllBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
894
+ cancelBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
895
+
896
+ previewBtn.key(['tab', 'right'], () => { saveBtn.focus(); screen.render(); });
897
+ previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
898
+
899
+ saveBtn.key(['tab', 'right'], () => { resetAllBtn.focus(); screen.render(); });
900
+ saveBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
901
+
902
+ resetAllBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
903
+ resetAllBtn.key(['left'], () => { saveBtn.focus(); screen.render(); });
904
+
905
+ cancelBtn.key(['tab', 'right'], () => { fieldList.focus(); screen.render(); });
906
+ cancelBtn.key(['left'], () => { resetAllBtn.focus(); screen.render(); });
907
+
908
+ fieldList.focus();
909
+ screen.render();
910
+ }
911
+
912
+ // -------------------------------------------------------------------------
913
+ // Voice picker for agent detail panel
914
+
915
+ function _openVoicePickerForAgent(agent, draft, onDone) {
916
+ let _allVoices = [];
917
+ let _filterText = '';
918
+ let _previewProc = null;
919
+ let _previewVoiceId = null;
920
+ let _vpClosed = false;
921
+
922
+ const _spawnEnv = buildAudioEnv();
923
+
924
+ function _killVP() {
925
+ if (_previewProc) {
926
+ try {
927
+ if (process.platform === 'win32') {
928
+ _previewProc.kill();
929
+ } else {
930
+ process.kill(-_previewProc.pid, 'SIGTERM');
931
+ }
932
+ } catch {}
933
+ _previewProc = null;
934
+ }
935
+ _previewVoiceId = null;
936
+ }
937
+
938
+ function _closeVP() {
939
+ if (_vpClosed) return;
940
+ _vpClosed = true;
941
+ _killVP();
942
+ destroyList(vpModal, screen, onDone);
943
+ }
944
+
945
+ const vpModal = blessed.box({
946
+ parent: screen,
947
+ top: '6%',
948
+ left: '3%',
949
+ width: '94%',
950
+ height: '88%',
951
+ border: { type: 'line' },
952
+ tags: true,
953
+ label: _modalTitle(`Select Voice for ${agent.icon || ''} ${agent.displayName}`),
954
+ style: {
955
+ fg: COLORS.labelFg,
956
+ bg: COLORS.contentBg,
957
+ border: { fg: 'bright-cyan' },
958
+ },
959
+ });
960
+ vpModal.setFront();
961
+
962
+ // Search
963
+ blessed.text({
964
+ parent: vpModal, top: 1, left: 2,
965
+ content: 'Search:', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
966
+ });
967
+ const vpSearch = blessed.textbox({
968
+ parent: vpModal, top: 1, left: 11, width: 40, height: 1,
969
+ inputOnFocus: true, keys: true,
970
+ style: { fg: COLORS.valueFg, bg: '#1a3a5c', focus: { bg: '#245a80' } },
971
+ });
972
+
973
+ // Column header
974
+ const COL_N = 28;
975
+ const COL_G = 10;
976
+ blessed.text({
977
+ parent: vpModal, top: 2, left: 6, tags: true,
978
+ content: `{bright-cyan-fg}${'Name'.padEnd(COL_N)}${'Gender'.padEnd(COL_G)}Provider{/bright-cyan-fg}`,
979
+ style: { bg: COLORS.contentBg },
980
+ });
981
+
982
+ const vpList = blessed.list({
983
+ parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
984
+ keys: true, vi: true, mouse: true,
985
+ border: { type: 'line' },
986
+ scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
987
+ style: {
988
+ fg: COLORS.labelFg, bg: COLORS.contentBg,
989
+ border: { fg: COLORS.borderFg },
990
+ selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
991
+ item: { fg: COLORS.labelFg },
992
+ },
993
+ });
994
+
995
+ const vpInfoLine = blessed.text({
996
+ parent: vpModal, bottom: 4, left: 2, right: 2, tags: true,
997
+ content: '', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
998
+ });
999
+
1000
+ const vpPreviewLine = blessed.text({
1001
+ parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
1002
+ content: '', style: { fg: 'bright-cyan', bg: COLORS.contentBg },
1003
+ });
1004
+
1005
+ blessed.text({
1006
+ parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
1007
+ content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [/] Search [Esc] Cancel{/#455a64-fg}',
1008
+ style: { bg: COLORS.contentBg },
1009
+ });
1010
+
1011
+ function _getFiltered() {
1012
+ if (!_filterText) return _allVoices;
1013
+ const f = _filterText.toLowerCase();
1014
+ return _allVoices.filter(v => v.toLowerCase().includes(f));
1015
+ }
1016
+
1017
+ function _buildVoiceItems(voices) {
1018
+ return voices.map(v => {
1019
+ const isActive = v === draft.voice;
1020
+ const isPrev = v === _previewVoiceId;
1021
+ const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
1022
+ const meta = getVoiceMeta(v);
1023
+ const name = meta.displayName.length > COL_N
1024
+ ? meta.displayName.slice(0, COL_N - 1) + '…'
1025
+ : meta.displayName.padEnd(COL_N);
1026
+ return ` ${dot} ${name}${meta.gender.padEnd(COL_G)}${meta.provider}`;
1027
+ });
1028
+ }
1029
+
1030
+ function _refreshVP() {
1031
+ if (_vpClosed) return;
1032
+ const savedIdx = vpList.selected ?? 0;
1033
+ const savedScroll = vpList.childBase ?? 0;
1034
+ _allVoices = scanInstalledVoices();
1035
+ const filtered = _getFiltered();
1036
+ const items = _buildVoiceItems(filtered);
1037
+ vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
1038
+ vpList.select(Math.min(savedIdx, items.length - 1));
1039
+ vpList.childBase = Math.min(savedScroll, Math.max(0, items.length - (vpList.height - 2)));
1040
+ screen.render();
1041
+ }
1042
+
1043
+ function _previewVoice(voiceId) {
1044
+ if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); screen.render(); return; }
1045
+ _killVP();
1046
+
1047
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1048
+
1049
+ const _ms = parseMultiSpeaker(voiceId);
1050
+ const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
1051
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
1052
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
1053
+
1054
+ const tempWav = _secureTempWav('vp');
1055
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1056
+
1057
+ // Resolve piper binary (on Windows, find piper.exe)
1058
+ let _piperBin = 'piper';
1059
+ if (_isWin) {
1060
+ const _lad = process.env.LOCALAPPDATA ||
1061
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
1062
+ if (_lad) {
1063
+ const _ep = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
1064
+ if (fs.existsSync(_ep)) _piperBin = _ep;
1065
+ }
1066
+ }
1067
+
1068
+ const args = ['--model', voicePath, '--output_file', tempWav];
1069
+ if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
1070
+ const piper = spawn(_piperBin, args, {
1071
+ stdio: ['pipe', 'ignore', 'ignore'],
1072
+ detached: !_isWin,
1073
+ windowsHide: true,
1074
+ env: _spawnEnv,
1075
+ });
1076
+ piper.stdin.write(phrase + '\n');
1077
+ piper.stdin.end();
1078
+ _previewProc = piper;
1079
+ _previewVoiceId = voiceId;
1080
+
1081
+ if (!_vpClosed) {
1082
+ vpPreviewLine.setContent(`{bright-cyan-fg}♪ Synthesizing: ${voiceId}...{/bright-cyan-fg}`);
1083
+ screen.render();
1084
+ }
1085
+
1086
+ piper.on('exit', (code) => {
1087
+ if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
1088
+ if (code !== 0) { _previewProc = null; _previewVoiceId = null; try { fs.unlinkSync(tempWav); } catch {} return; }
1089
+ const wp = detectWavPlayer(_spawnEnv);
1090
+ if (!wp) return;
1091
+ const pp = spawn(wp.bin, wp.args(tempWav), {
1092
+ stdio: 'ignore',
1093
+ detached: !_isWin,
1094
+ windowsHide: true,
1095
+ env: _spawnEnv,
1096
+ });
1097
+ _previewProc = pp;
1098
+ if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}{/bright-cyan-fg}`); screen.render(); }
1099
+ pp.on('exit', () => {
1100
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); screen.render(); } }
1101
+ try { fs.unlinkSync(tempWav); } catch {}
1102
+ });
1103
+ });
1104
+ piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1105
+ }
1106
+
1107
+ vpSearch.on('keypress', () => {
1108
+ setTimeout(() => { _filterText = vpSearch.getValue().trim(); _refreshVP(); }, 0);
1109
+ });
1110
+ vpSearch.key(['escape'], () => { vpList.focus(); screen.render(); });
1111
+ vpList.key(['/'], () => { vpSearch.clearValue(); vpSearch.focus(); screen.render(); });
1112
+ vpList.key(['enter'], () => {
1113
+ const filtered = _getFiltered();
1114
+ const sel = filtered[vpList.selected];
1115
+ if (sel) { draft.voice = sel; _closeVP(); }
1116
+ });
1117
+ vpList.key(['space'], () => {
1118
+ const filtered = _getFiltered();
1119
+ const sel = filtered[vpList.selected];
1120
+ if (sel) _previewVoice(sel);
1121
+ });
1122
+ vpList.key(['escape', 'q'], _closeVP);
1123
+
1124
+ _refreshVP();
1125
+ const activeIdx = _getFiltered().indexOf(draft.voice);
1126
+ if (activeIdx >= 0) vpList.select(activeIdx);
1127
+ vpList.focus();
1128
+ screen.render();
1129
+ }
1130
+
1131
+ // -------------------------------------------------------------------------
1132
+ // Pretext inline editor
1133
+
1134
+ function _openPretextEditor(parentModal, draft, onDone) {
1135
+ const editModal = blessed.box({
1136
+ parent: screen,
1137
+ top: 'center',
1138
+ left: 'center',
1139
+ width: 60,
1140
+ height: 8,
1141
+ border: { type: 'line' },
1142
+ tags: true,
1143
+ label: _modalTitle('Edit Pretext'),
1144
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'bright-cyan' } },
1145
+ });
1146
+ editModal.setFront();
1147
+
1148
+ blessed.text({
1149
+ parent: editModal, top: 1, left: 2,
1150
+ content: 'Agent pretext (spoken before each TTS message):',
1151
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1152
+ });
1153
+
1154
+ const inputBox = blessed.textbox({
1155
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
1156
+ border: { type: 'line' },
1157
+ inputOnFocus: true,
1158
+ value: draft.pretext,
1159
+ style: {
1160
+ fg: COLORS.valueFg, bg: '#0d1b35',
1161
+ border: { fg: COLORS.borderFg },
1162
+ focus: { border: { fg: 'bright-cyan' } },
1163
+ },
1164
+ });
1165
+
1166
+ let _editClosed = false;
1167
+ function _closeEdit(save) {
1168
+ if (_editClosed) return;
1169
+ _editClosed = true;
1170
+ if (save) {
1171
+ const raw = inputBox.getValue().trim();
1172
+ // M7: enforce max pretext length
1173
+ draft.pretext = (raw || draft.pretext).slice(0, MAX_PRETEXT_LENGTH);
1174
+ }
1175
+ destroyList(editModal, screen, onDone);
1176
+ }
1177
+
1178
+ inputBox.key(['enter'], () => _closeEdit(true));
1179
+ inputBox.key(['escape'], () => _closeEdit(false));
1180
+
1181
+ inputBox.focus();
1182
+ screen.render();
1183
+ }
1184
+
1185
+ // -------------------------------------------------------------------------
1186
+ // Sample agent with a draft profile (no save) — same full pipeline
1187
+
1188
+ function _sampleAgentWithDraft(agent, draft, onComplete) {
1189
+ _sampleWithFullProfile(agent, draft, onComplete);
1190
+ }
1191
+
1192
+ // -------------------------------------------------------------------------
1193
+ // Shared: sample with full profile via play-tts-enhanced.sh
1194
+ // Writes a temp agent profile JSON, then calls the enhanced TTS pipeline
1195
+ // which applies voice + reverb + background music.
1196
+
1197
+ function _sampleWithFullProfile(agent, profile, onComplete) {
1198
+ _killPreview();
1199
+ const gen = ++_playGeneration;
1200
+
1201
+ // Start spinner on the agent's row in the list
1202
+ const agentIdx = _agents.findIndex(a => a.id === agent.id);
1203
+ if (agentIdx >= 0) _startSpinner(agentIdx);
1204
+
1205
+ const voiceId = profile.voice || '';
1206
+ const pretext = profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title);
1207
+ const phrase = `${pretext} ${SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]}`;
1208
+
1209
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1210
+
1211
+ if (isWindows) {
1212
+ // On Windows, call play-tts.ps1 via PowerShell with draft settings patched
1213
+ // in so reverb, background music, and personality are applied.
1214
+ _sampleWithFullEffectsWindows(gen, agent, profile, phrase, onComplete);
1215
+ } else {
1216
+ // On Linux/macOS/WSL, use play-tts.sh
1217
+ const _spawnEnv = buildAudioEnv();
1218
+ const scriptDir = path.join(_projectRoot, '.claude', 'hooks');
1219
+ const plainScript = path.join(scriptDir, 'play-tts.sh');
1220
+ const args = [plainScript, phrase];
1221
+ if (voiceId) args.push(voiceId);
1222
+
1223
+ const proc = spawn('bash', args, {
1224
+ stdio: ['ignore', 'ignore', 'ignore'],
1225
+ detached: true,
1226
+ env: { ..._spawnEnv },
1227
+ cwd: _projectRoot,
1228
+ });
1229
+ _playingProcess = proc;
1230
+
1231
+ proc.on('exit', () => {
1232
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1233
+ });
1234
+ proc.on('error', () => {
1235
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1236
+ });
1237
+ }
1238
+ }
1239
+
1240
+ /** Windows-native sample: piper.exe → wav → detectWavPlayer */
1241
+ function _sampleWithPiperDirect(gen, voiceId, phrase) {
1242
+ const _spawnEnv = buildAudioEnv();
1243
+
1244
+ // Resolve piper binary
1245
+ let piperBin = 'piper';
1246
+ const localAppData = process.env.LOCALAPPDATA ||
1247
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
1248
+ if (localAppData) {
1249
+ const exePath = path.join(localAppData, 'Programs', 'Piper', 'piper.exe');
1250
+ if (fs.existsSync(exePath)) piperBin = exePath;
1251
+ }
1252
+
1253
+ // Resolve voice model path
1254
+ const ms = parseMultiSpeaker(voiceId);
1255
+ const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
1256
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
1257
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
1258
+ _stopSpinner();
1259
+ return;
1260
+ }
1261
+
1262
+ const tempWav = path.join(os.tmpdir(), `agentvibes-agent-preview-${Date.now()}.wav`);
1263
+ const piperArgs = ['--model', voicePath, '--output_file', tempWav];
1264
+ if (ms.speakerId != null) piperArgs.push('--speaker', String(ms.speakerId));
1265
+
1266
+ const piper = spawn(piperBin, piperArgs, {
1267
+ stdio: ['pipe', 'ignore', 'ignore'],
1268
+ detached: false,
1269
+ windowsHide: true,
1270
+ env: _spawnEnv,
1271
+ });
1272
+ piper.stdin.write(phrase + '\n');
1273
+ piper.stdin.end();
1274
+ _playingProcess = piper;
1275
+
1276
+ piper.on('exit', (code) => {
1277
+ if (gen !== _playGeneration) {
1278
+ try { fs.unlinkSync(tempWav); } catch {}
1279
+ return;
1280
+ }
1281
+ if (code !== 0) {
1282
+ _playingProcess = null;
1283
+ _stopSpinner();
1284
+ try { fs.unlinkSync(tempWav); } catch {}
1285
+ return;
1286
+ }
1287
+
1288
+ // Play the synthesized wav
1289
+ const wavPlayer = detectWavPlayer(_spawnEnv);
1290
+ if (!wavPlayer) {
1291
+ _playingProcess = null;
1292
+ _stopSpinner();
1293
+ try { fs.unlinkSync(tempWav); } catch {}
1294
+ return;
1295
+ }
1296
+ const playProc = spawn(wavPlayer.bin, wavPlayer.args(tempWav), {
1297
+ stdio: 'ignore',
1298
+ detached: false,
1299
+ windowsHide: true,
1300
+ env: _spawnEnv,
1301
+ });
1302
+ _playingProcess = playProc;
1303
+
1304
+ playProc.on('exit', () => {
1305
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1306
+ try { fs.unlinkSync(tempWav); } catch {}
1307
+ });
1308
+ playProc.on('error', () => {
1309
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1310
+ try { fs.unlinkSync(tempWav); } catch {}
1311
+ });
1312
+ });
1313
+
1314
+ piper.on('error', () => {
1315
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1316
+ });
1317
+ }
1318
+
1319
+ /** Windows full-effects preview: temporarily patches config files then calls play-tts.ps1 */
1320
+ function _sampleWithFullEffectsWindows(gen, agent, profile, phrase, onComplete) {
1321
+ const _spawnEnv = buildAudioEnv();
1322
+ const homeDir = process.env.USERPROFILE || os.homedir();
1323
+ // Use project-local .claude if it exists, else global ~/.claude
1324
+ const claudeDir = fs.existsSync(path.join(_projectRoot, '.claude'))
1325
+ ? path.join(_projectRoot, '.claude')
1326
+ : path.join(homeDir, '.claude');
1327
+ const configDir = path.join(claudeDir, 'config');
1328
+ const hooksDir = path.join(claudeDir, 'hooks-windows');
1329
+ const playTts = path.join(hooksDir, 'play-tts.ps1');
1330
+ if (!fs.existsSync(playTts)) { _sampleWithPiperDirect(gen, profile.voice || '', phrase); return; }
1331
+
1332
+ // Files to temporarily patch
1333
+ const personalityFile = path.join(configDir, 'personality.txt');
1334
+ const reverbFile = path.join(configDir, 'reverb-level.txt');
1335
+ const bgEnabledFile = path.join(configDir, 'background-music-enabled.txt');
1336
+ const audioEffectsCfg = path.join(configDir, 'audio-effects.cfg');
1337
+
1338
+ // Save originals
1339
+ const _read = f => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
1340
+ const origPersonality = _read(personalityFile);
1341
+ const origReverb = _read(reverbFile);
1342
+ const origBgEnabled = _read(bgEnabledFile);
1343
+ const origAudioEffects = _read(audioEffectsCfg);
1344
+
1345
+ const bgMusic = profile.backgroundMusic;
1346
+ let tempCfgLine = '';
1347
+
1348
+ try {
1349
+ if (profile.personality && profile.personality !== 'none')
1350
+ fs.writeFileSync(personalityFile, profile.personality);
1351
+ if (profile.reverbPreset)
1352
+ fs.writeFileSync(reverbFile, profile.reverbPreset);
1353
+ if (bgMusic?.enabled && bgMusic?.track) {
1354
+ fs.writeFileSync(bgEnabledFile, 'true');
1355
+ const vol = ((bgMusic.volume ?? 20) / 100).toFixed(2);
1356
+ tempCfgLine = `${agent.id}||${bgMusic.track}|${vol}`;
1357
+ fs.writeFileSync(audioEffectsCfg, `${tempCfgLine}\n${origAudioEffects || ''}`);
1358
+ }
1359
+ } catch { /* degrade gracefully */ }
1360
+
1361
+ const voiceId = profile.voice || '';
1362
+ const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase];
1363
+ if (voiceId) psArgs.push(voiceId);
1364
+
1365
+ const proc = spawn('powershell', psArgs, {
1366
+ stdio: 'ignore', detached: false, windowsHide: true,
1367
+ // CLAUDE_PROJECT_DIR tells play-tts.ps1 to use the project's .claude/config
1368
+ // rather than falling back to ~/.claude where our patches don't exist.
1369
+ env: { ..._spawnEnv, AGENTVIBES_AGENT_NAME: agent.id, CLAUDE_PROJECT_DIR: _projectRoot },
1370
+ });
1371
+ _playingProcess = proc;
1372
+
1373
+ function _restore() {
1374
+ try {
1375
+ if (origPersonality !== null) fs.writeFileSync(personalityFile, origPersonality);
1376
+ else try { fs.unlinkSync(personalityFile); } catch {}
1377
+ if (origReverb !== null) fs.writeFileSync(reverbFile, origReverb);
1378
+ if (bgMusic?.enabled && bgMusic?.track) {
1379
+ if (origBgEnabled !== null) fs.writeFileSync(bgEnabledFile, origBgEnabled);
1380
+ else try { fs.unlinkSync(bgEnabledFile); } catch {}
1381
+ if (origAudioEffects !== null) fs.writeFileSync(audioEffectsCfg, origAudioEffects);
1382
+ }
1383
+ } catch {}
1384
+ }
1385
+
1386
+ proc.on('exit', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1387
+ proc.on('error', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1388
+ }
1389
+
1390
+ // -------------------------------------------------------------------------
1391
+ // Auto-assign helpers
1392
+
1393
+ function _shuffleArray(arr) {
1394
+ const a = [...arr];
1395
+ for (let i = a.length - 1; i > 0; i--) {
1396
+ const j = Math.floor(Math.random() * (i + 1));
1397
+ [a[i], a[j]] = [a[j], a[i]];
1398
+ }
1399
+ return a;
1400
+ }
1401
+
1402
+ // Common first-name gender map for gender-aware auto-assign.
1403
+ // Only needs to cover names likely used as BMAD agent display names.
1404
+ const _NAME_GENDER = {
1405
+ // Female
1406
+ amelia: 'Female', amy: 'Female', anna: 'Female', betty: 'Female',
1407
+ claire: 'Female', dana: 'Female', emma: 'Female', faye: 'Female',
1408
+ grace: 'Female', heather: 'Female', ivy: 'Female', jane: 'Female',
1409
+ jenny: 'Female', julia: 'Female', kate: 'Female', laura: 'Female',
1410
+ lily: 'Female', maria: 'Female', mary: 'Female', nina: 'Female',
1411
+ olivia: 'Female', paige: 'Female', quinn: 'Female', rachel: 'Female',
1412
+ sally: 'Female', sara: 'Female', sarah: 'Female', sophie: 'Female',
1413
+ tina: 'Female', wendy: 'Female', zoe: 'Female',
1414
+ freya: 'Female', saga: 'Female',
1415
+ // Male
1416
+ alan: 'Male', barry: 'Male', bob: 'Male', carl: 'Male',
1417
+ charlie: 'Male', dan: 'Male', david: 'Male', eric: 'Male',
1418
+ frank: 'Male', george: 'Male', hank: 'Male', jack: 'Male',
1419
+ james: 'Male', joe: 'Male', john: 'Male', kevin: 'Male',
1420
+ leo: 'Male', mark: 'Male', max: 'Male', murat: 'Male',
1421
+ nick: 'Male', oscar: 'Male', paul: 'Male', ray: 'Male',
1422
+ ryan: 'Male', saif: 'Male', sam: 'Male', steve: 'Male',
1423
+ tom: 'Male', victor: 'Male', winston: 'Male', zach: 'Male',
1424
+ };
1425
+
1426
+ /** Infer agent gender from display name (first word). */
1427
+ function _inferAgentGender(agent) {
1428
+ const firstName = (agent.displayName || '').split(/[\s(]/)[0].toLowerCase();
1429
+ return _NAME_GENDER[firstName] || null;
1430
+ }
1431
+
1432
+ function _autoAssignVoices() {
1433
+ const installed = scanInstalledVoices();
1434
+ if (installed.length === 0) return false;
1435
+
1436
+ // Separate voices by gender
1437
+ const femaleVoices = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Female'));
1438
+ const maleVoices = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Male'));
1439
+ const otherVoices = _shuffleArray(installed.filter(v => !['Male', 'Female'].includes(getVoiceMeta(v).gender)));
1440
+
1441
+ // Separate agents by gender
1442
+ const femaleAgents = _agents.filter(a => _inferAgentGender(a) === 'Female');
1443
+ const maleAgents = _agents.filter(a => _inferAgentGender(a) === 'Male');
1444
+ const otherAgents = _agents.filter(a => !_inferAgentGender(a));
1445
+
1446
+ const usedVoices = new Set();
1447
+
1448
+ // Assign matching-gender voices first, then fall back to any available
1449
+ function assignGroup(agents, preferredPool, fallbackPools) {
1450
+ const allPools = [preferredPool, ...fallbackPools];
1451
+ agents.forEach(agent => {
1452
+ let voice = null;
1453
+ for (const pool of allPools) {
1454
+ voice = pool.find(v => !usedVoices.has(v));
1455
+ if (voice) break;
1456
+ }
1457
+ // If all unique voices exhausted, reuse from preferred pool
1458
+ if (!voice && preferredPool.length > 0) {
1459
+ voice = preferredPool[usedVoices.size % preferredPool.length];
1460
+ }
1461
+ if (voice) {
1462
+ usedVoices.add(voice);
1463
+ voiceStore.setAgentProfile(agent.id, { voice });
1464
+ }
1465
+ });
1466
+ }
1467
+
1468
+ assignGroup(femaleAgents, femaleVoices, [otherVoices, maleVoices]);
1469
+ assignGroup(maleAgents, maleVoices, [otherVoices, femaleVoices]);
1470
+ assignGroup(otherAgents, otherVoices, [maleVoices, femaleVoices]);
1471
+
1472
+ return true;
1473
+ }
1474
+
1475
+ function _autoAssignMusic() {
1476
+ const tracksDir = path.join(_projectRoot, '.claude', 'audio', 'tracks');
1477
+ let tracks = [];
1478
+ try {
1479
+ tracks = fs.readdirSync(tracksDir).filter(f => /\.mp3$/i.test(f));
1480
+ } catch { /* no tracks dir */ }
1481
+ if (tracks.length === 0) return false;
1482
+
1483
+ const shuffled = _shuffleArray(tracks);
1484
+ _agents.forEach((agent, i) => {
1485
+ const track = shuffled[i % shuffled.length];
1486
+ const existing = voiceStore.getAgentProfile(agent.id);
1487
+ voiceStore.setAgentProfile(agent.id, {
1488
+ backgroundMusic: { track, volume: existing.backgroundMusic?.volume ?? 20, enabled: true },
1489
+ });
1490
+ });
1491
+ return true;
1492
+ }
1493
+
1494
+ function _autoAssignAll() {
1495
+ if (_agents.length === 0) return;
1496
+ const voiceOk = _autoAssignVoices();
1497
+ const musicOk = _autoAssignMusic();
1498
+ refreshDisplay();
1499
+ const msg = voiceOk && musicOk ? 'Voices and music auto-assigned'
1500
+ : voiceOk ? 'Voices auto-assigned' : 'Auto-assign: no voices found';
1501
+ _showSavedToast(msg);
1502
+ }
1503
+
1504
+ // -------------------------------------------------------------------------
1505
+ // Bulk edit menu
1506
+
1507
+ function _openBulkEditMenu() {
1508
+ const BULK_ACTIONS = [
1509
+ { label: ' Randomize Voices (gender-aware)', key: 'voices' },
1510
+ { label: ' Randomize Music (unique per agent)', key: 'music' },
1511
+ { label: ' Randomize Both', key: 'both' },
1512
+ { label: ' Set Same Music for All Agents...', key: 'setMusic' },
1513
+ { label: ' Set Same Volume for All Agents...', key: 'setVolume' },
1514
+ { label: ' Set Same Pretext for All Agents...', key: 'setPretext' },
1515
+ { label: ' Set Same Reverb for All Agents...', key: 'setReverb' },
1516
+ { label: ' Reset All Agent Profiles', key: 'resetAll' },
1517
+ ];
1518
+
1519
+ const menuList = blessed.list({
1520
+ parent: screen,
1521
+ top: 'center',
1522
+ left: 'center',
1523
+ width: 52,
1524
+ height: BULK_ACTIONS.length + 4,
1525
+ border: { type: 'line' },
1526
+ tags: true,
1527
+ label: _modalTitle('Bulk Edit'),
1528
+ keys: true,
1529
+ vi: true,
1530
+ mouse: true,
1531
+ items: BULK_ACTIONS.map(a => a.label),
1532
+ style: {
1533
+ fg: COLORS.labelFg,
1534
+ bg: COLORS.contentBg,
1535
+ border: { fg: COLORS.btnFocus },
1536
+ selected: { bg: 'blue', fg: 'yellow' },
1537
+ item: { fg: COLORS.labelFg },
1538
+ },
1539
+ });
1540
+
1541
+ blessed.text({
1542
+ parent: menuList,
1543
+ bottom: -1,
1544
+ left: 1,
1545
+ width: 48,
1546
+ height: 1,
1547
+ tags: true,
1548
+ content: '{#455a64-fg}[Enter] Select [Esc] Cancel{/#455a64-fg}',
1549
+ style: { bg: COLORS.contentBg },
1550
+ });
1551
+
1552
+ menuList.setFront();
1553
+ menuList.focus();
1554
+ screen.render();
1555
+
1556
+ let _menuClosed = false;
1557
+ function _closeMenu(callback) {
1558
+ if (_menuClosed) return;
1559
+ _menuClosed = true;
1560
+ destroyList(menuList, screen, callback);
1561
+ }
1562
+
1563
+ menuList.key(['enter'], () => {
1564
+ const action = BULK_ACTIONS[menuList.selected];
1565
+ if (!action) return;
1566
+
1567
+ switch (action.key) {
1568
+ case 'voices':
1569
+ _closeMenu(() => {
1570
+ if (_autoAssignVoices()) { refreshDisplay(); _showSavedToast('Voices randomized'); }
1571
+ });
1572
+ break;
1573
+
1574
+ case 'music':
1575
+ _closeMenu(() => {
1576
+ if (_autoAssignMusic()) { refreshDisplay(); _showSavedToast('Music randomized'); }
1577
+ });
1578
+ break;
1579
+
1580
+ case 'both':
1581
+ _closeMenu(() => { _autoAssignAll(); });
1582
+ break;
1583
+
1584
+ case 'setMusic':
1585
+ _closeMenu(() => {
1586
+ openTrackPicker(screen, '', 20, (track, volume) => {
1587
+ _agents.forEach(agent => {
1588
+ const p = voiceStore.getAgentProfile(agent.id);
1589
+ voiceStore.setAgentProfile(agent.id, {
1590
+ backgroundMusic: { track, volume, enabled: true },
1591
+ });
1592
+ });
1593
+ refreshDisplay();
1594
+ _showSavedToast('Music set for all agents');
1595
+ agentList.focus();
1596
+ }, () => { agentList.focus(); screen.render(); });
1597
+ });
1598
+ break;
1599
+
1600
+ case 'setVolume':
1601
+ _closeMenu(() => {
1602
+ const curVol = voiceStore.getAgentProfile(_agents[0]?.id)?.backgroundMusic?.volume ?? 20;
1603
+ openVolumeInput(screen, curVol, (volume) => {
1604
+ _agents.forEach(agent => {
1605
+ const p = voiceStore.getAgentProfile(agent.id);
1606
+ const bm = p.backgroundMusic || {};
1607
+ voiceStore.setAgentProfile(agent.id, {
1608
+ backgroundMusic: { ...bm, volume },
1609
+ });
1610
+ });
1611
+ refreshDisplay();
1612
+ _showSavedToast(`Volume set to ${volume}% for all agents`);
1613
+ agentList.focus();
1614
+ }, () => { agentList.focus(); screen.render(); });
1615
+ });
1616
+ break;
1617
+
1618
+ case 'setPretext':
1619
+ _closeMenu(() => { _openBulkPretextEditor(); });
1620
+ break;
1621
+
1622
+ case 'setReverb':
1623
+ _closeMenu(() => {
1624
+ openReverbPicker(screen, '', (val) => {
1625
+ _agents.forEach(agent => voiceStore.setAgentProfile(agent.id, { reverbPreset: val }));
1626
+ refreshDisplay();
1627
+ _showSavedToast('Reverb set for all agents');
1628
+ agentList.focus();
1629
+ }, () => { agentList.focus(); screen.render(); }, { applyToEffectsManager: false });
1630
+ });
1631
+ break;
1632
+
1633
+ case 'resetAll':
1634
+ _closeMenu(() => {
1635
+ _agents.forEach(agent => voiceStore.resetAgentProfile(agent.id));
1636
+ refreshDisplay();
1637
+ _showSavedToast('All profiles reset');
1638
+ });
1639
+ break;
1640
+ }
1641
+ });
1642
+
1643
+ menuList.key(['escape', 'q'], () => {
1644
+ _closeMenu(() => { agentList.focus(); screen.render(); });
1645
+ });
1646
+ }
1647
+
1648
+ function _openBulkPretextEditor() {
1649
+ const editModal = blessed.box({
1650
+ parent: screen,
1651
+ top: 'center',
1652
+ left: 'center',
1653
+ width: 60,
1654
+ height: 9,
1655
+ border: { type: 'line' },
1656
+ tags: true,
1657
+ label: _modalTitle('Set Pretext for All Agents'),
1658
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'bright-cyan' } },
1659
+ });
1660
+ editModal.setFront();
1661
+
1662
+ blessed.text({
1663
+ parent: editModal, top: 1, left: 2,
1664
+ content: 'Pretext to apply to all agents (leave empty to clear):',
1665
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1666
+ });
1667
+
1668
+ const inputBox = blessed.textbox({
1669
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
1670
+ border: { type: 'line' },
1671
+ inputOnFocus: true,
1672
+ style: {
1673
+ fg: COLORS.valueFg, bg: '#0d1b35',
1674
+ border: { fg: COLORS.borderFg },
1675
+ focus: { border: { fg: 'bright-cyan' } },
1676
+ },
1677
+ });
1678
+
1679
+ blessed.text({
1680
+ parent: editModal, bottom: 1, left: 2, tags: true,
1681
+ content: '{#455a64-fg}[Enter] Apply to all [Esc] Cancel{/#455a64-fg}',
1682
+ style: { bg: COLORS.contentBg },
1683
+ });
1684
+
1685
+ let _closed = false;
1686
+ function _close(save) {
1687
+ if (_closed) return;
1688
+ _closed = true;
1689
+ if (save) {
1690
+ const raw = inputBox.getValue().trim().slice(0, MAX_PRETEXT_LENGTH);
1691
+ _agents.forEach(agent => {
1692
+ if (raw) {
1693
+ voiceStore.setAgentProfile(agent.id, { pretext: raw });
1694
+ } else {
1695
+ const p = voiceStore.getAgentProfile(agent.id);
1696
+ const { pretext: _removed, ...rest } = p;
1697
+ voiceStore.resetAgentProfile(agent.id);
1698
+ if (Object.keys(rest).length > 0) voiceStore.setAgentProfile(agent.id, rest);
1699
+ }
1700
+ });
1701
+ refreshDisplay();
1702
+ _showSavedToast('Pretext set for all agents');
1703
+ }
1704
+ destroyList(editModal, screen, () => { agentList.focus(); screen.render(); });
1705
+ }
1706
+
1707
+ inputBox.key(['enter'], () => _close(true));
1708
+ inputBox.key(['escape'], () => _close(false));
1709
+ inputBox.focus();
1710
+ screen.render();
1711
+ }
1712
+
1713
+ // -------------------------------------------------------------------------
1714
+ // Key bindings
1715
+
1716
+ agentList.key(['x', 'X'], () => {
1717
+ const agent = _agents[agentList.selected ?? 0];
1718
+ if (agent) {
1719
+ voiceStore.resetAgentProfile(agent.id);
1720
+ refreshDisplay();
1721
+ }
1722
+ });
1723
+
1724
+
1725
+ agentList.key(['enter'], () => {
1726
+ const agent = _agents[agentList.selected ?? 0];
1727
+ if (agent) _openAgentDetailPanel(agent);
1728
+ });
1729
+
1730
+ agentList.key(['space'], () => {
1731
+ const agent = _agents[agentList.selected ?? 0];
1732
+ if (agent) _sampleAgent(agent);
1733
+ });
1734
+
1735
+ agentList.key(['a', 'A'], () => { _autoAssignAll(); });
1736
+ agentList.key(['b', 'B'], () => { _openBulkEditMenu(); });
1737
+
1738
+ // Type-to-jump
1739
+ const _agentJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'x', 'a', 'b']);
1740
+ agentList.on('keypress', (ch, key) => {
1741
+ if (!ch || key.ctrl || key.meta) return;
1742
+ const lower = ch.toLowerCase();
1743
+ if (!/^[a-z]$/.test(lower)) return;
1744
+ if (_agentJumpBlocked.has(lower)) return;
1745
+ const count = _agents.length;
1746
+ if (count === 0) return;
1747
+ const start = agentList.selected ?? 0;
1748
+ for (let i = 1; i <= count; i++) {
1749
+ const idx = (start + i) % count;
1750
+ const name = (_agents[idx]?.displayName ?? '').toLowerCase();
1751
+ if (name.startsWith(lower)) {
1752
+ agentList.select(idx);
1753
+ screen.render();
1754
+ break;
1755
+ }
1756
+ }
1757
+ });
1758
+
1759
+ // Inline row hint (appended to selected row while list is focused)
1760
+ let _listFocused = false;
1761
+ let _hintIdx = -1;
1762
+ let _hintBase = ''; // row content before hint was appended (no hint, no █)
1763
+
1764
+ function _updateHint(idx) {
1765
+ const items = agentList.items;
1766
+ // Pad with spaces to overwrite ghost hint text from previous render
1767
+ const _pad = ' '.repeat(60);
1768
+ if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
1769
+ const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
1770
+ items[_hintIdx].setContent((hadBlink ? _hintBase + ' █' : _hintBase) + _pad);
1771
+ }
1772
+ if (idx >= 0 && items[idx]) {
1773
+ let c = items[idx].content ?? '';
1774
+ const hasBlink = c.endsWith(' █');
1775
+ if (hasBlink) c = c.slice(0, -3);
1776
+ _hintBase = c;
1777
+ items[idx].setContent(c + _ROW_HINT_BMAD + (hasBlink ? ' █' : ''));
1778
+ } else {
1779
+ _hintBase = '';
1780
+ }
1781
+ _hintIdx = idx;
1782
+ }
1783
+
1784
+ // Blinking cursor
1785
+ let _alBlink = { interval: null, on: false, sel: -1 };
1786
+ function _alTick() {
1787
+ _alBlink.on = !_alBlink.on;
1788
+ const items = agentList.items;
1789
+ const cur = agentList.selected ?? 0;
1790
+ if (_alBlink.sel !== cur && _alBlink.sel >= 0 && items[_alBlink.sel]) {
1791
+ items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '').replace(/ █$/, ''));
1792
+ }
1793
+ _alBlink.sel = cur;
1794
+ if (items[cur]) {
1795
+ const base = (items[cur].content ?? '').replace(/ █$/, '');
1796
+ items[cur].setContent(_alBlink.on ? `${base} █` : base);
1797
+ }
1798
+ screen.render();
1799
+ }
1800
+ agentList.on('focus', () => {
1801
+ _listFocused = true;
1802
+ _alBlink.on = true;
1803
+ _alBlink.sel = agentList.selected ?? 0;
1804
+ _hintIdx = -1;
1805
+ _hintBase = '';
1806
+ _updateHint(_alBlink.sel);
1807
+ const items = agentList.items;
1808
+ if (items[_alBlink.sel]) items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '') + ' █');
1809
+ screen.render();
1810
+ _alBlink.interval = setInterval(_alTick, 500);
1811
+ });
1812
+ agentList.on('blur', () => {
1813
+ _listFocused = false;
1814
+ if (_alBlink.interval) { clearInterval(_alBlink.interval); _alBlink.interval = null; }
1815
+ const items = agentList.items;
1816
+ const sel = agentList.selected ?? 0;
1817
+ if (items[sel]) {
1818
+ items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1819
+ }
1820
+ if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1821
+ items[_hintIdx].setContent(_hintBase);
1822
+ }
1823
+ _hintIdx = -1;
1824
+ _hintBase = '';
1825
+ screen.render();
1826
+ });
1827
+ agentList.on('select item', () => {
1828
+ _updateHint(agentList.selected ?? 0);
1829
+ if (_alBlink.interval) _alTick();
1830
+ });
1831
+
1832
+ // Navigation: up at top → tab bar, escape → tab bar
1833
+ // Track previous selection so arriving at index 0 doesn't immediately jump
1834
+ let _prevAgentSel = -1;
1835
+ agentList.key(['up'], () => {
1836
+ const cur = agentList.selected ?? 0;
1837
+ if (cur === 0 && _prevAgentSel === 0 && typeof focusMainTabBar === 'function') {
1838
+ focusMainTabBar();
1839
+ setTimeout(() => { agentList.select(0); screen.render(); }, 0);
1840
+ }
1841
+ _prevAgentSel = cur;
1842
+ });
1843
+ agentList.key(['escape'], () => {
1844
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1845
+ });
1846
+
1847
+ // -------------------------------------------------------------------------
1848
+ // Language change handler
1849
+
1850
+ if (languageService) {
1851
+ languageService.onChange(() => {
1852
+ onboardingBox.setContent(_buildOnboardingText());
1853
+ screen.render();
1854
+ });
1855
+ }
1856
+
1857
+ // -------------------------------------------------------------------------
1858
+ // Tab Component Contract
1859
+
1860
+ return {
1861
+ box,
1862
+
1863
+ show() {
1864
+ box.show();
1865
+ refreshDisplay();
1866
+ screen.render();
1867
+ },
1868
+
1869
+ hide() {
1870
+ _killPreview();
1871
+ box.hide();
1872
+ screen.render();
1873
+ },
1874
+
1875
+ onFocus() {
1876
+ if (_bmadDetected) {
1877
+ agentList.focus();
1878
+ } else {
1879
+ onboardingBox.focus();
1880
+ }
1881
+ screen.render();
1882
+ },
1883
+
1884
+ onBlur() {
1885
+ _killPreview();
1886
+ },
1887
+
1888
+ getFooterText() {
1889
+ return _bmadDetected ? _tl('bmadFooterBmad') : _tl('bmadFooterNobmad');
1890
+ },
1891
+
1892
+ getFooterColor() {
1893
+ return COLORS.footerBg;
1894
+ },
1895
+ };
1896
+ }