agentvibes 4.6.7 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) 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/README.md +51 -52
  5. package/.claude/config/audio-effects-bmad.cfg +50 -0
  6. package/.claude/config/audio-effects.cfg +4 -4
  7. package/.claude/config/background-music-enabled.txt +1 -0
  8. package/.claude/config/personality.txt +1 -0
  9. package/.claude/hooks/play-tts-piper.sh +3 -1
  10. package/.claude/hooks/play-tts.sh +373 -301
  11. package/.claude/hooks/session-start-tts.sh +81 -81
  12. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  13. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  14. package/.claude/hooks-windows/play-tts.ps1 +101 -9
  15. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  16. package/README.md +107 -7
  17. package/RELEASE_NOTES.md +54 -0
  18. package/bin/bmad-speak.js +16 -8
  19. package/mcp-server/server.py +15 -8
  20. package/package.json +1 -1
  21. package/src/console/app.js +899 -897
  22. package/src/console/footer-config.js +50 -50
  23. package/src/console/navigation.js +65 -65
  24. package/src/console/tabs/agents-tab.js +1896 -1886
  25. package/src/console/tabs/music-tab.js +1046 -1039
  26. package/src/console/tabs/placeholder-tab.js +81 -80
  27. package/src/console/tabs/settings-tab.js +939 -3988
  28. package/src/console/tabs/setup-tab.js +1811 -0
  29. package/src/console/tabs/voices-tab.js +1720 -1713
  30. package/src/installer.js +6147 -6092
  31. package/src/services/llm-provider-service.js +407 -0
  32. package/src/services/navigation-service.js +123 -123
  33. package/src/services/tts-engine-service.js +69 -0
  34. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  35. package/src/console/tabs/install-tab.js +0 -1081
