agentvibes 5.7.4 → 5.7.6

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.
@@ -17,6 +17,7 @@ set -euo pipefail
17
17
  TEXT="${1:-}"
18
18
  VOICE="${2:-en_US-lessac-medium}"
19
19
  AGENT_NAME="${3:-default}"
20
+ AGENT_PROFILE_FILE="${4:-}"
20
21
 
21
22
  # LLM identity — forwarded to the remote so it can look up its own
22
23
  # audio-effects.cfg llm:<name> row for voice, reverb, music, pretext, engine.
@@ -150,12 +151,23 @@ SOX_EFFECTS=""
150
151
  BG_FILE=""
151
152
  BG_VOLUME="0.10"
152
153
 
153
- EFFECTS_CFG="$PROJECT_ROOT/.claude/config/audio-effects.cfg"
154
+ # Use CLAUDE_PROJECT_DIR (injected via --project-dir by play-tts.sh) so the
155
+ # agent-name / default row is read from the user's project, not the package.
156
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" && -d "$CLAUDE_PROJECT_DIR/.claude" ]]; then
157
+ EFFECTS_CFG="$CLAUDE_PROJECT_DIR/.claude/config/audio-effects.cfg"
158
+ else
159
+ EFFECTS_CFG="$PROJECT_ROOT/.claude/config/audio-effects.cfg"
160
+ fi
154
161
  if [[ -f "$EFFECTS_CFG" ]]; then
155
- CONFIG_LINE=$(grep "^${AGENT_NAME}|" "$EFFECTS_CFG" 2>/dev/null || \
156
- grep "^default|" "$EFFECTS_CFG" 2>/dev/null || true)
162
+ # awk exact field-1 match — no regex injection risk from AGENT_NAME
163
+ CONFIG_LINE=$(awk -F'|' -v k="${AGENT_NAME}" '$1==k{print;exit}' "$EFFECTS_CFG" 2>/dev/null || true)
164
+ [[ -z "$CONFIG_LINE" ]] && \
165
+ CONFIG_LINE=$(awk -F'|' '$1=="default"{print;exit}' "$EFFECTS_CFG" 2>/dev/null || true)
157
166
  if [[ -n "$CONFIG_LINE" ]]; then
158
167
  IFS='|' read -r _ SOX_EFFECTS BG_FILE BG_VOLUME <<< "$CONFIG_LINE"
168
+ SOX_EFFECTS="${SOX_EFFECTS## }"; SOX_EFFECTS="${SOX_EFFECTS%% }"
169
+ BG_FILE="${BG_FILE## }"; BG_FILE="${BG_FILE%% }"
170
+ BG_VOLUME="${BG_VOLUME## }"; BG_VOLUME="${BG_VOLUME%% }"
159
171
  fi
160
172
  fi
161
173
 
@@ -171,14 +183,18 @@ LLM_REVERB=""
171
183
  LLM_BG_FILE=""
172
184
  LLM_BG_VOLUME=""
173
185
 
174
- # Build config search path: CLAUDE_PROJECT_DIR first (the actual user project,
175
- # even when hooks run from the package dir), then PROJECT_ROOT (may be the
176
- # same or the package dir), then global home fallback.
186
+ # Build config search path for LLM-specific row lookup.
187
+ # Priority: CLAUDE_PROJECT_DIR (real user project) global HOME fallback.
188
+ # PROJECT_ROOT is intentionally excluded when CLAUDE_PROJECT_DIR is set and
189
+ # different — prevents the AgentVibes package's own audio-effects.cfg from
190
+ # bleeding into a user project that doesn't define its own llm: row.
177
191
  _llm_cfg_paths=()
178
192
  if [[ -n "${CLAUDE_PROJECT_DIR:-}" && "$CLAUDE_PROJECT_DIR" != "$PROJECT_ROOT" ]]; then
179
193
  _llm_cfg_paths+=("$CLAUDE_PROJECT_DIR/.claude/config/audio-effects.cfg")
