agentvibes 4.0.0 → 4.2.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 (42) hide show
  1. package/.claude/config/audio-effects.cfg +3 -2
  2. package/.claude/config/background-music-position.txt +1 -1
  3. package/.claude/hooks/audio-processor.sh +87 -43
  4. package/.claude/hooks/bmad-speak.sh +184 -27
  5. package/.claude/hooks/play-tts-enhanced.sh +40 -5
  6. package/.claude/hooks/play-tts-macos.sh +29 -6
  7. package/.claude/hooks/play-tts-piper.sh +174 -67
  8. package/.claude/hooks/play-tts-soprano.sh +42 -6
  9. package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
  10. package/.claude/hooks/play-tts.sh +12 -9
  11. package/.claude/hooks/session-start-tts.sh +10 -0
  12. package/.claude/hooks/stop-tts.sh +84 -0
  13. package/.claude/hooks/tts-queue-worker.sh +51 -20
  14. package/.claude/hooks/tts-queue.sh +37 -8
  15. package/.claude/hooks/voice-manager.sh +5 -1
  16. package/CLAUDE.md +0 -11
  17. package/README.md +176 -78
  18. package/RELEASE_NOTES.md +1197 -60
  19. package/bin/agentvibes-voice-browser.js +35 -21
  20. package/mcp-server/server.py +36 -0
  21. package/package.json +1 -3
  22. package/src/console/app.js +23 -5
  23. package/src/console/constants/personalities.js +44 -0
  24. package/src/console/footer-config.js +8 -0
  25. package/src/console/navigation.js +3 -1
  26. package/src/console/tabs/agents-tab.js +1219 -72
  27. package/src/console/tabs/install-tab.js +2 -1
  28. package/src/console/tabs/placeholder-tab.js +9 -1
  29. package/src/console/tabs/receiver-tab.js +1212 -0
  30. package/src/console/tabs/settings-tab.js +33 -323
  31. package/src/console/widgets/destroy-list.js +25 -0
  32. package/src/console/widgets/format-utils.js +89 -0
  33. package/src/console/widgets/notice.js +55 -0
  34. package/src/console/widgets/personality-picker.js +185 -0
  35. package/src/console/widgets/reverb-picker.js +94 -0
  36. package/src/console/widgets/track-picker.js +285 -0
  37. package/src/installer.js +54 -2
  38. package/src/services/agent-voice-store.js +282 -22
  39. package/src/services/config-service.js +24 -0
  40. package/src/services/navigation-service.js +1 -1
  41. package/src/utils/music-file-validator.js +41 -31
  42. package/templates/agentvibes-receiver.sh +431 -111
@@ -1,14 +1,34 @@
1
1
  /**
2
- * AgentVibes TUI Console — Agents Tab
3
- * Epic 11: Stories 11.1-11.5
2
+ * AgentVibes TUI Console — Agents Tab (BMAD Integration)
4
3
  *
5
4
  * Implements the Tab Component Contract:
6
5
  * createAgentsTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
6
  *
8
- * Features: BMAD agent list, voice assignments, party mode toggle, single-voice warning.
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
9
10
  */
10
11
 
11
- import { AgentVoiceStore, scanBmadAgents, isSingleVoiceProvider } from '../../services/agent-voice-store.js';
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 crypto from 'node:crypto';
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { spawn } from 'node:child_process';
29
+
30
+ // Max pretext length to prevent excessively long TTS utterances
31
+ const MAX_PRETEXT_LENGTH = 200;
12
32
 
13
33
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
14
34
 
@@ -22,21 +42,39 @@ if (!IS_TEST) {
22
42
 
23
43
  const COLORS = {
24
44
  contentBg: '#0a0e1a',
25
- sectionHdr: '#7b1fa2', // Purple — section headers for Agents tab
45
+ sectionHdr: '#7b1fa2',
26
46
  labelFg: '#e3f2fd',
27
- valueFg: '#ffff00', // Yellow
28
- activeFg: '#ce93d8', // Light purple — selected agent
29
- btnDefault: '#6a1b9a', // Purple — Agents tab buttons
47
+ valueFg: '#ffff00',
48
+ activeFg: '#ce93d8',
49
+ btnDefault: '#6a1b9a',
30
50
  btnFocus: '#9c27b0',
31
51
  btnFocusFg: '#ffffff',
32
52
  btnPress: '#ff00ff',
33
53
  borderFg: '#9c27b0',
34
- footerBg: '#9c27b0', // Purple — Agents tab footer
54
+ footerBg: '#9c27b0',
35
55
  noticeFg: '#90a4ae',
36
56
  warnFg: '#ff9800',
57
+ linkFg: '#00e5ff',
37
58
  };
38
59
 
39
- const FOOTER_TEXT = '[↑↓/jk] Navigate [Enter] Details [R] Reset Voice [P] Party Mode [S/V/M/A/R] Tab [Q] Quit';
60
+ const FOOTER_TEXT_BMAD = '[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [X] Reset [Q] Quit';
61
+ const FOOTER_TEXT_NOBMAD = '[Tab] Switch Tab [Q] Quit';
62
+
63
+ const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
64
+
65
+ // Column widths for agent table
66
+ const COL_ICON = 4;
67
+ const COL_NAME = 16;
68
+ const COL_VOICE = 12; // beautified names avg 5-11 chars
69
+ const COL_GENDER = 8;
70
+ const COL_PROVIDER = 12;
71
+ const COL_PRETEXT = 14;
72
+ const COL_REVERB = 10;
73
+ const COL_MUSIC = 11;
74
+ const COL_VOL = 5; // e.g. "70%" or "100%"
75
+
76
+ // Inline hint appended to the selected row when list is focused
77
+ const _ROW_HINT_BMAD = ` {bright-black-fg}[Space] Preview [Enter] Configure{/bright-black-fg}`;
40
78
 
41
79
  // ---------------------------------------------------------------------------
42
80
 
@@ -47,28 +85,74 @@ function createTestStub() {
47
85
  hide: () => {},
48
86
  onFocus: () => {},
49
87
  onBlur: () => {},
50
- getFooterText: () => FOOTER_TEXT,
88
+ getFooterText: () => FOOTER_TEXT_BMAD,
51
89
  getFooterColor: () => COLORS.footerBg,
52
90
  };
53
91
  }
54
92
 
93
+ // ---------------------------------------------------------------------------
94
+ // No-BMAD onboarding content
95
+
96
+ const ONBOARDING_TEXT = `{bold}{#ce93d8-fg}🧙 BMAD Agents{/#ce93d8-fg}{/bold}
97
+
98
+ {bold}What is BMAD?{/bold}
99
+
100
+ The BMad Method (Build More Architect Dreams) is an AI-driven development
101
+ framework module within the BMad Method Ecosystem that helps you build
102
+ software through the whole process from ideation and planning all the way
103
+ through agentic implementation. It provides specialized AI agents, guided
104
+ workflows, and intelligent planning that adapts to your project's
105
+ complexity, whether you're fixing a bug or building an enterprise platform.
106
+
107
+ If you're comfortable working with AI coding assistants like Claude,
108
+ Cursor, or GitHub Copilot, you're ready to get started.
109
+
110
+
111
+ {bold}Install BMAD in your project:{/bold}
112
+
113
+ {#00e5ff-fg}npx bmad-method install{/#00e5ff-fg}
114
+
115
+
116
+ {bold}Learn more:{/bold}
117
+
118
+ {#00e5ff-fg}https://docs.bmad-method.org/{/#00e5ff-fg}
119
+ {#00e5ff-fg}https://github.com/bmad-code-org/BMAD-METHOD{/#00e5ff-fg}
120
+
121
+
122
+ {#90a4ae-fg}Once BMAD is installed, this tab will show all your agents and let you
123
+ customize each agent's voice, pretext, reverb, personality, and background
124
+ music independently.{/#90a4ae-fg}`;
125
+
55
126
  // ---------------------------------------------------------------------------
56
127
 
