agentvibes 5.15.0 → 5.15.2

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.
@@ -1 +1 @@
1
- 20260720
1
+ 20260721
package/README.md CHANGED
@@ -58,7 +58,19 @@ New here? The [**Quick Start guide**](docs/quick-start.md) walks you through you
58
58
 
59
59
  ---
60
60
 
61
- ## 🆕 Multi-session control on Windows (v5.15.0)
61
+ ## 🆕 Fixes for the preview indicators (v5.15.2)
62
+
63
+ - **Fixed a TUI crash** — filtering/favoriting a track while a preview was playing could rebuild the list under the indicator and crash; it can't now.
64
+ - **Honest per-agent badge** — the voice picker shows **(remotely via SSH)** vs **(locally)** from your real routing, not a partial guess.
65
+ - **BMAD agents show their real names** (Mary, Winston…) even when a skill file lacks the `Name — Title` heading; plus small preview/onboarding polish.
66
+
67
+ ### v5.15.1 — Know where your preview plays
68
+
69
+ - **Every preview shows where it plays** — voice and music previews now display **(locally)** or **(remotely via SSH)** right on the row you're auditioning, so you're never guessing (or hearing silence on a headless box).
70
+ - **Preview standardized across the app** — the voice pickers (Kokoro, Piper, ElevenLabs, per-agent BMAD) and the Music page all show the same indicator; music previews now follow a project's remote receiver too.
71
+ - **Cleaner Agents tab** — it lists your real BMAD agents (not a skill's internal helpers) and re-checks itself on focus; **Reset** moved off `X` (which jumped to the Receiver tab) to **`Del`**.
72
+
73
+ ### v5.15.0 — Multi-session control on Windows
62
74
 
63
75
  - **Sessions stay quiet unless you enable them** — a session speaks only in a project you've turned on; others add no instructions and no token cost.
64
76
  - **`/agent-vibes:mute` now works on Windows** — it previously had no effect there. Both project and global mute are honoured on every platform.
@@ -266,7 +278,7 @@ anything (no new network calls, no behavior change) for every current install.
266
278
 
267
279
  ## About
268
280
 
269
- **AgentVibes** · v5.15.0 · Licensed under [Apache-2.0](LICENSE)
281
+ **AgentVibes** · v5.15.2 · Licensed under [Apache-2.0](LICENSE)
270
282
 
271
283
  Built by **Paul Preibisch** — [@997Fire on X](https://x.com/997Fire) · [agentvibes.org](https://agentvibes.org) · [github.com/paulpreibisch/AgentVibes](https://github.com/paulpreibisch/AgentVibes)
272
284
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## v5.15.2 — Fixes for the preview indicators
4
+
5
+ **Released:** 2026-07-22 · `npm install agentvibes@latest`
6
+
7
+ Follow-up fixes for the 5.15.1 preview work, from an adversarial code review:
8
+
9
+ - **Fixed a crash** — filtering or favoriting a track *while a preview was
10
+ playing* could rebuild the list out from under the indicator and take the whole
11
+ TUI down. It can't anymore.
12
+ - **The badge tells the truth** — the per-agent voice picker now shows
13
+ **(remotely via SSH)** vs **(locally)** based on your actual routing (provider
14
+ file or remote receiver), not a partial guess.
15
+ - **Your BMAD agents show their real names** — the Agents tab reads the persona
16
+ name (Mary, Winston, Amelia…) even on installs whose skill files don't use the
17
+ `Name — Title` heading format.
18
+ - **Smaller polish** — a remote voice-preview error no longer leaves a stuck
19
+ spinner, the onboarding "re-check" hint is translated in all languages, and the
20
+ onboarding screen keeps keyboard focus after a re-check.
21
+
22
+ ## v5.15.1 — Know where your preview plays
23
+
24
+ **Released:** 2026-07-21 · `npm install agentvibes@latest`
25
+
26
+ Here's the thing we kept running into: when you press Space to preview a voice
27
+ or a music track, there was no way to tell whether it was about to play *on your
28
+ own machine* or get sent *to your remote receiver*. On a headless box that meant
29
+ pressing preview and hearing… nothing — with no idea why.
30
+
31
+ So we fixed it. Every preview now tells you exactly where the sound is going —
32
+ **(locally)** or **(remotely via SSH)** — right on the row you're auditioning.
33
+ No more guessing.
34
+
35
+ And while we were in there, we **standardized preview across the whole app**.
36
+ The voice pickers (Kokoro, Piper, ElevenLabs, and per-agent BMAD voices) and the
37
+ Music page now all show the same `Previewing (locally / remotely via SSH)`
38
+ indicator, so it looks and behaves identically everywhere. It's a small thing
39
+ that turns out to be genuinely useful.
40
+
41
+ One practical upshot: if a project routes its TTS to a remote receiver, **music
42
+ previews now follow it too** — before, they quietly played on the local machine,
43
+ which is silence on a headless server.
44
+
45
+ ### Also in this release
46
+
47
+ - **The Agents tab shows your real BMAD agents** (Mary, Winston, Amelia, John,
48
+ Paige, Sally…) instead of a skill's internal helpers — and it re-checks itself
49
+ when you switch back to the tab, so a fresh BMAD install shows up without a
50
+ restart.
51
+ - **Reset no longer yanks you to the Receiver tab.** It was mapped to `X`, the
52
+ same key that globally jumps to Receiver; it's now `Del`.
53
+
3
54
  ## v5.15.0 — Multi-session control on Windows
4
55
 
5
56
  **Released:** 2026-07-20 · `npm install agentvibes@latest`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "5.15.0",
4
+ "version": "5.15.2",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
@@ -33,7 +33,7 @@ export const FOOTER_CONFIG = {
33
33
  },
34
34
  agents: {
35
35
  color: '#9c27b0',
36
- text: ` ${key('↑↓')} Navigate ${key('Enter')} Edit Agent ${key('Space')} Sample ${key('R')} Reset`,
36
+ text: ` ${key('↑↓')} Navigate ${key('Enter')} Edit Agent ${key('Space')} Sample ${key('Del')} Reset`,
37
37
  },
38
38
  receiver: {
39
39
  color: '#00897b',
@@ -15,11 +15,17 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import os from 'node:os';
17
17
  import { spawn } from 'node:child_process';
18
+ import { detectRemoteLlm } from './audio-env.js';
18
19
 
19
20
  // Transport providers route audio to a remote receiver; local MP3 playback is
20
21
  // silent on a headless/remote box, so track previews must be forwarded instead.
21
22
  export const REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
22
23
 
24
+ // Preview transport badge + row spinner live in the neutral preview-transport
25
+ // module (shared with the voice pickers). Re-exported here for the music
26
+ // surfaces that already import from music-preview.js.
27
+ export { transportBadge, createRowSpinner, previewRowContent, SPIN_FRAMES } from './preview-transport.js';
28
+
23
29
  /**
24
30
  * Resolve the active provider and the project dir it was read from, using the
25
31
  * same search order as the voice pickers (CLAUDE_PROJECT_DIR → cwd → package →
@@ -30,16 +36,26 @@ export const REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
30
36
  */
31
37
  export function resolveMusicProvider(packageRoot) {
32
38
  const dirs = [process.env.CLAUDE_PROJECT_DIR, process.cwd(), packageRoot, os.homedir()].filter(Boolean);
39
+
40
+ // A configured remote TTS transport (transport-config.json mode=remote) routes
41
+ // audio to a receiver, so a LOCAL music preview would be silent on this box —
42
+ // forward it too, matching the voice preview. This is a SEPARATE signal from
43
+ // the provider file: a project can carry tts-provider.txt=piper (local) while
44
+ // the global transport routes TTS to a remote receiver, and the two surfaces
45
+ // must agree or the picker plays to an inaudible local sink.
46
+ const transportRemote = !!detectRemoteLlm();
47
+
33
48
  for (const d of dirs) {
34
49
  const p = path.join(d, '.claude', 'tts-provider.txt');
35
50
  try {
36
51
  if (fs.existsSync(p)) {
37
52
  const provider = fs.readFileSync(p, 'utf8').trim();
38
- return { remote: REMOTE_PROVIDERS.includes(provider), projectDir: d };
53
+ return { remote: REMOTE_PROVIDERS.includes(provider) || transportRemote, projectDir: d };
39
54
  }
40
55
  } catch { /* next */ }
41
56
  }
42
- return { remote: false, projectDir: '' };
57
+ // No provider file found anywhere — still forward if a remote transport is set.
58
+ return { remote: transportRemote, projectDir: process.env.CLAUDE_PROJECT_DIR || process.cwd() };
43
59
  }
44
60
 
45
61
  /**
@@ -0,0 +1,147 @@
1
+ /**
2
+ * AgentVibes — Shared preview transport badge + row spinner.
3
+ *
4
+ * Every music & voice preview surface renders an identical "previewing" cue ON
5
+ * THE SELECTED ROW (not a separate bottom line), so the user always sees where a
6
+ * preview is going and can stop it:
7
+ *
8
+ * ⠹ Previewing (locally) (Space to stop) — green, plays here
9
+ * ⠹ Previewing (remotely via SSH) (Space to stop) — red, forwarded to receiver
10
+ *
11
+ * The item name is intentionally omitted — the animated row IS the selected item.
12
+ * Kept in one neutral module so music and voice pickers can't drift.
13
+ */
14
+
15
+ /** Braille spinner frames, shared by every preview surface. */
16
+ export const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
17
+
18
+ /**
19
+ * Color-coded transport badge shown right after "Previewing".
20
+ * @param {boolean} remote - true when forwarded to the SSH receiver
21
+ * @returns {string} blessed-tagged badge, e.g. "{red-fg}(remotely via SSH){/red-fg}"
22
+ */
23
+ export function transportBadge(remote) {
24
+ return remote
25
+ ? '{red-fg}(remotely via SSH){/red-fg}'
26
+ : '{green-fg}(locally){/green-fg}';
27
+ }
28
+
29
+ /**
30
+ * Right-pad a blessed-tagged string with spaces to a target VISIBLE width, so a
31
+ * shorter row fully overwrites a longer previous row (blessed's list.setItem does
32
+ * not clear leftover cells → the old tail ghosts through). Color tags don't count
33
+ * toward width. Over-padding is safe: a non-wrapping list clips the overflow.
34
+ * @param {string} tagged
35
+ * @param {number} width - target visible width (e.g. the list's outer width)
36
+ * @returns {string}
37
+ */
38
+ export function padTaggedTo(tagged, width) {
39
+ if (!width || width <= 0) return tagged;
40
+ const visible = tagged.replace(/\{[^}]*\}/g, '');
41
+ const pad = width - visible.length;
42
+ return pad > 0 ? tagged + ' '.repeat(pad) : tagged;
43
+ }
44
+
45
+ /**
46
+ * The full "previewing" row content (spinner frame + Previewing + badge + hint).
47
+ * @param {string} frameChar - one SPIN_FRAMES glyph
48
+ * @param {boolean} remote
49
+ * @param {string} [stopHint]
50
+ * @returns {string} blessed-tagged row content
51
+ */
52
+ export function previewRowContent(frameChar, remote, stopHint = 'Space to stop') {
53
+ return `{cyan-fg}${frameChar} Previewing {/cyan-fg}${transportBadge(remote)}{cyan-fg} (${stopHint}){/cyan-fg}`;
54
+ }
55
+
56
+ /**
57
+ * Attach an animated preview indicator to the SELECTED row of a blessed list.
58
+ * Generalizes the Kokoro picker's row spinner so every picker behaves identically.
59
+ *
60
+ * @param {object} list - blessed list (needs setItem(idx, str))
61
+ * @param {object} screen - blessed screen (needs render())
62
+ * @param {(idx:number)=>string} renderItem - returns a row's normal content (to restore on stop)
63
+ * @param {object} [opts]
64
+ * @param {()=>boolean} [opts.isClosed] - guard: true once the picker is torn down
65
+ * @param {()=>number} [opts.now] - clock (ms); injectable for tests
66
+ * @param {number} [opts.intervalMs] - frame interval (default 80)
67
+ * @param {number} [opts.minVisibleMs]- min on-screen window for fire-and-forget remote (default 1100)
68
+ * @returns {{ start:Function, stop:Function, stopWithFloor:Function, isActive:Function, activeIdx:Function }}
69
+ */
70
+ export function createRowSpinner(list, screen, renderItem, opts = {}) {
71
+ const isClosed = opts.isClosed ?? (() => false);
72
+ const now = opts.now ?? (() => Date.now());
73
+ const intervalMs = opts.intervalMs ?? 80;
74
+ const minVisibleMs = opts.minVisibleMs ?? 1100;
75
+ // isStatic: write the indicator ONCE (no animation). Used for lists with
76
+ // double-width emoji (the Music tab): repeated in-place mutation of emoji rows
77
+ // desyncs blessed's terminal output. One write + full-width space padding is
78
+ // the least-fragile option there. A frozen "♪" stands in for the spinner.
79
+ const isStatic = opts.static ?? false;
80
+
81
+ let timer = null;
82
+ let floor = null;
83
+ let frame = 0;
84
+ let idx = -1;
85
+ let remote = false;
86
+ let startTs = 0;
87
+
88
+ // Guard: blessed's List.setItem dereferences this.items[i] unchecked, so a
89
+ // stale idx (e.g. the list was rebuilt shorter by a filter while a preview was
90
+ // active) would throw an uncaught TypeError and kill the whole TUI. Only touch
91
+ // a row that still exists. list.items is an array on a real list; the test stub
92
+ // uses a plain object, so treat "not an array" as always-in-range.
93
+ function _inRange(i) {
94
+ if (i < 0) return false;
95
+ return Array.isArray(list.items) ? i < list.items.length : true;
96
+ }
97
+
98
+ function _paint() {
99
+ if (!_inRange(idx)) return;
100
+ // Pad to the widest reliable measure so the row fully overwrites the previous
101
+ // (longer) content. list.width may still be a percentage string pre-layout, so
102
+ // fall back to the screen width; over-padding is clipped by the non-wrapping list.
103
+ const lw = (typeof list.width === 'number' && list.width > 0) ? list.width : 0;
104
+ const sw = (screen && typeof screen.width === 'number' && screen.width > 0) ? screen.width : 0;
105
+ const w = Math.max(lw, sw, 80);
106
+ const spin = isStatic ? '♪' : SPIN_FRAMES[frame++ % SPIN_FRAMES.length];
107
+ list.setItem(idx, padTaggedTo(previewRowContent(spin, remote), w));
108
+ screen.render();
109
+ }
110
+
111
+ function stop() {
112
+ if (floor) { clearTimeout(floor); floor = null; }
113
+ if (timer) { clearInterval(timer); timer = null; }
114
+ if (_inRange(idx) && !isClosed()) {
115
+ list.setItem(idx, renderItem(idx));
116
+ screen.render();
117
+ }
118
+ idx = -1;
119
+ }
120
+
121
+ return {
122
+ start(rowIdx, isRemote) {
123
+ stop();
124
+ idx = rowIdx;
125
+ frame = 0;
126
+ remote = !!isRemote;
127
+ startTs = now();
128
+ _paint();
129
+ if (isStatic) return; // static indicator: written once, no animation loop
130
+ timer = setInterval(() => { if (isClosed()) { stop(); return; } _paint(); }, intervalMs);
131
+ // A UI spinner must never keep the process alive (blessed's stdin does that
132
+ // in the real TUI); unref so a leaked spinner can't hang node --test on exit.
133
+ if (timer.unref) timer.unref();
134
+ },
135
+ // Fire-and-forget remote sends exit in ms; keep the row visible ≥ minVisibleMs
136
+ // so the user still sees a "preview sent" cue, then restore and run `after`.
137
+ stopWithFloor(after) {
138
+ if (floor) { clearTimeout(floor); floor = null; }
139
+ const wait = Math.max(0, minVisibleMs - (now() - startTs));
140
+ floor = setTimeout(() => { floor = null; stop(); if (!isClosed() && after) after(); }, wait);
141
+ if (floor.unref) floor.unref();
142
+ },
143
+ stop,
144
+ isActive: () => idx >= 0,
145
+ activeIdx: () => idx,
146
+ };
147
+ }
@@ -20,6 +20,8 @@ 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';
24
+ import { resolveMusicProvider } from '../music-preview.js';
23
25
  import { voicesForProvider } from '../../services/provider-voice-catalog.js';
24
26
  import { destroyList } from '../widgets/destroy-list.js';
25
27
  import { BRAND_PINK } from '../brand-colors.js';
@@ -139,7 +141,7 @@ const COLORS = {
139
141
  linkFg: 'bright-cyan',
140
142
  };
141
143
 
142
- const _FOOTER_BMAD_EN = '[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [X] Reset [Q] Quit';
144
+ const _FOOTER_BMAD_EN = '[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [Del] Reset [Q] Quit';
143
145
  const _FOOTER_NOBMAD_EN = '[Tab] Switch Tab [Q] Quit';
144
146
 
145
147
  const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
@@ -332,6 +334,14 @@ ${_tl('bmadDesc')}
332
334
  if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
333
335
  });