194
+ else
195
+ _llm_cfg_paths+=("$PROJECT_ROOT/.claude/config/audio-effects.cfg")
180
196
  fi
181
- _llm_cfg_paths+=("$PROJECT_ROOT/.claude/config/audio-effects.cfg" "$HOME/.claude/config/audio-effects.cfg")
197
+ _llm_cfg_paths+=("$HOME/.claude/config/audio-effects.cfg")
182
198
 
183
199
  _llm_key="llm:${LLM_NAME}"
184
200
  _llm_row_found=0
@@ -210,7 +226,24 @@ done
210
226
  [[ -n "$LLM_BG_FILE" ]] && BG_FILE="$LLM_BG_FILE"
211
227
  [[ -n "$LLM_BG_VOLUME" ]] && BG_VOLUME="$LLM_BG_VOLUME"
212
228
 
213
- # Read pretext if configured
229
+ # Per-agent profile (written by bmad-speak.sh) takes highest priority for music
230
+ if [[ -n "$AGENT_PROFILE_FILE" ]] && [[ -f "$AGENT_PROFILE_FILE" ]]; then
231
+ _prof_track=$(_AV_PROF="$AGENT_PROFILE_FILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._AV_PROF,'utf8'));process.stdout.write(p.backgroundMusic?.track??'')}catch{process.stdout.write('')}" 2>/dev/null || true)
232
+ _prof_vol=$(_AV_PROF="$AGENT_PROFILE_FILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._AV_PROF,'utf8'));process.stdout.write(String(p.backgroundMusic?.volume??''))}catch{process.stdout.write('')}" 2>/dev/null || true)
233
+ _prof_enabled=$(_AV_PROF="$AGENT_PROFILE_FILE" node -e "try{const p=JSON.parse(require('fs').readFileSync(process.env._AV_PROF,'utf8'));process.stdout.write(String(p.backgroundMusic?.enabled??''))}catch{process.stdout.write('')}" 2>/dev/null || true)
234
+ if [[ "$_prof_enabled" == "true" ]] && [[ -n "$_prof_track" ]]; then
235
+ BG_FILE="$_prof_track"
236
+ if [[ "$_prof_vol" =~ ^[0-9]+$ ]]; then
237
+ BG_VOLUME=$(awk "BEGIN{printf \"%.2f\", ${_prof_vol}/100}")
238
+ fi
239
+ fi
240
+ fi
241
+
242
+ # PRETEXT is NOT extracted from the llm row here.
243
+ # play-tts.sh already prepends the llm row's pretext to TEXT before calling this script.
244
+ # Extracting it again and sending it as a separate JSON field would cause the receiver
245
+ # to prepend it a second time — the user hears the intro text twice.
246
+ # PRETEXT here is only for the (rare) pretext.txt override file, not the llm row.
214
247
  PRETEXT=""
215
248
  PRETEXT_FILE="$PROJECT_ROOT/.agentvibes/config/pretext.txt"
216
249
  if [[ -f "$PRETEXT_FILE" ]]; then