57
128
  /**
58
129
  * Create the Agents tab component.
59
- *
60
- * @param {object} screen - Blessed screen instance (or test stub)
61
- * @param {object} services
62
- * @param {import('../../services/config-service.js').ConfigService} services.configService
63
- * @param {import('../../services/provider-service.js').ProviderService} services.providerService
64
- * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
65
130
  */
66
131
  export function createAgentsTab(screen, services) {
67
132
  if (IS_TEST) return createTestStub();
68
133
 
69
- const { configService, providerService, focusMainTabBar } = services;
134
+ const { configService, providerService, focusMainTabBar, navigationService } = services;
70
135
  const voiceStore = new AgentVoiceStore();
71
136
 
137
+ // Capture cwd once at construction (L1 fix)
138
+ const _projectRoot = process.cwd();
139
+
140
+ let _bmadDetected = false;
141
+ let _agents = [];
142
+ let _playingProcess = null;
143
+ let _playGeneration = 0; // H4: generation counter to prevent orphaned processes
144
+
145
+ /**
146
+ * Create a secure temp file path using XDG_RUNTIME_DIR or user-specific dir (H3 fix).
147
+ */
148
+ function _secureTempWav(prefix) {
149
+ const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
150
+ const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
151
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
152
+ try { fs.chmodSync(dir, 0o700); } catch {}
153
+ return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
154
+ }
155
+
72
156
  // -------------------------------------------------------------------------
73
157
  // Container
74
158
 
@@ -85,17 +169,48 @@ export function createAgentsTab(screen, services) {
85
169
  });
86
170
 
87
171
  // -------------------------------------------------------------------------
88
- // Section header
172
+ // Onboarding content (no-BMAD state)
173
+
174
+ const onboardingBox = blessed.box({
175
+ parent: box,
176
+ top: 1,
177
+ left: 3,
178
+ right: 3,
179
+ bottom: 1,
180
+ hidden: true,
181
+ tags: true,
182
+ scrollable: true,
183
+ keys: true,
184
+ vi: true,
185
+ mouse: true,
186
+ content: ONBOARDING_TEXT,
187
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
188
+ });
189
+
190
+ // -------------------------------------------------------------------------
191
+ // BMAD state — section header
89
192
 
