agentvibes 4.6.8 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.agentvibes/bmad-voice-map.json +104 -0
  2. package/.agentvibes/config.json +13 -12
  3. package/.agentvibes/copilot-sessions.log +4 -0
  4. package/.claude/audio/tracks/Drifting Down the Hall.mp3 +0 -0
  5. package/.claude/audio/tracks/Late Night Hip Hop Groove.mp3 +0 -0
  6. package/.claude/audio/tracks/Midnight Charleston Stomp.mp3 +0 -0
  7. package/.claude/audio/tracks/README.md +51 -52
  8. package/.claude/config/audio-effects-bmad.cfg +50 -0
  9. package/.claude/config/audio-effects.cfg +4 -4
  10. package/.claude/config/background-music-enabled.txt +1 -0
  11. package/.claude/config/personality.txt +1 -0
  12. package/.claude/hooks/play-tts-piper.sh +3 -1
  13. package/.claude/hooks/play-tts.sh +380 -301
  14. package/.claude/hooks/session-start-tts.sh +81 -81
  15. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  16. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  17. package/.claude/hooks-windows/play-tts.ps1 +28 -6
  18. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  19. package/README.md +112 -6
  20. package/RELEASE_NOTES.md +83 -0
  21. package/bin/bmad-speak.js +16 -8
  22. package/mcp-server/server.py +15 -8
  23. package/package.json +1 -1
  24. package/src/console/app.js +899 -897
  25. package/src/console/footer-config.js +50 -50
  26. package/src/console/navigation.js +65 -65
  27. package/src/console/tabs/agents-tab.js +1899 -1886
  28. package/src/console/tabs/music-tab.js +1076 -1039
  29. package/src/console/tabs/placeholder-tab.js +81 -80
  30. package/src/console/tabs/settings-tab.js +941 -3988
  31. package/src/console/tabs/setup-tab.js +2071 -0
  32. package/src/console/tabs/voices-tab.js +1843 -1714
  33. package/src/console/widgets/format-utils.js +92 -89
  34. package/src/console/widgets/track-picker.js +325 -322
  35. package/src/installer.js +6147 -6092
  36. package/src/services/llm-provider-service.js +486 -0
  37. package/src/services/navigation-service.js +123 -123
  38. package/src/services/tts-engine-service.js +69 -0
  39. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  40. package/src/console/tabs/install-tab.js +0 -1081