@@ -265,9 +298,9 @@ build_json_payload() {
265
298
  --arg llm "$LLM_NAME" \
266
299
  '{text: $text, voice: $voice, effects: $effects, music: $music, volume: $volume, project: $project, pretext: $pretext, speed: $speed, provider: $provider, llm: $llm}'
267
300
  else
268
- # Manual JSON — escape double quotes and backslashes in text
301
+ # Manual JSON — escape backslashes, quotes, control chars
269
302
  local escaped_text
270
- escaped_text=$(printf '%s' "$TEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')
303
+ escaped_text=$(printf '%s' "$TEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ' | sed 's/\r//g')
271
304
  local escaped_pretext
272
305
  escaped_pretext=$(printf '%s' "$PRETEXT" | sed 's/\\/\\\\/g; s/"/\\"/g')
273
306
  printf '{"text":"%s","voice":"%s","effects":"%s","music":"%s","volume":"%s","project":"%s","pretext":"%s","speed":"%s","provider":"%s","llm":"%s"}' \
@@ -289,6 +322,13 @@ fi
289
322
  # Send to receiver via SSH (fire and forget — backgrounded)
290
323
  # ---------------------------------------------------------------------------
291
324
 
325
+ # In test mode, dump the decoded payload to stdout so tests can inspect it
326
+ # without needing a real SSH connection or a mock binary.
327
+ if [[ "${AGENTVIBES_TEST_MODE:-false}" == "true" ]]; then
328
+ echo "$JSON_PAYLOAD"
329
+ exit 0
330
+ fi
331
+
292
332
  echo "Sending to $SSH_HOST..." >&2
293
333
 
294
334
  # Build SSH args — use explicit key/port from config if available, else rely on ~/.ssh/config
@@ -367,7 +367,7 @@ speak_text() {
367
367
  bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$text" "$voice"
368
368
  ;;
369
369
  ssh-remote)
370
- bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$text" "$voice"
370
+ bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$text" "$voice" "" "${profile_file:-}"
371
371
  ;;
372
372
  agentvibes-receiver)
373
373
  bash "$SCRIPT_DIR/play-tts-agentvibes-receiver-for-voiceless-connections.sh" "$text" "$voice"
@@ -497,7 +497,7 @@ case "$ACTIVE_PROVIDER" in
497
497
  exec bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$TEXT" "$VOICE_OVERRIDE"
498
498
  ;;
499
499
  ssh-remote)
500
- exec bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$TEXT" "$VOICE_OVERRIDE"
500
+ exec bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$TEXT" "$VOICE_OVERRIDE" "" "${AGENT_PROFILE_FILE:-}"
501
501
  ;;
502
502
  agentvibes-receiver)
503
503
  exec bash "$SCRIPT_DIR/play-tts-agentvibes-receiver-for-voiceless-connections.sh" "$TEXT" "$VOICE_OVERRIDE"
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.7.4
14
+ **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.7.6
15
15
 
16
16
  ---
17
17
 
@@ -40,7 +40,19 @@ Whether you're coding in Claude Code, chatting in Claude Desktop, using Warp Ter
40
40
 
41
41
  ---
42
42
 
43
- ## 🌟 NEW IN v5.7.0BMAD v6.6 Support + Windows Auto-Restart Watcher
43
+ ## 🌟 NEW IN v5.7.6SSH Remote Payload Integrity + Receiver Rewrite
44
+
45
+ **SSH remote music/voice fix:** The correct project music track and voice now reach the remote receiver — previously the global config was used instead of the active project's settings.
46
+
47
+ **Bash receiver rewrite:** The Linux/Termux `agentvibes-receiver.sh` has been fully rewritten to decode the current base64 JSON payload format. The old positional-arg format from pre-v5.5 is gone.
48
+
49
+ **No more double intro:** The personality pretext (e.g., "Bcs latin dance here") is no longer spoken twice over SSH remote. `play-tts.sh` prepends it to the text; the receiver no longer gets a separate pretext field to prepend again.
50
+
51
+ **SSH host visible in TUI:** The Settings and Voices tabs now display your configured SSH remote host alias.
52
+
53
+ **Security fixes** and 24 new BATS tests covering the full sender → receiver round-trip.
54
+
55
+ ## v5.7.5 — TUI Button Contrast + BMAD Routing Fixes
44
56
 
45
57
  **BMAD v6.6.0:** AgentVibes now detects the new `.claude/skills/*/agents/` agent structure, correctly handles globally-installed BMAD at `~/_bmad`, and gracefully skips v6.6+ plain-Markdown agents during TTS injection instead of erroring. The BMAD tab now shows detection correctly for global installs.
46
58
 
@@ -341,7 +353,7 @@ All 50+ Piper voices AgentVibes provides are sourced from Hugging Face's open-so
341
353
 