90
- blessed.text({
193
+ const sectionHeader = blessed.text({
91
194
  parent: box,
92
195
  top: 1,
93
196
  left: 2,
197
+ hidden: true,
94
198
  content: `{#7b1fa2-fg}── BMAD Agents ${'─'.repeat(53)}{/#7b1fa2-fg}`,
95
199
  tags: true,
96
200
  style: { bg: COLORS.contentBg },
97
201
  });
98
202
 
203
+ // Column header
204
+ const columnHeader = blessed.text({
205
+ parent: box,
206
+ top: 2,
207
+ left: 4,
208
+ hidden: true,
209
+ tags: true,
210
+ 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}`,
211
+ style: { bg: COLORS.contentBg },
212
+ });
213
+
99
214
  // -------------------------------------------------------------------------
100
215
  // Agent list
101
216
 
@@ -105,9 +220,11 @@ export function createAgentsTab(screen, services) {
105
220
  left: 2,
106
221
  width: '96%',
107
222
  height: '55%',
223
+ hidden: true,
108
224
  keys: true,
109
225
  vi: true,
110
226
  mouse: true,
227
+ tags: true,
111
228
  border: { type: 'line' },
112
229
  scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
113
230
  style: {
@@ -122,10 +239,11 @@ export function createAgentsTab(screen, services) {
122
239
  // -------------------------------------------------------------------------
123
240
  // Status panel
124
241
 
125
- blessed.text({
242
+ const statusDivider = blessed.text({
126
243
  parent: box,
127
244
  top: '64%',
128
245
  left: 2,
246
+ hidden: true,
129
247
  content: `{#7b1fa2-fg}── Status ${'─'.repeat(58)}{/#7b1fa2-fg}`,
130
248
  tags: true,
131
249
  style: { bg: COLORS.contentBg },
@@ -135,6 +253,7 @@ export function createAgentsTab(screen, services) {
135
253
  parent: box,
136
254
  top: '69%',
137
255
  left: 2,
256
+ hidden: true,
138
257
  tags: true,
139
258
  content: '',
140
259
  style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
@@ -144,11 +263,23 @@ export function createAgentsTab(screen, services) {
144
263
  parent: box,
145
264
  top: '74%',
146
265
  left: 2,
266
+ hidden: true,
147
267
  tags: true,
148
268
  content: '',
149
269
  style: { fg: COLORS.warnFg, bg: COLORS.contentBg },
150
270
  });
151
271
 
272
+ // Hint shown inline next to the action buttons at bottom of list
273
+ const hintLine = blessed.text({
274
+ parent: box,
275
+ bottom: 5,
276
+ left: 4,
277
+ hidden: true,
278
+ tags: true,
279
+ content: '{#546e7a-fg}[Space] Preview [Enter] Configure [X] Reset [A] Auto-assign [B] Bulk Edit{/#546e7a-fg}',
280
+ style: { bg: COLORS.contentBg },
281
+ });
282
+
152
283
  // -------------------------------------------------------------------------
153
284
  // Buttons
154
285
 
@@ -159,6 +290,7 @@ export function createAgentsTab(screen, services) {
159
290
  mouse: true,
160
291
  keys: true,
161
292
  shrink: true,
293
+ hidden: true,
162
294
  padding: { left: 1, right: 1 },
163
295
  style: {
164
296
  bg: COLORS.btnDefault,
@@ -191,82 +323,1056 @@ export function createAgentsTab(screen, services) {
191
323
  return btn;
192
324
  }
193
325
 
194
- const resetBtn = _createBtn('[R] Reset Voice', () => {
195
- const agents = _agents;
196
- const agent = agents[agentList.selected];
326
+ const resetBtn = _createBtn('[X] Reset', () => {
327
+ const agent = _agents[agentList.selected];
197
328
  if (agent) {
198
- voiceStore.resetVoice(agent.id);
329
+ voiceStore.resetAgentProfile(agent.id);
199
330
  refreshDisplay();
200
331
  }
201
332
  });
202
333
  resetBtn.bottom = 4;
203
334
  resetBtn.left = 4;
204
335
 
205
- const partyBtn = _createBtn('[P] Party Mode', () => {
206
- const current = voiceStore.getPartyMode();
207
- voiceStore.setPartyMode(!current);
208
- refreshDisplay();
209
- });
210
- partyBtn.bottom = 4;
211
- partyBtn.left = 24;
336
+ const autoAssignBtn = _createBtn('[A] Auto-assign', () => _autoAssignAll());
337
+ autoAssignBtn.bottom = 4;
338
+ autoAssignBtn.left = 18;
339
+
340
+ const bulkEditBtn = _createBtn('[B] Bulk Edit', () => _openBulkEditMenu());
341
+ bulkEditBtn.bottom = 4;
342
+ bulkEditBtn.left = 36;
212
343
 
213
344
  // -------------------------------------------------------------------------
214
- // State
345
+ // Show/hide helpers for the two states
215
346
 
216
- let _agents = [];
347
+ const _bmadWidgets = [sectionHeader, columnHeader, agentList, hintLine, resetBtn, autoAssignBtn, bulkEditBtn];
348
+
349
+ function _showBmadState() {
350
+ onboardingBox.hide();
351
+ for (const w of _bmadWidgets) w.show();
352
+ }
217
353
 
218
- function _buildListItems(agents, voiceMap) {
354
+ function _showOnboardingState() {
355
+ for (const w of _bmadWidgets) w.hide();
356
+ onboardingBox.show();
357
+ }
358
+
359
+ // -------------------------------------------------------------------------
360
+ // Build table row items
361
+
362
+ function _buildListItems(agents) {
219
363
  if (agents.length === 0) {
220
- return [' (no BMAD agents detected — open a project with .bmad/ or _bmad/)'];
364
+ return [' (no BMAD agents detected)'];
221
365
  }
222
366
  return agents.map(a => {
223
- const voice = voiceMap[a.id] ?? '(default)';
224
- return ` ${a.displayName.padEnd(20)} ${voice}`;
367
+ const profile = voiceStore.getAgentProfile(a.id);
368
+ // Strip variation selectors (e.g. U+FE0F on 🏗️) so padEnd uses visual width
369
+ const rawIcon = (a.icon || '').replace(/\uFE0F/g, '');
370
+ const icon = (rawIcon ? `${rawIcon} ` : ' ').padEnd(COL_ICON);
371
+ const name = ` ${a.displayName}`.padEnd(COL_NAME).slice(0, COL_NAME);
372
+ const voiceRaw = formatVoiceName(profile.voice);
373
+ const voice = (' ' + voiceRaw).padEnd(COL_VOICE).slice(0, COL_VOICE);
374
+ const meta = profile.voice ? getVoiceMeta(profile.voice) : { gender: '—', provider: '—' };
375
+ const gender = (' ' + meta.gender).padEnd(COL_GENDER).slice(0, COL_GENDER);
376
+ const provider = (' ' + meta.provider).padEnd(COL_PROVIDER).slice(0, COL_PROVIDER);
377
+ const reverb = (' ' + (profile.reverbPreset || '(global)')).padEnd(COL_REVERB).slice(0, COL_REVERB);
378
+ const music = (' ' + (profile.backgroundMusic?.track
379
+ ? formatTrackName(profile.backgroundMusic.track)
380
+ : '(global)')).padEnd(COL_MUSIC).slice(0, COL_MUSIC);
381
+ const vol = profile.backgroundMusic?.enabled
382
+ ? ` ${profile.backgroundMusic.volume ?? 70}%`.padEnd(COL_VOL)
383
+ : ' — ';
384
+ const pretext = ' ' + (profile.pretext || '(default)').slice(0, COL_PRETEXT - 1);
385
+ return ` ${icon}${name}${voice}${gender}${provider}${reverb}${music}${vol} ${pretext}`;
225
386
  });
226
387
  }
227
388
 
389
+ // -------------------------------------------------------------------------
390
+ // Refresh display
391
+
228
392
  function refreshDisplay() {
229
- _agents = scanBmadAgents(process.cwd());
230
- const voiceMap = voiceStore.getVoiceMap();
231
- const partyMode = voiceStore.getPartyMode();
232
- const provider = providerService.getProvider?.() ?? configService.getConfig().provider ?? 'piper';
233
- const singleVoice = isSingleVoiceProvider(provider);
393
+ _bmadDetected = isBmadDetected(_projectRoot);
394
+ _agents = scanBmadAgents(_projectRoot);
395
+
396
+ if (!_bmadDetected) {
397
+ _showOnboardingState();
398
+ screen.render();
399
+ return;
400
+ }
401
+
402
+ _showBmadState();
234
403
 
235
- const items = _buildListItems(_agents, voiceMap);
404
+ const items = _buildListItems(_agents);
236
405
  agentList.setItems(items);
237
406
 
238
- statusLine.setContent(
239
- ` Party Mode: ${partyMode ? 'Enabled' : 'Disabled'} | Provider: ${provider} | Agents: ${_agents.length}`
240
- );
407
+ if (_listFocused) {
408
+ _hintIdx = -1;
409
+ _hintBase = '';
410
+ _updateHint(agentList.selected ?? 0);
411
+ }
412
+
413
+ screen.render();
414
+ }
415
+
416
+ // -------------------------------------------------------------------------
417
+ // Temporary "Saved!" toast notification
418
+
419
+ function _showSavedToast(agentName) {
420
+ const toast = blessed.box({
421
+ parent: screen,
422
+ top: 'center',
423
+ left: 'center',
424
+ width: 34,
425
+ height: 3,
426
+ border: { type: 'line' },
427
+ tags: true,
428
+ content: ` {green-fg}{bold}✓ ${agentName} saved!{/bold}{/green-fg}`,
429
+ style: { fg: '#e3f2fd', bg: '#1b5e20', border: { fg: '#4caf50' } },
430
+ });
431
+ toast.setFront();
432
+ screen.render();
433
+ setTimeout(() => {
434
+ toast.destroy();
435
+ try {
436
+ for (let r = 0; r < screen.height; r++)
437
+ for (let c = 0; c < screen.width; c++)
438
+ if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
439
+ } catch {}
440
+ screen.render();
441
+ }, 1500);
442
+ }
443
+
444
+ // -------------------------------------------------------------------------
445
+ // Row spinner (animated braille while preview is playing)
446
+
447
+ const _SPIN_PFX = '{#00e5ff-fg}';
448
+ const _SPIN_SFX = '{/#00e5ff-fg}';
449
+ const _SPIN_PFX_TOTAL_LEN = _SPIN_PFX.length + 1 + _SPIN_SFX.length; // tag + 1 frame char + close tag
450
+ const _SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
451
+ let _spinnerInterval = null;
452
+ let _spinnerFrameIdx = 0;
453
+ let _spinnerAgentIdx = -1;
454
+
455
+ // Strip the spinner prefix (tag+frame+close or plain first char) to get the row tail.
456
+ function _stripSpinnerPfx(c) {
457
+ return c.startsWith(_SPIN_PFX) ? c.slice(_SPIN_PFX_TOTAL_LEN) : c.slice(1);
458
+ }
459
+
460
+ function _startSpinner(agentIdx) {
461
+ _stopSpinner();
462
+ _spinnerAgentIdx = agentIdx;
463
+ _spinnerFrameIdx = 0;
464
+ const items = agentList.items;
465
+ const item = items[_spinnerAgentIdx];
466
+ if (item) {
467
+ item.setContent(`${_SPIN_PFX}${_SPIN_FRAMES[0]}${_SPIN_SFX}${_stripSpinnerPfx(item.content ?? ' ')}`);
468
+ screen.render();
469
+ }
470
+ _spinnerInterval = setInterval(() => {
471
+ _spinnerFrameIdx = (_spinnerFrameIdx + 1) % _SPIN_FRAMES.length;
472
+ const it = agentList.items[_spinnerAgentIdx];
473
+ if (!it) return;
474
+ it.setContent(`${_SPIN_PFX}${_SPIN_FRAMES[_spinnerFrameIdx]}${_SPIN_SFX}${_stripSpinnerPfx(it.content ?? ' ')}`);
475
+ screen.render();
476
+ }, 80);
477
+ }
478
+
479
+ function _stopSpinner() {
480
+ if (_spinnerInterval) { clearInterval(_spinnerInterval); _spinnerInterval = null; }
481
+ if (_spinnerAgentIdx >= 0) {
482
+ const item = agentList.items[_spinnerAgentIdx];
483
+ if (item) item.setContent(' ' + _stripSpinnerPfx(item.content ?? ' '));
484
+ _spinnerAgentIdx = -1;
485
+ screen.render();
486
+ }
487
+ }
488
+
489
+ // -------------------------------------------------------------------------
490
+ // Kill any playing preview
491
+
492
+ function _killPreview() {
493
+ _stopSpinner();
494
+ if (_playingProcess) {
495
+ try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
496
+ _playingProcess = null;
497
+ }
498
+ }
499
+
500
+ // -------------------------------------------------------------------------
501
+ // Sample an agent with their full profile (voice + pretext + reverb + music)
502
+ // Uses play-tts-enhanced.sh for the complete effects pipeline.
503
+
504
+ function _sampleAgent(agent) {
505
+ const profile = voiceStore.getAgentProfile(agent.id);
506
+ const globalCfg = configService.getConfig();
507
+ _sampleWithFullProfile(agent, {
508
+ voice: profile.voice || globalCfg.voice || '',
509
+ pretext: profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title),
510
+ reverbPreset: profile.reverbPreset || globalCfg.effects?.reverbPreset || 'light',
511
+ personality: profile.personality || globalCfg.personality || 'none',
512
+ backgroundMusic: {
513
+ track: profile.backgroundMusic?.track || globalCfg.backgroundMusic?.track || '',
514
+ volume: profile.backgroundMusic?.volume ?? globalCfg.backgroundMusic?.volume ?? 70,
515
+ enabled: profile.backgroundMusic?.enabled ?? globalCfg.backgroundMusic?.enabled ?? false,
516
+ },
517
+ });
518
+ }
519
+
520
+ // -------------------------------------------------------------------------
521
+ // Agent detail panel (modal overlay)
522
+
523
+ function _openAgentDetailPanel(agent) {
524
+ const profile = voiceStore.getAgentProfile(agent.id);
525
+ const globalCfg = configService.getConfig();
526
+
527
+ // Working copy of the profile being edited
528
+ const draft = {
529
+ voice: profile.voice || globalCfg.voice || '',
530
+ pretext: profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title),
531
+ reverbPreset: profile.reverbPreset || globalCfg.effects?.reverbPreset || 'light',
532
+ personality: profile.personality || globalCfg.personality || 'none',
533
+ backgroundMusic: {
534
+ track: profile.backgroundMusic?.track || globalCfg.backgroundMusic?.track || '',
535
+ volume: profile.backgroundMusic?.volume ?? globalCfg.backgroundMusic?.volume ?? 70,
536
+ enabled: profile.backgroundMusic?.enabled ?? globalCfg.backgroundMusic?.enabled ?? false,
537
+ },
538
+ };
539
+
540
+ let _closed = false;
541
+ navigationService?.openModal();
542
+
543
+ const modal = blessed.box({
544
+ parent: screen,
545
+ top: 'center',
546
+ left: 'center',
547
+ width: 72,
548
+ height: 18,
549
+ border: { type: 'line' },
550
+ tags: true,
551
+ label: _modalTitle(`${agent.icon || '🧙'} ${agent.displayName} (${agent.title || 'Agent'})`),
552
+ style: {
553
+ fg: COLORS.labelFg,
554
+ bg: COLORS.contentBg,
555
+ border: { fg: COLORS.btnFocus },
556
+ },
557
+ });
558
+ modal.setFront();
559
+
560
+ // Field definitions
561
+ const FIELDS = [
562
+ { key: 'voice', label: 'Voice', getValue: () => draft.voice || '(global default)' },
563
+ { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(default)' },
564
+ { key: 'reverbPreset', label: 'Reverb', getValue: () => formatReverbState(draft.reverbPreset) },
565
+ { key: 'personality', label: 'Personality', getValue: () => {
566
+ const p = draft.personality;
567
+ const emoji = PERSONALITY_EMOJIS[p] || '';
568
+ return `${emoji} ${p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1)}`;
569
+ }},
570
+ { key: 'music', label: 'Music', getValue: () => {
571
+ if (!draft.backgroundMusic.enabled) return '(disabled)';
572
+ return `${formatTrackName(draft.backgroundMusic.track)} Vol:${draft.backgroundMusic.volume}%`;
573
+ }},
574
+ ];
575
+
576
+ // Build field list items
577
+ function _fieldItems() {
578
+ return FIELDS.map(f => {
579
+ const label = f.label.padEnd(14);
580
+ const val = f.getValue();
581
+ return ` ${label} ${val}`;
582
+ });
583
+ }
584
+
585
+ const fieldList = blessed.list({
586
+ parent: modal,
587
+ top: 1,
588
+ left: 2,
589
+ right: 2,
590
+ height: FIELDS.length + 2,
591
+ keys: true,
592
+ vi: true,
593
+ mouse: true,
594
+ border: { type: 'line' },
595
+ tags: true,
596
+ style: {
597
+ fg: COLORS.labelFg,
598
+ bg: COLORS.contentBg,
599
+ border: { fg: '#4a148c' },
600
+ selected: { bg: '#4a148c', fg: COLORS.activeFg, bold: true },
601
+ item: { fg: COLORS.labelFg },
602
+ },
603
+ });
604
+ fieldList.setItems(_fieldItems());
605
+
606
+ // Key hint
607
+ blessed.text({
608
+ parent: modal,
609
+ bottom: 4,
610
+ left: 2,
611
+ right: 2,
612
+ tags: true,
613
+ content: '{#455a64-fg}[↑↓] Navigate fields [Enter] Edit field [Space] Sample [Esc] Cancel{/#455a64-fg}',
614
+ style: { bg: COLORS.contentBg },
615
+ });
616
+
617
+ // Buttons
618
+ function _modalBtn(label, leftPos, onClick) {
619
+ const btn = blessed.button({
620
+ parent: modal,
621
+ content: label,
622
+ bottom: 2,
623
+ left: leftPos,
624
+ mouse: true,
625
+ keys: true,
626
+ shrink: true,
627
+ padding: { left: 1, right: 1 },
628
+ style: {
629
+ bg: COLORS.btnDefault,
630
+ fg: 'white',
631
+ focus: { bg: '#00e5ff', fg: '#000000', bold: true },
632
+ hover: { bg: '#00e5ff', fg: '#000000', bold: true },
633
+ },
634
+ });
635
+ btn.on('focus', () => {
636
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
637
+ btn.setContent(`►${raw}◄`);
638
+ screen.render();
639
+ });
640
+ btn.on('blur', () => {
641
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
642
+ btn.setContent(raw);
643
+ screen.render();
644
+ });
645
+ btn.key(['enter', 'space'], () => onClick());
646
+ btn.on('click', () => onClick());
647
+ return btn;
648
+ }
649
+
650
+ const saveBtn = _modalBtn('Save', 4, () => {
651
+ // Only save fields that differ from global
652
+ const toSave = {};
653
+ if (draft.voice && draft.voice !== globalCfg.voice) toSave.voice = draft.voice;
654
+ if (draft.pretext !== AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title)) toSave.pretext = draft.pretext;
655
+ if (draft.reverbPreset !== (globalCfg.effects?.reverbPreset || 'light')) toSave.reverbPreset = draft.reverbPreset;
656
+ if (draft.personality !== (globalCfg.personality || 'none')) toSave.personality = draft.personality;
657
+ if (draft.backgroundMusic.track !== (globalCfg.backgroundMusic?.track || '') ||
658
+ draft.backgroundMusic.volume !== (globalCfg.backgroundMusic?.volume ?? 70) ||
659
+ draft.backgroundMusic.enabled !== (globalCfg.backgroundMusic?.enabled ?? false)) {
660
+ toSave.backgroundMusic = draft.backgroundMusic;
661
+ }
662
+ voiceStore.setAgentProfile(agent.id, toSave);
663
+ _closeModal();
664
+ refreshDisplay();
665
+ // Show temporary "Saved!" toast
666
+ _showSavedToast(agent.displayName);
667
+ });
668
+
669
+ const resetAllBtn = _modalBtn('Reset to Defaults', 14, () => {
670
+ voiceStore.resetAgentProfile(agent.id);
671
+ _closeModal();
672
+ refreshDisplay();
673
+ });
674
+
675
+ const cancelBtn = _modalBtn('Cancel', 38, _closeModal);
676
+
677
+ function _closeModal() {
678
+ if (_closed) return;
679
+ _closed = true;
680
+ _killPreview();
681
+ navigationService?.closeModal();
682
+ destroyList(modal, screen);
683
+ }
684
+
685
+ // Field editing via Enter
686
+ fieldList.key(['enter'], () => {
687
+ const idx = fieldList.selected;
688
+ const field = FIELDS[idx];
689
+ if (!field) return;
690
+
691
+ switch (field.key) {
692
+ case 'voice':
693
+ _openVoicePickerForAgent(agent, draft, () => {
694
+ fieldList.setItems(_fieldItems());
695
+ fieldList.select(idx);
696
+ fieldList.focus();
697
+ screen.render();
698
+ });
699
+ break;
700
+
701
+ case 'pretext':
702
+ _openPretextEditor(modal, draft, () => {
703
+ fieldList.setItems(_fieldItems());
704
+ fieldList.select(idx);
705
+ fieldList.focus();
706
+ screen.render();
707
+ });
708
+ break;
709
+
710
+ case 'reverbPreset':
711
+ openReverbPicker(screen, draft.reverbPreset, (val) => {
712
+ draft.reverbPreset = val;
713
+ fieldList.setItems(_fieldItems());
714
+ fieldList.select(idx);
715
+ fieldList.focus();
716
+ screen.render();
717
+ }, () => {
718
+ fieldList.focus();
719
+ screen.render();
720
+ }, { applyToEffectsManager: false });
721
+ break;
722
+
723
+ case 'personality':
724
+ openPersonalityPicker(screen, draft.personality, (val) => {
725
+ draft.personality = val;
726
+ fieldList.setItems(_fieldItems());
727
+ fieldList.select(idx);
728
+ fieldList.focus();
729
+ screen.render();
730
+ }, () => {
731
+ fieldList.focus();
732
+ screen.render();
733
+ });
734
+ break;
735
+
736
+ case 'music':
737
+ openTrackPicker(screen, draft.backgroundMusic.track, draft.backgroundMusic.volume, (track, volume) => {
738
+ draft.backgroundMusic.track = track;
739
+ draft.backgroundMusic.volume = volume;
740
+ draft.backgroundMusic.enabled = true;
741
+ fieldList.setItems(_fieldItems());
742
+ fieldList.select(idx);
743
+ fieldList.focus();
744
+ screen.render();
745
+ }, () => {
746
+ fieldList.focus();
747
+ screen.render();
748
+ });
749
+ break;
750
+ }
751
+ });
752
+
753
+ // Space = sample with current draft
754
+ fieldList.key(['space'], () => {
755
+ const draftAgent = { ...agent };
756
+ // Temporarily set profile for sampling
757
+ _sampleAgentWithDraft(draftAgent, draft);
758
+ });
759
+
760
+ // Escape = close
761
+ fieldList.key(['escape', 'q'], _closeModal);
762
+ saveBtn.key(['escape'], _closeModal);
763
+ resetAllBtn.key(['escape'], _closeModal);
764
+ cancelBtn.key(['escape'], _closeModal);
765
+
766
+ // Tab navigation within modal
767
+ fieldList.key(['tab'], () => { saveBtn.focus(); screen.render(); });
768
+ saveBtn.key(['tab'], () => { resetAllBtn.focus(); screen.render(); });
769
+ resetAllBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
770
+ cancelBtn.key(['tab'], () => { fieldList.focus(); screen.render(); });
771
+
772
+ fieldList.focus();
773
+ screen.render();
774
+ }
775
+
776
+ // -------------------------------------------------------------------------
777
+ // Voice picker for agent detail panel
778
+
779
+ function _openVoicePickerForAgent(agent, draft, onDone) {
780
+ let _allVoices = [];
781
+ let _filterText = '';
782
+ let _previewProc = null;
783
+ let _previewVoiceId = null;
784
+ let _vpClosed = false;
785
+
786
+ const _spawnEnv = buildAudioEnv();
241
787
 
242
- warningLine.setContent(
243
- partyMode && singleVoice
244
- ? ` ⚠ Provider has only 1 voice — all agents sound the same`
245
- : ''
246
- );
788
+ function _killVP() {
789
+ if (_previewProc) {
790
+ try { process.kill(-_previewProc.pid, 'SIGTERM'); } catch {}
791
+ _previewProc = null;
792
+ }
793
+ _previewVoiceId = null;
794
+ }
795
+
796
+ function _closeVP() {
797
+ if (_vpClosed) return;
798
+ _vpClosed = true;
799
+ _killVP();
800
+ destroyList(vpModal, screen, onDone);
801
+ }
802
+
803
+ const vpModal = blessed.box({
804
+ parent: screen,
805
+ top: '6%',
806
+ left: '3%',
807
+ width: '94%',
808
+ height: '88%',
809
+ border: { type: 'line' },
810
+ tags: true,
811
+ label: _modalTitle(`Select Voice for ${agent.icon || ''} ${agent.displayName}`),
812
+ style: {
813
+ fg: COLORS.labelFg,
814
+ bg: COLORS.contentBg,
815
+ border: { fg: '#00e5ff' },
816
+ },
817
+ });
818
+ vpModal.setFront();
819
+
820
+ // Search
821
+ blessed.text({
822
+ parent: vpModal, top: 1, left: 2,
823
+ content: 'Search:', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
824
+ });
825
+ const vpSearch = blessed.textbox({
826
+ parent: vpModal, top: 1, left: 11, width: 40, height: 1,
827
+ inputOnFocus: true, keys: true,
828
+ style: { fg: COLORS.valueFg, bg: '#1a237e', focus: { bg: '#283593' } },
829
+ });
830
+
831
+ // Column header
832
+ const COL_N = 28;
833
+ const COL_G = 10;
834
+ blessed.text({
835
+ parent: vpModal, top: 2, left: 6, tags: true,
836
+ content: `{#7986cb-fg}${'Name'.padEnd(COL_N)}${'Gender'.padEnd(COL_G)}Provider{/#7986cb-fg}`,
837
+ style: { bg: COLORS.contentBg },
838
+ });
839
+
840
+ const vpList = blessed.list({
841
+ parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
842
+ keys: true, vi: true, mouse: true,
843
+ border: { type: 'line' },
844
+ scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
845
+ style: {
846
+ fg: COLORS.labelFg, bg: COLORS.contentBg,
847
+ border: { fg: COLORS.borderFg },
848
+ selected: { bg: '#1a237e', fg: '#00e5ff', bold: true },
849
+ item: { fg: COLORS.labelFg },
850
+ },
851
+ });
852
+
853
+ const vpInfoLine = blessed.text({
854
+ parent: vpModal, bottom: 4, left: 2, right: 2, tags: true,
855
+ content: '', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
856
+ });
857
+
858
+ const vpPreviewLine = blessed.text({
859
+ parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
860
+ content: '', style: { fg: '#00e5ff', bg: COLORS.contentBg },
861
+ });
862
+
863
+ blessed.text({
864
+ parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
865
+ content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [/] Search [Esc] Cancel{/#455a64-fg}',
866
+ style: { bg: COLORS.contentBg },
867
+ });
868
+
869
+ function _getFiltered() {
870
+ if (!_filterText) return _allVoices;
871
+ const f = _filterText.toLowerCase();
872
+ return _allVoices.filter(v => v.toLowerCase().includes(f));
873
+ }
874
+
875
+ function _buildVoiceItems(voices) {
876
+ return voices.map(v => {
877
+ const isActive = v === draft.voice;
878
+ const isPrev = v === _previewVoiceId;
879
+ const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
880
+ const meta = getVoiceMeta(v);
881
+ const name = meta.displayName.length > COL_N
882
+ ? meta.displayName.slice(0, COL_N - 1) + '…'
883
+ : meta.displayName.padEnd(COL_N);
884
+ return ` ${dot} ${name}${meta.gender.padEnd(COL_G)}${meta.provider}`;
885
+ });
886
+ }
887
+
888
+ function _refreshVP() {
889
+ if (_vpClosed) return;
890
+ _allVoices = scanInstalledVoices();
891
+ const filtered = _getFiltered();
892
+ const items = _buildVoiceItems(filtered);
893
+ vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
894
+ screen.render();
895
+ }
896
+
897
+ function _previewVoice(voiceId) {
898
+ if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); screen.render(); return; }
899
+ _killVP();
900
+
901
+ const _ms = parseMultiSpeaker(voiceId);
902
+ const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
903
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
904
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
905
+
906
+ const tempWav = _secureTempWav('vp');
907
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
908
+
909
+ const args = ['--model', voicePath, '--output_file', tempWav];
910
+ if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
911
+ const piper = spawn('piper', args, {
912
+ stdio: ['pipe', 'ignore', 'ignore'], detached: true, env: _spawnEnv,
913
+ });
914
+ piper.stdin.write(phrase + '\n');
915
+ piper.stdin.end();
916
+ _previewProc = piper;
917
+ _previewVoiceId = voiceId;
918
+
919
+ if (!_vpClosed) {
920
+ vpPreviewLine.setContent(`{#00e5ff-fg}♪ Synthesizing: ${voiceId}...{/#00e5ff-fg}`);
921
+ screen.render();
922
+ }
923
+
924
+ piper.on('exit', (code) => {
925
+ if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
926
+ if (code !== 0) { _previewProc = null; _previewVoiceId = null; return; }
927
+ const wp = detectWavPlayer(_spawnEnv);
928
+ if (!wp) return;
929
+ const pp = spawn(wp.bin, wp.args(tempWav), { stdio: 'ignore', detached: true, env: _spawnEnv });
930
+ _previewProc = pp;
931
+ if (!_vpClosed) { vpPreviewLine.setContent(`{#00e5ff-fg}♪ Playing: ${voiceId}{/#00e5ff-fg}`); screen.render(); }
932
+ pp.on('exit', () => {
933
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); screen.render(); } }
934
+ try { fs.unlinkSync(tempWav); } catch {}
935
+ });
936
+ });
937
+ piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
938
+ }
939
+
940
+ vpSearch.on('keypress', () => {
941
+ setTimeout(() => { _filterText = vpSearch.getValue().trim(); _refreshVP(); }, 0);
942
+ });
943
+ vpSearch.key(['escape'], () => { vpList.focus(); screen.render(); });
944
+ vpList.key(['/'], () => { vpSearch.clearValue(); vpSearch.focus(); screen.render(); });
945
+ vpList.key(['enter'], () => {
946
+ const filtered = _getFiltered();
947
+ const sel = filtered[vpList.selected];
948
+ if (sel) { draft.voice = sel; _closeVP(); }
949
+ });
950
+ vpList.key(['space'], () => {
951
+ const filtered = _getFiltered();
952
+ const sel = filtered[vpList.selected];
953
+ if (sel) _previewVoice(sel);
954
+ });
955
+ vpList.key(['escape', 'q'], _closeVP);
956
+
957
+ _refreshVP();
958
+ const activeIdx = _getFiltered().indexOf(draft.voice);
959
+ if (activeIdx >= 0) vpList.select(activeIdx);
960
+ vpList.focus();
961
+ screen.render();
962
+ }
963
+
964
+ // -------------------------------------------------------------------------
965
+ // Pretext inline editor
966
+
967
+ function _openPretextEditor(parentModal, draft, onDone) {
968
+ const editModal = blessed.box({
969
+ parent: screen,
970
+ top: 'center',
971
+ left: 'center',
972
+ width: 60,
973
+ height: 8,
974
+ border: { type: 'line' },
975
+ tags: true,
976
+ label: _modalTitle('Edit Pretext'),
977
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: '#00e5ff' } },
978
+ });
979
+ editModal.setFront();
980
+
981
+ blessed.text({
982
+ parent: editModal, top: 1, left: 2,
983
+ content: 'Agent pretext (spoken before each TTS message):',
984
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
985
+ });
986
+
987
+ const inputBox = blessed.textbox({
988
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
989
+ border: { type: 'line' },
990
+ inputOnFocus: true,
991
+ value: draft.pretext,
992
+ style: {
993
+ fg: COLORS.valueFg, bg: '#0d1b35',
994
+ border: { fg: COLORS.borderFg },
995
+ focus: { border: { fg: '#00e5ff' } },
996
+ },
997
+ });
998
+
999
+ let _editClosed = false;
1000
+ function _closeEdit(save) {
1001
+ if (_editClosed) return;
1002
+ _editClosed = true;
1003
+ if (save) {
1004
+ const raw = inputBox.getValue().trim();
1005
+ // M7: enforce max pretext length
1006
+ draft.pretext = (raw || draft.pretext).slice(0, MAX_PRETEXT_LENGTH);
1007
+ }
1008
+ destroyList(editModal, screen, onDone);
1009
+ }
1010
+
1011
+ inputBox.key(['enter'], () => _closeEdit(true));
1012
+ inputBox.key(['escape'], () => _closeEdit(false));
1013
+
1014
+ inputBox.focus();
1015
+ screen.render();
1016
+ }
1017
+
1018
+ // -------------------------------------------------------------------------
1019
+ // Sample agent with a draft profile (no save) — same full pipeline
1020
+
1021
+ function _sampleAgentWithDraft(agent, draft) {
1022
+ _sampleWithFullProfile(agent, draft);
1023
+ }
1024
+
1025
+ // -------------------------------------------------------------------------
1026
+ // Shared: sample with full profile via play-tts-enhanced.sh
1027
+ // Writes a temp agent profile JSON, then calls the enhanced TTS pipeline
1028
+ // which applies voice + reverb + background music.
1029
+
1030
+ function _sampleWithFullProfile(agent, profile) {
1031
+ _killPreview();
1032
+ const gen = ++_playGeneration;
1033
+
1034
+ // Start spinner on the agent's row in the list
1035
+ const agentIdx = _agents.findIndex(a => a.id === agent.id);
1036
+ if (agentIdx >= 0) _startSpinner(agentIdx);
1037
+
1038
+ const voiceId = profile.voice || '';
1039
+ const pretext = profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title);
1040
+ const phrase = `${pretext} ${SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]}`;
1041
+
1042
+ const _spawnEnv = buildAudioEnv();
1043
+ const scriptDir = path.join(_projectRoot, '.claude', 'hooks');
1044
+ const plainScript = path.join(scriptDir, 'play-tts.sh');
1045
+
1046
+ // Use play-tts.sh directly for reliable sample playback.
1047
+ // Voice is passed as CLI arg, pretext is prepended to text.
1048
+ const args = [plainScript, phrase];
1049
+ if (voiceId) args.push(voiceId);
1050
+
1051
+ const env = { ..._spawnEnv };
1052
+
1053
+ const proc = spawn('bash', args, {
1054
+ stdio: ['ignore', 'ignore', 'ignore'],
1055
+ detached: true,
1056
+ env,
1057
+ cwd: _projectRoot,
1058
+ });
1059
+ _playingProcess = proc;
1060
+
1061
+ proc.on('exit', () => {
1062
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1063
+ });
1064
+
1065
+ proc.on('error', () => {
1066
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1067
+ });
1068
+ }
1069
+
1070
+ // -------------------------------------------------------------------------
1071
+ // Auto-assign helpers
1072
+
1073
+ function _shuffleArray(arr) {
1074
+ const a = [...arr];
1075
+ for (let i = a.length - 1; i > 0; i--) {
1076
+ const j = Math.floor(Math.random() * (i + 1));
1077
+ [a[i], a[j]] = [a[j], a[i]];
1078
+ }
1079
+ return a;
1080
+ }
1081
+
1082
+ function _autoAssignVoices() {
1083
+ const installed = scanInstalledVoices();
1084
+ if (installed.length === 0) return false;
1085
+
1086
+ // Separate by gender for variety
1087
+ const females = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Female'));
1088
+ const males = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Male'));
1089
+ const others = _shuffleArray(installed.filter(v => !['Male', 'Female'].includes(getVoiceMeta(v).gender)));
1090
+
1091
+ // Interleave female/male for natural variety, then others
1092
+ const pool = [];
1093
+ const maxLen = Math.max(females.length, males.length);
1094
+ for (let i = 0; i < maxLen; i++) {
1095
+ if (i < females.length) pool.push(females[i]);
1096
+ if (i < males.length) pool.push(males[i]);
1097
+ }
1098
+ pool.push(...others);
1099
+
1100
+ const used = new Set();
1101
+ _agents.forEach((agent, i) => {
1102
+ const voice = pool.find(v => !used.has(v)) ?? pool[i % pool.length];
1103
+ if (voice) {
1104
+ used.add(voice);
1105
+ voiceStore.setAgentProfile(agent.id, { voice });
1106
+ }
1107
+ });
1108
+ return true;
1109
+ }
1110
+
1111
+ function _autoAssignMusic() {
1112
+ const tracksDir = path.join(_projectRoot, '.claude', 'audio', 'tracks');
1113
+ let tracks = [];
1114
+ try {
1115
+ tracks = fs.readdirSync(tracksDir).filter(f => /\.mp3$/i.test(f));
1116
+ } catch { /* no tracks dir */ }
1117
+ if (tracks.length === 0) return false;
1118
+
1119
+ const shuffled = _shuffleArray(tracks);
1120
+ _agents.forEach((agent, i) => {
1121
+ const track = shuffled[i % shuffled.length];
1122
+ const existing = voiceStore.getAgentProfile(agent.id);
1123
+ voiceStore.setAgentProfile(agent.id, {
1124
+ backgroundMusic: { track, volume: existing.backgroundMusic?.volume ?? 70, enabled: true },
1125
+ });
1126
+ });
1127
+ return true;
1128
+ }
1129
+
1130
+ function _autoAssignAll() {
1131
+ if (_agents.length === 0) return;
1132
+ const voiceOk = _autoAssignVoices();
1133
+ const musicOk = _autoAssignMusic();
1134
+ refreshDisplay();
1135
+ const msg = voiceOk && musicOk ? 'Voices and music auto-assigned'
1136
+ : voiceOk ? 'Voices auto-assigned' : 'Auto-assign: no voices found';
1137
+ _showSavedToast(msg);
1138
+ }
247
1139
 