@@ -1,897 +1,899 @@
1
- /**
2
- * AgentVibes TUI Console — App Scaffold
3
- * Story 6.1: Blessed.js App Scaffold & Screen Setup
4
- * Story 6.2: Tab Bar & Global Keyboard Navigation
5
- *
6
- * Foundational screen: header, tab bar, content area, footer, navigation.
7
- * Stories 6.3+ build on top of this scaffold.
8
- */
9
-
10
- import blessed from 'blessed';
11
- import path from 'node:path';
12
- import { readFileSync } from 'node:fs';
13
- import { fileURLToPath } from 'node:url';
14
- import { spawnSync, execFileSync } from 'node:child_process';
15
- import { NavigationService, TAB_ORDER } from '../services/navigation-service.js';
16
- import { setupNavigation } from './navigation.js';
17
- import { createPlaceholderTab, TAB_DISPLAY_LABELS, TAB_SHORTCUT_KEYS, getTabLabel } from './tabs/placeholder-tab.js';
18
- import { LanguageService } from '../services/language-service.js';
19
- import { t } from '../i18n/strings.js';
20
- import { FOOTER_CONFIG, DEFAULT_FOOTER_COLOR } from './footer-config.js';
21
- import { createModalOverlay } from './modals/modal-overlay.js';
22
- import { BRAND_PINK } from './brand-colors.js';
23
- import { createSettingsTab } from './tabs/settings-tab.js';
24
- import { createVoicesTab } from './tabs/voices-tab.js';
25
- import { createMusicTab } from './tabs/music-tab.js';
26
- import { createInstallTab } from './tabs/install-tab.js';
27
- import { createHelpTab } from './tabs/help-tab.js';
28
- import { createReadmeTab } from './tabs/readme-tab.js';
29
- import { createReceiverTab } from './tabs/receiver-tab.js';
30
- import { createAgentsTab } from './tabs/agents-tab.js';
31
- import { ConfigService } from '../services/config-service.js';
32
- import { ProviderService } from '../services/provider-service.js';
33
-
34
- const _dir = path.dirname(fileURLToPath(import.meta.url));
35
- const _pkg = JSON.parse(readFileSync(path.join(_dir, '../../package.json'), 'utf8'));
36
- const APP_VERSION = _pkg.version;
37
-
38
- // Brand colours — consistent with UX design plan and architecture.md
39
- const COLORS = {
40
- headerBg: '#1a237e', // Dark navy — header and footer
41
- tabBarBg: '#263238', // Dark blue-gray — tab bar
42
- contentBg: '#0a0e1a', // Near-black — content area background
43
- focusCyan: 'bright-cyan', // Matches "Agent" in header title
44
- activeTab: '#3949ab', // Blue — active tab highlight
45
- textWhite: 'white',
46
- textDim: '#90a4ae', // Gray — placeholder / dim text
47
- };
48
-
49
- export class AgentVibesConsole {
50
- constructor(opts = {}) {
51
- // opts.startTab is stored for use by story 6.5 (command routing)
52
- this.startTab = opts.startTab ?? 'settings';
53
- this._testMode = opts._testMode ?? false;
54
-
55
- this.screen = null;
56
- this.tabBarBox = null; // Exposed for story 6.2 (tab bar implementation)
57
- this.contentArea = null; // Exposed for story 6.2 (tab mounting)
58
- this.navigationService = null; // Exposed for story 6.3+ (context footer, etc.)
59
- this.tabs = {}; // { settings: BlessedBox, voices: BlessedBox, ... }
60
- this.contextFooterBox = null; // Exposed for story 6.3 (color-coded context footer)
61
- this.modalOverlay = null; // Exposed for story 6.4 (reusable modal overlay)
62
- }
63
-
64
- /**
65
- * Initialise all screen components and register event handlers.
66
- * Returns `this` so callers can access the instance after launch.
67
- */
68
- async init() {
69
- this._createScreen();
70
-
71
- // In test mode, skip blessed widget creation (widgets require an active screen)
72
- if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
73
- // Provide stub objects so callers can verify properties exist
74
- this.tabBarBox = {};
75
- this.contentArea = {};
76
- this.contextFooterBox = {};
77
- this.navigationService = new NavigationService(this.startTab);
78
- this.tabs = {};
79
- return this;
80
- }
81
-
82
- this._createHeader();
83
- this._createTabBar();
84
- this._createContentArea();
85
- this._createContextFooter();
86
- this._createFooter();
87
- this._registerHandlers();
88
- this._createPlaceholderTabs();
89
- this._initNavigation(); // must run first so navigationService is live in services
90
- this._createRealTabs();
91
- this._createModalOverlay();
92
- // Initial render: draws header/tab-bar/footer into blessed's line buffer
93
- // before forceActivate fires. Without this, lines[0..1] (header rows) are
94
- // uninitialized when clearRegion() runs inside onSwitch, so blessed's draw()
95
- // skips them (not dirty) and the header is invisible on first load.
96
- this.screen.render();
97
- // Force-activate the start tab: switchTab() no-ops when _activeTab is already
98
- // set by the NavigationService constructor, so forceActivate() bypasses the
99
- // same-tab guard to fire onSwitch callbacks and render the initial UI state.
100
- this.navigationService.forceActivate(this.startTab);
101
- this.screen.render();
102
- // Place cursor on the start tab's header item (purple = focused).
103
- // User presses ↓/Enter to descend into content, or ←/→ to pick a different tab.
104
- const startTabItem = this._tabItems?.[this.startTab];
105
- if (startTabItem) {
106
- startTabItem.focus();
107
- this.screen.render();
108
- }
109
- return this;
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // Private: Screen
114
-
115
- _createScreen() {
116
- // Screen options stored as property so tests can verify correct configuration
117
- // without needing to intercept the blessed.screen() call (ESM mock limitation).
118
- this._screenOptions = {
119
- smartCSR: true,
120
- mouse: true,
121
- fullUnicode: true,
122
- title: `AgentVibes v${APP_VERSION} TUI Console`,
123
- };
124
-
125
- // When AGENTVIBES_TEST_MODE is set, use a lightweight stub instead of a
126
- // real blessed screen. This prevents the event loop from blocking tests.
127
- if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
128
- this.screen = {
129
- append: () => {},
130
- key: () => {},
131
- on: () => {},
132
- render: () => {},
133
- destroy: () => {},
134
- };
135
- return;
136
- }
137
-
138
- this.screen = blessed.screen(this._screenOptions);
139
-
140
- // Reflow on terminal resize
141
- this.screen.on('resize', () => this.screen.render());
142
- }
143
-
144
- // ---------------------------------------------------------------------------
145
- // Private: Fixed header (rows 0-2)
146
-
147
- _createHeader() {
148
- const cwd = process.cwd();
149
-
150
- this.headerBox = blessed.box({
151
- top: 0,
152
- left: 0,
153
- width: '100%',
154
- height: 4,
155
- tags: false,
156
- wrap: false,
157
- scrollable: false,
158
- style: { fg: COLORS.textWhite, bg: COLORS.headerBg },
159
- });
160
- this.screen.append(this.headerBox);
161
-
162
- // Row 0: main title — explicit child avoids valign:middle redraw artifacts
163
- blessed.text({
164
- parent: this.headerBox,
165
- top: 0,
166
- left: 2,
167
- shrink: true,
168
- tags: true,
169
- content: `{bright-cyan-fg}Agent{/bright-cyan-fg}{${BRAND_PINK}-fg}Vibes{/${BRAND_PINK}-fg} {#90a4ae-fg}v{/#90a4ae-fg}{#ffff00-fg}${APP_VERSION}{/#ffff00-fg} \u2502 \uD83D\uDCC1 ${cwd}`,
170
- style: { bg: COLORS.headerBg },
171
- });
172
-
173
- // Row 1: subtitle
174
- this._headerSubtitleText = blessed.text({
175
- parent: this.headerBox,
176
- top: 1,
177
- left: 2,
178
- shrink: true,
179
- tags: true,
180
- content: `{green-fg}Customization Tool{/green-fg}`,
181
- style: { bg: COLORS.headerBg },
182
- });
183
-
184
- // Row 1: Quit shortcut — left-anchored after "Customization Tool" (18 chars at left:2)
185
- this._headerQuitText = blessed.text({
186
- parent: this.headerBox,
187
- top: 1,
188
- left: 22,
189
- shrink: true,
190
- tags: true,
191
- content: `{#ef9a9a-fg}[Q] Quit{/#ef9a9a-fg}`,
192
- style: { bg: COLORS.headerBg },
193
- });
194
-
195
- // Row 2: non-interactive mode hint — direct screen child (like tab items) so tags render correctly
196
- blessed.text({
197
- parent: this.screen,
198
- top: 2,
199
- left: 2,
200
- shrink: true,
201
- tags: true,
202
- content: `{white-fg}Skip this TUI?{/white-fg} {yellow-fg}npx agentvibes install --non-interactive{/yellow-fg}`,
203
- style: { bg: COLORS.headerBg },
204
- });
205
-
206
- // Row 2 (right): sponsor message
207
- blessed.text({
208
- parent: this.screen,
209
- top: 2,
210
- right: 2,
211
- shrink: true,
212
- tags: true,
213
- content: `{magenta-fg}\u2661{/magenta-fg} {white-fg}Sponsor this Developer{/white-fg} {magenta-fg}github.com/sponsors/paulpreibisch{/magenta-fg}`,
214
- style: { bg: COLORS.headerBg },
215
- });
216
-
217
- // Row 1 (right): Active settings summary [provider][voice][effects][music]
218
- this._headerStatusText = blessed.text({
219
- parent: this.headerBox,
220
- top: 1,
221
- right: 2,
222
- shrink: true,
223
- tags: true,
224
- content: '',
225
- style: { bg: COLORS.headerBg },
226
- });
227
-
228
- // Right-aligned: git remote + branch when available, else AgentVibes repo link
229
- let topRightContent = `{${BRAND_PINK}-fg}github.com/preibisch/agentvibes{/${BRAND_PINK}-fg}`;
230
- try {
231
- const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],
232
- { encoding: 'utf8', timeout: 2000, cwd });
233
- const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'],
234
- { encoding: 'utf8', timeout: 2000, cwd });
235
- if (branchResult.status === 0 && remoteResult.status === 0) {
236
- const branch = branchResult.stdout.trim();
237
- // Normalise SSH (git@github.com:user/repo.git) HTTPS, strip .git suffix
238
- const repoUrl = remoteResult.stdout.trim()
239
- .replace(/^git@([^:]+):/, 'https://$1/')
240
- .replace(/\.git$/, '');
241
- // Strip protocol for compact display: https://github.com/… → github.com/…
242
- const displayUrl = repoUrl.replace(/^https?:\/\//, '');
243
- topRightContent = `{${BRAND_PINK}-fg}${displayUrl}{/${BRAND_PINK}-fg} {#90a4ae-fg}\u2502{/#90a4ae-fg} {#90a4ae-fg}\u2387{/#90a4ae-fg} {bright-white-fg}${branch}{/bright-white-fg}`;
244
- }
245
- } catch {}
246
- blessed.text({
247
- parent: this.headerBox,
248
- top: 0,
249
- right: 2,
250
- shrink: true,
251
- tags: true,
252
- content: topRightContent,
253
- style: { bg: COLORS.headerBg },
254
- });
255
- }
256
-
257
- // ---------------------------------------------------------------------------
258
- // Private: Update header status summary [provider][voice][effects][music]
259
-
260
- _updateHeaderStatus() {
261
- if (!this._headerStatusText || !this._providerService || !this._configService) return;
262
- try {
263
- const provider = this._providerService.getActiveProvider() ?? 'piper';
264
- const rawVoice = this._providerService.getActiveVoiceId() ?? '';
265
- // Show speaker name for multi-speaker voices
266
- const msSep = rawVoice.indexOf('::');
267
- const voiceName = msSep >= 0 ? rawVoice.slice(msSep + 2) : rawVoice;
268
- // Truncate long names
269
- const voiceShort = voiceName.length > 18 ? voiceName.slice(0, 17) + '…' : voiceName;
270
-
271
- const cfg = this._configService.getConfig();
272
- const effects = cfg.effects ?? {};
273
- const reverb = effects.reverbPreset ?? 'light';
274
-
275
- const music = cfg.backgroundMusic ?? cfg.music ?? {};
276
- const musicEnabled = music.enabled ?? false;
277
- const trackFile = music.track ?? '';
278
- // Strip prefixes and suffixes for compact display
279
- const trackShort = trackFile
280
- .replace(/\.mp3$/i, '')
281
- .replace(/^agent_vibes_/i, '')
282
- .replace(/^agentvibes_/i, '')
283
- .replace(/_loop$/i, '')
284
- .replace(/_v\d+$/i, '')
285
- .replace(/_/g, ' ')
286
- .replace(/\b\w/g, c => c.toUpperCase())
287
- .slice(0, 16) || 'None';
288
-
289
- this._headerStatusText.setContent(
290
- `{#90a4ae-fg}[{/#90a4ae-fg}{bright-cyan-fg}${provider}{/bright-cyan-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
291
- `{#90a4ae-fg}[{/#90a4ae-fg}{green-fg}${voiceShort}{/green-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
292
- `{#90a4ae-fg}[{/#90a4ae-fg}{yellow-fg}${reverb}{/yellow-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
293
- `{#90a4ae-fg}[{/#90a4ae-fg}{${musicEnabled ? 'magenta' : 'bright-black'}-fg}${musicEnabled ? trackShort : 'off'}{/${musicEnabled ? 'magenta' : 'bright-black'}-fg}{#90a4ae-fg}]{/#90a4ae-fg}`
294
- );
295
- } catch { /* non-fatal */ }
296
- }
297
-
298
- // ---------------------------------------------------------------------------
299
- // Private: Tab bar (row 3) — individual child boxes, no tag parsing.
300
- // Each tab is a separate blessed.box. Active tab highlighted via style update.
301
-
302
- _createTabBar() {
303
- // Background strip — screen child so blessed uses absolute coordinates directly.
304
- // Tab items are ALSO screen children (not children of tabBarBox) to avoid the
305
- // WSL/Windows Terminal parent-relative positioning bug that renders them 1 row
306
- // too high (at row 3 instead of row 4), producing a ghost duplicate tab bar.
307
- this.tabBarBox = blessed.box({
308
- parent: this.screen,
309
- top: 4,
310
- left: 0,
311
- width: '100%',
312
- height: 1,
313
- style: { bg: COLORS.tabBarBg },
314
- });
315
-
316
- // One box per tab — direct screen children at absolute top:4. No tag parsing, no wrapping.
317
- this._tabItems = {};
318
- this._tabItemXOffsets = {}; // track x positions for label refresh
319
- let xOffset = 1;
320
- for (const id of TAB_ORDER) {
321
- const lang = this._languageService?.getLang() ?? 'en';
322
- const label = getTabLabel(id, lang);
323
- const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
324
- const text = ` [${shortcutKey}] ${label} `;
325
- const el = blessed.box({
326
- parent: this.screen,
327
- top: 4,
328
- left: xOffset,
329
- width: text.length,
330
- height: 1,
331
- content: text,
332
- tags: false,
333
- wrap: false,
334
- keys: true,
335
- focusable: true,
336
- style: { fg: COLORS.focusCyan, bg: COLORS.tabBarBg },
337
- });
338
- this._tabItems[id] = el;
339
- this._tabItemXOffsets[id] = xOffset;
340
- xOffset += text.length + 1; // 1-space gap between tabs
341
- }
342
-
343
- // Right-aligned Quit item — direct screen child at absolute top:4
344
- const _quitText = ' [Q] Quit ';
345
- const _quitBase = _quitText;
346
- const _quitBlock = _quitText.slice(0, -1) + '█';
347
- let _quitInterval = null;
348
- this._quitItem = blessed.box({
349
- parent: this.screen,
350
- top: 4,
351
- right: 1,
352
- width: _quitText.length,
353
- height: 1,
354
- content: _quitText,
355
- tags: false,
356
- keys: true,
357
- focusable: true,
358
- style: { fg: '#ef9a9a', bg: COLORS.tabBarBg }, // soft red — matches header quit hint
359
- });
360
- this._quitItem.on('focus', () => {
361
- this._quitItem.style.fg = 'white';
362
- this._quitItem.style.bg = '#9c27b0';
363
- this._quitItem.setContent(_quitBlock);
364
- this.screen.render();
365
- if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
366
- _quitInterval = setInterval(() => {
367
- const on = this._quitItem.content === _quitBlock;
368
- this._quitItem.setContent(on ? _quitBase : _quitBlock);
369
- this.screen.render();
370
- }, 500);
371
- });
372
- this._quitItem.on('blur', () => {
373
- if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
374
- this._quitItem.setContent(_quitBase);
375
- this._quitItem.style.fg = '#ef9a9a';
376
- this._quitItem.style.bg = COLORS.tabBarBg;
377
- this.screen.render();
378
- });
379
- this._quitItem.key(['enter', 'space', 'q', 'Q'], () => {
380
- this.screen.destroy();
381
- process.exit(0);
382
- });
383
-
384
- // Keyboard navigation on the main tab items
385
- const tabIds = TAB_ORDER;
386
- for (let i = 0; i < tabIds.length; i++) {
387
- const el = this._tabItems[tabIds[i]];
388
-
389
- // Blinking block cursor: replace trailing space with █, toggle at 500ms
390
- // Always derive from current el.content so language changes are preserved.
391
- const _getBaseContent = () => el.content.replace(/█$/, ' ');
392
- let _cursorInterval = null;
393
- let _cursorOn = false;
394
-
395
- el.on('focus', () => {
396
- el.style.fg = 'white';
397
- el.style.bg = '#9c27b0'; // purple — cursor on this tab item
398
- _cursorOn = true;
399
- const _base = _getBaseContent();
400
- const _block = _base.slice(0, -1) + '█';
401
- el.setContent(_block);
402
- this.screen.render();
403
- if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
404
- _cursorInterval = setInterval(() => {
405
- _cursorOn = !_cursorOn;
406
- const b = _getBaseContent();
407
- el.setContent(_cursorOn ? b.slice(0, -1) + '█' : b);
408
- this.screen.render();
409
- }, 500);
410
- });
411
- el.on('blur', () => {
412
- if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
413
- el.setContent(_getBaseContent());
414
- // navigationService set up after _createTabBar, but blur fires lazily — safe
415
- this._updateTabBar(this.navigationService?.getActiveTab() ?? tabIds[0]);
416
- this.screen.render();
417
- });
418
-
419
- el.key(['left'], () => {
420
- if (i === 0) {
421
- this._quitItem?.focus(); // wrap: first tab ← → Quit
422
- } else {
423
- this._tabItems[tabIds[i - 1]].focus();
424
- }
425
- });
426
- el.key(['right'], () => {
427
- if (i === tabIds.length - 1) {
428
- this._quitItem?.focus(); // wrap: last tab → → Quit
429
- } else {
430
- this._tabItems[tabIds[i + 1]].focus();
431
- }
432
- });
433
- el.key(['enter', 'space'], () => {
434
- this.navigationService.switchTab(tabIds[i]);
435
- });
436
- // ↓ or Escape returns focus to the active tab's content
437
- el.key(['down', 'escape'], () => {
438
- const activeTab = this.tabs[this.navigationService.getActiveTab()];
439
- if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
440
- });
441
-
442
- // Tab: forward through header items; last item → Quit item
443
- el.key(['tab'], () => {
444
- if (i < tabIds.length - 1) {
445
- this._tabItems[tabIds[i + 1]].focus();
446
- } else {
447
- this._quitItem?.focus();
448
- }
449
- });
450
- // S-tab: backward through header items; first item → active tab's last bottom button
451
- el.key(['S-tab'], () => {
452
- if (i > 0) {
453
- this._tabItems[tabIds[i - 1]].focus();
454
- } else {
455
- const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
456
- if (activeTab && typeof activeTab.focusLastBottomRow === 'function') {
457
- activeTab.focusLastBottomRow();
458
- } else {
459
- this._quitItem?.focus();
460
- }
461
- }
462
- });
463
- }
464
-
465
- // Wire Quit item ← → and Tab/S-tab into the header navigation cycle
466
- this._quitItem.key(['left'], () => {
467
- this._tabItems[tabIds[tabIds.length - 1]]?.focus(); //Quit last tab (Help)
468
- });
469
- this._quitItem.key(['right'], () => {
470
- this._tabItems[tabIds[0]]?.focus(); // → Quit → first tab (Install), wrap
471
- });
472
- this._quitItem.key(['tab'], () => {
473
- const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
474
- if (activeTab && typeof activeTab.focusBottomRow === 'function') {
475
- activeTab.focusBottomRow();
476
- } else {
477
- this._tabItems[tabIds[0]]?.focus();
478
- }
479
- });
480
- this._quitItem.key(['S-tab'], () => {
481
- this._tabItems[tabIds[tabIds.length - 1]]?.focus();
482
- });
483
- this._quitItem.key(['down', 'escape'], () => {
484
- const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
485
- if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
486
- });
487
- }
488
-
489
- // ---------------------------------------------------------------------------
490
- // Private: Update tab bar — set active item style, reset all others.
491
-
492
- _updateTabBar(activeTabId) {
493
- if (!this._tabItems) return; // guard: not initialized in test mode
494
- for (const [id, el] of Object.entries(this._tabItems)) {
495
- if (id === activeTabId) {
496
- el.style.fg = 'white';
497
- el.style.bg = '#0288d1'; // bright light blue — matches sub-tab active color
498
- el.style.bold = true;
499
- } else {
500
- el.style.fg = COLORS.focusCyan;
501
- el.style.bg = COLORS.tabBarBg;
502
- el.style.bold = false;
503
- }
504
- }
505
- }
506
-
507
- // ---------------------------------------------------------------------------
508
- // Private: Refresh all chrome strings (header subtitle, tab bar labels) when lang changes
509
-
510
- _refreshChrome(lang) {
511
- // Update header subtitle "Customization Tool"
512
- if (this._headerSubtitleText) {
513
- this._headerSubtitleText.setContent(`{green-fg}${t(lang, 'customizationTool')}{/green-fg}`);
514
- }
515
- if (this._headerQuitText) {
516
- this._headerQuitText.setContent(`{#ef9a9a-fg}${t(lang, 'quitLabel')}{/#ef9a9a-fg}`);
517
- }
518
-
519
- // Update tab bar item labels — resize and reposition to fit translated labels
520
- let xOffset = 1;
521
- for (const id of TAB_ORDER) {
522
- const el = this._tabItems?.[id];
523
- if (!el) continue;
524
- const label = getTabLabel(id, lang);
525
- const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
526
- const text = ` [${shortcutKey}] ${label} `;
527
- el.left = xOffset;
528
- el.width = text.length;
529
- el.setContent(text);
530
- xOffset += text.length + 1;
531
- }
532
-
533
- // Update active tab's footer text if it supports language-aware footer
534
- const activeId = this.navigationService?.getActiveTab();
535
- if (activeId) this._updateContextFooter(activeId);
536
-
537
- this.screen.render();
538
- }
539
-
540
- // ---------------------------------------------------------------------------
541
- // Private: Render tab bar content string for given active tab
542
- // (kept as a pure helper for unit tests; real rendering uses _updateTabBar)
543
-
544
- _renderTabBarContent(activeTabId) {
545
- const lang = this._languageService?.getLang() ?? 'en';
546
- return TAB_ORDER.map(id => {
547
- const label = getTabLabel(id, lang);
548
- const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
549
- if (id === activeTabId) {
550
- return `{bold}{white-fg}[${shortcutKey}] ${label}{/white-fg}{/bold}`;
551
- }
552
- return `{bright-cyan-fg}[${shortcutKey}] ${label}{/bright-cyan-fg}`;
553
- }).join(' ');
554
- }
555
-
556
- // ---------------------------------------------------------------------------
557
- // Private: Content area (rows 5..N-1) — tab components mount here
558
-
559
- _createContentArea() {
560
- // bottom: 2 reserves 2 rows at the bottom: context footer (story 6.3) + GitHub footer
561
- this.contentArea = blessed.box({
562
- top: 5,
563
- left: 0,
564
- width: '100%',
565
- bottom: 2,
566
- border: { type: 'line' },
567
- style: {
568
- fg: COLORS.textWhite,
569
- bg: COLORS.contentBg,
570
- border: { fg: COLORS.activeTab },
571
- },
572
- });
573
-
574
- this.screen.append(this.contentArea);
575
- }
576
-
577
- // ---------------------------------------------------------------------------
578
- // Private: Color-coded context footer (story 6.3) — above GitHub footer
579
-
580
- _createContextFooter() {
581
- this.contextFooterBox = blessed.box({
582
- bottom: 1,
583
- left: 0,
584
- width: '100%',
585
- height: 1,
586
- content: '',
587
- tags: true,
588
- style: {
589
- fg: COLORS.textWhite,
590
- bg: DEFAULT_FOOTER_COLOR,
591
- },
592
- });
593
-
594
- this.screen.append(this.contextFooterBox);
595
- }
596
-
597
- // ---------------------------------------------------------------------------
598
- // Private: Update context footer color + text for the given tab
599
-
600
- _updateContextFooter(tabId) {
601
- // Real tab components (Tab Component Contract) provide their own footer getters.
602
- // Placeholder tabs fall back to FOOTER_CONFIG.
603
- const tab = this.tabs[tabId];
604
- if (tab && typeof tab.getFooterColor === 'function') {
605
- this.contextFooterBox.style.bg = tab.getFooterColor();
606
- this.contextFooterBox.setContent(tab.getFooterText());
607
- } else {
608
- const config = FOOTER_CONFIG[tabId] ?? { color: DEFAULT_FOOTER_COLOR, text: '' };
609
- this.contextFooterBox.style.bg = config.color;
610
- this.contextFooterBox.setContent(config.text);
611
- }
612
- }
613
-
614
- // ---------------------------------------------------------------------------
615
- // Private: GitHub star footer (row N — fixed bottom)
616
-
617
- _createFooter() {
618
- // Detect installed providers inline (same logic as ProviderService)
619
- const _has = (bin) => {
620
- try { execFileSync('which', [bin], { stdio: 'ignore', timeout: 2000 }); return true; }
621
- catch { return false; }
622
- };
623
- const detected = {
624
- piper: _has('piper'),
625
- soprano: _has('soprano'),
626
- sapi: process.platform === 'win32',
627
- macos: process.platform === 'darwin' && _has('say'),
628
- };
629
-
630
- // Build provider status badges: ● Name (green if detected, grey if not)
631
- const on = (label) => `{green-fg}●{/green-fg} ${label}`;
632
- const off = (label) => `{#546e7a-fg}● ${label}{/#546e7a-fg}`;
633
- const badges = [
634
- detected.piper ? on('Piper') : off('Piper'),
635
- detected.soprano ? on('Soprano') : off('Soprano'),
636
- detected.sapi ? on('Windows SAPI') : off('Windows SAPI'),
637
- detected.macos ? on('Mac Say') : off('Mac Say'),
638
- ].join(' ');
639
-
640
- const footer = blessed.box({
641
- bottom: 0,
642
- left: 0,
643
- width: '100%',
644
- height: 1,
645
- tags: true,
646
- content: ` ${badges} {#ffff00-fg}⭐ Love AgentVibes? Give us a star!{/#ffff00-fg} github.com/preibisch/agentvibes`,
647
- style: {
648
- fg: COLORS.textWhite,
649
- bg: COLORS.headerBg,
650
- },
651
- });
652
-
653
- this.screen.append(footer);
654
- }
655
-
656
- // ---------------------------------------------------------------------------
657
- // Private: Create placeholder tab content boxes (story 6.2)
658
- // Each epic 7-11 story will replace its placeholder with real content.
659
-
660
- _createPlaceholderTabs() {
661
- for (const tabId of TAB_ORDER) {
662
- const label = TAB_DISPLAY_LABELS[tabId];
663
- this.tabs[tabId] = createPlaceholderTab(this.contentArea, label);
664
- }
665
- }
666
-
667
- // ---------------------------------------------------------------------------
668
- // Private: Replace placeholder tabs with real implementations (story 7.1+)
669
-
670
- _createRealTabs() {
671
- // Destroy the settings placeholder (real tab mounts directly to screen, not contentArea)
672
- const placeholder = this.tabs['settings'];
673
- if (placeholder && typeof placeholder.destroy === 'function') {
674
- placeholder.destroy();
675
- }
676
-
677
- const configService = new ConfigService();
678
- const providerService = new ProviderService(configService);
679
- this._configService = configService;
680
- this._providerService = providerService;
681
- const languageService = new LanguageService();
682
- this._languageService = languageService;
683
- // Refresh UI chrome when language changes
684
- languageService.onChange(lang => this._refreshChrome(lang));
685
- const services = {
686
- configService,
687
- providerService,
688
- languageService,
689
- navigationService: this.navigationService,
690
- updateHeaderStatus: () => this._updateHeaderStatus(),
691
- focusMainTabBar: () => {
692
- const id = this.navigationService.getActiveTab();
693
- const item = this._tabItems?.[id];
694
- if (item) item.focus();
695
- },
696
- focusFirstHeaderItem: () => {
697
- this._tabItems?.[TAB_ORDER[0]]?.focus();
698
- },
699
- focusLastHeaderItem: () => {
700
- this._tabItems?.[TAB_ORDER[TAB_ORDER.length - 1]]?.focus();
701
- },
702
- };
703
- this.tabs['settings'] = createSettingsTab(this.screen, services);
704
-
705
- // Destroy voices placeholder and mount real voices tab
706
- const voicesPlaceholder = this.tabs['voices'];
707
- if (voicesPlaceholder && typeof voicesPlaceholder.destroy === 'function') {
708
- voicesPlaceholder.destroy();
709
- }
710
- this.tabs['voices'] = createVoicesTab(this.screen, services);
711
-
712
- // Destroy music placeholder and mount real music tab
713
- const musicPlaceholder = this.tabs['music'];
714
- if (musicPlaceholder && typeof musicPlaceholder.destroy === 'function') {
715
- musicPlaceholder.destroy();
716
- }
717
- this.tabs['music'] = createMusicTab(this.screen, services);
718
-
719
- // Destroy install placeholder and mount real install wizard
720
- const installPlaceholder = this.tabs['install'];
721
- if (installPlaceholder && typeof installPlaceholder.destroy === 'function') {
722
- installPlaceholder.destroy();
723
- }
724
- this.tabs['install'] = createInstallTab(this.screen, services);
725
-
726
- // Destroy help/readme placeholders and mount real tabs
727
- const helpPlaceholder = this.tabs['help'];
728
- if (helpPlaceholder && typeof helpPlaceholder.destroy === 'function') {
729
- helpPlaceholder.destroy();
730
- }
731
- this.tabs['help'] = createHelpTab(this.screen, services);
732
-
733
- // Destroy agents placeholder and mount real agents tab
734
- const agentsPlaceholder = this.tabs['agents'];
735
- if (agentsPlaceholder && typeof agentsPlaceholder.destroy === 'function') {
736
- agentsPlaceholder.destroy();
737
- }
738
- this.tabs['agents'] = createAgentsTab(this.screen, services);
739
-
740
- // Destroy receiver placeholder and mount real receiver tab
741
- const receiverPlaceholder = this.tabs['receiver'];
742
- if (receiverPlaceholder && typeof receiverPlaceholder.destroy === 'function') {
743
- receiverPlaceholder.destroy();
744
- }
745
- this.tabs['receiver'] = createReceiverTab(this.screen, services);
746
-
747
- const readmePlaceholder = this.tabs['readme'];
748
- if (readmePlaceholder && typeof readmePlaceholder.destroy === 'function') {
749
- readmePlaceholder.destroy();
750
- }
751
- this.tabs['readme'] = createReadmeTab(this.screen, services);
752
- }
753
-
754
- // ---------------------------------------------------------------------------
755
- // Private: Initialise navigation service and wire key handlers (story 6.2)
756
-
757
- _initNavigation() {
758
- this.navigationService = new NavigationService(this.startTab);
759
-
760
- // On every tab switch: update tab bar, context footer, and show/hide tab boxes
761
- this.navigationService.onSwitch(tabId => {
762
- const activeTab = this.tabs[tabId];
763
-
764
- // Render-suppression tab switch:
765
- // All show/hide calls and UI updates happen inside this window so zero
766
- // intermediate frames are sent to the terminal. A single clean render
767
- // fires at the end, when exactly one tab is visible.
768
- const _origRender = this.screen.render.bind(this.screen);
769
- this.screen.render = () => {};
770
-
771
- try {
772
- // Nuclear clear: wipe the content area (row 5+) to remove stale cell content
773
- // from the previous tab. Start at row 5 — header (0-3) and tab bar (4) are
774
- // static widgets that don't need clearing; wiping them causes the double
775
- // tab bar artifact (row 3 of header shows tab bar ghost from prior render).
776
- // blessed's render loop never resets the `lines` buffer before rendering
777
- // (see: blessed/lib/widgets/screen.js line 733, commented-out clear).
778
- this.screen.clearRegion(0, this.screen.cols, 5, this.screen.rows - 2);
779
-
780
- // Force-invalidate olines for the entire visible area (rows 0..rows-3).
781
- // Includes header rows 0-3 so the branded header is always redrawn on
782
- // tab switches prevents corruption from persisting across tabs.
783
- // Row 3 (header bottom), row 4 (tab bar) and content rows accumulate
784
- // ghost rendering artifacts draw() skips them when lines==olines even
785
- // though the terminal still shows stale chars from earlier renders.
786
- // Setting attr=-1 is impossible for any real cell, so draw() is forced
787
- // to physically rewrite every cell on the next render call.
788
- for (let r = 0; r < this.screen.rows - 2; r++) {
789
- const orow = this.screen.olines[r];
790
- if (!orow) continue;
791
- for (let c = 0; c < this.screen.cols; c++) {
792
- if (orow[c]) orow[c][0] = -1; // impossible attr — forces draw() rewrite
793
- }
794
- orow.dirty = true;
795
- }
796
-
797
- // Row 3 (header bottom) is never dirty after draw 1 — its content (headerBg+
798
- // spaces) never changes so element.render() never marks it dirty. The olines
799
- // invalidation above sets olines[3][c][0]=-1, but draw() only compares cells
800
- // when lines[r].dirty is true; a false dirty flag skips the entire row without
801
- // ever consulting olines. Force-mark it dirty so draw() emits the explicit
802
- // cup(4,1)+headerBg+spaces sequence and overwrites any ghost terminal content.
803
- if (this.screen.lines?.[3]) this.screen.lines[3].dirty = true;
804
-
805
- // Update tab bar, footer, and header status inside suppression — no intermediate render.
806
- this._updateTabBar(tabId);
807
- this._updateContextFooter(tabId);
808
- this._updateHeaderStatus();
809
-
810
- // Hide all inactive tabs via their proper hide() method so side-effects
811
- // (e.g. voice preview kill, previewLine clear) run correctly.
812
- for (const [id, tab] of Object.entries(this.tabs)) {
813
- if (id !== tabId) {
814
- if (typeof tab.hide === 'function') tab.hide();
815
- else tab.hidden = true;
816
- if (typeof tab.onBlur === 'function') tab.onBlur();
817
- }
818
- }
819
-
820
- // Show the active tab via show() so refreshDisplay() populates labels.
821
- if (activeTab) {
822
- if (typeof activeTab.show === 'function') {
823
- activeTab.show();
824
- // setFront() moves the box to the end of screen.children so it
825
- // paints last (on top) — belt-and-suspenders against any z-order issue.
826
- if (activeTab.box && typeof activeTab.box.setFront === 'function') {
827
- activeTab.box.setFront();
828
- }
829
- // Move any screen-level overlay widgets (e.g. junction chars) to front
830
- // AFTER box.setFront() so they render on top of the box border.
831
- if (typeof activeTab.moveOverlaysToFront === 'function') {
832
- activeTab.moveOverlaysToFront();
833
- }
834
- } else {
835
- activeTab.hidden = false;
836
- }
837
- }
838
- } finally {
839
- // Always restore render even if something throws.
840
- this.screen.render = _origRender;
841
- }
842
-
843
- if (activeTab && typeof activeTab.onFocus === 'function') {
844
- activeTab.onFocus();
845
- }
846
- this.screen.render();
847
- });
848
-
849
- // Register global key bindings (S/V/M/A/R/H/I/T/Esc)
850
- setupNavigation(this.screen, this.navigationService, () => {
851
- const id = this.navigationService.getActiveTab();
852
- const item = this._tabItems?.[id];
853
- if (item) item.focus();
854
- });
855
- }
856
-
857
- // ---------------------------------------------------------------------------
858
- // Private: Modal overlay (story 6.4) — reusable base for all selector modals
859
-
860
- _createModalOverlay() {
861
- this.modalOverlay = createModalOverlay(this.screen, this.navigationService);
862
-
863
- // Esc key closes the modal overlay if one is open.
864
- // Blessed.js allows multiple handlers for the same key — all fire.
865
- // The navigation.js Esc handler calls nav.closeModal() (state only).
866
- // This second handler hides the overlay+container widgets.
867
- this.screen.key(['escape'], () => {
868
- if (this.modalOverlay) this.modalOverlay.close();
869
- });
870
- }
871
-
872
- // ---------------------------------------------------------------------------
873
- // Private: Global keyboard handlers
874
-
875
- _registerHandlers() {
876
- // Q or Ctrl+C → clean exit (no zombie processes)
877
- this.screen.key(['q', 'Q', 'C-c'], () => {
878
- this.screen.destroy();
879
- process.exit(0);
880
- });
881
- }
882
- }
883
-
884
- /**
885
- * Launch the AgentVibes TUI console.
886
- *
887
- * @param {object} opts
888
- * @param {string} [opts.startTab='settings'] - Which tab to show on launch.
889
- * Used by story 6.5 (command routing). Values: 'settings' | 'install' | 'voices' | 'music'
890
- * @param {boolean} [opts._testMode=false] - Internal: skip render in test environments.
891
- * @returns {Promise<AgentVibesConsole>}
892
- */
893
- export async function launchConsole(opts = {}) {
894
- const app = new AgentVibesConsole(opts);
895
- await app.init();
896
- return app;
897
- }
1
+ /**
2
+ * AgentVibes TUI Console — App Scaffold
3
+ * Story 6.1: Blessed.js App Scaffold & Screen Setup
4
+ * Story 6.2: Tab Bar & Global Keyboard Navigation
5
+ *
6
+ * Foundational screen: header, tab bar, content area, footer, navigation.
7
+ * Stories 6.3+ build on top of this scaffold.
8
+ */
9
+
10
+ import blessed from 'blessed';
11
+ import path from 'node:path';
12
+ import { readFileSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { spawnSync, execFileSync } from 'node:child_process';
15
+ import { NavigationService, TAB_ORDER } from '../services/navigation-service.js';
16
+ import { setupNavigation } from './navigation.js';
17
+ import { createPlaceholderTab, TAB_DISPLAY_LABELS, TAB_SHORTCUT_KEYS, getTabLabel } from './tabs/placeholder-tab.js';
18
+ import { LanguageService } from '../services/language-service.js';
19
+ import { t } from '../i18n/strings.js';
20
+ import { FOOTER_CONFIG, DEFAULT_FOOTER_COLOR } from './footer-config.js';
21
+ import { createModalOverlay } from './modals/modal-overlay.js';
22
+ import { BRAND_PINK } from './brand-colors.js';
23
+ import { createSettingsTab } from './tabs/settings-tab.js';
24
+ import { createVoicesTab } from './tabs/voices-tab.js';
25
+ import { createMusicTab } from './tabs/music-tab.js';
26
+ import { createSetupTab } from './tabs/setup-tab.js';
27
+ import { createHelpTab } from './tabs/help-tab.js';
28
+ import { createReadmeTab } from './tabs/readme-tab.js';
29
+ import { createReceiverTab } from './tabs/receiver-tab.js';
30
+ import { createAgentsTab } from './tabs/agents-tab.js';
31
+ import { ConfigService } from '../services/config-service.js';
32
+ import { ProviderService } from '../services/provider-service.js';
33
+
34
+ const _dir = path.dirname(fileURLToPath(import.meta.url));
35
+ const _pkg = JSON.parse(readFileSync(path.join(_dir, '../../package.json'), 'utf8'));
36
+ const APP_VERSION = _pkg.version;
37
+
38
+ // Brand colours — consistent with UX design plan and architecture.md
39
+ const COLORS = {
40
+ headerBg: '#1a237e', // Dark navy — header and footer
41
+ tabBarBg: '#263238', // Dark blue-gray — tab bar
42
+ contentBg: '#0a0e1a', // Near-black — content area background
43
+ focusCyan: 'bright-cyan', // Matches "Agent" in header title
44
+ activeTab: '#3949ab', // Blue — active tab highlight
45
+ textWhite: 'white',
46
+ textDim: '#90a4ae', // Gray — placeholder / dim text
47
+ };
48
+
49
+ export class AgentVibesConsole {
50
+ constructor(opts = {}) {
51
+ // opts.startTab is stored for use by story 6.5 (command routing)
52
+ // Map legacy 'install' tab ID to 'setup' for backward compat
53
+ const rawTab = opts.startTab ?? 'settings';
54
+ this.startTab = rawTab === 'install' ? 'setup' : rawTab;
55
+ this._testMode = opts._testMode ?? false;
56
+
57
+ this.screen = null;
58
+ this.tabBarBox = null; // Exposed for story 6.2 (tab bar implementation)
59
+ this.contentArea = null; // Exposed for story 6.2 (tab mounting)
60
+ this.navigationService = null; // Exposed for story 6.3+ (context footer, etc.)
61
+ this.tabs = {}; // { settings: BlessedBox, voices: BlessedBox, ... }
62
+ this.contextFooterBox = null; // Exposed for story 6.3 (color-coded context footer)
63
+ this.modalOverlay = null; // Exposed for story 6.4 (reusable modal overlay)
64
+ }
65
+
66
+ /**
67
+ * Initialise all screen components and register event handlers.
68
+ * Returns `this` so callers can access the instance after launch.
69
+ */
70
+ async init() {
71
+ this._createScreen();
72
+
73
+ // In test mode, skip blessed widget creation (widgets require an active screen)
74
+ if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
75
+ // Provide stub objects so callers can verify properties exist
76
+ this.tabBarBox = {};
77
+ this.contentArea = {};
78
+ this.contextFooterBox = {};
79
+ this.navigationService = new NavigationService(this.startTab);
80
+ this.tabs = {};
81
+ return this;
82
+ }
83
+
84
+ this._createHeader();
85
+ this._createTabBar();
86
+ this._createContentArea();
87
+ this._createContextFooter();
88
+ this._createFooter();
89
+ this._registerHandlers();
90
+ this._createPlaceholderTabs();
91
+ this._initNavigation(); // must run first so navigationService is live in services
92
+ this._createRealTabs();
93
+ this._createModalOverlay();
94
+ // Initial render: draws header/tab-bar/footer into blessed's line buffer
95
+ // before forceActivate fires. Without this, lines[0..1] (header rows) are
96
+ // uninitialized when clearRegion() runs inside onSwitch, so blessed's draw()
97
+ // skips them (not dirty) and the header is invisible on first load.
98
+ this.screen.render();
99
+ // Force-activate the start tab: switchTab() no-ops when _activeTab is already
100
+ // set by the NavigationService constructor, so forceActivate() bypasses the
101
+ // same-tab guard to fire onSwitch callbacks and render the initial UI state.
102
+ this.navigationService.forceActivate(this.startTab);
103
+ this.screen.render();
104
+ // Place cursor on the start tab's header item (purple = focused).
105
+ // User presses ↓/Enter to descend into content, or ←/→ to pick a different tab.
106
+ const startTabItem = this._tabItems?.[this.startTab];
107
+ if (startTabItem) {
108
+ startTabItem.focus();
109
+ this.screen.render();
110
+ }
111
+ return this;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Private: Screen
116
+
117
+ _createScreen() {
118
+ // Screen options stored as property so tests can verify correct configuration
119
+ // without needing to intercept the blessed.screen() call (ESM mock limitation).
120
+ this._screenOptions = {
121
+ smartCSR: true,
122
+ mouse: true,
123
+ fullUnicode: true,
124
+ title: `AgentVibes v${APP_VERSION} TUI Console`,
125
+ };
126
+
127
+ // When AGENTVIBES_TEST_MODE is set, use a lightweight stub instead of a
128
+ // real blessed screen. This prevents the event loop from blocking tests.
129
+ if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
130
+ this.screen = {
131
+ append: () => {},
132
+ key: () => {},
133
+ on: () => {},
134
+ render: () => {},
135
+ destroy: () => {},
136
+ };
137
+ return;
138
+ }
139
+
140
+ this.screen = blessed.screen(this._screenOptions);
141
+
142
+ // Reflow on terminal resize
143
+ this.screen.on('resize', () => this.screen.render());
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Private: Fixed header (rows 0-2)
148
+
149
+ _createHeader() {
150
+ const cwd = process.cwd();
151
+
152
+ this.headerBox = blessed.box({
153
+ top: 0,
154
+ left: 0,
155
+ width: '100%',
156
+ height: 4,
157
+ tags: false,
158
+ wrap: false,
159
+ scrollable: false,
160
+ style: { fg: COLORS.textWhite, bg: COLORS.headerBg },
161
+ });
162
+ this.screen.append(this.headerBox);
163
+
164
+ // Row 0: main title — explicit child avoids valign:middle redraw artifacts
165
+ blessed.text({
166
+ parent: this.headerBox,
167
+ top: 0,
168
+ left: 2,
169
+ shrink: true,
170
+ tags: true,
171
+ content: `{bright-cyan-fg}Agent{/bright-cyan-fg}{${BRAND_PINK}-fg}Vibes{/${BRAND_PINK}-fg} {#90a4ae-fg}v{/#90a4ae-fg}{#ffff00-fg}${APP_VERSION}{/#ffff00-fg} \u2502 \uD83D\uDCC1 ${cwd}`,
172
+ style: { bg: COLORS.headerBg },
173
+ });
174
+
175
+ // Row 1: subtitle
176
+ this._headerSubtitleText = blessed.text({
177
+ parent: this.headerBox,
178
+ top: 1,
179
+ left: 2,
180
+ shrink: true,
181
+ tags: true,
182
+ content: `{green-fg}Customization Tool{/green-fg}`,
183
+ style: { bg: COLORS.headerBg },
184
+ });
185
+
186
+ // Row 1: Quit shortcut — left-anchored after "Customization Tool" (18 chars at left:2)
187
+ this._headerQuitText = blessed.text({
188
+ parent: this.headerBox,
189
+ top: 1,
190
+ left: 22,
191
+ shrink: true,
192
+ tags: true,
193
+ content: `{#ef9a9a-fg}[Q] Quit{/#ef9a9a-fg}`,
194
+ style: { bg: COLORS.headerBg },
195
+ });
196
+
197
+ // Row 2: non-interactive mode hint — direct screen child (like tab items) so tags render correctly
198
+ blessed.text({
199
+ parent: this.screen,
200
+ top: 2,
201
+ left: 2,
202
+ shrink: true,
203
+ tags: true,
204
+ content: `{white-fg}Skip this TUI?{/white-fg} {yellow-fg}npx agentvibes install --non-interactive{/yellow-fg}`,
205
+ style: { bg: COLORS.headerBg },
206
+ });
207
+
208
+ // Row 2 (right): sponsor message
209
+ blessed.text({
210
+ parent: this.screen,
211
+ top: 2,
212
+ right: 2,
213
+ shrink: true,
214
+ tags: true,
215
+ content: `{magenta-fg}\u2661{/magenta-fg} {white-fg}Sponsor this Developer{/white-fg} {magenta-fg}github.com/sponsors/paulpreibisch{/magenta-fg}`,
216
+ style: { bg: COLORS.headerBg },
217
+ });
218
+
219
+ // Row 1 (right): Active settings summary [provider][voice][effects][music]
220
+ this._headerStatusText = blessed.text({
221
+ parent: this.headerBox,
222
+ top: 1,
223
+ right: 2,
224
+ shrink: true,
225
+ tags: true,
226
+ content: '',
227
+ style: { bg: COLORS.headerBg },
228
+ });
229
+
230
+ // Right-aligned: git remote + branch when available, else AgentVibes repo link
231
+ let topRightContent = `{${BRAND_PINK}-fg}github.com/preibisch/agentvibes{/${BRAND_PINK}-fg}`;
232
+ try {
233
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],
234
+ { encoding: 'utf8', timeout: 2000, cwd });
235
+ const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'],
236
+ { encoding: 'utf8', timeout: 2000, cwd });
237
+ if (branchResult.status === 0 && remoteResult.status === 0) {
238
+ const branch = branchResult.stdout.trim();
239
+ // Normalise SSH (git@github.com:user/repo.git) → HTTPS, strip .git suffix
240
+ const repoUrl = remoteResult.stdout.trim()
241
+ .replace(/^git@([^:]+):/, 'https://$1/')
242
+ .replace(/\.git$/, '');
243
+ // Strip protocol for compact display: https://github.com/… → github.com/…
244
+ const displayUrl = repoUrl.replace(/^https?:\/\//, '');
245
+ topRightContent = `{${BRAND_PINK}-fg}${displayUrl}{/${BRAND_PINK}-fg} {#90a4ae-fg}\u2502{/#90a4ae-fg} {#90a4ae-fg}\u2387{/#90a4ae-fg} {bright-white-fg}${branch}{/bright-white-fg}`;
246
+ }
247
+ } catch {}
248
+ blessed.text({
249
+ parent: this.headerBox,
250
+ top: 0,
251
+ right: 2,
252
+ shrink: true,
253
+ tags: true,
254
+ content: topRightContent,
255
+ style: { bg: COLORS.headerBg },
256
+ });
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Private: Update header status summary [provider][voice][effects][music]
261
+
262
+ _updateHeaderStatus() {
263
+ if (!this._headerStatusText || !this._providerService || !this._configService) return;
264
+ try {
265
+ const provider = this._providerService.getActiveProvider() ?? 'piper';
266
+ const rawVoice = this._providerService.getActiveVoiceId() ?? '';
267
+ // Show speaker name for multi-speaker voices
268
+ const msSep = rawVoice.indexOf('::');
269
+ const voiceName = msSep >= 0 ? rawVoice.slice(msSep + 2) : rawVoice;
270
+ // Truncate long names
271
+ const voiceShort = voiceName.length > 18 ? voiceName.slice(0, 17) + '…' : voiceName;
272
+
273
+ const cfg = this._configService.getConfig();
274
+ const effects = cfg.effects ?? {};
275
+ const reverb = effects.reverbPreset ?? 'light';
276
+
277
+ const music = cfg.backgroundMusic ?? cfg.music ?? {};
278
+ const musicEnabled = music.enabled ?? false;
279
+ const trackFile = music.track ?? '';
280
+ // Strip prefixes and suffixes for compact display
281
+ const trackShort = trackFile
282
+ .replace(/\.mp3$/i, '')
283
+ .replace(/^agent_vibes_/i, '')
284
+ .replace(/^agentvibes_/i, '')
285
+ .replace(/_loop$/i, '')
286
+ .replace(/_v\d+$/i, '')
287
+ .replace(/_/g, ' ')
288
+ .replace(/\b\w/g, c => c.toUpperCase())
289
+ .slice(0, 16) || 'None';
290
+
291
+ this._headerStatusText.setContent(
292
+ `{#90a4ae-fg}[{/#90a4ae-fg}{bright-cyan-fg}${provider}{/bright-cyan-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
293
+ `{#90a4ae-fg}[{/#90a4ae-fg}{green-fg}${voiceShort}{/green-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
294
+ `{#90a4ae-fg}[{/#90a4ae-fg}{yellow-fg}${reverb}{/yellow-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
295
+ `{#90a4ae-fg}[{/#90a4ae-fg}{${musicEnabled ? 'magenta' : 'bright-black'}-fg}${musicEnabled ? trackShort : 'off'}{/${musicEnabled ? 'magenta' : 'bright-black'}-fg}{#90a4ae-fg}]{/#90a4ae-fg}`
296
+ );
297
+ } catch { /* non-fatal */ }
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Private: Tab bar (row 3) — individual child boxes, no tag parsing.
302
+ // Each tab is a separate blessed.box. Active tab highlighted via style update.
303
+
304
+ _createTabBar() {
305
+ // Background strip screen child so blessed uses absolute coordinates directly.
306
+ // Tab items are ALSO screen children (not children of tabBarBox) to avoid the
307
+ // WSL/Windows Terminal parent-relative positioning bug that renders them 1 row
308
+ // too high (at row 3 instead of row 4), producing a ghost duplicate tab bar.
309
+ this.tabBarBox = blessed.box({
310
+ parent: this.screen,
311
+ top: 4,
312
+ left: 0,
313
+ width: '100%',
314
+ height: 1,
315
+ style: { bg: COLORS.tabBarBg },
316
+ });
317
+
318
+ // One box per tab direct screen children at absolute top:4. No tag parsing, no wrapping.
319
+ this._tabItems = {};
320
+ this._tabItemXOffsets = {}; // track x positions for label refresh
321
+ let xOffset = 1;
322
+ for (const id of TAB_ORDER) {
323
+ const lang = this._languageService?.getLang() ?? 'en';
324
+ const label = getTabLabel(id, lang);
325
+ const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
326
+ const text = ` [${shortcutKey}] ${label} `;
327
+ const el = blessed.box({
328
+ parent: this.screen,
329
+ top: 4,
330
+ left: xOffset,
331
+ width: text.length,
332
+ height: 1,
333
+ content: text,
334
+ tags: false,
335
+ wrap: false,
336
+ keys: true,
337
+ focusable: true,
338
+ style: { fg: COLORS.focusCyan, bg: COLORS.tabBarBg },
339
+ });
340
+ this._tabItems[id] = el;
341
+ this._tabItemXOffsets[id] = xOffset;
342
+ xOffset += text.length + 1; // 1-space gap between tabs
343
+ }
344
+
345
+ // Right-aligned Quit item — direct screen child at absolute top:4
346
+ const _quitText = ' [Q] Quit ';
347
+ const _quitBase = _quitText;
348
+ const _quitBlock = _quitText.slice(0, -1) + '█';
349
+ let _quitInterval = null;
350
+ this._quitItem = blessed.box({
351
+ parent: this.screen,
352
+ top: 4,
353
+ right: 1,
354
+ width: _quitText.length,
355
+ height: 1,
356
+ content: _quitText,
357
+ tags: false,
358
+ keys: true,
359
+ focusable: true,
360
+ style: { fg: '#ef9a9a', bg: COLORS.tabBarBg }, // soft red — matches header quit hint
361
+ });
362
+ this._quitItem.on('focus', () => {
363
+ this._quitItem.style.fg = 'white';
364
+ this._quitItem.style.bg = '#9c27b0';
365
+ this._quitItem.setContent(_quitBlock);
366
+ this.screen.render();
367
+ if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
368
+ _quitInterval = setInterval(() => {
369
+ const on = this._quitItem.content === _quitBlock;
370
+ this._quitItem.setContent(on ? _quitBase : _quitBlock);
371
+ this.screen.render();
372
+ }, 500);
373
+ });
374
+ this._quitItem.on('blur', () => {
375
+ if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
376
+ this._quitItem.setContent(_quitBase);
377
+ this._quitItem.style.fg = '#ef9a9a';
378
+ this._quitItem.style.bg = COLORS.tabBarBg;
379
+ this.screen.render();
380
+ });
381
+ this._quitItem.key(['enter', 'space', 'q', 'Q'], () => {
382
+ this.screen.destroy();
383
+ process.exit(0);
384
+ });
385
+
386
+ // Keyboard navigation on the main tab items
387
+ const tabIds = TAB_ORDER;
388
+ for (let i = 0; i < tabIds.length; i++) {
389
+ const el = this._tabItems[tabIds[i]];
390
+
391
+ // Blinking block cursor: replace trailing space with █, toggle at 500ms
392
+ // Always derive from current el.content so language changes are preserved.
393
+ const _getBaseContent = () => el.content.replace(/█$/, ' ');
394
+ let _cursorInterval = null;
395
+ let _cursorOn = false;
396
+
397
+ el.on('focus', () => {
398
+ el.style.fg = 'white';
399
+ el.style.bg = '#9c27b0'; // purple — cursor on this tab item
400
+ _cursorOn = true;
401
+ const _base = _getBaseContent();
402
+ const _block = _base.slice(0, -1) + '█';
403
+ el.setContent(_block);
404
+ this.screen.render();
405
+ if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
406
+ _cursorInterval = setInterval(() => {
407
+ _cursorOn = !_cursorOn;
408
+ const b = _getBaseContent();
409
+ el.setContent(_cursorOn ? b.slice(0, -1) + '█' : b);
410
+ this.screen.render();
411
+ }, 500);
412
+ });
413
+ el.on('blur', () => {
414
+ if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
415
+ el.setContent(_getBaseContent());
416
+ // navigationService set up after _createTabBar, but blur fires lazily — safe
417
+ this._updateTabBar(this.navigationService?.getActiveTab() ?? tabIds[0]);
418
+ this.screen.render();
419
+ });
420
+
421
+ el.key(['left'], () => {
422
+ if (i === 0) {
423
+ this._quitItem?.focus(); // wrap: first tab ← → Quit
424
+ } else {
425
+ this._tabItems[tabIds[i - 1]].focus();
426
+ }
427
+ });
428
+ el.key(['right'], () => {
429
+ if (i === tabIds.length - 1) {
430
+ this._quitItem?.focus(); // wrap: last tab → → Quit
431
+ } else {
432
+ this._tabItems[tabIds[i + 1]].focus();
433
+ }
434
+ });
435
+ el.key(['enter', 'space'], () => {
436
+ this.navigationService.switchTab(tabIds[i]);
437
+ });
438
+ // or Escape returns focus to the active tab's content
439
+ el.key(['down', 'escape'], () => {
440
+ const activeTab = this.tabs[this.navigationService.getActiveTab()];
441
+ if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
442
+ });
443
+
444
+ // Tab: forward through header items; last item → Quit item
445
+ el.key(['tab'], () => {
446
+ if (i < tabIds.length - 1) {
447
+ this._tabItems[tabIds[i + 1]].focus();
448
+ } else {
449
+ this._quitItem?.focus();
450
+ }
451
+ });
452
+ // S-tab: backward through header items; first item → active tab's last bottom button
453
+ el.key(['S-tab'], () => {
454
+ if (i > 0) {
455
+ this._tabItems[tabIds[i - 1]].focus();
456
+ } else {
457
+ const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
458
+ if (activeTab && typeof activeTab.focusLastBottomRow === 'function') {
459
+ activeTab.focusLastBottomRow();
460
+ } else {
461
+ this._quitItem?.focus();
462
+ }
463
+ }
464
+ });
465
+ }
466
+
467
+ // Wire Quit item ← → and Tab/S-tab into the header navigation cycle
468
+ this._quitItem.key(['left'], () => {
469
+ this._tabItems[tabIds[tabIds.length - 1]]?.focus(); // Quit → last tab (Help)
470
+ });
471
+ this._quitItem.key(['right'], () => {
472
+ this._tabItems[tabIds[0]]?.focus(); // → Quit → first tab (Install), wrap
473
+ });
474
+ this._quitItem.key(['tab'], () => {
475
+ const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
476
+ if (activeTab && typeof activeTab.focusBottomRow === 'function') {
477
+ activeTab.focusBottomRow();
478
+ } else {
479
+ this._tabItems[tabIds[0]]?.focus();
480
+ }
481
+ });
482
+ this._quitItem.key(['S-tab'], () => {
483
+ this._tabItems[tabIds[tabIds.length - 1]]?.focus();
484
+ });
485
+ this._quitItem.key(['down', 'escape'], () => {
486
+ const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
487
+ if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
488
+ });
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Private: Update tab bar — set active item style, reset all others.
493
+
494
+ _updateTabBar(activeTabId) {
495
+ if (!this._tabItems) return; // guard: not initialized in test mode
496
+ for (const [id, el] of Object.entries(this._tabItems)) {
497
+ if (id === activeTabId) {
498
+ el.style.fg = 'white';
499
+ el.style.bg = '#0288d1'; // bright light blue — matches sub-tab active color
500
+ el.style.bold = true;
501
+ } else {
502
+ el.style.fg = COLORS.focusCyan;
503
+ el.style.bg = COLORS.tabBarBg;
504
+ el.style.bold = false;
505
+ }
506
+ }
507
+ }
508
+
509
+ // ---------------------------------------------------------------------------
510
+ // Private: Refresh all chrome strings (header subtitle, tab bar labels) when lang changes
511
+
512
+ _refreshChrome(lang) {
513
+ // Update header subtitle "Customization Tool"
514
+ if (this._headerSubtitleText) {
515
+ this._headerSubtitleText.setContent(`{green-fg}${t(lang, 'customizationTool')}{/green-fg}`);
516
+ }
517
+ if (this._headerQuitText) {
518
+ this._headerQuitText.setContent(`{#ef9a9a-fg}${t(lang, 'quitLabel')}{/#ef9a9a-fg}`);
519
+ }
520
+
521
+ // Update tab bar item labels — resize and reposition to fit translated labels
522
+ let xOffset = 1;
523
+ for (const id of TAB_ORDER) {
524
+ const el = this._tabItems?.[id];
525
+ if (!el) continue;
526
+ const label = getTabLabel(id, lang);
527
+ const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
528
+ const text = ` [${shortcutKey}] ${label} `;
529
+ el.left = xOffset;
530
+ el.width = text.length;
531
+ el.setContent(text);
532
+ xOffset += text.length + 1;
533
+ }
534
+
535
+ // Update active tab's footer text if it supports language-aware footer
536
+ const activeId = this.navigationService?.getActiveTab();
537
+ if (activeId) this._updateContextFooter(activeId);
538
+
539
+ this.screen.render();
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Private: Render tab bar content string for given active tab
544
+ // (kept as a pure helper for unit tests; real rendering uses _updateTabBar)
545
+
546
+ _renderTabBarContent(activeTabId) {
547
+ const lang = this._languageService?.getLang() ?? 'en';
548
+ return TAB_ORDER.map(id => {
549
+ const label = getTabLabel(id, lang);
550
+ const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
551
+ if (id === activeTabId) {
552
+ return `{bold}{white-fg}[${shortcutKey}] ${label}{/white-fg}{/bold}`;
553
+ }
554
+ return `{bright-cyan-fg}[${shortcutKey}] ${label}{/bright-cyan-fg}`;
555
+ }).join(' ');
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Private: Content area (rows 5..N-1) — tab components mount here
560
+
561
+ _createContentArea() {
562
+ // bottom: 2 reserves 2 rows at the bottom: context footer (story 6.3) + GitHub footer
563
+ this.contentArea = blessed.box({
564
+ top: 5,
565
+ left: 0,
566
+ width: '100%',
567
+ bottom: 2,
568
+ border: { type: 'line' },
569
+ style: {
570
+ fg: COLORS.textWhite,
571
+ bg: COLORS.contentBg,
572
+ border: { fg: COLORS.activeTab },
573
+ },
574
+ });
575
+
576
+ this.screen.append(this.contentArea);
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Private: Color-coded context footer (story 6.3) — above GitHub footer
581
+
582
+ _createContextFooter() {
583
+ this.contextFooterBox = blessed.box({
584
+ bottom: 1,
585
+ left: 0,
586
+ width: '100%',
587
+ height: 1,
588
+ content: '',
589
+ tags: true,
590
+ style: {
591
+ fg: COLORS.textWhite,
592
+ bg: DEFAULT_FOOTER_COLOR,
593
+ },
594
+ });
595
+
596
+ this.screen.append(this.contextFooterBox);
597
+ }
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // Private: Update context footer color + text for the given tab
601
+
602
+ _updateContextFooter(tabId) {
603
+ // Real tab components (Tab Component Contract) provide their own footer getters.
604
+ // Placeholder tabs fall back to FOOTER_CONFIG.
605
+ const tab = this.tabs[tabId];
606
+ if (tab && typeof tab.getFooterColor === 'function') {
607
+ this.contextFooterBox.style.bg = tab.getFooterColor();
608
+ this.contextFooterBox.setContent(tab.getFooterText());
609
+ } else {
610
+ const config = FOOTER_CONFIG[tabId] ?? { color: DEFAULT_FOOTER_COLOR, text: '' };
611
+ this.contextFooterBox.style.bg = config.color;
612
+ this.contextFooterBox.setContent(config.text);
613
+ }
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Private: GitHub star footer (row N — fixed bottom)
618
+
619
+ _createFooter() {
620
+ // Detect installed providers inline (same logic as ProviderService)
621
+ const _has = (bin) => {
622
+ try { execFileSync('which', [bin], { stdio: 'ignore', timeout: 2000 }); return true; }
623
+ catch { return false; }
624
+ };
625
+ const detected = {
626
+ piper: _has('piper'),
627
+ soprano: _has('soprano'),
628
+ sapi: process.platform === 'win32',
629
+ macos: process.platform === 'darwin' && _has('say'),
630
+ };
631
+
632
+ // Build provider status badges: ● Name (green if detected, grey if not)
633
+ const on = (label) => `{green-fg}●{/green-fg} ${label}`;
634
+ const off = (label) => `{#546e7a-fg}● ${label}{/#546e7a-fg}`;
635
+ const badges = [
636
+ detected.piper ? on('Piper') : off('Piper'),
637
+ detected.soprano ? on('Soprano') : off('Soprano'),
638
+ detected.sapi ? on('Windows SAPI') : off('Windows SAPI'),
639
+ detected.macos ? on('Mac Say') : off('Mac Say'),
640
+ ].join(' ');
641
+
642
+ const footer = blessed.box({
643
+ bottom: 0,
644
+ left: 0,
645
+ width: '100%',
646
+ height: 1,
647
+ tags: true,
648
+ content: ` ${badges} {#ffff00-fg}⭐ Love AgentVibes? Give us a star!{/#ffff00-fg} github.com/preibisch/agentvibes`,
649
+ style: {
650
+ fg: COLORS.textWhite,
651
+ bg: COLORS.headerBg,
652
+ },
653
+ });
654
+
655
+ this.screen.append(footer);
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // Private: Create placeholder tab content boxes (story 6.2)
660
+ // Each epic 7-11 story will replace its placeholder with real content.
661
+
662
+ _createPlaceholderTabs() {
663
+ for (const tabId of TAB_ORDER) {
664
+ const label = TAB_DISPLAY_LABELS[tabId];
665
+ this.tabs[tabId] = createPlaceholderTab(this.contentArea, label);
666
+ }
667
+ }
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // Private: Replace placeholder tabs with real implementations (story 7.1+)
671
+
672
+ _createRealTabs() {
673
+ // Destroy the settings placeholder (real tab mounts directly to screen, not contentArea)
674
+ const placeholder = this.tabs['settings'];
675
+ if (placeholder && typeof placeholder.destroy === 'function') {
676
+ placeholder.destroy();
677
+ }
678
+
679
+ const configService = new ConfigService();
680
+ const providerService = new ProviderService(configService);
681
+ this._configService = configService;
682
+ this._providerService = providerService;
683
+ const languageService = new LanguageService();
684
+ this._languageService = languageService;
685
+ // Refresh UI chrome when language changes
686
+ languageService.onChange(lang => this._refreshChrome(lang));
687
+ const services = {
688
+ configService,
689
+ providerService,
690
+ languageService,
691
+ navigationService: this.navigationService,
692
+ updateHeaderStatus: () => this._updateHeaderStatus(),
693
+ focusMainTabBar: () => {
694
+ const id = this.navigationService.getActiveTab();
695
+ const item = this._tabItems?.[id];
696
+ if (item) item.focus();
697
+ },
698
+ focusFirstHeaderItem: () => {
699
+ this._tabItems?.[TAB_ORDER[0]]?.focus();
700
+ },
701
+ focusLastHeaderItem: () => {
702
+ this._tabItems?.[TAB_ORDER[TAB_ORDER.length - 1]]?.focus();
703
+ },
704
+ };
705
+ this.tabs['settings'] = createSettingsTab(this.screen, services);
706
+
707
+ // Destroy voices placeholder and mount real voices tab
708
+ const voicesPlaceholder = this.tabs['voices'];
709
+ if (voicesPlaceholder && typeof voicesPlaceholder.destroy === 'function') {
710
+ voicesPlaceholder.destroy();
711
+ }
712
+ this.tabs['voices'] = createVoicesTab(this.screen, services);
713
+
714
+ // Destroy music placeholder and mount real music tab
715
+ const musicPlaceholder = this.tabs['music'];
716
+ if (musicPlaceholder && typeof musicPlaceholder.destroy === 'function') {
717
+ musicPlaceholder.destroy();
718
+ }
719
+ this.tabs['music'] = createMusicTab(this.screen, services);
720
+
721
+ // Destroy setup placeholder and mount real setup wizard
722
+ const setupPlaceholder = this.tabs['setup'];
723
+ if (setupPlaceholder && typeof setupPlaceholder.destroy === 'function') {
724
+ setupPlaceholder.destroy();
725
+ }
726
+ this.tabs['setup'] = createSetupTab(this.screen, services);
727
+
728
+ // Destroy help/readme placeholders and mount real tabs
729
+ const helpPlaceholder = this.tabs['help'];
730
+ if (helpPlaceholder && typeof helpPlaceholder.destroy === 'function') {
731
+ helpPlaceholder.destroy();
732
+ }
733
+ this.tabs['help'] = createHelpTab(this.screen, services);
734
+
735
+ // Destroy agents placeholder and mount real agents tab
736
+ const agentsPlaceholder = this.tabs['agents'];
737
+ if (agentsPlaceholder && typeof agentsPlaceholder.destroy === 'function') {
738
+ agentsPlaceholder.destroy();
739
+ }
740
+ this.tabs['agents'] = createAgentsTab(this.screen, services);
741
+
742
+ // Destroy receiver placeholder and mount real receiver tab
743
+ const receiverPlaceholder = this.tabs['receiver'];
744
+ if (receiverPlaceholder && typeof receiverPlaceholder.destroy === 'function') {
745
+ receiverPlaceholder.destroy();
746
+ }
747
+ this.tabs['receiver'] = createReceiverTab(this.screen, services);
748
+
749
+ const readmePlaceholder = this.tabs['readme'];
750
+ if (readmePlaceholder && typeof readmePlaceholder.destroy === 'function') {
751
+ readmePlaceholder.destroy();
752
+ }
753
+ this.tabs['readme'] = createReadmeTab(this.screen, services);
754
+ }
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // Private: Initialise navigation service and wire key handlers (story 6.2)
758
+
759
+ _initNavigation() {
760
+ this.navigationService = new NavigationService(this.startTab);
761
+
762
+ // On every tab switch: update tab bar, context footer, and show/hide tab boxes
763
+ this.navigationService.onSwitch(tabId => {
764
+ const activeTab = this.tabs[tabId];
765
+
766
+ // Render-suppression tab switch:
767
+ // All show/hide calls and UI updates happen inside this window so zero
768
+ // intermediate frames are sent to the terminal. A single clean render
769
+ // fires at the end, when exactly one tab is visible.
770
+ const _origRender = this.screen.render.bind(this.screen);
771
+ this.screen.render = () => {};
772
+
773
+ try {
774
+ // Nuclear clear: wipe the content area (row 5+) to remove stale cell content
775
+ // from the previous tab. Start at row 5 header (0-3) and tab bar (4) are
776
+ // static widgets that don't need clearing; wiping them causes the double
777
+ // tab bar artifact (row 3 of header shows tab bar ghost from prior render).
778
+ // blessed's render loop never resets the `lines` buffer before rendering
779
+ // (see: blessed/lib/widgets/screen.js line 733, commented-out clear).
780
+ this.screen.clearRegion(0, this.screen.cols, 5, this.screen.rows - 2);
781
+
782
+ // Force-invalidate olines for the entire visible area (rows 0..rows-3).
783
+ // Includes header rows 0-3 so the branded header is always redrawn on
784
+ // tab switchesprevents corruption from persisting across tabs.
785
+ // Row 3 (header bottom), row 4 (tab bar) and content rows accumulate
786
+ // ghost rendering artifacts draw() skips them when lines==olines even
787
+ // though the terminal still shows stale chars from earlier renders.
788
+ // Setting attr=-1 is impossible for any real cell, so draw() is forced
789
+ // to physically rewrite every cell on the next render call.
790
+ for (let r = 0; r < this.screen.rows - 2; r++) {
791
+ const orow = this.screen.olines[r];
792
+ if (!orow) continue;
793
+ for (let c = 0; c < this.screen.cols; c++) {
794
+ if (orow[c]) orow[c][0] = -1; // impossible attr — forces draw() rewrite
795
+ }
796
+ orow.dirty = true;
797
+ }
798
+
799
+ // Row 3 (header bottom) is never dirty after draw 1 its content (headerBg+
800
+ // spaces) never changes so element.render() never marks it dirty. The olines
801
+ // invalidation above sets olines[3][c][0]=-1, but draw() only compares cells
802
+ // when lines[r].dirty is true; a false dirty flag skips the entire row without
803
+ // ever consulting olines. Force-mark it dirty so draw() emits the explicit
804
+ // cup(4,1)+headerBg+spaces sequence and overwrites any ghost terminal content.
805
+ if (this.screen.lines?.[3]) this.screen.lines[3].dirty = true;
806
+
807
+ // Update tab bar, footer, and header status inside suppression — no intermediate render.
808
+ this._updateTabBar(tabId);
809
+ this._updateContextFooter(tabId);
810
+ this._updateHeaderStatus();
811
+
812
+ // Hide all inactive tabs via their proper hide() method so side-effects
813
+ // (e.g. voice preview kill, previewLine clear) run correctly.
814
+ for (const [id, tab] of Object.entries(this.tabs)) {
815
+ if (id !== tabId) {
816
+ if (typeof tab.hide === 'function') tab.hide();
817
+ else tab.hidden = true;
818
+ if (typeof tab.onBlur === 'function') tab.onBlur();
819
+ }
820
+ }
821
+
822
+ // Show the active tab via show() so refreshDisplay() populates labels.
823
+ if (activeTab) {
824
+ if (typeof activeTab.show === 'function') {
825
+ activeTab.show();
826
+ // setFront() moves the box to the end of screen.children so it
827
+ // paints last (on top) — belt-and-suspenders against any z-order issue.
828
+ if (activeTab.box && typeof activeTab.box.setFront === 'function') {
829
+ activeTab.box.setFront();
830
+ }
831
+ // Move any screen-level overlay widgets (e.g. junction chars) to front
832
+ // AFTER box.setFront() so they render on top of the box border.
833
+ if (typeof activeTab.moveOverlaysToFront === 'function') {
834
+ activeTab.moveOverlaysToFront();
835
+ }
836
+ } else {
837
+ activeTab.hidden = false;
838
+ }
839
+ }
840
+ } finally {
841
+ // Always restore render even if something throws.
842
+ this.screen.render = _origRender;
843
+ }
844
+
845
+ if (activeTab && typeof activeTab.onFocus === 'function') {
846
+ activeTab.onFocus();
847
+ }
848
+ this.screen.render();
849
+ });
850
+
851
+ // Register global key bindings (S/V/M/A/R/H/I/T/Esc)
852
+ setupNavigation(this.screen, this.navigationService, () => {
853
+ const id = this.navigationService.getActiveTab();
854
+ const item = this._tabItems?.[id];
855
+ if (item) item.focus();
856
+ });
857
+ }
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Private: Modal overlay (story 6.4) — reusable base for all selector modals
861
+
862
+ _createModalOverlay() {
863
+ this.modalOverlay = createModalOverlay(this.screen, this.navigationService);
864
+
865
+ // Esc key closes the modal overlay if one is open.
866
+ // Blessed.js allows multiple handlers for the same key — all fire.
867
+ // The navigation.js Esc handler calls nav.closeModal() (state only).
868
+ // This second handler hides the overlay+container widgets.
869
+ this.screen.key(['escape'], () => {
870
+ if (this.modalOverlay) this.modalOverlay.close();
871
+ });
872
+ }
873
+
874
+ // ---------------------------------------------------------------------------
875
+ // Private: Global keyboard handlers
876
+
877
+ _registerHandlers() {
878
+ // Q or Ctrl+C → clean exit (no zombie processes)
879
+ this.screen.key(['q', 'Q', 'C-c'], () => {
880
+ this.screen.destroy();
881
+ process.exit(0);
882
+ });
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Launch the AgentVibes TUI console.
888
+ *
889
+ * @param {object} opts
890
+ * @param {string} [opts.startTab='settings'] - Which tab to show on launch.
891
+ * Used by story 6.5 (command routing). Values: 'settings' | 'install' | 'voices' | 'music'
892
+ * @param {boolean} [opts._testMode=false] - Internal: skip render in test environments.
893
+ * @returns {Promise<AgentVibesConsole>}
894
+ */
895
+ export async function launchConsole(opts = {}) {
896
+ const app = new AgentVibesConsole(opts);
897
+ await app.init();
898
+ return app;
899
+ }