agentvibes 3.5.9 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.agentvibes/bmad/bmad-voices-enabled.flag +0 -0
  2. package/.agentvibes/bmad/bmad-voices.md +69 -0
  3. package/.claude/config/audio-effects.cfg +1 -1
  4. package/.claude/config/background-music-position.txt +1 -27
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/audio-processor.sh +32 -17
  7. package/.claude/hooks/bmad-speak-enhanced.sh +5 -5
  8. package/.claude/hooks/bmad-speak.sh +4 -4
  9. package/.claude/hooks/bmad-voice-manager.sh +8 -8
  10. package/.claude/hooks/clawdbot-receiver-SECURE.sh +23 -25
  11. package/.claude/hooks/clawdbot-receiver.sh +28 -4
  12. package/.claude/hooks/language-manager.sh +1 -1
  13. package/.claude/hooks/path-resolver.sh +60 -0
  14. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -0
  15. package/.claude/hooks/play-tts-piper.sh +82 -24
  16. package/.claude/hooks/play-tts-ssh-remote.sh +13 -15
  17. package/.claude/hooks/play-tts.sh +16 -5
  18. package/.claude/hooks/session-start-tts.sh +26 -56
  19. package/.claude/hooks/soprano-gradio-synth.py +1 -1
  20. package/.claude/hooks/verbosity-manager.sh +10 -4
  21. package/.claude/settings.json +1 -1
  22. package/CLAUDE.md +129 -104
  23. package/README.md +418 -10
  24. package/RELEASE_NOTES.md +60 -1036
  25. package/bin/agentvibes-voice-browser.js +1827 -0
  26. package/bin/agentvibes.js +100 -0
  27. package/mcp-server/server.py +67 -3
  28. package/package.json +11 -2
  29. package/src/console/app.js +806 -0
  30. package/src/console/audio-env.js +123 -0
  31. package/src/console/brand-colors.js +13 -0
  32. package/src/console/footer-config.js +42 -0
  33. package/src/console/modals/.gitkeep +0 -0
  34. package/src/console/modals/modal-overlay.js +247 -0
  35. package/src/console/navigation.js +60 -0
  36. package/src/console/tabs/.gitkeep +0 -0
  37. package/src/console/tabs/agents-tab.js +369 -0
  38. package/src/console/tabs/help-tab.js +261 -0
  39. package/src/console/tabs/install-tab.js +990 -0
  40. package/src/console/tabs/music-tab.js +997 -0
  41. package/src/console/tabs/placeholder-tab.js +45 -0
  42. package/src/console/tabs/readme-tab.js +267 -0
  43. package/src/console/tabs/settings-tab.js +3949 -0
  44. package/src/console/tabs/voices-tab.js +1574 -0
  45. package/src/installer/music-file-input.js +304 -0
  46. package/src/installer.js +1353 -676
  47. package/src/services/.gitkeep +0 -0
  48. package/src/services/agent-voice-store.js +163 -0
  49. package/src/services/config-service.js +240 -0
  50. package/src/services/navigation-service.js +123 -0
  51. package/src/services/provider-service.js +132 -0
  52. package/src/services/verbosity-service.js +157 -0
  53. package/src/utils/audio-duration-validator.js +298 -0
  54. package/src/utils/audio-format-validator.js +277 -0
  55. package/src/utils/dependency-checker.js +3 -3
  56. package/src/utils/file-ownership-verifier.js +358 -0
  57. package/src/utils/music-file-validator.js +275 -0
  58. package/src/utils/preview-list-prompt.js +136 -0
  59. package/src/utils/provider-validator.js +144 -132
  60. package/src/utils/secure-music-storage.js +412 -0
  61. package/templates/agentvibes-receiver.sh +11 -7
  62. package/voice-assignments.json +8245 -0
  63. package/.claude/config/background-music-volume.txt +0 -1
  64. package/.claude/config/background-music.cfg +0 -1
  65. package/.claude/config/background-music.txt +0 -1
  66. package/.claude/config/tts-speech-rate.txt +0 -1
  67. package/.claude/config/tts-verbosity.txt +0 -1
  68. package/.claude/hooks/bmad-party-manager.sh +0 -225
  69. package/.claude/hooks/stop.sh +0 -38
  70. package/.claude/piper-voices-dir.txt +0 -1
  71. package/.mcp.json +0 -34
