agentvibes 5.15.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.
@@ -1 +1 @@
1
- 20260720
1
+ 20260721
package/README.md CHANGED
@@ -58,7 +58,13 @@ 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
+ ## 🆕 Know where your preview plays (v5.15.1)
62
+
63
+ - **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).
64
+ - **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.
65
+ - **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`**.
66
+
67
+ ### v5.15.0 — Multi-session control on Windows
62
68
 
63
69
  - **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
70
  - **`/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 +272,7 @@ anything (no new network calls, no behavior change) for every current install.
266
272
 
267
273
  ## About
268
274
 
269
- **AgentVibes** · v5.15.0 · Licensed under [Apache-2.0](LICENSE)
275
+ **AgentVibes** · v5.15.1 · Licensed under [Apache-2.0](LICENSE)
270
276
 
271
277
  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
278
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## v5.15.1 — Know where your preview plays
4
+
5
+ **Released:** 2026-07-21 · `npm install agentvibes@latest`
6
+
7
+ Here's the thing we kept running into: when you press Space to preview a voice
8
+ or a music track, there was no way to tell whether it was about to play *on your
9
+ own machine* or get sent *to your remote receiver*. On a headless box that meant
10
+ pressing preview and hearing… nothing — with no idea why.
11
+
12
+ So we fixed it. Every preview now tells you exactly where the sound is going —
13
+ **(locally)** or **(remotely via SSH)** — right on the row you're auditioning.
14
+ No more guessing.
15
+
16
+ And while we were in there, we **standardized preview across the whole app**.
17
+ The voice pickers (Kokoro, Piper, ElevenLabs, and per-agent BMAD voices) and the
18
+ Music page now all show the same `Previewing (locally / remotely via SSH)`
19
+ indicator, so it looks and behaves identically everywhere. It's a small thing
20
+ that turns out to be genuinely useful.
21
+
22
+ One practical upshot: if a project routes its TTS to a remote receiver, **music
23
+ previews now follow it too** — before, they quietly played on the local machine,
24
+ which is silence on a headless server.
25
+
26
+ ### Also in this release
27
+
28
+ - **The Agents tab shows your real BMAD agents** (Mary, Winston, Amelia, John,
29
+ Paige, Sally…) instead of a skill's internal helpers — and it re-checks itself
30
+ when you switch back to the tab, so a fresh BMAD install shows up without a
31
+ restart.
32
+ - **Reset no longer yanks you to the Receiver tab.** It was mapped to `X`, the
33
+ same key that globally jumps to Receiver; it's now `Del`.
34
+
3
35
  ## v5.15.0 — Multi-session control on Windows
4
36
 
