agentvibes 5.0.0 → 5.1.0

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.
@@ -51,4 +51,4 @@ _party_mode|compand 0.3,1 6:-70,-60,-20|agent_vibes_dark_chill_step_loop.mp3|0.4
51
51
  # Default (no agent specified) - clean with Bachata background|||
52
52
  default||agent_vibes_chillwave_v2_loop.mp3|0.15
53
53
  analyst|reverb 70 50 100|agentvibes_soft_flamenco_loop.mp3|0.30
54
- llm:claude-code|light|agent_vibes_chillwave_v2_loop.mp3|0.15|en_US-lessac-high|Codex Worktree here
54
+ llm:claude-code|light|agent_vibes_chillwave_v2_loop.mp3|0.15|en_US-lessac-high|Claude Code here|piper
@@ -131,20 +131,23 @@ TEXT="${TEXT//\\./.}" # Remove \. (keep the period)
131
131
  _LLM_VOICE=""
132
132
  _LLM_PRETEXT=""
133
133
  _LLM_REVERB=""
134
+ _LLM_ENGINE=""
134
135
  if [[ -n "$LLM_PROVIDER" ]]; then
135
136
  _llm_key="llm:${LLM_PROVIDER}"
136
137
  for _cfg in \
137
138
  "$PROJECT_ROOT/.claude/config/audio-effects.cfg" \
138
139
  "$HOME/.claude/config/audio-effects.cfg"; do
139
140
  if [[ -z "$_LLM_VOICE" && -z "$_LLM_PRETEXT" && -f "$_cfg" ]]; then
140
- while IFS='|' read -r _key _reverb _bgfile _bgvol _voice _pretext _rest; do
141
+ while IFS='|' read -r _key _reverb _bgfile _bgvol _voice _pretext _engine _rest; do
141
142
  if [[ "$_key" == "$_llm_key" ]]; then
142
143
  _reverb="${_reverb## }"; _reverb="${_reverb%% }"
143
144
  _voice="${_voice## }"; _voice="${_voice%% }"
144
145
  _pretext="${_pretext## }"; _pretext="${_pretext%% }"
146
+ _engine="${_engine## }"; _engine="${_engine%% }"
145
147
  [[ -n "$_reverb" ]] && _LLM_REVERB="$_reverb"
146
148
  [[ -n "$_voice" ]] && _LLM_VOICE="$_voice"
147
149
  [[ -n "$_pretext" ]] && _LLM_PRETEXT="$_pretext"
150
+ [[ -n "$_engine" ]] && _LLM_ENGINE="$_engine"
148
151
  break
149
152
  fi
150
153
  done < "$_cfg"
@@ -187,8 +190,12 @@ fi
187
190
  # Source provider manager to get active provider
188
191
  source "$SCRIPT_DIR/provider-manager.sh"
189
192
 
190
- # Get active provider
191
- ACTIVE_PROVIDER=$(get_active_provider)
193
+ # Get active provider (LLM-specific engine overrides global)
194
+ if [[ -n "$_LLM_ENGINE" ]]; then
195
+ ACTIVE_PROVIDER="$_LLM_ENGINE"
196
+ else
197
+ ACTIVE_PROVIDER=$(get_active_provider)
198
+ fi
192
199
 
193
200
  # Show GitHub star reminder (once per day)
194
201
  "$SCRIPT_DIR/github-star-reminder.sh" 2>/dev/null || true
@@ -10,25 +10,21 @@ param(
10
10
  [string]$Text,
11
11
 
12
12
  [Parameter(Mandatory = $false, Position = 1)]
13
- [string]$VoiceOverride,
14
-
15
- [Parameter(Mandatory = $false)]
16
- [string]$llm = ""
13
+ [string]$VoiceOverride
17
14
  )
18
15
 
19
16
  # Configuration paths
20
17
  # Priority: CLAUDE_PROJECT_DIR env var → script's parent project → user profile
21
- # Local project settings ALWAYS override global (~/.claude)
22
18
  $ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
23
19
 
24
20
  if ($env:CLAUDE_PROJECT_DIR -and (Test-Path "$env:CLAUDE_PROJECT_DIR\.claude")) {
25
21
  $ClaudeDir = "$env:CLAUDE_PROJECT_DIR\.claude"
26
22
  } else {
27
23
  $PackageClaudeDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ScriptPath)) ".claude"
