agentvibes 4.2.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/.agentvibes/bmad/bmad-voices.md +69 -69
  2. package/.agentvibes/config.json +12 -0
  3. package/.claude/activation-instructions +54 -54
  4. package/.claude/audio/tracks/README.md +52 -52
  5. package/.claude/commands/agent-vibes/add.md +21 -21
  6. package/.claude/commands/agent-vibes/agent-vibes.md +101 -101
  7. package/.claude/commands/agent-vibes/agent.md +79 -79
  8. package/.claude/commands/agent-vibes/background-music.md +111 -111
  9. package/.claude/commands/agent-vibes/bmad.md +198 -198
  10. package/.claude/commands/agent-vibes/clean.md +18 -18
  11. package/.claude/commands/agent-vibes/cleanup.md +18 -18
  12. package/.claude/commands/agent-vibes/commands.json +145 -145
  13. package/.claude/commands/agent-vibes/effects.md +97 -97
  14. package/.claude/commands/agent-vibes/get.md +9 -9
  15. package/.claude/commands/agent-vibes/hide.md +91 -91
  16. package/.claude/commands/agent-vibes/language.md +23 -23
  17. package/.claude/commands/agent-vibes/learn.md +67 -67
  18. package/.claude/commands/agent-vibes/list.md +13 -13
  19. package/.claude/commands/agent-vibes/mute.md +37 -37
  20. package/.claude/commands/agent-vibes/preview.md +17 -17
  21. package/.claude/commands/agent-vibes/provider.md +68 -68
  22. package/.claude/commands/agent-vibes/replay-target.md +14 -14
  23. package/.claude/commands/agent-vibes/sample.md +12 -12
  24. package/.claude/commands/agent-vibes/set-favorite-voice.md +84 -84
  25. package/.claude/commands/agent-vibes/set-pretext.md +65 -65
  26. package/.claude/commands/agent-vibes/set-speed.md +41 -41
  27. package/.claude/commands/agent-vibes/show.md +84 -84
  28. package/.claude/commands/agent-vibes/switch.md +87 -87
  29. package/.claude/commands/agent-vibes/target-voice.md +26 -26
  30. package/.claude/commands/agent-vibes/target.md +30 -30
  31. package/.claude/commands/agent-vibes/translate.md +68 -68
  32. package/.claude/commands/agent-vibes/unmute.md +45 -45
  33. package/.claude/commands/agent-vibes/verbosity.md +89 -89
  34. package/.claude/commands/agent-vibes/whoami.md +7 -7
  35. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  36. package/.claude/commands/agent-vibes-rdp.md +24 -24
  37. package/.claude/config/agentvibes.json +1 -0
  38. package/.claude/config/audio-effects.cfg +2 -2
  39. package/.claude/config/audio-effects.cfg.sample +52 -52
  40. package/.claude/config/background-music-volume.txt +1 -0
  41. package/.claude/config/intro-text.txt +1 -0
  42. package/.claude/config/piper-speech-rate.txt +4 -0
  43. package/.claude/config/piper-target-speech-rate.txt +1 -0
  44. package/.claude/config/reverb-level.txt +1 -0
  45. package/.claude/config/tts-speech-rate.txt +4 -0
  46. package/.claude/config/tts-target-speech-rate.txt +1 -0
  47. package/.claude/docs/TERMUX_SETUP.md +408 -408
  48. package/.claude/github-star-reminder.txt +1 -1
  49. package/.claude/hooks/README-TTS-QUEUE.md +135 -135
  50. package/.claude/hooks/audio-cache-utils.sh +246 -246
  51. package/.claude/hooks/audio-processor.sh +433 -433
  52. package/.claude/hooks/background-music-manager.sh +404 -404
  53. package/.claude/hooks/bmad-speak-enhanced.sh +165 -165
  54. package/.claude/hooks/bmad-speak.sh +269 -269
  55. package/.claude/hooks/bmad-tts-injector.sh +568 -568
  56. package/.claude/hooks/bmad-voice-manager.sh +928 -928
  57. package/.claude/hooks/clawdbot-receiver-SECURE.sh +129 -129
  58. package/.claude/hooks/clawdbot-receiver.sh +107 -107
  59. package/.claude/hooks/clean-audio-cache.sh +22 -22
  60. package/.claude/hooks/cleanup-cache.sh +106 -106
  61. package/.claude/hooks/configure-rdp-mode.sh +137 -137
  62. package/.claude/hooks/download-extra-voices.sh +244 -244
  63. package/.claude/hooks/effects-manager.sh +268 -268
  64. package/.claude/hooks/github-star-reminder.sh +154 -154
  65. package/.claude/hooks/language-manager.sh +362 -362
  66. package/.claude/hooks/learn-manager.sh +492 -492
  67. package/.claude/hooks/macos-voice-manager.sh +205 -205
  68. package/.claude/hooks/migrate-background-music.sh +125 -125
  69. package/.claude/hooks/migrate-to-agentvibes.sh +161 -161
  70. package/.claude/hooks/optimize-background-music.sh +87 -87
  71. package/.claude/hooks/path-resolver.sh +60 -60
  72. package/.claude/hooks/personality-manager.sh +448 -448
  73. package/.claude/hooks/piper-download-voices.sh +225 -225
  74. package/.claude/hooks/piper-installer.sh +292 -292
  75. package/.claude/hooks/piper-multispeaker-registry.sh +171 -171
  76. package/.claude/hooks/piper-voice-manager.sh +24 -3
  77. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -90
  78. package/.claude/hooks/play-tts-enhanced.sh +105 -105
  79. package/.claude/hooks/play-tts-macos.sh +368 -368
  80. package/.claude/hooks/play-tts-piper.sh +679 -679
  81. package/.claude/hooks/play-tts-soprano.sh +356 -356
  82. package/.claude/hooks/play-tts-ssh-remote.sh +167 -167
  83. package/.claude/hooks/play-tts-termux-ssh.sh +169 -169
  84. package/.claude/hooks/play-tts.sh +301 -301
  85. package/.claude/hooks/prepare-release.sh +54 -54
  86. package/.claude/hooks/provider-commands.sh +617 -617
  87. package/.claude/hooks/provider-manager.sh +399 -399
  88. package/.claude/hooks/replay-target-audio.sh +95 -95
  89. package/.claude/hooks/requirements.txt +6 -6
  90. package/.claude/hooks/sentiment-manager.sh +201 -201
  91. package/.claude/hooks/session-start-tts.sh +81 -81
  92. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  93. package/.claude/hooks/speed-manager.sh +291 -291
  94. package/.claude/hooks/stop-tts.sh +84 -84
  95. package/.claude/hooks/termux-installer.sh +261 -261
  96. package/.claude/hooks/translate-manager.sh +341 -341
  97. package/.claude/hooks/translator.py +237 -237
  98. package/.claude/hooks/tts-queue-worker.sh +145 -145
  99. package/.claude/hooks/tts-queue.sh +165 -165
  100. package/.claude/hooks/verbosity-manager.sh +178 -178
  101. package/.claude/hooks/voice-manager.sh +548 -548
  102. package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
  103. package/.claude/hooks-windows/background-music-manager.ps1 +348 -0
  104. package/.claude/hooks-windows/clean-audio-cache.ps1 +53 -0
  105. package/.claude/hooks-windows/download-extra-voices.ps1 +185 -0
  106. package/.claude/hooks-windows/effects-manager.ps1 +294 -0
  107. package/.claude/hooks-windows/language-manager.ps1 +193 -0
  108. package/.claude/hooks-windows/learn-manager.ps1 +241 -0
  109. package/.claude/hooks-windows/personality-manager.ps1 +266 -0
  110. package/.claude/hooks-windows/play-tts-piper.ps1 +209 -0
  111. package/.claude/hooks-windows/play-tts-sapi.ps1 +108 -0
  112. package/.claude/hooks-windows/play-tts-soprano.ps1 +159 -158
  113. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +50 -5
  114. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -108
  115. package/.claude/hooks-windows/play-tts.ps1 +344 -266
  116. package/.claude/hooks-windows/provider-manager.ps1 +29 -10
  117. package/.claude/hooks-windows/session-start-tts.ps1 +124 -124
  118. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  119. package/.claude/hooks-windows/speed-manager.ps1 +166 -0
  120. package/.claude/hooks-windows/verbosity-manager.ps1 +119 -0
  121. package/.claude/hooks-windows/voice-manager-windows.ps1 +92 -8
  122. package/.claude/output-styles/agent-vibes.md +202 -202
  123. package/.claude/personalities/angry.md +14 -14
  124. package/.claude/personalities/annoying.md +14 -14
  125. package/.claude/personalities/crass.md +14 -14
  126. package/.claude/personalities/dramatic.md +14 -14
  127. package/.claude/personalities/dry-humor.md +50 -50
  128. package/.claude/personalities/flirty.md +20 -20
  129. package/.claude/personalities/funny.md +14 -14
  130. package/.claude/personalities/grandpa.md +32 -32
  131. package/.claude/personalities/millennial.md +14 -14
  132. package/.claude/personalities/moody.md +14 -14
  133. package/.claude/personalities/normal.md +16 -16
  134. package/.claude/personalities/pirate.md +14 -14
  135. package/.claude/personalities/poetic.md +14 -14
  136. package/.claude/personalities/professional.md +14 -14
  137. package/.claude/personalities/rapper.md +55 -55
  138. package/.claude/personalities/robot.md +14 -14
  139. package/.claude/personalities/sarcastic.md +38 -38
  140. package/.claude/personalities/sassy.md +14 -14
  141. package/.claude/personalities/surfer-dude.md +14 -14
  142. package/.claude/personalities/zen.md +14 -14
  143. package/.claude/settings.json +15 -15
  144. package/.claude/verbosity.txt +1 -1
  145. package/.clawdbot/README.md +105 -105
  146. package/.clawdbot/skill/SKILL.md +241 -241
  147. package/.mcp.json +12 -0
  148. package/CLAUDE.md +170 -170
  149. package/README.md +2029 -2007
  150. package/RELEASE_NOTES.md +1310 -1203
  151. package/WINDOWS-SETUP.md +208 -208
  152. package/bin/agent-vibes +39 -39
  153. package/bin/agentvibes-voice-browser.js +1840 -1840
  154. package/bin/agentvibes.js +48 -2
  155. package/bin/mcp-server.js +121 -121
  156. package/bin/mcp-server.sh +206 -206
  157. package/bin/test-bmad-pr +78 -78
  158. package/mcp-server/QUICK_START.md +203 -203
  159. package/mcp-server/README.md +345 -345
  160. package/mcp-server/WINDOWS_SETUP.md +260 -260
  161. package/mcp-server/docs/troubleshooting-audio.md +313 -313
  162. package/mcp-server/examples/claude_desktop_config.json +11 -11
  163. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  164. package/mcp-server/examples/custom_instructions.md +169 -169
  165. package/mcp-server/install-deps.js +130 -130
  166. package/mcp-server/pyproject.toml +52 -52
  167. package/mcp-server/requirements.txt +2 -2
  168. package/mcp-server/server.py +1465 -1453
  169. package/mcp-server/test_server.py +395 -395
  170. package/mcp-server/test_windows_script_parity.py +336 -0
  171. package/package.json +110 -110
  172. package/setup-windows.ps1 +815 -815
  173. package/src/bmad-detector.js +71 -71
  174. package/src/cli/list-personalities.js +110 -110
  175. package/src/cli/list-voices.js +114 -114
  176. package/src/commands/bmad-voices.js +394 -394
  177. package/src/commands/install-mcp.js +476 -476
  178. package/src/console/app.js +824 -824
  179. package/src/console/audio-env.js +20 -1
  180. package/src/console/brand-colors.js +13 -13
  181. package/src/console/constants/personalities.js +44 -44
  182. package/src/console/footer-config.js +50 -50
  183. package/src/console/modals/modal-overlay.js +247 -247
  184. package/src/console/navigation.js +62 -62
  185. package/src/console/tabs/agents-tab.js +1684 -1516
  186. package/src/console/tabs/help-tab.js +261 -261
  187. package/src/console/tabs/install-tab.js +1007 -991
  188. package/src/console/tabs/music-tab.js +22 -8
  189. package/src/console/tabs/placeholder-tab.js +53 -53
  190. package/src/console/tabs/readme-tab.js +267 -267
  191. package/src/console/tabs/receiver-tab.js +1472 -1212
  192. package/src/console/tabs/settings-tab.js +208 -84
  193. package/src/console/tabs/voices-tab.js +100 -21
  194. package/src/console/widgets/destroy-list.js +25 -25
  195. package/src/console/widgets/format-utils.js +89 -89
  196. package/src/console/widgets/notice.js +55 -55
  197. package/src/console/widgets/personality-picker.js +185 -185
  198. package/src/console/widgets/reverb-picker.js +94 -94
  199. package/src/console/widgets/track-picker.js +285 -285
  200. package/src/installer/music-file-input.js +304 -304
  201. package/src/installer.js +5895 -5829
  202. package/src/services/agent-voice-store.js +423 -423
  203. package/src/services/config-service.js +264 -264
  204. package/src/services/navigation-service.js +123 -123
  205. package/src/services/provider-service.js +143 -132
  206. package/src/services/verbosity-service.js +157 -157
  207. package/src/utils/audio-duration-validator.js +298 -298
  208. package/src/utils/audio-format-validator.js +277 -277
  209. package/src/utils/dependency-checker.js +469 -466
  210. package/src/utils/file-ownership-verifier.js +358 -358
  211. package/src/utils/list-formatter.js +194 -194
  212. package/src/utils/music-file-validator.js +285 -285
  213. package/src/utils/preview-list-prompt.js +136 -136
  214. package/src/utils/provider-validator.js +96 -12
  215. package/src/utils/secure-music-storage.js +412 -412
  216. package/templates/agentvibes-receiver.sh +482 -482
  217. package/templates/audio/welcome-music.mp3 +0 -0
  218. package/voice-assignments.json +8244 -8244
  219. package/.claude/config/background-music-position.txt +0 -1