334
336
 
337
+ // Manual re-check: after installing BMAD from a separate terminal, Enter
338
+ // re-scans without leaving the tab. (Switching tabs also re-scans via onFocus.)
339
+ // If BMAD now shows up, move focus to the roster (else keys go to the hidden box).
340
+ onboardingBox.key(['enter'], () => {
341
+ refreshDisplay();
342
+ if (_bmadDetected) { agentList.focus(); screen.render(); }
343
+ });
344
+
335
345
  // -------------------------------------------------------------------------
336
346
  // BMAD state — section header
337
347
 
@@ -421,7 +431,7 @@ ${_tl('bmadDesc')}
421
431
  left: 4,
422
432
  hidden: true,
423
433
  tags: true,
424
- content: '{#546e7a-fg}[Space] Preview [Enter] Configure [X] Reset [A] Auto-assign [B] Bulk Edit{/#546e7a-fg}',
434
+ content: '{#546e7a-fg}[Space] Preview [Enter] Configure [Del] Reset [A] Auto-assign [B] Bulk Edit{/#546e7a-fg}',
425
435
  style: { bg: COLORS.contentBg },
426
436
  });
427
437
 
@@ -468,7 +478,7 @@ ${_tl('bmadDesc')}
468
478
  return btn;
469
479
  }
