agentvibes 5.14.0 → 5.15.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.
@@ -20,6 +20,7 @@ import {
20
20
  getFavorites, getThumbsDown, toggleThumbsUp, toggleThumbsDown,
21
21
  } from './voices-tab.js';
22
22
  import { buildAudioEnv, detectWavPlayer, detectRemoteLlm } from '../audio-env.js';
23
+ import { createRowSpinner } from '../preview-transport.js';
23
24
  import { voicesForProvider } from '../../services/provider-voice-catalog.js';
24
25
  import { destroyList } from '../widgets/destroy-list.js';
25
26
  import { BRAND_PINK } from '../brand-colors.js';
@@ -139,7 +140,7 @@ const COLORS = {
139
140
  linkFg: 'bright-cyan',
140
141
  };
141
142
 
142
- const _FOOTER_BMAD_EN = '[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [X] Reset [Q] Quit';
143
+ const _FOOTER_BMAD_EN = '[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [Del] Reset [Q] Quit';
143
144
  const _FOOTER_NOBMAD_EN = '[Tab] Switch Tab [Q] Quit';
144
145
 
145
146
  const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
@@ -332,6 +333,10 @@ ${_tl('bmadDesc')}
332
333
  if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
333
334
  });
334
335
 
336
+ // Manual re-check: after installing BMAD from a separate terminal, Enter
337
+ // re-scans without leaving the tab. (Switching tabs also re-scans via onFocus.)
338
+ onboardingBox.key(['enter'], () => { refreshDisplay(); });
339
+
335
340
  // -------------------------------------------------------------------------
336
341
  // BMAD state — section header
337
342
 
@@ -421,7 +426,7 @@ ${_tl('bmadDesc')}
421
426
  left: 4,
422
427
  hidden: true,
423
428
  tags: true,
424
- content: '{#546e7a-fg}[Space] Preview [Enter] Configure [X] Reset [A] Auto-assign [B] Bulk Edit{/#546e7a-fg}',
429
+ content: '{#546e7a-fg}[Space] Preview [Enter] Configure [Del] Reset [A] Auto-assign [B] Bulk Edit{/#546e7a-fg}',
425
430
  style: { bg: COLORS.contentBg },
426
431
  });
427
432
 
@@ -468,7 +473,7 @@ ${_tl('bmadDesc')}
468
473
  return btn;
469
474
  }
470
475
 
471
- const resetBtn = _createBtn('[X] Reset', () => {
476
+ const resetBtn = _createBtn('[Del] Reset', () => {
472
477
  const agent = _agents[agentList.selected ?? 0];
473
478
  if (agent) {
474
479
  voiceStore.resetAgentProfile(agent.id);
@@ -1073,6 +1078,11 @@ ${_tl('bmadDesc')}
1073
1078
  content: '', style: { fg: 'bright-cyan', bg: COLORS.contentBg },
1074
1079
  });
1075
1080
 
1081
+ // Row spinner: "⠹ Previewing (locally|remotely via SSH) (Space to stop)" ON
1082
+ // the selected row — shared with every other picker. renderItem restores the
1083
+ // row on stop. vpPreviewLine is retained for parity but no longer shows preview.
1084
+ const _vpSpin = createRowSpinner(vpList, screen, (i) => _buildVoiceItems([_allVoices[i]])[0], { isClosed: () => _vpClosed });
1085
+
1076
1086
  blessed.text({
1077
1087
  parent: vpModal, bottom: 3, left: 2, right: 2, height: 1, tags: true,
1078
1088
  content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [a-z] Jump{/white-fg}',
@@ -1119,7 +1129,7 @@ ${_tl('bmadDesc')}
1119
1129
  }
1120
1130
 
1121
1131
  function _previewVoice(voiceId) {
1122
- if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
1132
+ if (_previewVoiceId === voiceId) { _killVP(); _vpSpin.stop(); return; }
1123
1133
  _killVP();
1124
1134
  if (_previewMinTimer) { clearTimeout(_previewMinTimer); _previewMinTimer = null; }
1125
1135
 
@@ -1131,15 +1141,15 @@ ${_tl('bmadDesc')}
1131
1141
  const playTtsScript = _hookScript('hooks-windows', 'play-tts.ps1');
1132
1142
  if (!fs.existsSync(playTtsScript)) return;
1133
1143
  _previewVoiceId = voiceId;
1134
- if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}...{/bright-cyan-fg}`); _refreshVP(); }
1144
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, false); }
1135
1145
  _previewProc = spawn('powershell', [ // NOSONAR
1136
1146
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTtsScript, phrase, voiceId,
1137
1147
  ], { stdio: 'ignore', detached: false, windowsHide: true,
1138
1148
  env: { ..._spawnEnv, AGENTVIBES_VOICE_SOURCE: 'audition' } });
1139
1149
  _previewProc.on('exit', () => {
1140
- if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
1150
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; _vpSpin.stop(); }
1141
1151
  });
1142
- _previewProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1152
+ _previewProc.on('error', () => { _previewProc = null; _previewVoiceId = null; _vpSpin.stop(); });
1143
1153
  return;
1144
1154
  }
1145
1155
 
@@ -1166,12 +1176,12 @@ ${_tl('bmadDesc')}
1166
1176
  cwd: _projectRoot,
1167
1177
  });
1168
1178
  _previewVoiceId = voiceId;
1169
- if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}...{/bright-cyan-fg}`); _refreshVP(); }
1179
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, !!remoteLlm); }
1170
1180
 
