agentvibes 4.4.0 → 4.5.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 (51) hide show
  1. package/.agentvibes/config.json +4 -4
  2. package/.claude/config/reverb-level.txt +1 -1
  3. package/.claude/github-star-reminder.txt +1 -1
  4. package/.claude/hooks-windows/bmad-speak.ps1 +112 -0
  5. package/.claude/hooks-windows/play-tts-piper.ps1 +3 -4
  6. package/.claude/hooks-windows/play-tts-sapi.ps1 +3 -4
  7. package/.claude/hooks-windows/play-tts-soprano.ps1 +2 -3
  8. package/.claude/hooks-windows/play-tts-termux-ssh.ps1 +138 -0
  9. package/.claude/hooks-windows/play-tts.ps1 +14 -6
  10. package/.claude/hooks-windows/provider-manager.ps1 +16 -1
  11. package/CLAUDE.md +4 -0
  12. package/README.md +39 -9
  13. package/RELEASE_NOTES.md +39 -0
  14. package/bin/agent-vibes +1 -1
  15. package/bin/agentvibes-voice-browser.js +1 -1
  16. package/bin/bmad-speak.js +52 -0
  17. package/bin/mcp-server.js +1 -1
  18. package/bin/test-bmad-pr +1 -1
  19. package/package.json +1 -1
  20. package/setup-windows.ps1 +4 -4
  21. package/src/console/app.js +58 -11
  22. package/src/console/tabs/agents-tab.js +61 -65
  23. package/src/console/tabs/help-tab.js +107 -54
  24. package/src/console/tabs/install-tab.js +107 -47
  25. package/src/console/tabs/music-tab.js +1030 -1011
  26. package/src/console/tabs/placeholder-tab.js +27 -0
  27. package/src/console/tabs/readme-tab.js +9 -7
  28. package/src/console/tabs/receiver-tab.js +23 -12
  29. package/src/console/tabs/settings-tab.js +4001 -3732
  30. package/src/console/tabs/voices-tab.js +1680 -1653
  31. package/src/console/widgets/personality-picker.js +35 -7
  32. package/src/console/widgets/reverb-picker.js +9 -6
  33. package/src/console/widgets/track-picker.js +6 -1
  34. package/src/i18n/de.js +201 -0
  35. package/src/i18n/en.js +201 -0
  36. package/src/i18n/es.js +201 -0
  37. package/src/i18n/fr.js +201 -0
  38. package/src/i18n/hi.js +201 -0
  39. package/src/i18n/ja.js +201 -0
  40. package/src/i18n/ko.js +201 -0
  41. package/src/i18n/pt.js +201 -0
  42. package/src/i18n/strings.js +54 -0
  43. package/src/i18n/zh-CN.js +201 -0
  44. package/src/installer/language-screen.js +31 -0
  45. package/src/installer.js +92 -25
  46. package/src/services/language-service.js +47 -0
  47. package/src/services/provider-service.js +14 -3
  48. package/src/utils/file-ownership-verifier.js +2 -2
  49. package/src/utils/provider-validator.js +9 -13
  50. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +0 -209
  51. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +0 -108
