agentvibes 4.6.7 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +373 -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 +101 -9
- package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
- package/README.md +107 -7
- package/RELEASE_NOTES.md +54 -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 +1896 -1886
- package/src/console/tabs/music-tab.js +1046 -1039
- package/src/console/tabs/placeholder-tab.js +81 -80
- package/src/console/tabs/settings-tab.js +939 -3988
- package/src/console/tabs/setup-tab.js +1811 -0
- package/src/console/tabs/voices-tab.js +1720 -1713
- package/src/installer.js +6147 -6092
- package/src/services/llm-provider-service.js +407 -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
|
@@ -1,3988 +1,939 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AgentVibes TUI Console — Settings Tab
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
} from '
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
let
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
screen.render();
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
item.
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
screen.render();
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
//
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
if (
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
const provider = providerService.getActiveProvider();
|
|
942
|
-
const _activePers = (configService.getConfig().personality ?? '').trim();
|
|
943
|
-
const _hasPersonality = _activePers && _activePers !== 'none' && _activePers !== 'normal';
|
|
944
|
-
const _rawPlay = providerService.getActiveVoiceId() ?? 'this voice';
|
|
945
|
-
const _msPlay = parseMultiSpeaker(_rawPlay);
|
|
946
|
-
let phrase = `${_testGreeting()}. Agent Vibes here. I am ${provider === 'soprano' ? 'Soprano' : (_msPlay.isMultiSpeaker ? _msPlay.speakerName : _rawPlay)}`;
|
|
947
|
-
if (_hasPersonality) phrase += `, with ${_activePers} personality`;
|
|
948
|
-
phrase += '.';
|
|
949
|
-
const tempWav = path.join(os.tmpdir(), `agentvibes-sample-${randomUUID()}.wav`);
|
|
950
|
-
|
|
951
|
-
_samplePlaying = true;
|
|
952
|
-
|
|
953
|
-
const _onSynthDone = (code) => {
|
|
954
|
-
_stopSpinner();
|
|
955
|
-
if (!_samplePlaying) { try { fs.unlinkSync(tempWav); } catch {} return; }
|
|
956
|
-
if (code !== 0) {
|
|
957
|
-
_killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn);
|
|
958
|
-
_showNotice(screen, 'Voice synthesis failed — check voice model');
|
|
959
|
-
try { fs.unlinkSync(tempWav); } catch {}
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
playBtn.setContent('■ Stop');
|
|
963
|
-
screen.render();
|
|
964
|
-
const _wavPlayer2 = detectWavPlayer(_sampleEnv);
|
|
965
|
-
if (!_wavPlayer2) {
|
|
966
|
-
_stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
|
|
967
|
-
_showNotice(screen, 'No audio player found — install ffplay, sox, or mpv');
|
|
968
|
-
screen.render(); return;
|
|
969
|
-
}
|
|
970
|
-
const playProc = spawn(_wavPlayer2.bin, _wavPlayer2.args(tempWav), _spawnOpts(_sampleEnv));
|
|
971
|
-
_sampleProcess = playProc;
|
|
972
|
-
const _done = () => { _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn); try { fs.unlinkSync(tempWav); } catch {} };
|
|
973
|
-
playProc.on('exit', _done);
|
|
974
|
-
playProc.on('error', _done);
|
|
975
|
-
};
|
|
976
|
-
|
|
977
|
-
if (provider === 'soprano') {
|
|
978
|
-
const port = process.env.SOPRANO_PORT || '7860';
|
|
979
|
-
const synther = path.resolve(new URL(import.meta.url).pathname,
|
|
980
|
-
'..', '..', '..', '..', '.claude', 'hooks', 'soprano-gradio-synth.py');
|
|
981
|
-
const managerPath = path.resolve(new URL(import.meta.url).pathname,
|
|
982
|
-
'..', '..', '..', '..', '.claude', 'hooks', 'soprano-manager.sh');
|
|
983
|
-
|
|
984
|
-
// Fast-path: cached green status glyph confirms WebUI is healthy — skip manager wait.
|
|
985
|
-
// @why soprano-manager does an HTTP health check (up to 2s) even when already running;
|
|
986
|
-
// if _refreshSopranoStatus() already confirmed 🟢 we can synthesize immediately.
|
|
987
|
-
if (_sopranoStatusGlyph === ' 🟢') {
|
|
988
|
-
_doSopranoSynth(true);
|
|
989
|
-
} else {
|
|
990
|
-
// Ask soprano-manager to ensure the WebUI is running and wait until healthy.
|
|
991
|
-
// If already running: exits in <200ms. If cold-starting: blocks up to 60s.
|
|
992
|
-
// Progressive label updates tell the user how long it's been waiting.
|
|
993
|
-
_startSpinner(playBtn, 'Starting Soprano…');
|
|
994
|
-
|
|
995
|
-
let _startSecs = 0;
|
|
996
|
-
const _startLabelTimer = setInterval(() => {
|
|
997
|
-
if (!_samplePlaying) { clearInterval(_startLabelTimer); return; }
|
|
998
|
-
_startSecs += 10;
|
|
999
|
-
// Re-call _startSpinner to replace the label; it stops the old interval first.
|
|
1000
|
-
_startSpinner(playBtn, `Starting Soprano… (${_startSecs}s)`);
|
|
1001
|
-
}, 10000);
|
|
1002
|
-
|
|
1003
|
-
if (fs.existsSync(managerPath)) {
|
|
1004
|
-
const mgrProc = spawn('bash', [managerPath, 'start', '--wait'], {
|
|
1005
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
1006
|
-
env: { ...process.env, SOPRANO_PORT: port },
|
|
1007
|
-
});
|
|
1008
|
-
_sopranoMgrProc = mgrProc; // tracked separately — kill() won't cascade to soprano-webui
|
|
1009
|
-
|
|
1010
|
-
mgrProc.on('exit', (code) => {
|
|
1011
|
-
clearInterval(_startLabelTimer);
|
|
1012
|
-
_sopranoMgrProc = null;
|
|
1013
|
-
if (!_samplePlaying) return;
|
|
1014
|
-
if (code === 5) {
|
|
1015
|
-
// soprano-webui binary not installed
|
|
1016
|
-
_stopSpinner();
|
|
1017
|
-
_killSample();
|
|
1018
|
-
playBtn.setContent('▶ Play');
|
|
1019
|
-
_showNotice(screen, 'Soprano not installed — run: pip install soprano-tts');
|
|
1020
|
-
_refreshSopranoStatus();
|
|
1021
|
-
_focusButton(playBtn);
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
// code 0 = WebUI ready (Synthesizing…); any other = timed out/error (Loading model…)
|
|
1025
|
-
_doSopranoSynth(code === 0);
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
mgrProc.on('error', () => {
|
|
1029
|
-
clearInterval(_startLabelTimer);
|
|
1030
|
-
_sopranoMgrProc = null;
|
|
1031
|
-
if (_samplePlaying) _doSopranoSynth(false);
|
|
1032
|
-
});
|
|
1033
|
-
} else {
|
|
1034
|
-
// soprano-manager.sh not present — fall back to direct 2s HTTP check
|
|
1035
|
-
clearInterval(_startLabelTimer);
|
|
1036
|
-
const checkReq = http.get(
|
|
1037
|
-
`http://127.0.0.1:${port}/gradio_api/info`,
|
|
1038
|
-
{ timeout: 2000 },
|
|
1039
|
-
(res) => { res.resume(); _doSopranoSynth(true); },
|
|
1040
|
-
);
|
|
1041
|
-
checkReq.on('error', () => _doSopranoSynth(false));
|
|
1042
|
-
checkReq.on('timeout', () => { checkReq.destroy(); _doSopranoSynth(false); });
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
function _doSopranoSynth(webUIUp) {
|
|
1047
|
-
if (!_samplePlaying) return;
|
|
1048
|
-
_startSpinner(playBtn, webUIUp ? 'Synthesizing…' : 'Loading model…');
|
|
1049
|
-
|
|
1050
|
-
// Pass phrase and output path via env vars — avoids all shell-escaping issues.
|
|
1051
|
-
// Mode chain: WebUI (Gradio, model warm) → API server (OpenAI-compat) → CLI (slow cold-load).
|
|
1052
|
-
const sopranoEnv = {
|
|
1053
|
-
...(_sampleEnv),
|
|
1054
|
-
_AV_PHRASE: _sanitizeForShell(phrase),
|
|
1055
|
-
_AV_WAV: tempWav,
|
|
1056
|
-
_AV_SYNTHER: synther,
|
|
1057
|
-
_AV_PORT: String(port),
|
|
1058
|
-
};
|
|
1059
|
-
const cmd = [
|
|
1060
|
-
// Mode 1: Gradio WebUI
|
|
1061
|
-
`python3 "$_AV_SYNTHER" "$_AV_PHRASE" "$_AV_WAV" "$_AV_PORT" 2>/dev/null`,
|
|
1062
|
-
// Mode 2: OpenAI-compatible API server (curl writes WAV directly)
|
|
1063
|
-
`curl -sf --max-time 30 "http://127.0.0.1:$_AV_PORT/v1/audio/speech"` +
|
|
1064
|
-
` -H "Content-Type: application/json"` +
|
|
1065
|
-
` -d "{\\"input\\":\\"$_AV_PHRASE\\"}" --output "$_AV_WAV" 2>/dev/null`,
|
|
1066
|
-
// Mode 3: CLI — reloads neural model each call (~15-30s)
|
|
1067
|
-
`soprano "$_AV_PHRASE" -o "$_AV_WAV"`,
|
|
1068
|
-
].join(' || ');
|
|
1069
|
-
|
|
1070
|
-
const soprano = spawn('sh', ['-c', cmd], {
|
|
1071
|
-
stdio: 'ignore', detached: !_IS_WINDOWS, windowsHide: true, env: sopranoEnv,
|
|
1072
|
-
});
|
|
1073
|
-
_sampleProcess = soprano;
|
|
1074
|
-
soprano.on('exit', (code) => {
|
|
1075
|
-
_onSynthDone(code);
|
|
1076
|
-
_refreshSopranoStatus(); // Update status glyph after synthesis completes
|
|
1077
|
-
});
|
|
1078
|
-
soprano.on('error', () => { _stopSpinner(); _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn); });
|
|
1079
|
-
}
|
|
1080
|
-
} else {
|
|
1081
|
-
// Piper (default): pipe text via stdin
|
|
1082
|
-
_startSpinner(playBtn, 'Synthesizing…');
|
|
1083
|
-
const voiceId = providerService.getActiveVoiceId();
|
|
1084
|
-
if (!voiceId) {
|
|
1085
|
-
_stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
|
|
1086
|
-
_showNotice(screen, 'No voice selected — choose a voice first');
|
|
1087
|
-
screen.render(); return;
|
|
1088
|
-
}
|
|
1089
|
-
const _ms2 = parseMultiSpeaker(voiceId);
|
|
1090
|
-
const voicePath = path.resolve(PIPER_VOICES_DIR, _ms2.model + '.onnx');
|
|
1091
|
-
const safeBase = path.resolve(PIPER_VOICES_DIR);
|
|
1092
|
-
if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
|
|
1093
|
-
_stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
|
|
1094
|
-
_showNotice(screen, 'Invalid voice path');
|
|
1095
|
-
screen.render(); return;
|
|
1096
|
-
}
|
|
1097
|
-
const piperBin2 = _resolvePiperBin();
|
|
1098
|
-
if (piperBin2 === 'piper') {
|
|
1099
|
-
// Bare command — verify it exists in PATH before spawning
|
|
1100
|
-
const whichCmd = _IS_WINDOWS ? 'where' : 'which';
|
|
1101
|
-
const whichResult = spawnSync(whichCmd, [_IS_WINDOWS ? 'piper.exe' : 'piper'], { stdio: 'pipe', env: _sampleEnv });
|
|
1102
|
-
if (whichResult.status !== 0) {
|
|
1103
|
-
_stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
|
|
1104
|
-
_showNotice(screen, 'Piper not installed — run the installer or: pip install piper-tts');
|
|
1105
|
-
_focusButton(playBtn); screen.render(); return;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
const _piperArgs2 = ['--model', voicePath, '--output_file', tempWav];
|
|
1109
|
-
if (_ms2.speakerId != null) _piperArgs2.push('--speaker', String(_ms2.speakerId));
|
|
1110
|
-
const piper = spawn(piperBin2, _piperArgs2, {
|
|
1111
|
-
stdio: ['pipe', 'ignore', 'pipe'], detached: !_IS_WINDOWS, windowsHide: true, env: _sampleEnv,
|
|
1112
|
-
});
|
|
1113
|
-
let _piperStderr = '';
|
|
1114
|
-
piper.stderr.on('data', (d) => { _piperStderr += d.toString(); });
|
|
1115
|
-
piper.stdin.write(phrase + '\n');
|
|
1116
|
-
piper.stdin.end();
|
|
1117
|
-
_sampleProcess = piper;
|
|
1118
|
-
piper.on('exit', (code) => {
|
|
1119
|
-
if (code !== 0 && _piperStderr) {
|
|
1120
|
-
// Python tracebacks: actual error is the LAST non-empty line
|
|
1121
|
-
const lines = _piperStderr.split('\n').map(l => l.trim()).filter(Boolean);
|
|
1122
|
-
const errLine = lines[lines.length - 1] || lines[0] || 'unknown error';
|
|
1123
|
-
_stopSpinner();
|
|
1124
|
-
if (!_samplePlaying) { try { fs.unlinkSync(tempWav); } catch {} return; }
|
|
1125
|
-
_killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn);
|
|
1126
|
-
_showNotice(screen, errLine.length > 100 ? errLine.substring(0, 97) + '…' : errLine);
|
|
1127
|
-
try { fs.unlinkSync(tempWav); } catch {}
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
_onSynthDone(code);
|
|
1131
|
-
});
|
|
1132
|
-
piper.on('error', (e) => {
|
|
1133
|
-
_stopSpinner(); _killSample(); playBtn.setContent('▶ Play');
|
|
1134
|
-
_showNotice(screen, `Piper failed: ${e.message}`);
|
|
1135
|
-
_focusButton(playBtn);
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
});
|
|
1139
|
-
playBtn.top = 7;
|
|
1140
|
-
playBtn.left = 64;
|
|
1141
|
-
|
|
1142
|
-
const voiceFileText = blessed.text({
|
|
1143
|
-
parent: box,
|
|
1144
|
-
top: 8,
|
|
1145
|
-
left: 22,
|
|
1146
|
-
right: 2,
|
|
1147
|
-
wrap: false,
|
|
1148
|
-
content: '.claude/tts-voice.txt',
|
|
1149
|
-
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
// -------------------------------------------------------------------------
|
|
1153
|
-
// Section header: ── Audio Effects ──
|
|
1154
|
-
|
|
1155
|
-
const audioEffectsHeader = blessed.text({
|
|
1156
|
-
parent: box,
|
|
1157
|
-
top: 3,
|
|
1158
|
-
left: 1,
|
|
1159
|
-
content: '{bright-cyan-fg} ⚡ Audio Effects {/bright-cyan-fg}',
|
|
1160
|
-
tags: true,
|
|
1161
|
-
style: { bg: COLORS.contentBg },
|
|
1162
|
-
});
|
|
1163
|
-
|
|
1164
|
-
// -------------------------------------------------------------------------
|
|
1165
|
-
// Reverb row: label + value + [Toggle] + [Adjust] buttons
|
|
1166
|
-
|
|
1167
|
-
const reverbLabel = blessed.text({
|
|
1168
|
-
parent: box,
|
|
1169
|
-
top: 5,
|
|
1170
|
-
left: 6,
|
|
1171
|
-
content: 'Reverb:',
|
|
1172
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
const reverbValue = blessed.text({
|
|
1176
|
-
parent: box,
|
|
1177
|
-
top: 5,
|
|
1178
|
-
left: 22,
|
|
1179
|
-
width: 26, // truncate before [Change] at left:40
|
|
1180
|
-
wrap: false,
|
|
1181
|
-
content: '', // populated by refreshDisplay()
|
|
1182
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1183
|
-
});
|
|
1184
|
-
|
|
1185
|
-
const reverbChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1186
|
-
openReverbPicker(screen, configService.getConfig().effects?.reverbPreset ?? 'light', (preset) => {
|
|
1187
|
-
_setEffects(configService, { reverbPreset: preset });
|
|
1188
|
-
// On Windows, sync reverb-level.txt so play-tts.ps1 picks it up
|
|
1189
|
-
if (_IS_WINDOWS) {
|
|
1190
|
-
const _validPresets = new Set(['off', 'light', 'medium', 'heavy', 'cathedral']);
|
|
1191
|
-
if (_validPresets.has(preset)) {
|
|
1192
|
-
const _cwdMgr = path.join(process.cwd(), '.claude', 'hooks-windows', 'effects-manager.ps1');
|
|
1193
|
-
const _homeMgr = path.join(os.homedir(), '.claude', 'hooks-windows', 'effects-manager.ps1');
|
|
1194
|
-
const effectsMgr = fs.existsSync(_cwdMgr) ? _cwdMgr : _homeMgr;
|
|
1195
|
-
spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', effectsMgr, 'set-reverb', preset, 'default'], {
|
|
1196
|
-
stdio: 'ignore', timeout: 5000,
|
|
1197
|
-
});
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
refreshDisplay();
|
|
1201
|
-
}, _restoreFocus);
|
|
1202
|
-
}, { bg: COLORS.btnChange });
|
|
1203
|
-
reverbChangeBtn.top = 5;
|
|
1204
|
-
reverbChangeBtn.left = 52;
|
|
1205
|
-
|
|
1206
|
-
const reverbTestBtn = _createButton(box, screen, '▶ Preview', COLORS, () => _runTest(false), { bg: COLORS.btnTest });
|
|
1207
|
-
reverbTestBtn.top = 5;
|
|
1208
|
-
reverbTestBtn.left = 64;
|
|
1209
|
-
|
|
1210
|
-
const reverbPathText = blessed.text({
|
|
1211
|
-
parent: box,
|
|
1212
|
-
top: 6,
|
|
1213
|
-
left: 22,
|
|
1214
|
-
right: 2,
|
|
1215
|
-
wrap: false,
|
|
1216
|
-
content: '.agentvibes/config.json',
|
|
1217
|
-
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1218
|
-
});
|
|
1219
|
-
|
|
1220
|
-
// -------------------------------------------------------------------------
|
|
1221
|
-
// Section header: ── Background Music ──
|
|
1222
|
-
|
|
1223
|
-
const bgMusicHeader = blessed.text({
|
|
1224
|
-
parent: box,
|
|
1225
|
-
top: 7,
|
|
1226
|
-
left: 1,
|
|
1227
|
-
content: '{bright-cyan-fg} 🎸 Background Music {/bright-cyan-fg}',
|
|
1228
|
-
tags: true,
|
|
1229
|
-
style: { bg: COLORS.contentBg },
|
|
1230
|
-
});
|
|
1231
|
-
|
|
1232
|
-
// -------------------------------------------------------------------------
|
|
1233
|
-
// Music row (single): Track value + [Change] + [Enabled/Disabled] + [Test]
|
|
1234
|
-
|
|
1235
|
-
const trackLabel = blessed.text({
|
|
1236
|
-
parent: box,
|
|
1237
|
-
top: 9,
|
|
1238
|
-
left: 6,
|
|
1239
|
-
content: 'Track:',
|
|
1240
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1243
|
-
const trackValue = blessed.text({
|
|
1244
|
-
parent: box,
|
|
1245
|
-
top: 9,
|
|
1246
|
-
left: 22,
|
|
1247
|
-
width: 26, // truncate before [Change] at left:40
|
|
1248
|
-
wrap: false,
|
|
1249
|
-
content: '', // populated by refreshDisplay()
|
|
1250
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1251
|
-
});
|
|
1252
|
-
|
|
1253
|
-
const trackChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1254
|
-
_openMusicBrowserModal(screen, configService, navigationService, () => {
|
|
1255
|
-
refreshDisplay();
|
|
1256
|
-
_buttons[_currentIdx].focus();
|
|
1257
|
-
screen.render();
|
|
1258
|
-
}, _restoreFocus);
|
|
1259
|
-
}, { bg: COLORS.btnChange });
|
|
1260
|
-
trackChangeBtn.top = 9;
|
|
1261
|
-
trackChangeBtn.left = 52;
|
|
1262
|
-
|
|
1263
|
-
const musicToggleBtn = _createButton(box, screen, 'Disabled', COLORS, () => {
|
|
1264
|
-
const music = _getMusic(configService);
|
|
1265
|
-
_setMusic(configService, { enabled: !music.enabled });
|
|
1266
|
-
refreshDisplay();
|
|
1267
|
-
}, {
|
|
1268
|
-
bg: COLORS.btnEnableOff,
|
|
1269
|
-
getDynamicBg: () => _getMusic(configService).enabled ? COLORS.btnEnableOn : COLORS.btnEnableOff,
|
|
1270
|
-
});
|
|
1271
|
-
musicToggleBtn.top = 9;
|
|
1272
|
-
musicToggleBtn.left = 64;
|
|
1273
|
-
|
|
1274
|
-
const musicTestBtn = _createButton(box, screen, '▶ Preview', COLORS, _runMusicTest, { bg: COLORS.btnTest });
|
|
1275
|
-
musicTestBtn.top = 9;
|
|
1276
|
-
musicTestBtn.left = 78;
|
|
1277
|
-
|
|
1278
|
-
const trackPathText = blessed.text({
|
|
1279
|
-
parent: box,
|
|
1280
|
-
top: 10,
|
|
1281
|
-
left: 22,
|
|
1282
|
-
right: 2,
|
|
1283
|
-
wrap: false,
|
|
1284
|
-
content: '.agentvibes/config.json',
|
|
1285
|
-
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1286
|
-
});
|
|
1287
|
-
|
|
1288
|
-
// -------------------------------------------------------------------------
|
|
1289
|
-
// Volume row: label + value + [Change] button
|
|
1290
|
-
|
|
1291
|
-
const volumeLabel = blessed.text({
|
|
1292
|
-
parent: box,
|
|
1293
|
-
top: 11,
|
|
1294
|
-
left: 6,
|
|
1295
|
-
content: 'Volume:',
|
|
1296
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1297
|
-
});
|
|
1298
|
-
|
|
1299
|
-
const volumeValue = blessed.text({
|
|
1300
|
-
parent: box,
|
|
1301
|
-
top: 11,
|
|
1302
|
-
left: 22,
|
|
1303
|
-
width: 26,
|
|
1304
|
-
wrap: false,
|
|
1305
|
-
content: '', // populated by refreshDisplay()
|
|
1306
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1309
|
-
const volumeChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1310
|
-
_openVolumePicker(screen, configService, (vol) => {
|
|
1311
|
-
_setMusic(configService, { volume: vol });
|
|
1312
|
-
// If music test is active, restart it at the new volume
|
|
1313
|
-
if (_musicTestActive) {
|
|
1314
|
-
_killMusicTest();
|
|
1315
|
-
_runMusicTest();
|
|
1316
|
-
}
|
|
1317
|
-
refreshDisplay();
|
|
1318
|
-
}, _restoreFocus);
|
|
1319
|
-
}, { bg: COLORS.btnChange });
|
|
1320
|
-
volumeChangeBtn.top = 11;
|
|
1321
|
-
volumeChangeBtn.left = 52;
|
|
1322
|
-
|
|
1323
|
-
// -------------------------------------------------------------------------
|
|
1324
|
-
// Section header: ── Style ──
|
|
1325
|
-
|
|
1326
|
-
const styleHeader = blessed.text({
|
|
1327
|
-
parent: box,
|
|
1328
|
-
top: 3,
|
|
1329
|
-
left: 1,
|
|
1330
|
-
content: '{bright-cyan-fg} 🎭 Style {/bright-cyan-fg}',
|
|
1331
|
-
tags: true,
|
|
1332
|
-
style: { bg: COLORS.contentBg },
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
// -------------------------------------------------------------------------
|
|
1336
|
-
// Verbosity row: label + value + [Change] button
|
|
1337
|
-
|
|
1338
|
-
const verbosityLabel = blessed.text({
|
|
1339
|
-
parent: box,
|
|
1340
|
-
top: 5,
|
|
1341
|
-
left: 6,
|
|
1342
|
-
content: 'Verbosity:',
|
|
1343
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1344
|
-
});
|
|
1345
|
-
|
|
1346
|
-
const verbosityValue = blessed.text({
|
|
1347
|
-
parent: box,
|
|
1348
|
-
top: 5,
|
|
1349
|
-
left: 22,
|
|
1350
|
-
width: 26, // truncate before [Change] at left:40
|
|
1351
|
-
wrap: false,
|
|
1352
|
-
content: '', // populated by refreshDisplay()
|
|
1353
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1354
|
-
});
|
|
1355
|
-
|
|
1356
|
-
const verbosityChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1357
|
-
_openVerbosityPicker(screen, configService, () => refreshDisplay(), _restoreFocus);
|
|
1358
|
-
}, { bg: COLORS.btnChange });
|
|
1359
|
-
verbosityChangeBtn.top = 5;
|
|
1360
|
-
verbosityChangeBtn.left = 52;
|
|
1361
|
-
|
|
1362
|
-
const verbosityPathText = blessed.text({
|
|
1363
|
-
parent: box,
|
|
1364
|
-
top: 6,
|
|
1365
|
-
left: 22,
|
|
1366
|
-
right: 2,
|
|
1367
|
-
wrap: false,
|
|
1368
|
-
content: '.claude/tts-verbosity.txt',
|
|
1369
|
-
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1370
|
-
});
|
|
1371
|
-
|
|
1372
|
-
// -------------------------------------------------------------------------
|
|
1373
|
-
// Personality row: label + value + [Change] button
|
|
1374
|
-
|
|
1375
|
-
const personalityLabel = blessed.text({
|
|
1376
|
-
parent: box,
|
|
1377
|
-
top: 7,
|
|
1378
|
-
left: 6,
|
|
1379
|
-
content: 'Personality:',
|
|
1380
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1381
|
-
});
|
|
1382
|
-
|
|
1383
|
-
const personalityValue = blessed.text({
|
|
1384
|
-
parent: box,
|
|
1385
|
-
top: 7,
|
|
1386
|
-
left: 22,
|
|
1387
|
-
width: 26, // truncate before [Change] at left:40
|
|
1388
|
-
wrap: false,
|
|
1389
|
-
content: '', // populated by refreshDisplay()
|
|
1390
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1391
|
-
});
|
|
1392
|
-
|
|
1393
|
-
const personalityChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1394
|
-
openPersonalityPicker(screen, configService.getConfig().personality ?? 'none', (name) => {
|
|
1395
|
-
configService.set('personality', name);
|
|
1396
|
-
refreshDisplay();
|
|
1397
|
-
}, _restoreFocus);
|
|
1398
|
-
}, { bg: COLORS.btnChange });
|
|
1399
|
-
personalityChangeBtn.top = 7;
|
|
1400
|
-
personalityChangeBtn.left = 52;
|
|
1401
|
-
|
|
1402
|
-
const personalityTestBtn = _createButton(box, screen, '▶ Preview', COLORS, () => {
|
|
1403
|
-
const personality = (configService.getConfig().personality ?? '').trim();
|
|
1404
|
-
const example = _getPersonalityPhrase(personality);
|
|
1405
|
-
const phrase = example
|
|
1406
|
-
? `${_testGreeting()}. Agent Vibes here. ${example}`
|
|
1407
|
-
: _buildPreviewPhrase();
|
|
1408
|
-
_runTest(false, phrase);
|
|
1409
|
-
}, { bg: COLORS.btnTest });
|
|
1410
|
-
personalityTestBtn.top = 7;
|
|
1411
|
-
personalityTestBtn.left = 64;
|
|
1412
|
-
|
|
1413
|
-
const personalityFileText = blessed.text({
|
|
1414
|
-
parent: box,
|
|
1415
|
-
top: 8,
|
|
1416
|
-
left: 22,
|
|
1417
|
-
right: 2,
|
|
1418
|
-
wrap: false,
|
|
1419
|
-
tags: true,
|
|
1420
|
-
content: '', // populated by refreshDisplay()
|
|
1421
|
-
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1422
|
-
});
|
|
1423
|
-
|
|
1424
|
-
// -------------------------------------------------------------------------
|
|
1425
|
-
// Section header: ── Intro Text ──
|
|
1426
|
-
|
|
1427
|
-
const introTextHeader = blessed.text({
|
|
1428
|
-
parent: box,
|
|
1429
|
-
top: 10,
|
|
1430
|
-
left: 1,
|
|
1431
|
-
content: '{bright-cyan-fg} ✍️ Intro Text {/bright-cyan-fg}',
|
|
1432
|
-
tags: true,
|
|
1433
|
-
style: { bg: COLORS.contentBg },
|
|
1434
|
-
});
|
|
1435
|
-
|
|
1436
|
-
// -------------------------------------------------------------------------
|
|
1437
|
-
// Intro Text row: label + value + [Edit] + [Clear] buttons
|
|
1438
|
-
|
|
1439
|
-
const introTextLabel = blessed.text({
|
|
1440
|
-
parent: box,
|
|
1441
|
-
top: 12,
|
|
1442
|
-
left: 6,
|
|
1443
|
-
content: 'Intro Text:',
|
|
1444
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1445
|
-
});
|
|
1446
|
-
|
|
1447
|
-
const introTextValue = blessed.text({
|
|
1448
|
-
parent: box,
|
|
1449
|
-
top: 12,
|
|
1450
|
-
left: 22,
|
|
1451
|
-
width: 26, // truncate before [Edit] at left:40
|
|
1452
|
-
wrap: false,
|
|
1453
|
-
content: '', // populated by refreshDisplay()
|
|
1454
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1455
|
-
});
|
|
1456
|
-
|
|
1457
|
-
const introEditBtn = _createButton(box, screen, 'Edit', COLORS, () => {
|
|
1458
|
-
_openIntroTextEditor(screen, configService, () => { refreshDisplay(); }, _restoreFocus);
|
|
1459
|
-
}, { bg: COLORS.btnEdit });
|
|
1460
|
-
introEditBtn.top = 12;
|
|
1461
|
-
introEditBtn.left = 52;
|
|
1462
|
-
|
|
1463
|
-
const introClearBtn = _createButton(box, screen, 'Clear', COLORS, () => {
|
|
1464
|
-
configService.set('pretext', '');
|
|
1465
|
-
refreshDisplay();
|
|
1466
|
-
}, { bg: '#c62828' });
|
|
1467
|
-
introClearBtn.top = 12;
|
|
1468
|
-
introClearBtn.left = 64;
|
|
1469
|
-
|
|
1470
|
-
const introPathText = blessed.text({
|
|
1471
|
-
parent: box,
|
|
1472
|
-
top: 13,
|
|
1473
|
-
left: 22,
|
|
1474
|
-
right: 2,
|
|
1475
|
-
wrap: false,
|
|
1476
|
-
content: '.agentvibes/config.json',
|
|
1477
|
-
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1478
|
-
});
|
|
1479
|
-
|
|
1480
|
-
// Full Preview button — voice + reverb + background track combined
|
|
1481
|
-
const fullPreviewBtn = _createButton(box, screen, '▶ Full Preview', COLORS, () => _runTest(true));
|
|
1482
|
-
fullPreviewBtn.bottom = 0;
|
|
1483
|
-
fullPreviewBtn.left = 2;
|
|
1484
|
-
|
|
1485
|
-
// -------------------------------------------------------------------------
|
|
1486
|
-
// Section header: 📡 Audio Destination
|
|
1487
|
-
|
|
1488
|
-
const audioDstHeader = blessed.text({
|
|
1489
|
-
parent: box,
|
|
1490
|
-
top: 3,
|
|
1491
|
-
left: 2,
|
|
1492
|
-
content: '{bright-cyan-fg} 📡 Audio Destination {/bright-cyan-fg}',
|
|
1493
|
-
tags: true,
|
|
1494
|
-
style: { bg: COLORS.contentBg },
|
|
1495
|
-
});
|
|
1496
|
-
|
|
1497
|
-
// -------------------------------------------------------------------------
|
|
1498
|
-
// Destination row: label + value + [Change] button
|
|
1499
|
-
|
|
1500
|
-
const audioDstLabel = blessed.text({
|
|
1501
|
-
parent: box,
|
|
1502
|
-
top: 5,
|
|
1503
|
-
left: 6,
|
|
1504
|
-
content: 'Destination:',
|
|
1505
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1506
|
-
});
|
|
1507
|
-
|
|
1508
|
-
const audioDstValue = blessed.text({
|
|
1509
|
-
parent: box,
|
|
1510
|
-
top: 5,
|
|
1511
|
-
left: 22,
|
|
1512
|
-
width: 26,
|
|
1513
|
-
wrap: false,
|
|
1514
|
-
content: '', // populated by refreshDisplay()
|
|
1515
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1516
|
-
});
|
|
1517
|
-
|
|
1518
|
-
const audioDstChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1519
|
-
const aliases = _detectSshAliases();
|
|
1520
|
-
const current = configService.getConfig().audio_destination ?? 'local';
|
|
1521
|
-
const choices = ['local', 'remote'];
|
|
1522
|
-
const nextIdx = (choices.indexOf(current) + 1) % choices.length;
|
|
1523
|
-
const next = choices[nextIdx];
|
|
1524
|
-
configService.set('audio_destination', next);
|
|
1525
|
-
if (next === 'remote' && !(configService.getConfig().audio_ssh_alias)) {
|
|
1526
|
-
// Prompt for alias immediately if switching to remote with no alias set
|
|
1527
|
-
const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
|
|
1528
|
-
const prompt = blessed.prompt({
|
|
1529
|
-
parent: screen,
|
|
1530
|
-
top: 'center', left: 'center',
|
|
1531
|
-
height: 'shrink', width: '60%',
|
|
1532
|
-
border: 'line', tags: true,
|
|
1533
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
|
|
1534
|
-
});
|
|
1535
|
-
prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
|
|
1536
|
-
aliases[0] ?? '',
|
|
1537
|
-
(err, val) => {
|
|
1538
|
-
prompt.destroy();
|
|
1539
|
-
if (!err && val && val.trim()) {
|
|
1540
|
-
const trimmed = val.trim();
|
|
1541
|
-
if (/[;&|`$(){}\\<>]/.test(trimmed)) {
|
|
1542
|
-
_showNotice(screen, 'Invalid alias — special characters not allowed');
|
|
1543
|
-
} else {
|
|
1544
|
-
configService.set('audio_ssh_alias', trimmed);
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
refreshDisplay();
|
|
1548
|
-
screen.render();
|
|
1549
|
-
});
|
|
1550
|
-
screen.render();
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
refreshDisplay();
|
|
1554
|
-
}, { bg: COLORS.btnChange });
|
|
1555
|
-
audioDstChangeBtn.top = 5;
|
|
1556
|
-
audioDstChangeBtn.left = 52;
|
|
1557
|
-
|
|
1558
|
-
// -------------------------------------------------------------------------
|
|
1559
|
-
// SSH Alias row: label + value + [Edit] + [stream mode toggle] buttons
|
|
1560
|
-
// Hidden when destination is Local — shown/hidden by refreshDisplay()
|
|
1561
|
-
|
|
1562
|
-
const audioSshLabel = blessed.text({
|
|
1563
|
-
parent: box,
|
|
1564
|
-
top: 7,
|
|
1565
|
-
left: 6,
|
|
1566
|
-
hidden: true,
|
|
1567
|
-
content: 'SSH Alias:',
|
|
1568
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1569
|
-
});
|
|
1570
|
-
|
|
1571
|
-
const audioSshValue = blessed.text({
|
|
1572
|
-
parent: box,
|
|
1573
|
-
top: 7,
|
|
1574
|
-
left: 22,
|
|
1575
|
-
width: 26,
|
|
1576
|
-
wrap: false,
|
|
1577
|
-
hidden: true,
|
|
1578
|
-
content: '', // populated by refreshDisplay()
|
|
1579
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1580
|
-
});
|
|
1581
|
-
|
|
1582
|
-
const audioSshEditBtn = _createButton(box, screen, 'Edit', COLORS, () => {
|
|
1583
|
-
const aliases = _detectSshAliases();
|
|
1584
|
-
const current = configService.getConfig().audio_ssh_alias ?? '';
|
|
1585
|
-
const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
|
|
1586
|
-
const prompt = blessed.prompt({
|
|
1587
|
-
parent: screen,
|
|
1588
|
-
top: 'center', left: 'center',
|
|
1589
|
-
height: 'shrink', width: '60%',
|
|
1590
|
-
border: 'line', tags: true,
|
|
1591
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
|
|
1592
|
-
});
|
|
1593
|
-
prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
|
|
1594
|
-
current || (aliases[0] ?? ''),
|
|
1595
|
-
(err, val) => {
|
|
1596
|
-
prompt.destroy();
|
|
1597
|
-
if (!err && val !== null) {
|
|
1598
|
-
const trimmed = val.trim();
|
|
1599
|
-
if (/[;&|`$(){}\\<>]/.test(trimmed)) {
|
|
1600
|
-
_showNotice(screen, 'Invalid alias — special characters not allowed');
|
|
1601
|
-
} else {
|
|
1602
|
-
configService.set('audio_ssh_alias', trimmed);
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
refreshDisplay();
|
|
1606
|
-
screen.render();
|
|
1607
|
-
});
|
|
1608
|
-
screen.render();
|
|
1609
|
-
}, { bg: COLORS.btnEdit });
|
|
1610
|
-
audioSshEditBtn.top = 7;
|
|
1611
|
-
audioSshEditBtn.left = 52;
|
|
1612
|
-
audioSshEditBtn.hide();
|
|
1613
|
-
|
|
1614
|
-
// Stream mode toggle
|
|
1615
|
-
// Streaming Text Only = send TTS text to remote AgentVibes Receiver which speaks locally (no audio data transfer)
|
|
1616
|
-
// Streaming Pulse Audio = stream audio file over SSH/PulseAudio tunnel
|
|
1617
|
-
const audioStreamModeBtn = _createButton(box, screen, 'Streaming Text Only ✓', COLORS, () => {
|
|
1618
|
-
const current = configService.getConfig().audio_stream_mode ?? 'text';
|
|
1619
|
-
configService.set('audio_stream_mode', current === 'text' ? 'pulse' : 'text');
|
|
1620
|
-
refreshDisplay();
|
|
1621
|
-
}, { bg: '#1565c0' }); // blue — distinct from green focus
|
|
1622
|
-
audioStreamModeBtn.top = 7;
|
|
1623
|
-
audioStreamModeBtn.left = 64;
|
|
1624
|
-
audioStreamModeBtn.hide();
|
|
1625
|
-
|
|
1626
|
-
// Explanation note
|
|
1627
|
-
const audioExplanationNote = blessed.text({
|
|
1628
|
-
parent: box,
|
|
1629
|
-
top: 9,
|
|
1630
|
-
left: 6,
|
|
1631
|
-
right: 2,
|
|
1632
|
-
wrap: false,
|
|
1633
|
-
tags: true,
|
|
1634
|
-
content: `{#546e7a-fg}Remote: sends TTS over SSH. Text Only = remote speaks (no audio transfer). Pulse = streams audio.{/#546e7a-fg}`,
|
|
1635
|
-
style: { bg: COLORS.contentBg },
|
|
1636
|
-
});
|
|
1637
|
-
|
|
1638
|
-
// -------------------------------------------------------------------------
|
|
1639
|
-
// Section header: 💾 Config Storage
|
|
1640
|
-
|
|
1641
|
-
const configStorageHeader = blessed.text({
|
|
1642
|
-
parent: box,
|
|
1643
|
-
top: 11,
|
|
1644
|
-
left: 2,
|
|
1645
|
-
content: '{bright-cyan-fg} 💾 Config Storage {/bright-cyan-fg}',
|
|
1646
|
-
tags: true,
|
|
1647
|
-
style: { bg: COLORS.contentBg },
|
|
1648
|
-
});
|
|
1649
|
-
|
|
1650
|
-
// Info row 1: global config path
|
|
1651
|
-
const configGlobalLabel = blessed.text({
|
|
1652
|
-
parent: box,
|
|
1653
|
-
top: 12,
|
|
1654
|
-
left: 6,
|
|
1655
|
-
content: 'Global:',
|
|
1656
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1657
|
-
});
|
|
1658
|
-
|
|
1659
|
-
const configGlobalValue = blessed.text({
|
|
1660
|
-
parent: box,
|
|
1661
|
-
top: 12,
|
|
1662
|
-
left: 22,
|
|
1663
|
-
right: 2,
|
|
1664
|
-
wrap: false,
|
|
1665
|
-
content: '', // populated by refreshConfigDisplay()
|
|
1666
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1667
|
-
});
|
|
1668
|
-
|
|
1669
|
-
// Info row 2: local config path (or "None")
|
|
1670
|
-
const configLocalLabel = blessed.text({
|
|
1671
|
-
parent: box,
|
|
1672
|
-
top: 13,
|
|
1673
|
-
left: 6,
|
|
1674
|
-
content: 'Local:',
|
|
1675
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1676
|
-
});
|
|
1677
|
-
|
|
1678
|
-
const configLocalValue = blessed.text({
|
|
1679
|
-
parent: box,
|
|
1680
|
-
top: 13,
|
|
1681
|
-
left: 22,
|
|
1682
|
-
right: 2,
|
|
1683
|
-
wrap: false,
|
|
1684
|
-
content: '', // populated by refreshConfigDisplay()
|
|
1685
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1686
|
-
});
|
|
1687
|
-
|
|
1688
|
-
// Action buttons row — right column, row 17
|
|
1689
|
-
const saveGloballyBtn = _createButton(box, screen, 'Save Globally', COLORS, () => {
|
|
1690
|
-
const data = configService.getConfig();
|
|
1691
|
-
const configPath = configService.getGlobalConfigPath();
|
|
1692
|
-
_showSavePreview(screen, configPath, data, () => {
|
|
1693
|
-
configService.saveAllToGlobal(data);
|
|
1694
|
-
applyTrackToAudioEffects(data.backgroundMusic?.track);
|
|
1695
|
-
refreshConfigDisplay();
|
|
1696
|
-
_showNotice(screen, 'Settings Saved');
|
|
1697
|
-
}, () => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); });
|
|
1698
|
-
}, { bg: '#7b1fa2' }); // purple
|
|
1699
|
-
saveGloballyBtn.bottom = 0;
|
|
1700
|
-
saveGloballyBtn.left = 24;
|
|
1701
|
-
|
|
1702
|
-
const saveLocallyBtn = _createButton(box, screen, 'Save Locally', COLORS, () => {
|
|
1703
|
-
const data = configService.getConfig();
|
|
1704
|
-
const configPath = configService.getLocalConfigPath();
|
|
1705
|
-
_showSavePreview(screen, configPath, data, () => {
|
|
1706
|
-
configService.saveAllToLocal(data);
|
|
1707
|
-
applyTrackToAudioEffects(data.backgroundMusic?.track);
|
|
1708
|
-
refreshConfigDisplay();
|
|
1709
|
-
_showNotice(screen, 'Settings Saved');
|
|
1710
|
-
}, () => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); });
|
|
1711
|
-
}, { bg: '#1565c0' }); // blue — distinct from green focus
|
|
1712
|
-
saveLocallyBtn.bottom = 0;
|
|
1713
|
-
saveLocallyBtn.left = 46;
|
|
1714
|
-
|
|
1715
|
-
const cancelChangesBtn = _createButton(box, screen, 'Cancel Changes', COLORS, () => {
|
|
1716
|
-
// Restore global config to snapshot taken at tab open
|
|
1717
|
-
if (_snapshotGlobal !== null) configService.saveAllToGlobal(_snapshotGlobal);
|
|
1718
|
-
// Restore (or remove) local config
|
|
1719
|
-
if (_snapshotLocal !== null) {
|
|
1720
|
-
configService.saveAllToLocal(_snapshotLocal);
|
|
1721
|
-
} else {
|
|
1722
|
-
// Local didn't exist at tab open — remove it if created during this session
|
|
1723
|
-
const localPath = configService.getLocalConfigPath();
|
|
1724
|
-
try { if (fs.existsSync(localPath)) fs.unlinkSync(localPath); } catch {}
|
|
1725
|
-
}
|
|
1726
|
-
refreshDisplay();
|
|
1727
|
-
refreshConfigDisplay();
|
|
1728
|
-
_showNotice(screen, 'Changes reverted');
|
|
1729
|
-
}, { bg: '#c62828' }); // red
|
|
1730
|
-
cancelChangesBtn.bottom = 0;
|
|
1731
|
-
cancelChangesBtn.left = 66;
|
|
1732
|
-
|
|
1733
|
-
// -------------------------------------------------------------------------
|
|
1734
|
-
// Section: 🌐 Language
|
|
1735
|
-
|
|
1736
|
-
const languageSectionHeader = blessed.text({
|
|
1737
|
-
parent: box,
|
|
1738
|
-
top: 3,
|
|
1739
|
-
left: 1,
|
|
1740
|
-
content: '{bright-cyan-fg} 🌐 Language {/bright-cyan-fg}',
|
|
1741
|
-
tags: true,
|
|
1742
|
-
style: { bg: COLORS.contentBg },
|
|
1743
|
-
});
|
|
1744
|
-
|
|
1745
|
-
const languageCurrentLabel = blessed.text({
|
|
1746
|
-
parent: box,
|
|
1747
|
-
top: 5,
|
|
1748
|
-
left: 6,
|
|
1749
|
-
content: 'Language:',
|
|
1750
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1751
|
-
});
|
|
1752
|
-
|
|
1753
|
-
const languageCurrentValue = blessed.text({
|
|
1754
|
-
parent: box,
|
|
1755
|
-
top: 5,
|
|
1756
|
-
left: 22,
|
|
1757
|
-
width: 30,
|
|
1758
|
-
wrap: false,
|
|
1759
|
-
content: '',
|
|
1760
|
-
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1761
|
-
});
|
|
1762
|
-
|
|
1763
|
-
// Visible list height — cap at 10 rows (fits standard 24-row terminals with headers)
|
|
1764
|
-
const LANG_LIST_HEIGHT = Math.min(SUPPORTED_LANGUAGES.length, 10);
|
|
1765
|
-
|
|
1766
|
-
// blessed.list: natively focusable, handles selection highlight, scrolling.
|
|
1767
|
-
// keys:true needed so keypress events propagate to the element (keyable=true).
|
|
1768
|
-
// We immediately removeAllListeners('keypress') to strip blessed's built-in up/down nav —
|
|
1769
|
-
// our manual .key() handlers (registered on 'key down'/'key up') are the sole navigators.
|
|
1770
|
-
const languageList = blessed.list({
|
|
1771
|
-
parent: box,
|
|
1772
|
-
top: 7,
|
|
1773
|
-
left: 4,
|
|
1774
|
-
width: 44,
|
|
1775
|
-
height: LANG_LIST_HEIGHT + 2, // +2 for border
|
|
1776
|
-
keys: true,
|
|
1777
|
-
mouse: true,
|
|
1778
|
-
tags: false,
|
|
1779
|
-
items: SUPPORTED_LANGUAGES.map(l => l.name),
|
|
1780
|
-
style: {
|
|
1781
|
-
selected: { bg: 'green', fg: 'white', bold: true },
|
|
1782
|
-
item: { fg: 'white' },
|
|
1783
|
-
border: { fg: 'cyan' },
|
|
1784
|
-
focus: { border: { fg: 'yellow' } },
|
|
1785
|
-
},
|
|
1786
|
-
border: { type: 'line' },
|
|
1787
|
-
scrollable: true,
|
|
1788
|
-
scrollbar: { style: { bg: 'blue' } },
|
|
1789
|
-
});
|
|
1790
|
-
// Strip blessed's built-in keypress nav (up/down/j/k/etc.) — our .key() handlers take over.
|
|
1791
|
-
// .key() registers on 'key <name>' events (not 'keypress'), so they survive this removal.
|
|
1792
|
-
languageList.removeAllListeners('keypress');
|
|
1793
|
-
|
|
1794
|
-
// Hint shown below the list
|
|
1795
|
-
const langHint = blessed.text({
|
|
1796
|
-
parent: box,
|
|
1797
|
-
top: 7 + LANG_LIST_HEIGHT + 3,
|
|
1798
|
-
left: 4,
|
|
1799
|
-
content: '{gray-fg}↑↓ navigate · Enter to apply{/gray-fg}',
|
|
1800
|
-
tags: true,
|
|
1801
|
-
style: { bg: COLORS.contentBg },
|
|
1802
|
-
});
|
|
1803
|
-
|
|
1804
|
-
// Apply button — kept for mouse users; keyboard users just press Enter on the list
|
|
1805
|
-
const langApplyBtn = _createButton(box, screen, '✓ Apply Language', COLORS, () => {
|
|
1806
|
-
const selected = SUPPORTED_LANGUAGES[languageList.selected ?? 0];
|
|
1807
|
-
if (selected && services.languageService) {
|
|
1808
|
-
services.languageService.setLang(selected.value);
|
|
1809
|
-
refreshLanguageDisplay();
|
|
1810
|
-
_showNotice(screen, `Language: ${selected.name}`);
|
|
1811
|
-
}
|
|
1812
|
-
}, { bg: '#2e7d32' });
|
|
1813
|
-
langApplyBtn.top = 7 + LANG_LIST_HEIGHT + 5;
|
|
1814
|
-
langApplyBtn.left = 4;
|
|
1815
|
-
|
|
1816
|
-
function refreshLanguageDisplay() {
|
|
1817
|
-
const currentLang = services.languageService?.getLang() ?? 'en';
|
|
1818
|
-
const found = SUPPORTED_LANGUAGES.find(l => l.value === currentLang);
|
|
1819
|
-
languageCurrentValue.setContent(found ? found.name : currentLang);
|
|
1820
|
-
const idx = SUPPORTED_LANGUAGES.findIndex(l => l.value === currentLang);
|
|
1821
|
-
if (idx >= 0) languageList.select(idx);
|
|
1822
|
-
screen.render();
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// Key navigation wired after _navigateRow is defined (see below)
|
|
1826
|
-
|
|
1827
|
-
// -------------------------------------------------------------------------
|
|
1828
|
-
// Display state + button-level focus navigation (story 7.6)
|
|
1829
|
-
|
|
1830
|
-
// Widget groups for each sub-tab (used by _showSubTab to show/hide)
|
|
1831
|
-
const _subTabWidgets = {
|
|
1832
|
-
voice: [
|
|
1833
|
-
providerVoiceHeader,
|
|
1834
|
-
providerLabel, providerValue, switchBtn,
|
|
1835
|
-
voiceLabel, voiceValue, changeBtn, playBtn, voiceFileText,
|
|
1836
|
-
],
|
|
1837
|
-
effects: [
|
|
1838
|
-
audioEffectsHeader,
|
|
1839
|
-
reverbLabel, reverbValue, reverbChangeBtn, reverbTestBtn, reverbPathText,
|
|
1840
|
-
bgMusicHeader,
|
|
1841
|
-
trackLabel, trackValue, trackChangeBtn, musicToggleBtn, musicTestBtn, trackPathText,
|
|
1842
|
-
volumeLabel, volumeValue, volumeChangeBtn,
|
|
1843
|
-
],
|
|
1844
|
-
personality: [
|
|
1845
|
-
styleHeader,
|
|
1846
|
-
verbosityLabel, verbosityValue, verbosityChangeBtn, verbosityPathText,
|
|
1847
|
-
personalityLabel, personalityValue, personalityChangeBtn, personalityTestBtn, personalityFileText,
|
|
1848
|
-
introTextHeader,
|
|
1849
|
-
introTextLabel, introTextValue, introEditBtn, introClearBtn, introPathText,
|
|
1850
|
-
],
|
|
1851
|
-
output: [
|
|
1852
|
-
audioDstHeader,
|
|
1853
|
-
audioDstLabel, audioDstValue, audioDstChangeBtn,
|
|
1854
|
-
audioSshLabel, audioSshValue, audioSshEditBtn, audioStreamModeBtn, audioExplanationNote,
|
|
1855
|
-
configStorageHeader,
|
|
1856
|
-
configGlobalLabel, configGlobalValue,
|
|
1857
|
-
configLocalLabel, configLocalValue,
|
|
1858
|
-
],
|
|
1859
|
-
language: [
|
|
1860
|
-
languageSectionHeader,
|
|
1861
|
-
languageCurrentLabel, languageCurrentValue,
|
|
1862
|
-
languageList, langHint, langApplyBtn,
|
|
1863
|
-
],
|
|
1864
|
-
};
|
|
1865
|
-
|
|
1866
|
-
// Row groups per sub-tab for ↑↓ navigation
|
|
1867
|
-
const _rowsBySubTab = {
|
|
1868
|
-
voice: [[switchBtn], [changeBtn, playBtn]],
|
|
1869
|
-
effects: [[reverbChangeBtn, reverbTestBtn], [trackChangeBtn, musicToggleBtn, musicTestBtn], [volumeChangeBtn]],
|
|
1870
|
-
personality: [[verbosityChangeBtn], [personalityChangeBtn, personalityTestBtn], [introEditBtn, introClearBtn]],
|
|
1871
|
-
output: [[audioDstChangeBtn], [audioSshEditBtn, audioStreamModeBtn]],
|
|
1872
|
-
language: [[languageList], [langApplyBtn]],
|
|
1873
|
-
};
|
|
1874
|
-
|
|
1875
|
-
const _subTabItemsArray = SUB_TABS.map(id => _subTabItemsMap[id]);
|
|
1876
|
-
|
|
1877
|
-
function _showSubTab(name, keepFocusOnBar = false) {
|
|
1878
|
-
_activeSubTab = name;
|
|
1879
|
-
|
|
1880
|
-
// Hide all section widgets; clear any active blink intervals on hidden buttons
|
|
1881
|
-
for (const widgets of Object.values(_subTabWidgets)) {
|
|
1882
|
-
for (const w of widgets) {
|
|
1883
|
-
if (w._btnBlinkInterval) { clearInterval(w._btnBlinkInterval); w._btnBlinkInterval = null; }
|
|
1884
|
-
w.hide();
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
// Show active section widgets (SSH row controlled by refreshDisplay, not here)
|
|
1889
|
-
const sshSpecific = [audioSshLabel, audioSshValue, audioSshEditBtn, audioStreamModeBtn];
|
|
1890
|
-
for (const w of _subTabWidgets[name]) {
|
|
1891
|
-
if (sshSpecific.includes(w)) continue;
|
|
1892
|
-
w.show();
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
// If showing output tab, let refreshDisplay control SSH row visibility
|
|
1896
|
-
if (name === 'output') refreshDisplay();
|
|
1897
|
-
|
|
1898
|
-
// Rebuild _rows: [subTabRow, ...contentRows, fullPreview, save, cancel]
|
|
1899
|
-
_rows.length = 0;
|
|
1900
|
-
_rows.push(_subTabItemsArray);
|
|
1901
|
-
for (const row of _rowsBySubTab[name]) _rows.push(row);
|
|
1902
|
-
_rows.push([fullPreviewBtn]);
|
|
1903
|
-
_rows.push([saveGloballyBtn, saveLocallyBtn, cancelChangesBtn]);
|
|
1904
|
-
|
|
1905
|
-
_updateSubTabBar();
|
|
1906
|
-
|
|
1907
|
-
if (!keepFocusOnBar) {
|
|
1908
|
-
const firstRow = _rowsBySubTab[name].find(row => !row[0].hidden);
|
|
1909
|
-
if (firstRow) {
|
|
1910
|
-
_currentIdx = _buttons.indexOf(firstRow[0]);
|
|
1911
|
-
_focusButton(firstRow[0]);
|
|
1912
|
-
}
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
screen.render();
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
const _buttons = [
|
|
1919
|
-
_subTabItemsMap.voice, _subTabItemsMap.effects,
|
|
1920
|
-
_subTabItemsMap.personality, _subTabItemsMap.output, _subTabItemsMap.language,
|
|
1921
|
-
switchBtn, changeBtn, playBtn,
|
|
1922
|
-
reverbChangeBtn, reverbTestBtn,
|
|
1923
|
-
trackChangeBtn, musicToggleBtn, musicTestBtn,
|
|
1924
|
-
volumeChangeBtn,
|
|
1925
|
-
verbosityChangeBtn, personalityChangeBtn, personalityTestBtn,
|
|
1926
|
-
introEditBtn, introClearBtn,
|
|
1927
|
-
audioDstChangeBtn, audioSshEditBtn, audioStreamModeBtn,
|
|
1928
|
-
languageList, langApplyBtn,
|
|
1929
|
-
fullPreviewBtn,
|
|
1930
|
-
saveGloballyBtn, saveLocallyBtn, cancelChangesBtn,
|
|
1931
|
-
];
|
|
1932
|
-
|
|
1933
|
-
// Restore focus to the active settings button after any modal closes.
|
|
1934
|
-
const _restoreFocus = () => _focusButton(_buttons[_currentIdx]);
|
|
1935
|
-
|
|
1936
|
-
// Register test buttons for label sync (reverb + full preview share state)
|
|
1937
|
-
_testBtns.push(reverbTestBtn, personalityTestBtn, fullPreviewBtn);
|
|
1938
|
-
_testBtnLabels.set(reverbTestBtn, '▶ Preview');
|
|
1939
|
-
_testBtnLabels.set(personalityTestBtn, '▶ Preview');
|
|
1940
|
-
_testBtnLabels.set(fullPreviewBtn, '▶ Full Preview');
|
|
1941
|
-
|
|
1942
|
-
let _currentIdx = 0;
|
|
1943
|
-
|
|
1944
|
-
// Map each button to its row label + value widgets for focus-highlight
|
|
1945
|
-
const _buttonToLabel = new Map([
|
|
1946
|
-
[switchBtn, providerLabel],
|
|
1947
|
-
[changeBtn, voiceLabel],
|
|
1948
|
-
[playBtn, voiceLabel],
|
|
1949
|
-
[reverbChangeBtn, reverbLabel],
|
|
1950
|
-
[reverbTestBtn, reverbLabel],
|
|
1951
|
-
[trackChangeBtn, trackLabel],
|
|
1952
|
-
[musicToggleBtn, trackLabel],
|
|
1953
|
-
[musicTestBtn, trackLabel],
|
|
1954
|
-
[volumeChangeBtn, volumeLabel],
|
|
1955
|
-
[verbosityChangeBtn, verbosityLabel],
|
|
1956
|
-
[personalityChangeBtn, personalityLabel],
|
|
1957
|
-
[personalityTestBtn, personalityLabel],
|
|
1958
|
-
[introEditBtn, introTextLabel],
|
|
1959
|
-
[introClearBtn, introTextLabel],
|
|
1960
|
-
[audioDstChangeBtn, audioDstLabel],
|
|
1961
|
-
[audioSshEditBtn, audioSshLabel],
|
|
1962
|
-
[audioStreamModeBtn, audioDstLabel],
|
|
1963
|
-
]);
|
|
1964
|
-
|
|
1965
|
-
const _buttonToValue = new Map([
|
|
1966
|
-
[switchBtn, providerValue],
|
|
1967
|
-
[changeBtn, voiceValue],
|
|
1968
|
-
[playBtn, voiceValue],
|
|
1969
|
-
[reverbChangeBtn, reverbValue],
|
|
1970
|
-
[reverbTestBtn, reverbValue],
|
|
1971
|
-
[trackChangeBtn, trackValue],
|
|
1972
|
-
[musicToggleBtn, trackValue],
|
|
1973
|
-
[musicTestBtn, trackValue],
|
|
1974
|
-
[volumeChangeBtn, volumeValue],
|
|
1975
|
-
[verbosityChangeBtn, verbosityValue],
|
|
1976
|
-
[personalityChangeBtn, personalityValue],
|
|
1977
|
-
[personalityTestBtn, personalityValue],
|
|
1978
|
-
[introEditBtn, introTextValue],
|
|
1979
|
-
[introClearBtn, introTextValue],
|
|
1980
|
-
[audioDstChangeBtn, audioDstValue],
|
|
1981
|
-
[audioSshEditBtn, audioSshValue],
|
|
1982
|
-
[audioStreamModeBtn, audioDstValue],
|
|
1983
|
-
]);
|
|
1984
|
-
|
|
1985
|
-
// Sync _currentIdx; highlight label (cyan) + value (bright blue + underline) on focus
|
|
1986
|
-
for (const [i, btn] of _buttons.entries()) {
|
|
1987
|
-
btn.on('focus', () => {
|
|
1988
|
-
_currentIdx = i;
|
|
1989
|
-
const lbl = _buttonToLabel.get(btn);
|
|
1990
|
-
if (lbl) lbl.style.fg = COLORS.btnFocus;
|
|
1991
|
-
const val = _buttonToValue.get(btn);
|
|
1992
|
-
if (val) { val.style.fg = COLORS.btnFocus; val.style.underline = true; }
|
|
1993
|
-
});
|
|
1994
|
-
btn.on('blur', () => {
|
|
1995
|
-
const lbl = _buttonToLabel.get(btn);
|
|
1996
|
-
if (lbl) lbl.style.fg = COLORS.labelFg;
|
|
1997
|
-
const val = _buttonToValue.get(btn);
|
|
1998
|
-
if (val) { val.style.fg = COLORS.valueFg; val.style.underline = false; }
|
|
1999
|
-
});
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
// Shared focus helper — suppresses intermediate renders, force-invalidates olines.
|
|
2003
|
-
// Prevents the olines desync artifact where setContent() updates lines[] but
|
|
2004
|
-
// olines[] stays stale, causing draw() to skip repainting those cells.
|
|
2005
|
-
function _focusButton(btn) {
|
|
2006
|
-
const _orig = screen.render.bind(screen);
|
|
2007
|
-
screen.render = () => {};
|
|
2008
|
-
try { btn.focus(); } finally { screen.render = _orig; }
|
|
2009
|
-
|
|
2010
|
-
screen.clearRegion(0, screen.cols, 4, screen.rows - 2);
|
|
2011
|
-
for (let r = 4; r < screen.rows - 2; r++) {
|
|
2012
|
-
const orow = screen.olines[r];
|
|
2013
|
-
if (!orow) continue;
|
|
2014
|
-
for (let c = 0; c < screen.cols; c++) {
|
|
2015
|
-
if (orow[c]) orow[c][0] = -1;
|
|
2016
|
-
}
|
|
2017
|
-
orow.dirty = true;
|
|
2018
|
-
}
|
|
2019
|
-
screen.render();
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
// ↓ / ↑ → navigate between row groups (skips siblings; use ←/→ for those)
|
|
2023
|
-
|
|
2024
|
-
// Returns the first non-hidden button in a row, or the first button if all are hidden.
|
|
2025
|
-
// Needed because some rows have a hidden first button (e.g. [changeBtn, playBtn] when
|
|
2026
|
-
// provider is not piper — changeBtn is hidden but playBtn is still reachable).
|
|
2027
|
-
function _firstVisibleBtn(row) {
|
|
2028
|
-
return row.find(b => !b.hidden) ?? row[0];
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
function _isRowVisible(row) {
|
|
2032
|
-
return row.some(b => !b.hidden);
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
function _navigateRow(delta) {
|
|
2036
|
-
const focused = _buttons[_currentIdx];
|
|
2037
|
-
let rowIdx = _rows.findIndex(row => row.includes(focused));
|
|
2038
|
-
if (rowIdx === -1) rowIdx = 0;
|
|
2039
|
-
// At the sub-tab bar (row 0): pressing ↑ moves focus to the main header tab bar
|
|
2040
|
-
if (rowIdx === 0 && delta < 0) {
|
|
2041
|
-
if (typeof focusMainTabBar === 'function') focusMainTabBar();
|
|
2042
|
-
return;
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
// Skip rows where ALL buttons are hidden (e.g. SSH alias row when destination is local).
|
|
2046
|
-
// Use _firstVisibleBtn so we land on the first visible button in a mixed row.
|
|
2047
|
-
let attempts = 0;
|
|
2048
|
-
do {
|
|
2049
|
-
rowIdx = (rowIdx + delta + _rows.length) % _rows.length;
|
|
2050
|
-
attempts++;
|
|
2051
|
-
} while (!_isRowVisible(_rows[rowIdx]) && attempts < _rows.length);
|
|
2052
|
-
// When landing on the sub-tab bar (row 0), focus the ACTIVE sub-tab item, not the first one
|
|
2053
|
-
const btn = rowIdx === 0
|
|
2054
|
-
? (_subTabItemsMap[_activeSubTab] ?? _firstVisibleBtn(_rows[0]))
|
|
2055
|
-
: _firstVisibleBtn(_rows[rowIdx]);
|
|
2056
|
-
_currentIdx = _buttons.indexOf(btn);
|
|
2057
|
-
_focusButton(btn);
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
for (const btn of _buttons) {
|
|
2061
|
-
btn.key(['down'], () => {
|
|
2062
|
-
if (btn === languageList) return; // languageList has its own boundary-aware down handler
|
|
2063
|
-
_navigateRow(1);
|
|
2064
|
-
});
|
|
2065
|
-
btn.key(['up'], () => {
|
|
2066
|
-
if (btn === languageList) return; // languageList has its own boundary-aware up handler
|
|
2067
|
-
_navigateRow(-1);
|
|
2068
|
-
});
|
|
2069
|
-
btn.key(['escape'], () => { if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0); });
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
// Language list — fully manual navigation (keys:false on the list disables blessed's built-in
|
|
2073
|
-
// so only our handlers run, giving us clean boundary detection without double-move issues).
|
|
2074
|
-
languageList.key(['down'], () => {
|
|
2075
|
-
const cur = languageList.selected ?? 0;
|
|
2076
|
-
if (cur >= SUPPORTED_LANGUAGES.length - 1) {
|
|
2077
|
-
_navigateRow(1); // past last item → Apply button
|
|
2078
|
-
} else {
|
|
2079
|
-
languageList.select(cur + 1);
|
|
2080
|
-
screen.render();
|
|
2081
|
-
}
|
|
2082
|
-
});
|
|
2083
|
-
languageList.key(['up'], () => {
|
|
2084
|
-
const cur = languageList.selected ?? 0;
|
|
2085
|
-
if (cur <= 0) {
|
|
2086
|
-
_navigateRow(-1); // past first item → sub-tab bar
|
|
2087
|
-
} else {
|
|
2088
|
-
languageList.select(cur - 1);
|
|
2089
|
-
screen.render();
|
|
2090
|
-
}
|
|
2091
|
-
});
|
|
2092
|
-
languageList.key(['enter', 'return', 'space'], () => {
|
|
2093
|
-
const selected = SUPPORTED_LANGUAGES[languageList.selected ?? 0];
|
|
2094
|
-
if (selected && services.languageService) {
|
|
2095
|
-
services.languageService.setLang(selected.value);
|
|
2096
|
-
refreshLanguageDisplay();
|
|
2097
|
-
_showNotice(screen, `Language: ${selected.name}`);
|
|
2098
|
-
}
|
|
2099
|
-
});
|
|
2100
|
-
languageList.key(['escape'], () => { if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0); });
|
|
2101
|
-
|
|
2102
|
-
// ← / → within content rows — uses _buttonGroups (static); sub-tab bar has its own wiring
|
|
2103
|
-
const _rows = []; // populated dynamically by _showSubTab()
|
|
2104
|
-
|
|
2105
|
-
const _buttonGroups = [
|
|
2106
|
-
[switchBtn], [changeBtn, playBtn],
|
|
2107
|
-
[reverbChangeBtn, reverbTestBtn],
|
|
2108
|
-
[trackChangeBtn, musicToggleBtn, musicTestBtn],
|
|
2109
|
-
[volumeChangeBtn],
|
|
2110
|
-
[verbosityChangeBtn],
|
|
2111
|
-
[personalityChangeBtn, personalityTestBtn],
|
|
2112
|
-
[introEditBtn, introClearBtn],
|
|
2113
|
-
[audioDstChangeBtn],
|
|
2114
|
-
[audioSshEditBtn, audioStreamModeBtn],
|
|
2115
|
-
[languageList],
|
|
2116
|
-
[langApplyBtn],
|
|
2117
|
-
[fullPreviewBtn, saveGloballyBtn, saveLocallyBtn, cancelChangesBtn],
|
|
2118
|
-
];
|
|
2119
|
-
|
|
2120
|
-
for (const row of _buttonGroups) {
|
|
2121
|
-
for (let i = 0; i < row.length; i++) {
|
|
2122
|
-
if (i < row.length - 1) {
|
|
2123
|
-
row[i].key(['right'], () => {
|
|
2124
|
-
// Skip hidden siblings (e.g. SSH/stream mode when destination is local)
|
|
2125
|
-
let next = i + 1;
|
|
2126
|
-
while (next < row.length && row[next].hidden) next++;
|
|
2127
|
-
if (next < row.length) { _currentIdx = _buttons.indexOf(row[next]); _focusButton(row[next]); }
|
|
2128
|
-
});
|
|
2129
|
-
}
|
|
2130
|
-
if (i > 0) {
|
|
2131
|
-
row[i].key(['left'], () => {
|
|
2132
|
-
let prev = i - 1;
|
|
2133
|
-
while (prev >= 0 && row[prev].hidden) prev--;
|
|
2134
|
-
if (prev >= 0) { _currentIdx = _buttons.indexOf(row[prev]); _focusButton(row[prev]); }
|
|
2135
|
-
});
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
// Tab/S-tab cycle: bottom buttons ↔ main header tab bar
|
|
2141
|
-
// Debounce prevents key-repeat from firing on the newly-focused button in the same stroke.
|
|
2142
|
-
let _tabBusy = false;
|
|
2143
|
-
const _withTabDebounce = (fn) => () => {
|
|
2144
|
-
if (_tabBusy) return;
|
|
2145
|
-
_tabBusy = true;
|
|
2146
|
-
setTimeout(() => { _tabBusy = false; }, 120);
|
|
2147
|
-
fn();
|
|
2148
|
-
};
|
|
2149
|
-
|
|
2150
|
-
fullPreviewBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); }));
|
|
2151
|
-
saveGloballyBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); }));
|
|
2152
|
-
saveLocallyBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(cancelChangesBtn); _focusButton(cancelChangesBtn); }));
|
|
2153
|
-
cancelChangesBtn.key(['tab'], _withTabDebounce(() => { if (typeof focusFirstHeaderItem === 'function') focusFirstHeaderItem(); }));
|
|
2154
|
-
|
|
2155
|
-
fullPreviewBtn.key(['S-tab'], _withTabDebounce(() => { if (typeof focusLastHeaderItem === 'function') focusLastHeaderItem(); }));
|
|
2156
|
-
saveGloballyBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(fullPreviewBtn); _focusButton(fullPreviewBtn); }));
|
|
2157
|
-
saveLocallyBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); }));
|
|
2158
|
-
cancelChangesBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); }));
|
|
2159
|
-
|
|
2160
|
-
// Wire sub-tab ←/→ and Tab/S-tab to switch sub-tabs
|
|
2161
|
-
for (let i = 0; i < SUB_TABS.length; i++) {
|
|
2162
|
-
const item = _subTabItemsMap[SUB_TABS[i]];
|
|
2163
|
-
if (i < SUB_TABS.length - 1) {
|
|
2164
|
-
item.key(['right'], () => {
|
|
2165
|
-
_showSubTab(SUB_TABS[i + 1], true);
|
|
2166
|
-
_focusButton(_subTabItemsArray[i + 1]);
|
|
2167
|
-
});
|
|
2168
|
-
}
|
|
2169
|
-
if (i > 0) {
|
|
2170
|
-
item.key(['left'], () => {
|
|
2171
|
-
_showSubTab(SUB_TABS[i - 1], true);
|
|
2172
|
-
_focusButton(_subTabItemsArray[i - 1]);
|
|
2173
|
-
});
|
|
2174
|
-
}
|
|
2175
|
-
// Tab/S-tab wrap-cycle through sub-tab bar (independent of global header Tab cycle)
|
|
2176
|
-
item.key(['tab'], () => {
|
|
2177
|
-
const next = (i + 1) % SUB_TABS.length;
|
|
2178
|
-
_showSubTab(SUB_TABS[next], true);
|
|
2179
|
-
_focusButton(_subTabItemsArray[next]);
|
|
2180
|
-
});
|
|
2181
|
-
item.key(['S-tab'], () => {
|
|
2182
|
-
const prev = (i - 1 + SUB_TABS.length) % SUB_TABS.length;
|
|
2183
|
-
_showSubTab(SUB_TABS[prev], true);
|
|
2184
|
-
_focusButton(_subTabItemsArray[prev]);
|
|
2185
|
-
});
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
|
-
// Initialize with Voice sub-tab active
|
|
2189
|
-
_showSubTab('voice');
|
|
2190
|
-
_currentIdx = _buttons.indexOf(switchBtn);
|
|
2191
|
-
|
|
2192
|
-
// Keyboard shortcuts for direct sub-tab access
|
|
2193
|
-
box.key(['l', 'L'], () => { _showSubTab('language'); });
|
|
2194
|
-
|
|
2195
|
-
// @function _refreshSopranoStatus
|
|
2196
|
-
// @intent Update the provider status glyph (🟢/🟡/🔴) without blocking the render loop
|
|
2197
|
-
// @why soprano-manager status does an HTTP health-check (up to 2s) — must be async
|
|
2198
|
-
function _refreshSopranoStatus() {
|
|
2199
|
-
if (_sopranoStatusProc) {
|
|
2200
|
-
try { _sopranoStatusProc.kill(); } catch {}
|
|
2201
|
-
_sopranoStatusProc = null;
|
|
2202
|
-
}
|
|
2203
|
-
const managerPath = path.resolve(new URL(import.meta.url).pathname,
|
|
2204
|
-
'..', '..', '..', '..', '.claude', 'hooks', 'soprano-manager.sh');
|
|
2205
|
-
if (!fs.existsSync(managerPath)) return;
|
|
2206
|
-
|
|
2207
|
-
const proc = spawn('bash', [managerPath, 'status'], {
|
|
2208
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
2209
|
-
});
|
|
2210
|
-
_sopranoStatusProc = proc;
|
|
2211
|
-
|
|
2212
|
-
proc.on('exit', (code) => {
|
|
2213
|
-
_sopranoStatusProc = null;
|
|
2214
|
-
// 0=running(🟢) 1=starting(🟡) 2=stopped(🔴) 3=conflict(🔴)
|
|
2215
|
-
_sopranoStatusGlyph = code === 0 ? ' 🟢' : code === 1 ? ' 🟡' : ' 🔴';
|
|
2216
|
-
if (providerService.getActiveProvider() === 'soprano') {
|
|
2217
|
-
const name = _ALL_PROVIDERS.find(p => p.id === 'soprano')?.name ?? 'Soprano';
|
|
2218
|
-
providerValue.setContent(name + _sopranoStatusGlyph);
|
|
2219
|
-
screen.render();
|
|
2220
|
-
}
|
|
2221
|
-
});
|
|
2222
|
-
|
|
2223
|
-
proc.on('error', () => { _sopranoStatusProc = null; });
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
function refreshDisplay() {
|
|
2227
|
-
const activeProvider = providerService.getActiveProvider();
|
|
2228
|
-
const activeVoice = providerService.getActiveVoiceId();
|
|
2229
|
-
const provName = _ALL_PROVIDERS.find(p => p.id === activeProvider)?.name ?? activeProvider;
|
|
2230
|
-
if (activeProvider === 'soprano') {
|
|
2231
|
-
// Show cached glyph immediately, kick off async refresh for updated status
|
|
2232
|
-
providerValue.setContent(provName + _sopranoStatusGlyph);
|
|
2233
|
-
_refreshSopranoStatus();
|
|
2234
|
-
} else {
|
|
2235
|
-
providerValue.setContent(provName);
|
|
2236
|
-
// Cancel any pending status check and clear glyph when leaving soprano
|
|
2237
|
-
_sopranoStatusGlyph = '';
|
|
2238
|
-
if (_sopranoStatusProc) { try { _sopranoStatusProc.kill(); } catch {} _sopranoStatusProc = null; }
|
|
2239
|
-
}
|
|
2240
|
-
// Single-voice providers: show the provider name instead of voice ID
|
|
2241
|
-
// For multi-speaker voices, show speaker name (e.g., "Kristin_Hughes" not "16Speakers::Kristin_Hughes")
|
|
2242
|
-
const _msDisplay = parseMultiSpeaker(activeVoice);
|
|
2243
|
-
voiceValue.setContent(activeProvider === 'soprano' ? 'Soprano' : (_msDisplay.isMultiSpeaker ? _msDisplay.speakerName : activeVoice));
|
|
2244
|
-
// Only Piper supports multiple installed voices — hide Change for single-voice providers
|
|
2245
|
-
if (activeProvider === 'piper') { changeBtn.show(); playBtn.left = 64; voiceFileText.setContent('.claude/tts-voice.txt'); }
|
|
2246
|
-
else { changeBtn.hide(); playBtn.left = 52; voiceFileText.setContent(''); }
|
|
2247
|
-
|
|
2248
|
-
// Group 2: Audio Effects
|
|
2249
|
-
const effects = configService.getConfig().effects ?? EFFECTS_DEFAULTS;
|
|
2250
|
-
reverbValue.setContent(formatReverbState(effects.reverbPreset ?? 'light'));
|
|
2251
|
-
|
|
2252
|
-
// Group 3: Background Music
|
|
2253
|
-
const music = configService.getConfig().backgroundMusic ?? configService.getConfig().music ?? MUSIC_DEFAULTS;
|
|
2254
|
-
// Strip leading emoji so double-width chars don't misalign buttons on the same row
|
|
2255
|
-
trackValue.setContent(_stripLeadingEmoji(formatTrackName(music.track)));
|
|
2256
|
-
const musicEnabled = music.enabled ?? false;
|
|
2257
|
-
musicToggleBtn.setContent(musicEnabled ? 'Enabled' : 'Disabled');
|
|
2258
|
-
musicToggleBtn.style.bg = musicEnabled ? COLORS.btnEnableOn : COLORS.btnEnableOff;
|
|
2259
|
-
volumeValue.setContent(formatVolume(music.volume));
|
|
2260
|
-
|
|
2261
|
-
// Group 4: Personality & Verbosity
|
|
2262
|
-
const cfg = configService.getConfig();
|
|
2263
|
-
verbosityValue.setContent(formatVerbosity(cfg.verbosity));
|
|
2264
|
-
personalityValue.setContent(_stripLeadingEmoji(formatPersonality(cfg.personality)));
|
|
2265
|
-
const _pers = (cfg.personality ?? '').trim();
|
|
2266
|
-
personalityFileText.setContent(
|
|
2267
|
-
(_pers && _pers !== 'none' && _pers !== 'normal')
|
|
2268
|
-
? `.claude/personalities/${_pers}.md`
|
|
2269
|
-
: '',
|
|
2270
|
-
);
|
|
2271
|
-
|
|
2272
|
-
// Group 5: Intro Text
|
|
2273
|
-
introTextValue.setContent(formatIntroText(cfg.pretext));
|
|
2274
|
-
|
|
2275
|
-
// Group 6: Audio Destination
|
|
2276
|
-
const audioDst = cfg.audio_destination ?? 'local';
|
|
2277
|
-
const audioAlias = cfg.audio_ssh_alias ?? '';
|
|
2278
|
-
audioDstValue.setContent(formatAudioDst(audioDst, audioAlias));
|
|
2279
|
-
// Show/hide SSH Alias row and stream mode toggle based on destination
|
|
2280
|
-
if (audioDst === 'remote') {
|
|
2281
|
-
audioSshLabel.show();
|
|
2282
|
-
audioSshValue.show();
|
|
2283
|
-
audioSshEditBtn.show();
|
|
2284
|
-
audioStreamModeBtn.show();
|
|
2285
|
-
audioSshValue.setContent(audioAlias || '(none)');
|
|
2286
|
-
const streamMode = cfg.audio_stream_mode ?? 'text';
|
|
2287
|
-
audioStreamModeBtn.setContent(streamMode === 'pulse' ? 'Streaming Pulse Audio' : 'Streaming Text Only ✓');
|
|
2288
|
-
audioStreamModeBtn.style.bg = streamMode === 'text' ? '#1565c0' : COLORS.btnChange;
|
|
2289
|
-
} else {
|
|
2290
|
-
audioSshLabel.hide();
|
|
2291
|
-
audioSshValue.hide();
|
|
2292
|
-
audioSshEditBtn.hide();
|
|
2293
|
-
audioStreamModeBtn.hide();
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
|
|
2297
|
-
screen.render();
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
function refreshConfigDisplay() {
|
|
2301
|
-
const globalPath = configService.getGlobalConfigPath();
|
|
2302
|
-
const localPath = configService.getLocalConfigPath();
|
|
2303
|
-
const hasLocal = configService.hasLocalConfig();
|
|
2304
|
-
// Abbreviate home dir with ~ for readability
|
|
2305
|
-
const home = os.homedir();
|
|
2306
|
-
const abbrev = (p) => p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
2307
|
-
configGlobalValue.setContent(abbrev(globalPath));
|
|
2308
|
-
// Local path shown in full (not abbreviated) so the user sees the real location
|
|
2309
|
-
configLocalValue.setContent(
|
|
2310
|
-
hasLocal ? localPath : 'None (settings saved to global)',
|
|
2311
|
-
);
|
|
2312
|
-
screen.render();
|
|
2313
|
-
}
|
|
2314
|
-
|
|
2315
|
-
// -------------------------------------------------------------------------
|
|
2316
|
-
// refreshLabels — update all static label/header/button strings to current language
|
|
2317
|
-
// Called from show() so strings update whenever the user returns to this tab after a lang change.
|
|
2318
|
-
|
|
2319
|
-
function refreshLabels() {
|
|
2320
|
-
// Sub-tab bar labels (content only — widths fixed at creation)
|
|
2321
|
-
for (const id of SUB_TABS) {
|
|
2322
|
-
_subTabItemsMap[id].setContent(_t(SUB_TAB_KEYS[id]));
|
|
2323
|
-
}
|
|
2324
|
-
// Section headers
|
|
2325
|
-
providerVoiceHeader.setContent(`{bright-cyan-fg}${_t('sectionProviderVoice')}{/bright-cyan-fg}`);
|
|
2326
|
-
audioEffectsHeader.setContent(`{bright-cyan-fg}${_t('sectionAudioEffects')}{/bright-cyan-fg}`);
|
|
2327
|
-
bgMusicHeader.setContent(`{bright-cyan-fg}${_t('sectionBgMusic')}{/bright-cyan-fg}`);
|
|
2328
|
-
styleHeader.setContent(`{bright-cyan-fg}${_t('sectionStyle')}{/bright-cyan-fg}`);
|
|
2329
|
-
introTextHeader.setContent(`{bright-cyan-fg}${_t('sectionIntroText')}{/bright-cyan-fg}`);
|
|
2330
|
-
audioDstHeader.setContent(`{bright-cyan-fg}${_t('sectionAudioDest')}{/bright-cyan-fg}`);
|
|
2331
|
-
configStorageHeader.setContent(`{bright-cyan-fg}${_t('sectionConfigStorage')}{/bright-cyan-fg}`);
|
|
2332
|
-
languageSectionHeader.setContent(`{bright-cyan-fg}${_t('sectionLanguage')}{/bright-cyan-fg}`);
|
|
2333
|
-
// Row labels
|
|
2334
|
-
providerLabel.setContent(_t('providerRowLabel'));
|
|
2335
|
-
voiceLabel.setContent(_t('currentVoiceLabel'));
|
|
2336
|
-
reverbLabel.setContent(_t('reverbLabel'));
|
|
2337
|
-
trackLabel.setContent(_t('trackLabel'));
|
|
2338
|
-
volumeLabel.setContent(_t('volumeLabel'));
|
|
2339
|
-
verbosityLabel.setContent(_t('verbosityLabel'));
|
|
2340
|
-
personalityLabel.setContent(_t('personalityLabel'));
|
|
2341
|
-
introTextLabel.setContent(_t('introTextRowLabel'));
|
|
2342
|
-
audioDstLabel.setContent(_t('destinationLabel'));
|
|
2343
|
-
audioSshLabel.setContent(_t('sshAliasLabel'));
|
|
2344
|
-
configGlobalLabel.setContent(_t('globalLabel'));
|
|
2345
|
-
configLocalLabel.setContent(_t('localLabel'));
|
|
2346
|
-
languageCurrentLabel.setContent(_t('languageLabel'));
|
|
2347
|
-
// Buttons (only the ones with fixed labels — not dynamic state buttons)
|
|
2348
|
-
switchBtn.setContent(_t('switchBtn'));
|
|
2349
|
-
changeBtn.setContent(_t('changeBtn'));
|
|
2350
|
-
reverbChangeBtn.setContent(_t('changeBtn'));
|
|
2351
|
-
trackChangeBtn.setContent(_t('changeBtn'));
|
|
2352
|
-
volumeChangeBtn.setContent(_t('changeBtn'));
|
|
2353
|
-
verbosityChangeBtn.setContent(_t('changeBtn'));
|
|
2354
|
-
personalityChangeBtn.setContent(_t('changeBtn'));
|
|
2355
|
-
audioDstChangeBtn.setContent(_t('changeBtn'));
|
|
2356
|
-
playBtn.setContent(_t('playBtn'));
|
|
2357
|
-
reverbTestBtn.setContent(_t('previewBtn'));
|
|
2358
|
-
_testBtnLabels.set(reverbTestBtn, _t('previewBtn'));
|
|
2359
|
-
personalityTestBtn.setContent(_t('previewBtn'));
|
|
2360
|
-
_testBtnLabels.set(personalityTestBtn, _t('previewBtn'));
|
|
2361
|
-
fullPreviewBtn.setContent(_t('fullPreviewBtn'));
|
|
2362
|
-
_testBtnLabels.set(fullPreviewBtn, _t('fullPreviewBtn'));
|
|
2363
|
-
musicTestBtn.setContent(_t('previewBtn'));
|
|
2364
|
-
saveGloballyBtn.setContent(_t('saveGloballyBtn'));
|
|
2365
|
-
saveLocallyBtn.setContent(_t('saveLocallyBtn'));
|
|
2366
|
-
cancelChangesBtn.setContent(_t('cancelChangesBtn'));
|
|
2367
|
-
introEditBtn.setContent(_t('editBtn'));
|
|
2368
|
-
introClearBtn.setContent(_t('clearBtn'));
|
|
2369
|
-
audioSshEditBtn.setContent(_t('editBtn'));
|
|
2370
|
-
langApplyBtn.setContent(_t('applyLanguageBtn'));
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
// -------------------------------------------------------------------------
|
|
2374
|
-
// Tab Component Contract implementation
|
|
2375
|
-
|
|
2376
|
-
return {
|
|
2377
|
-
box,
|
|
2378
|
-
|
|
2379
|
-
show() {
|
|
2380
|
-
_captureSnapshot();
|
|
2381
|
-
box.show();
|
|
2382
|
-
refreshLabels();
|
|
2383
|
-
refreshDisplay();
|
|
2384
|
-
refreshConfigDisplay();
|
|
2385
|
-
refreshLanguageDisplay();
|
|
2386
|
-
// Force full olines invalidation — prevents ghost rows when the tab becomes visible
|
|
2387
|
-
try {
|
|
2388
|
-
for (let r = 0; r < screen.height; r++)
|
|
2389
|
-
for (let c = 0; c < screen.width; c++)
|
|
2390
|
-
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2391
|
-
} catch {}
|
|
2392
|
-
screen.render();
|
|
2393
|
-
},
|
|
2394
|
-
|
|
2395
|
-
hide() {
|
|
2396
|
-
_killSample();
|
|
2397
|
-
playBtn.setContent('▶ Play');
|
|
2398
|
-
_killTest();
|
|
2399
|
-
_restoreTestBtnsLabels();
|
|
2400
|
-
_killMusicTest();
|
|
2401
|
-
musicTestBtn.setContent('▶ Preview');
|
|
2402
|
-
// Kill any pending soprano status check
|
|
2403
|
-
if (_sopranoStatusProc) {
|
|
2404
|
-
try { _sopranoStatusProc.kill(); } catch {}
|
|
2405
|
-
_sopranoStatusProc = null;
|
|
2406
|
-
}
|
|
2407
|
-
box.hide();
|
|
2408
|
-
screen.render();
|
|
2409
|
-
},
|
|
2410
|
-
|
|
2411
|
-
onFocus() {
|
|
2412
|
-
// Land on the active sub-tab bar item so the user can ↑↓ from there.
|
|
2413
|
-
// Use _focusButton (not raw .focus()) so olines get invalidated before render,
|
|
2414
|
-
// preventing the ghost-duplicate-row artifact on initial tab activation.
|
|
2415
|
-
const activeSubTabItem = _subTabItemsMap[_activeSubTab];
|
|
2416
|
-
_currentIdx = _buttons.indexOf(activeSubTabItem);
|
|
2417
|
-
_focusButton(activeSubTabItem);
|
|
2418
|
-
},
|
|
2419
|
-
|
|
2420
|
-
onBlur() {
|
|
2421
|
-
_killSample();
|
|
2422
|
-
playBtn.setContent('▶ Play');
|
|
2423
|
-
_killTest();
|
|
2424
|
-
_restoreTestBtnsLabels();
|
|
2425
|
-
_killMusicTest();
|
|
2426
|
-
musicTestBtn.setContent('▶ Preview');
|
|
2427
|
-
},
|
|
2428
|
-
|
|
2429
|
-
getFooterText() {
|
|
2430
|
-
return _t('settingsFooter');
|
|
2431
|
-
},
|
|
2432
|
-
|
|
2433
|
-
getFooterColor() {
|
|
2434
|
-
return COLORS.footerBg;
|
|
2435
|
-
},
|
|
2436
|
-
|
|
2437
|
-
focusBottomRow() {
|
|
2438
|
-
_currentIdx = _buttons.indexOf(fullPreviewBtn);
|
|
2439
|
-
_focusButton(fullPreviewBtn);
|
|
2440
|
-
},
|
|
2441
|
-
|
|
2442
|
-
focusLastBottomRow() {
|
|
2443
|
-
_currentIdx = _buttons.indexOf(cancelChangesBtn);
|
|
2444
|
-
_focusButton(cancelChangesBtn);
|
|
2445
|
-
},
|
|
2446
|
-
};
|
|
2447
|
-
}
|
|
2448
|
-
|
|
2449
|
-
// ---------------------------------------------------------------------------
|
|
2450
|
-
// Private: Create a styled focusable button
|
|
2451
|
-
|
|
2452
|
-
function _createButton(parent, screen, label, COLORS, onClick, opts = {}) {
|
|
2453
|
-
const baseBg = opts.bg ?? COLORS.btnDefault;
|
|
2454
|
-
const getDynamicBg = opts.getDynamicBg ?? null;
|
|
2455
|
-
const btn = blessed.button({
|
|
2456
|
-
parent,
|
|
2457
|
-
content: label,
|
|
2458
|
-
mouse: true,
|
|
2459
|
-
keys: true,
|
|
2460
|
-
shrink: true,
|
|
2461
|
-
padding: { left: 1, right: 1 },
|
|
2462
|
-
style: {
|
|
2463
|
-
bg: baseBg,
|
|
2464
|
-
fg: 'white',
|
|
2465
|
-
focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
2466
|
-
},
|
|
2467
|
-
});
|
|
2468
|
-
|
|
2469
|
-
// Focus indicators: ►label◄ with blinking █ cursor
|
|
2470
|
-
// Store interval on the button so it can be cleared when the button is hidden.
|
|
2471
|
-
btn._btnBlinkInterval = null;
|
|
2472
|
-
btn.on('focus', () => {
|
|
2473
|
-
btn.style.bg = COLORS.btnFocus;
|
|
2474
|
-
btn.style.fg = COLORS.btnFocusFg;
|
|
2475
|
-
const raw = btn.content.replace(/[►◄█]/g, '').trim();
|
|
2476
|
-
btn.setContent(`►${raw}◄ █`);
|
|
2477
|
-
let _on = true;
|
|
2478
|
-
screen.render();
|
|
2479
|
-
btn._btnBlinkInterval = setInterval(() => {
|
|
2480
|
-
_on = !_on;
|
|
2481
|
-
// Skip if spinner has overridden the content (no ► means spinner is active)
|
|
2482
|
-
if (!btn.content.includes('►')) return;
|
|
2483
|
-
const r = btn.content.replace(/[►◄█]/g, '').trim();
|
|
2484
|
-
btn.setContent(_on ? `►${r}◄ █` : `►${r}◄`);
|
|
2485
|
-
screen.render();
|
|
2486
|
-
}, 500);
|
|
2487
|
-
});
|
|
2488
|
-
btn.on('blur', () => {
|
|
2489
|
-
if (btn._btnBlinkInterval) { clearInterval(btn._btnBlinkInterval); btn._btnBlinkInterval = null; }
|
|
2490
|
-
btn.style.bg = getDynamicBg ? getDynamicBg() : baseBg;
|
|
2491
|
-
btn.style.fg = 'white';
|
|
2492
|
-
const raw = btn.content.replace(/[►◄█]/g, '').trim();
|
|
2493
|
-
btn.setContent(raw);
|
|
2494
|
-
screen.render();
|
|
2495
|
-
});
|
|
2496
|
-
|
|
2497
|
-
// Keyboard activation with magenta flash
|
|
2498
|
-
btn.key(['enter', 'space'], () => {
|
|
2499
|
-
btn.style.bg = COLORS.btnPress;
|
|
2500
|
-
btn.style.fg = 'white';
|
|
2501
|
-
screen.render();
|
|
2502
|
-
setTimeout(() => {
|
|
2503
|
-
btn.style.bg = getDynamicBg ? getDynamicBg() : baseBg;
|
|
2504
|
-
btn.style.fg = 'white';
|
|
2505
|
-
screen.render();
|
|
2506
|
-
onClick();
|
|
2507
|
-
}, 150);
|
|
2508
|
-
});
|
|
2509
|
-
|
|
2510
|
-
// Mouse click only — no mouseover so hover never causes render artifacts
|
|
2511
|
-
btn.on('click', () => btn.press());
|
|
2512
|
-
|
|
2513
|
-
return btn;
|
|
2514
|
-
}
|
|
2515
|
-
|
|
2516
|
-
// ---------------------------------------------------------------------------
|
|
2517
|
-
// Private: Provider picker modal — all providers, install status, instructions
|
|
2518
|
-
|
|
2519
|
-
const _ALL_PROVIDERS = [
|
|
2520
|
-
{ id: 'piper', name: 'Piper TTS', platforms: ['linux', 'darwin', 'win32'], desc: 'High-quality local neural TTS' },
|
|
2521
|
-
{ id: 'soprano', name: 'Soprano', platforms: ['linux', 'darwin'], desc: 'Ultra-fast neural TTS (single voice)' },
|
|
2522
|
-
{ id: 'sapi', name: 'Windows SAPI', platforms: ['win32'], desc: 'Windows built-in text-to-speech' },
|
|
2523
|
-
{ id: 'macos', name: 'Mac Say', platforms: ['darwin'], desc: 'macOS built-in text-to-speech' },
|
|
2524
|
-
];
|
|
2525
|
-
|
|
2526
|
-
const _INSTALL_CMDS = {
|
|
2527
|
-
piper: ['pip install piper-tts', 'OR: pipx install piper-tts', '', 'Voices are downloaded separately:', 'Run: agentvibes install (then choose Piper)'],
|
|
2528
|
-
soprano: ['pip install soprano-tts', 'OR: pipx install soprano-tts', '', 'Keep model loaded for fast synthesis:', 'soprano-webui'],
|
|
2529
|
-
sapi: ['Built-in on Windows — no install required.', 'Only works in a native Windows shell,', 'not inside WSL. Use piper or soprano in WSL.'],
|
|
2530
|
-
macos: ['Built-in on macOS — no install required.', 'The say command ships with every Mac.'],
|
|
2531
|
-
};
|
|
2532
|
-
|
|
2533
|
-
function _detectEnvLabel() {
|
|
2534
|
-
if (process.platform === 'win32') return { label: 'Windows', platform: 'win32' };
|
|
2535
|
-
if (process.platform === 'darwin') return { label: 'macOS', platform: 'darwin' };
|
|
2536
|
-
try {
|
|
2537
|
-
const v = fs.readFileSync('/proc/version', 'utf8');
|
|
2538
|
-
if (v.toLowerCase().includes('microsoft')) return { label: 'WSL (Linux/Microsoft)', platform: 'linux' };
|
|
2539
|
-
} catch {}
|
|
2540
|
-
return { label: 'Linux', platform: 'linux' };
|
|
2541
|
-
}
|
|
2542
|
-
|
|
2543
|
-
function _openProviderPicker(screen, providerService, onSelect, onClose) {
|
|
2544
|
-
const { label: envLabel, platform } = _detectEnvLabel();
|
|
2545
|
-
const installed = new Set(providerService.getInstalledProviders());
|
|
2546
|
-
const current = providerService.getActiveProvider();
|
|
2547
|
-
|
|
2548
|
-
const modal = blessed.box({
|
|
2549
|
-
parent: screen,
|
|
2550
|
-
top: 'center',
|
|
2551
|
-
left: 'center',
|
|
2552
|
-
width: 70,
|
|
2553
|
-
height: 24,
|
|
2554
|
-
border: { type: 'line' },
|
|
2555
|
-
tags: true,
|
|
2556
|
-
label: _modalTitle('Select Provider'),
|
|
2557
|
-
style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
|
|
2558
|
-
});
|
|
2559
|
-
|
|
2560
|
-
function _close() {
|
|
2561
|
-
modal.destroy();
|
|
2562
|
-
try {
|
|
2563
|
-
for (let r = 0; r < screen.height; r++)
|
|
2564
|
-
for (let c = 0; c < screen.width; c++)
|
|
2565
|
-
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2566
|
-
} catch {}
|
|
2567
|
-
onClose?.();
|
|
2568
|
-
screen.render();
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
// Environment header
|
|
2572
|
-
blessed.text({
|
|
2573
|
-
parent: modal, top: 0, left: 1, tags: true,
|
|
2574
|
-
content: `{bright-cyan-fg}🖥 Environment:{/bright-cyan-fg} {bold}${envLabel}{/bold}`,
|
|
2575
|
-
style: { bg: COLORS.contentBg },
|
|
2576
|
-
});
|
|
2577
|
-
blessed.text({
|
|
2578
|
-
parent: modal, top: 1, left: 0,
|
|
2579
|
-
content: ' ' + '─'.repeat(66),
|
|
2580
|
-
style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg },
|
|
2581
|
-
});
|
|
2582
|
-
|
|
2583
|
-
// Provider rows (top 2–5)
|
|
2584
|
-
const actionBtns = [];
|
|
2585
|
-
let focusIdx = 0;
|
|
2586
|
-
|
|
2587
|
-
_ALL_PROVIDERS.forEach((prov, i) => {
|
|
2588
|
-
const rowTop = 2 + (i * 2); // 2 rows per provider: name row + description row
|
|
2589
|
-
const isSupported = prov.platforms.includes(platform);
|
|
2590
|
-
const isInstalled = installed.has(prov.id);
|
|
2591
|
-
const isCurrent = prov.id === current;
|
|
2592
|
-
|
|
2593
|
-
if (!isSupported) {
|
|
2594
|
-
const osMap = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' };
|
|
2595
|
-
const forOs = prov.platforms.map(p => osMap[p] ?? p).join('/');
|
|
2596
|
-
blessed.text({
|
|
2597
|
-
parent: modal, top: rowTop, left: 1, width: 66, tags: true,
|
|
2598
|
-
content: `{#546e7a-fg}✗ ${prov.name} — only on: ${forOs}{/#546e7a-fg}`,
|
|
2599
|
-
style: { bg: COLORS.contentBg },
|
|
2600
|
-
});
|
|
2601
|
-
blessed.text({
|
|
2602
|
-
parent: modal, top: rowTop + 1, left: 5, width: 62, tags: true,
|
|
2603
|
-
content: `{#455a64-fg}${prov.desc}{/#455a64-fg}`,
|
|
2604
|
-
style: { bg: COLORS.contentBg },
|
|
2605
|
-
});
|
|
2606
|
-
return;
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
const icon = isInstalled ? '{green-fg}✓{/green-fg}' : '{#ef9a9a-fg}✗{/#ef9a9a-fg}';
|
|
2610
|
-
const name = isInstalled ? `{bold}${prov.name}{/bold}` : prov.name;
|
|
2611
|
-
const active = isCurrent ? ' {yellow-fg}[active]{/yellow-fg}' : '';
|
|
2612
|
-
const status = isInstalled ? '{green-fg}Installed{/green-fg}' : '{#ef9a9a-fg}Not found{/#ef9a9a-fg}';
|
|
2613
|
-
|
|
2614
|
-
blessed.text({ parent: modal, top: rowTop, left: 1, width: 30, tags: true, content: `${icon} ${name}${active}`, style: { bg: COLORS.contentBg } });
|
|
2615
|
-
blessed.text({ parent: modal, top: rowTop, left: 44, width: 12, tags: true, content: status, style: { bg: COLORS.contentBg } });
|
|
2616
|
-
blessed.text({ parent: modal, top: rowTop + 1, left: 5, width: 60, tags: true,
|
|
2617
|
-
content: `{#90a4ae-fg}${prov.desc}{/#90a4ae-fg}`, style: { bg: COLORS.contentBg } });
|
|
2618
|
-
|
|
2619
|
-
const btn = _createButton(modal, screen, isInstalled ? 'Select' : 'Install', COLORS, () => {
|
|
2620
|
-
if (isInstalled) {
|
|
2621
|
-
_close(); onSelect(prov.id);
|
|
2622
|
-
} else {
|
|
2623
|
-
const lines = _INSTALL_CMDS[prov.id] ?? ['No instructions available.'];
|
|
2624
|
-
instrTitle.setContent(`{bright-cyan-fg}Install — ${prov.name}:{/bright-cyan-fg}`);
|
|
2625
|
-
instrContent.setContent(lines.map(l => l ? `{bright-cyan-fg}${l}{/bright-cyan-fg}` : '').join('\n'));
|
|
2626
|
-
screen.render();
|
|
2627
|
-
}
|
|
2628
|
-
});
|
|
2629
|
-
btn.top = rowTop; btn.left = 57;
|
|
2630
|
-
if (isCurrent) focusIdx = actionBtns.length;
|
|
2631
|
-
actionBtns.push(btn);
|
|
2632
|
-
});
|
|
2633
|
-
|
|
2634
|
-
// Separator + instructions panel (shifted down 4 rows due to 2-row provider layout)
|
|
2635
|
-
blessed.text({ parent: modal, top: 10, left: 0, content: ' ' + '─'.repeat(66), style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg } });
|
|
2636
|
-
|
|
2637
|
-
const instrTitle = blessed.text({
|
|
2638
|
-
parent: modal, top: 11, left: 1, width: 66, tags: true,
|
|
2639
|
-
content: '{bright-cyan-fg}Install instructions — click Install beside a provider:{/bright-cyan-fg}',
|
|
2640
|
-
style: { bg: COLORS.contentBg },
|
|
2641
|
-
});
|
|
2642
|
-
const instrContent = blessed.text({
|
|
2643
|
-
parent: modal, top: 12, left: 3, width: 64, height: 5, tags: true,
|
|
2644
|
-
content: '{#546e7a-fg}(click Install beside a provider to see commands){/#546e7a-fg}',
|
|
2645
|
-
style: { bg: COLORS.contentBg },
|
|
2646
|
-
});
|
|
2647
|
-
|
|
2648
|
-
// Bottom separator + Cancel
|
|
2649
|
-
blessed.text({ parent: modal, top: 18, left: 0, content: ' ' + '─'.repeat(66), style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg } });
|
|
2650
|
-
|
|
2651
|
-
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close);
|
|
2652
|
-
cancelBtn.top = 19; cancelBtn.left = 'center';
|
|
2653
|
-
actionBtns.push(cancelBtn);
|
|
2654
|
-
|
|
2655
|
-
// Keyboard navigation
|
|
2656
|
-
for (let i = 0; i < actionBtns.length; i++) {
|
|
2657
|
-
actionBtns[i].key(['down', 'tab'], () => {
|
|
2658
|
-
const cur = actionBtns.findIndex(b => b === screen.focused);
|
|
2659
|
-
actionBtns[(cur + 1) % actionBtns.length].focus();
|
|
2660
|
-
});
|
|
2661
|
-
actionBtns[i].key(['up', 'S-tab'], () => {
|
|
2662
|
-
const cur = actionBtns.findIndex(b => b === screen.focused);
|
|
2663
|
-
actionBtns[(cur - 1 + actionBtns.length) % actionBtns.length].focus();
|
|
2664
|
-
});
|
|
2665
|
-
}
|
|
2666
|
-
modal.key(['escape', 'q'], _close);
|
|
2667
|
-
|
|
2668
|
-
(actionBtns[focusIdx] ?? actionBtns[0])?.focus();
|
|
2669
|
-
screen.render();
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
|
-
// ---------------------------------------------------------------------------
|
|
2673
|
-
// Private: Destroy helper — now imported from shared widgets/destroy-list.js
|
|
2674
|
-
// (kept as comment for git blame traceability)
|
|
2675
|
-
|
|
2676
|
-
// NOTE: The following line was the old _destroyList definition, now using shared import:
|
|
2677
|
-
// import { destroyList } from '../widgets/destroy-list.js';
|
|
2678
|
-
//
|
|
2679
|
-
// Old code removed to eliminate duplication (M1 fix).
|
|
2680
|
-
// The shared destroyList has identical behavior.
|
|
2681
|
-
|
|
2682
|
-
// ---------------------------------------------------------------------------
|
|
2683
|
-
// Private: Show a temporary stub notice text
|
|
2684
|
-
|
|
2685
|
-
// Strip a leading emoji character (code points > U+2500 cover emoji ranges)
|
|
2686
|
-
// while preserving punctuation like en-dash (U+2013) and em-dash (U+2014).
|
|
2687
|
-
function _stripLeadingEmoji(s) {
|
|
2688
|
-
if (!s) return s;
|
|
2689
|
-
const cp = s.codePointAt(0);
|
|
2690
|
-
return cp > 0x2500 ? s.slice(String.fromCodePoint(cp).length).trimStart() : s;
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
/**
|
|
2694
|
-
* Show a "Save Preview" confirmation modal.
|
|
2695
|
-
* Displays the destination path and all key-value pairs that will be saved.
|
|
2696
|
-
* User must press [OK — Save] to confirm or [Cancel] to abort.
|
|
2697
|
-
*
|
|
2698
|
-
* @param {object} screen - blessed screen
|
|
2699
|
-
* @param {string} filePath - absolute destination path
|
|
2700
|
-
* @param {object} data - config object to be saved
|
|
2701
|
-
* @param {Function} onConfirm - called only if user presses OK
|
|
2702
|
-
*/
|
|
2703
|
-
function _showSavePreview(screen, filePath, data, onConfirm, onClose) {
|
|
2704
|
-
// Flatten nested objects one level deep
|
|
2705
|
-
const rawLines = [];
|
|
2706
|
-
for (const [k, v] of Object.entries(data)) {
|
|
2707
|
-
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
2708
|
-
for (const [sk, sv] of Object.entries(v)) {
|
|
2709
|
-
rawLines.push([`${k}.${sk}`, String(sv ?? '')]);
|
|
2710
|
-
}
|
|
2711
|
-
} else {
|
|
2712
|
-
rawLines.push([k, String(v ?? '')]);
|
|
2713
|
-
}
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
|
-
const keyWidth = rawLines.length ? Math.max(...rawLines.map(([k]) => k.length)) : 0;
|
|
2717
|
-
const pathLine = ` Path: ${filePath}`;
|
|
2718
|
-
const kvMaxW = rawLines.length ? Math.max(...rawLines.map(([k, v]) => 2 + keyWidth + 2 + v.length)) : 0;
|
|
2719
|
-
const innerW = Math.max(52, pathLine.length + 2, kvMaxW + 4);
|
|
2720
|
-
const width = Math.min(innerW + 4, screen.width - 4);
|
|
2721
|
-
const sep = '─'.repeat(Math.max(0, Math.min(innerW - 2, width - 6)));
|
|
2722
|
-
|
|
2723
|
-
const taggedKV = rawLines.map(([k, v]) =>
|
|
2724
|
-
` {#90a4ae-fg}${k.padEnd(keyWidth)}:{/#90a4ae-fg} {#ffff00-fg}${v}{/#ffff00-fg}`
|
|
2725
|
-
);
|
|
2726
|
-
|
|
2727
|
-
// Content rows (all text rendered via box.content; buttons are child widgets)
|
|
2728
|
-
const contentLines = [
|
|
2729
|
-
` {#90a4ae-fg}Path:{/#90a4ae-fg} ${filePath}`,
|
|
2730
|
-
` ${sep}`,
|
|
2731
|
-
...taggedKV,
|
|
2732
|
-
` ${sep}`,
|
|
2733
|
-
'', // blank row — buttons sit here as child widgets
|
|
2734
|
-
];
|
|
2735
|
-
|
|
2736
|
-
const height = contentLines.length + 2; // +2 for top/bottom border
|
|
2737
|
-
|
|
2738
|
-
const modal = blessed.box({
|
|
2739
|
-
parent: screen,
|
|
2740
|
-
top: 'center',
|
|
2741
|
-
left: 'center',
|
|
2742
|
-
width,
|
|
2743
|
-
height,
|
|
2744
|
-
label: _modalTitle('Save Preview'),
|
|
2745
|
-
border: { type: 'line' },
|
|
2746
|
-
tags: true,
|
|
2747
|
-
content: contentLines.join('\n'),
|
|
2748
|
-
style: {
|
|
2749
|
-
fg: '#e3f2fd',
|
|
2750
|
-
bg: COLORS.contentBg,
|
|
2751
|
-
border: { fg: 'bright-cyan' },
|
|
2752
|
-
},
|
|
2753
|
-
});
|
|
2754
|
-
|
|
2755
|
-
function _close() { destroyList(modal, screen, onClose); }
|
|
2756
|
-
|
|
2757
|
-
modal.key(['escape'], _close);
|
|
2758
|
-
|
|
2759
|
-
// Buttons are children of the modal box; top is relative to box content area
|
|
2760
|
-
const btnRow = contentLines.length - 1; // last content line (the blank row)
|
|
2761
|
-
const midX = Math.floor((width - 2) / 2);
|
|
2762
|
-
|
|
2763
|
-
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close, { bg: '#c62828' });
|
|
2764
|
-
cancelBtn.top = btnRow;
|
|
2765
|
-
cancelBtn.left = midX - 14;
|
|
2766
|
-
|
|
2767
|
-
const okBtn = _createButton(modal, screen, 'OK — Save', COLORS, () => {
|
|
2768
|
-
_close();
|
|
2769
|
-
onConfirm();
|
|
2770
|
-
}, { bg: '#1565c0' });
|
|
2771
|
-
okBtn.top = btnRow;
|
|
2772
|
-
okBtn.left = midX + 2;
|
|
2773
|
-
|
|
2774
|
-
// Keyboard navigation between OK and Cancel buttons
|
|
2775
|
-
okBtn.key(['tab', 'left', 'right'], () => { cancelBtn.focus(); screen.render(); });
|
|
2776
|
-
cancelBtn.key(['tab', 'left', 'right'], () => { okBtn.focus(); screen.render(); });
|
|
2777
|
-
|
|
2778
|
-
screen.render();
|
|
2779
|
-
okBtn.focus();
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
function _showNotice(screen, message) {
|
|
2783
|
-
_showNoticeWidget(screen, message, { bg: COLORS.contentBg });
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
// ---------------------------------------------------------------------------
|
|
2787
|
-
// Private: Effects config read/write helpers
|
|
2788
|
-
|
|
2789
|
-
function _getEffects(configService) {
|
|
2790
|
-
return configService.getConfig().effects ?? EFFECTS_DEFAULTS;
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
function _setEffects(configService, partial) {
|
|
2794
|
-
const current = configService.getConfig().effects ?? EFFECTS_DEFAULTS;
|
|
2795
|
-
const merged = { ...current, ...partial };
|
|
2796
|
-
configService.set('effects', merged);
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
|
-
// ---------------------------------------------------------------------------
|
|
2800
|
-
// Private: _openReverbPicker removed — now using shared import:
|
|
2801
|
-
// import { openReverbPicker } from '../widgets/reverb-picker.js';
|
|
2802
|
-
|
|
2803
|
-
// ---------------------------------------------------------------------------
|
|
2804
|
-
// Private: Background music config read/write helpers
|
|
2805
|
-
|
|
2806
|
-
function _getMusic(configService) {
|
|
2807
|
-
return configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
|
-
function _setMusic(configService, partial) {
|
|
2811
|
-
const current = configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
|
|
2812
|
-
const merged = { ...current, ...partial };
|
|
2813
|
-
configService.set('backgroundMusic', merged);
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
// ---------------------------------------------------------------------------
|
|
2817
|
-
// Private: Inline track picker
|
|
2818
|
-
|
|
2819
|
-
function _openTrackPicker(screen, configService, onSelect, onClose) {
|
|
2820
|
-
// Scan .claude/audio/tracks/ dynamically; fall back to BUILT_IN_TRACKS if missing.
|
|
2821
|
-
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
2822
|
-
let tracks;
|
|
2823
|
-
try {
|
|
2824
|
-
const files = fs.readdirSync(tracksDir);
|
|
2825
|
-
tracks = files
|
|
2826
|
-
.filter(f => /\.mp3$/i.test(f))
|
|
2827
|
-
.sort()
|
|
2828
|
-
.map(f => ({ file: f, label: formatTrackName(f) }));
|
|
2829
|
-
} catch {
|
|
2830
|
-
tracks = BUILT_IN_TRACKS;
|
|
2831
|
-
}
|
|
2832
|
-
|
|
2833
|
-
const ADD_SENTINEL = '__ADD_CUSTOM_TRACK__';
|
|
2834
|
-
const allItems = [...tracks, { file: ADD_SENTINEL, label: '+ Add Custom Track' }];
|
|
2835
|
-
|
|
2836
|
-
const currentTrack = (configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track);
|
|
2837
|
-
const items = allItems.map(t =>
|
|
2838
|
-
t.file === ADD_SENTINEL
|
|
2839
|
-
? ` {bright-cyan-fg}+ Add Custom Track{/bright-cyan-fg}`
|
|
2840
|
-
: (t.file === currentTrack ? `● ${t.label}` : ` ${t.label}`)
|
|
2841
|
-
);
|
|
2842
|
-
const currentIdx = tracks.findIndex(t => t.file === currentTrack);
|
|
2843
|
-
|
|
2844
|
-
const listHeight = Math.min(allItems.length + 4, Math.floor(screen.rows * 0.7));
|
|
2845
|
-
const list = blessed.list({
|
|
2846
|
-
parent: screen,
|
|
2847
|
-
top: 'center',
|
|
2848
|
-
left: 'center',
|
|
2849
|
-
width: 50,
|
|
2850
|
-
height: listHeight,
|
|
2851
|
-
border: { type: 'line' },
|
|
2852
|
-
tags: true,
|
|
2853
|
-
label: _modalTitle('Select Track'),
|
|
2854
|
-
items,
|
|
2855
|
-
keys: true,
|
|
2856
|
-
vi: false,
|
|
2857
|
-
mouse: true,
|
|
2858
|
-
scrollable: true,
|
|
2859
|
-
scrollbar: { ch: '│', track: { bg: '#1e2a3a' }, style: { fg: COLORS.btnFocus } },
|
|
2860
|
-
style: {
|
|
2861
|
-
border: { fg: COLORS.btnFocus },
|
|
2862
|
-
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
2863
|
-
item: { fg: '#e3f2fd' },
|
|
2864
|
-
},
|
|
2865
|
-
});
|
|
2866
|
-
|
|
2867
|
-
if (currentIdx >= 0) list.select(currentIdx);
|
|
2868
|
-
list.focus();
|
|
2869
|
-
screen.render();
|
|
2870
|
-
|
|
2871
|
-
list.key(['enter', 'space'], () => {
|
|
2872
|
-
const selected = allItems[list.selected];
|
|
2873
|
-
if (!selected) return;
|
|
2874
|
-
if (selected.file === ADD_SENTINEL) {
|
|
2875
|
-
// Destroy list first, then open path-input dialog
|
|
2876
|
-
destroyList(list, screen);
|
|
2877
|
-
_openCustomTrackInput(screen, tracksDir, (newFile) => {
|
|
2878
|
-
onSelect(newFile);
|
|
2879
|
-
}, onClose);
|
|
2880
|
-
return;
|
|
2881
|
-
}
|
|
2882
|
-
destroyList(list, screen, onClose);
|
|
2883
|
-
onSelect(selected.file);
|
|
2884
|
-
});
|
|
2885
|
-
|
|
2886
|
-
list.key(['escape', 'q'], () => {
|
|
2887
|
-
destroyList(list, screen, onClose);
|
|
2888
|
-
});
|
|
2889
|
-
}
|
|
2890
|
-
|
|
2891
|
-
// ---------------------------------------------------------------------------
|
|
2892
|
-
// Private: Custom track path-input dialog — copies an MP3 into tracks dir
|
|
2893
|
-
|
|
2894
|
-
function _openCustomTrackInput(screen, tracksDir, onDone, onClose) {
|
|
2895
|
-
let _closed = false;
|
|
2896
|
-
|
|
2897
|
-
const modal = blessed.box({
|
|
2898
|
-
parent: screen,
|
|
2899
|
-
top: 'center',
|
|
2900
|
-
left: 'center',
|
|
2901
|
-
width: 64,
|
|
2902
|
-
height: 11,
|
|
2903
|
-
border: { type: 'line' },
|
|
2904
|
-
tags: true,
|
|
2905
|
-
label: _modalTitle('Add Custom Track'),
|
|
2906
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.btnFocus } },
|
|
2907
|
-
});
|
|
2908
|
-
|
|
2909
|
-
blessed.text({
|
|
2910
|
-
parent: modal, top: 1, left: 2,
|
|
2911
|
-
content: 'Enter full path to an MP3 file:',
|
|
2912
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
2913
|
-
});
|
|
2914
|
-
|
|
2915
|
-
const inputBox = blessed.textbox({
|
|
2916
|
-
parent: modal, top: 3, left: 2, right: 2, height: 3,
|
|
2917
|
-
border: { type: 'line' },
|
|
2918
|
-
inputOnFocus: true,
|
|
2919
|
-
style: {
|
|
2920
|
-
fg: COLORS.valueFg, bg: '#0d1b35',
|
|
2921
|
-
border: { fg: COLORS.borderFg },
|
|
2922
|
-
focus: { border: { fg: COLORS.btnFocus } },
|
|
2923
|
-
},
|
|
2924
|
-
});
|
|
2925
|
-
|
|
2926
|
-
const errText = blessed.text({
|
|
2927
|
-
parent: modal, top: 7, left: 2, width: 58,
|
|
2928
|
-
tags: true, content: '',
|
|
2929
|
-
style: { bg: COLORS.contentBg },
|
|
2930
|
-
});
|
|
2931
|
-
|
|
2932
|
-
function _close() {
|
|
2933
|
-
if (_closed) return;
|
|
2934
|
-
_closed = true;
|
|
2935
|
-
modal.destroy();
|
|
2936
|
-
try {
|
|
2937
|
-
for (let r = 0; r < screen.height; r++)
|
|
2938
|
-
for (let c = 0; c < screen.width; c++)
|
|
2939
|
-
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2940
|
-
} catch {}
|
|
2941
|
-
onClose?.();
|
|
2942
|
-
screen.render();
|
|
2943
|
-
}
|
|
2944
|
-
|
|
2945
|
-
function _addTrack() {
|
|
2946
|
-
const raw = inputBox.getValue().trim();
|
|
2947
|
-
if (!raw) return;
|
|
2948
|
-
const src = path.resolve(raw);
|
|
2949
|
-
|
|
2950
|
-
// Validate: must be a readable .mp3 file owned by the current user
|
|
2951
|
-
if (!/\.mp3$/i.test(src)) {
|
|
2952
|
-
errText.setContent('{red-fg}File must be an MP3 (.mp3){/red-fg}');
|
|
2953
|
-
screen.render(); return;
|
|
2954
|
-
}
|
|
2955
|
-
try {
|
|
2956
|
-
const stat = fs.statSync(src);
|
|
2957
|
-
if (!stat.isFile()) throw new Error('not a file');
|
|
2958
|
-
if (stat.uid !== undefined && stat.uid !== process.getuid?.()) {
|
|
2959
|
-
errText.setContent('{red-fg}File not owned by current user{/red-fg}');
|
|
2960
|
-
screen.render(); return;
|
|
2961
|
-
}
|
|
2962
|
-
} catch {
|
|
2963
|
-
errText.setContent('{red-fg}File not found or not accessible{/red-fg}');
|
|
2964
|
-
screen.render(); return;
|
|
2965
|
-
}
|
|
2966
|
-
|
|
2967
|
-
const dest = path.join(tracksDir, path.basename(src));
|
|
2968
|
-
try {
|
|
2969
|
-
fs.mkdirSync(tracksDir, { recursive: true });
|
|
2970
|
-
fs.copyFileSync(src, dest);
|
|
2971
|
-
} catch {
|
|
2972
|
-
errText.setContent('{red-fg}Could not copy file to tracks directory{/red-fg}');
|
|
2973
|
-
screen.render(); return;
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
|
-
_closed = true;
|
|
2977
|
-
modal.destroy();
|
|
2978
|
-
try {
|
|
2979
|
-
for (let r = 0; r < screen.height; r++)
|
|
2980
|
-
for (let c = 0; c < screen.width; c++)
|
|
2981
|
-
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2982
|
-
} catch {}
|
|
2983
|
-
screen.render();
|
|
2984
|
-
onDone(path.basename(src));
|
|
2985
|
-
}
|
|
2986
|
-
|
|
2987
|
-
inputBox.key(['enter'], _addTrack);
|
|
2988
|
-
inputBox.key(['escape'], _close);
|
|
2989
|
-
|
|
2990
|
-
const addBtn = _createButton(modal, screen, 'Add Track', COLORS, _addTrack);
|
|
2991
|
-
addBtn.bottom = 1; addBtn.left = 4;
|
|
2992
|
-
|
|
2993
|
-
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close);
|
|
2994
|
-
cancelBtn.bottom = 1; cancelBtn.left = 18;
|
|
2995
|
-
|
|
2996
|
-
addBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
|
|
2997
|
-
cancelBtn.key(['tab'], () => { inputBox.focus(); screen.render(); });
|
|
2998
|
-
|
|
2999
|
-
modal.setFront();
|
|
3000
|
-
inputBox.focus();
|
|
3001
|
-
screen.render();
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
// ---------------------------------------------------------------------------
|
|
3005
|
-
// Private: Inline volume picker (10% steps: 10–100)
|
|
3006
|
-
|
|
3007
|
-
function _openVolumePicker(screen, configService, onSelect, onClose) {
|
|
3008
|
-
const VOLUMES = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
|
3009
|
-
const currentVol = configService.getConfig().backgroundMusic?.volume ?? MUSIC_DEFAULTS.volume;
|
|
3010
|
-
const currentIdx = Math.max(0, VOLUMES.indexOf(currentVol));
|
|
3011
|
-
|
|
3012
|
-
// Preview state
|
|
3013
|
-
let _previewProcess = null;
|
|
3014
|
-
let _previewVol = null;
|
|
3015
|
-
|
|
3016
|
-
const _previewEnv = buildAudioEnv();
|
|
3017
|
-
|
|
3018
|
-
function _killPreview() {
|
|
3019
|
-
if (_previewProcess) {
|
|
3020
|
-
if (_IS_WINDOWS) {
|
|
3021
|
-
try { _previewProcess.kill(); } catch {}
|
|
3022
|
-
} else {
|
|
3023
|
-
try { process.kill(-_previewProcess.pid, 'SIGTERM'); } catch {}
|
|
3024
|
-
}
|
|
3025
|
-
_previewProcess = null;
|
|
3026
|
-
}
|
|
3027
|
-
_previewVol = null;
|
|
3028
|
-
}
|
|
3029
|
-
|
|
3030
|
-
function _buildItems() {
|
|
3031
|
-
return VOLUMES.map((v, i) => {
|
|
3032
|
-
const mark = (v === _previewVol) ? '♪' : (i === currentIdx ? '●' : ' ');
|
|
3033
|
-
const hint = (v === _previewVol) ? ' (Space to stop) ' : ' (Space to test) ';
|
|
3034
|
-
return ` ${mark} ${String(v).padStart(3)}%${hint}`;
|
|
3035
|
-
});
|
|
3036
|
-
}
|
|
3037
|
-
|
|
3038
|
-
function _refreshList() {
|
|
3039
|
-
const sel = list.selected;
|
|
3040
|
-
list.setItems(_buildItems());
|
|
3041
|
-
list.select(sel);
|
|
3042
|
-
screen.render();
|
|
3043
|
-
}
|
|
3044
|
-
|
|
3045
|
-
function _close() {
|
|
3046
|
-
_killPreview();
|
|
3047
|
-
list.destroy();
|
|
3048
|
-
// Force-invalidate olines so blessed redraws every cell the modal covered
|
|
3049
|
-
try {
|
|
3050
|
-
for (let r = 0; r < screen.height; r++)
|
|
3051
|
-
for (let c = 0; c < screen.width; c++)
|
|
3052
|
-
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
3053
|
-
} catch {}
|
|
3054
|
-
onClose?.();
|
|
3055
|
-
screen.render();
|
|
3056
|
-
}
|
|
3057
|
-
|
|
3058
|
-
function _previewVolume(vol) {
|
|
3059
|
-
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
3060
|
-
const trackId = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
|
|
3061
|
-
const trackPath = path.resolve(tracksDir, trackId);
|
|
3062
|
-
const safeBase = path.resolve(tracksDir);
|
|
3063
|
-
if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
|
|
3064
|
-
|
|
3065
|
-
// Toggle: pressing Space on the currently playing volume stops it
|
|
3066
|
-
if (_previewVol === vol) {
|
|
3067
|
-
_killPreview();
|
|
3068
|
-
_refreshList();
|
|
3069
|
-
return;
|
|
3070
|
-
}
|
|
3071
|
-
|
|
3072
|
-
_killPreview();
|
|
3073
|
-
_previewVol = vol;
|
|
3074
|
-
|
|
3075
|
-
const volFraction = (Math.max(10, Math.min(100, vol)) / 100).toFixed(2);
|
|
3076
|
-
const cmd = [
|
|
3077
|
-
`ffplay -nodisp -t 10 -loglevel quiet -volume ${vol} "${trackPath}"`,
|
|
3078
|
-
`play "${trackPath}" trim 0 10 vol ${volFraction}`,
|
|
3079
|
-
`mpg123 -q "${trackPath}"`,
|
|
3080
|
-
].join(' 2>/dev/null || ') + ' 2>/dev/null';
|
|
3081
|
-
|
|
3082
|
-
if (_IS_WINDOWS) {
|
|
3083
|
-
const _mp3P3 = detectMp3Player(_previewEnv);
|
|
3084
|
-
_previewProcess = _mp3P3
|
|
3085
|
-
? spawn(_mp3P3.bin, _mp3P3.args(trackPath), _spawnOpts(_previewEnv))
|
|
3086
|
-
: null;
|
|
3087
|
-
if (_previewProcess) {
|
|
3088
|
-
_previewProcess.on('error', () => { _previewProcess = null; _previewVol = null; _refreshList(); });
|
|
3089
|
-
}
|
|
3090
|
-
} else {
|
|
3091
|
-
_previewProcess = spawn('sh', ['-c', cmd], _spawnOpts(_previewEnv));
|
|
3092
|
-
}
|
|
3093
|
-
if (!_previewProcess) { _previewVol = null; return; }
|
|
3094
|
-
_previewProcess.unref();
|
|
3095
|
-
_refreshList();
|
|
3096
|
-
|
|
3097
|
-
_previewProcess.on('exit', () => {
|
|
3098
|
-
if (_previewVol === vol) { _killPreview(); _refreshList(); }
|
|
3099
|
-
});
|
|
3100
|
-
_previewProcess.on('error', () => {
|
|
3101
|
-
if (_previewVol === vol) { _killPreview(); _refreshList(); }
|
|
3102
|
-
});
|
|
3103
|
-
}
|
|
3104
|
-
|
|
3105
|
-
const list = blessed.list({
|
|
3106
|
-
parent: screen,
|
|
3107
|
-
top: 'center',
|
|
3108
|
-
left: 'center',
|
|
3109
|
-
width: 28,
|
|
3110
|
-
height: VOLUMES.length + 4,
|
|
3111
|
-
border: { type: 'line' },
|
|
3112
|
-
tags: true,
|
|
3113
|
-
label: _modalTitle('Volume'),
|
|
3114
|
-
items: _buildItems(),
|
|
3115
|
-
keys: true,
|
|
3116
|
-
vi: false,
|
|
3117
|
-
mouse: true,
|
|
3118
|
-
style: {
|
|
3119
|
-
border: { fg: COLORS.btnFocus },
|
|
3120
|
-
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
3121
|
-
item: { fg: '#e3f2fd' },
|
|
3122
|
-
},
|
|
3123
|
-
});
|
|
3124
|
-
|
|
3125
|
-
list.select(currentIdx);
|
|
3126
|
-
list.focus();
|
|
3127
|
-
screen.render();
|
|
3128
|
-
|
|
3129
|
-
// Space → preview audio at selected volume (toggle stop/play)
|
|
3130
|
-
list.key(['space'], () => {
|
|
3131
|
-
const vol = VOLUMES[list.selected];
|
|
3132
|
-
if (vol !== undefined) _previewVolume(vol);
|
|
3133
|
-
});
|
|
3134
|
-
|
|
3135
|
-
// Enter → accept selected volume and close
|
|
3136
|
-
list.key(['enter'], () => {
|
|
3137
|
-
const vol = VOLUMES[list.selected];
|
|
3138
|
-
if (vol === undefined) return;
|
|
3139
|
-
_close();
|
|
3140
|
-
onSelect(vol);
|
|
3141
|
-
});
|
|
3142
|
-
|
|
3143
|
-
list.key(['escape', 'q'], () => {
|
|
3144
|
-
_close();
|
|
3145
|
-
});
|
|
3146
|
-
}
|
|
3147
|
-
|
|
3148
|
-
// ---------------------------------------------------------------------------
|
|
3149
|
-
// Private: Full music browser modal — rich track selection with favorites + preview
|
|
3150
|
-
|
|
3151
|
-
function _openMusicBrowserModal(screen, configService, navigationService, onDone, onClose) {
|
|
3152
|
-
let _allTracks = [];
|
|
3153
|
-
let _showFavoritesOnly = false;
|
|
3154
|
-
let _previewProcess = null;
|
|
3155
|
-
let _previewTrackId = null;
|
|
3156
|
-
let _closed = false;
|
|
3157
|
-
|
|
3158
|
-
// Block global Tab-to-cycle-tab while modal is open
|
|
3159
|
-
navigationService?.openModal();
|
|
3160
|
-
|
|
3161
|
-
const _modalEnv = buildAudioEnv();
|
|
3162
|
-
|
|
3163
|
-
function _killPreview() {
|
|
3164
|
-
if (_previewProcess) {
|
|
3165
|
-
if (_IS_WINDOWS) {
|
|
3166
|
-
try { _previewProcess.kill(); } catch {}
|
|
3167
|
-
} else {
|
|
3168
|
-
try { process.kill(-_previewProcess.pid, 'SIGTERM'); } catch {}
|
|
3169
|
-
}
|
|
3170
|
-
_previewProcess = null;
|
|
3171
|
-
}
|
|
3172
|
-
_previewTrackId = null;
|
|
3173
|
-
}
|
|
3174
|
-
|
|
3175
|
-
function _closeModal() {
|
|
3176
|
-
if (_closed) return;
|
|
3177
|
-
_closed = true;
|
|
3178
|
-
navigationService?.closeModal();
|
|
3179
|
-
_killPreview();
|
|
3180
|
-
modal.destroy();
|
|
3181
|
-
|
|
3182
|
-
// Force-invalidate olines so draw() rewrites every cell the modal covered
|
|
3183
|
-
screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
|
|
3184
|
-
for (let r = 2; r < screen.rows - 2; r++) {
|
|
3185
|
-
const orow = screen.olines[r];
|
|
3186
|
-
if (!orow) continue;
|
|
3187
|
-
for (let c = 0; c < screen.cols; c++) {
|
|
3188
|
-
if (orow[c]) orow[c][0] = -1;
|
|
3189
|
-
}
|
|
3190
|
-
orow.dirty = true;
|
|
3191
|
-
}
|
|
3192
|
-
|
|
3193
|
-
onClose?.();
|
|
3194
|
-
screen.render();
|
|
3195
|
-
onDone();
|
|
3196
|
-
}
|
|
3197
|
-
|
|
3198
|
-
// ---- Modal overlay ----
|
|
3199
|
-
const modal = blessed.box({
|
|
3200
|
-
parent: screen,
|
|
3201
|
-
top: '5%',
|
|
3202
|
-
left: '3%',
|
|
3203
|
-
width: '94%',
|
|
3204
|
-
height: '90%',
|
|
3205
|
-
border: { type: 'line' },
|
|
3206
|
-
tags: true,
|
|
3207
|
-
label: _modalTitle('🎵 Select Music Track'),
|
|
3208
|
-
style: {
|
|
3209
|
-
fg: COLORS.labelFg,
|
|
3210
|
-
bg: COLORS.contentBg,
|
|
3211
|
-
border: { fg: COLORS.btnFocus },
|
|
3212
|
-
label: { fg: COLORS.btnFocus },
|
|
3213
|
-
},
|
|
3214
|
-
});
|
|
3215
|
-
modal.setFront();
|
|
3216
|
-
|
|
3217
|
-
// ---- Track list ----
|
|
3218
|
-
const modalTrackList = blessed.list({
|
|
3219
|
-
parent: modal,
|
|
3220
|
-
top: 1,
|
|
3221
|
-
left: 2,
|
|
3222
|
-
right: 2,
|
|
3223
|
-
bottom: 6,
|
|
3224
|
-
keys: true,
|
|
3225
|
-
vi: true,
|
|
3226
|
-
mouse: true,
|
|
3227
|
-
border: { type: 'line' },
|
|
3228
|
-
scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
|
|
3229
|
-
style: {
|
|
3230
|
-
fg: COLORS.labelFg,
|
|
3231
|
-
bg: COLORS.contentBg,
|
|
3232
|
-
border: { fg: COLORS.borderFg },
|
|
3233
|
-
selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
|
|
3234
|
-
item: { fg: COLORS.labelFg },
|
|
3235
|
-
},
|
|
3236
|
-
});
|
|
3237
|
-
|
|
3238
|
-
// ---- Preview status line ----
|
|
3239
|
-
const modalPreviewLine = blessed.text({
|
|
3240
|
-
parent: modal,
|
|
3241
|
-
bottom: 5,
|
|
3242
|
-
left: 2,
|
|
3243
|
-
right: 2,
|
|
3244
|
-
tags: true,
|
|
3245
|
-
content: '',
|
|
3246
|
-
style: { fg: 'bright-cyan', bg: COLORS.contentBg },
|
|
3247
|
-
});
|
|
3248
|
-
|
|
3249
|
-
// ---- File location hint ----
|
|
3250
|
-
blessed.text({
|
|
3251
|
-
parent: modal,
|
|
3252
|
-
bottom: 4,
|
|
3253
|
-
left: 2,
|
|
3254
|
-
right: 2,
|
|
3255
|
-
tags: true,
|
|
3256
|
-
content: `{#455a64-fg}Add MP3 files to: .claude/audio/tracks/ • Supports ffplay / mpg123 / play{/#455a64-fg}`,
|
|
3257
|
-
style: { bg: COLORS.contentBg },
|
|
3258
|
-
});
|
|
3259
|
-
|
|
3260
|
-
// ---- Key hint bar ----
|
|
3261
|
-
blessed.text({
|
|
3262
|
-
parent: modal,
|
|
3263
|
-
bottom: 3,
|
|
3264
|
-
left: 2,
|
|
3265
|
-
right: 2,
|
|
3266
|
-
content: '{#455a64-fg}[\u2191\u2193] Navigate [Enter] Select [Space] Preview [F] Favorite [/] Favorites only [Esc] Cancel{/#455a64-fg}',
|
|
3267
|
-
tags: true,
|
|
3268
|
-
style: { bg: COLORS.contentBg },
|
|
3269
|
-
});
|
|
3270
|
-
|
|
3271
|
-
// ---- Buttons ----
|
|
3272
|
-
const selectTrackBtn = _createButton(modal, screen, 'Select Track', COLORS, () => {
|
|
3273
|
-
const visible = _getVisibleTracks();
|
|
3274
|
-
const selected = visible[modalTrackList.selected];
|
|
3275
|
-
if (selected) {
|
|
3276
|
-
try {
|
|
3277
|
-
const current = configService.getConfig().backgroundMusic ?? {};
|
|
3278
|
-
configService.set('backgroundMusic', { ...current, track: selected.id });
|
|
3279
|
-
} catch {}
|
|
3280
|
-
_closeModal();
|
|
3281
|
-
}
|
|
3282
|
-
});
|
|
3283
|
-
selectTrackBtn.bottom = 1;
|
|
3284
|
-
selectTrackBtn.left = 4;
|
|
3285
|
-
|
|
3286
|
-
const cancelModalBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeModal);
|
|
3287
|
-
cancelModalBtn.bottom = 1;
|
|
3288
|
-
cancelModalBtn.left = 22;
|
|
3289
|
-
|
|
3290
|
-
// ---- Helper functions ----
|
|
3291
|
-
|
|
3292
|
-
function _getVisibleTracks() {
|
|
3293
|
-
if (!_showFavoritesOnly) return _allTracks;
|
|
3294
|
-
const favs = getMusicFavorites(configService);
|
|
3295
|
-
return _allTracks.filter(t => favs.includes(t.id));
|
|
3296
|
-
}
|
|
3297
|
-
|
|
3298
|
-
function _buildListItems(tracks) {
|
|
3299
|
-
const currentTrack = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
|
|
3300
|
-
const favs = getMusicFavorites(configService);
|
|
3301
|
-
return tracks.map(t => {
|
|
3302
|
-
const isActive = t.id === currentTrack;
|
|
3303
|
-
const isFav = favs.includes(t.id);
|
|
3304
|
-
const isPrev = t.id === _previewTrackId;
|
|
3305
|
-
const activeMark = isPrev ? '\u266A' : (isActive ? '\u25B6' : ' ');
|
|
3306
|
-
const favMark = isFav ? '\u2605' : ' ';
|
|
3307
|
-
return ` ${activeMark} ${favMark} ${formatTrackName(t.id) || t.label}`;
|
|
3308
|
-
});
|
|
3309
|
-
}
|
|
3310
|
-
|
|
3311
|
-
function _refreshList() {
|
|
3312
|
-
if (_closed) return;
|
|
3313
|
-
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
3314
|
-
const scanned = scanTracks();
|
|
3315
|
-
_allTracks = scanned;
|
|
3316
|
-
const visible = _getVisibleTracks();
|
|
3317
|
-
const items = _buildListItems(visible);
|
|
3318
|
-
modalTrackList.setItems(items.length > 0 ? items : [' (no tracks found)']);
|
|
3319
|
-
screen.render();
|
|
3320
|
-
}
|
|
3321
|
-
|
|
3322
|
-
function _previewTrack(trackId) {
|
|
3323
|
-
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
3324
|
-
const trackPath = path.resolve(tracksDir, trackId);
|
|
3325
|
-
const safeBase = path.resolve(tracksDir);
|
|
3326
|
-
if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
|
|
3327
|
-
|
|
3328
|
-
// Toggle: second press on same track → stop
|
|
3329
|
-
if (_previewTrackId === trackId) {
|
|
3330
|
-
_killPreview();
|
|
3331
|
-
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3332
|
-
_refreshList();
|
|
3333
|
-
return;
|
|
3334
|
-
}
|
|
3335
|
-
|
|
3336
|
-
_killPreview();
|
|
3337
|
-
|
|
3338
|
-
const _mp3Player = detectMp3Player(_modalEnv);
|
|
3339
|
-
if (!_mp3Player) return;
|
|
3340
|
-
_previewProcess = spawn(_mp3Player.bin, _mp3Player.args(trackPath), {
|
|
3341
|
-
stdio: 'ignore', detached: !_IS_WINDOWS, windowsHide: true, env: _modalEnv,
|
|
3342
|
-
});
|
|
3343
|
-
_previewProcess.unref();
|
|
3344
|
-
_previewTrackId = trackId;
|
|
3345
|
-
|
|
3346
|
-
const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
|
|
3347
|
-
if (!_closed) {
|
|
3348
|
-
modalPreviewLine.setContent(`{bright-cyan-fg}\u266A Previewing: ${label} (Space to stop){/bright-cyan-fg}`);
|
|
3349
|
-
screen.render();
|
|
3350
|
-
}
|
|
3351
|
-
|
|
3352
|
-
_previewProcess.on('exit', () => {
|
|
3353
|
-
if (_previewTrackId === trackId) {
|
|
3354
|
-
_previewTrackId = null;
|
|
3355
|
-
_previewProcess = null;
|
|
3356
|
-
if (!_closed) { modalPreviewLine.setContent(''); _refreshList(); }
|
|
3357
|
-
}
|
|
3358
|
-
});
|
|
3359
|
-
|
|
3360
|
-
_previewProcess.on('error', () => {
|
|
3361
|
-
_previewTrackId = null;
|
|
3362
|
-
_previewProcess = null;
|
|
3363
|
-
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3364
|
-
});
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
// ---- Key bindings ----
|
|
3368
|
-
|
|
3369
|
-
modalTrackList.key(['enter'], () => {
|
|
3370
|
-
const visible = _getVisibleTracks();
|
|
3371
|
-
const sel = visible[modalTrackList.selected];
|
|
3372
|
-
if (sel) {
|
|
3373
|
-
try {
|
|
3374
|
-
const current = configService.getConfig().backgroundMusic ?? {};
|
|
3375
|
-
configService.set('backgroundMusic', { ...current, track: sel.id });
|
|
3376
|
-
} catch {}
|
|
3377
|
-
_closeModal();
|
|
3378
|
-
}
|
|
3379
|
-
});
|
|
3380
|
-
|
|
3381
|
-
modalTrackList.key(['space'], () => {
|
|
3382
|
-
const visible = _getVisibleTracks();
|
|
3383
|
-
const sel = visible[modalTrackList.selected];
|
|
3384
|
-
if (sel) { _previewTrack(sel.id); }
|
|
3385
|
-
});
|
|
3386
|
-
|
|
3387
|
-
modalTrackList.key(['f', 'F'], () => {
|
|
3388
|
-
const visible = _getVisibleTracks();
|
|
3389
|
-
const sel = visible[modalTrackList.selected];
|
|
3390
|
-
if (sel) {
|
|
3391
|
-
toggleMusicFavorite(configService, sel.id);
|
|
3392
|
-
_refreshList();
|
|
3393
|
-
}
|
|
3394
|
-
});
|
|
3395
|
-
|
|
3396
|
-
modalTrackList.key(['/'], () => {
|
|
3397
|
-
_showFavoritesOnly = !_showFavoritesOnly;
|
|
3398
|
-
_refreshList();
|
|
3399
|
-
});
|
|
3400
|
-
|
|
3401
|
-
modalTrackList.key(['escape', 'q'], _closeModal);
|
|
3402
|
-
|
|
3403
|
-
// Tab: list → [Select Track] → [Cancel] → list
|
|
3404
|
-
modalTrackList.key(['tab'], () => { selectTrackBtn.focus(); screen.render(); });
|
|
3405
|
-
selectTrackBtn.key(['tab'], () => { cancelModalBtn.focus(); screen.render(); });
|
|
3406
|
-
cancelModalBtn.key(['tab'], () => { modalTrackList.focus(); screen.render(); });
|
|
3407
|
-
selectTrackBtn.key(['escape'], _closeModal);
|
|
3408
|
-
cancelModalBtn.key(['escape'], _closeModal);
|
|
3409
|
-
|
|
3410
|
-
// ---- Initial load ----
|
|
3411
|
-
_refreshList();
|
|
3412
|
-
|
|
3413
|
-
// Scroll to active track on open
|
|
3414
|
-
const currentTrack = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
|
|
3415
|
-
const activeIdx = _getVisibleTracks().findIndex(t => t.id === currentTrack);
|
|
3416
|
-
if (activeIdx >= 0) modalTrackList.select(activeIdx);
|
|
3417
|
-
|
|
3418
|
-
modalTrackList.focus();
|
|
3419
|
-
screen.render();
|
|
3420
|
-
}
|
|
3421
|
-
|
|
3422
|
-
// ---------------------------------------------------------------------------
|
|
3423
|
-
// Private: Inline verbosity picker
|
|
3424
|
-
|
|
3425
|
-
function _openVerbosityPicker(screen, configService, onDone, onClose) {
|
|
3426
|
-
const levels = ['Minimal', 'Low', 'Medium', 'High', 'Custom'];
|
|
3427
|
-
const current = configService.getConfig().verbosity ?? 'high';
|
|
3428
|
-
const currentIdx = Math.max(0, levels.findIndex(l => l.toLowerCase() === current));
|
|
3429
|
-
|
|
3430
|
-
const list = blessed.list({
|
|
3431
|
-
parent: screen,
|
|
3432
|
-
top: 'center',
|
|
3433
|
-
left: 'center',
|
|
3434
|
-
width: 28,
|
|
3435
|
-
height: levels.length + 4,
|
|
3436
|
-
border: { type: 'line' },
|
|
3437
|
-
tags: true,
|
|
3438
|
-
label: _modalTitle('Verbosity Level'),
|
|
3439
|
-
items: levels.map((l, i) => (i === currentIdx ? `● ${l}` : ` ${l}`)),
|
|
3440
|
-
keys: true,
|
|
3441
|
-
vi: false,
|
|
3442
|
-
mouse: true,
|
|
3443
|
-
style: {
|
|
3444
|
-
border: { fg: COLORS.btnFocus },
|
|
3445
|
-
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
3446
|
-
item: { fg: '#e3f2fd' },
|
|
3447
|
-
},
|
|
3448
|
-
});
|
|
3449
|
-
|
|
3450
|
-
list.select(currentIdx);
|
|
3451
|
-
list.focus();
|
|
3452
|
-
screen.render();
|
|
3453
|
-
|
|
3454
|
-
list.key(['enter', 'space'], () => {
|
|
3455
|
-
const selected = levels[list.selected];
|
|
3456
|
-
if (!selected) return;
|
|
3457
|
-
destroyList(list, screen, onClose);
|
|
3458
|
-
configService.set('verbosity', selected.toLowerCase());
|
|
3459
|
-
onDone();
|
|
3460
|
-
});
|
|
3461
|
-
|
|
3462
|
-
list.key(['escape', 'q'], () => {
|
|
3463
|
-
destroyList(list, screen, onClose);
|
|
3464
|
-
});
|
|
3465
|
-
}
|
|
3466
|
-
|
|
3467
|
-
// ---------------------------------------------------------------------------
|
|
3468
|
-
// Private: Inline intro text editor
|
|
3469
|
-
|
|
3470
|
-
function _openIntroTextEditor(screen, configService, onDone, onClose) {
|
|
3471
|
-
const current = configService.getConfig().pretext ?? '';
|
|
3472
|
-
let _closed = false;
|
|
3473
|
-
|
|
3474
|
-
const modal = blessed.box({
|
|
3475
|
-
parent: screen,
|
|
3476
|
-
top: 'center',
|
|
3477
|
-
left: 'center',
|
|
3478
|
-
width: 62,
|
|
3479
|
-
height: 11,
|
|
3480
|
-
border: { type: 'line' },
|
|
3481
|
-
tags: true,
|
|
3482
|
-
label: _modalTitle('Edit Intro Text'),
|
|
3483
|
-
style: {
|
|
3484
|
-
fg: COLORS.labelFg,
|
|
3485
|
-
bg: COLORS.contentBg,
|
|
3486
|
-
border: { fg: COLORS.btnFocus },
|
|
3487
|
-
},
|
|
3488
|
-
});
|
|
3489
|
-
|
|
3490
|
-
blessed.text({
|
|
3491
|
-
parent: modal,
|
|
3492
|
-
top: 1,
|
|
3493
|
-
left: 2,
|
|
3494
|
-
content: 'Enter intro text (max 50 chars, prepended before TTS):',
|
|
3495
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
3496
|
-
});
|
|
3497
|
-
|
|
3498
|
-
const inputBox = blessed.textbox({
|
|
3499
|
-
parent: modal,
|
|
3500
|
-
top: 3,
|
|
3501
|
-
left: 2,
|
|
3502
|
-
right: 2,
|
|
3503
|
-
height: 3,
|
|
3504
|
-
border: { type: 'line' },
|
|
3505
|
-
inputOnFocus: true,
|
|
3506
|
-
style: {
|
|
3507
|
-
fg: COLORS.valueFg,
|
|
3508
|
-
bg: '#0d1b35',
|
|
3509
|
-
border: { fg: COLORS.borderFg },
|
|
3510
|
-
focus: { border: { fg: COLORS.btnFocus } },
|
|
3511
|
-
},
|
|
3512
|
-
});
|
|
3513
|
-
inputBox.setValue(current);
|
|
3514
|
-
|
|
3515
|
-
blessed.text({
|
|
3516
|
-
parent: modal,
|
|
3517
|
-
bottom: 1,
|
|
3518
|
-
left: 2,
|
|
3519
|
-
content: '{#455a64-fg}[Enter] Save [Esc] Cancel{/#455a64-fg}',
|
|
3520
|
-
tags: true,
|
|
3521
|
-
style: { bg: COLORS.contentBg },
|
|
3522
|
-
});
|
|
3523
|
-
|
|
3524
|
-
function _close() {
|
|
3525
|
-
if (_closed) return;
|
|
3526
|
-
_closed = true;
|
|
3527
|
-
modal.destroy();
|
|
3528
|
-
screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
|
|
3529
|
-
for (let r = 2; r < screen.rows - 2; r++) {
|
|
3530
|
-
const orow = screen.olines[r];
|
|
3531
|
-
if (!orow) continue;
|
|
3532
|
-
for (let c = 0; c < screen.cols; c++) { if (orow[c]) orow[c][0] = -1; }
|
|
3533
|
-
orow.dirty = true;
|
|
3534
|
-
}
|
|
3535
|
-
onClose?.();
|
|
3536
|
-
screen.render();
|
|
3537
|
-
}
|
|
3538
|
-
|
|
3539
|
-
inputBox.key(['enter'], () => {
|
|
3540
|
-
const value = inputBox.getValue().replace(/\n/g, ' ').trim().slice(0, 50);
|
|
3541
|
-
try { configService.set('pretext', value); } catch {}
|
|
3542
|
-
_close();
|
|
3543
|
-
onDone();
|
|
3544
|
-
});
|
|
3545
|
-
|
|
3546
|
-
inputBox.key(['escape'], () => {
|
|
3547
|
-
_close();
|
|
3548
|
-
});
|
|
3549
|
-
|
|
3550
|
-
modal.setFront();
|
|
3551
|
-
inputBox.focus();
|
|
3552
|
-
screen.render();
|
|
3553
|
-
}
|
|
3554
|
-
|
|
3555
|
-
// ---------------------------------------------------------------------------
|
|
3556
|
-
// Private: Full voice browser modal — replicates the Voices tab UX
|
|
3557
|
-
|
|
3558
|
-
function _openVoiceBrowserModal(screen, providerService, configService, navigationService, onDone, onClose) {
|
|
3559
|
-
let _allVoices = [];
|
|
3560
|
-
let _filterText = '';
|
|
3561
|
-
let _playingProcess = null;
|
|
3562
|
-
let _playingVoiceId = null;
|
|
3563
|
-
let _closed = false;
|
|
3564
|
-
|
|
3565
|
-
// Block global Tab-to-cycle-tab while modal is open
|
|
3566
|
-
navigationService?.openModal();
|
|
3567
|
-
|
|
3568
|
-
const _spawnEnv = buildAudioEnv();
|
|
3569
|
-
|
|
3570
|
-
function _killPreview() {
|
|
3571
|
-
if (_playingProcess) {
|
|
3572
|
-
if (_IS_WINDOWS) {
|
|
3573
|
-
try { _playingProcess.kill(); } catch {}
|
|
3574
|
-
} else {
|
|
3575
|
-
try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
|
|
3576
|
-
}
|
|
3577
|
-
_playingProcess = null;
|
|
3578
|
-
}
|
|
3579
|
-
_playingVoiceId = null;
|
|
3580
|
-
}
|
|
3581
|
-
|
|
3582
|
-
function _closeModal() {
|
|
3583
|
-
if (_closed) return;
|
|
3584
|
-
_closed = true;
|
|
3585
|
-
navigationService?.closeModal();
|
|
3586
|
-
_killPreview();
|
|
3587
|
-
modal.destroy();
|
|
3588
|
-
|
|
3589
|
-
// Force-invalidate olines so draw() rewrites every cell the modal covered.
|
|
3590
|
-
// modal.destroy() removes the widget from lines[] but leaves olines[] stale,
|
|
3591
|
-
// so draw() skips repainting cells where lines==olines — terminal retains
|
|
3592
|
-
// modal content. Setting attr=-1 is impossible for any real cell, so draw()
|
|
3593
|
-
// is forced to physically rewrite each cell on the next render.
|
|
3594
|
-
screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
|
|
3595
|
-
for (let r = 2; r < screen.rows - 2; r++) {
|
|
3596
|
-
const orow = screen.olines[r];
|
|
3597
|
-
if (!orow) continue;
|
|
3598
|
-
for (let c = 0; c < screen.cols; c++) {
|
|
3599
|
-
if (orow[c]) orow[c][0] = -1;
|
|
3600
|
-
}
|
|
3601
|
-
orow.dirty = true;
|
|
3602
|
-
}
|
|
3603
|
-
|
|
3604
|
-
onClose?.();
|
|
3605
|
-
screen.render();
|
|
3606
|
-
onDone();
|
|
3607
|
-
}
|
|
3608
|
-
|
|
3609
|
-
// ---- Modal overlay ----
|
|
3610
|
-
const modal = blessed.box({
|
|
3611
|
-
parent: screen,
|
|
3612
|
-
top: '8%',
|
|
3613
|
-
left: '4%',
|
|
3614
|
-
width: '92%',
|
|
3615
|
-
height: '84%',
|
|
3616
|
-
border: { type: 'line' },
|
|
3617
|
-
tags: true,
|
|
3618
|
-
label: _modalTitle('Change Voice'),
|
|
3619
|
-
style: {
|
|
3620
|
-
fg: COLORS.labelFg,
|
|
3621
|
-
bg: COLORS.contentBg,
|
|
3622
|
-
border: { fg: COLORS.btnFocus },
|
|
3623
|
-
label: { fg: COLORS.btnFocus },
|
|
3624
|
-
},
|
|
3625
|
-
});
|
|
3626
|
-
modal.setFront();
|
|
3627
|
-
|
|
3628
|
-
// ---- Search ----
|
|
3629
|
-
blessed.text({
|
|
3630
|
-
parent: modal,
|
|
3631
|
-
top: 1,
|
|
3632
|
-
left: 2,
|
|
3633
|
-
content: 'Search:',
|
|
3634
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
3635
|
-
});
|
|
3636
|
-
|
|
3637
|
-
const modalSearch = blessed.textbox({
|
|
3638
|
-
parent: modal,
|
|
3639
|
-
top: 1,
|
|
3640
|
-
left: 11,
|
|
3641
|
-
width: 40,
|
|
3642
|
-
height: 1,
|
|
3643
|
-
inputOnFocus: true,
|
|
3644
|
-
keys: true,
|
|
3645
|
-
style: {
|
|
3646
|
-
fg: COLORS.valueFg,
|
|
3647
|
-
bg: '#1a3a5c',
|
|
3648
|
-
focus: { bg: '#283593' },
|
|
3649
|
-
},
|
|
3650
|
-
});
|
|
3651
|
-
|
|
3652
|
-
// ---- Column header ----
|
|
3653
|
-
blessed.text({
|
|
3654
|
-
parent: modal,
|
|
3655
|
-
top: 2,
|
|
3656
|
-
left: 6,
|
|
3657
|
-
content: `{bright-cyan-fg}${'Name'.padEnd(COL_NAME_W)}${'Gender'.padEnd(COL_GENDER_W)}Provider{/bright-cyan-fg}`,
|
|
3658
|
-
tags: true,
|
|
3659
|
-
style: { bg: COLORS.contentBg },
|
|
3660
|
-
});
|
|
3661
|
-
|
|
3662
|
-
// ---- Voice list ----
|
|
3663
|
-
const modalVoiceList = blessed.list({
|
|
3664
|
-
parent: modal,
|
|
3665
|
-
top: 3,
|
|
3666
|
-
left: 2,
|
|
3667
|
-
right: 2,
|
|
3668
|
-
bottom: 6,
|
|
3669
|
-
keys: true,
|
|
3670
|
-
vi: true,
|
|
3671
|
-
mouse: true,
|
|
3672
|
-
border: { type: 'line' },
|
|
3673
|
-
scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
|
|
3674
|
-
style: {
|
|
3675
|
-
fg: COLORS.labelFg,
|
|
3676
|
-
bg: COLORS.contentBg,
|
|
3677
|
-
border: { fg: COLORS.borderFg },
|
|
3678
|
-
selected: { bg: '#2e7d32', fg: '#ffffff', bold: true },
|
|
3679
|
-
item: { fg: COLORS.labelFg },
|
|
3680
|
-
},
|
|
3681
|
-
});
|
|
3682
|
-
|
|
3683
|
-
// ---- Info panel ----
|
|
3684
|
-
blessed.text({
|
|
3685
|
-
parent: modal,
|
|
3686
|
-
bottom: 5,
|
|
3687
|
-
left: 2,
|
|
3688
|
-
content: `{bright-cyan-fg}── Voice Info ${'─'.repeat(50)}{/bright-cyan-fg}`,
|
|
3689
|
-
tags: true,
|
|
3690
|
-
style: { bg: COLORS.contentBg },
|
|
3691
|
-
});
|
|
3692
|
-
|
|
3693
|
-
const modalInfoLine = blessed.text({
|
|
3694
|
-
parent: modal,
|
|
3695
|
-
bottom: 4,
|
|
3696
|
-
left: 2,
|
|
3697
|
-
right: 2,
|
|
3698
|
-
tags: true,
|
|
3699
|
-
content: '',
|
|
3700
|
-
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
3701
|
-
});
|
|
3702
|
-
|
|
3703
|
-
const modalPreviewLine = blessed.text({
|
|
3704
|
-
parent: modal,
|
|
3705
|
-
bottom: 3,
|
|
3706
|
-
left: 2,
|
|
3707
|
-
right: 2,
|
|
3708
|
-
tags: true,
|
|
3709
|
-
content: '',
|
|
3710
|
-
style: { fg: 'bright-cyan', bg: COLORS.contentBg },
|
|
3711
|
-
});
|
|
3712
|
-
|
|
3713
|
-
// ---- Key hint bar ----
|
|
3714
|
-
blessed.text({
|
|
3715
|
-
parent: modal,
|
|
3716
|
-
bottom: 2,
|
|
3717
|
-
left: 2,
|
|
3718
|
-
right: 2,
|
|
3719
|
-
content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [F] Favorite [/] Search [Esc] Cancel{/#455a64-fg}',
|
|
3720
|
-
tags: true,
|
|
3721
|
-
style: { bg: COLORS.contentBg },
|
|
3722
|
-
});
|
|
3723
|
-
|
|
3724
|
-
// ---- Buttons ----
|
|
3725
|
-
const selectBtn = _createButton(modal, screen, 'Select Voice', COLORS, () => {
|
|
3726
|
-
const voices = _getFiltered();
|
|
3727
|
-
const selected = voices[modalVoiceList.selected];
|
|
3728
|
-
if (selected) {
|
|
3729
|
-
providerService.setActiveVoice(selected);
|
|
3730
|
-
_closeModal();
|
|
3731
|
-
}
|
|
3732
|
-
});
|
|
3733
|
-
selectBtn.bottom = 1;
|
|
3734
|
-
selectBtn.left = 4;
|
|
3735
|
-
|
|
3736
|
-
const favBtn = _createButton(modal, screen, '★ Fav', COLORS, () => {
|
|
3737
|
-
const filtered = _getFiltered();
|
|
3738
|
-
const sel = filtered[modalVoiceList.selected];
|
|
3739
|
-
if (sel) { toggleFavorite(configService, sel); _refreshList(); }
|
|
3740
|
-
});
|
|
3741
|
-
favBtn.bottom = 1;
|
|
3742
|
-
favBtn.left = 22;
|
|
3743
|
-
|
|
3744
|
-
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeModal);
|
|
3745
|
-
cancelBtn.bottom = 1;
|
|
3746
|
-
cancelBtn.left = 33;
|
|
3747
|
-
|
|
3748
|
-
// ---- Helper functions ----
|
|
3749
|
-
|
|
3750
|
-
function _getFiltered() {
|
|
3751
|
-
if (!_filterText) return _allVoices;
|
|
3752
|
-
const f = _filterText.toLowerCase();
|
|
3753
|
-
return _allVoices.filter(v => v.toLowerCase().includes(f));
|
|
3754
|
-
}
|
|
3755
|
-
|
|
3756
|
-
function _buildItems(voices) {
|
|
3757
|
-
const active = providerService.getActiveVoiceId();
|
|
3758
|
-
const favs = getFavorites(configService);
|
|
3759
|
-
return voices.map(v => {
|
|
3760
|
-
const isFav = favs.includes(v);
|
|
3761
|
-
const isActive = v === active;
|
|
3762
|
-
const isPrev = v === _playingVoiceId;
|
|
3763
|
-
const star = isFav ? '★' : ' ';
|
|
3764
|
-
const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
|
|
3765
|
-
const { displayName, gender, provider } = getVoiceMeta(v);
|
|
3766
|
-
const name = displayName.length > COL_NAME_W
|
|
3767
|
-
? displayName.slice(0, COL_NAME_W - 1) + '…'
|
|
3768
|
-
: displayName.padEnd(COL_NAME_W);
|
|
3769
|
-
return ` ${star}${dot} ${name}${gender.padEnd(COL_GENDER_W)}${provider}`;
|
|
3770
|
-
});
|
|
3771
|
-
}
|
|
3772
|
-
|
|
3773
|
-
function _formatInfo(voiceId) {
|
|
3774
|
-
const { lang, name, quality } = parseVoiceId(voiceId);
|
|
3775
|
-
const Y = COLORS.valueFg;
|
|
3776
|
-
if (lang === 'unknown') {
|
|
3777
|
-
return `{${Y}-fg}Voice:{/${Y}-fg} ${voiceId} {${Y}-fg}Provider:{/${Y}-fg} Piper`;
|
|
3778
|
-
}
|
|
3779
|
-
return `{${Y}-fg}Voice:{/${Y}-fg} ${name} ` +
|
|
3780
|
-
`{${Y}-fg}Language:{/${Y}-fg} ${lang} ` +
|
|
3781
|
-
`{${Y}-fg}Quality:{/${Y}-fg} ${quality} ` +
|
|
3782
|
-
`{${Y}-fg}Provider:{/${Y}-fg} Piper ` +
|
|
3783
|
-
`{${Y}-fg}ID:{/${Y}-fg} ${voiceId}`;
|
|
3784
|
-
}
|
|
3785
|
-
|
|
3786
|
-
function _refreshList() {
|
|
3787
|
-
if (_closed) return;
|
|
3788
|
-
_allVoices = scanInstalledVoices();
|
|
3789
|
-
const filtered = _getFiltered();
|
|
3790
|
-
const items = _buildItems(filtered);
|
|
3791
|
-
modalVoiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
|
|
3792
|
-
const active = providerService.getActiveVoiceId();
|
|
3793
|
-
const sel = filtered[modalVoiceList.selected] ?? active ?? '';
|
|
3794
|
-
if (sel) modalInfoLine.setContent(` ${_formatInfo(sel)}`);
|
|
3795
|
-
screen.render();
|
|
3796
|
-
}
|
|
3797
|
-
|
|
3798
|
-
function _previewVoice(voiceId) {
|
|
3799
|
-
if (_playingVoiceId === voiceId) {
|
|
3800
|
-
_killPreview();
|
|
3801
|
-
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3802
|
-
return;
|
|
3803
|
-
}
|
|
3804
|
-
_killPreview();
|
|
3805
|
-
|
|
3806
|
-
// Path traversal guard
|
|
3807
|
-
const _ms3 = parseMultiSpeaker(voiceId);
|
|
3808
|
-
const voicePath = path.resolve(PIPER_VOICES_DIR, _ms3.model + '.onnx');
|
|
3809
|
-
const safeBase = path.resolve(PIPER_VOICES_DIR);
|
|
3810
|
-
if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
|
|
3811
|
-
|
|
3812
|
-
const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${randomUUID()}.wav`);
|
|
3813
|
-
const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
|
|
3814
|
-
|
|
3815
|
-
const _IS_WINDOWS = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
3816
|
-
let _piperBin3 = 'piper';
|
|
3817
|
-
if (_IS_WINDOWS) {
|
|
3818
|
-
const _lad = process.env.LOCALAPPDATA ||
|
|
3819
|
-
(process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
|
|
3820
|
-
if (_lad) {
|
|
3821
|
-
const _exe = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
|
|
3822
|
-
if (fs.existsSync(_exe)) _piperBin3 = _exe;
|
|
3823
|
-
}
|
|
3824
|
-
}
|
|
3825
|
-
const _piperArgs3 = ['--model', voicePath, '--output_file', tempWav];
|
|
3826
|
-
if (_ms3.speakerId != null) _piperArgs3.push('--speaker', String(_ms3.speakerId));
|
|
3827
|
-
const piper = spawn(_piperBin3, _piperArgs3, {
|
|
3828
|
-
stdio: ['pipe', 'ignore', 'ignore'],
|
|
3829
|
-
detached: !_IS_WINDOWS,
|
|
3830
|
-
windowsHide: true,
|
|
3831
|
-
env: _spawnEnv,
|
|
3832
|
-
});
|
|
3833
|
-
piper.stdin.write(phrase + '\n');
|
|
3834
|
-
piper.stdin.end();
|
|
3835
|
-
|
|
3836
|
-
_playingProcess = piper;
|
|
3837
|
-
_playingVoiceId = voiceId;
|
|
3838
|
-
if (!_closed) {
|
|
3839
|
-
modalPreviewLine.setContent(`{bright-cyan-fg}♪ Synthesizing: ${voiceId}…{/bright-cyan-fg}`);
|
|
3840
|
-
screen.render();
|
|
3841
|
-
}
|
|
3842
|
-
|
|
3843
|
-
piper.on('exit', (code) => {
|
|
3844
|
-
if (_playingVoiceId !== voiceId) {
|
|
3845
|
-
try { fs.unlinkSync(tempWav); } catch {}
|
|
3846
|
-
return;
|
|
3847
|
-
}
|
|
3848
|
-
if (code !== 0) {
|
|
3849
|
-
_playingVoiceId = null;
|
|
3850
|
-
_playingProcess = null;
|
|
3851
|
-
if (!_closed) {
|
|
3852
|
-
modalPreviewLine.setContent('{bright-cyan-fg}♪ Preview failed (piper error — is piper installed?){/bright-cyan-fg}');
|
|
3853
|
-
screen.render();
|
|
3854
|
-
setTimeout(() => { if (!_closed) { modalPreviewLine.setContent(''); screen.render(); } }, 4000);
|
|
3855
|
-
}
|
|
3856
|
-
return;
|
|
3857
|
-
}
|
|
3858
|
-
|
|
3859
|
-
const _wavPlayer3 = detectWavPlayer(_spawnEnv);
|
|
3860
|
-
if (!_wavPlayer3) return;
|
|
3861
|
-
const playProc = spawn(_wavPlayer3.bin, _wavPlayer3.args(tempWav), {
|
|
3862
|
-
stdio: 'ignore',
|
|
3863
|
-
detached: !_IS_WINDOWS,
|
|
3864
|
-
windowsHide: true,
|
|
3865
|
-
env: _spawnEnv,
|
|
3866
|
-
});
|
|
3867
|
-
_playingProcess = playProc;
|
|
3868
|
-
|
|
3869
|
-
if (!_closed) {
|
|
3870
|
-
modalPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId} (Space to stop){/bright-cyan-fg}`);
|
|
3871
|
-
screen.render();
|
|
3872
|
-
}
|
|
3873
|
-
|
|
3874
|
-
playProc.on('exit', () => {
|
|
3875
|
-
if (_playingVoiceId === voiceId) {
|
|
3876
|
-
_playingVoiceId = null;
|
|
3877
|
-
_playingProcess = null;
|
|
3878
|
-
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3879
|
-
}
|
|
3880
|
-
try { fs.unlinkSync(tempWav); } catch {}
|
|
3881
|
-
});
|
|
3882
|
-
|
|
3883
|
-
playProc.on('error', () => {
|
|
3884
|
-
_playingVoiceId = null;
|
|
3885
|
-
_playingProcess = null;
|
|
3886
|
-
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3887
|
-
try { fs.unlinkSync(tempWav); } catch {}
|
|
3888
|
-
});
|
|
3889
|
-
});
|
|
3890
|
-
|
|
3891
|
-
piper.on('error', () => {
|
|
3892
|
-
_playingVoiceId = null;
|
|
3893
|
-
_playingProcess = null;
|
|
3894
|
-
if (!_closed) {
|
|
3895
|
-
modalPreviewLine.setContent('{bright-cyan-fg}♪ Cannot find piper — install with: pipx install piper-tts{/bright-cyan-fg}');
|
|
3896
|
-
screen.render();
|
|
3897
|
-
setTimeout(() => { if (!_closed) { modalPreviewLine.setContent(''); screen.render(); } }, 4000);
|
|
3898
|
-
}
|
|
3899
|
-
});
|
|
3900
|
-
}
|
|
3901
|
-
|
|
3902
|
-
// ---- Key bindings ----
|
|
3903
|
-
|
|
3904
|
-
// Search: update filter on keypress
|
|
3905
|
-
modalSearch.on('keypress', () => {
|
|
3906
|
-
setTimeout(() => {
|
|
3907
|
-
_filterText = modalSearch.getValue().trim();
|
|
3908
|
-
_refreshList();
|
|
3909
|
-
}, 0);
|
|
3910
|
-
});
|
|
3911
|
-
|
|
3912
|
-
// Escape in search → back to list (not close)
|
|
3913
|
-
modalSearch.key(['escape'], () => {
|
|
3914
|
-
modalVoiceList.focus();
|
|
3915
|
-
screen.render();
|
|
3916
|
-
});
|
|
3917
|
-
|
|
3918
|
-
// Tab out of search → select button
|
|
3919
|
-
modalSearch.key(['tab'], () => { selectBtn.focus(); screen.render(); });
|
|
3920
|
-
|
|
3921
|
-
// / in list → open search
|
|
3922
|
-
modalVoiceList.key(['/'], () => {
|
|
3923
|
-
modalSearch.clearValue();
|
|
3924
|
-
modalSearch.focus();
|
|
3925
|
-
screen.render();
|
|
3926
|
-
});
|
|
3927
|
-
|
|
3928
|
-
// f → toggle favorite
|
|
3929
|
-
modalVoiceList.key(['f'], () => {
|
|
3930
|
-
const filtered = _getFiltered();
|
|
3931
|
-
const sel = filtered[modalVoiceList.selected];
|
|
3932
|
-
if (sel) { toggleFavorite(configService, sel); _refreshList(); }
|
|
3933
|
-
});
|
|
3934
|
-
|
|
3935
|
-
// Enter → select voice (set active + close modal)
|
|
3936
|
-
modalVoiceList.key(['enter'], () => {
|
|
3937
|
-
const filtered = _getFiltered();
|
|
3938
|
-
const sel = filtered[modalVoiceList.selected];
|
|
3939
|
-
if (sel) {
|
|
3940
|
-
providerService.setActiveVoice(sel);
|
|
3941
|
-
_closeModal();
|
|
3942
|
-
}
|
|
3943
|
-
});
|
|
3944
|
-
|
|
3945
|
-
// Space → preview voice (toggle)
|
|
3946
|
-
modalVoiceList.key(['space'], () => {
|
|
3947
|
-
const filtered = _getFiltered();
|
|
3948
|
-
const sel = filtered[modalVoiceList.selected];
|
|
3949
|
-
if (sel) { _previewVoice(sel); _refreshList(); }
|
|
3950
|
-
});
|
|
3951
|
-
|
|
3952
|
-
// Update info panel on selection change
|
|
3953
|
-
modalVoiceList.on('select item', () => {
|
|
3954
|
-
const filtered = _getFiltered();
|
|
3955
|
-
const sel = filtered[modalVoiceList.selected] ?? '';
|
|
3956
|
-
if (sel && !_closed) {
|
|
3957
|
-
modalInfoLine.setContent(` ${_formatInfo(sel)}`);
|
|
3958
|
-
screen.render();
|
|
3959
|
-
}
|
|
3960
|
-
});
|
|
3961
|
-
|
|
3962
|
-
// Tab navigation: list → [Select] → [★ Fav] → [Cancel] → list
|
|
3963
|
-
modalVoiceList.key(['tab'], () => { selectBtn.focus(); screen.render(); });
|
|
3964
|
-
selectBtn.key(['tab'], () => { favBtn.focus(); screen.render(); });
|
|
3965
|
-
favBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
|
|
3966
|
-
cancelBtn.key(['tab'], () => { modalVoiceList.focus(); screen.render(); });
|
|
3967
|
-
|
|
3968
|
-
// Escape / q closes modal
|
|
3969
|
-
modalVoiceList.key(['escape', 'q'], _closeModal);
|
|
3970
|
-
selectBtn.key(['escape'], _closeModal);
|
|
3971
|
-
favBtn.key(['escape'], _closeModal);
|
|
3972
|
-
cancelBtn.key(['escape'], _closeModal);
|
|
3973
|
-
|
|
3974
|
-
// ---- Initial load ----
|
|
3975
|
-
_refreshList();
|
|
3976
|
-
|
|
3977
|
-
// Scroll to active voice on open
|
|
3978
|
-
const activeVoiceId = providerService.getActiveVoiceId();
|
|
3979
|
-
const activeIdx = _getFiltered().indexOf(activeVoiceId);
|
|
3980
|
-
if (activeIdx >= 0) modalVoiceList.select(activeIdx);
|
|
3981
|
-
|
|
3982
|
-
modalVoiceList.focus();
|
|
3983
|
-
screen.render();
|
|
3984
|
-
}
|
|
3985
|
-
|
|
3986
|
-
// ---------------------------------------------------------------------------
|
|
3987
|
-
// Private: _openPersonalityPicker removed — now using shared import:
|
|
3988
|
-
// import { openPersonalityPicker } from '../widgets/personality-picker.js';
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes TUI Console — Settings Tab (Redesigned)
|
|
3
|
+
*
|
|
4
|
+
* Simplified flat settings list matching the mockup:
|
|
5
|
+
* 1. Interface Language
|
|
6
|
+
* 2. Default TTS Engine
|
|
7
|
+
* 3. Default Voice
|
|
8
|
+
* 4. Verbosity
|
|
9
|
+
* 5. Audio Destination
|
|
10
|
+
* 6. Config Storage (read-only)
|
|
11
|
+
* 7. Re-run Setup Wizard
|
|
12
|
+
*
|
|
13
|
+
* Implements the Tab Component Contract:
|
|
14
|
+
* createSettingsTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import {
|
|
23
|
+
scanInstalledVoices, getVoiceMeta, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker,
|
|
24
|
+
} from './voices-tab.js';
|
|
25
|
+
import { LanguageService } from '../../services/language-service.js';
|
|
26
|
+
import { SUPPORTED_LANGUAGES, t } from '../../i18n/strings.js';
|
|
27
|
+
import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
|
|
28
|
+
import { destroyList } from '../widgets/destroy-list.js';
|
|
29
|
+
import { openReverbPicker } from '../widgets/reverb-picker.js';
|
|
30
|
+
import { openPersonalityPicker } from '../widgets/personality-picker.js';
|
|
31
|
+
import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
|
|
32
|
+
import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
|
|
33
|
+
import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
|
|
34
|
+
import {
|
|
35
|
+
getAvailableEngines, checkEngineInstalled,
|
|
36
|
+
} from '../../services/tts-engine-service.js';
|
|
37
|
+
|
|
38
|
+
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
39
|
+
|
|
40
|
+
let blessed;
|
|
41
|
+
if (!IS_TEST) {
|
|
42
|
+
const { default: b } = await import('blessed');
|
|
43
|
+
blessed = b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Named ANSI colors only — hex renders as white on Paul's terminal
|
|
48
|
+
|
|
49
|
+
const COLORS = {
|
|
50
|
+
contentBg: 'black',
|
|
51
|
+
sectionHdr: 'bright-cyan',
|
|
52
|
+
labelFg: 'white',
|
|
53
|
+
valueFg: 'yellow',
|
|
54
|
+
btnDefault: 'blue',
|
|
55
|
+
btnFocus: 'green',
|
|
56
|
+
btnFocusFg: 'white',
|
|
57
|
+
btnPress: 'magenta',
|
|
58
|
+
borderFg: 'bright-cyan',
|
|
59
|
+
footerBg: '#2196f3',
|
|
60
|
+
noticeFg: 'white',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const FOOTER_TEXT =
|
|
64
|
+
'[↑↓] Navigate [Enter] Edit [Esc] Tab Bar';
|
|
65
|
+
|
|
66
|
+
const MUSIC_DEFAULTS = Object.freeze({ enabled: false, track: 'agentvibes_soft_flamenco_loop.mp3', volume: 20 });
|
|
67
|
+
const VERBOSITY_LABELS = Object.freeze({ high: 'High', medium: 'Medium', low: 'Low', minimal: 'Minimal', custom: 'Custom' });
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Exported format helpers (pure functions — used by tests and UI)
|
|
71
|
+
|
|
72
|
+
export const formatReverbState = _sharedFormatReverbState;
|
|
73
|
+
|
|
74
|
+
export function formatMusicState(enabled) {
|
|
75
|
+
return enabled ? 'Enabled' : 'Disabled';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatVolume(volume) {
|
|
79
|
+
const v = typeof volume === 'number' && !isNaN(volume) ? volume : MUSIC_DEFAULTS.volume;
|
|
80
|
+
return `${Math.max(10, Math.min(100, v))}%`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const formatTrackName = _sharedFormatTrackName;
|
|
84
|
+
|
|
85
|
+
export function formatVerbosity(verbosity) {
|
|
86
|
+
return VERBOSITY_LABELS[verbosity] ?? 'High';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatPersonality(personality) {
|
|
90
|
+
const name = personality || 'none';
|
|
91
|
+
const emoji = PERSONALITY_EMOJIS[name] ?? '✨';
|
|
92
|
+
const label = name === 'none' ? 'None' : name.charAt(0).toUpperCase() + name.slice(1);
|
|
93
|
+
return `${emoji} ${label}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatIntroText(pretext) {
|
|
97
|
+
if (!pretext) return '(none)';
|
|
98
|
+
return pretext.length > 30 ? pretext.slice(0, 30) + '…' : pretext;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Test stub
|
|
103
|
+
|
|
104
|
+
function createTestStub() {
|
|
105
|
+
return {
|
|
106
|
+
box: {},
|
|
107
|
+
show: () => {},
|
|
108
|
+
hide: () => {},
|
|
109
|
+
onFocus: () => {},
|
|
110
|
+
onBlur: () => {},
|
|
111
|
+
getFooterText: () => FOOTER_TEXT,
|
|
112
|
+
getFooterColor: () => COLORS.footerBg,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create the Settings tab component (redesigned flat list).
|
|
120
|
+
*
|
|
121
|
+
* @param {object} screen - Blessed screen instance (or test stub)
|
|
122
|
+
* @param {object} services
|
|
123
|
+
* @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
|
|
124
|
+
*/
|
|
125
|
+
export function createSettingsTab(screen, services) {
|
|
126
|
+
if (IS_TEST) return createTestStub();
|
|
127
|
+
|
|
128
|
+
const { configService, providerService, navigationService, focusMainTabBar, languageService } = services;
|
|
129
|
+
|
|
130
|
+
// ── Container ────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
const box = blessed.box({
|
|
133
|
+
parent: screen,
|
|
134
|
+
top: 5,
|
|
135
|
+
left: 0,
|
|
136
|
+
width: '100%',
|
|
137
|
+
bottom: 2,
|
|
138
|
+
tags: true,
|
|
139
|
+
keys: true,
|
|
140
|
+
scrollable: false,
|
|
141
|
+
hidden: true,
|
|
142
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── Settings items definition ────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
const SETTINGS = [
|
|
148
|
+
{
|
|
149
|
+
key: 'language',
|
|
150
|
+
label: 'Interface Language',
|
|
151
|
+
getValue: () => {
|
|
152
|
+
const lang = languageService?.getLang() ?? configService?.getConfig?.()?.language ?? 'en';
|
|
153
|
+
const entry = SUPPORTED_LANGUAGES.find(l => l.value === lang);
|
|
154
|
+
return entry ? entry.name : lang;
|
|
155
|
+
},
|
|
156
|
+
desc: 'Press Enter to change — also accessible during first-run Setup wizard',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: 'ttsEngine',
|
|
160
|
+
label: 'Default TTS Engine',
|
|
161
|
+
getValue: () => {
|
|
162
|
+
const engine = configService?.getConfig?.()?.ttsEngine ?? '';
|
|
163
|
+
if (!engine) {
|
|
164
|
+
const engines = getAvailableEngines();
|
|
165
|
+
const installed = engines.find(e => checkEngineInstalled(e.id));
|
|
166
|
+
return installed ? installed.name : '(none)';
|
|
167
|
+
}
|
|
168
|
+
const match = getAvailableEngines().find(e => e.id === engine);
|
|
169
|
+
return match ? match.name : engine;
|
|
170
|
+
},
|
|
171
|
+
desc: 'Global default — individual providers can override in Setup → Configure',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
key: 'voice',
|
|
175
|
+
label: 'Default Voice',
|
|
176
|
+
getValue: () => {
|
|
177
|
+
const voice = providerService?.getActiveVoiceId() ?? configService?.getConfig?.()?.voice ?? '';
|
|
178
|
+
if (!voice) return '(none)';
|
|
179
|
+
const meta = getVoiceMeta(voice);
|
|
180
|
+
return meta.displayName || voice;
|
|
181
|
+
},
|
|
182
|
+
desc: 'Global default voice — providers can override',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
key: 'verbosity',
|
|
186
|
+
label: 'Verbosity',
|
|
187
|
+
getValue: () => formatVerbosity(configService?.getConfig?.()?.verbosity ?? 'high'),
|
|
188
|
+
desc: null,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
key: 'audioDst',
|
|
192
|
+
label: 'Audio Destination',
|
|
193
|
+
getValue: () => {
|
|
194
|
+
const dst = configService?.getConfig?.()?.audio_destination ?? 'local';
|
|
195
|
+
if (dst === 'remote') {
|
|
196
|
+
const alias = configService?.getConfig?.()?.audio_ssh_alias ?? '';
|
|
197
|
+
const mode = configService?.getConfig?.()?.audio_stream_mode ?? 'text';
|
|
198
|
+
return `Remote (${alias || 'no alias'}) — ${mode === 'pulse' ? 'PulseAudio' : 'Text Only'}`;
|
|
199
|
+
}
|
|
200
|
+
return 'Local';
|
|
201
|
+
},
|
|
202
|
+
desc: 'Play audio locally or stream to a remote host via SSH',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
key: 'configStorage',
|
|
206
|
+
label: 'Config Storage',
|
|
207
|
+
getValue: () => {
|
|
208
|
+
const home = os.homedir();
|
|
209
|
+
const globalPath = path.join(home, '.claude', 'config', 'audio-effects.cfg');
|
|
210
|
+
const localPath = path.join('.claude', 'config', 'audio-effects.cfg');
|
|
211
|
+
return `Global: ${globalPath} | Local: ${localPath}`;
|
|
212
|
+
},
|
|
213
|
+
desc: null,
|
|
214
|
+
readOnly: true,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
key: 'rerunWizard',
|
|
218
|
+
label: 'Re-run Setup Wizard',
|
|
219
|
+
getValue: () => '',
|
|
220
|
+
desc: 'Press Enter to re-run the first-time setup (Language → Deps → TTS → Providers)',
|
|
221
|
+
isAction: true,
|
|
222
|
+
},
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
// ── Build UI ─────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
const headerText = blessed.text({
|
|
228
|
+
parent: box,
|
|
229
|
+
top: 0,
|
|
230
|
+
left: 2,
|
|
231
|
+
tags: true,
|
|
232
|
+
content: '{bold}{cyan-fg}Settings{/cyan-fg}{/bold}',
|
|
233
|
+
style: { bg: COLORS.contentBg },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Settings rows — each is a bordered section
|
|
237
|
+
const _settingWidgets = [];
|
|
238
|
+
let yPos = 2;
|
|
239
|
+
|
|
240
|
+
for (let i = 0; i < SETTINGS.length; i++) {
|
|
241
|
+
const setting = SETTINGS[i];
|
|
242
|
+
const rowHeight = setting.desc ? 3 : 2;
|
|
243
|
+
|
|
244
|
+
const rowBox = blessed.box({
|
|
245
|
+
parent: box,
|
|
246
|
+
top: yPos,
|
|
247
|
+
left: 2,
|
|
248
|
+
right: 2,
|
|
249
|
+
height: rowHeight,
|
|
250
|
+
border: { type: 'line' },
|
|
251
|
+
tags: true,
|
|
252
|
+
style: {
|
|
253
|
+
fg: COLORS.labelFg,
|
|
254
|
+
bg: COLORS.contentBg,
|
|
255
|
+
border: { fg: 'blue' },
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const labelWidget = blessed.text({
|
|
260
|
+
parent: rowBox,
|
|
261
|
+
top: 0,
|
|
262
|
+
left: 1,
|
|
263
|
+
tags: true,
|
|
264
|
+
content: `{bold}{cyan-fg}${setting.label}{/cyan-fg}{/bold}`,
|
|
265
|
+
style: { bg: COLORS.contentBg },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const valueWidget = blessed.text({
|
|
269
|
+
parent: rowBox,
|
|
270
|
+
top: 0,
|
|
271
|
+
left: setting.label.length + 4,
|
|
272
|
+
right: 1,
|
|
273
|
+
tags: true,
|
|
274
|
+
wrap: false,
|
|
275
|
+
content: '',
|
|
276
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
let descWidget = null;
|
|
280
|
+
if (setting.desc) {
|
|
281
|
+
descWidget = blessed.text({
|
|
282
|
+
parent: rowBox,
|
|
283
|
+
top: 1,
|
|
284
|
+
left: 1,
|
|
285
|
+
right: 1,
|
|
286
|
+
tags: true,
|
|
287
|
+
wrap: false,
|
|
288
|
+
content: `{white-fg}${setting.desc}{/white-fg}`,
|
|
289
|
+
style: { bg: COLORS.contentBg },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_settingWidgets.push({ setting, rowBox, labelWidget, valueWidget, descWidget });
|
|
294
|
+
yPos += rowHeight + 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Footer hint
|
|
298
|
+
const hintLine = blessed.text({
|
|
299
|
+
parent: box,
|
|
300
|
+
bottom: 0,
|
|
301
|
+
left: 2,
|
|
302
|
+
right: 2,
|
|
303
|
+
tags: true,
|
|
304
|
+
content: `{white-fg}${FOOTER_TEXT}{/white-fg}`,
|
|
305
|
+
style: { bg: COLORS.contentBg },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── Selection state ──────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
let _selectedIdx = 0;
|
|
311
|
+
|
|
312
|
+
function _highlightRow(idx) {
|
|
313
|
+
for (let i = 0; i < _settingWidgets.length; i++) {
|
|
314
|
+
const w = _settingWidgets[i];
|
|
315
|
+
if (i === idx) {
|
|
316
|
+
w.rowBox.style.bg = 'magenta';
|
|
317
|
+
w.rowBox.style.border.fg = 'magenta';
|
|
318
|
+
w.labelWidget.style.bg = 'magenta';
|
|
319
|
+
w.labelWidget.style.fg = 'white';
|
|
320
|
+
w.valueWidget.style.bg = 'magenta';
|
|
321
|
+
if (w.descWidget) w.descWidget.style.bg = 'magenta';
|
|
322
|
+
} else {
|
|
323
|
+
w.rowBox.style.bg = COLORS.contentBg;
|
|
324
|
+
w.rowBox.style.border.fg = 'blue';
|
|
325
|
+
w.labelWidget.style.bg = COLORS.contentBg;
|
|
326
|
+
w.labelWidget.style.fg = 'cyan';
|
|
327
|
+
w.valueWidget.style.bg = COLORS.contentBg;
|
|
328
|
+
if (w.descWidget) w.descWidget.style.bg = COLORS.contentBg;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
screen.render();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function _refreshValues() {
|
|
335
|
+
for (const w of _settingWidgets) {
|
|
336
|
+
const val = w.setting.getValue();
|
|
337
|
+
if (w.setting.isAction) {
|
|
338
|
+
w.valueWidget.setContent('');
|
|
339
|
+
} else {
|
|
340
|
+
w.valueWidget.setContent(`{yellow-fg}${val}{/yellow-fg}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Key navigation ───────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
box.key(['down', 'j'], () => {
|
|
348
|
+
_selectedIdx = Math.min(_selectedIdx + 1, SETTINGS.length - 1);
|
|
349
|
+
_highlightRow(_selectedIdx);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
box.key(['up', 'k'], () => {
|
|
353
|
+
if (_selectedIdx === 0) {
|
|
354
|
+
_selectedIdx = -1;
|
|
355
|
+
_highlightRow(-1);
|
|
356
|
+
if (typeof focusMainTabBar === 'function') {
|
|
357
|
+
focusMainTabBar();
|
|
358
|
+
screen.render();
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
_selectedIdx = _selectedIdx - 1;
|
|
363
|
+
_highlightRow(_selectedIdx);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
box.key(['escape'], () => {
|
|
367
|
+
if (typeof focusMainTabBar === 'function') {
|
|
368
|
+
focusMainTabBar();
|
|
369
|
+
screen.render();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
box.key(['enter', 'space'], () => {
|
|
374
|
+
const setting = SETTINGS[_selectedIdx];
|
|
375
|
+
if (setting.readOnly) return;
|
|
376
|
+
_handleEdit(setting);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ── Edit handlers ────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
function _handleEdit(setting) {
|
|
382
|
+
switch (setting.key) {
|
|
383
|
+
case 'language':
|
|
384
|
+
_editLanguage();
|
|
385
|
+
break;
|
|
386
|
+
case 'ttsEngine':
|
|
387
|
+
_editTtsEngine();
|
|
388
|
+
break;
|
|
389
|
+
case 'voice':
|
|
390
|
+
_editVoice();
|
|
391
|
+
break;
|
|
392
|
+
case 'verbosity':
|
|
393
|
+
_editVerbosity();
|
|
394
|
+
break;
|
|
395
|
+
case 'audioDst':
|
|
396
|
+
_editAudioDst();
|
|
397
|
+
break;
|
|
398
|
+
case 'rerunWizard':
|
|
399
|
+
_rerunWizard();
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Language editor ──────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
function _editLanguage() {
|
|
407
|
+
navigationService?.openModal();
|
|
408
|
+
|
|
409
|
+
const modal = blessed.list({
|
|
410
|
+
parent: screen,
|
|
411
|
+
top: 'center',
|
|
412
|
+
left: 'center',
|
|
413
|
+
width: 40,
|
|
414
|
+
height: SUPPORTED_LANGUAGES.length + 4,
|
|
415
|
+
border: { type: 'line' },
|
|
416
|
+
tags: true,
|
|
417
|
+
label: ' {bold}{cyan-fg} Select Language {/cyan-fg}{/bold} ',
|
|
418
|
+
keys: true,
|
|
419
|
+
vi: true,
|
|
420
|
+
mouse: true,
|
|
421
|
+
style: {
|
|
422
|
+
fg: COLORS.labelFg,
|
|
423
|
+
bg: COLORS.contentBg,
|
|
424
|
+
border: { fg: 'cyan' },
|
|
425
|
+
selected: { bg: 'blue', fg: 'yellow' },
|
|
426
|
+
item: { fg: COLORS.labelFg },
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
modal.setFront();
|
|
430
|
+
|
|
431
|
+
const items = SUPPORTED_LANGUAGES.map(l => ` ${l.name}`);
|
|
432
|
+
modal.setItems(items);
|
|
433
|
+
|
|
434
|
+
const currentLang = languageService?.getLang() ?? 'en';
|
|
435
|
+
const currentIdx = SUPPORTED_LANGUAGES.findIndex(l => l.value === currentLang);
|
|
436
|
+
if (currentIdx >= 0) modal.select(currentIdx);
|
|
437
|
+
|
|
438
|
+
modal.key(['enter'], () => {
|
|
439
|
+
const sel = SUPPORTED_LANGUAGES[modal.selected];
|
|
440
|
+
if (sel) {
|
|
441
|
+
configService.set('language', sel.value);
|
|
442
|
+
if (languageService) languageService.setLang(sel.value);
|
|
443
|
+
}
|
|
444
|
+
_closeModal();
|
|
445
|
+
});
|
|
446
|
+
modal.key(['escape', 'q'], _closeModal);
|
|
447
|
+
|
|
448
|
+
function _closeModal() {
|
|
449
|
+
navigationService?.closeModal();
|
|
450
|
+
destroyList(modal, screen);
|
|
451
|
+
_refreshValues();
|
|
452
|
+
box.focus();
|
|
453
|
+
screen.render();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
modal.focus();
|
|
457
|
+
screen.render();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── TTS Engine editor ────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
function _editTtsEngine() {
|
|
463
|
+
navigationService?.openModal();
|
|
464
|
+
|
|
465
|
+
const engines = getAvailableEngines();
|
|
466
|
+
const modal = blessed.list({
|
|
467
|
+
parent: screen,
|
|
468
|
+
top: 'center',
|
|
469
|
+
left: 'center',
|
|
470
|
+
width: 50,
|
|
471
|
+
height: engines.length + 4,
|
|
472
|
+
border: { type: 'line' },
|
|
473
|
+
tags: true,
|
|
474
|
+
label: ' {bold}{cyan-fg} Default TTS Engine {/cyan-fg}{/bold} ',
|
|
475
|
+
keys: true,
|
|
476
|
+
vi: true,
|
|
477
|
+
mouse: true,
|
|
478
|
+
style: {
|
|
479
|
+
fg: COLORS.labelFg,
|
|
480
|
+
bg: COLORS.contentBg,
|
|
481
|
+
border: { fg: 'cyan' },
|
|
482
|
+
selected: { bg: 'blue', fg: 'yellow' },
|
|
483
|
+
item: { fg: COLORS.labelFg },
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
modal.setFront();
|
|
487
|
+
|
|
488
|
+
const items = engines.map(e => {
|
|
489
|
+
const installed = checkEngineInstalled(e.id);
|
|
490
|
+
const status = installed ? '{green-fg}[OK]{/green-fg}' : '{yellow-fg}[N/A]{/yellow-fg}';
|
|
491
|
+
return ` ${e.name} ${status}`;
|
|
492
|
+
});
|
|
493
|
+
modal.setItems(items);
|
|
494
|
+
|
|
495
|
+
modal.key(['enter'], () => {
|
|
496
|
+
const sel = engines[modal.selected];
|
|
497
|
+
if (sel) configService.set('ttsEngine', sel.id);
|
|
498
|
+
_closeModal();
|
|
499
|
+
});
|
|
500
|
+
modal.key(['escape', 'q'], _closeModal);
|
|
501
|
+
|
|
502
|
+
function _closeModal() {
|
|
503
|
+
navigationService?.closeModal();
|
|
504
|
+
destroyList(modal, screen);
|
|
505
|
+
_refreshValues();
|
|
506
|
+
box.focus();
|
|
507
|
+
screen.render();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
modal.focus();
|
|
511
|
+
screen.render();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Voice editor (with space bar preview — matches agents-tab pattern) ──
|
|
515
|
+
|
|
516
|
+
function _secureTempWav(prefix) {
|
|
517
|
+
const baseDir = process.env.XDG_RUNTIME_DIR || os.tmpdir();
|
|
518
|
+
const dir = path.join(baseDir, `agentvibes-${process.getuid?.() ?? 'u'}`);
|
|
519
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
520
|
+
try { fs.chmodSync(dir, 0o700); } catch {}
|
|
521
|
+
return path.join(dir, `${prefix}-${crypto.randomUUID()}.wav`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function _editVoice() {
|
|
525
|
+
navigationService?.openModal();
|
|
526
|
+
|
|
527
|
+
let _allVoices = [];
|
|
528
|
+
let _filterText = '';
|
|
529
|
+
let _previewProc = null;
|
|
530
|
+
let _previewVoiceId = null;
|
|
531
|
+
let _vpClosed = false;
|
|
532
|
+
|
|
533
|
+
const _spawnEnv = buildAudioEnv();
|
|
534
|
+
const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
535
|
+
|
|
536
|
+
function _killVP() {
|
|
537
|
+
if (_previewProc) {
|
|
538
|
+
try {
|
|
539
|
+
if (_isWin) { _previewProc.kill(); } else { process.kill(-_previewProc.pid, 'SIGTERM'); }
|
|
540
|
+
} catch {}
|
|
541
|
+
_previewProc = null;
|
|
542
|
+
}
|
|
543
|
+
_previewVoiceId = null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function _closeVP() {
|
|
547
|
+
if (_vpClosed) return;
|
|
548
|
+
_vpClosed = true;
|
|
549
|
+
_killVP();
|
|
550
|
+
navigationService?.closeModal();
|
|
551
|
+
destroyList(vpModal, screen);
|
|
552
|
+
_refreshValues();
|
|
553
|
+
box.focus();
|
|
554
|
+
screen.render();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const vpModal = blessed.box({
|
|
558
|
+
parent: screen,
|
|
559
|
+
top: '6%',
|
|
560
|
+
left: '3%',
|
|
561
|
+
width: '94%',
|
|
562
|
+
height: '88%',
|
|
563
|
+
border: { type: 'line' },
|
|
564
|
+
tags: true,
|
|
565
|
+
label: ' {bold}{cyan-fg} Select Default Voice {/cyan-fg}{/bold} ',
|
|
566
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: 'cyan' } },
|
|
567
|
+
});
|
|
568
|
+
vpModal.setFront();
|
|
569
|
+
|
|
570
|
+
blessed.text({
|
|
571
|
+
parent: vpModal, top: 1, left: 2,
|
|
572
|
+
content: 'Search:', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
573
|
+
});
|
|
574
|
+
const vpSearch = blessed.textbox({
|
|
575
|
+
parent: vpModal, top: 1, left: 11, width: 40, height: 1,
|
|
576
|
+
inputOnFocus: true, keys: true,
|
|
577
|
+
style: { fg: COLORS.valueFg, bg: 'blue', focus: { bg: 'cyan' } },
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const COL_N = 28;
|
|
581
|
+
const COL_G = 10;
|
|
582
|
+
blessed.text({
|
|
583
|
+
parent: vpModal, top: 2, left: 6, tags: true,
|
|
584
|
+
content: `{cyan-fg}${'Name'.padEnd(COL_N)}${'Gender'.padEnd(COL_G)}Provider{/cyan-fg}`,
|
|
585
|
+
style: { bg: COLORS.contentBg },
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const vpList = blessed.list({
|
|
589
|
+
parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
|
|
590
|
+
keys: true, vi: true, mouse: true,
|
|
591
|
+
border: { type: 'line' },
|
|
592
|
+
scrollbar: { ch: '|', style: { fg: 'cyan' } },
|
|
593
|
+
tags: true,
|
|
594
|
+
style: {
|
|
595
|
+
fg: COLORS.labelFg, bg: COLORS.contentBg,
|
|
596
|
+
border: { fg: 'blue' },
|
|
597
|
+
selected: { bg: 'green', fg: 'white', bold: true },
|
|
598
|
+
item: { fg: COLORS.labelFg },
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const vpPreviewLine = blessed.text({
|
|
603
|
+
parent: vpModal, bottom: 3, left: 2, right: 2, tags: true,
|
|
604
|
+
content: '', style: { fg: 'cyan', bg: COLORS.contentBg },
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
blessed.text({
|
|
608
|
+
parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
|
|
609
|
+
content: '{white-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [/] Search [Esc] Cancel{/white-fg}',
|
|
610
|
+
style: { bg: COLORS.contentBg },
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
function _getFiltered() {
|
|
614
|
+
if (!_filterText) return _allVoices;
|
|
615
|
+
const f = _filterText.toLowerCase();
|
|
616
|
+
return _allVoices.filter(v => v.toLowerCase().includes(f));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function _buildItems(voices) {
|
|
620
|
+
const currentVoice = providerService?.getActiveVoiceId() ?? '';
|
|
621
|
+
return voices.map(v => {
|
|
622
|
+
const isActive = v === currentVoice;
|
|
623
|
+
const isPrev = v === _previewVoiceId;
|
|
624
|
+
const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
|
|
625
|
+
const meta = getVoiceMeta(v);
|
|
626
|
+
const name = meta.displayName.length > COL_N
|
|
627
|
+
? meta.displayName.slice(0, COL_N - 1) + '…'
|
|
628
|
+
: meta.displayName.padEnd(COL_N);
|
|
629
|
+
return ` ${dot} ${name}${meta.gender.padEnd(COL_G)}${meta.provider}`;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function _refreshVP() {
|
|
634
|
+
if (_vpClosed) return;
|
|
635
|
+
const savedIdx = vpList.selected ?? 0;
|
|
636
|
+
const savedScroll = vpList.childBase ?? 0;
|
|
637
|
+
_allVoices = scanInstalledVoices();
|
|
638
|
+
const filtered = _getFiltered();
|
|
639
|
+
const items = _buildItems(filtered);
|
|
640
|
+
vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
|
|
641
|
+
vpList.select(Math.min(savedIdx, items.length - 1));
|
|
642
|
+
vpList.childBase = Math.min(savedScroll, Math.max(0, items.length - (vpList.height - 2)));
|
|
643
|
+
screen.render();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function _previewVoice(voiceId) {
|
|
647
|
+
if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
|
|
648
|
+
_killVP();
|
|
649
|
+
|
|
650
|
+
const _ms = parseMultiSpeaker(voiceId);
|
|
651
|
+
const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
|
|
652
|
+
const safeBase = path.resolve(PIPER_VOICES_DIR);
|
|
653
|
+
if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
|
|
654
|
+
|
|
655
|
+
const tempWav = _secureTempWav('vp');
|
|
656
|
+
const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
|
|
657
|
+
|
|
658
|
+
let _piperBin = 'piper';
|
|
659
|
+
if (_isWin) {
|
|
660
|
+
const _lad = process.env.LOCALAPPDATA ||
|
|
661
|
+
(process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
|
|
662
|
+
if (_lad) {
|
|
663
|
+
const _ep = path.join(_lad, 'Programs', 'Piper', 'piper.exe');
|
|
664
|
+
if (fs.existsSync(_ep)) _piperBin = _ep;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const args = ['--model', voicePath, '--output_file', tempWav];
|
|
669
|
+
if (_ms.speakerId != null) args.push('--speaker', String(_ms.speakerId));
|
|
670
|
+
const piper = spawn(_piperBin, args, {
|
|
671
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
672
|
+
detached: !_isWin,
|
|
673
|
+
windowsHide: true,
|
|
674
|
+
env: _spawnEnv,
|
|
675
|
+
});
|
|
676
|
+
piper.stdin.write(phrase + '\n');
|
|
677
|
+
piper.stdin.end();
|
|
678
|
+
_previewProc = piper;
|
|
679
|
+
_previewVoiceId = voiceId;
|
|
680
|
+
|
|
681
|
+
if (!_vpClosed) {
|
|
682
|
+
vpPreviewLine.setContent(`{cyan-fg}♪ Synthesizing: ${voiceId}...{/cyan-fg}`);
|
|
683
|
+
_refreshVP();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
piper.on('exit', (code) => {
|
|
687
|
+
if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
|
|
688
|
+
if (code !== 0) { _previewProc = null; _previewVoiceId = null; try { fs.unlinkSync(tempWav); } catch {} return; }
|
|
689
|
+
const wp = detectWavPlayer(_spawnEnv);
|
|
690
|
+
if (!wp) return;
|
|
691
|
+
const pp = spawn(wp.bin, wp.args(tempWav), {
|
|
692
|
+
stdio: 'ignore',
|
|
693
|
+
detached: !_isWin,
|
|
694
|
+
windowsHide: true,
|
|
695
|
+
env: _spawnEnv,
|
|
696
|
+
});
|
|
697
|
+
_previewProc = pp;
|
|
698
|
+
if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}{/cyan-fg}`); screen.render(); }
|
|
699
|
+
pp.on('exit', () => {
|
|
700
|
+
if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
|
|
701
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
vpSearch.on('keypress', () => {
|
|
708
|
+
setTimeout(() => { _filterText = vpSearch.getValue().trim(); _refreshVP(); }, 0);
|
|
709
|
+
});
|
|
710
|
+
vpSearch.key(['escape'], () => { vpList.focus(); screen.render(); });
|
|
711
|
+
vpList.key(['/'], () => { vpSearch.clearValue(); vpSearch.focus(); screen.render(); });
|
|
712
|
+
vpList.key(['enter'], () => {
|
|
713
|
+
const filtered = _getFiltered();
|
|
714
|
+
const sel = filtered[vpList.selected];
|
|
715
|
+
if (sel) {
|
|
716
|
+
if (providerService) providerService.setActiveVoice(sel);
|
|
717
|
+
else configService.set('voice', sel);
|
|
718
|
+
}
|
|
719
|
+
_closeVP();
|
|
720
|
+
});
|
|
721
|
+
vpList.key(['space'], () => {
|
|
722
|
+
const filtered = _getFiltered();
|
|
723
|
+
const sel = filtered[vpList.selected];
|
|
724
|
+
if (sel) _previewVoice(sel);
|
|
725
|
+
});
|
|
726
|
+
vpList.key(['escape', 'q'], _closeVP);
|
|
727
|
+
|
|
728
|
+
_refreshVP();
|
|
729
|
+
const currentVoice = providerService?.getActiveVoiceId() ?? '';
|
|
730
|
+
const activeIdx = _getFiltered().indexOf(currentVoice);
|
|
731
|
+
if (activeIdx >= 0) vpList.select(activeIdx);
|
|
732
|
+
vpList.focus();
|
|
733
|
+
screen.render();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── Verbosity editor ─────────────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
function _editVerbosity() {
|
|
739
|
+
navigationService?.openModal();
|
|
740
|
+
|
|
741
|
+
const levels = ['high', 'medium', 'low'];
|
|
742
|
+
const modal = blessed.list({
|
|
743
|
+
parent: screen,
|
|
744
|
+
top: 'center',
|
|
745
|
+
left: 'center',
|
|
746
|
+
width: 30,
|
|
747
|
+
height: levels.length + 4,
|
|
748
|
+
border: { type: 'line' },
|
|
749
|
+
tags: true,
|
|
750
|
+
label: ' {bold}{cyan-fg} Verbosity {/cyan-fg}{/bold} ',
|
|
751
|
+
keys: true,
|
|
752
|
+
vi: true,
|
|
753
|
+
mouse: true,
|
|
754
|
+
style: {
|
|
755
|
+
fg: COLORS.labelFg,
|
|
756
|
+
bg: COLORS.contentBg,
|
|
757
|
+
border: { fg: 'cyan' },
|
|
758
|
+
selected: { bg: 'blue', fg: 'yellow' },
|
|
759
|
+
item: { fg: COLORS.labelFg },
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
modal.setFront();
|
|
763
|
+
|
|
764
|
+
modal.setItems(levels.map(l => ` ${formatVerbosity(l)}`));
|
|
765
|
+
const current = configService?.getConfig?.()?.verbosity ?? 'high';
|
|
766
|
+
const idx = levels.indexOf(current);
|
|
767
|
+
if (idx >= 0) modal.select(idx);
|
|
768
|
+
|
|
769
|
+
modal.key(['enter'], () => {
|
|
770
|
+
const sel = levels[modal.selected];
|
|
771
|
+
if (sel) configService.set('verbosity', sel);
|
|
772
|
+
_closeModal();
|
|
773
|
+
});
|
|
774
|
+
modal.key(['escape', 'q'], _closeModal);
|
|
775
|
+
|
|
776
|
+
function _closeModal() {
|
|
777
|
+
navigationService?.closeModal();
|
|
778
|
+
destroyList(modal, screen);
|
|
779
|
+
_refreshValues();
|
|
780
|
+
box.focus();
|
|
781
|
+
screen.render();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
modal.focus();
|
|
785
|
+
screen.render();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── Audio Destination editor ─────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
function _detectSshAliases() {
|
|
791
|
+
try {
|
|
792
|
+
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
|
|
793
|
+
const raw = fs.readFileSync(sshConfigPath, 'utf8');
|
|
794
|
+
const matches = raw.match(/^Host\s+(\S+)/gm);
|
|
795
|
+
if (!matches) return [];
|
|
796
|
+
return matches.map(m => m.replace(/^Host\s+/, '').trim()).filter(a => a !== '*');
|
|
797
|
+
} catch {
|
|
798
|
+
return [];
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function _editAudioDst() {
|
|
803
|
+
navigationService?.openModal();
|
|
804
|
+
|
|
805
|
+
const choices = ['local', 'remote'];
|
|
806
|
+
const modal = blessed.list({
|
|
807
|
+
parent: screen,
|
|
808
|
+
top: 'center',
|
|
809
|
+
left: 'center',
|
|
810
|
+
width: 40,
|
|
811
|
+
height: choices.length + 4,
|
|
812
|
+
border: { type: 'line' },
|
|
813
|
+
tags: true,
|
|
814
|
+
label: ' {bold}{cyan-fg} Audio Destination {/cyan-fg}{/bold} ',
|
|
815
|
+
keys: true,
|
|
816
|
+
vi: true,
|
|
817
|
+
mouse: true,
|
|
818
|
+
style: {
|
|
819
|
+
fg: COLORS.labelFg,
|
|
820
|
+
bg: COLORS.contentBg,
|
|
821
|
+
border: { fg: 'cyan' },
|
|
822
|
+
selected: { bg: 'blue', fg: 'yellow' },
|
|
823
|
+
item: { fg: COLORS.labelFg },
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
modal.setFront();
|
|
827
|
+
|
|
828
|
+
modal.setItems(choices.map(c => ` ${c === 'local' ? 'Local' : 'Remote (SSH)'}`));
|
|
829
|
+
const current = configService?.getConfig?.()?.audio_destination ?? 'local';
|
|
830
|
+
const idx = choices.indexOf(current);
|
|
831
|
+
if (idx >= 0) modal.select(idx);
|
|
832
|
+
|
|
833
|
+
modal.key(['enter'], () => {
|
|
834
|
+
const sel = choices[modal.selected];
|
|
835
|
+
if (sel) {
|
|
836
|
+
configService.set('audio_destination', sel);
|
|
837
|
+
if (sel === 'remote' && !(configService.getConfig().audio_ssh_alias)) {
|
|
838
|
+
// Prompt for SSH alias
|
|
839
|
+
_closeModal();
|
|
840
|
+
_promptSshAlias();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
_closeModal();
|
|
845
|
+
});
|
|
846
|
+
modal.key(['escape', 'q'], _closeModal);
|
|
847
|
+
|
|
848
|
+
function _closeModal() {
|
|
849
|
+
navigationService?.closeModal();
|
|
850
|
+
destroyList(modal, screen);
|
|
851
|
+
_refreshValues();
|
|
852
|
+
box.focus();
|
|
853
|
+
screen.render();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
modal.focus();
|
|
857
|
+
screen.render();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function _promptSshAlias() {
|
|
861
|
+
navigationService?.openModal();
|
|
862
|
+
const aliases = _detectSshAliases();
|
|
863
|
+
const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
|
|
864
|
+
const prompt = blessed.prompt({
|
|
865
|
+
parent: screen,
|
|
866
|
+
top: 'center', left: 'center',
|
|
867
|
+
height: 'shrink', width: '60%',
|
|
868
|
+
border: 'line', tags: true,
|
|
869
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
|
|
870
|
+
});
|
|
871
|
+
prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
|
|
872
|
+
aliases[0] ?? '',
|
|
873
|
+
(err, val) => {
|
|
874
|
+
prompt.destroy();
|
|
875
|
+
navigationService?.closeModal();
|
|
876
|
+
if (!err && val && val.trim()) {
|
|
877
|
+
const trimmed = val.trim();
|
|
878
|
+
if (/[;&|`$(){}\\<>]/.test(trimmed)) {
|
|
879
|
+
_showNoticeWidget(screen, 'Invalid alias — special characters not allowed');
|
|
880
|
+
} else {
|
|
881
|
+
configService.set('audio_ssh_alias', trimmed);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
_refreshValues();
|
|
885
|
+
box.focus();
|
|
886
|
+
screen.render();
|
|
887
|
+
});
|
|
888
|
+
screen.render();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ── Re-run wizard ────────────────────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
function _rerunWizard() {
|
|
894
|
+
// Clear setupCompleted flag so the Setup tab shows the wizard
|
|
895
|
+
configService.set('setupCompleted', false);
|
|
896
|
+
// Navigate to setup tab
|
|
897
|
+
if (navigationService) {
|
|
898
|
+
navigationService.switchTab('setup');
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ── Refresh display ──────────────────────────────────────────────────────
|
|
903
|
+
|
|
904
|
+
function refreshDisplay() {
|
|
905
|
+
_refreshValues();
|
|
906
|
+
_highlightRow(_selectedIdx);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ── Tab Component Contract ───────────────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
function show() {
|
|
912
|
+
refreshDisplay();
|
|
913
|
+
box.show();
|
|
914
|
+
box.focus();
|
|
915
|
+
screen.render();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function hide() {
|
|
919
|
+
box.hide();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function onFocus() {
|
|
923
|
+
if (_selectedIdx < 0) _selectedIdx = 0;
|
|
924
|
+
refreshDisplay();
|
|
925
|
+
box.focus();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function onBlur() {}
|
|
929
|
+
|
|
930
|
+
function getFooterText() {
|
|
931
|
+
return FOOTER_TEXT;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function getFooterColor() {
|
|
935
|
+
return COLORS.footerBg;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor };
|
|
939
|
+
}
|