agentvibes 5.1.3 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) 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 +6 -1
  4. package/.claude/hooks/bmad-speak.sh +2 -2
  5. package/.claude/hooks/piper-download-voices.sh +233 -225
  6. package/.claude/hooks/piper-installer.sh +1 -1
  7. package/.claude/hooks/piper-voice-manager.sh +125 -0
  8. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +97 -90
  9. package/.claude/hooks/play-tts-enhanced.sh +1 -1
  10. package/.claude/hooks/play-tts-piper.sh +16 -5
  11. package/.claude/hooks/play-tts-ssh-remote.sh +168 -167
  12. package/.claude/hooks/play-tts.sh +31 -9
  13. package/.claude/hooks/session-start-tts.sh +4 -1
  14. package/.claude/hooks/stop-tts.sh +1 -1
  15. package/.claude/hooks/verbosity-manager.sh +185 -178
  16. package/.claude/hooks-windows/download-extra-voices.ps1 +243 -185
  17. package/.claude/hooks-windows/play-tts-piper.ps1 +7 -2
  18. package/.claude/hooks-windows/play-tts.ps1 +219 -65
  19. package/.claude/hooks-windows/session-start-tts.ps1 +2 -1
  20. package/.claude/hooks-windows/verbosity-manager.ps1 +126 -119
  21. package/README.md +24 -1
  22. package/RELEASE_NOTES.md +113 -0
  23. package/bin/agentvibes-voice-browser.js +1939 -1840
  24. package/mcp-server/server.py +75 -25
  25. package/package.json +1 -1
  26. package/src/console/tabs/receiver-tab.js +1527 -1483
  27. package/src/console/tabs/settings-tab.js +2 -2
  28. package/src/console/tabs/setup-tab.js +122 -20
  29. package/src/console/tabs/voices-tab.js +130 -13
  30. package/src/i18n/en.js +202 -202
  31. package/src/installer.js +29 -25
  32. package/src/services/llm-provider-service.js +114 -11
  33. package/src/services/verbosity-service.js +159 -157
  34. 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';
@@ -565,6 +565,13 @@ export function createSetupTab(screen, services) {
565
565
  },
566
566
  });
567
567
 
568
+ // The "default" provider is config-only — it has no install/remove
569
+ // semantics. Hide those buttons and only show Configure.
570
+ if (provider.isDefault) {
571
+ installBtn.hide();
572
+ removeBtn.hide();
573
+ }
574
+
568
575
  // Wire actions
569
576
  installBtn.on('press', async () => { await handleProviderInstall(provider); });
570
577
  installBtn.key(['enter', 'space'], async () => { await handleProviderInstall(provider); });