@@ -0,0 +1,1811 @@
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,
27
+ installCopilotMcp, removeCopilotMcp,
28
+ installCopilotInstructions, removeCopilotInstructions,
29
+ installCodexMcp, removeCodexMcp,
30
+ installCodexInstructions, installCodexHooks,
31
+ removeCodexInstructions, removeCodexHooks,
32
+ loadLlmConfigSync, saveLlmConfigSync,
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, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker } 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 = -1;
205
+ const _getLang = () => languageService?.getLang() ?? 'en';
206
+ const _tl = (key) => languageService?.t(key) ?? t('en', key);
207
+ let _langIdx = 0;
208
+ let _deps = null;
209
+ let _checking = false;
210
+
211
+ // First-run detection: evaluated at show() time so async config init is complete
212
+ function _isFirstRun() {
213
+ return !(configService?.getConfig?.()?.setupCompleted);
214
+ }
215
+
216
+ // -------------------------------------------------------------------------
217
+ // Content area
218
+
219
+ const contentBox = blessed.box({
220
+ parent: box,
221
+ top: 1,
222
+ left: 2,
223
+ width: '96%',
224
+ bottom: 5,
225
+ tags: true,
226
+ wrap: false,
227
+ scrollable: false,
228
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
229
+ });
230
+
231
+ const hintLine = blessed.text({
232
+ parent: box,
233
+ bottom: 2,
234
+ left: 2,
235
+ right: 2,
236
+ tags: true,
237
+ content: '',
238
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
239
+ });
240
+
241
+ function _c(lines) { return lines.join('\n'); }
242
+
243
+ // -------------------------------------------------------------------------
244
+ // Shared button factory
245
+
246
+ function _createBtn(label, bg, onClick, textColor = 'white') {
247
+ const btn = blessed.button({
248
+ parent: box,
249
+ content: label,
250
+ mouse: true,
251
+ keys: true,
252
+ shrink: true,
253
+ hidden: true,
254
+ padding: { left: 1, right: 1 },
255
+ style: {
256
+ bg,
257
+ fg: textColor,
258
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
259
+ },
260
+ });
261
+
262
+ let _blinkInterval = null;
263
+ const _origLabel = label;
264
+ btn.on('focus', () => {
265
+ btn.style.bg = COLORS.btnFocus;
266
+ btn.style.fg = COLORS.btnFocusFg;
267
+ btn.setContent(`\u25ba ${_origLabel} \u25c4`);
268
+ let _on = true;
269
+ screen.render();
270
+ _blinkInterval = setInterval(() => {
271
+ _on = !_on;
272
+ btn.setContent(_on ? `\u25ba ${_origLabel} \u25c4` : ` ${_origLabel} `);
273
+ screen.render();
274
+ }, 500);
275
+ });
276
+ btn.on('blur', () => {
277
+ if (_blinkInterval) { clearInterval(_blinkInterval); _blinkInterval = null; }
278
+ btn.style.bg = bg;
279
+ btn.style.fg = textColor;
280
+ btn.setContent(_origLabel);
281
+ screen.render();
282
+ });
283
+
284
+ btn.key(['enter', 'space'], () => {
285
+ btn.style.bg = COLORS.btnPress;
286
+ btn.style.fg = 'white';
287
+ screen.render();
288
+ setTimeout(() => {
289
+ btn.style.bg = bg;
290
+ btn.style.fg = textColor;
291
+ screen.render();
292
+ onClick();
293
+ }, 150);
294
+ });
295
+ btn.on('click', () => btn.press());
296
+ return btn;
297
+ }
298
+
299
+ // =========================================================================
300
+ // SCREEN 0: Language picker (kept as-is)
301
+ // =========================================================================
302
+
303
+ // =========================================================================
304
+ // SCREEN 1: Dependency check (was Screen 2, renumbered)
305
+ // =========================================================================
306
+
307
+ const _s1ContinueBtn = _createBtn('Continue ->', 'blue', () => {
308
+ _screen++;
309
+ _showCurrentScreen();
310
+ });
311
+ _s1ContinueBtn.top = 12; _s1ContinueBtn.left = 4;
312
+ _s1ContinueBtn.key(['right'], () => { _screen++; _showCurrentScreen(); });
313
+
314
+ // =========================================================================
315
+ // SCREEN 2: TTS Engine selection (new)
316
+ // =========================================================================
317
+
318
+ // TTS engine install buttons — created once, shown/hidden per screen
319
+ const _ttsEngineRows = [];
320
+ const _ttsFocusableItems = [];
321
+ let _ttsFocusIndex = 0;
322
+
323
+ const _ttsEngines = getAvailableEngines();
324
+ for (let i = 0; i < _ttsEngines.length; i++) {
325
+ const engine = _ttsEngines[i];
326
+ const yOff = 5 + (i * 3);
327
+
328
+ const nameLabel = blessed.text({
329
+ parent: box, top: yOff, left: 2, tags: true, hidden: true,
330
+ content: '', style: { bg: COLORS.contentBg },
331
+ });
332
+
333
+ const statusLabel = blessed.text({
334
+ parent: box, top: yOff, left: 22, tags: true, hidden: true,
335
+ content: '', style: { bg: COLORS.contentBg },
336
+ });
337
+
338
+ const descLabel = blessed.text({
339
+ parent: box, top: yOff + 1, left: 4, tags: true, hidden: true,
340
+ content: `{cyan-fg}${engine.desc}{/cyan-fg}`,
341
+ style: { bg: COLORS.contentBg },
342
+ });
343
+
344
+ const installBtn = blessed.button({
345
+ parent: box, top: yOff, left: 40, width: 14, height: 1,
346
+ content: ' Install ', tags: true, mouse: true, keys: true, hidden: true,
347
+ style: {
348
+ fg: COLORS.btnFg, bg: COLORS.btnBg,
349
+ focus: { fg: 'black', bg: COLORS.btnFocusBg },
350
+ },
351
+ });
352
+
353
+ installBtn.on('press', () => _handleTtsInstall(engine));
354
+ installBtn.key(['enter', 'space'], () => _handleTtsInstall(engine));
355
+ installBtn.key(['tab', 'down'], () => _cycleTtsFocus(1));
356
+ installBtn.key(['S-tab', 'up'], () => _cycleTtsFocus(-1));
357
+ installBtn.key(['escape'], () => {
358
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
359
+ });
360
+
361
+ _ttsEngineRows.push({ engine, nameLabel, statusLabel, descLabel, installBtn });
362
+ if (!engine.native) _ttsFocusableItems.push(installBtn);
363
+ }
364
+
365
+ function _cycleTtsFocus(dir) {
366
+ const items = _ttsFocusableItems.filter(b => !b.hidden);
367
+ if (!items.length) return;
368
+ _ttsFocusIndex = (_ttsFocusIndex + dir + items.length) % items.length;
369
+ items[_ttsFocusIndex].focus();
370
+ screen.render();
371
+ }
372
+
373
+ function _showTtsEngineRows() {
374
+ for (const row of _ttsEngineRows) {
375
+ const installed = checkEngineInstalled(row.engine.id);
376
+ row.nameLabel.setContent(`{bold}{white-fg}${row.engine.name}{/white-fg}{/bold}`);
377
+ row.statusLabel.setContent(installed
378
+ ? '{green-fg}[Installed]{/green-fg}'
379
+ : '{yellow-fg}[Not Found]{/yellow-fg}');
380
+ row.nameLabel.show();
381
+ row.statusLabel.show();
382
+ row.descLabel.show();
383
+ if (!installed && !row.engine.native) {
384
+ row.installBtn.show();
385
+ } else {
386
+ row.installBtn.hide();
387
+ }
388
+ }
389
+ }
390
+
391
+ function _hideTtsEngineRows() {
392
+ for (const row of _ttsEngineRows) {
393
+ row.nameLabel.hide();
394
+ row.statusLabel.hide();
395
+ row.descLabel.hide();
396
+ row.installBtn.hide();
397
+ }
398
+ }
399
+
400
+ let _ttsInstalling = false;
401
+ async function _handleTtsInstall(engine) {
402
+ if (!engine.installCmd || _ttsInstalling) return;
403
+ _ttsInstalling = true;
404
+
405
+ // Show installing status
406
+ const row = _ttsEngineRows.find(r => r.engine.id === engine.id);
407
+ if (row) {
408
+ row.statusLabel.setContent('{yellow-fg}[Installing...]{/yellow-fg}');
409
+ screen.render();
410
+ }
411
+
412
+ try {
413
+ const opts = { stdio: 'pipe', timeout: 120000 };
414
+ if (process.platform === 'win32') {
415
+ opts.shell = true;
416
+ await _execFileAsync(engine.installCmd, [], opts);
417
+ } else {
418
+ const parts = engine.installCmd.split(' ');
419
+ await _execFileAsync(parts[0], parts.slice(1), opts);
420
+ }
421
+
422
+ // Re-check and update status
423
+ const installed = checkEngineInstalled(engine.id);
424
+ if (row) {
425
+ row.statusLabel.setContent(installed
426
+ ? '{green-fg}[Installed]{/green-fg}'
427
+ : '{red-fg}[Install Failed]{/red-fg}');
428
+ if (installed) row.installBtn.hide();
429
+ }
430
+ } catch (err) {
431
+ if (row) {
432
+ row.statusLabel.setContent(`{red-fg}[Failed]{/red-fg}`);
433
+ }
434
+ }
435
+ _ttsInstalling = false;
436
+ screen.render();
437
+ }
438
+
439
+ // Continue button for Screen 2
440
+ const _s2ContinueBtn = _createBtn('Continue ->', 'blue', () => {
441
+ if (_screen < 3) { _screen++; _showCurrentScreen(); }
442
+ });
443
+ _s2ContinueBtn.hidden = true;
444
+
445
+ // =========================================================================
446
+ // SCREEN 3: LLM Providers (new — from llm-providers-tab)
447
+ // =========================================================================
448
+
449
+ let installedState = {};
450
+ let providerFocusableItems = [];
451
+ let providerFocusIndex = 0;
452
+ let providerView = 'list'; // 'list' or 'info'
453
+
454
+ // Provider row widgets (created once)
455
+ const providerRows = [];
456
+ const providerStatusTexts = [];
457
+
458
+ // Info box for provider details
459
+ const infoBox = blessed.box({
460
+ parent: box,
461
+ top: 1,
462
+ left: 2,
463
+ width: '96%',
464
+ bottom: 1,
465
+ hidden: true,
466
+ scrollable: true,
467
+ alwaysScroll: true,
468
+ tags: true,
469
+ keys: true,
470
+ vi: true,
471
+ mouse: true,
472
+ scrollbar: { ch: '|', style: { fg: 'cyan' } },
473
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
474
+ });
475
+
476
+ // Provider header
477
+ const providerHeader = blessed.text({
478
+ parent: box,
479
+ top: 0,
480
+ left: 2,
481
+ tags: true,
482
+ hidden: true,
483
+ content: '{bold}{cyan-fg}LLM Providers{/cyan-fg}{/bold} Configure AgentVibes for your AI assistant:',
484
+ style: { bg: COLORS.contentBg },
485
+ });
486
+
487
+ function createProviderRow(provider, rowIndex) {
488
+ const yOffset = 2 + (rowIndex * 3);
489
+
490
+ const label = blessed.text({
491
+ parent: box,
492
+ top: yOffset,
493
+ left: 2,
494
+ tags: true,
495
+ hidden: true,
496
+ content: `{bold}{white-fg}${provider.name}{/white-fg}{/bold} {cyan-fg}${provider.desc}{/cyan-fg}`,
497
+ style: { bg: COLORS.contentBg },
498
+ });
499
+
500
+ const statusText = blessed.text({
501
+ parent: box,
502
+ top: yOffset + 1,
503
+ left: 4,
504
+ tags: true,
505
+ hidden: true,
506
+ content: '{yellow-fg}Checking...{/yellow-fg}',
507
+ style: { bg: COLORS.contentBg },
508
+ });
509
+ providerStatusTexts.push({ id: provider.id, widget: statusText });
510
+
511
+ const installBtn = blessed.button({
512
+ parent: box,
513
+ top: yOffset + 1,
514
+ left: 30,
515
+ width: 14,
516
+ height: 1,
517
+ content: ' Install ',
518
+ tags: true,
519
+ mouse: true,
520
+ keys: true,
521
+ hidden: true,
522
+ style: {
523
+ fg: COLORS.btnFg,
524
+ bg: COLORS.btnBg,
525
+ focus: { fg: 'black', bg: COLORS.btnFocusBg },
526
+ },
527
+ });
528
+
529
+ const removeBtn = blessed.button({
530
+ parent: box,
531
+ top: yOffset + 1,
532
+ left: 46,
533
+ width: 12,
534
+ height: 1,
535
+ content: ' Remove ',
536
+ tags: true,
537
+ mouse: true,
538
+ keys: true,
539
+ hidden: true,
540
+ style: {
541
+ fg: COLORS.btnFg,
542
+ bg: COLORS.removeBg,
543
+ focus: { fg: 'black', bg: COLORS.removeFocusBg },
544
+ },
545
+ });
546
+
547
+ const configBtn = blessed.button({
548
+ parent: box,
549
+ top: yOffset + 1,
550
+ left: 60,
551
+ width: 14,
552
+ height: 1,
553
+ content: ' Configure ',
554
+ tags: true,
555
+ mouse: true,
556
+ keys: true,
557
+ hidden: true,
558
+ style: {
559
+ fg: 'black',
560
+ bg: COLORS.cfgBg,
561
+ focus: { fg: 'black', bg: COLORS.cfgFocusBg },
562
+ },
563
+ });
564
+
565
+ // Wire actions
566
+ installBtn.on('press', async () => { await handleProviderInstall(provider); });
567
+ installBtn.key(['enter', 'space'], async () => { await handleProviderInstall(provider); });
568
+
569
+ removeBtn.on('press', async () => { await handleProviderRemove(provider); });
570
+ removeBtn.key(['enter', 'space'], async () => { await handleProviderRemove(provider); });
571
+
572
+ configBtn.on('press', async () => { await handleProviderConfigure(provider); });
573
+ configBtn.key(['enter', 'space'], async () => { await handleProviderConfigure(provider); });
574
+
575
+ // Navigation on each button
576
+ for (const btn of [installBtn, removeBtn, configBtn]) {
577
+ btn.key(['tab', 'right'], () => { cycleFocus(1); });
578
+ btn.key(['S-tab', 'left'], () => { cycleFocus(-1); });
579
+ btn.key(['escape'], () => {
580
+ if (typeof focusMainTabBar === 'function') {
581
+ focusMainTabBar();
582
+ screen.render();
583
+ }
584
+ });
585
+ btn.key(['up'], () => {
586
+ const prevIdx = providerFocusIndex - 3;
587
+ if (prevIdx >= 0) {
588
+ providerFocusIndex = prevIdx;
589
+ providerFocusableItems[providerFocusIndex].focus();
590
+ screen.render();
591
+ } else if (typeof focusMainTabBar === 'function') {
592
+ focusMainTabBar();
593
+ }
594
+ });
595
+ btn.key(['down'], () => {
596
+ const nextIdx = providerFocusIndex + 3;
597
+ if (nextIdx < providerFocusableItems.length) {
598
+ providerFocusIndex = nextIdx;
599
+ providerFocusableItems[providerFocusIndex].focus();
600
+ screen.render();
601
+ }
602
+ });
603
+ }
604
+
605
+ providerRows.push({ id: provider.id, label, statusText, installBtn, removeBtn, configBtn });
606
+ return { installBtn, removeBtn, configBtn };
607
+ }
608
+
609
+ // Build all provider rows
610
+ for (let i = 0; i < PROVIDERS.length; i++) {
611
+ const { installBtn, removeBtn, configBtn } = createProviderRow(PROVIDERS[i], i);
612
+ providerFocusableItems.push(installBtn, removeBtn, configBtn);
613
+ }
614
+
615
+ function cycleFocus(dir) {
616
+ providerFocusIndex = (providerFocusIndex + dir + providerFocusableItems.length) % providerFocusableItems.length;
617
+ providerFocusableItems[providerFocusIndex].focus();
618
+ screen.render();
619
+ }
620
+
621
+ // ── Provider install/remove handlers ──────────────────────────────────────
622
+
623
+ async function handleProviderInstall(provider) {
624
+ if (provider.id === 'claude-code') {
625
+ const wasInstalled = installedState[provider.id];
626
+ const result = await installClaudeMcp(targetDir);
627
+ await refreshInstalledState();
628
+ showClaudeCodeInfo(result, wasInstalled);
629
+ return;
630
+ }
631
+
632
+ if (provider.id === 'github-copilot') {
633
+ const wasInstalled = installedState[provider.id];
634
+ const result = await installCopilotMcp(targetDir);
635
+ await installCopilotInstructions(targetDir, packageDir);
636
+ await refreshInstalledState();
637
+ showCopilotInfo(result, wasInstalled);
638
+ }
639
+
640
+ if (provider.id === 'openai-codex') {
641
+ const wasInstalled = installedState[provider.id];
642
+ const result = await installCodexMcp(targetDir);
643
+ await installCopilotMcp(targetDir);
644
+ await installCodexInstructions(targetDir, packageDir);
645
+ await installCodexHooks(targetDir, packageDir);
646
+ await refreshInstalledState();
647
+ showCodexInfo(result, wasInstalled);
648
+ }
649
+ }
650
+
651
+ async function handleProviderRemove(provider) {
652
+ if (provider.id === 'claude-code') {
653
+ await removeClaudeMcp(targetDir);
654
+ await refreshInstalledState();
655
+ showRemoveInfo('claude-code');
656
+ return;
657
+ }
658
+
659
+ if (provider.id === 'github-copilot') {
660
+ await removeCopilotMcp(targetDir);
661
+ await removeCopilotInstructions(targetDir);
662
+ await refreshInstalledState();
663
+ showRemoveInfo('github-copilot');
664
+ }
665
+
666
+ if (provider.id === 'openai-codex') {
667
+ await removeCodexMcp(targetDir);
668
+ await removeCopilotMcp(targetDir);
669
+ await removeCodexInstructions(targetDir);
670
+ await removeCodexHooks(targetDir);
671
+ await refreshInstalledState();
672
+ showRemoveInfo('openai-codex');
673
+ }
674
+ }
675
+
676
+ // ── Provider configure handler ────────────────────────────────────────────
677
+
678
+ async function handleProviderConfigure(provider) {
679
+ const llmKeyMap = {
680
+ 'claude-code': 'claude-code',
681
+ 'github-copilot': 'copilot',
682
+ 'openai-codex': 'codex',
683
+ };
684
+ const llmKey = llmKeyMap[provider.id] || provider.id;
685
+ const config = loadLlmConfigSync(llmKey, targetDir);
686
+ _openLlmConfigModal(provider, llmKey, config);
687
+ }
688
+
689
+ // ── LLM Config Modal ─────────────────────────────────────────────────────
690
+
691
+ function _openLlmConfigModal(provider, llmKey, config) {
692
+ // Guard against double-open (key repeat, double-click)
693
+ if (navigationService?.isModalOpen()) return;
694
+ let _closed = false;
695
+ navigationService?.openModal();
696
+
697
+ const draft = {
698
+ ttsEngine: config.ttsEngine || '',
699
+ voice: config.voice || '',
700
+ pretext: config.pretext || '',
701
+ reverbPreset: config.effects || 'off',
702
+ bgTrack: config.bgTrack || '',
703
+ bgVolume: config.bgVolume || '0.15',
704
+ };
705
+
706
+ const modal = blessed.box({
707
+ parent: screen,
708
+ top: 'center',
709
+ left: 'center',
710
+ width: 72,
711
+ height: 21,
712
+ border: { type: 'line' },
713
+ tags: true,
714
+ label: ` {bold}{cyan-fg} ${provider.name} -- Audio Config {/cyan-fg}{/bold} `,
715
+ style: {
716
+ fg: COLORS.labelFg,
717
+ bg: COLORS.contentBg,
718
+ border: { fg: 'cyan' },
719
+ },
720
+ });
721
+ modal.setFront();
722
+
723
+ // Field definitions
724
+ const FIELDS = [
725
+ { key: 'ttsEngine', label: 'TTS Engine', getValue: () => draft.ttsEngine || '(global default)' },
726
+ { key: 'voice', label: 'Voice', getValue: () => draft.voice || '(global default)' },
727
+ { key: 'pretext', label: 'Pretext', getValue: () => draft.pretext || '(none)' },
728
+ { key: 'reverb', label: 'Reverb', getValue: () => {
729
+ const p = REVERB_PRESETS.find(r => r.value === draft.reverbPreset);
730
+ return p ? p.label : draft.reverbPreset || 'Off';
731
+ }},
732
+ { key: 'bgTrack', label: 'Music Track', getValue: () => formatTrackName(draft.bgTrack) || '(default)' },
733
+ { key: 'bgVolume', label: 'Music Vol', getValue: () => {
734
+ const pct = Math.round(parseFloat(draft.bgVolume) * 100);
735
+ return `${pct}%`;
736
+ }},
737
+ ];
738
+
739
+ function _fieldItems() {
740
+ return FIELDS.map(f => {
741
+ const label = f.label.padEnd(14);
742
+ return ` ${label} ${f.getValue()}`;
743
+ });
744
+ }
745
+
746
+ const fieldList = blessed.list({
747
+ parent: modal,
748
+ top: 1,
749
+ left: 2,
750
+ right: 2,
751
+ height: FIELDS.length + 2,
752
+ keys: true,
753
+ vi: false,
754
+ mouse: true,
755
+ border: { type: 'line' },
756
+ tags: true,
757
+ style: {
758
+ fg: COLORS.labelFg,
759
+ bg: COLORS.contentBg,
760
+ border: { fg: 'blue' },
761
+ selected: { bg: 'blue', fg: 'yellow' },
762
+ item: { fg: COLORS.labelFg },
763
+ },
764
+ });
765
+ fieldList.setItems(_fieldItems());
766
+
767
+ blessed.text({
768
+ parent: modal,
769
+ bottom: 4,
770
+ left: 2,
771
+ right: 2,
772
+ tags: true,
773
+ content: '{white-fg}[Up/Down] Navigate [Enter] Edit [Tab] Save/Cancel [Esc] Cancel{/white-fg}',
774
+ style: { bg: COLORS.contentBg },
775
+ });
776
+
777
+ // Buttons
778
+ function _modalBtn(label, leftPos, onClick) {
779
+ const btn = blessed.button({
780
+ parent: modal,
781
+ content: label,
782
+ bottom: 2,
783
+ left: leftPos,
784
+ mouse: true,
785
+ keys: true,
786
+ shrink: true,
787
+ padding: { left: 1, right: 1 },
788
+ style: {
789
+ bg: 'blue',
790
+ fg: 'white',
791
+ focus: { bg: 'cyan', fg: 'black', bold: true },
792
+ hover: { bg: 'cyan', fg: 'black', bold: true },
793
+ },
794
+ });
795
+ btn.key(['enter', 'space'], () => onClick());
796
+ btn.on('click', () => onClick());
797
+ return btn;
798
+ }
799
+
800
+ const saveBtn = _modalBtn('Save', 4, () => {
801
+ saveLlmConfigSync(llmKey, {
802
+ voice: draft.voice,
803
+ pretext: draft.pretext,
804
+ effects: draft.reverbPreset === 'off' ? '' : draft.reverbPreset,
805
+ bgTrack: draft.bgTrack,
806
+ bgVolume: draft.bgVolume,
807
+ ttsEngine: draft.ttsEngine,
808
+ sourcePath: config.sourcePath,
809
+ }, targetDir);
810
+ _closeModal();
811
+ _showSavedToast(provider.name);
812
+ });
813
+
814
+ const resetBtn = _modalBtn('Reset', 16, () => {
815
+ draft.ttsEngine = '';
816
+ draft.voice = '';
817
+ draft.pretext = '';
818
+ draft.reverbPreset = 'off';
819
+ draft.bgTrack = '';
820
+ draft.bgVolume = '0.15';
821
+ fieldList.setItems(_fieldItems());
822
+ fieldList.focus();
823
+ screen.render();
824
+ });
825
+
826
+ const cancelBtn = _modalBtn('Cancel', 30, _closeModal);
827
+
828
+ const allBtns = [saveBtn, resetBtn, cancelBtn];
829
+ const btnBlink = attachBtnBlink(allBtns, screen);
830
+
831
+ function _closeModal() {
832
+ if (_closed) return;
833
+ _closed = true;
834
+ btnBlink.cleanup();
835
+ navigationService?.closeModal();
836
+ destroyList(modal, screen);
837
+ if (providerFocusableItems.length) providerFocusableItems[providerFocusIndex]?.focus();
838
+ screen.render();
839
+ }
840
+
841
+ // Field editing via Enter
842
+ fieldList.key(['enter'], () => {
843
+ const idx = fieldList.selected;
844
+ const field = FIELDS[idx];
845
+ if (!field) return;
846
+
847
+ const _refreshField = () => {
848
+ fieldList.setItems(_fieldItems());
849
+ fieldList.select(idx);
850
+ fieldList.focus();
851
+ screen.render();
852
+ };
853
+ const _cancelField = () => {
854
+ fieldList.focus();
855
+ screen.render();
856
+ };
857
+
858
+ switch (field.key) {
859
+ case 'ttsEngine':
860
+ _openTtsEnginePicker(draft, _refreshField);
861
+ break;
862
+
863
+ case 'voice':
864
+ _openVoicePickerForLlm(draft, _refreshField);
865
+ break;
866
+
867
+ case 'pretext':
868
+ _openPretextEditor(modal, draft, _refreshField);
869
+ break;
870
+
871
+ case 'reverb':
872
+ openReverbPicker(screen, draft.reverbPreset, (val) => {
873
+ draft.reverbPreset = val;
874
+ _refreshField();
875
+ }, _cancelField, { applyToEffectsManager: false });
876
+ break;
877
+
878
+ case 'bgTrack':
879
+ openTrackPicker(screen, draft.bgTrack, Math.round(parseFloat(draft.bgVolume) * 100), (track) => {
880
+ draft.bgTrack = track;
881
+ _refreshField();
882
+ }, _cancelField, { skipVolume: true });
883
+ break;
884
+
885
+ case 'bgVolume':
886
+ openVolumeInput(screen, Math.round(parseFloat(draft.bgVolume) * 100), (volume) => {
887
+ draft.bgVolume = (volume / 100).toFixed(2);
888
+ _refreshField();
889
+ }, _cancelField);
890
+ break;
891
+ }
892
+ });
893
+
894
+ fieldList.key(['escape'], _closeModal);
895
+
896
+ // Remove selection highlight when field list loses focus
897
+ fieldList.on('blur', () => {
898
+ fieldList.style.selected = { bg: COLORS.contentBg, fg: COLORS.labelFg };
899
+ fieldList.setItems(_fieldItems());
900
+ screen.render();
901
+ });
902
+ fieldList.on('focus', () => {
903
+ fieldList.style.selected = { bg: 'blue', fg: 'yellow' };
904
+ fieldList.setItems(_fieldItems());
905
+ screen.render();
906
+ });
907
+
908
+ // Wrap: down on last field → focus Save; up on first field → focus Save
909
+ // One extra arrow press at boundary moves to button row.
910
+ // Track previous selection so arriving at boundary doesn't immediately jump.
911
+ let _prevFieldSel = 0;
912
+ fieldList.key(['down'], () => {
913
+ const cur = fieldList.selected ?? 0;
914
+ if (cur === FIELDS.length - 1 && _prevFieldSel === FIELDS.length - 1) {
915
+ allBtns[0].focus(); screen.render();
916
+ }
917
+ _prevFieldSel = cur;
918
+ });
919
+ fieldList.key(['up'], () => {
920
+ const cur = fieldList.selected ?? 0;
921
+ if (cur === 0 && _prevFieldSel === 0) {
922
+ allBtns[0].focus(); screen.render();
923
+ }
924
+ _prevFieldSel = cur;
925
+ });
926
+ fieldList.key(['tab'], () => {
927
+ allBtns[0].focus();
928
+ screen.render();
929
+ });
930
+
931
+ for (let i = 0; i < allBtns.length; i++) {
932
+ allBtns[i].key(['tab', 'right'], () => {
933
+ allBtns[(i + 1) % allBtns.length].focus();
934
+ screen.render();
935
+ });
936
+ allBtns[i].key(['S-tab', 'left'], () => {
937
+ allBtns[(i - 1 + allBtns.length) % allBtns.length].focus();
938
+ screen.render();
939
+ });
940
+ allBtns[i].key(['escape'], _closeModal);
941
+ allBtns[i].key(['up'], () => {
942
+ fieldList.focus();
943
+ screen.render();
944
+ });
945
+ }
946
+
947
+ modal.key(['escape'], _closeModal);
948
+ fieldList.focus();
949
+ screen.render();
950
+ }
951
+
952
+ // ── TTS Engine picker (for config modal) ──────────────────────────────────
953
+
954
+ function _openTtsEnginePicker(draft, onDone) {
955
+ navigationService?.openModal();
956
+
957
+ const engines = getEngineStatuses();
958
+ const items = engines.map(e => {
959
+ const status = e.installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[Not Found]{/yellow-fg}';
960
+ return ` ${e.name.padEnd(20)} ${status} ${e.desc}`;
961
+ });
962
+ // Add "(global default)" option at top
963
+ items.unshift(' (global default)');
964
+
965
+ const picker = blessed.list({
966
+ parent: screen,
967
+ top: 'center',
968
+ left: 'center',
969
+ width: 70,
970
+ height: Math.min(items.length + 4, 16),
971
+ border: { type: 'line' },
972
+ tags: true,
973
+ label: ' {bold}{cyan-fg} Select TTS Engine {/cyan-fg}{/bold} ',
974
+ keys: true,
975
+ vi: false,
976
+ mouse: true,
977
+ style: {
978
+ fg: COLORS.labelFg,
979
+ bg: COLORS.contentBg,
980
+ border: { fg: 'cyan' },
981
+ selected: { bg: 'blue', fg: 'yellow' },
982
+ item: { fg: COLORS.labelFg },
983
+ },
984
+ });
985
+ picker.setFront();
986
+ picker.setItems(items);
987
+
988
+ picker.key(['enter'], () => {
989
+ const idx = picker.selected;
990
+ if (idx === 0) {
991
+ draft.ttsEngine = '';
992
+ } else {
993
+ draft.ttsEngine = engines[idx - 1].id;
994
+ }
995
+ navigationService?.closeModal();
996
+ destroyList(picker, screen);
997
+ onDone();
998
+ });
999
+
1000
+ picker.key(['escape'], () => {
1001
+ navigationService?.closeModal();
1002
+ destroyList(picker, screen);
1003
+ onDone();
1004
+ });
1005
+
1006
+ picker.focus();
1007
+ screen.render();
1008
+ }
1009
+
1010
+ // ── Voice picker for LLM config (matches agents-tab pattern) ──────────────
1011
+
1012
+ function _secureTempWav(prefix) {
1013
+ const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
1014
+ const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
1015
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1016
+ try { fs.chmodSync(dir, 0o700); } catch {}
1017
+ return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
1018
+ }
1019
+
1020
+ function _openVoicePickerForLlm(draft, onDone) {
1021
+ navigationService?.openModal();
1022
+
1023
+ let _allVoices = [];
1024
+ let _filterText = '';
1025
+ let _previewProc = null;
1026
+ let _previewVoiceId = null;
1027
+ let _vpClosed = false;
1028
+
1029
+ const _spawnEnv = buildAudioEnv();
1030
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1031
+
1032
+ function _killVP() {
1033
+ if (_previewProc) {
1034
+ try {
1035
+ if (_isWin) { _previewProc.kill(); } else { process.kill(-_previewProc.pid, 'SIGTERM'); }
1036
+ } catch {}
1037
+ _previewProc = null;
1038
+ }
1039
+ _previewVoiceId = null;
1040
+ }
1041
+
1042
+ function _closeVP() {
1043
+ if (_vpClosed) return;
1044
+ _vpClosed = true;
1045
+ _killVP();
1046
+ navigationService?.closeModal();
1047
+ destroyList(vpModal, screen, onDone);
1048
+ }
1049
+
1050
+ const vpModal = blessed.box({
1051
+ parent: screen,
1052
+ top: '6%',
1053
+ left: '3%',
1054
+ width: '94%',
1055
+ height: '88%',
1056
+ border: { type: 'line' },
1057
+ tags: true,
1058
+ label: ' {bold}{cyan-fg} Select Voice {/cyan-fg}{/bold} ',
1059
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
1060
+ });
1061
+ vpModal.setFront();
1062
+
1063
+ // Search
1064
+ blessed.text({
1065
+ parent: vpModal, top: 1, left: 2,
1066
+ content: 'Search:', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
1067
+ });
1068
+ const vpSearch = blessed.textbox({
1069
+ parent: vpModal, top: 1, left: 11, width: 40, height: 1,
1070
+ inputOnFocus: true, keys: true,
1071
+ style: { fg: COLORS.valueFg, bg: 'blue', focus: { bg: 'cyan' } },
1072
+ });
1073
+
1074
+ // Column header
1075
+ const COL_N = 28;
1076
+ const COL_G = 10;
1077
+ blessed.text({
1078
+ parent: vpModal, top: 2, left: 6, tags: true,
1079
+ content: `{cyan-fg}${'Name'.padEnd(COL_N)}${'Gender'.padEnd(COL_G)}Provider{/cyan-fg}`,
1080
+ style: { bg: COLORS.contentBg },
1081
+ });
1082
+
1083
+ const vpList = blessed.list({
1084
+ parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
1085
+ keys: true, vi: true, mouse: true,
1086
+ border: { type: 'line' },
1087
+ scrollbar: { ch: '|', style: { fg: 'cyan' } },
1088
+ tags: true,
1089
+ style: {
1090
+ fg: COLORS.labelFg, bg: COLORS.contentBg,
1091
+ border: { fg: 'blue' },
1092
+ selected: { bg: 'green', fg: 'white', bold: true },
1093
+ item: { fg: COLORS.labelFg },
1094
+ },
1095
+ });
1096
+
1097
+ const vpPreviewLine = blessed.text({
1098
+ parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
1099
+ content: '', style: { fg: 'cyan', bg: COLORS.contentBg },
1100
+ });
1101
+
1102
+ blessed.text({
1103
+ parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
1104
+ content: '{white-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [/] Search [Esc] Cancel{/white-fg}',
1105
+ style: { bg: COLORS.contentBg },
1106
+ });
1107
+
1108
+ function _getFiltered() {
1109
+ if (!_filterText) return _allVoices;
1110
+ const f = _filterText.toLowerCase();
1111
+ return _allVoices.filter(v => v.toLowerCase().includes(f));
1112
+ }
1113
+
1114
+ function _buildVoiceItems(voices) {
1115
+ return voices.map(v => {
1116
+ const isActive = v === draft.voice;
1117
+ const isPrev = v === _previewVoiceId;
1118
+ const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
1119
+ const meta = getVoiceMeta(v);
1120
+ const name = meta.displayName.length > COL_N
1121
+ ? meta.displayName.slice(0, COL_N - 1) + '…'
1122
+ : meta.displayName.padEnd(COL_N);
1123
+ return ` ${dot} ${name}${meta.gender.padEnd(COL_G)}${meta.provider}`;
1124
+ });
1125
+ }
1126
+
1127
+ function _refreshVP() {
1128
+ if (_vpClosed) return;
1129
+ const savedIdx = vpList.selected ?? 0;
1130
+ const savedScroll = vpList.childBase ?? 0;
1131
+ _allVoices = scanInstalledVoices();
1132
+ const filtered = _getFiltered();
1133
+ const items = _buildVoiceItems(filtered);
1134
+ vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
1135
+ vpList.select(Math.min(savedIdx, items.length - 1));
1136
+ vpList.childBase = Math.min(savedScroll, Math.max(0, items.length - (vpList.height - 2)));
1137
+ screen.render();
1138
+ }
1139
+
1140
+ function _previewVoice(voiceId) {
1141
+ if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
1142
+ _killVP();
1143
+
1144
+ const _ms = parseMultiSpeaker(voiceId);
1145
+ const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
1146
+ const safeBase = path.resolve(PIPER_VOICES_DIR);
1147
+ if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
1148
+
1149
+ const tempWav = _secureTempWav('vp');
1150
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1151
+
1152
+ let _piperBin = 'piper';
1153
+ if (_isWin) {
1154
+ const _lad = process.env.LOCALAPPDATA ||
1155
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
1156
+ if (_lad) {
1157
+ const _ep = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
1158
+ if (fs.existsSync(_ep)) _piperBin = _ep;
1159
+ }
1160
+ }
1161
+
1162
+ const args = ['--model', voicePath, '--output_file', tempWav];
1163
+ if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
1164
+ const piper = spawn(_piperBin, args, {
1165
+ stdio: ['pipe', 'ignore', 'ignore'],
1166
+ detached: !_isWin,
1167
+ windowsHide: true,
1168
+ env: _spawnEnv,
1169
+ });
1170
+ piper.stdin.write(phrase + '\n');
1171
+ piper.stdin.end();
1172
+ _previewProc = piper;
1173
+ _previewVoiceId = voiceId;
1174
+
1175
+ if (!_vpClosed) {
1176
+ vpPreviewLine.setContent(`{cyan-fg}♪ Synthesizing: ${voiceId}...{/cyan-fg}`);
1177
+ _refreshVP();
1178
+ }
1179
+
1180
+ piper.on('exit', (code) => {
1181
+ if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
1182
+ if (code !== 0) { _previewProc = null; _previewVoiceId = null; try { fs.unlinkSync(tempWav); } catch {} return; }
1183
+ const wp = detectWavPlayer(_spawnEnv);
1184
+ if (!wp) return;
1185
+ const pp = spawn(wp.bin, wp.args(tempWav), {
1186
+ stdio: 'ignore',
1187
+ detached: !_isWin,
1188
+ windowsHide: true,
1189
+ env: _spawnEnv,
1190
+ });
1191
+ _previewProc = pp;
1192
+ if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}{/cyan-fg}`); screen.render(); }
1193
+ pp.on('exit', () => {
1194
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
1195
+ try { fs.unlinkSync(tempWav); } catch {}
1196
+ });
1197
+ });
1198
+ piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1199
+ }
1200
+
1201
+ vpSearch.on('keypress', () => {
1202
+ setTimeout(() => { _filterText = vpSearch.getValue().trim(); _refreshVP(); }, 0);
1203
+ });
1204
+ vpSearch.key(['escape'], () => { vpList.focus(); screen.render(); });
1205
+ vpList.key(['/'], () => { vpSearch.clearValue(); vpSearch.focus(); screen.render(); });
1206
+ vpList.key(['enter'], () => {
1207
+ const filtered = _getFiltered();
1208
+ const sel = filtered[vpList.selected];
1209
+ if (sel) { draft.voice = sel; _closeVP(); }
1210
+ });
1211
+ vpList.key(['space'], () => {
1212
+ const filtered = _getFiltered();
1213
+ const sel = filtered[vpList.selected];
1214
+ if (sel) _previewVoice(sel);
1215
+ });
1216
+ vpList.key(['escape', 'q'], _closeVP);
1217
+
1218
+ _refreshVP();
1219
+ const activeIdx = _getFiltered().indexOf(draft.voice);
1220
+ if (activeIdx >= 0) vpList.select(activeIdx);
1221
+ vpList.focus();
1222
+ screen.render();
1223
+ }
1224
+
1225
+ // ── Pretext editor ────────────────────────────────────────────────────────
1226
+
1227
+ function _openPretextEditor(parentModal, draft, onDone) {
1228
+ const editModal = blessed.box({
1229
+ parent: screen, top: 'center', left: 'center',
1230
+ width: 60, height: 8,
1231
+ border: { type: 'line' },
1232
+ tags: true,
1233
+ label: ' {bold}{cyan-fg} Edit Pretext {/cyan-fg}{/bold} ',
1234
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
1235
+ });
1236
+ editModal.setFront();
1237
+
1238
+ blessed.text({
1239
+ parent: editModal, top: 1, left: 2, tags: true,
1240
+ content: '{white-fg}Spoken before every TTS message (max 200 chars):{/white-fg}',
1241
+ style: { bg: COLORS.contentBg },
1242
+ });
1243
+
1244
+ const inputBox = blessed.textbox({
1245
+ parent: editModal, top: 3, left: 2, right: 2, height: 3,
1246
+ border: { type: 'line' },
1247
+ inputOnFocus: true,
1248
+ value: draft.pretext,
1249
+ style: {
1250
+ fg: 'white', bg: 'black',
1251
+ border: { fg: 'blue' },
1252
+ focus: { border: { fg: 'cyan' } },
1253
+ },
1254
+ });
1255
+
1256
+ function _closeEdit(save) {
1257
+ if (save) {
1258
+ const val = (inputBox.getValue() || '').trim().slice(0, 200);
1259
+ draft.pretext = val;
1260
+ }
1261
+ destroyList(editModal, screen);
1262
+ onDone();
1263
+ }
1264
+
1265
+ inputBox.key(['enter'], () => _closeEdit(true));
1266
+ inputBox.key(['escape'], () => _closeEdit(false));
1267
+
1268
+ inputBox.focus();
1269
+ inputBox.readInput(() => {});
1270
+ screen.render();
1271
+ }
1272
+
1273
+ // ── Saved toast ───────────────────────────────────────────────────────────
1274
+
1275
+ function _showSavedToast(name) {
1276
+ const toast = blessed.box({
1277
+ parent: screen,
1278
+ top: 'center',
1279
+ left: 'center',
1280
+ width: 30,
1281
+ height: 3,
1282
+ border: { type: 'line' },
1283
+ tags: true,
1284
+ content: `{center}{green-fg}{bold}${name} saved!{/bold}{/green-fg}{/center}`,
1285
+ style: { bg: COLORS.contentBg, border: { fg: 'green' } },
1286
+ });
1287
+ toast.setFront();
1288
+ screen.render();
1289
+ setTimeout(() => {
1290
+ toast.destroy();
1291
+ screen.render();
1292
+ }, 1500);
1293
+ }
1294
+
1295
+ // ── Provider info panels ──────────────────────────────────────────────────
1296
+
1297
+ function hideAllProviderRows() {
1298
+ providerHeader.hide();
1299
+ for (const row of providerRows) {
1300
+ row.label.hide();
1301
+ row.statusText.hide();
1302
+ row.installBtn.hide();
1303
+ row.removeBtn.hide();
1304
+ row.configBtn.hide();
1305
+ }
1306
+ }
1307
+
1308
+ function showAllProviderRows() {
1309
+ providerHeader.show();
1310
+ for (const row of providerRows) {
1311
+ row.label.show();
1312
+ row.statusText.show();
1313
+ row.installBtn.show();
1314
+ row.removeBtn.show();
1315
+ row.configBtn.show();
1316
+ }
1317
+ }
1318
+
1319
+ function showClaudeCodeInfo(result = null, wasInstalled = false) {
1320
+ providerView = 'info';
1321
+ hideAllProviderRows();
1322
+ contentBox.hide();
1323
+
1324
+ const mcpPath = path.join(targetDir, '.mcp.json');
1325
+ const hooksDir = path.join(targetDir, '.claude', process.platform === 'win32' ? 'hooks-windows' : 'hooks');
1326
+ const installed = installedState['claude-code'];
1327
+ const verb = wasInstalled ? 'reinstalled' : 'installed';
1328
+
1329
+ const lines = [];
1330
+ lines.push('{bold}{cyan-fg}Claude Code -- AgentVibes Integration{/cyan-fg}{/bold}');
1331
+ lines.push('');
1332
+
1333
+ if (result) {
1334
+ lines.push(result.success
1335
+ ? `{green-fg}AgentVibes for Claude Code ${verb}!{/green-fg}`
1336
+ : `{red-fg}Installation failed{/red-fg}`);
1337
+ } else {
1338
+ lines.push(installed
1339
+ ? '{green-fg}Installed{/green-fg}'
1340
+ : '{yellow-fg}Not installed{/yellow-fg}');
1341
+ }
1342
+
1343
+ lines.push('');
1344
+ lines.push(`{bold}{cyan-fg}What ${result ? `got ${verb}` : 'gets installed'}:{/cyan-fg}{/bold}`);
1345
+ lines.push('');
1346
+ lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.mcp.json{/bold} (project root)');
1347
+ lines.push(` Location: ${mcpPath}`);
1348
+ lines.push(' Registers the AgentVibes MCP server for Claude Code.');
1349
+ lines.push('');
1350
+ lines.push(' {yellow-fg}2.{/yellow-fg} {bold}.claude/hooks/{/bold} (session-start + pre-tool hooks)');
1351
+ lines.push(` Location: ${hooksDir}`);
1352
+ lines.push('');
1353
+ lines.push(' {yellow-fg}3.{/yellow-fg} {bold}.claude/commands/{/bold} (slash commands)');
1354
+ lines.push('');
1355
+ lines.push(' {yellow-fg}4.{/yellow-fg} {bold}.claude/config/{/bold} (personality, verbosity, voice settings)');
1356
+ lines.push('');
1357
+ lines.push('{white-fg}Press {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1358
+
1359
+ infoBox.setContent(lines.join('\n'));
1360
+ infoBox.show();
1361
+ infoBox.focus();
1362
+ infoBox.scrollTo(0);
1363
+ screen.render();
1364
+ }
1365
+
1366
+ function showCopilotInfo(result, wasInstalled = false) {
1367
+ providerView = 'info';
1368
+ hideAllProviderRows();
1369
+ contentBox.hide();
1370
+
1371
+ const verb = wasInstalled ? 'reinstalled' : 'installed';
1372
+
1373
+ const lines = [];
1374
+ lines.push('{bold}{cyan-fg}GitHub Copilot -- AgentVibes Integration{/cyan-fg}{/bold}');
1375
+ lines.push('');
1376
+ lines.push(result.success
1377
+ ? `{green-fg}AgentVibes for Copilot ${verb}!{/green-fg}`
1378
+ : `{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1379
+ lines.push('');
1380
+ lines.push(`{bold}{cyan-fg}What got ${verb}:{/cyan-fg}{/bold}`);
1381
+ lines.push('');
1382
+ lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.vscode/mcp.json{/bold}');
1383
+ lines.push(' {yellow-fg}2.{/yellow-fg} {bold}.github/copilot-instructions.md{/bold}');
1384
+ lines.push('');
1385
+ lines.push('{white-fg}Press {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1386
+
1387
+ infoBox.setContent(lines.join('\n'));
1388
+ infoBox.show();
1389
+ infoBox.focus();
1390
+ infoBox.scrollTo(0);
1391
+ screen.render();
1392
+ }
1393
+
1394
+ function showCodexInfo(result, wasInstalled = false) {
1395
+ providerView = 'info';
1396
+ hideAllProviderRows();
1397
+ contentBox.hide();
1398
+
1399
+ const verb = wasInstalled ? 'reinstalled' : 'installed';
1400
+
1401
+ const lines = [];
1402
+ lines.push('{bold}{cyan-fg}OpenAI Codex -- AgentVibes Integration{/cyan-fg}{/bold}');
1403
+ lines.push('');
1404
+ lines.push(result.success
1405
+ ? `{green-fg}AgentVibes for Codex ${verb}!{/green-fg}`
1406
+ : `{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1407
+ lines.push('');
1408
+ lines.push(`{bold}{cyan-fg}What got ${verb}:{/cyan-fg}{/bold}`);
1409
+ lines.push('');
1410
+ lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.codex/config.toml{/bold}');
1411
+ lines.push(' {yellow-fg}2.{/yellow-fg} {bold}.vscode/mcp.json{/bold}');
1412
+ lines.push(' {yellow-fg}3.{/yellow-fg} {bold}AGENTS.md{/bold}');
1413
+ lines.push(' {yellow-fg}4.{/yellow-fg} {bold}.codex/hooks/{/bold}');
1414
+ lines.push('');
1415
+ lines.push('{white-fg}Press {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1416
+
1417
+ infoBox.setContent(lines.join('\n'));
1418
+ infoBox.show();
1419
+ infoBox.focus();
1420
+ infoBox.scrollTo(0);
1421
+ screen.render();
1422
+ }
1423
+
1424
+ function showRemoveInfo(providerId) {
1425
+ providerView = 'info';
1426
+ hideAllProviderRows();
1427
+ contentBox.hide();
1428
+
1429
+ const lines = [];
1430
+ if (providerId === 'claude-code') {
1431
+ lines.push('{bold}{cyan-fg}Remove Claude Code Integration{/cyan-fg}{/bold}');
1432
+ lines.push('');
1433
+ lines.push('To remove, run: {yellow-fg}npx agentvibes uninstall{/yellow-fg}');
1434
+ } else if (providerId === 'github-copilot') {
1435
+ lines.push('{bold}{cyan-fg}GitHub Copilot -- Removed{/cyan-fg}{/bold}');
1436
+ lines.push('');
1437
+ lines.push('{green-fg}Successfully removed!{/green-fg}');
1438
+ } else if (providerId === 'openai-codex') {
1439
+ lines.push('{bold}{cyan-fg}OpenAI Codex -- Removed{/cyan-fg}{/bold}');
1440
+ lines.push('');
1441
+ lines.push('{green-fg}Successfully removed!{/green-fg}');
1442
+ }
1443
+ lines.push('');
1444
+ lines.push('{white-fg}Press {bold}Escape{/bold} to return to the provider list.{/white-fg}');
1445
+
1446
+ infoBox.setContent(lines.join('\n'));
1447
+ infoBox.show();
1448
+ infoBox.focus();
1449
+ infoBox.scrollTo(0);
1450
+ screen.render();
1451
+ }
1452
+
1453
+ function showProviderListView() {
1454
+ providerView = 'list';
1455
+ infoBox.hide();
1456
+ contentBox.hide();
1457
+ showAllProviderRows();
1458
+ providerFocusIndex = 0;
1459
+ if (providerFocusableItems.length) providerFocusableItems[0].focus();
1460
+ screen.render();
1461
+ }
1462
+
1463
+ infoBox.key(['escape'], () => {
1464
+ showProviderListView();
1465
+ });
1466
+
1467
+ async function refreshInstalledState() {
1468
+ for (const p of PROVIDERS) {
1469
+ const checkFn = p.id === 'claude-code' ? checkClaudeInstalled
1470
+ : p.id === 'github-copilot' ? checkCopilotInstalled
1471
+ : checkCodexInstalled;
1472
+ installedState[p.id] = await checkFn(targetDir);
1473
+ }
1474
+ for (const row of providerRows) {
1475
+ const installed = installedState[row.id];
1476
+ row.statusText.setContent(
1477
+ installed
1478
+ ? '{green-fg}[Installed]{/green-fg}'
1479
+ : '{yellow-fg}[Not Installed]{/yellow-fg}'
1480
+ );
1481
+ row.installBtn.setContent(installed ? ' Re-install ' : ' Install ');
1482
+ }
1483
+ }
1484
+
1485
+ // =========================================================================
1486
+ // Screen renderers
1487
+ // =========================================================================
1488
+
1489
+ const _HDR = (emoji, label) =>
1490
+ `{${COLORS.sectionHdr}-fg}${emoji} ${label} ${'--'.repeat(50)}{/${COLORS.sectionHdr}-fg}`;
1491
+
1492
+ function _renderScreen0() {
1493
+ const lines = [
1494
+ _HDR('', 'Language / Idioma / Langue / Sprache'),
1495
+ '',
1496
+ ' Select your language:',
1497
+ '',
1498
+ ...SUPPORTED_LANGUAGES.map((l, i) =>
1499
+ i === _langIdx
1500
+ ? ` {green-fg}> ${l.name}{/green-fg}`
1501
+ : ` ${l.name}`
1502
+ ),
1503
+ ];
1504
+ contentBox.setContent(_c(lines));
1505
+ hintLine.setContent(' Screen 0: Language | [Up/Down] Select | [Enter] Apply & Continue | [->] Skip (English)');
1506
+ screen.render();
1507
+ }
1508
+
1509
+ async function _renderScreen1() {
1510
+ const frames = ['|','/','-','\\'];
1511
+ let frameIdx = 0;
1512
+ _checking = true;
1513
+ _s1ContinueBtn.hide();
1514
+
1515
+ contentBox.setContent(_c([
1516
+ _HDR('', t(_getLang(), 'dependencyCheck')),
1517
+ '',
1518
+ ` {white-fg}${frames[0]} ${t(_getLang(), 'checkingDependencies')}{/white-fg}`,
1519
+ ]));
1520
+ hintLine.setContent(` ${t(_getLang(), 'screen2Hint')}`);
1521
+ screen.render();
1522
+
1523
+ const spinInterval = setInterval(() => {
1524
+ frameIdx = (frameIdx + 1) % frames.length;
1525
+ contentBox.setContent(_c([
1526
+ _HDR('', t(_getLang(), 'dependencyCheck')),
1527
+ '',
1528
+ ` {white-fg}${frames[frameIdx]} ${t(_getLang(), 'checkingDependencies')}{/white-fg}`,
1529
+ ]));
1530
+ screen.render();
1531
+ }, 100);
1532
+
1533
+ try {
1534
+ _deps = await _checkDependenciesAsync();
1535
+ } finally {
1536
+ clearInterval(spinInterval);
1537
+ _checking = false;
1538
+ }
1539
+
1540
+ const ok = () => `{green-fg}OK ${t(_getLang(), 'installed')}{/green-fg}`;
1541
+ const bad = () => `{red-fg}X ${t(_getLang(), 'notFound')}{/red-fg}`;
1542
+
1543
+ const ttsOk = _deps.piper || _deps.soprano;
1544
+ contentBox.setContent(_c([
1545
+ _HDR('', t(_getLang(), 'dependencyCheck')),
1546
+ '',
1547
+ ` {white-fg}${'Dependency'.padEnd(14)}${'Status'}{/white-fg}`,
1548
+ ` {white-fg}${'---'.repeat(26)}{/white-fg}`,
1549
+ ` {white-fg}${'Node.js'.padEnd(14)}{/white-fg}${_deps.node ? ok() : bad()}`,
1550
+ ` {white-fg}${'npm'.padEnd(14)}{/white-fg}${_deps.npm ? ok() : bad()}`,
1551
+ ` {white-fg}${'Piper TTS'.padEnd(14)}{/white-fg}${_deps.piper ? ok() : bad()}`,
1552
+ ` {white-fg}${'Soprano TTS'.padEnd(14)}{/white-fg}${_deps.soprano ? ok() : bad()}`,
1553
+ ` {white-fg}${'ffmpeg'.padEnd(14)}{/white-fg}${_deps.ffmpeg ? ok() : `{red-fg}! ${t(_getLang(), 'ffmpegMissing')}{/red-fg}`}`,
1554
+ '',
1555
+ ttsOk
1556
+ ? ` {green-fg}OK ${t(_getLang(), 'ttsDetected')}{/green-fg}`
1557
+ : ` {red-fg}! ${t(_getLang(), 'noTtsFound')}{/red-fg}`,
1558
+ '',
1559
+ '',
1560
+ ]));
1561
+ if (ttsOk) {
1562
+ _s1ContinueBtn.setContent(_tl('continueArrowBtn'));
1563
+ _s1ContinueBtn.show();
1564
+ _s1ContinueBtn.focus();
1565
+ }
1566
+ screen.render();
1567
+ }
1568
+
1569
+ function _renderScreen2() {
1570
+ const lines = [
1571
+ _HDR('', 'TTS Engine Selection'),
1572
+ '',
1573
+ ' {white-fg}Select which TTS engines to use with AgentVibes:{/white-fg}',
1574
+ ];
1575
+
1576
+ contentBox.setContent(_c(lines));
1577
+
1578
+ _showTtsEngineRows();
1579
+
1580
+ // Position continue button below engine rows
1581
+ const btnY = 5 + (_ttsEngines.length * 3) + 1;
1582
+ _s2ContinueBtn.top = btnY;
1583
+ _s2ContinueBtn.left = 4;
1584
+ _s2ContinueBtn.show();
1585
+
1586
+ hintLine.setContent(' Screen 2: TTS Engines | [Tab] Install | [Enter/->] Continue | [Esc/<-] Back');
1587
+
1588
+ // Focus first visible install button or continue button
1589
+ const visibleBtns = _ttsFocusableItems.filter(b => !b.hidden);
1590
+ if (visibleBtns.length) {
1591
+ _ttsFocusIndex = 0;
1592
+ visibleBtns[0].focus();
1593
+ } else {
1594
+ _s2ContinueBtn.focus();
1595
+ }
1596
+ screen.render();
1597
+ }
1598
+
1599
+ function _renderScreen3() {
1600
+ // Mark setup as completed once user reaches the providers screen
1601
+ try { configService.set('setupCompleted', true); } catch {}
1602
+
1603
+ // Show provider rows instead of contentBox
1604
+ contentBox.hide();
1605
+ hintLine.setContent(' Screen 3: LLM Providers | [Enter] Action | [Tab] Next button | [Esc] Tab bar');
1606
+ showAllProviderRows();
1607
+ refreshInstalledState().then(() => {
1608
+ if (providerFocusableItems.length) {
1609
+ providerFocusIndex = 0;
1610
+ providerFocusableItems[0].focus();
1611
+ }
1612
+ screen.render();
1613
+ });
1614
+ }
1615
+
1616
+ function _showCurrentScreen() {
1617
+ // Hide Screen 1 continue button on other screens
1618
+ if (_screen !== 1) _s1ContinueBtn.hide();
1619
+
1620
+ // Hide Screen 2 TTS engine rows on other screens
1621
+ if (_screen !== 2) {
1622
+ _hideTtsEngineRows();
1623
+ _s2ContinueBtn.hide();
1624
+ }
1625
+
1626
+ // Hide provider rows on non-provider screens
1627
+ if (_screen !== 3) {
1628
+ hideAllProviderRows();
1629
+ infoBox.hide();
1630
+ providerView = 'list';
1631
+ }
1632
+
1633
+ // Show contentBox on screens 0-2
1634
+ if (_screen <= 2) {
1635
+ contentBox.show();
1636
+ }
1637
+
1638
+ if (_screen !== _lastScreen) {
1639
+ // Nuclear clear
1640
+ try {
1641
+ for (let r = 0; r < screen.height; r++) {
1642
+ const orow = screen.olines?.[r];
1643
+ if (!orow) continue;
1644
+ for (let c = 0; c < screen.width; c++) {
1645
+ if (orow[c]) orow[c][0] = -1;
1646
+ }
1647
+ }
1648
+ if (screen.lines?.[2]) screen.lines[2].dirty = true;
1649
+ } catch {}
1650
+
1651
+ const _clearLine = ' '.repeat(150);
1652
+ const _clearPage = Array(25).fill(_clearLine).join('\n');
1653
+ contentBox.setContent(_clearPage);
1654
+ hintLine.setContent(_clearLine);
1655
+ screen.render();
1656
+
1657
+ const targetScreen = _screen;
1658
+ _lastScreen = _screen;
1659
+ setTimeout(() => {
1660
+ if (_screen !== targetScreen) return;
1661
+ switch (_screen) {
1662
+ case 0: _renderScreen0(); break;
1663
+ case 1: _renderScreen1(); break;
1664
+ case 2: _renderScreen2(); break;
1665
+ case 3: _renderScreen3(); break;
1666
+ }
1667
+ }, 50);
1668
+ return;
1669
+ }
1670
+ switch (_screen) {
1671
+ case 0: _renderScreen0(); break;
1672
+ case 1: _renderScreen1(); break;
1673
+ case 2: _renderScreen2(); break;
1674
+ case 3: _renderScreen3(); break;
1675
+ }
1676
+ }
1677
+
1678
+ // =========================================================================
1679
+ // Navigation (key handlers)
1680
+ // =========================================================================
1681
+
1682
+ screen.key(['enter'], () => {
1683
+ if (box.hidden || _checking) return;
1684
+ if (_screen === 0) {
1685
+ if (languageService) languageService.setLang(SUPPORTED_LANGUAGES[_langIdx].value);
1686
+ _screen = 1;
1687
+ _showCurrentScreen();
1688
+ return;
1689
+ }
1690
+ if (_screen === 1) return; // Enter handled by Continue button
1691
+ if (_screen === 2) return; // Enter handled by Continue button and install buttons
1692
+ if (_screen === 3) return; // Enter handled by provider buttons
1693
+ });
1694
+
1695
+ screen.key(['escape'], () => {
1696
+ if (box.hidden || _checking) return;
1697
+ if (_screen === 3 && providerView === 'info') {
1698
+ showProviderListView();
1699
+ return;
1700
+ }
1701
+ if (_screen > 0) {
1702
+ _screen--;
1703
+ _showCurrentScreen();
1704
+ } else {
1705
+ setTimeout(() => navigationService?.switchTab('settings'), 0);
1706
+ }
1707
+ });
1708
+
1709
+ screen.key(['up'], () => {
1710
+ if (box.hidden) return;
1711
+ if (_screen === 0) {
1712
+ _langIdx = Math.max(0, _langIdx - 1);
1713
+ _renderScreen0();
1714
+ return;
1715
+ }
1716
+ });
1717
+
1718
+ screen.key(['left'], () => {
1719
+ if (box.hidden || _checking) return;
1720
+ if (_screen === 3) return; // Left handled by button nav
1721
+ if (_screen > 0) {
1722
+ _screen--;
1723
+ _showCurrentScreen();
1724
+ }
1725
+ });
1726
+
1727
+ screen.key(['right'], () => {
1728
+ if (box.hidden || _checking) return;
1729
+ if (_screen === 0) {
1730
+ if (languageService) languageService.setLang(SUPPORTED_LANGUAGES[_langIdx].value);
1731
+ _screen = 1;
1732
+ _showCurrentScreen();
1733
+ return;
1734
+ }
1735
+ if (_screen === 1) return; // Right handled by Continue button
1736
+ if (_screen === 2) { if (_screen < 3) { _screen++; _showCurrentScreen(); } return; }
1737
+ if (_screen === 3) return; // Right handled by button nav
1738
+ });
1739
+
1740
+ screen.key(['down'], () => {
1741
+ if (box.hidden) return;
1742
+ if (_screen === 0) {
1743
+ _langIdx = Math.min(SUPPORTED_LANGUAGES.length - 1, _langIdx + 1);
1744
+ _renderScreen0();
1745
+ return;
1746
+ }
1747
+ });
1748
+
1749
+ // =========================================================================
1750
+ // Tab Component Contract
1751
+ // =========================================================================
1752
+
1753
+ return {
1754
+ box,
1755
+
1756
+ show() {
1757
+ // If not first run, skip directly to Screen 3 (providers)
1758
+ if (!_isFirstRun()) {
1759
+ _screen = 3;
1760
+ } else {
1761
+ _screen = 0;
1762
+ _langIdx = 0;
1763
+ }
1764
+ _lastScreen = -1;
1765
+ providerView = 'list';
1766
+ box.show();
1767
+ _showCurrentScreen();
1768
+ screen.render();
1769
+ },
1770
+
1771
+ hide() {
1772
+ box.hide();
1773
+ hideAllProviderRows();
1774
+ infoBox.hide();
1775
+ providerView = 'list';
1776
+ screen.render();
1777
+ },
1778
+
1779
+ onFocus() {
1780
+ if (_screen === 0) {
1781
+ box.focus();
1782
+ } else if (_screen === 3) {
1783
+ if (providerView === 'list') {
1784
+ providerFocusIndex = 0;
1785
+ if (providerFocusableItems.length) providerFocusableItems[0].focus();
1786
+ } else {
1787
+ infoBox.focus();
1788
+ }
1789
+ } else {
1790
+ box.focus();
1791
+ }
1792
+ screen.render();
1793
+ },
1794
+
1795
+ onBlur() {},
1796
+
1797
+ getFooterText() {
1798
+ if (_screen === 3) {
1799
+ if (providerView === 'info') {
1800
+ return '[Esc] Back to list [Up/Down] Scroll';
1801
+ }
1802
+ return '[Enter] Action [Tab] Next button [Esc] Tab bar';
1803
+ }
1804
+ return _tl('footerText');
1805
+ },
1806
+
1807
+ getFooterColor() {
1808
+ return COLORS.footerBg;
1809
+ },
1810
+ };
1811
+ }