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