28
- if (Test-Path $PackageClaudeDir) {
29
- $ClaudeDir = $PackageClaudeDir
30
- } elseif (Test-Path "$env:USERPROFILE\.claude\tts-provider.txt") {
24
+ if (Test-Path "$env:USERPROFILE\.claude\tts-provider.txt") {
31
25
  $ClaudeDir = "$env:USERPROFILE\.claude"
26
+ } elseif (Test-Path $PackageClaudeDir) {
27
+ $ClaudeDir = $PackageClaudeDir
32
28
  } else {
33
29
  $ClaudeDir = "$env:USERPROFILE\.claude"
34
30
  }
@@ -46,78 +42,6 @@ if (Test-Path $MuteFile) {
46
42
  }
47
43
  }
48
44
 
49
- # Per-LLM config lookup: if --llm is passed, look up llm:<name> in audio-effects.cfg
50
- # Format: llm:<name>|SOX_EFFECTS|BACKGROUND_FILE|BACKGROUND_VOLUME|VOICE|PRETEXT
51
- $LlmVoice = ""
52
- $LlmPretext = ""
53
- $LlmReverb = ""
54
- $ProjectRoot = Split-Path -Parent $ClaudeDir
55
- $ConfigDir = "$ClaudeDir\config"
56
-
57
- if ($llm) {
58
- $llmKey = "llm:$llm"
59
- # Check project-local audio-effects.cfg first, then global
60
- $cfgPaths = @(
61
- "$ConfigDir\audio-effects.cfg",
62
- "$env:USERPROFILE\.claude\config\audio-effects.cfg"
63
- )
64
- foreach ($cfgPath in $cfgPaths) {
65
- if (-not $LlmVoice -and -not $LlmPretext -and (Test-Path $cfgPath)) {
66
- foreach ($line in (Get-Content $cfgPath)) {
67
- if ($line -match "^$([regex]::Escape($llmKey))\|") {
68
- $parts = $line -split '\|'
69
- # parts: [0]=key [1]=reverb_preset [2]=bg_file [3]=bg_vol [4]=voice [5]=pretext
70
- if ($parts.Length -ge 2 -and $parts[1].Trim()) {
71
- $LlmReverb = $parts[1].Trim()
72
- }
73
- if ($parts.Length -ge 5 -and $parts[4].Trim()) {
74
- $LlmVoice = $parts[4].Trim()
75
- }
76
- if ($parts.Length -ge 6 -and $parts[5].Trim()) {
77
- $LlmPretext = $parts[5].Trim()
78
- }
79
- break
80
- }
81
- }
82
- }
83
- }
84
- # Apply LLM voice override (only if no explicit VoiceOverride was passed)
85
- if ($LlmVoice -and -not $VoiceOverride) {
86
- $VoiceOverride = $LlmVoice
87
- }
88
- # Export LLM key for child scripts (process-local, not system-wide)
89
- $env:AGENTVIBES_LLM_KEY = "llm:$llm"
90
- }
91
-
92
- # Prepend pretext if configured
93
- # Priority: LLM-specific pretext → project .agentvibes/config.json → project .claude/config/tts-pretext.txt
94
- # → global ~/.agentvibes/config.json → global ~/.claude/config/tts-pretext.txt
95
- $Pretext = $LlmPretext
96
- if (-not $Pretext) {
97
- $PretextSources = @(
98
- (Join-Path $ProjectRoot ".agentvibes\config.json"),
99
- "$ClaudeDir\config\tts-pretext.txt",
100
- "$env:USERPROFILE\.agentvibes\config.json",
101
- "$env:USERPROFILE\.claude\config\tts-pretext.txt"
102
- )
103
- foreach ($src in $PretextSources) {
104
- if (-not $Pretext -and (Test-Path $src)) {
105
- if ($src -match '\.json$') {
106
- try {
107
- $avCfg = Get-Content $src -Raw | ConvertFrom-Json
108
- if ($avCfg.pretext) { $Pretext = $avCfg.pretext.Trim() }
109
- } catch { }
110
- } else {
111
- $val = (Get-Content $src -Raw).Trim()
112
- if ($val) { $Pretext = $val }
113
- }
114
- }
115
- }
116
- }
117
- if ($Pretext) {
118
- $Text = "$Pretext, $Text"
119
- }
120
-
121
45
  # Determine active provider
122
46
  $ActiveProvider = "sapi"
