agentvibes 4.6.8 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.agentvibes/bmad-voice-map.json +104 -0
  2. package/.agentvibes/config.json +13 -12
  3. package/.agentvibes/copilot-sessions.log +4 -0
  4. package/.claude/audio/tracks/Drifting Down the Hall.mp3 +0 -0
  5. package/.claude/audio/tracks/Late Night Hip Hop Groove.mp3 +0 -0
  6. package/.claude/audio/tracks/Midnight Charleston Stomp.mp3 +0 -0
  7. package/.claude/audio/tracks/README.md +51 -52
  8. package/.claude/config/audio-effects-bmad.cfg +50 -0
  9. package/.claude/config/audio-effects.cfg +4 -4
  10. package/.claude/config/background-music-enabled.txt +1 -0
  11. package/.claude/config/personality.txt +1 -0
  12. package/.claude/hooks/play-tts-piper.sh +3 -1
  13. package/.claude/hooks/play-tts.sh +380 -301
  14. package/.claude/hooks/session-start-tts.sh +81 -81
  15. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  16. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  17. package/.claude/hooks-windows/play-tts.ps1 +28 -6
  18. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  19. package/README.md +112 -6
  20. package/RELEASE_NOTES.md +83 -0
  21. package/bin/bmad-speak.js +16 -8
  22. package/mcp-server/server.py +15 -8
  23. package/package.json +1 -1
  24. package/src/console/app.js +899 -897
  25. package/src/console/footer-config.js +50 -50
  26. package/src/console/navigation.js +65 -65
  27. package/src/console/tabs/agents-tab.js +1899 -1886
  28. package/src/console/tabs/music-tab.js +1076 -1039
  29. package/src/console/tabs/placeholder-tab.js +81 -80
  30. package/src/console/tabs/settings-tab.js +941 -3988
  31. package/src/console/tabs/setup-tab.js +2071 -0
  32. package/src/console/tabs/voices-tab.js +1843 -1714
  33. package/src/console/widgets/format-utils.js +92 -89
  34. package/src/console/widgets/track-picker.js +325 -322
  35. package/src/installer.js +6147 -6092
  36. package/src/services/llm-provider-service.js +486 -0
  37. package/src/services/navigation-service.js +123 -123
  38. package/src/services/tts-engine-service.js +69 -0
  39. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  40. package/src/console/tabs/install-tab.js +0 -1081
