agentvibes 5.0.0 → 5.1.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.
@@ -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
@@ -51,6 +51,7 @@ if (Test-Path $MuteFile) {
51
51
  $LlmVoice = ""
52
52
  $LlmPretext = ""
53
53
  $LlmReverb = ""
54
+ $LlmEngine = ""
54
55
  $ProjectRoot = Split-Path -Parent $ClaudeDir
55
56
  $ConfigDir = "$ClaudeDir\config"
56
57
 
@@ -76,6 +77,9 @@ if ($llm) {
76
77
  if ($parts.Length -ge 6 -and $parts[5].Trim()) {
77
78
  $LlmPretext = $parts[5].Trim()
78
79
  }
80
+ if ($parts.Length -ge 7 -and $parts[6].Trim()) {
81
+ $LlmEngine = $parts[6].Trim()
82
+ }
79
83
  break
80
84
  }
81
85
  }
@@ -119,8 +123,11 @@ if ($Pretext) {
119
123
  }
120
124
 
121
125
  # Determine active provider
126
+ # LLM-specific engine overrides global provider
122
127
  $ActiveProvider = "sapi"
123
- if (Test-Path $ProviderFile) {
128
+ if ($LlmEngine) {
129
+ $ActiveProvider = $LlmEngine
130
+ } elseif (Test-Path $ProviderFile) {
124
131
  $ActiveProvider = (Get-Content $ProviderFile -Raw).Trim()
125
132
  }
126
133
 
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.1
15
15
 
16
16
  ---
17
17
 
@@ -43,7 +43,27 @@ Whether you're using Claude Code, GitHub Copilot, OpenAI Codex, Claude Desktop,
43
43
 
44
44
  ---
45
45
 
46
- ## 🚀 NEW IN v5.0.0Multi-Provider Support: Claude Code + Copilot + Codex
46
+ ## 🩹 v5.1.1Windows TTS Hook Hotfix
47
+
48
+ - **`play-tts.ps1 -llm` parameter restored** — npm-published v5.1.0 shipped a regressed copy without `-llm` support, breaking the Setup tab Preview button and the agentvibes MCP `text_to_speech` tool on Windows. Fixed in v5.1.1. If you hit the error, clear your npx cache: `npm cache clean --force` then reinstall.
49
+
50
+ ---
51
+
52
+ ## 🎙️ NEW IN v5.1.0 — Voice Picker Overhaul + Auto-Save Agent Modal
53
+
54
+ - **Auto-save in agent modal** — Voice/personality/music/reverb/pretext changes save automatically as you edit them. Brief "✓ Saved!" toast confirms each change.
55
+ - **Unique LibriTTS names** — 904 speakers get deterministic surnames: **Anna Bell**, **Anna Carter**, …, **Anna Quinn**. No more "Anna-2", "Anna-3" duplicates.
56
+ - **Pink ♀ / blue ♂ gender symbols** — Colored gender indicators in the main Voices tab and all voice picker modals.
57
+ - **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.
58
+ - **PgUp / PgDn / Home / End** in voice pickers
59
+ - **3 new background music tracks** — Late Night Hip Hop Groove, Drifting Down the Hall, Midnight Charleston Stomp
60
+ - **Search bar removed from voice pickers** — replaced by first-letter jump (faster, no focus issues)
61
+ - **Voices tab corruption fix** — uninstalled rows no longer lose their Provider column when navigated onto
62
+ - **Music + Voices tab blink artifacts gone**
63
+
64
+ ---
65
+
66
+ ## 🚀 v5.0.0 — Multi-Provider Support: Claude Code + Copilot + Codex
47
67
 
48
68
  - **GitHub Copilot + OpenAI Codex in VS Code** — AgentVibes now supports all three major AI coding assistants. Install and configure each from the TUI.
49
69
  - **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,81 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## 🩹 v5.1.1 — Windows TTS Hook Hotfix
4
+
5
+ **Release Date:** April 2026
6
+
7
+ ### Bug Fixes
8
+
9
+ - **Windows `play-tts.ps1` `-llm` parameter restored** — A regression caused the npm-published v5.1.0 package to ship a `play-tts.ps1` that lacked the `-llm` parameter and the per-LLM config lookup. Per-LLM TTS routing on Windows failed with `A parameter cannot be found that matches parameter name 'llm'`. This affected:
10
+ - **Setup tab Preview button** for any provider configured with per-LLM voice/effects
11
+ - **agentvibes MCP `text_to_speech` tool** when called from Codex / Copilot / Claude Code
12
+ - Any code path that invokes `play-tts.ps1 ... -llm <provider>`
13
+
14
+ The git tag `v5.1.0` had the correct file all along — only the npm tarball was affected, because `npm publish` packs the working tree (which contained an uncommitted local regression) instead of the git tag.
15
+
16
+ ### How to Update
17
+
18
+ If you installed v5.1.0 from npm and hit the `-llm` error, clear your npx cache and reinstall:
19
+
20
+ ```
21
+ npm cache clean --force
22
+ npx --yes agentvibes@5.1.1
23
+ ```
24
+
25
+ ### Note for Maintainers
26
+
27
+ To prevent this kind of working-tree-vs-tag drift in future releases, the release workflow should run `npm publish` from a freshly checked-out clone of the tag, not from the development working directory.
28
+
29
+ ---
30
+
31
+ ## 🎙️ v5.1.0 — Voice Picker Overhaul + Auto-Save Agent Modal
32
+
33
+ **Release Date:** April 2026
34
+
35
+ ### New Features
36
+
37
+ - **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.
38
+
39
+ - **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.
40
+
41
+ - **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).
42
+
43
+ - **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.
44
+
45
+ - **Page navigation in voice pickers** — `PgUp`, `PgDn`, `Home`, `End` now work in all voice picker modals.
46
+
47
+ - **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.
48
+
49
+ ### Improvements
50
+
51
+ - **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.
52
+
53
+ - **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.
54
+
55
+ - **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.
56
+
57
+ ### Bug Fixes
58
+
59
+ - **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.
60
+
61
+ - **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.
62
+
63
+ - **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.
64
+
65
+ - **Voice picker `q` cancel now works** — The new first-letter jump was swallowing `q` (and other vi nav keys). Reserved key blocklist added.
66
+
67
+ - **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.
68
+
69
+ ### User Impact
70
+
71
+ - Editing an agent's voice or settings is now faster — no need to remember to click Save
72
+ - The voice picker is dramatically less cluttered with 904 LibriTTS speakers all having unique, friendly names
73
+ - Gender at a glance via colored symbols
74
+ - Three new music tracks for variety
75
+ - Blink/scroll artifacts gone in both Voices and Music tabs
76
+
77
+ ---
78
+
3
79
  ## 🚀 v5.0.0 — Multi-Provider Support: Claude Code + Copilot + Codex