123
47
  if (Test-Path $ProviderFile) {
@@ -177,17 +101,12 @@ if (Test-Path $AgentVibesConfig) {
177
101
  }
178
102
 
179
103
  # Check if reverb is enabled (allowlist validation)
180
- # LLM-specific reverb overrides global setting
181
104
  $ReverbLevel = "off"
182
- if ($LlmReverb -and $LlmReverb -in @("off", "light", "medium", "heavy", "cathedral")) {
183
- $ReverbLevel = $LlmReverb
184
- } else {
185
- $ReverbFile = "$ConfigDir\reverb-level.txt"
186
- if (Test-Path $ReverbFile) {
187
- $reverbVal = (Get-Content $ReverbFile -Raw).Trim()
188
- if ($reverbVal -in @("off", "light", "medium", "heavy", "cathedral")) {
189
- $ReverbLevel = $reverbVal
190
- }
105
+ $ReverbFile = "$ConfigDir\reverb-level.txt"
106
+ if (Test-Path $ReverbFile) {
107
+ $reverbVal = (Get-Content $ReverbFile -Raw).Trim()
108
+ if ($reverbVal -in @("off", "light", "medium", "heavy", "cathedral")) {
109
+ $ReverbLevel = $reverbVal
191
110
  }
192
111
  }
193
112
  $HasReverb = $ReverbLevel -ne "off"
@@ -214,6 +133,10 @@ $PreSynthWav = $env:AGENTVIBES_PRESYNTHESIZED_WAV
214
133
  $UsePreSynth = $PreSynthWav -and (Test-Path $PreSynthWav) -and
215
134
  (Get-Item $PreSynthWav -ErrorAction SilentlyContinue).Length -gt 0
216
135
 
136
+ # Tracks the path of the WAV the provider just synthesized.
137
+ # Used to defend against playing stale cached audio when synthesis silently fails.
138
+ $SynthesizedWavPath = $null
139
+
217
140
  # If background music or reverb enabled and ffmpeg available, tell provider to skip playback
218
141
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
219
142
  $env:AGENTVIBES_NO_PLAY = "1"
@@ -248,17 +171,25 @@ if ($UsePreSynth) {
248
171
  $providerOutput = & $ProviderScript $Text 6>&1 2>&1
249
172
  }
250
173
  # Re-emit preserving colors from InformationRecords (Write-Host output)
174
+ # Also extract the synthesized WAV path from the provider's OK line.
251
175
  foreach ($item in $providerOutput) {
176
+ $lineText = $null
252
177
  if ($item -is [System.Management.Automation.InformationRecord]) {
253
178
  $msg = $item.MessageData
254
179
  if ($msg -is [System.Management.Automation.HostInformationMessage]) {
255
180
  Write-Host $msg.Message -ForegroundColor $msg.ForegroundColor -NoNewline:$msg.NoNewLine
256
181
  if (-not $msg.NoNewLine) { Write-Host }
182
+ $lineText = $msg.Message
257
183
  } else {
258
184
  Write-Host "$item"
185
+ $lineText = "$item"
259
186
  }
260
187
  } else {
261
188
  Write-Host "$item"
189
+ $lineText = "$item"
190
+ }
191
+ if ($lineText -and $lineText -match '\[OK\]\s+Saved to:\s+(.+\.wav)') {
192
+ $SynthesizedWavPath = $Matches[1].Trim()
262
193
  }
263
194
  }
264
195
  } else {
@@ -280,13 +211,23 @@ if ($UsePreSynth) {
280
211
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
281
212
  Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
282
213
 
283
- # Find the WAV to post-process: use pre-synthesized file if available, else most recent
214
+ # Find the WAV to post-process. Never fall back to "most recent on disk"
215
+ # because that plays a random stale cache file when synthesis silently fails.
284
216
  $AudioDir = "$ClaudeDir\audio"
285
- $RecentWav = if ($UsePreSynth) {
286
- Get-Item $PreSynthWav -ErrorAction SilentlyContinue
287
- } else {
288
- Get-ChildItem -Path $AudioDir -Filter "tts-*.wav" -ErrorAction SilentlyContinue |
289
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
217
+ $RecentWav = $null
218
+ if ($UsePreSynth) {
219
+ $RecentWav = Get-Item $PreSynthWav -ErrorAction SilentlyContinue
220
+ }
221
+ elseif ($SynthesizedWavPath -and (Test-Path $SynthesizedWavPath)) {
222
+ $cand = Get-Item $SynthesizedWavPath -ErrorAction SilentlyContinue
223
+ if ($cand -and $cand.Length -gt 0) {
224
+ $RecentWav = $cand
225
+ }
226
+ }
227
+ if (-not $RecentWav) {
228
+ Write-Host "[ERROR] No fresh synthesized WAV - refusing stale cache" -ForegroundColor Red
229
+ Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
230
+ exit 1
290
231
  }
291
232
 
292
233
  if ($RecentWav -and $RecentWav.Length -gt 0) {
@@ -322,7 +263,6 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
322
263
  if (Test-Path $AudioEffectsCfg) {
323
264
  # Try agent-specific config first, then fall back to default
324
265
  # Format: AGENT_NAME|SOX_EFFECTS|BACKGROUND_FILE|BACKGROUND_VOLUME
325
- # Lookup order: agent name → llm:<name> → default
326
266
  $agentName = $env:AGENTVIBES_AGENT_NAME
327
267
  $configLine = $null
328
268
 
@@ -335,16 +275,6 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
335
275
  }
336
276
  }
337
277
  }
338
- # Try LLM-specific config (--llm parameter)
339
- if (-not $configLine -and $llm) {
340
- $llmBgKey = "llm:$llm"
341
- foreach ($line in $cfgLines) {
342
- if ($line -match "^$([regex]::Escape($llmBgKey))\|") {
343
- $configLine = $line
344
- break
345
- }
346
- }
347
- }
348
278
  # Fall back to default
349
279
  if (-not $configLine) {
350
280
  foreach ($line in $cfgLines) {
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Publish](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml/badge.svg)](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml)
12
12
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
13
13
 
14
- **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.0.0
14
+ **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.1.0
15
15
 
16
16
  ---
17
17
 
@@ -43,7 +43,21 @@ Whether you're using Claude Code, GitHub Copilot, OpenAI Codex, Claude Desktop,
43
43
 
44
44
  ---
45
45
 
46
- ## 🚀 NEW IN v5.0.0 — Multi-Provider Support: Claude Code + Copilot + Codex
46
+ ## 🎙️ NEW IN v5.1.0 — Voice Picker Overhaul + Auto-Save Agent Modal
47
+
48
+ - **Auto-save in agent modal** — Voice/personality/music/reverb/pretext changes save automatically as you edit them. Brief "✓ Saved!" toast confirms each change.
49
+ - **Unique LibriTTS names** — 904 speakers get deterministic surnames: **Anna Bell**, **Anna Carter**, …, **Anna Quinn**. No more "Anna-2", "Anna-3" duplicates.
50
+ - **Pink ♀ / blue ♂ gender symbols** — Colored gender indicators in the main Voices tab and all voice picker modals.
51
+ - **First-letter quick jump** — Press `a`–`z` in any voice picker to jump to that letter. `q`, `j`, `k`, `g`, `h`, `l` reserved for nav/cancel.
52
+ - **PgUp / PgDn / Home / End** in voice pickers
53
+ - **3 new background music tracks** — Late Night Hip Hop Groove, Drifting Down the Hall, Midnight Charleston Stomp
54
+ - **Search bar removed from voice pickers** — replaced by first-letter jump (faster, no focus issues)
55
+ - **Voices tab corruption fix** — uninstalled rows no longer lose their Provider column when navigated onto
56
+ - **Music + Voices tab blink artifacts gone**
57
+
58
+ ---
59
+
60
+ ## 🚀 v5.0.0 — Multi-Provider Support: Claude Code + Copilot + Codex
47
61
 
48
62
  - **GitHub Copilot + OpenAI Codex in VS Code** — AgentVibes now supports all three major AI coding assistants. Install and configure each from the TUI.
49
63
  - **One Setup tab** — 4-step wizard (Language → Deps → TTS Engine → Providers) replaces old installer + LLM tabs. Returning users skip to Providers.
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## 🎙️ v5.1.0 — Voice Picker Overhaul + Auto-Save Agent Modal
4
+
5
+ **Release Date:** April 2026
6
+
7
+ ### New Features
8
+
9
+ - **Auto-save in agent edit modal** — Per-agent voice/personality/music/reverb/pretext changes now save automatically as you edit them. The explicit Save button is gone; a brief "✓ Saved!" toast confirms each change. Cancel and Reset to Defaults still behave as before.
10
+
11
+ - **Unique LibriTTS speaker names** — The 904 LibriTTS speakers no longer show as "Anna", "Anna-2", "Anna-3", … "Anna-16". Each gets a deterministic surname from a 16-name pool: **Anna Bell**, **Anna Carter**, **Anna Davis**, …, **Anna Quinn**. Underlying voice IDs are unchanged so existing user configs still resolve.
12
+
13
+ - **Pink/blue gender symbols** — Female voices show **♀** in pink (magenta), male voices show **♂** in light blue (bright-cyan), unknown shows `—`. Header `Gender` column replaced with colored `♀/♂` (10 → 4 chars wide), freeing room for longer names. Applied to the main Voices tab AND all 3 voice picker modals (Setup, Agents, Settings).
14
+
15
+ - **First-letter quick jump in voice pickers** — Press any letter `a`–`z` to jump to the first voice starting with that letter. Reserved keys (`q`, `j`, `k`, `g`, `h`, `l`) are blocked so they keep their cancel / vi-nav meanings.
16
+
17
+ - **Page navigation in voice pickers** — `PgUp`, `PgDn`, `Home`, `End` now work in all voice picker modals.
18
+
19
+ - **3 new background music tracks** — `Late Night Hip Hop Groove`, `Drifting Down the Hall` (90s vibes), and `Midnight Charleston Stomp` (swing). Track count goes from 15 → 18.
20
+
21
+ ### Improvements
22
+
23
+ - **Voice picker search bar removed** — Replaced with first-letter quick jump. The old search textbox had focus issues that swallowed nav keys. The jump is faster for typical "find voice X" use.
24
+
25
+ - **Track list sorting fixed** — Tracks with emoji prefixes (e.g. `🎤 Late Night Hip Hop Groove`) now sort by the alphabetic part of the name, not the emoji codepoint. Order is consistent across Node/ICU versions.
26
+
27
+ - **Favorite hotkey is now `*` only** — Removed the duplicate `f` binding for marking favorites in voice pickers and the main Voices tab. `f` is now free for first-letter jump (e.g. jumping to Frank or Felix). The `*` marker remains the canonical way to toggle favorites.
28
+
29
+ ### Bug Fixes
30
+
31
+ - **Voices tab uninstalled rows no longer corrupt** — Selecting an uninstalled voice was visually deleting its Provider column due to a regex strip that over-matched the row's `bright-black-fg` wrapper. Replaced with a precise hint anchor that only strips the exact hint text.
32
+
33
+ - **Music tab + Voices tab blink artifacts gone** — `█` cursors no longer leave stray blocks behind when scrolling rapidly through the list. Both tabs now use a precise blink-strip helper instead of the fragile position-based slicer.
34
+
35
+ - **Setup tab no longer silently fails** — `_renderScreen3` was wrapping the entire `setupCompleted` write block in a single empty `try/catch {}`. Corrupt local config files are now backed up to `config.json.bak` and rewritten fresh, with errors logged to stderr — no more "stuck repeating setup" with no explanation.
36
+
37
+ - **Voice picker `q` cancel now works** — The new first-letter jump was swallowing `q` (and other vi nav keys). Reserved key blocklist added.
38
+
39
+ - **Track picker case-insensitive sort** — New tracks with Title Case names (`Late Night Hip Hop Groove.mp3`) no longer jump to the top of the list above the lowercase `agent_vibes_*` tracks.
40
+
41
+ ### User Impact
42
+
43
+ - Editing an agent's voice or settings is now faster — no need to remember to click Save
44
+ - The voice picker is dramatically less cluttered with 904 LibriTTS speakers all having unique, friendly names
45
+ - Gender at a glance via colored symbols
46
+ - Three new music tracks for variety
47
+ - Blink/scroll artifacts gone in both Voices and Music tabs
48
+
49
+ ---
50
+
3
51
  ## 🚀 v5.0.0 — Multi-Provider Support: Claude Code + Copilot + Codex
4
52
 
5
53
  **Release Date:** April 2026
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.0.0",
4
+ "version": "5.1.0",
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": [
@@ -16,7 +16,7 @@ import { openTrackPicker, openVolumeInput } from '../widgets/track-picker.js';
16
16
  import { formatReverbState, formatTrackName, formatVoiceName } from '../widgets/format-utils.js';
17
17
  import {
18
18
  PIPER_VOICES_DIR, SAMPLE_PHRASES,
19
- parseMultiSpeaker, scanInstalledVoices, getVoiceMeta,
19
+ parseMultiSpeaker, scanInstalledVoices, getVoiceMeta, genderIconTag,
20
20
  } from './voices-tab.js';
21
21
  import { buildAudioEnv, detectWavPlayer } from '../audio-env.js';
22
22
  import { destroyList } from '../widgets/destroy-list.js';
@@ -729,8 +729,10 @@ ${_tl('bmadDesc')}
729
729
  btnBlink.startSpinner(previewBtn);
730
730
  });
731
731
 
732
- const saveBtn = _modalBtn('Save', 18, () => {
733
- // Only save fields that differ from global
732
+ // Auto-save the current draft (called after every field change).
733
+ // Only persists fields that differ from the global defaults — same logic
734
+ // the explicit Save button used to use, just triggered automatically.
735
+ function _autoSaveAgent() {
734
736
  const toSave = {};
735
737
  if (draft.voice && draft.voice !== globalCfg.voice) toSave.voice = draft.voice;
736
738
  if (draft.pretext !== AgentVoiceStore.getDefaultPretext(agent.displayName, agent.title)) toSave.pretext = draft.pretext;
@@ -742,22 +744,20 @@ ${_tl('bmadDesc')}
742
744
  toSave.backgroundMusic = draft.backgroundMusic;
743
745
  }
744
746
  voiceStore.setAgentProfile(agent.id, toSave);
745
- _closeModal();
746
747
  refreshDisplay();
747
- // Show temporary "Saved!" toast
748
748
  _showSavedToast(agent.displayName);
749
- });
749
+ }
750
750
 
751
- const resetAllBtn = _modalBtn('Reset to Defaults', 26, () => {
751
+ const resetBtn = _modalBtn('Reset to Defaults', 18, () => {
752
752
  voiceStore.resetAgentProfile(agent.id);
753
753
  _closeModal();
754
754
  refreshDisplay();
755
755
  });
756
756
 
757
- const cancelBtn = _modalBtn('Cancel', 50, _closeModal);
757
+ const closeBtn = _modalBtn('Cancel', 42, _closeModal);
758
758
 
759
759
  // Blinking █ cursor + preview spinner — reusable across all modal buttons
760
- const btnBlink = attachBtnBlink([previewBtn, saveBtn, resetAllBtn, cancelBtn], screen);
760
+ const btnBlink = attachBtnBlink([previewBtn, resetBtn, closeBtn], screen);
761
761
 
762
762
  function _closeModal() {
763
763
  if (_closed) return;
@@ -779,6 +779,7 @@ ${_tl('bmadDesc')}
779
779
  switch (field.key) {
780
780
  case 'voice':
781
781
  _openVoicePickerForAgent(agent, draft, () => {
782
+ _autoSaveAgent();
782
783
  fieldList.setItems(_fieldItems());
783
784
  fieldList.select(idx);
784
785
  fieldList.focus();
@@ -788,6 +789,7 @@ ${_tl('bmadDesc')}
788
789
 
789
790
  case 'pretext':
790
791
  _openPretextEditor(modal, draft, () => {
792
+ _autoSaveAgent();
791
793
  fieldList.setItems(_fieldItems());
792
794
  fieldList.select(idx);
793
795
  fieldList.focus();
@@ -798,6 +800,7 @@ ${_tl('bmadDesc')}
798
800
  case 'reverbPreset':
799
801
  openReverbPicker(screen, draft.reverbPreset, (val) => {
800
802
  draft.reverbPreset = val;
803
+ _autoSaveAgent();
801
804
  fieldList.setItems(_fieldItems());
802
805
  fieldList.select(idx);
803
806
  fieldList.focus();
@@ -811,6 +814,7 @@ ${_tl('bmadDesc')}
811
814
  case 'personality':
812
815
  openPersonalityPicker(screen, draft.personality, (val) => {
813
816
  draft.personality = val;
817
+ _autoSaveAgent();
814
818
  fieldList.setItems(_fieldItems());
815
819
  fieldList.select(idx);
816
820
  fieldList.focus();
@@ -825,6 +829,7 @@ ${_tl('bmadDesc')}
825
829
  openTrackPicker(screen, draft.backgroundMusic.track, draft.backgroundMusic.volume, (track) => {
826
830
  draft.backgroundMusic.track = track;
827
831
  draft.backgroundMusic.enabled = true;
832
+ _autoSaveAgent();
828
833
  fieldList.setItems(_fieldItems());
829
834
  fieldList.select(idx);
830
835
  fieldList.focus();
@@ -839,6 +844,7 @@ ${_tl('bmadDesc')}
839
844
  openVolumeInput(screen, draft.backgroundMusic.volume, (volume) => {
840
845
  draft.backgroundMusic.volume = volume;
841
846
  if (draft.backgroundMusic.track) draft.backgroundMusic.enabled = true;
847
+ _autoSaveAgent();
842
848
  fieldList.setItems(_fieldItems());
843
849
  fieldList.select(idx);
844
850
  fieldList.focus();
@@ -861,9 +867,8 @@ ${_tl('bmadDesc')}
861
867
  // Escape = close
862
868
  fieldList.key(['escape', 'q'], _closeModal);
863
869
  previewBtn.key(['escape'], _closeModal);
864
- saveBtn.key(['escape'], _closeModal);
865
- resetAllBtn.key(['escape'], _closeModal);
866
- cancelBtn.key(['escape'], _closeModal);
870
+ resetBtn.key(['escape'], _closeModal);
871
+ closeBtn.key(['escape'], _closeModal);
867
872
 
868
873
  // Tab + arrow navigation within modal
869
874
  fieldList.key(['tab'], () => { previewBtn.focus(); screen.render(); });
@@ -887,23 +892,19 @@ ${_tl('bmadDesc')}
887
892
  _prevFieldSel = cur;
888
893
  });
889
894
 
890
- // Wrap: up on buttons → back to field list (last/first field respectively)
895
+ // Wrap: up on buttons → back to field list
891
896
  previewBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
892
- saveBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
893
- resetAllBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
894
- cancelBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
895
-
896
- previewBtn.key(['tab', 'right'], () => { saveBtn.focus(); screen.render(); });
897
- previewBtn.key(['left'], () => { cancelBtn.focus(); screen.render(); });
897
+ resetBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
898
+ closeBtn.key(['up'], () => { fieldList.focus(); fieldList.select(FIELDS.length - 1); screen.render(); });
898
899
 
899
- saveBtn.key(['tab', 'right'], () => { resetAllBtn.focus(); screen.render(); });
900
- saveBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
900
+ previewBtn.key(['tab', 'right'], () => { resetBtn.focus(); screen.render(); });
901
+ previewBtn.key(['left'], () => { closeBtn.focus(); screen.render(); });
901
902
 
902
- resetAllBtn.key(['tab', 'right'], () => { cancelBtn.focus(); screen.render(); });
903
- resetAllBtn.key(['left'], () => { saveBtn.focus(); screen.render(); });
903
+ resetBtn.key(['tab', 'right'], () => { closeBtn.focus(); screen.render(); });
904
+ resetBtn.key(['left'], () => { previewBtn.focus(); screen.render(); });
904
905
 
905
- cancelBtn.key(['tab', 'right'], () => { fieldList.focus(); screen.render(); });
906
- cancelBtn.key(['left'], () => { resetAllBtn.focus(); screen.render(); });
906
+ closeBtn.key(['tab', 'right'], () => { fieldList.focus(); screen.render(); });
907
+ closeBtn.key(['left'], () => { resetBtn.focus(); screen.render(); });
907
908
 
908
909
  fieldList.focus();
909
910
  screen.render();
@@ -914,7 +915,6 @@ ${_tl('bmadDesc')}
914
915
 
915
916
  function _openVoicePickerForAgent(agent, draft, onDone) {
916
917
  let _allVoices = [];
917
- let _filterText = '';
918
918
  let _previewProc = null;
919
919
  let _previewVoiceId = null;
920
920
  let _vpClosed = false;
@@ -959,29 +959,19 @@ ${_tl('bmadDesc')}
959
959
  });
960
960
  vpModal.setFront();
961
961
 
962
- // Search
963
- blessed.text({
964
- parent: vpModal, top: 1, left: 2,
965
- content: 'Search:', style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
966
- });
967
- const vpSearch = blessed.textbox({
968
- parent: vpModal, top: 1, left: 11, width: 40, height: 1,
969
- inputOnFocus: true, keys: true,
970
- style: { fg: COLORS.valueFg, bg: '#1a3a5c', focus: { bg: '#245a80' } },
971
- });
972
-
973
962
  // Column header
974
- const COL_N = 28;
975
- const COL_G = 10;
963
+ const COL_N = 30;
964
+ const COL_G = 4;
976
965
  blessed.text({
977
- parent: vpModal, top: 2, left: 6, tags: true,
978
- content: `{bright-cyan-fg}${'Name'.padEnd(COL_N)}${'Gender'.padEnd(COL_G)}Provider{/bright-cyan-fg}`,
966
+ parent: vpModal, top: 1, left: 6, tags: true,
967
+ content: `{bright-cyan-fg}${'Name'.padEnd(COL_N)}{/bright-cyan-fg}{magenta-fg}♀{/magenta-fg}/{bright-cyan-fg}♂{/bright-cyan-fg} {bright-cyan-fg}Provider{/bright-cyan-fg}`,
979
968
  style: { bg: COLORS.contentBg },
980
969
  });
981
970
 
982
971
  const vpList = blessed.list({
983
- parent: vpModal, top: 3, left: 2, right: 2, bottom: 5,
972
+ parent: vpModal, top: 2, left: 2, right: 2, bottom: 5,
984
973
  keys: true, vi: true, mouse: true,
974
+ tags: true,
985
975
  border: { type: 'line' },
986
976
  scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
987
977
  style: {
@@ -1004,16 +994,10 @@ ${_tl('bmadDesc')}
1004
994
 
1005
995
  blessed.text({
1006
996
  parent: vpModal, bottom: 2, left: 2, right: 2, tags: true,
1007
- content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [/] Search [Esc] Cancel{/#455a64-fg}',
997
+ content: '{#455a64-fg}[↑↓] Nav [PgUp/PgDn] Page [Home/End] [a-z] Jump [Enter] Select [Space] Preview [Esc] Cancel{/#455a64-fg}',
1008
998
  style: { bg: COLORS.contentBg },
1009
999
  });
1010
1000
 
1011
- function _getFiltered() {
1012
- if (!_filterText) return _allVoices;
1013
- const f = _filterText.toLowerCase();
1014
- return _allVoices.filter(v => v.toLowerCase().includes(f));
1015
- }
1016
-
1017
1001
  function _buildVoiceItems(voices) {
1018
1002
  return voices.map(v => {
1019
1003
  const isActive = v === draft.voice;
@@ -1023,7 +1007,8 @@ ${_tl('bmadDesc')}
1023
1007
  const name = meta.displayName.length > COL_N
1024
1008
  ? meta.displayName.slice(0, COL_N - 1) + '…'
1025
1009
  : meta.displayName.padEnd(COL_N);
1026
- return ` ${dot} ${name}${meta.gender.padEnd(COL_G)}${meta.provider}`;
1010
+ // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
1011
+ return ` ${dot} ${name}${genderIconTag(meta.gender)} ${meta.provider}`;
1027
1012
  });
1028
1013
  }
1029
1014
 
@@ -1032,8 +1017,10 @@ ${_tl('bmadDesc')}
1032
1017
  const savedIdx = vpList.selected ?? 0;
1033
1018
  const savedScroll = vpList.childBase ?? 0;
1034
1019
  _allVoices = scanInstalledVoices();
1035
- const filtered = _getFiltered();
1036
- const items = _buildVoiceItems(filtered);
1020
+ // Sort by display name so the first-letter quick jump is intuitive
1021
+ _allVoices.sort((a, b) => getVoiceMeta(a).displayName.localeCompare(
1022
+ getVoiceMeta(b).displayName, undefined, { sensitivity: 'base' }));
1023
+ const items = _buildVoiceItems(_allVoices);
1037
1024
  vpList.setItems(items.length > 0 ? items : [' (no voices found)']);
1038
1025
  vpList.select(Math.min(savedIdx, items.length - 1));
1039
1026
  vpList.childBase = Math.min(savedScroll, Math.max(0, items.length - (vpList.height - 2)));
@@ -1104,25 +1091,41 @@ ${_tl('bmadDesc')}
1104
1091
  piper.on('error', () => { _previewProc = null; _previewVoiceId = null; });
1105
1092
  }
1106
1093
 
1107
- vpSearch.on('keypress', () => {
1108
- setTimeout(() => { _filterText = vpSearch.getValue().trim(); _refreshVP(); }, 0);
1109
- });
1110
- vpSearch.key(['escape'], () => { vpList.focus(); screen.render(); });
1111
- vpList.key(['/'], () => { vpSearch.clearValue(); vpSearch.focus(); screen.render(); });
1112
1094
  vpList.key(['enter'], () => {
1113
- const filtered = _getFiltered();
1114
- const sel = filtered[vpList.selected];
1095
+ const sel = _allVoices[vpList.selected];
1115
1096
  if (sel) { draft.voice = sel; _closeVP(); }
1116
1097
  });
1117
1098
  vpList.key(['space'], () => {
1118
- const filtered = _getFiltered();
1119
- const sel = filtered[vpList.selected];
1099
+ const sel = _allVoices[vpList.selected];
1120
1100
  if (sel) _previewVoice(sel);
1121
1101
  });
1122
1102
  vpList.key(['escape', 'q'], _closeVP);
1123
1103
 
1104
+ // PageUp / PageDown / Home / End navigation
1105
+ const _pageSize = () => Math.max(1, (vpList.height ?? 10) - 2);
1106
+ vpList.key(['pageup'], () => { vpList.up(_pageSize()); screen.render(); });
1107
+ vpList.key(['pagedown'], () => { vpList.down(_pageSize()); screen.render(); });
1108
+ vpList.key(['home'], () => { vpList.select(0); screen.render(); });
1109
+ vpList.key(['end'], () => { vpList.select(Math.max(0, _allVoices.length - 1)); screen.render(); });
1110
+
1111
+ // First-letter quick jump: typing 'a' jumps to the first voice starting
1112
+ // with A. Block keys reserved by the list widget (vi nav, cancel) so
1113
+ // they don't get swallowed: q (cancel), j/k/g/h/l (vi navigation).
1114
+ const _vpJumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'q']);
1115
+ vpList.on('keypress', (ch, key) => {
1116
+ if (!ch || key?.ctrl || key?.meta) return;
1117
+ if (!/^[a-zA-Z]$/.test(ch)) return;
1118
+ const target = ch.toLowerCase();
1119
+ if (_vpJumpBlocked.has(target)) return;
1120
+ const idx = _allVoices.findIndex(v => {
1121
+ const name = getVoiceMeta(v).displayName.toLowerCase();
1122
+ return name.startsWith(target);
1123
+ });
1124
+ if (idx >= 0) { vpList.select(idx); screen.render(); }
1125
+ });
1126
+
1124
1127
  _refreshVP();
1125
- const activeIdx = _getFiltered().indexOf(draft.voice);
1128
+ const activeIdx = _allVoices.indexOf(draft.voice);
1126
1129
  if (activeIdx >= 0) vpList.select(activeIdx);
1127
1130
  vpList.focus();
1128
1131
  screen.render();