@@ -575,8 +582,10 @@ export function createSetupTab(screen, services) {
575
582
  configBtn.on('press', async () => { await handleProviderConfigure(provider); });
576
583
  configBtn.key(['enter', 'space'], async () => { await handleProviderConfigure(provider); });
577
584
 
578
- // Navigation on each button
579
- for (const btn of [installBtn, removeBtn, configBtn]) {
585
+ // Navigation on each button — for the default provider, only Configure
586
+ // is focusable since install/remove are hidden.
587
+ const navButtons = provider.isDefault ? [configBtn] : [installBtn, removeBtn, configBtn];
588
+ for (const btn of navButtons) {
580
589
  btn.key(['tab', 'right'], () => { cycleFocus(1); });
581
590
  btn.key(['S-tab', 'left'], () => { cycleFocus(-1); });
582
591
  btn.key(['escape'], () => {
@@ -609,10 +618,17 @@ export function createSetupTab(screen, services) {
609
618
  return { installBtn, removeBtn, configBtn };
610
619
  }
611
620
 
612
- // Build all provider rows
621
+ // Build all provider rows.
622
+ // For the default provider, install/remove are hidden — push configBtn
623
+ // three times so the row-of-3 arrow-nav arithmetic still works (every
624
+ // "slot" in the default row lands on Configure, the only visible button).
613
625
  for (let i = 0; i < PROVIDERS.length; i++) {
614
626
  const { installBtn, removeBtn, configBtn } = createProviderRow(PROVIDERS[i], i);
615
- providerFocusableItems.push(installBtn, removeBtn, configBtn);
627
+ if (PROVIDERS[i].isDefault) {
628
+ providerFocusableItems.push(configBtn, configBtn, configBtn);
629
+ } else {
630
+ providerFocusableItems.push(installBtn, removeBtn, configBtn);
631
+ }
616
632
  }
617
633
 
618
634
  function cycleFocus(dir) {
@@ -624,6 +640,10 @@ export function createSetupTab(screen, services) {
624
640
  // ── Provider install/remove handlers ──────────────────────────────────────
625
641
 
626
642
  async function handleProviderInstall(provider) {
643
+ // Remember which button the user was on so we can advance focus to
644
+ // the NEXT row (same column) after they dismiss the info page.
645
+ _preInfoFocusIndex = providerFocusIndex;
646
+
627
647
  if (provider.id === 'claude-code') {
628
648
  const wasInstalled = installedState[provider.id];
629
649
  const result = await installClaudeMcp(targetDir);
@@ -643,7 +663,6 @@ export function createSetupTab(screen, services) {
643
663
  if (provider.id === 'openai-codex') {
644
664
  const wasInstalled = installedState[provider.id];
645
665
  const result = await installCodexMcp(targetDir);
646
- await installCopilotMcp(targetDir);
647
666
  await installCodexInstructions(targetDir, packageDir);
648
667
  await installCodexHooks(targetDir, packageDir);
649
668
  await refreshInstalledState();
@@ -652,6 +671,10 @@ export function createSetupTab(screen, services) {
652
671
  }
653
672
 
654
673
  async function handleProviderRemove(provider) {
674
+ // Remember which button the user was on so we can advance focus to
675
+ // the NEXT row (same column) after they dismiss the info page.
676
+ _preInfoFocusIndex = providerFocusIndex;
677
+
655
678
  if (provider.id === 'claude-code') {
656
679
  const result = await uninstallClaude(targetDir);
657
680
  await refreshInstalledState();
@@ -668,7 +691,6 @@ export function createSetupTab(screen, services) {
668
691
 
669
692
  if (provider.id === 'openai-codex') {
670
693
  await removeCodexMcp(targetDir);
671
- await removeCopilotMcp(targetDir);
672
694
  await removeCodexInstructions(targetDir);
673
695
  await removeCodexHooks(targetDir);
674
696
  await refreshInstalledState();
@@ -683,6 +705,7 @@ export function createSetupTab(screen, services) {
683
705
  'claude-code': 'claude-code',
684
706
  'github-copilot': 'copilot',
685
707
  'openai-codex': 'codex',
708
+ 'default': 'default',
686
709
  };
687
710
  const llmKey = llmKeyMap[provider.id] || provider.id;
688
711
  const config = loadLlmConfigSync(llmKey, targetDir);
@@ -701,6 +724,7 @@ export function createSetupTab(screen, services) {
701
724
  'claude-code': 'Claude Code here',
702
725
  'copilot': 'Copilot here',
703
726
  'codex': 'Codex here',
727
+ 'default': '', // empty by default — user sets via Configure
704
728
  };
705
729
 
706
730
  // Read global defaults for display
@@ -865,7 +889,7 @@ export function createSetupTab(screen, services) {
865
889
  const hooksSubdir = process.platform === 'win32' ? 'hooks-windows' : 'hooks';
866
890
  const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
867
891
  // Don't include pretext — play-tts already prepends it from the config
868
- const sampleText = 'Here is a preview of your audio settings.';
892
+ const sampleText = 'This is how your audio settings sound right now.';
869
893
 
870
894
  let cmd, args;
871
895
  if (isWin) {
@@ -1202,18 +1226,20 @@ export function createSetupTab(screen, services) {
1202
1226
 
1203
1227
  blessed.text({
1204
1228
  parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
1205
- content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [Home/End] [a-z] Jump [Enter] Select [Space] Preview [*] Fav [Esc] Cancel{/white-fg}',
1229
+ content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [a-z] Jump [Enter] Select [Space] Preview [+] 👍 [-] 👎 [Esc] Cancel{/white-fg}',
1206
1230
  style: { bg: COLORS.contentBg },
1207
1231
  });
1208
1232
 
1209
1233
  function _buildVoiceItems(voices) {
1210
1234
  const favs = getFavorites(configService);
1235
+ const td = getThumbsDown(configService);
1211
1236
  return voices.map(v => {
1212
1237
  const isActive = v === draft.voice;
1213
1238
  const isPrev = v === _previewVoiceId;
1214
- const isFav = favs.includes(v);
1239
+ const isUp = favs.includes(v);
1240
+ const isDown = td.includes(v);
1215
1241
  const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
1216
- const star = isFav ? '{yellow-fg}{/yellow-fg}' : ' ';
1242
+ const star = isUp ? '{green-fg}👍{/green-fg}' : (isDown ? '{red-fg}👎{/red-fg}' : ' ');
1217
1243
  const meta = getVoiceMeta(v);
1218
1244
  const name = meta.displayName.length > COL_N
1219
1245
  ? meta.displayName.slice(0, COL_N - 1) + '…'
@@ -1242,13 +1268,52 @@ export function createSetupTab(screen, services) {
1242
1268
  if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
1243
1269
  _killVP();
1244
1270
 
1271
+ const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1272
+
1273
+ // Route through remote provider if active
1274
+ const _remoteProviders = ['ssh-remote', 'agentvibes-receiver'];
1275
+ let _activeProvider = '';
1276
+ try {
1277
+ const _projectRoot = path.resolve(__dirname, '..', '..');
1278
+ const _provPaths = [
1279
+ path.join(_projectRoot, '.claude', 'tts-provider.txt'),
1280
+ path.join(os.homedir(), '.claude', 'tts-provider.txt'),
1281
+ ];
1282
+ for (const p of _provPaths) {
1283
+ if (fs.existsSync(p)) { _activeProvider = fs.readFileSync(p, 'utf8').trim(); break; }
1284
+ }
1285
+ } catch {}
1286
+
1287
+ if (_remoteProviders.includes(_activeProvider)) {
1288
+ const _projectRoot = path.resolve(__dirname, '..', '..');
1289
+ let rProc;
1290
+ if (_isWin) {
1291
+ const _playTts = path.join(_projectRoot, '.claude', 'hooks-windows', 'play-tts.ps1');
1292
+ rProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _playTts, phrase, voiceId], {
1293
+ stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
1294
+ });
1295
+ } else {
1296
+ const _playTts = path.join(_projectRoot, '.claude', 'hooks', 'play-tts.sh');
1297
+ rProc = spawn('bash', [_playTts, phrase, voiceId], {
1298
+ stdio: 'ignore', detached: true, env: _spawnEnv,
1299
+ });
1300
+ }
1301
+ _previewProc = rProc;
1302
+ _previewVoiceId = voiceId;
1303
+ if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing (remote): ${voiceId}{/cyan-fg}`); screen.render(); }
1304
+ rProc.on('exit', () => {
1305
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
1306
+ });
1307
+ rProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1308
+ return;
1309
+ }
1310
+
1245
1311
  const _ms = parseMultiSpeaker(voiceId);
1246
1312
  const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
1247
1313
  const safeBase = path.resolve(PIPER_VOICES_DIR);
1248
1314
  if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
1249
1315
 
1250
1316
  const tempWav = _secureTempWav('vp');
1251
- const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
1252
1317
 
1253
1318
  let _piperBin = 'piper';
1254
1319
  if (_isWin) {
@@ -1307,9 +1372,13 @@ export function createSetupTab(screen, services) {
1307
1372
  const sel = _allVoices[vpList.selected];
1308
1373
  if (sel) _previewVoice(sel);
1309
1374
  });
1310
- vpList.key(['*'], () => {
1375
+ vpList.key(['*', '+'], () => {
1376
+ const sel = _allVoices[vpList.selected];
1377
+ if (sel) { toggleThumbsUp(configService, sel); _refreshVP(); }
1378
+ });
1379
+ vpList.key(['-'], () => {
1311
1380
  const sel = _allVoices[vpList.selected];
1312
- if (sel) { toggleFavorite(configService, sel); _refreshVP(); }
1381
+ if (sel) { toggleThumbsDown(configService, sel); _refreshVP(); }
1313
1382
  });
1314
1383
  vpList.key(['escape', 'q'], _closeVP);
1315
1384
 
@@ -1432,10 +1501,18 @@ export function createSetupTab(screen, services) {
1432
1501
  function showAllProviderRows() {
1433
1502
  providerHeader.show();
1434
1503
  for (const row of providerRows) {
1504
+ const provider = PROVIDERS.find(p => p.id === row.id);
1435
1505
  row.label.show();
1436
1506
  row.statusText.show();
1437
- row.installBtn.show();
1438
- row.removeBtn.show();
1507
+ // The "default" provider has no install/remove semantics — keep its
1508
+ // install/remove buttons hidden so only Configure shows.
1509
+ if (provider?.isDefault) {
1510
+ row.installBtn.hide();
1511
+ row.removeBtn.hide();
1512
+ } else {
1513
+ row.installBtn.show();
1514
+ row.removeBtn.show();
1515
+ }
1439
1516
  row.configBtn.show();
1440
1517
  }
1441
1518
  }
@@ -1587,28 +1664,53 @@ export function createSetupTab(screen, services) {
1587
1664
  screen.render();
1588
1665
  }
1589
1666
 
1590
- function showProviderListView() {
1667
+ function showProviderListView(targetIdx = 0) {
1591
1668
  providerView = 'list';
1592
1669
  infoBox.hide();
1593
1670
  contentBox.hide();
1594
1671
  showAllProviderRows();
1595
- providerFocusIndex = 0;
1596
- if (providerFocusableItems.length) providerFocusableItems[0].focus();
1672
+ const max = providerFocusableItems.length;
1673
+ if (max > 0) {
1674
+ providerFocusIndex = ((targetIdx % max) + max) % max;
1675
+ providerFocusableItems[providerFocusIndex].focus();
1676
+ }
1597
1677
  screen.render();
1598
1678
  }
1599
1679
 
1600
1680
  infoBox.key(['escape', 'enter'], () => {
1601
- showProviderListView();
1681
+ // After dismissing the install/remove info page, advance focus to the
1682
+ // NEXT provider row but keep the same column (Install stays on Install,
1683
+ // Remove stays on Remove, Configure stays on Configure). Each row has
1684
+ // 3 focusable slots so +3 moves one full row down with wraparound.
1685
+ const max = providerFocusableItems.length;
1686
+ const nextIdx = max > 0 ? (_preInfoFocusIndex + 3) % max : 0;
1687
+ showProviderListView(nextIdx);
1602
1688
  });
1603
1689
 
1690
+ // Captured by handleProviderInstall/Remove right before showing info.
1691
+ // Defaults to 0 so the first-time flow still lands on Claude Code Install.
1692
+ let _preInfoFocusIndex = 0;
1693
+
1604
1694
  async function refreshInstalledState() {
1605
1695
  for (const p of PROVIDERS) {
1696
+ // The "default" provider is config-only — always treat as available.
1697
+ if (p.isDefault) {
1698
+ installedState[p.id] = true;
1699
+ continue;
1700
+ }
1606
1701
  const checkFn = p.id === 'claude-code' ? checkClaudeInstalled
1607
1702
  : p.id === 'github-copilot' ? checkCopilotInstalled
1608
1703
  : checkCodexInstalled;
1609
1704
  installedState[p.id] = await checkFn(targetDir);
1610
1705
  }
1611
1706
  for (const row of providerRows) {
1707
+ const provider = PROVIDERS.find(p => p.id === row.id);
1708
+ // The default provider has no install state to display — show its
1709
+ // config-only nature instead.
1710
+ if (provider?.isDefault) {
1711
+ row.statusText.setContent('{cyan-fg}[Config Only]{/cyan-fg}');
1712
+ continue;
1713
+ }
1612
1714
  const installed = installedState[row.id];
1613
1715
  row.statusText.setContent(
1614
1716
  installed
@@ -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
  });