342
354
  ## 📰 Latest Release
343
355
 
344
- **[v3.6.0 - "Voice Explorer" Release](https://github.com/paulpreibisch/AgentVibes/releases/tag/v3.6.0)** 🎉
356
+ **[v3.6.0 - "Voice Explorer" Release](https://github.com/paulpreibisch/AgentVibes/releases/tag/v5.7.5)** 🎉
345
357
 
346
358
  ### 🎤 Voices Tab — Browse & Sample 914 Voices
347
359
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## 🔧 v5.7.6 — SSH Remote Payload Integrity + Receiver Rewrite
4
+
5
+ **Released:** 2026-05-16
6
+
7
+ ### 🐛 SSH Remote Playing Wrong Music and Voice
8
+
9
+ When using the SSH remote TTS feature, the wrong project's music track and voice were being applied. Root cause: `CLAUDE_PROJECT_DIR` was not forwarded to the sender, causing it to fall back to the global config instead of the active project's `audio-effects.cfg`.
10
+
11
+ ### 🐛 Bash Receiver Incompatible with JSON Payload Format
12
+
13
+ The Linux/Termux bash receiver (`agentvibes-receiver.sh`) was using a positional-argument format from pre-v5.5 and could not decode the current base64 JSON payload at all. The receiver has been fully rewritten to match the PowerShell receiver's logic: decodes base64, parses JSON, applies voice/music/effects/volume, and validates all fields.
14
+
15
+ ### 🐛 Personality Intro Heard Twice on Remote
16
+
17
+ The personality pretext (e.g., "Bcs latin dance here") was being spoken twice when using SSH remote TTS. Root cause: `play-tts.sh` already prepends the pretext to the speech text before calling the sender; the sender was also packing it into the JSON `pretext` field, causing the receiver to prepend it again. The `pretext` JSON field is now intentionally left empty — the personality is delivered via the `text` field only.
18
+
19
+ ### 🆕 SSH Host Alias Visible in Settings Tab
20
+
21
+ The configured SSH remote host alias is now displayed in the Settings and Voices tabs so users can confirm which remote machine TTS is targeting without opening config files.
22
+
23
+ ### 🔒 Security Fixes
24
+
25
+ Input validation improvements in the SSH remote sender and receiver.
26
+
27
+ ### 🧪 24 New BATS Tests
28
+
29
+ - 15 SSH remote payload tests: verify voice, music track, volume, reverb/effects, pretext handling, LLM identifier, project config precedence, and JSON validity
30
+ - 9 end-to-end round-trip tests: sender builds payload → receiver decodes and applies all fields simultaneously, catching regressions in either end
31
+
32
+ ---
33
+
34
+ ## 🖥️ v5.7.5 — TUI Button Contrast + BMAD Routing Fixes
35
+
36
+ **Released:** 2026-05-13
37
+
38
+ ### 🐛 TUI Button Focus: Grey Text Eliminated Across All Terminals
39
+
40
+ Focused and selected buttons in the TUI (voices, music, settings, setup tabs) displayed light grey text on light-blue backgrounds in many terminals. Root cause: `bold: true` combined with a dark foreground triggers terminal "bright mode," rendering the color as grey regardless of shade.
41
+
42
+ **Fix:** All button focus states now use **white text on dark green (`#2e7d32`) background** — the same high-contrast pattern already used by the Agents tab. Explicit `focus`/`blur` handlers were added to setup-tab modal buttons to prevent `attachBtnBlink` from interfering with blessed's passive `style.focus` color application.
43
+
44
+ ### 🐛 BMAD Tab Voice Picker ♪ Indicator Not Showing
45
+
46
+ The ♪ preview indicator in the BMAD tab voice list didn't appear during preview. The Agents tab was missing `_refreshVP()` calls that the Settings tab already had. A 2-second minimum display timer now keeps the indicator visible when SSH-remote exits immediately (fire-and-forget mode).
47
+
48
+ ### 🐛 Non-Interactive Install: Generic Pretext Instead of Project Name
49
+
50
+ Running `agentvibes install` non-interactively always set the pretext to `"Claude Code here"` regardless of project. The installer now derives a project-aware pretext from `path.basename(process.cwd())` with capitalization (e.g., `"MyProject here"`), with a safe fallback for Docker root paths.
51
+
52
+ ### 🐛 Global Pretext Overriding Per-Project Config
53
+
54
+ `seedAllLlmDefaultsSync` was seeding project-level LLM rows with the global pretext string, causing the global `"Claude Code here"` to override per-project `tts-pretext.txt` values. Project-level rows are now seeded with empty pretexts so the per-project file takes precedence.
55
+
56
+ ### 🐛 `screen`/`tmux` TERM Variant Caused `plab_norm` Capability Error
57
+
58
+ When `TERM` was set to a `screen-*` or `tmux-*` variant, blessed threw a `plab_norm` terminal capability error on startup. The app now overrides `TERM` to `xterm-256color` before creating the blessed screen when such a variant is detected.
59
+
60
+ ### 🐛 BMAD Per-Agent Music/Reverb Not Reaching SSH Receiver
61
+
62
+ `play-tts.sh` was not forwarding `AGENT_PROFILE_FILE` to the SSH remote transport, so per-agent background music and reverb overrides in the BMAD tab were silently ignored for remote audio. The profile file path is now passed as argument 4 to `play-tts-ssh-remote.sh`.
63
+
64
+ ### 🐛 Node 18 Compatibility: `import.meta.dirname` Replaced
65
+
66
+ A test file used `import.meta.dirname`, available only in Node 21+. Replaced with the `fileURLToPath(import.meta.url)` pattern so tests run correctly on Node 18 and 20.
67
+
68
+ ---
69
+
3
70
  ## 🎭 v5.7.0 — BMAD v6.6 Support + Windows Auto-Restart Watcher
4
71
 
5
72
  **Released:** 2026-05-11
@@ -121,7 +121,7 @@ Now we'll tell Claude Desktop to use AgentVibes.
121
121
  "mcpServers": {
122
122
  "agentvibes": {
123
123
  "command": "npx",
124
- "args": ["-y", "agentvibes@beta", "agentvibes-mcp-server"]
124
+ "args": ["-y", "-p", "agentvibes@beta", "agentvibes-mcp-server"]
125
125
  }
126
126
  }
127
127
  }
@@ -270,7 +270,7 @@ Your config file has syntax errors:
270
270
  "mcpServers": {
271
271
  "agentvibes": {
272
272
  "command": "npx",
273
- "args": ["-y", "agentvibes@beta", "agentvibes-mcp-server"]
273
+ "args": ["-y", "-p", "agentvibes@beta", "agentvibes-mcp-server"]
274
274
  }
275
275
  }
276
276
  }
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.7.4",
4
+ "version": "5.7.6",
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": [
@@ -138,7 +138,14 @@ export class AgentVibesConsole {
138
138
  return;
139
139
  }
