agentvibes 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude/config/audio-effects.cfg +3 -2
  2. package/.claude/config/background-music-position.txt +1 -1
  3. package/.claude/hooks/audio-processor.sh +87 -43
  4. package/.claude/hooks/bmad-speak.sh +184 -27
  5. package/.claude/hooks/play-tts-enhanced.sh +40 -5
  6. package/.claude/hooks/play-tts-macos.sh +29 -6
  7. package/.claude/hooks/play-tts-piper.sh +166 -65
  8. package/.claude/hooks/play-tts-soprano.sh +42 -6
  9. package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
  10. package/.claude/hooks/play-tts.sh +12 -9
  11. package/.claude/hooks/session-start-tts.sh +10 -0
  12. package/.claude/hooks/stop-tts.sh +84 -0
  13. package/.claude/hooks/tts-queue-worker.sh +51 -20
  14. package/.claude/hooks/tts-queue.sh +37 -8
  15. package/.claude/hooks/voice-manager.sh +5 -1
  16. package/CLAUDE.md +0 -11
  17. package/README.md +176 -78
  18. package/RELEASE_NOTES.md +1197 -60
  19. package/bin/agentvibes-voice-browser.js +35 -21
  20. package/mcp-server/server.py +36 -0
  21. package/package.json +1 -3
  22. package/src/console/app.js +22 -4
  23. package/src/console/constants/personalities.js +44 -0
  24. package/src/console/footer-config.js +5 -1
  25. package/src/console/navigation.js +3 -2
  26. package/src/console/tabs/agents-tab.js +1219 -72
  27. package/src/console/tabs/placeholder-tab.js +9 -2
  28. package/src/console/tabs/receiver-tab.js +1212 -0
  29. package/src/console/tabs/settings-tab.js +33 -323
  30. package/src/console/widgets/destroy-list.js +25 -0
  31. package/src/console/widgets/format-utils.js +89 -0
  32. package/src/console/widgets/notice.js +55 -0
  33. package/src/console/widgets/personality-picker.js +185 -0
  34. package/src/console/widgets/reverb-picker.js +94 -0
  35. package/src/console/widgets/track-picker.js +285 -0
  36. package/src/installer.js +54 -2
  37. package/src/services/agent-voice-store.js +282 -22
  38. package/src/services/navigation-service.js +1 -1
  39. package/src/utils/music-file-validator.js +41 -31
  40. package/templates/agentvibes-receiver.sh +431 -111
