agentvibes 5.6.9 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.agentvibes/config.json +3 -38
  2. package/.claude/commands/agent-vibes/provider.md +0 -0
  3. package/.claude/config/audio-effects.cfg +1 -1
  4. package/.claude/config/background-music-position.txt +6 -8
  5. package/.claude/config/reverb-level.txt +0 -0
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/bmad-tts-injector.sh +49 -21
  8. package/.claude/hooks/migrate-to-agentvibes.sh +24 -16
  9. package/.claude/hooks/personality-manager.sh +15 -2
  10. package/.claude/hooks/play-tts.sh +6 -0
  11. package/.claude/hooks/provider-commands.sh +16 -4
  12. package/.claude/hooks/provider-manager.sh +38 -0
  13. package/.claude/hooks/stop.sh +2 -27
  14. package/.claude/hooks/voice-manager.sh +50 -2
  15. package/.claude/hooks-windows/play-tts.ps1 +34 -1
  16. package/.claude/hooks-windows/tts-watcher.ps1 +122 -0
  17. package/.claude/piper-voices-dir.txt +1 -1
  18. package/.mcp.json +13 -33
  19. package/README.md +6 -8
  20. package/RELEASE_NOTES.md +32 -0
  21. package/bin/agent-vibes +39 -39
  22. package/package.json +1 -1
  23. package/src/bmad-detector.js +85 -71
  24. package/src/cli/list-personalities.js +110 -110
  25. package/src/cli/list-voices.js +114 -114
  26. package/src/commands/bmad-voices.js +394 -394
  27. package/src/commands/install-mcp.js +476 -476
  28. package/src/console/brand-colors.js +13 -13
  29. package/src/console/constants/personalities.js +44 -44
  30. package/src/console/tabs/help-tab.js +314 -314
  31. package/src/console/tabs/readme-tab.js +272 -272
  32. package/src/console/widgets/destroy-list.js +25 -25
  33. package/src/console/widgets/notice.js +55 -55
  34. package/src/console/widgets/personality-picker.js +213 -213
  35. package/src/i18n/de.js +202 -202
  36. package/src/i18n/es.js +202 -202
  37. package/src/i18n/fr.js +202 -202
  38. package/src/i18n/hi.js +202 -202
  39. package/src/i18n/ja.js +202 -202
  40. package/src/i18n/ko.js +202 -202
  41. package/src/i18n/pt.js +202 -202
  42. package/src/i18n/strings.js +54 -54
  43. package/src/i18n/zh-CN.js +202 -202
  44. package/src/installer/language-screen.js +31 -31
  45. package/src/installer/music-file-input.js +304 -304
  46. package/src/installer.js +330 -64
  47. package/src/services/agent-voice-store.js +59 -12
  48. package/src/services/config-service.js +264 -264
  49. package/src/services/language-service.js +47 -47
  50. package/src/services/llm-provider-service.js +57 -12
  51. package/src/services/provider-service.js +143 -143
  52. package/src/utils/audio-duration-validator.js +298 -298
  53. package/src/utils/audio-format-validator.js +277 -277
  54. package/src/utils/dependency-checker.js +469 -469
  55. package/src/utils/file-ownership-verifier.js +358 -358
  56. package/src/utils/list-formatter.js +194 -194
  57. package/src/utils/music-file-validator.js +285 -285
  58. package/src/utils/preview-list-prompt.js +136 -136
  59. package/src/utils/secure-music-storage.js +412 -412
  60. package/.agentvibes/LITE-MODE.md +0 -236
  61. package/.agentvibes/README.md +0 -136
  62. package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
  63. package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
  64. package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
  65. package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
  66. package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
  67. package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
  68. package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
  69. package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
  70. package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
  71. package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
  72. package/.agentvibes/config/README-personality-defaults.md +0 -162
  73. package/.agentvibes/config/agentvibes.json +0 -1
  74. package/.agentvibes/config/mode.txt +0 -1
  75. package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
  76. package/.agentvibes/config/save-audio.txt +0 -1
  77. package/.agentvibes/config/voice-metadata.json +0 -160
  78. package/.agentvibes/hooks/help.sh +0 -191
  79. package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
  80. package/.agentvibes/hooks/save-audio-manager.sh +0 -162
  81. package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
  82. package/.agentvibes/hooks/session-start-full.sh +0 -142
  83. package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
  84. package/.agentvibes/hooks/session-start-lite.sh +0 -29
  85. package/.agentvibes/hooks/stop-lite.sh +0 -115
  86. package/.agentvibes/hooks/switch-mode.sh +0 -215
  87. package/.agentvibes/output-styles/audio-summary.md +0 -30
  88. package/.claude/audio/voice-samples/piper/alan.wav +0 -0
  89. package/.claude/audio/voice-samples/piper/amy.wav +0 -0
  90. package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
  91. package/.claude/audio/voice-samples/piper/joe.wav +0 -0
  92. package/.claude/audio/voice-samples/piper/john.wav +0 -0
  93. package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
  94. package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
  95. package/.claude/audio/voice-samples/piper/linda.wav +0 -0
  96. package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
  97. package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
  98. package/.claude/hooks/post-response.sh +0 -41
  99. package/bin/ensure-soprano-running.sh +0 -43
