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.
- package/.agentvibes/bmad-voice-map.json +104 -0
- package/.agentvibes/config.json +13 -12
- package/.agentvibes/copilot-sessions.log +4 -0
- package/.claude/audio/tracks/Drifting Down the Hall.mp3 +0 -0
- package/.claude/audio/tracks/Late Night Hip Hop Groove.mp3 +0 -0
- package/.claude/audio/tracks/Midnight Charleston Stomp.mp3 +0 -0
- package/.claude/audio/tracks/README.md +51 -52
- package/.claude/config/audio-effects-bmad.cfg +50 -0
- package/.claude/config/audio-effects.cfg +4 -4
- package/.claude/config/background-music-enabled.txt +1 -0
- package/.claude/config/personality.txt +1 -0
- package/.claude/hooks/play-tts-piper.sh +3 -1
- package/.claude/hooks/play-tts.sh +380 -301
- package/.claude/hooks/session-start-tts.sh +81 -81
- package/.claude/hooks-windows/audio-processor.ps1 +181 -0
- package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
- package/.claude/hooks-windows/play-tts.ps1 +28 -6
- package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
- package/README.md +112 -6
- package/RELEASE_NOTES.md +83 -0
- package/bin/bmad-speak.js +16 -8
- package/mcp-server/server.py +15 -8
- package/package.json +1 -1
- package/src/console/app.js +899 -897
- package/src/console/footer-config.js +50 -50
- package/src/console/navigation.js +65 -65
- package/src/console/tabs/agents-tab.js +1899 -1886
- package/src/console/tabs/music-tab.js +1076 -1039
- package/src/console/tabs/placeholder-tab.js +81 -80
- package/src/console/tabs/settings-tab.js +941 -3988
- package/src/console/tabs/setup-tab.js +2071 -0
- package/src/console/tabs/voices-tab.js +1843 -1714
- package/src/console/widgets/format-utils.js +92 -89
- package/src/console/widgets/track-picker.js +325 -322
- package/src/installer.js +6147 -6092
- package/src/services/llm-provider-service.js +486 -0
- package/src/services/navigation-service.js +123 -123
- package/src/services/tts-engine-service.js +69 -0
- package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
- package/src/console/tabs/install-tab.js +0 -1081
package/src/console/app.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
|
|
57
|
-
this.
|
|
58
|
-
this.
|
|
59
|
-
this.
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.
|
|
77
|
-
this.
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this.
|
|
85
|
-
this.
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
89
|
-
this.
|
|
90
|
-
this.
|
|
91
|
-
this.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
{ encoding: 'utf8', timeout: 2000, cwd });
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
.replace(
|
|
283
|
-
.replace(/
|
|
284
|
-
.replace(/
|
|
285
|
-
.replace(/
|
|
286
|
-
.replace(
|
|
287
|
-
.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
`{#90a4ae-fg}[{/#90a4ae-fg}{
|
|
293
|
-
`{#90a4ae-fg}[{/#90a4ae-fg}{
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
this._quitItem.
|
|
364
|
-
this.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
this.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
this._quitItem.
|
|
377
|
-
this.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this.
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
this.
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
this.
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
el.style.
|
|
499
|
-
|
|
500
|
-
el.style.
|
|
501
|
-
|
|
502
|
-
el.style.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
el.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if
|
|
536
|
-
|
|
537
|
-
this.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
detected.
|
|
637
|
-
detected.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
this.
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
//
|
|
775
|
-
// tab
|
|
776
|
-
//
|
|
777
|
-
// (
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
this.
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
if (typeof tab.
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
* @param {
|
|
891
|
-
*
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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 switches — prevents 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
|
+
}
|