agentvibes 5.1.4 → 5.2.1

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 (69) hide show
  1. package/.agentvibes/config.json +23 -13
  2. package/.claude/commands/agent-vibes/verbosity.md +98 -89
  3. package/.claude/config/audio-effects.cfg +4 -1
  4. package/.claude/hooks/audio-cache-utils.sh +246 -246
  5. package/.claude/hooks/background-music-manager.sh +404 -404
  6. package/.claude/hooks/bmad-speak-enhanced.sh +165 -165
  7. package/.claude/hooks/bmad-speak.sh +290 -290
  8. package/.claude/hooks/bmad-tts-injector.sh +568 -568
  9. package/.claude/hooks/bmad-voice-manager.sh +928 -928
  10. package/.claude/hooks/clawdbot-receiver-SECURE.sh +129 -129
  11. package/.claude/hooks/clawdbot-receiver.sh +107 -107
  12. package/.claude/hooks/clean-audio-cache.sh +22 -22
  13. package/.claude/hooks/cleanup-cache.sh +106 -106
  14. package/.claude/hooks/configure-rdp-mode.sh +137 -137
  15. package/.claude/hooks/download-extra-voices.sh +244 -244
  16. package/.claude/hooks/effects-manager.sh +268 -268
  17. package/.claude/hooks/github-star-reminder.sh +154 -154
  18. package/.claude/hooks/language-manager.sh +362 -362
  19. package/.claude/hooks/learn-manager.sh +492 -492
  20. package/.claude/hooks/macos-voice-manager.sh +205 -205
  21. package/.claude/hooks/migrate-background-music.sh +125 -125
  22. package/.claude/hooks/migrate-to-agentvibes.sh +161 -161
  23. package/.claude/hooks/optimize-background-music.sh +87 -87
  24. package/.claude/hooks/path-resolver.sh +60 -60
  25. package/.claude/hooks/personality-manager.sh +448 -448
  26. package/.claude/hooks/piper-download-voices.sh +233 -225
  27. package/.claude/hooks/piper-installer.sh +292 -292
  28. package/.claude/hooks/piper-multispeaker-registry.sh +171 -171
  29. package/.claude/hooks/piper-voice-manager.sh +125 -0
  30. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +97 -90
  31. package/.claude/hooks/play-tts-enhanced.sh +105 -105
  32. package/.claude/hooks/play-tts-piper.sh +16 -5
  33. package/.claude/hooks/play-tts-ssh-remote.sh +168 -167
  34. package/.claude/hooks/play-tts-termux-ssh.sh +169 -169
  35. package/.claude/hooks/play-tts.sh +35 -14
  36. package/.claude/hooks/prepare-release.sh +54 -54
  37. package/.claude/hooks/provider-commands.sh +617 -617
  38. package/.claude/hooks/provider-manager.sh +399 -399
  39. package/.claude/hooks/replay-target-audio.sh +95 -95
  40. package/.claude/hooks/sentiment-manager.sh +201 -201
  41. package/.claude/hooks/session-start-tts.sh +4 -1
  42. package/.claude/hooks/speed-manager.sh +291 -291
  43. package/.claude/hooks/stop-tts.sh +84 -84
  44. package/.claude/hooks/termux-installer.sh +261 -261
  45. package/.claude/hooks/translate-manager.sh +341 -341
  46. package/.claude/hooks/tts-queue-worker.sh +145 -145
  47. package/.claude/hooks/tts-queue.sh +165 -165
  48. package/.claude/hooks/verbosity-manager.sh +185 -178
  49. package/.claude/hooks/voice-manager.sh +552 -548
  50. package/.claude/hooks-windows/download-extra-voices.ps1 +243 -185
  51. package/.claude/hooks-windows/play-tts-piper.ps1 +7 -2
  52. package/.claude/hooks-windows/play-tts.ps1 +9 -3
  53. package/.claude/hooks-windows/session-start-tts.ps1 +2 -1
  54. package/.claude/hooks-windows/verbosity-manager.ps1 +126 -119
  55. package/README.md +19 -2
  56. package/RELEASE_NOTES.md +74 -0
  57. package/bin/agentvibes-voice-browser.js +1939 -1840
  58. package/bin/mcp-server.sh +206 -206
  59. package/mcp-server/server.py +87 -15
  60. package/package.json +1 -1
  61. package/src/console/tabs/receiver-tab.js +1527 -1483
  62. package/src/console/tabs/settings-tab.js +2 -2
  63. package/src/console/tabs/setup-tab.js +112 -31
  64. package/src/console/tabs/voices-tab.js +130 -13
  65. package/src/i18n/en.js +202 -202
  66. package/src/installer.js +79 -213
  67. package/src/services/llm-provider-service.js +126 -75
  68. package/src/services/verbosity-service.js +159 -157
  69. package/templates/agentvibes-receiver.sh +3 -2