1140
+ // -------------------------------------------------------------------------
1141
+ // Bulk edit menu
1142
+
1143
+ function _openBulkEditMenu() {
1144
+ const BULK_ACTIONS = [
1145
+ { label: ' Randomize Voices (gender-aware)', key: 'voices' },
1146
+ { label: ' Randomize Music (unique per agent)', key: 'music' },
1147
+ { label: ' Randomize Both', key: 'both' },
1148
+ { label: ' Set Same Music for All Agents...', key: 'setMusic' },
1149
+ { label: ' Set Same Volume for All Agents...', key: 'setVolume' },
1150
+ { label: ' Set Same Pretext for All Agents...', key: 'setPretext' },
1151
+ { label: ' Set Same Reverb for All Agents...', key: 'setReverb' },
1152
+ { label: ' Reset All Agent Profiles', key: 'resetAll' },
1153
+ ];
1154
+
1155
+ const menuList = blessed.list({
1156
+ parent: screen,
1157
+ top: 'center',
1158
+ left: 'center',
1159
+ width: 52,
1160
+ height: BULK_ACTIONS.length + 4,
1161
+ border: { type: 'line' },
1162
+ tags: true,
1163
+ label: _modalTitle('Bulk Edit'),
1164
+ keys: true,
1165
+ vi: true,
1166
+ mouse: true,
1167
+ items: BULK_ACTIONS.map(a => a.label),
1168
+ style: {
1169
+ fg: COLORS.labelFg,
1170
+ bg: COLORS.contentBg,
1171
+ border: { fg: COLORS.btnFocus },
1172
+ selected: { bg: '#4a148c', fg: COLORS.activeFg, bold: true },
1173
+ item: { fg: COLORS.labelFg },
1174
+ },
1175
+ });
1176
+
1177
+ blessed.text({
1178
+ parent: menuList,
1179
+ bottom: -1,
1180
+ left: 1,
1181
+ width: 48,
1182
+ height: 1,
1183
+ tags: true,
1184
+ content: '{#455a64-fg}[Enter] Select [Esc] Cancel{/#455a64-fg}',
1185
+ style: { bg: COLORS.contentBg },
1186
+ });
1187
+
1188
+ menuList.setFront();
1189
+ menuList.focus();
1190
+ screen.render();
1191
+
1192
+ let _menuClosed = false;
1193
+ function _closeMenu(callback) {
1194
+ if (_menuClosed) return;
1195
+ _menuClosed = true;
1196
+ destroyList(menuList, screen, callback);
1197
+ }
1198
+
1199
+ menuList.key(['enter'], () => {
1200
+ const action = BULK_ACTIONS[menuList.selected];
1201
+ if (!action) return;
1202
+
1203
+ switch (action.key) {
1204
+ case 'voices':
1205
+ _closeMenu(() => {
1206
+ if (_autoAssignVoices()) { refreshDisplay(); _showSavedToast('Voices randomized'); }
1207
+ });
1208
+ break;
1209
+
1210
+ case 'music':
1211
+ _closeMenu(() => {
1212
+ if (_autoAssignMusic()) { refreshDisplay(); _showSavedToast('Music randomized'); }
1213
+ });
1214
+ break;
1215
+
1216
+ case 'both':
1217
+ _closeMenu(() => { _autoAssignAll(); });
1218
+ break;
1219
+
1220
+ case 'setMusic':
1221
+ _closeMenu(() => {
1222
+ openTrackPicker(screen, '', 70, (track, volume) => {
1223
+ _agents.forEach(agent => {
1224
+ const p = voiceStore.getAgentProfile(agent.id);
1225
+ voiceStore.setAgentProfile(agent.id, {
1226
+ backgroundMusic: { track, volume, enabled: true },
1227
+ });
1228
+ });
1229
+ refreshDisplay();
1230
+ _showSavedToast('Music set for all agents');
1231
+ agentList.focus();
1232
+ }, () => { agentList.focus(); screen.render(); });
1233
+ });
1234
+ break;
1235
+
1236
+ case 'setVolume':
1237
+ _closeMenu(() => {
1238
+ const curVol = voiceStore.getAgentProfile(_agents[0]?.id)?.backgroundMusic?.volume ?? 70;
1239
+ openVolumeInput(screen, curVol, (volume) => {
1240
+ _agents.forEach(agent => {
1241
+ const p = voiceStore.getAgentProfile(agent.id);
1242
+ const bm = p.backgroundMusic || {};
1243
+ voiceStore.setAgentProfile(agent.id, {
1244
+ backgroundMusic: { ...bm, volume },
1245
+ });
1246
+ });
1247
+ refreshDisplay();
1248
+ _showSavedToast(`Volume set to ${volume}% for all agents`);
1249
+ agentList.focus();
1250
+ }, () => { agentList.focus(); screen.render(); });
1251
+ });
1252
+ break;
1253
+
1254
+ case 'setPretext':
1255
+ _closeMenu(() => { _openBulkPretextEditor(); });
1256
+ break;
1257
+
1258
+ case 'setReverb':
1259
+ _closeMenu(() => {
1260
+ openReverbPicker(screen, '', (val) => {
1261
+ _agents.forEach(agent => voiceStore.setAgentProfile(agent.id, { reverbPreset: val }));
1262
+ refreshDisplay();
1263
+ _showSavedToast('Reverb set for all agents');
1264
+ agentList.focus();
1265
+ }, () => { agentList.focus(); screen.render(); }, { applyToEffectsManager: false });
1266
+ });
1267
+ break;
1268
+
1269
+ case 'resetAll':
1270
+ _closeMenu(() => {
1271
+ _agents.forEach(agent => voiceStore.resetAgentProfile(agent.id));
1272
+ refreshDisplay();
1273
+ _showSavedToast('All profiles reset');
1274
+ });
1275
+ break;
1276
+ }
1277
+ });
1278
+
1279
+ menuList.key(['escape', 'q'], () => {
1280
+ _closeMenu(() => { agentList.focus(); screen.render(); });
1281
+ });
1282
+ }
1283
+
1284
+ function _openBulkPretextEditor() {
1285
+ const editModal = blessed.box({
1286
+ parent: screen,
1287
+ top: 'center',
1288
+ left: 'center',
1289
+ width: 60,
1290
+ height: 9,
1291
+ border: { type: 'line' },
1292
+ tags: true,
1293
+ label: _modalTitle('Set Pretext for All Agents'),
1294
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: '#00e5ff' } },
1295
+ });
1296
+ editModal.setFront();
1297
+
1298
+ blessed.text({
1299
+ parent: editModal, top: 1, left: 2,
1300
+ content: 'Pretext to apply to all agents (leave empty to clear):',
1301
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1302
+ });
1303
+
1304
+ const inputBox = blessed.textbox({
1305
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
1306
+ border: { type: 'line' },
1307
+ inputOnFocus: true,
1308
+ style: {
1309
+ fg: COLORS.valueFg, bg: '#0d1b35',
1310
+ border: { fg: COLORS.borderFg },
1311
+ focus: { border: { fg: '#00e5ff' } },
1312
+ },
1313
+ });
1314
+
1315
+ blessed.text({
1316
+ parent: editModal, bottom: 1, left: 2, tags: true,
1317
+ content: '{#455a64-fg}[Enter] Apply to all [Esc] Cancel{/#455a64-fg}',
1318
+ style: { bg: COLORS.contentBg },
1319
+ });
1320
+
1321
+ let _closed = false;
1322
+ function _close(save) {
1323
+ if (_closed) return;
1324
+ _closed = true;
1325
+ if (save) {
1326
+ const raw = inputBox.getValue().trim().slice(0, MAX_PRETEXT_LENGTH);
1327
+ _agents.forEach(agent => {
1328
+ if (raw) {
1329
+ voiceStore.setAgentProfile(agent.id, { pretext: raw });
1330
+ } else {
1331
+ const p = voiceStore.getAgentProfile(agent.id);
1332
+ const { pretext: _removed, ...rest } = p;
1333
+ voiceStore.resetAgentProfile(agent.id);
1334
+ if (Object.keys(rest).length > 0) voiceStore.setAgentProfile(agent.id, rest);
1335
+ }
1336
+ });
1337
+ refreshDisplay();
1338
+ _showSavedToast('Pretext set for all agents');
1339
+ }
1340
+ destroyList(editModal, screen, () => { agentList.focus(); screen.render(); });
1341
+ }
1342
+
1343
+ inputBox.key(['enter'], () => _close(true));
1344
+ inputBox.key(['escape'], () => _close(false));
1345
+ inputBox.focus();
248
1346
  screen.render();