@@ -1,55 +1,55 @@
1
- /**
2
- * AgentVibes TUI — Shared Widget: Notice Toast
3
- *
4
- * Displays a short auto-dismissing notice modal centred on screen.
5
- * Usable from any tab; no settings-specific state required.
6
- */
7
-
8
- import { destroyList } from './destroy-list.js';
9
-
10
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
11
- let blessed;
12
- if (!IS_TEST) {
13
- const { default: b } = await import('blessed');
14
- blessed = b;
15
- }
16
-
17
- /**
18
- * Show a temporary notice that auto-dismisses after 2.5 seconds.
19
- *
20
- * @param {object} screen - blessed screen instance
21
- * @param {string} message - text to display
22
- * @param {object} [opts]
23
- * @param {string} [opts.bg='#0a0e1a'] - background colour
24
- * @param {string} [opts.fg='#e3f2fd'] - foreground colour
25
- * @param {string} [opts.borderFg='bright-cyan'] - border colour
26
- * @param {number} [opts.durationMs=2500] - auto-dismiss delay in ms
27
- */
28
- export function showNotice(screen, message, opts = {}) {
29
- const bg = opts.bg ?? '#0a0e1a';
30
- const fg = opts.fg ?? '#e3f2fd';
31
- const borderFg = opts.borderFg ?? 'bright-cyan';
32
- const durationMs = opts.durationMs ?? 2500;
33
-
34
- const width = Math.max(28, message.length + 6);
35
- const modal = blessed.box({
36
- parent: screen,
37
- top: 'center',
38
- left: 'center',
39
- width,
40
- height: 3,
41
- border: { type: 'line' },
42
- tags: true,
43
- content: `{center}${message}{/center}`,
44
- style: {
45
- fg,
46
- bg,
47
- border: { fg: borderFg },
48
- },
49
- });
50
- screen.render();
51
-
52
- setTimeout(() => {
53
- destroyList(modal, screen);
54
- }, durationMs);
55
- }
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Notice Toast
3
+ *
4
+ * Displays a short auto-dismissing notice modal centred on screen.
5
+ * Usable from any tab; no settings-specific state required.
6
+ */
7
+
8
+ import { destroyList } from './destroy-list.js';
9
+
10
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
11
+ let blessed;
12
+ if (!IS_TEST) {
13
+ const { default: b } = await import('blessed');
14
+ blessed = b;
15
+ }
16
+
17
+ /**
18
+ * Show a temporary notice that auto-dismisses after 2.5 seconds.
19
+ *
20
+ * @param {object} screen - blessed screen instance
21
+ * @param {string} message - text to display
22
+ * @param {object} [opts]
23
+ * @param {string} [opts.bg='#0a0e1a'] - background colour
24
+ * @param {string} [opts.fg='#e3f2fd'] - foreground colour
25
+ * @param {string} [opts.borderFg='bright-cyan'] - border colour
26
+ * @param {number} [opts.durationMs=2500] - auto-dismiss delay in ms
27
+ */
28
+ export function showNotice(screen, message, opts = {}) {
29
+ const bg = opts.bg ?? '#0a0e1a';
30
+ const fg = opts.fg ?? '#e3f2fd';
31
+ const borderFg = opts.borderFg ?? 'bright-cyan';
32
+ const durationMs = opts.durationMs ?? 2500;
33
+
34
+ const width = Math.max(28, message.length + 6);
35
+ const modal = blessed.box({
36
+ parent: screen,
37
+ top: 'center',
38
+ left: 'center',
39
+ width,
40
+ height: 3,
41
+ border: { type: 'line' },
42
+ tags: true,
43
+ content: `{center}${message}{/center}`,
44
+ style: {
45
+ fg,
46
+ bg,
47
+ border: { fg: borderFg },
48
+ },
49
+ });
50
+ screen.render();
51
+
52
+ setTimeout(() => {
53
+ destroyList(modal, screen);
54
+ }, durationMs);
55
+ }
@@ -1,213 +1,213 @@
1
- /**
2
- * AgentVibes TUI — Shared Widget: Personality Picker
3
- *
4
- * Inline modal list for selecting voice personalities with TTS preview.
5
- * Extracted from settings-tab.js for reuse across tabs.
6
- */
7
-
8
- import path from 'node:path';
9
- import fs from 'node:fs';
10
- import os from 'node:os';
11
- import { spawn } from 'node:child_process';
12
- import { destroyList } from './destroy-list.js';
13
- import { buildAudioEnv } from '../audio-env.js';
14
- import { BRAND_PINK } from '../brand-colors.js';
15
- import { PERSONALITY_EMOJIS, PERSONALITIES } from '../constants/personalities.js';
16
-
17
- export { PERSONALITY_EMOJIS, PERSONALITIES };
18
-
19
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
20
- let blessed;
21
- if (!IS_TEST) {
22
- const { default: b } = await import('blessed');
23
- blessed = b;
24
- }
25
-
26
- const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
27
-
28
- const PERSONALITY_PREVIEW_PHRASES = Object.freeze({
29
- angry: "UNACCEPTABLE! This build time is a DISASTER! Fix it NOW or so help me!",
30
- annoying: "Oh oh oh! Can I tell you something? Can I? Can I? PLEASE? It is so important!",
31
- crass: "Well damn, that code runs like my uncle's truck. Barely, and it smells funny.",
32
- dramatic: "The tests... have failed. I don't know how much longer I can do this.",
33
- 'dry-humor': "Your code worked. I too am surprised.",
34
- flirty: "Ooh, a clean merge? You know exactly how to make my heart race.",
35
- funny: "Why do programmers hate nature? Too many bugs. I will show myself out.",
36
- grandpa: "Back in my day, we compiled by hand. Uphill. In the snow. Both ways.",
37
- millennial: "I literally cannot even with this error. I am so done. Like, actually deceased.",
38
- moody: "...It works. Whatever. Do not get used to it.",
39
- pirate: "Arrr! The build be sailin' smooth today, matey! No barnacles in sight!",
40
- poetic: "Like rivers to the sea, your code flows toward eventual compilation.",
41
- professional: "I have completed the requested task and am prepared to document outcomes.",
42
- rapper: "Yo! Clean code flowin', tests are glowin', no bugs showin'!",
43
- robot: "TASK COMPLETE. EFFICIENCY: OPTIMAL. PROBABILITY OF SUCCESS: 97.3 PERCENT. BEEP.",
44
- sarcastic: "Oh wow, another bug. What a completely unexpected surprise. Truly shocking.",
45
- sassy: "Honey, whoever told you that was good code was not your friend.",
46
- 'surfer-dude':"Duuude! That commit totally shredded! Gnarly clean code, bro!",
47
- zen: "The bug is not the enemy. The bug is the teacher. Breathe. Commit.",
48
- random: "Who will I be today? Even I do not know. Expect the unexpected.",
49
- });
50
-
51
- /**
52
- * Open the personality picker modal.
53
- *
54
- * @param {object} screen - blessed screen
55
- * @param {string} currentPersonality - current personality value
56
- * @param {Function} onSelect - called with selected personality
57
- * @param {Function} [onClose] - called after modal closes
58
- */
59
- export function openPersonalityPicker(screen, currentPersonality, onSelect, onClose) {
60
- const current = currentPersonality ?? 'none';
61
- const currentIdx = Math.max(0, PERSONALITIES.indexOf(current));
62
-
63
- const COLORS = {
64
- btnFocus: '#2e7d32',
65
- btnFocusFg: '#ffffff',
66
- };
67
-
68
- const list = blessed.list({
69
- parent: screen,
70
- top: 'center',
71
- left: 'center',
72
- width: 44,
73
- height: Math.min(PERSONALITIES.length + 4, 22),
74
- border: { type: 'line' },
75
- tags: true,
76
- label: _modalTitle('Select Personality'),
77
- items: PERSONALITIES.map((p, i) => {
78
- const emoji = PERSONALITY_EMOJIS[p] ?? '✨';
79
- const label = p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1);
80
- const mark = i === currentIdx ? '✅' : ' ';
81
- return `${mark} ${emoji} ${label}`;
82
- }),
83
- keys: true,
84
- vi: true,
85
- mouse: true,
86
- style: {
87
- border: { fg: COLORS.btnFocus },
88
- selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
89
- item: { fg: '#e3f2fd' },
90
- },
91
- });
92
-
93
- list.select(currentIdx);
94
- list.focus();
95
- screen.render();
96
-
97
- // TTS preview on hover
98
- let _pickerTtsProc = null;
99
- let _playingItemIdx = -1;
100
-
101
- function _setItemPlaying(idx, playing) {
102
- const item = list.items?.[idx];
103
- if (!item) return;
104
- const base = (item.content ?? '').replace(/ █$/, '').replace(/ \(playing\)$/, '');
105
- item.setContent(playing ? `${base} (playing)` : base);
106
- }
107
-
108
- function _killPickerTts() {
109
- if (_pickerTtsProc) {
110
- const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
111
- if (_isWin) {
112
- try { _pickerTtsProc.kill(); } catch {}
113
- } else {
114
- try { process.kill(-_pickerTtsProc.pid, 'SIGTERM'); } catch {}
115
- }
116
- _pickerTtsProc = null;
117
- }
118
- if (_playingItemIdx >= 0) {
119
- _setItemPlaying(_playingItemIdx, false);
120
- _playingItemIdx = -1;
121
- }
122
- }
123
-
124
- function _speakPreview(personality) {
125
- _killPickerTts();
126
- const phrase = PERSONALITY_PREVIEW_PHRASES[personality];
127
- if (!phrase) return;
128
- const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
129
- const _env = buildAudioEnv();
130
- if (_isWin) {
131
- // Prefer project-local install, fall back to global ~/.claude install
132
- const _cwdScript = path.join(process.cwd(), '.claude', 'hooks-windows', 'play-tts.ps1');
133
- const _homeScript = path.join(os.homedir(), '.claude', 'hooks-windows', 'play-tts.ps1');
134
- const ttsScript = fs.existsSync(_cwdScript) ? _cwdScript : _homeScript;
135
- _pickerTtsProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ttsScript, phrase], {
136
- stdio: 'ignore',
137
- env: _env,
138
- });
139
- } else {
140
- const ttsScript = path.join(process.cwd(), '.claude', 'hooks', 'play-tts.sh');
141
- _pickerTtsProc = spawn('bash', [ttsScript, phrase], {
142
- stdio: 'ignore',
143
- detached: true,
144
- env: _env,
145
- });
146
- }
147
- _playingItemIdx = list.selected;
148
- _setItemPlaying(_playingItemIdx, true);
149
- screen.render();
150
- _pickerTtsProc.on('exit', () => {
151
- if (_playingItemIdx >= 0) {
152
- _setItemPlaying(_playingItemIdx, false);
153
- _playingItemIdx = -1;
154
- screen.render();
155
- }
156
- _pickerTtsProc = null;
157
- });
158
- _pickerTtsProc.on('error', () => {
159
- _pickerTtsProc = null;
160
- if (_playingItemIdx >= 0) {
161
- _setItemPlaying(_playingItemIdx, false);
162
- _playingItemIdx = -1;
163
- screen.render();
164
- }
165
- });
166
- _pickerTtsProc.unref();
167
- }
168
-
169
- list.on('select item', () => {
170
- _speakPreview(PERSONALITIES[list.selected]);
171
- });
172
-
173
- list.key(['space'], () => {
174
- if (_pickerTtsProc && _playingItemIdx === list.selected) {
175
- _killPickerTts();
176
- } else {
177
- _speakPreview(PERSONALITIES[list.selected]);
178
- }
179
- });
180
-
181
- // Type-to-jump
182
- const _jumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'q']);
183
- list.on('keypress', (ch, key) => {
184
- if (!ch || key.ctrl || key.meta) return;
185
- const lower = ch.toLowerCase();
186
- if (!/^[a-z]$/.test(lower)) return;
187
- if (_jumpBlocked.has(lower)) return;
188
- const count = PERSONALITIES.length;
189
- const start = list.selected ?? 0;
190
- for (let i = 1; i <= count; i++) {
191
- const idx = (start + i) % count;
192
- if (PERSONALITIES[idx].startsWith(lower)) {
193
- list.select(idx);
194
- screen.render();
195
- break;
196
- }
197
- }
198
- });
199
-
200
- list.key(['enter'], () => {
201
- const selected = PERSONALITIES[list.selected];
202
- if (!selected) return;
203
- _killPickerTts();
204
- // Call onSelect before destroying to avoid stale-state re-renders
205
- onSelect(selected);
206
- destroyList(list, screen, onClose);
207
- });
208
-
209
- list.key(['escape', 'q'], () => {
210
- _killPickerTts();
211
- destroyList(list, screen, onClose);
212
- });
213
- }
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Personality Picker
3
+ *
4
+ * Inline modal list for selecting voice personalities with TTS preview.
5
+ * Extracted from settings-tab.js for reuse across tabs.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import { spawn } from 'node:child_process';
12
+ import { destroyList } from './destroy-list.js';
13
+ import { buildAudioEnv } from '../audio-env.js';
14
+ import { BRAND_PINK } from '../brand-colors.js';
15
+ import { PERSONALITY_EMOJIS, PERSONALITIES } from '../constants/personalities.js';
16
+
17
+ export { PERSONALITY_EMOJIS, PERSONALITIES };
18
+
19
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
20
+ let blessed;
21
+ if (!IS_TEST) {
22
+ const { default: b } = await import('blessed');
23
+ blessed = b;
24
+ }
25
+
26
+ const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
27
+
28
+ const PERSONALITY_PREVIEW_PHRASES = Object.freeze({
29
+ angry: "UNACCEPTABLE! This build time is a DISASTER! Fix it NOW or so help me!",
30
+ annoying: "Oh oh oh! Can I tell you something? Can I? Can I? PLEASE? It is so important!",
31
+ crass: "Well damn, that code runs like my uncle's truck. Barely, and it smells funny.",
32
+ dramatic: "The tests... have failed. I don't know how much longer I can do this.",
33
+ 'dry-humor': "Your code worked. I too am surprised.",
34
+ flirty: "Ooh, a clean merge? You know exactly how to make my heart race.",
35
+ funny: "Why do programmers hate nature? Too many bugs. I will show myself out.",
36
+ grandpa: "Back in my day, we compiled by hand. Uphill. In the snow. Both ways.",
37
+ millennial: "I literally cannot even with this error. I am so done. Like, actually deceased.",
38
+ moody: "...It works. Whatever. Do not get used to it.",
39
+ pirate: "Arrr! The build be sailin' smooth today, matey! No barnacles in sight!",
40
+ poetic: "Like rivers to the sea, your code flows toward eventual compilation.",
41
+ professional: "I have completed the requested task and am prepared to document outcomes.",
42
+ rapper: "Yo! Clean code flowin', tests are glowin', no bugs showin'!",
43
+ robot: "TASK COMPLETE. EFFICIENCY: OPTIMAL. PROBABILITY OF SUCCESS: 97.3 PERCENT. BEEP.",
44
+ sarcastic: "Oh wow, another bug. What a completely unexpected surprise. Truly shocking.",
45
+ sassy: "Honey, whoever told you that was good code was not your friend.",
46
+ 'surfer-dude':"Duuude! That commit totally shredded! Gnarly clean code, bro!",
47
+ zen: "The bug is not the enemy. The bug is the teacher. Breathe. Commit.",
48
+ random: "Who will I be today? Even I do not know. Expect the unexpected.",
49
+ });
50
+
51
+ /**
52
+ * Open the personality picker modal.
53
+ *
54
+ * @param {object} screen - blessed screen
55
+ * @param {string} currentPersonality - current personality value
56
+ * @param {Function} onSelect - called with selected personality
57
+ * @param {Function} [onClose] - called after modal closes
58
+ */
59
+ export function openPersonalityPicker(screen, currentPersonality, onSelect, onClose) {
60
+ const current = currentPersonality ?? 'none';
61
+ const currentIdx = Math.max(0, PERSONALITIES.indexOf(current));
62
+
63
+ const COLORS = {
64
+ btnFocus: '#2e7d32',
65
+ btnFocusFg: '#ffffff',
66
+ };
67
+
68
+ const list = blessed.list({
69
+ parent: screen,
70
+ top: 'center',
71
+ left: 'center',
72
+ width: 44,
73
+ height: Math.min(PERSONALITIES.length + 4, 22),
74
+ border: { type: 'line' },
75
+ tags: true,
76
+ label: _modalTitle('Select Personality'),
77
+ items: PERSONALITIES.map((p, i) => {
78
+ const emoji = PERSONALITY_EMOJIS[p] ?? '✨';
79
+ const label = p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1);
80
+ const mark = i === currentIdx ? '✅' : ' ';
81
+ return `${mark} ${emoji} ${label}`;
82
+ }),
83
+ keys: true,
84
+ vi: true,
85
+ mouse: true,
86
+ style: {
87
+ border: { fg: COLORS.btnFocus },
88
+ selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
89
+ item: { fg: '#e3f2fd' },
90
+ },
91
+ });
92
+
93
+ list.select(currentIdx);
94
+ list.focus();
95
+ screen.render();
96
+
97
+ // TTS preview on hover
98
+ let _pickerTtsProc = null;
99
+ let _playingItemIdx = -1;
100
+
101
+ function _setItemPlaying(idx, playing) {
102
+ const item = list.items?.[idx];
103
+ if (!item) return;
104
+ const base = (item.content ?? '').replace(/ █$/, '').replace(/ \(playing\)$/, '');
105
+ item.setContent(playing ? `${base} (playing)` : base);
106
+ }
107
+
108
+ function _killPickerTts() {
109
+ if (_pickerTtsProc) {
110
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
111
+ if (_isWin) {
112
+ try { _pickerTtsProc.kill(); } catch {}
113
+ } else {
114
+ try { process.kill(-_pickerTtsProc.pid, 'SIGTERM'); } catch {}
115
+ }
116
+ _pickerTtsProc = null;
117
+ }
118
+ if (_playingItemIdx >= 0) {
119
+ _setItemPlaying(_playingItemIdx, false);
120
+ _playingItemIdx = -1;
121
+ }
122
+ }
123
+
124
+ function _speakPreview(personality) {
125
+ _killPickerTts();
126
+ const phrase = PERSONALITY_PREVIEW_PHRASES[personality];
127
+ if (!phrase) return;
128
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
129
+ const _env = buildAudioEnv();
130
+ if (_isWin) {
131
+ // Prefer project-local install, fall back to global ~/.claude install
132
+ const _cwdScript = path.join(process.cwd(), '.claude', 'hooks-windows', 'play-tts.ps1');
133
+ const _homeScript = path.join(os.homedir(), '.claude', 'hooks-windows', 'play-tts.ps1');
134
+ const ttsScript = fs.existsSync(_cwdScript) ? _cwdScript : _homeScript;
135
+ _pickerTtsProc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ttsScript, phrase], {
136
+ stdio: 'ignore',
137
+ env: _env,
138
+ });
139
+ } else {
140
+ const ttsScript = path.join(process.cwd(), '.claude', 'hooks', 'play-tts.sh');
141
+ _pickerTtsProc = spawn('bash', [ttsScript, phrase], {
142
+ stdio: 'ignore',
143
+ detached: true,
144
+ env: _env,
145
+ });
146
+ }
147
+ _playingItemIdx = list.selected;
148
+ _setItemPlaying(_playingItemIdx, true);
149
+ screen.render();
150
+ _pickerTtsProc.on('exit', () => {
151
+ if (_playingItemIdx >= 0) {
152
+ _setItemPlaying(_playingItemIdx, false);
153
+ _playingItemIdx = -1;
154
+ screen.render();
155
+ }
156
+ _pickerTtsProc = null;
157
+ });
158
+ _pickerTtsProc.on('error', () => {
159
+ _pickerTtsProc = null;
160
+ if (_playingItemIdx >= 0) {
161
+ _setItemPlaying(_playingItemIdx, false);
162
+ _playingItemIdx = -1;
163
+ screen.render();
164
+ }
165
+ });
166
+ _pickerTtsProc.unref();
167
+ }
168
+
169
+ list.on('select item', () => {
170
+ _speakPreview(PERSONALITIES[list.selected]);
171
+ });
172
+
173
+ list.key(['space'], () => {
174
+ if (_pickerTtsProc && _playingItemIdx === list.selected) {
175
+ _killPickerTts();
176
+ } else {
177
+ _speakPreview(PERSONALITIES[list.selected]);
178
+ }
179
+ });
180
+
181
+ // Type-to-jump
182
+ const _jumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'q']);
183
+ list.on('keypress', (ch, key) => {
184
+ if (!ch || key.ctrl || key.meta) return;
185
+ const lower = ch.toLowerCase();
186
+ if (!/^[a-z]$/.test(lower)) return;
187
+ if (_jumpBlocked.has(lower)) return;
188
+ const count = PERSONALITIES.length;
189
+ const start = list.selected ?? 0;
190
+ for (let i = 1; i <= count; i++) {
191
+ const idx = (start + i) % count;
192
+ if (PERSONALITIES[idx].startsWith(lower)) {
193
+ list.select(idx);
194
+ screen.render();
195
+ break;
196
+ }
197
+ }
198
+ });
199
+
200
+ list.key(['enter'], () => {
201
+ const selected = PERSONALITIES[list.selected];
202
+ if (!selected) return;
203
+ _killPickerTts();
204
+ // Call onSelect before destroying to avoid stale-state re-renders
205
+ onSelect(selected);
206
+ destroyList(list, screen, onClose);
207
+ });
208
+
209
+ list.key(['escape', 'q'], () => {
210
+ _killPickerTts();
211
+ destroyList(list, screen, onClose);
212
+ });
213
+ }