@@ -0,0 +1,2071 @@
1
+ /**
2
+ * AgentVibes TUI Console — Setup Tab (Unified Setup Wizard)
3
+ *
4
+ * Replaces install-tab.js + llm-providers-tab.js with a single unified tab.
5
+ *
6
+ * Implements the Tab Component Contract:
7
+ * createSetupTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
8
+ *
9
+ * 4-screen wizard flow:
10
+ * Screen 0: Language picker
11
+ * Screen 1: Dependency check
12
+ * Screen 2: TTS Engine selection (new)
13
+ * Screen 3: LLM Providers (new — install/remove/configure)
14
+ */
15
+
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import fs from 'node:fs';
21
+ import { promises as _fsP } from 'node:fs';
22
+ import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
23
+ import {
24
+ PROVIDERS,
25
+ checkClaudeInstalled, checkCopilotInstalled, checkCodexInstalled,
26
+ installClaudeMcp, removeClaudeMcp, uninstallClaude,
27
+ installCopilotMcp, removeCopilotMcp,
28
+ installCopilotInstructions, removeCopilotInstructions,
29
+ installCodexMcp, removeCodexMcp,
30
+ installCodexInstructions, installCodexHooks,
31
+ removeCodexInstructions, removeCodexHooks,
32
+ loadLlmConfigSync, saveLlmConfigSync, resolveCfgPath,
33
+ } from '../../services/llm-provider-service.js';
34
+ import {
35
+ getAvailableEngines, getEngineStatuses, checkEngineInstalled,
36
+ } from '../../services/tts-engine-service.js';
37
+ import { openReverbPicker, REVERB_PRESETS } from '../widgets/reverb-picker.js';
38
+ import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
39
+ import { formatTrackName } from '../widgets/format-utils.js';
40
+ import { destroyList } from '../widgets/destroy-list.js';
41
+ import { scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, toggleFavorite } from './voices-tab.js';
42
+ import { attachBtnBlink } from './agents-tab.js';
43
+ import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
44
+ import { spawn } from 'node:child_process';
45
+ import os from 'node:os';
46
+ import crypto from 'node:crypto';
47
+
48
+ const _execFileAsync = promisify(execFile);
49
+
50
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
51
+
52
+ let blessed;
53
+ if (!IS_TEST) {
54
+ const { default: b } = await import('blessed');
55
+ blessed = b;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Named ANSI colors only — hex renders as white on Paul's terminal
60
+
61
+ const COLORS = {
62
+ contentBg: 'black',
63
+ sectionHdr: 'bright-cyan',
64
+ labelFg: 'white',
65
+ valueFg: 'yellow',
66
+ brandPink: 'magenta',
67
+ successFg: 'green',
68
+ errorFg: 'red',
69
+ btnDefault: 'blue',
70
+ btnFocus: 'green',
71
+ btnFocusFg: 'white',
72
+ btnPress: 'magenta',
73
+ borderFg: 'bright-cyan',
74
+ footerBg: 'blue',
75
+ noticeFg: 'white',
76
+ btnBg: 'blue',
77
+ btnFg: 'white',
78
+ btnFocusBg: 'cyan',
79
+ removeBg: 'red',
80
+ removeFocusBg: 'magenta',
81
+ cfgBg: 'green',
82
+ cfgFocusBg: 'yellow',
83
+ };
84
+
85
+ const FOOTER_TEXT = '[Enter] Continue [Esc] Back [Tab] Next Tab [Q] Quit';
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Exported pure helpers (kept from install-tab for backward compat)
89
+
90
+ /**
91
+ * Returns the default intro text suggestion (project folder name).
92
+ * @param {string} projectDir
93
+ * @returns {string}
94
+ */
95
+ export function getIntroDefault(projectDir) {
96
+ if (!projectDir) return '';
97
+ return path.basename(projectDir);
98
+ }
99
+
100
+ /**
101
+ * Format the TTS greeting message.
102
+ * @param {string} introText - User's intro text (may be empty)
103
+ * @param {string} projectName - Project folder name
104
+ * @returns {string}
105
+ */
106
+ export function formatGreeting(introText, projectName) {
107
+ const name = introText || projectName || 'AgentVibes';
108
+ return `${name} is ready! Welcome to AgentVibes. Love AgentVibes? We'd really appreciate it if you could give us a star on GitHub.`;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Dependency detection helpers
113
+
114
+ async function _commandExistsAsync(cmd) {
115
+ try {
116
+ const opts = { stdio: 'pipe', timeout: 5000 };
117
+ if (process.platform === 'win32') {
118
+ opts.shell = true;
119
+ await _execFileAsync(`${cmd} --version`, [], opts);
120
+ } else {
121
+ await _execFileAsync(cmd, ['--version'], opts);
122
+ }
123
+ return true;
124
+ } catch (err) {
125
+ if (err.code === 'ENOENT') return false;
126
+ return true;
127
+ }
128
+ }
129
+
130
+ async function _checkDependenciesAsync() {
131
+ const [node, npm, piperCmd, sopranoTts, sopranoWebui, ffmpeg] = await Promise.all([
132
+ _commandExistsAsync('node'),
133
+ _commandExistsAsync('npm'),
134
+ _commandExistsAsync('piper'),
135
+ _commandExistsAsync('soprano-tts'),
136
+ _commandExistsAsync('soprano-webui'),
137
+ _commandExistsAsync('ffmpeg'),
138
+ ]);
139
+
140
+ let piper = piperCmd;
141
+ if (!piper && process.platform === 'win32') {
142
+ const localAppData = process.env.LOCALAPPDATA ||
143
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
144
+ if (localAppData) {
145
+ piper = fs.existsSync(path.join(localAppData, 'Programs', 'Piper', 'piper.exe'));
146
+ }
147
+ }
148
+
149
+ return { node, npm, piper, soprano: sopranoTts || sopranoWebui, ffmpeg };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Test stub
154
+
155
+ function createTestStub() {
156
+ return {
157
+ box: {},
158
+ show: () => {},
159
+ hide: () => {},
160
+ onFocus: () => {},
161
+ onBlur: () => {},
162
+ getFooterText: () => t('en', 'footerText'),
163
+ getFooterColor: () => COLORS.footerBg,
164
+ };
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Create the Setup tab component (unified install + provider configuration).
171
+ *
172
+ * @param {object} screen - Blessed screen instance (or test stub)
173
+ * @param {object} services
174
+ * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
175
+ */
176
+ export function createSetupTab(screen, services) {
177
+ if (IS_TEST) return createTestStub();
178
+
179
+ const { configService, providerService, navigationService, focusMainTabBar, languageService } = services;
180
+
181
+ const targetDir = process.env.INIT_CWD || process.cwd();
182
+ const _thisFile = fileURLToPath(import.meta.url);
183
+ const packageDir = path.resolve(path.dirname(_thisFile), '..', '..', '..');
184
+
185
+ // -------------------------------------------------------------------------
186
+ // Container
187
+
188
+ const box = blessed.box({
189
+ parent: screen,
190
+ top: 5,
191
+ left: 0,
192
+ width: '100%',
193
+ bottom: 2,
194
+ hidden: true,
195
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
196
+ border: { type: 'line' },
197
+ borderStyle: { fg: COLORS.borderFg },
198
+ });
199
+
200
+ // -------------------------------------------------------------------------
201
+ // Wizard state
202
+
203
+ let _screen = 0;
204
+ let _lastScreen = -2;
205
+ let _pendingGlobalCfg = null; // Set when global config detected on first run
206
+ let _globalChoiceIdx = 0; // 0 = Load Global, 1 = Start Fresh
207
+ const _getLang = () => languageService?.getLang() ?? 'en';
208
+ const _tl = (key) => languageService?.t(key) ?? t('en', key);
209
+ let _langIdx = 0;
210
+ let _deps = null;
211
+ let _checking = false;
212
+
213
+ // First-run detection: evaluated at show() time so async config init is complete
214
+ function _isFirstRun() {
215
+ return !(configService?.getConfig?.()?.setupCompleted);
216
+ }
217
+
218
+ // -------------------------------------------------------------------------
219
+ // Content area
220
+
221
+ const contentBox = blessed.box({
222
+ parent: box,
223
+ top: 1,
224
+ left: 2,
225
+ width: '96%',
226
+ bottom: 5,
227
+ tags: true,
228
+ wrap: false,
229
+ scrollable: false,
230
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
231
+ });
232
+
233
+ const hintLine = blessed.text({
234
+ parent: box,
235
+ bottom: 2,
236
+ left: 2,
237
+ right: 2,
238
+ tags: true,
239
+ content: '',
240
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
241
+ });
242
+
243
+ function _c(lines) { return lines.join('\n'); }
244
+
245
+ // -------------------------------------------------------------------------
246
+ // Shared button factory
247
+
248
+ function _createBtn(label, bg, onClick, textColor = 'white') {
249
+ const btn = blessed.button({
250
+ parent: box,
251
+ content: label,
252
+ mouse: true,
253
+ keys: true,
254
+ shrink: true,
255
+ hidden: true,
256
+ padding: { left: 1, right: 1 },
257
+ style: {
258
+ bg,
259
+ fg: textColor,
260
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
261
+ },
262
+ });
263
+
264
+ let _blinkInterval = null;
265
+ const _origLabel = label;
266
+ btn.on('focus', () => {
267
+ btn.style.bg = COLORS.btnFocus;
268
+ btn.style.fg = COLORS.btnFocusFg;
269
+ btn.setContent(`\u25ba ${_origLabel} \u25c4`);
270
+ let _on = true;
271
+ screen.render();
272
+ _blinkInterval = setInterval(() => {
273
+ _on = !_on;
274
+ btn.setContent(_on ? `\u25ba ${_origLabel} \u25c4` : ` ${_origLabel} `);
275
+ screen.render();
276
+ }, 500);
277
+ });
278
+ btn.on('blur', () => {
279
+ if (_blinkInterval) { clearInterval(_blinkInterval); _blinkInterval = null; }
280
+ btn.style.bg = bg;
281
+ btn.style.fg = textColor;
282
+ btn.setContent(_origLabel);
283
+ screen.render();
284
+ });
285
+
286
+ btn.key(['enter', 'space'], () => {
287
+ btn.style.bg = COLORS.btnPress;
288
+ btn.style.fg = 'white';
289
+ screen.render();
290
+ setTimeout(() => {
291
+ btn.style.bg = bg;
292
+ btn.style.fg = textColor;
293
+ screen.render();
294
+ onClick();
295
+ }, 150);
296
+ });
297
+ btn.on('click', () => btn.press());
298
+ return btn;
299
+ }
300
+
301
+ // =========================================================================
302
+ // SCREEN 0: Language picker (kept as-is)
303
+ // =========================================================================
304
+
305
+ // =========================================================================
306
+ // SCREEN 1: Dependency check (was Screen 2, renumbered)
307
+ // =========================================================================
308
+
309
+ const _s1ContinueBtn = _createBtn('Continue ->', 'blue', () => {
310
+ _screen++;
311
+ _showCurrentScreen();
312
+ });
313
+ _s1ContinueBtn.top = 12; _s1ContinueBtn.left = 4;
314
+ _s1ContinueBtn.key(['right'], () => { _screen++; _showCurrentScreen(); });
315
+
316
+ // =========================================================================
317
+ // SCREEN 2: TTS Engine selection (new)
318
+ // =========================================================================
319
+
320
+ // TTS engine install buttons — created once, shown/hidden per screen
321
+ const _ttsEngineRows = [];
322
+ const _ttsFocusableItems = [];
323
+ let _ttsFocusIndex = 0;
324
+
325
+ const _ttsEngines = getAvailableEngines();
326
+ for (let i = 0; i < _ttsEngines.length; i++) {
327
+ const engine = _ttsEngines[i];
328
+ const yOff = 5 + (i * 3);
329
+
330
+ const nameLabel = blessed.text({
331
+ parent: box, top: yOff, left: 2, tags: true, hidden: true,
332
+ content: '', style: { bg: COLORS.contentBg },
333
+ });
334
+
335
+ const statusLabel = blessed.text({
336
+ parent: box, top: yOff, left: 22, tags: true, hidden: true,
337
+ content: '', style: { bg: COLORS.contentBg },
338
+ });
339
+
340
+ const descLabel = blessed.text({
341
+ parent: box, top: yOff + 1, left: 4, tags: true, hidden: true,
342
+ content: `{cyan-fg}${engine.desc}{/cyan-fg}`,
343
+ style: { bg: COLORS.contentBg },
344
+ });
345
+
346
+ const installBtn = blessed.button({
347
+ parent: box, top: yOff, left: 40, width: 14, height: 1,
348
+ content: ' Install ', tags: true, mouse: true, keys: true, hidden: true,
349
+ style: {
350
+ fg: COLORS.btnFg, bg: COLORS.btnBg,
351
+ focus: { fg: 'black', bg: COLORS.btnFocusBg },
352
+ },
353
+ });
354
+
355
+ installBtn.on('press', () => _handleTtsInstall(engine));
356
+ installBtn.key(['enter', 'space'], () => _handleTtsInstall(engine));
357
+ installBtn.key(['tab', 'down'], () => _cycleTtsFocus(1));
358
+ installBtn.key(['S-tab', 'up'], () => _cycleTtsFocus(-1));
359
+ installBtn.key(['escape'], () => {
360
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
361
+ });
362
+
363
+ _ttsEngineRows.push({ engine, nameLabel, statusLabel, descLabel, installBtn });
364
+ if (!engine.native) _ttsFocusableItems.push(installBtn);
365
+ }
366
+
367
+ function _cycleTtsFocus(dir) {
368
+ const items = _ttsFocusableItems.filter(b => !b.hidden);
369
+ if (!items.length) return;
370
+ _ttsFocusIndex = (_ttsFocusIndex + dir + items.length) % items.length;
371
+ items[_ttsFocusIndex].focus();
372
+ screen.render();
373
+ }
374
+
375
+ function _showTtsEngineRows() {
376
+ for (const row of _ttsEngineRows) {
377
+ const installed = checkEngineInstalled(row.engine.id);
378
+ row.nameLabel.setContent(`{bold}{white-fg}${row.engine.name}{/white-fg}{/bold}`);
379
+ row.statusLabel.setContent(installed
380
+ ? '{green-fg}[Installed]{/green-fg}'
381
+ : '{yellow-fg}[Not Found]{/yellow-fg}');
382
+ row.nameLabel.show();
383
+ row.statusLabel.show();
384
+ row.descLabel.show();
385
+ if (!installed && !row.engine.native) {
386
+ row.installBtn.show();
387
+ } else {
388
+ row.installBtn.hide();
389
+ }
390
+ }
391
+ }
392
+
393
+ function _hideTtsEngineRows() {
394
+ for (const row of _ttsEngineRows) {
395
+ row.nameLabel.hide();
396
+ row.statusLabel.hide();
397
+ row.descLabel.hide();
398
+ row.installBtn.hide();
399
+ }
400
+ }
401
+
402
+ let _ttsInstalling = false;
403
+ async function _handleTtsInstall(engine) {
404
+ if (!engine.installCmd || _ttsInstalling) return;
405
+ _ttsInstalling = true;
406
+
407
+ // Show installing status
408
+ const row = _ttsEngineRows.find(r => r.engine.id === engine.id);
409
+ if (row) {
410
+ row.statusLabel.setContent('{yellow-fg}[Installing...]{/yellow-fg}');
411
+ screen.render();
412
+ }
413
+
414
+ try {
415
+ const opts = { stdio: 'pipe', timeout: 120000 };
416
+ if (process.platform === 'win32') {
417
+ opts.shell = true;
418
+ await _execFileAsync(engine.installCmd, [], opts);
419
+ } else {
420
+ const parts = engine.installCmd.split(' ');
421
+ await _execFileAsync(parts[0], parts.slice(1), opts);
422
+ }
423
+
424
+ // Re-check and update status
425
+ const installed = checkEngineInstalled(engine.id);
426
+ if (row) {
427
+ row.statusLabel.setContent(installed
428
+ ? '{green-fg}[Installed]{/green-fg}'
429
+ : '{red-fg}[Install Failed]{/red-fg}');
430
+ if (installed) row.installBtn.hide();
431
+ }
432
+ } catch (err) {
433
+ if (row) {
434
+ row.statusLabel.setContent(`{red-fg}[Failed]{/red-fg}`);
435
+ }
436
+ }
437
+ _ttsInstalling = false;
438
+ screen.render();
439
+ }
440
+
441
+ // Continue button for Screen 2
442
+ const _s2ContinueBtn = _createBtn('Continue ->', 'blue', () => {
443
+ if (_screen < 3) { _screen++; _showCurrentScreen(); }
444
+ });
445
+ _s2ContinueBtn.hidden = true;
446
+
447
+ // =========================================================================
448
+ // SCREEN 3: LLM Providers (new — from llm-providers-tab)
449
+ // =========================================================================
450
+
451
+ let installedState = {};
452
+ let providerFocusableItems = [];
453
+ let providerFocusIndex = 0;
454
+ let providerView = 'list'; // 'list' or 'info'
455
+
456
+ // Provider row widgets (created once)
457
+ const providerRows = [];
458
+ const providerStatusTexts = [];
459
+
460
+ // Info box for provider details
461
+ const infoBox = blessed.box({
462
+ parent: box,
463
+ top: 0,
464
+ left: 0,
465
+ width: '100%',
466
+ bottom: 0,
467
+ hidden: true,
468
+ scrollable: true,
469
+ alwaysScroll: true,
470
+ tags: true,
471
+ keys: true,
472
+ vi: true,
473
+ mouse: true,
474
+ valign: 'top',
475
+ scrollbar: { ch: '|', style: { fg: 'cyan' } },
476
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
477
+ });
478
+
479
+ // Provider header
480
+ const providerHeader = blessed.text({
481
+ parent: box,
482
+ top: 0,
483
+ left: 2,
484
+ tags: true,
485
+ hidden: true,
486
+ content: '{bold}{cyan-fg}LLM Providers{/cyan-fg}{/bold} Configure AgentVibes for your AI assistant:',
487
+ style: { bg: COLORS.contentBg },
488
+ });
489
+
490
+ function createProviderRow(provider, rowIndex) {
491
+ const yOffset = 2 + (rowIndex * 3);
492
+
493
+ const label = blessed.text({
494
+ parent: box,
495
+ top: yOffset,
496
+ left: 2,
497
+ tags: true,
498
+ hidden: true,
499
+ content: `{bold}{white-fg}${provider.name}{/white-fg}{/bold} {cyan-fg}${provider.desc}{/cyan-fg}`,
500
+ style: { bg: COLORS.contentBg },
501
+ });
502
+
503
+ const statusText = blessed.text({
504
+ parent: box,
505
+ top: yOffset + 1,
506
+ left: 4,
507
+ tags: true,
508
+ hidden: true,
509
+ content: '{yellow-fg}Checking...{/yellow-fg}',
510
+ style: { bg: COLORS.contentBg },
511
+ });
512
+ providerStatusTexts.push({ id: provider.id, widget: statusText });
513
+
514
+ const installBtn = blessed.button({
515
+ parent: box,
516
+ top: yOffset + 1,
517
+ left: 30,
518
+ width: 14,
519
+ height: 1,
520
+ content: ' Install ',
521
+ tags: true,
522
+ mouse: true,
523
+ keys: true,
524
+ hidden: true,
525
+ style: {
526
+ fg: COLORS.btnFg,
527
+ bg: COLORS.btnBg,
528
+ focus: { fg: 'black', bg: COLORS.btnFocusBg },
529
+ },
530
+ });
531
+
532
+ const removeBtn = blessed.button({
533
+ parent: box,
534
+ top: yOffset + 1,
535
+ left: 46,
536
+ width: 12,
537
+ height: 1,
538
+ content: ' Remove ',
539
+ tags: true,
540
+ mouse: true,
541
+ keys: true,
542
+ hidden: true,
543
+ style: {
544
+ fg: COLORS.btnFg,
545
+ bg: COLORS.removeBg,
546
+ focus: { fg: 'black', bg: COLORS.removeFocusBg },
547
+ },
548
+ });
549
+
550
+ const configBtn = blessed.button({
551
+ parent: box,
552
+ top: yOffset + 1,
553
+ left: 60,
554
+ width: 14,
555
+ height: 1,
556
+ content: ' Configure ',
557
+ tags: true,
558
+ mouse: true,
559
+ keys: true,
560
+ hidden: true,
561
+ style: {
562
+ fg: 'black',
563
+ bg: COLORS.cfgBg,
564
+ focus: { fg: 'black', bg: COLORS.cfgFocusBg },
565
+ },
566
+ });
567
+
568
+ // Wire actions
569
+ installBtn.on('press', async () => { await handleProviderInstall(provider); });
570
+ installBtn.key(['enter', 'space'], async () => { await handleProviderInstall(provider); });
571
+
572
+ removeBtn.on('press', async () => { await handleProviderRemove(provider); });
573
+ removeBtn.key(['enter', 'space'], async () => { await handleProviderRemove(provider); });
574
+
575
+ configBtn.on('press', async () => { await handleProviderConfigure(provider); });
576
+ configBtn.key(['enter', 'space'], async () => { await handleProviderConfigure(provider); });
577
+
578
+ // Navigation on each button
579
+ for (const btn of [installBtn, removeBtn, configBtn]) {
580
+ btn.key(['tab', 'right'], () => { cycleFocus(1); });
581
+ btn.key(['S-tab', 'left'], () => { cycleFocus(-1); });
582
+ btn.key(['escape'], () => {
583
+ if (typeof focusMainTabBar === 'function') {
584
+ focusMainTabBar();
585
+ screen.render();
586
+ }
587
+ });
588
+ btn.key(['up'], () => {
589
+ const prevIdx = providerFocusIndex - 3;
590
+ if (prevIdx >= 0) {
591
+ providerFocusIndex = prevIdx;
592
+ providerFocusableItems[providerFocusIndex].focus();
593
+ screen.render();
594
+ } else if (typeof focusMainTabBar === 'function') {
595
+ focusMainTabBar();
596
+ }
597
+ });
598
+ btn.key(['down'], () => {
599
+ const nextIdx = providerFocusIndex + 3;
600
+ if (nextIdx < providerFocusableItems.length) {
601
+ providerFocusIndex = nextIdx;
602
+ providerFocusableItems[providerFocusIndex].focus();
603
+ screen.render();
604
+ }
605
+ });
606
+ }
607
+
608
+ providerRows.push({ id: provider.id, label, statusText, installBtn, removeBtn, configBtn });
609
+ return { installBtn, removeBtn, configBtn };
610
+ }
611
+
612
+ // Build all provider rows
613
+ for (let i = 0; i < PROVIDERS.length; i++) {
614
+ const { installBtn, removeBtn, configBtn } = createProviderRow(PROVIDERS[i], i);
615
+ providerFocusableItems.push(installBtn, removeBtn, configBtn);
616
+ }
617
+
618
+ function cycleFocus(dir) {
619
+ providerFocusIndex = (providerFocusIndex + dir + providerFocusableItems.length) % providerFocusableItems.length;
620
+ providerFocusableItems[providerFocusIndex].focus();
621
+ screen.render();
622
+ }
623
+
624
+ // ── Provider install/remove handlers ──────────────────────────────────────
625
+
626
+ async function handleProviderInstall(provider) {
627
+ if (provider.id === 'claude-code') {
628
+ const wasInstalled = installedState[provider.id];
629
+ const result = await installClaudeMcp(targetDir);
630
+ await refreshInstalledState();
631
+ showClaudeCodeInfo(result, wasInstalled);
632
+ return;
633
+ }
634
+
635
+ if (provider.id === 'github-copilot') {
636
+ const wasInstalled = installedState[provider.id];
637
+ const result = await installCopilotMcp(targetDir);
638
+ await installCopilotInstructions(targetDir, packageDir);
639
+ await refreshInstalledState();
640
+ showCopilotInfo(result, wasInstalled);
641
+ }
642
+
643
+ if (provider.id === 'openai-codex') {
644
+ const wasInstalled = installedState[provider.id];
645
+ const result = await installCodexMcp(targetDir);
646
+ await installCopilotMcp(targetDir);
647
+ await installCodexInstructions(targetDir, packageDir);
648
+ await installCodexHooks(targetDir, packageDir);
649
+ await refreshInstalledState();
650
+ showCodexInfo(result, wasInstalled);
651
+ }
652
+ }
653
+
654
+ async function handleProviderRemove(provider) {
655
+ if (provider.id === 'claude-code') {
656
+ const result = await uninstallClaude(targetDir);
657
+ await refreshInstalledState();
658
+ showRemoveInfo('claude-code', result.removed || []);
659
+ return;
660
+ }
661
+
662
+ if (provider.id === 'github-copilot') {
663
+ await removeCopilotMcp(targetDir);
664
+ await removeCopilotInstructions(targetDir);
665
+ await refreshInstalledState();
666
+ showRemoveInfo('github-copilot');
667
+ }
668
+
669
+ if (provider.id === 'openai-codex') {
670
+ await removeCodexMcp(targetDir);
671
+ await removeCopilotMcp(targetDir);
672
+ await removeCodexInstructions(targetDir);
673
+ await removeCodexHooks(targetDir);
674
+ await refreshInstalledState();
675
+ showRemoveInfo('openai-codex');
676
+ }
677
+ }
678
+
679
+ // ── Provider configure handler ────────────────────────────────────────────
680
+
681
+ async function handleProviderConfigure(provider) {
682
+ const llmKeyMap = {
683
+ 'claude-code': 'claude-code',
684
+ 'github-copilot': 'copilot',
685
+ 'openai-codex': 'codex',
686
+ };
687
+ const llmKey = llmKeyMap[provider.id] || provider.id;
688
+ const config = loadLlmConfigSync(llmKey, targetDir);
689
+ _openLlmConfigModal(provider, llmKey, config);
690
+ }
691
+
692
+ // ── LLM Config Modal ─────────────────────────────────────────────────────
693
+
694
+ function _openLlmConfigModal(provider, llmKey, config) {
695
+ // Guard against double-open (key repeat, double-click)
696
+ if (navigationService?.isModalOpen()) return;
697
+ let _closed = false;
698
+ navigationService?.openModal();
699
+
700
+ const defaultPretext = {
701
+ 'claude-code': 'Claude Code here',
702
+ 'copilot': 'Copilot here',
703
+ 'codex': 'Codex here',
704
+ };
705
+
706
+ // Read global defaults for display
707
+ const globalEngine = providerService?.getActiveProvider?.() || 'piper';
708
+ const globalVoice = providerService?.getActiveVoiceId?.() || 'none';
709
+
710
+ const draft = {
711
+ ttsEngine: config.ttsEngine || '',
712
+ voice: config.voice || '',
713
+ pretext: config.pretext || defaultPretext[llmKey] || '',
714
+ reverbPreset: config.effects || 'off',
715
+ bgTrack: config.bgTrack || '',
716
+ bgVolume: config.bgVolume || '0.15',
717
+ };
718
+
719
+ const modal = blessed.box({
720
+ parent: screen,
721
+ top: 'center',
722
+ left: 'center',
723
+ width: 72,
724
+ height: 22,
725
+ border: { type: 'line' },
726
+ tags: true,
727
+ label: ` {bold}{cyan-fg} ${provider.name} -- Audio Config {/cyan-fg}{/bold} `,
728
+ style: {
729
+ fg: COLORS.labelFg,
730
+ bg: COLORS.contentBg,
731
+ border: { fg: 'cyan' },
732
+ },
733
+ });
734
+ modal.setFront();
735
+
736
+ // Field definitions
737
+ const FIELDS = [
738
+ { key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || `(global: ${globalEngine})` },
739
+ { key: 'voice', label: 'Voice', getValue: () => draft.voice || `(global: ${globalVoice})` },
740
+ { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
741
+ { key: 'reverb', label: 'Reverb', getValue: () => {
742
+ const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
743
+ return p ? p.label : draft.reverbPreset || 'Off';
744
+ }},
745
+ { key: 'bgTrack', label: 'Music Track', getValue: () => formatTrackName(draft.bgTrack) || '(default)' },
746
+ { key: 'bgVolume', label: 'Music Vol', getValue: () => {
747
+ const pct = Math.round(parseFloat(draft.bgVolume) * 100);
748
+ return `${pct}%`;
749
+ }},
750
+ ];
751
+
752
+ function _fieldItems() {
753
+ return FIELDS.map(f => {
754
+ const label = f.label.padEnd(14);
755
+ return ` ${label} ${f.getValue()}`;
756
+ });
757
+ }
758
+
759
+ const fieldList = blessed.list({
760
+ parent: modal,
761
+ top: 1,
762
+ left: 2,
763
+ right: 2,
764
+ height: FIELDS.length + 2,
765
+ keys: true,
766
+ vi: false,
767
+ mouse: true,
768
+ border: { type: 'line' },
769
+ tags: true,
770
+ style: {
771
+ fg: COLORS.labelFg,
772
+ bg: COLORS.contentBg,
773
+ border: { fg: 'blue' },
774
+ selected: { bg: 'blue', fg: 'yellow' },
775
+ item: { fg: COLORS.labelFg },
776
+ },
777
+ });
778
+ fieldList.setItems(_fieldItems());
779
+
780
+ blessed.text({
781
+ parent: modal,
782
+ bottom: 4,
783
+ left: 2,
784
+ right: 2,
785
+ tags: true,
786
+ content: '{white-fg}[Up/Down] Navigate [Enter] Edit [Tab] Buttons [Esc] Close{/white-fg}',
787
+ style: { bg: COLORS.contentBg },
788
+ });
789
+
790
+ // Buttons
791
+ function _modalBtn(label, leftPos, onClick) {
792
+ const btn = blessed.button({
793
+ parent: modal,
794
+ content: label,
795
+ bottom: 2,
796
+ left: leftPos,
797
+ mouse: true,
798
+ keys: true,
799
+ shrink: true,
800
+ padding: { left: 1, right: 1 },
801
+ style: {
802
+ bg: 'blue',
803
+ fg: 'white',
804
+ focus: { bg: 'cyan', fg: 'black', bold: true },
805
+ hover: { bg: 'cyan', fg: 'black', bold: true },
806
+ },
807
+ });
808
+ btn.key(['enter', 'space'], () => onClick());
809
+ btn.on('click', () => onClick());
810
+ return btn;
811
+ }
812
+
813
+ // Preview status line
814
+ const previewLine = blessed.text({
815
+ parent: modal,
816
+ bottom: 1,
817
+ left: 2,
818
+ right: 2,
819
+ tags: true,
820
+ content: '',
821
+ style: { bg: COLORS.contentBg },
822
+ });
823
+
824
+ let _previewModalProc = null;
825
+ function _killPreview() {
826
+ if (_previewModalProc) {
827
+ try { _previewModalProc.kill(); } catch {}
828
+ _previewModalProc = null;
829
+ }
830
+ }
831
+
832
+ function _playPreview() {
833
+ _killPreview();
834
+ previewLine.setContent('{cyan-fg}♪ Previewing...{/cyan-fg}');
835
+ screen.render();
836
+
837
+ // Save first so play-tts picks up current settings
838
+ _autoSave(true);
839
+
840
+ // Temporarily enable background music for preview if a track is configured
841
+ const hasBgTrack = !!draft.bgTrack;
842
+ let _bgRestore = null;
843
+ if (hasBgTrack) {
844
+ const avCfgPath = path.join(targetDir, '.agentvibes', 'config.json');
845
+ try {
846
+ const raw = fs.readFileSync(avCfgPath, 'utf8');
847
+ const cfg = JSON.parse(raw);
848
+ if (cfg.backgroundMusic && !cfg.backgroundMusic.enabled) {
849
+ cfg.backgroundMusic.enabled = true;
850
+ fs.writeFileSync(avCfgPath, JSON.stringify(cfg, null, 2) + '\n');
851
+ _bgRestore = () => { cfg.backgroundMusic.enabled = false; fs.writeFileSync(avCfgPath, JSON.stringify(cfg, null, 2) + '\n'); };
852
+ }
853
+ } catch {
854
+ // No .agentvibes/config.json — use legacy txt fallback
855
+ const bgEnabledFile = path.join(targetDir, '.claude', 'config', 'background-music-enabled.txt');
856
+ let bgWas = false;
857
+ try { bgWas = fs.readFileSync(bgEnabledFile, 'utf8').trim() === 'true'; } catch {}
858
+ if (!bgWas) {
859
+ try { fs.writeFileSync(bgEnabledFile, 'true', 'utf8'); } catch {}
860
+ _bgRestore = () => { try { fs.writeFileSync(bgEnabledFile, 'false', 'utf8'); } catch {} };
861
+ }
862
+ }
863
+ }
864
+
865
+ const hooksSubdir = process.platform === 'win32' ? 'hooks-windows' : 'hooks';
866
+ const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
867
+ // Don't include pretext — play-tts already prepends it from the config
868
+ const sampleText = 'Here is a preview of your audio settings.';
869
+
870
+ let cmd, args;
871
+ if (isWin) {
872
+ const script = path.join(targetDir, '.claude', hooksSubdir, 'play-tts.ps1');
873
+ cmd = 'powershell';
874
+ args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, sampleText, '', '-llm', llmKey];
875
+ } else {
876
+ const script = path.join(targetDir, '.claude', hooksSubdir, 'play-tts.sh');
877
+ cmd = 'bash';
878
+ args = [script, sampleText, '', '--llm', llmKey];
879
+ }
880
+
881
+ const proc = spawn(cmd, args, {
882
+ stdio: 'ignore',
883
+ windowsHide: true,
884
+ env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir },
885
+ });
886
+ _previewModalProc = proc;
887
+
888
+ const _restoreBg = () => { if (_bgRestore) _bgRestore(); };
889
+
890
+ proc.on('exit', () => {
891
+ _previewModalProc = null;
892
+ _restoreBg();
893
+ if (!_closed) { previewLine.setContent(''); screen.render(); }
894
+ });
895
+ proc.on('error', () => {
896
+ _previewModalProc = null;
897
+ _restoreBg();
898
+ if (!_closed) { previewLine.setContent('{red-fg}Preview failed{/red-fg}'); screen.render(); }
899
+ });
900
+ }
901
+
902
+ // Auto-save: persist draft to config immediately on any change
903
+ function _autoSave(silent) {
904
+ // Infer engine from voice — voice picker only shows Piper voices,
905
+ // so if a voice is set but no engine chosen, default to piper
906
+ const engine = draft.ttsEngine || (draft.voice ? 'piper' : '');
907
+ saveLlmConfigSync(llmKey, {
908
+ voice: draft.voice,
909
+ pretext: draft.pretext,
910
+ effects: draft.reverbPreset === 'off' ? '' : draft.reverbPreset,
911
+ bgTrack: draft.bgTrack,
912
+ bgVolume: draft.bgVolume,
913
+ ttsEngine: engine,
914
+ sourcePath: config.sourcePath,
915
+ }, targetDir);
916
+ if (!silent) {
917
+ const cfgPath = config.sourcePath || resolveCfgPath(targetDir);
918
+ _showSavedToast('Settings', cfgPath);
919
+ }
920
+ }
921
+
922
+ const previewBtn = _modalBtn('Preview', 4, _playPreview);
923
+
924
+ const resetBtn = _modalBtn('Reset', 18, () => {
925
+ draft.ttsEngine = '';
926
+ draft.voice = '';
927
+ draft.pretext = defaultPretext[llmKey] || '';
928
+ draft.reverbPreset = 'off';
929
+ draft.bgTrack = '';
930
+ draft.bgVolume = '0.15';
931
+ _autoSave();
932
+ fieldList.setItems(_fieldItems());
933
+ fieldList.focus();
934
+ screen.render();
935
+ });
936
+
937
+ const closeBtn = _modalBtn('Close', 30, _closeModal);
938
+
939
+ const allBtns = [previewBtn, resetBtn, closeBtn];
940
+ const btnBlink = attachBtnBlink(allBtns, screen);
941
+
942
+ function _closeModal() {
943
+ if (_closed) return;
944
+ _closed = true;
945
+ _killPreview();
946
+ btnBlink.cleanup();
947
+ navigationService?.closeModal();
948
+ destroyList(modal, screen);
949
+ if (providerFocusableItems.length) providerFocusableItems[providerFocusIndex]?.focus();
950
+ screen.render();
951
+ }
952
+
953
+ // Field editing via Enter
954
+ fieldList.key(['enter'], () => {
955
+ const idx = fieldList.selected;
956
+ const field = FIELDS[idx];
957
+ if (!field) return;
958
+
959
+ const _refreshField = () => {
960
+ _autoSave();
961
+ fieldList.setItems(_fieldItems());
962
+ fieldList.select(idx);
963
+ fieldList.focus();
964
+ screen.render();
965
+ };
966
+ const _cancelField = () => {
967
+ fieldList.focus();
968
+ screen.render();
969
+ };
970
+
971
+ switch (field.key) {
972
+ case 'ttsEngine':
973
+ _openTtsEnginePicker(draft, _refreshField);
974
+ break;
975
+
976
+ case 'voice':
977
+ _openVoicePickerForLlm(draft, _refreshField);
978
+ break;
979
+
980
+ case 'pretext':
981
+ _openPretextEditor(modal, draft, _refreshField);
982
+ break;
983
+
984
+ case 'reverb':
985
+ openReverbPicker(screen, draft.reverbPreset, (val) => {
986
+ draft.reverbPreset = val;
987
+ _refreshField();
988
+ }, _cancelField, { applyToEffectsManager: false });
989
+ break;
990
+
991
+ case 'bgTrack':
992
+ openTrackPicker(screen, draft.bgTrack, Math.round(parseFloat(draft.bgVolume) * 100), (track) => {
993
+ draft.bgTrack = track;
994
+ _refreshField();
995
+ }, _cancelField, { skipVolume: true });
996
+ break;
997
+
998
+ case 'bgVolume':
999
+ openVolumeInput(screen, Math.round(parseFloat(draft.bgVolume) * 100), (volume) => {
1000
+ draft.bgVolume = (volume / 100).toFixed(2);
1001
+ _refreshField();
1002
+ }, _cancelField);
1003
+ break;
1004
+ }
1005
+ });
1006
+
1007
+ fieldList.key(['escape'], _closeModal);
1008
+
1009
+ // Remove selection highlight when field list loses focus
1010
+ fieldList.on('blur', () => {
1011
+ fieldList.style.selected = { bg: COLORS.contentBg, fg: COLORS.labelFg };
1012
+ fieldList.setItems(_fieldItems());
1013
+ screen.render();
1014
+ });
1015
+ fieldList.on('focus', () => {
1016
+ fieldList.style.selected = { bg: 'blue', fg: 'yellow' };
1017
+ fieldList.setItems(_fieldItems());
1018
+ screen.render();
1019
+ });
1020
+
1021
+ // Wrap: down on last field → focus Save; up on first field → focus Save
1022
+ // One extra arrow press at boundary moves to button row.
1023
+ // Track previous selection so arriving at boundary doesn't immediately jump.
1024
+ let _prevFieldSel = 0;
1025
+ fieldList.key(['down'], () => {
1026
+ const cur = fieldList.selected ?? 0;
1027
+ if (cur === FIELDS.length - 1 && _prevFieldSel === FIELDS.length - 1) {
1028
+ allBtns[0].focus(); screen.render();
1029
+ }
1030
+ _prevFieldSel = cur;
1031
+ });
1032
+ fieldList.key(['up'], () => {
1033
+ const cur = fieldList.selected ?? 0;
1034
+ if (cur === 0 && _prevFieldSel === 0) {
1035
+ allBtns[0].focus(); screen.render();
1036
+ }
1037
+ _prevFieldSel = cur;
1038
+ });
1039
+ fieldList.key(['tab'], () => {
1040
+ allBtns[0].focus();
1041
+ screen.render();
1042
+ });
1043
+
1044
+ for (let i = 0; i < allBtns.length; i++) {
1045
+ allBtns[i].key(['tab', 'right'], () => {
1046
+ allBtns[(i + 1) % allBtns.length].focus();
1047
+ screen.render();
1048
+ });
1049
+ allBtns[i].key(['S-tab', 'left'], () => {
1050
+ allBtns[(i - 1 + allBtns.length) % allBtns.length].focus();
1051
+ screen.render();
1052
+ });
1053
+ allBtns[i].key(['escape'], _closeModal);
1054
+ allBtns[i].key(['up'], () => {
1055
+ fieldList.focus();
1056
+ screen.render();
1057
+ });
1058
+ }
1059
+
1060
+ modal.key(['escape'], _closeModal);
1061
+ fieldList.focus();
1062
+ screen.render();
1063
+ }
1064
+
1065
+ // ── TTS Engine picker (for config modal) ──────────────────────────────────
1066
+
1067
+ function _openTtsEnginePicker(draft, onDone) {
1068
+ navigationService?.openModal();
1069
+
1070
+ const engines = getEngineStatuses();
1071
+ const items = engines.map(e => {
1072
+ const status = e.installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[Not Found]{/yellow-fg}';
1073
+ return ` ${e.name.padEnd(20)} ${status} ${e.desc}`;
1074
+ });
1075
+ // Add "(global default)" option at top
1076
+ items.unshift(' (global default)');
1077
+
1078
+ const picker = blessed.list({
1079
+ parent: screen,
1080
+ top: 'center',
1081
+ left: 'center',
1082
+ width: 70,
1083
+ height: Math.min(items.length + 4, 16),
1084
+ border: { type: 'line' },
1085
+ tags: true,
1086
+ label: ' {bold}{cyan-fg} Select TTS Engine {/cyan-fg}{/bold} ',
1087
+ keys: true,
1088
+ vi: false,
1089
+ mouse: true,
1090
+ style: {
1091
+ fg: COLORS.labelFg,
1092
+ bg: COLORS.contentBg,
1093
+ border: { fg: 'cyan' },
1094
+ selected: { bg: 'blue', fg: 'yellow' },
1095
+ item: { fg: COLORS.labelFg },
1096
+ },
1097
+ });
1098
+ picker.setFront();
1099
+ picker.setItems(items);
1100
+
1101
+ picker.key(['enter'], () => {
1102
+ const idx = picker.selected;
1103
+ if (idx === 0) {
1104
+ draft.ttsEngine = '';
1105
+ } else {
1106
+ draft.ttsEngine = engines[idx - 1].id;
1107
+ }
1108
+ navigationService?.closeModal();
1109
+ destroyList(picker, screen);
1110
+ onDone();
1111
+ });
1112
+
1113
+ picker.key(['escape'], () => {
1114
+ navigationService?.closeModal();
1115
+ destroyList(picker, screen);
1116
+ onDone();
1117
+ });
1118
+
1119
+ picker.focus();
1120
+ screen.render();
1121
+ }
1122
+
1123
+ // ── Voice picker for LLM config (matches agents-tab pattern) ──────────────
1124
+
1125
+ function _secureTempWav(prefix) {
1126
+ const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
1127
+ const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
1128
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1129
+ try { fs.chmodSync(dir, 0o700); } catch {}
1130
+ return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
1131
+ }
1132
+
1133
+ function _openVoicePickerForLlm(draft, onDone) {
1134
+ navigationService?.openModal();
1135
+
1136
+ let _allVoices = [];
1137
+ let _previewProc = null;
1138
+ let _previewVoiceId = null;
1139
+ let _vpClosed = false;
1140
+
1141
+ const _spawnEnv = buildAudioEnv();
1142
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1143
+
1144
+ function _killVP() {
1145
+ if (_previewProc) {
1146
+ try {
1147
+ if (_isWin) { _previewProc.kill(); } else { process.kill(-_previewProc.pid, 'SIGTERM'); }
1148
+ } catch {}
1149
+ _previewProc = null;
1150
+ }
1151
+ _previewVoiceId = null;
1152
+ }
1153
+
1154
+ function _closeVP() {
1155
+ if (_vpClosed) return;
1156
+ _vpClosed = true;
1157
+ _killVP();
1158
+ navigationService?.closeModal();
1159
+ destroyList(vpModal, screen, onDone);
1160
+ }
1161
+
1162
+ const vpModal = blessed.box({
1163
+ parent: screen,
1164
+ top: '6%',
1165
+ left: '3%',
1166
+ width: '94%',
1167
+ height: '88%',
1168
+ border: { type: 'line' },
1169
+ tags: true,
1170
+ label: ' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ',
1171
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
1172
+ });
1173
+ vpModal.setFront();
1174
+
1175
+ // Column header
1176
+ const COL_N = 30;
1177
+ const COL_G = 4;
1178
+ blessed.text({
1179
+ parent: vpModal, top: 1, left: 6, tags: true,
1180
+ content: `{cyan-fg}${'Name'.padEnd(COL_N)}{/cyan-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {cyan-fg}Provider{/cyan-fg}`,
1181
+ style: { bg: COLORS.contentBg },
1182
+ });
1183
+
1184
+ const vpList = blessed.list({
1185
+ parent: vpModal, top: 2, left: 2, right: 2, bottom: 5,
1186
+ keys: true, vi: true, mouse: true,
1187
+ border: { type: 'line' },
1188
+ scrollbar: { ch: '|', style: { fg: 'cyan' } },
1189
+ tags: true,
1190
+ style: {
1191
+ fg: COLORS.labelFg, bg: COLORS.contentBg,
1192
+ border: { fg: 'blue' },
1193
+ selected: { bg: 'green', fg: 'white', bold: true },
1194
+ item: { fg: COLORS.labelFg },
1195
+ },
1196
+ });
1197
+
1198
+ const vpPreviewLine = blessed.text({
1199
+ parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
1200
+ content: '', style: { fg: 'cyan', bg: COLORS.contentBg },
1201
+ });
1202
+
1203
+ blessed.text({
1204
+ parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
1205
+ content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [Home/End] [a-z] Jump [Enter] Select [Space] Preview [*] Fav [Esc] Cancel{/white-fg}',
1206
+ style: { bg: COLORS.contentBg },
1207
+ });
1208
+
1209
+ function _buildVoiceItems(voices) {
1210
+ const favs = getFavorites(configService);
1211
+ return voices.map(v => {
1212
+ const isActive = v === draft.voice;
1213
+ const isPrev = v === _previewVoiceId;
1214
+ const isFav = favs.includes(v);
1215
+ const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
1216
+ const star = isFav ? '{yellow-fg}★{/yellow-fg}' : ' ';
1217
+ const meta = getVoiceMeta(v);
1218
+ const name = meta.displayName.length > COL_N
1219
+ ? meta.displayName.slice(0, COL_N - 1) + '…'
1220
+ : meta.displayName.padEnd(COL_N);
1221
+ // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
1222
+ return ` ${dot}${star} ${name}${genderIconTag(meta.gender)} ${meta.provider}`;
1223
+ });
1224
+ }
1225
+
1226
+ function _refreshVP() {
1227
+ if (_vpClosed) return;
1228
+ const savedIdx = vpList.selected ?? 0;
1229
+ const savedScroll = vpList.childBase ?? 0;
1230
+ _allVoices = scanInstalledVoices();
1231
+ // Sort by display name so the first-letter quick jump is intuitive
1232
+ _allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
1233
+ getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
1234
+ const items = _buildVoiceItems(_allVoices);
1235
+ vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
1236
+ vpList.select(Math.min(savedIdx, items.length - 1));
1237
+ vpList.childBase = Math.min(savedScroll, Math.max(0, items.length - (vpList.height - 2)));
1238
+ screen.render();
1239
+ }
1240
+
1241
+ function _previewVoice(voiceId) {
1242
+ if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
1243
+ _killVP();
1244
+
1245
+ const _ms = parseMultiSpeaker(voiceId);
1246
+ const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
1247
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
1248
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
1249
+
1250
+ const tempWav = _secureTempWav('vp');
1251
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1252
+
1253
+ let _piperBin = 'piper';
1254
+ if (_isWin) {
1255
+ const _lad = process.env.LOCALAPPDATA ||
1256
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
1257
+ if (_lad) {
1258
+ const _ep = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
1259
+ if (fs.existsSync(_ep)) _piperBin = _ep;
1260
+ }
1261
+ }
1262
+
1263
+ const args = ['--model', voicePath, '--output_file', tempWav];
1264
+ if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
1265
+ const piper = spawn(_piperBin, args, {
1266
+ stdio: ['pipe', 'ignore', 'ignore'],
1267
+ detached: !_isWin,
1268
+ windowsHide: true,
1269
+ env: _spawnEnv,
1270
+ });
1271
+ piper.stdin.write(phrase + '\n');
1272
+ piper.stdin.end();
1273
+ _previewProc = piper;
1274
+ _previewVoiceId = voiceId;
1275
+
1276
+ if (!_vpClosed) {
1277
+ vpPreviewLine.setContent(`{cyan-fg}♪ Synthesizing: ${voiceId}...{/cyan-fg}`);
1278
+ _refreshVP();
1279
+ }
1280
+
1281
+ piper.on('exit', (code) => {
1282
+ if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
1283
+ if (code !== 0) { _previewProc = null; _previewVoiceId = null; try { fs.unlinkSync(tempWav); } catch {} return; }
1284
+ const wp = detectWavPlayer(_spawnEnv);
1285
+ if (!wp) return;
1286
+ const pp = spawn(wp.bin, wp.args(tempWav), {
1287
+ stdio: 'ignore',
1288
+ detached: !_isWin,
1289
+ windowsHide: true,
1290
+ env: _spawnEnv,
1291
+ });
1292
+ _previewProc = pp;
1293
+ if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}{/cyan-fg}`); screen.render(); }
1294
+ pp.on('exit', () => {
1295
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
1296
+ try { fs.unlinkSync(tempWav); } catch {}
1297
+ });
1298
+ });
1299
+ piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1300
+ }
1301
+
1302
+ vpList.key(['enter'], () => {
1303
+ const sel = _allVoices[vpList.selected];
1304
+ if (sel) { draft.voice = sel; _closeVP(); }
1305
+ });
1306
+ vpList.key(['space'], () => {
1307
+ const sel = _allVoices[vpList.selected];
1308
+ if (sel) _previewVoice(sel);
1309
+ });
1310
+ vpList.key(['*'], () => {
1311
+ const sel = _allVoices[vpList.selected];
1312
+ if (sel) { toggleFavorite(configService, sel); _refreshVP(); }
1313
+ });
1314
+ vpList.key(['escape', 'q'], _closeVP);
1315
+
1316
+ // PageUp / PageDown / Home / End navigation
1317
+ const _pageSize = () => Math.max(1, (vpList.height ?? 10) - 2);
1318
+ vpList.key(['pageup'], () => { vpList.up(_pageSize()); screen.render(); });
1319
+ vpList.key(['pagedown'], () => { vpList.down(_pageSize()); screen.render(); });
1320
+ vpList.key(['home'], () => { vpList.select(0); screen.render(); });
1321
+ vpList.key(['end'], () => { vpList.select(Math.max(0, _allVoices.length - 1)); screen.render(); });
1322
+
1323
+ // First-letter quick jump: typing 'a' jumps to the first voice starting
1324
+ // with A. Block keys reserved by the list widget (vi nav, cancel) so
1325
+ // they don't get swallowed: q (cancel), j/k/g/h/l (vi navigation).
1326
+ const _vpJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'q']);
1327
+ vpList.on('keypress', (ch, key) => {
1328
+ if (!ch || key?.ctrl || key?.meta) return;
1329
+ if (!/^[a-zA-Z]$/.test(ch)) return;
1330
+ const target = ch.toLowerCase();
1331
+ if (_vpJumpBlocked.has(target)) return;
1332
+ const idx = _allVoices.findIndex(v => {
1333
+ const name = getVoiceMeta(v).displayName.toLowerCase();
1334
+ return name.startsWith(target);
1335
+ });
1336
+ if (idx >= 0) { vpList.select(idx); screen.render(); }
1337
+ });
1338
+
1339
+ _refreshVP();
1340
+ const activeIdx = _allVoices.indexOf(draft.voice);
1341
+ if (activeIdx >= 0) vpList.select(activeIdx);
1342
+ vpList.focus();
1343
+ screen.render();
1344
+ }
1345
+
1346
+ // ── Pretext editor ────────────────────────────────────────────────────────
1347
+
1348
+ function _openPretextEditor(parentModal, draft, onDone) {
1349
+ const editModal = blessed.box({
1350
+ parent: screen, top: 'center', left: 'center',
1351
+ width: 60, height: 8,
1352
+ border: { type: 'line' },
1353
+ tags: true,
1354
+ label: ' {bold}{cyan-fg} Edit Pretext {/cyan-fg}{/bold} ',
1355
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
1356
+ });
1357
+ editModal.setFront();
1358
+
1359
+ blessed.text({
1360
+ parent: editModal, top: 1, left: 2, tags: true,
1361
+ content: '{white-fg}Spoken before every TTS message (max 200 chars):{/white-fg}',
1362
+ style: { bg: COLORS.contentBg },
1363
+ });
1364
+
1365
+ const inputBox = blessed.textbox({
1366
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
1367
+ border: { type: 'line' },
1368
+ inputOnFocus: true,
1369
+ value: draft.pretext,
1370
+ style: {
1371
+ fg: 'white', bg: 'black',
1372
+ border: { fg: 'blue' },
1373
+ focus: { border: { fg: 'cyan' } },
1374
+ },
1375
+ });
1376
+
1377
+ function _closeEdit(save) {
1378
+ if (save) {
1379
+ const val = (inputBox.getValue() || '').trim().slice(0, 200);
1380
+ draft.pretext = val;
1381
+ }
1382
+ destroyList(editModal, screen);
1383
+ onDone();
1384
+ }
1385
+
1386
+ inputBox.key(['enter'], () => _closeEdit(true));
1387
+ inputBox.key(['escape'], () => _closeEdit(false));
1388
+
1389
+ inputBox.focus();
1390
+ inputBox.readInput(() => {});
1391
+ screen.render();
1392
+ }
1393
+
1394
+ // ── Saved toast ───────────────────────────────────────────────────────────
1395
+
1396
+ function _showSavedToast(name, filePath) {
1397
+ const lines = [`{center}{green-fg}{bold}${name} saved!{/bold}{/green-fg}{/center}`];
1398
+ if (filePath) lines.push(`{center}{white-fg}${filePath}{/white-fg}{/center}`);
1399
+ const w = filePath ? Math.min(Math.max(filePath.length + 6, 30), 70) : 30;
1400
+ const toast = blessed.box({
1401
+ parent: screen,
1402
+ top: 'center',
1403
+ left: 'center',
1404
+ width: w,
1405
+ height: filePath ? 4 : 3,
1406
+ border: { type: 'line' },
1407
+ tags: true,
1408
+ content: lines.join('\n'),
1409
+ style: { bg: COLORS.contentBg, border: { fg: 'green' } },
1410
+ });
1411
+ toast.setFront();
1412
+ screen.render();
1413
+ setTimeout(() => {
1414
+ toast.destroy();
1415
+ screen.render();
1416
+ }, 1500);
1417
+ }
1418
+
1419
+ // ── Provider info panels ──────────────────────────────────────────────────
1420
+
1421
+ function hideAllProviderRows() {
1422
+ providerHeader.hide();
1423
+ for (const row of providerRows) {
1424
+ row.label.hide();
1425
+ row.statusText.hide();
1426
+ row.installBtn.hide();
1427
+ row.removeBtn.hide();
1428
+ row.configBtn.hide();
1429
+ }
1430
+ }
1431
+
1432
+ function showAllProviderRows() {
1433
+ providerHeader.show();
1434
+ for (const row of providerRows) {
1435
+ row.label.show();
1436
+ row.statusText.show();
1437
+ row.installBtn.show();
1438
+ row.removeBtn.show();
1439
+ row.configBtn.show();
1440
+ }
1441
+ }
1442
+
1443
+ function showClaudeCodeInfo(result = null, wasInstalled = false) {
1444
+ providerView = 'info';
1445
+ hideAllProviderRows();
1446
+ contentBox.hide();
1447
+
1448
+ const mcpPath = path.join(targetDir, '.mcp.json');
1449
+ const hooksDir = path.join(targetDir, '.claude', process.platform === 'win32' ? 'hooks-windows' : 'hooks');
1450
+ const installed = installedState['claude-code'];
1451
+ const verb = wasInstalled ? 'reinstalled' : 'installed';
1452
+
1453
+ const lines = [];
1454
+ lines.push('{bold}{cyan-fg}Claude Code -- AgentVibes Integration{/cyan-fg}{/bold}');
1455
+ lines.push('');
1456
+
1457
+ if (result) {
1458
+ lines.push(result.success
1459
+ ? `{green-fg}AgentVibes for Claude Code ${verb}!{/green-fg}`
1460
+ : `{red-fg}Installation failed{/red-fg}`);
1461
+ } else {
1462
+ lines.push(installed
1463
+ ? '{green-fg}Installed{/green-fg}'
1464
+ : '{yellow-fg}Not installed{/yellow-fg}');
1465
+ }
1466
+
1467
+ lines.push('');
1468
+ lines.push(`{bold}{cyan-fg}What ${result ? `got ${verb}` : 'gets installed'}:{/cyan-fg}{/bold}`);
1469
+ lines.push('');
1470
+ lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.mcp.json{/bold} (project root)');
1471
+ lines.push(` Location: ${mcpPath}`);
1472
+ lines.push(' Registers the AgentVibes MCP server for Claude Code.');
1473
+ lines.push('');
1474
+ lines.push(' {yellow-fg}2.{/yellow-fg} {bold}.claude/hooks/{/bold} (session-start + pre-tool hooks)');
1475
+ lines.push(` Location: ${hooksDir}`);
1476
+ lines.push('');
1477
+ lines.push(' {yellow-fg}3.{/yellow-fg} {bold}.claude/commands/{/bold} (slash commands)');
1478
+ lines.push('');
1479
+ lines.push(' {yellow-fg}4.{/yellow-fg} {bold}.claude/config/{/bold} (personality, verbosity, voice settings)');
1480
+ lines.push('');
1481
+ lines.push('{white-fg}Press {bold}Enter{/bold} or {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1482
+
1483
+ infoBox.setContent(lines.join('\n'));
1484
+ infoBox.show();
1485
+ infoBox.setFront();
1486
+ infoBox.focus();
1487
+ infoBox.scrollTo(0);
1488
+ screen.render();
1489
+ }
1490
+
1491
+ function showCopilotInfo(result, wasInstalled = false) {
1492
+ providerView = 'info';
1493
+ hideAllProviderRows();
1494
+ contentBox.hide();
1495
+
1496
+ const verb = wasInstalled ? 'reinstalled' : 'installed';
1497
+
1498
+ const lines = [];
1499
+ lines.push('{bold}{cyan-fg}GitHub Copilot -- AgentVibes Integration{/cyan-fg}{/bold}');
1500
+ lines.push('');
1501
+ lines.push(result.success
1502
+ ? `{green-fg}AgentVibes for Copilot ${verb}!{/green-fg}`
1503
+ : `{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1504
+ lines.push('');
1505
+ lines.push(`{bold}{cyan-fg}What got ${verb}:{/cyan-fg}{/bold}`);
1506
+ lines.push('');
1507
+ lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.vscode/mcp.json{/bold}');
1508
+ lines.push(' {yellow-fg}2.{/yellow-fg} {bold}.github/copilot-instructions.md{/bold}');
1509
+ lines.push('');
1510
+ lines.push('{white-fg}Press {bold}Enter{/bold} or {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1511
+
1512
+ infoBox.setContent(lines.join('\n'));
1513
+ infoBox.show();
1514
+ infoBox.setFront();
1515
+ infoBox.focus();
1516
+ infoBox.scrollTo(0);
1517
+ screen.render();
1518
+ }
1519
+
1520
+ function showCodexInfo(result, wasInstalled = false) {
1521
+ providerView = 'info';
1522
+ hideAllProviderRows();
1523
+ contentBox.hide();
1524
+
1525
+ const verb = wasInstalled ? 'reinstalled' : 'installed';
1526
+
1527
+ const lines = [];
1528
+ lines.push('{bold}{cyan-fg}OpenAI Codex -- AgentVibes Integration{/cyan-fg}{/bold}');
1529
+ lines.push('');
1530
+ lines.push(result.success
1531
+ ? `{green-fg}AgentVibes for Codex ${verb}!{/green-fg}`
1532
+ : `{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1533
+ lines.push('');
1534
+ lines.push(`{bold}{cyan-fg}What got ${verb}:{/cyan-fg}{/bold}`);
1535
+ lines.push('');
1536
+ lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.codex/config.toml{/bold}');
1537
+ lines.push(' {yellow-fg}2.{/yellow-fg} {bold}.vscode/mcp.json{/bold}');
1538
+ lines.push(' {yellow-fg}3.{/yellow-fg} {bold}AGENTS.md{/bold}');
1539
+ lines.push(' {yellow-fg}4.{/yellow-fg} {bold}.codex/hooks/{/bold}');
1540
+ lines.push('');
1541
+ lines.push('{white-fg}Press {bold}Enter{/bold} or {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1542
+
1543
+ infoBox.setContent(lines.join('\n'));
1544
+ infoBox.show();
1545
+ infoBox.setFront();
1546
+ infoBox.focus();
1547
+ infoBox.scrollTo(0);
1548
+ screen.render();
1549
+ }
1550
+
1551
+ function showRemoveInfo(providerId, removedItems) {
1552
+ providerView = 'info';
1553
+ hideAllProviderRows();
1554
+ contentBox.hide();
1555
+
1556
+ const lines = [];
1557
+ if (providerId === 'claude-code') {
1558
+ lines.push('{bold}{cyan-fg}Claude Code -- Uninstalled{/cyan-fg}{/bold}');
1559
+ lines.push('');
1560
+ lines.push('{green-fg}AgentVibes fully removed from this project!{/green-fg}');
1561
+ lines.push('');
1562
+ if (removedItems && removedItems.length > 0) {
1563
+ lines.push('{bold}{cyan-fg}Removed:{/cyan-fg}{/bold}');
1564
+ for (const item of removedItems) {
1565
+ lines.push(` {yellow-fg}•{/yellow-fg} ${item}`);
1566
+ }
1567
+ lines.push('');
1568
+ }
1569
+ lines.push('{white-fg}Re-install anytime with the Install button.{/white-fg}');
1570
+ } else if (providerId === 'github-copilot') {
1571
+ lines.push('{bold}{cyan-fg}GitHub Copilot -- Removed{/cyan-fg}{/bold}');
1572
+ lines.push('');
1573
+ lines.push('{green-fg}Successfully removed!{/green-fg}');
1574
+ } else if (providerId === 'openai-codex') {
1575
+ lines.push('{bold}{cyan-fg}OpenAI Codex -- Removed{/cyan-fg}{/bold}');
1576
+ lines.push('');
1577
+ lines.push('{green-fg}Successfully removed!{/green-fg}');
1578
+ }
1579
+ lines.push('');
1580
+ lines.push('{white-fg}Press {bold}Enter{/bold} or {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1581
+
1582
+ infoBox.setContent(lines.join('\n'));
1583
+ infoBox.show();
1584
+ infoBox.setFront();
1585
+ infoBox.focus();
1586
+ infoBox.scrollTo(0);
1587
+ screen.render();
1588
+ }
1589
+
1590
+ function showProviderListView() {
1591
+ providerView = 'list';
1592
+ infoBox.hide();
1593
+ contentBox.hide();
1594
+ showAllProviderRows();
1595
+ providerFocusIndex = 0;
1596
+ if (providerFocusableItems.length) providerFocusableItems[0].focus();
1597
+ screen.render();
1598
+ }
1599
+
1600
+ infoBox.key(['escape', 'enter'], () => {
1601
+ showProviderListView();
1602
+ });
1603
+
1604
+ async function refreshInstalledState() {
1605
+ for (const p of PROVIDERS) {
1606
+ const checkFn = p.id === 'claude-code' ? checkClaudeInstalled
1607
+ : p.id === 'github-copilot' ? checkCopilotInstalled
1608
+ : checkCodexInstalled;
1609
+ installedState[p.id] = await checkFn(targetDir);
1610
+ }
1611
+ for (const row of providerRows) {
1612
+ const installed = installedState[row.id];
1613
+ row.statusText.setContent(
1614
+ installed
1615
+ ? '{green-fg}[Installed]{/green-fg}'
1616
+ : '{yellow-fg}[Not Installed]{/yellow-fg}'
1617
+ );
1618
+ row.installBtn.setContent(installed ? ' Re-install ' : ' Install ');
1619
+ }
1620
+ }
1621
+
1622
+ // =========================================================================
1623
+ // Screen renderers
1624
+ // =========================================================================
1625
+
1626
+ const _HDR = (emoji, label) =>
1627
+ `{${COLORS.sectionHdr}-fg}${emoji} ${label} ${'--'.repeat(50)}{/${COLORS.sectionHdr}-fg}`;
1628
+
1629
+ function _renderScreen0() {
1630
+ const lines = [
1631
+ _HDR('', 'Language / Idioma / Langue / Sprache'),
1632
+ '',
1633
+ ' Select your language:',
1634
+ '',
1635
+ ...SUPPORTED_LANGUAGES.map((l, i) =>
1636
+ i === _langIdx
1637
+ ? ` {green-fg}> ${l.name}{/green-fg}`
1638
+ : ` ${l.name}`
1639
+ ),
1640
+ ];
1641
+ contentBox.setContent(_c(lines));
1642
+ hintLine.setContent(' Screen 0: Language | [Up/Down] Select | [Enter] Apply & Continue | [->] Skip (English)');
1643
+ screen.render();
1644
+ }
1645
+
1646
+ async function _renderScreen1() {
1647
+ const frames = ['|','/','-','\\'];
1648
+ let frameIdx = 0;
1649
+ _checking = true;
1650
+ _s1ContinueBtn.hide();
1651
+
1652
+ contentBox.setContent(_c([
1653
+ _HDR('', t(_getLang(), 'dependencyCheck')),
1654
+ '',
1655
+ ` {white-fg}${frames[0]} ${t(_getLang(), 'checkingDependencies')}{/white-fg}`,
1656
+ ]));
1657
+ hintLine.setContent(` ${t(_getLang(), 'screen2Hint')}`);
1658
+ screen.render();
1659
+
1660
+ const spinInterval = setInterval(() => {
1661
+ frameIdx = (frameIdx + 1) % frames.length;
1662
+ contentBox.setContent(_c([
1663
+ _HDR('', t(_getLang(), 'dependencyCheck')),
1664
+ '',
1665
+ ` {white-fg}${frames[frameIdx]} ${t(_getLang(), 'checkingDependencies')}{/white-fg}`,
1666
+ ]));
1667
+ screen.render();
1668
+ }, 100);
1669
+
1670
+ try {
1671
+ _deps = await _checkDependenciesAsync();
1672
+ } finally {
1673
+ clearInterval(spinInterval);
1674
+ _checking = false;
1675
+ }
1676
+
1677
+ const ok = () => `{green-fg}OK ${t(_getLang(), 'installed')}{/green-fg}`;
1678
+ const bad = () => `{red-fg}X ${t(_getLang(), 'notFound')}{/red-fg}`;
1679
+
1680
+ const ttsOk = _deps.piper || _deps.soprano;
1681
+ contentBox.setContent(_c([
1682
+ _HDR('', t(_getLang(), 'dependencyCheck')),
1683
+ '',
1684
+ ` {white-fg}${'Dependency'.padEnd(14)}${'Status'}{/white-fg}`,
1685
+ ` {white-fg}${'---'.repeat(26)}{/white-fg}`,
1686
+ ` {white-fg}${'Node.js'.padEnd(14)}{/white-fg}${_deps.node ? ok() : bad()}`,
1687
+ ` {white-fg}${'npm'.padEnd(14)}{/white-fg}${_deps.npm ? ok() : bad()}`,
1688
+ ` {white-fg}${'Piper TTS'.padEnd(14)}{/white-fg}${_deps.piper ? ok() : bad()}`,
1689
+ ` {white-fg}${'Soprano TTS'.padEnd(14)}{/white-fg}${_deps.soprano ? ok() : bad()}`,
1690
+ ` {white-fg}${'ffmpeg'.padEnd(14)}{/white-fg}${_deps.ffmpeg ? ok() : `{red-fg}! ${t(_getLang(), 'ffmpegMissing')}{/red-fg}`}`,
1691
+ '',
1692
+ ttsOk
1693
+ ? ` {green-fg}OK ${t(_getLang(), 'ttsDetected')}{/green-fg}`
1694
+ : ` {red-fg}! ${t(_getLang(), 'noTtsFound')}{/red-fg}`,
1695
+ '',
1696
+ '',
1697
+ ]));
1698
+ if (ttsOk) {
1699
+ _s1ContinueBtn.setContent(_tl('continueArrowBtn'));
1700
+ _s1ContinueBtn.show();
1701
+ _s1ContinueBtn.focus();
1702
+ }
1703
+ screen.render();
1704
+ }
1705
+
1706
+ function _renderScreen2() {
1707
+ const lines = [
1708
+ _HDR('', 'TTS Engine Selection'),
1709
+ '',
1710
+ ' {white-fg}Select which TTS engines to use with AgentVibes:{/white-fg}',
1711
+ ];
1712
+
1713
+ contentBox.setContent(_c(lines));
1714
+
1715
+ _showTtsEngineRows();
1716
+
1717
+ // Position continue button below engine rows
1718
+ const btnY = 5 + (_ttsEngines.length * 3) + 1;
1719
+ _s2ContinueBtn.top = btnY;
1720
+ _s2ContinueBtn.left = 4;
1721
+ _s2ContinueBtn.show();
1722
+
1723
+ hintLine.setContent(' Screen 2: TTS Engines | [Tab] Install | [Enter/->] Continue | [Esc/<-] Back');
1724
+
1725
+ // Focus first visible install button or continue button
1726
+ const visibleBtns = _ttsFocusableItems.filter(b => !b.hidden);
1727
+ if (visibleBtns.length) {
1728
+ _ttsFocusIndex = 0;
1729
+ visibleBtns[0].focus();
1730
+ } else {
1731
+ _s2ContinueBtn.focus();
1732
+ }
1733
+ screen.render();
1734
+ }
1735
+
1736
+ function _renderScreen3() {
1737
+ // Mark setup as completed — write to targetDir in case configService
1738
+ // has a different projectRoot (e.g. npm link resolves differently).
1739
+ // Each step is wrapped individually so a partial failure (e.g. corrupt
1740
+ // local config file) does not block the others — and errors are logged
1741
+ // to stderr so the user can see why setup keeps re-running.
1742
+ try { configService.set('setupCompleted', true); }
1743
+ catch (e) { console.error('setupCompleted (project): ' + e.message); }
1744
+ try { configService.setGlobal?.('setupCompleted', true); }
1745
+ catch (e) { console.error('setupCompleted (global): ' + e.message); }
1746
+
1747
+ try {
1748
+ const localCfgDir = path.join(targetDir, '.agentvibes');
1749
+ const localCfgPath = path.join(localCfgDir, 'config.json');
1750
+ if (!fs.existsSync(localCfgPath)) {
1751
+ fs.mkdirSync(localCfgDir, { recursive: true });
1752
+ fs.writeFileSync(localCfgPath, JSON.stringify({ setupCompleted: true }, null, 2));
1753
+ } else {
1754
+ let existing = {};
1755
+ try {
1756
+ existing = JSON.parse(fs.readFileSync(localCfgPath, 'utf8'));
1757
+ } catch (e) {
1758
+ // Corrupt JSON — back up the old file and start fresh so the user
1759
+ // doesn't get stuck in an endless setup loop.
1760
+ console.error(`setupCompleted: ${localCfgPath} is corrupt (${e.message}); rewriting`);
1761
+ try { fs.renameSync(localCfgPath, localCfgPath + '.bak'); } catch {}
1762
+ existing = {};
1763
+ }
1764
+ if (!existing.setupCompleted) {
1765
+ existing.setupCompleted = true;
1766
+ fs.writeFileSync(localCfgPath, JSON.stringify(existing, null, 2));
1767
+ }
1768
+ }
1769
+ } catch (e) {
1770
+ console.error('setupCompleted (local file): ' + e.message);
1771
+ }
1772
+
1773
+ // Show provider rows instead of contentBox
1774
+ contentBox.hide();
1775
+ hintLine.setContent(' Screen 3: LLM Providers | [Enter] Action | [Tab] Next button | [Esc] Tab bar');
1776
+ showAllProviderRows();
1777
+ refreshInstalledState().then(() => {
1778
+ if (providerFocusableItems.length) {
1779
+ providerFocusIndex = 0;
1780
+ providerFocusableItems[0].focus();
1781
+ }
1782
+ screen.render();
1783
+ });
1784
+ }
1785
+
1786
+ function _showCurrentScreen() {
1787
+ // Hide Screen 1 continue button on other screens
1788
+ if (_screen !== 1) _s1ContinueBtn.hide();
1789
+
1790
+ // Hide Screen 2 TTS engine rows on other screens
1791
+ if (_screen !== 2) {
1792
+ _hideTtsEngineRows();
1793
+ _s2ContinueBtn.hide();
1794
+ }
1795
+
1796
+ // Hide provider rows on non-provider screens
1797
+ if (_screen !== 3) {
1798
+ hideAllProviderRows();
1799
+ infoBox.hide();
1800
+ providerView = 'list';
1801
+ }
1802
+
1803
+ // Show contentBox on screens 0-2
1804
+ if (_screen <= 2) {
1805
+ contentBox.show();
1806
+ }
1807
+
1808
+ if (_screen !== _lastScreen) {
1809
+ // Nuclear clear
1810
+ try {
1811
+ for (let r = 0; r < screen.height; r++) {
1812
+ const orow = screen.olines?.[r];
1813
+ if (!orow) continue;
1814
+ for (let c = 0; c < screen.width; c++) {
1815
+ if (orow[c]) orow[c][0] = -1;
1816
+ }
1817
+ }
1818
+ if (screen.lines?.[2]) screen.lines[2].dirty = true;
1819
+ } catch {}
1820
+
1821
+ const _clearLine = ' '.repeat(150);
1822
+ const _clearPage = Array(25).fill(_clearLine).join('\n');
1823
+ contentBox.setContent(_clearPage);
1824
+ hintLine.setContent(_clearLine);
1825
+ screen.render();
1826
+
1827
+ const targetScreen = _screen;
1828
+ _lastScreen = _screen;
1829
+ setTimeout(() => {
1830
+ if (_screen !== targetScreen) return;
1831
+ switch (_screen) {
1832
+ case -1: _renderScreenGlobal(); break;
1833
+ case 0: _renderScreen0(); break;
1834
+ case 1: _renderScreen1(); break;
1835
+ case 2: _renderScreen2(); break;
1836
+ case 3: _renderScreen3(); break;
1837
+ }
1838
+ }, 50);
1839
+ return;
1840
+ }
1841
+ switch (_screen) {
1842
+ case -1: _renderScreenGlobal(); break;
1843
+ case 0: _renderScreen0(); break;
1844
+ case 1: _renderScreen1(); break;
1845
+ case 2: _renderScreen2(); break;
1846
+ case 3: _renderScreen3(); break;
1847
+ }
1848
+ }
1849
+
1850
+ // =========================================================================
1851
+ // Navigation (key handlers)
1852
+ // =========================================================================
1853
+
1854
+ screen.key(['enter'], () => {
1855
+ if (box.hidden || _checking || navigationService?.isModalOpen()) return;
1856
+ if (_screen === -1) {
1857
+ // Global config choice screen
1858
+ if (_globalChoiceIdx === 0) {
1859
+ try { configService.saveAllToLocal(_pendingGlobalCfg); } catch {}
1860
+ _screen = 3;
1861
+ } else {
1862
+ _screen = 0;
1863
+ _langIdx = 0;
1864
+ }
1865
+ _pendingGlobalCfg = null;
1866
+ _showCurrentScreen();
1867
+ return;
1868
+ }
1869
+ if (_screen === 0) {
1870
+ if (languageService) languageService.setLang(SUPPORTED_LANGUAGES[_langIdx].value);
1871
+ _screen = 1;
1872
+ _showCurrentScreen();
1873
+ return;
1874
+ }
1875
+ if (_screen === 1) return; // Enter handled by Continue button
1876
+ if (_screen === 2) return; // Enter handled by Continue button and install buttons
1877
+ if (_screen === 3) return; // Enter handled by provider buttons
1878
+ });
1879
+
1880
+ screen.key(['escape'], () => {
1881
+ if (box.hidden || _checking || navigationService?.isModalOpen()) return;
1882
+ if (_screen === -1) {
1883
+ setTimeout(() => navigationService?.switchTab('settings'), 0);
1884
+ return;
1885
+ }
1886
+ if (_screen === 3 && providerView === 'info') {
1887
+ showProviderListView();
1888
+ return;
1889
+ }
1890
+ if (_screen > 0) {
1891
+ _screen--;
1892
+ _showCurrentScreen();
1893
+ } else {
1894
+ setTimeout(() => navigationService?.switchTab('settings'), 0);
1895
+ }
1896
+ });
1897
+
1898
+ screen.key(['up'], () => {
1899
+ if (box.hidden || navigationService?.isModalOpen()) return;
1900
+ if (_screen === -1) {
1901
+ _globalChoiceIdx = 0;
1902
+ _renderScreenGlobal();
1903
+ return;
1904
+ }
1905
+ if (_screen === 0) {
1906
+ _langIdx = Math.max(0, _langIdx - 1);
1907
+ _renderScreen0();
1908
+ return;
1909
+ }
1910
+ });
1911
+
1912
+ screen.key(['left'], () => {
1913
+ if (box.hidden || _checking || navigationService?.isModalOpen()) return;
1914
+ if (_screen === -1) return;
1915
+ if (_screen === 3) return; // Left handled by button nav
1916
+ if (_screen > 0) {
1917
+ _screen--;
1918
+ _showCurrentScreen();
1919
+ }
1920
+ });
1921
+
1922
+ screen.key(['right'], () => {
1923
+ if (box.hidden || _checking || navigationService?.isModalOpen()) return;
1924
+ if (_screen === -1) return;
1925
+ if (_screen === 0) {
1926
+ if (languageService) languageService.setLang(SUPPORTED_LANGUAGES[_langIdx].value);
1927
+ _screen = 1;
1928
+ _showCurrentScreen();
1929
+ return;
1930
+ }
1931
+ if (_screen === 1) return; // Right handled by Continue button
1932
+ if (_screen === 2) { if (_screen < 3) { _screen++; _showCurrentScreen(); } return; }
1933
+ if (_screen === 3) return; // Right handled by button nav
1934
+ });
1935
+
1936
+ screen.key(['down'], () => {
1937
+ if (box.hidden || navigationService?.isModalOpen()) return;
1938
+ if (_screen === -1) {
1939
+ _globalChoiceIdx = 1;
1940
+ _renderScreenGlobal();
1941
+ return;
1942
+ }
1943
+ if (_screen === 0) {
1944
+ _langIdx = Math.min(SUPPORTED_LANGUAGES.length - 1, _langIdx + 1);
1945
+ _renderScreen0();
1946
+ return;
1947
+ }
1948
+ });
1949
+
1950
+ // =========================================================================
1951
+ // Screen -1: Global Config Detection (pre-wizard)
1952
+ // =========================================================================
1953
+
1954
+ function _renderScreenGlobal() {
1955
+ const cfg = _pendingGlobalCfg || {};
1956
+ const cfgPath = configService?.getGlobalConfigPath?.() || '~/.agentvibes/config.json';
1957
+
1958
+ // Build settings preview
1959
+ const voice = cfg.voice || '(default)';
1960
+ const lang = cfg.language || 'en';
1961
+ const ttsEngine = cfg.ttsEngine || '(auto)';
1962
+ const verbosity = cfg.verbosity || 'high';
1963
+ const personality = cfg.personality || 'none';
1964
+
1965
+ const sel0 = _globalChoiceIdx === 0;
1966
+ const sel1 = _globalChoiceIdx === 1;
1967
+ const hi = '{magenta-bg}{white-fg}';
1968
+ const lo = '{/white-fg}{/magenta-bg}';
1969
+
1970
+ const lines = [
1971
+ _HDR('', 'Global Settings Found'),
1972
+ '',
1973
+ ` {white-fg}Location:{/white-fg} {yellow-fg}${cfgPath}{/yellow-fg}`,
1974
+ '',
1975
+ ` {cyan-fg}Voice:{/cyan-fg} {yellow-fg}${voice}{/yellow-fg}`,
1976
+ ` {cyan-fg}Language:{/cyan-fg} {yellow-fg}${lang}{/yellow-fg}`,
1977
+ ` {cyan-fg}TTS Engine:{/cyan-fg} {yellow-fg}${ttsEngine}{/yellow-fg}`,
1978
+ ` {cyan-fg}Verbosity:{/cyan-fg} {yellow-fg}${verbosity}{/yellow-fg}`,
1979
+ ` {cyan-fg}Personality:{/cyan-fg}{yellow-fg} ${personality}{/yellow-fg}`,
1980
+ '',
1981
+ ' {white-fg}What would you like to do for this project?{/white-fg}',
1982
+ '',
1983
+ ` ${sel0 ? hi : ''}> Load Global Settings${sel0 ? lo : ''} {white-fg}— use these settings for this project{/white-fg}`,
1984
+ ` ${sel1 ? hi : ''}> Start Fresh${sel1 ? lo : ''} {white-fg}— run the full setup wizard from scratch{/white-fg}`,
1985
+ '',
1986
+ ];
1987
+ contentBox.setContent(_c(lines));
1988
+ hintLine.setContent(' [Up/Down] Choose | [Enter] Select');
1989
+ box.focus();
1990
+ screen.render();
1991
+ }
1992
+
1993
+ // =========================================================================
1994
+ // Tab Component Contract
1995
+ // =========================================================================
1996
+
1997
+ return {
1998
+ box,
1999
+
2000
+ show() {
2001
+ _lastScreen = -1;
2002
+ providerView = 'list';
2003
+ box.show();
2004
+
2005
+ // Detect if AgentVibes is already installed in the target directory
2006
+ // (e.g. user ran install, closed TUI, came back)
2007
+ const alreadyInstalled = fs.existsSync(path.join(targetDir, '.claude', 'commands', 'agent-vibes'));
2008
+
2009
+ // Check: no local config but global exists with setupCompleted
2010
+ const hasLocal = configService?.hasLocalConfig?.();
2011
+ const globalCfg = configService?.getGlobalConfig?.() ?? {};
2012
+ if (!alreadyInstalled && !hasLocal && globalCfg.setupCompleted) {
2013
+ _pendingGlobalCfg = globalCfg;
2014
+ _screen = -1;
2015
+ _showCurrentScreen();
2016
+ screen.render();
2017
+ return;
2018
+ }
2019
+
2020
+ // If already installed or not first run, skip directly to Screen 3 (providers)
2021
+ if (alreadyInstalled || !_isFirstRun()) {
2022
+ _screen = 3;
2023
+ } else {
2024
+ _screen = 0;
2025
+ _langIdx = 0;
2026
+ }
2027
+ _showCurrentScreen();
2028
+ screen.render();
2029
+ },
2030
+
2031
+ hide() {
2032
+ box.hide();
2033
+ hideAllProviderRows();
2034
+ infoBox.hide();
2035
+ providerView = 'list';
2036
+ screen.render();
2037
+ },
2038
+
2039
+ onFocus() {
2040
+ if (_screen === 0) {
2041
+ box.focus();
2042
+ } else if (_screen === 3) {
2043
+ if (providerView === 'list') {
2044
+ providerFocusIndex = 0;
2045
+ if (providerFocusableItems.length) providerFocusableItems[0].focus();
2046
+ } else {
2047
+ infoBox.focus();
2048
+ }
2049
+ } else {
2050
+ box.focus();
2051
+ }
2052
+ screen.render();
2053
+ },
2054
+
2055
+ onBlur() {},
2056
+
2057
+ getFooterText() {
2058
+ if (_screen === 3) {
2059
+ if (providerView === 'info') {
2060
+ return '[Esc] Back to list [Up/Down] Scroll';
2061
+ }
2062
+ return '[Enter] Action [Tab] Next button [Esc] Tab bar';
2063
+ }
2064
+ return _tl('footerText');
2065
+ },
2066
+
2067
+ getFooterColor() {
2068
+ return COLORS.footerBg;
2069
+ },
2070
+ };
2071
+ }