249
1347
  }
250
1348
 
251
1349
  // -------------------------------------------------------------------------
252
1350
  // Key bindings
253
1351
 
254
- agentList.key(['r', 'R'], () => {
1352
+ agentList.key(['x', 'X'], () => {
255
1353
  const agent = _agents[agentList.selected];
256
1354
  if (agent) {
257
- voiceStore.resetVoice(agent.id);
1355
+ voiceStore.resetAgentProfile(agent.id);
258
1356
  refreshDisplay();
259
1357
  }
260
1358
  });
261
1359
 
262
- agentList.key(['p', 'P'], () => {
263
- const current = voiceStore.getPartyMode();
264
- voiceStore.setPartyMode(!current);
265
- refreshDisplay();
1360
+
1361
+ agentList.key(['enter'], () => {
1362
+ const agent = _agents[agentList.selected];
1363
+ if (agent) _openAgentDetailPanel(agent);
266
1364
  });
267
1365
 
268
- // Type-to-jump: press a letter to jump to first agent whose name starts with it
269
- const _agentJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'r', 'p']);
1366
+ agentList.key(['space'], () => {
1367
+ const agent = _agents[agentList.selected];
1368
+ if (agent) _sampleAgent(agent);
1369
+ });
1370
+
1371
+ agentList.key(['a', 'A'], () => { _autoAssignAll(); });
1372
+ agentList.key(['b', 'B'], () => { _openBulkEditMenu(); });
1373
+
1374
+ // Type-to-jump
1375
+ const _agentJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'x', 'a', 'b']);
270
1376
  agentList.on('keypress', (ch, key) => {
271
1377
  if (!ch || key.ctrl || key.meta) return;
272
1378
  const lower = ch.toLowerCase();
@@ -286,50 +1392,84 @@ export function createAgentsTab(screen, services) {
286
1392
  }
287
1393
  });
