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.
- package/.claude/github-star-reminder.txt +1 -1
- package/README.md +14 -2
- package/RELEASE_NOTES.md +51 -0
- package/package.json +1 -1
- package/src/console/footer-config.js +1 -1
- package/src/console/music-preview.js +18 -2
- package/src/console/preview-transport.js +147 -0
- package/src/console/tabs/agents-tab.js +40 -10
- package/src/console/tabs/music-tab.js +70 -11
- package/src/console/tabs/setup-tab.js +37 -21
- package/src/console/widgets/track-picker.js +19 -10
- package/src/i18n/de.js +2 -2
- package/src/i18n/en.js +3 -3
- package/src/i18n/es.js +2 -2
- package/src/i18n/fr.js +2 -2
- package/src/i18n/hi.js +2 -2
- package/src/i18n/ja.js +2 -2
- package/src/i18n/ko.js +2 -2
- package/src/i18n/pt.js +2 -2
- package/src/i18n/zh-CN.js +2 -2
- package/src/services/agent-voice-store.js +96 -41
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
-
## 🆕
|
|
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.
|
|
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.
|
|
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('
|
|
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
|
-
|
|
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 [
|
|
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 [
|
|
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('[
|
|
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();
|
|
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) {
|
|
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;
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2927
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
|
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) {
|
|
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
|
-
//
|
|
4246
|
-
|
|
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) {
|
|
4283
|
-
nProc.on('exit', () => { if (_previewVoiceId === voiceId) { _previewVoiceId = null; _previewProc = null;
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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] बल्क [
|
|
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] 一括 [
|
|
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] 일괄 [
|
|
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 [
|
|
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] 批量 [
|
|
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
|
|
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
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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;
|