@@ -21,6 +21,12 @@ import {
21
21
  import { formatTrackLabel, scanTracks, getMusicFavorites, toggleMusicFavorite, applyTrackToAudioEffects } from './music-tab.js';
22
22
  import { BRAND_PINK, BRAND_BLUE } from '../brand-colors.js';
23
23
  import { buildAudioEnv, detectMp3Player, detectWavPlayer } from '../audio-env.js';
24
+ import { destroyList } from '../widgets/destroy-list.js';
25
+ import { openReverbPicker } from '../widgets/reverb-picker.js';
26
+ import { openPersonalityPicker } from '../widgets/personality-picker.js';
27
+ import { PERSONALITY_EMOJIS } from '../constants/personalities.js';
28
+ import { formatTrackName as _sharedFormatTrackName, formatReverbState as _sharedFormatReverbState } from '../widgets/format-utils.js';
29
+ import { showNotice as _showNoticeWidget } from '../widgets/notice.js';
24
30
 
25
31
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
26
32
 
@@ -74,82 +80,11 @@ const MUSIC_DEFAULTS = Object.freeze({ enabled: false, track: 'agentvibes_soft_f
74
80
  // Verbosity display labels
75
81
  const VERBOSITY_LABELS = Object.freeze({ high: 'High', medium: 'Medium', low: 'Low', minimal: 'Minimal', custom: 'Custom' });
76
82
 
77
- // Personality emojis mirrors installer.js personalityEmojis (src/installer.js:84)
78
- const PERSONALITY_EMOJIS = Object.freeze({
79
- angry: '😠',
80
- annoying: '😤',
81
- crass: '🤬',
82
- dramatic: '🎭',
83
- 'dry-humor': '😐',
84
- flirty: '😘',
85
- funny: '😂',
86
- grandpa: '👴',
87
- millennial: '🙄',
88
- moody: '😒',
89
- none: '😊',
90
- normal: '😊',
91
- pirate: '⚓',
92
- poetic: '📜',
93
- professional: '👔',
94
- rapper: '🎤',
95
- robot: '🤖',
96
- sarcastic: '😏',
97
- sassy: '💁',
98
- 'surfer-dude':'🏄',
99
- zen: '🧘',
100
- });
101
-
102
- // Known personalities (matches .claude/personalities/ directory)
103
- const PERSONALITIES = Object.freeze([
104
- 'none', 'angry', 'annoying', 'crass', 'dramatic', 'dry-humor',
105
- 'flirty', 'funny', 'grandpa', 'millennial', 'moody', 'normal',
106
- 'pirate', 'poetic', 'professional', 'rapper', 'robot', 'sarcastic',
107
- 'sassy', 'surfer-dude', 'zen',
108
- ]);
109
-
110
- // Preview phrases — one short, exemplary, in-character line per personality.
111
- // Spoken automatically when the cursor lands on a personality in the picker.
112
- const PERSONALITY_PREVIEW_PHRASES = Object.freeze({
113
- angry: "UNACCEPTABLE! This build time is a DISASTER! Fix it NOW or so help me!",
114
- annoying: "Oh oh oh! Can I tell you something? Can I? Can I? PLEASE? It is so important!",
115
- crass: "Well damn, that code runs like my uncle's truck. Barely, and it smells funny.",
116
- dramatic: "The tests... have failed. I don't know how much longer I can do this.",
117
- 'dry-humor': "Your code worked. I too am surprised.",
118
- flirty: "Ooh, a clean merge? You know exactly how to make my heart race.",
119
- funny: "Why do programmers hate nature? Too many bugs. I will show myself out.",
120
- grandpa: "Back in my day, we compiled by hand. Uphill. In the snow. Both ways.",
121
- millennial: "I literally cannot even with this error. I am so done. Like, actually deceased.",
122
- moody: "...It works. Whatever. Do not get used to it.",
123
- pirate: "Arrr! The build be sailin' smooth today, matey! No barnacles in sight!",
124
- poetic: "Like rivers to the sea, your code flows toward eventual compilation.",
125
- professional: "I have completed the requested task and am prepared to document outcomes.",
126
- rapper: "Yo! Clean code flowin', tests are glowin', no bugs showin'!",
127
- robot: "TASK COMPLETE. EFFICIENCY: OPTIMAL. PROBABILITY OF SUCCESS: 97.3 PERCENT. BEEP.",
128
- sarcastic: "Oh wow, another bug. What a completely unexpected surprise. Truly shocking.",
129
- sassy: "Honey, whoever told you that was good code was not your friend.",
130
- 'surfer-dude':"Duuude! That commit totally shredded! Gnarly clean code, bro!",
131
- zen: "The bug is not the enemy. The bug is the teacher. Breathe. Commit.",
132
- random: "Who will I be today? Even I do not know. Expect the unexpected.",
133
- });
134
-
135
- // Human-readable track display names — matches installer track picker (src/installer.js:2280)
136
- const TRACK_NAMES = Object.freeze({
137
- 'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
138
- 'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
139
- 'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
140
- 'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
141
- 'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
142
- 'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
143
- 'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
144
- 'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
145
- 'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
146
- 'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
147
- 'agent_vibes_celtic_harp_v1_loop.mp3': '🎻 Celtic Harp',
148
- 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
149
- 'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
150
- 'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
151
- 'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
152
- });
83
+ // Personality emojis and names imported from src/console/constants/personalities.js
84
+ // (via the import at the top of this file)
85
+
86
+ // Human-readable track display names — moved to shared widgets/format-utils.js
87
+ // TRACK_NAMES constant removed (M1 dedup). Use formatTrackName() instead.
153
88
 
154
89
  // Built-in track list for the picker (fallback when tracks dir is missing)
155
90
  const BUILT_IN_TRACKS = [
@@ -166,10 +101,7 @@ const BUILT_IN_TRACKS = [
166
101
  * @param {string} preset - 'off' | 'light' | 'medium' | 'heavy' | 'cathedral'
167
102
  * @returns {string}
168
103
  */
169
- export function formatReverbState(preset) {
170
- const LABELS = { off: 'Off', light: 'Light (Small room)', medium: 'Medium (Conference room)', heavy: 'Heavy (Large hall)', cathedral: 'Cathedral (Epic space)' };
171
- return LABELS[preset] ?? LABELS.light;
172
- }
104
+ export const formatReverbState = _sharedFormatReverbState;
173
105
 
174
106
  /**
175
107
  * @param {boolean} enabled
@@ -192,18 +124,7 @@ export function formatVolume(volume) {
192
124
  * @param {string} track - filename (e.g. 'agentvibes_soft_flamenco_loop.mp3')
193
125
  * @returns {string}
194
126
  */
195
- export function formatTrackName(track) {
196
- if (!track) return 'None';
197
- if (TRACK_NAMES[track]) return TRACK_NAMES[track];
198
- // Custom/unknown track: strip extension, agentvibes_/agent_vibes_ prefix,
199
- // _v1/_v2/_loop/_v1_loop/_v2_loop suffixes, then title-case each word
200
- return track
201
- .replace(/\.[^.]+$/, '')
202
- .replace(/^agentvibes_|^agent_vibes_/, '')
203
- .replace(/_v\d+_loop$|_loop$|_v\d+$/, '')
204
- .replace(/_/g, ' ')
205
- .replace(/\b\w/g, c => c.toUpperCase());
206
- }
127
+ export const formatTrackName = _sharedFormatTrackName;
207
128
 
208
129
  /**
209
130
  * @param {string} verbosity - 'high' | 'medium' | 'low'
@@ -1131,7 +1052,7 @@ export function createSettingsTab(screen, services) {
1131
1052
  });
1132
1053
 
1133
1054
  const reverbChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1134
- _openReverbPicker(screen, configService, (preset) => {
1055
+ openReverbPicker(screen, configService.getConfig().effects?.reverbPreset ?? 'light', (preset) => {
1135
1056
  _setEffects(configService, { reverbPreset: preset });
1136
1057
  refreshDisplay();
1137
1058
  }, _restoreFocus);
@@ -1315,7 +1236,7 @@ export function createSettingsTab(screen, services) {
1315
1236
  });
1316
1237
 
1317
1238
  const personalityChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
1318
- _openPersonalityPicker(screen, configService, (name) => {
1239
+ openPersonalityPicker(screen, configService.getConfig().personality ?? 'none', (name) => {
1319
1240
  configService.set('personality', name);
1320
1241
  refreshDisplay();
1321
1242
  }, _restoreFocus);
@@ -2453,19 +2374,14 @@ function _openProviderPicker(screen, providerService, onSelect, onClose) {
2453
2374
  }
2454
2375
 
2455
2376
  // ---------------------------------------------------------------------------
2456
- // Private: Destroy a list/modal widget and force-invalidate olines so blessed
2457
- // physically redraws every cell the widget covered (avoids ghost rendering).
2377
+ // Private: Destroy helper now imported from shared widgets/destroy-list.js
2378
+ // (kept as comment for git blame traceability)
2458
2379
 
2459
- function _destroyList(list, screen, onClose) {
2460
- list.destroy();
2461
- try {
2462
- for (let r = 0; r < screen.height; r++)
2463
- for (let c = 0; c < screen.width; c++)
2464
- if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
2465
- } catch {}
2466
- onClose?.();
2467
- screen.render();
2468
- }
2380
+ // NOTE: The following line was the old _destroyList definition, now using shared import:
2381
+ // import { destroyList } from '../widgets/destroy-list.js';
2382
+ //
2383
+ // Old code removed to eliminate duplication (M1 fix).
2384
+ // The shared destroyList has identical behavior.
2469
2385
 
2470
2386
  // ---------------------------------------------------------------------------
2471
2387
  // Private: Show a temporary stub notice text
@@ -2540,7 +2456,7 @@ function _showSavePreview(screen, filePath, data, onConfirm, onClose) {
2540
2456
  },
2541
2457
  });
2542
2458
 
2543
- function _close() { _destroyList(modal, screen, onClose); }
2459
+ function _close() { destroyList(modal, screen, onClose); }
2544
2460
 
2545
2461
  modal.key(['escape'], _close);
2546
2462
 
@@ -2568,27 +2484,7 @@ function _showSavePreview(screen, filePath, data, onConfirm, onClose) {
2568
2484
  }
2569
2485
 
2570
2486
  function _showNotice(screen, message) {
2571
- const width = Math.max(28, message.length + 6);
2572
- const modal = blessed.box({
2573
- parent: screen,
2574
- top: 'center',
2575
- left: 'center',
2576
- width,
2577
- height: 3,
2578
- border: { type: 'line' },
2579
- tags: true,
2580
- content: `{center}${message}{/center}`,
2581
- style: {
2582
- fg: '#e3f2fd',
2583
- bg: COLORS.contentBg,
2584
- border: { fg: '#00e5ff' },
2585
- },
2586
- });
2587
- screen.render();
2588
-
2589
- setTimeout(() => {
2590
- _destroyList(modal, screen);
2591
- }, 2500);
2487
+ _showNoticeWidget(screen, message, { bg: COLORS.contentBg });
2592
2488
  }
2593
2489
 
2594
2490
  // ---------------------------------------------------------------------------
@@ -2605,64 +2501,8 @@ function _setEffects(configService, partial) {
2605
2501
  }
2606
2502
 
2607
2503
  // ---------------------------------------------------------------------------
2608
- // Private: Inline reverb preset picker
2609
-
2610
- function _openReverbPicker(screen, configService, onSelect, onClose) {
2611
- const PRESETS = [
2612
- { label: 'Off (Dry, no reverb)', value: 'off' },
2613
- { label: 'Light (Small room)', value: 'light' },
2614
- { label: 'Medium (Conference room)', value: 'medium' },
2615
- { label: 'Heavy (Large hall)', value: 'heavy' },
2616
- { label: 'Cathedral (Epic space)', value: 'cathedral' },
2617
- ];
2618
-
2619
- const currentPreset = configService.getConfig().effects?.reverbPreset ?? 'light';
2620
- const currentIdx = Math.max(0, PRESETS.findIndex(p => p.value === currentPreset));
2621
-
2622
- const list = blessed.list({
2623
- parent: screen,
2624
- top: 'center',
2625
- left: 'center',
2626
- width: 40,
2627
- height: PRESETS.length + 4,
2628
- border: { type: 'line' },
2629
- tags: true,
2630
- label: _modalTitle('Select Reverb Preset'),
2631
- items: PRESETS.map((p, i) => (i === currentIdx ? `● ${p.label}` : ` ${p.label}`)),
2632
- keys: true,
2633
- vi: false,
2634
- mouse: true,
2635
- style: {
2636
- border: { fg: COLORS.btnFocus },
2637
- selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
2638
- item: { fg: '#e3f2fd' },
2639
- },
2640
- });
2641
-
2642
- list.select(currentIdx);
2643
- list.focus();
2644
- screen.render();
2645
-
2646
- list.key(['enter', 'space'], () => {
2647
- const selected = PRESETS[list.selected];
2648
- if (!selected) return;
2649
- _destroyList(list, screen, onClose);
2650
-
2651
- // Apply to audio config via effects-manager.sh
2652
- const effectsScript = path.join(process.cwd(), '.claude', 'hooks', 'effects-manager.sh');
2653
- spawnSync('bash', [effectsScript, 'set-reverb', selected.value, 'default'], {
2654
- stdio: 'ignore',
2655
- timeout: 5000,
2656
- env: { ...process.env },
2657
- });
2658
-
2659
- onSelect(selected.value);
2660
- });
2661
-
2662
- list.key(['escape', 'q'], () => {
2663
- _destroyList(list, screen, onClose);
2664
- });
2665
- }
2504
+ // Private: _openReverbPicker removed now using shared import:
2505
+ // import { openReverbPicker } from '../widgets/reverb-picker.js';
2666
2506
 
2667
2507
  // ---------------------------------------------------------------------------
2668
2508
  // Private: Background music config read/write helpers
@@ -2737,18 +2577,18 @@ function _openTrackPicker(screen, configService, onSelect, onClose) {
2737
2577
  if (!selected) return;
2738
2578
  if (selected.file === ADD_SENTINEL) {
2739
2579
  // Destroy list first, then open path-input dialog
2740
- _destroyList(list, screen);
2580
+ destroyList(list, screen);
2741
2581
  _openCustomTrackInput(screen, tracksDir, (newFile) => {
2742
2582
  onSelect(newFile);
2743
2583
  }, onClose);
2744
2584
  return;
2745
2585
  }
2746
- _destroyList(list, screen, onClose);
2586
+ destroyList(list, screen, onClose);
2747
2587
  onSelect(selected.file);
2748
2588
  });
2749
2589
 
2750
2590
  list.key(['escape', 'q'], () => {
2751
- _destroyList(list, screen, onClose);
2591
+ destroyList(list, screen, onClose);
2752
2592
  });
2753
2593
  }
2754
2594
 
@@ -3301,13 +3141,13 @@ function _openVerbosityPicker(screen, configService, onDone, onClose) {
3301
3141
  list.key(['enter', 'space'], () => {
3302
3142
  const selected = levels[list.selected];
3303
3143
  if (!selected) return;
3304
- _destroyList(list, screen, onClose);
3144
+ destroyList(list, screen, onClose);
3305
3145
  configService.set('verbosity', selected.toLowerCase());
3306
3146
  onDone();
3307
3147
  });
3308
3148
 
3309
3149
  list.key(['escape', 'q'], () => {
3310
- _destroyList(list, screen, onClose);
3150
+ destroyList(list, screen, onClose);
3311
3151
  });
3312
3152
  }
3313
3153
 
@@ -3815,135 +3655,5 @@ function _openVoiceBrowserModal(screen, providerService, configService, navigati
3815
3655
  }
3816
3656
 
3817
3657
  // ---------------------------------------------------------------------------
3818
- // Private: Inline personality picker
3819
-
3820
- function _openPersonalityPicker(screen, configService, onSelect, onClose) {
3821
- const current = configService.getConfig().personality ?? 'none';
3822
- const currentIdx = Math.max(0, PERSONALITIES.indexOf(current));
3823
-
3824
- const list = blessed.list({
3825
- parent: screen,
3826
- top: 'center',
3827
- left: 'center',
3828
- width: 44,
3829
- height: Math.min(PERSONALITIES.length + 4, 22),
3830
- border: { type: 'line' },
3831
- tags: true,
3832
- label: _modalTitle('Select Personality'),
3833
- items: PERSONALITIES.map((p, i) => {
3834
- const emoji = PERSONALITY_EMOJIS[p] ?? '✨';
3835
- const label = p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1);
3836
- const mark = i === currentIdx ? '✅' : ' ';
3837
- return `${mark} ${emoji} ${label}`;
3838
- }),
3839
- keys: true,
3840
- vi: true,
3841
- mouse: true,
3842
- style: {
3843
- border: { fg: COLORS.btnFocus },
3844
- selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
3845
- item: { fg: '#e3f2fd' },
3846
- },
3847
- });
3848
-
3849
- list.select(currentIdx);
3850
- list.focus();
3851
- screen.render();
3852
-
3853
- // ---------- Hover TTS preview ----------
3854
-
3855
- let _pickerTtsProc = null;
3856
- let _playingItemIdx = -1;
3857
-
3858
- // Add or remove " (playing)" from a list item (strips any trailing █ first)
3859
- function _setItemPlaying(idx, playing) {
3860
- const item = list.items?.[idx];
3861
- if (!item) return;
3862
- const base = (item.content ?? '').replace(/ █$/, '').replace(/ \(playing\)$/, '');
3863
- item.setContent(playing ? `${base} (playing)` : base);
3864
- }
3865
-
3866
- function _killPickerTts() {
3867
- if (_pickerTtsProc) {
3868
- try { process.kill(-_pickerTtsProc.pid, 'SIGTERM'); } catch {}
3869
- _pickerTtsProc = null;
3870
- }
3871
- if (_playingItemIdx >= 0) {
3872
- _setItemPlaying(_playingItemIdx, false);
3873
- _playingItemIdx = -1;
3874
- }
3875
- }
3876
-
3877
- function _speakPersonalityPreview(personality) {
3878
- _killPickerTts();
3879
- const phrase = PERSONALITY_PREVIEW_PHRASES[personality];
3880
- if (!phrase) return;
3881
- const ttsScript = path.join(process.cwd(), '.claude', 'hooks', 'play-tts.sh');
3882
- _pickerTtsProc = spawn('bash', [ttsScript, phrase], {
3883
- stdio: 'ignore',
3884
- detached: true,
3885
- env: buildAudioEnv(),
3886
- });
3887
- _playingItemIdx = list.selected;
3888
- _setItemPlaying(_playingItemIdx, true);
3889
- screen.render();
3890
- // Clear indicator when audio finishes naturally
3891
- _pickerTtsProc.on('exit', () => {
3892
- if (_playingItemIdx >= 0) {
3893
- _setItemPlaying(_playingItemIdx, false);
3894
- _playingItemIdx = -1;
3895
- screen.render();
3896
- }
3897
- _pickerTtsProc = null;
3898
- });
3899
- _pickerTtsProc.unref();
3900
- }
3901
-
3902
- // Hover: auto-speaks preview phrase when cursor moves
3903
- list.on('select item', () => {
3904
- _speakPersonalityPreview(PERSONALITIES[list.selected]);
3905
- });
3906
-
3907
- // [Space] plays the selected personality, or stops if the same item is already playing.
3908
- // Uses item-aware toggle so navigating with ↓ (which auto-plays) doesn't prevent Space from working.
3909
- list.key(['space'], () => {
3910
- if (_pickerTtsProc && _playingItemIdx === list.selected) {
3911
- _killPickerTts(); // true toggle: stop only if this exact item is playing
3912
- } else {
3913
- _speakPersonalityPreview(PERSONALITIES[list.selected]); // play or switch
3914
- }
3915
- });
3916
-
3917
- // Type-to-jump: press a letter to jump to the first matching personality (cycles on repeat)
3918
- const _jumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'q']);
3919
- list.on('keypress', (ch, key) => {
3920
- if (!ch || key.ctrl || key.meta) return;
3921
- const lower = ch.toLowerCase();
3922
- if (!/^[a-z]$/.test(lower)) return;
3923
- if (_jumpBlocked.has(lower)) return;
3924
- const count = PERSONALITIES.length;
3925
- const start = list.selected ?? 0;
3926
- for (let i = 1; i <= count; i++) {
3927
- const idx = (start + i) % count;
3928
- if (PERSONALITIES[idx].startsWith(lower)) {
3929
- list.select(idx);
3930
- screen.render();
3931
- break;
3932
- }
3933
- }
3934
- });
3935
-
3936
- // [Enter] confirms selection
3937
- list.key(['enter'], () => {
3938
- const selected = PERSONALITIES[list.selected];
3939
- if (!selected) return;
3940
- _killPickerTts();
3941
- _destroyList(list, screen, onClose);
3942
- onSelect(selected);
3943
- });
3944
-
3945
- list.key(['escape', 'q'], () => {
3946
- _killPickerTts();
3947
- _destroyList(list, screen, onClose);
3948
- });
3949
- }
3658
+ // Private: _openPersonalityPicker removed — now using shared import:
3659
+ // import { openPersonalityPicker } from '../widgets/personality-picker.js';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Modal Destroy Helper
3
+ *
4
+ * Force-invalidates blessed's olines buffer after destroying a modal widget.
5
+ * Without this, blessed skips repainting cells where lines==olines and the
6
+ * terminal retains stale modal content as ghost artifacts.
7
+ */
8
+
9
+ /**
10
+ * Destroy a blessed list/box widget and force full screen repaint.
11
+ *
12
+ * @param {object} widget - blessed widget to destroy
13
+ * @param {object} screen - blessed screen instance
14
+ * @param {Function} [onClose] - optional callback after destruction
15
+ */
16
+ export function destroyList(widget, screen, onClose) {
17
+ widget.destroy();
18
+ try {
19
+ for (let r = 0; r < screen.height; r++)
20
+ for (let c = 0; c < screen.width; c++)
21
+ if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
22
+ } catch {}
23
+ onClose?.();
24
+ screen.render();
25
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * AgentVibes TUI — Shared Format Utilities
3
+ *
4
+ * Pure formatting functions extracted from settings-tab.js to avoid
5
+ * circular imports between widgets and tabs.
6
+ */
7
+
8
+ const TRACK_NAMES = Object.freeze({
9
+ 'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
10
+ 'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
11
+ 'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
12
+ 'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
13
+ 'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
14
+ 'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
15
+ 'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
16
+ 'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
17
+ 'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
18
+ 'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
19
+ 'agent_vibes_celtic_harp_v1_loop.mp3': '🎻 Celtic Harp',
20
+ 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
21
+ 'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
22
+ 'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
23
+ 'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
24
+ });
25
+
26
+ /**
27
+ * @param {string} track - filename (e.g. 'agentvibes_soft_flamenco_loop.mp3')
28
+ * @returns {string}
29
+ */
30
+ export function formatTrackName(track) {
31
+ if (!track) return 'None';
32
+ if (TRACK_NAMES[track]) return TRACK_NAMES[track];
33
+ return track
34
+ .replace(/\.[^.]+$/, '')
35
+ .replace(/^agentvibes_|^agent_vibes_/, '')
36
+ .replace(/_v\d+_loop$|_loop$|_v\d+$/, '')
37
+ .replace(/_/g, ' ')
38
+ .replace(/\b\w/g, c => c.toUpperCase());
39
+ }
40
+
41
+ /**
42
+ * Beautify a raw voice identifier for display in narrow table columns.
43
+ *
44
+ * Examples:
45
+ * 16Speakers::Rose_Ibex → Rose Ibex
46
+ * 16Speakers::Emily_Cripps → Emily Cripps
47
+ * en_US-kusal-medium → Kusal
48
+ * en_US-lessac-high → Lessac
49
+ * en_US-libritts_r-medium → Libritts R
50
+ * kristin → Kristin
51
+ *
52
+ * @param {string} voice - raw voice identifier
53
+ * @returns {string}
54
+ */
55
+ export function formatVoiceName(voice) {
56
+ if (!voice) return '(global)';
57
+
58
+ let name;
59
+ if (voice.includes('::')) {
60
+ // 16Speakers::Rose_Ibex → extract after '::'
61
+ name = voice.split('::')[1];
62
+ } else {
63
+ const parts = voice.split('-');
64
+ const QUALITIES = new Set(['high', 'medium', 'low']);
65
+ if (parts.length >= 2 && /^[a-z]{2}_[A-Z]{2}$/.test(parts[0])) {
66
+ // Strip locale prefix and quality suffix
67
+ name = parts.slice(1).filter(p => !QUALITIES.has(p)).join(' ');
68
+ } else {
69
+ name = voice;
70
+ }
71
+ }
72
+
73
+ // Replace underscores with spaces, title-case each word
74
+ return name
75
+ .replace(/_/g, ' ')
76
+ .split(' ')
77
+ .filter(Boolean)
78
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
79
+ .join(' ') || '(global)';
80
+ }
81
+
82
+ /**
83
+ * @param {string} preset - 'off' | 'light' | 'medium' | 'heavy' | 'cathedral'
84
+ * @returns {string}
85
+ */
86
+ export function formatReverbState(preset) {
87
+ const LABELS = { off: 'Off', light: 'Light (Small room)', medium: 'Medium (Conference room)', heavy: 'Heavy (Large hall)', cathedral: 'Cathedral (Epic space)' };
88
+ return LABELS[preset] ?? LABELS.light;
89
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Notice Toast
3
+ *
4
+ * Displays a short auto-dismissing notice modal centred on screen.
5
+ * Usable from any tab; no settings-specific state required.
6
+ */
7
+
8
+ import { destroyList } from './destroy-list.js';
9
+
10
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
11
+ let blessed;
12
+ if (!IS_TEST) {
13
+ const { default: b } = await import('blessed');
14
+ blessed = b;
15
+ }
16
+
17
+ /**
18
+ * Show a temporary notice that auto-dismisses after 2.5 seconds.
19
+ *
20
+ * @param {object} screen - blessed screen instance
21
+ * @param {string} message - text to display
22
+ * @param {object} [opts]
23
+ * @param {string} [opts.bg='#0a0e1a'] - background colour
24
+ * @param {string} [opts.fg='#e3f2fd'] - foreground colour
25
+ * @param {string} [opts.borderFg='#00e5ff'] - border colour
26
+ * @param {number} [opts.durationMs=2500] - auto-dismiss delay in ms
27
+ */
28
+ export function showNotice(screen, message, opts = {}) {
29
+ const bg = opts.bg ?? '#0a0e1a';
30
+ const fg = opts.fg ?? '#e3f2fd';
31
+ const borderFg = opts.borderFg ?? '#00e5ff';
32
+ const durationMs = opts.durationMs ?? 2500;
33
+
34
+ const width = Math.max(28, message.length + 6);
35
+ const modal = blessed.box({
36
+ parent: screen,
37
+ top: 'center',
38
+ left: 'center',
39
+ width,
40
+ height: 3,
41
+ border: { type: 'line' },
42
+ tags: true,
43
+ content: `{center}${message}{/center}`,
44
+ style: {
45
+ fg,
46
+ bg,
47
+ border: { fg: borderFg },
48
+ },
49
+ });
50
+ screen.render();
51
+
52
+ setTimeout(() => {
53
+ destroyList(modal, screen);
54
+ }, durationMs);
55
+ }