5
37
  **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.1",
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,174 @@
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
+ * Preview indicator APPENDED after a row's existing label (keepLabel mode).
58
+ * Used where replacing the label would be jarring — e.g. the Music tab, whose
59
+ * rows carry emoji: keeping the name in place avoids a perceived "jump", and
60
+ * only the trailing spinner char changes per frame so blessed never re-diffs the
61
+ * (wide) emoji cell.
62
+ * @param {string} baseRow - the row's normal content (with its label/emoji)
63
+ * @param {string} frameChar
64
+ * @param {boolean} remote
65
+ * @param {string} [stopHint]
66
+ * @returns {string}
67
+ */
68
+ export function previewRowAppend(baseRow, frameChar, remote, stopHint = 'Space to stop') {
69
+ return `${baseRow} {cyan-fg}${frameChar}{/cyan-fg} ${transportBadge(remote)}{cyan-fg} (${stopHint}){/cyan-fg}`;
70
+ }
71
+
72
+ /**
73
+ * Attach an animated preview indicator to the SELECTED row of a blessed list.
74
+ * Generalizes the Kokoro picker's row spinner so every picker behaves identically.
75
+ *
76
+ * @param {object} list - blessed list (needs setItem(idx, str))
77
+ * @param {object} screen - blessed screen (needs render())
78
+ * @param {(idx:number)=>string} renderItem - returns a row's normal content (to restore on stop)
79
+ * @param {object} [opts]
80
+ * @param {()=>boolean} [opts.isClosed] - guard: true once the picker is torn down
81
+ * @param {()=>number} [opts.now] - clock (ms); injectable for tests
82
+ * @param {number} [opts.intervalMs] - frame interval (default 80)
83
+ * @param {number} [opts.minVisibleMs]- min on-screen window for fire-and-forget remote (default 1100)
84
+ * @returns {{ start:Function, stop:Function, stopWithFloor:Function, isActive:Function, activeIdx:Function }}
85
+ */
86
+ export function createRowSpinner(list, screen, renderItem, opts = {}) {
87
+ const isClosed = opts.isClosed ?? (() => false);
88
+ const now = opts.now ?? (() => Date.now());
89
+ const intervalMs = opts.intervalMs ?? 80;
90
+ const minVisibleMs = opts.minVisibleMs ?? 1100;
91
+ // fullRedraw: force a complete repaint (realloc) instead of a diff render.
92
+ // Needed for lists containing double-width emoji (the Music tab): blessed's
93
+ // diff render desyncs the terminal cursor around wide chars during rapid
94
+ // in-place updates, corrupting rows — even though the internal buffer is
95
+ // correct. A realloc rewrites the terminal from that correct buffer.
96
+ const fullRedraw = opts.fullRedraw ?? false;
97
+ // keepLabel: append the indicator after the row's existing label instead of
98
+ // replacing it (avoids a perceived "jump" on emoji rows — see previewRowAppend).
99
+ const keepLabel = opts.keepLabel ?? false;
100
+ // isStatic: write the indicator ONCE (no animation). Needed for lists with
101
+ // double-width emoji (the Music tab): repeated in-place mutation of emoji rows
102
+ // desyncs blessed's terminal output. One write + full-width space padding is
103
+ // the least-fragile option there. A frozen "♪" stands in for the spinner.
104
+ const isStatic = opts.static ?? false;
105
+
106
+ let timer = null;
107
+ let floor = null;
108
+ let frame = 0;
109
+ let idx = -1;
110
+ let remote = false;
111
+ let startTs = 0;
112
+
113
+ // full=true forces a complete repaint (realloc) — used ONCE on start and stop
114
+ // to fix the wide-char cursor desync from the emoji-row→spinner transition (and
115
+ // stale decorations). Per-frame renders are plain diffs: the spinner row has no
116
+ // wide chars, so animating it is clean AND doesn't shimmer the whole screen.
117
+ function _render(full) {
118
+ if (full && fullRedraw && typeof screen.realloc === 'function') { try { screen.realloc(); } catch { /* ignore */ } }
119
+ screen.render();
120
+ }
121
+
122
+ function _paint(full) {
123
+ if (idx < 0) return;
124
+ // Pad to the widest reliable measure so the row fully overwrites the previous
125
+ // (longer) content. list.width may still be a percentage string pre-layout, so
126
+ // fall back to the screen width; over-padding is clipped by the non-wrapping list.
127
+ const lw = (typeof list.width === 'number' && list.width > 0) ? list.width : 0;
128
+ const sw = (screen && typeof screen.width === 'number' && screen.width > 0) ? screen.width : 0;
129
+ const w = Math.max(lw, sw, 80);
130
+ const spin = isStatic ? '♪' : SPIN_FRAMES[frame++ % SPIN_FRAMES.length];
131
+ const content = keepLabel
132
+ ? previewRowAppend(renderItem(idx), spin, remote)
133
+ : previewRowContent(spin, remote);
134
+ list.setItem(idx, padTaggedTo(content, w));
135
+ _render(full);
136
+ }
137
+
138
+ function stop() {
139
+ if (floor) { clearTimeout(floor); floor = null; }
140
+ if (timer) { clearInterval(timer); timer = null; }
141
+ if (idx >= 0 && !isClosed()) {
142
+ list.setItem(idx, renderItem(idx));
143
+ _render(true); // one full repaint to clear the wide-char desync on restore
144
+ }
145
+ idx = -1;
146
+ }
147
+
148
+ return {
149
+ start(rowIdx, isRemote) {
150
+ stop();
151
+ idx = rowIdx;
152
+ frame = 0;
153
+ remote = !!isRemote;
154
+ startTs = now();
155
+ _paint(fullRedraw); // one repaint (realloc only if fullRedraw) fixes the transition
156
+ if (isStatic) return; // static indicator: written once, no animation loop
157
+ timer = setInterval(() => { if (isClosed()) { stop(); return; } _paint(false); }, intervalMs);
158
+ // A UI spinner must never keep the process alive (blessed's stdin does that
159
+ // in the real TUI); unref so a leaked spinner can't hang node --test on exit.
160
+ if (timer.unref) timer.unref();
161
+ },
162
+ // Fire-and-forget remote sends exit in ms; keep the row visible ≥ minVisibleMs
163
+ // so the user still sees a "preview sent" cue, then restore and run `after`.
164
+ stopWithFloor(after) {
165
+ if (floor) { clearTimeout(floor); floor = null; }
166
+ const wait = Math.max(0, minVisibleMs - (now() - startTs));
167
+ floor = setTimeout(() => { floor = null; stop(); if (!isClosed() && after) after(); }, wait);
168
+ if (floor.unref) floor.unref();
169
+ },
170
+ stop,
171
+ isActive: () => idx >= 0,
172
+ activeIdx: () => idx,
173
+ };
174
+ }
@@ -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
  };