140
140
 
141
+ // blessed's terminfo parser chokes on screen-256color's plab_norm capability.
142
+ // xterm-256color is functionally identical for our TUI and parses cleanly.
143
+ const _origTerm = process.env.TERM;
144
+ if (/^(screen|tmux)(-256color)?$/.test(process.env.TERM || '')) {
145
+ process.env.TERM = 'xterm-256color';
146
+ }
141
147
  this.screen = blessed.screen(this._screenOptions);
148
+ if (_origTerm !== undefined) process.env.TERM = _origTerm;
142
149
 
143
150
  // Reflow on terminal resize
144
151
  this.screen.on('resize', () => this.screen.render());
@@ -163,3 +163,22 @@ export function detectWavPlayer(env) {
163
163
  }
164
164
  return _detect(WAV_PLAYERS, env);
165
165
  }
166
+
167
+ /**
168
+ * Returns all installed WAV players in preference order.
169
+ * Used for fallback playback — try each until one succeeds.
170
+ * On Windows, always appends the PowerShell fallback.
171
+ *
172
+ * @param {Object} [env] - Environment (defaults to buildAudioEnv())
173
+ * @returns {Player[]}
174
+ */
175
+ export function getAllWavPlayers(env) {
176
+ env = env || buildAudioEnv();
177
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
178
+ const installed = WAV_PLAYERS.filter(p => {
179
+ const r = spawnSync(whichCmd, [p.bin], { stdio: 'pipe', env });
180
+ return r.status === 0;
181
+ });
182
+ if (process.platform === 'win32') installed.push(WIN_WAV_PLAYER);
183
+ return installed;
184
+ }
@@ -367,7 +367,7 @@ ${_tl('bmadDesc')}
367
367
  padding: { left: 1, right: 1 },