470
480
 
471
- const resetBtn = _createBtn('[X] Reset', () => {
481
+ const resetBtn = _createBtn('[Del] Reset', () => {
472
482
  const agent = _agents[agentList.selected ?? 0];
473
483
  if (agent) {
474
484
  voiceStore.resetAgentProfile(agent.id);
@@ -1073,6 +1083,11 @@ ${_tl('bmadDesc')}
1073
1083
  content: '', style: { fg: 'bright-cyan', bg: COLORS.contentBg },
1074
1084
  });
1075
1085
 
1086
+ // Row spinner: "⠹ Previewing (locally|remotely via SSH) (Space to stop)" ON
1087
+ // the selected row — shared with every other picker. renderItem restores the
1088
+ // row on stop. vpPreviewLine is retained for parity but no longer shows preview.
1089
+ const _vpSpin = createRowSpinner(vpList, screen, (i) => _buildVoiceItems([_allVoices[i]])[0], { isClosed: () => _vpClosed });
1090
+
1076
1091
  blessed.text({
1077
1092
  parent: vpModal, bottom: 3, left: 2, right: 2, height: 1, tags: true,
1078
1093
  content: '{white-fg}[↑↓] Nav [PgUp/PgDn] Page [a-z] Jump{/white-fg}',
@@ -1119,27 +1134,32 @@ ${_tl('bmadDesc')}
1119
1134
  }
1120
1135
 
1121
1136
  function _previewVoice(voiceId) {
1122
- if (_previewVoiceId === voiceId) { _killVP(); vpPreviewLine.setContent(''); _refreshVP(); return; }
1137
+ if (_previewVoiceId === voiceId) { _killVP(); _vpSpin.stop(); return; }
1123
1138
  _killVP();
1124
1139
  if (_previewMinTimer) { clearTimeout(_previewMinTimer); _previewMinTimer = null; }
1125
1140
 
1126
1141
  const phrase = previewPhrase(voiceId);
1127
1142
  const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1143
+ // Badge must match where play-tts actually routes: remote if the provider
1144
+ // file is ssh-remote/agentvibes-receiver OR a transport-config entry is
1145
+ // mode=remote. resolveMusicProvider is the shared transport resolver (both
1146
+ // signals) — don't re-derive routing here (CLAUDE.md invariant #1).
1147
+ const _isRemote = resolveMusicProvider(path.dirname(_pkgClaudeDir)).remote;
1128
1148
 
1129
1149
  if (_isWin) {
1130
1150
  // Windows: route through play-tts.ps1 (same pattern as non-Windows bash route)
1131
1151
  const playTtsScript = _hookScript('hooks-windows', 'play-tts.ps1');
1132
1152
  if (!fs.existsSync(playTtsScript)) return;
1133
1153
  _previewVoiceId = voiceId;
1134
- if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}...{/bright-cyan-fg}`); _refreshVP(); }
1154
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, _isRemote); }
1135
1155
  _previewProc = spawn('powershell', [ // NOSONAR
1136
1156
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTtsScript, phrase, voiceId,
1137
1157
  ], { stdio: 'ignore', detached: false, windowsHide: true,
1138
1158
  env: { ..._spawnEnv, AGENTVIBES_VOICE_SOURCE: 'audition' } });
1139
1159
  _previewProc.on('exit', () => {
1140
- if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); } }
1160
+ if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null; _vpSpin.stop(); }
1141
1161
  });
1142
- _previewProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1162
+ _previewProc.on('error', () => { _previewProc = null; _previewVoiceId = null; _vpSpin.stop(); });
1143
1163
  return;
1144
1164
  }
1145
1165
 
@@ -1166,12 +1186,12 @@ ${_tl('bmadDesc')}
1166
1186
  cwd: _projectRoot,
1167
1187
  });
1168
1188
  _previewVoiceId = voiceId;
1169
- if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}...{/bright-cyan-fg}`); _refreshVP(); }
1189
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, _isRemote); }
1170
1190
 
1171
1191
  const _clearAfterMinDisplay = () => {
1172
1192
  if (_previewVoiceId === voiceId) {
1173
1193
  _previewVoiceId = null; _previewProc = null;
1174
- if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); }
1194
+ _vpSpin.stop();
1175
1195
  }