4
80
 
5
81
  **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.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": [
@@ -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();
@@ -75,6 +75,9 @@ const TRACK_DISPLAY = Object.freeze({
75
75
  'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
76
76
  'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
77
77
  'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
78
+ 'Late Night Hip Hop Groove.mp3': '🎤 Late Night Hip Hop Groove',
79
+ 'Drifting Down the Hall.mp3': '🌃 Drifting Down the Hall',
80
+ 'Midnight Charleston Stomp.mp3': '🎩 Midnight Charleston Stomp',
78
81
  });
79
82
 
80
83
  const BUILT_IN_TRACK_CATALOG = Object.freeze([
@@ -93,6 +96,9 @@ const BUILT_IN_TRACK_CATALOG = Object.freeze([
93
96
  { id: 'agent_vibes_japanese_city_pop_v1_loop.mp3', label: '🌆 Japanese City Pop' },
94
97
  { id: 'agent_vibes_salsa_v2_loop.mp3', label: '💃 Salsa' },
95
98
  { id: 'agent_vibes_tabla_dream_pop_v1_loop.mp3', label: '🥁 Tabla Dream Pop' },
99
+ { id: 'Late Night Hip Hop Groove.mp3', label: '🎤 Late Night Hip Hop Groove' },
100
+ { id: 'Drifting Down the Hall.mp3', label: '🌃 Drifting Down the Hall' },
101
+ { id: 'Midnight Charleston Stomp.mp3', label: '🎩 Midnight Charleston Stomp' },
96
102
  ]);
97
103
 
98
104
  // ---------------------------------------------------------------------------
@@ -180,10 +186,13 @@ export function scanTracks() {
180
186
  try {
181
187
  const files = fs.readdirSync(tracksDir);
182
188
  const builtInIds = new Set(BUILT_IN_TRACK_CATALOG.map(t => t.id));
189
+ // Sort by the alphabetic part of the label (skip leading emoji/symbols)
190
+ // so the order reflects the track NAME, not the emoji codepoint.
191
+ const _sortKey = (s) => s.replace(/^[^a-zA-Z]+/, '');
183
192
  return files
184
193
  .filter(f => /\.mp3$/i.test(f))
185
- .sort()
186
- .map(f => ({ id: f, label: formatTrackLabel(f), isBuiltIn: builtInIds.has(f) }));
194
+ .map(f => ({ id: f, label: formatTrackLabel(f), isBuiltIn: builtInIds.has(f) }))
195
+ .sort((a, b) => _sortKey(a.label).localeCompare(_sortKey(b.label), undefined, { sensitivity: 'base' }));
187
196
  } catch {
188
197
  // Directory not found or unreadable — use the static catalog
189
198
  return BUILT_IN_TRACK_CATALOG.map(t => ({ ...t, isBuiltIn: true }));
@@ -470,24 +479,36 @@ export function createMusicTab(screen, services) {
470
479
  let _hintBase = ''; // content of items[_hintIdx] before hint was appended
471
480
  let _refreshing = false;
472
481
 
473
- // Known limitation: _updateHint and _tlTick (blink) can interleave,
474
- // causing brief visual glitches. The _refreshing guard prevents the worst
475
- // cases but is not a complete fix. Acceptable for a TUI animation.
482
+ // Decoration helpers strip blink cursor and (optionally) hint text from
483
+ // a list item's content. We strip hint by anchoring on the EXACT text from
484
+ // _rowHint() rather than a generic regex, so we cannot accidentally erase
485
+ // unrelated content that happens to contain similar tags.
486
+ const _BLINK_RE = / █/g;
487
+ function _stripBlink(raw) {
488
+ return (raw ?? '').replace(_BLINK_RE, '');
489
+ }
490
+ function _stripHint(raw) {
491
+ const hint = _rowHint();
492
+ if (!hint) return raw;
493
+ const noBlink = (raw ?? '').replace(_BLINK_RE, '');
494
+ if (noBlink.endsWith(hint)) return noBlink.slice(0, -hint.length);
495
+ return noBlink;
496
+ }
497
+ function _stripDecorations(raw) {
498
+ return _stripHint(_stripBlink(raw));
499
+ }
500
+
476
501
  function _updateHint(idx) {
477
502
  const items = trackList.items;
478
503
  // Restore previously hinted row — pad with spaces to overwrite ghost hint text
479
504
  const _pad = ' '.repeat(60);
480
505
  if (_hintIdx >= 0 && _hintIdx !== idx && items[_hintIdx]) {
481
- const hadBlink = (items[_hintIdx].content ?? '').endsWith(' █');
482
- items[_hintIdx].setContent((hadBlink ? _hintBase + ' █' : _hintBase) + _pad);
506
+ items[_hintIdx].setContent(_hintBase + _pad);
483
507
  }
484
508
  // Add hint to the new row, saving its clean base first
485
509
  if (idx >= 0 && items[idx]) {
486
- let c = items[idx].content ?? '';
487
- const hasBlink = c.endsWith(' █');
488
- if (hasBlink) c = c.slice(0, -2);
489
- _hintBase = c;
490
- items[idx].setContent(c + _rowHint() + (hasBlink ? ' █' : ''));
510
+ _hintBase = _stripDecorations(items[idx].content);
511
+ items[idx].setContent(_hintBase + _rowHint());
491
512
  } else {
492
513
  _hintBase = '';
493
514
  }
@@ -909,13 +930,14 @@ export function createMusicTab(screen, services) {
909
930
  _tlBlink.on = !_tlBlink.on;
910
931
  const items = trackList.items;
911
932
  const cur = trackList.selected ?? 0;
933
+ // Clean old row — only strip blink (hint, if any, is preserved)
912
934
  if (_tlBlink.sel !== cur && _tlBlink.sel >= 0 && items[_tlBlink.sel]) {
913
- items[_tlBlink.sel].setContent((items[_tlBlink.sel].content ?? '').replace(/ █$/, ''));
935
+ items[_tlBlink.sel].setContent(_stripBlink(items[_tlBlink.sel].content));
914
936
  }
915
937
  _tlBlink.sel = cur;
916
938
  if (items[cur]) {
917
- const base = (items[cur].content ?? '').replace(/ █$/, '');
918
- items[cur].setContent(_tlBlink.on ? `${base} █` : base);
939
+ const raw = _stripBlink(items[cur].content);
940
+ items[cur].setContent(_tlBlink.on ? `${raw} █` : raw);
919
941
  }
920
942
  screen.render();
921
943
  }
@@ -927,7 +949,11 @@ export function createMusicTab(screen, services) {
927
949
  _hintBase = '';
928
950
  _updateHint(_tlBlink.sel);
929
951
  const items = trackList.items;
930
- if (items[_tlBlink.sel]) items[_tlBlink.sel].setContent((items[_tlBlink.sel].content ?? '') + ' █');
952
+ // Append blink cursor — content already has hint from _updateHint
953
+ if (items[_tlBlink.sel]) {
954
+ const raw = _stripBlink(items[_tlBlink.sel].content);
955
+ items[_tlBlink.sel].setContent(raw + ' █');
956
+ }
931
957
  if (!_playingTrackId) previewLine.setContent(_hintText());
932
958
  screen.render();
933
959
  _tlBlink.interval = setInterval(_tlTick, 500);
@@ -937,13 +963,17 @@ export function createMusicTab(screen, services) {
937
963
  if (!_playingTrackId) previewLine.setContent('');
938
964
  if (_tlBlink.interval) { clearInterval(_tlBlink.interval); _tlBlink.interval = null; }
939
965
  const items = trackList.items;
966
+ // Strip blink from selected row, strip both from the hinted row
940
967
  const sel = trackList.selected ?? 0;
941
968
  if (items[sel]) {
942
- // Restore the hinted item to its clean base; for non-hinted items just strip █
943
- items[sel].setContent(sel === _hintIdx ? _hintBase : (items[sel].content ?? '').replace(/ █$/, ''));
969
+ if (sel === _hintIdx) {
970
+ items[sel].setContent(_stripDecorations(items[sel].content));
971
+ } else {
972
+ items[sel].setContent(_stripBlink(items[sel].content));
973
+ }
944
974
  }
945
975
  if (_hintIdx >= 0 && _hintIdx !== sel && items[_hintIdx]) {
946
- items[_hintIdx].setContent(_hintBase);
976
+ items[_hintIdx].setContent(_stripDecorations(items[_hintIdx].content));
947
977
  }
948
978
  _hintIdx = -1;
949
979
  _hintBase = '';