agentvibes 5.14.0 → 5.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 20260719
1
+ 20260721
@@ -0,0 +1,83 @@
1
+ #
2
+ # File: .claude/hooks-windows/agentvibes-session-id.ps1
3
+ #
4
+ # AgentVibes - Text-to-Speech WITH personality for AI Assistants
5
+ # Website: https://agentvibes.org
6
+ # Repository: https://github.com/paulpreibisch/AgentVibes
7
+ #
8
+ # Licensed under the Apache License, Version 2.0
9
+ #
10
+ # @fileoverview Print a short spoken self-identifier for THIS session, e.g.
11
+ # "Claude on AgentVibes in Windows Terminal"
12
+ # @why With several agent sessions open at once, every one saying the same thing
13
+ # makes it impossible to tell which tab just spoke. Prefixing each session's
14
+ # speech with who+where answers "which one is that?" (community request).
15
+ # @usage agentvibes-session-id.ps1 <llm-key> <project-dir>
16
+ # Also used via the {{session}} token in a per-LLM PRETEXT (see play-tts.ps1).
17
+ #
18
+ # PowerShell mirror of .claude/hooks/agentvibes-session-id.sh — the LLM name map,
19
+ # the project-name fallback, and the terminal-detection ORDER must stay identical
20
+ # so a given session announces itself the same way on either runtime.
21
+ #
22
+ param(
23
+ [string]$LlmKey = "claude-code",
24
+ [string]$ProjectDir = ""
25
+ )
26
+
27
+ $ErrorActionPreference = "SilentlyContinue"
28
+
29
+ if (-not $ProjectDir) { $ProjectDir = (Get-Location).Path }
30
+
31
+ # LLM display name (same mapping as the bash version)
32
+ $LlmName = switch -Regex ($LlmKey) {
33
+ '^claude' { "Claude"; break }
34
+ '^gemini' { "Gemini"; break }
35
+ '^(codex|openai|gpt)' { "Codex"; break }
36
+ '^cursor' { "Cursor"; break }
37
+ '^default$' { "Agent"; break }
38
+ default { $LlmKey }
39
+ }
40
+
41
+ # Project name = leaf of the project dir. Split-Path is used rather than
42
+ # Get-Item so a path that no longer exists still yields a usable name.
43
+ $ProjName = ""
44
+ try { $ProjName = Split-Path -Leaf ($ProjectDir.TrimEnd('\', '/')) } catch { }
45
+ if (-not $ProjName -or $ProjName -in @('/', '.', '\')) { $ProjName = "this project" }
46
+
47
+ # Terminal detection — prefer the most specific signal available.
48
+ # Order mirrors the bash version exactly.
49
+ $TermName = ""
50
+ if ($env:WT_SESSION) {
51
+ $TermName = "Windows Terminal"
52
+ } elseif ($env:TERM_PROGRAM) {
53
+ $TermName = switch ($env:TERM_PROGRAM) {
54
+ "vscode" { "VS Code" }
55
+ "iTerm.app" { "iTerm" }
56
+ "Apple_Terminal" { "Terminal" }
57
+ # One entry only: PowerShell's switch is case-insensitive by default, so
58
+ # separate "ghostty"/"Ghostty" arms BOTH match and both emit, yielding
59
+ # "Ghostty Ghostty". Bash needs the two arms; PowerShell must not have them.
60
+ "ghostty" { "Ghostty" }
61
+ "WezTerm" { "WezTerm" }
62
+ "Hyper" { "Hyper" }
63
+ "tmux" { "tmux" }
64
+ "Tabby" { "Tabby" }
65
+ default { $env:TERM_PROGRAM }
66
+ }
67
+ } elseif ($env:TERMINAL_EMULATOR) {
68
+ $TermName = $env:TERMINAL_EMULATOR
69
+ } elseif ($env:KITTY_WINDOW_ID) {
70
+ $TermName = "kitty"
71
+ } elseif ($env:ALACRITTY_WINDOW_ID) {
72
+ $TermName = "Alacritty"
73
+ } elseif ($env:TMUX) {
74
+ $TermName = "tmux"
75
+ }
76
+
77
+ # -NoNewline so the caller can embed the result directly in a pretext, matching
78
+ # the bash version's printf (which also emits no trailing newline).
79
+ if ($TermName) {
80
+ Write-Host -NoNewline "$LlmName on $ProjName in $TermName"
81
+ } else {
82
+ Write-Host -NoNewline "$LlmName on $ProjName"
83
+ }
@@ -73,7 +73,8 @@ $HooksDir = "$ClaudeDir\hooks-windows"
73
73
  $ProviderFile = "$ClaudeDir\tts-provider.txt"
74
74
  $MuteFile = "$ClaudeDir\tts-muted.txt"
75
75
 
76
- # Check if TTS is muted
76
+ # Check if TTS is muted (receiver master switch — /agent-vibes:receiver off).
77
+ # Kept as an absolute pre-check so existing behaviour does not regress.
77
78
  if (Test-Path $MuteFile) {
78
79
  $muteStatus = Get-Content $MuteFile -Raw
79
80
  if ($muteStatus.Trim() -eq "true") {
@@ -81,6 +82,35 @@ if (Test-Path $MuteFile) {
81
82
  }
82
83
  }
83
84
 
85
+ # ---------------------------------------------------------------------------
86
+ # MUTE PRECEDENCE (parity with .claude/hooks/play-tts.sh:89-114).
87
+ #
88
+ # Until now this player read ONLY tts-muted.txt above, which /agent-vibes:mute
89
+ # never writes — so `/agent-vibes:mute` did not silence Windows audio at all,
90
+ # and the global `~/.agentvibes-muted` kill-switch was ignored on Windows.
91
+ # Same three levels as bash, in the same order:
92
+ # 1. project agentvibes-unmuted -> speak (overrides a global mute)
93
+ # 2. project agentvibes-muted -> silent
94
+ # 3. global ~/.agentvibes-muted -> silent
95
+ #
96
+ # The project markers live under the REAL project root (CLAUDE_PROJECT_DIR when
97
+ # set), not $ClaudeDir, which falls back to the user profile for global installs.
98
+ # $HOME is preferred over $env:USERPROFILE so markers written by bash under
99
+ # git-bash resolve to the same file here.
100
+ $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
101
+ $MuteScopeDir = if ($env:CLAUDE_PROJECT_DIR -and (Test-Path "$env:CLAUDE_PROJECT_DIR\.claude")) {
102
+ "$env:CLAUDE_PROJECT_DIR\.claude"
103
+ } else {
104
+ $ClaudeDir
105
+ }
106
+ if (Test-Path (Join-Path $MuteScopeDir "agentvibes-unmuted")) {
107
+ # explicit per-project enable — wins over the global kill-switch
108
+ } elseif (Test-Path (Join-Path $MuteScopeDir "agentvibes-muted")) {
109
+ exit 0
110
+ } elseif (Test-Path (Join-Path $HomeDir ".agentvibes-muted")) {
111
+ exit 0
112
+ }
113
+
84
114
  # Determine active provider
85
115
  $ActiveProvider = "windows-sapi"
86
116
  if (Test-Path $ProviderFile) {
@@ -453,6 +483,70 @@ elseif (-not $PlanOk -and $_LlmVoice -and -not $VoiceOverride) {
453
483
  $VoiceOverride = $_LlmVoice
454
484
  }
455
485
 
486
+ # --- Dynamic session self-ID ({{session}} token) ------------------------------
487
+ # Parity with play-tts.sh:250-296. A per-LLM PRETEXT containing {{session}}
488
+ # expands to "<LLM> on <Project> in <Terminal>" so multi-session users can tell
489
+ # which window just spoke. Announced ONCE per session; on later utterances the
490
+ # token is dropped rather than prefixing every line. Opt-in — this whole block
491
+ # is inert unless the configured pretext actually contains the token.
492
+ if ($_LlmPretext -like "*{{session}}*") {
493
+ # Session key. There is no true Claude-session id available to a hook, so
494
+ # this is best-effort: a terminal-session id when the emulator exports one
495
+ # (stable for that tab's life), else this process's parent PID. Worst case
496
+ # the self-ID repeats or is skipped — cosmetic, and only when opted in.
497
+ $_SidRaw = if ($env:WT_SESSION) { $env:WT_SESSION }
498
+ elseif ($env:TERM_SESSION_ID) { $env:TERM_SESSION_ID }
499
+ elseif ($env:TMUX) { $env:TMUX }
500
+ else {
501
+ try { (Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId } catch { $PID }
502
+ }
503
+ $_SidProj = ""
504
+ $_SidProjDirRaw = if ($env:CLAUDE_PROJECT_DIR) { $env:CLAUDE_PROJECT_DIR } else { $ClaudeDir }
505
+ try { $_SidProj = Split-Path -Leaf ($_SidProjDirRaw.TrimEnd('\', '/')) } catch { }
506
+ $_SidKey = (("$llm|$_SidProj|$_SidRaw") -replace '[^A-Za-z0-9]', '_')
507
+
508
+ # Announce-marker dir under the per-user temp path (already user-scoped on
509
+ # Windows: %LOCALAPPDATA%\Temp), so no cross-user marker collisions.
510
+ $_AnnDir = Join-Path ([System.IO.Path]::GetTempPath()) "agentvibes-session"
511
+ $_AnnOk = $false
512
+ try { $null = New-Item -ItemType Directory -Path $_AnnDir -Force -ErrorAction Stop; $_AnnOk = $true } catch { }
513
+ $_AnnFile = Join-Path $_AnnDir "announced-$_SidKey"
514
+
515
+ # First utterance -> replacement is the self-ID; later utterances -> empty.
516
+ $_SessionId = ""
517
+ if ($_AnnOk -and (Test-Path $_AnnFile)) {
518
+ $_SessionId = ""
519
+ } else {
520
+ $_SidScript = Join-Path $HooksDir "agentvibes-session-id.ps1"
521
+ if (Test-Path $_SidScript) {
522
+ $_SidProjDir = if ($env:CLAUDE_PROJECT_DIR) { $env:CLAUDE_PROJECT_DIR } else { $ClaudeDir }
523
+ try { $_SessionId = & $_SidScript -LlmKey $llm -ProjectDir $_SidProjDir } catch { $_SessionId = "" }
524
+ }
525
+ if ($_AnnOk) {
526
+ try {
527
+ if (-not (Test-Path $_AnnFile)) { $null = New-Item -ItemType File -Path $_AnnFile -ErrorAction Stop }
528
+ # Bound growth: drop announce-markers older than a day.
529
+ Get-ChildItem -Path $_AnnDir -Filter 'announced-*' -ErrorAction SilentlyContinue |
530
+ Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } |
531
+ Remove-Item -Force -ErrorAction SilentlyContinue
532
+ } catch { }
533
+ }
534
+ }
535
+
536
+ if ($_SessionId) {
537
+ $_LlmPretext = $_LlmPretext -replace [regex]::Escape('{{session}}'), $_SessionId
538
+ } else {
539
+ # Empty replacement: also swallow one adjacent ", " so "Hey {{session}}, ready"
540
+ # becomes "Hey ready", not "Hey , ready"; then drop any bare token.
541
+ $_LlmPretext = $_LlmPretext -replace [regex]::Escape('{{session}}, '), ''
542
+ $_LlmPretext = $_LlmPretext -replace [regex]::Escape(', {{session}}'), ''
543
+ $_LlmPretext = $_LlmPretext -replace [regex]::Escape('{{session}}'), ''
544
+ }
545
+ # Final tidy: collapse doubled/edge commas and runs of whitespace.
546
+ $_LlmPretext = $_LlmPretext -replace ',\s*,', ',' -replace '^\s*,\s*', '' -replace '\s*,\s*$', '' -replace '\s{2,}', ' '
547
+ $_LlmPretext = $_LlmPretext.Trim()
548
+ }
549
+
456
550
  # --- Apply LLM-specific pretext ----------------------------------------------
457
551
  # Prepend the configured pretext (e.g. "Agent Vibes Here") to the speech
458
552
  # text. Guard against double-prefixing on re-entrant or looped calls by
@@ -33,6 +33,42 @@ if ($env:CLAUDE_PROJECT_DIR -and (Test-Path "$env:CLAUDE_PROJECT_DIR\.claude"))
33
33
  $ProjectClaudeDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ScriptDir)) ".claude"
34
34
  }
35
35
 
36
+ # ---------------------------------------------------------------------------
37
+ # OPT-IN INJECTION GATE (Issue: global-install cacophony).
38
+ #
39
+ # Mirror of the gate in .claude/hooks/session-start-tts.sh — see the long
40
+ # rationale there. Previously this hook injected the ~250-token TTS protocol
41
+ # into EVERY Windows session where AgentVibes was found on disk, so a global
42
+ # install made every open session start speaking at once, and idle sessions
43
+ # still paid the token cost. Injection is now OPT-IN on Windows too.
44
+ #
45
+ # Precedence (identical to bash, so injection and audio agree):
46
+ # 1. project agentvibes-unmuted -> inject (explicit per-project ON)
47
+ # 2. project agentvibes-muted -> silent
48
+ # 3. global ~/.agentvibes-muted -> silent (the global kill-switch)
49
+ # 4. project agentvibes-enabled, OR global opt-in
50
+ # (~/.claude/agentvibes-enabled or ~/.claude/agentvibes-unmuted) -> inject
51
+ # 5. otherwise -> silent (opt-in default; zero tokens)
52
+ #
53
+ # $HOME is honoured ahead of $env:USERPROFILE so the markers resolve the same
54
+ # way under git-bash (where bash writes them) as under native PowerShell.
55
+ $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
56
+ if (Test-Path (Join-Path $ProjectClaudeDir "agentvibes-unmuted")) {
57
+ # explicit per-project enable — wins over a global mute, as in play-tts.sh
58
+ } elseif (Test-Path (Join-Path $ProjectClaudeDir "agentvibes-muted")) {
59
+ exit 0
60
+ } elseif (Test-Path (Join-Path $HomeDir ".agentvibes-muted")) {
61
+ exit 0 # global kill-switch, no per-project override
62
+ } elseif ((Test-Path (Join-Path $ProjectClaudeDir "agentvibes-enabled")) -or
63
+ (Test-Path (Join-Path $HomeDir ".claude\agentvibes-enabled")) -or
64
+ (Test-Path (Join-Path $HomeDir ".claude\agentvibes-unmuted"))) {
65
+ # opted in (project install marker, or a deliberate global opt-in)
66
+ } else {
67
+ # Not enabled for this project -> inject nothing (zero tokens).
68
+ # Enable for this project with: /agent-vibes:unmute
69
+ exit 0
70
+ }
71
+
36
72
  # Build the -ProjectDir flag to inject into TTS commands (empty string = omit flag).
37
73
  # Sanitize: strip any embedded quotes that would break PowerShell argument quoting.
38
74
  $ProjectDirFlag = ""
package/README.md CHANGED
@@ -58,7 +58,21 @@ New here? The [**Quick Start guide**](docs/quick-start.md) walks you through you
58
58
 
59
59
  ---
60
60
 
61
- ## 🆕 Reliable setup and complete audio previews (v5.14.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
68
+
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.
70
+ - **`/agent-vibes:mute` now works on Windows** — it previously had no effect there. Both project and global mute are honoured on every platform.
71
+ - **Sessions can introduce themselves on Windows** — `{{session}}` announces "Claude on my-app in Windows Terminal", once per session.
72
+ - **Self-introductions now reach global installs** — the script behind them was never delivered by the updater on any platform.
73
+ - **Note for global Windows installs:** sessions are off by default after this update — enable with `/agent-vibes:unmute`.
74
+
75
+ ### v5.14.0 — Reliable setup and complete audio previews
62
76
 
63
77
  Includes all changes from 5.13.2, which was not published to npm.
64
78
 
@@ -258,7 +272,7 @@ anything (no new network calls, no behavior change) for every current install.
258
272
 
259
273
  ## About
260
274
 
261
- **AgentVibes** · v5.14.0 · Licensed under [Apache-2.0](LICENSE)
275
+ **AgentVibes** · v5.15.1 · Licensed under [Apache-2.0](LICENSE)
262
276
 
263
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)
264
278
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,108 @@
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
+
35
+ ## v5.15.0 — Multi-session control on Windows
36
+
37
+ **Released:** 2026-07-20 · `npm install agentvibes@latest`
38
+
39
+ If you run several agent sessions at once, this release gives Windows the same
40
+ control macOS and Linux gained in 5.13.0: sessions stay quiet unless you turn
41
+ them on, and each one can tell you which window is speaking.
42
+
43
+ ### Sessions no longer all speak at once
44
+
45
+ Running AgentVibes across several projects previously meant every open session
46
+ announced itself, with no straightforward way to keep just one talking. On
47
+ macOS and Linux this was addressed in 5.13.0. **On Windows it was not** — the
48
+ Windows session hook enabled speech in every session regardless of your
49
+ settings.
50
+
51
+ Windows now follows the same rule: a session speaks only in a project you have
52
+ explicitly enabled. Projects you have not enabled contribute **nothing at all**
53
+ — not silent audio, but no added instructions and no token cost.
54
+
55
+ ### Mute now works on Windows
56
+
57
+ `/agent-vibes:mute` had no effect on Windows audio. It silenced macOS and Linux
58
+ only, because the Windows player read a different setting than the one the
59
+ command wrote.
60
+
61
+ Both the project mute and the global switch are now honoured on Windows:
62
+
63
+ - `/agent-vibes:mute` — quiet this project
64
+ - `/agent-vibes:unmute` — turn this project back on, even while everything else
65
+ is globally muted
66
+
67
+ This makes "off everywhere, on in the one project I am working in" a single
68
+ pair of commands on every platform.
69
+
70
+ ### Sessions can identify themselves on Windows
71
+
72
+ Include `{{session}}` in your pretext and a session introduces itself once:
73
+
74
+ ```
75
+ Claude on my-app in Windows Terminal
76
+ ```
77
+
78
+ It names the project and detects the terminal — Windows Terminal, VS Code,
79
+ Ghostty, iTerm, Terminal, WezTerm, tmux and others. Announced once per session
80
+ rather than before every line. This previously worked only on macOS and Linux.
81
+
82
+ ### A related fix for every platform
83
+
84
+ The script that produces that introduction was missing from the update process,
85
+ so on a global install it was never delivered and the feature silently did
86
+ nothing. It is now included on macOS, Linux and Windows alike.
87
+
88
+ ### Upgrading
89
+
90
+ Your existing choices are preserved — if you have already muted, unmuted, or
91
+ enabled a project, updating leaves that setting alone.
92
+
93
+ **One change worth noting for Windows users on a global install:** because
94
+ sessions are now off unless enabled, a global Windows install will be quiet
95
+ after updating. Turn on the projects you want with `/agent-vibes:unmute`.
96
+ Project installs are enabled automatically and are unaffected.
97
+
98
+ ### Quality
99
+
100
+ New regression tests assert that the session gate, the mute rules, and the
101
+ self-identification behave identically on both runtimes, so the platforms
102
+ cannot drift apart again without the build failing.
103
+
104
+ ---
105
+
3
106
  ## v5.14.0 — Reliable setup and complete audio previews
4
107
 
5
108
  **Released:** 2026-07-19 · `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.14.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
+ }