1171
1181
  const _clearAfterMinDisplay = () => {
1172
1182
  if (_previewVoiceId === voiceId) {
1173
1183
  _previewVoiceId = null; _previewProc = null;
1174
- if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); }
1184
+ _vpSpin.stop();
1175
1185
  }
1176
1186
  _previewMinTimer = null;
1177
1187
  };
@@ -1878,7 +1888,12 @@ ${_tl('bmadDesc')}
1878
1888
  // -------------------------------------------------------------------------
1879
1889
  // Key bindings
1880
1890
 
1881
- agentList.key(['x', 'X'], () => {
1891
+ // Reset uses Delete/Backspace — NOT a letter. 'x'/'X' is the GLOBAL shortcut
1892
+ // for the Receiver tab (navigation.js KEY_TO_TAB), and screen-level keys fire
1893
+ // even while this list is focused, so binding Reset to X reset the agent AND
1894
+ // jumped to the Receiver tab. Delete has no global binding and doesn't clash
1895
+ // with type-to-jump.
1896
+ agentList.key(['delete', 'backspace'], () => {
1882
1897
  const agent = _agents[agentList.selected ?? 0];
1883
1898
  if (agent) {
1884
1899
  voiceStore.resetAgentProfile(agent.id);
@@ -2038,6 +2053,11 @@ ${_tl('bmadDesc')}
2038
2053
  },
2039
2054
 
2040
2055
  onFocus() {
2056
+ // Re-detect on focus so BMAD installed in another tab/terminal (or just
2057
+ // now, via the onboarding install command) appears without restarting the
2058
+ // TUI. Tab switching invokes onFocus, so returning to this tab IS the
2059
+ // rescan — onboarding ⇄ agent list flips automatically.
2060
+ refreshDisplay();
2041
2061
  if (_bmadDetected) {
2042
2062
  agentList.focus();
2043
2063
  } else {
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
16
- import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
16
+ import { resolveMusicProvider, spawnMusicRemote, createRowSpinner } from '../music-preview.js';
17
17
  import { playBlingCue } from '../bling.js';
18
18
  import { t } from '../../i18n/strings.js';
19
19
 
@@ -372,6 +372,20 @@ export function createMusicTab(screen, services) {
372
372
  },
373
373
  });
374
374
 
375
+ // Row spinner: paints "⠹ Previewing (locally|remotely via SSH) (Space to stop)"
376
+ // ON the selected track row — shared with every other picker. renderItem
377
+ // restores the row's normal content when the preview stops.
378
+ const _trackSpin = createRowSpinner(trackList, screen, (i) => {
379
+ const t = _getVisibleTracks()[i];
380
+ if (!t) return '';
381
+ const { track: activeTrackId } = _getMusic(configService);
382
+ return _buildListItems([t], activeTrackId, getMusicFavorites(configService))[0];
383
+ // static: track rows carry double-width emoji; animating/reallocating them
384
+ // desyncs blessed's terminal output (jumps/corruption). Write the indicator
385
+ // ONCE (padded to full width to clear the old row), no animation, no realloc —
386
+ // the least-fragile option for emoji rows.
387
+ }, { isClosed: () => box.hidden, static: true });
388
+
375
389
  // -------------------------------------------------------------------------
376
390
  // Status panel
377
391
 
@@ -522,7 +536,23 @@ export function createMusicTab(screen, services) {
522
536
  return _stripHint(_stripBlink(raw));
523
537
  }
524
538
 
539
+ // Strip hint text + blink cursor from EVERY row and forget the hint anchor.
540
+ // Called when a preview starts so the internal buffer is clean before the
541
+ // spinner's full-repaint (otherwise a stale hint/blink would be faithfully
542
+ // re-rendered by the realloc).
543
+ function _clearRowDecorations() {
544
+ const items = trackList.items || [];
545
+ for (let i = 0; i < items.length; i++) {
546
+ if (items[i]) items[i].setContent(_stripDecorations(items[i].content));
547
+ }
548
+ _hintIdx = -1; _hintBase = '';
549
+ }
550
+
525
551
  function _updateHint(idx) {
552
+ // While a preview spinner is animating a row, it owns row rendering — the
553
+ // inline hint must not rewrite rows (it would fight the 80ms spinner and
554
+ // leave ghost characters on the previewing row when the cursor moves).
555
+ if (_trackSpin.isActive()) return;
526
556
  const items = trackList.items;
527
557
  // Restore previously hinted row — pad with spaces to overwrite ghost hint text
528
558
  const _pad = ' '.repeat(60);
@@ -621,17 +651,20 @@ export function createMusicTab(screen, services) {
621
651
  if (_remotePlayingTrackId === trackId) {
622
652
  _sendMusicRemote(_mp, { stop: true });
623
653
  _remotePlayingTrackId = null;
654
+ _trackSpin.stop();
655
+ if (_listFocused) _updateHint(trackList.selected);
624
656
  previewLine.setContent(_listFocused ? _hintText() : '');
625
657
  screen.render();
626
658
  return;
627
659
  }
628
660
  // Bling first (fire-and-forget, plays locally) — same readiness cue as the
629
- // voice preview — then forward the track to the receiver.
661
+ // voice preview — then forward the track to the receiver. The receiver keeps
662
+ // playing until an explicit stop, so the row spinner persists (no floor).
630
663
  playBlingCue(_PKG_ROOT);
631
- const rlabel = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
632
664
  if (_sendMusicRemote(_mp, { track: trackId })) {
633
665
  _remotePlayingTrackId = trackId;
634
- previewLine.setContent(`{${COLORS.playingFg}-fg}♪ Playing on receiver: ${rlabel} (Space to stop){/${COLORS.playingFg}-fg}`);
666
+ _clearRowDecorations(); // spinner owns the rows; wipe stale hint/blink first
667
+ _trackSpin.start(trackList.selected, true);
635
668
  }
636
669
  screen.render();
637
670
  return;
@@ -642,6 +675,8 @@ export function createMusicTab(screen, services) {
642
675
  if (_playingTrackId === trackId) {
643
676
  _killPlayingProcess();
644
677
  _playingTrackId = null;
678
+ _trackSpin.stop();
679
+ if (_listFocused) _updateHint(trackList.selected);
645
680
  previewLine.setContent(_listFocused ? _hintText() : '');
646
681
  screen.render();
647
682
  return;
@@ -668,17 +703,15 @@ export function createMusicTab(screen, services) {
668
703
 
669
704
  _playingProcess = proc;
670
705
  _playingTrackId = trackId;
671
-
672
- const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
673
- previewLine.setContent(`{${COLORS.playingFg}-fg}♪ Previewing: ${label} (Space again to stop){/${COLORS.playingFg}-fg}`);
706
+ _clearRowDecorations(); // spinner owns the rows; wipe stale hint/blink first
707
+ _trackSpin.start(trackList.selected, false);
674
708
  screen.render();
675
709
 
676
710
  proc.on('exit', () => {
677
711
  if (_playingTrackId === trackId) {
678
712
  _playingTrackId = null;
679
713
  _playingProcess = null;
680
- previewLine.setContent(_listFocused ? _hintText() : '');
681
- refreshDisplay(); // clears (playing) label
714
+ _trackSpin.stop();
682
715
  }
683
716
  });
684
717
 
@@ -687,7 +720,7 @@ export function createMusicTab(screen, services) {
687
720
  _killPlayingProcess();
688
721
  _playingTrackId = null;
689
722
  _playingProcess = null;
690
- previewLine.setContent(_listFocused ? _hintText() : '');
723
+ _trackSpin.stop();
691
724
  }
692
725
  });
693
726
  }
@@ -819,6 +852,7 @@ export function createMusicTab(screen, services) {
819
852
  function _close() {
820
853
  _killPlayingProcess();
821
854
  _playingTrackId = null;
855
+ _trackSpin.stop();
822
856
  previewLine.setContent(_listFocused ? _hintText() : '');
823
857
  modal.destroy();
824
858
  trackList.focus();
@@ -942,8 +976,9 @@ export function createMusicTab(screen, services) {
942
976
  trackList.key(['space'], () => {
943
977
  const trackId = _getSelectedTrackId();
944
978
  if (trackId) {
979
+ // No refreshDisplay() here — it rebuilds every row via setItems and would
980
+ // clobber the preview spinner's row (the spinner now shows preview state).
945
981
  _playTrack(trackId);
946
- refreshDisplay();
947
982
  }
948
983
  });
949
984
 
@@ -1013,6 +1048,10 @@ export function createMusicTab(screen, services) {
1013
1048
  let _tlBlink = { interval: null, on: false, sel: -1 };
1014
1049
  process.on('exit', () => { if (_tlBlink.interval) clearInterval(_tlBlink.interval); });
1015
1050
  function _tlTick() {
1051
+ // While a preview is animating a row, the preview spinner (80ms) owns the
1052
+ // display — don't fight it with the 500ms blink cursor (that interleaving
1053
+ // left ghost characters on the previewing row).
1054
+ if (_trackSpin.isActive()) return;
1016
1055
  _tlBlink.on = !_tlBlink.on;
1017
1056
  const items = trackList.items;
1018
1057
  const cur = trackList.selected ?? 0;
@@ -1134,6 +1173,7 @@ export function createMusicTab(screen, services) {
1134
1173
  // Stop any preview when leaving the tab
1135
1174
  _killPlayingProcess();
1136
1175
  _playingTrackId = null;
1176
+ _trackSpin.stop();
1137
1177
  previewLine.setContent('');
1138
1178
  box.hide();
1139
1179
  screen.render();
@@ -1148,6 +1188,7 @@ export function createMusicTab(screen, services) {
1148
1188
  // Stop preview when focus leaves Music tab
1149
1189
  _killPlayingProcess();
1150
1190
  _playingTrackId = null;
1191
+ _trackSpin.stop();
1151
1192
  },
1152
1193
 
1153
1194
  getFooterText() {
@@ -46,6 +46,7 @@ import { destroyList } from '../widgets/destroy-list.js';
46
46
  import { scanInstalledVoices, getVoiceMeta, previewPhrase, genderIconTag, formatVoiceRow, voiceRowHeader, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker, getFavorites, getThumbsDown, toggleFavorite, toggleThumbsUp, toggleThumbsDown } from './voices-tab.js';
47
47
  import { attachBtnBlink } from './agents-tab.js';
48
48
  import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
49
+ import { previewRowContent, createRowSpinner, padTaggedTo } from '../preview-transport.js';
49
50
  import { buildBlingCommand, playBlingCue } from '../bling.js';
50
51
  import { spawn, spawnSync } from 'node:child_process';
51
52
 
@@ -2907,6 +2908,7 @@ export function createSetupTab(screen, services) {
2907
2908
  let _kSpinFrame = 0;
2908
2909
  let _kSpinningIdx = -1;
2909
2910
  let _kSpinStartTs = 0;
2911
+ let _kSpinRemote = false; // whether the active preview is forwarded to the receiver
2910
2912
  let _kFloorTimer = null; // pending _stopKSpinnerWithFloor timer (tracked so it can be cancelled)
2911
2913
  // Remote SSH preview is fire-and-forget: play-tts-ssh-remote.sh backgrounds
2912
2914
  // the ssh call and exits within milliseconds, so the row spinner would be
@@ -2916,17 +2918,20 @@ export function createSetupTab(screen, services) {
2916
2918
  // visible "preview sent" cue (fire-and-forget has no playback signal to await).
2917
2919
  const _K_MIN_SPIN_MS = 1100;
2918
2920
 
2919
- function _startKSpinner(listIdx) {
2921
+ function _startKSpinner(listIdx, remote = false) {
2920
2922
  _stopKSpinner();
2921
2923
  _kSpinningIdx = listIdx;
2922
2924
  _kSpinFrame = 0;
2923
2925
  _kSpinStartTs = Date.now();
2926
+ _kSpinRemote = remote;
2924
2927
  _kSpinInterval = setInterval(() => {
2925
2928
  if (_kClosed) { _stopKSpinner(); return; }
2926
- const spin = `{cyan-fg}${_K_SPIN[_kSpinFrame++ % _K_SPIN.length]}{/cyan-fg}`;
2927
- kPicker.setItem(listIdx, `${_kokoroItem(voices[listIdx])} ${spin}`);
2929
+ // Row indicator: "⠹ Previewing (locally|remotely via SSH) (Space to stop)".
2930
+ // The row IS the selected voice, so the name is intentionally omitted.
2931
+ kPicker.setItem(listIdx, padTaggedTo(previewRowContent(_K_SPIN[_kSpinFrame++ % _K_SPIN.length], _kSpinRemote), kPicker.width || 78));
2928
2932
  screen.render();
2929
2933
  }, 80);
2934
+ if (_kSpinInterval.unref) _kSpinInterval.unref(); // never keep the process alive
2930
2935
  }
2931
2936
 
2932
2937
  function _stopKSpinner() {
@@ -3277,7 +3282,7 @@ export function createSetupTab(screen, services) {
3277
3282
 
3278
3283
  // ── Remote preview: route through SSH pipeline so receiver plays it ──
3279
3284
  if (_validSshHost) {
3280
- _startKSpinner(kPicker.selected);
3285
+ _startKSpinner(kPicker.selected, true);
3281
3286
  const hookDir = path.join(packageDir, '.claude', 'hooks');
3282
3287
  const remoteEnv = { ...process.env, CLAUDE_PROJECT_DIR: targetDir, AGENTVIBES_SSH_HOST: _sshHost };
3283
3288
  if (_validSshKey) remoteEnv.AGENTVIBES_SSH_KEY = _sshKey;
@@ -3392,6 +3397,7 @@ export function createSetupTab(screen, services) {
3392
3397
  }
3393
3398
  screen.render();
3394
3399
  }, 120);
3400
+ if (_kAnimInterval.unref) _kAnimInterval.unref(); // never keep the process alive
3395
3401
  }
3396
3402
 
3397
3403
  function _stopDlAnim() {
@@ -3788,10 +3794,15 @@ export function createSetupTab(screen, services) {
3788
3794
  function _setHint(text) { elBox.setLabel(text || _defaultHint); screen.render(); }
3789
3795
  _setHint(_defaultHint);
3790
3796
 
3797
+ // Row spinner: "⠹ Previewing (locally) (Space to stop)" on the selected row.
3798
+ // ElevenLabs preview always plays locally today, so the badge is honestly
3799
+ // local; _setHint stays for error messages only.
3800
+ const _elSpin = createRowSpinner(elPicker, screen, (i) => _items[i], { isClosed: () => _elClosed });
3801
+
3791
3802
  function _previewEl() {
3792
3803
  if (_elPreviewProc) { // toggle off
3793
3804
  _killElPreview();
3794
- _setHint(_defaultHint);
3805
+ _elSpin.stop();
3795
3806
  return;
3796
3807
  }
3797
3808
  const v = ELEVENLABS_VOICES[elPicker.selected];
@@ -3808,18 +3819,17 @@ export function createSetupTab(screen, services) {
3808
3819
  return;
3809
3820
  }
3810
3821
  _elPreviewProc = proc;
3811
- _setHint(` {cyan-fg}♪ ${v.name}... (Space=stop){/cyan-fg} `);
3822
+ _elSpin.start(elPicker.selected, false);
3812
3823
  proc.on('exit', (code) => {
3813
3824
  _elPreviewProc = null;
3814
3825
  if (_elClosed) return;
3826
+ _elSpin.stop();
3815
3827
  if (code && code !== 0) {
3816
3828
  _setHint(' {red-fg}Preview failed — check API key / plan{/red-fg} ');
3817
3829
  setTimeout(() => { if (!_elClosed) _setHint(_defaultHint); }, 3000);
3818
- } else {
3819
- _setHint(_defaultHint);
3820
3830
  }
3821
3831
  });
3822
- proc.on('error', () => { _elPreviewProc = null; if (!_elClosed) _setHint(' {red-fg}Preview failed{/red-fg} '); });
3832
+ proc.on('error', () => { _elPreviewProc = null; _elSpin.stop(); if (!_elClosed) _setHint(' {red-fg}Preview failed{/red-fg} '); });
3823
3833
  }
3824
3834
 
3825
3835
  elPicker.key(['enter'], () => {
@@ -4137,6 +4147,11 @@ export function createSetupTab(screen, services) {
4137
4147
  content: ' ', style: { fg: 'cyan', bg: COLORS.contentBg },
4138
4148
  });
4139
4149
 
4150
+ // Row spinner: paints "⠹ Previewing (locally|remotely via SSH) (Space to stop)"
4151
+ // ON the selected row (shared with every other picker). renderItem restores the
4152
+ // row's normal content on stop. vpPreviewLine is kept for error messages only.
4153
+ const _vpSpin = createRowSpinner(vpList, screen, (i) => _buildVoiceItems([_allVoices[i]])[0], { isClosed: () => _vpClosed });
4154
+
4140
4155
  // Movement hints at the bottom (primary actions live in the top help bar, so
4141
4156
  // they are not duplicated here). Standardized [key] = label formatting.
4142
4157
  blessed.text({
@@ -4186,7 +4201,7 @@ export function createSetupTab(screen, services) {
4186
4201
  }
4187
4202
 
4188
4203
  function _previewVoice(voiceId) {
4189
- if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
4204
+ if (_previewVoiceId === voiceId) { _killVP(); _vpSpin.stop(); return; }
4190
4205
  _killVP();
4191
4206
 
4192
4207
  const phrase = previewPhrase(voiceId);
@@ -4238,12 +4253,12 @@ export function createSetupTab(screen, services) {
4238
4253
  }
4239
4254
  _previewProc = rProc;
4240
4255
  _previewVoiceId = voiceId;
4241
- if (!_vpClosed) { _refreshVP(); vpPreviewLine.setContent('{bright-magenta-fg}♪ Synthesizing on remote...{/bright-magenta-fg}'); screen.render(); }
4256
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, true); }
4242
4257
  rProc.on('exit', () => {
4243
4258
  if (_previewVoiceId === voiceId) {
4244
4259
  _previewVoiceId = null; _previewProc = null;
4245
- // Keep message + visible for 5s while remote device synthesises and plays
4246
- setTimeout(() => { if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }, 5000);
4260
+ // Fire-and-forget SSH exits in ms; keep the row cue up briefly, then restore.
4261
+ _vpSpin.stopWithFloor();
4247
4262
  }
4248
4263
  });
4249
4264
  rProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
@@ -4279,9 +4294,9 @@ export function createSetupTab(screen, services) {
4279
4294
  }
4280
4295
  _previewProc = nProc;
4281
4296
  _previewVoiceId = voiceId;
4282
- if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}...{/cyan-fg}`); _refreshVP(); }
4283
- nProc.on('exit', () => { if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } } });
4284
- nProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
4297
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, false); }
4298
+ nProc.on('exit', () => { if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; _vpSpin.stop(); } });
4299
+ nProc.on('error', () => { _previewProc = null; _previewVoiceId = null; _vpSpin.stop(); });
4285
4300
  return;
4286
4301
  }
4287
4302
 
@@ -4316,14 +4331,14 @@ export function createSetupTab(screen, services) {
4316
4331
  _previewVoiceId = voiceId;
4317
4332
 
4318
4333
  if (!_vpClosed) {
4319
- vpPreviewLine.setContent(`{cyan-fg}♪ Synthesizing: ${voiceId}...{/cyan-fg}`);
4320
- _refreshVP();
4334
+ _vpSpin.start(vpList.selected, false);
4321
4335
  }
4322
4336
 
4323
4337
  piper.on('exit', (code) => {
4324
4338
  if (_previewVoiceId !== voiceId) { try { fs.unlinkSync(tempWav); } catch {} return; }
4325
4339
  if (code !== 0) {
4326
4340
  _previewProc = null; _previewVoiceId = null;
4341
+ _vpSpin.stop();
4327
4342
  if (!_vpClosed) {
4328
4343
  vpPreviewLine.setContent('{red-fg}♪ Preview failed — is Piper installed?{/red-fg}');
4329
4344
  screen.render();
@@ -4341,14 +4356,15 @@ export function createSetupTab(screen, services) {
4341
4356
  env: _spawnEnv,
4342
4357
  });
4343
4358
  _previewProc = pp;
4344
- if (!_vpClosed) { vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}{/cyan-fg}`); screen.render(); }
4359
+ // Row spinner already running from the synth phase — keep it through playback.
4345
4360
  pp.on('exit', () => {
4346
- if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
4361
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; _vpSpin.stop(); }
4347
4362
  try { fs.unlinkSync(tempWav); } catch {}
4348
4363
  });
4349
4364
  });
4350
4365
  piper.on('error', () => {
4351
4366
  _previewProc = null; _previewVoiceId = null;
4367
+ _vpSpin.stop();
4352
4368
  if (!_vpClosed) {
4353
4369
  vpPreviewLine.setContent('{red-fg}♪ Cannot find Piper — install it first{/red-fg}');
4354
4370
  screen.render();
@@ -14,7 +14,7 @@ import { BRAND_PINK } from '../brand-colors.js';
14
14
  import { renderHelpBar, selectorTitle } from './help-bar.js';
15
15
  import { formatTrackName } from './format-utils.js';
16
16
  import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
17
- import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
17
+ import { resolveMusicProvider, spawnMusicRemote, createRowSpinner } from '../music-preview.js';
18
18
  import { playBlingCue } from '../bling.js';
19
19
 
20
20
  // AgentVibes package/repo root — resolves bundled assets (bling cue) and is the
@@ -231,12 +231,20 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
231
231
  },
232
232
  });
233
233
 
234
- // Transient preview/error status shows in the title area; idle restores the title.
234
+ // Transient error status shows in the title area; idle restores the title.
235
235
  function _setStatus(text) {
236
236
  box.setLabel(text || TITLE);
237
237
  screen.render();
238
238
  }
239
239
 
240
+ // Row spinner: paints "⠹ Previewing (locally|remotely via SSH) (Space to stop)"
241
+ // ON the selected track row — shared with every other picker. _setStatus stays
242
+ // for error messages only.
243
+ let _closed = false;
244
+ const _rowSpin = createRowSpinner(list, screen, (i) => (
245
+ tracks[i] && tracks[i].file === currentTrack ? `● ${tracks[i].label}` : ` ${tracks[i] ? tracks[i].label : ''}`
246
+ ), { isClosed: () => _closed });
247
+
240
248
  if (currentIdx >= 0) list.select(currentIdx);
241
249
  list.focus();
242
250
  screen.render();
@@ -263,6 +271,7 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
263
271
  if (mp.remote) _sendRemote(mp, { stop: true });
264
272
  _remotePreviewTrackId = null;
265
273
  }
274
+ _rowSpin.stop();
266
275
  }
267
276
 
268
277
  /**
@@ -310,16 +319,16 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
310
319
  if (_remotePreviewTrackId === trackFile) {
311
320
  _sendRemote(_mp, { stop: true });
312
321
  _remotePreviewTrackId = null;
313
- _setStatus();
322
+ _rowSpin.stop();
314
323
  return;
315
324
  }
316
325
  // Bling first (fire-and-forget, plays locally like the voice preview),
317
- // then forward the track to the receiver.
326
+ // then forward the track to the receiver. The receiver keeps playing until
327
+ // an explicit stop, so the row spinner persists (no floor).
318
328
  playBlingCue(_PKG_ROOT);
319
329
  if (_sendRemote(_mp, { track: trackFile })) {
320
330
  _remotePreviewTrackId = trackFile;
321
- const rlabel = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
322
- _setStatus(` {bright-cyan-fg}♪ ${rlabel} (Space to stop){/bright-cyan-fg} `);
331
+ _rowSpin.start(list.selected, true);
323
332
  }
324
333
  return;
325
334
  }
@@ -357,25 +366,25 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
357
366
 
358
367
  _previewProc = proc;
359
368
  _previewTrackId = trackFile;
360
-
361
- const label = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
362
- _setStatus(` {bright-cyan-fg}♪ ${label} (Space to stop){/bright-cyan-fg} `);
369
+ _rowSpin.start(list.selected, false);
363
370
 
364
371
  proc.on('exit', () => {
365
372
  if (_previewTrackId === trackFile) {
366
373
  _previewTrackId = null;
367
374
  _previewProc = null;
368
- _setStatus();
375
+ _rowSpin.stop();
369
376
  }
370
377
  });
371
378
 
372
379
  proc.on('error', () => {
373
380
  _previewTrackId = null;
374
381
  _previewProc = null;
382
+ _rowSpin.stop();
375
383
  });
376
384
  }
377
385
 
378
386
  function _close(callback) {
387
+ _closed = true;
379
388
  _killPreview();
380
389
  if (callback) {
381
390
  callback();
package/src/i18n/de.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ Scrollen für mehr Inhalt ↓",
200
200
  readmeNotFound: "*(Keine README.md im aktuellen Verzeichnis gefunden)*",
201
201
  bmadFooterNobmad: "[Tab] Tab wechseln [Q] Beenden",
202
- bmadFooterBmad: "[↑↓/jk] Navigieren [Space] Vorschau [Enter] Konfigurieren [A] Auto-zuweisen [B] Stapel [X] Zurücksetzen [Q] Beenden",
202
+ bmadFooterBmad: "[↑↓/jk] Navigieren [Space] Vorschau [Enter] Konfigurieren [A] Auto-zuweisen [B] Stapel [Del] Zurücksetzen [Q] Beenden",
203
203
  };
package/src/i18n/en.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Search:",
199
199
  readmeScrollMore: "↓ Scroll for more content ↓",
200
200
  readmeNotFound: "*(No README.md found in current directory)*",
201
- bmadFooterNobmad: "[Tab] Switch Tab [Q] Quit",
202
- bmadFooterBmad: "[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [X] Reset [Q] Quit",
201
+ bmadFooterNobmad: "[Enter] Re-check [Tab] Switch Tab [Q] Quit",
202
+ bmadFooterBmad: "[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [Del] Reset [Q] Quit",
203
203
  };
package/src/i18n/es.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ Desplázate para ver más ↓",
200
200
  readmeNotFound: "*(No se encontró README.md en el directorio actual)*",
201
201
  bmadFooterNobmad: "[Tab] Cambiar Pestaña [Q] Salir",
202
- bmadFooterBmad: "[↑↓/jk] Navegar [Space] Previsualizar [Enter] Configurar [A] Auto-asignar [B] Masivo [X] Restablecer [Q] Salir",
202
+ bmadFooterBmad: "[↑↓/jk] Navegar [Space] Previsualizar [Enter] Configurar [A] Auto-asignar [B] Masivo [Del] Restablecer [Q] Salir",
203
203
  };
package/src/i18n/fr.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ Défiler pour plus de contenu ↓",
200
200
  readmeNotFound: "*(Aucun README.md trouvé dans le répertoire actuel)*",
201
201
  bmadFooterNobmad: "[Tab] Changer d'onglet [Q] Quitter",
202
- bmadFooterBmad: "[↑↓/jk] Naviguer [Space] Aperçu [Enter] Configurer [A] Auto-assigner [B] Lot [X] Réinitialiser [Q] Quitter",
202
+ bmadFooterBmad: "[↑↓/jk] Naviguer [Space] Aperçu [Enter] Configurer [A] Auto-assigner [B] Lot [Del] Réinitialiser [Q] Quitter",
203
203
  };
package/src/i18n/hi.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ अधिक सामग्री के लिए स्क्रॉल करें ↓",
200
200
  readmeNotFound: "*(वर्तमान निर्देशिका में README.md नहीं मिला)*",
201
201
  bmadFooterNobmad: "[Tab] टैब बदलें [Q] बाहर",
202
- bmadFooterBmad: "[↑↓/jk] नेविगेट [Space] प्रीव्यू [Enter] कॉन्फ़िगर [A] ऑटो-असाइन [B] बल्क [X] रीसेट [Q] बाहर",
202
+ bmadFooterBmad: "[↑↓/jk] नेविगेट [Space] प्रीव्यू [Enter] कॉन्फ़िगर [A] ऑटो-असाइन [B] बल्क [Del] रीसेट [Q] बाहर",
203
203
  };
package/src/i18n/ja.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ スクロールして続きを見る ↓",
200
200
  readmeNotFound: "*(現在のディレクトリにREADME.mdが見つかりません)*",
201
201
  bmadFooterNobmad: "[Tab] タブ切替 [Q] 終了",
202
- bmadFooterBmad: "[↑↓/jk] 移動 [Space] プレビュー [Enter] 設定 [A] 自動割当 [B] 一括 [X] リセット [Q] 終了",
202
+ bmadFooterBmad: "[↑↓/jk] 移動 [Space] プレビュー [Enter] 設定 [A] 自動割当 [B] 一括 [Del] リセット [Q] 終了",
203
203
  };
package/src/i18n/ko.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ 아래로 스크롤하여 더 보기 ↓",
200
200
  readmeNotFound: "*(현재 디렉토리에서 README.md를 찾을 수 없습니다)*",
201
201
  bmadFooterNobmad: "[Tab] 탭 전환 [Q] 나가기",
202
- bmadFooterBmad: "[↑↓/jk] 탐색 [Space] 미리보기 [Enter] 설정 [A] 자동배정 [B] 일괄 [X] 초기화 [Q] 나가기",
202
+ bmadFooterBmad: "[↑↓/jk] 탐색 [Space] 미리보기 [Enter] 설정 [A] 자동배정 [B] 일괄 [Del] 초기화 [Q] 나가기",
203
203
  };
package/src/i18n/pt.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ Role para mais conteúdo ↓",
200
200
  readmeNotFound: "*(Nenhum README.md encontrado no diretório atual)*",
201
201
  bmadFooterNobmad: "[Tab] Mudar Aba [Q] Sair",
202
- bmadFooterBmad: "[↑↓/jk] Navegar [Space] Visualizar [Enter] Configurar [A] Auto-atribuir [B] Lote [X] Redefinir [Q] Sair",
202
+ bmadFooterBmad: "[↑↓/jk] Navegar [Space] Visualizar [Enter] Configurar [A] Auto-atribuir [B] Lote [Del] Redefinir [Q] Sair",
203
203
  };
package/src/i18n/zh-CN.js CHANGED
@@ -199,5 +199,5 @@ export default {
199
199
  readmeScrollMore: "↓ 向下滚动查看更多 ↓",
200
200
  readmeNotFound: "*(当前目录中未找到README.md)*",
201
201
  bmadFooterNobmad: "[Tab] 切换标签 [Q] 退出",
202
- bmadFooterBmad: "[↑↓/jk] 导航 [Space] 预览 [Enter] 配置 [A] 自动分配 [B] 批量 [X] 重置 [Q] 退出",
202
+ bmadFooterBmad: "[↑↓/jk] 导航 [Space] 预览 [Enter] 配置 [A] 自动分配 [B] 批量 [Del] 重置 [Q] 退出",
203
203
  };
package/src/installer.js CHANGED
@@ -5442,8 +5442,13 @@ async function updateCommandFiles(targetDir, spinner) {
5442
5442
  * These hooks contain bug fixes (e.g. markdown stripping) that must propagate
5443
5443
  * on every `npx agentvibes update` regardless of target directory.
5444
5444
  */
5445
- const CRITICAL_HOOKS = ['stop-tts.sh', 'stop.sh', 'play-tts.sh', 'play-tts-piper.sh', 'audio-processor.sh', 'session-start-tts.sh', 'bmad-party-speak.sh'];
5446
- const CRITICAL_HOOKS_WINDOWS = ['play-tts.ps1', 'play-tts-piper.ps1', 'audio-processor.ps1', 'session-start-tts.ps1', 'bmad-speak.ps1', 'bmad-party-speak.ps1', 'tts-watcher.ps1'];
5445
+ // agentvibes-session-id.{sh,ps1} are listed because play-tts.{sh,ps1} SHELL OUT to
5446
+ // them to expand a {{session}} pretext. They were absent here, so on a global
5447
+ // install the global-update path never shipped them and the self-ID silently
5448
+ // degraded to an empty string forever — the callers fail soft by design, so the
5449
+ // omission produced no error, just a feature that never worked.
5450
+ const CRITICAL_HOOKS = ['stop-tts.sh', 'stop.sh', 'play-tts.sh', 'play-tts-piper.sh', 'audio-processor.sh', 'session-start-tts.sh', 'bmad-party-speak.sh', 'agentvibes-session-id.sh'];
5451
+ const CRITICAL_HOOKS_WINDOWS = ['play-tts.ps1', 'play-tts-piper.ps1', 'audio-processor.ps1', 'session-start-tts.ps1', 'bmad-speak.ps1', 'bmad-party-speak.ps1', 'tts-watcher.ps1', 'agentvibes-session-id.ps1'];
5447
5452
 
5448
5453
  /**
5449
5454
  * Update critical hooks in the global ~/.claude/hooks/ directory if it exists.