@@ -0,0 +1,990 @@
1
+ /**
2
+ * AgentVibes TUI Console — Install Tab (Installer Wizard)
3
+ * Epic 12: Stories 12.1-12.5
4
+ *
5
+ * Implements the Tab Component Contract:
6
+ * createInstallTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
+ *
8
+ * 5-screen wizard flow:
9
+ * Screen 1: Welcome & Purpose
10
+ * Screen 2: Auto Dependency Check
11
+ * Screen 3: Provider Selection
12
+ * Screen 4: Voice Config & Intro Text
13
+ * Screen 5: Complete & TTS Greeting
14
+ */
15
+
16
+ import path from 'node:path';
17
+ import { execFile } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+ import { promises as _fsP } from 'node:fs';
20
+ import { buildAudioEnv } from '../audio-env.js';
21
+ import {
22
+ copyCommandFiles, copyHookFiles, copyPersonalityFiles,
23
+ copyPluginFiles, copyBmadConfigFiles, copyBackgroundMusicFiles,
24
+ copyConfigFiles, configureSessionStartHook,
25
+ installPluginManifest, checkAndInstallPiper,
26
+ } from '../../installer.js';
27
+
28
+ const _execFileAsync = promisify(execFile);
29
+
30
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
31
+
32
+ let blessed;
33
+ if (!IS_TEST) {
34
+ const { default: b } = await import('blessed');
35
+ blessed = b;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const COLORS = {
41
+ contentBg: '#0a0e1a',
42
+ sectionHdr: '#7986cb', // Light indigo/purple — section headers (matches settings tab)
43
+ labelFg: '#e3f2fd',
44
+ valueFg: '#ffff00', // Yellow
45
+ brandPink: '#f06292', // Light magenta — AgentVibes logotype
46
+ successFg: '#69f0ae', // Green — success
47
+ errorFg: '#ef9a9a', // Red — error/missing
48
+ btnDefault: '#283593',
49
+ btnFocus: '#00e5ff', // Cyan — focused button (system standard)
50
+ btnFocusFg: '#000000', // Black text on cyan
51
+ btnPress: '#ff00ff',
52
+ borderFg: '#3f51b5',
53
+ footerBg: '#3f51b5', // Indigo — Install tab footer
54
+ noticeFg: '#90a4ae',
55
+ };
56
+
57
+ const FOOTER_TEXT = '[Enter] Continue/Finish [Esc] Back/Exit [C] Open Console [S/V/M/A/R] Tab [Q] Quit';
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Exported pure helpers (stories 12.1, 12.5)
61
+
62
+ /**
63
+ * Returns the default intro text suggestion (project folder name).
64
+ * @param {string} projectDir
65
+ * @returns {string}
66
+ */
67
+ export function getIntroDefault(projectDir) {
68
+ if (!projectDir) return '';
69
+ return path.basename(projectDir);
70
+ }
71
+
72
+ /**
73
+ * Format the TTS greeting message for Screen 5.
74
+ * @param {string} introText - User's intro text (may be empty)
75
+ * @param {string} projectName - Project folder name
76
+ * @returns {string}
77
+ */
78
+ export function formatGreeting(introText, projectName) {
79
+ const name = introText || projectName || 'AgentVibes';
80
+ return `${name} is ready! Welcome to AgentVibes. Love AgentVibes? We'd really appreciate it if you could give us a star on GitHub.`;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Dependency detection helpers (story 12.2)
85
+
86
+ /**
87
+ * Check if a command exists on the system (async).
88
+ * Only ENOENT means "not installed" — non-zero exit code still means the binary exists.
89
+ * @param {string} cmd
90
+ * @returns {Promise<boolean>}
91
+ */
92
+ async function _commandExistsAsync(cmd) {
93
+ try {
94
+ await _execFileAsync(cmd, ['--version'], { stdio: 'pipe', timeout: 5000 });
95
+ return true;
96
+ } catch (err) {
97
+ if (err.code === 'ENOENT') return false;
98
+ return true; // binary exists but --version returned non-zero
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Run dependency checks asynchronously. Returns results map.
104
+ * @returns {Promise<{ node: boolean, npm: boolean, piper: boolean, soprano: boolean }>}
105
+ */
106
+ async function _checkDependenciesAsync() {
107
+ const [node, npm, piper, sopranoTts, sopranoWebui] = await Promise.all([
108
+ _commandExistsAsync('node'),
109
+ _commandExistsAsync('npm'),
110
+ _commandExistsAsync('piper'),
111
+ _commandExistsAsync('soprano-tts'),
112
+ _commandExistsAsync('soprano-webui'),
113
+ ]);
114
+ return { node, npm, piper, soprano: sopranoTts || sopranoWebui };
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Test stub
119
+
120
+ function createTestStub() {
121
+ return {
122
+ box: {},
123
+ show: () => {},
124
+ hide: () => {},
125
+ onFocus: () => {},
126
+ onBlur: () => {},
127
+ getFooterText: () => FOOTER_TEXT,
128
+ getFooterColor: () => COLORS.footerBg,
129
+ };
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Create the Install tab component.
136
+ *
137
+ * @param {object} screen - Blessed screen instance (or test stub)
138
+ * @param {object} services
139
+ * @param {import('../../services/config-service.js').ConfigService} services.configService
140
+ * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
141
+ */
142
+ export function createInstallTab(screen, services) {
143
+ if (IS_TEST) return createTestStub();
144
+
145
+ const { configService, providerService, navigationService, focusMainTabBar } = services;
146
+
147
+ // -------------------------------------------------------------------------
148
+ // Container
149
+
150
+ const box = blessed.box({
151
+ parent: screen,
152
+ top: 4,
153
+ left: 0,
154
+ width: '100%',
155
+ bottom: 2,
156
+ hidden: true,
157
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
158
+ border: { type: 'line' },
159
+ borderStyle: { fg: COLORS.borderFg },
160
+ });
161
+
162
+ // -------------------------------------------------------------------------
163
+ // Wizard state
164
+
165
+ let _screen = 1;
166
+ let _lastScreen = 0;
167
+ let _deps = null;
168
+ let _checking = false;
169
+ let _selectedProvider = null;
170
+ let _introText = getIntroDefault(process.cwd());
171
+ let _screen5Announced = false; // TTS greeting fires once per wizard run
172
+ let _completionModalOpen = false;
173
+ let _completionModalBox = null;
174
+
175
+ // Install state (populated during screen 5)
176
+ let _installLog = []; // array of blessed-tagged strings
177
+ let _installRunning = false;
178
+ let _installComplete = false;
179
+ let _installError = null;
180
+ let _lastSpinnerIdx = -1; // index of last ⟳ entry, replaced by ✓ on succeed
181
+
182
+ // -------------------------------------------------------------------------
183
+ // Content area — single persistent box, never detached.
184
+ //
185
+ // KEY INSIGHT: detach+recreate fails because the new widget has no previous
186
+ // cell state, so blessed's diff renderer doesn't know which cells to clear.
187
+ // Keeping the SAME element and calling setContent('') lets blessed diff
188
+ // old-content → empty and write spaces over every character that was there.
189
+
190
+ const contentBox = blessed.box({
191
+ parent: box,
192
+ top: 1,
193
+ left: 2,
194
+ width: '96%',
195
+ bottom: 5,
196
+ tags: true,
197
+ wrap: false,
198
+ scrollable: false,
199
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
200
+ });
201
+
202
+ // Footer hint
203
+ const hintLine = blessed.text({
204
+ parent: box,
205
+ bottom: 2,
206
+ left: 2,
207
+ right: 2, // explicit right bound — prevents blessed auto-shrink which leaves stale chars
208
+ tags: true,
209
+ content: '',
210
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
211
+ });
212
+
213
+ function _c(lines) { return lines.join('\n'); }
214
+
215
+ // -------------------------------------------------------------------------
216
+ // Screen 4 action button callbacks
217
+
218
+ function _doEdit() {
219
+ if (box.hidden || _screen !== 4) return;
220
+ const prompt = blessed.prompt({
221
+ parent: screen,
222
+ top: 'center',
223
+ left: 'center',
224
+ height: 'shrink',
225
+ width: '60%',
226
+ border: 'line',
227
+ tags: true,
228
+ style: {
229
+ fg: COLORS.labelFg,
230
+ bg: COLORS.contentBg,
231
+ border: { fg: COLORS.sectionHdr },
232
+ label: { fg: COLORS.sectionHdr },
233
+ },
234
+ });
235
+ prompt.input('Intro text (prefix spoken before every TTS message):', _introText, (err, val) => {
236
+ prompt.destroy();
237
+ if (!err && val !== null) {
238
+ _introText = val.trim();
239
+ _renderScreen4();
240
+ }
241
+ screen.render();
242
+ });
243
+ screen.render();
244
+ }
245
+
246
+ // -------------------------------------------------------------------------
247
+ // TUI spinner adapter — captures copy-function progress into _installLog
248
+
249
+ function _makeSpinner() {
250
+ return {
251
+ start(msg) {
252
+ _installLog.push(`{${COLORS.noticeFg}-fg} ⟳ ${msg}{/${COLORS.noticeFg}-fg}`);
253
+ _lastSpinnerIdx = _installLog.length - 1;
254
+ _renderScreen5();
255
+ },
256
+ succeed(msg) {
257
+ const line = `{${COLORS.successFg}-fg} ✓ ${msg || ''}{/${COLORS.successFg}-fg}`;
258
+ if (_lastSpinnerIdx >= 0) {
259
+ _installLog[_lastSpinnerIdx] = line;
260
+ } else {
261
+ _installLog.push(line);
262
+ }
263
+ _lastSpinnerIdx = -1;
264
+ _renderScreen5();
265
+ },
266
+ info(msg) {
267
+ _installLog.push(`{${COLORS.noticeFg}-fg} ℹ ${msg}{/${COLORS.noticeFg}-fg}`);
268
+ _renderScreen5();
269
+ },
270
+ warn(msg) {
271
+ _installLog.push(`{#ffcc00-fg} ⚠ ${msg}{/#ffcc00-fg}`);
272
+ _renderScreen5();
273
+ },
274
+ stop() {},
275
+ };
276
+ }
277
+
278
+ // -------------------------------------------------------------------------
279
+ // Write AgentVibes config files into targetDir/.claude/
280
+
281
+ async function _writeInstallConfig(targetDir, provider) {
282
+ const claudeDir = path.join(targetDir, '.claude');
283
+ const configDir = path.join(claudeDir, 'config');
284
+ await _fsP.mkdir(configDir, { recursive: true });
285
+
286
+ const defaultVoices = {
287
+ piper: 'en_US-ryan-high',
288
+ macos: 'Samantha',
289
+ soprano: 'soprano-default',
290
+ 'windows-piper': 'en_US-ryan-high',
291
+ 'windows-sapi': 'Microsoft David Desktop',
292
+ };
293
+ // Use voice from Settings if configured, otherwise fall back to provider default
294
+ const configuredVoice = configService?.getConfig?.()?.voice;
295
+ const voice = configuredVoice ?? (defaultVoices[provider] ?? 'en_US-ryan-high');
296
+
297
+ await _fsP.writeFile(path.join(claudeDir, 'tts-provider.txt'), provider);
298
+ await _fsP.writeFile(path.join(claudeDir, 'tts-voice.txt'), voice);
299
+ await _fsP.writeFile(path.join(claudeDir, 'tts-verbosity.txt'), 'medium');
300
+
301
+ const pretext = _introText?.trim() ?? '';
302
+ if (pretext) {
303
+ await _fsP.writeFile(path.join(configDir, 'tts-pretext.txt'), pretext, { mode: 0o600 });
304
+ } else {
305
+ try { await _fsP.unlink(path.join(configDir, 'tts-pretext.txt')); } catch { /* ok */ }
306
+ }
307
+
308
+ // Apply background music settings from Settings tab.
309
+ // play-tts-piper.sh reads background-music-enabled.txt (not background-music.txt),
310
+ // so we must write that file explicitly when music is enabled.
311
+ const bgMusic = configService?.getConfig?.()?.backgroundMusic;
312
+ if (bgMusic?.enabled) {
313
+ await _fsP.writeFile(path.join(configDir, 'background-music-enabled.txt'), 'true');
314
+ // Update the track in audio-effects.cfg (copied from package defaults a moment ago).
315
+ // Only apply if the track name is a safe filename (no pipe characters or path separators).
316
+ const track = bgMusic.track;
317
+ if (track && !/[|/\\]/.test(track)) {
318
+ try {
319
+ const audioEffectsPath = path.join(configDir, 'audio-effects.cfg');
320
+ let content = await _fsP.readFile(audioEffectsPath, 'utf-8');
321
+ content = content.replace(
322
+ /^default\|([^|]*)\|([^|]*)\|(.*)$/m,
323
+ `default|$1|${track}|$3`,
324
+ );
325
+ await _fsP.writeFile(audioEffectsPath, content);
326
+ } catch { /* audio-effects.cfg not yet present — non-fatal */ }
327
+ }
328
+ }
329
+ }
330
+
331
+ // -------------------------------------------------------------------------
332
+ // Full installation sequence (runs on screen 5)
333
+
334
+ async function _runInstall() {
335
+ _installLog = [];
336
+ _installRunning = true;
337
+ _installComplete = false;
338
+ _installError = null;
339
+ _lastSpinnerIdx = -1;
340
+
341
+ const targetDir = process.cwd();
342
+ const provider = _selectedProvider ?? 'piper';
343
+ const spinner = _makeSpinner();
344
+
345
+ // Suppress console output from installer.js copy functions — they use
346
+ // chalk+console.log which would corrupt the blessed display.
347
+ const _origLog = console.log;
348
+ const _origWarn = console.warn;
349
+ const _origErr = console.error;
350
+ console.log = () => {};
351
+ console.warn = () => {};
352
+ console.error = () => {};
353
+
354
+ try {
355
+ // Create directory structure
356
+ spinner.start('Preparing .claude directory...');
357
+ await _fsP.mkdir(path.join(targetDir, '.claude', 'commands'), { recursive: true });
358
+ await _fsP.mkdir(path.join(targetDir, '.claude', 'hooks'), { recursive: true });
359
+ await _fsP.mkdir(path.join(targetDir, '.claude', 'audio', 'tracks'), { recursive: true });
360
+ spinner.succeed('Directory structure ready');
361
+
362
+ await copyCommandFiles(targetDir, spinner);
363
+ await copyHookFiles(targetDir, spinner);
364
+ await copyPersonalityFiles(targetDir, spinner);
365
+ await copyPluginFiles(targetDir, spinner);
366
+ await copyBmadConfigFiles(targetDir, spinner);
367
+ await copyBackgroundMusicFiles(targetDir, spinner);
368
+ await copyConfigFiles(targetDir, spinner);
369
+ await configureSessionStartHook(targetDir, spinner);
370
+ await installPluginManifest(targetDir, spinner);
371
+
372
+ spinner.start('Writing configuration...');
373
+ await _writeInstallConfig(targetDir, provider);
374
+ spinner.succeed('Configuration saved');
375
+
376
+ // Create .mcp.json if it doesn't already exist
377
+ const mcpConfigPath = path.join(targetDir, '.mcp.json');
378
+ let _mcpCreated = false;
379
+ try {
380
+ await _fsP.access(mcpConfigPath);
381
+ // Already exists — skip to avoid overwriting user's config
382
+ } catch {
383
+ const mcpConfig = {
384
+ mcpServers: {
385
+ agentvibes: {
386
+ command: 'npx',
387
+ args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
388
+ },
389
+ },
390
+ };
391
+ spinner.start('Creating .mcp.json...');
392
+ await _fsP.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
393
+ spinner.succeed('.mcp.json created');
394
+ _mcpCreated = true;
395
+ }
396
+
397
+ if (provider === 'piper') {
398
+ spinner.start('Checking Piper TTS voices...');
399
+ await checkAndInstallPiper(targetDir, { yes: true, silent: true });
400
+ spinner.succeed('Piper TTS ready');
401
+ }
402
+
403
+ _installComplete = true;
404
+ _installRunning = false;
405
+ _installLog.push('');
406
+ _installLog.push(`{${COLORS.successFg}-fg} ✅ AgentVibes installed successfully!{/${COLORS.successFg}-fg}`);
407
+ if (_mcpCreated) {
408
+ _installLog.push(`{${COLORS.successFg}-fg} 📡 .mcp.json created — run: claude --mcp-config .mcp.json{/${COLORS.successFg}-fg}`);
409
+ }
410
+ _installLog.push(`{${COLORS.noticeFg}-fg} ⭐ Star us on GitHub: github.com/preibisch/agentvibes{/${COLORS.noticeFg}-fg}`);
411
+
412
+ } catch (err) {
413
+ _installRunning = false;
414
+ _installError = err.message;
415
+ _installLog.push(`{${COLORS.errorFg}-fg} ✗ Installation failed: ${err.message}{/${COLORS.errorFg}-fg}`);
416
+ } finally {
417
+ console.log = _origLog;
418
+ console.warn = _origWarn;
419
+ console.error = _origErr;
420
+ }
421
+
422
+ _renderScreen5();
423
+
424
+ // Show OK button now that install is done (success or error)
425
+ _s5OkBtn.show();
426
+ _s5OkBtn.focus();
427
+ screen.render();
428
+
429
+ // Play TTS greeting on success
430
+ if (_installComplete && !_screen5Announced) {
431
+ _screen5Announced = true;
432
+ const greeting = formatGreeting(_introText, getIntroDefault(process.cwd()));
433
+ const ttsScript = path.resolve(targetDir, '.claude/hooks/play-tts.sh');
434
+ execFile('bash', [ttsScript, greeting], {
435
+ env: buildAudioEnv(),
436
+ timeout: 30000,
437
+ }, () => {});
438
+ }
439
+ }
440
+
441
+ function _doAccept() {
442
+ if (_screen !== 4 || _installRunning) return;
443
+ _screen++;
444
+ _showCurrentScreen();
445
+ // Start install after screen transition renders (50ms delay in _showCurrentScreen)
446
+ setTimeout(() => _runInstall().catch(() => {}), 100);
447
+ }
448
+
449
+ // -------------------------------------------------------------------------
450
+ // Screen 4 action buttons — real blessed widgets for keyboard focus + ←/→ nav
451
+
452
+ function _createInstallBtn(label, bg, onClick, textColor = '#ffffff') {
453
+ const btn = blessed.button({
454
+ parent: box,
455
+ content: label,
456
+ mouse: true,
457
+ keys: true,
458
+ shrink: true,
459
+ hidden: true,
460
+ padding: { left: 1, right: 1 },
461
+ style: {
462
+ bg,
463
+ fg: textColor,
464
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
465
+ },
466
+ });
467
+
468
+ // Focus indicator: ►label◄ with blinking █ — matches settings-tab standard
469
+ let _blinkInterval = null;
470
+ btn.on('focus', () => {
471
+ btn.style.bg = COLORS.btnFocus;
472
+ btn.style.fg = COLORS.btnFocusFg;
473
+ const raw = btn.content.replace(/[►◄█]/g, '').trim();
474
+ btn.setContent(`►${raw}◄ █`);
475
+ let _on = true;
476
+ screen.render();
477
+ _blinkInterval = setInterval(() => {
478
+ _on = !_on;
479
+ if (!btn.content.includes('►')) return;
480
+ const r = btn.content.replace(/[►◄█]/g, '').trim();
481
+ btn.setContent(_on ? `►${r}◄ █` : `►${r}◄`);
482
+ screen.render();
483
+ }, 500);
484
+ });
485
+ btn.on('blur', () => {
486
+ if (_blinkInterval) { clearInterval(_blinkInterval); _blinkInterval = null; }
487
+ btn.style.bg = bg;
488
+ btn.style.fg = textColor;
489
+ const raw = btn.content.replace(/[►◄█]/g, '').trim();
490
+ btn.setContent(raw);
491
+ screen.render();
492
+ });
493
+
494
+ // Press: magenta flash then invoke onClick
495
+ // Guard: don't fire onClick when the completion modal is open — Enter should dismiss it.
496
+ btn.key(['enter', 'space'], () => {
497
+ if (_completionModalOpen) return;
498
+ btn.style.bg = COLORS.btnPress;
499
+ btn.style.fg = 'white';
500
+ screen.render();
501
+ setTimeout(() => {
502
+ btn.style.bg = bg;
503
+ btn.style.fg = textColor;
504
+ screen.render();
505
+ onClick();
506
+ }, 150);
507
+ });
508
+ btn.on('click', () => btn.press());
509
+ return btn;
510
+ }
511
+
512
+ const _editBtn = _createInstallBtn('Edit', '#1565c0', _doEdit);
513
+ const _acceptBtn = _createInstallBtn('✓ Accept & Install', COLORS.btnDefault, _doAccept);
514
+
515
+ // Edit sits inline with the intro text row; Accept & Install is below
516
+ _editBtn.top = 8; _editBtn.left = 36;
517
+ _acceptBtn.top = 13; _acceptBtn.left = 4;
518
+
519
+ // ↓/↑ navigate between Edit and Accept & Install
520
+ // Note: Tab is NOT used here — 'tab' is registered globally by navigation.js (cycles tabs)
521
+ _editBtn.key(['down', 'right'], () => { _acceptBtn.focus(); screen.render(); });
522
+ _acceptBtn.key(['up', 'left'], () => { _editBtn.focus(); screen.render(); });
523
+
524
+ // -------------------------------------------------------------------------
525
+ // Screen 1 buttons — Begin (cyan) and Exit (grey)
526
+
527
+ const _s1BeginBtn = _createInstallBtn('▶ Begin', '#00838f', () => {
528
+ _screen++;
529
+ _showCurrentScreen();
530
+ });
531
+ const _s1ExitBtn = _createInstallBtn('✗ Exit', '#546e7a', () => {
532
+ box.hide();
533
+ screen.render();
534
+ if (typeof focusMainTabBar === 'function') focusMainTabBar();
535
+ });
536
+
537
+ _s1BeginBtn.top = 5; _s1BeginBtn.left = 4;
538
+ _s1ExitBtn.top = 5; _s1ExitBtn.left = 20;
539
+
540
+ // ←/→ horizontal and ↑/↓ vertical — both navigate between the two buttons
541
+ _s1BeginBtn.key(['right', 'down'], () => { _s1ExitBtn.focus(); screen.render(); });
542
+ _s1ExitBtn.key(['right', 'down'], () => { _s1BeginBtn.focus(); screen.render(); });
543
+ _s1ExitBtn.key(['left', 'up', 'S-tab'], () => { _s1BeginBtn.focus(); screen.render(); });
544
+ _s1BeginBtn.key(['left', 'up', 'S-tab'], () => { _s1ExitBtn.focus(); screen.render(); });
545
+
546
+ // -------------------------------------------------------------------------
547
+ // Screen 2 button — Continue (shown after deps check passes)
548
+
549
+ const _s2ContinueBtn = _createInstallBtn('Continue →', '#1565c0', () => {
550
+ _screen++;
551
+ _showCurrentScreen();
552
+ });
553
+ _s2ContinueBtn.top = 12; _s2ContinueBtn.left = 4;
554
+ // → also advances without the flash delay
555
+ _s2ContinueBtn.key(['right'], () => { _screen++; _showCurrentScreen(); });
556
+
557
+ // Screen 3: no Continue button — Enter/→ on the list confirms selection and advances
558
+
559
+ // -------------------------------------------------------------------------
560
+ // Screen 5 button — OK (summary page only, config already saved on screen 4)
561
+
562
+ const _s5OkBtn = _createInstallBtn('✓ OK — Done', '#2e7d32', () => {
563
+ _dismissCompletionModal();
564
+ });
565
+ _s5OkBtn.bottom = 3; _s5OkBtn.left = 4; // bottom-anchored: sits above hintLine (bottom:2)
566
+
567
+ // -------------------------------------------------------------------------
568
+ // Screen renderers
569
+
570
+ const _HDR = (emoji, label) =>
571
+ `{${COLORS.sectionHdr}-fg}${emoji} ${label} ${'─'.repeat(100)}{/${COLORS.sectionHdr}-fg}`;
572
+
573
+ function _renderScreen1() {
574
+ contentBox.setContent(_c([
575
+ _HDR('🔧', 'Setup Wizard'),
576
+ '',
577
+ ` {${COLORS.noticeFg}-fg}TTS for AI assistants with personality.{/${COLORS.noticeFg}-fg}`,
578
+ '',
579
+ '', // ← [▶ Begin] [✗ Exit] buttons here (box row 5)
580
+ ]));
581
+ hintLine.setContent(' Screen 1/5: Welcome | [←/→] Navigate | [Enter] Begin | [Esc] Exit');
582
+ _s1BeginBtn.focus();
583
+ screen.render();
584
+ }
585
+
586
+ async function _renderScreen2() {
587
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
588
+ let frameIdx = 0;
589
+ _checking = true;
590
+ _s2ContinueBtn.hide(); // hidden during spinner
591
+
592
+ contentBox.setContent(_c([
593
+ _HDR('🔍', 'Dependency Check'),
594
+ '',
595
+ ` {${COLORS.noticeFg}-fg}${frames[0]} Checking dependencies...{/${COLORS.noticeFg}-fg}`,
596
+ ]));
597
+ hintLine.setContent(' Screen 2/5: Dependencies | [←] Back | [Enter] Next');
598
+ screen.render();
599
+
600
+ const spinInterval = setInterval(() => {
601
+ frameIdx = (frameIdx + 1) % frames.length;
602
+ contentBox.setContent(_c([
603
+ _HDR('🔍', 'Dependency Check'),
604
+ '',
605
+ ` {${COLORS.noticeFg}-fg}${frames[frameIdx]} Checking dependencies...{/${COLORS.noticeFg}-fg}`,
606
+ ]));
607
+ screen.render();
608
+ }, 100);
609
+
610
+ try {
611
+ _deps = await _checkDependenciesAsync();
612
+ } finally {
613
+ clearInterval(spinInterval);
614
+ _checking = false;
615
+ }
616
+
617
+ const ok = () => `{${COLORS.successFg}-fg}✅ Installed{/${COLORS.successFg}-fg}`;
618
+ const bad = () => `{${COLORS.errorFg}-fg}❌ Not found{/${COLORS.errorFg}-fg}`;
619
+
620
+ const ttsOk = _deps.piper || _deps.soprano;
621
+ contentBox.setContent(_c([
622
+ _HDR('🔍', 'Dependency Check'),
623
+ '',
624
+ ` {${COLORS.noticeFg}-fg}${'Dependency'.padEnd(14)}Status{/${COLORS.noticeFg}-fg}`,
625
+ ` {${COLORS.noticeFg}-fg}${'─'.repeat(78)}{/${COLORS.noticeFg}-fg}`,
626
+ ` {${COLORS.labelFg}-fg}${'Node.js'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.node ? ok() : bad()}`,
627
+ ` {${COLORS.labelFg}-fg}${'npm'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.npm ? ok() : bad()}`,
628
+ ` {${COLORS.labelFg}-fg}${'Piper TTS'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.piper ? ok() : bad()}`,
629
+ ` {${COLORS.labelFg}-fg}${'Soprano TTS'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.soprano ? ok() : bad()}`,
630
+ '',
631
+ ttsOk
632
+ ? ` {${COLORS.successFg}-fg}✅ TTS Providers Detected{/${COLORS.successFg}-fg}`
633
+ : ` {${COLORS.errorFg}-fg}⚠ No TTS provider found. Install Piper or Soprano first.{/${COLORS.errorFg}-fg}`,
634
+ '', // blank separator
635
+ '', // ← [Continue →] button here (box row 12) when TTS detected
636
+ ]));
637
+ if (ttsOk) {
638
+ _s2ContinueBtn.show();
639
+ _s2ContinueBtn.focus();
640
+ }
641
+ screen.render();
642
+ }
643
+
644
+ function _renderScreen3() {
645
+ const providers = [];
646
+ if (_deps?.piper) providers.push('piper');
647
+ if (_deps?.soprano) providers.push('soprano');
648
+
649
+ if (providers.length === 0) providers.push('piper'); // fallback
650
+ if (!_selectedProvider) _selectedProvider = providers[0];
651
+
652
+ // Pad items to 96 visible chars so they fully overwrite any stale cells from Screen 2.
653
+ // Selected row uses cyan bg + black text (matches button focus standard).
654
+ const items = providers.map(p =>
655
+ p === _selectedProvider
656
+ ? `{#00e5ff-bg}{#000000-fg}{bold} ● ${p.padEnd(92)}{/bold}{/#000000-fg}{/#00e5ff-bg}`
657
+ : `{${COLORS.labelFg}-fg} ${p.padEnd(93)}{/${COLORS.labelFg}-fg}`
658
+ );
659
+
660
+ // Pad item list to 3 entries so the Continue button sits at a fixed row
661
+ // and all stale lines from Screen 2 (which has ~10 lines) are overwritten.
662
+ const paddedItems = [...items];
663
+ while (paddedItems.length < 3) paddedItems.push(` ${''.padEnd(93)}`);
664
+
665
+ // Append trailing blank rows (space-padded) so blessed rewrites every cell that
666
+ // screen 2 used. Two screen.render() calls in the same tick are batched, so the
667
+ // intermediate "clear" render never fires — trailing spaces here fix that in one pass.
668
+ const _blank = ' '.repeat(120);
669
+ const _trail = Array(12).fill(_blank);
670
+ contentBox.setContent(_c([
671
+ _HDR('🎤', 'Provider Selection'),
672
+ '',
673
+ ` {${COLORS.noticeFg}-fg}${'Available TTS providers:'.padEnd(94)}{/${COLORS.noticeFg}-fg}`,
674
+ '',
675
+ ...paddedItems.map(i => ` ${i}`),
676
+ ..._trail,
677
+ ]));
678
+ hintLine.setContent(' Screen 3/5: Provider | [←] Back | [↑↓] Choose | [Enter/→] Confirm & Continue');
679
+ box.focus();
680
+ screen.render();
681
+ }
682
+
683
+ function _renderScreen4() {
684
+ const provider = _selectedProvider ?? 'piper';
685
+ const intro = _introText || '';
686
+ const folderName = getIntroDefault(process.cwd()) || 'AgentVibes';
687
+ const example = `${folderName}: Here`;
688
+ const voiceId = providerService?.getActiveVoiceId?.() ?? 'en_US-amy-medium';
689
+
690
+ contentBox.setContent(_c([
691
+ _HDR('🎤', 'Provider & Voice'),
692
+ '',
693
+ ` {${COLORS.labelFg}-fg}${'Provider:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${provider}{/${COLORS.valueFg}-fg}`,
694
+ ` {${COLORS.labelFg}-fg}${'Voice:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${voiceId}{/${COLORS.valueFg}-fg} {${COLORS.noticeFg}-fg}(after installation, you can change in Settings){/${COLORS.noticeFg}-fg}`,
695
+ '',
696
+ _HDR('✍️', 'Intro Text'),
697
+ '',
698
+ ` {${COLORS.labelFg}-fg}${'Intro text:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${intro || '(none)'}{/${COLORS.valueFg}-fg}`,
699
+ // ↑ [Edit] button rendered inline at box row 8, left=36
700
+ '',
701
+ ` {${COLORS.noticeFg}-fg}Example:{/${COLORS.noticeFg}-fg} {${COLORS.valueFg}-fg}"${example}"{/${COLORS.valueFg}-fg}`,
702
+ '',
703
+ '',
704
+ '', // ← [✓ Accept & Install] button rendered as real widget here (box row 13)
705
+ ]));
706
+ hintLine.setContent(' Screen 4/5: Config | [Esc] Back | [E] Edit | [↓] Accept & Install');
707
+ _acceptBtn.focus();
708
+ screen.render();
709
+ }
710
+
711
+ function _renderScreen5() {
712
+ const header = _installError
713
+ ? _HDR('❌', 'Installation Failed')
714
+ : _installComplete
715
+ ? _HDR('✅', 'Installation Complete')
716
+ : _HDR('⚙️', 'Installing AgentVibes...');
717
+
718
+ const hint = (_installComplete || _installError)
719
+ ? ' Screen 5/5: Complete | [Enter] OK — Done'
720
+ : ' Screen 5/5: Installing... | Please wait';
721
+
722
+ // Show last 18 log lines so content fits in the box
723
+ const MAX_LINES = 18;
724
+ const visibleLog = _installLog.length > MAX_LINES
725
+ ? _installLog.slice(-MAX_LINES)
726
+ : _installLog;
727
+
728
+ contentBox.setContent(_c([
729
+ header,
730
+ '',
731
+ ...visibleLog,
732
+ ]));
733
+ hintLine.setContent(hint);
734
+ screen.render();
735
+ }
736
+
737
+ function _showInstallNotice(message) {
738
+ const width = Math.max(28, message.length + 6);
739
+ const notice = blessed.box({
740
+ parent: screen,
741
+ top: 'center',
742
+ left: 'center',
743
+ width,
744
+ height: 3,
745
+ border: { type: 'line' },
746
+ tags: true,
747
+ content: `{center}${message}{/center}`,
748
+ style: {
749
+ fg: '#e3f2fd',
750
+ bg: COLORS.contentBg,
751
+ border: { fg: '#00e5ff' },
752
+ },
753
+ });
754
+ screen.render();
755
+ setTimeout(() => { try { notice.destroy(); screen.render(); } catch {} }, 2500);
756
+ }
757
+
758
+ function _dismissCompletionModal() {
759
+ if (_completionModalBox) {
760
+ _completionModalBox.destroy();
761
+ _completionModalBox = null;
762
+ }
763
+ _completionModalOpen = false;
764
+ _screen = 1;
765
+ box.hide();
766
+ _showInstallNotice('Installation Complete — Settings Saved');
767
+ screen.render();
768
+ navigationService?.switchTab('settings');
769
+ }
770
+
771
+ function _showCurrentScreen() {
772
+ // Show Screen 1 buttons only on screen 1
773
+ if (_screen === 1) {
774
+ _s1BeginBtn.show(); _s1ExitBtn.show();
775
+ } else {
776
+ _s1BeginBtn.hide(); _s1ExitBtn.hide();
777
+ }
778
+
779
+ // Screen 2 continue button: hidden on other screens; _renderScreen2 manages show/focus
780
+ if (_screen !== 2) _s2ContinueBtn.hide();
781
+
782
+ // Screen 5 OK button: hidden during active install, shown by _runInstall() on completion
783
+ if (_screen === 5 && (_installComplete || _installError)) {
784
+ _s5OkBtn.show();
785
+ } else {
786
+ _s5OkBtn.hide();
787
+ }
788
+
789
+ // Show Screen 4 action buttons only on screen 4
790
+ if (_screen === 4) {
791
+ _editBtn.show(); _acceptBtn.show();
792
+ } else {
793
+ _editBtn.hide(); _acceptBtn.hide();
794
+ }
795
+
796
+ if (_screen !== _lastScreen) {
797
+ // Nuclear clear: force-invalidate every olines cell so blessed's diff renderer
798
+ // actually writes blanks to the terminal (blessed skips cells it thinks are
799
+ // unchanged — setting attr=-1 is impossible for any real cell so draw() is
800
+ // forced to physically rewrite every character).
801
+ try {
802
+ for (let r = 0; r < screen.height; r++) {
803
+ const orow = screen.olines?.[r];
804
+ if (!orow) continue;
805
+ for (let c = 0; c < screen.width; c++) {
806
+ if (orow[c]) orow[c][0] = -1;
807
+ }
808
+ }
809
+ // Row 2 (header bottom) never becomes dirty on its own — force it so
810
+ // draw() writes headerBg+spaces and overwrites any ghost terminal content.
811
+ if (screen.lines?.[2]) screen.lines[2].dirty = true;
812
+ } catch {}
813
+
814
+ const _clearLine = ' '.repeat(150);
815
+ const _clearPage = Array(25).fill(_clearLine).join('\n');
816
+ contentBox.setContent(_clearPage);
817
+ hintLine.setContent(_clearLine);
818
+ screen.render();
819
+
820
+ const targetScreen = _screen;
821
+ _lastScreen = _screen;
822
+ // 50 ms delay: enough for the terminal to display the blank frame before
823
+ // the new screen content overwrites it. setTimeout(0) is too fast —
824
+ // both renders land in the same display frame.
825
+ setTimeout(() => {
826
+ if (_screen !== targetScreen) return;
827
+ switch (_screen) {
828
+ case 1: _renderScreen1(); break;
829
+ case 2: _renderScreen2(); break;
830
+ case 3: _renderScreen3(); break;
831
+ case 4: _renderScreen4(); break;
832
+ case 5: _renderScreen5(); break;
833
+ }
834
+ }, 50);
835
+ return;
836
+ }
837
+ switch (_screen) {
838
+ case 1: _renderScreen1(); break;
839
+ case 2: _renderScreen2(); break;
840
+ case 3: _renderScreen3(); break;
841
+ case 4: _renderScreen4(); break;
842
+ case 5: _renderScreen5(); break;
843
+ }
844
+ }
845
+
846
+ // -------------------------------------------------------------------------
847
+ // Navigation
848
+
849
+ // Use screen.key() instead of box.key() so handlers fire regardless of which
850
+ // blessed element currently holds focus. Guard with `box.hidden` so they are
851
+ // no-ops when another tab is active.
852
+
853
+ screen.key(['enter'], () => {
854
+ if (box.hidden || _checking) return;
855
+ if (_completionModalOpen) { _dismissCompletionModal(); return; } // always first
856
+ if (_screen === 1) return; // Screen 1: Enter handled by Begin/Exit buttons
857
+ if (_screen === 2) return; // Screen 2: Enter handled by Continue button
858
+ if (_screen === 4) return; // Screen 4: Enter handled by the focused button
859
+ if (_screen === 5) return; // Screen 5: Enter handled by OK button
860
+ if (_screen < 5) {
861
+ _screen++;
862
+ _showCurrentScreen();
863
+ }
864
+ });
865
+
866
+ screen.key(['escape'], () => {
867
+ if (box.hidden || _checking) return;
868
+ if (_completionModalOpen) { _dismissCompletionModal(); return; }
869
+ if (_screen > 1) {
870
+ _screen--;
871
+ _showCurrentScreen();
872
+ } else {
873
+ box.hide();
874
+ screen.render();
875
+ // Defer so the escape keypress event finishes propagating before focus changes.
876
+ // Calling focusMainTabBar() synchronously here would set focus to the tab bar
877
+ // item mid-event, causing its own key(['escape']) handler to fire in the same
878
+ // emission and call onFocus() → re-focus a button inside the now-hidden box.
879
+ if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0);
880
+ }
881
+ });
882
+
883
+ screen.key(['up'], () => {
884
+ if (box.hidden) return;
885
+ if (_screen === 3 && _deps) {
886
+ const providers = [];
887
+ if (_deps.piper) providers.push('piper');
888
+ if (_deps.soprano) providers.push('soprano');
889
+ const idx = providers.indexOf(_selectedProvider ?? providers[0]);
890
+ _selectedProvider = providers[Math.max(0, idx - 1)];
891
+ _renderScreen3();
892
+ }
893
+ });
894
+
895
+ // Left arrow = go back (same logic as Escape)
896
+ // Screen 4: left arrow is handled by button ←/→ navigation; use Escape to go back
897
+ screen.key(['left'], () => {
898
+ if (box.hidden || _checking) return;
899
+ if (_screen === 4) return;
900
+ if (_screen > 1) {
901
+ _screen--;
902
+ _showCurrentScreen();
903
+ }
904
+ });
905
+
906
+ // Right arrow = go forward (same logic as Enter, without save/finish side-effects)
907
+ // Screen 1: right arrow handled by button ←/→ navigation
908
+ screen.key(['right'], () => {
909
+ if (box.hidden || _checking) return;
910
+ if (_screen === 1) return;
911
+ if (_screen === 2) return; // Screen 2: → handled by Continue button
912
+ if (_screen === 3) { _screen++; _showCurrentScreen(); return; } // → confirms provider and advances
913
+ if (_screen === 4) return; // Screen 4: → handled by button nav
914
+ if (_screen === 5) return; // Screen 5: → handled by button nav
915
+ });
916
+
917
+ // Down arrow: Screen 3 provider nav; Screen 1 ↓ is handled by button key handlers
918
+ // (tab bar's el.key(['down']) → onFocus() focuses Begin, then button ↓ → Exit)
919
+ screen.key(['down'], () => {
920
+ if (box.hidden) return;
921
+ if (_screen === 3 && _deps) {
922
+ const providers = [];
923
+ if (_deps.piper) providers.push('piper');
924
+ if (_deps.soprano) providers.push('soprano');
925
+ const idx = providers.indexOf(_selectedProvider ?? providers[0]);
926
+ _selectedProvider = providers[Math.min(providers.length - 1, idx + 1)];
927
+ _renderScreen3();
928
+ }
929
+ });
930
+
931
+ // [E] on Screen 4: edit intro text inline
932
+ screen.key(['e', 'E'], () => { _doEdit(); });
933
+
934
+ // [O] anywhere: dismiss the completion modal (OK button)
935
+ screen.key(['o', 'O'], () => {
936
+ if (box.hidden || !_completionModalOpen) return;
937
+ _dismissCompletionModal();
938
+ });
939
+
940
+ // -------------------------------------------------------------------------
941
+ // Tab Component Contract
942
+
943
+ return {
944
+ box,
945
+
946
+ show() {
947
+ _screen = 1;
948
+ _screen5Announced = false;
949
+ _installLog = [];
950
+ _installRunning = false;
951
+ _installComplete = false;
952
+ _installError = null;
953
+ _lastSpinnerIdx = -1;
954
+ if (_completionModalBox) { _completionModalBox.destroy(); _completionModalBox = null; }
955
+ _completionModalOpen = false;
956
+ box.show();
957
+ _showCurrentScreen();
958
+ screen.render();
959
+ },
960
+
961
+ hide() {
962
+ box.hide();
963
+ screen.render();
964
+ },
965
+
966
+ onFocus() {
967
+ // Focus the active interactive element, not just the box container
968
+ if (_screen === 1) {
969
+ _s1BeginBtn.focus();
970
+ } else if (_screen === 4) {
971
+ _editBtn.focus();
972
+ } else if (_screen === 5 && (_installComplete || _installError)) {
973
+ _s5OkBtn.focus();
974
+ } else {
975
+ box.focus();
976
+ }
977
+ screen.render();
978
+ },
979
+
980
+ onBlur() {},
981
+
982
+ getFooterText() {
983
+ return FOOTER_TEXT;
984
+ },
985
+
986
+ getFooterColor() {
987
+ return COLORS.footerBg;
988
+ },
989
+ };
990
+ }