368
368
  style: {
369
369
  bg: COLORS.btnDefault,
370
- fg: 'white',
370
+ fg: 'bright-white',
371
371
  focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
372
372
  hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
373
373
  },
@@ -711,7 +711,7 @@ ${_tl('bmadDesc')}
711
711
  padding: { left: 1, right: 1 },
712
712
  style: {
713
713
  bg: COLORS.btnDefault,
714
- fg: 'white',
714
+ fg: 'bright-white',
715
715
  focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
716
716
  hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
717
717
  },
@@ -1139,8 +1139,6 @@ ${_tl('bmadDesc')}
1139
1139
  const inputBox = blessed.textbox({
1140
1140
  parent: editModal, top: 3, left: 2, right: 2, height: 3,
1141
1141
  border: { type: 'line' },
1142
- inputOnFocus: true,
1143
- value: draft.pretext,
1144
1142
  style: {
1145
1143
  fg: COLORS.valueFg, bg: '#0d1b35',
1146
1144
  border: { fg: COLORS.borderFg },
@@ -1149,22 +1147,78 @@ ${_tl('bmadDesc')}
1149
1147
  });
1150
1148
 
1151
1149
  let _editClosed = false;
1150
+ let _cursor = draft.pretext.length;
1151
+ inputBox.value = draft.pretext;
1152
+
1153
+ function _renderPretext() {
1154
+ const val = inputBox.value;
1155
+ const lpos = inputBox._getCoords();
1156
+ if (!lpos) { screen.render(); return; }
1157
+ const contentWidth = Math.max(1, (lpos.xl - lpos.xi) - inputBox.iwidth);
1158
+ const start = _cursor > contentWidth - 1 ? _cursor - contentWidth + 1 : 0;
1159
+ inputBox.setContent(val.slice(start));
1160
+ screen.render();
1161
+ screen.program.cup(lpos.yi + inputBox.itop, lpos.xi + inputBox.ileft + (_cursor - start));
1162
+ }
1163
+
1164
+ const _prevGrabKeys = screen.grabKeys;
1152
1165
  function _closeEdit(save) {
1153
1166
  if (_editClosed) return;
1154
1167
  _editClosed = true;
1168
+ inputBox.removeAllListeners('keypress');
1169
+ screen.grabKeys = _prevGrabKeys;
1170
+ screen.program.hideCursor();
1155
1171
  if (save) {
1156
- const raw = inputBox.getValue().trim();
1157
- // M7: enforce max pretext length
1158
- draft.pretext = (raw || draft.pretext).slice(0, MAX_PRETEXT_LENGTH);
1172
+ // M7: enforce max pretext length; allow clearing to empty string
1173
+ draft.pretext = inputBox.value.trim().slice(0, MAX_PRETEXT_LENGTH);
1159
1174
  }
1160
1175
  destroyList(editModal, screen, onDone);
1161
1176
  }
1162
1177
 
1163
- inputBox.key(['enter'], () => _closeEdit(true));
1164
- inputBox.key(['escape'], () => _closeEdit(false));
1178
+ // Guard: if editModal is destroyed externally (parent closed, signal, etc.)
1179
+ // without _closeEdit being called, restore grab state so TUI stays responsive.
1180
+ editModal.once('destroy', () => {
1181
+ if (!_editClosed) {
1182
+ _editClosed = true;
1183
+ inputBox.removeAllListeners('keypress');
1184
+ screen.grabKeys = _prevGrabKeys;
1185
+ screen.program.hideCursor();
1186
+ }
1187
+ });
1165
1188
 
1189
+ screen.grabKeys = true;
1166
1190
  inputBox.focus();
1167
- screen.render();
1191
+ screen.render(); // Layout pass so _getCoords() returns valid coords on first _renderPretext
1192
+ screen.program.showCursor();
1193
+ _renderPretext();
1194
+
1195
+ inputBox.on('keypress', function(ch, key) {
1196
+ if (_editClosed) return;
1197
+ const val = inputBox.value;
1198
+ if (key.name === 'enter') { _closeEdit(true); return; }
1199
+ if (key.name === 'escape') { _closeEdit(false); return; }
1200
+ if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
1201
+ _cursor = 0;
1202
+ } else if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
1203
+ _cursor = val.length;
1204
+ } else if (key.name === 'left') {
1205
+ if (_cursor > 0) _cursor--; else return;
1206
+ } else if (key.name === 'right') {
1207
+ if (_cursor < val.length) _cursor++; else return;
1208
+ } else if (key.name === 'backspace') {
1209
+ if (_cursor > 0) { inputBox.value = val.slice(0, _cursor - 1) + val.slice(_cursor); _cursor--; }
1210
+ else return;
1211
+ } else if (key.name === 'delete') {
1212
+ if (_cursor < val.length) { inputBox.value = val.slice(0, _cursor) + val.slice(_cursor + 1); }
1213
+ else return;
1214
+ } else if (ch && !key.ctrl && !key.meta && !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]$/.test(ch)) {
1215
+ inputBox.value = val.slice(0, _cursor) + ch + val.slice(_cursor);
1216
+ _cursor++;
1217
+ } else {
1218
+ return;
1219
+ }
1220
+ _renderPretext();
1221
+ });
1168
1222
  }