288
1394
 
289
- // Blinking on selected row while list is focused
1395
+ // Inline row hint (appended to selected row while list is focused)
1396
+ let _listFocused = false;
1397
+ let _hintIdx = -1;
1398
+ let _hintBase = ''; // row content before hint was appended (no hint, no █)
1399
+
1400
+ function _updateHint(idx) {
1401
+ const items = agentList.items;
1402
+ if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
1403
+ const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
1404
+ items[_hintIdx].setContent(hadBlink ? _hintBase + ' █' : _hintBase);
1405
+ }
1406
+ if (idx >= 0 && items[idx]) {
1407
+ let c = items[idx].content ?? '';
1408
+ const hasBlink = c.endsWith(' █');
1409
+ if (hasBlink) c = c.slice(0, -3);
1410
+ _hintBase = c;
1411
+ items[idx].setContent(c + _ROW_HINT_BMAD + (hasBlink ? ' █' : ''));
1412
+ } else {
1413
+ _hintBase = '';
1414
+ }
1415
+ _hintIdx = idx;
1416
+ }
1417
+
1418
+ // Blinking cursor
290
1419
  let _alBlink = { interval: null, on: false, sel: -1 };
291
1420
  function _alTick() {
292
1421
  _alBlink.on = !_alBlink.on;
293
1422
  const items = agentList.items;
294
1423
  const cur = agentList.selected ?? 0;
295
1424
  if (_alBlink.sel !== cur && _alBlink.sel >= 0 && items[_alBlink.sel]) {
296
- items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '').replace(/ █$/, ''));
1425
+ items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '').replace(/ █$/, ''));
297
1426
  }