1176
1196
  _previewMinTimer = null;
1177
1197
  };
@@ -1878,7 +1898,12 @@ ${_tl('bmadDesc')}
1878
1898
  // -------------------------------------------------------------------------
1879
1899
  // Key bindings
1880
1900
 
1881
- agentList.key(['x', 'X'], () => {
1901
+ // Reset uses Delete/Backspace — NOT a letter. 'x'/'X' is the GLOBAL shortcut
1902
+ // for the Receiver tab (navigation.js KEY_TO_TAB), and screen-level keys fire
1903
+ // even while this list is focused, so binding Reset to X reset the agent AND
1904
+ // jumped to the Receiver tab. Delete has no global binding and doesn't clash
1905
+ // with type-to-jump.
1906
+ agentList.key(['delete', 'backspace'], () => {
1882
1907
  const agent = _agents[agentList.selected ?? 0];
1883
1908
  if (agent) {
1884
1909
  voiceStore.resetAgentProfile(agent.id);
@@ -2038,6 +2063,11 @@ ${_tl('bmadDesc')}
2038
2063
  },
2039
2064
 
2040
2065
  onFocus() {
2066
+ // Re-detect on focus so BMAD installed in another tab/terminal (or just
2067
+ // now, via the onboarding install command) appears without restarting the
2068
+ // TUI. Tab switching invokes onFocus, so returning to this tab IS the
2069
+ // rescan — onboarding ⇄ agent list flips automatically.
2070
+ refreshDisplay();
2041
2071
  if (_bmadDetected) {
2042
2072
  agentList.focus();
2043
2073
  } 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
  }
@@ -732,8 +765,26 @@ export function createMusicTab(screen, services) {
732
765
  });
733
766
  }
734
767
 
768
+ // Stop any in-flight preview (local player, remote receiver, and the row
769
+ // spinner). Used before a list rebuild so a stale spinner index can't crash
770
+ // blessed's setItem, and so a preview can't be left "playing" invisibly.
771
+ function _stopAnyPreview() {
772
+ if (_remotePlayingTrackId) {
773
+ const mp = resolveMusicProvider(_PKG_ROOT);
774
+ if (mp.remote) _sendMusicRemote(mp, { stop: true });
775
+ _remotePlayingTrackId = null;
776
+ }
777
+ _killPlayingProcess();
778
+ _playingTrackId = null;
779
+ _trackSpin.stop();
780
+ }
781
+
735
782
  function refreshDisplay() {
736
783
  _refreshing = true;
784
+ // A rebuild (setItems) invalidates the spinner's row index and wipes its
785
+ // indicator — stop any active preview first (guarded, so a normal refresh with
786
+ // no preview is a no-op). Prevents a stale-index crash and a stuck preview.
787
+ if (_trackSpin.isActive()) _stopAnyPreview();
737
788
  const savedIdx = trackList.selected ?? 0;
738
789
 
739
790
  _allTracks = _buildAllTracks();
@@ -819,6 +870,7 @@ export function createMusicTab(screen, services) {
819
870
  function _close() {
820
871
  _killPlayingProcess();
821
872
  _playingTrackId = null;
873
+ _trackSpin.stop();
822
874
  previewLine.setContent(_listFocused ? _hintText() : '');
823
875
  modal.destroy();
824
876
  trackList.focus();
@@ -942,8 +994,9 @@ export function createMusicTab(screen, services) {
942
994
  trackList.key(['space'], () => {
943
995
  const trackId = _getSelectedTrackId();
944
996
  if (trackId) {
997
+ // No refreshDisplay() here — it rebuilds every row via setItems and would
998
+ // clobber the preview spinner's row (the spinner now shows preview state).
945
999
  _playTrack(trackId);
946
- refreshDisplay();
947
1000
  }
948
1001
  });
949
1002
 
@@ -1013,6 +1066,10 @@ export function createMusicTab(screen, services) {
1013
1066
  let _tlBlink = { interval: null, on: false, sel: -1 };
1014
1067
  process.on('exit', () => { if (_tlBlink.interval) clearInterval(_tlBlink.interval); });
1015
1068
  function _tlTick() {
1069
+ // While a preview is animating a row, the preview spinner (80ms) owns the
1070
+ // display — don't fight it with the 500ms blink cursor (that interleaving
1071
+ // left ghost characters on the previewing row).
1072
+ if (_trackSpin.isActive()) return;
1016
1073
  _tlBlink.on = !_tlBlink.on;
1017
1074
  const items = trackList.items;
1018
1075
  const cur = trackList.selected ?? 0;
@@ -1134,6 +1191,7 @@ export function createMusicTab(screen, services) {
1134
1191
  // Stop any preview when leaving the tab
1135
1192
  _killPlayingProcess();
1136
1193
  _playingTrackId = null;
1194
+ _trackSpin.stop();
1137
1195
  previewLine.setContent('');
1138
1196
  box.hide();
1139
1197
  screen.render();
@@ -1148,6 +1206,7 @@ export function createMusicTab(screen, services) {
1148
1206
  // Stop preview when focus leaves Music tab
1149
1207
  _killPlayingProcess();
1150
1208
  _playingTrackId = null;
1209
+ _trackSpin.stop();
1151
1210
  },
1152
1211
 
1153
1212
  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,15 +4253,15 @@ 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
- rProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
4264
+ rProc.on('error', () => { _previewProc = null; _previewVoiceId = null; _vpSpin.stop(); });
4250
4265
  return;
4251
4266
  }
4252
4267
 
@@ -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
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Suche:",
199
199
  readmeScrollMore: "↓ Scrollen für mehr Inhalt ↓",
200
200
  readmeNotFound: "*(Keine README.md im aktuellen Verzeichnis gefunden)*",
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",
201
+ bmadFooterNobmad: "[Enter] Erneut prüfen [Tab] Tab wechseln [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",
203
- };
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
+ };
package/src/i18n/es.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Buscar:",
199
199
  readmeScrollMore: "↓ Desplázate para ver más ↓",
200
200
  readmeNotFound: "*(No se encontró README.md en el directorio actual)*",
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",
201
+ bmadFooterNobmad: "[Enter] Volver a comprobar [Tab] Cambiar Pestaña [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
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Recherche:",
199
199
  readmeScrollMore: "↓ Défiler pour plus de contenu ↓",
200
200
  readmeNotFound: "*(Aucun README.md trouvé dans le répertoire actuel)*",
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",
201
+ bmadFooterNobmad: "[Enter] Revérifier [Tab] Changer d'onglet [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
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "खोजें:",
199
199
  readmeScrollMore: "↓ अधिक सामग्री के लिए स्क्रॉल करें ↓",
200
200
  readmeNotFound: "*(वर्तमान निर्देशिका में README.md नहीं मिला)*",
201
- bmadFooterNobmad: "[Tab] टैब बदलें [Q] बाहर",
202
- bmadFooterBmad: "[↑↓/jk] नेविगेट [Space] प्रीव्यू [Enter] कॉन्फ़िगर [A] ऑटो-असाइन [B] बल्क [X] रीसेट [Q] बाहर",
201
+ bmadFooterNobmad: "[Enter] फिर से जाँचें [Tab] टैब बदलें [Q] बाहर",
202
+ bmadFooterBmad: "[↑↓/jk] नेविगेट [Space] प्रीव्यू [Enter] कॉन्फ़िगर [A] ऑटो-असाइन [B] बल्क [Del] रीसेट [Q] बाहर",
203
203
  };
package/src/i18n/ja.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "検索:",
199
199
  readmeScrollMore: "↓ スクロールして続きを見る ↓",
200
200
  readmeNotFound: "*(現在のディレクトリにREADME.mdが見つかりません)*",
201
- bmadFooterNobmad: "[Tab] タブ切替 [Q] 終了",
202
- bmadFooterBmad: "[↑↓/jk] 移動 [Space] プレビュー [Enter] 設定 [A] 自動割当 [B] 一括 [X] リセット [Q] 終了",
201
+ bmadFooterNobmad: "[Enter] 再確認 [Tab] タブ切替 [Q] 終了",
202
+ bmadFooterBmad: "[↑↓/jk] 移動 [Space] プレビュー [Enter] 設定 [A] 自動割当 [B] 一括 [Del] リセット [Q] 終了",
203
203
  };
package/src/i18n/ko.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "검색:",
199
199
  readmeScrollMore: "↓ 아래로 스크롤하여 더 보기 ↓",
200
200
  readmeNotFound: "*(현재 디렉토리에서 README.md를 찾을 수 없습니다)*",
201
- bmadFooterNobmad: "[Tab] 탭 전환 [Q] 나가기",
202
- bmadFooterBmad: "[↑↓/jk] 탐색 [Space] 미리보기 [Enter] 설정 [A] 자동배정 [B] 일괄 [X] 초기화 [Q] 나가기",
201
+ bmadFooterNobmad: "[Enter] 다시 확인 [Tab] 탭 전환 [Q] 나가기",
202
+ bmadFooterBmad: "[↑↓/jk] 탐색 [Space] 미리보기 [Enter] 설정 [A] 자동배정 [B] 일괄 [Del] 초기화 [Q] 나가기",
203
203
  };
package/src/i18n/pt.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Buscar:",
199
199
  readmeScrollMore: "↓ Role para mais conteúdo ↓",
200
200
  readmeNotFound: "*(Nenhum README.md encontrado no diretório atual)*",
201
- bmadFooterNobmad: "[Tab] Mudar Aba [Q] Sair",
202
- bmadFooterBmad: "[↑↓/jk] Navegar [Space] Visualizar [Enter] Configurar [A] Auto-atribuir [B] Lote [X] Redefinir [Q] Sair",
201
+ bmadFooterNobmad: "[Enter] Verificar novamente [Tab] Mudar Aba [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
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "搜索:",
199
199
  readmeScrollMore: "↓ 向下滚动查看更多 ↓",
200
200
  readmeNotFound: "*(当前目录中未找到README.md)*",
201
- bmadFooterNobmad: "[Tab] 切换标签 [Q] 退出",
202
- bmadFooterBmad: "[↑↓/jk] 导航 [Space] 预览 [Enter] 配置 [A] 自动分配 [B] 批量 [X] 重置 [Q] 退出",
201
+ bmadFooterNobmad: "[Enter] 重新检查 [Tab] 切换标签 [Q] 退出",
202
+ bmadFooterBmad: "[↑↓/jk] 导航 [Space] 预览 [Enter] 配置 [A] 自动分配 [B] 批量 [Del] 重置 [Q] 退出",
203
203
  };
@@ -192,9 +192,76 @@ function _parseCSVLine(line) {
192
192
  // ---------------------------------------------------------------------------
193
193
  // BMAD agent scanner (story 11.5) — fallback when manifest is unavailable
194
194
 
195
+ /** Title-case a hyphenated id: "tech-writer" → "Tech Writer". */
196
+ function _titleCaseId(id) {
197
+ return id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
198
+ }
199
+
200
+ /**
201
+ * Find v6.6+ persona-agent skills: top-level skill directories named
202
+ * `bmad-agent-<role>` under <root>/.claude/skills (e.g. bmad-agent-analyst →
203
+ * "analyst"). Deliberately IGNORES a skill's private `agents/` subfolder (e.g.
204
+ * bmad-prfaq/agents/) — those hold a skill's internal helper sub-agents, not
205
+ * BMAD persona agents. Matching them produced a bogus "2 agents" roster in the
206
+ * Agents tab when no real BMAD roster was installed.
207
+ *
208
+ * @param {string} root
209
+ * @returns {{ id: string, dir: string }[]}
210
+ */
211
+ function _findBmadAgentSkills(root) {
212
+ const skillsDir = path.resolve(root, '.claude', 'skills');
213
+ const out = [];
214
+ if (!fs.existsSync(skillsDir)) return out;
215
+ try {
216
+ for (const skill of fs.readdirSync(skillsDir)) {
217
+ if (!skill.startsWith('bmad-agent-')) continue;
218
+ const id = skill.slice('bmad-agent-'.length);
219
+ if (!_isValidAgentId(id)) continue;
220
+ out.push({ id, dir: path.resolve(skillsDir, skill) });
221
+ }
222
+ } catch { /* skip */ }
223
+ return out;
224
+ }
225
+
226
+ /**
227
+ * Build an agent record from a `bmad-agent-*` skill dir, reading the SKILL.md
228
+ * heading ("# Mary — Business Analyst") for the persona name + title. Falls back
229
+ * to a title-cased id when the heading is missing/unreadable.
230
+ */
231
+ // Generic section words that a bare "# Word" heading might be (not a persona).
232
+ const _GENERIC_HEADING = /^(overview|about|usage|conventions|activation|role|purpose|description|instructions)$/i;
233
+
234
+ function _agentFromSkill(id, skillDir) {
235
+ let displayName = _titleCaseId(id);
236
+ let title = '';
237
+ try {
238
+ const md = fs.readFileSync(path.resolve(skillDir, 'SKILL.md'), 'utf8');
239
+ // 1) Preferred: "# Mary — Business Analyst" (name <dash> title).
240
+ const dash = md.match(/^#\s+([^\n]+?)\s+[—–-]\s+([^\n]+?)\s*$/m); // em / en / hyphen
241
+ if (dash) {
242
+ displayName = dash[1].trim() || displayName;
243
+ title = dash[2].trim();
244
+ } else {
245
+ // 2) Frontmatter description often names the persona: "...talk to Mary...".
246
+ const desc = md.match(/^description:\s*(.+)$/mi);
247
+ const talk = desc && desc[1].match(/talk to ([A-Z][\w'’-]+)/);
248
+ if (talk) {
249
+ displayName = talk[1];
250
+ } else {
251
+ // 3) A bare "# Mary" heading — but skip generic section headings so a
252
+ // skill without a persona heading (e.g. "# Overview") falls back to the id.
253
+ const h1 = md.match(/^#\s+([A-Z][\w'’-]*)\s*$/m);
254
+ if (h1 && !_GENERIC_HEADING.test(h1[1])) displayName = h1[1];
255
+ }
256
+ }
257
+ } catch { /* use derived displayName */ }
258
+ return { id, displayName, title, icon: '', module: 'bmm' };
259
+ }
260
+
195
261
  /**
196
262
  * Scan for BMAD agents in the project root.
197
- * Prefers manifest-based discovery; falls back to directory scan.
263
+ * Prefers manifest-based discovery; falls back to legacy dir scan, then to the
264
+ * v6.6+ skills-only layout (`bmad-agent-*` skills).
198
265
  *
199
266
  * @param {string} projectRoot
200
267
  * @returns {{ id: string, displayName: string, title: string, icon: string, module: string }[]}
@@ -207,50 +274,42 @@ export function scanBmadAgents(projectRoot) {
207
274
  // Fallback: directory scan — check project-local then home dir
208
275
  const safeRoot = path.resolve(projectRoot ?? process.cwd());
209
276
  const homeDir2 = os.homedir();
277
+ const roots = safeRoot !== homeDir2 ? [safeRoot, homeDir2] : [safeRoot];
210
278
 
211
- // v6.6+: agents under .claude/skills/*/agents/ collect all such dirs
212
- const skillsAgentDirs = [];
213
- for (const root of (safeRoot !== homeDir2 ? [safeRoot, homeDir2] : [safeRoot])) {
214
- const skillsDir = path.resolve(root, '.claude', 'skills');
215
- if (fs.existsSync(skillsDir)) {
216
- try {
217
- for (const skill of fs.readdirSync(skillsDir)) {
218
- const agentsDir = path.resolve(skillsDir, skill, 'agents');
219
- if (fs.existsSync(agentsDir)) skillsAgentDirs.push(agentsDir);
220
- }
221
- } catch { /* skip */ }
222
- }
279
+ // Legacy layouts: <root>/_bmad/bmm/agents and <root>/.bmad/agents hold one
280
+ // .md per agent.
281
+ const candidateDirs = [];
282
+ for (const root of roots) {
283
+ candidateDirs.push(path.resolve(root, '_bmad', 'bmm', 'agents'));
284
+ candidateDirs.push(path.resolve(root, '.bmad', 'agents'));
223
285
  }
224
-
225
- const candidateDirs = [
226
- path.resolve(safeRoot, '_bmad', 'bmm', 'agents'),
227
- path.resolve(safeRoot, '.bmad', 'agents'),
228
- ...(safeRoot !== homeDir2 ? [
229
- path.resolve(homeDir2, '_bmad', 'bmm', 'agents'),
230
- path.resolve(homeDir2, '.bmad', 'agents'),
231
- ] : []),
232
- ...skillsAgentDirs,
233
- ];
234
-
235
286
  for (const dir of candidateDirs) {
236
287
  if (!fs.existsSync(dir)) continue;
237
288
  try {
238
- const files = fs.readdirSync(dir);
239
- return files
289
+ const agents = fs.readdirSync(dir)
240
290
  .filter(f => f.endsWith('.md') && !f.includes('.backup') && !f.includes('.bak'))
241
291
  .map(f => {
242
292
  const id = f.replace(/\.md$/, '');
243
- const displayName = id
244
- .split('-')
245
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
246
- .join(' ');
247
- return { id, displayName, title: '', icon: '', module: 'bmm' };
248
- })
249
- .sort((a, b) => a.id.localeCompare(b.id));
293
+ return { id, displayName: _titleCaseId(id), title: '', icon: '', module: 'bmm' };
294
+ });
295
+ if (agents.length > 0) return agents.sort((a, b) => a.id.localeCompare(b.id));
250
296
  } catch {
251
297
  // Directory not readable — skip
252
298
  }
253
299
  }
300
+
301
+ // v6.6+ skills-only install: persona agents are `bmad-agent-<role>` skills.
302
+ const seen = new Set();
303
+ const skillAgents = [];
304
+ for (const root of roots) {
305
+ for (const { id, dir } of _findBmadAgentSkills(root)) {
306
+ if (seen.has(id)) continue;
307
+ seen.add(id);
308
+ skillAgents.push(_agentFromSkill(id, dir));
309
+ }
310
+ }
311
+ if (skillAgents.length > 0) return skillAgents.sort((a, b) => a.id.localeCompare(b.id));
312
+
254
313
  return [];
255
314
  }
256
315
 
@@ -277,14 +336,10 @@ export function isBmadDetected(projectRoot) {
277
336
  ];
278
337
  if (dirs.some(d => fs.existsSync(d))) return true;
279
338
 
280
- // v6.6+: agents live under .claude/skills/*/agents/
281
- const skillsDir = path.resolve(root, '.claude', 'skills');
282
- if (fs.existsSync(skillsDir)) {
283
- try {
284
- const skills = fs.readdirSync(skillsDir);
285
- if (skills.some(s => fs.existsSync(path.resolve(skillsDir, s, 'agents')))) return true;
286
- } catch { /* skip */ }
287
- }
339
+ // v6.6+ skills-only install: persona agents are `bmad-agent-<role>` skills.
340
+ // (NOT `<skill>/agents/` subfolders — those are a skill's private helper
341
+ // sub-agents and must not count as a BMAD install.)
342
+ if (_findBmadAgentSkills(root).length > 0) return true;
288
343
  }
289
344
 
290
345
  return false;