agentvibes 3.5.9 → 4.0.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.
- package/.agentvibes/bmad/bmad-voices-enabled.flag +0 -0
- package/.agentvibes/bmad/bmad-voices.md +69 -0
- package/.claude/config/audio-effects.cfg +1 -1
- package/.claude/config/background-music-position.txt +1 -27
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-processor.sh +32 -17
- package/.claude/hooks/bmad-speak-enhanced.sh +5 -5
- package/.claude/hooks/bmad-speak.sh +4 -4
- package/.claude/hooks/bmad-voice-manager.sh +8 -8
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +23 -25
- package/.claude/hooks/clawdbot-receiver.sh +28 -4
- package/.claude/hooks/language-manager.sh +1 -1
- package/.claude/hooks/path-resolver.sh +60 -0
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -0
- package/.claude/hooks/play-tts-piper.sh +82 -24
- package/.claude/hooks/play-tts-ssh-remote.sh +13 -15
- package/.claude/hooks/play-tts.sh +16 -5
- package/.claude/hooks/session-start-tts.sh +26 -56
- package/.claude/hooks/soprano-gradio-synth.py +1 -1
- package/.claude/hooks/verbosity-manager.sh +10 -4
- package/.claude/settings.json +1 -1
- package/CLAUDE.md +129 -104
- package/README.md +418 -10
- package/RELEASE_NOTES.md +60 -1036
- package/bin/agentvibes-voice-browser.js +1827 -0
- package/bin/agentvibes.js +100 -0
- package/mcp-server/server.py +67 -3
- package/package.json +11 -2
- package/src/console/app.js +806 -0
- package/src/console/audio-env.js +123 -0
- package/src/console/brand-colors.js +13 -0
- package/src/console/footer-config.js +42 -0
- package/src/console/modals/.gitkeep +0 -0
- package/src/console/modals/modal-overlay.js +247 -0
- package/src/console/navigation.js +60 -0
- package/src/console/tabs/.gitkeep +0 -0
- package/src/console/tabs/agents-tab.js +369 -0
- package/src/console/tabs/help-tab.js +261 -0
- package/src/console/tabs/install-tab.js +990 -0
- package/src/console/tabs/music-tab.js +997 -0
- package/src/console/tabs/placeholder-tab.js +45 -0
- package/src/console/tabs/readme-tab.js +267 -0
- package/src/console/tabs/settings-tab.js +3949 -0
- package/src/console/tabs/voices-tab.js +1574 -0
- package/src/installer/music-file-input.js +304 -0
- package/src/installer.js +1353 -676
- package/src/services/.gitkeep +0 -0
- package/src/services/agent-voice-store.js +163 -0
- package/src/services/config-service.js +240 -0
- package/src/services/navigation-service.js +123 -0
- package/src/services/provider-service.js +132 -0
- package/src/services/verbosity-service.js +157 -0
- package/src/utils/audio-duration-validator.js +298 -0
- package/src/utils/audio-format-validator.js +277 -0
- package/src/utils/dependency-checker.js +3 -3
- package/src/utils/file-ownership-verifier.js +358 -0
- package/src/utils/music-file-validator.js +275 -0
- package/src/utils/preview-list-prompt.js +136 -0
- package/src/utils/provider-validator.js +144 -132
- package/src/utils/secure-music-storage.js +412 -0
- package/templates/agentvibes-receiver.sh +11 -7
- package/voice-assignments.json +8245 -0
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks/bmad-party-manager.sh +0 -225
- package/.claude/hooks/stop.sh +0 -38
- package/.claude/piper-voices-dir.txt +0 -1
- package/.mcp.json +0 -34
|
@@ -0,0 +1,3949 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentVibes TUI Console — Settings Tab
|
|
3
|
+
* Stories 7.1 (Provider & Voice) + 7.2 (Audio Effects)
|
|
4
|
+
*
|
|
5
|
+
* Implements the Tab Component Contract:
|
|
6
|
+
* createSettingsTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
|
|
7
|
+
*
|
|
8
|
+
* Groups 1-2 implemented. Groups 3-5 added in stories 7.3-7.5.
|
|
9
|
+
* Button-level focus navigation (↑↓←→) implemented in story 7.6.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
17
|
+
import {
|
|
18
|
+
PIPER_VOICES_DIR, COL_NAME_W, COL_GENDER_W, SAMPLE_PHRASES,
|
|
19
|
+
parseVoiceId, parseMultiSpeaker, scanInstalledVoices, getVoiceMeta, getFavorites, toggleFavorite,
|
|
20
|
+
} from './voices-tab.js';
|
|
21
|
+
import { formatTrackLabel, scanTracks, getMusicFavorites, toggleMusicFavorite, applyTrackToAudioEffects } from './music-tab.js';
|
|
22
|
+
import { BRAND_PINK, BRAND_BLUE } from '../brand-colors.js';
|
|
23
|
+
import { buildAudioEnv, detectMp3Player, detectWavPlayer } from '../audio-env.js';
|
|
24
|
+
|
|
25
|
+
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
26
|
+
|
|
27
|
+
// Sanitize strings before passing as env vars to shell commands.
|
|
28
|
+
// Removes characters that could cause shell injection when expanded inside sh -c.
|
|
29
|
+
function _sanitizeForShell(str) {
|
|
30
|
+
return str.replace(/[`$\\(){}!]/g, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Lazy-load blessed only in non-test mode (avoids screen requirement in tests)
|
|
34
|
+
let blessed;
|
|
35
|
+
if (!IS_TEST) {
|
|
36
|
+
const { default: b } = await import('blessed');
|
|
37
|
+
blessed = b;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Brand colours (matches architecture.md + UX design plan)
|
|
42
|
+
|
|
43
|
+
// Modal label helper — wraps text in BRAND_PINK for consistent modal titles
|
|
44
|
+
const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
|
|
45
|
+
|
|
46
|
+
const COLORS = {
|
|
47
|
+
contentBg: '#0a0e1a', // Near-black content background
|
|
48
|
+
sectionHdr: '#7986cb', // Light blue — section dividers
|
|
49
|
+
labelFg: '#e3f2fd', // Light blue text — labels
|
|
50
|
+
valueFg: '#ffff00', // Yellow — current values
|
|
51
|
+
btnDefault: '#37474f', // Dark slate — default button bg
|
|
52
|
+
btnFocus: '#00e5ff', // Cyan — focused button bg
|
|
53
|
+
btnFocusFg: '#000000', // Black — focused button text
|
|
54
|
+
btnPress: '#ff00ff', // Magenta — pressed button bg
|
|
55
|
+
btnChange: '#37474f', // Dark slate — Change buttons
|
|
56
|
+
btnTest: '#37474f', // Dark slate — Test buttons
|
|
57
|
+
btnEdit: '#37474f', // Dark slate — Edit buttons
|
|
58
|
+
btnEnableOn: '#37474f', // Dark slate — Enabled toggle
|
|
59
|
+
btnEnableOff: '#37474f', // Dark slate — Disabled toggle
|
|
60
|
+
borderFg: '#7986cb', // Light blue — borders
|
|
61
|
+
footerBg: '#2196f3', // Blue — settings footer
|
|
62
|
+
noticeFg: '#90a4ae', // Gray — stub notice text
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const FOOTER_TEXT =
|
|
66
|
+
'[↑↓] Group [←→] Sibling/Sub-tab [Enter/Space] Activate [Tab] Switch Tab [Q] Quit';
|
|
67
|
+
|
|
68
|
+
// Default effects — single source of truth (used by _getEffects, _setEffects, refreshDisplay)
|
|
69
|
+
const EFFECTS_DEFAULTS = Object.freeze({ reverbPreset: 'light' });
|
|
70
|
+
|
|
71
|
+
// Default background music config
|
|
72
|
+
const MUSIC_DEFAULTS = Object.freeze({ enabled: false, track: 'agentvibes_soft_flamenco_loop.mp3', volume: 70 });
|
|
73
|
+
|
|
74
|
+
// Verbosity display labels
|
|
75
|
+
const VERBOSITY_LABELS = Object.freeze({ high: 'High', medium: 'Medium', low: 'Low', minimal: 'Minimal', custom: 'Custom' });
|
|
76
|
+
|
|
77
|
+
// Personality emojis — mirrors installer.js personalityEmojis (src/installer.js:84)
|
|
78
|
+
const PERSONALITY_EMOJIS = Object.freeze({
|
|
79
|
+
angry: '😠',
|
|
80
|
+
annoying: '😤',
|
|
81
|
+
crass: '🤬',
|
|
82
|
+
dramatic: '🎭',
|
|
83
|
+
'dry-humor': '😐',
|
|
84
|
+
flirty: '😘',
|
|
85
|
+
funny: '😂',
|
|
86
|
+
grandpa: '👴',
|
|
87
|
+
millennial: '🙄',
|
|
88
|
+
moody: '😒',
|
|
89
|
+
none: '😊',
|
|
90
|
+
normal: '😊',
|
|
91
|
+
pirate: '⚓',
|
|
92
|
+
poetic: '📜',
|
|
93
|
+
professional: '👔',
|
|
94
|
+
rapper: '🎤',
|
|
95
|
+
robot: '🤖',
|
|
96
|
+
sarcastic: '😏',
|
|
97
|
+
sassy: '💁',
|
|
98
|
+
'surfer-dude':'🏄',
|
|
99
|
+
zen: '🧘',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Known personalities (matches .claude/personalities/ directory)
|
|
103
|
+
const PERSONALITIES = Object.freeze([
|
|
104
|
+
'none', 'angry', 'annoying', 'crass', 'dramatic', 'dry-humor',
|
|
105
|
+
'flirty', 'funny', 'grandpa', 'millennial', 'moody', 'normal',
|
|
106
|
+
'pirate', 'poetic', 'professional', 'rapper', 'robot', 'sarcastic',
|
|
107
|
+
'sassy', 'surfer-dude', 'zen',
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// Preview phrases — one short, exemplary, in-character line per personality.
|
|
111
|
+
// Spoken automatically when the cursor lands on a personality in the picker.
|
|
112
|
+
const PERSONALITY_PREVIEW_PHRASES = Object.freeze({
|
|
113
|
+
angry: "UNACCEPTABLE! This build time is a DISASTER! Fix it NOW or so help me!",
|
|
114
|
+
annoying: "Oh oh oh! Can I tell you something? Can I? Can I? PLEASE? It is so important!",
|
|
115
|
+
crass: "Well damn, that code runs like my uncle's truck. Barely, and it smells funny.",
|
|
116
|
+
dramatic: "The tests... have failed. I don't know how much longer I can do this.",
|
|
117
|
+
'dry-humor': "Your code worked. I too am surprised.",
|
|
118
|
+
flirty: "Ooh, a clean merge? You know exactly how to make my heart race.",
|
|
119
|
+
funny: "Why do programmers hate nature? Too many bugs. I will show myself out.",
|
|
120
|
+
grandpa: "Back in my day, we compiled by hand. Uphill. In the snow. Both ways.",
|
|
121
|
+
millennial: "I literally cannot even with this error. I am so done. Like, actually deceased.",
|
|
122
|
+
moody: "...It works. Whatever. Do not get used to it.",
|
|
123
|
+
pirate: "Arrr! The build be sailin' smooth today, matey! No barnacles in sight!",
|
|
124
|
+
poetic: "Like rivers to the sea, your code flows toward eventual compilation.",
|
|
125
|
+
professional: "I have completed the requested task and am prepared to document outcomes.",
|
|
126
|
+
rapper: "Yo! Clean code flowin', tests are glowin', no bugs showin'!",
|
|
127
|
+
robot: "TASK COMPLETE. EFFICIENCY: OPTIMAL. PROBABILITY OF SUCCESS: 97.3 PERCENT. BEEP.",
|
|
128
|
+
sarcastic: "Oh wow, another bug. What a completely unexpected surprise. Truly shocking.",
|
|
129
|
+
sassy: "Honey, whoever told you that was good code was not your friend.",
|
|
130
|
+
'surfer-dude':"Duuude! That commit totally shredded! Gnarly clean code, bro!",
|
|
131
|
+
zen: "The bug is not the enemy. The bug is the teacher. Breathe. Commit.",
|
|
132
|
+
random: "Who will I be today? Even I do not know. Expect the unexpected.",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Human-readable track display names — matches installer track picker (src/installer.js:2280)
|
|
136
|
+
const TRACK_NAMES = Object.freeze({
|
|
137
|
+
'agentvibes_soft_flamenco_loop.mp3': '🎻 Soft Flamenco',
|
|
138
|
+
'agent_vibes_bachata_v1_loop.mp3': '🎺 Bachata',
|
|
139
|
+
'agent_vibes_salsa_v2_loop.mp3': '💃 Salsa',
|
|
140
|
+
'agent_vibes_cumbia_v1_loop.mp3': '🎸 Cumbia',
|
|
141
|
+
'agent_vibes_bossa_nova_v2_loop.mp3': '🌸 Bossa Nova',
|
|
142
|
+
'agent_vibes_japanese_city_pop_v1_loop.mp3': '🌆 Japanese City Pop',
|
|
143
|
+
'agent_vibes_chillwave_v2_loop.mp3': '🌊 Chillwave',
|
|
144
|
+
'agent_vibes_dark_chill_step_loop.mp3': '🌙 Dark Chill Step',
|
|
145
|
+
'agent_vibes_goa_trance_v2_loop.mp3': '🌀 Goa Trance',
|
|
146
|
+
'agent_vibes_harpsichord_v2_loop.mp3': '🎼 Harpsichord',
|
|
147
|
+
'agent_vibes_celtic_harp_v1_loop.mp3': '🎻 Celtic Harp',
|
|
148
|
+
'agent_vibes_hawaiian_slack_key_guitar_v2_loop.mp3': '🌺 Hawaiian Slack Key Guitar',
|
|
149
|
+
'agent_vibes_arabic_v2_loop.mp3': '🎵 Arabic Oud',
|
|
150
|
+
'agent_vibes_ganawa_ambient_v2_loop.mp3': '🪘 Gnawa Ambient',
|
|
151
|
+
'agent_vibes_tabla_dream_pop_v1_loop.mp3': '🥁 Tabla Dream Pop',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Built-in track list for the picker (fallback when tracks dir is missing)
|
|
155
|
+
const BUILT_IN_TRACKS = [
|
|
156
|
+
{ label: '🎻 Soft Flamenco', file: 'agentvibes_soft_flamenco_loop.mp3' },
|
|
157
|
+
{ label: '🌸 Bossa Nova', file: 'agent_vibes_bossa_nova_v2_loop.mp3' },
|
|
158
|
+
{ label: '🌊 Chillwave', file: 'agent_vibes_chillwave_v2_loop.mp3' },
|
|
159
|
+
{ label: '🪘 Gnawa Ambient', file: 'agent_vibes_ganawa_ambient_v2_loop.mp3' },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Exported format helpers (pure functions — used by tests and UI)
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} preset - 'off' | 'light' | 'medium' | 'heavy' | 'cathedral'
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function formatReverbState(preset) {
|
|
170
|
+
const LABELS = { off: 'Off', light: 'Light (Small room)', medium: 'Medium (Conference room)', heavy: 'Heavy (Large hall)', cathedral: 'Cathedral (Epic space)' };
|
|
171
|
+
return LABELS[preset] ?? LABELS.light;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {boolean} enabled
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function formatMusicState(enabled) {
|
|
179
|
+
return enabled ? 'Enabled' : 'Disabled';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {number} volume - integer 10–100
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
export function formatVolume(volume) {
|
|
187
|
+
const v = typeof volume === 'number' && !isNaN(volume) ? volume : MUSIC_DEFAULTS.volume;
|
|
188
|
+
return `${Math.max(10, Math.min(100, v))}%`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {string} track - filename (e.g. 'agentvibes_soft_flamenco_loop.mp3')
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function formatTrackName(track) {
|
|
196
|
+
if (!track) return 'None';
|
|
197
|
+
if (TRACK_NAMES[track]) return TRACK_NAMES[track];
|
|
198
|
+
// Custom/unknown track: strip extension, agentvibes_/agent_vibes_ prefix,
|
|
199
|
+
// _v1/_v2/_loop/_v1_loop/_v2_loop suffixes, then title-case each word
|
|
200
|
+
return track
|
|
201
|
+
.replace(/\.[^.]+$/, '')
|
|
202
|
+
.replace(/^agentvibes_|^agent_vibes_/, '')
|
|
203
|
+
.replace(/_v\d+_loop$|_loop$|_v\d+$/, '')
|
|
204
|
+
.replace(/_/g, ' ')
|
|
205
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {string} verbosity - 'high' | 'medium' | 'low'
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function formatVerbosity(verbosity) {
|
|
213
|
+
return VERBOSITY_LABELS[verbosity] ?? 'High';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {string} personality
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
export function formatPersonality(personality) {
|
|
221
|
+
const name = personality || 'none';
|
|
222
|
+
const emoji = PERSONALITY_EMOJIS[name] ?? '✨';
|
|
223
|
+
const label = name === 'none' ? 'None' : name.charAt(0).toUpperCase() + name.slice(1);
|
|
224
|
+
return `${emoji} ${label}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {string} pretext - intro text (max 50 chars from installer)
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
export function formatIntroText(pretext) {
|
|
232
|
+
if (!pretext) return '(none)';
|
|
233
|
+
return pretext.length > 30 ? pretext.slice(0, 30) + '…' : pretext;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Test stub — returned in AGENTVIBES_TEST_MODE to avoid blessed widgets
|
|
238
|
+
|
|
239
|
+
function createTestStub() {
|
|
240
|
+
return {
|
|
241
|
+
box: {},
|
|
242
|
+
show: () => {},
|
|
243
|
+
hide: () => {},
|
|
244
|
+
onFocus: () => {},
|
|
245
|
+
onBlur: () => {},
|
|
246
|
+
getFooterText: () => FOOTER_TEXT,
|
|
247
|
+
getFooterColor: () => COLORS.footerBg,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create the Settings tab component.
|
|
255
|
+
* Follows the Tab Component Contract defined in architecture.md.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} screen - Blessed screen instance (or test stub)
|
|
258
|
+
* @param {object} services
|
|
259
|
+
* @param {import('../../services/config-service.js').ConfigService} services.configService
|
|
260
|
+
* @param {import('../../services/provider-service.js').ProviderService} services.providerService
|
|
261
|
+
* @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
|
|
262
|
+
*/
|
|
263
|
+
export function createSettingsTab(screen, services) {
|
|
264
|
+
if (IS_TEST) return createTestStub();
|
|
265
|
+
|
|
266
|
+
const { configService, providerService, navigationService, focusMainTabBar, focusFirstHeaderItem, focusLastHeaderItem, updateHeaderStatus } = services;
|
|
267
|
+
|
|
268
|
+
// Playback state for the voice sample button
|
|
269
|
+
let _sampleProcess = null;
|
|
270
|
+
let _samplePlaying = false;
|
|
271
|
+
|
|
272
|
+
// soprano-manager.sh wait-proc — tracked separately so killing it does NOT kill soprano-webui
|
|
273
|
+
let _sopranoMgrProc = null;
|
|
274
|
+
|
|
275
|
+
// Soprano WebUI status glyph — updated asynchronously when provider is soprano
|
|
276
|
+
let _sopranoStatusGlyph = '';
|
|
277
|
+
let _sopranoStatusProc = null;
|
|
278
|
+
|
|
279
|
+
const _sampleEnv = buildAudioEnv();
|
|
280
|
+
|
|
281
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
282
|
+
|
|
283
|
+
const SPINNER_PROCESSING_BG = '#00838f'; // teal — distinct from blue default and cyan focus
|
|
284
|
+
|
|
285
|
+
// Single-button spinner (▶ Play, music Test)
|
|
286
|
+
let _spinnerTimer = null;
|
|
287
|
+
let _spinnerIdx = 0;
|
|
288
|
+
let _spinnerBtn = null;
|
|
289
|
+
|
|
290
|
+
function _startSpinner(btn, label) {
|
|
291
|
+
_stopSpinner(); // Clear any running spinner so label updates don't leak intervals
|
|
292
|
+
_spinnerBtn = btn;
|
|
293
|
+
_spinnerIdx = 0;
|
|
294
|
+
btn.style.bg = SPINNER_PROCESSING_BG;
|
|
295
|
+
btn.setContent(`${SPINNER_FRAMES[0]} ${label}`);
|
|
296
|
+
screen.render();
|
|
297
|
+
_spinnerTimer = setInterval(() => {
|
|
298
|
+
_spinnerIdx = (_spinnerIdx + 1) % SPINNER_FRAMES.length;
|
|
299
|
+
btn.setContent(`${SPINNER_FRAMES[_spinnerIdx]} ${label}`);
|
|
300
|
+
screen.render();
|
|
301
|
+
}, 100);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _stopSpinner() {
|
|
305
|
+
if (_spinnerTimer) { clearInterval(_spinnerTimer); _spinnerTimer = null; }
|
|
306
|
+
if (_spinnerBtn) { _spinnerBtn.style.bg = COLORS.btnDefault; _spinnerBtn = null; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Multi-button spinner for _testBtns (reverb Test + Full Preview — each keeps its own label)
|
|
310
|
+
let _testSpinnerTimer = null;
|
|
311
|
+
let _testSpinnerIdx = 0;
|
|
312
|
+
// Populated alongside _testBtns so we know each button's rest label
|
|
313
|
+
const _testBtnLabels = new Map();
|
|
314
|
+
|
|
315
|
+
function _startTestSpinner() {
|
|
316
|
+
_testSpinnerIdx = 0;
|
|
317
|
+
for (const b of _testBtns) {
|
|
318
|
+
b.style.bg = SPINNER_PROCESSING_BG;
|
|
319
|
+
b.setContent(`${SPINNER_FRAMES[0]} ${_testBtnLabels.get(b) ?? '▶ Preview'}`);
|
|
320
|
+
}
|
|
321
|
+
screen.render();
|
|
322
|
+
_testSpinnerTimer = setInterval(() => {
|
|
323
|
+
_testSpinnerIdx = (_testSpinnerIdx + 1) % SPINNER_FRAMES.length;
|
|
324
|
+
for (const b of _testBtns) {
|
|
325
|
+
b.setContent(`${SPINNER_FRAMES[_testSpinnerIdx]} ${_testBtnLabels.get(b) ?? '▶ Preview'}`);
|
|
326
|
+
}
|
|
327
|
+
screen.render();
|
|
328
|
+
}, 100);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _stopTestSpinner() {
|
|
332
|
+
if (_testSpinnerTimer) { clearInterval(_testSpinnerTimer); _testSpinnerTimer = null; }
|
|
333
|
+
for (const b of _testBtns) { b.style.bg = COLORS.btnDefault; }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function _killSample() {
|
|
337
|
+
_stopSpinner();
|
|
338
|
+
// Kill manager wait-proc with direct SIGTERM (NOT process group) so soprano-webui keeps running
|
|
339
|
+
if (_sopranoMgrProc) {
|
|
340
|
+
try { _sopranoMgrProc.kill('SIGTERM'); } catch {}
|
|
341
|
+
_sopranoMgrProc = null;
|
|
342
|
+
}
|
|
343
|
+
if (_sampleProcess) {
|
|
344
|
+
try { process.kill(-_sampleProcess.pid, 'SIGTERM'); } catch {}
|
|
345
|
+
_sampleProcess = null;
|
|
346
|
+
}
|
|
347
|
+
_samplePlaying = false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Test button state (shared for reverb [Test])
|
|
351
|
+
let _testActive = false;
|
|
352
|
+
let _testMusicProc = null;
|
|
353
|
+
let _testVoiceProc = null;
|
|
354
|
+
let _testTimeout = null;
|
|
355
|
+
let _testInitiatorBtn = null; // button that started the current test (restored on completion)
|
|
356
|
+
const _testBtns = []; // populated after button creation
|
|
357
|
+
|
|
358
|
+
// Music-only test state (background music [Test] — no voice synthesis)
|
|
359
|
+
let _musicTestActive = false;
|
|
360
|
+
let _musicTestProc = null;
|
|
361
|
+
|
|
362
|
+
// Config Storage snapshot — taken when tab is shown, used by Cancel Changes
|
|
363
|
+
let _snapshotGlobal = null;
|
|
364
|
+
let _snapshotLocal = null;
|
|
365
|
+
|
|
366
|
+
function _captureSnapshot() {
|
|
367
|
+
try {
|
|
368
|
+
_snapshotGlobal = JSON.parse(JSON.stringify(configService.getGlobalConfig()));
|
|
369
|
+
const local = configService.getProjectConfig();
|
|
370
|
+
_snapshotLocal = local ? JSON.parse(JSON.stringify(local)) : null;
|
|
371
|
+
} catch {
|
|
372
|
+
_snapshotGlobal = {};
|
|
373
|
+
_snapshotLocal = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const _testEnv = buildAudioEnv();
|
|
378
|
+
|
|
379
|
+
function _killTest() {
|
|
380
|
+
_stopTestSpinner();
|
|
381
|
+
if (_testTimeout) { clearTimeout(_testTimeout); _testTimeout = null; }
|
|
382
|
+
if (_testMusicProc) { try { process.kill(-_testMusicProc.pid, 'SIGTERM'); } catch {} _testMusicProc = null; }
|
|
383
|
+
if (_testVoiceProc) { try { process.kill(-_testVoiceProc.pid, 'SIGTERM'); } catch {} _testVoiceProc = null; }
|
|
384
|
+
_testActive = false;
|
|
385
|
+
_testInitiatorBtn = null;
|
|
386
|
+
// Restore spinner labels to defaults (may have been overridden for soprano 'Loading model…')
|
|
387
|
+
_testBtnLabels.set(reverbTestBtn, '▶ Preview');
|
|
388
|
+
_testBtnLabels.set(personalityTestBtn, '▶ Preview');
|
|
389
|
+
_testBtnLabels.set(fullPreviewBtn, '▶ Full Preview');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function _setTestBtnsLabel(label) {
|
|
393
|
+
for (const b of _testBtns) { b.setContent(label); }
|
|
394
|
+
screen.render();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Restore each test button to its individual default label (from _testBtnLabels map).
|
|
398
|
+
// Replaces the old _restoreTestBtnsLabels() pattern which stomped on non-'Test' labels.
|
|
399
|
+
function _restoreTestBtnsLabels() {
|
|
400
|
+
for (const b of _testBtns) b.setContent(_testBtnLabels.get(b) ?? '▶ Preview');
|
|
401
|
+
screen.render();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Read a random example response from the personality's .md file.
|
|
405
|
+
// Returns null when no personality is set or the file can't be parsed.
|
|
406
|
+
function _getPersonalityPhrase(personality) {
|
|
407
|
+
if (!personality || personality === 'none' || personality === 'normal') return null;
|
|
408
|
+
if (personality.includes('..') || personality.includes('/') || personality.includes('\\')) return null;
|
|
409
|
+
try {
|
|
410
|
+
const file = path.join(process.cwd(), '.claude', 'personalities', personality + '.md');
|
|
411
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
412
|
+
const examples = lines
|
|
413
|
+
.filter(l => /^\s*- "/.test(l))
|
|
414
|
+
.map(l => l.trim().replace(/^- "/, '').replace(/"$/, '').trim())
|
|
415
|
+
.filter(Boolean);
|
|
416
|
+
return examples.length ? examples[Math.floor(Math.random() * examples.length)] : null;
|
|
417
|
+
} catch { return null; }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const _TEST_GREETINGS = [
|
|
421
|
+
'Hey', 'Hi there', 'Hello', 'Hey there', 'Howdy', 'Greetings', 'What\'s up',
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
function _testGreeting() {
|
|
425
|
+
return _TEST_GREETINGS[Math.floor(Math.random() * _TEST_GREETINGS.length)];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function _buildPreviewPhrase() {
|
|
429
|
+
const cfg = configService.getConfig();
|
|
430
|
+
const provider = providerService.getActiveProvider();
|
|
431
|
+
const _rawVoice = providerService.getActiveVoiceId() ?? 'unknown';
|
|
432
|
+
const _msV = parseMultiSpeaker(_rawVoice);
|
|
433
|
+
const voice = provider === 'soprano' ? 'Soprano' : (_msV.isMultiSpeaker ? _msV.speakerName : _rawVoice);
|
|
434
|
+
|
|
435
|
+
const effects = cfg.effects ?? {};
|
|
436
|
+
const reverbOn = effects.reverb !== false;
|
|
437
|
+
const reverbPreset = effects.reverbPreset ?? 'light';
|
|
438
|
+
|
|
439
|
+
const music = cfg.backgroundMusic ?? {};
|
|
440
|
+
const musicOn = music.enabled !== false;
|
|
441
|
+
const trackLabel = _stripLeadingEmoji(formatTrackName(music.track ?? ''));
|
|
442
|
+
|
|
443
|
+
const personality = (cfg.personality ?? '').trim();
|
|
444
|
+
const hasPersonality = personality && personality !== 'none' && personality !== 'normal';
|
|
445
|
+
|
|
446
|
+
const parts = [];
|
|
447
|
+
parts.push(`${_testGreeting()}.`);
|
|
448
|
+
parts.push('Agent Vibes here.');
|
|
449
|
+
|
|
450
|
+
let voicePart = `I am ${voice}`;
|
|
451
|
+
if (hasPersonality) voicePart += `, with ${personality} personality`;
|
|
452
|
+
voicePart += ',';
|
|
453
|
+
parts.push(voicePart);
|
|
454
|
+
|
|
455
|
+
if (reverbOn) parts.push(`reverb set at ${reverbPreset},`);
|
|
456
|
+
else parts.push('reverb off,');
|
|
457
|
+
|
|
458
|
+
if (musicOn) parts.push(`and background music set to ${trackLabel}.`);
|
|
459
|
+
else parts.push('and background music off.');
|
|
460
|
+
|
|
461
|
+
return parts.join(' ');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// withMusic=true → Full Preview (voice + reverb + background track)
|
|
465
|
+
// withMusic=false → Reverb Test (voice + reverb only, no background music)
|
|
466
|
+
// phraseOverride → speak this text instead of the full _buildPreviewPhrase() summary
|
|
467
|
+
function _runTest(withMusic = true, phraseOverride = null) {
|
|
468
|
+
if (_testActive) { _killTest(); _restoreTestBtnsLabels(); return; }
|
|
469
|
+
|
|
470
|
+
_testActive = true;
|
|
471
|
+
_testInitiatorBtn = _buttons[_currentIdx];
|
|
472
|
+
_startTestSpinner();
|
|
473
|
+
|
|
474
|
+
// Prefer the settings-tab key (backgroundMusic) over the music-tab key (music)
|
|
475
|
+
const musicCfg = configService.getConfig().backgroundMusic
|
|
476
|
+
?? configService.getConfig().music
|
|
477
|
+
?? {};
|
|
478
|
+
const trackId = musicCfg.track ?? 'agentvibes_soft_flamenco_loop.mp3';
|
|
479
|
+
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
480
|
+
const trackPath = path.resolve(tracksDir, trackId);
|
|
481
|
+
const safeMusic = path.resolve(tracksDir);
|
|
482
|
+
|
|
483
|
+
// Start background music loop (Full Preview only — not reverb Test)
|
|
484
|
+
if (withMusic) {
|
|
485
|
+
const trackExists = (trackPath.startsWith(safeMusic + path.sep) || trackPath === safeMusic)
|
|
486
|
+
&& (() => { try { fs.accessSync(trackPath); return true; } catch { return false; } })();
|
|
487
|
+
if (trackExists) {
|
|
488
|
+
const vol = musicCfg.volume ?? MUSIC_DEFAULTS.volume;
|
|
489
|
+
const volFraction = (Math.max(10, Math.min(100, vol)) / 100).toFixed(2);
|
|
490
|
+
const musicCmd = [
|
|
491
|
+
`ffplay -nodisp -loop 0 -loglevel quiet -volume ${vol} "${trackPath}"`,
|
|
492
|
+
`play "${trackPath}" repeat 9999 vol ${volFraction}`,
|
|
493
|
+
`mpg123 -q --loop -1 "${trackPath}"`,
|
|
494
|
+
].join(' 2>/dev/null || ') + ' 2>/dev/null';
|
|
495
|
+
_testMusicProc = spawn('sh', ['-c', musicCmd], {
|
|
496
|
+
stdio: 'ignore', detached: true, env: _testEnv,
|
|
497
|
+
});
|
|
498
|
+
_testMusicProc.unref();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Lead-in before voice synthesis.
|
|
503
|
+
// Soprano CLI loads the neural model fresh each call (cold-start: 5–120s depending on hardware).
|
|
504
|
+
// No artificial delay needed — music will be playing well before synthesis completes.
|
|
505
|
+
// The spinner label is updated to "Loading model…" so the user knows it's working.
|
|
506
|
+
const provider = providerService.getActiveProvider();
|
|
507
|
+
const leadInMs = provider === 'soprano' ? 0 : 2000;
|
|
508
|
+
if (provider === 'soprano') {
|
|
509
|
+
// Use "Synthesizing…" when WebUI is already warm, "Loading model…" when cold.
|
|
510
|
+
const sopranoLabel = _sopranoStatusGlyph === ' 🟢' ? 'Synthesizing…' : 'Loading model…';
|
|
511
|
+
for (const b of _testBtns) _testBtnLabels.set(b, sopranoLabel);
|
|
512
|
+
for (const b of _testBtns) b.setContent(`${SPINNER_FRAMES[0]} ${sopranoLabel}`);
|
|
513
|
+
screen.render();
|
|
514
|
+
}
|
|
515
|
+
_testTimeout = setTimeout(() => {
|
|
516
|
+
_testTimeout = null;
|
|
517
|
+
if (!_testActive) return;
|
|
518
|
+
|
|
519
|
+
const provider = providerService.getActiveProvider(); // re-read (may have changed)
|
|
520
|
+
const tempWav = path.join(os.tmpdir(), `agentvibes-test-${Date.now()}.wav`);
|
|
521
|
+
const ttsInput = phraseOverride ?? _buildPreviewPhrase();
|
|
522
|
+
|
|
523
|
+
let synthProc;
|
|
524
|
+
if (provider === 'soprano') {
|
|
525
|
+
const port = process.env.SOPRANO_PORT || '7860';
|
|
526
|
+
const synther = path.resolve(new URL(import.meta.url).pathname,
|
|
527
|
+
'..', '..', '..', '..', '.claude', 'hooks', 'soprano-gradio-synth.py');
|
|
528
|
+
const sopranoEnv = {
|
|
529
|
+
...(_testEnv),
|
|
530
|
+
_AV_PHRASE: _sanitizeForShell(ttsInput),
|
|
531
|
+
_AV_WAV: tempWav,
|
|
532
|
+
_AV_SYNTHER: synther,
|
|
533
|
+
_AV_PORT: String(port),
|
|
534
|
+
};
|
|
535
|
+
// Mirror the Play button: try Gradio WebUI → OpenAI-compat API → CLI fallback.
|
|
536
|
+
// This keeps the model warm instead of cold-loading it on every Test press.
|
|
537
|
+
const cmd = [
|
|
538
|
+
`python3 "$_AV_SYNTHER" "$_AV_PHRASE" "$_AV_WAV" "$_AV_PORT" 2>/dev/null`,
|
|
539
|
+
`curl -sf --max-time 30 "http://127.0.0.1:$_AV_PORT/v1/audio/speech"` +
|
|
540
|
+
` -H "Content-Type: application/json"` +
|
|
541
|
+
` -d "{\\"input\\":\\"$_AV_PHRASE\\"}" --output "$_AV_WAV" 2>/dev/null`,
|
|
542
|
+
`soprano "$_AV_PHRASE" -o "$_AV_WAV"`,
|
|
543
|
+
].join(' || ');
|
|
544
|
+
synthProc = spawn('sh', ['-c', cmd], {
|
|
545
|
+
stdio: 'ignore', detached: true, env: sopranoEnv,
|
|
546
|
+
});
|
|
547
|
+
} else {
|
|
548
|
+
const voiceId = providerService.getActiveVoiceId();
|
|
549
|
+
if (!voiceId) { _killTest(); _restoreTestBtnsLabels(); return; }
|
|
550
|
+
const _ms = parseMultiSpeaker(voiceId);
|
|
551
|
+
const voicePath = path.resolve(PIPER_VOICES_DIR, _ms.model + '.onnx');
|
|
552
|
+
const safePiper = path.resolve(PIPER_VOICES_DIR);
|
|
553
|
+
if (!voicePath.startsWith(safePiper + path.sep) && voicePath !== safePiper) {
|
|
554
|
+
_killTest(); _restoreTestBtnsLabels(); return;
|
|
555
|
+
}
|
|
556
|
+
const _piperArgs = ['--model', voicePath, '--output_file', tempWav];
|
|
557
|
+
if (_ms.speakerId != null) _piperArgs.push('--speaker', String(_ms.speakerId));
|
|
558
|
+
synthProc = spawn('piper', _piperArgs, {
|
|
559
|
+
stdio: ['pipe', 'ignore', 'ignore'], detached: true, env: _testEnv,
|
|
560
|
+
});
|
|
561
|
+
synthProc.stdin.write(ttsInput + '\n');
|
|
562
|
+
synthProc.stdin.end();
|
|
563
|
+
}
|
|
564
|
+
synthProc.unref();
|
|
565
|
+
_testVoiceProc = synthProc;
|
|
566
|
+
|
|
567
|
+
synthProc.on('exit', (code) => {
|
|
568
|
+
if (!_testActive || code !== 0) {
|
|
569
|
+
_killTest(); _restoreTestBtnsLabels();
|
|
570
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Apply sox reverb based on current preset
|
|
575
|
+
const effectsScript = path.join(process.cwd(), '.claude', 'hooks', 'effects-manager.sh');
|
|
576
|
+
const presetResult = spawnSync('bash', [effectsScript, 'get-reverb', 'default'], {
|
|
577
|
+
encoding: 'utf8', timeout: 3000, env: _testEnv,
|
|
578
|
+
});
|
|
579
|
+
const preset = (presetResult.stdout || '').trim();
|
|
580
|
+
|
|
581
|
+
const SOX_REVERB = {
|
|
582
|
+
light: 'reverb 20 50 50',
|
|
583
|
+
medium: 'reverb 40 50 70',
|
|
584
|
+
heavy: 'reverb 70 50 100',
|
|
585
|
+
cathedral: 'reverb 90 30 100',
|
|
586
|
+
};
|
|
587
|
+
const soxFx = SOX_REVERB[preset];
|
|
588
|
+
|
|
589
|
+
let wavToPlay = tempWav;
|
|
590
|
+
let processedWav = null;
|
|
591
|
+
|
|
592
|
+
if (soxFx) {
|
|
593
|
+
processedWav = path.join(os.tmpdir(), `agentvibes-test-fx-${Date.now()}.wav`);
|
|
594
|
+
spawnSync('sox', [tempWav, processedWav, ...soxFx.split(' ')], {
|
|
595
|
+
stdio: 'ignore', timeout: 5000, env: _testEnv,
|
|
596
|
+
});
|
|
597
|
+
// Use processed wav if sox succeeded
|
|
598
|
+
try {
|
|
599
|
+
fs.accessSync(processedWav);
|
|
600
|
+
wavToPlay = processedWav;
|
|
601
|
+
} catch {
|
|
602
|
+
processedWav = null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
_stopTestSpinner();
|
|
607
|
+
_setTestBtnsLabel('■ Stop');
|
|
608
|
+
const _wavPlayer1 = detectWavPlayer(_testEnv);
|
|
609
|
+
const playProc = _wavPlayer1
|
|
610
|
+
? spawn(_wavPlayer1.bin, _wavPlayer1.args(wavToPlay), { stdio: 'ignore', detached: true, env: _testEnv })
|
|
611
|
+
: null;
|
|
612
|
+
if (!playProc) { _killTest(); _restoreTestBtnsLabels(); return; }
|
|
613
|
+
_testVoiceProc = playProc;
|
|
614
|
+
playProc.on('exit', () => {
|
|
615
|
+
const btn = _testInitiatorBtn;
|
|
616
|
+
_killTest(); _restoreTestBtnsLabels();
|
|
617
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
618
|
+
if (processedWav) { try { fs.unlinkSync(processedWav); } catch {} }
|
|
619
|
+
if (btn && !btn.hidden) setImmediate(() => _focusButton(btn));
|
|
620
|
+
});
|
|
621
|
+
playProc.on('error', () => {
|
|
622
|
+
const btn = _testInitiatorBtn;
|
|
623
|
+
_killTest(); _restoreTestBtnsLabels();
|
|
624
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
625
|
+
if (processedWav) { try { fs.unlinkSync(processedWav); } catch {} }
|
|
626
|
+
if (btn && !btn.hidden) setImmediate(() => _focusButton(btn));
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
synthProc.on('error', () => {
|
|
631
|
+
const btn = _testInitiatorBtn;
|
|
632
|
+
_killTest(); _restoreTestBtnsLabels();
|
|
633
|
+
if (btn && !btn.hidden) setImmediate(() => _focusButton(btn));
|
|
634
|
+
});
|
|
635
|
+
}, leadInMs);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function _killMusicTest() {
|
|
639
|
+
if (_musicTestProc) {
|
|
640
|
+
try { process.kill(-_musicTestProc.pid, 'SIGTERM'); } catch {}
|
|
641
|
+
_musicTestProc = null;
|
|
642
|
+
}
|
|
643
|
+
_musicTestActive = false;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function _runMusicTest() {
|
|
647
|
+
if (_musicTestActive) {
|
|
648
|
+
_killMusicTest();
|
|
649
|
+
musicTestBtn.setContent('▶ Preview');
|
|
650
|
+
screen.render();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const musicCfg = configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
|
|
655
|
+
if (!musicCfg.enabled) {
|
|
656
|
+
// Show a small popup offering to enable music on the spot
|
|
657
|
+
const modal = blessed.box({
|
|
658
|
+
parent: screen, top: 'center', left: 'center', width: 46, height: 7,
|
|
659
|
+
border: { type: 'line' },
|
|
660
|
+
tags: true,
|
|
661
|
+
label: _modalTitle('Background Music'),
|
|
662
|
+
style: { bg: COLORS.contentBg, border: { fg: COLORS.btnFocus } },
|
|
663
|
+
});
|
|
664
|
+
blessed.text({
|
|
665
|
+
parent: modal, top: 1, left: 2, tags: true,
|
|
666
|
+
content: '{#e3f2fd-fg}Music is disabled. Enable it now?{/#e3f2fd-fg}',
|
|
667
|
+
style: { bg: COLORS.contentBg },
|
|
668
|
+
});
|
|
669
|
+
function _closeConfirm() {
|
|
670
|
+
modal.destroy();
|
|
671
|
+
try {
|
|
672
|
+
for (let r = 0; r < screen.height; r++)
|
|
673
|
+
for (let c = 0; c < screen.width; c++)
|
|
674
|
+
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
675
|
+
} catch {}
|
|
676
|
+
_restoreFocus();
|
|
677
|
+
screen.render();
|
|
678
|
+
}
|
|
679
|
+
const enableBtn = _createButton(modal, screen, 'Enable', COLORS, () => {
|
|
680
|
+
_closeConfirm();
|
|
681
|
+
_setMusic(configService, { enabled: true });
|
|
682
|
+
refreshDisplay();
|
|
683
|
+
_runMusicTest();
|
|
684
|
+
});
|
|
685
|
+
enableBtn.top = 4; enableBtn.left = 4;
|
|
686
|
+
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeConfirm);
|
|
687
|
+
cancelBtn.top = 4; cancelBtn.left = 16;
|
|
688
|
+
modal.key(['escape', 'q'], _closeConfirm);
|
|
689
|
+
enableBtn.key(['right', 'tab'], () => { cancelBtn.focus(); screen.render(); });
|
|
690
|
+
cancelBtn.key(['left', 'S-tab'], () => { enableBtn.focus(); screen.render(); });
|
|
691
|
+
modal.setFront();
|
|
692
|
+
enableBtn.focus();
|
|
693
|
+
screen.render();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const trackId = musicCfg.track ?? MUSIC_DEFAULTS.track;
|
|
697
|
+
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
698
|
+
const trackPath = path.resolve(tracksDir, trackId);
|
|
699
|
+
const safeBase = path.resolve(tracksDir);
|
|
700
|
+
if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
|
|
701
|
+
|
|
702
|
+
_musicTestActive = true;
|
|
703
|
+
|
|
704
|
+
// Apply volume: ffplay 0-100, sox vol 0.0-1.0
|
|
705
|
+
const vol = musicCfg.volume ?? MUSIC_DEFAULTS.volume;
|
|
706
|
+
const volFraction = (Math.max(10, Math.min(100, vol)) / 100).toFixed(2);
|
|
707
|
+
|
|
708
|
+
// Play up to 10 seconds of the track (music-only, no voice)
|
|
709
|
+
const cmd = [
|
|
710
|
+
`ffplay -nodisp -t 10 -loglevel quiet -volume ${vol} "${trackPath}"`,
|
|
711
|
+
`play "${trackPath}" trim 0 10 vol ${volFraction}`,
|
|
712
|
+
`mpg123 -q "${trackPath}"`,
|
|
713
|
+
].join(' 2>/dev/null || ') + ' 2>/dev/null';
|
|
714
|
+
|
|
715
|
+
_musicTestProc = spawn('sh', ['-c', cmd], {
|
|
716
|
+
stdio: 'ignore', detached: true, env: _testEnv,
|
|
717
|
+
});
|
|
718
|
+
_musicTestProc.unref();
|
|
719
|
+
musicTestBtn.setContent('■ Stop');
|
|
720
|
+
screen.render();
|
|
721
|
+
|
|
722
|
+
_musicTestProc.on('exit', () => {
|
|
723
|
+
if (_musicTestActive) {
|
|
724
|
+
_killMusicTest();
|
|
725
|
+
musicTestBtn.setContent('▶ Preview');
|
|
726
|
+
_focusButton(musicTestBtn);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
_musicTestProc.on('error', () => {
|
|
730
|
+
_killMusicTest();
|
|
731
|
+
musicTestBtn.setContent('▶ Preview');
|
|
732
|
+
_focusButton(musicTestBtn);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// -------------------------------------------------------------------------
|
|
737
|
+
// Audio destination helpers
|
|
738
|
+
|
|
739
|
+
function _detectSshAliases() {
|
|
740
|
+
try {
|
|
741
|
+
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
|
|
742
|
+
const raw = fs.readFileSync(sshConfigPath, 'utf8');
|
|
743
|
+
const aliases = [];
|
|
744
|
+
for (const line of raw.split('\n')) {
|
|
745
|
+
const m = line.match(/^Host\s+(\S+)/i);
|
|
746
|
+
if (m && !m[1].includes('*') && !m[1].includes('?')) aliases.push(m[1]);
|
|
747
|
+
}
|
|
748
|
+
return aliases;
|
|
749
|
+
} catch {
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function formatAudioDst(dst, alias) {
|
|
755
|
+
if (dst === 'remote') return `Remote → ${alias || '(no alias set)'}`;
|
|
756
|
+
return 'Local Speakers';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// -------------------------------------------------------------------------
|
|
760
|
+
// Container box — fills content area, hidden until activated
|
|
761
|
+
|
|
762
|
+
const box = blessed.box({
|
|
763
|
+
parent: screen,
|
|
764
|
+
top: 4, // Below header (row 0-2) + tab bar (row 3)
|
|
765
|
+
left: 0,
|
|
766
|
+
width: '100%',
|
|
767
|
+
bottom: 2, // Above context footer + GitHub footer
|
|
768
|
+
hidden: true,
|
|
769
|
+
scrollable: true,
|
|
770
|
+
alwaysScroll: true,
|
|
771
|
+
mouse: true,
|
|
772
|
+
scrollbar: { ch: '│', track: { bg: '#1e2a3a' }, style: { fg: COLORS.borderFg } },
|
|
773
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.borderFg } },
|
|
774
|
+
border: { type: 'line' },
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
// -------------------------------------------------------------------------
|
|
779
|
+
// Sub-tab bar — Voice | Effects | Personality | Output
|
|
780
|
+
|
|
781
|
+
const SUB_TABS = ['voice', 'effects', 'personality', 'output'];
|
|
782
|
+
const SUB_TAB_LABELS = {
|
|
783
|
+
voice: ' [V] Voice ',
|
|
784
|
+
effects: ' [E] Effects ',
|
|
785
|
+
personality: ' [P] Personality ',
|
|
786
|
+
output: ' [O] Output ',
|
|
787
|
+
};
|
|
788
|
+
let _activeSubTab = 'voice';
|
|
789
|
+
|
|
790
|
+
const _subTabBar = blessed.box({
|
|
791
|
+
parent: box, top: 1, left: 1, height: 1,
|
|
792
|
+
style: { bg: COLORS.contentBg },
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
blessed.text({
|
|
796
|
+
parent: box, top: 2, left: 1, right: 1,
|
|
797
|
+
content: '─'.repeat(80),
|
|
798
|
+
style: { fg: '#37474f', bg: COLORS.contentBg },
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
const _subTabItemsMap = {};
|
|
802
|
+
let _xOff = 0;
|
|
803
|
+
for (const id of SUB_TABS) {
|
|
804
|
+
const lbl = SUB_TAB_LABELS[id];
|
|
805
|
+
const item = blessed.box({
|
|
806
|
+
parent: _subTabBar,
|
|
807
|
+
content: lbl, width: lbl.length, height: 1,
|
|
808
|
+
top: 0, left: _xOff,
|
|
809
|
+
keys: true, focusable: true,
|
|
810
|
+
style: { fg: '#00e5ff', bg: '#263238' },
|
|
811
|
+
});
|
|
812
|
+
_subTabItemsMap[id] = item;
|
|
813
|
+
_xOff += lbl.length;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function _updateSubTabBar() {
|
|
817
|
+
for (const id of SUB_TABS) {
|
|
818
|
+
const item = _subTabItemsMap[id];
|
|
819
|
+
if (id === _activeSubTab) {
|
|
820
|
+
item.style.fg = 'white';
|
|
821
|
+
item.style.bg = '#0288d1'; // light blue — active tab
|
|
822
|
+
item.style.bold = true;
|
|
823
|
+
} else {
|
|
824
|
+
item.style.fg = '#00e5ff';
|
|
825
|
+
item.style.bg = '#263238';
|
|
826
|
+
item.style.bold = false;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Focused sub-tab item turns purple + blinking █; blur restores active/inactive colours
|
|
832
|
+
for (const id of SUB_TABS) {
|
|
833
|
+
const item = _subTabItemsMap[id];
|
|
834
|
+
const _stBase = SUB_TAB_LABELS[id];
|
|
835
|
+
const _stBlock = _stBase.slice(0, -1) + '█'; // replace trailing space with block
|
|
836
|
+
let _stInterval = null;
|
|
837
|
+
item.on('focus', () => {
|
|
838
|
+
item.style.fg = 'white';
|
|
839
|
+
item.style.bg = '#9c27b0';
|
|
840
|
+
item.style.bold = true;
|
|
841
|
+
let _stOn = true;
|
|
842
|
+
item.setContent(_stBlock);
|
|
843
|
+
screen.render();
|
|
844
|
+
_stInterval = setInterval(() => {
|
|
845
|
+
_stOn = !_stOn;
|
|
846
|
+
item.setContent(_stOn ? _stBlock : _stBase);
|
|
847
|
+
screen.render();
|
|
848
|
+
}, 500);
|
|
849
|
+
});
|
|
850
|
+
item.on('blur', () => {
|
|
851
|
+
if (_stInterval) { clearInterval(_stInterval); _stInterval = null; }
|
|
852
|
+
item.setContent(_stBase);
|
|
853
|
+
_updateSubTabBar();
|
|
854
|
+
screen.render();
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// -------------------------------------------------------------------------
|
|
859
|
+
// Provider row: label + value + [Switch] button
|
|
860
|
+
|
|
861
|
+
const providerLabel = blessed.text({
|
|
862
|
+
parent: box,
|
|
863
|
+
top: 3,
|
|
864
|
+
left: 6,
|
|
865
|
+
content: 'Provider:',
|
|
866
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
const providerValue = blessed.text({
|
|
870
|
+
parent: box,
|
|
871
|
+
top: 3,
|
|
872
|
+
left: 22,
|
|
873
|
+
width: 26, // truncate before [Switch] at left:40
|
|
874
|
+
wrap: false,
|
|
875
|
+
content: '', // populated by refreshDisplay()
|
|
876
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
const switchBtn = _createButton(box, screen, 'Switch', COLORS, () => {
|
|
880
|
+
_openProviderPicker(screen, providerService, (selected) => {
|
|
881
|
+
providerService.setActiveProvider(selected);
|
|
882
|
+
refreshDisplay();
|
|
883
|
+
_buttons[_currentIdx].focus();
|
|
884
|
+
screen.render();
|
|
885
|
+
}, _restoreFocus);
|
|
886
|
+
});
|
|
887
|
+
switchBtn.top = 3;
|
|
888
|
+
switchBtn.left = 52;
|
|
889
|
+
|
|
890
|
+
// -------------------------------------------------------------------------
|
|
891
|
+
// Voice row: label + value + [Change] button (stub for story 7-8)
|
|
892
|
+
|
|
893
|
+
const voiceLabel = blessed.text({
|
|
894
|
+
parent: box,
|
|
895
|
+
top: 5,
|
|
896
|
+
left: 6,
|
|
897
|
+
content: 'Current Voice:',
|
|
898
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
const voiceValue = blessed.text({
|
|
902
|
+
parent: box,
|
|
903
|
+
top: 5,
|
|
904
|
+
left: 22,
|
|
905
|
+
width: 26, // truncate before [Change] at left:40
|
|
906
|
+
wrap: false,
|
|
907
|
+
content: '', // populated by refreshDisplay()
|
|
908
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const changeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
912
|
+
if (changeBtn.hidden) return; // Guard: non-piper providers hide this button
|
|
913
|
+
_openVoiceBrowserModal(screen, providerService, configService, navigationService, () => {
|
|
914
|
+
refreshDisplay();
|
|
915
|
+
_buttons[_currentIdx].focus();
|
|
916
|
+
screen.render();
|
|
917
|
+
}, _restoreFocus);
|
|
918
|
+
}, { bg: COLORS.btnChange });
|
|
919
|
+
changeBtn.top = 5;
|
|
920
|
+
changeBtn.left = 52;
|
|
921
|
+
|
|
922
|
+
const playBtn = _createButton(box, screen, '▶ Play', COLORS, () => {
|
|
923
|
+
if (_samplePlaying) {
|
|
924
|
+
_killSample();
|
|
925
|
+
playBtn.setContent('▶ Play');
|
|
926
|
+
screen.render();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const provider = providerService.getActiveProvider();
|
|
931
|
+
const _activePers = (configService.getConfig().personality ?? '').trim();
|
|
932
|
+
const _hasPersonality = _activePers && _activePers !== 'none' && _activePers !== 'normal';
|
|
933
|
+
const _rawPlay = providerService.getActiveVoiceId() ?? 'this voice';
|
|
934
|
+
const _msPlay = parseMultiSpeaker(_rawPlay);
|
|
935
|
+
let phrase = `${_testGreeting()}. Agent Vibes here. I am ${provider === 'soprano' ? 'Soprano' : (_msPlay.isMultiSpeaker ? _msPlay.speakerName : _rawPlay)}`;
|
|
936
|
+
if (_hasPersonality) phrase += `, with ${_activePers} personality`;
|
|
937
|
+
phrase += '.';
|
|
938
|
+
const tempWav = path.join(os.tmpdir(), `agentvibes-sample-${Date.now()}.wav`);
|
|
939
|
+
|
|
940
|
+
_samplePlaying = true;
|
|
941
|
+
|
|
942
|
+
const _onSynthDone = (code) => {
|
|
943
|
+
_stopSpinner();
|
|
944
|
+
if (!_samplePlaying) { try { fs.unlinkSync(tempWav); } catch {} return; }
|
|
945
|
+
if (code !== 0) {
|
|
946
|
+
_killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn);
|
|
947
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
playBtn.setContent('■ Stop');
|
|
951
|
+
screen.render();
|
|
952
|
+
const _wavPlayer2 = detectWavPlayer(_sampleEnv);
|
|
953
|
+
if (!_wavPlayer2) { _stopSpinner(); _killSample(); playBtn.setContent('▶ Play'); screen.render(); return; }
|
|
954
|
+
const playProc = spawn(_wavPlayer2.bin, _wavPlayer2.args(tempWav), { stdio: 'ignore', detached: true, env: _sampleEnv });
|
|
955
|
+
_sampleProcess = playProc;
|
|
956
|
+
const _done = () => { _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn); try { fs.unlinkSync(tempWav); } catch {} };
|
|
957
|
+
playProc.on('exit', _done);
|
|
958
|
+
playProc.on('error', _done);
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
if (provider === 'soprano') {
|
|
962
|
+
const port = process.env.SOPRANO_PORT || '7860';
|
|
963
|
+
const synther = path.resolve(new URL(import.meta.url).pathname,
|
|
964
|
+
'..', '..', '..', '..', '.claude', 'hooks', 'soprano-gradio-synth.py');
|
|
965
|
+
const managerPath = path.resolve(new URL(import.meta.url).pathname,
|
|
966
|
+
'..', '..', '..', '..', '.claude', 'hooks', 'soprano-manager.sh');
|
|
967
|
+
|
|
968
|
+
// Fast-path: cached green status glyph confirms WebUI is healthy — skip manager wait.
|
|
969
|
+
// @why soprano-manager does an HTTP health check (up to 2s) even when already running;
|
|
970
|
+
// if _refreshSopranoStatus() already confirmed 🟢 we can synthesize immediately.
|
|
971
|
+
if (_sopranoStatusGlyph === ' 🟢') {
|
|
972
|
+
_doSopranoSynth(true);
|
|
973
|
+
} else {
|
|
974
|
+
// Ask soprano-manager to ensure the WebUI is running and wait until healthy.
|
|
975
|
+
// If already running: exits in <200ms. If cold-starting: blocks up to 60s.
|
|
976
|
+
// Progressive label updates tell the user how long it's been waiting.
|
|
977
|
+
_startSpinner(playBtn, 'Starting Soprano…');
|
|
978
|
+
|
|
979
|
+
let _startSecs = 0;
|
|
980
|
+
const _startLabelTimer = setInterval(() => {
|
|
981
|
+
if (!_samplePlaying) { clearInterval(_startLabelTimer); return; }
|
|
982
|
+
_startSecs += 10;
|
|
983
|
+
// Re-call _startSpinner to replace the label; it stops the old interval first.
|
|
984
|
+
_startSpinner(playBtn, `Starting Soprano… (${_startSecs}s)`);
|
|
985
|
+
}, 10000);
|
|
986
|
+
|
|
987
|
+
if (fs.existsSync(managerPath)) {
|
|
988
|
+
const mgrProc = spawn('bash', [managerPath, 'start', '--wait'], {
|
|
989
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
990
|
+
env: { ...process.env, SOPRANO_PORT: port },
|
|
991
|
+
});
|
|
992
|
+
_sopranoMgrProc = mgrProc; // tracked separately — kill() won't cascade to soprano-webui
|
|
993
|
+
|
|
994
|
+
mgrProc.on('exit', (code) => {
|
|
995
|
+
clearInterval(_startLabelTimer);
|
|
996
|
+
_sopranoMgrProc = null;
|
|
997
|
+
if (!_samplePlaying) return;
|
|
998
|
+
if (code === 5) {
|
|
999
|
+
// soprano-webui binary not installed
|
|
1000
|
+
_stopSpinner();
|
|
1001
|
+
_killSample();
|
|
1002
|
+
playBtn.setContent('▶ Play');
|
|
1003
|
+
_showNotice(screen, 'Soprano not installed — run: pip install soprano-tts');
|
|
1004
|
+
_refreshSopranoStatus();
|
|
1005
|
+
_focusButton(playBtn);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
// code 0 = WebUI ready (Synthesizing…); any other = timed out/error (Loading model…)
|
|
1009
|
+
_doSopranoSynth(code === 0);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
mgrProc.on('error', () => {
|
|
1013
|
+
clearInterval(_startLabelTimer);
|
|
1014
|
+
_sopranoMgrProc = null;
|
|
1015
|
+
if (_samplePlaying) _doSopranoSynth(false);
|
|
1016
|
+
});
|
|
1017
|
+
} else {
|
|
1018
|
+
// soprano-manager.sh not present — fall back to direct 2s HTTP check
|
|
1019
|
+
clearInterval(_startLabelTimer);
|
|
1020
|
+
const checkReq = http.get(
|
|
1021
|
+
`http://127.0.0.1:${port}/gradio_api/info`,
|
|
1022
|
+
{ timeout: 2000 },
|
|
1023
|
+
(res) => { res.resume(); _doSopranoSynth(true); },
|
|
1024
|
+
);
|
|
1025
|
+
checkReq.on('error', () => _doSopranoSynth(false));
|
|
1026
|
+
checkReq.on('timeout', () => { checkReq.destroy(); _doSopranoSynth(false); });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function _doSopranoSynth(webUIUp) {
|
|
1031
|
+
if (!_samplePlaying) return;
|
|
1032
|
+
_startSpinner(playBtn, webUIUp ? 'Synthesizing…' : 'Loading model…');
|
|
1033
|
+
|
|
1034
|
+
// Pass phrase and output path via env vars — avoids all shell-escaping issues.
|
|
1035
|
+
// Mode chain: WebUI (Gradio, model warm) → API server (OpenAI-compat) → CLI (slow cold-load).
|
|
1036
|
+
const sopranoEnv = {
|
|
1037
|
+
...(_sampleEnv),
|
|
1038
|
+
_AV_PHRASE: _sanitizeForShell(phrase),
|
|
1039
|
+
_AV_WAV: tempWav,
|
|
1040
|
+
_AV_SYNTHER: synther,
|
|
1041
|
+
_AV_PORT: String(port),
|
|
1042
|
+
};
|
|
1043
|
+
const cmd = [
|
|
1044
|
+
// Mode 1: Gradio WebUI
|
|
1045
|
+
`python3 "$_AV_SYNTHER" "$_AV_PHRASE" "$_AV_WAV" "$_AV_PORT" 2>/dev/null`,
|
|
1046
|
+
// Mode 2: OpenAI-compatible API server (curl writes WAV directly)
|
|
1047
|
+
`curl -sf --max-time 30 "http://127.0.0.1:$_AV_PORT/v1/audio/speech"` +
|
|
1048
|
+
` -H "Content-Type: application/json"` +
|
|
1049
|
+
` -d "{\\"input\\":\\"$_AV_PHRASE\\"}" --output "$_AV_WAV" 2>/dev/null`,
|
|
1050
|
+
// Mode 3: CLI — reloads neural model each call (~15-30s)
|
|
1051
|
+
`soprano "$_AV_PHRASE" -o "$_AV_WAV"`,
|
|
1052
|
+
].join(' || ');
|
|
1053
|
+
|
|
1054
|
+
const soprano = spawn('sh', ['-c', cmd], {
|
|
1055
|
+
stdio: 'ignore', detached: true, env: sopranoEnv,
|
|
1056
|
+
});
|
|
1057
|
+
_sampleProcess = soprano;
|
|
1058
|
+
soprano.on('exit', (code) => {
|
|
1059
|
+
_onSynthDone(code);
|
|
1060
|
+
_refreshSopranoStatus(); // Update status glyph after synthesis completes
|
|
1061
|
+
});
|
|
1062
|
+
soprano.on('error', () => { _stopSpinner(); _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn); });
|
|
1063
|
+
}
|
|
1064
|
+
} else {
|
|
1065
|
+
// Piper (default): pipe text via stdin
|
|
1066
|
+
_startSpinner(playBtn, 'Synthesizing…');
|
|
1067
|
+
const voiceId = providerService.getActiveVoiceId();
|
|
1068
|
+
if (!voiceId) { _stopSpinner(); _killSample(); playBtn.setContent('▶ Play'); screen.render(); return; }
|
|
1069
|
+
const _ms2 = parseMultiSpeaker(voiceId);
|
|
1070
|
+
const voicePath = path.resolve(PIPER_VOICES_DIR, _ms2.model + '.onnx');
|
|
1071
|
+
const safeBase = path.resolve(PIPER_VOICES_DIR);
|
|
1072
|
+
if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) {
|
|
1073
|
+
_stopSpinner(); _killSample(); playBtn.setContent('▶ Play'); screen.render(); return;
|
|
1074
|
+
}
|
|
1075
|
+
const _piperArgs2 = ['--model', voicePath, '--output_file', tempWav];
|
|
1076
|
+
if (_ms2.speakerId != null) _piperArgs2.push('--speaker', String(_ms2.speakerId));
|
|
1077
|
+
const piper = spawn('piper', _piperArgs2, {
|
|
1078
|
+
stdio: ['pipe', 'ignore', 'ignore'], detached: true, env: _sampleEnv,
|
|
1079
|
+
});
|
|
1080
|
+
piper.stdin.write(phrase + '\n');
|
|
1081
|
+
piper.stdin.end();
|
|
1082
|
+
_sampleProcess = piper;
|
|
1083
|
+
piper.on('exit', _onSynthDone);
|
|
1084
|
+
piper.on('error', () => { _stopSpinner(); _killSample(); playBtn.setContent('▶ Play'); _focusButton(playBtn); });
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
playBtn.top = 5;
|
|
1088
|
+
playBtn.left = 64;
|
|
1089
|
+
|
|
1090
|
+
const voiceFileText = blessed.text({
|
|
1091
|
+
parent: box,
|
|
1092
|
+
top: 6,
|
|
1093
|
+
left: 22,
|
|
1094
|
+
right: 2,
|
|
1095
|
+
wrap: false,
|
|
1096
|
+
content: '.claude/tts-voice.txt',
|
|
1097
|
+
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
// -------------------------------------------------------------------------
|
|
1101
|
+
// Section header: ── Audio Effects ──
|
|
1102
|
+
|
|
1103
|
+
const audioEffectsHeader = blessed.text({
|
|
1104
|
+
parent: box,
|
|
1105
|
+
top: 3,
|
|
1106
|
+
left: 1,
|
|
1107
|
+
content: '{#7986cb-fg} ⚡ Audio Effects {/#7986cb-fg}',
|
|
1108
|
+
tags: true,
|
|
1109
|
+
style: { bg: COLORS.contentBg },
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// -------------------------------------------------------------------------
|
|
1113
|
+
// Reverb row: label + value + [Toggle] + [Adjust] buttons
|
|
1114
|
+
|
|
1115
|
+
const reverbLabel = blessed.text({
|
|
1116
|
+
parent: box,
|
|
1117
|
+
top: 5,
|
|
1118
|
+
left: 6,
|
|
1119
|
+
content: 'Reverb:',
|
|
1120
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
const reverbValue = blessed.text({
|
|
1124
|
+
parent: box,
|
|
1125
|
+
top: 5,
|
|
1126
|
+
left: 22,
|
|
1127
|
+
width: 26, // truncate before [Change] at left:40
|
|
1128
|
+
wrap: false,
|
|
1129
|
+
content: '', // populated by refreshDisplay()
|
|
1130
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
const reverbChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1134
|
+
_openReverbPicker(screen, configService, (preset) => {
|
|
1135
|
+
_setEffects(configService, { reverbPreset: preset });
|
|
1136
|
+
refreshDisplay();
|
|
1137
|
+
}, _restoreFocus);
|
|
1138
|
+
}, { bg: COLORS.btnChange });
|
|
1139
|
+
reverbChangeBtn.top = 5;
|
|
1140
|
+
reverbChangeBtn.left = 52;
|
|
1141
|
+
|
|
1142
|
+
const reverbTestBtn = _createButton(box, screen, '▶ Preview', COLORS, () => _runTest(false), { bg: COLORS.btnTest });
|
|
1143
|
+
reverbTestBtn.top = 5;
|
|
1144
|
+
reverbTestBtn.left = 64;
|
|
1145
|
+
|
|
1146
|
+
const reverbPathText = blessed.text({
|
|
1147
|
+
parent: box,
|
|
1148
|
+
top: 6,
|
|
1149
|
+
left: 22,
|
|
1150
|
+
right: 2,
|
|
1151
|
+
wrap: false,
|
|
1152
|
+
content: '.agentvibes/config.json',
|
|
1153
|
+
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// -------------------------------------------------------------------------
|
|
1157
|
+
// Section header: ── Background Music ──
|
|
1158
|
+
|
|
1159
|
+
const bgMusicHeader = blessed.text({
|
|
1160
|
+
parent: box,
|
|
1161
|
+
top: 7,
|
|
1162
|
+
left: 1,
|
|
1163
|
+
content: '{#7986cb-fg} 🎸 Background Music {/#7986cb-fg}',
|
|
1164
|
+
tags: true,
|
|
1165
|
+
style: { bg: COLORS.contentBg },
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
// -------------------------------------------------------------------------
|
|
1169
|
+
// Music row (single): Track value + [Change] + [Enabled/Disabled] + [Test]
|
|
1170
|
+
|
|
1171
|
+
const trackLabel = blessed.text({
|
|
1172
|
+
parent: box,
|
|
1173
|
+
top: 9,
|
|
1174
|
+
left: 6,
|
|
1175
|
+
content: 'Track:',
|
|
1176
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
const trackValue = blessed.text({
|
|
1180
|
+
parent: box,
|
|
1181
|
+
top: 9,
|
|
1182
|
+
left: 22,
|
|
1183
|
+
width: 26, // truncate before [Change] at left:40
|
|
1184
|
+
wrap: false,
|
|
1185
|
+
content: '', // populated by refreshDisplay()
|
|
1186
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
const trackChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1190
|
+
_openMusicBrowserModal(screen, configService, navigationService, () => {
|
|
1191
|
+
refreshDisplay();
|
|
1192
|
+
_buttons[_currentIdx].focus();
|
|
1193
|
+
screen.render();
|
|
1194
|
+
}, _restoreFocus);
|
|
1195
|
+
}, { bg: COLORS.btnChange });
|
|
1196
|
+
trackChangeBtn.top = 9;
|
|
1197
|
+
trackChangeBtn.left = 52;
|
|
1198
|
+
|
|
1199
|
+
const musicToggleBtn = _createButton(box, screen, 'Disabled', COLORS, () => {
|
|
1200
|
+
const music = _getMusic(configService);
|
|
1201
|
+
_setMusic(configService, { enabled: !music.enabled });
|
|
1202
|
+
refreshDisplay();
|
|
1203
|
+
}, {
|
|
1204
|
+
bg: COLORS.btnEnableOff,
|
|
1205
|
+
getDynamicBg: () => _getMusic(configService).enabled ? COLORS.btnEnableOn : COLORS.btnEnableOff,
|
|
1206
|
+
});
|
|
1207
|
+
musicToggleBtn.top = 9;
|
|
1208
|
+
musicToggleBtn.left = 64;
|
|
1209
|
+
|
|
1210
|
+
const musicTestBtn = _createButton(box, screen, '▶ Preview', COLORS, _runMusicTest, { bg: COLORS.btnTest });
|
|
1211
|
+
musicTestBtn.top = 9;
|
|
1212
|
+
musicTestBtn.left = 78;
|
|
1213
|
+
|
|
1214
|
+
const trackPathText = blessed.text({
|
|
1215
|
+
parent: box,
|
|
1216
|
+
top: 10,
|
|
1217
|
+
left: 22,
|
|
1218
|
+
right: 2,
|
|
1219
|
+
wrap: false,
|
|
1220
|
+
content: '.agentvibes/config.json',
|
|
1221
|
+
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
// -------------------------------------------------------------------------
|
|
1225
|
+
// Volume row: label + value + [Change] button
|
|
1226
|
+
|
|
1227
|
+
const volumeLabel = blessed.text({
|
|
1228
|
+
parent: box,
|
|
1229
|
+
top: 11,
|
|
1230
|
+
left: 6,
|
|
1231
|
+
content: 'Volume:',
|
|
1232
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
const volumeValue = blessed.text({
|
|
1236
|
+
parent: box,
|
|
1237
|
+
top: 11,
|
|
1238
|
+
left: 22,
|
|
1239
|
+
width: 26,
|
|
1240
|
+
wrap: false,
|
|
1241
|
+
content: '', // populated by refreshDisplay()
|
|
1242
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
const volumeChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1246
|
+
_openVolumePicker(screen, configService, (vol) => {
|
|
1247
|
+
_setMusic(configService, { volume: vol });
|
|
1248
|
+
// If music test is active, restart it at the new volume
|
|
1249
|
+
if (_musicTestActive) {
|
|
1250
|
+
_killMusicTest();
|
|
1251
|
+
_runMusicTest();
|
|
1252
|
+
}
|
|
1253
|
+
refreshDisplay();
|
|
1254
|
+
}, _restoreFocus);
|
|
1255
|
+
}, { bg: COLORS.btnChange });
|
|
1256
|
+
volumeChangeBtn.top = 11;
|
|
1257
|
+
volumeChangeBtn.left = 52;
|
|
1258
|
+
|
|
1259
|
+
// -------------------------------------------------------------------------
|
|
1260
|
+
// Verbosity row: label + value + [Change] button
|
|
1261
|
+
|
|
1262
|
+
const verbosityLabel = blessed.text({
|
|
1263
|
+
parent: box,
|
|
1264
|
+
top: 3,
|
|
1265
|
+
left: 6,
|
|
1266
|
+
content: 'Verbosity:',
|
|
1267
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
const verbosityValue = blessed.text({
|
|
1271
|
+
parent: box,
|
|
1272
|
+
top: 3,
|
|
1273
|
+
left: 22,
|
|
1274
|
+
width: 26, // truncate before [Change] at left:40
|
|
1275
|
+
wrap: false,
|
|
1276
|
+
content: '', // populated by refreshDisplay()
|
|
1277
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
const verbosityChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1281
|
+
_openVerbosityPicker(screen, configService, () => refreshDisplay(), _restoreFocus);
|
|
1282
|
+
}, { bg: COLORS.btnChange });
|
|
1283
|
+
verbosityChangeBtn.top = 3;
|
|
1284
|
+
verbosityChangeBtn.left = 52;
|
|
1285
|
+
|
|
1286
|
+
const verbosityPathText = blessed.text({
|
|
1287
|
+
parent: box,
|
|
1288
|
+
top: 4,
|
|
1289
|
+
left: 22,
|
|
1290
|
+
right: 2,
|
|
1291
|
+
wrap: false,
|
|
1292
|
+
content: '.claude/tts-verbosity.txt',
|
|
1293
|
+
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
// -------------------------------------------------------------------------
|
|
1297
|
+
// Personality row: label + value + [Change] button
|
|
1298
|
+
|
|
1299
|
+
const personalityLabel = blessed.text({
|
|
1300
|
+
parent: box,
|
|
1301
|
+
top: 5,
|
|
1302
|
+
left: 6,
|
|
1303
|
+
content: 'Personality:',
|
|
1304
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
const personalityValue = blessed.text({
|
|
1308
|
+
parent: box,
|
|
1309
|
+
top: 5,
|
|
1310
|
+
left: 22,
|
|
1311
|
+
width: 26, // truncate before [Change] at left:40
|
|
1312
|
+
wrap: false,
|
|
1313
|
+
content: '', // populated by refreshDisplay()
|
|
1314
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
const personalityChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1318
|
+
_openPersonalityPicker(screen, configService, (name) => {
|
|
1319
|
+
configService.set('personality', name);
|
|
1320
|
+
refreshDisplay();
|
|
1321
|
+
}, _restoreFocus);
|
|
1322
|
+
}, { bg: COLORS.btnChange });
|
|
1323
|
+
personalityChangeBtn.top = 5;
|
|
1324
|
+
personalityChangeBtn.left = 52;
|
|
1325
|
+
|
|
1326
|
+
const personalityTestBtn = _createButton(box, screen, '▶ Preview', COLORS, () => {
|
|
1327
|
+
const personality = (configService.getConfig().personality ?? '').trim();
|
|
1328
|
+
const example = _getPersonalityPhrase(personality);
|
|
1329
|
+
const phrase = example
|
|
1330
|
+
? `${_testGreeting()}. Agent Vibes here. ${example}`
|
|
1331
|
+
: _buildPreviewPhrase();
|
|
1332
|
+
_runTest(false, phrase);
|
|
1333
|
+
}, { bg: COLORS.btnTest });
|
|
1334
|
+
personalityTestBtn.top = 5;
|
|
1335
|
+
personalityTestBtn.left = 64;
|
|
1336
|
+
|
|
1337
|
+
const personalityFileText = blessed.text({
|
|
1338
|
+
parent: box,
|
|
1339
|
+
top: 6,
|
|
1340
|
+
left: 22,
|
|
1341
|
+
right: 2,
|
|
1342
|
+
wrap: false,
|
|
1343
|
+
tags: true,
|
|
1344
|
+
content: '', // populated by refreshDisplay()
|
|
1345
|
+
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
// -------------------------------------------------------------------------
|
|
1349
|
+
// Section header: ── Intro Text ──
|
|
1350
|
+
|
|
1351
|
+
const introTextHeader = blessed.text({
|
|
1352
|
+
parent: box,
|
|
1353
|
+
top: 8,
|
|
1354
|
+
left: 1,
|
|
1355
|
+
content: '{#7986cb-fg} ✍️ Intro Text {/#7986cb-fg}',
|
|
1356
|
+
tags: true,
|
|
1357
|
+
style: { bg: COLORS.contentBg },
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
// -------------------------------------------------------------------------
|
|
1361
|
+
// Intro Text row: label + value + [Edit] + [Clear] buttons
|
|
1362
|
+
|
|
1363
|
+
const introTextLabel = blessed.text({
|
|
1364
|
+
parent: box,
|
|
1365
|
+
top: 10,
|
|
1366
|
+
left: 6,
|
|
1367
|
+
content: 'Intro Text:',
|
|
1368
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
const introTextValue = blessed.text({
|
|
1372
|
+
parent: box,
|
|
1373
|
+
top: 10,
|
|
1374
|
+
left: 22,
|
|
1375
|
+
width: 26, // truncate before [Edit] at left:40
|
|
1376
|
+
wrap: false,
|
|
1377
|
+
content: '', // populated by refreshDisplay()
|
|
1378
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
const introEditBtn = _createButton(box, screen, 'Edit', COLORS, () => {
|
|
1382
|
+
_openIntroTextEditor(screen, configService, () => { refreshDisplay(); }, _restoreFocus);
|
|
1383
|
+
}, { bg: COLORS.btnEdit });
|
|
1384
|
+
introEditBtn.top = 10;
|
|
1385
|
+
introEditBtn.left = 52;
|
|
1386
|
+
|
|
1387
|
+
const introClearBtn = _createButton(box, screen, 'Clear', COLORS, () => {
|
|
1388
|
+
configService.set('pretext', '');
|
|
1389
|
+
refreshDisplay();
|
|
1390
|
+
}, { bg: '#c62828' });
|
|
1391
|
+
introClearBtn.top = 10;
|
|
1392
|
+
introClearBtn.left = 64;
|
|
1393
|
+
|
|
1394
|
+
const introPathText = blessed.text({
|
|
1395
|
+
parent: box,
|
|
1396
|
+
top: 11,
|
|
1397
|
+
left: 22,
|
|
1398
|
+
right: 2,
|
|
1399
|
+
wrap: false,
|
|
1400
|
+
content: '.agentvibes/config.json',
|
|
1401
|
+
style: { fg: '#546e7a', bg: COLORS.contentBg },
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
// Full Preview button — voice + reverb + background track combined
|
|
1405
|
+
const fullPreviewBtn = _createButton(box, screen, '▶ Full Preview', COLORS, () => _runTest(true));
|
|
1406
|
+
fullPreviewBtn.bottom = 0;
|
|
1407
|
+
fullPreviewBtn.left = 2;
|
|
1408
|
+
|
|
1409
|
+
// -------------------------------------------------------------------------
|
|
1410
|
+
// Section header: 📡 Audio Destination
|
|
1411
|
+
|
|
1412
|
+
const audioDstHeader = blessed.text({
|
|
1413
|
+
parent: box,
|
|
1414
|
+
top: 3,
|
|
1415
|
+
left: 2,
|
|
1416
|
+
content: '{#7986cb-fg} 📡 Audio Destination {/#7986cb-fg}',
|
|
1417
|
+
tags: true,
|
|
1418
|
+
style: { bg: COLORS.contentBg },
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// -------------------------------------------------------------------------
|
|
1422
|
+
// Destination row: label + value + [Change] button
|
|
1423
|
+
|
|
1424
|
+
const audioDstLabel = blessed.text({
|
|
1425
|
+
parent: box,
|
|
1426
|
+
top: 5,
|
|
1427
|
+
left: 6,
|
|
1428
|
+
content: 'Destination:',
|
|
1429
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
const audioDstValue = blessed.text({
|
|
1433
|
+
parent: box,
|
|
1434
|
+
top: 5,
|
|
1435
|
+
left: 22,
|
|
1436
|
+
width: 26,
|
|
1437
|
+
wrap: false,
|
|
1438
|
+
content: '', // populated by refreshDisplay()
|
|
1439
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
const audioDstChangeBtn = _createButton(box, screen, 'Change', COLORS, () => {
|
|
1443
|
+
const aliases = _detectSshAliases();
|
|
1444
|
+
const current = configService.getConfig().audio_destination ?? 'local';
|
|
1445
|
+
const choices = ['local', 'remote'];
|
|
1446
|
+
const nextIdx = (choices.indexOf(current) + 1) % choices.length;
|
|
1447
|
+
const next = choices[nextIdx];
|
|
1448
|
+
configService.set('audio_destination', next);
|
|
1449
|
+
if (next === 'remote' && !(configService.getConfig().audio_ssh_alias)) {
|
|
1450
|
+
// Prompt for alias immediately if switching to remote with no alias set
|
|
1451
|
+
const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
|
|
1452
|
+
const prompt = blessed.prompt({
|
|
1453
|
+
parent: screen,
|
|
1454
|
+
top: 'center', left: 'center',
|
|
1455
|
+
height: 'shrink', width: '60%',
|
|
1456
|
+
border: 'line', tags: true,
|
|
1457
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
|
|
1458
|
+
});
|
|
1459
|
+
prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
|
|
1460
|
+
aliases[0] ?? '',
|
|
1461
|
+
(err, val) => {
|
|
1462
|
+
prompt.destroy();
|
|
1463
|
+
if (!err && val && val.trim()) {
|
|
1464
|
+
const trimmed = val.trim();
|
|
1465
|
+
if (/[;&|`$(){}\\<>]/.test(trimmed)) {
|
|
1466
|
+
_showNotice(screen, 'Invalid alias — special characters not allowed');
|
|
1467
|
+
} else {
|
|
1468
|
+
configService.set('audio_ssh_alias', trimmed);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
refreshDisplay();
|
|
1472
|
+
screen.render();
|
|
1473
|
+
});
|
|
1474
|
+
screen.render();
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
refreshDisplay();
|
|
1478
|
+
}, { bg: COLORS.btnChange });
|
|
1479
|
+
audioDstChangeBtn.top = 5;
|
|
1480
|
+
audioDstChangeBtn.left = 52;
|
|
1481
|
+
|
|
1482
|
+
// -------------------------------------------------------------------------
|
|
1483
|
+
// SSH Alias row: label + value + [Edit] + [stream mode toggle] buttons
|
|
1484
|
+
// Hidden when destination is Local — shown/hidden by refreshDisplay()
|
|
1485
|
+
|
|
1486
|
+
const audioSshLabel = blessed.text({
|
|
1487
|
+
parent: box,
|
|
1488
|
+
top: 7,
|
|
1489
|
+
left: 6,
|
|
1490
|
+
hidden: true,
|
|
1491
|
+
content: 'SSH Alias:',
|
|
1492
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
const audioSshValue = blessed.text({
|
|
1496
|
+
parent: box,
|
|
1497
|
+
top: 7,
|
|
1498
|
+
left: 22,
|
|
1499
|
+
width: 26,
|
|
1500
|
+
wrap: false,
|
|
1501
|
+
hidden: true,
|
|
1502
|
+
content: '', // populated by refreshDisplay()
|
|
1503
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
const audioSshEditBtn = _createButton(box, screen, 'Edit', COLORS, () => {
|
|
1507
|
+
const aliases = _detectSshAliases();
|
|
1508
|
+
const current = configService.getConfig().audio_ssh_alias ?? '';
|
|
1509
|
+
const detectedAliases = aliases.length > 0 ? ` (detected: ${aliases.join(', ')})` : '';
|
|
1510
|
+
const prompt = blessed.prompt({
|
|
1511
|
+
parent: screen,
|
|
1512
|
+
top: 'center', left: 'center',
|
|
1513
|
+
height: 'shrink', width: '60%',
|
|
1514
|
+
border: 'line', tags: true,
|
|
1515
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.sectionHdr } },
|
|
1516
|
+
});
|
|
1517
|
+
prompt.input(`SSH Host alias from ~/.ssh/config${detectedAliases}:`,
|
|
1518
|
+
current || (aliases[0] ?? ''),
|
|
1519
|
+
(err, val) => {
|
|
1520
|
+
prompt.destroy();
|
|
1521
|
+
if (!err && val !== null) {
|
|
1522
|
+
const trimmed = val.trim();
|
|
1523
|
+
if (/[;&|`$(){}\\<>]/.test(trimmed)) {
|
|
1524
|
+
_showNotice(screen, 'Invalid alias — special characters not allowed');
|
|
1525
|
+
} else {
|
|
1526
|
+
configService.set('audio_ssh_alias', trimmed);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
refreshDisplay();
|
|
1530
|
+
screen.render();
|
|
1531
|
+
});
|
|
1532
|
+
screen.render();
|
|
1533
|
+
}, { bg: COLORS.btnEdit });
|
|
1534
|
+
audioSshEditBtn.top = 7;
|
|
1535
|
+
audioSshEditBtn.left = 52;
|
|
1536
|
+
audioSshEditBtn.hide();
|
|
1537
|
+
|
|
1538
|
+
// Stream mode toggle
|
|
1539
|
+
// Streaming Text Only = send TTS text to remote AgentVibes Receiver which speaks locally (no audio data transfer)
|
|
1540
|
+
// Streaming Pulse Audio = stream audio file over SSH/PulseAudio tunnel
|
|
1541
|
+
const audioStreamModeBtn = _createButton(box, screen, 'Streaming Text Only ✓', COLORS, () => {
|
|
1542
|
+
const current = configService.getConfig().audio_stream_mode ?? 'text';
|
|
1543
|
+
configService.set('audio_stream_mode', current === 'text' ? 'pulse' : 'text');
|
|
1544
|
+
refreshDisplay();
|
|
1545
|
+
}, { bg: '#2e7d32' }); // green = recommended
|
|
1546
|
+
audioStreamModeBtn.top = 7;
|
|
1547
|
+
audioStreamModeBtn.left = 64;
|
|
1548
|
+
audioStreamModeBtn.hide();
|
|
1549
|
+
|
|
1550
|
+
// Explanation note
|
|
1551
|
+
const audioExplanationNote = blessed.text({
|
|
1552
|
+
parent: box,
|
|
1553
|
+
top: 9,
|
|
1554
|
+
left: 6,
|
|
1555
|
+
right: 2,
|
|
1556
|
+
wrap: false,
|
|
1557
|
+
tags: true,
|
|
1558
|
+
content: `{#546e7a-fg}Remote: sends TTS over SSH. Text Only = remote speaks (no audio transfer). Pulse = streams audio.{/#546e7a-fg}`,
|
|
1559
|
+
style: { bg: COLORS.contentBg },
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
// -------------------------------------------------------------------------
|
|
1563
|
+
// Section header: 💾 Config Storage
|
|
1564
|
+
|
|
1565
|
+
const configStorageHeader = blessed.text({
|
|
1566
|
+
parent: box,
|
|
1567
|
+
top: 11,
|
|
1568
|
+
left: 2,
|
|
1569
|
+
content: '{#7986cb-fg} 💾 Config Storage {/#7986cb-fg}',
|
|
1570
|
+
tags: true,
|
|
1571
|
+
style: { bg: COLORS.contentBg },
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
// Info row 1: global config path
|
|
1575
|
+
const configGlobalLabel = blessed.text({
|
|
1576
|
+
parent: box,
|
|
1577
|
+
top: 12,
|
|
1578
|
+
left: 6,
|
|
1579
|
+
content: 'Global:',
|
|
1580
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
const configGlobalValue = blessed.text({
|
|
1584
|
+
parent: box,
|
|
1585
|
+
top: 12,
|
|
1586
|
+
left: 22,
|
|
1587
|
+
right: 2,
|
|
1588
|
+
wrap: false,
|
|
1589
|
+
content: '', // populated by refreshConfigDisplay()
|
|
1590
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
// Info row 2: local config path (or "None")
|
|
1594
|
+
const configLocalLabel = blessed.text({
|
|
1595
|
+
parent: box,
|
|
1596
|
+
top: 13,
|
|
1597
|
+
left: 6,
|
|
1598
|
+
content: 'Local:',
|
|
1599
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
const configLocalValue = blessed.text({
|
|
1603
|
+
parent: box,
|
|
1604
|
+
top: 13,
|
|
1605
|
+
left: 22,
|
|
1606
|
+
right: 2,
|
|
1607
|
+
wrap: false,
|
|
1608
|
+
content: '', // populated by refreshConfigDisplay()
|
|
1609
|
+
style: { fg: COLORS.valueFg, bg: COLORS.contentBg },
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
// Action buttons row — right column, row 17
|
|
1613
|
+
const saveGloballyBtn = _createButton(box, screen, 'Save Globally', COLORS, () => {
|
|
1614
|
+
const data = configService.getConfig();
|
|
1615
|
+
const configPath = configService.getGlobalConfigPath();
|
|
1616
|
+
_showSavePreview(screen, configPath, data, () => {
|
|
1617
|
+
configService.saveAllToGlobal(data);
|
|
1618
|
+
applyTrackToAudioEffects(data.backgroundMusic?.track);
|
|
1619
|
+
refreshConfigDisplay();
|
|
1620
|
+
_showNotice(screen, 'Settings Saved');
|
|
1621
|
+
}, () => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); });
|
|
1622
|
+
}, { bg: '#7b1fa2' }); // purple
|
|
1623
|
+
saveGloballyBtn.bottom = 0;
|
|
1624
|
+
saveGloballyBtn.left = 24;
|
|
1625
|
+
|
|
1626
|
+
const saveLocallyBtn = _createButton(box, screen, 'Save Locally', COLORS, () => {
|
|
1627
|
+
const data = configService.getConfig();
|
|
1628
|
+
const configPath = configService.getLocalConfigPath();
|
|
1629
|
+
_showSavePreview(screen, configPath, data, () => {
|
|
1630
|
+
configService.saveAllToLocal(data);
|
|
1631
|
+
applyTrackToAudioEffects(data.backgroundMusic?.track);
|
|
1632
|
+
refreshConfigDisplay();
|
|
1633
|
+
_showNotice(screen, 'Settings Saved');
|
|
1634
|
+
}, () => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); });
|
|
1635
|
+
}, { bg: '#2e7d32' }); // green
|
|
1636
|
+
saveLocallyBtn.bottom = 0;
|
|
1637
|
+
saveLocallyBtn.left = 46;
|
|
1638
|
+
|
|
1639
|
+
const cancelChangesBtn = _createButton(box, screen, 'Cancel Changes', COLORS, () => {
|
|
1640
|
+
// Restore global config to snapshot taken at tab open
|
|
1641
|
+
if (_snapshotGlobal !== null) configService.saveAllToGlobal(_snapshotGlobal);
|
|
1642
|
+
// Restore (or remove) local config
|
|
1643
|
+
if (_snapshotLocal !== null) {
|
|
1644
|
+
configService.saveAllToLocal(_snapshotLocal);
|
|
1645
|
+
} else {
|
|
1646
|
+
// Local didn't exist at tab open — remove it if created during this session
|
|
1647
|
+
const localPath = configService.getLocalConfigPath();
|
|
1648
|
+
try { if (fs.existsSync(localPath)) fs.unlinkSync(localPath); } catch {}
|
|
1649
|
+
}
|
|
1650
|
+
refreshDisplay();
|
|
1651
|
+
refreshConfigDisplay();
|
|
1652
|
+
_showNotice(screen, 'Changes reverted');
|
|
1653
|
+
}, { bg: '#c62828' }); // red
|
|
1654
|
+
cancelChangesBtn.bottom = 0;
|
|
1655
|
+
cancelChangesBtn.left = 66;
|
|
1656
|
+
|
|
1657
|
+
// -------------------------------------------------------------------------
|
|
1658
|
+
// Display state + button-level focus navigation (story 7.6)
|
|
1659
|
+
|
|
1660
|
+
// Widget groups for each sub-tab (used by _showSubTab to show/hide)
|
|
1661
|
+
const _subTabWidgets = {
|
|
1662
|
+
voice: [
|
|
1663
|
+
providerLabel, providerValue, switchBtn,
|
|
1664
|
+
voiceLabel, voiceValue, changeBtn, playBtn, voiceFileText,
|
|
1665
|
+
],
|
|
1666
|
+
effects: [
|
|
1667
|
+
audioEffectsHeader,
|
|
1668
|
+
reverbLabel, reverbValue, reverbChangeBtn, reverbTestBtn, reverbPathText,
|
|
1669
|
+
bgMusicHeader,
|
|
1670
|
+
trackLabel, trackValue, trackChangeBtn, musicToggleBtn, musicTestBtn, trackPathText,
|
|
1671
|
+
volumeLabel, volumeValue, volumeChangeBtn,
|
|
1672
|
+
],
|
|
1673
|
+
personality: [
|
|
1674
|
+
verbosityLabel, verbosityValue, verbosityChangeBtn, verbosityPathText,
|
|
1675
|
+
personalityLabel, personalityValue, personalityChangeBtn, personalityTestBtn, personalityFileText,
|
|
1676
|
+
introTextHeader,
|
|
1677
|
+
introTextLabel, introTextValue, introEditBtn, introClearBtn, introPathText,
|
|
1678
|
+
],
|
|
1679
|
+
output: [
|
|
1680
|
+
audioDstHeader,
|
|
1681
|
+
audioDstLabel, audioDstValue, audioDstChangeBtn,
|
|
1682
|
+
audioSshLabel, audioSshValue, audioSshEditBtn, audioStreamModeBtn, audioExplanationNote,
|
|
1683
|
+
configStorageHeader,
|
|
1684
|
+
configGlobalLabel, configGlobalValue,
|
|
1685
|
+
configLocalLabel, configLocalValue,
|
|
1686
|
+
],
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
// Row groups per sub-tab for ↑↓ navigation
|
|
1690
|
+
const _rowsBySubTab = {
|
|
1691
|
+
voice: [[switchBtn], [changeBtn, playBtn]],
|
|
1692
|
+
effects: [[reverbChangeBtn, reverbTestBtn], [trackChangeBtn, musicToggleBtn, musicTestBtn], [volumeChangeBtn]],
|
|
1693
|
+
personality: [[verbosityChangeBtn], [personalityChangeBtn, personalityTestBtn], [introEditBtn, introClearBtn]],
|
|
1694
|
+
output: [[audioDstChangeBtn], [audioSshEditBtn, audioStreamModeBtn]],
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
const _subTabItemsArray = SUB_TABS.map(id => _subTabItemsMap[id]);
|
|
1698
|
+
|
|
1699
|
+
function _showSubTab(name, keepFocusOnBar = false) {
|
|
1700
|
+
_activeSubTab = name;
|
|
1701
|
+
|
|
1702
|
+
// Hide all section widgets; clear any active blink intervals on hidden buttons
|
|
1703
|
+
for (const widgets of Object.values(_subTabWidgets)) {
|
|
1704
|
+
for (const w of widgets) {
|
|
1705
|
+
if (w._btnBlinkInterval) { clearInterval(w._btnBlinkInterval); w._btnBlinkInterval = null; }
|
|
1706
|
+
w.hide();
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Show active section widgets (SSH row controlled by refreshDisplay, not here)
|
|
1711
|
+
const sshSpecific = [audioSshLabel, audioSshValue, audioSshEditBtn, audioStreamModeBtn];
|
|
1712
|
+
for (const w of _subTabWidgets[name]) {
|
|
1713
|
+
if (sshSpecific.includes(w)) continue;
|
|
1714
|
+
w.show();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// If showing output tab, let refreshDisplay control SSH row visibility
|
|
1718
|
+
if (name === 'output') refreshDisplay();
|
|
1719
|
+
|
|
1720
|
+
// Rebuild _rows: [subTabRow, ...contentRows, fullPreview, save, cancel]
|
|
1721
|
+
_rows.length = 0;
|
|
1722
|
+
_rows.push(_subTabItemsArray);
|
|
1723
|
+
for (const row of _rowsBySubTab[name]) _rows.push(row);
|
|
1724
|
+
_rows.push([fullPreviewBtn]);
|
|
1725
|
+
_rows.push([saveGloballyBtn, saveLocallyBtn, cancelChangesBtn]);
|
|
1726
|
+
|
|
1727
|
+
_updateSubTabBar();
|
|
1728
|
+
|
|
1729
|
+
if (!keepFocusOnBar) {
|
|
1730
|
+
const firstRow = _rowsBySubTab[name].find(row => !row[0].hidden);
|
|
1731
|
+
if (firstRow) {
|
|
1732
|
+
_currentIdx = _buttons.indexOf(firstRow[0]);
|
|
1733
|
+
_focusButton(firstRow[0]);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
screen.render();
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
const _buttons = [
|
|
1741
|
+
_subTabItemsMap.voice, _subTabItemsMap.effects,
|
|
1742
|
+
_subTabItemsMap.personality, _subTabItemsMap.output,
|
|
1743
|
+
switchBtn, changeBtn, playBtn,
|
|
1744
|
+
reverbChangeBtn, reverbTestBtn,
|
|
1745
|
+
trackChangeBtn, musicToggleBtn, musicTestBtn,
|
|
1746
|
+
volumeChangeBtn,
|
|
1747
|
+
verbosityChangeBtn, personalityChangeBtn, personalityTestBtn,
|
|
1748
|
+
introEditBtn, introClearBtn,
|
|
1749
|
+
audioDstChangeBtn, audioSshEditBtn, audioStreamModeBtn,
|
|
1750
|
+
fullPreviewBtn,
|
|
1751
|
+
saveGloballyBtn, saveLocallyBtn, cancelChangesBtn,
|
|
1752
|
+
];
|
|
1753
|
+
|
|
1754
|
+
// Restore focus to the active settings button after any modal closes.
|
|
1755
|
+
const _restoreFocus = () => _focusButton(_buttons[_currentIdx]);
|
|
1756
|
+
|
|
1757
|
+
// Register test buttons for label sync (reverb + full preview share state)
|
|
1758
|
+
_testBtns.push(reverbTestBtn, personalityTestBtn, fullPreviewBtn);
|
|
1759
|
+
_testBtnLabels.set(reverbTestBtn, '▶ Preview');
|
|
1760
|
+
_testBtnLabels.set(personalityTestBtn, '▶ Preview');
|
|
1761
|
+
_testBtnLabels.set(fullPreviewBtn, '▶ Full Preview');
|
|
1762
|
+
|
|
1763
|
+
let _currentIdx = 0;
|
|
1764
|
+
|
|
1765
|
+
// Map each button to its row label + value widgets for focus-highlight
|
|
1766
|
+
const _buttonToLabel = new Map([
|
|
1767
|
+
[switchBtn, providerLabel],
|
|
1768
|
+
[changeBtn, voiceLabel],
|
|
1769
|
+
[playBtn, voiceLabel],
|
|
1770
|
+
[reverbChangeBtn, reverbLabel],
|
|
1771
|
+
[reverbTestBtn, reverbLabel],
|
|
1772
|
+
[trackChangeBtn, trackLabel],
|
|
1773
|
+
[musicToggleBtn, trackLabel],
|
|
1774
|
+
[musicTestBtn, trackLabel],
|
|
1775
|
+
[volumeChangeBtn, volumeLabel],
|
|
1776
|
+
[verbosityChangeBtn, verbosityLabel],
|
|
1777
|
+
[personalityChangeBtn, personalityLabel],
|
|
1778
|
+
[personalityTestBtn, personalityLabel],
|
|
1779
|
+
[introEditBtn, introTextLabel],
|
|
1780
|
+
[introClearBtn, introTextLabel],
|
|
1781
|
+
[audioDstChangeBtn, audioDstLabel],
|
|
1782
|
+
[audioSshEditBtn, audioSshLabel],
|
|
1783
|
+
[audioStreamModeBtn, audioDstLabel],
|
|
1784
|
+
]);
|
|
1785
|
+
|
|
1786
|
+
const _buttonToValue = new Map([
|
|
1787
|
+
[switchBtn, providerValue],
|
|
1788
|
+
[changeBtn, voiceValue],
|
|
1789
|
+
[playBtn, voiceValue],
|
|
1790
|
+
[reverbChangeBtn, reverbValue],
|
|
1791
|
+
[reverbTestBtn, reverbValue],
|
|
1792
|
+
[trackChangeBtn, trackValue],
|
|
1793
|
+
[musicToggleBtn, trackValue],
|
|
1794
|
+
[musicTestBtn, trackValue],
|
|
1795
|
+
[volumeChangeBtn, volumeValue],
|
|
1796
|
+
[verbosityChangeBtn, verbosityValue],
|
|
1797
|
+
[personalityChangeBtn, personalityValue],
|
|
1798
|
+
[personalityTestBtn, personalityValue],
|
|
1799
|
+
[introEditBtn, introTextValue],
|
|
1800
|
+
[introClearBtn, introTextValue],
|
|
1801
|
+
[audioDstChangeBtn, audioDstValue],
|
|
1802
|
+
[audioSshEditBtn, audioSshValue],
|
|
1803
|
+
[audioStreamModeBtn, audioDstValue],
|
|
1804
|
+
]);
|
|
1805
|
+
|
|
1806
|
+
// Sync _currentIdx; highlight label (cyan) + value (bright blue + underline) on focus
|
|
1807
|
+
for (const [i, btn] of _buttons.entries()) {
|
|
1808
|
+
btn.on('focus', () => {
|
|
1809
|
+
_currentIdx = i;
|
|
1810
|
+
const lbl = _buttonToLabel.get(btn);
|
|
1811
|
+
if (lbl) lbl.style.fg = COLORS.btnFocus;
|
|
1812
|
+
const val = _buttonToValue.get(btn);
|
|
1813
|
+
if (val) { val.style.fg = COLORS.btnFocus; val.style.underline = true; }
|
|
1814
|
+
});
|
|
1815
|
+
btn.on('blur', () => {
|
|
1816
|
+
const lbl = _buttonToLabel.get(btn);
|
|
1817
|
+
if (lbl) lbl.style.fg = COLORS.labelFg;
|
|
1818
|
+
const val = _buttonToValue.get(btn);
|
|
1819
|
+
if (val) { val.style.fg = COLORS.valueFg; val.style.underline = false; }
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// Shared focus helper — suppresses intermediate renders, force-invalidates olines.
|
|
1824
|
+
// Prevents the olines desync artifact where setContent() updates lines[] but
|
|
1825
|
+
// olines[] stays stale, causing draw() to skip repainting those cells.
|
|
1826
|
+
function _focusButton(btn) {
|
|
1827
|
+
const _orig = screen.render.bind(screen);
|
|
1828
|
+
screen.render = () => {};
|
|
1829
|
+
try { btn.focus(); } finally { screen.render = _orig; }
|
|
1830
|
+
|
|
1831
|
+
screen.clearRegion(0, screen.cols, 4, screen.rows - 2);
|
|
1832
|
+
for (let r = 4; r < screen.rows - 2; r++) {
|
|
1833
|
+
const orow = screen.olines[r];
|
|
1834
|
+
if (!orow) continue;
|
|
1835
|
+
for (let c = 0; c < screen.cols; c++) {
|
|
1836
|
+
if (orow[c]) orow[c][0] = -1;
|
|
1837
|
+
}
|
|
1838
|
+
orow.dirty = true;
|
|
1839
|
+
}
|
|
1840
|
+
screen.render();
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// ↓ / ↑ → navigate between row groups (skips siblings; use ←/→ for those)
|
|
1844
|
+
|
|
1845
|
+
// Returns the first non-hidden button in a row, or the first button if all are hidden.
|
|
1846
|
+
// Needed because some rows have a hidden first button (e.g. [changeBtn, playBtn] when
|
|
1847
|
+
// provider is not piper — changeBtn is hidden but playBtn is still reachable).
|
|
1848
|
+
function _firstVisibleBtn(row) {
|
|
1849
|
+
return row.find(b => !b.hidden) ?? row[0];
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function _isRowVisible(row) {
|
|
1853
|
+
return row.some(b => !b.hidden);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function _navigateRow(delta) {
|
|
1857
|
+
const focused = _buttons[_currentIdx];
|
|
1858
|
+
let rowIdx = _rows.findIndex(row => row.includes(focused));
|
|
1859
|
+
if (rowIdx === -1) rowIdx = 0;
|
|
1860
|
+
// At the sub-tab bar (row 0): pressing ↑ moves focus to the main header tab bar
|
|
1861
|
+
if (rowIdx === 0 && delta < 0) {
|
|
1862
|
+
if (typeof focusMainTabBar === 'function') focusMainTabBar();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// _rows layout: [0]=sub-tab bar, [1..lastContent]=per-tab rows, then 3 shared bottom rows
|
|
1867
|
+
const BOTTOM_ROWS = 2; // [fullPreviewBtn], [saveGlobally+saveLocally+cancelChanges]
|
|
1868
|
+
const lastContentIdx = _rows.length - BOTTOM_ROWS - 1;
|
|
1869
|
+
|
|
1870
|
+
// Cross-tab forward: ↓ from the effective last visible content row → jump to next sub-tab
|
|
1871
|
+
if (delta > 0 && rowIdx >= 1 && rowIdx <= lastContentIdx) {
|
|
1872
|
+
let isEffectiveLast = true;
|
|
1873
|
+
for (let r = rowIdx + 1; r <= lastContentIdx; r++) {
|
|
1874
|
+
if (_isRowVisible(_rows[r])) { isEffectiveLast = false; break; }
|
|
1875
|
+
}
|
|
1876
|
+
if (isEffectiveLast) {
|
|
1877
|
+
const tabIdx = SUB_TABS.indexOf(_activeSubTab);
|
|
1878
|
+
if (tabIdx < SUB_TABS.length - 1) {
|
|
1879
|
+
const nextTab = SUB_TABS[tabIdx + 1];
|
|
1880
|
+
_showSubTab(nextTab, true);
|
|
1881
|
+
const firstRow = _rowsBySubTab[nextTab].find(row => _isRowVisible(row));
|
|
1882
|
+
if (firstRow) {
|
|
1883
|
+
const btn = _firstVisibleBtn(firstRow);
|
|
1884
|
+
_currentIdx = _buttons.indexOf(btn);
|
|
1885
|
+
_focusButton(btn);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
// On the last sub-tab: fall through to normal (go to fullPreviewBtn)
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Cross-tab backward: ↑ from first content row (row 1) → jump to previous sub-tab's last row
|
|
1894
|
+
if (delta < 0 && rowIdx === 1) {
|
|
1895
|
+
const tabIdx = SUB_TABS.indexOf(_activeSubTab);
|
|
1896
|
+
if (tabIdx > 0) {
|
|
1897
|
+
const prevTab = SUB_TABS[tabIdx - 1];
|
|
1898
|
+
_showSubTab(prevTab, true);
|
|
1899
|
+
const prevRows = _rowsBySubTab[prevTab];
|
|
1900
|
+
let lastRow = null;
|
|
1901
|
+
for (let i = prevRows.length - 1; i >= 0; i--) {
|
|
1902
|
+
if (_isRowVisible(prevRows[i])) { lastRow = prevRows[i]; break; }
|
|
1903
|
+
}
|
|
1904
|
+
if (lastRow) {
|
|
1905
|
+
const btn = _firstVisibleBtn(lastRow);
|
|
1906
|
+
_currentIdx = _buttons.indexOf(btn);
|
|
1907
|
+
_focusButton(btn);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
// First sub-tab: fall through (goes to sub-tab bar at row 0)
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// In the bottom save row: ↓ navigates to the next visible sibling within the row
|
|
1915
|
+
// rather than wrapping to row 0 (sub-tab bar) via modulo.
|
|
1916
|
+
const lastRowIdx = _rows.length - 1;
|
|
1917
|
+
if (delta > 0 && rowIdx === lastRowIdx) {
|
|
1918
|
+
const row = _rows[lastRowIdx];
|
|
1919
|
+
const posInRow = row.indexOf(focused);
|
|
1920
|
+
for (let i = posInRow + 1; i < row.length; i++) {
|
|
1921
|
+
if (!row[i].hidden) {
|
|
1922
|
+
_currentIdx = _buttons.indexOf(row[i]);
|
|
1923
|
+
_focusButton(row[i]);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
// Last sibling in the row — wrap up to sub-tab bar (row 0)
|
|
1928
|
+
const topBtn = _firstVisibleBtn(_rows[0]);
|
|
1929
|
+
_currentIdx = _buttons.indexOf(topBtn);
|
|
1930
|
+
_focusButton(topBtn);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// Skip rows where ALL buttons are hidden (e.g. SSH alias row when destination is local).
|
|
1935
|
+
// Use _firstVisibleBtn so we land on the first visible button in a mixed row.
|
|
1936
|
+
let attempts = 0;
|
|
1937
|
+
do {
|
|
1938
|
+
rowIdx = (rowIdx + delta + _rows.length) % _rows.length;
|
|
1939
|
+
attempts++;
|
|
1940
|
+
} while (!_isRowVisible(_rows[rowIdx]) && attempts < _rows.length);
|
|
1941
|
+
const btn = _firstVisibleBtn(_rows[rowIdx]);
|
|
1942
|
+
_currentIdx = _buttons.indexOf(btn);
|
|
1943
|
+
_focusButton(btn);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
for (const btn of _buttons) {
|
|
1947
|
+
btn.key(['down'], () => _navigateRow(1));
|
|
1948
|
+
btn.key(['up'], () => _navigateRow(-1));
|
|
1949
|
+
btn.key(['escape'], () => { if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0); });
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// ← / → within content rows — uses _buttonGroups (static); sub-tab bar has its own wiring
|
|
1953
|
+
const _rows = []; // populated dynamically by _showSubTab()
|
|
1954
|
+
|
|
1955
|
+
const _buttonGroups = [
|
|
1956
|
+
[switchBtn], [changeBtn, playBtn],
|
|
1957
|
+
[reverbChangeBtn, reverbTestBtn],
|
|
1958
|
+
[trackChangeBtn, musicToggleBtn, musicTestBtn],
|
|
1959
|
+
[volumeChangeBtn],
|
|
1960
|
+
[verbosityChangeBtn],
|
|
1961
|
+
[personalityChangeBtn, personalityTestBtn],
|
|
1962
|
+
[introEditBtn, introClearBtn],
|
|
1963
|
+
[audioDstChangeBtn],
|
|
1964
|
+
[audioSshEditBtn, audioStreamModeBtn],
|
|
1965
|
+
[fullPreviewBtn],
|
|
1966
|
+
[saveGloballyBtn, saveLocallyBtn, cancelChangesBtn],
|
|
1967
|
+
];
|
|
1968
|
+
|
|
1969
|
+
for (const row of _buttonGroups) {
|
|
1970
|
+
for (let i = 0; i < row.length; i++) {
|
|
1971
|
+
if (i < row.length - 1) {
|
|
1972
|
+
row[i].key(['right'], () => {
|
|
1973
|
+
// Skip hidden siblings (e.g. SSH/stream mode when destination is local)
|
|
1974
|
+
let next = i + 1;
|
|
1975
|
+
while (next < row.length && row[next].hidden) next++;
|
|
1976
|
+
if (next < row.length) { _currentIdx = _buttons.indexOf(row[next]); _focusButton(row[next]); }
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
if (i > 0) {
|
|
1980
|
+
row[i].key(['left'], () => {
|
|
1981
|
+
let prev = i - 1;
|
|
1982
|
+
while (prev >= 0 && row[prev].hidden) prev--;
|
|
1983
|
+
if (prev >= 0) { _currentIdx = _buttons.indexOf(row[prev]); _focusButton(row[prev]); }
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Tab/S-tab cycle: bottom buttons ↔ main header tab bar
|
|
1990
|
+
// Debounce prevents key-repeat from firing on the newly-focused button in the same stroke.
|
|
1991
|
+
let _tabBusy = false;
|
|
1992
|
+
const _withTabDebounce = (fn) => () => {
|
|
1993
|
+
if (_tabBusy) return;
|
|
1994
|
+
_tabBusy = true;
|
|
1995
|
+
setTimeout(() => { _tabBusy = false; }, 120);
|
|
1996
|
+
fn();
|
|
1997
|
+
};
|
|
1998
|
+
|
|
1999
|
+
fullPreviewBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); }));
|
|
2000
|
+
saveGloballyBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); }));
|
|
2001
|
+
saveLocallyBtn.key(['tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(cancelChangesBtn); _focusButton(cancelChangesBtn); }));
|
|
2002
|
+
cancelChangesBtn.key(['tab'], _withTabDebounce(() => { if (typeof focusFirstHeaderItem === 'function') focusFirstHeaderItem(); }));
|
|
2003
|
+
|
|
2004
|
+
fullPreviewBtn.key(['S-tab'], _withTabDebounce(() => { if (typeof focusLastHeaderItem === 'function') focusLastHeaderItem(); }));
|
|
2005
|
+
saveGloballyBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(fullPreviewBtn); _focusButton(fullPreviewBtn); }));
|
|
2006
|
+
saveLocallyBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveGloballyBtn); _focusButton(saveGloballyBtn); }));
|
|
2007
|
+
cancelChangesBtn.key(['S-tab'], _withTabDebounce(() => { _currentIdx = _buttons.indexOf(saveLocallyBtn); _focusButton(saveLocallyBtn); }));
|
|
2008
|
+
|
|
2009
|
+
// Wire sub-tab ←/→ and Tab/S-tab to switch sub-tabs
|
|
2010
|
+
for (let i = 0; i < SUB_TABS.length; i++) {
|
|
2011
|
+
const item = _subTabItemsMap[SUB_TABS[i]];
|
|
2012
|
+
if (i < SUB_TABS.length - 1) {
|
|
2013
|
+
item.key(['right'], () => {
|
|
2014
|
+
_showSubTab(SUB_TABS[i + 1], true);
|
|
2015
|
+
_focusButton(_subTabItemsArray[i + 1]);
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
if (i > 0) {
|
|
2019
|
+
item.key(['left'], () => {
|
|
2020
|
+
_showSubTab(SUB_TABS[i - 1], true);
|
|
2021
|
+
_focusButton(_subTabItemsArray[i - 1]);
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
// Tab/S-tab wrap-cycle through sub-tab bar (independent of global header Tab cycle)
|
|
2025
|
+
item.key(['tab'], () => {
|
|
2026
|
+
const next = (i + 1) % SUB_TABS.length;
|
|
2027
|
+
_showSubTab(SUB_TABS[next], true);
|
|
2028
|
+
_focusButton(_subTabItemsArray[next]);
|
|
2029
|
+
});
|
|
2030
|
+
item.key(['S-tab'], () => {
|
|
2031
|
+
const prev = (i - 1 + SUB_TABS.length) % SUB_TABS.length;
|
|
2032
|
+
_showSubTab(SUB_TABS[prev], true);
|
|
2033
|
+
_focusButton(_subTabItemsArray[prev]);
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// Initialize with Voice sub-tab active
|
|
2038
|
+
_showSubTab('voice');
|
|
2039
|
+
_currentIdx = _buttons.indexOf(switchBtn);
|
|
2040
|
+
|
|
2041
|
+
// @function _refreshSopranoStatus
|
|
2042
|
+
// @intent Update the provider status glyph (🟢/🟡/🔴) without blocking the render loop
|
|
2043
|
+
// @why soprano-manager status does an HTTP health-check (up to 2s) — must be async
|
|
2044
|
+
function _refreshSopranoStatus() {
|
|
2045
|
+
if (_sopranoStatusProc) {
|
|
2046
|
+
try { _sopranoStatusProc.kill(); } catch {}
|
|
2047
|
+
_sopranoStatusProc = null;
|
|
2048
|
+
}
|
|
2049
|
+
const managerPath = path.resolve(new URL(import.meta.url).pathname,
|
|
2050
|
+
'..', '..', '..', '..', '.claude', 'hooks', 'soprano-manager.sh');
|
|
2051
|
+
if (!fs.existsSync(managerPath)) return;
|
|
2052
|
+
|
|
2053
|
+
const proc = spawn('bash', [managerPath, 'status'], {
|
|
2054
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
2055
|
+
});
|
|
2056
|
+
_sopranoStatusProc = proc;
|
|
2057
|
+
|
|
2058
|
+
proc.on('exit', (code) => {
|
|
2059
|
+
_sopranoStatusProc = null;
|
|
2060
|
+
// 0=running(🟢) 1=starting(🟡) 2=stopped(🔴) 3=conflict(🔴)
|
|
2061
|
+
_sopranoStatusGlyph = code === 0 ? ' 🟢' : code === 1 ? ' 🟡' : ' 🔴';
|
|
2062
|
+
if (providerService.getActiveProvider() === 'soprano') {
|
|
2063
|
+
const name = _ALL_PROVIDERS.find(p => p.id === 'soprano')?.name ?? 'Soprano';
|
|
2064
|
+
providerValue.setContent(name + _sopranoStatusGlyph);
|
|
2065
|
+
screen.render();
|
|
2066
|
+
}
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
proc.on('error', () => { _sopranoStatusProc = null; });
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function refreshDisplay() {
|
|
2073
|
+
const activeProvider = providerService.getActiveProvider();
|
|
2074
|
+
const activeVoice = providerService.getActiveVoiceId();
|
|
2075
|
+
const provName = _ALL_PROVIDERS.find(p => p.id === activeProvider)?.name ?? activeProvider;
|
|
2076
|
+
if (activeProvider === 'soprano') {
|
|
2077
|
+
// Show cached glyph immediately, kick off async refresh for updated status
|
|
2078
|
+
providerValue.setContent(provName + _sopranoStatusGlyph);
|
|
2079
|
+
_refreshSopranoStatus();
|
|
2080
|
+
} else {
|
|
2081
|
+
providerValue.setContent(provName);
|
|
2082
|
+
// Cancel any pending status check and clear glyph when leaving soprano
|
|
2083
|
+
_sopranoStatusGlyph = '';
|
|
2084
|
+
if (_sopranoStatusProc) { try { _sopranoStatusProc.kill(); } catch {} _sopranoStatusProc = null; }
|
|
2085
|
+
}
|
|
2086
|
+
// Single-voice providers: show the provider name instead of voice ID
|
|
2087
|
+
// For multi-speaker voices, show speaker name (e.g., "Kristin_Hughes" not "16Speakers::Kristin_Hughes")
|
|
2088
|
+
const _msDisplay = parseMultiSpeaker(activeVoice);
|
|
2089
|
+
voiceValue.setContent(activeProvider === 'soprano' ? 'Soprano' : (_msDisplay.isMultiSpeaker ? _msDisplay.speakerName : activeVoice));
|
|
2090
|
+
// Only Piper supports multiple installed voices — hide Change for single-voice providers
|
|
2091
|
+
if (activeProvider === 'piper') { changeBtn.show(); playBtn.left = 64; voiceFileText.setContent('.claude/tts-voice.txt'); }
|
|
2092
|
+
else { changeBtn.hide(); playBtn.left = 52; voiceFileText.setContent(''); }
|
|
2093
|
+
|
|
2094
|
+
// Group 2: Audio Effects
|
|
2095
|
+
const effects = configService.getConfig().effects ?? EFFECTS_DEFAULTS;
|
|
2096
|
+
reverbValue.setContent(formatReverbState(effects.reverbPreset ?? 'light'));
|
|
2097
|
+
|
|
2098
|
+
// Group 3: Background Music
|
|
2099
|
+
const music = configService.getConfig().backgroundMusic ?? configService.getConfig().music ?? MUSIC_DEFAULTS;
|
|
2100
|
+
// Strip leading emoji so double-width chars don't misalign buttons on the same row
|
|
2101
|
+
trackValue.setContent(_stripLeadingEmoji(formatTrackName(music.track)));
|
|
2102
|
+
const musicEnabled = music.enabled ?? false;
|
|
2103
|
+
musicToggleBtn.setContent(musicEnabled ? 'Enabled' : 'Disabled');
|
|
2104
|
+
musicToggleBtn.style.bg = musicEnabled ? COLORS.btnEnableOn : COLORS.btnEnableOff;
|
|
2105
|
+
volumeValue.setContent(formatVolume(music.volume));
|
|
2106
|
+
|
|
2107
|
+
// Group 4: Personality & Verbosity
|
|
2108
|
+
const cfg = configService.getConfig();
|
|
2109
|
+
verbosityValue.setContent(formatVerbosity(cfg.verbosity));
|
|
2110
|
+
personalityValue.setContent(_stripLeadingEmoji(formatPersonality(cfg.personality)));
|
|
2111
|
+
const _pers = (cfg.personality ?? '').trim();
|
|
2112
|
+
personalityFileText.setContent(
|
|
2113
|
+
(_pers && _pers !== 'none' && _pers !== 'normal')
|
|
2114
|
+
? `.claude/personalities/${_pers}.md`
|
|
2115
|
+
: '',
|
|
2116
|
+
);
|
|
2117
|
+
|
|
2118
|
+
// Group 5: Intro Text
|
|
2119
|
+
introTextValue.setContent(formatIntroText(cfg.pretext));
|
|
2120
|
+
|
|
2121
|
+
// Group 6: Audio Destination
|
|
2122
|
+
const audioDst = cfg.audio_destination ?? 'local';
|
|
2123
|
+
const audioAlias = cfg.audio_ssh_alias ?? '';
|
|
2124
|
+
audioDstValue.setContent(formatAudioDst(audioDst, audioAlias));
|
|
2125
|
+
// Show/hide SSH Alias row and stream mode toggle based on destination
|
|
2126
|
+
if (audioDst === 'remote') {
|
|
2127
|
+
audioSshLabel.show();
|
|
2128
|
+
audioSshValue.show();
|
|
2129
|
+
audioSshEditBtn.show();
|
|
2130
|
+
audioStreamModeBtn.show();
|
|
2131
|
+
audioSshValue.setContent(audioAlias || '(none)');
|
|
2132
|
+
const streamMode = cfg.audio_stream_mode ?? 'text';
|
|
2133
|
+
audioStreamModeBtn.setContent(streamMode === 'pulse' ? 'Streaming Pulse Audio' : 'Streaming Text Only ✓');
|
|
2134
|
+
audioStreamModeBtn.style.bg = streamMode === 'text' ? '#2e7d32' : COLORS.btnChange;
|
|
2135
|
+
} else {
|
|
2136
|
+
audioSshLabel.hide();
|
|
2137
|
+
audioSshValue.hide();
|
|
2138
|
+
audioSshEditBtn.hide();
|
|
2139
|
+
audioStreamModeBtn.hide();
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
if (typeof updateHeaderStatus === 'function') updateHeaderStatus();
|
|
2143
|
+
screen.render();
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function refreshConfigDisplay() {
|
|
2147
|
+
const globalPath = configService.getGlobalConfigPath();
|
|
2148
|
+
const localPath = configService.getLocalConfigPath();
|
|
2149
|
+
const hasLocal = configService.hasLocalConfig();
|
|
2150
|
+
// Abbreviate home dir with ~ for readability
|
|
2151
|
+
const home = os.homedir();
|
|
2152
|
+
const abbrev = (p) => p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
2153
|
+
configGlobalValue.setContent(abbrev(globalPath));
|
|
2154
|
+
// Local path shown in full (not abbreviated) so the user sees the real location
|
|
2155
|
+
configLocalValue.setContent(
|
|
2156
|
+
hasLocal ? localPath : 'None (settings saved to global)',
|
|
2157
|
+
);
|
|
2158
|
+
screen.render();
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// -------------------------------------------------------------------------
|
|
2162
|
+
// Tab Component Contract implementation
|
|
2163
|
+
|
|
2164
|
+
return {
|
|
2165
|
+
box,
|
|
2166
|
+
|
|
2167
|
+
show() {
|
|
2168
|
+
_captureSnapshot();
|
|
2169
|
+
box.show();
|
|
2170
|
+
refreshDisplay();
|
|
2171
|
+
refreshConfigDisplay();
|
|
2172
|
+
// Force full olines invalidation — prevents ghost rows when the tab becomes visible
|
|
2173
|
+
try {
|
|
2174
|
+
for (let r = 0; r < screen.height; r++)
|
|
2175
|
+
for (let c = 0; c < screen.width; c++)
|
|
2176
|
+
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2177
|
+
} catch {}
|
|
2178
|
+
screen.render();
|
|
2179
|
+
},
|
|
2180
|
+
|
|
2181
|
+
hide() {
|
|
2182
|
+
_killSample();
|
|
2183
|
+
playBtn.setContent('▶ Play');
|
|
2184
|
+
_killTest();
|
|
2185
|
+
_restoreTestBtnsLabels();
|
|
2186
|
+
_killMusicTest();
|
|
2187
|
+
musicTestBtn.setContent('▶ Preview');
|
|
2188
|
+
// Kill any pending soprano status check
|
|
2189
|
+
if (_sopranoStatusProc) {
|
|
2190
|
+
try { _sopranoStatusProc.kill(); } catch {}
|
|
2191
|
+
_sopranoStatusProc = null;
|
|
2192
|
+
}
|
|
2193
|
+
box.hide();
|
|
2194
|
+
screen.render();
|
|
2195
|
+
},
|
|
2196
|
+
|
|
2197
|
+
onFocus() {
|
|
2198
|
+
// Use _focusButton (not raw .focus()) so olines get invalidated before render,
|
|
2199
|
+
// preventing the ghost-duplicate-row artifact on initial tab activation.
|
|
2200
|
+
_focusButton(_buttons[_currentIdx]);
|
|
2201
|
+
},
|
|
2202
|
+
|
|
2203
|
+
onBlur() {
|
|
2204
|
+
_killSample();
|
|
2205
|
+
playBtn.setContent('▶ Play');
|
|
2206
|
+
_killTest();
|
|
2207
|
+
_restoreTestBtnsLabels();
|
|
2208
|
+
_killMusicTest();
|
|
2209
|
+
musicTestBtn.setContent('▶ Preview');
|
|
2210
|
+
},
|
|
2211
|
+
|
|
2212
|
+
getFooterText() {
|
|
2213
|
+
return FOOTER_TEXT;
|
|
2214
|
+
},
|
|
2215
|
+
|
|
2216
|
+
getFooterColor() {
|
|
2217
|
+
return COLORS.footerBg;
|
|
2218
|
+
},
|
|
2219
|
+
|
|
2220
|
+
focusBottomRow() {
|
|
2221
|
+
_currentIdx = _buttons.indexOf(fullPreviewBtn);
|
|
2222
|
+
_focusButton(fullPreviewBtn);
|
|
2223
|
+
},
|
|
2224
|
+
|
|
2225
|
+
focusLastBottomRow() {
|
|
2226
|
+
_currentIdx = _buttons.indexOf(cancelChangesBtn);
|
|
2227
|
+
_focusButton(cancelChangesBtn);
|
|
2228
|
+
},
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// ---------------------------------------------------------------------------
|
|
2233
|
+
// Private: Create a styled focusable button
|
|
2234
|
+
|
|
2235
|
+
function _createButton(parent, screen, label, COLORS, onClick, opts = {}) {
|
|
2236
|
+
const baseBg = opts.bg ?? COLORS.btnDefault;
|
|
2237
|
+
const getDynamicBg = opts.getDynamicBg ?? null;
|
|
2238
|
+
const btn = blessed.button({
|
|
2239
|
+
parent,
|
|
2240
|
+
content: label,
|
|
2241
|
+
mouse: true,
|
|
2242
|
+
keys: true,
|
|
2243
|
+
shrink: true,
|
|
2244
|
+
padding: { left: 1, right: 1 },
|
|
2245
|
+
style: {
|
|
2246
|
+
bg: baseBg,
|
|
2247
|
+
fg: 'white',
|
|
2248
|
+
focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
2249
|
+
},
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
// Focus indicators: ►label◄ with blinking █ cursor
|
|
2253
|
+
// Store interval on the button so it can be cleared when the button is hidden.
|
|
2254
|
+
btn._btnBlinkInterval = null;
|
|
2255
|
+
btn.on('focus', () => {
|
|
2256
|
+
btn.style.bg = COLORS.btnFocus;
|
|
2257
|
+
btn.style.fg = COLORS.btnFocusFg;
|
|
2258
|
+
const raw = btn.content.replace(/[►◄█]/g, '').trim();
|
|
2259
|
+
btn.setContent(`►${raw}◄ █`);
|
|
2260
|
+
let _on = true;
|
|
2261
|
+
screen.render();
|
|
2262
|
+
btn._btnBlinkInterval = setInterval(() => {
|
|
2263
|
+
_on = !_on;
|
|
2264
|
+
// Skip if spinner has overridden the content (no ► means spinner is active)
|
|
2265
|
+
if (!btn.content.includes('►')) return;
|
|
2266
|
+
const r = btn.content.replace(/[►◄█]/g, '').trim();
|
|
2267
|
+
btn.setContent(_on ? `►${r}◄ █` : `►${r}◄`);
|
|
2268
|
+
screen.render();
|
|
2269
|
+
}, 500);
|
|
2270
|
+
});
|
|
2271
|
+
btn.on('blur', () => {
|
|
2272
|
+
if (btn._btnBlinkInterval) { clearInterval(btn._btnBlinkInterval); btn._btnBlinkInterval = null; }
|
|
2273
|
+
btn.style.bg = getDynamicBg ? getDynamicBg() : baseBg;
|
|
2274
|
+
btn.style.fg = 'white';
|
|
2275
|
+
const raw = btn.content.replace(/[►◄█]/g, '').trim();
|
|
2276
|
+
btn.setContent(raw);
|
|
2277
|
+
screen.render();
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
// Keyboard activation with magenta flash
|
|
2281
|
+
btn.key(['enter', 'space'], () => {
|
|
2282
|
+
btn.style.bg = COLORS.btnPress;
|
|
2283
|
+
btn.style.fg = 'white';
|
|
2284
|
+
screen.render();
|
|
2285
|
+
setTimeout(() => {
|
|
2286
|
+
btn.style.bg = getDynamicBg ? getDynamicBg() : baseBg;
|
|
2287
|
+
btn.style.fg = 'white';
|
|
2288
|
+
screen.render();
|
|
2289
|
+
onClick();
|
|
2290
|
+
}, 150);
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
// Mouse click only — no mouseover so hover never causes render artifacts
|
|
2294
|
+
btn.on('click', () => btn.press());
|
|
2295
|
+
|
|
2296
|
+
return btn;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// ---------------------------------------------------------------------------
|
|
2300
|
+
// Private: Provider picker modal — all providers, install status, instructions
|
|
2301
|
+
|
|
2302
|
+
const _ALL_PROVIDERS = [
|
|
2303
|
+
{ id: 'piper', name: 'Piper TTS', platforms: ['linux', 'darwin', 'win32'], desc: 'High-quality local neural TTS' },
|
|
2304
|
+
{ id: 'soprano', name: 'Soprano', platforms: ['linux', 'darwin'], desc: 'Ultra-fast neural TTS (single voice)' },
|
|
2305
|
+
{ id: 'windows-sapi', name: 'Windows SAPI', platforms: ['win32'], desc: 'Windows built-in text-to-speech' },
|
|
2306
|
+
{ id: 'macos', name: 'Mac Say', platforms: ['darwin'], desc: 'macOS built-in text-to-speech' },
|
|
2307
|
+
];
|
|
2308
|
+
|
|
2309
|
+
const _INSTALL_CMDS = {
|
|
2310
|
+
piper: ['pip install piper-tts', 'OR: pipx install piper-tts', '', 'Voices are downloaded separately:', 'Run: agentvibes install (then choose Piper)'],
|
|
2311
|
+
soprano: ['pip install soprano-tts', 'OR: pipx install soprano-tts', '', 'Keep model loaded for fast synthesis:', 'soprano-webui'],
|
|
2312
|
+
'windows-sapi': ['Built-in on Windows — no install required.', 'Only works in a native Windows shell,', 'not inside WSL. Use piper or soprano in WSL.'],
|
|
2313
|
+
macos: ['Built-in on macOS — no install required.', 'The say command ships with every Mac.'],
|
|
2314
|
+
};
|
|
2315
|
+
|
|
2316
|
+
function _detectEnvLabel() {
|
|
2317
|
+
if (process.platform === 'win32') return { label: 'Windows', platform: 'win32' };
|
|
2318
|
+
if (process.platform === 'darwin') return { label: 'macOS', platform: 'darwin' };
|
|
2319
|
+
try {
|
|
2320
|
+
const v = fs.readFileSync('/proc/version', 'utf8');
|
|
2321
|
+
if (v.toLowerCase().includes('microsoft')) return { label: 'WSL (Linux/Microsoft)', platform: 'linux' };
|
|
2322
|
+
} catch {}
|
|
2323
|
+
return { label: 'Linux', platform: 'linux' };
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function _openProviderPicker(screen, providerService, onSelect, onClose) {
|
|
2327
|
+
const { label: envLabel, platform } = _detectEnvLabel();
|
|
2328
|
+
const installed = new Set(providerService.getInstalledProviders());
|
|
2329
|
+
const current = providerService.getActiveProvider();
|
|
2330
|
+
|
|
2331
|
+
const modal = blessed.box({
|
|
2332
|
+
parent: screen,
|
|
2333
|
+
top: 'center',
|
|
2334
|
+
left: 'center',
|
|
2335
|
+
width: 70,
|
|
2336
|
+
height: 24,
|
|
2337
|
+
border: { type: 'line' },
|
|
2338
|
+
tags: true,
|
|
2339
|
+
label: _modalTitle('Select Provider'),
|
|
2340
|
+
style: { border: { fg: COLORS.btnFocus }, bg: COLORS.contentBg },
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
function _close() {
|
|
2344
|
+
modal.destroy();
|
|
2345
|
+
try {
|
|
2346
|
+
for (let r = 0; r < screen.height; r++)
|
|
2347
|
+
for (let c = 0; c < screen.width; c++)
|
|
2348
|
+
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2349
|
+
} catch {}
|
|
2350
|
+
onClose?.();
|
|
2351
|
+
screen.render();
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// Environment header
|
|
2355
|
+
blessed.text({
|
|
2356
|
+
parent: modal, top: 0, left: 1, tags: true,
|
|
2357
|
+
content: `{#00e5ff-fg}🖥 Environment:{/#00e5ff-fg} {bold}${envLabel}{/bold}`,
|
|
2358
|
+
style: { bg: COLORS.contentBg },
|
|
2359
|
+
});
|
|
2360
|
+
blessed.text({
|
|
2361
|
+
parent: modal, top: 1, left: 0,
|
|
2362
|
+
content: ' ' + '─'.repeat(66),
|
|
2363
|
+
style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg },
|
|
2364
|
+
});
|
|
2365
|
+
|
|
2366
|
+
// Provider rows (top 2–5)
|
|
2367
|
+
const actionBtns = [];
|
|
2368
|
+
let focusIdx = 0;
|
|
2369
|
+
|
|
2370
|
+
_ALL_PROVIDERS.forEach((prov, i) => {
|
|
2371
|
+
const rowTop = 2 + (i * 2); // 2 rows per provider: name row + description row
|
|
2372
|
+
const isSupported = prov.platforms.includes(platform);
|
|
2373
|
+
const isInstalled = installed.has(prov.id);
|
|
2374
|
+
const isCurrent = prov.id === current;
|
|
2375
|
+
|
|
2376
|
+
if (!isSupported) {
|
|
2377
|
+
const osMap = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' };
|
|
2378
|
+
const forOs = prov.platforms.map(p => osMap[p] ?? p).join('/');
|
|
2379
|
+
blessed.text({
|
|
2380
|
+
parent: modal, top: rowTop, left: 1, width: 66, tags: true,
|
|
2381
|
+
content: `{#546e7a-fg}✗ ${prov.name} — only on: ${forOs}{/#546e7a-fg}`,
|
|
2382
|
+
style: { bg: COLORS.contentBg },
|
|
2383
|
+
});
|
|
2384
|
+
blessed.text({
|
|
2385
|
+
parent: modal, top: rowTop + 1, left: 5, width: 62, tags: true,
|
|
2386
|
+
content: `{#455a64-fg}${prov.desc}{/#455a64-fg}`,
|
|
2387
|
+
style: { bg: COLORS.contentBg },
|
|
2388
|
+
});
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
const icon = isInstalled ? '{green-fg}✓{/green-fg}' : '{#ef9a9a-fg}✗{/#ef9a9a-fg}';
|
|
2393
|
+
const name = isInstalled ? `{bold}${prov.name}{/bold}` : prov.name;
|
|
2394
|
+
const active = isCurrent ? ' {yellow-fg}[active]{/yellow-fg}' : '';
|
|
2395
|
+
const status = isInstalled ? '{green-fg}Installed{/green-fg}' : '{#ef9a9a-fg}Not found{/#ef9a9a-fg}';
|
|
2396
|
+
|
|
2397
|
+
blessed.text({ parent: modal, top: rowTop, left: 1, width: 30, tags: true, content: `${icon} ${name}${active}`, style: { bg: COLORS.contentBg } });
|
|
2398
|
+
blessed.text({ parent: modal, top: rowTop, left: 44, width: 12, tags: true, content: status, style: { bg: COLORS.contentBg } });
|
|
2399
|
+
blessed.text({ parent: modal, top: rowTop + 1, left: 5, width: 60, tags: true,
|
|
2400
|
+
content: `{#90a4ae-fg}${prov.desc}{/#90a4ae-fg}`, style: { bg: COLORS.contentBg } });
|
|
2401
|
+
|
|
2402
|
+
const btn = _createButton(modal, screen, isInstalled ? 'Select' : 'Install', COLORS, () => {
|
|
2403
|
+
if (isInstalled) {
|
|
2404
|
+
_close(); onSelect(prov.id);
|
|
2405
|
+
} else {
|
|
2406
|
+
const lines = _INSTALL_CMDS[prov.id] ?? ['No instructions available.'];
|
|
2407
|
+
instrTitle.setContent(`{#7986cb-fg}Install — ${prov.name}:{/#7986cb-fg}`);
|
|
2408
|
+
instrContent.setContent(lines.map(l => l ? `{#00e5ff-fg}${l}{/#00e5ff-fg}` : '').join('\n'));
|
|
2409
|
+
screen.render();
|
|
2410
|
+
}
|
|
2411
|
+
});
|
|
2412
|
+
btn.top = rowTop; btn.left = 57;
|
|
2413
|
+
if (isCurrent) focusIdx = actionBtns.length;
|
|
2414
|
+
actionBtns.push(btn);
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
// Separator + instructions panel (shifted down 4 rows due to 2-row provider layout)
|
|
2418
|
+
blessed.text({ parent: modal, top: 10, left: 0, content: ' ' + '─'.repeat(66), style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg } });
|
|
2419
|
+
|
|
2420
|
+
const instrTitle = blessed.text({
|
|
2421
|
+
parent: modal, top: 11, left: 1, width: 66, tags: true,
|
|
2422
|
+
content: '{#7986cb-fg}Install instructions — click Install beside a provider:{/#7986cb-fg}',
|
|
2423
|
+
style: { bg: COLORS.contentBg },
|
|
2424
|
+
});
|
|
2425
|
+
const instrContent = blessed.text({
|
|
2426
|
+
parent: modal, top: 12, left: 3, width: 64, height: 5, tags: true,
|
|
2427
|
+
content: '{#546e7a-fg}(click Install beside a provider to see commands){/#546e7a-fg}',
|
|
2428
|
+
style: { bg: COLORS.contentBg },
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
// Bottom separator + Cancel
|
|
2432
|
+
blessed.text({ parent: modal, top: 18, left: 0, content: ' ' + '─'.repeat(66), style: { fg: COLORS.sectionHdr, bg: COLORS.contentBg } });
|
|
2433
|
+
|
|
2434
|
+
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close);
|
|
2435
|
+
cancelBtn.top = 19; cancelBtn.left = 'center';
|
|
2436
|
+
actionBtns.push(cancelBtn);
|
|
2437
|
+
|
|
2438
|
+
// Keyboard navigation
|
|
2439
|
+
for (let i = 0; i < actionBtns.length; i++) {
|
|
2440
|
+
actionBtns[i].key(['down', 'tab'], () => {
|
|
2441
|
+
const cur = actionBtns.findIndex(b => b === screen.focused);
|
|
2442
|
+
actionBtns[(cur + 1) % actionBtns.length].focus();
|
|
2443
|
+
});
|
|
2444
|
+
actionBtns[i].key(['up', 'S-tab'], () => {
|
|
2445
|
+
const cur = actionBtns.findIndex(b => b === screen.focused);
|
|
2446
|
+
actionBtns[(cur - 1 + actionBtns.length) % actionBtns.length].focus();
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
modal.key(['escape', 'q'], _close);
|
|
2450
|
+
|
|
2451
|
+
(actionBtns[focusIdx] ?? actionBtns[0])?.focus();
|
|
2452
|
+
screen.render();
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// ---------------------------------------------------------------------------
|
|
2456
|
+
// Private: Destroy a list/modal widget and force-invalidate olines so blessed
|
|
2457
|
+
// physically redraws every cell the widget covered (avoids ghost rendering).
|
|
2458
|
+
|
|
2459
|
+
function _destroyList(list, screen, onClose) {
|
|
2460
|
+
list.destroy();
|
|
2461
|
+
try {
|
|
2462
|
+
for (let r = 0; r < screen.height; r++)
|
|
2463
|
+
for (let c = 0; c < screen.width; c++)
|
|
2464
|
+
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2465
|
+
} catch {}
|
|
2466
|
+
onClose?.();
|
|
2467
|
+
screen.render();
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// ---------------------------------------------------------------------------
|
|
2471
|
+
// Private: Show a temporary stub notice text
|
|
2472
|
+
|
|
2473
|
+
// Strip a leading emoji character (code points > U+2500 cover emoji ranges)
|
|
2474
|
+
// while preserving punctuation like en-dash (U+2013) and em-dash (U+2014).
|
|
2475
|
+
function _stripLeadingEmoji(s) {
|
|
2476
|
+
if (!s) return s;
|
|
2477
|
+
const cp = s.codePointAt(0);
|
|
2478
|
+
return cp > 0x2500 ? s.slice(String.fromCodePoint(cp).length).trimStart() : s;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/**
|
|
2482
|
+
* Show a "Save Preview" confirmation modal.
|
|
2483
|
+
* Displays the destination path and all key-value pairs that will be saved.
|
|
2484
|
+
* User must press [OK — Save] to confirm or [Cancel] to abort.
|
|
2485
|
+
*
|
|
2486
|
+
* @param {object} screen - blessed screen
|
|
2487
|
+
* @param {string} filePath - absolute destination path
|
|
2488
|
+
* @param {object} data - config object to be saved
|
|
2489
|
+
* @param {Function} onConfirm - called only if user presses OK
|
|
2490
|
+
*/
|
|
2491
|
+
function _showSavePreview(screen, filePath, data, onConfirm, onClose) {
|
|
2492
|
+
// Flatten nested objects one level deep
|
|
2493
|
+
const rawLines = [];
|
|
2494
|
+
for (const [k, v] of Object.entries(data)) {
|
|
2495
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
2496
|
+
for (const [sk, sv] of Object.entries(v)) {
|
|
2497
|
+
rawLines.push([`${k}.${sk}`, String(sv ?? '')]);
|
|
2498
|
+
}
|
|
2499
|
+
} else {
|
|
2500
|
+
rawLines.push([k, String(v ?? '')]);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
const keyWidth = rawLines.length ? Math.max(...rawLines.map(([k]) => k.length)) : 0;
|
|
2505
|
+
const pathLine = ` Path: ${filePath}`;
|
|
2506
|
+
const kvMaxW = rawLines.length ? Math.max(...rawLines.map(([k, v]) => 2 + keyWidth + 2 + v.length)) : 0;
|
|
2507
|
+
const innerW = Math.max(52, pathLine.length + 2, kvMaxW + 4);
|
|
2508
|
+
const width = Math.min(innerW + 4, screen.width - 4);
|
|
2509
|
+
const sep = '─'.repeat(Math.max(0, Math.min(innerW - 2, width - 6)));
|
|
2510
|
+
|
|
2511
|
+
const taggedKV = rawLines.map(([k, v]) =>
|
|
2512
|
+
` {#90a4ae-fg}${k.padEnd(keyWidth)}:{/#90a4ae-fg} {#ffff00-fg}${v}{/#ffff00-fg}`
|
|
2513
|
+
);
|
|
2514
|
+
|
|
2515
|
+
// Content rows (all text rendered via box.content; buttons are child widgets)
|
|
2516
|
+
const contentLines = [
|
|
2517
|
+
` {#90a4ae-fg}Path:{/#90a4ae-fg} ${filePath}`,
|
|
2518
|
+
` ${sep}`,
|
|
2519
|
+
...taggedKV,
|
|
2520
|
+
` ${sep}`,
|
|
2521
|
+
'', // blank row — buttons sit here as child widgets
|
|
2522
|
+
];
|
|
2523
|
+
|
|
2524
|
+
const height = contentLines.length + 2; // +2 for top/bottom border
|
|
2525
|
+
|
|
2526
|
+
const modal = blessed.box({
|
|
2527
|
+
parent: screen,
|
|
2528
|
+
top: 'center',
|
|
2529
|
+
left: 'center',
|
|
2530
|
+
width,
|
|
2531
|
+
height,
|
|
2532
|
+
label: _modalTitle('Save Preview'),
|
|
2533
|
+
border: { type: 'line' },
|
|
2534
|
+
tags: true,
|
|
2535
|
+
content: contentLines.join('\n'),
|
|
2536
|
+
style: {
|
|
2537
|
+
fg: '#e3f2fd',
|
|
2538
|
+
bg: COLORS.contentBg,
|
|
2539
|
+
border: { fg: '#00e5ff' },
|
|
2540
|
+
},
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
function _close() { _destroyList(modal, screen, onClose); }
|
|
2544
|
+
|
|
2545
|
+
modal.key(['escape'], _close);
|
|
2546
|
+
|
|
2547
|
+
// Buttons are children of the modal box; top is relative to box content area
|
|
2548
|
+
const btnRow = contentLines.length - 1; // last content line (the blank row)
|
|
2549
|
+
const midX = Math.floor((width - 2) / 2);
|
|
2550
|
+
|
|
2551
|
+
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close, { bg: '#c62828' });
|
|
2552
|
+
cancelBtn.top = btnRow;
|
|
2553
|
+
cancelBtn.left = midX - 14;
|
|
2554
|
+
|
|
2555
|
+
const okBtn = _createButton(modal, screen, 'OK — Save', COLORS, () => {
|
|
2556
|
+
_close();
|
|
2557
|
+
onConfirm();
|
|
2558
|
+
}, { bg: '#2e7d32' });
|
|
2559
|
+
okBtn.top = btnRow;
|
|
2560
|
+
okBtn.left = midX + 2;
|
|
2561
|
+
|
|
2562
|
+
// Keyboard navigation between OK and Cancel buttons
|
|
2563
|
+
okBtn.key(['tab', 'left', 'right'], () => { cancelBtn.focus(); screen.render(); });
|
|
2564
|
+
cancelBtn.key(['tab', 'left', 'right'], () => { okBtn.focus(); screen.render(); });
|
|
2565
|
+
|
|
2566
|
+
screen.render();
|
|
2567
|
+
okBtn.focus();
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function _showNotice(screen, message) {
|
|
2571
|
+
const width = Math.max(28, message.length + 6);
|
|
2572
|
+
const modal = blessed.box({
|
|
2573
|
+
parent: screen,
|
|
2574
|
+
top: 'center',
|
|
2575
|
+
left: 'center',
|
|
2576
|
+
width,
|
|
2577
|
+
height: 3,
|
|
2578
|
+
border: { type: 'line' },
|
|
2579
|
+
tags: true,
|
|
2580
|
+
content: `{center}${message}{/center}`,
|
|
2581
|
+
style: {
|
|
2582
|
+
fg: '#e3f2fd',
|
|
2583
|
+
bg: COLORS.contentBg,
|
|
2584
|
+
border: { fg: '#00e5ff' },
|
|
2585
|
+
},
|
|
2586
|
+
});
|
|
2587
|
+
screen.render();
|
|
2588
|
+
|
|
2589
|
+
setTimeout(() => {
|
|
2590
|
+
_destroyList(modal, screen);
|
|
2591
|
+
}, 2500);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// ---------------------------------------------------------------------------
|
|
2595
|
+
// Private: Effects config read/write helpers
|
|
2596
|
+
|
|
2597
|
+
function _getEffects(configService) {
|
|
2598
|
+
return configService.getConfig().effects ?? EFFECTS_DEFAULTS;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
function _setEffects(configService, partial) {
|
|
2602
|
+
const current = configService.getConfig().effects ?? EFFECTS_DEFAULTS;
|
|
2603
|
+
const merged = { ...current, ...partial };
|
|
2604
|
+
configService.set('effects', merged);
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// ---------------------------------------------------------------------------
|
|
2608
|
+
// Private: Inline reverb preset picker
|
|
2609
|
+
|
|
2610
|
+
function _openReverbPicker(screen, configService, onSelect, onClose) {
|
|
2611
|
+
const PRESETS = [
|
|
2612
|
+
{ label: 'Off (Dry, no reverb)', value: 'off' },
|
|
2613
|
+
{ label: 'Light (Small room)', value: 'light' },
|
|
2614
|
+
{ label: 'Medium (Conference room)', value: 'medium' },
|
|
2615
|
+
{ label: 'Heavy (Large hall)', value: 'heavy' },
|
|
2616
|
+
{ label: 'Cathedral (Epic space)', value: 'cathedral' },
|
|
2617
|
+
];
|
|
2618
|
+
|
|
2619
|
+
const currentPreset = configService.getConfig().effects?.reverbPreset ?? 'light';
|
|
2620
|
+
const currentIdx = Math.max(0, PRESETS.findIndex(p => p.value === currentPreset));
|
|
2621
|
+
|
|
2622
|
+
const list = blessed.list({
|
|
2623
|
+
parent: screen,
|
|
2624
|
+
top: 'center',
|
|
2625
|
+
left: 'center',
|
|
2626
|
+
width: 40,
|
|
2627
|
+
height: PRESETS.length + 4,
|
|
2628
|
+
border: { type: 'line' },
|
|
2629
|
+
tags: true,
|
|
2630
|
+
label: _modalTitle('Select Reverb Preset'),
|
|
2631
|
+
items: PRESETS.map((p, i) => (i === currentIdx ? `● ${p.label}` : ` ${p.label}`)),
|
|
2632
|
+
keys: true,
|
|
2633
|
+
vi: false,
|
|
2634
|
+
mouse: true,
|
|
2635
|
+
style: {
|
|
2636
|
+
border: { fg: COLORS.btnFocus },
|
|
2637
|
+
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
2638
|
+
item: { fg: '#e3f2fd' },
|
|
2639
|
+
},
|
|
2640
|
+
});
|
|
2641
|
+
|
|
2642
|
+
list.select(currentIdx);
|
|
2643
|
+
list.focus();
|
|
2644
|
+
screen.render();
|
|
2645
|
+
|
|
2646
|
+
list.key(['enter', 'space'], () => {
|
|
2647
|
+
const selected = PRESETS[list.selected];
|
|
2648
|
+
if (!selected) return;
|
|
2649
|
+
_destroyList(list, screen, onClose);
|
|
2650
|
+
|
|
2651
|
+
// Apply to audio config via effects-manager.sh
|
|
2652
|
+
const effectsScript = path.join(process.cwd(), '.claude', 'hooks', 'effects-manager.sh');
|
|
2653
|
+
spawnSync('bash', [effectsScript, 'set-reverb', selected.value, 'default'], {
|
|
2654
|
+
stdio: 'ignore',
|
|
2655
|
+
timeout: 5000,
|
|
2656
|
+
env: { ...process.env },
|
|
2657
|
+
});
|
|
2658
|
+
|
|
2659
|
+
onSelect(selected.value);
|
|
2660
|
+
});
|
|
2661
|
+
|
|
2662
|
+
list.key(['escape', 'q'], () => {
|
|
2663
|
+
_destroyList(list, screen, onClose);
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// ---------------------------------------------------------------------------
|
|
2668
|
+
// Private: Background music config read/write helpers
|
|
2669
|
+
|
|
2670
|
+
function _getMusic(configService) {
|
|
2671
|
+
return configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function _setMusic(configService, partial) {
|
|
2675
|
+
const current = configService.getConfig().backgroundMusic ?? MUSIC_DEFAULTS;
|
|
2676
|
+
const merged = { ...current, ...partial };
|
|
2677
|
+
configService.set('backgroundMusic', merged);
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// ---------------------------------------------------------------------------
|
|
2681
|
+
// Private: Inline track picker
|
|
2682
|
+
|
|
2683
|
+
function _openTrackPicker(screen, configService, onSelect, onClose) {
|
|
2684
|
+
// Scan .claude/audio/tracks/ dynamically; fall back to BUILT_IN_TRACKS if missing.
|
|
2685
|
+
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
2686
|
+
let tracks;
|
|
2687
|
+
try {
|
|
2688
|
+
const files = fs.readdirSync(tracksDir);
|
|
2689
|
+
tracks = files
|
|
2690
|
+
.filter(f => /\.mp3$/i.test(f))
|
|
2691
|
+
.sort()
|
|
2692
|
+
.map(f => ({ file: f, label: formatTrackName(f) }));
|
|
2693
|
+
} catch {
|
|
2694
|
+
tracks = BUILT_IN_TRACKS;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
const ADD_SENTINEL = '__ADD_CUSTOM_TRACK__';
|
|
2698
|
+
const allItems = [...tracks, { file: ADD_SENTINEL, label: '+ Add Custom Track' }];
|
|
2699
|
+
|
|
2700
|
+
const currentTrack = (configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track);
|
|
2701
|
+
const items = allItems.map(t =>
|
|
2702
|
+
t.file === ADD_SENTINEL
|
|
2703
|
+
? ` {#00e5ff-fg}+ Add Custom Track{/#00e5ff-fg}`
|
|
2704
|
+
: (t.file === currentTrack ? `● ${t.label}` : ` ${t.label}`)
|
|
2705
|
+
);
|
|
2706
|
+
const currentIdx = tracks.findIndex(t => t.file === currentTrack);
|
|
2707
|
+
|
|
2708
|
+
const listHeight = Math.min(allItems.length + 4, Math.floor(screen.rows * 0.7));
|
|
2709
|
+
const list = blessed.list({
|
|
2710
|
+
parent: screen,
|
|
2711
|
+
top: 'center',
|
|
2712
|
+
left: 'center',
|
|
2713
|
+
width: 50,
|
|
2714
|
+
height: listHeight,
|
|
2715
|
+
border: { type: 'line' },
|
|
2716
|
+
tags: true,
|
|
2717
|
+
label: _modalTitle('Select Track'),
|
|
2718
|
+
items,
|
|
2719
|
+
keys: true,
|
|
2720
|
+
vi: false,
|
|
2721
|
+
mouse: true,
|
|
2722
|
+
scrollable: true,
|
|
2723
|
+
scrollbar: { ch: '│', track: { bg: '#1e2a3a' }, style: { fg: COLORS.btnFocus } },
|
|
2724
|
+
style: {
|
|
2725
|
+
border: { fg: COLORS.btnFocus },
|
|
2726
|
+
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
2727
|
+
item: { fg: '#e3f2fd' },
|
|
2728
|
+
},
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
if (currentIdx >= 0) list.select(currentIdx);
|
|
2732
|
+
list.focus();
|
|
2733
|
+
screen.render();
|
|
2734
|
+
|
|
2735
|
+
list.key(['enter', 'space'], () => {
|
|
2736
|
+
const selected = allItems[list.selected];
|
|
2737
|
+
if (!selected) return;
|
|
2738
|
+
if (selected.file === ADD_SENTINEL) {
|
|
2739
|
+
// Destroy list first, then open path-input dialog
|
|
2740
|
+
_destroyList(list, screen);
|
|
2741
|
+
_openCustomTrackInput(screen, tracksDir, (newFile) => {
|
|
2742
|
+
onSelect(newFile);
|
|
2743
|
+
}, onClose);
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
_destroyList(list, screen, onClose);
|
|
2747
|
+
onSelect(selected.file);
|
|
2748
|
+
});
|
|
2749
|
+
|
|
2750
|
+
list.key(['escape', 'q'], () => {
|
|
2751
|
+
_destroyList(list, screen, onClose);
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// ---------------------------------------------------------------------------
|
|
2756
|
+
// Private: Custom track path-input dialog — copies an MP3 into tracks dir
|
|
2757
|
+
|
|
2758
|
+
function _openCustomTrackInput(screen, tracksDir, onDone, onClose) {
|
|
2759
|
+
let _closed = false;
|
|
2760
|
+
|
|
2761
|
+
const modal = blessed.box({
|
|
2762
|
+
parent: screen,
|
|
2763
|
+
top: 'center',
|
|
2764
|
+
left: 'center',
|
|
2765
|
+
width: 64,
|
|
2766
|
+
height: 11,
|
|
2767
|
+
border: { type: 'line' },
|
|
2768
|
+
tags: true,
|
|
2769
|
+
label: _modalTitle('Add Custom Track'),
|
|
2770
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg, border: { fg: COLORS.btnFocus } },
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
blessed.text({
|
|
2774
|
+
parent: modal, top: 1, left: 2,
|
|
2775
|
+
content: 'Enter full path to an MP3 file:',
|
|
2776
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
const inputBox = blessed.textbox({
|
|
2780
|
+
parent: modal, top: 3, left: 2, right: 2, height: 3,
|
|
2781
|
+
border: { type: 'line' },
|
|
2782
|
+
inputOnFocus: true,
|
|
2783
|
+
style: {
|
|
2784
|
+
fg: COLORS.valueFg, bg: '#0d1b35',
|
|
2785
|
+
border: { fg: COLORS.borderFg },
|
|
2786
|
+
focus: { border: { fg: COLORS.btnFocus } },
|
|
2787
|
+
},
|
|
2788
|
+
});
|
|
2789
|
+
|
|
2790
|
+
const errText = blessed.text({
|
|
2791
|
+
parent: modal, top: 7, left: 2, width: 58,
|
|
2792
|
+
tags: true, content: '',
|
|
2793
|
+
style: { bg: COLORS.contentBg },
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
function _close() {
|
|
2797
|
+
if (_closed) return;
|
|
2798
|
+
_closed = true;
|
|
2799
|
+
modal.destroy();
|
|
2800
|
+
try {
|
|
2801
|
+
for (let r = 0; r < screen.height; r++)
|
|
2802
|
+
for (let c = 0; c < screen.width; c++)
|
|
2803
|
+
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2804
|
+
} catch {}
|
|
2805
|
+
onClose?.();
|
|
2806
|
+
screen.render();
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
function _addTrack() {
|
|
2810
|
+
const raw = inputBox.getValue().trim();
|
|
2811
|
+
if (!raw) return;
|
|
2812
|
+
const src = path.resolve(raw);
|
|
2813
|
+
|
|
2814
|
+
// Validate: must be a readable .mp3 file owned by the current user
|
|
2815
|
+
if (!/\.mp3$/i.test(src)) {
|
|
2816
|
+
errText.setContent('{red-fg}File must be an MP3 (.mp3){/red-fg}');
|
|
2817
|
+
screen.render(); return;
|
|
2818
|
+
}
|
|
2819
|
+
try {
|
|
2820
|
+
const stat = fs.statSync(src);
|
|
2821
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
2822
|
+
if (stat.uid !== undefined && stat.uid !== process.getuid?.()) {
|
|
2823
|
+
errText.setContent('{red-fg}File not owned by current user{/red-fg}');
|
|
2824
|
+
screen.render(); return;
|
|
2825
|
+
}
|
|
2826
|
+
} catch {
|
|
2827
|
+
errText.setContent('{red-fg}File not found or not accessible{/red-fg}');
|
|
2828
|
+
screen.render(); return;
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
const dest = path.join(tracksDir, path.basename(src));
|
|
2832
|
+
try {
|
|
2833
|
+
fs.mkdirSync(tracksDir, { recursive: true });
|
|
2834
|
+
fs.copyFileSync(src, dest);
|
|
2835
|
+
} catch {
|
|
2836
|
+
errText.setContent('{red-fg}Could not copy file to tracks directory{/red-fg}');
|
|
2837
|
+
screen.render(); return;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
_closed = true;
|
|
2841
|
+
modal.destroy();
|
|
2842
|
+
try {
|
|
2843
|
+
for (let r = 0; r < screen.height; r++)
|
|
2844
|
+
for (let c = 0; c < screen.width; c++)
|
|
2845
|
+
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2846
|
+
} catch {}
|
|
2847
|
+
screen.render();
|
|
2848
|
+
onDone(path.basename(src));
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
inputBox.key(['enter'], _addTrack);
|
|
2852
|
+
inputBox.key(['escape'], _close);
|
|
2853
|
+
|
|
2854
|
+
const addBtn = _createButton(modal, screen, 'Add Track', COLORS, _addTrack);
|
|
2855
|
+
addBtn.bottom = 1; addBtn.left = 4;
|
|
2856
|
+
|
|
2857
|
+
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _close);
|
|
2858
|
+
cancelBtn.bottom = 1; cancelBtn.left = 18;
|
|
2859
|
+
|
|
2860
|
+
addBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
|
|
2861
|
+
cancelBtn.key(['tab'], () => { inputBox.focus(); screen.render(); });
|
|
2862
|
+
|
|
2863
|
+
modal.setFront();
|
|
2864
|
+
inputBox.focus();
|
|
2865
|
+
screen.render();
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// ---------------------------------------------------------------------------
|
|
2869
|
+
// Private: Inline volume picker (10% steps: 10–100)
|
|
2870
|
+
|
|
2871
|
+
function _openVolumePicker(screen, configService, onSelect, onClose) {
|
|
2872
|
+
const VOLUMES = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
|
2873
|
+
const currentVol = configService.getConfig().backgroundMusic?.volume ?? MUSIC_DEFAULTS.volume;
|
|
2874
|
+
const currentIdx = Math.max(0, VOLUMES.indexOf(currentVol));
|
|
2875
|
+
|
|
2876
|
+
// Preview state
|
|
2877
|
+
let _previewProcess = null;
|
|
2878
|
+
let _previewVol = null;
|
|
2879
|
+
|
|
2880
|
+
const _previewEnv = buildAudioEnv();
|
|
2881
|
+
|
|
2882
|
+
function _killPreview() {
|
|
2883
|
+
if (_previewProcess) {
|
|
2884
|
+
try { process.kill(-_previewProcess.pid, 'SIGTERM'); } catch {}
|
|
2885
|
+
_previewProcess = null;
|
|
2886
|
+
}
|
|
2887
|
+
_previewVol = null;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function _buildItems() {
|
|
2891
|
+
return VOLUMES.map((v, i) => {
|
|
2892
|
+
const mark = (v === _previewVol) ? '♪' : (i === currentIdx ? '●' : ' ');
|
|
2893
|
+
const hint = (v === _previewVol) ? ' (Space to stop) ' : ' (Space to test) ';
|
|
2894
|
+
return ` ${mark} ${String(v).padStart(3)}%${hint}`;
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function _refreshList() {
|
|
2899
|
+
const sel = list.selected;
|
|
2900
|
+
list.setItems(_buildItems());
|
|
2901
|
+
list.select(sel);
|
|
2902
|
+
screen.render();
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
function _close() {
|
|
2906
|
+
_killPreview();
|
|
2907
|
+
list.destroy();
|
|
2908
|
+
// Force-invalidate olines so blessed redraws every cell the modal covered
|
|
2909
|
+
try {
|
|
2910
|
+
for (let r = 0; r < screen.height; r++)
|
|
2911
|
+
for (let c = 0; c < screen.width; c++)
|
|
2912
|
+
if (screen.olines[r]?.[c]) screen.olines[r][c][0] = -1;
|
|
2913
|
+
} catch {}
|
|
2914
|
+
onClose?.();
|
|
2915
|
+
screen.render();
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
function _previewVolume(vol) {
|
|
2919
|
+
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
2920
|
+
const trackId = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
|
|
2921
|
+
const trackPath = path.resolve(tracksDir, trackId);
|
|
2922
|
+
const safeBase = path.resolve(tracksDir);
|
|
2923
|
+
if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
|
|
2924
|
+
|
|
2925
|
+
// Toggle: pressing Space on the currently playing volume stops it
|
|
2926
|
+
if (_previewVol === vol) {
|
|
2927
|
+
_killPreview();
|
|
2928
|
+
_refreshList();
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
_killPreview();
|
|
2933
|
+
_previewVol = vol;
|
|
2934
|
+
|
|
2935
|
+
const volFraction = (Math.max(10, Math.min(100, vol)) / 100).toFixed(2);
|
|
2936
|
+
const cmd = [
|
|
2937
|
+
`ffplay -nodisp -t 10 -loglevel quiet -volume ${vol} "${trackPath}"`,
|
|
2938
|
+
`play "${trackPath}" trim 0 10 vol ${volFraction}`,
|
|
2939
|
+
`mpg123 -q "${trackPath}"`,
|
|
2940
|
+
].join(' 2>/dev/null || ') + ' 2>/dev/null';
|
|
2941
|
+
|
|
2942
|
+
_previewProcess = spawn('sh', ['-c', cmd], {
|
|
2943
|
+
stdio: 'ignore', detached: true, env: _previewEnv,
|
|
2944
|
+
});
|
|
2945
|
+
_previewProcess.unref();
|
|
2946
|
+
_refreshList();
|
|
2947
|
+
|
|
2948
|
+
_previewProcess.on('exit', () => {
|
|
2949
|
+
if (_previewVol === vol) { _killPreview(); _refreshList(); }
|
|
2950
|
+
});
|
|
2951
|
+
_previewProcess.on('error', () => {
|
|
2952
|
+
if (_previewVol === vol) { _killPreview(); _refreshList(); }
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
const list = blessed.list({
|
|
2957
|
+
parent: screen,
|
|
2958
|
+
top: 'center',
|
|
2959
|
+
left: 'center',
|
|
2960
|
+
width: 28,
|
|
2961
|
+
height: VOLUMES.length + 4,
|
|
2962
|
+
border: { type: 'line' },
|
|
2963
|
+
tags: true,
|
|
2964
|
+
label: _modalTitle('Volume'),
|
|
2965
|
+
items: _buildItems(),
|
|
2966
|
+
keys: true,
|
|
2967
|
+
vi: false,
|
|
2968
|
+
mouse: true,
|
|
2969
|
+
style: {
|
|
2970
|
+
border: { fg: COLORS.btnFocus },
|
|
2971
|
+
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
2972
|
+
item: { fg: '#e3f2fd' },
|
|
2973
|
+
},
|
|
2974
|
+
});
|
|
2975
|
+
|
|
2976
|
+
list.select(currentIdx);
|
|
2977
|
+
list.focus();
|
|
2978
|
+
screen.render();
|
|
2979
|
+
|
|
2980
|
+
// Space → preview audio at selected volume (toggle stop/play)
|
|
2981
|
+
list.key(['space'], () => {
|
|
2982
|
+
const vol = VOLUMES[list.selected];
|
|
2983
|
+
if (vol !== undefined) _previewVolume(vol);
|
|
2984
|
+
});
|
|
2985
|
+
|
|
2986
|
+
// Enter → accept selected volume and close
|
|
2987
|
+
list.key(['enter'], () => {
|
|
2988
|
+
const vol = VOLUMES[list.selected];
|
|
2989
|
+
if (vol === undefined) return;
|
|
2990
|
+
_close();
|
|
2991
|
+
onSelect(vol);
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
list.key(['escape', 'q'], () => {
|
|
2995
|
+
_close();
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
// ---------------------------------------------------------------------------
|
|
3000
|
+
// Private: Full music browser modal — rich track selection with favorites + preview
|
|
3001
|
+
|
|
3002
|
+
function _openMusicBrowserModal(screen, configService, navigationService, onDone, onClose) {
|
|
3003
|
+
let _allTracks = [];
|
|
3004
|
+
let _showFavoritesOnly = false;
|
|
3005
|
+
let _previewProcess = null;
|
|
3006
|
+
let _previewTrackId = null;
|
|
3007
|
+
let _closed = false;
|
|
3008
|
+
|
|
3009
|
+
// Block global Tab-to-cycle-tab while modal is open
|
|
3010
|
+
navigationService?.openModal();
|
|
3011
|
+
|
|
3012
|
+
const _modalEnv = buildAudioEnv();
|
|
3013
|
+
|
|
3014
|
+
function _killPreview() {
|
|
3015
|
+
if (_previewProcess) {
|
|
3016
|
+
try { process.kill(-_previewProcess.pid, 'SIGTERM'); } catch {}
|
|
3017
|
+
_previewProcess = null;
|
|
3018
|
+
}
|
|
3019
|
+
_previewTrackId = null;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
function _closeModal() {
|
|
3023
|
+
if (_closed) return;
|
|
3024
|
+
_closed = true;
|
|
3025
|
+
navigationService?.closeModal();
|
|
3026
|
+
_killPreview();
|
|
3027
|
+
modal.destroy();
|
|
3028
|
+
|
|
3029
|
+
// Force-invalidate olines so draw() rewrites every cell the modal covered
|
|
3030
|
+
screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
|
|
3031
|
+
for (let r = 2; r < screen.rows - 2; r++) {
|
|
3032
|
+
const orow = screen.olines[r];
|
|
3033
|
+
if (!orow) continue;
|
|
3034
|
+
for (let c = 0; c < screen.cols; c++) {
|
|
3035
|
+
if (orow[c]) orow[c][0] = -1;
|
|
3036
|
+
}
|
|
3037
|
+
orow.dirty = true;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
onClose?.();
|
|
3041
|
+
screen.render();
|
|
3042
|
+
onDone();
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// ---- Modal overlay ----
|
|
3046
|
+
const modal = blessed.box({
|
|
3047
|
+
parent: screen,
|
|
3048
|
+
top: '5%',
|
|
3049
|
+
left: '3%',
|
|
3050
|
+
width: '94%',
|
|
3051
|
+
height: '90%',
|
|
3052
|
+
border: { type: 'line' },
|
|
3053
|
+
tags: true,
|
|
3054
|
+
label: _modalTitle('🎵 Select Music Track'),
|
|
3055
|
+
style: {
|
|
3056
|
+
fg: COLORS.labelFg,
|
|
3057
|
+
bg: COLORS.contentBg,
|
|
3058
|
+
border: { fg: COLORS.btnFocus },
|
|
3059
|
+
label: { fg: COLORS.btnFocus },
|
|
3060
|
+
},
|
|
3061
|
+
});
|
|
3062
|
+
modal.setFront();
|
|
3063
|
+
|
|
3064
|
+
// ---- Track list ----
|
|
3065
|
+
const modalTrackList = blessed.list({
|
|
3066
|
+
parent: modal,
|
|
3067
|
+
top: 1,
|
|
3068
|
+
left: 2,
|
|
3069
|
+
right: 2,
|
|
3070
|
+
bottom: 6,
|
|
3071
|
+
keys: true,
|
|
3072
|
+
vi: true,
|
|
3073
|
+
mouse: true,
|
|
3074
|
+
border: { type: 'line' },
|
|
3075
|
+
scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
|
|
3076
|
+
style: {
|
|
3077
|
+
fg: COLORS.labelFg,
|
|
3078
|
+
bg: COLORS.contentBg,
|
|
3079
|
+
border: { fg: COLORS.borderFg },
|
|
3080
|
+
selected: { bg: '#1a237e', fg: '#00e5ff', bold: true },
|
|
3081
|
+
item: { fg: COLORS.labelFg },
|
|
3082
|
+
},
|
|
3083
|
+
});
|
|
3084
|
+
|
|
3085
|
+
// ---- Preview status line ----
|
|
3086
|
+
const modalPreviewLine = blessed.text({
|
|
3087
|
+
parent: modal,
|
|
3088
|
+
bottom: 5,
|
|
3089
|
+
left: 2,
|
|
3090
|
+
right: 2,
|
|
3091
|
+
tags: true,
|
|
3092
|
+
content: '',
|
|
3093
|
+
style: { fg: '#00e5ff', bg: COLORS.contentBg },
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
// ---- File location hint ----
|
|
3097
|
+
blessed.text({
|
|
3098
|
+
parent: modal,
|
|
3099
|
+
bottom: 4,
|
|
3100
|
+
left: 2,
|
|
3101
|
+
right: 2,
|
|
3102
|
+
tags: true,
|
|
3103
|
+
content: `{#455a64-fg}Add MP3 files to: .claude/audio/tracks/ • Supports ffplay / mpg123 / play{/#455a64-fg}`,
|
|
3104
|
+
style: { bg: COLORS.contentBg },
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
// ---- Key hint bar ----
|
|
3108
|
+
blessed.text({
|
|
3109
|
+
parent: modal,
|
|
3110
|
+
bottom: 3,
|
|
3111
|
+
left: 2,
|
|
3112
|
+
right: 2,
|
|
3113
|
+
content: '{#455a64-fg}[\u2191\u2193] Navigate [Enter] Select [Space] Preview [F] Favorite [/] Favorites only [Esc] Cancel{/#455a64-fg}',
|
|
3114
|
+
tags: true,
|
|
3115
|
+
style: { bg: COLORS.contentBg },
|
|
3116
|
+
});
|
|
3117
|
+
|
|
3118
|
+
// ---- Buttons ----
|
|
3119
|
+
const selectTrackBtn = _createButton(modal, screen, 'Select Track', COLORS, () => {
|
|
3120
|
+
const visible = _getVisibleTracks();
|
|
3121
|
+
const selected = visible[modalTrackList.selected];
|
|
3122
|
+
if (selected) {
|
|
3123
|
+
try {
|
|
3124
|
+
const current = configService.getConfig().backgroundMusic ?? {};
|
|
3125
|
+
configService.set('backgroundMusic', { ...current, track: selected.id });
|
|
3126
|
+
} catch {}
|
|
3127
|
+
_closeModal();
|
|
3128
|
+
}
|
|
3129
|
+
});
|
|
3130
|
+
selectTrackBtn.bottom = 1;
|
|
3131
|
+
selectTrackBtn.left = 4;
|
|
3132
|
+
|
|
3133
|
+
const cancelModalBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeModal);
|
|
3134
|
+
cancelModalBtn.bottom = 1;
|
|
3135
|
+
cancelModalBtn.left = 22;
|
|
3136
|
+
|
|
3137
|
+
// ---- Helper functions ----
|
|
3138
|
+
|
|
3139
|
+
function _getVisibleTracks() {
|
|
3140
|
+
if (!_showFavoritesOnly) return _allTracks;
|
|
3141
|
+
const favs = getMusicFavorites(configService);
|
|
3142
|
+
return _allTracks.filter(t => favs.includes(t.id));
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
function _buildListItems(tracks) {
|
|
3146
|
+
const currentTrack = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
|
|
3147
|
+
const favs = getMusicFavorites(configService);
|
|
3148
|
+
return tracks.map(t => {
|
|
3149
|
+
const isActive = t.id === currentTrack;
|
|
3150
|
+
const isFav = favs.includes(t.id);
|
|
3151
|
+
const isPrev = t.id === _previewTrackId;
|
|
3152
|
+
const activeMark = isPrev ? '\u266A' : (isActive ? '\u25B6' : ' ');
|
|
3153
|
+
const favMark = isFav ? '\u2605' : ' ';
|
|
3154
|
+
return ` ${activeMark} ${favMark} ${formatTrackName(t.id) || t.label}`;
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function _refreshList() {
|
|
3159
|
+
if (_closed) return;
|
|
3160
|
+
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
3161
|
+
const scanned = scanTracks();
|
|
3162
|
+
_allTracks = scanned;
|
|
3163
|
+
const visible = _getVisibleTracks();
|
|
3164
|
+
const items = _buildListItems(visible);
|
|
3165
|
+
modalTrackList.setItems(items.length > 0 ? items : [' (no tracks found)']);
|
|
3166
|
+
screen.render();
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
function _previewTrack(trackId) {
|
|
3170
|
+
const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
|
|
3171
|
+
const trackPath = path.resolve(tracksDir, trackId);
|
|
3172
|
+
const safeBase = path.resolve(tracksDir);
|
|
3173
|
+
if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
|
|
3174
|
+
|
|
3175
|
+
// Toggle: second press on same track → stop
|
|
3176
|
+
if (_previewTrackId === trackId) {
|
|
3177
|
+
_killPreview();
|
|
3178
|
+
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3179
|
+
_refreshList();
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
_killPreview();
|
|
3184
|
+
|
|
3185
|
+
const _mp3Player = detectMp3Player(_modalEnv);
|
|
3186
|
+
if (!_mp3Player) return;
|
|
3187
|
+
_previewProcess = spawn(_mp3Player.bin, _mp3Player.args(trackPath), {
|
|
3188
|
+
stdio: 'ignore', detached: true, env: _modalEnv,
|
|
3189
|
+
});
|
|
3190
|
+
_previewProcess.unref();
|
|
3191
|
+
_previewTrackId = trackId;
|
|
3192
|
+
|
|
3193
|
+
const label = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
|
|
3194
|
+
if (!_closed) {
|
|
3195
|
+
modalPreviewLine.setContent(`{#00e5ff-fg}\u266A Previewing: ${label} (Space to stop){/#00e5ff-fg}`);
|
|
3196
|
+
screen.render();
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
_previewProcess.on('exit', () => {
|
|
3200
|
+
if (_previewTrackId === trackId) {
|
|
3201
|
+
_previewTrackId = null;
|
|
3202
|
+
_previewProcess = null;
|
|
3203
|
+
if (!_closed) { modalPreviewLine.setContent(''); _refreshList(); }
|
|
3204
|
+
}
|
|
3205
|
+
});
|
|
3206
|
+
|
|
3207
|
+
_previewProcess.on('error', () => {
|
|
3208
|
+
_previewTrackId = null;
|
|
3209
|
+
_previewProcess = null;
|
|
3210
|
+
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
// ---- Key bindings ----
|
|
3215
|
+
|
|
3216
|
+
modalTrackList.key(['enter'], () => {
|
|
3217
|
+
const visible = _getVisibleTracks();
|
|
3218
|
+
const sel = visible[modalTrackList.selected];
|
|
3219
|
+
if (sel) {
|
|
3220
|
+
try {
|
|
3221
|
+
const current = configService.getConfig().backgroundMusic ?? {};
|
|
3222
|
+
configService.set('backgroundMusic', { ...current, track: sel.id });
|
|
3223
|
+
} catch {}
|
|
3224
|
+
_closeModal();
|
|
3225
|
+
}
|
|
3226
|
+
});
|
|
3227
|
+
|
|
3228
|
+
modalTrackList.key(['space'], () => {
|
|
3229
|
+
const visible = _getVisibleTracks();
|
|
3230
|
+
const sel = visible[modalTrackList.selected];
|
|
3231
|
+
if (sel) { _previewTrack(sel.id); }
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
modalTrackList.key(['f', 'F'], () => {
|
|
3235
|
+
const visible = _getVisibleTracks();
|
|
3236
|
+
const sel = visible[modalTrackList.selected];
|
|
3237
|
+
if (sel) {
|
|
3238
|
+
toggleMusicFavorite(configService, sel.id);
|
|
3239
|
+
_refreshList();
|
|
3240
|
+
}
|
|
3241
|
+
});
|
|
3242
|
+
|
|
3243
|
+
modalTrackList.key(['/'], () => {
|
|
3244
|
+
_showFavoritesOnly = !_showFavoritesOnly;
|
|
3245
|
+
_refreshList();
|
|
3246
|
+
});
|
|
3247
|
+
|
|
3248
|
+
modalTrackList.key(['escape', 'q'], _closeModal);
|
|
3249
|
+
|
|
3250
|
+
// Tab: list → [Select Track] → [Cancel] → list
|
|
3251
|
+
modalTrackList.key(['tab'], () => { selectTrackBtn.focus(); screen.render(); });
|
|
3252
|
+
selectTrackBtn.key(['tab'], () => { cancelModalBtn.focus(); screen.render(); });
|
|
3253
|
+
cancelModalBtn.key(['tab'], () => { modalTrackList.focus(); screen.render(); });
|
|
3254
|
+
selectTrackBtn.key(['escape'], _closeModal);
|
|
3255
|
+
cancelModalBtn.key(['escape'], _closeModal);
|
|
3256
|
+
|
|
3257
|
+
// ---- Initial load ----
|
|
3258
|
+
_refreshList();
|
|
3259
|
+
|
|
3260
|
+
// Scroll to active track on open
|
|
3261
|
+
const currentTrack = configService.getConfig().backgroundMusic?.track ?? MUSIC_DEFAULTS.track;
|
|
3262
|
+
const activeIdx = _getVisibleTracks().findIndex(t => t.id === currentTrack);
|
|
3263
|
+
if (activeIdx >= 0) modalTrackList.select(activeIdx);
|
|
3264
|
+
|
|
3265
|
+
modalTrackList.focus();
|
|
3266
|
+
screen.render();
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
// ---------------------------------------------------------------------------
|
|
3270
|
+
// Private: Inline verbosity picker
|
|
3271
|
+
|
|
3272
|
+
function _openVerbosityPicker(screen, configService, onDone, onClose) {
|
|
3273
|
+
const levels = ['Minimal', 'Low', 'Medium', 'High', 'Custom'];
|
|
3274
|
+
const current = configService.getConfig().verbosity ?? 'high';
|
|
3275
|
+
const currentIdx = Math.max(0, levels.findIndex(l => l.toLowerCase() === current));
|
|
3276
|
+
|
|
3277
|
+
const list = blessed.list({
|
|
3278
|
+
parent: screen,
|
|
3279
|
+
top: 'center',
|
|
3280
|
+
left: 'center',
|
|
3281
|
+
width: 28,
|
|
3282
|
+
height: levels.length + 4,
|
|
3283
|
+
border: { type: 'line' },
|
|
3284
|
+
tags: true,
|
|
3285
|
+
label: _modalTitle('Verbosity Level'),
|
|
3286
|
+
items: levels.map((l, i) => (i === currentIdx ? `● ${l}` : ` ${l}`)),
|
|
3287
|
+
keys: true,
|
|
3288
|
+
vi: false,
|
|
3289
|
+
mouse: true,
|
|
3290
|
+
style: {
|
|
3291
|
+
border: { fg: COLORS.btnFocus },
|
|
3292
|
+
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
3293
|
+
item: { fg: '#e3f2fd' },
|
|
3294
|
+
},
|
|
3295
|
+
});
|
|
3296
|
+
|
|
3297
|
+
list.select(currentIdx);
|
|
3298
|
+
list.focus();
|
|
3299
|
+
screen.render();
|
|
3300
|
+
|
|
3301
|
+
list.key(['enter', 'space'], () => {
|
|
3302
|
+
const selected = levels[list.selected];
|
|
3303
|
+
if (!selected) return;
|
|
3304
|
+
_destroyList(list, screen, onClose);
|
|
3305
|
+
configService.set('verbosity', selected.toLowerCase());
|
|
3306
|
+
onDone();
|
|
3307
|
+
});
|
|
3308
|
+
|
|
3309
|
+
list.key(['escape', 'q'], () => {
|
|
3310
|
+
_destroyList(list, screen, onClose);
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
// ---------------------------------------------------------------------------
|
|
3315
|
+
// Private: Inline intro text editor
|
|
3316
|
+
|
|
3317
|
+
function _openIntroTextEditor(screen, configService, onDone, onClose) {
|
|
3318
|
+
const current = configService.getConfig().pretext ?? '';
|
|
3319
|
+
let _closed = false;
|
|
3320
|
+
|
|
3321
|
+
const modal = blessed.box({
|
|
3322
|
+
parent: screen,
|
|
3323
|
+
top: 'center',
|
|
3324
|
+
left: 'center',
|
|
3325
|
+
width: 62,
|
|
3326
|
+
height: 11,
|
|
3327
|
+
border: { type: 'line' },
|
|
3328
|
+
tags: true,
|
|
3329
|
+
label: _modalTitle('Edit Intro Text'),
|
|
3330
|
+
style: {
|
|
3331
|
+
fg: COLORS.labelFg,
|
|
3332
|
+
bg: COLORS.contentBg,
|
|
3333
|
+
border: { fg: COLORS.btnFocus },
|
|
3334
|
+
},
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
blessed.text({
|
|
3338
|
+
parent: modal,
|
|
3339
|
+
top: 1,
|
|
3340
|
+
left: 2,
|
|
3341
|
+
content: 'Enter intro text (max 50 chars, prepended before TTS):',
|
|
3342
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
3343
|
+
});
|
|
3344
|
+
|
|
3345
|
+
const inputBox = blessed.textbox({
|
|
3346
|
+
parent: modal,
|
|
3347
|
+
top: 3,
|
|
3348
|
+
left: 2,
|
|
3349
|
+
right: 2,
|
|
3350
|
+
height: 3,
|
|
3351
|
+
border: { type: 'line' },
|
|
3352
|
+
inputOnFocus: true,
|
|
3353
|
+
style: {
|
|
3354
|
+
fg: COLORS.valueFg,
|
|
3355
|
+
bg: '#0d1b35',
|
|
3356
|
+
border: { fg: COLORS.borderFg },
|
|
3357
|
+
focus: { border: { fg: COLORS.btnFocus } },
|
|
3358
|
+
},
|
|
3359
|
+
});
|
|
3360
|
+
inputBox.setValue(current);
|
|
3361
|
+
|
|
3362
|
+
blessed.text({
|
|
3363
|
+
parent: modal,
|
|
3364
|
+
bottom: 1,
|
|
3365
|
+
left: 2,
|
|
3366
|
+
content: '{#455a64-fg}[Enter] Save [Esc] Cancel{/#455a64-fg}',
|
|
3367
|
+
tags: true,
|
|
3368
|
+
style: { bg: COLORS.contentBg },
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
function _close() {
|
|
3372
|
+
if (_closed) return;
|
|
3373
|
+
_closed = true;
|
|
3374
|
+
modal.destroy();
|
|
3375
|
+
screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
|
|
3376
|
+
for (let r = 2; r < screen.rows - 2; r++) {
|
|
3377
|
+
const orow = screen.olines[r];
|
|
3378
|
+
if (!orow) continue;
|
|
3379
|
+
for (let c = 0; c < screen.cols; c++) { if (orow[c]) orow[c][0] = -1; }
|
|
3380
|
+
orow.dirty = true;
|
|
3381
|
+
}
|
|
3382
|
+
onClose?.();
|
|
3383
|
+
screen.render();
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
inputBox.key(['enter'], () => {
|
|
3387
|
+
const value = inputBox.getValue().replace(/\n/g, ' ').trim().slice(0, 50);
|
|
3388
|
+
try { configService.set('pretext', value); } catch {}
|
|
3389
|
+
_close();
|
|
3390
|
+
onDone();
|
|
3391
|
+
});
|
|
3392
|
+
|
|
3393
|
+
inputBox.key(['escape'], () => {
|
|
3394
|
+
_close();
|
|
3395
|
+
});
|
|
3396
|
+
|
|
3397
|
+
modal.setFront();
|
|
3398
|
+
inputBox.focus();
|
|
3399
|
+
screen.render();
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// ---------------------------------------------------------------------------
|
|
3403
|
+
// Private: Full voice browser modal — replicates the Voices tab UX
|
|
3404
|
+
|
|
3405
|
+
function _openVoiceBrowserModal(screen, providerService, configService, navigationService, onDone, onClose) {
|
|
3406
|
+
let _allVoices = [];
|
|
3407
|
+
let _filterText = '';
|
|
3408
|
+
let _playingProcess = null;
|
|
3409
|
+
let _playingVoiceId = null;
|
|
3410
|
+
let _closed = false;
|
|
3411
|
+
|
|
3412
|
+
// Block global Tab-to-cycle-tab while modal is open
|
|
3413
|
+
navigationService?.openModal();
|
|
3414
|
+
|
|
3415
|
+
const _spawnEnv = buildAudioEnv();
|
|
3416
|
+
|
|
3417
|
+
function _killPreview() {
|
|
3418
|
+
if (_playingProcess) {
|
|
3419
|
+
try { process.kill(-_playingProcess.pid, 'SIGTERM'); } catch {}
|
|
3420
|
+
_playingProcess = null;
|
|
3421
|
+
}
|
|
3422
|
+
_playingVoiceId = null;
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
function _closeModal() {
|
|
3426
|
+
if (_closed) return;
|
|
3427
|
+
_closed = true;
|
|
3428
|
+
navigationService?.closeModal();
|
|
3429
|
+
_killPreview();
|
|
3430
|
+
modal.destroy();
|
|
3431
|
+
|
|
3432
|
+
// Force-invalidate olines so draw() rewrites every cell the modal covered.
|
|
3433
|
+
// modal.destroy() removes the widget from lines[] but leaves olines[] stale,
|
|
3434
|
+
// so draw() skips repainting cells where lines==olines — terminal retains
|
|
3435
|
+
// modal content. Setting attr=-1 is impossible for any real cell, so draw()
|
|
3436
|
+
// is forced to physically rewrite each cell on the next render.
|
|
3437
|
+
screen.clearRegion(0, screen.cols, 2, screen.rows - 2);
|
|
3438
|
+
for (let r = 2; r < screen.rows - 2; r++) {
|
|
3439
|
+
const orow = screen.olines[r];
|
|
3440
|
+
if (!orow) continue;
|
|
3441
|
+
for (let c = 0; c < screen.cols; c++) {
|
|
3442
|
+
if (orow[c]) orow[c][0] = -1;
|
|
3443
|
+
}
|
|
3444
|
+
orow.dirty = true;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
onClose?.();
|
|
3448
|
+
screen.render();
|
|
3449
|
+
onDone();
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
// ---- Modal overlay ----
|
|
3453
|
+
const modal = blessed.box({
|
|
3454
|
+
parent: screen,
|
|
3455
|
+
top: '8%',
|
|
3456
|
+
left: '4%',
|
|
3457
|
+
width: '92%',
|
|
3458
|
+
height: '84%',
|
|
3459
|
+
border: { type: 'line' },
|
|
3460
|
+
tags: true,
|
|
3461
|
+
label: _modalTitle('Change Voice'),
|
|
3462
|
+
style: {
|
|
3463
|
+
fg: COLORS.labelFg,
|
|
3464
|
+
bg: COLORS.contentBg,
|
|
3465
|
+
border: { fg: COLORS.btnFocus },
|
|
3466
|
+
label: { fg: COLORS.btnFocus },
|
|
3467
|
+
},
|
|
3468
|
+
});
|
|
3469
|
+
modal.setFront();
|
|
3470
|
+
|
|
3471
|
+
// ---- Search ----
|
|
3472
|
+
blessed.text({
|
|
3473
|
+
parent: modal,
|
|
3474
|
+
top: 1,
|
|
3475
|
+
left: 2,
|
|
3476
|
+
content: 'Search:',
|
|
3477
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
3478
|
+
});
|
|
3479
|
+
|
|
3480
|
+
const modalSearch = blessed.textbox({
|
|
3481
|
+
parent: modal,
|
|
3482
|
+
top: 1,
|
|
3483
|
+
left: 11,
|
|
3484
|
+
width: 40,
|
|
3485
|
+
height: 1,
|
|
3486
|
+
inputOnFocus: true,
|
|
3487
|
+
keys: true,
|
|
3488
|
+
style: {
|
|
3489
|
+
fg: COLORS.valueFg,
|
|
3490
|
+
bg: '#1a237e',
|
|
3491
|
+
focus: { bg: '#283593' },
|
|
3492
|
+
},
|
|
3493
|
+
});
|
|
3494
|
+
|
|
3495
|
+
// ---- Column header ----
|
|
3496
|
+
blessed.text({
|
|
3497
|
+
parent: modal,
|
|
3498
|
+
top: 2,
|
|
3499
|
+
left: 6,
|
|
3500
|
+
content: `{#7986cb-fg}${'Name'.padEnd(COL_NAME_W)}${'Gender'.padEnd(COL_GENDER_W)}Provider{/#7986cb-fg}`,
|
|
3501
|
+
tags: true,
|
|
3502
|
+
style: { bg: COLORS.contentBg },
|
|
3503
|
+
});
|
|
3504
|
+
|
|
3505
|
+
// ---- Voice list ----
|
|
3506
|
+
const modalVoiceList = blessed.list({
|
|
3507
|
+
parent: modal,
|
|
3508
|
+
top: 3,
|
|
3509
|
+
left: 2,
|
|
3510
|
+
right: 2,
|
|
3511
|
+
bottom: 6,
|
|
3512
|
+
keys: true,
|
|
3513
|
+
vi: true,
|
|
3514
|
+
mouse: true,
|
|
3515
|
+
border: { type: 'line' },
|
|
3516
|
+
scrollbar: { ch: '│', style: { fg: COLORS.borderFg } },
|
|
3517
|
+
style: {
|
|
3518
|
+
fg: COLORS.labelFg,
|
|
3519
|
+
bg: COLORS.contentBg,
|
|
3520
|
+
border: { fg: COLORS.borderFg },
|
|
3521
|
+
selected: { bg: '#1a237e', fg: '#00e5ff', bold: true },
|
|
3522
|
+
item: { fg: COLORS.labelFg },
|
|
3523
|
+
},
|
|
3524
|
+
});
|
|
3525
|
+
|
|
3526
|
+
// ---- Info panel ----
|
|
3527
|
+
blessed.text({
|
|
3528
|
+
parent: modal,
|
|
3529
|
+
bottom: 5,
|
|
3530
|
+
left: 2,
|
|
3531
|
+
content: `{#7986cb-fg}── Voice Info ${'─'.repeat(50)}{/#7986cb-fg}`,
|
|
3532
|
+
tags: true,
|
|
3533
|
+
style: { bg: COLORS.contentBg },
|
|
3534
|
+
});
|
|
3535
|
+
|
|
3536
|
+
const modalInfoLine = blessed.text({
|
|
3537
|
+
parent: modal,
|
|
3538
|
+
bottom: 4,
|
|
3539
|
+
left: 2,
|
|
3540
|
+
right: 2,
|
|
3541
|
+
tags: true,
|
|
3542
|
+
content: '',
|
|
3543
|
+
style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
|
|
3544
|
+
});
|
|
3545
|
+
|
|
3546
|
+
const modalPreviewLine = blessed.text({
|
|
3547
|
+
parent: modal,
|
|
3548
|
+
bottom: 3,
|
|
3549
|
+
left: 2,
|
|
3550
|
+
right: 2,
|
|
3551
|
+
tags: true,
|
|
3552
|
+
content: '',
|
|
3553
|
+
style: { fg: '#00e5ff', bg: COLORS.contentBg },
|
|
3554
|
+
});
|
|
3555
|
+
|
|
3556
|
+
// ---- Key hint bar ----
|
|
3557
|
+
blessed.text({
|
|
3558
|
+
parent: modal,
|
|
3559
|
+
bottom: 2,
|
|
3560
|
+
left: 2,
|
|
3561
|
+
right: 2,
|
|
3562
|
+
content: '{#455a64-fg}[↑↓/jk] Navigate [Enter] Select [Space] Preview [F] Favorite [/] Search [Esc] Cancel{/#455a64-fg}',
|
|
3563
|
+
tags: true,
|
|
3564
|
+
style: { bg: COLORS.contentBg },
|
|
3565
|
+
});
|
|
3566
|
+
|
|
3567
|
+
// ---- Buttons ----
|
|
3568
|
+
const selectBtn = _createButton(modal, screen, 'Select Voice', COLORS, () => {
|
|
3569
|
+
const voices = _getFiltered();
|
|
3570
|
+
const selected = voices[modalVoiceList.selected];
|
|
3571
|
+
if (selected) {
|
|
3572
|
+
providerService.setActiveVoice(selected);
|
|
3573
|
+
_closeModal();
|
|
3574
|
+
}
|
|
3575
|
+
});
|
|
3576
|
+
selectBtn.bottom = 1;
|
|
3577
|
+
selectBtn.left = 4;
|
|
3578
|
+
|
|
3579
|
+
const favBtn = _createButton(modal, screen, '★ Fav', COLORS, () => {
|
|
3580
|
+
const filtered = _getFiltered();
|
|
3581
|
+
const sel = filtered[modalVoiceList.selected];
|
|
3582
|
+
if (sel) { toggleFavorite(configService, sel); _refreshList(); }
|
|
3583
|
+
});
|
|
3584
|
+
favBtn.bottom = 1;
|
|
3585
|
+
favBtn.left = 22;
|
|
3586
|
+
|
|
3587
|
+
const cancelBtn = _createButton(modal, screen, 'Cancel', COLORS, _closeModal);
|
|
3588
|
+
cancelBtn.bottom = 1;
|
|
3589
|
+
cancelBtn.left = 33;
|
|
3590
|
+
|
|
3591
|
+
// ---- Helper functions ----
|
|
3592
|
+
|
|
3593
|
+
function _getFiltered() {
|
|
3594
|
+
if (!_filterText) return _allVoices;
|
|
3595
|
+
const f = _filterText.toLowerCase();
|
|
3596
|
+
return _allVoices.filter(v => v.toLowerCase().includes(f));
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
function _buildItems(voices) {
|
|
3600
|
+
const active = providerService.getActiveVoiceId();
|
|
3601
|
+
const favs = getFavorites(configService);
|
|
3602
|
+
return voices.map(v => {
|
|
3603
|
+
const isFav = favs.includes(v);
|
|
3604
|
+
const isActive = v === active;
|
|
3605
|
+
const isPrev = v === _playingVoiceId;
|
|
3606
|
+
const star = isFav ? '★' : ' ';
|
|
3607
|
+
const dot = isPrev ? '♪' : (isActive ? '●' : ' ');
|
|
3608
|
+
const { displayName, gender, provider } = getVoiceMeta(v);
|
|
3609
|
+
const name = displayName.length > COL_NAME_W
|
|
3610
|
+
? displayName.slice(0, COL_NAME_W - 1) + '…'
|
|
3611
|
+
: displayName.padEnd(COL_NAME_W);
|
|
3612
|
+
return ` ${star}${dot} ${name}${gender.padEnd(COL_GENDER_W)}${provider}`;
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
function _formatInfo(voiceId) {
|
|
3617
|
+
const { lang, name, quality } = parseVoiceId(voiceId);
|
|
3618
|
+
const Y = COLORS.valueFg;
|
|
3619
|
+
if (lang === 'unknown') {
|
|
3620
|
+
return `{${Y}-fg}Voice:{/${Y}-fg} ${voiceId} {${Y}-fg}Provider:{/${Y}-fg} Piper`;
|
|
3621
|
+
}
|
|
3622
|
+
return `{${Y}-fg}Voice:{/${Y}-fg} ${name} ` +
|
|
3623
|
+
`{${Y}-fg}Language:{/${Y}-fg} ${lang} ` +
|
|
3624
|
+
`{${Y}-fg}Quality:{/${Y}-fg} ${quality} ` +
|
|
3625
|
+
`{${Y}-fg}Provider:{/${Y}-fg} Piper ` +
|
|
3626
|
+
`{${Y}-fg}ID:{/${Y}-fg} ${voiceId}`;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
function _refreshList() {
|
|
3630
|
+
if (_closed) return;
|
|
3631
|
+
_allVoices = scanInstalledVoices();
|
|
3632
|
+
const filtered = _getFiltered();
|
|
3633
|
+
const items = _buildItems(filtered);
|
|
3634
|
+
modalVoiceList.setItems(items.length > 0 ? items : [' (no voices found — install piper first)']);
|
|
3635
|
+
const active = providerService.getActiveVoiceId();
|
|
3636
|
+
const sel = filtered[modalVoiceList.selected] ?? active ?? '';
|
|
3637
|
+
if (sel) modalInfoLine.setContent(` ${_formatInfo(sel)}`);
|
|
3638
|
+
screen.render();
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
function _previewVoice(voiceId) {
|
|
3642
|
+
if (_playingVoiceId === voiceId) {
|
|
3643
|
+
_killPreview();
|
|
3644
|
+
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
_killPreview();
|
|
3648
|
+
|
|
3649
|
+
// Path traversal guard
|
|
3650
|
+
const _ms3 = parseMultiSpeaker(voiceId);
|
|
3651
|
+
const voicePath = path.resolve(PIPER_VOICES_DIR, _ms3.model + '.onnx');
|
|
3652
|
+
const safeBase = path.resolve(PIPER_VOICES_DIR);
|
|
3653
|
+
if (!voicePath.startsWith(safeBase + path.sep) && voicePath !== safeBase) return;
|
|
3654
|
+
|
|
3655
|
+
const tempWav = path.join(os.tmpdir(), `agentvibes-preview-${Date.now()}.wav`);
|
|
3656
|
+
const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)];
|
|
3657
|
+
|
|
3658
|
+
const _piperArgs3 = ['--model', voicePath, '--output_file', tempWav];
|
|
3659
|
+
if (_ms3.speakerId != null) _piperArgs3.push('--speaker', String(_ms3.speakerId));
|
|
3660
|
+
const piper = spawn('piper', _piperArgs3, {
|
|
3661
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
3662
|
+
detached: true,
|
|
3663
|
+
env: _spawnEnv,
|
|
3664
|
+
});
|
|
3665
|
+
piper.stdin.write(phrase + '\n');
|
|
3666
|
+
piper.stdin.end();
|
|
3667
|
+
|
|
3668
|
+
_playingProcess = piper;
|
|
3669
|
+
_playingVoiceId = voiceId;
|
|
3670
|
+
if (!_closed) {
|
|
3671
|
+
modalPreviewLine.setContent(`{#00e5ff-fg}♪ Synthesizing: ${voiceId}…{/#00e5ff-fg}`);
|
|
3672
|
+
screen.render();
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
piper.on('exit', (code) => {
|
|
3676
|
+
if (_playingVoiceId !== voiceId) {
|
|
3677
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
if (code !== 0) {
|
|
3681
|
+
_playingVoiceId = null;
|
|
3682
|
+
_playingProcess = null;
|
|
3683
|
+
if (!_closed) {
|
|
3684
|
+
modalPreviewLine.setContent('{#00e5ff-fg}♪ Preview failed (piper error — is piper installed?){/#00e5ff-fg}');
|
|
3685
|
+
screen.render();
|
|
3686
|
+
setTimeout(() => { if (!_closed) { modalPreviewLine.setContent(''); screen.render(); } }, 4000);
|
|
3687
|
+
}
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
const _wavPlayer3 = detectWavPlayer(_spawnEnv);
|
|
3692
|
+
if (!_wavPlayer3) return;
|
|
3693
|
+
const playProc = spawn(_wavPlayer3.bin, _wavPlayer3.args(tempWav), {
|
|
3694
|
+
stdio: 'ignore',
|
|
3695
|
+
detached: true,
|
|
3696
|
+
env: _spawnEnv,
|
|
3697
|
+
});
|
|
3698
|
+
_playingProcess = playProc;
|
|
3699
|
+
|
|
3700
|
+
if (!_closed) {
|
|
3701
|
+
modalPreviewLine.setContent(`{#00e5ff-fg}♪ Playing: ${voiceId} (Space to stop){/#00e5ff-fg}`);
|
|
3702
|
+
screen.render();
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
playProc.on('exit', () => {
|
|
3706
|
+
if (_playingVoiceId === voiceId) {
|
|
3707
|
+
_playingVoiceId = null;
|
|
3708
|
+
_playingProcess = null;
|
|
3709
|
+
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3710
|
+
}
|
|
3711
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
3712
|
+
});
|
|
3713
|
+
|
|
3714
|
+
playProc.on('error', () => {
|
|
3715
|
+
_playingVoiceId = null;
|
|
3716
|
+
_playingProcess = null;
|
|
3717
|
+
if (!_closed) { modalPreviewLine.setContent(''); screen.render(); }
|
|
3718
|
+
try { fs.unlinkSync(tempWav); } catch {}
|
|
3719
|
+
});
|
|
3720
|
+
});
|
|
3721
|
+
|
|
3722
|
+
piper.on('error', () => {
|
|
3723
|
+
_playingVoiceId = null;
|
|
3724
|
+
_playingProcess = null;
|
|
3725
|
+
if (!_closed) {
|
|
3726
|
+
modalPreviewLine.setContent('{#00e5ff-fg}♪ Cannot find piper — install with: pipx install piper-tts{/#00e5ff-fg}');
|
|
3727
|
+
screen.render();
|
|
3728
|
+
setTimeout(() => { if (!_closed) { modalPreviewLine.setContent(''); screen.render(); } }, 4000);
|
|
3729
|
+
}
|
|
3730
|
+
});
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
// ---- Key bindings ----
|
|
3734
|
+
|
|
3735
|
+
// Search: update filter on keypress
|
|
3736
|
+
modalSearch.on('keypress', () => {
|
|
3737
|
+
setTimeout(() => {
|
|
3738
|
+
_filterText = modalSearch.getValue().trim();
|
|
3739
|
+
_refreshList();
|
|
3740
|
+
}, 0);
|
|
3741
|
+
});
|
|
3742
|
+
|
|
3743
|
+
// Escape in search → back to list (not close)
|
|
3744
|
+
modalSearch.key(['escape'], () => {
|
|
3745
|
+
modalVoiceList.focus();
|
|
3746
|
+
screen.render();
|
|
3747
|
+
});
|
|
3748
|
+
|
|
3749
|
+
// Tab out of search → select button
|
|
3750
|
+
modalSearch.key(['tab'], () => { selectBtn.focus(); screen.render(); });
|
|
3751
|
+
|
|
3752
|
+
// / in list → open search
|
|
3753
|
+
modalVoiceList.key(['/'], () => {
|
|
3754
|
+
modalSearch.clearValue();
|
|
3755
|
+
modalSearch.focus();
|
|
3756
|
+
screen.render();
|
|
3757
|
+
});
|
|
3758
|
+
|
|
3759
|
+
// f → toggle favorite
|
|
3760
|
+
modalVoiceList.key(['f'], () => {
|
|
3761
|
+
const filtered = _getFiltered();
|
|
3762
|
+
const sel = filtered[modalVoiceList.selected];
|
|
3763
|
+
if (sel) { toggleFavorite(configService, sel); _refreshList(); }
|
|
3764
|
+
});
|
|
3765
|
+
|
|
3766
|
+
// Enter → select voice (set active + close modal)
|
|
3767
|
+
modalVoiceList.key(['enter'], () => {
|
|
3768
|
+
const filtered = _getFiltered();
|
|
3769
|
+
const sel = filtered[modalVoiceList.selected];
|
|
3770
|
+
if (sel) {
|
|
3771
|
+
providerService.setActiveVoice(sel);
|
|
3772
|
+
_closeModal();
|
|
3773
|
+
}
|
|
3774
|
+
});
|
|
3775
|
+
|
|
3776
|
+
// Space → preview voice (toggle)
|
|
3777
|
+
modalVoiceList.key(['space'], () => {
|
|
3778
|
+
const filtered = _getFiltered();
|
|
3779
|
+
const sel = filtered[modalVoiceList.selected];
|
|
3780
|
+
if (sel) { _previewVoice(sel); _refreshList(); }
|
|
3781
|
+
});
|
|
3782
|
+
|
|
3783
|
+
// Update info panel on selection change
|
|
3784
|
+
modalVoiceList.on('select item', () => {
|
|
3785
|
+
const filtered = _getFiltered();
|
|
3786
|
+
const sel = filtered[modalVoiceList.selected] ?? '';
|
|
3787
|
+
if (sel && !_closed) {
|
|
3788
|
+
modalInfoLine.setContent(` ${_formatInfo(sel)}`);
|
|
3789
|
+
screen.render();
|
|
3790
|
+
}
|
|
3791
|
+
});
|
|
3792
|
+
|
|
3793
|
+
// Tab navigation: list → [Select] → [★ Fav] → [Cancel] → list
|
|
3794
|
+
modalVoiceList.key(['tab'], () => { selectBtn.focus(); screen.render(); });
|
|
3795
|
+
selectBtn.key(['tab'], () => { favBtn.focus(); screen.render(); });
|
|
3796
|
+
favBtn.key(['tab'], () => { cancelBtn.focus(); screen.render(); });
|
|
3797
|
+
cancelBtn.key(['tab'], () => { modalVoiceList.focus(); screen.render(); });
|
|
3798
|
+
|
|
3799
|
+
// Escape / q closes modal
|
|
3800
|
+
modalVoiceList.key(['escape', 'q'], _closeModal);
|
|
3801
|
+
selectBtn.key(['escape'], _closeModal);
|
|
3802
|
+
favBtn.key(['escape'], _closeModal);
|
|
3803
|
+
cancelBtn.key(['escape'], _closeModal);
|
|
3804
|
+
|
|
3805
|
+
// ---- Initial load ----
|
|
3806
|
+
_refreshList();
|
|
3807
|
+
|
|
3808
|
+
// Scroll to active voice on open
|
|
3809
|
+
const activeVoiceId = providerService.getActiveVoiceId();
|
|
3810
|
+
const activeIdx = _getFiltered().indexOf(activeVoiceId);
|
|
3811
|
+
if (activeIdx >= 0) modalVoiceList.select(activeIdx);
|
|
3812
|
+
|
|
3813
|
+
modalVoiceList.focus();
|
|
3814
|
+
screen.render();
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
// ---------------------------------------------------------------------------
|
|
3818
|
+
// Private: Inline personality picker
|
|
3819
|
+
|
|
3820
|
+
function _openPersonalityPicker(screen, configService, onSelect, onClose) {
|
|
3821
|
+
const current = configService.getConfig().personality ?? 'none';
|
|
3822
|
+
const currentIdx = Math.max(0, PERSONALITIES.indexOf(current));
|
|
3823
|
+
|
|
3824
|
+
const list = blessed.list({
|
|
3825
|
+
parent: screen,
|
|
3826
|
+
top: 'center',
|
|
3827
|
+
left: 'center',
|
|
3828
|
+
width: 44,
|
|
3829
|
+
height: Math.min(PERSONALITIES.length + 4, 22),
|
|
3830
|
+
border: { type: 'line' },
|
|
3831
|
+
tags: true,
|
|
3832
|
+
label: _modalTitle('Select Personality'),
|
|
3833
|
+
items: PERSONALITIES.map((p, i) => {
|
|
3834
|
+
const emoji = PERSONALITY_EMOJIS[p] ?? '✨';
|
|
3835
|
+
const label = p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1);
|
|
3836
|
+
const mark = i === currentIdx ? '✅' : ' ';
|
|
3837
|
+
return `${mark} ${emoji} ${label}`;
|
|
3838
|
+
}),
|
|
3839
|
+
keys: true,
|
|
3840
|
+
vi: true,
|
|
3841
|
+
mouse: true,
|
|
3842
|
+
style: {
|
|
3843
|
+
border: { fg: COLORS.btnFocus },
|
|
3844
|
+
selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
|
|
3845
|
+
item: { fg: '#e3f2fd' },
|
|
3846
|
+
},
|
|
3847
|
+
});
|
|
3848
|
+
|
|
3849
|
+
list.select(currentIdx);
|
|
3850
|
+
list.focus();
|
|
3851
|
+
screen.render();
|
|
3852
|
+
|
|
3853
|
+
// ---------- Hover TTS preview ----------
|
|
3854
|
+
|
|
3855
|
+
let _pickerTtsProc = null;
|
|
3856
|
+
let _playingItemIdx = -1;
|
|
3857
|
+
|
|
3858
|
+
// Add or remove " (playing)" from a list item (strips any trailing █ first)
|
|
3859
|
+
function _setItemPlaying(idx, playing) {
|
|
3860
|
+
const item = list.items?.[idx];
|
|
3861
|
+
if (!item) return;
|
|
3862
|
+
const base = (item.content ?? '').replace(/ █$/, '').replace(/ \(playing\)$/, '');
|
|
3863
|
+
item.setContent(playing ? `${base} (playing)` : base);
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
function _killPickerTts() {
|
|
3867
|
+
if (_pickerTtsProc) {
|
|
3868
|
+
try { process.kill(-_pickerTtsProc.pid, 'SIGTERM'); } catch {}
|
|
3869
|
+
_pickerTtsProc = null;
|
|
3870
|
+
}
|
|
3871
|
+
if (_playingItemIdx >= 0) {
|
|
3872
|
+
_setItemPlaying(_playingItemIdx, false);
|
|
3873
|
+
_playingItemIdx = -1;
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
function _speakPersonalityPreview(personality) {
|
|
3878
|
+
_killPickerTts();
|
|
3879
|
+
const phrase = PERSONALITY_PREVIEW_PHRASES[personality];
|
|
3880
|
+
if (!phrase) return;
|
|
3881
|
+
const ttsScript = path.join(process.cwd(), '.claude', 'hooks', 'play-tts.sh');
|
|
3882
|
+
_pickerTtsProc = spawn('bash', [ttsScript, phrase], {
|
|
3883
|
+
stdio: 'ignore',
|
|
3884
|
+
detached: true,
|
|
3885
|
+
env: buildAudioEnv(),
|
|
3886
|
+
});
|
|
3887
|
+
_playingItemIdx = list.selected;
|
|
3888
|
+
_setItemPlaying(_playingItemIdx, true);
|
|
3889
|
+
screen.render();
|
|
3890
|
+
// Clear indicator when audio finishes naturally
|
|
3891
|
+
_pickerTtsProc.on('exit', () => {
|
|
3892
|
+
if (_playingItemIdx >= 0) {
|
|
3893
|
+
_setItemPlaying(_playingItemIdx, false);
|
|
3894
|
+
_playingItemIdx = -1;
|
|
3895
|
+
screen.render();
|
|
3896
|
+
}
|
|
3897
|
+
_pickerTtsProc = null;
|
|
3898
|
+
});
|
|
3899
|
+
_pickerTtsProc.unref();
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
// Hover: auto-speaks preview phrase when cursor moves
|
|
3903
|
+
list.on('select item', () => {
|
|
3904
|
+
_speakPersonalityPreview(PERSONALITIES[list.selected]);
|
|
3905
|
+
});
|
|
3906
|
+
|
|
3907
|
+
// [Space] plays the selected personality, or stops if the same item is already playing.
|
|
3908
|
+
// Uses item-aware toggle so navigating with ↓ (which auto-plays) doesn't prevent Space from working.
|
|
3909
|
+
list.key(['space'], () => {
|
|
3910
|
+
if (_pickerTtsProc && _playingItemIdx === list.selected) {
|
|
3911
|
+
_killPickerTts(); // true toggle: stop only if this exact item is playing
|
|
3912
|
+
} else {
|
|
3913
|
+
_speakPersonalityPreview(PERSONALITIES[list.selected]); // play or switch
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
|
|
3917
|
+
// Type-to-jump: press a letter to jump to the first matching personality (cycles on repeat)
|
|
3918
|
+
const _jumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'q']);
|
|
3919
|
+
list.on('keypress', (ch, key) => {
|
|
3920
|
+
if (!ch || key.ctrl || key.meta) return;
|
|
3921
|
+
const lower = ch.toLowerCase();
|
|
3922
|
+
if (!/^[a-z]$/.test(lower)) return;
|
|
3923
|
+
if (_jumpBlocked.has(lower)) return;
|
|
3924
|
+
const count = PERSONALITIES.length;
|
|
3925
|
+
const start = list.selected ?? 0;
|
|
3926
|
+
for (let i = 1; i <= count; i++) {
|
|
3927
|
+
const idx = (start + i) % count;
|
|
3928
|
+
if (PERSONALITIES[idx].startsWith(lower)) {
|
|
3929
|
+
list.select(idx);
|
|
3930
|
+
screen.render();
|
|
3931
|
+
break;
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
});
|
|
3935
|
+
|
|
3936
|
+
// [Enter] confirms selection
|
|
3937
|
+
list.key(['enter'], () => {
|
|
3938
|
+
const selected = PERSONALITIES[list.selected];
|
|
3939
|
+
if (!selected) return;
|
|
3940
|
+
_killPickerTts();
|
|
3941
|
+
_destroyList(list, screen, onClose);
|
|
3942
|
+
onSelect(selected);
|
|
3943
|
+
});
|
|
3944
|
+
|
|
3945
|
+
list.key(['escape', 'q'], () => {
|
|
3946
|
+
_killPickerTts();
|
|
3947
|
+
_destroyList(list, screen, onClose);
|
|
3948
|
+
});
|
|
3949
|
+
}
|