1169
1223
 
1170
1224
  // -------------------------------------------------------------------------
@@ -409,7 +409,7 @@ export function createMusicTab(screen, services) {
409
409
  padding: { left: 1, right: 1 },
410
410
  style: {
411
411
  bg: COLORS.btnDefault,
412
- fg: 'white',
412
+ fg: 'bright-white',
413
413
  focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
414
414
  hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
415
415
  },
@@ -423,7 +423,7 @@ export function createMusicTab(screen, services) {
423
423
  });
424
424
  btn.on('blur', () => {
425
425
  btn.style.bg = COLORS.btnDefault;
426
- btn.style.fg = 'white';
426
+ btn.style.fg = 'bright-white';
427
427
  const raw = btn.content.replace(/[►◄]/g, '').trim();
428
428
  btn.setContent(raw);
429
429
  screen.render();
@@ -776,7 +776,7 @@ export function createMusicTab(screen, services) {
776
776
  padding: { left: 1, right: 1 },
777
777
  style: {
778
778
  bg,
779
- fg: 'white',
779
+ fg: 'bright-white',
780
780
  focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
781
781
  hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
782
782
  },
@@ -819,7 +819,7 @@ export function createMusicTab(screen, services) {
819
819
  padding: { left: 1, right: 1 },
820
820
  style: {
821
821
  bg: '#e65100',
822
- fg: 'white',
822
+ fg: 'bright-white',
823
823
  focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
824
824
  hover: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
825
825
  },
@@ -201,6 +201,15 @@ export function createSettingsTab(screen, services) {
201
201
  },
202
202
  desc: 'Play audio locally or stream to a remote host via SSH',
203
203
  },
204
+ {
205
+ key: 'sshAlias',
206
+ label: 'SSH Host Alias',
207
+ getValue: () => {
208
+ const alias = configService?.getConfig?.()?.audio_ssh_alias ?? '';
209
+ return alias || '(not set)';
210
+ },
211
+ desc: 'SSH host alias from ~/.ssh/config used when Audio Destination is Remote',
212
+ },
204
213
  {
205
214
  key: 'configStorage',
206
215
  label: 'Config Storage',
@@ -395,6 +404,9 @@ export function createSettingsTab(screen, services) {
395
404
  case 'audioDst':
396
405
  _editAudioDst();
397
406
  break;
407
+ case 'sshAlias':
408
+ _editSshAlias();
409
+ break;
398
410
  case 'rerunWizard':
399
411
  _rerunWizard();
400
412
  break;
@@ -583,7 +595,7 @@ export function createSettingsTab(screen, services) {
583
595
  style: {
584
596
  fg: COLORS.labelFg, bg: COLORS.contentBg,
585
597
  border: { fg: 'blue' },
586
- selected: { bg: 'green', fg: 'white', bold: true },
598
+ selected: { bg: 'green', fg: 'black', bold: true },
587
599
  item: { fg: COLORS.labelFg },
588
600
  },
589
601
  });
@@ -864,6 +876,68 @@ export function createSettingsTab(screen, services) {
864
876
  screen.render();
865
877
  }
866
878
 
879
+ // ── SSH alias editor ─────────────────────────────────────────────────────
880
+
881
+ function _editSshAlias() {
882
+ navigationService?.openModal();
883
+
884
+ const aliases = _detectSshAliases().filter(a => !a.includes('github.com'));
885
+ const MANUAL_OPTION = ' Type manually...';
886
+ const items = [...aliases.map(a => ` ${a}`), MANUAL_OPTION];
887
+ const currentAlias = configService?.getConfig?.()?.audio_ssh_alias ?? '';
888
+
889
+ const listHeight = Math.min(items.length + 4, 18);
890
+ const modal = blessed.list({
891
+ parent: screen,
892
+ top: 'center',
893
+ left: 'center',
894
+ width: 44,
895
+ height: listHeight,
896
+ border: { type: 'line' },
897
+ tags: true,
898
+ label: ' {bold}{cyan-fg} Select SSH Host Alias {/cyan-fg}{/bold} ',
899
+ keys: true,
900
+ vi: true,
901
+ mouse: true,
902
+ style: {
903
+ fg: COLORS.labelFg,
904
+ bg: COLORS.contentBg,
905
+ border: { fg: 'cyan' },
906
+ selected: { bg: 'blue', fg: 'yellow' },
907
+ item: { fg: COLORS.labelFg },
908
+ },
909
+ });
910
+ modal.setFront();
911
+ modal.setItems(items);
912
+
913
+ const currentIdx = aliases.indexOf(currentAlias);
914
+ if (currentIdx >= 0) modal.select(currentIdx);
915
+
916
+ modal.key(['enter'], () => {
917
+ const selItem = items[modal.selected];
918
+ if (selItem === MANUAL_OPTION) {
919
+ _closeModal();
920
+ _promptSshAlias();
921
+ return;
922
+ }
923
+ const alias = selItem?.trim();
924
+ if (alias) configService.set('audio_ssh_alias', alias);
925
+ _closeModal();
926
+ });
927
+ modal.key(['escape', 'q'], _closeModal);
928
+
929
+ function _closeModal() {
930
+ navigationService?.closeModal();
931
+ destroyList(modal, screen);
932
+ _refreshValues();
933
+ box.focus();
934
+ screen.render();
935
+ }
936
+
937
+ modal.focus();
938
+ screen.render();
939
+ }
940
+
867
941
  // ── Re-run wizard ────────────────────────────────────────────────────────
868
942
 
869
943
  function _rerunWizard() {