@@ -1,1011 +1,1030 @@
1
- /**
2
- * AgentVibes TUI Console — Music Tab
3
- * Epic 9: Stories 9.1-9.3
4
- *
5
- * Implements the Tab Component Contract:
6
- * createMusicTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
- *
8
- * Features: dynamic track library from .claude/audio/tracks/, favorites (★), active track (▶),
9
- * toggle music on/off, favorites filter, preview playback on Enter/Space (toggle).
10
- */
11
-
12
- import fs from 'node:fs';
13
- import path from 'node:path';
14
- import os from 'node:os';
15
- import { spawn } from 'node:child_process';
16
- import { buildAudioEnv, detectMp3Player } from '../audio-env.js';
17
-
18
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
19
-
20
- let blessed;
21
- if (!IS_TEST) {
22
- const { default: b } = await import('blessed');
23
- blessed = b;
24
- }
25
-
26
- // ---------------------------------------------------------------------------
27
-
28
- const COLORS = {
29
- contentBg: '#0a0e1a',
30
- sectionHdr: '#f06292', // Light magenta — section headers for Music tab
31
- labelFg: '#e3f2fd',
32
- valueFg: '#f06292', // Light magenta — brand color
33
- activeFg: '#69f0ae', // Greenactive/playing track
34
- favoriteFg: '#ffff00', // Yellowfavorite star
35
- btnDefault: '#880e4f', // Dark magenta Music tab buttons
36
- btnFocus: '#2e7d32', // Greenfocused/selected
37
- btnFocusFg: '#ffffff',
38
- btnPress: '#ff00ff',
39
- borderFg: '#f06292', // Light magenta — border
40
- footerBg: '#880e4f', // Dark magenta — Music tab footer
41
- noticeFg: '#90a4ae',
42
- dimFg: '#455a64',
43
- playingFg: 'bright-cyan', // Cyan — currently previewing track indicator
44
- };
45
-
46
- const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select [M] Toggle [*] Favorite [F] Filter [Q] Quit';
47
-
48
- // ---------------------------------------------------------------------------
49
- // Static catalog — correct real filenames; Soft Flamenco kept first for compat.
50
- // At runtime the UI scans .claude/audio/tracks/ dynamically so new tracks appear.
51
-
52
- // Full display names per track — emoji + label. Single-codepoint emoji only (no \uFE0F
53
- // variation selectors) so blessed renders them cleanly in list widgets.
54
- const TRACK_DISPLAY = Object.freeze({
55
- 'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
56
- 'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
57
- 'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
58
- 'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
59
- 'agent_vibes_celtic_harp_v1_loop.mp3': '🎶 Celtic Harp',
60
- 'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
61
- 'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
62
- 'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
63
- 'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
64
- 'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
65
- 'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
66
- 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
67
- 'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
68
- 'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
69
- 'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
70
- });
71
-
72
- const BUILT_IN_TRACK_CATALOG = Object.freeze([
73
- { id: 'agentvibes_soft_flamenco_loop.mp3', label: '🎻 Soft Flamenco' },
74
- { id: 'agent_vibes_arabic_v2_loop.mp3', label: '🎵 Arabic Oud' },
75
- { id: 'agent_vibes_bachata_v1_loop.mp3', label: '🎺 Bachata' },
76
- { id: 'agent_vibes_bossa_nova_v2_loop.mp3', label: '🌸 Bossa Nova' },
77
- { id: 'agent_vibes_celtic_harp_v1_loop.mp3', label: '🎶 Celtic Harp' },
78
- { id: 'agent_vibes_chillwave_v2_loop.mp3', label: '🌊 Chillwave' },
79
- { id: 'agent_vibes_cumbia_v1_loop.mp3', label: '🎸 Cumbia' },
80
- { id: 'agent_vibes_dark_chill_step_loop.mp3', label: '🌙 Dark Chill Step' },
81
- { id: 'agent_vibes_ganawa_ambient_v2_loop.mp3', label: '🪘 Gnawa Ambient' },
82
- { id: 'agent_vibes_goa_trance_v2_loop.mp3', label: '🌀 Goa Trance' },
83
- { id: 'agent_vibes_harpsichord_v2_loop.mp3', label: '🎼 Harpsichord' },
84
- { id: 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3', label: '🌺 Hawaiian Slack Key Guitar' },
85
- { id: 'agent_vibes_japanese_city_pop_v1_loop.mp3', label: '🌆 Japanese City Pop' },
86
- { id: 'agent_vibes_salsa_v2_loop.mp3', label: '💃 Salsa' },
87
- { id: 'agent_vibes_tabla_dream_pop_v1_loop.mp3', label: '🥁 Tabla Dream Pop' },
88
- ]);
89
-
90
- // ---------------------------------------------------------------------------
91
- // Exported pure helpers (testable without blessed)
92
-
93
- /**
94
- * Return the built-in track catalog (static, predictable for tests).
95
- * @returns {{ id: string, label: string }[]}
96
- */
97
- export function getBuiltInTracks() {
98
- return [...BUILT_IN_TRACK_CATALOG];
99
- }
100
-
101
- /**
102
- * Generate a pretty label from a track filename.
103
- * Returns the canonical display name (with emoji) for known tracks.
104
- * For unknown tracks, strips agent_vibes_/agentvibes_ prefix and _loop/_vN suffixes,
105
- * then title-cases the result.
106
- *
107
- * @param {string} filename
108
- * @returns {string}
109
- */
110
- export function formatTrackLabel(filename) {
111
- if (TRACK_DISPLAY[filename]) return TRACK_DISPLAY[filename];
112
- const label = filename
113
- .replace(/\.mp3$/i, '')
114
- .replace(/^agent_vibes_/i, '')
115
- .replace(/^agentvibes_/i, '')
116
- .replace(/_loop$/i, '')
117
- .replace(/_v\d+$/i, '')
118
- .replace(/_/g, ' ')
119
- .replace(/\b\w/g, c => c.toUpperCase())
120
- .trim();
121
- return label || filename;
122
- }
123
-
124
- /**
125
- * Format music enabled state as readable string.
126
- * @param {boolean|undefined} enabled
127
- * @returns {string}
128
- */
129
- export function formatMusicStatus(enabled) {
130
- return enabled ? 'Enabled' : 'Disabled';
131
- }
132
-
133
- // ---------------------------------------------------------------------------
134
- // Test stub
135
-
136
- function createTestStub() {
137
- return {
138
- box: {},
139
- show: () => {},
140
- hide: () => {},
141
- onFocus: () => {},
142
- onBlur: () => {},
143
- getFooterText: () => FOOTER_TEXT,
144
- getFooterColor: () => COLORS.footerBg,
145
- };
146
- }
147
-
148
- // ---------------------------------------------------------------------------
149
- // Helpers (used inside createMusicTab)
150
-
151
- /**
152
- * Resolve the tracks directory for the running project.
153
- * Uses process.cwd() because the TUI always runs from the project root
154
- * and this function is called both internally and from exported helpers
155
- * that lack configService context.
156
- * @returns {string}
157
- */
158
- function _getTracksDir() {
159
- return path.join(process.cwd(), '.claude', 'audio', 'tracks');
160
- }
161
-
162
- /**
163
- * Scan .claude/audio/tracks/ for .mp3 files.
164
- * Falls back to the static catalog if the directory is absent.
165
- *
166
- * @returns {{ id: string, label: string, isBuiltIn: boolean }[]}
167
- */
168
- export function scanTracks() {
169
- const tracksDir = _getTracksDir();
170
- try {
171
- const files = fs.readdirSync(tracksDir);
172
- const builtInIds = new Set(BUILT_IN_TRACK_CATALOG.map(t => t.id));
173
- return files
174
- .filter(f => /\.mp3$/i.test(f))
175
- .sort()
176
- .map(f => ({ id: f, label: formatTrackLabel(f), isBuiltIn: builtInIds.has(f) }));
177
- } catch {
178
- // Directory not found or unreadable — use the static catalog
179
- return BUILT_IN_TRACK_CATALOG.map(t => ({ ...t, isBuiltIn: true }));
180
- }
181
- }
182
-
183
- /**
184
- * Get music config from configService.
185
- */
186
- function _getMusic(configService) {
187
- const cfg = configService.getConfig();
188
- // Use backgroundMusic (matches settings-tab); fall back to legacy 'music' key
189
- const music = cfg.backgroundMusic ?? cfg.music ?? {};
190
- return {
191
- enabled: music.enabled ?? false,
192
- track: music.track ?? BUILT_IN_TRACK_CATALOG[0].id,
193
- volume: music.volume ?? 70,
194
- };
195
- }
196
-
197
- /**
198
- * Update music config (merge, never overwrite).
199
- * Writes to 'backgroundMusic' key (shared with settings-tab).
200
- */
201
- function _setMusic(configService, update) {
202
- const current = _getMusic(configService);
203
- configService.set('backgroundMusic', { ...current, ...update });
204
- }
205
-
206
- /**
207
- * Patch the 'default' entry in audio-effects.cfg to use the given track.
208
- * play-tts-piper.sh reads the track from audio-effects.cfg (not from config.json),
209
- * so any track change must be reflected here to take effect at runtime.
210
- * Safe to call with invalid/missing tracks non-fatal on failure.
211
- * @param {string} track - Filename like "agent_vibes_salsa_v2_loop.mp3"
212
- */
213
- export function applyTrackToAudioEffects(track) {
214
- if (!track || /[|/\\]/.test(track)) return;
215
- const cfgFile = path.join(process.cwd(), '.claude', 'config', 'audio-effects.cfg');
216
- try {
217
- let content = fs.readFileSync(cfgFile, 'utf-8');
218
- content = content.replace(
219
- /^default\|([^|]*)\|([^|]*)\|(.*)$/m,
220
- (match, g1, g2, g3) => `default|${g1}|${track}|${g3}`,
221
- );
222
- fs.writeFileSync(cfgFile, content, 'utf-8');
223
- } catch { /* file may not exist — non-fatal */ }
224
- }
225
-
226
- /**
227
- * Get favorites array from config.musicFavorites.
228
- */
229
- export function getMusicFavorites(configService) {
230
- const favs = configService.getConfig().musicFavorites;
231
- return Array.isArray(favs) ? favs : [];
232
- }
233
-
234
- /**
235
- * Toggle a track in the favorites list.
236
- */
237
- export function toggleMusicFavorite(configService, trackId) {
238
- const favs = getMusicFavorites(configService);
239
- const idx = favs.indexOf(trackId);
240
- if (idx >= 0) {
241
- favs.splice(idx, 1);
242
- } else {
243
- favs.push(trackId);
244
- }
245
- configService.set('musicFavorites', favs);
246
- }
247
-
248
- /**
249
- * Get custom tracks from config.
250
- */
251
- function _getCustomTracks(configService) {
252
- const custom = configService.getConfig().customTracks;
253
- return Array.isArray(custom) ? custom : [];
254
- }
255
-
256
- // ---------------------------------------------------------------------------
257
-
258
- /**
259
- * Create the Music tab component.
260
- *
261
- * @param {object} screen - Blessed screen instance (or test stub)
262
- * @param {object} services
263
- * @param {import('../../services/config-service.js').ConfigService} services.configService
264
- * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
265
- */
266
- export function createMusicTab(screen, services) {
267
- if (IS_TEST) return createTestStub();
268
-
269
- const { configService, focusMainTabBar, updateHeaderStatus } = services;
270
-
271
- // -------------------------------------------------------------------------
272
- // Container
273
-
274
- const box = blessed.box({
275
- parent: screen,
276
- top: 4,
277
- left: 0,
278
- width: '100%',
279
- bottom: 2,
280
- hidden: true,
281
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
282
- border: { type: 'line' },
283
- borderStyle: { fg: COLORS.borderFg },
284
- });
285
-
286
- // -------------------------------------------------------------------------
287
- // Section headers
288
-
289
- blessed.text({
290
- parent: box,
291
- top: 1,
292
- left: 2,
293
- content: `{${COLORS.sectionHdr}-fg}── Built-in Tracks ${'─'.repeat(48)}{/${COLORS.sectionHdr}-fg}`,
294
- tags: true,
295
- style: { bg: COLORS.contentBg },
296
- });
297
-
298
- // Currently selected track indicator (updated by refreshDisplay)
299
- const activeTrackText = blessed.text({
300
- parent: box,
301
- top: 1,
302
- right: 4,
303
- shrink: true,
304
- tags: true,
305
- content: '',
306
- style: { bg: COLORS.contentBg },
307
- });
308
-
309
- // -------------------------------------------------------------------------
310
- // Track list
311
-
312
- const trackList = blessed.list({
313
- parent: box,
314
- top: 3,
315
- left: 2,
316
- width: '96%',
317
- height: '55%',
318
- keys: true,
319
- vi: true,
320
- mouse: true,
321
- tags: true,
322
- border: { type: 'line' },
323
- scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
324
- style: {
325
- fg: COLORS.labelFg,
326
- bg: COLORS.contentBg,
327
- border: { fg: COLORS.borderFg },
328
- selected: { bg: '#3e2000', fg: COLORS.activeFg, bold: true },
329
- item: { fg: COLORS.labelFg },
330
- },
331
- });
332
-
333
- // -------------------------------------------------------------------------
334
- // Status panel
335
-
336
- blessed.text({
337
- parent: box,
338
- top: '64%',
339
- left: 2,
340
- content: `{${COLORS.sectionHdr}-fg}── Music Status ${''.repeat(52)}{/${COLORS.sectionHdr}-fg}`,
341
- tags: true,
342
- style: { bg: COLORS.contentBg },
343
- });
344
-
345
- const statusLine = blessed.text({
346
- parent: box,
347
- top: '69%',
348
- left: 2,
349
- tags: true,
350
- content: '',
351
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
352
- });
353
-
354
- const previewLine = blessed.text({
355
- parent: box,
356
- top: '74%',
357
- left: 2,
358
- tags: true,
359
- content: '',
360
- style: { fg: COLORS.playingFg, bg: COLORS.contentBg },
361
- });
362
-
363
- // -------------------------------------------------------------------------
364
- // Buttons
365
-
366
- function _createBtn(label, onClick) {
367
- const btn = blessed.button({
368
- parent: box,
369
- content: label,
370
- mouse: true,
371
- keys: true,
372
- shrink: true,
373
- padding: { left: 1, right: 1 },
374
- style: {
375
- bg: COLORS.btnDefault,
376
- fg: 'white',
377
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
378
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
379
- },
380
- });
381
- btn.on('focus', () => {
382
- btn.style.bg = COLORS.btnFocus;
383
- btn.style.fg = COLORS.btnFocusFg;
384
- const raw = btn.content.replace(/[►◄]/g, '').trim();
385
- btn.setContent(`►${raw}◄`);
386
- screen.render();
387
- });
388
- btn.on('blur', () => {
389
- btn.style.bg = COLORS.btnDefault;
390
- btn.style.fg = 'white';
391
- const raw = btn.content.replace(/[►◄]/g, '').trim();
392
- btn.setContent(raw);
393
- screen.render();
394
- });
395
- btn.key(['enter', 'space'], () => {
396
- btn.style.bg = COLORS.btnPress;
397
- screen.render();
398
- setTimeout(() => {
399
- btn.style.bg = COLORS.btnDefault;
400
- screen.render();
401
- onClick();
402
- }, 150);
403
- });
404
- btn.on('click', () => btn.press());
405
- btn.on('mouseover', () => btn.focus());
406
- return btn;
407
- }
408
-
409
- const toggleBtn = _createBtn('[Toggle Music]', () => {
410
- const { enabled } = _getMusic(configService);
411
- _setMusic(configService, { enabled: !enabled });
412
- refreshDisplay();
413
- });
414
- toggleBtn.bottom = 4;
415
- toggleBtn.left = 4;
416
-
417
- const addCustomTrackBtn = _createBtn('[Add Custom Track]', () => {
418
- const modal = blessed.box({
419
- parent: screen,
420
- top: 'center',
421
- left: 'center',
422
- width: 66,
423
- height: 11,
424
- border: { type: 'line' },
425
- tags: true,
426
- label: ` {${COLORS.activeFg}-fg}Add Custom Background Track{/${COLORS.activeFg}-fg} `,
427
- style: { border: { fg: COLORS.borderFg }, bg: COLORS.contentBg },
428
- content: [
429
- '',
430
- ` {${COLORS.labelFg}-fg}To add a custom track:{/${COLORS.labelFg}-fg}`,
431
- '',
432
- ` {${COLORS.valueFg}-fg}1.{/${COLORS.valueFg}-fg} Place an MP3/OGG/WAV file in:`,
433
- ` {${COLORS.noticeFg}-fg}.claude/audio/tracks/{/${COLORS.noticeFg}-fg}`,
434
- '',
435
- ` {${COLORS.valueFg}-fg}2.{/${COLORS.valueFg}-fg} Or run: {${COLORS.noticeFg}-fg}/agent-vibes:background-music{/${COLORS.noticeFg}-fg}`,
436
- '',
437
- ` {${COLORS.dimFg}-fg}[Esc / Enter] Close{/${COLORS.dimFg}-fg}`,
438
- ].join('\n'),
439
- });
440
- modal.key(['escape', 'enter', 'q'], () => { modal.destroy(); trackList.focus(); screen.render(); });
441
- modal.setFront();
442
- modal.focus();
443
- screen.render();
444
- });
445
- addCustomTrackBtn.bottom = 4;
446
- addCustomTrackBtn.left = 26;
447
-
448
- // -------------------------------------------------------------------------
449
- // Hint text shown in previewLine when the list has focus and nothing is playing
450
- const HINT_TEXT = `{${COLORS.dimFg}-fg}[Space] preview [Enter] set as background track [*] favorite{/${COLORS.dimFg}-fg}`;
451
- let _listFocused = false;
452
-
453
- // Inline selection hint appended to the currently highlighted track row.
454
- // _hintBase stores the item's clean content (no hint, no █) so we never need
455
- // a sentinel character — PUA chars like U+E000 render as Nerd Font icons.
456
- const _ROW_HINT = ` {bright-black-fg}[Space] Play [Enter] Select [*] Favorite{/bright-black-fg}`;
457
- let _hintIdx = -1;
458
- let _hintBase = ''; // content of items[_hintIdx] before hint was appended
459
- let _refreshing = false;
460
-
461
- // Known limitation: _updateHint and _tlTick (blink) can interleave,
462
- // causing brief visual glitches. The _refreshing guard prevents the worst
463
- // cases but is not a complete fix. Acceptable for a TUI animation.
464
- function _updateHint(idx) {
465
- const items = trackList.items;
466
- // Restore previously hinted row using its saved base content
467
- if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
468
- const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
469
- items[_hintIdx].setContent(hadBlink ? _hintBase + ' █' : _hintBase);
470
- }
471
- // Add hint to the new row, saving its clean base first
472
- if (idx >= 0 && items[idx]) {
473
- let c = items[idx].content ?? '';
474
- const hasBlink = c.endsWith(' █');
475
- if (hasBlink) c = c.slice(0, -2);
476
- _hintBase = c;
477
- items[idx].setContent(c + _ROW_HINT + (hasBlink ? ' █' : ''));
478
- } else {
479
- _hintBase = '';
480
- }
481
- _hintIdx = idx;
482
- }
483
-
484
- // -------------------------------------------------------------------------
485
- // Playback state
486
-
487
- let _playingProcess = null;
488
- let _playingTrackId = null;
489
-
490
- // Kill the entire process group so child audio processes (ffplay, play, mpg123) all die
491
- function _killPlayingProcess() {
492
- if (_playingProcess) {
493
- const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
494
- try {
495
- if (_isWin) {
496
- // Windows: kill the process tree via taskkill (process group kill doesn't work)
497
- spawn('taskkill', ['/F', '/T', '/PID', String(_playingProcess.pid)], {
498
- stdio: 'ignore', windowsHide: true,
499
- });
500
- } else {
501
- process.kill(-_playingProcess.pid, 'SIGTERM');
502
- }
503
- } catch (e) {
504
- if (e.code !== 'ESRCH') { /* ignore */ }
505
- }
506
- _playingProcess = null;
507
- }
508
- }
509
-
510
- const _spawnEnv = buildAudioEnv();
511
- const _detectedPlayer = detectMp3Player(_spawnEnv);
512
-
513
- process.on('exit', () => { _killPlayingProcess(); });
514
-
515
- /**
516
- * Preview a track by spawning an audio player.
517
- * Second call with the same trackId stops playback (toggle).
518
- */
519
- function _playTrack(trackId) {
520
- const tracksDir = _getTracksDir();
521
- const trackPath = path.resolve(tracksDir, trackId);
522
-
523
- // Guard: path must stay inside tracksDir
524
- const safeBase = path.resolve(tracksDir);
525
- if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) {
526
- return;
527
- }
528
-
529
- // Toggle: second press on the same track → stop
530
- if (_playingTrackId === trackId) {
531
- _killPlayingProcess();
532
- _playingTrackId = null;
533
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
534
- screen.render();
535
- return;
536
- }
537
-
538
- // Kill any previously playing track
539
- _killPlayingProcess();
540
- _playingTrackId = null;
541
-
542
- if (!_detectedPlayer) {
543
- const installHint = process.platform === 'win32'
544
- ? 'No MP3 player found. Install ffmpeg: winget install ffmpeg'
545
- : 'No MP3 player found. Install ffmpeg: sudo apt install ffmpeg';
546
- previewLine.setContent(`{red-fg}${installHint}{/red-fg}`);
547
- screen.render();
548
- setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 5000);
549
- return;
550
- }
551
-
552
- const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
553
- // Spawn the detected player directly (no sh -c chain — avoids VLC/cvlc stderr issues)
554
- _playingProcess = spawn(_detectedPlayer.bin, _detectedPlayer.args(trackPath), {
555
- stdio: 'ignore', detached: !_isWin, windowsHide: true, env: _spawnEnv,
556
- });
557
- _playingTrackId = trackId;
558
-
559
- const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
560
- previewLine.setContent(`{${COLORS.playingFg}-fg}♪ Previewing: ${label} (Space again to stop){/${COLORS.playingFg}-fg}`);
561
- screen.render();
562
-
563
- _playingProcess.on('exit', () => {
564
- if (_playingTrackId === trackId) {
565
- _playingTrackId = null;
566
- _playingProcess = null;
567
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
568
- refreshDisplay(); // clears (playing) label
569
- }
570
- });
571
-
572
- _playingProcess.on('error', () => {
573
- if (_playingTrackId === trackId) {
574
- _killPlayingProcess();
575
- _playingTrackId = null;
576
- _playingProcess = null;
577
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
578
- }
579
- });
580
- }
581
-
582
- // -------------------------------------------------------------------------
583
- // Display state
584
-
585
- let _showFavoritesOnly = false;
586
- let _allTracks = [];
587
-
588
- function _buildAllTracks() {
589
- const scanned = scanTracks();
590
- const scannedIds = new Set(scanned.map(t => t.id));
591
- // Append custom tracks not already present from disk scan
592
- const custom = _getCustomTracks(configService)
593
- .filter(id => !scannedIds.has(id))
594
- .map(id => ({ id, label: formatTrackLabel(id), isBuiltIn: false }));
595
- return [...scanned, ...custom];
596
- }
597
-
598
- function _getVisibleTracks() {
599
- if (!_showFavoritesOnly) return _allTracks;
600
- const favs = getMusicFavorites(configService);
601
- return _allTracks.filter(t => favs.includes(t.id));
602
- }
603
-
604
- function _getSelectedTrackId() {
605
- const visible = _getVisibleTracks();
606
- const entry = visible[trackList.selected];
607
- return entry ? entry.id : null;
608
- }
609
-
610
- function _buildListItems(tracks, activeTrackId, favorites) {
611
- return tracks.map(t => {
612
- const isFav = favorites.includes(t.id);
613
- const isActive = t.id === activeTrackId;
614
- const isPrev = t.id === _playingTrackId;
615
- const star = isFav ? '★' : ' ';
616
- const dot = isPrev ? '♪' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
617
- const tag = t.isBuiltIn ? '' : ' [custom]';
618
- return ` ${star}${dot} ${t.label}${tag}${isPrev ? ' (playing)' : ''}`;
619
- });
620
- }
621
-
622
- function refreshDisplay() {
623
- _refreshing = true;
624
- const savedIdx = trackList.selected ?? 0;
625
-
626
- _allTracks = _buildAllTracks();
627
- const { enabled, track: activeTrackId } = _getMusic(configService);
628
- const favorites = getMusicFavorites(configService);
629
- const visible = _getVisibleTracks();
630
- const items = _buildListItems(visible, activeTrackId, favorites);
631
-
632
- const activeTrack = _allTracks.find(t => t.id === activeTrackId);
633
- const activeLabel = (activeTrack?.label ?? formatTrackLabel(activeTrackId ?? '')) || 'None';
634
-
635
- trackList.setItems(items.length > 0 ? items : [' (no tracks match filter)']);
636
- // Restore selection (setItems resets to 0)
637
- const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
638
- trackList.select(Math.min(savedIdx, maxIdx));
639
-
640
- // Re-apply inline hint if list is focused
641
- if (_listFocused) {
642
- _hintIdx = -1;
643
- _hintBase = '';
644
- _updateHint(trackList.selected ?? 0);
645
- }
646
-
647
- statusLine.setContent(
648
- ` Music: ${formatMusicStatus(enabled)} | Active Track: ${activeLabel} | Filter: ${_showFavoritesOnly ? 'Favorites' : 'All'}`
649
- );
650
-
651
- // Update "Currently Selected" header
652
- activeTrackText.setContent(`{${COLORS.activeFg}-fg}✓ ${activeLabel}{/${COLORS.activeFg}-fg}`);
653
-
654
- _refreshing = false;
655
- if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
656
- screen.render();
657
- }
658
-
659
- // -------------------------------------------------------------------------
660
- // Key bindings on trackList
661
-
662
- // [Enter] → open save modal for selected track
663
- trackList.key(['enter'], () => {
664
- const trackId = _getSelectedTrackId();
665
- if (!trackId) return;
666
- const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
667
- _openSaveModal(trackId, label);
668
- });
669
-
670
- /**
671
- * Save-track modal: Save Locally | Save Globally & Locally | Cancel | Preview
672
- */
673
- function _openSaveModal(trackId, displayName) {
674
- const modal = blessed.box({
675
- parent: screen,
676
- top: 'center',
677
- left: 'center',
678
- width: 72,
679
- height: 8,
680
- border: { type: 'line' },
681
- tags: true,
682
- label: ` {${COLORS.activeFg}-fg}Set Background Track{/${COLORS.activeFg}-fg} `,
683
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
684
- });
685
-
686
- blessed.text({
687
- parent: modal,
688
- top: 1,
689
- left: 2,
690
- right: 2,
691
- content: `Set {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg} as your background track?`,
692
- tags: true,
693
- style: { bg: COLORS.contentBg },
694
- });
695
-
696
- const modalStatus = blessed.text({
697
- parent: modal,
698
- top: 3,
699
- left: 2,
700
- right: 2,
701
- tags: true,
702
- content: `{${COLORS.dimFg}-fg}Press Preview to audition this track{/${COLORS.dimFg}-fg}`,
703
- style: { bg: COLORS.contentBg },
704
- });
705
-
706
- function _close() {
707
- _killPlayingProcess();
708
- _playingTrackId = null;
709
- previewLine.setContent(_listFocused ? HINT_TEXT : '');
710
- modal.destroy();
711
- trackList.focus();
712
- screen.render();
713
- }
714
-
715
- function _makeBtn(lbl, bg, left, top, onClick) {
716
- const btn = blessed.button({
717
- parent: modal,
718
- content: lbl,
719
- top,
720
- left,
721
- mouse: true,
722
- keys: true,
723
- shrink: true,
724
- padding: { left: 1, right: 1 },
725
- style: {
726
- bg,
727
- fg: 'white',
728
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
729
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
730
- },
731
- });
732
- btn.key(['enter', 'space'], () => { _close(); onClick(); });
733
- btn.on('click', () => btn.press());
734
- return btn;
735
- }
736
-
737
- function _saveLocally() {
738
- _setMusic(configService, { track: trackId });
739
- applyTrackToAudioEffects(trackId);
740
- refreshDisplay();
741
- _showTrackChangedNotice(displayName);
742
- }
743
-
744
- function _saveGlobally() {
745
- configService.setGlobal('backgroundMusic', { track: trackId });
746
- }
747
-
748
- const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
749
- _saveLocally();
750
- });
751
- const okGlobalBtn = _makeBtn('Save Globally & Locally', '#1565c0', 18, 5, () => {
752
- _saveLocally();
753
- _saveGlobally();
754
- });
755
- const cancelBtn = _makeBtn('Cancel', '#546e7a', 46, 5, () => {});
756
-
757
- // Preview button — does NOT close the modal; plays/stops the track inline
758
- const previewBtn = blessed.button({
759
- parent: modal,
760
- content: 'Preview',
761
- top: 5,
762
- left: 58,
763
- mouse: true,
764
- keys: true,
765
- shrink: true,
766
- padding: { left: 1, right: 1 },
767
- style: {
768
- bg: '#e65100',
769
- fg: 'white',
770
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
771
- hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
772
- },
773
- });
774
- previewBtn.key(['enter', 'space'], () => {
775
- const isPlaying = _playingTrackId === trackId;
776
- _playTrack(trackId);
777
- modalStatus.setContent(isPlaying
778
- ? `{${COLORS.dimFg}-fg}Stopped.{/${COLORS.dimFg}-fg}`
779
- : `{${COLORS.playingFg}-fg}♪ Playing: ${displayName}…{/${COLORS.playingFg}-fg}`
780
- );
781
- screen.render();
782
- });
783
- previewBtn.on('click', () => previewBtn.press());
784
-
785
- // Tab/arrow navigation: SaveLocal → SaveGlobal → Cancel → Preview → SaveLocal
786
- okLocalBtn.key(['tab', 'right'], () => { okGlobalBtn.focus(); screen.render(); });
787
- okGlobalBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
788
- cancelBtn.key(['tab', 'right'], () => { previewBtn.focus(); screen.render(); });
789
- previewBtn.key(['tab', 'right'], () => { okLocalBtn.focus(); screen.render(); });
790
- previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
791
- cancelBtn.key(['left'], () => { okGlobalBtn.focus(); screen.render(); });
792
- okGlobalBtn.key(['left'], () => { okLocalBtn.focus(); screen.render(); });
793
- okLocalBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
794
-
795
- modal.key(['escape', 'q'], _close);
796
-
797
- modal.setFront();
798
- okLocalBtn.focus();
799
- screen.render();
800
- }
801
-
802
- function _showTrackChangedNotice(displayName) {
803
- const notice = blessed.box({
804
- parent: screen,
805
- top: 'center',
806
- left: 'center',
807
- width: 50,
808
- height: 5,
809
- border: { type: 'line' },
810
- tags: true,
811
- label: ` {${COLORS.activeFg}-fg}Done{/${COLORS.activeFg}-fg} `,
812
- style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
813
- content: `\n {${COLORS.activeFg}-fg}✓ Track set: ${displayName}{/${COLORS.activeFg}-fg}`,
814
- });
815
- notice.setFront();
816
- screen.render();
817
- let noticeDestroyed = false;
818
- setTimeout(() => { if (!noticeDestroyed) { notice.destroy(); noticeDestroyed = true; screen.render(); } }, 2000);
819
- }
820
-
821
- // [Space] preview/stop track (toggle)
822
- trackList.key(['space'], () => {
823
- const trackId = _getSelectedTrackId();
824
- if (trackId) {
825
- _playTrack(trackId);
826
- refreshDisplay();
827
- }
828
- });
829
-
830
- // [m/M] → toggle music enabled in config
831
- trackList.key(['m', 'M'], () => {
832
- const { enabled } = _getMusic(configService);
833
- _setMusic(configService, { enabled: !enabled });
834
- refreshDisplay();
835
- });
836
-
837
- // [*] → toggle favorite
838
- trackList.key(['*'], () => {
839
- const trackId = _getSelectedTrackId();
840
- if (trackId) {
841
- toggleMusicFavorite(configService, trackId);
842
- refreshDisplay();
843
- }
844
- });
845
-
846
- // [f/F] → toggle favorites filter
847
- trackList.key(['f', 'F'], () => {
848
- _showFavoritesOnly = !_showFavoritesOnly;
849
- refreshDisplay();
850
- });
851
-
852
- // [↑] at top of list → jump to main header tab bar
853
- trackList.key(['up'], () => {
854
- if (trackList.selected === 0 && typeof focusMainTabBar === 'function') {
855
- focusMainTabBar();
856
- setTimeout(() => { trackList.select(0); screen.render(); }, 0);
857
- }
858
- });
859
-
860
- // Escape at the list level → return to header tab bar
861
- trackList.key(['escape'], () => {
862
- if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
863
- });
864
-
865
- // at the last item descend into the button row (Toggle Music gets focus first)
866
- // Note: Tab is NOT used here — navigation.js registers screen.key(['tab']) to cycle tabs,
867
- // so element.key(['tab']) + screen.key(['tab']) both fire, causing a simultaneous tab-cycle.
868
- trackList.key(['down'], () => {
869
- const visible = _getVisibleTracks();
870
- if (trackList.selected >= visible.length - 1) {
871
- toggleBtn.focus();
872
- screen.render();
873
- }
874
- });
875
-
876
- // ←/→ navigate between the two buttons
877
- toggleBtn.key(['right'], () => { addCustomTrackBtn.focus(); screen.render(); });
878
- addCustomTrackBtn.key(['right'], () => { toggleBtn.focus(); screen.render(); });
879
- toggleBtn.key(['left'], () => { addCustomTrackBtn.focus(); screen.render(); });
880
- addCustomTrackBtn.key(['left'], () => { toggleBtn.focus(); screen.render(); });
881
-
882
- // or Escape from any button → back to track list
883
- toggleBtn.key(['up', 'escape'], () => { trackList.focus(); screen.render(); });
884
- addCustomTrackBtn.key(['up', 'escape'], () => { trackList.focus(); screen.render(); });
885
-
886
- // Blinking on selected row while list is focused
887
- let _tlBlink = { interval: null, on: false, sel: -1 };
888
- process.on('exit', () => { if (_tlBlink.interval) clearInterval(_tlBlink.interval); });
889
- function _tlTick() {
890
- _tlBlink.on = !_tlBlink.on;
891
- const items = trackList.items;
892
- const cur = trackList.selected ?? 0;
893
- if (_tlBlink.sel !== cur && _tlBlink.sel >= 0 && items[_tlBlink.sel]) {
894
- items[_tlBlink.sel].setContent((items[_tlBlink.sel].content ?? '').replace(/ █$/, ''));
895
- }
896
- _tlBlink.sel = cur;
897
- if (items[cur]) {
898
- const base = (items[cur].content ?? '').replace(/ █$/, '');
899
- items[cur].setContent(_tlBlink.on ? `${base} █` : base);
900
- }
901
- screen.render();
902
- }
903
- trackList.on('focus', () => {
904
- _listFocused = true;
905
- _tlBlink.on = true;
906
- _tlBlink.sel = trackList.selected ?? 0;
907
- _hintIdx = -1;
908
- _hintBase = '';
909
- _updateHint(_tlBlink.sel);
910
- const items = trackList.items;
911
- if (items[_tlBlink.sel]) items[_tlBlink.sel].setContent((items[_tlBlink.sel].content ?? '') + ' █');
912
- if (!_playingTrackId) previewLine.setContent(HINT_TEXT);
913
- screen.render();
914
- _tlBlink.interval = setInterval(_tlTick, 500);
915
- });
916
- trackList.on('blur', () => {
917
- _listFocused = false;
918
- if (!_playingTrackId) previewLine.setContent('');
919
- if (_tlBlink.interval) { clearInterval(_tlBlink.interval); _tlBlink.interval = null; }
920
- const items = trackList.items;
921
- const sel = trackList.selected ?? 0;
922
- if (items[sel]) {
923
- // Restore the hinted item to its clean base; for non-hinted items just strip █
924
- items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
925
- }
926
- if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
927
- items[_hintIdx].setContent(_hintBase);
928
- }
929
- _hintIdx = -1;
930
- _hintBase = '';
931
- screen.render();
932
- });
933
-
934
- // Refresh status text on cursor movement
935
- trackList.on('select item', () => {
936
- if (_refreshing) return;
937
- _updateHint(trackList.selected ?? 0);
938
- if (_tlBlink.interval) _tlTick(); // move █ to newly selected row
939
- const { enabled, track: activeTrackId } = _getMusic(configService);
940
- const activeTrack = _allTracks.find(t => t.id === activeTrackId);
941
- const activeLabel = (activeTrack?.label ?? formatTrackLabel(activeTrackId ?? '')) || 'None';
942
- statusLine.setContent(
943
- ` Music: ${formatMusicStatus(enabled)} | Active Track: ${activeLabel} | Filter: ${_showFavoritesOnly ? 'Favorites' : 'All'}`
944
- );
945
- screen.render();
946
- });
947
-
948
- // Type-to-jump: press a letter to jump to first track whose label starts with it
949
- const _trackJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'm', 'f']);
950
- trackList.on('keypress', (ch, key) => {
951
- if (!ch || key.ctrl || key.meta) return;
952
- const lower = ch.toLowerCase();
953
- if (!/^[a-z]$/.test(lower)) return;
954
- if (_trackJumpBlocked.has(lower)) return;
955
- const tracks = _getVisibleTracks();
956
- const count = tracks.length;
957
- if (count === 0) return;
958
- const start = trackList.selected ?? 0;
959
- for (let i = 1; i <= count; i++) {
960
- const idx = (start + i) % count;
961
- // Strip leading emoji/symbols to get first letter of track name
962
- const firstLetter = tracks[idx].label.replace(/^[^a-zA-Z]*/, '')[0]?.toLowerCase() ?? '';
963
- if (firstLetter === lower) {
964
- trackList.select(idx);
965
- screen.render();
966
- break;
967
- }
968
- }
969
- });
970
-
971
- // -------------------------------------------------------------------------
972
- // Tab Component Contract
973
-
974
- return {
975
- box,
976
-
977
- show() {
978
- box.show();
979
- refreshDisplay();
980
- screen.render();
981
- },
982
-
983
- hide() {
984
- // Stop any preview when leaving the tab
985
- _killPlayingProcess();
986
- _playingTrackId = null;
987
- previewLine.setContent('');
988
- box.hide();
989
- screen.render();
990
- },
991
-
992
- onFocus() {
993
- trackList.focus();
994
- screen.render();
995
- },
996
-
997
- onBlur() {
998
- // Stop preview when focus leaves Music tab
999
- _killPlayingProcess();
1000
- _playingTrackId = null;
1001
- },
1002
-
1003
- getFooterText() {
1004
- return FOOTER_TEXT;
1005
- },
1006
-
1007
- getFooterColor() {
1008
- return COLORS.footerBg;
1009
- },
1010
- };
1011
- }
1
+ /**
2
+ * AgentVibes TUI Console — Music Tab
3
+ * Epic 9: Stories 9.1-9.3
4
+ *
5
+ * Implements the Tab Component Contract:
6
+ * createMusicTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
+ *
8
+ * Features: dynamic track library from .claude/audio/tracks/, favorites (★), active track (▶),
9
+ * toggle music on/off, favorites filter, preview playback on Enter/Space (toggle).
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import { spawn } from 'node:child_process';
16
+ import { buildAudioEnv, detectMp3Player } from '../audio-env.js';
17
+ import { t } from '../../i18n/strings.js';
18
+
19
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
20
+
21
+ let blessed;
22
+ if (!IS_TEST) {
23
+ const { default: b } = await import('blessed');
24
+ blessed = b;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const COLORS = {
30
+ contentBg: '#0a0e1a',
31
+ sectionHdr: '#f06292', // Light magenta — section headers for Music tab
32
+ labelFg: '#e3f2fd',
33
+ valueFg: '#f06292', // Light magenta brand color
34
+ activeFg: '#69f0ae', // Greenactive/playing track
35
+ favoriteFg: '#ffff00', // Yellowfavorite star
36
+ btnDefault: '#880e4f', // Dark magenta Music tab buttons
37
+ btnFocus: '#2e7d32', // Green — focused/selected
38
+ btnFocusFg: '#ffffff',
39
+ btnPress: '#ff00ff',
40
+ borderFg: '#f06292', // Light magenta — border
41
+ footerBg: '#880e4f', // Dark magenta — Music tab footer
42
+ noticeFg: '#90a4ae',
43
+ dimFg: '#455a64',
44
+ playingFg: 'bright-cyan', // Cyan — currently previewing track indicator
45
+ };
46
+
47
+ const FOOTER_TEXT = '[↑↓/jk] Navigate [Space] Preview [Enter] Select [M] Toggle [*] Favorite [F] Filter [Q] Quit';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Static catalog correct real filenames; Soft Flamenco kept first for compat.
51
+ // At runtime the UI scans .claude/audio/tracks/ dynamically so new tracks appear.
52
+
53
+ // Full display names per track emoji + label. Single-codepoint emoji only (no \uFE0F
54
+ // variation selectors) so blessed renders them cleanly in list widgets.
55
+ const TRACK_DISPLAY = Object.freeze({
56
+ 'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
57
+ 'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
58
+ 'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
59
+ 'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
60
+ 'agent_vibes_celtic_harp_v1_loop.mp3': '🎶 Celtic Harp',
61
+ 'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
62
+ 'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
63
+ 'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
64
+ 'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
65
+ 'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
66
+ 'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
67
+ 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
68
+ 'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
69
+ 'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
70
+ 'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
71
+ });
72
+
73
+ const BUILT_IN_TRACK_CATALOG = Object.freeze([
74
+ { id: 'agentvibes_soft_flamenco_loop.mp3', label: '🎻 Soft Flamenco' },
75
+ { id: 'agent_vibes_arabic_v2_loop.mp3', label: '🎵 Arabic Oud' },
76
+ { id: 'agent_vibes_bachata_v1_loop.mp3', label: '🎺 Bachata' },
77
+ { id: 'agent_vibes_bossa_nova_v2_loop.mp3', label: '🌸 Bossa Nova' },
78
+ { id: 'agent_vibes_celtic_harp_v1_loop.mp3', label: '🎶 Celtic Harp' },
79
+ { id: 'agent_vibes_chillwave_v2_loop.mp3', label: '🌊 Chillwave' },
80
+ { id: 'agent_vibes_cumbia_v1_loop.mp3', label: '🎸 Cumbia' },
81
+ { id: 'agent_vibes_dark_chill_step_loop.mp3', label: '🌙 Dark Chill Step' },
82
+ { id: 'agent_vibes_ganawa_ambient_v2_loop.mp3', label: '🪘 Gnawa Ambient' },
83
+ { id: 'agent_vibes_goa_trance_v2_loop.mp3', label: '🌀 Goa Trance' },
84
+ { id: 'agent_vibes_harpsichord_v2_loop.mp3', label: '🎼 Harpsichord' },
85
+ { id: 'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3', label: '🌺 Hawaiian Slack Key Guitar' },
86
+ { id: 'agent_vibes_japanese_city_pop_v1_loop.mp3', label: '🌆 Japanese City Pop' },
87
+ { id: 'agent_vibes_salsa_v2_loop.mp3', label: '💃 Salsa' },
88
+ { id: 'agent_vibes_tabla_dream_pop_v1_loop.mp3', label: '🥁 Tabla Dream Pop' },
89
+ ]);
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Exported pure helpers (testable without blessed)
93
+
94
+ /**
95
+ * Return the built-in track catalog (static, predictable for tests).
96
+ * @returns {{ id: string, label: string }[]}
97
+ */
98
+ export function getBuiltInTracks() {
99
+ return [...BUILT_IN_TRACK_CATALOG];
100
+ }
101
+
102
+ /**
103
+ * Generate a pretty label from a track filename.
104
+ * Returns the canonical display name (with emoji) for known tracks.
105
+ * For unknown tracks, strips agent_vibes_/agentvibes_ prefix and _loop/_vN suffixes,
106
+ * then title-cases the result.
107
+ *
108
+ * @param {string} filename
109
+ * @returns {string}
110
+ */
111
+ export function formatTrackLabel(filename) {
112
+ if (TRACK_DISPLAY[filename]) return TRACK_DISPLAY[filename];
113
+ const label = filename
114
+ .replace(/\.mp3$/i, '')
115
+ .replace(/^agent_vibes_/i, '')
116
+ .replace(/^agentvibes_/i, '')
117
+ .replace(/_loop$/i, '')
118
+ .replace(/_v\d+$/i, '')
119
+ .replace(/_/g, ' ')
120
+ .replace(/\b\w/g, c => c.toUpperCase())
121
+ .trim();
122
+ return label || filename;
123
+ }
124
+
125
+ /**
126
+ * Format music enabled state as readable string.
127
+ * @param {boolean|undefined} enabled
128
+ * @returns {string}
129
+ */
130
+ export function formatMusicStatus(enabled) {
131
+ return enabled ? 'Enabled' : 'Disabled';
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Test stub
136
+
137
+ function createTestStub() {
138
+ return {
139
+ box: {},
140
+ show: () => {},
141
+ hide: () => {},
142
+ onFocus: () => {},
143
+ onBlur: () => {},
144
+ getFooterText: () => FOOTER_TEXT,
145
+ getFooterColor: () => COLORS.footerBg,
146
+ };
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Helpers (used inside createMusicTab)
151
+
152
+ /**
153
+ * Resolve the tracks directory for the running project.
154
+ * Uses process.cwd() because the TUI always runs from the project root
155
+ * and this function is called both internally and from exported helpers
156
+ * that lack configService context.
157
+ * @returns {string}
158
+ */
159
+ function _getTracksDir() {
160
+ return path.join(process.cwd(), '.claude', 'audio', 'tracks');
161
+ }
162
+
163
+ /**
164
+ * Scan .claude/audio/tracks/ for .mp3 files.
165
+ * Falls back to the static catalog if the directory is absent.
166
+ *
167
+ * @returns {{ id: string, label: string, isBuiltIn: boolean }[]}
168
+ */
169
+ export function scanTracks() {
170
+ const tracksDir = _getTracksDir();
171
+ try {
172
+ const files = fs.readdirSync(tracksDir);
173
+ const builtInIds = new Set(BUILT_IN_TRACK_CATALOG.map(t => t.id));
174
+ return files
175
+ .filter(f => /\.mp3$/i.test(f))
176
+ .sort()
177
+ .map(f => ({ id: f, label: formatTrackLabel(f), isBuiltIn: builtInIds.has(f) }));
178
+ } catch {
179
+ // Directory not found or unreadable use the static catalog
180
+ return BUILT_IN_TRACK_CATALOG.map(t => ({ ...t, isBuiltIn: true }));
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Get music config from configService.
186
+ */
187
+ function _getMusic(configService) {
188
+ const cfg = configService.getConfig();
189
+ // Use backgroundMusic (matches settings-tab); fall back to legacy 'music' key
190
+ const music = cfg.backgroundMusic ?? cfg.music ?? {};
191
+ return {
192
+ enabled: music.enabled ?? false,
193
+ track: music.track ?? BUILT_IN_TRACK_CATALOG[0].id,
194
+ volume: music.volume ?? 70,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Update music config (merge, never overwrite).
200
+ * Writes to 'backgroundMusic' key (shared with settings-tab).
201
+ */
202
+ function _setMusic(configService, update) {
203
+ const current = _getMusic(configService);
204
+ configService.set('backgroundMusic', { ...current, ...update });
205
+ }
206
+
207
+ /**
208
+ * Patch the 'default' entry in audio-effects.cfg to use the given track.
209
+ * play-tts-piper.sh reads the track from audio-effects.cfg (not from config.json),
210
+ * so any track change must be reflected here to take effect at runtime.
211
+ * Safe to call with invalid/missing tracks — non-fatal on failure.
212
+ * @param {string} track - Filename like "agent_vibes_salsa_v2_loop.mp3"
213
+ */
214
+ export function applyTrackToAudioEffects(track) {
215
+ if (!track || /[|/\\]/.test(track)) return;
216
+ const cfgFile = path.join(process.cwd(), '.claude', 'config', 'audio-effects.cfg');
217
+ try {
218
+ let content = fs.readFileSync(cfgFile, 'utf-8');
219
+ content = content.replace(
220
+ /^default\|([^|]*)\|([^|]*)\|(.*)$/m,
221
+ (match, g1, g2, g3) => `default|${g1}|${track}|${g3}`,
222
+ );
223
+ fs.writeFileSync(cfgFile, content, 'utf-8');
224
+ } catch { /* file may not exist — non-fatal */ }
225
+ }
226
+
227
+ /**
228
+ * Get favorites array from config.musicFavorites.
229
+ */
230
+ export function getMusicFavorites(configService) {
231
+ const favs = configService.getConfig().musicFavorites;
232
+ return Array.isArray(favs) ? favs : [];
233
+ }
234
+
235
+ /**
236
+ * Toggle a track in the favorites list.
237
+ */
238
+ export function toggleMusicFavorite(configService, trackId) {
239
+ const favs = getMusicFavorites(configService);
240
+ const idx = favs.indexOf(trackId);
241
+ if (idx >= 0) {
242
+ favs.splice(idx, 1);
243
+ } else {
244
+ favs.push(trackId);
245
+ }
246
+ configService.set('musicFavorites', favs);
247
+ }
248
+
249
+ /**
250
+ * Get custom tracks from config.
251
+ */
252
+ function _getCustomTracks(configService) {
253
+ const custom = configService.getConfig().customTracks;
254
+ return Array.isArray(custom) ? custom : [];
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Create the Music tab component.
261
+ *
262
+ * @param {object} screen - Blessed screen instance (or test stub)
263
+ * @param {object} services
264
+ * @param {import('../../services/config-service.js').ConfigService} services.configService
265
+ * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
266
+ */
267
+ export function createMusicTab(screen, services) {
268
+ if (IS_TEST) return createTestStub();
269
+
270
+ const { configService, focusMainTabBar, updateHeaderStatus, languageService } = services;
271
+ const _tl = (key) => languageService ? languageService.t(key) : t('en', key);
272
+
273
+ // -------------------------------------------------------------------------
274
+ // Container
275
+
276
+ const box = blessed.box({
277
+ parent: screen,
278
+ top: 4,
279
+ left: 0,
280
+ width: '100%',
281
+ bottom: 2,
282
+ hidden: true,
283
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
284
+ border: { type: 'line' },
285
+ borderStyle: { fg: COLORS.borderFg },
286
+ });
287
+
288
+ // -------------------------------------------------------------------------
289
+ // Section headers
290
+
291
+ const builtInHdr = blessed.text({
292
+ parent: box,
293
+ top: 1,
294
+ left: 2,
295
+ content: `{${COLORS.sectionHdr}-fg}${_tl('musicBuiltInHeader')}${'─'.repeat(48)}{/${COLORS.sectionHdr}-fg}`,
296
+ tags: true,
297
+ style: { bg: COLORS.contentBg },
298
+ });
299
+
300
+ // Currently selected track indicator (updated by refreshDisplay)
301
+ const activeTrackText = blessed.text({
302
+ parent: box,
303
+ top: 1,
304
+ right: 4,
305
+ shrink: true,
306
+ tags: true,
307
+ content: '',
308
+ style: { bg: COLORS.contentBg },
309
+ });
310
+
311
+ // -------------------------------------------------------------------------
312
+ // Track list
313
+
314
+ const trackList = blessed.list({
315
+ parent: box,
316
+ top: 3,
317
+ left: 2,
318
+ width: '96%',
319
+ height: '55%',
320
+ keys: true,
321
+ vi: true,
322
+ mouse: true,
323
+ tags: true,
324
+ border: { type: 'line' },
325
+ scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
326
+ style: {
327
+ fg: COLORS.labelFg,
328
+ bg: COLORS.contentBg,
329
+ border: { fg: COLORS.borderFg },
330
+ selected: { bg: '#3e2000', fg: COLORS.activeFg, bold: true },
331
+ item: { fg: COLORS.labelFg },
332
+ },
333
+ });
334
+
335
+ // -------------------------------------------------------------------------
336
+ // Status panel
337
+
338
+ const musicStatusHdr = blessed.text({
339
+ parent: box,
340
+ top: '64%',
341
+ left: 2,
342
+ content: `{${COLORS.sectionHdr}-fg}${_tl('musicStatusHeader')}${'─'.repeat(52)}{/${COLORS.sectionHdr}-fg}`,
343
+ tags: true,
344
+ style: { bg: COLORS.contentBg },
345
+ });
346
+
347
+ const statusLine = blessed.text({
348
+ parent: box,
349
+ top: '69%',
350
+ left: 2,
351
+ tags: true,
352
+ content: '',
353
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
354
+ });
355
+
356
+ const previewLine = blessed.text({
357
+ parent: box,
358
+ top: '74%',
359
+ left: 2,
360
+ tags: true,
361
+ content: '',
362
+ style: { fg: COLORS.playingFg, bg: COLORS.contentBg },
363
+ });
364
+
365
+ // -------------------------------------------------------------------------
366
+ // Buttons
367
+
368
+ function _createBtn(label, onClick) {
369
+ const btn = blessed.button({
370
+ parent: box,
371
+ content: label,
372
+ mouse: true,
373
+ keys: true,
374
+ shrink: true,
375
+ padding: { left: 1, right: 1 },
376
+ style: {
377
+ bg: COLORS.btnDefault,
378
+ fg: 'white',
379
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
380
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
381
+ },
382
+ });
383
+ btn.on('focus', () => {
384
+ btn.style.bg = COLORS.btnFocus;
385
+ btn.style.fg = COLORS.btnFocusFg;
386
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
387
+ btn.setContent(`►${raw}◄`);
388
+ screen.render();
389
+ });
390
+ btn.on('blur', () => {
391
+ btn.style.bg = COLORS.btnDefault;
392
+ btn.style.fg = 'white';
393
+ const raw = btn.content.replace(/[►◄]/g, '').trim();
394
+ btn.setContent(raw);
395
+ screen.render();
396
+ });
397
+ btn.key(['enter', 'space'], () => {
398
+ btn.style.bg = COLORS.btnPress;
399
+ screen.render();
400
+ setTimeout(() => {
401
+ btn.style.bg = COLORS.btnDefault;
402
+ screen.render();
403
+ onClick();
404
+ }, 150);
405
+ });
406
+ btn.on('click', () => btn.press());
407
+ btn.on('mouseover', () => btn.focus());
408
+ return btn;
409
+ }
410
+
411
+ const toggleBtn = _createBtn(_tl('musicToggleBtn'), () => {
412
+ const { enabled } = _getMusic(configService);
413
+ _setMusic(configService, { enabled: !enabled });
414
+ refreshDisplay();
415
+ });
416
+ toggleBtn.bottom = 4;
417
+ toggleBtn.left = 4;
418
+
419
+ const addCustomTrackBtn = _createBtn(_tl('musicAddCustomBtn'), () => {
420
+ const modal = blessed.box({
421
+ parent: screen,
422
+ top: 'center',
423
+ left: 'center',
424
+ width: 66,
425
+ height: 11,
426
+ border: { type: 'line' },
427
+ tags: true,
428
+ label: ` {${COLORS.activeFg}-fg}Add Custom Background Track{/${COLORS.activeFg}-fg} `,
429
+ style: { border: { fg: COLORS.borderFg }, bg: COLORS.contentBg },
430
+ content: [
431
+ '',
432
+ ` {${COLORS.labelFg}-fg}To add a custom track:{/${COLORS.labelFg}-fg}`,
433
+ '',
434
+ ` {${COLORS.valueFg}-fg}1.{/${COLORS.valueFg}-fg} Place an MP3/OGG/WAV file in:`,
435
+ ` {${COLORS.noticeFg}-fg}.claude/audio/tracks/{/${COLORS.noticeFg}-fg}`,
436
+ '',
437
+ ` {${COLORS.valueFg}-fg}2.{/${COLORS.valueFg}-fg} Or run: {${COLORS.noticeFg}-fg}/agent-vibes:background-music{/${COLORS.noticeFg}-fg}`,
438
+ '',
439
+ ` {${COLORS.dimFg}-fg}[Esc / Enter] Close{/${COLORS.dimFg}-fg}`,
440
+ ].join('\n'),
441
+ });
442
+ modal.key(['escape', 'enter', 'q'], () => { modal.destroy(); trackList.focus(); screen.render(); });
443
+ modal.setFront();
444
+ modal.focus();
445
+ screen.render();
446
+ });
447
+ addCustomTrackBtn.bottom = 4;
448
+ addCustomTrackBtn.left = 26;
449
+
450
+ // -------------------------------------------------------------------------
451
+ // Hint text shown in previewLine when the list has focus and nothing is playing.
452
+ // Getter functions so they re-translate when language changes.
453
+ const _hintText = () => `{${COLORS.dimFg}-fg}${_tl('musicHintText')}{/${COLORS.dimFg}-fg}`;
454
+ const _rowHint = () => ` {bright-black-fg}${_tl('musicRowHint')}{/bright-black-fg}`;
455
+ let _listFocused = false;
456
+
457
+ // Inline selection hint appended to the currently highlighted track row.
458
+ // _hintBase stores the item's clean content (no hint, no █) so we never need
459
+ // a sentinel character — PUA chars like U+E000 render as Nerd Font icons.
460
+ let _hintIdx = -1;
461
+ let _hintBase = ''; // content of items[_hintIdx] before hint was appended
462
+ let _refreshing = false;
463
+
464
+ // Known limitation: _updateHint and _tlTick (blink) can interleave,
465
+ // causing brief visual glitches. The _refreshing guard prevents the worst
466
+ // cases but is not a complete fix. Acceptable for a TUI animation.
467
+ function _updateHint(idx) {
468
+ const items = trackList.items;
469
+ // Restore previously hinted row using its saved base content
470
+ if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
471
+ const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
472
+ items[_hintIdx].setContent(hadBlink ? _hintBase + ' █' : _hintBase);
473
+ }
474
+ // Add hint to the new row, saving its clean base first
475
+ if (idx >= 0 && items[idx]) {
476
+ let c = items[idx].content ?? '';
477
+ const hasBlink = c.endsWith(' █');
478
+ if (hasBlink) c = c.slice(0, -2);
479
+ _hintBase = c;
480
+ items[idx].setContent(c + _rowHint() + (hasBlink ? ' █' : ''));
481
+ } else {
482
+ _hintBase = '';
483
+ }
484
+ _hintIdx = idx;
485
+ }
486
+
487
+ // -------------------------------------------------------------------------
488
+ // Playback state
489
+
490
+ let _playingProcess = null;
491
+ let _playingTrackId = null;
492
+
493
+ // Kill the entire process group so child audio processes (ffplay, play, mpg123) all die
494
+ function _killPlayingProcess() {
495
+ if (_playingProcess) {
496
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
497
+ try {
498
+ if (_isWin) {
499
+ // Windows: kill the process tree via taskkill (process group kill doesn't work)
500
+ spawn('taskkill', ['/F', '/T', '/PID', String(_playingProcess.pid)], {
501
+ stdio: 'ignore', windowsHide: true,
502
+ });
503
+ } else {
504
+ process.kill(-_playingProcess.pid, 'SIGTERM');
505
+ }
506
+ } catch (e) {
507
+ if (e.code !== 'ESRCH') { /* ignore */ }
508
+ }
509
+ _playingProcess = null;
510
+ }
511
+ }
512
+
513
+ const _spawnEnv = buildAudioEnv();
514
+ const _detectedPlayer = detectMp3Player(_spawnEnv);
515
+
516
+ process.on('exit', () => { _killPlayingProcess(); });
517
+
518
+ /**
519
+ * Preview a track by spawning an audio player.
520
+ * Second call with the same trackId stops playback (toggle).
521
+ */
522
+ function _playTrack(trackId) {
523
+ const tracksDir = _getTracksDir();
524
+ const trackPath = path.resolve(tracksDir, trackId);
525
+
526
+ // Guard: path must stay inside tracksDir
527
+ const safeBase = path.resolve(tracksDir);
528
+ if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) {
529
+ return;
530
+ }
531
+
532
+ // Toggle: second press on the same track → stop
533
+ if (_playingTrackId === trackId) {
534
+ _killPlayingProcess();
535
+ _playingTrackId = null;
536
+ previewLine.setContent(_listFocused ? _hintText() : '');
537
+ screen.render();
538
+ return;
539
+ }
540
+
541
+ // Kill any previously playing track
542
+ _killPlayingProcess();
543
+ _playingTrackId = null;
544
+
545
+ if (!_detectedPlayer) {
546
+ const installHint = process.platform === 'win32'
547
+ ? 'No MP3 player found. Install ffmpeg: winget install ffmpeg'
548
+ : 'No MP3 player found. Install ffmpeg: sudo apt install ffmpeg';
549
+ previewLine.setContent(`{red-fg}${installHint}{/red-fg}`);
550
+ screen.render();
551
+ setTimeout(() => { previewLine.setContent(_listFocused ? _hintText() : ''); screen.render(); }, 5000);
552
+ return;
553
+ }
554
+
555
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
556
+ // Spawn the detected player directly (no sh -c chain — avoids VLC/cvlc stderr issues)
557
+ _playingProcess = spawn(_detectedPlayer.bin, _detectedPlayer.args(trackPath), {
558
+ stdio: 'ignore', detached: !_isWin, windowsHide: true, env: _spawnEnv,
559
+ });
560
+ _playingTrackId = trackId;
561
+
562
+ const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
563
+ previewLine.setContent(`{${COLORS.playingFg}-fg}♪ Previewing: ${label} (Space again to stop){/${COLORS.playingFg}-fg}`);
564
+ screen.render();
565
+
566
+ _playingProcess.on('exit', () => {
567
+ if (_playingTrackId === trackId) {
568
+ _playingTrackId = null;
569
+ _playingProcess = null;
570
+ previewLine.setContent(_listFocused ? _hintText() : '');
571
+ refreshDisplay(); // clears (playing) label
572
+ }
573
+ });
574
+
575
+ _playingProcess.on('error', () => {
576
+ if (_playingTrackId === trackId) {
577
+ _killPlayingProcess();
578
+ _playingTrackId = null;
579
+ _playingProcess = null;
580
+ previewLine.setContent(_listFocused ? _hintText() : '');
581
+ }
582
+ });
583
+ }
584
+
585
+ // -------------------------------------------------------------------------
586
+ // Display state
587
+
588
+ let _showFavoritesOnly = false;
589
+ let _allTracks = [];
590
+
591
+ function _buildAllTracks() {
592
+ const scanned = scanTracks();
593
+ const scannedIds = new Set(scanned.map(t => t.id));
594
+ // Append custom tracks not already present from disk scan
595
+ const custom = _getCustomTracks(configService)
596
+ .filter(id => !scannedIds.has(id))
597
+ .map(id => ({ id, label: formatTrackLabel(id), isBuiltIn: false }));
598
+ return [...scanned, ...custom];
599
+ }
600
+
601
+ function _getVisibleTracks() {
602
+ if (!_showFavoritesOnly) return _allTracks;
603
+ const favs = getMusicFavorites(configService);
604
+ return _allTracks.filter(t => favs.includes(t.id));
605
+ }
606
+
607
+ function _getSelectedTrackId() {
608
+ const visible = _getVisibleTracks();
609
+ const entry = visible[trackList.selected];
610
+ return entry ? entry.id : null;
611
+ }
612
+
613
+ function _buildListItems(tracks, activeTrackId, favorites) {
614
+ return tracks.map(t => {
615
+ const isFav = favorites.includes(t.id);
616
+ const isActive = t.id === activeTrackId;
617
+ const isPrev = t.id === _playingTrackId;
618
+ const star = isFav ? '' : ' ';
619
+ const dot = isPrev ? '♪' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
620
+ const tag = t.isBuiltIn ? '' : ' [custom]';
621
+ return ` ${star}${dot} ${t.label}${tag}${isPrev ? ' (playing)' : ''}`;
622
+ });
623
+ }
624
+
625
+ function refreshDisplay() {
626
+ _refreshing = true;
627
+ const savedIdx = trackList.selected ?? 0;
628
+
629
+ _allTracks = _buildAllTracks();
630
+ const { enabled, track: activeTrackId } = _getMusic(configService);
631
+ const favorites = getMusicFavorites(configService);
632
+ const visible = _getVisibleTracks();
633
+ const items = _buildListItems(visible, activeTrackId, favorites);
634
+
635
+ const activeTrack = _allTracks.find(t => t.id === activeTrackId);
636
+ const activeLabel = (activeTrack?.label ?? formatTrackLabel(activeTrackId ?? '')) || 'None';
637
+
638
+ trackList.setItems(items.length > 0 ? items : [' (no tracks match filter)']);
639
+ // Restore selection (setItems resets to 0)
640
+ const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
641
+ trackList.select(Math.min(savedIdx, maxIdx));
642
+
643
+ // Re-apply inline hint if list is focused
644
+ if (_listFocused) {
645
+ _hintIdx = -1;
646
+ _hintBase = '';
647
+ _updateHint(trackList.selected ?? 0);
648
+ }
649
+
650
+ statusLine.setContent(
651
+ ` ${_tl('musicStatusLabel')} ${formatMusicStatus(enabled)} | ${_tl('musicActiveTrack')} ${activeLabel} | ${_tl('musicFilterLabel')} ${_showFavoritesOnly ? _tl('musicFilterFavs') : _tl('musicFilterAll')}`
652
+ );
653
+
654
+ // Update "Currently Selected" header
655
+ activeTrackText.setContent(`{${COLORS.activeFg}-fg}✓ ${activeLabel}{/${COLORS.activeFg}-fg}`);
656
+
657
+ _refreshing = false;
658
+ if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
659
+ screen.render();
660
+ }
661
+
662
+ // -------------------------------------------------------------------------
663
+ // Key bindings on trackList
664
+
665
+ // [Enter] → open save modal for selected track
666
+ trackList.key(['enter'], () => {
667
+ const trackId = _getSelectedTrackId();
668
+ if (!trackId) return;
669
+ const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
670
+ _openSaveModal(trackId, label);
671
+ });
672
+
673
+ /**
674
+ * Save-track modal: Save Locally | Save Globally & Locally | Cancel | Preview
675
+ */
676
+ function _openSaveModal(trackId, displayName) {
677
+ const modal = blessed.box({
678
+ parent: screen,
679
+ top: 'center',
680
+ left: 'center',
681
+ width: 72,
682
+ height: 8,
683
+ border: { type: 'line' },
684
+ tags: true,
685
+ label: ` {${COLORS.activeFg}-fg}Set Background Track{/${COLORS.activeFg}-fg} `,
686
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
687
+ });
688
+
689
+ blessed.text({
690
+ parent: modal,
691
+ top: 1,
692
+ left: 2,
693
+ right: 2,
694
+ content: `Set {${COLORS.valueFg}-fg}${displayName}{/${COLORS.valueFg}-fg} as your background track?`,
695
+ tags: true,
696
+ style: { bg: COLORS.contentBg },
697
+ });
698
+
699
+ const modalStatus = blessed.text({
700
+ parent: modal,
701
+ top: 3,
702
+ left: 2,
703
+ right: 2,
704
+ tags: true,
705
+ content: `{${COLORS.dimFg}-fg}Press Preview to audition this track{/${COLORS.dimFg}-fg}`,
706
+ style: { bg: COLORS.contentBg },
707
+ });
708
+
709
+ function _close() {
710
+ _killPlayingProcess();
711
+ _playingTrackId = null;
712
+ previewLine.setContent(_listFocused ? _hintText() : '');
713
+ modal.destroy();
714
+ trackList.focus();
715
+ screen.render();
716
+ }
717
+
718
+ function _makeBtn(lbl, bg, left, top, onClick) {
719
+ const btn = blessed.button({
720
+ parent: modal,
721
+ content: lbl,
722
+ top,
723
+ left,
724
+ mouse: true,
725
+ keys: true,
726
+ shrink: true,
727
+ padding: { left: 1, right: 1 },
728
+ style: {
729
+ bg,
730
+ fg: 'white',
731
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
732
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
733
+ },
734
+ });
735
+ btn.key(['enter', 'space'], () => { _close(); onClick(); });
736
+ btn.on('click', () => btn.press());
737
+ return btn;
738
+ }
739
+
740
+ function _saveLocally() {
741
+ _setMusic(configService, { track: trackId });
742
+ applyTrackToAudioEffects(trackId);
743
+ refreshDisplay();
744
+ _showTrackChangedNotice(displayName);
745
+ }
746
+
747
+ function _saveGlobally() {
748
+ configService.setGlobal('backgroundMusic', { track: trackId });
749
+ }
750
+
751
+ const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
752
+ _saveLocally();
753
+ });
754
+ const okGlobalBtn = _makeBtn('Save Globally & Locally', '#1565c0', 18, 5, () => {
755
+ _saveLocally();
756
+ _saveGlobally();
757
+ });
758
+ const cancelBtn = _makeBtn('Cancel', '#546e7a', 46, 5, () => {});
759
+
760
+ // Preview button — does NOT close the modal; plays/stops the track inline
761
+ const previewBtn = blessed.button({
762
+ parent: modal,
763
+ content: 'Preview',
764
+ top: 5,
765
+ left: 58,
766
+ mouse: true,
767
+ keys: true,
768
+ shrink: true,
769
+ padding: { left: 1, right: 1 },
770
+ style: {
771
+ bg: '#e65100',
772
+ fg: 'white',
773
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
774
+ hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
775
+ },
776
+ });
777
+ previewBtn.key(['enter', 'space'], () => {
778
+ const isPlaying = _playingTrackId === trackId;
779
+ _playTrack(trackId);
780
+ modalStatus.setContent(isPlaying
781
+ ? `{${COLORS.dimFg}-fg}Stopped.{/${COLORS.dimFg}-fg}`
782
+ : `{${COLORS.playingFg}-fg}♪ Playing: ${displayName}…{/${COLORS.playingFg}-fg}`
783
+ );
784
+ screen.render();
785
+ });
786
+ previewBtn.on('click', () => previewBtn.press());
787
+
788
+ // Tab/arrow navigation: SaveLocal SaveGlobal → Cancel → Preview → SaveLocal
789
+ okLocalBtn.key(['tab', 'right'], () => { okGlobalBtn.focus(); screen.render(); });
790
+ okGlobalBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
791
+ cancelBtn.key(['tab', 'right'], () => { previewBtn.focus(); screen.render(); });
792
+ previewBtn.key(['tab', 'right'], () => { okLocalBtn.focus(); screen.render(); });
793
+ previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
794
+ cancelBtn.key(['left'], () => { okGlobalBtn.focus(); screen.render(); });
795
+ okGlobalBtn.key(['left'], () => { okLocalBtn.focus(); screen.render(); });
796
+ okLocalBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
797
+
798
+ modal.key(['escape', 'q'], _close);
799
+
800
+ modal.setFront();
801
+ okLocalBtn.focus();
802
+ screen.render();
803
+ }
804
+
805
+ function _showTrackChangedNotice(displayName) {
806
+ const notice = blessed.box({
807
+ parent: screen,
808
+ top: 'center',
809
+ left: 'center',
810
+ width: 50,
811
+ height: 5,
812
+ border: { type: 'line' },
813
+ tags: true,
814
+ label: ` {${COLORS.activeFg}-fg}Done{/${COLORS.activeFg}-fg} `,
815
+ style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
816
+ content: `\n {${COLORS.activeFg}-fg}✓ Track set: ${displayName}{/${COLORS.activeFg}-fg}`,
817
+ });
818
+ notice.setFront();
819
+ screen.render();
820
+ let noticeDestroyed = false;
821
+ setTimeout(() => { if (!noticeDestroyed) { notice.destroy(); noticeDestroyed = true; screen.render(); } }, 2000);
822
+ }
823
+
824
+ // [Space] → preview/stop track (toggle)
825
+ trackList.key(['space'], () => {
826
+ const trackId = _getSelectedTrackId();
827
+ if (trackId) {
828
+ _playTrack(trackId);
829
+ refreshDisplay();
830
+ }
831
+ });
832
+
833
+ // [m/M] toggle music enabled in config
834
+ trackList.key(['m', 'M'], () => {
835
+ const { enabled } = _getMusic(configService);
836
+ _setMusic(configService, { enabled: !enabled });
837
+ refreshDisplay();
838
+ });
839
+
840
+ // [*] → toggle favorite
841
+ trackList.key(['*'], () => {
842
+ const trackId = _getSelectedTrackId();
843
+ if (trackId) {
844
+ toggleMusicFavorite(configService, trackId);
845
+ refreshDisplay();
846
+ }
847
+ });
848
+
849
+ // [f/F] → toggle favorites filter
850
+ trackList.key(['f', 'F'], () => {
851
+ _showFavoritesOnly = !_showFavoritesOnly;
852
+ refreshDisplay();
853
+ });
854
+
855
+ // [↑] at top of list → jump to main header tab bar
856
+ trackList.key(['up'], () => {
857
+ if (trackList.selected === 0 && typeof focusMainTabBar === 'function') {
858
+ focusMainTabBar();
859
+ setTimeout(() => { trackList.select(0); screen.render(); }, 0);
860
+ }
861
+ });
862
+
863
+ // Escape at the list level → return to header tab bar
864
+ trackList.key(['escape'], () => {
865
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
866
+ });
867
+
868
+ // ↓ at the last item → descend into the button row (Toggle Music gets focus first)
869
+ // Note: Tab is NOT used here — navigation.js registers screen.key(['tab']) to cycle tabs,
870
+ // so element.key(['tab']) + screen.key(['tab']) both fire, causing a simultaneous tab-cycle.
871
+ trackList.key(['down'], () => {
872
+ const visible = _getVisibleTracks();
873
+ if (trackList.selected >= visible.length - 1) {
874
+ toggleBtn.focus();
875
+ screen.render();
876
+ }
877
+ });
878
+
879
+ // ←/→ navigate between the two buttons
880
+ toggleBtn.key(['right'], () => { addCustomTrackBtn.focus(); screen.render(); });
881
+ addCustomTrackBtn.key(['right'], () => { toggleBtn.focus(); screen.render(); });
882
+ toggleBtn.key(['left'], () => { addCustomTrackBtn.focus(); screen.render(); });
883
+ addCustomTrackBtn.key(['left'], () => { toggleBtn.focus(); screen.render(); });
884
+
885
+ // ↑ or Escape from any button → back to track list
886
+ toggleBtn.key(['up', 'escape'], () => { trackList.focus(); screen.render(); });
887
+ addCustomTrackBtn.key(['up', 'escape'], () => { trackList.focus(); screen.render(); });
888
+
889
+ // Blinking █ on selected row while list is focused
890
+ let _tlBlink = { interval: null, on: false, sel: -1 };
891
+ process.on('exit', () => { if (_tlBlink.interval) clearInterval(_tlBlink.interval); });
892
+ function _tlTick() {
893
+ _tlBlink.on = !_tlBlink.on;
894
+ const items = trackList.items;
895
+ const cur = trackList.selected ?? 0;
896
+ if (_tlBlink.sel !== cur && _tlBlink.sel >= 0 && items[_tlBlink.sel]) {
897
+ items[_tlBlink.sel].setContent((items[_tlBlink.sel].content ?? '').replace(/ █$/, ''));
898
+ }
899
+ _tlBlink.sel = cur;
900
+ if (items[cur]) {
901
+ const base = (items[cur].content ?? '').replace(/ █$/, '');
902
+ items[cur].setContent(_tlBlink.on ? `${base} █` : base);
903
+ }
904
+ screen.render();
905
+ }
906
+ trackList.on('focus', () => {
907
+ _listFocused = true;
908
+ _tlBlink.on = true;
909
+ _tlBlink.sel = trackList.selected ?? 0;
910
+ _hintIdx = -1;
911
+ _hintBase = '';
912
+ _updateHint(_tlBlink.sel);
913
+ const items = trackList.items;
914
+ if (items[_tlBlink.sel]) items[_tlBlink.sel].setContent((items[_tlBlink.sel].content ?? '') + ' █');
915
+ if (!_playingTrackId) previewLine.setContent(_hintText());
916
+ screen.render();
917
+ _tlBlink.interval = setInterval(_tlTick, 500);
918
+ });
919
+ trackList.on('blur', () => {
920
+ _listFocused = false;
921
+ if (!_playingTrackId) previewLine.setContent('');
922
+ if (_tlBlink.interval) { clearInterval(_tlBlink.interval); _tlBlink.interval = null; }
923
+ const items = trackList.items;
924
+ const sel = trackList.selected ?? 0;
925
+ if (items[sel]) {
926
+ // Restore the hinted item to its clean base; for non-hinted items just strip █
927
+ items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
928
+ }
929
+ if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
930
+ items[_hintIdx].setContent(_hintBase);
931
+ }
932
+ _hintIdx = -1;
933
+ _hintBase = '';
934
+ screen.render();
935
+ });
936
+
937
+ // Refresh status text on cursor movement
938
+ trackList.on('select item', () => {
939
+ if (_refreshing) return;
940
+ _updateHint(trackList.selected ?? 0);
941
+ if (_tlBlink.interval) _tlTick(); // move to newly selected row
942
+ const { enabled, track: activeTrackId } = _getMusic(configService);
943
+ const activeTrack = _allTracks.find(t => t.id === activeTrackId);
944
+ const activeLabel = (activeTrack?.label ?? formatTrackLabel(activeTrackId ?? '')) || 'None';
945
+ statusLine.setContent(
946
+ ` ${_tl('musicStatusLabel')} ${formatMusicStatus(enabled)} | ${_tl('musicActiveTrack')} ${activeLabel} | ${_tl('musicFilterLabel')} ${_showFavoritesOnly ? _tl('musicFilterFavs') : _tl('musicFilterAll')}`
947
+ );
948
+ screen.render();
949
+ });
950
+
951
+ // Type-to-jump: press a letter to jump to first track whose label starts with it
952
+ const _trackJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'm', 'f']);
953
+ trackList.on('keypress', (ch, key) => {
954
+ if (!ch || key.ctrl || key.meta) return;
955
+ const lower = ch.toLowerCase();
956
+ if (!/^[a-z]$/.test(lower)) return;
957
+ if (_trackJumpBlocked.has(lower)) return;
958
+ const tracks = _getVisibleTracks();
959
+ const count = tracks.length;
960
+ if (count === 0) return;
961
+ const start = trackList.selected ?? 0;
962
+ for (let i = 1; i <= count; i++) {
963
+ const idx = (start + i) % count;
964
+ // Strip leading emoji/symbols to get first letter of track name
965
+ const firstLetter = tracks[idx].label.replace(/^[^a-zA-Z]*/, '')[0]?.toLowerCase() ?? '';
966
+ if (firstLetter === lower) {
967
+ trackList.select(idx);
968
+ screen.render();
969
+ break;
970
+ }
971
+ }
972
+ });
973
+
974
+ // -------------------------------------------------------------------------
975
+ // -------------------------------------------------------------------------
976
+ // Language refresh
977
+
978
+ function refreshMusicLabels() {
979
+ builtInHdr.setContent(`{${COLORS.sectionHdr}-fg}${_tl('musicBuiltInHeader')}${'─'.repeat(48)}{/${COLORS.sectionHdr}-fg}`);
980
+ musicStatusHdr.setContent(`{${COLORS.sectionHdr}-fg}${_tl('musicStatusHeader')}${'─'.repeat(52)}{/${COLORS.sectionHdr}-fg}`);
981
+ toggleBtn.setContent(_tl('musicToggleBtn'));
982
+ addCustomTrackBtn.setContent(_tl('musicAddCustomBtn'));
983
+ refreshDisplay();
984
+ }
985
+
986
+ if (languageService) {
987
+ languageService.onChange(() => { refreshMusicLabels(); screen.render(); });
988
+ }
989
+
990
+ // Tab Component Contract
991
+
992
+ return {
993
+ box,
994
+
995
+ show() {
996
+ box.show();
997
+ refreshDisplay();
998
+ screen.render();
999
+ },
1000
+
1001
+ hide() {
1002
+ // Stop any preview when leaving the tab
1003
+ _killPlayingProcess();
1004
+ _playingTrackId = null;
1005
+ previewLine.setContent('');
1006
+ box.hide();
1007
+ screen.render();
1008
+ },
1009
+
1010
+ onFocus() {
1011
+ trackList.focus();
1012
+ screen.render();
1013
+ },
1014
+
1015
+ onBlur() {
1016
+ // Stop preview when focus leaves Music tab
1017
+ _killPlayingProcess();
1018
+ _playingTrackId = null;
1019
+ },
1020
+
1021
+ getFooterText() {
1022
+ return _tl('musicFooter');
1023
+ },
1024
+
1025
+ getFooterColor() {
1026
+ return COLORS.footerBg;
1027
+ },
1028
+ };
1029
+
1030
+ }