@@ -64,7 +64,7 @@ const FOOTER_TEXT =
64
64
  '[↑↓] Navigate [Enter] Edit [Esc] Tab Bar';
65
65
 
66
66
  const MUSIC_DEFAULTS = Object.freeze({ enabled: false, track: 'agentvibes_soft_flamenco_loop.mp3', volume: 20 });
67
- const VERBOSITY_LABELS = Object.freeze({ high: 'High', medium: 'Medium', low: 'Low', minimal: 'Minimal', custom: 'Custom' });
67
+ const VERBOSITY_LABELS = Object.freeze({ high: 'High', medium: 'Medium', low: 'Low', caveman: 'Caveman', minimal: 'Minimal', custom: 'Custom' });
68
68
 
69
69
  // ---------------------------------------------------------------------------
70
70
  // Exported format helpers (pure functions — used by tests and UI)
@@ -740,7 +740,7 @@ export function createSettingsTab(screen, services) {
740
740
  function _editVerbosity() {
741
741
  navigationService?.openModal();
742
742
 
743
- const levels = ['high', 'medium', 'low'];
743
+ const levels = ['high', 'medium', 'low', 'caveman'];
744
744
  const modal = blessed.list({
745
745
  parent: screen,
746
746
  top: 'center',
@@ -38,7 +38,7 @@ import { openReverbPicker, REVERB_PRESETS } from '../widgets/reverb-picker.js';
38
38
  import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
39
39
  import { formatTrackName } from '../widgets/format-utils.js';
40
40
  import { destroyList } from '../widgets/destroy-list.js';
41
- import { scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, toggleFavorite } from './voices-tab.js';
41
+ import { scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
42
42
  import { attachBtnBlink } from './agents-tab.js';
43
43
  import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
44
44
  import { spawn } from 'node:child_process';
@@ -605,12 +605,19 @@ export function createSetupTab(screen, services) {
605
605
  }
606
606
  });
607
607
  btn.key(['down'], () => {
608
+ // Column-preserving down nav. If pressing down from Install/Remove
609
+ // would land on the Default row (which has no Install/Remove — all
610
+ // three slots are configBtn duplicates), don't move. Configure
611
+ // column navigates normally into Default row's Configure.
612
+ const col = providerFocusIndex % 3;
608
613
  const nextIdx = providerFocusIndex + 3;
609
- if (nextIdx < providerFocusableItems.length) {
610
- providerFocusIndex = nextIdx;
611
- providerFocusableItems[providerFocusIndex].focus();
612
- screen.render();
613
- }
614
+ if (nextIdx >= providerFocusableItems.length) return;
615
+ const nextRowIdx = Math.floor(nextIdx / 3);
616
+ const nextRow = PROVIDERS[nextRowIdx];
617
+ if (col < 2 && nextRow && nextRow.isDefault) return; // skip Default from Install/Remove
618
+ providerFocusIndex = nextIdx;
619
+ providerFocusableItems[providerFocusIndex].focus();
620
+ screen.render();
614
621
  });
615
622
  }
616
623
 
@@ -889,7 +896,7 @@ export function createSetupTab(screen, services) {
889
896
  const hooksSubdir = process.platform === 'win32' ? 'hooks-windows' : 'hooks';
890
897
  const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
891
898
  // Don't include pretext — play-tts already prepends it from the config
892
- const sampleText = 'Here is a preview of your audio settings.';
899
+ const sampleText = 'This is how your audio settings sound right now.';
893
900
 
894
901
  let cmd, args;
895
902
  if (isWin) {
@@ -1226,18 +1233,20 @@ export function createSetupTab(screen, services) {
1226
1233
 
1227
1234
  blessed.text({
1228
1235
  parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
1229
- content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [Home/End] [a-z] Jump [Enter] Select [Space] Preview [*] Fav [Esc] Cancel{/white-fg}',
1236
+ content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [a-z] Jump [Enter] Select [Space] Preview [+] 👍 [-] 👎 [Esc] Cancel{/white-fg}',
1230
1237
  style: { bg: COLORS.contentBg },
1231
1238
  });
1232
1239
 
1233
1240
  function _buildVoiceItems(voices) {
1234
1241
  const favs = getFavorites(configService);
1242
+ const td = getThumbsDown(configService);
1235
1243
  return voices.map(v => {
1236
1244
  const isActive = v === draft.voice;
1237
1245
  const isPrev = v === _previewVoiceId;
1238
- const isFav = favs.includes(v);
1246
+ const isUp = favs.includes(v);
1247
+ const isDown = td.includes(v);
1239
1248
  const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
1240
- const star = isFav ? '{yellow-fg}{/yellow-fg}' : ' ';
1249
+ const star = isUp ? '{green-fg}👍{/green-fg}' : (isDown ? '{red-fg}👎{/red-fg}' : ' ');
1241
1250
  const meta = getVoiceMeta(v);
1242
1251
  const name = meta.displayName.length > COL_N
1243
1252
  ? meta.displayName.slice(0, COL_N - 1) + '…'
@@ -1266,13 +1275,52 @@ export function createSetupTab(screen, services) {
1266
1275
  if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
1267
1276
  _killVP();
1268
1277
 
1278
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1279
+
1280
+ // Route through remote provider if active
1281
+ const _remoteProviders = ['ssh-remote', 'agentvibes-receiver'];
1282
+ let _activeProvider = '';
1283
+ try {
1284
+ const _projectRoot = path.resolve(__dirname, '..', '..');
1285
+ const _provPaths = [
1286
+ path.join(_projectRoot, '.claude', 'tts-provider.txt'),
1287
+ path.join(os.homedir(), '.claude', 'tts-provider.txt'),
1288
+ ];
1289
+ for (const p of _provPaths) {
1290
+ if (fs.existsSync(p)) { _activeProvider = fs.readFileSync(p, 'utf8').trim(); break; }
1291
+ }
1292
+ } catch {}
1293
+
1294
+ if (_remoteProviders.includes(_activeProvider)) {
1295
+ const _projectRoot = path.resolve(__dirname, '..', '..');
1296
+ let rProc;
1297
+ if (_isWin) {
1298
+ const _playTts = path.join(_projectRoot, '.claude', 'hooks-windows', 'play-tts.ps1');
1299
+ rProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _playTts, phrase, voiceId], {
1300
+ stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
1301
+ });
1302
+ } else {
1303
+ const _playTts = path.join(_projectRoot, '.claude', 'hooks', 'play-tts.sh');
1304
+ rProc = spawn('bash', [_playTts, phrase, voiceId], {
1305
+ stdio: 'ignore', detached: true, env: _spawnEnv,
1306
+ });
1307
+ }
1308
+ _previewProc = rProc;
1309
+ _previewVoiceId = voiceId;
1310
+ if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing (remote): ${voiceId}{/cyan-fg}`); screen.render(); }
1311
+ rProc.on('exit', () => {
1312
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
1313
+ });
1314
+ rProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1315
+ return;
1316
+ }
1317
+
1269
1318
  const _ms = parseMultiSpeaker(voiceId);
1270
1319
  const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
1271
1320
  const safeBase = path.resolve(PIPER_VOICES_DIR);
1272
1321
  if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
1273
1322
 
1274
1323
  const tempWav = _secureTempWav('vp');
1275
- const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1276
1324
 
1277
1325
  let _piperBin = 'piper';
1278
1326
  if (_isWin) {
@@ -1331,9 +1379,13 @@ export function createSetupTab(screen, services) {
1331
1379
  const sel = _allVoices[vpList.selected];
1332
1380
  if (sel) _previewVoice(sel);
1333
1381
  });
1334
- vpList.key(['*'], () => {
1382
+ vpList.key(['*', '+'], () => {
1383
+ const sel = _allVoices[vpList.selected];
1384
+ if (sel) { toggleThumbsUp(configService, sel); _refreshVP(); }
1385
+ });
1386
+ vpList.key(['-'], () => {
1335
1387
  const sel = _allVoices[vpList.selected];
1336
- if (sel) { toggleFavorite(configService, sel); _refreshVP(); }
1388
+ if (sel) { toggleThumbsDown(configService, sel); _refreshVP(); }
1337
1389
  });
1338
1390
  vpList.key(['escape', 'q'], _closeVP);
1339
1391
 
@@ -1477,7 +1529,6 @@ export function createSetupTab(screen, services) {
1477
1529
  hideAllProviderRows();
1478
1530
  contentBox.hide();
1479
1531
 
1480
- const mcpPath = path.join(targetDir, '.mcp.json');
1481
1532
  const hooksDir = path.join(targetDir, '.claude', process.platform === 'win32' ? 'hooks-windows' : 'hooks');
1482
1533
  const installed = installedState['claude-code'];
1483
1534
  const verb = wasInstalled ? 'reinstalled' : 'installed';
@@ -1487,9 +1538,14 @@ export function createSetupTab(screen, services) {
1487
1538
  lines.push('');
1488
1539
 
1489
1540
  if (result) {
1490
- lines.push(result.success
1491
- ? `{green-fg}AgentVibes for Claude Code ${verb}!{/green-fg}`
1492
- : `{red-fg}Installation failed{/red-fg}`);
1541
+ if (result.success) {
1542
+ lines.push(`{green-fg}AgentVibes for Claude Code ${verb}!{/green-fg}`);
1543
+ if (result.mcpError) {
1544
+ lines.push(`{yellow-fg}Warning:{/yellow-fg} ${result.mcpError}`);
1545
+ }
1546
+ } else {
1547
+ lines.push(`{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1548
+ }
1493
1549
  } else {
1494
1550
  lines.push(installed
1495
1551
  ? '{green-fg}Installed{/green-fg}'
@@ -1498,10 +1554,7 @@ export function createSetupTab(screen, services) {
1498
1554
 
1499
1555
  lines.push('');
1500
1556
  lines.push(`{bold}{cyan-fg}What ${result ? `got ${verb}` : 'gets installed'}:{/cyan-fg}{/bold}`);
1501
- lines.push('');
1502
- lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.mcp.json{/bold} (project root)');
1503
- lines.push(` Location: ${mcpPath}`);
1504
- lines.push(' Registers the AgentVibes MCP server for Claude Code.');
1557
+ lines.push(' {yellow-fg}1.{/yellow-fg} {bold}.mcp.json{/bold} (MCP server — natural language voice control)');
1505
1558
  lines.push('');
1506
1559
  lines.push(' {yellow-fg}2.{/yellow-fg} {bold}.claude/hooks/{/bold} (session-start + pre-tool hooks)');
1507
1560
  lines.push(` Location: ${hooksDir}`);
@@ -1530,9 +1583,14 @@ export function createSetupTab(screen, services) {
1530
1583
  const lines = [];
1531
1584
  lines.push('{bold}{cyan-fg}GitHub Copilot -- AgentVibes Integration{/cyan-fg}{/bold}');
1532
1585
  lines.push('');
1533
- lines.push(result.success
1534
- ? `{green-fg}AgentVibes for Copilot ${verb}!{/green-fg}`
1535
- : `{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1586
+ if (result.success) {
1587
+ lines.push(`{green-fg}AgentVibes for Copilot ${verb}!{/green-fg}`);
1588
+ if (result.mcpError) {
1589
+ lines.push(`{yellow-fg}MCP config failed:{/yellow-fg} ${result.mcpError}`);
1590
+ }
1591
+ } else {
1592
+ lines.push(`{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1593
+ }
1536
1594
  lines.push('');
1537
1595
  lines.push(`{bold}{cyan-fg}What got ${verb}:{/cyan-fg}{/bold}`);
1538
1596
  lines.push('');
@@ -1559,9 +1617,14 @@ export function createSetupTab(screen, services) {
1559
1617
  const lines = [];
1560
1618
  lines.push('{bold}{cyan-fg}OpenAI Codex -- AgentVibes Integration{/cyan-fg}{/bold}');
1561
1619
  lines.push('');
1562
- lines.push(result.success
1563
- ? `{green-fg}AgentVibes for Codex ${verb}!{/green-fg}`
1564
- : `{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1620
+ if (result.success) {
1621
+ lines.push(`{green-fg}AgentVibes for Codex ${verb}!{/green-fg}`);
1622
+ if (result.mcpError) {
1623
+ lines.push(`{yellow-fg}MCP config failed:{/yellow-fg} ${result.mcpError}`);
1624
+ }
1625
+ } else {
1626
+ lines.push(`{red-fg}Installation failed:{/red-fg} ${result.error || 'Unknown error'}`);
1627
+ }
1565
1628
  lines.push('');
1566
1629
  lines.push(`{bold}{cyan-fg}What got ${verb}:{/cyan-fg}{/bold}`);
1567
1630
  lines.push('');
@@ -1634,11 +1697,29 @@ export function createSetupTab(screen, services) {
1634
1697
 
1635
1698
  infoBox.key(['escape', 'enter'], () => {
1636
1699
  // After dismissing the install/remove info page, advance focus to the
1637
- // NEXT provider row but keep the same column (Install stays on Install,
1638
- // Remove stays on Remove, Configure stays on Configure). Each row has
1639
- // 3 focusable slots so +3 moves one full row down with wraparound.
1700
+ // NEXT provider row but keep the same column (Install/Remove/Configure).
1701
+ // Each row has 3 focusable slots, so +3 moves one full row down.
1702
+ //
1703
+ // Special case: when leaving the LAST installable provider (Codex) from
1704
+ // Install or Remove column, skip the Default row (it has no Install or
1705
+ // Remove) and wrap to the FIRST Configure button (Claude Code Configure).
1706
+ // This lets the user cleanly walk all three installs, then all three
1707
+ // Configures, ending on Default Configure.
1640
1708
  const max = providerFocusableItems.length;
1641
- const nextIdx = max > 0 ? (_preInfoFocusIndex + 3) % max : 0;
1709
+ if (max === 0) { showProviderListView(0); return; }
1710
+ const col = _preInfoFocusIndex % 3; // 0=Install, 1=Remove, 2=Configure
1711
+ const row = Math.floor(_preInfoFocusIndex / 3);
1712
+ const nextRow = PROVIDERS[row + 1];
1713
+ const nextRowIsDefault = nextRow && nextRow.isDefault;
1714
+ let nextIdx;
1715
+ if (col < 2 && nextRowIsDefault) {
1716
+ // Last Install/Remove → jump to the FIRST non-default provider's
1717
+ // Configure column (dynamic: don't hardcode PROVIDERS[0]).
1718
+ const firstInstallableIdx = PROVIDERS.findIndex(p => !p.isDefault);
1719
+ nextIdx = firstInstallableIdx >= 0 ? firstInstallableIdx * 3 + 2 : (_preInfoFocusIndex + 3) % max;
1720
+ } else {
1721
+ nextIdx = (_preInfoFocusIndex + 3) % max;
1722
+ }
1642
1723
  showProviderListView(nextIdx);
1643
1724
  });
1644
1725
 
@@ -471,29 +471,85 @@ export function scanInstalledVoices() {
471
471
  }
472
472
 
473
473
  /**
474
- * Get favorites array from config.
474
+ * Get favorites (thumbs-up) array from config.
475
475
  * @param {object} configService
476
476
  * @returns {string[]}
477
477
  */
478
478
  export function getFavorites(configService) {
479
- const favs = configService.getConfig().favorites;
479
+ const cfg = configService.getConfig();
480
+ // Prefer thumbsUp if present, fall back to legacy favorites
481
+ const favs = cfg.thumbsUp ?? cfg.favorites;
480
482
  return Array.isArray(favs) ? favs : [];
481
483
  }
482
484
 
483
485
  /**
484
- * Toggle a voice in the favorites list.
486
+ * Get thumbs-down array from config.
487
+ * @param {object} configService
488
+ * @returns {string[]}
489
+ */
490
+ export function getThumbsDown(configService) {
491
+ const td = configService.getConfig().thumbsDown;
492
+ return Array.isArray(td) ? td : [];
493
+ }
494
+
495
+ /**
496
+ * Toggle thumbs-up on a voice. Clears thumbs-down if set.
485
497
  * @param {object} configService
486
498
  * @param {string} voiceId
499
+ * @returns {'added'|'removed'}
487
500
  */
488
- export function toggleFavorite(configService, voiceId) {
501
+ export function toggleThumbsUp(configService, voiceId) {
489
502
  const favs = getFavorites(configService);
490
503
  const idx = favs.indexOf(voiceId);
504
+ let result;
491
505
  if (idx >= 0) {
492
506
  favs.splice(idx, 1);
507
+ result = 'removed';
493
508
  } else {
494
509
  favs.push(voiceId);
510
+ // Remove from thumbs-down
511
+ const td = getThumbsDown(configService);
512
+ const tdIdx = td.indexOf(voiceId);
513
+ if (tdIdx >= 0) { td.splice(tdIdx, 1); configService.set('thumbsDown', td); }
514
+ result = 'added';
495
515
  }
496
- configService.set('favorites', favs);
516
+ configService.set('thumbsUp', favs);
517
+ configService.set('favorites', favs); // backward compat
518
+ return result;
519
+ }
520
+
521
+ /**
522
+ * Toggle thumbs-down on a voice. Clears thumbs-up if set.
523
+ * @param {object} configService
524
+ * @param {string} voiceId
525
+ * @returns {'added'|'removed'}
526
+ */
527
+ export function toggleThumbsDown(configService, voiceId) {
528
+ const td = getThumbsDown(configService);
529
+ const idx = td.indexOf(voiceId);
530
+ let result;
531
+ if (idx >= 0) {
532
+ td.splice(idx, 1);
533
+ result = 'removed';
534
+ } else {
535
+ td.push(voiceId);
536
+ // Remove from thumbs-up / favorites
537
+ const favs = getFavorites(configService);
538
+ const fIdx = favs.indexOf(voiceId);
539
+ if (fIdx >= 0) { favs.splice(fIdx, 1); configService.set('thumbsUp', favs); configService.set('favorites', favs); }
540
+ result = 'added';
541
+ }
542
+ configService.set('thumbsDown', td);
543
+ return result;
544
+ }
545
+
546
+ /**
547
+ * Toggle a voice in the favorites list (legacy compat — calls toggleThumbsUp).
548
+ * @param {object} configService
549
+ * @param {string} voiceId
550
+ */
551
+ export function toggleFavorite(configService, voiceId) {
552
+ toggleThumbsUp(configService, voiceId);
497
553
  }
498
554
 
499
555
  // ---------------------------------------------------------------------------
@@ -708,7 +764,7 @@ export function createVoicesTab(screen, services) {
708
764
 
709
765
  // -------------------------------------------------------------------------
710
766
  // Hint text shown in previewLine when the list has focus and nothing is playing
711
- const HINT_TEXT = '{white-fg}[Space] preview [Enter] select as default voice{/white-fg}';
767
+ const HINT_TEXT = '{white-fg}[Space] preview [Enter] select [+] thumbs up [-] thumbs down{/white-fg}';
712
768
  let _listFocused = false;
713
769
 
714
770
  // Inline selection hint appended to the currently highlighted voice row.
@@ -811,6 +867,55 @@ export function createVoicesTab(screen, services) {
811
867
  _killPlayingProcess();
812
868
  _playingVoiceId = null;
813
869
 
870
+ // Check if we should route through remote provider (ssh-remote / agentvibes-receiver)
871
+ const projectRoot = path.resolve(__dirname, '..', '..');
872
+ const remoteProviders = ['ssh-remote', 'agentvibes-receiver'];
873
+ let activeProvider = '';
874
+ try {
875
+ const providerPaths = [
876
+ path.join(projectRoot, '.claude', 'tts-provider.txt'),
877
+ path.join(os.homedir(), '.claude', 'tts-provider.txt'),
878
+ ];
879
+ for (const p of providerPaths) {
880
+ if (fs.existsSync(p)) { activeProvider = fs.readFileSync(p, 'utf8').trim(); break; }
881
+ }
882
+ } catch {}
883
+
884
+ if (remoteProviders.includes(activeProvider)) {
885
+ const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
886
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
887
+ let proc;
888
+ if (isWindows) {
889
+ const playTts = path.join(projectRoot, '.claude', 'hooks-windows', 'play-tts.ps1');
890
+ proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId], {
891
+ stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
892
+ });
893
+ } else {
894
+ const playTts = path.join(projectRoot, '.claude', 'hooks', 'play-tts.sh');
895
+ proc = spawn('bash', [playTts, phrase, voiceId], {
896
+ stdio: 'ignore', detached: true, env: _spawnEnv,
897
+ });
898
+ }
899
+ _playingProcess = proc;
900
+ _playingVoiceId = voiceId;
901
+ previewLine.setContent(`{${COLORS.activeFg}-fg}♪ Playing (remote): ${voiceId}{/${COLORS.activeFg}-fg}`);
902
+ screen.render();
903
+ proc.on('exit', () => {
904
+ if (_playingVoiceId === voiceId) {
905
+ _playingVoiceId = null; _playingProcess = null;
906
+ previewLine.setContent(_listFocused ? HINT_TEXT : '');
907
+ refreshDisplay();
908
+ }
909
+ });
910
+ proc.on('error', () => {
911
+ _playingVoiceId = null; _playingProcess = null;
912
+ previewLine.setContent(`{red-fg}Remote preview failed{/red-fg}`);
913
+ screen.render();
914
+ setTimeout(() => { previewLine.setContent(_listFocused ? HINT_TEXT : ''); screen.render(); }, 4000);
915
+ });
916
+ return;
917
+ }
918
+
814
919
  // Resolve model path (may be multi-speaker)
815
920
  const ms = parseMultiSpeaker(voiceId);
816
921
  const voicePath = path.resolve(PIPER_VOICES_DIR, ms.model + '.onnx');
@@ -1435,13 +1540,14 @@ export function createVoicesTab(screen, services) {
1435
1540
  return _installedSet.has(voiceId);
1436
1541
  }
1437
1542
 
1438
- function _buildListItems(voices, active, favorites) {
1543
+ function _buildListItems(voices, active, favorites, thumbsDown) {
1439
1544
  return voices.map(v => {
1440
1545
  const installed = _isInstalled(v);
1441
- const isFav = favorites.includes(v);
1546
+ const isUp = favorites.includes(v);
1547
+ const isDown = thumbsDown.includes(v);
1442
1548
  const isActive = v === active;
1443
1549
  const isPrev = v === _playingVoiceId;
1444
- const star = isFav ? '' : ' ';
1550
+ const star = isUp ? '{green-fg}👍{/green-fg}' : (isDown ? '{red-fg}👎{/red-fg}' : ' ');
1445
1551
  const dot = isPrev ? '♪' : (isActive ? '{green-fg}✓{/green-fg}' : ' ');
1446
1552
 
1447
1553
  let displayName, gender, provider;
@@ -1531,8 +1637,9 @@ export function createVoicesTab(screen, services) {
1531
1637
 
1532
1638
  const active = providerService.getActiveVoiceId();
1533
1639
  const favorites = getFavorites(configService);
1640
+ const thumbsDown = getThumbsDown(configService);
1534
1641
  const filtered = _getFilteredVoices();
1535
- const items = _buildListItems(filtered, active, favorites);
1642
+ const items = _buildListItems(filtered, active, favorites, thumbsDown);
1536
1643
 
1537
1644
  voiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
1538
1645
  const maxIdx = Math.max(0, (items.length > 0 ? items.length : 1) - 1);
@@ -1620,12 +1727,22 @@ export function createVoicesTab(screen, services) {
1620
1727
  if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
1621
1728
  });
1622
1729
 
1623
- // 'f' or '*' in voiceList toggles favorite
1624
- voiceList.key(['*'], () => {
1730
+ // '*' or '+' in voiceList toggles thumbs-up
1731
+ voiceList.key(['*', '+'], () => {
1625
1732
  const voices = _getFilteredVoices();
1626
1733
  const selected = voices[voiceList.selected];
1627
1734
  if (selected) {
1628
- toggleFavorite(configService, selected);
1735
+ toggleThumbsUp(configService, selected);
1736
+ refreshDisplay();
1737
+ }
1738
+ });
1739
+
1740
+ // '-' in voiceList toggles thumbs-down
1741
+ voiceList.key(['-'], () => {
1742
+ const voices = _getFilteredVoices();
1743
+ const selected = voices[voiceList.selected];
1744
+ if (selected) {
1745
+ toggleThumbsDown(configService, selected);
1629
1746
  refreshDisplay();
1630
1747
  }
1631
1748
  });