298
1427
  _alBlink.sel = cur;
299
1428
  if (items[cur]) {
300
- const base = (items[cur].content ?? '').replace(/ █$/, '');
301
- items[cur].setContent(_alBlink.on ? `${base} █` : base);
1429
+ const base = (items[cur].content ?? '').replace(/ █$/, '');
1430
+ items[cur].setContent(_alBlink.on ? `${base} █` : base);
302
1431
  }
303
1432
  screen.render();
304
1433
  }
305
1434
  agentList.on('focus', () => {
1435
+ _listFocused = true;
306
1436
  _alBlink.on = true;
307
1437
  _alBlink.sel = agentList.selected ?? 0;
1438
+ _hintIdx = -1;
1439
+ _hintBase = '';
1440
+ _updateHint(_alBlink.sel);
308
1441
  const items = agentList.items;
309
- if (items[_alBlink.sel]) items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '') + ' █');
1442
+ if (items[_alBlink.sel]) items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '') + ' █');
310
1443
  screen.render();
311
1444
  _alBlink.interval = setInterval(_alTick, 500);
312
1445
  });
313
1446
  agentList.on('blur', () => {
1447
+ _listFocused = false;
314
1448
  if (_alBlink.interval) { clearInterval(_alBlink.interval); _alBlink.interval = null; }
315
1449
  const items = agentList.items;
316
1450
  const sel = agentList.selected ?? 0;
317
- if (items[sel]) items[sel].setContent((items[sel].content ?? '').replace(/ █$/, ''));
1451
+ if (items[sel]) {
1452
+ items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1453
+ }
1454
+ if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1455
+ items[_hintIdx].setContent(_hintBase);
1456
+ }
1457
+ _hintIdx = -1;
1458
+ _hintBase = '';
318
1459
  screen.render();
319
1460
  });