@@ -1,1516 +1,1684 @@
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 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;
32
-
33
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
34
-
35
- let blessed;
36
- if (!IS_TEST) {
37
- const { default: b } = await import('blessed');
38
- blessed = b;
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
-
43
- const COLORS = {
44
- contentBg: '#0a0e1a',
45
- sectionHdr: '#7b1fa2',
46
- labelFg: '#e3f2fd',
47
- valueFg: '#ffff00',
48
- activeFg: '#ce93d8',
49
- btnDefault: '#6a1b9a',
50
- btnFocus: '#9c27b0',
51
- btnFocusFg: '#ffffff',
52
- btnPress: '#ff00ff',
53
- borderFg: '#9c27b0',
54
- footerBg: '#9c27b0',
55
- noticeFg: '#90a4ae',
56
- warnFg: '#ff9800',
57
- linkFg: '#00e5ff',
58
- };
59
-
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}`;
78
-
79
- // ---------------------------------------------------------------------------
80
-
81
- function createTestStub() {
82
- return {
83
- box: {},
84
- show: () => {},
85
- hide: () => {},
86
- onFocus: () => {},
87
- onBlur: () => {},
88
- getFooterText: () => FOOTER_TEXT_BMAD,
89
- getFooterColor: () => COLORS.footerBg,
90
- };
91
- }
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
-
126
- // ---------------------------------------------------------------------------
127
-
128
- /**
129
- * Create the Agents tab component.
130
- */
131
- export function createAgentsTab(screen, services) {
132
- if (IS_TEST) return createTestStub();
133
-
134
- const { configService, providerService, focusMainTabBar, navigationService } = services;
135
- const voiceStore = new AgentVoiceStore();
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
-
156
- // -------------------------------------------------------------------------
157
- // Container
158
-
159
- const box = blessed.box({
160
- parent: screen,
161
- top: 4,
162
- left: 0,
163
- width: '100%',
164
- bottom: 2,
165
- hidden: true,
166
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
167
- border: { type: 'line' },
168
- borderStyle: { fg: COLORS.borderFg },
169
- });
170
-
171
- // -------------------------------------------------------------------------
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
192
-
193
- const sectionHeader = blessed.text({
194
- parent: box,
195
- top: 1,
196
- left: 2,
197
- hidden: true,
198
- content: `{#7b1fa2-fg}── BMAD Agents ${'─'.repeat(53)}{/#7b1fa2-fg}`,
199
- tags: true,
200
- style: { bg: COLORS.contentBg },
201
- });
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
-
214
- // -------------------------------------------------------------------------
215
- // Agent list
216
-
217
- const agentList = blessed.list({
218
- parent: box,
219
- top: 3,
220
- left: 2,
221
- width: '96%',
222
- height: '55%',
223
- hidden: true,
224
- keys: true,
225
- vi: true,
226
- mouse: true,
227
- tags: true,
228
- border: { type: 'line' },
229
- scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
230
- style: {
231
- fg: COLORS.labelFg,
232
- bg: COLORS.contentBg,
233
- border: { fg: COLORS.borderFg },
234
- selected: { bg: '#4a148c', fg: COLORS.activeFg, bold: true },
235
- item: { fg: COLORS.labelFg },
236
- },
237
- });
238
-
239
- // -------------------------------------------------------------------------
240
- // Status panel
241
-
242
- const statusDivider = blessed.text({
243
- parent: box,
244
- top: '64%',
245
- left: 2,
246
- hidden: true,
247
- content: `{#7b1fa2-fg}── Status ${'─'.repeat(58)}{/#7b1fa2-fg}`,
248
- tags: true,
249
- style: { bg: COLORS.contentBg },
250
- });
251
-
252
- const statusLine = blessed.text({
253
- parent: box,
254
- top: '69%',
255
- left: 2,
256
- hidden: true,
257
- tags: true,
258
- content: '',
259
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
260
- });
261
-
262
- const warningLine = blessed.text({
263
- parent: box,
264
- top: '74%',
265
- left: 2,
266
- hidden: true,
267
- tags: true,
268
- content: '',
269
- style: { fg: COLORS.warnFg, bg: COLORS.contentBg },
270
- });
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
-
283
- // -------------------------------------------------------------------------
284
- // Buttons
285
-
286
- function _createBtn(label, onClick) {
287
- const btn = blessed.button({
288
- parent: box,
289
- content: label,
290
- mouse: true,
291
- keys: true,
292
- shrink: true,
293
- hidden: true,
294
- padding: { left: 1, right: 1 },
295
- style: {
296
- bg: COLORS.btnDefault,
297
- fg: 'white',
298
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
299
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
300
- },
301
- });
302
- btn.on('focus', () => {
303
- const raw = btn.content.replace(/[►◄]/g, '').trim();
304
- btn.setContent(`►${raw}◄`);
305
- screen.render();
306
- });
307
- btn.on('blur', () => {
308
- const raw = btn.content.replace(/[►◄]/g, '').trim();
309
- btn.setContent(raw);
310
- screen.render();
311
- });
312
- btn.key(['enter', 'space'], () => {
313
- btn.style.bg = COLORS.btnPress;
314
- screen.render();
315
- setTimeout(() => {
316
- btn.style.bg = COLORS.btnDefault;
317
- screen.render();
318
- onClick();
319
- }, 150);
320
- });
321
- btn.on('click', () => btn.press());
322
- btn.on('mouseover', () => btn.focus());
323
- return btn;
324
- }
325
-
326
- const resetBtn = _createBtn('[X] Reset', () => {
327
- const agent = _agents[agentList.selected];
328
- if (agent) {
329
- voiceStore.resetAgentProfile(agent.id);
330
- refreshDisplay();
331
- }
332
- });
333
- resetBtn.bottom = 4;
334
- resetBtn.left = 4;
335
-
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;
343
-
344
- // -------------------------------------------------------------------------
345
- // Show/hide helpers for the two states
346
-
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
- }
353
-
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) {
363
- if (agents.length === 0) {
364
- return [' (no BMAD agents detected)'];
365
- }
366
- return agents.map(a => {
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}`;
386
- });
387
- }
388
-
389
- // -------------------------------------------------------------------------
390
- // Refresh display
391
-
392
- function refreshDisplay() {
393
- _bmadDetected = isBmadDetected(_projectRoot);
394
- _agents = scanBmadAgents(_projectRoot);
395
-
396
- if (!_bmadDetected) {
397
- _showOnboardingState();
398
- screen.render();
399
- return;
400
- }
401
-
402
- _showBmadState();
403
-
404
- const items = _buildListItems(_agents);
405
- agentList.setItems(items);
406
-
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();
787
-
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
- }
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();
1346
- screen.render();
1347
- }
1348
-
1349
- // -------------------------------------------------------------------------
1350
- // Key bindings
1351
-
1352
- agentList.key(['x', 'X'], () => {
1353
- const agent = _agents[agentList.selected];
1354
- if (agent) {
1355
- voiceStore.resetAgentProfile(agent.id);
1356
- refreshDisplay();
1357
- }
1358
- });
1359
-
1360
-
1361
- agentList.key(['enter'], () => {
1362
- const agent = _agents[agentList.selected];
1363
- if (agent) _openAgentDetailPanel(agent);
1364
- });
1365
-
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']);
1376
- agentList.on('keypress', (ch, key) => {
1377
- if (!ch || key.ctrl || key.meta) return;
1378
- const lower = ch.toLowerCase();
1379
- if (!/^[a-z]$/.test(lower)) return;
1380
- if (_agentJumpBlocked.has(lower)) return;
1381
- const count = _agents.length;
1382
- if (count === 0) return;
1383
- const start = agentList.selected ?? 0;
1384
- for (let i = 1; i <= count; i++) {
1385
- const idx = (start + i) % count;
1386
- const name = (_agents[idx]?.displayName ?? '').toLowerCase();
1387
- if (name.startsWith(lower)) {
1388
- agentList.select(idx);
1389
- screen.render();
1390
- break;
1391
- }
1392
- }
1393
- });
1394
-
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
1419
- let _alBlink = { interval: null, on: false, sel: -1 };
1420
- function _alTick() {
1421
- _alBlink.on = !_alBlink.on;
1422
- const items = agentList.items;
1423
- const cur = agentList.selected ?? 0;
1424
- if (_alBlink.sel !== cur && _alBlink.sel >= 0 && items[_alBlink.sel]) {
1425
- items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '').replace(/ █$/, ''));
1426
- }
1427
- _alBlink.sel = cur;
1428
- if (items[cur]) {
1429
- const base = (items[cur].content ?? '').replace(/ █$/, '');
1430
- items[cur].setContent(_alBlink.on ? `${base} █` : base);
1431
- }
1432
- screen.render();
1433
- }
1434
- agentList.on('focus', () => {
1435
- _listFocused = true;
1436
- _alBlink.on = true;
1437
- _alBlink.sel = agentList.selected ?? 0;
1438
- _hintIdx = -1;
1439
- _hintBase = '';
1440
- _updateHint(_alBlink.sel);
1441
- const items = agentList.items;
1442
- if (items[_alBlink.sel]) items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '') + ' █');
1443
- screen.render();
1444
- _alBlink.interval = setInterval(_alTick, 500);
1445
- });
1446
- agentList.on('blur', () => {
1447
- _listFocused = false;
1448
- if (_alBlink.interval) { clearInterval(_alBlink.interval); _alBlink.interval = null; }
1449
- const items = agentList.items;
1450
- const sel = agentList.selected ?? 0;
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 = '';
1459
- screen.render();
1460
- });
1461
- agentList.on('select item', () => {
1462
- _updateHint(agentList.selected ?? 0);
1463
- if (_alBlink.interval) _alTick();
1464
- });
1465
-
1466
- // Navigation: up at top → tab bar, escape → tab bar
1467
- agentList.key(['up'], () => {
1468
- if (agentList.selected === 0 && typeof focusMainTabBar === 'function') {
1469
- focusMainTabBar();
1470
- setTimeout(() => { agentList.select(0); screen.render(); }, 0);
1471
- }
1472
- });
1473
- agentList.key(['escape'], () => {
1474
- if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1475
- });
1476
-
1477
- // -------------------------------------------------------------------------
1478
- // Tab Component Contract
1479
-
1480
- return {
1481
- box,
1482
-
1483
- show() {
1484
- box.show();
1485
- refreshDisplay();
1486
- screen.render();
1487
- },
1488
-
1489
- hide() {
1490
- _killPreview();
1491
- box.hide();
1492
- screen.render();
1493
- },
1494
-
1495
- onFocus() {
1496
- if (_bmadDetected) {
1497
- agentList.focus();
1498
- } else {
1499
- onboardingBox.focus();
1500
- }
1501
- screen.render();
1502
- },
1503
-
1504
- onBlur() {
1505
- _killPreview();
1506
- },
1507
-
1508
- getFooterText() {
1509
- return _bmadDetected ? FOOTER_TEXT_BMAD : FOOTER_TEXT_NOBMAD;
1510
- },
1511
-
1512
- getFooterColor() {
1513
- return COLORS.footerBg;
1514
- },
1515
- };
1516
- }
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 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;
32
+
33
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
34
+
35
+ let blessed;
36
+ if (!IS_TEST) {
37
+ const { default: b } = await import('blessed');
38
+ blessed = b;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const COLORS = {
44
+ contentBg: '#0a0e1a',
45
+ sectionHdr: '#7b1fa2',
46
+ labelFg: '#e3f2fd',
47
+ valueFg: '#ffff00',
48
+ activeFg: '#ce93d8',
49
+ btnDefault: '#6a1b9a',
50
+ btnFocus: '#2e7d32', // Green — focused/selected
51
+ btnFocusFg: '#ffffff',
52
+ btnPress: '#ff00ff',
53
+ borderFg: '#9c27b0',
54
+ footerBg: '#9c27b0',
55
+ noticeFg: '#90a4ae',
56
+ warnFg: '#ff9800',
57
+ linkFg: 'bright-cyan',
58
+ };
59
+
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}`;
78
+
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function createTestStub() {
82
+ return {
83
+ box: {},
84
+ show: () => {},
85
+ hide: () => {},
86
+ onFocus: () => {},
87
+ onBlur: () => {},
88
+ getFooterText: () => FOOTER_TEXT_BMAD,
89
+ getFooterColor: () => COLORS.footerBg,
90
+ };
91
+ }
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
+ {bright-cyan-fg}npx bmad-method install{/bright-cyan-fg}
114
+
115
+
116
+ {bold}Learn more:{/bold}
117
+
118
+ {bright-cyan-fg}https://docs.bmad-method.org/{/bright-cyan-fg}
119
+ {bright-cyan-fg}https://github.com/bmad-code-org/BMAD-METHOD{/bright-cyan-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
+
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Create the Agents tab component.
130
+ */
131
+ export function createAgentsTab(screen, services) {
132
+ if (IS_TEST) return createTestStub();
133
+
134
+ const { configService, providerService, focusMainTabBar, navigationService } = services;
135
+ const voiceStore = new AgentVoiceStore();
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
+
156
+ // -------------------------------------------------------------------------
157
+ // Container
158
+
159
+ const box = blessed.box({
160
+ parent: screen,
161
+ top: 4,
162
+ left: 0,
163
+ width: '100%',
164
+ bottom: 2,
165
+ hidden: true,
166
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
167
+ border: { type: 'line' },
168
+ borderStyle: { fg: COLORS.borderFg },
169
+ });
170
+
171
+ // -------------------------------------------------------------------------
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
192
+
193
+ const sectionHeader = blessed.text({
194
+ parent: box,
195
+ top: 1,
196
+ left: 2,
197
+ hidden: true,
198
+ content: `{#7b1fa2-fg}── BMAD Agents ${'─'.repeat(53)}{/#7b1fa2-fg}`,
199
+ tags: true,
200
+ style: { bg: COLORS.contentBg },
201
+ });
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
+
214
+ // -------------------------------------------------------------------------
215
+ // Agent list
216
+
217
+ const agentList = blessed.list({
218
+ parent: box,
219
+ top: 3,
220
+ left: 2,
221
+ width: '96%',
222
+ height: '55%',
223
+ hidden: true,
224
+ keys: true,
225
+ vi: true,
226
+ mouse: true,
227
+ tags: true,
228
+ border: { type: 'line' },
229
+ scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
230
+ style: {
231
+ fg: COLORS.labelFg,
232
+ bg: COLORS.contentBg,
233
+ border: { fg: COLORS.borderFg },
234
+ selected: { bg: '#4a148c', fg: COLORS.activeFg, bold: true },
235
+ item: { fg: COLORS.labelFg },
236
+ },
237
+ });
238
+
239
+ // -------------------------------------------------------------------------
240
+ // Status panel
241
+
242
+ const statusDivider = blessed.text({
243
+ parent: box,
244
+ top: '64%',
245
+ left: 2,
246
+ hidden: true,
247
+ content: `{#7b1fa2-fg}── Status ${'─'.repeat(58)}{/#7b1fa2-fg}`,
248
+ tags: true,
249
+ style: { bg: COLORS.contentBg },
250
+ });
251
+
252
+ const statusLine = blessed.text({
253
+ parent: box,
254
+ top: '69%',
255
+ left: 2,
256
+ hidden: true,
257
+ tags: true,
258
+ content: '',
259
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
260
+ });
261
+
262
+ const warningLine = blessed.text({
263
+ parent: box,
264
+ top: '74%',
265
+ left: 2,
266
+ hidden: true,
267
+ tags: true,
268
+ content: '',
269
+ style: { fg: COLORS.warnFg, bg: COLORS.contentBg },
270
+ });
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
+
283
+ // -------------------------------------------------------------------------
284
+ // Buttons
285
+
286
+ function _createBtn(label, onClick) {
287
+ const btn = blessed.button({
288
+ parent: box,
289
+ content: label,
290
+ mouse: true,
291
+ keys: true,
292
+ shrink: true,
293
+ hidden: true,
294
+ padding: { left: 1, right: 1 },
295
+ style: {
296
+ bg: COLORS.btnDefault,
297
+ fg: 'white',
298
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
299
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
300
+ },
301
+ });
302
+ btn.on('focus', () => {
303
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
304
+ btn.setContent(`►${raw}◄`);
305
+ screen.render();
306
+ });
307
+ btn.on('blur', () => {
308
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
309
+ btn.setContent(raw);
310
+ screen.render();
311
+ });
312
+ btn.key(['enter', 'space'], () => {
313
+ btn.style.bg = COLORS.btnPress;
314
+ screen.render();
315
+ setTimeout(() => {
316
+ btn.style.bg = COLORS.btnDefault;
317
+ screen.render();
318
+ onClick();
319
+ }, 150);
320
+ });
321
+ btn.on('click', () => btn.press());
322
+ btn.on('mouseover', () => btn.focus());
323
+ return btn;
324
+ }
325
+
326
+ const resetBtn = _createBtn('[X] Reset', () => {
327
+ const agent = _agents[agentList.selected];
328
+ if (agent) {
329
+ voiceStore.resetAgentProfile(agent.id);
330
+ refreshDisplay();
331
+ }
332
+ });
333
+ resetBtn.bottom = 4;
334
+ resetBtn.left = 4;
335
+
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;
343
+
344
+ // -------------------------------------------------------------------------
345
+ // Show/hide helpers for the two states
346
+
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
+ }
353
+
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) {
363
+ if (agents.length === 0) {
364
+ return [' (no BMAD agents detected)'];
365
+ }
366
+ return agents.map(a => {
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}`;
386
+ });
387
+ }
388
+
389
+ // -------------------------------------------------------------------------
390
+ // Refresh display
391
+
392
+ function refreshDisplay() {
393
+ _bmadDetected = isBmadDetected(_projectRoot);
394
+ _agents = scanBmadAgents(_projectRoot);
395
+
396
+ if (!_bmadDetected) {
397
+ _showOnboardingState();
398
+ screen.render();
399
+ return;
400
+ }
401
+
402
+ _showBmadState();
403
+
404
+ const items = _buildListItems(_agents);
405
+ agentList.setItems(items);
406
+
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 = '{bright-cyan-fg}';
448
+ const _SPIN_SFX = '{/bright-cyan-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
+ // Resolve piper binary — shared helper to avoid duplication (#153)
491
+
492
+ function _resolvePiperBin() {
493
+ if (process.platform !== 'win32' || process.env.WSL_DISTRO_NAME) return 'piper';
494
+ const localAppData = process.env.LOCALAPPDATA ||
495
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
496
+ if (localAppData) {
497
+ const exePath = path.join(localAppData, 'Programs', 'Piper', 'piper.exe');
498
+ if (fs.existsSync(exePath)) return exePath;
499
+ }
500
+ return 'piper';
501
+ }
502
+
503
+ // -------------------------------------------------------------------------
504
+ // Kill any playing preview
505
+
506
+ function _killPreview() {
507
+ _stopSpinner();
508
+ if (_playingProcess) {
509
+ try {
510
+ // On Windows, negative-PID process group kill is unsupported
511
+ if (process.platform === 'win32') {
512
+ _playingProcess.kill();
513
+ } else {
514
+ process.kill(-_playingProcess.pid, 'SIGTERM');
515
+ }
516
+ } catch {}
517
+ _playingProcess = null;
518
+ }
519
+ }
520
+
521
+ // -------------------------------------------------------------------------
522
+ // Sample an agent with their full profile (voice + pretext + reverb + music)
523
+ // Uses play-tts-enhanced.sh for the complete effects pipeline.
524
+
525
+ function _sampleAgent(agent) {
526
+ const profile = voiceStore.getAgentProfile(agent.id);
527
+ const globalCfg = configService.getConfig();
528
+ _sampleWithFullProfile(agent, {
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
+
541
+ // -------------------------------------------------------------------------
542
+ // Agent detail panel (modal overlay)
543
+
544
+ function _openAgentDetailPanel(agent) {
545
+ const profile = voiceStore.getAgentProfile(agent.id);
546
+ const globalCfg = configService.getConfig();
547
+
548
+ // Working copy of the profile being edited
549
+ const draft = {
550
+ voice: profile.voice || globalCfg.voice || '',
551
+ pretext: profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title),
552
+ reverbPreset: profile.reverbPreset || globalCfg.effects?.reverbPreset || 'light',
553
+ personality: profile.personality || globalCfg.personality || 'none',
554
+ backgroundMusic: {
555
+ track: profile.backgroundMusic?.track || globalCfg.backgroundMusic?.track || '',
556
+ volume: profile.backgroundMusic?.volume ?? globalCfg.backgroundMusic?.volume ?? 70,
557
+ enabled: profile.backgroundMusic?.enabled ?? globalCfg.backgroundMusic?.enabled ?? false,
558
+ },
559
+ };
560
+
561
+ let _closed = false;
562
+ navigationService?.openModal();
563
+
564
+ const modal = blessed.box({
565
+ parent: screen,
566
+ top: 'center',
567
+ left: 'center',
568
+ width: 72,
569
+ height: 18,
570
+ border: { type: 'line' },
571
+ tags: true,
572
+ label: _modalTitle(`${agent.icon || '🧙'} ${agent.displayName} (${agent.title || 'Agent'})`),
573
+ style: {
574
+ fg: COLORS.labelFg,
575
+ bg: COLORS.contentBg,
576
+ border: { fg: COLORS.btnFocus },
577
+ },
578
+ });
579
+ modal.setFront();
580
+
581
+ // Field definitions
582
+ const FIELDS = [
583
+ { key: 'voice', label: 'Voice', getValue: () => draft.voice || '(global default)' },
584
+ { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(default)' },
585
+ { key: 'reverbPreset', label: 'Reverb', getValue: () => formatReverbState(draft.reverbPreset) },
586
+ { key: 'personality', label: 'Personality', getValue: () => {
587
+ const p = draft.personality;
588
+ const emoji = PERSONALITY_EMOJIS[p] || '';
589
+ return `${emoji} ${p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1)}`;
590
+ }},
591
+ { key: 'music', label: 'Music', getValue: () => {
592
+ if (!draft.backgroundMusic.enabled) return '(disabled)';
593
+ return `${formatTrackName(draft.backgroundMusic.track)} Vol:${draft.backgroundMusic.volume}%`;
594
+ }},
595
+ ];
596
+
597
+ // Build field list items
598
+ function _fieldItems() {
599
+ return FIELDS.map(f => {
600
+ const label = f.label.padEnd(14);
601
+ const val = f.getValue();
602
+ return ` ${label} ${val}`;
603
+ });
604
+ }
605
+
606
+ const fieldList = blessed.list({
607
+ parent: modal,
608
+ top: 1,
609
+ left: 2,
610
+ right: 2,
611
+ height: FIELDS.length + 2,
612
+ keys: true,
613
+ vi: true,
614
+ mouse: true,
615
+ border: { type: 'line' },
616
+ tags: true,
617
+ style: {
618
+ fg: COLORS.labelFg,
619
+ bg: COLORS.contentBg,
620
+ border: { fg: '#4a148c' },
621
+ selected: { bg: '#4a148c', fg: COLORS.activeFg, bold: true },
622
+ item: { fg: COLORS.labelFg },
623
+ },
624
+ });
625
+ fieldList.setItems(_fieldItems());
626
+
627
+ // Key hint
628
+ blessed.text({
629
+ parent: modal,
630
+ bottom: 4,
631
+ left: 2,
632
+ right: 2,
633
+ tags: true,
634
+ content: '{#455a64-fg}[↑↓] Navigate fields [Enter] Edit field [Space] Sample [Esc] Cancel{/#455a64-fg}',
635
+ style: { bg: COLORS.contentBg },
636
+ });
637
+
638
+ // Buttons
639
+ function _modalBtn(label, leftPos, onClick) {
640
+ const btn = blessed.button({
641
+ parent: modal,
642
+ content: label,
643
+ bottom: 2,
644
+ left: leftPos,
645
+ mouse: true,
646
+ keys: true,
647
+ shrink: true,
648
+ padding: { left: 1, right: 1 },
649
+ style: {
650
+ bg: COLORS.btnDefault,
651
+ fg: 'white',
652
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
653
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
654
+ },
655
+ });
656
+ btn.on('focus', () => {
657
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
658
+ btn.setContent(`►${raw}◄`);
659
+ screen.render();
660
+ });
661
+ btn.on('blur', () => {
662
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
663
+ btn.setContent(raw);
664
+ screen.render();
665
+ });
666
+ btn.key(['enter', 'space'], () => onClick());
667
+ btn.on('click', () => onClick());
668
+ return btn;
669
+ }
670
+
671
+ const saveBtn = _modalBtn('Save', 4, () => {
672
+ // Only save fields that differ from global
673
+ const toSave = {};
674
+ if (draft.voice && draft.voice !== globalCfg.voice) toSave.voice = draft.voice;
675
+ if (draft.pretext !== AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title)) toSave.pretext = draft.pretext;
676
+ if (draft.reverbPreset !== (globalCfg.effects?.reverbPreset || 'light')) toSave.reverbPreset = draft.reverbPreset;
677
+ if (draft.personality !== (globalCfg.personality || 'none')) toSave.personality = draft.personality;
678
+ if (draft.backgroundMusic.track !== (globalCfg.backgroundMusic?.track || '') ||
679
+ draft.backgroundMusic.volume !== (globalCfg.backgroundMusic?.volume ?? 70) ||
680
+ draft.backgroundMusic.enabled !== (globalCfg.backgroundMusic?.enabled ?? false)) {
681
+ toSave.backgroundMusic = draft.backgroundMusic;
682
+ }
683
+ voiceStore.setAgentProfile(agent.id, toSave);
684
+ _closeModal();
685
+ refreshDisplay();
686
+ // Show temporary "Saved!" toast
687
+ _showSavedToast(agent.displayName);
688
+ });
689
+
690
+ const resetAllBtn = _modalBtn('Reset to Defaults', 14, () => {
691
+ voiceStore.resetAgentProfile(agent.id);
692
+ _closeModal();
693
+ refreshDisplay();
694
+ });
695
+
696
+ const cancelBtn = _modalBtn('Cancel', 38, _closeModal);
697
+
698
+ function _closeModal() {
699
+ if (_closed) return;
700
+ _closed = true;
701
+ _killPreview();
702
+ navigationService?.closeModal();
703
+ destroyList(modal, screen);
704
+ }
705
+
706
+ // Field editing via Enter
707
+ fieldList.key(['enter'], () => {
708
+ const idx = fieldList.selected;
709
+ const field = FIELDS[idx];
710
+ if (!field) return;
711
+
712
+ switch (field.key) {
713
+ case 'voice':
714
+ _openVoicePickerForAgent(agent, draft, () => {
715
+ fieldList.setItems(_fieldItems());
716
+ fieldList.select(idx);
717
+ fieldList.focus();
718
+ screen.render();
719
+ });
720
+ break;
721
+
722
+ case 'pretext':
723
+ _openPretextEditor(modal, draft, () => {
724
+ fieldList.setItems(_fieldItems());
725
+ fieldList.select(idx);
726
+ fieldList.focus();
727
+ screen.render();
728
+ });
729
+ break;
730
+
731
+ case 'reverbPreset':
732
+ openReverbPicker(screen, draft.reverbPreset, (val) => {
733
+ draft.reverbPreset = val;
734
+ fieldList.setItems(_fieldItems());
735
+ fieldList.select(idx);
736
+ fieldList.focus();
737
+ screen.render();
738
+ }, () => {
739
+ fieldList.focus();
740
+ screen.render();
741
+ }, { applyToEffectsManager: false });
742
+ break;
743
+
744
+ case 'personality':
745
+ openPersonalityPicker(screen, draft.personality, (val) => {
746
+ draft.personality = val;
747
+ fieldList.setItems(_fieldItems());
748
+ fieldList.select(idx);
749
+ fieldList.focus();
750
+ screen.render();
751
+ }, () => {
752
+ fieldList.focus();
753
+ screen.render();
754
+ });
755
+ break;
756
+
757
+ case 'music':
758
+ openTrackPicker(screen, draft.backgroundMusic.track, draft.backgroundMusic.volume, (track, volume) => {
759
+ draft.backgroundMusic.track = track;
760
+ draft.backgroundMusic.volume = volume;
761
+ draft.backgroundMusic.enabled = true;
762
+ fieldList.setItems(_fieldItems());
763
+ fieldList.select(idx);
764
+ fieldList.focus();
765
+ screen.render();
766
+ }, () => {
767
+ fieldList.focus();
768
+ screen.render();
769
+ });
770
+ break;
771
+ }
772
+ });
773
+
774
+ // Space = sample with current draft
775
+ fieldList.key(['space'], () => {
776
+ const draftAgent = { ...agent };
777
+ // Temporarily set profile for sampling
778
+ _sampleAgentWithDraft(draftAgent, draft);
779
+ });
780
+
781
+ // Escape = close
782
+ fieldList.key(['escape', 'q'], _closeModal);
783
+ saveBtn.key(['escape'], _closeModal);
784
+ resetAllBtn.key(['escape'], _closeModal);
785
+ cancelBtn.key(['escape'], _closeModal);
786
+
787
+ // Tab navigation within modal
788
+ fieldList.key(['tab'], () => { saveBtn.focus(); screen.render(); });
789
+ saveBtn.key(['tab'], () => { resetAllBtn.focus(); screen.render(); });
790
+ resetAllBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
791
+ cancelBtn.key(['tab'], () => { fieldList.focus(); screen.render(); });
792
+
793
+ fieldList.focus();
794
+ screen.render();
795
+ }
796
+
797
+ // -------------------------------------------------------------------------
798
+ // Voice picker for agent detail panel
799
+
800
+ function _openVoicePickerForAgent(agent, draft, onDone) {
801
+ let _allVoices = [];
802
+ let _filterText = '';
803
+ let _previewProc = null;
804
+ let _previewVoiceId = null;
805
+ let _vpClosed = false;
806
+
807
+ const _spawnEnv = buildAudioEnv();
808
+
809
+ function _killVP() {
810
+ if (_previewProc) {
811
+ try {
812
+ if (process.platform === 'win32') {
813
+ _previewProc.kill();
814
+ } else {
815
+ process.kill(-_previewProc.pid, 'SIGTERM');
816
+ }
817
+ } catch {}
818
+ _previewProc = null;
819
+ }
820
+ _previewVoiceId = null;
821
+ }
822
+
823
+ function _closeVP() {
824
+ if (_vpClosed) return;
825
+ _vpClosed = true;
826
+ _killVP();
827
+ destroyList(vpModal, screen, onDone);
828
+ }
829
+
830
+ const vpModal = blessed.box({
831
+ parent: screen,
832
+ top: '6%',
833
+ left: '3%',
834
+ width: '94%',
835
+ height: '88%',
836
+ border: { type: 'line' },
837
+ tags: true,
838
+ label: _modalTitle(`Select Voice for ${agent.icon || ''} ${agent.displayName}`),
839
+ style: {
840
+ fg: COLORS.labelFg,
841
+ bg: COLORS.contentBg,
842
+ border: { fg: 'bright-cyan' },
843
+ },
844
+ });
845
+ vpModal.setFront();
846
+
847
+ // Search
848
+ blessed.text({
849
+ parent: vpModal, top: 1, left: 2,
850
+ content: 'Search:', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
851
+ });
852
+ const vpSearch = blessed.textbox({
853
+ parent: vpModal, top: 1, left: 11, width: 40, height: 1,
854
+ inputOnFocus: true, keys: true,
855
+ style: { fg: COLORS.valueFg, bg: '#1a3a5c', focus: { bg: '#245a80' } },
856
+ });
857
+
858
+ // Column header
859
+ const COL_N = 28;
860
+ const COL_G = 10;
861
+ blessed.text({
862
+ parent: vpModal, top: 2, left: 6, tags: true,
863
+ content: `{bright-cyan-fg}${'Name'.padEnd(COL_N)}${'Gender'.padEnd(COL_G)}Provider{/bright-cyan-fg}`,
864
+ style: { bg: COLORS.contentBg },
865
+ });
866
+
867
+ const vpList = blessed.list({
868
+ parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
869
+ keys: true, vi: true, mouse: true,
870
+ border: { type: 'line' },
871
+ scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
872
+ style: {
873
+ fg: COLORS.labelFg, bg: COLORS.contentBg,
874
+ border: { fg: COLORS.borderFg },
875
+ selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
876
+ item: { fg: COLORS.labelFg },
877
+ },
878
+ });
879
+
880
+ const vpInfoLine = blessed.text({
881
+ parent: vpModal, bottom: 4, left: 2, right: 2, tags: true,
882
+ content: '', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
883
+ });
884
+
885
+ const vpPreviewLine = blessed.text({
886
+ parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
887
+ content: '', style: { fg: 'bright-cyan', bg: COLORS.contentBg },
888
+ });
889
+
890
+ blessed.text({
891
+ parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
892
+ content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [/] Search [Esc] Cancel{/#455a64-fg}',
893
+ style: { bg: COLORS.contentBg },
894
+ });
895
+
896
+ function _getFiltered() {
897
+ if (!_filterText) return _allVoices;
898
+ const f = _filterText.toLowerCase();
899
+ return _allVoices.filter(v => v.toLowerCase().includes(f));
900
+ }
901
+
902
+ function _buildVoiceItems(voices) {
903
+ return voices.map(v => {
904
+ const isActive = v === draft.voice;
905
+ const isPrev = v === _previewVoiceId;
906
+ const dot = isPrev ? '♪' : (isActive ? '' : ' ');
907
+ const meta = getVoiceMeta(v);
908
+ const name = meta.displayName.length > COL_N
909
+ ? meta.displayName.slice(0, COL_N - 1) + ''
910
+ : meta.displayName.padEnd(COL_N);
911
+ return ` ${dot} ${name}${meta.gender.padEnd(COL_G)}${meta.provider}`;
912
+ });
913
+ }
914
+
915
+ function _refreshVP() {
916
+ if (_vpClosed) return;
917
+ _allVoices = scanInstalledVoices();
918
+ const filtered = _getFiltered();
919
+ const items = _buildVoiceItems(filtered);
920
+ vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
921
+ screen.render();
922
+ }
923
+
924
+ function _previewVoice(voiceId) {
925
+ if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); screen.render(); return; }
926
+ _killVP();
927
+
928
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
929
+
930
+ const _ms = parseMultiSpeaker(voiceId);
931
+ const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
932
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
933
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
934
+
935
+ const tempWav = _secureTempWav('vp');
936
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
937
+
938
+ const _piperBin = _resolvePiperBin();
939
+
940
+ const args = ['--model', voicePath, '--output_file', tempWav];
941
+ if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
942
+ const piper = spawn(_piperBin, args, {
943
+ stdio: ['pipe', 'ignore', 'ignore'],
944
+ detached: !_isWin,
945
+ windowsHide: true,
946
+ env: _spawnEnv,
947
+ });
948
+ piper.stdin.write(phrase + '\n');
949
+ piper.stdin.end();
950
+ _previewProc = piper;
951
+ _previewVoiceId = voiceId;
952
+
953
+ if (!_vpClosed) {
954
+ vpPreviewLine.setContent(`{bright-cyan-fg}♪ Synthesizing: ${voiceId}...{/bright-cyan-fg}`);
955
+ screen.render();
956
+ }
957
+
958
+ piper.on('exit', (code) => {
959
+ if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
960
+ if (code !== 0) { _previewProc = null; _previewVoiceId = null; return; }
961
+ const wp = detectWavPlayer(_spawnEnv);
962
+ if (!wp) return;
963
+ const pp = spawn(wp.bin, wp.args(tempWav), {
964
+ stdio: 'ignore',
965
+ detached: !_isWin,
966
+ windowsHide: true,
967
+ env: _spawnEnv,
968
+ });
969
+ _previewProc = pp;
970
+ if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}{/bright-cyan-fg}`); screen.render(); }
971
+ pp.on('exit', () => {
972
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); screen.render(); } }
973
+ try { fs.unlinkSync(tempWav); } catch {}
974
+ });
975
+ });
976
+ piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
977
+ }
978
+
979
+ vpSearch.on('keypress', () => {
980
+ setTimeout(() => { _filterText = vpSearch.getValue().trim(); _refreshVP(); }, 0);
981
+ });
982
+ vpSearch.key(['escape'], () => { vpList.focus(); screen.render(); });
983
+ vpList.key(['/'], () => { vpSearch.clearValue(); vpSearch.focus(); screen.render(); });
984
+ vpList.key(['enter'], () => {
985
+ const filtered = _getFiltered();
986
+ const sel = filtered[vpList.selected];
987
+ if (sel) { draft.voice = sel; _closeVP(); }
988
+ });
989
+ vpList.key(['space'], () => {
990
+ const filtered = _getFiltered();
991
+ const sel = filtered[vpList.selected];
992
+ if (sel) _previewVoice(sel);
993
+ });
994
+ vpList.key(['escape', 'q'], _closeVP);
995
+
996
+ _refreshVP();
997
+ const activeIdx = _getFiltered().indexOf(draft.voice);
998
+ if (activeIdx >= 0) vpList.select(activeIdx);
999
+ vpList.focus();
1000
+ screen.render();
1001
+ }
1002
+
1003
+ // -------------------------------------------------------------------------
1004
+ // Pretext inline editor
1005
+
1006
+ function _openPretextEditor(parentModal, draft, onDone) {
1007
+ const editModal = blessed.box({
1008
+ parent: screen,
1009
+ top: 'center',
1010
+ left: 'center',
1011
+ width: 60,
1012
+ height: 8,
1013
+ border: { type: 'line' },
1014
+ tags: true,
1015
+ label: _modalTitle('Edit Pretext'),
1016
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'bright-cyan' } },
1017
+ });
1018
+ editModal.setFront();
1019
+
1020
+ blessed.text({
1021
+ parent: editModal, top: 1, left: 2,
1022
+ content: 'Agent pretext (spoken before each TTS message):',
1023
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1024
+ });
1025
+
1026
+ const inputBox = blessed.textbox({
1027
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
1028
+ border: { type: 'line' },
1029
+ inputOnFocus: true,
1030
+ value: draft.pretext,
1031
+ style: {
1032
+ fg: COLORS.valueFg, bg: '#0d1b35',
1033
+ border: { fg: COLORS.borderFg },
1034
+ focus: { border: { fg: 'bright-cyan' } },
1035
+ },
1036
+ });
1037
+
1038
+ let _editClosed = false;
1039
+ function _closeEdit(save) {
1040
+ if (_editClosed) return;
1041
+ _editClosed = true;
1042
+ if (save) {
1043
+ const raw = inputBox.getValue().trim();
1044
+ // M7: enforce max pretext length
1045
+ draft.pretext = (raw || draft.pretext).slice(0, MAX_PRETEXT_LENGTH);
1046
+ }
1047
+ destroyList(editModal, screen, onDone);
1048
+ }
1049
+
1050
+ inputBox.key(['enter'], () => _closeEdit(true));
1051
+ inputBox.key(['escape'], () => _closeEdit(false));
1052
+
1053
+ inputBox.focus();
1054
+ screen.render();
1055
+ }
1056
+
1057
+ // -------------------------------------------------------------------------
1058
+ // Sample agent with a draft profile (no save) — same full pipeline
1059
+
1060
+ function _sampleAgentWithDraft(agent, draft) {
1061
+ _sampleWithFullProfile(agent, draft);
1062
+ }
1063
+
1064
+ // -------------------------------------------------------------------------
1065
+ // Shared: sample with full profile via play-tts-enhanced.sh
1066
+ // Writes a temp agent profile JSON, then calls the enhanced TTS pipeline
1067
+ // which applies voice + reverb + background music.
1068
+
1069
+ function _sampleWithFullProfile(agent, profile) {
1070
+ _killPreview();
1071
+ const gen = ++_playGeneration;
1072
+
1073
+ // Start spinner on the agent's row in the list
1074
+ const agentIdx = _agents.findIndex(a => a.id === agent.id);
1075
+ if (agentIdx >= 0) _startSpinner(agentIdx);
1076
+
1077
+ const voiceId = profile.voice || '';
1078
+ const pretext = profile.pretext || AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title);
1079
+ const phrase = `${pretext} ${SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]}`;
1080
+
1081
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1082
+
1083
+ if (isWindows) {
1084
+ // On Windows, synthesize with piper.exe directly then play the wav,
1085
+ // avoiding bash/wsl.exe which opens a visible console window.
1086
+ _sampleWithPiperDirect(gen, voiceId, phrase);
1087
+ } else {
1088
+ // On Linux/macOS/WSL, use play-tts.sh
1089
+ const _spawnEnv = buildAudioEnv();
1090
+ const scriptDir = path.join(_projectRoot, '.claude', 'hooks');
1091
+ const plainScript = path.join(scriptDir, 'play-tts.sh');
1092
+ const args = [plainScript, phrase];
1093
+ if (voiceId) args.push(voiceId);
1094
+
1095
+ const proc = spawn('bash', args, {
1096
+ stdio: ['ignore', 'ignore', 'ignore'],
1097
+ detached: true,
1098
+ env: { ..._spawnEnv },
1099
+ cwd: _projectRoot,
1100
+ });
1101
+ _playingProcess = proc;
1102
+
1103
+ proc.on('exit', () => {
1104
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1105
+ });
1106
+ proc.on('error', () => {
1107
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1108
+ });
1109
+ }
1110
+ }
1111
+
1112
+ /** Windows-native sample: piper.exe wav → detectWavPlayer */
1113
+ function _sampleWithPiperDirect(gen, voiceId, phrase) {
1114
+ const _spawnEnv = buildAudioEnv();
1115
+ const piperBin = _resolvePiperBin();
1116
+
1117
+ // Resolve voice model path
1118
+ const ms = parseMultiSpeaker(voiceId);
1119
+ const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
1120
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
1121
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
1122
+ _stopSpinner();
1123
+ return;
1124
+ }
1125
+
1126
+ const tempWav = path.join(os.tmpdir(), `agentvibes-agent-preview-${Date.now()}.wav`);
1127
+ const piperArgs = ['--model', voicePath, '--output_file', tempWav];
1128
+ if (ms.speakerId != null) piperArgs.push('--speaker', String(ms.speakerId));
1129
+
1130
+ const piper = spawn(piperBin, piperArgs, {
1131
+ stdio: ['pipe', 'ignore', 'ignore'],
1132
+ detached: false,
1133
+ windowsHide: true,
1134
+ env: _spawnEnv,
1135
+ });
1136
+ piper.stdin.write(phrase + '\n');
1137
+ piper.stdin.end();
1138
+ _playingProcess = piper;
1139
+
1140
+ piper.on('exit', (code) => {
1141
+ // Generation changed — another preview was triggered; clean up silently
1142
+ if (gen !== _playGeneration) {
1143
+ try { fs.unlinkSync(tempWav); } catch {}
1144
+ return;
1145
+ }
1146
+ if (code !== 0) {
1147
+ _playingProcess = null;
1148
+ _stopSpinner();
1149
+ try { fs.unlinkSync(tempWav); } catch {}
1150
+ return;
1151
+ }
1152
+
1153
+ // Re-check generation after piper exit to close the race window (#154)
1154
+ if (gen !== _playGeneration) {
1155
+ try { fs.unlinkSync(tempWav); } catch {}
1156
+ return;
1157
+ }
1158
+
1159
+ // Play the synthesized wav
1160
+ const wavPlayer = detectWavPlayer(_spawnEnv);
1161
+ if (!wavPlayer) {
1162
+ _playingProcess = null;
1163
+ _stopSpinner();
1164
+ try { fs.unlinkSync(tempWav); } catch {}
1165
+ return;
1166
+ }
1167
+ const playProc = spawn(wavPlayer.bin, wavPlayer.args(tempWav), {
1168
+ stdio: 'ignore',
1169
+ detached: false,
1170
+ windowsHide: true,
1171
+ env: _spawnEnv,
1172
+ });
1173
+ _playingProcess = playProc;
1174
+
1175
+ playProc.on('exit', () => {
1176
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1177
+ try { fs.unlinkSync(tempWav); } catch {}
1178
+ });
1179
+ playProc.on('error', () => {
1180
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1181
+ try { fs.unlinkSync(tempWav); } catch {}
1182
+ });
1183
+ });
1184
+
1185
+ piper.on('error', () => {
1186
+ if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); }
1187
+ try { fs.unlinkSync(tempWav); } catch {}
1188
+ });
1189
+ }
1190
+
1191
+ // -------------------------------------------------------------------------
1192
+ // Auto-assign helpers
1193
+
1194
+ function _shuffleArray(arr) {
1195
+ const a = [...arr];
1196
+ for (let i = a.length - 1; i > 0; i--) {
1197
+ const j = Math.floor(Math.random() * (i + 1));
1198
+ [a[i], a[j]] = [a[j], a[i]];
1199
+ }
1200
+ return a;
1201
+ }
1202
+
1203
+ // Common first-name → gender map for gender-aware auto-assign.
1204
+ // Only needs to cover names likely used as BMAD agent display names.
1205
+ // Ambiguous names (sam, charlie, dana, max, pat, etc.) are intentionally
1206
+ // omitted so they fall through to the gender-neutral 'other' pool (#156).
1207
+ const _NAME_GENDER = {
1208
+ // Female
1209
+ amelia: 'Female', amy: 'Female', anna: 'Female', betty: 'Female',
1210
+ claire: 'Female', emma: 'Female', faye: 'Female',
1211
+ grace: 'Female', heather: 'Female', ivy: 'Female', jane: 'Female',
1212
+ jenny: 'Female', julia: 'Female', kate: 'Female', laura: 'Female',
1213
+ lily: 'Female', maria: 'Female', mary: 'Female', nina: 'Female',
1214
+ olivia: 'Female', paige: 'Female', rachel: 'Female', sally: 'Female',
1215
+ sara: 'Female', sarah: 'Female', sophie: 'Female', tina: 'Female',
1216
+ wendy: 'Female', zoe: 'Female',
1217
+ // Male
1218
+ alan: 'Male', barry: 'Male', bob: 'Male', carl: 'Male',
1219
+ dan: 'Male', david: 'Male', eric: 'Male',
1220
+ frank: 'Male', george: 'Male', hank: 'Male', jack: 'Male',
1221
+ james: 'Male', joe: 'Male', john: 'Male', kevin: 'Male',
1222
+ leo: 'Male', mark: 'Male', murat: 'Male',
1223
+ nick: 'Male', oscar: 'Male', paul: 'Male', ray: 'Male',
1224
+ ryan: 'Male', saif: 'Male', steve: 'Male',
1225
+ tom: 'Male', victor: 'Male', winston: 'Male', zach: 'Male',
1226
+ };
1227
+
1228
+ /** Infer agent gender from display name (first word). */
1229
+ function _inferAgentGender(agent) {
1230
+ const firstName = (agent.displayName || '').split(/[\s(]/)[0].toLowerCase();
1231
+ return _NAME_GENDER[firstName] || null;
1232
+ }
1233
+
1234
+ function _autoAssignVoices() {
1235
+ const installed = scanInstalledVoices();
1236
+ if (installed.length === 0) return false;
1237
+
1238
+ // Separate voices by gender
1239
+ const femaleVoices = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Female'));
1240
+ const maleVoices = _shuffleArray(installed.filter(v => getVoiceMeta(v).gender === 'Male'));
1241
+ const otherVoices = _shuffleArray(installed.filter(v => !['Male', 'Female'].includes(getVoiceMeta(v).gender)));
1242
+
1243
+ // Separate agents by gender
1244
+ const femaleAgents = _agents.filter(a => _inferAgentGender(a) === 'Female');
1245
+ const maleAgents = _agents.filter(a => _inferAgentGender(a) === 'Male');
1246
+ const otherAgents = _agents.filter(a => !_inferAgentGender(a));
1247
+
1248
+ const usedVoices = new Set();
1249
+
1250
+ // Assign matching-gender voices first, then fall back to any available
1251
+ function assignGroup(agents, preferredPool, fallbackPools) {
1252
+ const allPools = [preferredPool, ...fallbackPools];
1253
+ let reuseIdx = 0;
1254
+ agents.forEach(agent => {
1255
+ let voice = null;
1256
+ for (const pool of allPools) {
1257
+ voice = pool.find(v => !usedVoices.has(v));
1258
+ if (voice) break;
1259
+ }
1260
+ // If all unique voices exhausted, round-robin reuse from preferred pool
1261
+ if (!voice && preferredPool.length > 0) {
1262
+ voice = preferredPool[reuseIdx % preferredPool.length];
1263
+ reuseIdx++;
1264
+ }
1265
+ if (voice) {
1266
+ usedVoices.add(voice);
1267
+ voiceStore.setAgentProfile(agent.id, { voice });
1268
+ }
1269
+ });
1270
+ }
1271
+
1272
+ assignGroup(femaleAgents, femaleVoices, [otherVoices, maleVoices]);
1273
+ assignGroup(maleAgents, maleVoices, [otherVoices, femaleVoices]);
1274
+ assignGroup(otherAgents, otherVoices, [maleVoices, femaleVoices]);
1275
+
1276
+ return true;
1277
+ }
1278
+
1279
+ function _autoAssignMusic() {
1280
+ const tracksDir = path.join(_projectRoot, '.claude', 'audio', 'tracks');
1281
+ let tracks = [];
1282
+ try {
1283
+ tracks = fs.readdirSync(tracksDir).filter(f => /\.mp3$/i.test(f));
1284
+ } catch { /* no tracks dir */ }
1285
+ if (tracks.length === 0) return false;
1286
+
1287
+ const shuffled = _shuffleArray(tracks);
1288
+ _agents.forEach((agent, i) => {
1289
+ const track = shuffled[i % shuffled.length];
1290
+ const existing = voiceStore.getAgentProfile(agent.id);
1291
+ voiceStore.setAgentProfile(agent.id, {
1292
+ backgroundMusic: { track, volume: existing.backgroundMusic?.volume ?? 70, enabled: true },
1293
+ });
1294
+ });
1295
+ return true;
1296
+ }
1297
+
1298
+ function _autoAssignAll() {
1299
+ if (_agents.length === 0) return;
1300
+ const voiceOk = _autoAssignVoices();
1301
+ const musicOk = _autoAssignMusic();
1302
+ refreshDisplay();
1303
+ const msg = voiceOk && musicOk ? 'Voices and music auto-assigned'
1304
+ : voiceOk ? 'Voices auto-assigned' : 'Auto-assign: no voices found';
1305
+ _showSavedToast(msg);
1306
+ }
1307
+
1308
+ // -------------------------------------------------------------------------
1309
+ // Bulk edit menu
1310
+
1311
+ function _openBulkEditMenu() {
1312
+ const BULK_ACTIONS = [
1313
+ { label: ' Randomize Voices (gender-aware)', key: 'voices' },
1314
+ { label: ' Randomize Music (unique per agent)', key: 'music' },
1315
+ { label: ' Randomize Both', key: 'both' },
1316
+ { label: ' Set Same Music for All Agents...', key: 'setMusic' },
1317
+ { label: ' Set Same Volume for All Agents...', key: 'setVolume' },
1318
+ { label: ' Set Same Pretext for All Agents...', key: 'setPretext' },
1319
+ { label: ' Set Same Reverb for All Agents...', key: 'setReverb' },
1320
+ { label: ' Reset All Agent Profiles', key: 'resetAll' },
1321
+ ];
1322
+
1323
+ const menuList = blessed.list({
1324
+ parent: screen,
1325
+ top: 'center',
1326
+ left: 'center',
1327
+ width: 52,
1328
+ height: BULK_ACTIONS.length + 4,
1329
+ border: { type: 'line' },
1330
+ tags: true,
1331
+ label: _modalTitle('Bulk Edit'),
1332
+ keys: true,
1333
+ vi: true,
1334
+ mouse: true,
1335
+ items: BULK_ACTIONS.map(a => a.label),
1336
+ style: {
1337
+ fg: COLORS.labelFg,
1338
+ bg: COLORS.contentBg,
1339
+ border: { fg: COLORS.btnFocus },
1340
+ selected: { bg: '#4a148c', fg: COLORS.activeFg, bold: true },
1341
+ item: { fg: COLORS.labelFg },
1342
+ },
1343
+ });
1344
+
1345
+ blessed.text({
1346
+ parent: menuList,
1347
+ bottom: -1,
1348
+ left: 1,
1349
+ width: 48,
1350
+ height: 1,
1351
+ tags: true,
1352
+ content: '{#455a64-fg}[Enter] Select [Esc] Cancel{/#455a64-fg}',
1353
+ style: { bg: COLORS.contentBg },
1354
+ });
1355
+
1356
+ menuList.setFront();
1357
+ menuList.focus();
1358
+ screen.render();
1359
+
1360
+ let _menuClosed = false;
1361
+ function _closeMenu(callback) {
1362
+ if (_menuClosed) return;
1363
+ _menuClosed = true;
1364
+ destroyList(menuList, screen, callback);
1365
+ }
1366
+
1367
+ menuList.key(['enter'], () => {
1368
+ const action = BULK_ACTIONS[menuList.selected];
1369
+ if (!action) return;
1370
+
1371
+ switch (action.key) {
1372
+ case 'voices':
1373
+ _closeMenu(() => {
1374
+ if (_autoAssignVoices()) { refreshDisplay(); _showSavedToast('Voices randomized'); }
1375
+ });
1376
+ break;
1377
+
1378
+ case 'music':
1379
+ _closeMenu(() => {
1380
+ if (_autoAssignMusic()) { refreshDisplay(); _showSavedToast('Music randomized'); }
1381
+ });
1382
+ break;
1383
+
1384
+ case 'both':
1385
+ _closeMenu(() => { _autoAssignAll(); });
1386
+ break;
1387
+
1388
+ case 'setMusic':
1389
+ _closeMenu(() => {
1390
+ openTrackPicker(screen, '', 70, (track, volume) => {
1391
+ _agents.forEach(agent => {
1392
+ const p = voiceStore.getAgentProfile(agent.id);
1393
+ voiceStore.setAgentProfile(agent.id, {
1394
+ backgroundMusic: { track, volume, enabled: true },
1395
+ });
1396
+ });
1397
+ refreshDisplay();
1398
+ _showSavedToast('Music set for all agents');
1399
+ agentList.focus();
1400
+ }, () => { agentList.focus(); screen.render(); });
1401
+ });
1402
+ break;
1403
+
1404
+ case 'setVolume':
1405
+ _closeMenu(() => {
1406
+ const curVol = voiceStore.getAgentProfile(_agents[0]?.id)?.backgroundMusic?.volume ?? 70;
1407
+ openVolumeInput(screen, curVol, (volume) => {
1408
+ _agents.forEach(agent => {
1409
+ const p = voiceStore.getAgentProfile(agent.id);
1410
+ const bm = p.backgroundMusic || {};
1411
+ voiceStore.setAgentProfile(agent.id, {
1412
+ backgroundMusic: { ...bm, volume },
1413
+ });
1414
+ });
1415
+ refreshDisplay();
1416
+ _showSavedToast(`Volume set to ${volume}% for all agents`);
1417
+ agentList.focus();
1418
+ }, () => { agentList.focus(); screen.render(); });
1419
+ });
1420
+ break;
1421
+
1422
+ case 'setPretext':
1423
+ _closeMenu(() => { _openBulkPretextEditor(); });
1424
+ break;
1425
+
1426
+ case 'setReverb':
1427
+ _closeMenu(() => {
1428
+ openReverbPicker(screen, '', (val) => {
1429
+ _agents.forEach(agent => voiceStore.setAgentProfile(agent.id, { reverbPreset: val }));
1430
+ refreshDisplay();
1431
+ _showSavedToast('Reverb set for all agents');
1432
+ agentList.focus();
1433
+ }, () => { agentList.focus(); screen.render(); }, { applyToEffectsManager: false });
1434
+ });
1435
+ break;
1436
+
1437
+ case 'resetAll':
1438
+ _closeMenu(() => {
1439
+ _agents.forEach(agent => voiceStore.resetAgentProfile(agent.id));
1440
+ refreshDisplay();
1441
+ _showSavedToast('All profiles reset');
1442
+ });
1443
+ break;
1444
+ }
1445
+ });
1446
+
1447
+ menuList.key(['escape', 'q'], () => {
1448
+ _closeMenu(() => { agentList.focus(); screen.render(); });
1449
+ });
1450
+ }
1451
+
1452
+ function _openBulkPretextEditor() {
1453
+ const editModal = blessed.box({
1454
+ parent: screen,
1455
+ top: 'center',
1456
+ left: 'center',
1457
+ width: 60,
1458
+ height: 9,
1459
+ border: { type: 'line' },
1460
+ tags: true,
1461
+ label: _modalTitle('Set Pretext for All Agents'),
1462
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'bright-cyan' } },
1463
+ });
1464
+ editModal.setFront();
1465
+
1466
+ blessed.text({
1467
+ parent: editModal, top: 1, left: 2,
1468
+ content: 'Pretext to apply to all agents (leave empty to clear):',
1469
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1470
+ });
1471
+
1472
+ const inputBox = blessed.textbox({
1473
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
1474
+ border: { type: 'line' },
1475
+ inputOnFocus: true,
1476
+ style: {
1477
+ fg: COLORS.valueFg, bg: '#0d1b35',
1478
+ border: { fg: COLORS.borderFg },
1479
+ focus: { border: { fg: 'bright-cyan' } },
1480
+ },
1481
+ });
1482
+
1483
+ blessed.text({
1484
+ parent: editModal, bottom: 1, left: 2, tags: true,
1485
+ content: '{#455a64-fg}[Enter] Apply to all [Esc] Cancel{/#455a64-fg}',
1486
+ style: { bg: COLORS.contentBg },
1487
+ });
1488
+
1489
+ let _closed = false;
1490
+ function _close(save) {
1491
+ if (_closed) return;
1492
+ _closed = true;
1493
+ if (save) {
1494
+ const raw = inputBox.getValue().trim().slice(0, MAX_PRETEXT_LENGTH);
1495
+ _agents.forEach(agent => {
1496
+ if (raw) {
1497
+ voiceStore.setAgentProfile(agent.id, { pretext: raw });
1498
+ } else {
1499
+ const p = voiceStore.getAgentProfile(agent.id);
1500
+ const { pretext: _removed, ...rest } = p;
1501
+ voiceStore.resetAgentProfile(agent.id);
1502
+ if (Object.keys(rest).length > 0) voiceStore.setAgentProfile(agent.id, rest);
1503
+ }
1504
+ });
1505
+ refreshDisplay();
1506
+ _showSavedToast('Pretext set for all agents');
1507
+ }
1508
+ destroyList(editModal, screen, () => { agentList.focus(); screen.render(); });
1509
+ }
1510
+
1511
+ inputBox.key(['enter'], () => _close(true));
1512
+ inputBox.key(['escape'], () => _close(false));
1513
+ inputBox.focus();
1514
+ screen.render();
1515
+ }
1516
+
1517
+ // -------------------------------------------------------------------------
1518
+ // Key bindings
1519
+
1520
+ agentList.key(['x', 'X'], () => {
1521
+ const agent = _agents[agentList.selected];
1522
+ if (agent) {
1523
+ voiceStore.resetAgentProfile(agent.id);
1524
+ refreshDisplay();
1525
+ }
1526
+ });
1527
+
1528
+
1529
+ agentList.key(['enter'], () => {
1530
+ const agent = _agents[agentList.selected];
1531
+ if (agent) _openAgentDetailPanel(agent);
1532
+ });
1533
+
1534
+ agentList.key(['space'], () => {
1535
+ const agent = _agents[agentList.selected];
1536
+ if (agent) _sampleAgent(agent);
1537
+ });
1538
+
1539
+ agentList.key(['a', 'A'], () => { _autoAssignAll(); });
1540
+ agentList.key(['b', 'B'], () => { _openBulkEditMenu(); });
1541
+
1542
+ // Type-to-jump
1543
+ const _agentJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'x', 'a', 'b']);
1544
+ agentList.on('keypress', (ch, key) => {
1545
+ if (!ch || key.ctrl || key.meta) return;
1546
+ const lower = ch.toLowerCase();
1547
+ if (!/^[a-z]$/.test(lower)) return;
1548
+ if (_agentJumpBlocked.has(lower)) return;
1549
+ const count = _agents.length;
1550
+ if (count === 0) return;
1551
+ const start = agentList.selected ?? 0;
1552
+ for (let i = 1; i <= count; i++) {
1553
+ const idx = (start + i) % count;
1554
+ const name = (_agents[idx]?.displayName ?? '').toLowerCase();
1555
+ if (name.startsWith(lower)) {
1556
+ agentList.select(idx);
1557
+ screen.render();
1558
+ break;
1559
+ }
1560
+ }
1561
+ });
1562
+
1563
+ // Inline row hint (appended to selected row while list is focused)
1564
+ let _listFocused = false;
1565
+ let _hintIdx = -1;
1566
+ let _hintBase = ''; // row content before hint was appended (no hint, no █)
1567
+
1568
+ function _updateHint(idx) {
1569
+ const items = agentList.items;
1570
+ if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
1571
+ const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
1572
+ items[_hintIdx].setContent(hadBlink ? _hintBase + ' █' : _hintBase);
1573
+ }
1574
+ if (idx >= 0 && items[idx]) {
1575
+ let c = items[idx].content ?? '';
1576
+ const hasBlink = c.endsWith(' █');
1577
+ if (hasBlink) c = c.slice(0, -3);
1578
+ _hintBase = c;
1579
+ items[idx].setContent(c + _ROW_HINT_BMAD + (hasBlink ? ' █' : ''));
1580
+ } else {
1581
+ _hintBase = '';
1582
+ }
1583
+ _hintIdx = idx;
1584
+ }
1585
+
1586
+ // Blinking cursor
1587
+ let _alBlink = { interval: null, on: false, sel: -1 };
1588
+ function _alTick() {
1589
+ _alBlink.on = !_alBlink.on;
1590
+ const items = agentList.items;
1591
+ const cur = agentList.selected ?? 0;
1592
+ if (_alBlink.sel !== cur && _alBlink.sel >= 0 && items[_alBlink.sel]) {
1593
+ items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '').replace(/ █$/, ''));
1594
+ }
1595
+ _alBlink.sel = cur;
1596
+ if (items[cur]) {
1597
+ const base = (items[cur].content ?? '').replace(/ █$/, '');
1598
+ items[cur].setContent(_alBlink.on ? `${base} █` : base);
1599
+ }
1600
+ screen.render();
1601
+ }
1602
+ agentList.on('focus', () => {
1603
+ _listFocused = true;
1604
+ _alBlink.on = true;
1605
+ _alBlink.sel = agentList.selected ?? 0;
1606
+ _hintIdx = -1;
1607
+ _hintBase = '';
1608
+ _updateHint(_alBlink.sel);
1609
+ const items = agentList.items;
1610
+ if (items[_alBlink.sel]) items[_alBlink.sel].setContent((items[_alBlink.sel].content ?? '') + ' █');
1611
+ screen.render();
1612
+ _alBlink.interval = setInterval(_alTick, 500);
1613
+ });
1614
+ agentList.on('blur', () => {
1615
+ _listFocused = false;
1616
+ if (_alBlink.interval) { clearInterval(_alBlink.interval); _alBlink.interval = null; }
1617
+ const items = agentList.items;
1618
+ const sel = agentList.selected ?? 0;
1619
+ if (items[sel]) {
1620
+ items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
1621
+ }
1622
+ if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
1623
+ items[_hintIdx].setContent(_hintBase);
1624
+ }
1625
+ _hintIdx = -1;
1626
+ _hintBase = '';
1627
+ screen.render();
1628
+ });
1629
+ agentList.on('select item', () => {
1630
+ _updateHint(agentList.selected ?? 0);
1631
+ if (_alBlink.interval) _alTick();
1632
+ });
1633
+
1634
+ // Navigation: up at top → tab bar, escape → tab bar
1635
+ agentList.key(['up'], () => {
1636
+ if (agentList.selected === 0 && typeof focusMainTabBar === 'function') {
1637
+ focusMainTabBar();
1638
+ setTimeout(() => { agentList.select(0); screen.render(); }, 0);
1639
+ }
1640
+ });
1641
+ agentList.key(['escape'], () => {
1642
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1643
+ });
1644
+
1645
+ // -------------------------------------------------------------------------
1646
+ // Tab Component Contract
1647
+
1648
+ return {
1649
+ box,
1650
+
1651
+ show() {
1652
+ box.show();
1653
+ refreshDisplay();
1654
+ screen.render();
1655
+ },
1656
+
1657
+ hide() {
1658
+ _killPreview();
1659
+ box.hide();
1660
+ screen.render();
1661
+ },
1662
+
1663
+ onFocus() {
1664
+ if (_bmadDetected) {
1665
+ agentList.focus();
1666
+ } else {
1667
+ onboardingBox.focus();
1668
+ }
1669
+ screen.render();
1670
+ },
1671
+
1672
+ onBlur() {
1673
+ _killPreview();
1674
+ },
1675
+
1676
+ getFooterText() {
1677
+ return _bmadDetected ? FOOTER_TEXT_BMAD : FOOTER_TEXT_NOBMAD;
1678
+ },
1679
+
1680
+ getFooterColor() {
1681
+ return COLORS.footerBg;
1682
+ },
1683
+ };
1684
+ }