@@ -192,9 +192,63 @@ 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
+ function _agentFromSkill(id, skillDir) {
232
+ let displayName = _titleCaseId(id);
233
+ let title = '';
234
+ try {
235
+ const md = fs.readFileSync(path.resolve(skillDir, 'SKILL.md'), 'utf8');
236
+ // Persona heading format: "# Mary — Business Analyst" (name <dash> title).
237
+ // Require the spaced dash so a generic section heading like "# Overview" is
238
+ // skipped and we fall back to the title-cased id instead of mislabeling.
239
+ const m = md.match(/^#\s+([^\n]+?)\s+[—–-]\s+([^\n]+?)\s*$/m); // em / en / hyphen
240
+ if (m) {
241
+ displayName = m[1].trim() || displayName;
242
+ title = m[2].trim();
243
+ }
244
+ } catch { /* use derived displayName */ }
245
+ return { id, displayName, title, icon: '', module: 'bmm' };
246
+ }
247
+
195
248
  /**
196
249
  * Scan for BMAD agents in the project root.
197
- * Prefers manifest-based discovery; falls back to directory scan.
250
+ * Prefers manifest-based discovery; falls back to legacy dir scan, then to the
251
+ * v6.6+ skills-only layout (`bmad-agent-*` skills).
198
252
  *
199
253
  * @param {string} projectRoot
200
254
  * @returns {{ id: string, displayName: string, title: string, icon: string, module: string }[]}
@@ -207,50 +261,42 @@ export function scanBmadAgents(projectRoot) {
207
261
  // Fallback: directory scan — check project-local then home dir
208
262
  const safeRoot = path.resolve(projectRoot ?? process.cwd());
209
263
  const homeDir2 = os.homedir();
264
+ const roots = safeRoot !== homeDir2 ? [safeRoot, homeDir2] : [safeRoot];
210
265
 
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
- }
266
+ // Legacy layouts: <root>/_bmad/bmm/agents and <root>/.bmad/agents hold one
267
+ // .md per agent.
268
+ const candidateDirs = [];
269
+ for (const root of roots) {
270
+ candidateDirs.push(path.resolve(root, '_bmad', 'bmm', 'agents'));
271
+ candidateDirs.push(path.resolve(root, '.bmad', 'agents'));
223
272
  }
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
273
  for (const dir of candidateDirs) {
236
274
  if (!fs.existsSync(dir)) continue;
237
275
  try {
238
- const files = fs.readdirSync(dir);
239
- return files
276
+ const agents = fs.readdirSync(dir)
240
277
  .filter(f => f.endsWith('.md') && !f.includes('.backup') && !f.includes('.bak'))
241
278
  .map(f => {
242
279
  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));
280
+ return { id, displayName: _titleCaseId(id), title: '', icon: '', module: 'bmm' };
281
+ });
282
+ if (agents.length > 0) return agents.sort((a, b) => a.id.localeCompare(b.id));
250
283
  } catch {
251
284
  // Directory not readable — skip
252
285
  }
253
286
  }
287
+
288
+ // v6.6+ skills-only install: persona agents are `bmad-agent-<role>` skills.
289
+ const seen = new Set();
290
+ const skillAgents = [];
291
+ for (const root of roots) {
292
+ for (const { id, dir } of _findBmadAgentSkills(root)) {
293
+ if (seen.has(id)) continue;
294
+ seen.add(id);
295
+ skillAgents.push(_agentFromSkill(id, dir));
296
+ }
297
+ }
298
+ if (skillAgents.length > 0) return skillAgents.sort((a, b) => a.id.localeCompare(b.id));
299
+
254
300
  return [];
255
301
  }
256
302
 
@@ -277,14 +323,10 @@ export function isBmadDetected(projectRoot) {
277
323
  ];
278
324
  if (dirs.some(d => fs.existsSync(d))) return true;
279
325
 
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
- }
326
+ // v6.6+ skills-only install: persona agents are `bmad-agent-<role>` skills.
327
+ // (NOT `<skill>/agents/` subfolders — those are a skill's private helper
328
+ // sub-agents and must not count as a BMAD install.)
329
+ if (_findBmadAgentSkills(root).length > 0) return true;
288
330
  }
289
331
 
290
332
  return false;