320
1461
  agentList.on('select item', () => {
1462
+ _updateHint(agentList.selected ?? 0);
321
1463
  if (_alBlink.interval) _alTick();
322
1464
  });
323
1465
 
324
- // [↑] at top of list jump to main header tab bar
1466
+ // Navigation: up at top → tab bar, escape tab bar
325
1467
  agentList.key(['up'], () => {
326
1468
  if (agentList.selected === 0 && typeof focusMainTabBar === 'function') {
327
1469
  focusMainTabBar();
328
1470
  setTimeout(() => { agentList.select(0); screen.render(); }, 0);
329
1471
  }
330
1472
  });
331
-
332
- // Escape at the list level → return to header tab bar
333
1473
  agentList.key(['escape'], () => {
334
1474
  if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
335
1475
  });
@@ -347,19 +1487,26 @@ export function createAgentsTab(screen, services) {
347
1487
  },
348
1488
 
349
1489
  hide() {
1490
+ _killPreview();
350
1491
  box.hide();
351
1492
  screen.render();
352
1493
  },
353
1494
 
354
1495
  onFocus() {
355
- agentList.focus();
1496
+ if (_bmadDetected) {
1497
+ agentList.focus();
1498
+ } else {
1499
+ onboardingBox.focus();
1500
+ }
356
1501
  screen.render();
357
1502
  },
358
1503
 
359
- onBlur() {},
1504
+ onBlur() {
1505
+ _killPreview();
1506
+ },
360
1507
 
361
1508
  getFooterText() {
362
- return FOOTER_TEXT;
1509
+ return _bmadDetected ? FOOTER_TEXT_BMAD : FOOTER_TEXT_NOBMAD;
363
1510
  },
364
1511
 
365
1512
  getFooterColor() {