agentvibes 4.2.0 → 4.4.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.
Files changed (219) hide show
  1. package/.agentvibes/bmad/bmad-voices.md +69 -69
  2. package/.agentvibes/config.json +12 -0
  3. package/.claude/activation-instructions +54 -54
  4. package/.claude/audio/tracks/README.md +52 -52
  5. package/.claude/commands/agent-vibes/add.md +21 -21
  6. package/.claude/commands/agent-vibes/agent-vibes.md +101 -101
  7. package/.claude/commands/agent-vibes/agent.md +79 -79
  8. package/.claude/commands/agent-vibes/background-music.md +111 -111
  9. package/.claude/commands/agent-vibes/bmad.md +198 -198
  10. package/.claude/commands/agent-vibes/clean.md +18 -18
  11. package/.claude/commands/agent-vibes/cleanup.md +18 -18
  12. package/.claude/commands/agent-vibes/commands.json +145 -145
  13. package/.claude/commands/agent-vibes/effects.md +97 -97
  14. package/.claude/commands/agent-vibes/get.md +9 -9
  15. package/.claude/commands/agent-vibes/hide.md +91 -91
  16. package/.claude/commands/agent-vibes/language.md +23 -23
  17. package/.claude/commands/agent-vibes/learn.md +67 -67
  18. package/.claude/commands/agent-vibes/list.md +13 -13
  19. package/.claude/commands/agent-vibes/mute.md +37 -37
  20. package/.claude/commands/agent-vibes/preview.md +17 -17
  21. package/.claude/commands/agent-vibes/provider.md +68 -68
  22. package/.claude/commands/agent-vibes/replay-target.md +14 -14
  23. package/.claude/commands/agent-vibes/sample.md +12 -12
  24. package/.claude/commands/agent-vibes/set-favorite-voice.md +84 -84
  25. package/.claude/commands/agent-vibes/set-pretext.md +65 -65
  26. package/.claude/commands/agent-vibes/set-speed.md +41 -41
  27. package/.claude/commands/agent-vibes/show.md +84 -84
  28. package/.claude/commands/agent-vibes/switch.md +87 -87
  29. package/.claude/commands/agent-vibes/target-voice.md +26 -26
  30. package/.claude/commands/agent-vibes/target.md +30 -30
  31. package/.claude/commands/agent-vibes/translate.md +68 -68
  32. package/.claude/commands/agent-vibes/unmute.md +45 -45
  33. package/.claude/commands/agent-vibes/verbosity.md +89 -89
  34. package/.claude/commands/agent-vibes/whoami.md +7 -7
  35. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  36. package/.claude/commands/agent-vibes-rdp.md +24 -24
  37. package/.claude/config/agentvibes.json +1 -0
  38. package/.claude/config/audio-effects.cfg +2 -2
  39. package/.claude/config/audio-effects.cfg.sample +52 -52
  40. package/.claude/config/background-music-volume.txt +1 -0
  41. package/.claude/config/intro-text.txt +1 -0
  42. package/.claude/config/piper-speech-rate.txt +4 -0
  43. package/.claude/config/piper-target-speech-rate.txt +1 -0
  44. package/.claude/config/reverb-level.txt +1 -0
  45. package/.claude/config/tts-speech-rate.txt +4 -0
  46. package/.claude/config/tts-target-speech-rate.txt +1 -0
  47. package/.claude/docs/TERMUX_SETUP.md +408 -408
  48. package/.claude/github-star-reminder.txt +1 -1
  49. package/.claude/hooks/README-TTS-QUEUE.md +135 -135
  50. package/.claude/hooks/audio-cache-utils.sh +246 -246
  51. package/.claude/hooks/audio-processor.sh +433 -433
  52. package/.claude/hooks/background-music-manager.sh +404 -404
  53. package/.claude/hooks/bmad-speak-enhanced.sh +165 -165
  54. package/.claude/hooks/bmad-speak.sh +269 -269
  55. package/.claude/hooks/bmad-tts-injector.sh +568 -568
  56. package/.claude/hooks/bmad-voice-manager.sh +928 -928
  57. package/.claude/hooks/clawdbot-receiver-SECURE.sh +129 -129
  58. package/.claude/hooks/clawdbot-receiver.sh +107 -107
  59. package/.claude/hooks/clean-audio-cache.sh +22 -22
  60. package/.claude/hooks/cleanup-cache.sh +106 -106
  61. package/.claude/hooks/configure-rdp-mode.sh +137 -137
  62. package/.claude/hooks/download-extra-voices.sh +244 -244
  63. package/.claude/hooks/effects-manager.sh +268 -268
  64. package/.claude/hooks/github-star-reminder.sh +154 -154
  65. package/.claude/hooks/language-manager.sh +362 -362
  66. package/.claude/hooks/learn-manager.sh +492 -492
  67. package/.claude/hooks/macos-voice-manager.sh +205 -205
  68. package/.claude/hooks/migrate-background-music.sh +125 -125
  69. package/.claude/hooks/migrate-to-agentvibes.sh +161 -161
  70. package/.claude/hooks/optimize-background-music.sh +87 -87
  71. package/.claude/hooks/path-resolver.sh +60 -60
  72. package/.claude/hooks/personality-manager.sh +448 -448
  73. package/.claude/hooks/piper-download-voices.sh +225 -225
  74. package/.claude/hooks/piper-installer.sh +292 -292
  75. package/.claude/hooks/piper-multispeaker-registry.sh +171 -171
  76. package/.claude/hooks/piper-voice-manager.sh +24 -3
  77. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +90 -90
  78. package/.claude/hooks/play-tts-enhanced.sh +105 -105
  79. package/.claude/hooks/play-tts-macos.sh +368 -368
  80. package/.claude/hooks/play-tts-piper.sh +679 -679
  81. package/.claude/hooks/play-tts-soprano.sh +356 -356
  82. package/.claude/hooks/play-tts-ssh-remote.sh +167 -167
  83. package/.claude/hooks/play-tts-termux-ssh.sh +169 -169
  84. package/.claude/hooks/play-tts.sh +301 -301
  85. package/.claude/hooks/prepare-release.sh +54 -54
  86. package/.claude/hooks/provider-commands.sh +617 -617
  87. package/.claude/hooks/provider-manager.sh +399 -399
  88. package/.claude/hooks/replay-target-audio.sh +95 -95
  89. package/.claude/hooks/requirements.txt +6 -6
  90. package/.claude/hooks/sentiment-manager.sh +201 -201
  91. package/.claude/hooks/session-start-tts.sh +81 -81
  92. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  93. package/.claude/hooks/speed-manager.sh +291 -291
  94. package/.claude/hooks/stop-tts.sh +84 -84
  95. package/.claude/hooks/termux-installer.sh +261 -261
  96. package/.claude/hooks/translate-manager.sh +341 -341
  97. package/.claude/hooks/translator.py +237 -237
  98. package/.claude/hooks/tts-queue-worker.sh +145 -145
  99. package/.claude/hooks/tts-queue.sh +165 -165
  100. package/.claude/hooks/verbosity-manager.sh +178 -178
  101. package/.claude/hooks/voice-manager.sh +548 -548
  102. package/.claude/hooks-windows/audio-cache-utils.ps1 +119 -119
  103. package/.claude/hooks-windows/background-music-manager.ps1 +348 -0
  104. package/.claude/hooks-windows/clean-audio-cache.ps1 +53 -0
  105. package/.claude/hooks-windows/download-extra-voices.ps1 +185 -0
  106. package/.claude/hooks-windows/effects-manager.ps1 +294 -0
  107. package/.claude/hooks-windows/language-manager.ps1 +193 -0
  108. package/.claude/hooks-windows/learn-manager.ps1 +241 -0
  109. package/.claude/hooks-windows/personality-manager.ps1 +266 -0
  110. package/.claude/hooks-windows/play-tts-piper.ps1 +209 -0
  111. package/.claude/hooks-windows/play-tts-sapi.ps1 +108 -0
  112. package/.claude/hooks-windows/play-tts-soprano.ps1 +159 -158
  113. package/.claude/hooks-windows/play-tts-windows-piper.ps1 +50 -5
  114. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -108
  115. package/.claude/hooks-windows/play-tts.ps1 +344 -266
  116. package/.claude/hooks-windows/provider-manager.ps1 +29 -10
  117. package/.claude/hooks-windows/session-start-tts.ps1 +124 -124
  118. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  119. package/.claude/hooks-windows/speed-manager.ps1 +166 -0
  120. package/.claude/hooks-windows/verbosity-manager.ps1 +119 -0
  121. package/.claude/hooks-windows/voice-manager-windows.ps1 +92 -8
  122. package/.claude/output-styles/agent-vibes.md +202 -202
  123. package/.claude/personalities/angry.md +14 -14
  124. package/.claude/personalities/annoying.md +14 -14
  125. package/.claude/personalities/crass.md +14 -14
  126. package/.claude/personalities/dramatic.md +14 -14
  127. package/.claude/personalities/dry-humor.md +50 -50
  128. package/.claude/personalities/flirty.md +20 -20
  129. package/.claude/personalities/funny.md +14 -14
  130. package/.claude/personalities/grandpa.md +32 -32
  131. package/.claude/personalities/millennial.md +14 -14
  132. package/.claude/personalities/moody.md +14 -14
  133. package/.claude/personalities/normal.md +16 -16
  134. package/.claude/personalities/pirate.md +14 -14
  135. package/.claude/personalities/poetic.md +14 -14
  136. package/.claude/personalities/professional.md +14 -14
  137. package/.claude/personalities/rapper.md +55 -55
  138. package/.claude/personalities/robot.md +14 -14
  139. package/.claude/personalities/sarcastic.md +38 -38
  140. package/.claude/personalities/sassy.md +14 -14
  141. package/.claude/personalities/surfer-dude.md +14 -14
  142. package/.claude/personalities/zen.md +14 -14
  143. package/.claude/settings.json +15 -15
  144. package/.claude/verbosity.txt +1 -1
  145. package/.clawdbot/README.md +105 -105
  146. package/.clawdbot/skill/SKILL.md +241 -241
  147. package/.mcp.json +12 -0
  148. package/CLAUDE.md +170 -170
  149. package/README.md +2029 -2007
  150. package/RELEASE_NOTES.md +1310 -1203
  151. package/WINDOWS-SETUP.md +208 -208
  152. package/bin/agent-vibes +39 -39
  153. package/bin/agentvibes-voice-browser.js +1840 -1840
  154. package/bin/agentvibes.js +48 -2
  155. package/bin/mcp-server.js +121 -121
  156. package/bin/mcp-server.sh +206 -206
  157. package/bin/test-bmad-pr +78 -78
  158. package/mcp-server/QUICK_START.md +203 -203
  159. package/mcp-server/README.md +345 -345
  160. package/mcp-server/WINDOWS_SETUP.md +260 -260
  161. package/mcp-server/docs/troubleshooting-audio.md +313 -313
  162. package/mcp-server/examples/claude_desktop_config.json +11 -11
  163. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  164. package/mcp-server/examples/custom_instructions.md +169 -169
  165. package/mcp-server/install-deps.js +130 -130
  166. package/mcp-server/pyproject.toml +52 -52
  167. package/mcp-server/requirements.txt +2 -2
  168. package/mcp-server/server.py +1465 -1453
  169. package/mcp-server/test_server.py +395 -395
  170. package/mcp-server/test_windows_script_parity.py +336 -0
  171. package/package.json +110 -110
  172. package/setup-windows.ps1 +815 -815
  173. package/src/bmad-detector.js +71 -71
  174. package/src/cli/list-personalities.js +110 -110
  175. package/src/cli/list-voices.js +114 -114
  176. package/src/commands/bmad-voices.js +394 -394
  177. package/src/commands/install-mcp.js +476 -476
  178. package/src/console/app.js +824 -824
  179. package/src/console/audio-env.js +20 -1
  180. package/src/console/brand-colors.js +13 -13
  181. package/src/console/constants/personalities.js +44 -44
  182. package/src/console/footer-config.js +50 -50
  183. package/src/console/modals/modal-overlay.js +247 -247
  184. package/src/console/navigation.js +62 -62
  185. package/src/console/tabs/agents-tab.js +1684 -1516
  186. package/src/console/tabs/help-tab.js +261 -261
  187. package/src/console/tabs/install-tab.js +1007 -991
  188. package/src/console/tabs/music-tab.js +22 -8
  189. package/src/console/tabs/placeholder-tab.js +53 -53
  190. package/src/console/tabs/readme-tab.js +267 -267
  191. package/src/console/tabs/receiver-tab.js +1472 -1212
  192. package/src/console/tabs/settings-tab.js +152 -79
  193. package/src/console/tabs/voices-tab.js +100 -21
  194. package/src/console/widgets/destroy-list.js +25 -25
  195. package/src/console/widgets/format-utils.js +89 -89
  196. package/src/console/widgets/notice.js +55 -55
  197. package/src/console/widgets/personality-picker.js +185 -185
  198. package/src/console/widgets/reverb-picker.js +94 -94
  199. package/src/console/widgets/track-picker.js +285 -285
  200. package/src/installer/music-file-input.js +304 -304
  201. package/src/installer.js +5882 -5829
  202. package/src/services/agent-voice-store.js +423 -423
  203. package/src/services/config-service.js +264 -264
  204. package/src/services/navigation-service.js +123 -123
  205. package/src/services/provider-service.js +132 -132
  206. package/src/services/verbosity-service.js +157 -157
  207. package/src/utils/audio-duration-validator.js +298 -298
  208. package/src/utils/audio-format-validator.js +277 -277
  209. package/src/utils/dependency-checker.js +469 -466
  210. package/src/utils/file-ownership-verifier.js +358 -358
  211. package/src/utils/list-formatter.js +194 -194
  212. package/src/utils/music-file-validator.js +285 -285
  213. package/src/utils/preview-list-prompt.js +136 -136
  214. package/src/utils/provider-validator.js +96 -12
  215. package/src/utils/secure-music-storage.js +412 -412
  216. package/templates/agentvibes-receiver.sh +482 -482
  217. package/templates/audio/welcome-music.mp3 +0 -0
  218. package/voice-assignments.json +8244 -8244
  219. package/.claude/config/background-music-position.txt +0 -1
@@ -1,991 +1,1007 @@
1
- /**
2
- * AgentVibes TUI Console — Install Tab (Installer Wizard)
3
- * Epic 12: Stories 12.1-12.5
4
- *
5
- * Implements the Tab Component Contract:
6
- * createInstallTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
- *
8
- * 5-screen wizard flow:
9
- * Screen 1: Welcome & Purpose
10
- * Screen 2: Auto Dependency Check
11
- * Screen 3: Provider Selection
12
- * Screen 4: Voice Config & Intro Text
13
- * Screen 5: Complete & TTS Greeting
14
- */
15
-
16
- import path from 'node:path';
17
- import { execFile } from 'node:child_process';
18
- import { promisify } from 'node:util';
19
- import { promises as _fsP } from 'node:fs';
20
- import { buildAudioEnv } from '../audio-env.js';
21
- import {
22
- copyCommandFiles, copyHookFiles, copyPersonalityFiles,
23
- copyPluginFiles, copyBmadConfigFiles, copyBackgroundMusicFiles,
24
- copyConfigFiles, configureSessionStartHook,
25
- installPluginManifest, checkAndInstallPiper, ensureGitRepo,
26
- } from '../../installer.js';
27
-
28
- const _execFileAsync = promisify(execFile);
29
-
30
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
31
-
32
- let blessed;
33
- if (!IS_TEST) {
34
- const { default: b } = await import('blessed');
35
- blessed = b;
36
- }
37
-
38
- // ---------------------------------------------------------------------------
39
-
40
- const COLORS = {
41
- contentBg: '#0a0e1a',
42
- sectionHdr: '#7986cb', // Light indigo/purple — section headers (matches settings tab)
43
- labelFg: '#e3f2fd',
44
- valueFg: '#ffff00', // Yellow
45
- brandPink: '#f06292', // Light magenta — AgentVibes logotype
46
- successFg: '#69f0ae', // Greensuccess
47
- errorFg: '#ef9a9a', // Rederror/missing
48
- btnDefault: '#283593',
49
- btnFocus: '#00e5ff', // Cyan — focused button (system standard)
50
- btnFocusFg: '#000000', // Black text on cyan
51
- btnPress: '#ff00ff',
52
- borderFg: '#3f51b5',
53
- footerBg: '#3f51b5', // Indigo — Install tab footer
54
- noticeFg: '#90a4ae',
55
- };
56
-
57
- const FOOTER_TEXT = '[Enter] Continue/Finish [Esc] Back/Exit [C] Open Console [S/V/M/A/R] Tab [Q] Quit';
58
-
59
- // ---------------------------------------------------------------------------
60
- // Exported pure helpers (stories 12.1, 12.5)
61
-
62
- /**
63
- * Returns the default intro text suggestion (project folder name).
64
- * @param {string} projectDir
65
- * @returns {string}
66
- */
67
- export function getIntroDefault(projectDir) {
68
- if (!projectDir) return '';
69
- return path.basename(projectDir);
70
- }
71
-
72
- /**
73
- * Format the TTS greeting message for Screen 5.
74
- * @param {string} introText - User's intro text (may be empty)
75
- * @param {string} projectName - Project folder name
76
- * @returns {string}
77
- */
78
- export function formatGreeting(introText, projectName) {
79
- const name = introText || projectName || 'AgentVibes';
80
- return `${name} is ready! Welcome to AgentVibes. Love AgentVibes? We'd really appreciate it if you could give us a star on GitHub.`;
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Dependency detection helpers (story 12.2)
85
-
86
- /**
87
- * Check if a command exists on the system (async).
88
- * Only ENOENT means "not installed" non-zero exit code still means the binary exists.
89
- * @param {string} cmd
90
- * @returns {Promise<boolean>}
91
- */
92
- async function _commandExistsAsync(cmd) {
93
- try {
94
- await _execFileAsync(cmd, ['--version'], { stdio: 'pipe', timeout: 5000 });
95
- return true;
96
- } catch (err) {
97
- if (err.code === 'ENOENT') return false;
98
- return true; // binary exists but --version returned non-zero
99
- }
100
- }
101
-
102
- /**
103
- * Run dependency checks asynchronously. Returns results map.
104
- * @returns {Promise<{ node: boolean, npm: boolean, piper: boolean, soprano: boolean }>}
105
- */
106
- async function _checkDependenciesAsync() {
107
- const [node, npm, piper, sopranoTts, sopranoWebui] = await Promise.all([
108
- _commandExistsAsync('node'),
109
- _commandExistsAsync('npm'),
110
- _commandExistsAsync('piper'),
111
- _commandExistsAsync('soprano-tts'),
112
- _commandExistsAsync('soprano-webui'),
113
- ]);
114
- return { node, npm, piper, soprano: sopranoTts || sopranoWebui };
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // Test stub
119
-
120
- function createTestStub() {
121
- return {
122
- box: {},
123
- show: () => {},
124
- hide: () => {},
125
- onFocus: () => {},
126
- onBlur: () => {},
127
- getFooterText: () => FOOTER_TEXT,
128
- getFooterColor: () => COLORS.footerBg,
129
- };
130
- }
131
-
132
- // ---------------------------------------------------------------------------
133
-
134
- /**
135
- * Create the Install tab component.
136
- *
137
- * @param {object} screen - Blessed screen instance (or test stub)
138
- * @param {object} services
139
- * @param {import('../../services/config-service.js').ConfigService} services.configService
140
- * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
141
- */
142
- export function createInstallTab(screen, services) {
143
- if (IS_TEST) return createTestStub();
144
-
145
- const { configService, providerService, navigationService, focusMainTabBar } = services;
146
-
147
- // -------------------------------------------------------------------------
148
- // Container
149
-
150
- const box = blessed.box({
151
- parent: screen,
152
- top: 4,
153
- left: 0,
154
- width: '100%',
155
- bottom: 2,
156
- hidden: true,
157
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
158
- border: { type: 'line' },
159
- borderStyle: { fg: COLORS.borderFg },
160
- });
161
-
162
- // -------------------------------------------------------------------------
163
- // Wizard state
164
-
165
- let _screen = 1;
166
- let _lastScreen = 0;
167
- let _deps = null;
168
- let _checking = false;
169
- let _selectedProvider = null;
170
- let _introText = getIntroDefault(process.cwd());
171
- let _screen5Announced = false; // TTS greeting fires once per wizard run
172
- let _completionModalOpen = false;
173
- let _completionModalBox = null;
174
-
175
- // Install state (populated during screen 5)
176
- let _installLog = []; // array of blessed-tagged strings
177
- let _installRunning = false;
178
- let _installComplete = false;
179
- let _installError = null;
180
- let _lastSpinnerIdx = -1; // index of last ⟳ entry, replaced by ✓ on succeed
181
-
182
- // -------------------------------------------------------------------------
183
- // Content area — single persistent box, never detached.
184
- //
185
- // KEY INSIGHT: detach+recreate fails because the new widget has no previous
186
- // cell state, so blessed's diff renderer doesn't know which cells to clear.
187
- // Keeping the SAME element and calling setContent('') lets blessed diff
188
- // old-content empty and write spaces over every character that was there.
189
-
190
- const contentBox = blessed.box({
191
- parent: box,
192
- top: 1,
193
- left: 2,
194
- width: '96%',
195
- bottom: 5,
196
- tags: true,
197
- wrap: false,
198
- scrollable: false,
199
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
200
- });
201
-
202
- // Footer hint
203
- const hintLine = blessed.text({
204
- parent: box,
205
- bottom: 2,
206
- left: 2,
207
- right: 2, // explicit right bound — prevents blessed auto-shrink which leaves stale chars
208
- tags: true,
209
- content: '',
210
- style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
211
- });
212
-
213
- function _c(lines) { return lines.join('\n'); }
214
-
215
- // -------------------------------------------------------------------------
216
- // Screen 4 action button callbacks
217
-
218
- function _doEdit() {
219
- if (box.hidden || _screen !== 4) return;
220
- const prompt = blessed.prompt({
221
- parent: screen,
222
- top: 'center',
223
- left: 'center',
224
- height: 'shrink',
225
- width: '60%',
226
- border: 'line',
227
- tags: true,
228
- style: {
229
- fg: COLORS.labelFg,
230
- bg: COLORS.contentBg,
231
- border: { fg: COLORS.sectionHdr },
232
- label: { fg: COLORS.sectionHdr },
233
- },
234
- });
235
- prompt.input('Intro text (prefix spoken before every TTS message):', _introText, (err, val) => {
236
- prompt.destroy();
237
- if (!err && val !== null) {
238
- _introText = val.trim();
239
- _renderScreen4();
240
- }
241
- screen.render();
242
- });
243
- screen.render();
244
- }
245
-
246
- // -------------------------------------------------------------------------
247
- // TUI spinner adapter — captures copy-function progress into _installLog
248
-
249
- function _makeSpinner() {
250
- return {
251
- start(msg) {
252
- _installLog.push(`{${COLORS.noticeFg}-fg} ⟳ ${msg}{/${COLORS.noticeFg}-fg}`);
253
- _lastSpinnerIdx = _installLog.length - 1;
254
- _renderScreen5();
255
- },
256
- succeed(msg) {
257
- const line = `{${COLORS.successFg}-fg} ✓ ${msg || ''}{/${COLORS.successFg}-fg}`;
258
- if (_lastSpinnerIdx >= 0) {
259
- _installLog[_lastSpinnerIdx] = line;
260
- } else {
261
- _installLog.push(line);
262
- }
263
- _lastSpinnerIdx = -1;
264
- _renderScreen5();
265
- },
266
- info(msg) {
267
- _installLog.push(`{${COLORS.noticeFg}-fg} ℹ ${msg}{/${COLORS.noticeFg}-fg}`);
268
- _renderScreen5();
269
- },
270
- warn(msg) {
271
- _installLog.push(`{#ffcc00-fg} ⚠ ${msg}{/#ffcc00-fg}`);
272
- _renderScreen5();
273
- },
274
- stop() {},
275
- };
276
- }
277
-
278
- // -------------------------------------------------------------------------
279
- // Write AgentVibes config files into targetDir/.claude/
280
-
281
- async function _writeInstallConfig(targetDir, provider) {
282
- const claudeDir = path.join(targetDir, '.claude');
283
- const configDir = path.join(claudeDir, 'config');
284
- await _fsP.mkdir(configDir, { recursive: true });
285
-
286
- const defaultVoices = {
287
- piper: 'en_US-ryan-high',
288
- macos: 'Samantha',
289
- soprano: 'soprano-default',
290
- 'windows-piper': 'en_US-ryan-high',
291
- 'windows-sapi': 'Microsoft David Desktop',
292
- };
293
- // Use voice from Settings if configured, otherwise fall back to provider default
294
- const configuredVoice = configService?.getConfig?.()?.voice;
295
- const voice = configuredVoice ?? (defaultVoices[provider] ?? 'en_US-ryan-high');
296
-
297
- await _fsP.writeFile(path.join(claudeDir, 'tts-provider.txt'), provider);
298
- await _fsP.writeFile(path.join(claudeDir, 'tts-voice.txt'), voice);
299
- await _fsP.writeFile(path.join(claudeDir, 'tts-verbosity.txt'), 'medium');
300
-
301
- const pretext = _introText?.trim() ?? '';
302
- if (pretext) {
303
- await _fsP.writeFile(path.join(configDir, 'tts-pretext.txt'), pretext, { mode: 0o600 });
304
- } else {
305
- try { await _fsP.unlink(path.join(configDir, 'tts-pretext.txt')); } catch { /* ok */ }
306
- }
307
-
308
- // Apply background music settings from Settings tab.
309
- // play-tts-piper.sh reads background-music-enabled.txt (not background-music.txt),
310
- // so we must write that file explicitly when music is enabled.
311
- const bgMusic = configService?.getConfig?.()?.backgroundMusic;
312
- if (bgMusic?.enabled) {
313
- await _fsP.writeFile(path.join(configDir, 'background-music-enabled.txt'), 'true');
314
- // Update the track in audio-effects.cfg (copied from package defaults a moment ago).
315
- // Only apply if the track name is a safe filename (no pipe characters or path separators).
316
- const track = bgMusic.track;
317
- if (track && !/[|/\\]/.test(track)) {
318
- try {
319
- const audioEffectsPath = path.join(configDir, 'audio-effects.cfg');
320
- let content = await _fsP.readFile(audioEffectsPath, 'utf-8');
321
- content = content.replace(
322
- /^default\|([^|]*)\|([^|]*)\|(.*)$/m,
323
- `default|$1|${track}|$3`,
324
- );
325
- await _fsP.writeFile(audioEffectsPath, content);
326
- } catch { /* audio-effects.cfg not yet present — non-fatal */ }
327
- }
328
- }
329
- }
330
-
331
- // -------------------------------------------------------------------------
332
- // Full installation sequence (runs on screen 5)
333
-
334
- async function _runInstall() {
335
- _installLog = [];
336
- _installRunning = true;
337
- _installComplete = false;
338
- _installError = null;
339
- _lastSpinnerIdx = -1;
340
-
341
- const targetDir = process.cwd();
342
- const provider = _selectedProvider ?? 'piper';
343
- const spinner = _makeSpinner();
344
-
345
- // Suppress console output from installer.js copy functions — they use
346
- // chalk+console.log which would corrupt the blessed display.
347
- const _origLog = console.log;
348
- const _origWarn = console.warn;
349
- const _origErr = console.error;
350
- console.log = () => {};
351
- console.warn = () => {};
352
- console.error = () => {};
353
-
354
- try {
355
- // Create directory structure
356
- spinner.start('Preparing .claude directory...');
357
- await _fsP.mkdir(path.join(targetDir, '.claude', 'commands'), { recursive: true });
358
- await _fsP.mkdir(path.join(targetDir, '.claude', 'hooks'), { recursive: true });
359
- await _fsP.mkdir(path.join(targetDir, '.claude', 'audio', 'tracks'), { recursive: true });
360
- spinner.succeed('Directory structure ready');
361
-
362
- await copyCommandFiles(targetDir, spinner);
363
- await copyHookFiles(targetDir, spinner);
364
- await copyPersonalityFiles(targetDir, spinner);
365
- await copyPluginFiles(targetDir, spinner);
366
- await copyBmadConfigFiles(targetDir, spinner);
367
- await copyBackgroundMusicFiles(targetDir, spinner);
368
- await copyConfigFiles(targetDir, spinner);
369
- await configureSessionStartHook(targetDir, spinner);
370
- await installPluginManifest(targetDir, spinner);
371
- await ensureGitRepo(targetDir, spinner);
372
-
373
- spinner.start('Writing configuration...');
374
- await _writeInstallConfig(targetDir, provider);
375
- spinner.succeed('Configuration saved');
376
-
377
- // Create .mcp.json if it doesn't already exist
378
- const mcpConfigPath = path.join(targetDir, '.mcp.json');
379
- let _mcpCreated = false;
380
- try {
381
- await _fsP.access(mcpConfigPath);
382
- // Already exists — skip to avoid overwriting user's config
383
- } catch {
384
- const mcpConfig = {
385
- mcpServers: {
386
- agentvibes: {
387
- command: 'npx',
388
- args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
389
- },
390
- },
391
- };
392
- spinner.start('Creating .mcp.json...');
393
- await _fsP.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
394
- spinner.succeed('.mcp.json created');
395
- _mcpCreated = true;
396
- }
397
-
398
- if (provider === 'piper') {
399
- spinner.start('Checking Piper TTS voices...');
400
- await checkAndInstallPiper(targetDir, { yes: true, silent: true });
401
- spinner.succeed('Piper TTS ready');
402
- }
403
-
404
- _installComplete = true;
405
- _installRunning = false;
406
- _installLog.push('');
407
- _installLog.push(`{${COLORS.successFg}-fg} ✅ AgentVibes installed successfully!{/${COLORS.successFg}-fg}`);
408
- if (_mcpCreated) {
409
- _installLog.push(`{${COLORS.successFg}-fg} 📡 .mcp.json created — run: claude --mcp-config .mcp.json{/${COLORS.successFg}-fg}`);
410
- }
411
- _installLog.push(`{${COLORS.noticeFg}-fg} ⭐ Star us on GitHub: github.com/preibisch/agentvibes{/${COLORS.noticeFg}-fg}`);
412
-
413
- } catch (err) {
414
- _installRunning = false;
415
- _installError = err.message;
416
- _installLog.push(`{${COLORS.errorFg}-fg} ✗ Installation failed: ${err.message}{/${COLORS.errorFg}-fg}`);
417
- } finally {
418
- console.log = _origLog;
419
- console.warn = _origWarn;
420
- console.error = _origErr;
421
- }
422
-
423
- _renderScreen5();
424
-
425
- // Show OK button now that install is done (success or error)
426
- _s5OkBtn.show();
427
- _s5OkBtn.focus();
428
- screen.render();
429
-
430
- // Play TTS greeting on success
431
- if (_installComplete && !_screen5Announced) {
432
- _screen5Announced = true;
433
- const greeting = formatGreeting(_introText, getIntroDefault(process.cwd()));
434
- const ttsScript = path.resolve(targetDir, '.claude/hooks/play-tts.sh');
435
- execFile('bash', [ttsScript, greeting], {
436
- env: buildAudioEnv(),
437
- timeout: 30000,
438
- }, () => {});
439
- }
440
- }
441
-
442
- function _doAccept() {
443
- if (_screen !== 4 || _installRunning) return;
444
- _screen++;
445
- _showCurrentScreen();
446
- // Start install after screen transition renders (50ms delay in _showCurrentScreen)
447
- setTimeout(() => _runInstall().catch(() => {}), 100);
448
- }
449
-
450
- // -------------------------------------------------------------------------
451
- // Screen 4 action buttons — real blessed widgets for keyboard focus + ←/→ nav
452
-
453
- function _createInstallBtn(label, bg, onClick, textColor = '#ffffff') {
454
- const btn = blessed.button({
455
- parent: box,
456
- content: label,
457
- mouse: true,
458
- keys: true,
459
- shrink: true,
460
- hidden: true,
461
- padding: { left: 1, right: 1 },
462
- style: {
463
- bg,
464
- fg: textColor,
465
- focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
466
- },
467
- });
468
-
469
- // Focus indicator: ►label◄ with blinking █ — matches settings-tab standard
470
- let _blinkInterval = null;
471
- btn.on('focus', () => {
472
- btn.style.bg = COLORS.btnFocus;
473
- btn.style.fg = COLORS.btnFocusFg;
474
- const raw = btn.content.replace(/[►◄█]/g, '').trim();
475
- btn.setContent(`►${raw}◄ █`);
476
- let _on = true;
477
- screen.render();
478
- _blinkInterval = setInterval(() => {
479
- _on = !_on;
480
- if (!btn.content.includes('►')) return;
481
- const r = btn.content.replace(/[►◄█]/g, '').trim();
482
- btn.setContent(_on ? `►${r}◄ █` : `►${r}◄`);
483
- screen.render();
484
- }, 500);
485
- });
486
- btn.on('blur', () => {
487
- if (_blinkInterval) { clearInterval(_blinkInterval); _blinkInterval = null; }
488
- btn.style.bg = bg;
489
- btn.style.fg = textColor;
490
- const raw = btn.content.replace(/[►◄█]/g, '').trim();
491
- btn.setContent(raw);
492
- screen.render();
493
- });
494
-
495
- // Press: magenta flash then invoke onClick
496
- // Guard: don't fire onClick when the completion modal is open — Enter should dismiss it.
497
- btn.key(['enter', 'space'], () => {
498
- if (_completionModalOpen) return;
499
- btn.style.bg = COLORS.btnPress;
500
- btn.style.fg = 'white';
501
- screen.render();
502
- setTimeout(() => {
503
- btn.style.bg = bg;
504
- btn.style.fg = textColor;
505
- screen.render();
506
- onClick();
507
- }, 150);
508
- });
509
- btn.on('click', () => btn.press());
510
- return btn;
511
- }
512
-
513
- const _editBtn = _createInstallBtn('Edit', '#1565c0', _doEdit);
514
- const _acceptBtn = _createInstallBtn('✓ Accept & Install', COLORS.btnDefault, _doAccept);
515
-
516
- // Edit sits inline with the intro text row; Accept & Install is below
517
- _editBtn.top = 8; _editBtn.left = 36;
518
- _acceptBtn.top = 13; _acceptBtn.left = 4;
519
-
520
- // ↓/↑ navigate between Edit and Accept & Install
521
- // Note: Tab is NOT used here — 'tab' is registered globally by navigation.js (cycles tabs)
522
- _editBtn.key(['down', 'right'], () => { _acceptBtn.focus(); screen.render(); });
523
- _acceptBtn.key(['up', 'left'], () => { _editBtn.focus(); screen.render(); });
524
-
525
- // -------------------------------------------------------------------------
526
- // Screen 1 buttons — Begin (cyan) and Exit (grey)
527
-
528
- const _s1BeginBtn = _createInstallBtn('▶ Begin', '#00838f', () => {
529
- _screen++;
530
- _showCurrentScreen();
531
- });
532
- const _s1ExitBtn = _createInstallBtn('✗ Exit', '#546e7a', () => {
533
- box.hide();
534
- screen.render();
535
- if (typeof focusMainTabBar === 'function') focusMainTabBar();
536
- });
537
-
538
- _s1BeginBtn.top = 5; _s1BeginBtn.left = 4;
539
- _s1ExitBtn.top = 5; _s1ExitBtn.left = 20;
540
-
541
- // ←/→ horizontal and ↑/↓ vertical both navigate between the two buttons
542
- _s1BeginBtn.key(['right', 'down'], () => { _s1ExitBtn.focus(); screen.render(); });
543
- _s1ExitBtn.key(['right', 'down'], () => { _s1BeginBtn.focus(); screen.render(); });
544
- _s1ExitBtn.key(['left', 'up', 'S-tab'], () => { _s1BeginBtn.focus(); screen.render(); });
545
- _s1BeginBtn.key(['left', 'up', 'S-tab'], () => { _s1ExitBtn.focus(); screen.render(); });
546
-
547
- // -------------------------------------------------------------------------
548
- // Screen 2 button — Continue (shown after deps check passes)
549
-
550
- const _s2ContinueBtn = _createInstallBtn('Continue ', '#1565c0', () => {
551
- _screen++;
552
- _showCurrentScreen();
553
- });
554
- _s2ContinueBtn.top = 12; _s2ContinueBtn.left = 4;
555
- // → also advances without the flash delay
556
- _s2ContinueBtn.key(['right'], () => { _screen++; _showCurrentScreen(); });
557
-
558
- // Screen 3: no Continue button — Enter/→ on the list confirms selection and advances
559
-
560
- // -------------------------------------------------------------------------
561
- // Screen 5 button — OK (summary page only, config already saved on screen 4)
562
-
563
- const _s5OkBtn = _createInstallBtn('✓ OKDone', '#2e7d32', () => {
564
- _dismissCompletionModal();
565
- });
566
- _s5OkBtn.bottom = 3; _s5OkBtn.left = 4; // bottom-anchored: sits above hintLine (bottom:2)
567
-
568
- // -------------------------------------------------------------------------
569
- // Screen renderers
570
-
571
- const _HDR = (emoji, label) =>
572
- `{${COLORS.sectionHdr}-fg}${emoji} ${label} ${'─'.repeat(100)}{/${COLORS.sectionHdr}-fg}`;
573
-
574
- function _renderScreen1() {
575
- contentBox.setContent(_c([
576
- _HDR('🔧', 'Setup Wizard'),
577
- '',
578
- ` {${COLORS.noticeFg}-fg}TTS for AI assistants with personality.{/${COLORS.noticeFg}-fg}`,
579
- '',
580
- '', // ← [▶ Begin] [✗ Exit] buttons here (box row 5)
581
- ]));
582
- hintLine.setContent(' Screen 1/5: Welcome | [←/→] Navigate | [Enter] Begin | [Esc] Exit');
583
- _s1BeginBtn.focus();
584
- screen.render();
585
- }
586
-
587
- async function _renderScreen2() {
588
- const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
589
- let frameIdx = 0;
590
- _checking = true;
591
- _s2ContinueBtn.hide(); // hidden during spinner
592
-
593
- contentBox.setContent(_c([
594
- _HDR('🔍', 'Dependency Check'),
595
- '',
596
- ` {${COLORS.noticeFg}-fg}${frames[0]} Checking dependencies...{/${COLORS.noticeFg}-fg}`,
597
- ]));
598
- hintLine.setContent(' Screen 2/5: Dependencies | [←] Back | [Enter] Next');
599
- screen.render();
600
-
601
- const spinInterval = setInterval(() => {
602
- frameIdx = (frameIdx + 1) % frames.length;
603
- contentBox.setContent(_c([
604
- _HDR('🔍', 'Dependency Check'),
605
- '',
606
- ` {${COLORS.noticeFg}-fg}${frames[frameIdx]} Checking dependencies...{/${COLORS.noticeFg}-fg}`,
607
- ]));
608
- screen.render();
609
- }, 100);
610
-
611
- try {
612
- _deps = await _checkDependenciesAsync();
613
- } finally {
614
- clearInterval(spinInterval);
615
- _checking = false;
616
- }
617
-
618
- const ok = () => `{${COLORS.successFg}-fg}✅ Installed{/${COLORS.successFg}-fg}`;
619
- const bad = () => `{${COLORS.errorFg}-fg}❌ Not found{/${COLORS.errorFg}-fg}`;
620
-
621
- const ttsOk = _deps.piper || _deps.soprano;
622
- contentBox.setContent(_c([
623
- _HDR('🔍', 'Dependency Check'),
624
- '',
625
- ` {${COLORS.noticeFg}-fg}${'Dependency'.padEnd(14)}Status{/${COLORS.noticeFg}-fg}`,
626
- ` {${COLORS.noticeFg}-fg}${'─'.repeat(78)}{/${COLORS.noticeFg}-fg}`,
627
- ` {${COLORS.labelFg}-fg}${'Node.js'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.node ? ok() : bad()}`,
628
- ` {${COLORS.labelFg}-fg}${'npm'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.npm ? ok() : bad()}`,
629
- ` {${COLORS.labelFg}-fg}${'Piper TTS'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.piper ? ok() : bad()}`,
630
- ` {${COLORS.labelFg}-fg}${'Soprano TTS'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.soprano ? ok() : bad()}`,
631
- '',
632
- ttsOk
633
- ? ` {${COLORS.successFg}-fg}✅ TTS Providers Detected{/${COLORS.successFg}-fg}`
634
- : ` {${COLORS.errorFg}-fg}⚠ No TTS provider found. Install Piper or Soprano first.{/${COLORS.errorFg}-fg}`,
635
- '', // blank separator
636
- '', // [Continue →] button here (box row 12) when TTS detected
637
- ]));
638
- if (ttsOk) {
639
- _s2ContinueBtn.show();
640
- _s2ContinueBtn.focus();
641
- }
642
- screen.render();
643
- }
644
-
645
- function _renderScreen3() {
646
- const providers = [];
647
- if (_deps?.piper) providers.push('piper');
648
- if (_deps?.soprano) providers.push('soprano');
649
-
650
- if (providers.length === 0) providers.push('piper'); // fallback
651
- if (!_selectedProvider) _selectedProvider = providers[0];
652
-
653
- // Pad items to 96 visible chars so they fully overwrite any stale cells from Screen 2.
654
- // Selected row uses cyan bg + black text (matches button focus standard).
655
- const items = providers.map(p =>
656
- p === _selectedProvider
657
- ? `{#00e5ff-bg}{#000000-fg}{bold} ● ${p.padEnd(92)}{/bold}{/#000000-fg}{/#00e5ff-bg}`
658
- : `{${COLORS.labelFg}-fg} ${p.padEnd(93)}{/${COLORS.labelFg}-fg}`
659
- );
660
-
661
- // Pad item list to 3 entries so the Continue button sits at a fixed row
662
- // and all stale lines from Screen 2 (which has ~10 lines) are overwritten.
663
- const paddedItems = [...items];
664
- while (paddedItems.length < 3) paddedItems.push(` ${''.padEnd(93)}`);
665
-
666
- // Append trailing blank rows (space-padded) so blessed rewrites every cell that
667
- // screen 2 used. Two screen.render() calls in the same tick are batched, so the
668
- // intermediate "clear" render never fires — trailing spaces here fix that in one pass.
669
- const _blank = ' '.repeat(120);
670
- const _trail = Array(12).fill(_blank);
671
- contentBox.setContent(_c([
672
- _HDR('🎤', 'Provider Selection'),
673
- '',
674
- ` {${COLORS.noticeFg}-fg}${'Available TTS providers:'.padEnd(94)}{/${COLORS.noticeFg}-fg}`,
675
- '',
676
- ...paddedItems.map(i => ` ${i}`),
677
- ..._trail,
678
- ]));
679
- hintLine.setContent(' Screen 3/5: Provider | [←] Back | [↑↓] Choose | [Enter/→] Confirm & Continue');
680
- box.focus();
681
- screen.render();
682
- }
683
-
684
- function _renderScreen4() {
685
- const provider = _selectedProvider ?? 'piper';
686
- const intro = _introText || '';
687
- const folderName = getIntroDefault(process.cwd()) || 'AgentVibes';
688
- const example = `${folderName}: Here`;
689
- const voiceId = providerService?.getActiveVoiceId?.() ?? 'en_US-amy-medium';
690
-
691
- contentBox.setContent(_c([
692
- _HDR('🎤', 'Provider & Voice'),
693
- '',
694
- ` {${COLORS.labelFg}-fg}${'Provider:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${provider}{/${COLORS.valueFg}-fg}`,
695
- ` {${COLORS.labelFg}-fg}${'Voice:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${voiceId}{/${COLORS.valueFg}-fg} {${COLORS.noticeFg}-fg}(after installation, you can change in Settings){/${COLORS.noticeFg}-fg}`,
696
- '',
697
- _HDR('✍️', 'Intro Text'),
698
- '',
699
- ` {${COLORS.labelFg}-fg}${'Intro text:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${intro || '(none)'}{/${COLORS.valueFg}-fg}`,
700
- // [Edit] button rendered inline at box row 8, left=36
701
- '',
702
- ` {${COLORS.noticeFg}-fg}Example:{/${COLORS.noticeFg}-fg} {${COLORS.valueFg}-fg}"${example}"{/${COLORS.valueFg}-fg}`,
703
- '',
704
- '',
705
- '', // [✓ Accept & Install] button rendered as real widget here (box row 13)
706
- ]));
707
- hintLine.setContent(' Screen 4/5: Config | [Esc] Back | [E] Edit | [↓] Accept & Install');
708
- _acceptBtn.focus();
709
- screen.render();
710
- }
711
-
712
- function _renderScreen5() {
713
- const header = _installError
714
- ? _HDR('', 'Installation Failed')
715
- : _installComplete
716
- ? _HDR('✅', 'Installation Complete')
717
- : _HDR('⚙️', 'Installing AgentVibes...');
718
-
719
- const hint = (_installComplete || _installError)
720
- ? ' Screen 5/5: Complete | [Enter] OK — Done'
721
- : ' Screen 5/5: Installing... | Please wait';
722
-
723
- // Show last 18 log lines so content fits in the box
724
- const MAX_LINES = 18;
725
- const visibleLog = _installLog.length > MAX_LINES
726
- ? _installLog.slice(-MAX_LINES)
727
- : _installLog;
728
-
729
- contentBox.setContent(_c([
730
- header,
731
- '',
732
- ...visibleLog,
733
- ]));
734
- hintLine.setContent(hint);
735
- screen.render();
736
- }
737
-
738
- function _showInstallNotice(message) {
739
- const width = Math.max(28, message.length + 6);
740
- const notice = blessed.box({
741
- parent: screen,
742
- top: 'center',
743
- left: 'center',
744
- width,
745
- height: 3,
746
- border: { type: 'line' },
747
- tags: true,
748
- content: `{center}${message}{/center}`,
749
- style: {
750
- fg: '#e3f2fd',
751
- bg: COLORS.contentBg,
752
- border: { fg: '#00e5ff' },
753
- },
754
- });
755
- screen.render();
756
- setTimeout(() => { try { notice.destroy(); screen.render(); } catch {} }, 2500);
757
- }
758
-
759
- function _dismissCompletionModal() {
760
- if (_completionModalBox) {
761
- _completionModalBox.destroy();
762
- _completionModalBox = null;
763
- }
764
- _completionModalOpen = false;
765
- _screen = 1;
766
- box.hide();
767
- _showInstallNotice('Installation Complete — Settings Saved');
768
- screen.render();
769
- navigationService?.switchTab('settings');
770
- }
771
-
772
- function _showCurrentScreen() {
773
- // Show Screen 1 buttons only on screen 1
774
- if (_screen === 1) {
775
- _s1BeginBtn.show(); _s1ExitBtn.show();
776
- } else {
777
- _s1BeginBtn.hide(); _s1ExitBtn.hide();
778
- }
779
-
780
- // Screen 2 continue button: hidden on other screens; _renderScreen2 manages show/focus
781
- if (_screen !== 2) _s2ContinueBtn.hide();
782
-
783
- // Screen 5 OK button: hidden during active install, shown by _runInstall() on completion
784
- if (_screen === 5 && (_installComplete || _installError)) {
785
- _s5OkBtn.show();
786
- } else {
787
- _s5OkBtn.hide();
788
- }
789
-
790
- // Show Screen 4 action buttons only on screen 4
791
- if (_screen === 4) {
792
- _editBtn.show(); _acceptBtn.show();
793
- } else {
794
- _editBtn.hide(); _acceptBtn.hide();
795
- }
796
-
797
- if (_screen !== _lastScreen) {
798
- // Nuclear clear: force-invalidate every olines cell so blessed's diff renderer
799
- // actually writes blanks to the terminal (blessed skips cells it thinks are
800
- // unchanged setting attr=-1 is impossible for any real cell so draw() is
801
- // forced to physically rewrite every character).
802
- try {
803
- for (let r = 0; r < screen.height; r++) {
804
- const orow = screen.olines?.[r];
805
- if (!orow) continue;
806
- for (let c = 0; c < screen.width; c++) {
807
- if (orow[c]) orow[c][0] = -1;
808
- }
809
- }
810
- // Row 2 (header bottom) never becomes dirty on its own — force it so
811
- // draw() writes headerBg+spaces and overwrites any ghost terminal content.
812
- if (screen.lines?.[2]) screen.lines[2].dirty = true;
813
- } catch {}
814
-
815
- const _clearLine = ' '.repeat(150);
816
- const _clearPage = Array(25).fill(_clearLine).join('\n');
817
- contentBox.setContent(_clearPage);
818
- hintLine.setContent(_clearLine);
819
- screen.render();
820
-
821
- const targetScreen = _screen;
822
- _lastScreen = _screen;
823
- // 50 ms delay: enough for the terminal to display the blank frame before
824
- // the new screen content overwrites it. setTimeout(0) is too fast —
825
- // both renders land in the same display frame.
826
- setTimeout(() => {
827
- if (_screen !== targetScreen) return;
828
- switch (_screen) {
829
- case 1: _renderScreen1(); break;
830
- case 2: _renderScreen2(); break;
831
- case 3: _renderScreen3(); break;
832
- case 4: _renderScreen4(); break;
833
- case 5: _renderScreen5(); break;
834
- }
835
- }, 50);
836
- return;
837
- }
838
- switch (_screen) {
839
- case 1: _renderScreen1(); break;
840
- case 2: _renderScreen2(); break;
841
- case 3: _renderScreen3(); break;
842
- case 4: _renderScreen4(); break;
843
- case 5: _renderScreen5(); break;
844
- }
845
- }
846
-
847
- // -------------------------------------------------------------------------
848
- // Navigation
849
-
850
- // Use screen.key() instead of box.key() so handlers fire regardless of which
851
- // blessed element currently holds focus. Guard with `box.hidden` so they are
852
- // no-ops when another tab is active.
853
-
854
- screen.key(['enter'], () => {
855
- if (box.hidden || _checking) return;
856
- if (_completionModalOpen) { _dismissCompletionModal(); return; } // always first
857
- if (_screen === 1) return; // Screen 1: Enter handled by Begin/Exit buttons
858
- if (_screen === 2) return; // Screen 2: Enter handled by Continue button
859
- if (_screen === 4) return; // Screen 4: Enter handled by the focused button
860
- if (_screen === 5) return; // Screen 5: Enter handled by OK button
861
- if (_screen < 5) {
862
- _screen++;
863
- _showCurrentScreen();
864
- }
865
- });
866
-
867
- screen.key(['escape'], () => {
868
- if (box.hidden || _checking) return;
869
- if (_completionModalOpen) { _dismissCompletionModal(); return; }
870
- if (_screen > 1) {
871
- _screen--;
872
- _showCurrentScreen();
873
- } else {
874
- box.hide();
875
- screen.render();
876
- // Defer so the escape keypress event finishes propagating before focus changes.
877
- // Calling focusMainTabBar() synchronously here would set focus to the tab bar
878
- // item mid-event, causing its own key(['escape']) handler to fire in the same
879
- // emission and call onFocus() → re-focus a button inside the now-hidden box.
880
- if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0);
881
- }
882
- });
883
-
884
- screen.key(['up'], () => {
885
- if (box.hidden) return;
886
- if (_screen === 3 && _deps) {
887
- const providers = [];
888
- if (_deps.piper) providers.push('piper');
889
- if (_deps.soprano) providers.push('soprano');
890
- const idx = providers.indexOf(_selectedProvider ?? providers[0]);
891
- _selectedProvider = providers[Math.max(0, idx - 1)];
892
- _renderScreen3();
893
- }
894
- });
895
-
896
- // Left arrow = go back (same logic as Escape)
897
- // Screen 4: left arrow is handled by button ←/→ navigation; use Escape to go back
898
- screen.key(['left'], () => {
899
- if (box.hidden || _checking) return;
900
- if (_screen === 4) return;
901
- if (_screen > 1) {
902
- _screen--;
903
- _showCurrentScreen();
904
- }
905
- });
906
-
907
- // Right arrow = go forward (same logic as Enter, without save/finish side-effects)
908
- // Screen 1: right arrow handled by button ←/→ navigation
909
- screen.key(['right'], () => {
910
- if (box.hidden || _checking) return;
911
- if (_screen === 1) return;
912
- if (_screen === 2) return; // Screen 2: handled by Continue button
913
- if (_screen === 3) { _screen++; _showCurrentScreen(); return; } // confirms provider and advances
914
- if (_screen === 4) return; // Screen 4: → handled by button nav
915
- if (_screen === 5) return; // Screen 5: → handled by button nav
916
- });
917
-
918
- // Down arrow: Screen 3 provider nav; Screen 1 ↓ is handled by button key handlers
919
- // (tab bar's el.key(['down']) → onFocus() focuses Begin, then button ↓ → Exit)
920
- screen.key(['down'], () => {
921
- if (box.hidden) return;
922
- if (_screen === 3 && _deps) {
923
- const providers = [];
924
- if (_deps.piper) providers.push('piper');
925
- if (_deps.soprano) providers.push('soprano');
926
- const idx = providers.indexOf(_selectedProvider ?? providers[0]);
927
- _selectedProvider = providers[Math.min(providers.length - 1, idx + 1)];
928
- _renderScreen3();
929
- }
930
- });
931
-
932
- // [E] on Screen 4: edit intro text inline
933
- screen.key(['e', 'E'], () => { _doEdit(); });
934
-
935
- // [O] anywhere: dismiss the completion modal (OK button)
936
- screen.key(['o', 'O'], () => {
937
- if (box.hidden || !_completionModalOpen) return;
938
- _dismissCompletionModal();
939
- });
940
-
941
- // -------------------------------------------------------------------------
942
- // Tab Component Contract
943
-
944
- return {
945
- box,
946
-
947
- show() {
948
- _screen = 1;
949
- _screen5Announced = false;
950
- _installLog = [];
951
- _installRunning = false;
952
- _installComplete = false;
953
- _installError = null;
954
- _lastSpinnerIdx = -1;
955
- if (_completionModalBox) { _completionModalBox.destroy(); _completionModalBox = null; }
956
- _completionModalOpen = false;
957
- box.show();
958
- _showCurrentScreen();
959
- screen.render();
960
- },
961
-
962
- hide() {
963
- box.hide();
964
- screen.render();
965
- },
966
-
967
- onFocus() {
968
- // Focus the active interactive element, not just the box container
969
- if (_screen === 1) {
970
- _s1BeginBtn.focus();
971
- } else if (_screen === 4) {
972
- _editBtn.focus();
973
- } else if (_screen === 5 && (_installComplete || _installError)) {
974
- _s5OkBtn.focus();
975
- } else {
976
- box.focus();
977
- }
978
- screen.render();
979
- },
980
-
981
- onBlur() {},
982
-
983
- getFooterText() {
984
- return FOOTER_TEXT;
985
- },
986
-
987
- getFooterColor() {
988
- return COLORS.footerBg;
989
- },
990
- };
991
- }
1
+ /**
2
+ * AgentVibes TUI Console — Install Tab (Installer Wizard)
3
+ * Epic 12: Stories 12.1-12.5
4
+ *
5
+ * Implements the Tab Component Contract:
6
+ * createInstallTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
+ *
8
+ * 5-screen wizard flow:
9
+ * Screen 1: Welcome & Purpose
10
+ * Screen 2: Auto Dependency Check
11
+ * Screen 3: Provider Selection
12
+ * Screen 4: Voice Config & Intro Text
13
+ * Screen 5: Complete & TTS Greeting
14
+ */
15
+
16
+ import path from 'node:path';
17
+ import { execFile } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+ import fs from 'node:fs';
20
+ import { promises as _fsP } from 'node:fs';
21
+ import { buildAudioEnv } from '../audio-env.js';
22
+ import {
23
+ copyCommandFiles, copyHookFiles, copyPersonalityFiles,
24
+ copyPluginFiles, copyBmadConfigFiles, copyBackgroundMusicFiles,
25
+ copyConfigFiles, configureSessionStartHook,
26
+ installPluginManifest, checkAndInstallPiper, ensureGitRepo,
27
+ } from '../../installer.js';
28
+
29
+ const _execFileAsync = promisify(execFile);
30
+
31
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
32
+
33
+ let blessed;
34
+ if (!IS_TEST) {
35
+ const { default: b } = await import('blessed');
36
+ blessed = b;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const COLORS = {
42
+ contentBg: '#0a0e1a',
43
+ sectionHdr: 'bright-cyan', // Matches header "Agent" color
44
+ labelFg: '#e3f2fd',
45
+ valueFg: '#ffff00', // Yellow
46
+ brandPink: '#f06292', // Light magenta AgentVibes logotype
47
+ successFg: '#69f0ae', // Greensuccess
48
+ errorFg: '#ef9a9a', // Red — error/missing
49
+ btnDefault: '#283593',
50
+ btnFocus: '#2e7d32', // Green focused/selected
51
+ btnFocusFg: '#ffffff', // White text on green
52
+ btnPress: '#ff00ff',
53
+ borderFg: 'bright-cyan',
54
+ footerBg: '#5c6bc0', // Lighter indigo — Install tab footer
55
+ noticeFg: '#90a4ae',
56
+ };
57
+
58
+ const FOOTER_TEXT = '[Enter] Continue/Finish [Esc] Back/Exit [C] Open Console [S/V/M/A/R] Tab [Q] Quit';
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Exported pure helpers (stories 12.1, 12.5)
62
+
63
+ /**
64
+ * Returns the default intro text suggestion (project folder name).
65
+ * @param {string} projectDir
66
+ * @returns {string}
67
+ */
68
+ export function getIntroDefault(projectDir) {
69
+ if (!projectDir) return '';
70
+ return path.basename(projectDir);
71
+ }
72
+
73
+ /**
74
+ * Format the TTS greeting message for Screen 5.
75
+ * @param {string} introText - User's intro text (may be empty)
76
+ * @param {string} projectName - Project folder name
77
+ * @returns {string}
78
+ */
79
+ export function formatGreeting(introText, projectName) {
80
+ const name = introText || projectName || 'AgentVibes';
81
+ return `${name} is ready! Welcome to AgentVibes. Love AgentVibes? We'd really appreciate it if you could give us a star on GitHub.`;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Dependency detection helpers (story 12.2)
86
+
87
+ /**
88
+ * Check if a command exists on the system (async).
89
+ * Only ENOENT means "not installed" — non-zero exit code still means the binary exists.
90
+ * @param {string} cmd
91
+ * @returns {Promise<boolean>}
92
+ */
93
+ async function _commandExistsAsync(cmd) {
94
+ try {
95
+ // On Windows, commands like 'npm' are .cmd batch files that require shell: true
96
+ const opts = { stdio: 'pipe', timeout: 5000 };
97
+ if (process.platform === 'win32') opts.shell = true;
98
+ await _execFileAsync(cmd, ['--version'], opts);
99
+ return true;
100
+ } catch (err) {
101
+ if (err.code === 'ENOENT') return false;
102
+ return true; // binary exists but --version returned non-zero
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Run dependency checks asynchronously. Returns results map.
108
+ * @returns {Promise<{ node: boolean, npm: boolean, piper: boolean, soprano: boolean }>}
109
+ */
110
+ async function _checkDependenciesAsync() {
111
+ const [node, npm, piperCmd, sopranoTts, sopranoWebui, ffmpeg] = await Promise.all([
112
+ _commandExistsAsync('node'),
113
+ _commandExistsAsync('npm'),
114
+ _commandExistsAsync('piper'),
115
+ _commandExistsAsync('soprano-tts'),
116
+ _commandExistsAsync('soprano-webui'),
117
+ _commandExistsAsync('ffmpeg'),
118
+ ]);
119
+
120
+ // On Windows, Piper is a standalone exe at %LOCALAPPDATA%\Programs\Piper\piper.exe
121
+ let piper = piperCmd;
122
+ if (!piper && process.platform === 'win32') {
123
+ const localAppData = process.env.LOCALAPPDATA ||
124
+ (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'AppData', 'Local') : null);
125
+ if (localAppData) {
126
+ piper = fs.existsSync(path.join(localAppData, 'Programs', 'Piper', 'piper.exe'));
127
+ }
128
+ }
129
+
130
+ return { node, npm, piper, soprano: sopranoTts || sopranoWebui, ffmpeg };
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Test stub
135
+
136
+ function createTestStub() {
137
+ return {
138
+ box: {},
139
+ show: () => {},
140
+ hide: () => {},
141
+ onFocus: () => {},
142
+ onBlur: () => {},
143
+ getFooterText: () => FOOTER_TEXT,
144
+ getFooterColor: () => COLORS.footerBg,
145
+ };
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Create the Install tab component.
152
+ *
153
+ * @param {object} screen - Blessed screen instance (or test stub)
154
+ * @param {object} services
155
+ * @param {import('../../services/config-service.js').ConfigService} services.configService
156
+ * @returns {{ box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }}
157
+ */
158
+ export function createInstallTab(screen, services) {
159
+ if (IS_TEST) return createTestStub();
160
+
161
+ const { configService, providerService, navigationService, focusMainTabBar } = services;
162
+
163
+ // -------------------------------------------------------------------------
164
+ // Container
165
+
166
+ const box = blessed.box({
167
+ parent: screen,
168
+ top: 4,
169
+ left: 0,
170
+ width: '100%',
171
+ bottom: 2,
172
+ hidden: true,
173
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
174
+ border: { type: 'line' },
175
+ borderStyle: { fg: COLORS.borderFg },
176
+ });
177
+
178
+ // -------------------------------------------------------------------------
179
+ // Wizard state
180
+
181
+ let _screen = 1;
182
+ let _lastScreen = 0;
183
+ let _deps = null;
184
+ let _checking = false;
185
+ let _selectedProvider = null;
186
+ let _introText = getIntroDefault(process.cwd());
187
+ let _screen5Announced = false; // TTS greeting fires once per wizard run
188
+ let _completionModalOpen = false;
189
+ let _completionModalBox = null;
190
+
191
+ // Install state (populated during screen 5)
192
+ let _installLog = []; // array of blessed-tagged strings
193
+ let _installRunning = false;
194
+ let _installComplete = false;
195
+ let _installError = null;
196
+ let _lastSpinnerIdx = -1; // index of last ⟳ entry, replaced by ✓ on succeed
197
+
198
+ // -------------------------------------------------------------------------
199
+ // Content area — single persistent box, never detached.
200
+ //
201
+ // KEY INSIGHT: detach+recreate fails because the new widget has no previous
202
+ // cell state, so blessed's diff renderer doesn't know which cells to clear.
203
+ // Keeping the SAME element and calling setContent('') lets blessed diff
204
+ // old-content → empty and write spaces over every character that was there.
205
+
206
+ const contentBox = blessed.box({
207
+ parent: box,
208
+ top: 1,
209
+ left: 2,
210
+ width: '96%',
211
+ bottom: 5,
212
+ tags: true,
213
+ wrap: false,
214
+ scrollable: false,
215
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
216
+ });
217
+
218
+ // Footer hint
219
+ const hintLine = blessed.text({
220
+ parent: box,
221
+ bottom: 2,
222
+ left: 2,
223
+ right: 2, // explicit right bound — prevents blessed auto-shrink which leaves stale chars
224
+ tags: true,
225
+ content: '',
226
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
227
+ });
228
+
229
+ function _c(lines) { return lines.join('\n'); }
230
+
231
+ // -------------------------------------------------------------------------
232
+ // Screen 4 action button callbacks
233
+
234
+ function _doEdit() {
235
+ if (box.hidden || _screen !== 4) return;
236
+ const prompt = blessed.prompt({
237
+ parent: screen,
238
+ top: 'center',
239
+ left: 'center',
240
+ height: 'shrink',
241
+ width: '60%',
242
+ border: 'line',
243
+ tags: true,
244
+ style: {
245
+ fg: COLORS.labelFg,
246
+ bg: COLORS.contentBg,
247
+ border: { fg: COLORS.sectionHdr },
248
+ label: { fg: COLORS.sectionHdr },
249
+ },
250
+ });
251
+ prompt.input('Intro text (prefix spoken before every TTS message):', _introText, (err, val) => {
252
+ prompt.destroy();
253
+ if (!err && val !== null) {
254
+ _introText = val.trim();
255
+ _renderScreen4();
256
+ }
257
+ screen.render();
258
+ });
259
+ screen.render();
260
+ }
261
+
262
+ // -------------------------------------------------------------------------
263
+ // TUI spinner adapter — captures copy-function progress into _installLog
264
+
265
+ function _makeSpinner() {
266
+ return {
267
+ start(msg) {
268
+ _installLog.push(`{${COLORS.noticeFg}-fg} ⟳ ${msg}{/${COLORS.noticeFg}-fg}`);
269
+ _lastSpinnerIdx = _installLog.length - 1;
270
+ _renderScreen5();
271
+ },
272
+ succeed(msg) {
273
+ const line = `{${COLORS.successFg}-fg} ✓ ${msg || ''}{/${COLORS.successFg}-fg}`;
274
+ if (_lastSpinnerIdx >= 0) {
275
+ _installLog[_lastSpinnerIdx] = line;
276
+ } else {
277
+ _installLog.push(line);
278
+ }
279
+ _lastSpinnerIdx = -1;
280
+ _renderScreen5();
281
+ },
282
+ info(msg) {
283
+ _installLog.push(`{${COLORS.noticeFg}-fg} ℹ ${msg}{/${COLORS.noticeFg}-fg}`);
284
+ _renderScreen5();
285
+ },
286
+ warn(msg) {
287
+ _installLog.push(`{#ffcc00-fg} ⚠ ${msg}{/#ffcc00-fg}`);
288
+ _renderScreen5();
289
+ },
290
+ stop() {},
291
+ };
292
+ }
293
+
294
+ // -------------------------------------------------------------------------
295
+ // Write AgentVibes config files into targetDir/.claude/
296
+
297
+ async function _writeInstallConfig(targetDir, provider) {
298
+ const claudeDir = path.join(targetDir, '.claude');
299
+ const configDir = path.join(claudeDir, 'config');
300
+ await _fsP.mkdir(configDir, { recursive: true });
301
+
302
+ const defaultVoices = {
303
+ piper: 'en_US-ryan-high',
304
+ macos: 'Samantha',
305
+ soprano: 'soprano-default',
306
+ sapi: 'Microsoft David Desktop',
307
+ };
308
+ // Use voice from Settings if configured, otherwise fall back to provider default
309
+ const configuredVoice = configService?.getConfig?.()?.voice;
310
+ const voice = configuredVoice ?? (defaultVoices[provider] ?? 'en_US-ryan-high');
311
+
312
+ await _fsP.writeFile(path.join(claudeDir, 'tts-provider.txt'), provider);
313
+ await _fsP.writeFile(path.join(claudeDir, 'tts-voice.txt'), voice);
314
+ await _fsP.writeFile(path.join(claudeDir, 'tts-verbosity.txt'), 'medium');
315
+
316
+ const pretext = _introText?.trim() ?? '';
317
+ if (pretext) {
318
+ await _fsP.writeFile(path.join(configDir, 'tts-pretext.txt'), pretext, { mode: 0o600 });
319
+ } else {
320
+ try { await _fsP.unlink(path.join(configDir, 'tts-pretext.txt')); } catch { /* ok */ }
321
+ }
322
+
323
+ // Apply background music settings from Settings tab.
324
+ // play-tts-piper.sh reads background-music-enabled.txt (not background-music.txt),
325
+ // so we must write that file explicitly when music is enabled.
326
+ const bgMusic = configService?.getConfig?.()?.backgroundMusic;
327
+ if (bgMusic?.enabled) {
328
+ await _fsP.writeFile(path.join(configDir, 'background-music-enabled.txt'), 'true');
329
+ // Update the track in audio-effects.cfg (copied from package defaults a moment ago).
330
+ // Only apply if the track name is a safe filename (no pipe characters or path separators).
331
+ const track = bgMusic.track;
332
+ if (track && !/[|/\\]/.test(track)) {
333
+ try {
334
+ const audioEffectsPath = path.join(configDir, 'audio-effects.cfg');
335
+ let content = await _fsP.readFile(audioEffectsPath, 'utf-8');
336
+ content = content.replace(
337
+ /^default\|([^|]*)\|([^|]*)\|(.*)$/m,
338
+ `default|$1|${track}|$3`,
339
+ );
340
+ await _fsP.writeFile(audioEffectsPath, content);
341
+ } catch { /* audio-effects.cfg not yet present — non-fatal */ }
342
+ }
343
+ }
344
+ }
345
+
346
+ // -------------------------------------------------------------------------
347
+ // Full installation sequence (runs on screen 5)
348
+
349
+ async function _runInstall() {
350
+ _installLog = [];
351
+ _installRunning = true;
352
+ _installComplete = false;
353
+ _installError = null;
354
+ _lastSpinnerIdx = -1;
355
+
356
+ const targetDir = process.cwd();
357
+ const provider = _selectedProvider ?? 'piper';
358
+ const spinner = _makeSpinner();
359
+
360
+ // Suppress console output from installer.js copy functions — they use
361
+ // chalk+console.log which would corrupt the blessed display.
362
+ const _origLog = console.log;
363
+ const _origWarn = console.warn;
364
+ const _origErr = console.error;
365
+ console.log = () => {};
366
+ console.warn = () => {};
367
+ console.error = () => {};
368
+
369
+ try {
370
+ // Create directory structure
371
+ spinner.start('Preparing .claude directory...');
372
+ await _fsP.mkdir(path.join(targetDir, '.claude', 'commands'), { recursive: true });
373
+ await _fsP.mkdir(path.join(targetDir, '.claude', 'hooks'), { recursive: true });
374
+ await _fsP.mkdir(path.join(targetDir, '.claude', 'audio', 'tracks'), { recursive: true });
375
+ spinner.succeed('Directory structure ready');
376
+
377
+ await copyCommandFiles(targetDir, spinner);
378
+ await copyHookFiles(targetDir, spinner);
379
+ await copyPersonalityFiles(targetDir, spinner);
380
+ await copyPluginFiles(targetDir, spinner);
381
+ await copyBmadConfigFiles(targetDir, spinner);
382
+ await copyBackgroundMusicFiles(targetDir, spinner);
383
+ await copyConfigFiles(targetDir, spinner);
384
+ await configureSessionStartHook(targetDir, spinner);
385
+ await installPluginManifest(targetDir, spinner);
386
+ await ensureGitRepo(targetDir, spinner);
387
+
388
+ spinner.start('Writing configuration...');
389
+ await _writeInstallConfig(targetDir, provider);
390
+ spinner.succeed('Configuration saved');
391
+
392
+ // Create .mcp.json if it doesn't already exist
393
+ const mcpConfigPath = path.join(targetDir, '.mcp.json');
394
+ let _mcpCreated = false;
395
+ try {
396
+ await _fsP.access(mcpConfigPath);
397
+ // Already exists — skip to avoid overwriting user's config
398
+ } catch {
399
+ const mcpConfig = {
400
+ mcpServers: {
401
+ agentvibes: {
402
+ command: 'npx',
403
+ args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
404
+ },
405
+ },
406
+ };
407
+ spinner.start('Creating .mcp.json...');
408
+ await _fsP.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
409
+ spinner.succeed('.mcp.json created');
410
+ _mcpCreated = true;
411
+ }
412
+
413
+ if (provider === 'piper') {
414
+ spinner.start('Checking Piper TTS voices...');
415
+ await checkAndInstallPiper(targetDir, { yes: true, silent: true });
416
+ spinner.succeed('Piper TTS ready');
417
+ }
418
+
419
+ _installComplete = true;
420
+ _installRunning = false;
421
+ _installLog.push('');
422
+ _installLog.push(`{${COLORS.successFg}-fg} ✅ AgentVibes installed successfully!{/${COLORS.successFg}-fg}`);
423
+ if (_mcpCreated) {
424
+ _installLog.push(`{${COLORS.successFg}-fg} 📡 .mcp.json created — run: claude --mcp-config .mcp.json{/${COLORS.successFg}-fg}`);
425
+ }
426
+ _installLog.push(`{${COLORS.noticeFg}-fg} ⭐ Star us on GitHub: github.com/preibisch/agentvibes{/${COLORS.noticeFg}-fg}`);
427
+
428
+ } catch (err) {
429
+ _installRunning = false;
430
+ _installError = err.message;
431
+ _installLog.push(`{${COLORS.errorFg}-fg} ✗ Installation failed: ${err.message}{/${COLORS.errorFg}-fg}`);
432
+ } finally {
433
+ console.log = _origLog;
434
+ console.warn = _origWarn;
435
+ console.error = _origErr;
436
+ }
437
+
438
+ _renderScreen5();
439
+
440
+ // Show OK button now that install is done (success or error)
441
+ _s5OkBtn.show();
442
+ _s5OkBtn.focus();
443
+ screen.render();
444
+
445
+ // Play TTS greeting on success
446
+ if (_installComplete && !_screen5Announced) {
447
+ _screen5Announced = true;
448
+ const greeting = formatGreeting(_introText, getIntroDefault(process.cwd()));
449
+ const ttsScript = path.resolve(targetDir, '.claude/hooks/play-tts.sh');
450
+ execFile('bash', [ttsScript, greeting], {
451
+ env: buildAudioEnv(),
452
+ timeout: 30000,
453
+ }, () => {});
454
+ }
455
+ }
456
+
457
+ function _doAccept() {
458
+ if (_screen !== 4 || _installRunning) return;
459
+ _screen++;
460
+ _showCurrentScreen();
461
+ // Start install after screen transition renders (50ms delay in _showCurrentScreen)
462
+ setTimeout(() => _runInstall().catch(() => {}), 100);
463
+ }
464
+
465
+ // -------------------------------------------------------------------------
466
+ // Screen 4 action buttons — real blessed widgets for keyboard focus + ←/→ nav
467
+
468
+ function _createInstallBtn(label, bg, onClick, textColor = '#ffffff') {
469
+ const btn = blessed.button({
470
+ parent: box,
471
+ content: label,
472
+ mouse: true,
473
+ keys: true,
474
+ shrink: true,
475
+ hidden: true,
476
+ padding: { left: 1, right: 1 },
477
+ style: {
478
+ bg,
479
+ fg: textColor,
480
+ focus: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
481
+ },
482
+ });
483
+
484
+ // Focus indicator: ►label◄ with blinking █ — matches settings-tab standard
485
+ let _blinkInterval = null;
486
+ btn.on('focus', () => {
487
+ btn.style.bg = COLORS.btnFocus;
488
+ btn.style.fg = COLORS.btnFocusFg;
489
+ const raw = btn.content.replace(/[►◄█]/g, '').trim();
490
+ btn.setContent(`►${raw}◄ █`);
491
+ let _on = true;
492
+ screen.render();
493
+ _blinkInterval = setInterval(() => {
494
+ _on = !_on;
495
+ if (!btn.content.includes('►')) return;
496
+ const r = btn.content.replace(/[►◄█]/g, '').trim();
497
+ btn.setContent(_on ? `►${r}◄ █` : `►${r}◄`);
498
+ screen.render();
499
+ }, 500);
500
+ });
501
+ btn.on('blur', () => {
502
+ if (_blinkInterval) { clearInterval(_blinkInterval); _blinkInterval = null; }
503
+ btn.style.bg = bg;
504
+ btn.style.fg = textColor;
505
+ const raw = btn.content.replace(/[►◄█]/g, '').trim();
506
+ btn.setContent(raw);
507
+ screen.render();
508
+ });
509
+
510
+ // Press: magenta flash then invoke onClick
511
+ // Guard: don't fire onClick when the completion modal is open — Enter should dismiss it.
512
+ btn.key(['enter', 'space'], () => {
513
+ if (_completionModalOpen) return;
514
+ btn.style.bg = COLORS.btnPress;
515
+ btn.style.fg = 'white';
516
+ screen.render();
517
+ setTimeout(() => {
518
+ btn.style.bg = bg;
519
+ btn.style.fg = textColor;
520
+ screen.render();
521
+ onClick();
522
+ }, 150);
523
+ });
524
+ btn.on('click', () => btn.press());
525
+ return btn;
526
+ }
527
+
528
+ const _editBtn = _createInstallBtn('Edit', '#1565c0', _doEdit);
529
+ const _acceptBtn = _createInstallBtn('✓ Accept & Install', COLORS.btnDefault, _doAccept);
530
+
531
+ // Edit sits inline with the intro text row; Accept & Install is below
532
+ _editBtn.top = 8; _editBtn.left = 36;
533
+ _acceptBtn.top = 13; _acceptBtn.left = 4;
534
+
535
+ // ↓/↑ navigate between Edit and Accept & Install
536
+ // Note: Tab is NOT used here — 'tab' is registered globally by navigation.js (cycles tabs)
537
+ _editBtn.key(['down', 'right'], () => { _acceptBtn.focus(); screen.render(); });
538
+ _acceptBtn.key(['up', 'left'], () => { _editBtn.focus(); screen.render(); });
539
+
540
+ // -------------------------------------------------------------------------
541
+ // Screen 1 buttonsBegin (cyan) and Exit (grey)
542
+
543
+ const _s1BeginBtn = _createInstallBtn('▶ Begin', '#00838f', () => {
544
+ _screen++;
545
+ _showCurrentScreen();
546
+ });
547
+ const _s1ExitBtn = _createInstallBtn('✗ Exit', '#546e7a', () => {
548
+ box.hide();
549
+ screen.render();
550
+ if (typeof focusMainTabBar === 'function') focusMainTabBar();
551
+ });
552
+
553
+ _s1BeginBtn.top = 5; _s1BeginBtn.left = 4;
554
+ _s1ExitBtn.top = 5; _s1ExitBtn.left = 20;
555
+
556
+ // ←/→ horizontal and ↑/↓ vertical — both navigate between the two buttons
557
+ _s1BeginBtn.key(['right', 'down'], () => { _s1ExitBtn.focus(); screen.render(); });
558
+ _s1ExitBtn.key(['right', 'down'], () => { _s1BeginBtn.focus(); screen.render(); });
559
+ _s1ExitBtn.key(['left', 'up', 'S-tab'], () => { _s1BeginBtn.focus(); screen.render(); });
560
+ _s1BeginBtn.key(['left', 'up', 'S-tab'], () => { _s1ExitBtn.focus(); screen.render(); });
561
+
562
+ // -------------------------------------------------------------------------
563
+ // Screen 2 buttonContinue (shown after deps check passes)
564
+
565
+ const _s2ContinueBtn = _createInstallBtn('Continue →', '#1565c0', () => {
566
+ _screen++;
567
+ _showCurrentScreen();
568
+ });
569
+ _s2ContinueBtn.top = 12; _s2ContinueBtn.left = 4;
570
+ // → also advances without the flash delay
571
+ _s2ContinueBtn.key(['right'], () => { _screen++; _showCurrentScreen(); });
572
+
573
+ // Screen 3: no Continue button — Enter/→ on the list confirms selection and advances
574
+
575
+ // -------------------------------------------------------------------------
576
+ // Screen 5 button — OK (summary page only, config already saved on screen 4)
577
+
578
+ const _s5OkBtn = _createInstallBtn('✓ OK Done', '#1565c0', () => {
579
+ _dismissCompletionModal();
580
+ });
581
+ _s5OkBtn.bottom = 3; _s5OkBtn.left = 4; // bottom-anchored: sits above hintLine (bottom:2)
582
+
583
+ // -------------------------------------------------------------------------
584
+ // Screen renderers
585
+
586
+ const _HDR = (emoji, label) =>
587
+ `{${COLORS.sectionHdr}-fg}${emoji} ${label} ${'─'.repeat(100)}{/${COLORS.sectionHdr}-fg}`;
588
+
589
+ function _renderScreen1() {
590
+ contentBox.setContent(_c([
591
+ _HDR('🔧', 'Setup Wizard'),
592
+ '',
593
+ ` {${COLORS.noticeFg}-fg}TTS for AI assistants with personality.{/${COLORS.noticeFg}-fg}`,
594
+ '',
595
+ '', // ← [▶ Begin] [✗ Exit] buttons here (box row 5)
596
+ ]));
597
+ hintLine.setContent(' Screen 1/5: Welcome | [←/→] Navigate | [Enter] Begin | [Esc] Exit');
598
+ _s1BeginBtn.focus();
599
+ screen.render();
600
+ }
601
+
602
+ async function _renderScreen2() {
603
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
604
+ let frameIdx = 0;
605
+ _checking = true;
606
+ _s2ContinueBtn.hide(); // hidden during spinner
607
+
608
+ contentBox.setContent(_c([
609
+ _HDR('🔍', 'Dependency Check'),
610
+ '',
611
+ ` {${COLORS.noticeFg}-fg}${frames[0]} Checking dependencies...{/${COLORS.noticeFg}-fg}`,
612
+ ]));
613
+ hintLine.setContent(' Screen 2/5: Dependencies | [←] Back | [Enter] Next');
614
+ screen.render();
615
+
616
+ const spinInterval = setInterval(() => {
617
+ frameIdx = (frameIdx + 1) % frames.length;
618
+ contentBox.setContent(_c([
619
+ _HDR('🔍', 'Dependency Check'),
620
+ '',
621
+ ` {${COLORS.noticeFg}-fg}${frames[frameIdx]} Checking dependencies...{/${COLORS.noticeFg}-fg}`,
622
+ ]));
623
+ screen.render();
624
+ }, 100);
625
+
626
+ try {
627
+ _deps = await _checkDependenciesAsync();
628
+ } finally {
629
+ clearInterval(spinInterval);
630
+ _checking = false;
631
+ }
632
+
633
+ const ok = () => `{${COLORS.successFg}-fg}✅ Installed{/${COLORS.successFg}-fg}`;
634
+ const bad = () => `{${COLORS.errorFg}-fg}❌ Not found{/${COLORS.errorFg}-fg}`;
635
+
636
+ const ttsOk = _deps.piper || _deps.soprano;
637
+ contentBox.setContent(_c([
638
+ _HDR('🔍', 'Dependency Check'),
639
+ '',
640
+ ` {${COLORS.noticeFg}-fg}${'Dependency'.padEnd(14)}Status{/${COLORS.noticeFg}-fg}`,
641
+ ` {${COLORS.noticeFg}-fg}${'─'.repeat(78)}{/${COLORS.noticeFg}-fg}`,
642
+ ` {${COLORS.labelFg}-fg}${'Node.js'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.node ? ok() : bad()}`,
643
+ ` {${COLORS.labelFg}-fg}${'npm'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.npm ? ok() : bad()}`,
644
+ ` {${COLORS.labelFg}-fg}${'Piper TTS'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.piper ? ok() : bad()}`,
645
+ ` {${COLORS.labelFg}-fg}${'Soprano TTS'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.soprano ? ok() : bad()}`,
646
+ ` {${COLORS.labelFg}-fg}${'ffmpeg'.padEnd(14)}{/${COLORS.labelFg}-fg}${_deps.ffmpeg ? ok() : `{${COLORS.errorFg}-fg}⚠ Not found (needed for background music){/${COLORS.errorFg}-fg}`}`,
647
+ '',
648
+ ttsOk
649
+ ? ` {${COLORS.successFg}-fg}✅ TTS Providers Detected{/${COLORS.successFg}-fg}`
650
+ : ` {${COLORS.errorFg}-fg}⚠ No TTS provider found. Install Piper or Soprano first.{/${COLORS.errorFg}-fg}`,
651
+ '', // blank separator
652
+ '', // ← [Continue →] button here (box row 12) when TTS detected
653
+ ]));
654
+ if (ttsOk) {
655
+ _s2ContinueBtn.show();
656
+ _s2ContinueBtn.focus();
657
+ }
658
+ screen.render();
659
+ }
660
+
661
+ function _renderScreen3() {
662
+ const providers = [];
663
+ if (_deps?.piper) providers.push('piper');
664
+ if (_deps?.soprano) providers.push('soprano');
665
+
666
+ if (providers.length === 0) providers.push('piper'); // fallback
667
+ if (!_selectedProvider) _selectedProvider = providers[0];
668
+
669
+ // Pad items to 96 visible chars so they fully overwrite any stale cells from Screen 2.
670
+ // Selected row uses cyan bg + black text (matches button focus standard).
671
+ const items = providers.map(p =>
672
+ p === _selectedProvider
673
+ ? `{#00e5ff-bg}{#000000-fg}{bold} ● ${p.padEnd(92)}{/bold}{/#000000-fg}{/#00e5ff-bg}`
674
+ : `{${COLORS.labelFg}-fg} ${p.padEnd(93)}{/${COLORS.labelFg}-fg}`
675
+ );
676
+
677
+ // Pad item list to 3 entries so the Continue button sits at a fixed row
678
+ // and all stale lines from Screen 2 (which has ~10 lines) are overwritten.
679
+ const paddedItems = [...items];
680
+ while (paddedItems.length < 3) paddedItems.push(` ${''.padEnd(93)}`);
681
+
682
+ // Append trailing blank rows (space-padded) so blessed rewrites every cell that
683
+ // screen 2 used. Two screen.render() calls in the same tick are batched, so the
684
+ // intermediate "clear" render never fires — trailing spaces here fix that in one pass.
685
+ const _blank = ' '.repeat(120);
686
+ const _trail = Array(12).fill(_blank);
687
+ contentBox.setContent(_c([
688
+ _HDR('🎤', 'Provider Selection'),
689
+ '',
690
+ ` {${COLORS.noticeFg}-fg}${'Available TTS providers:'.padEnd(94)}{/${COLORS.noticeFg}-fg}`,
691
+ '',
692
+ ...paddedItems.map(i => ` ${i}`),
693
+ ..._trail,
694
+ ]));
695
+ hintLine.setContent(' Screen 3/5: Provider | [←] Back | [↑↓] Choose | [Enter/→] Confirm & Continue');
696
+ box.focus();
697
+ screen.render();
698
+ }
699
+
700
+ function _renderScreen4() {
701
+ const provider = _selectedProvider ?? 'piper';
702
+ const intro = _introText || '';
703
+ const folderName = getIntroDefault(process.cwd()) || 'AgentVibes';
704
+ const example = `${folderName}: Here`;
705
+ const voiceId = providerService?.getActiveVoiceId?.() ?? 'en_US-amy-medium';
706
+
707
+ contentBox.setContent(_c([
708
+ _HDR('🎤', 'Provider & Voice'),
709
+ '',
710
+ ` {${COLORS.labelFg}-fg}${'Provider:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${provider}{/${COLORS.valueFg}-fg}`,
711
+ ` {${COLORS.labelFg}-fg}${'Voice:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${voiceId}{/${COLORS.valueFg}-fg} {${COLORS.noticeFg}-fg}(after installation, you can change in Settings){/${COLORS.noticeFg}-fg}`,
712
+ '',
713
+ _HDR('✍️', 'Intro Text'),
714
+ '',
715
+ ` {${COLORS.labelFg}-fg}${'Intro text:'.padEnd(14)}{/${COLORS.labelFg}-fg}{${COLORS.valueFg}-fg}${intro || '(none)'}{/${COLORS.valueFg}-fg}`,
716
+ // ↑ [Edit] button rendered inline at box row 8, left=36
717
+ '',
718
+ ` {${COLORS.noticeFg}-fg}Example:{/${COLORS.noticeFg}-fg} {${COLORS.valueFg}-fg}"${example}"{/${COLORS.valueFg}-fg}`,
719
+ '',
720
+ '',
721
+ '', // [✓ Accept & Install] button rendered as real widget here (box row 13)
722
+ ]));
723
+ hintLine.setContent(' Screen 4/5: Config | [Esc] Back | [E] Edit | [↓] Accept & Install');
724
+ _acceptBtn.focus();
725
+ screen.render();
726
+ }
727
+
728
+ function _renderScreen5() {
729
+ const header = _installError
730
+ ? _HDR('❌', 'Installation Failed')
731
+ : _installComplete
732
+ ? _HDR('✅', 'Installation Complete')
733
+ : _HDR('⚙️', 'Installing AgentVibes...');
734
+
735
+ const hint = (_installComplete || _installError)
736
+ ? ' Screen 5/5: Complete | [Enter] OK — Done'
737
+ : ' Screen 5/5: Installing... | Please wait';
738
+
739
+ // Show last 18 log lines so content fits in the box
740
+ const MAX_LINES = 18;
741
+ const visibleLog = _installLog.length > MAX_LINES
742
+ ? _installLog.slice(-MAX_LINES)
743
+ : _installLog;
744
+
745
+ contentBox.setContent(_c([
746
+ header,
747
+ '',
748
+ ...visibleLog,
749
+ ]));
750
+ hintLine.setContent(hint);
751
+ screen.render();
752
+ }
753
+
754
+ function _showInstallNotice(message) {
755
+ const width = Math.max(28, message.length + 6);
756
+ const notice = blessed.box({
757
+ parent: screen,
758
+ top: 'center',
759
+ left: 'center',
760
+ width,
761
+ height: 3,
762
+ border: { type: 'line' },
763
+ tags: true,
764
+ content: `{center}${message}{/center}`,
765
+ style: {
766
+ fg: '#e3f2fd',
767
+ bg: COLORS.contentBg,
768
+ border: { fg: 'bright-cyan' },
769
+ },
770
+ });
771
+ screen.render();
772
+ setTimeout(() => { try { notice.destroy(); screen.render(); } catch {} }, 2500);
773
+ }
774
+
775
+ function _dismissCompletionModal() {
776
+ if (_completionModalBox) {
777
+ _completionModalBox.destroy();
778
+ _completionModalBox = null;
779
+ }
780
+ _completionModalOpen = false;
781
+ _screen = 1;
782
+ box.hide();
783
+ _showInstallNotice('Installation Complete Settings Saved');
784
+ screen.render();
785
+ navigationService?.switchTab('settings');
786
+ }
787
+
788
+ function _showCurrentScreen() {
789
+ // Show Screen 1 buttons only on screen 1
790
+ if (_screen === 1) {
791
+ _s1BeginBtn.show(); _s1ExitBtn.show();
792
+ } else {
793
+ _s1BeginBtn.hide(); _s1ExitBtn.hide();
794
+ }
795
+
796
+ // Screen 2 continue button: hidden on other screens; _renderScreen2 manages show/focus
797
+ if (_screen !== 2) _s2ContinueBtn.hide();
798
+
799
+ // Screen 5 OK button: hidden during active install, shown by _runInstall() on completion
800
+ if (_screen === 5 && (_installComplete || _installError)) {
801
+ _s5OkBtn.show();
802
+ } else {
803
+ _s5OkBtn.hide();
804
+ }
805
+
806
+ // Show Screen 4 action buttons only on screen 4
807
+ if (_screen === 4) {
808
+ _editBtn.show(); _acceptBtn.show();
809
+ } else {
810
+ _editBtn.hide(); _acceptBtn.hide();
811
+ }
812
+
813
+ if (_screen !== _lastScreen) {
814
+ // Nuclear clear: force-invalidate every olines cell so blessed's diff renderer
815
+ // actually writes blanks to the terminal (blessed skips cells it thinks are
816
+ // unchanged setting attr=-1 is impossible for any real cell so draw() is
817
+ // forced to physically rewrite every character).
818
+ try {
819
+ for (let r = 0; r < screen.height; r++) {
820
+ const orow = screen.olines?.[r];
821
+ if (!orow) continue;
822
+ for (let c = 0; c < screen.width; c++) {
823
+ if (orow[c]) orow[c][0] = -1;
824
+ }
825
+ }
826
+ // Row 2 (header bottom) never becomes dirty on its own — force it so
827
+ // draw() writes headerBg+spaces and overwrites any ghost terminal content.
828
+ if (screen.lines?.[2]) screen.lines[2].dirty = true;
829
+ } catch {}
830
+
831
+ const _clearLine = ' '.repeat(150);
832
+ const _clearPage = Array(25).fill(_clearLine).join('\n');
833
+ contentBox.setContent(_clearPage);
834
+ hintLine.setContent(_clearLine);
835
+ screen.render();
836
+
837
+ const targetScreen = _screen;
838
+ _lastScreen = _screen;
839
+ // 50 ms delay: enough for the terminal to display the blank frame before
840
+ // the new screen content overwrites it. setTimeout(0) is too fast —
841
+ // both renders land in the same display frame.
842
+ setTimeout(() => {
843
+ if (_screen !== targetScreen) return;
844
+ switch (_screen) {
845
+ case 1: _renderScreen1(); break;
846
+ case 2: _renderScreen2(); break;
847
+ case 3: _renderScreen3(); break;
848
+ case 4: _renderScreen4(); break;
849
+ case 5: _renderScreen5(); break;
850
+ }
851
+ }, 50);
852
+ return;
853
+ }
854
+ switch (_screen) {
855
+ case 1: _renderScreen1(); break;
856
+ case 2: _renderScreen2(); break;
857
+ case 3: _renderScreen3(); break;
858
+ case 4: _renderScreen4(); break;
859
+ case 5: _renderScreen5(); break;
860
+ }
861
+ }
862
+
863
+ // -------------------------------------------------------------------------
864
+ // Navigation
865
+
866
+ // Use screen.key() instead of box.key() so handlers fire regardless of which
867
+ // blessed element currently holds focus. Guard with `box.hidden` so they are
868
+ // no-ops when another tab is active.
869
+
870
+ screen.key(['enter'], () => {
871
+ if (box.hidden || _checking) return;
872
+ if (_completionModalOpen) { _dismissCompletionModal(); return; } // always first
873
+ if (_screen === 1) return; // Screen 1: Enter handled by Begin/Exit buttons
874
+ if (_screen === 2) return; // Screen 2: Enter handled by Continue button
875
+ if (_screen === 4) return; // Screen 4: Enter handled by the focused button
876
+ if (_screen === 5) return; // Screen 5: Enter handled by OK button
877
+ if (_screen < 5) {
878
+ _screen++;
879
+ _showCurrentScreen();
880
+ }
881
+ });
882
+
883
+ screen.key(['escape'], () => {
884
+ if (box.hidden || _checking) return;
885
+ if (_completionModalOpen) { _dismissCompletionModal(); return; }
886
+ if (_screen > 1) {
887
+ _screen--;
888
+ _showCurrentScreen();
889
+ } else {
890
+ box.hide();
891
+ screen.render();
892
+ // Defer so the escape keypress event finishes propagating before focus changes.
893
+ // Calling focusMainTabBar() synchronously here would set focus to the tab bar
894
+ // item mid-event, causing its own key(['escape']) handler to fire in the same
895
+ // emission and call onFocus() → re-focus a button inside the now-hidden box.
896
+ if (typeof focusMainTabBar === 'function') setTimeout(() => focusMainTabBar(), 0);
897
+ }
898
+ });
899
+
900
+ screen.key(['up'], () => {
901
+ if (box.hidden) return;
902
+ if (_screen === 3 && _deps) {
903
+ const providers = [];
904
+ if (_deps.piper) providers.push('piper');
905
+ if (_deps.soprano) providers.push('soprano');
906
+ const idx = providers.indexOf(_selectedProvider ?? providers[0]);
907
+ _selectedProvider = providers[Math.max(0, idx - 1)];
908
+ _renderScreen3();
909
+ }
910
+ });
911
+
912
+ // Left arrow = go back (same logic as Escape)
913
+ // Screen 4: left arrow is handled by button ←/→ navigation; use Escape to go back
914
+ screen.key(['left'], () => {
915
+ if (box.hidden || _checking) return;
916
+ if (_screen === 4) return;
917
+ if (_screen > 1) {
918
+ _screen--;
919
+ _showCurrentScreen();
920
+ }
921
+ });
922
+
923
+ // Right arrow = go forward (same logic as Enter, without save/finish side-effects)
924
+ // Screen 1: right arrow handled by button ←/→ navigation
925
+ screen.key(['right'], () => {
926
+ if (box.hidden || _checking) return;
927
+ if (_screen === 1) return;
928
+ if (_screen === 2) return; // Screen 2: → handled by Continue button
929
+ if (_screen === 3) { _screen++; _showCurrentScreen(); return; } // → confirms provider and advances
930
+ if (_screen === 4) return; // Screen 4: → handled by button nav
931
+ if (_screen === 5) return; // Screen 5: → handled by button nav
932
+ });
933
+
934
+ // Down arrow: Screen 3 provider nav; Screen 1 ↓ is handled by button key handlers
935
+ // (tab bar's el.key(['down']) onFocus() focuses Begin, then button ↓ → Exit)
936
+ screen.key(['down'], () => {
937
+ if (box.hidden) return;
938
+ if (_screen === 3 && _deps) {
939
+ const providers = [];
940
+ if (_deps.piper) providers.push('piper');
941
+ if (_deps.soprano) providers.push('soprano');
942
+ const idx = providers.indexOf(_selectedProvider ?? providers[0]);
943
+ _selectedProvider = providers[Math.min(providers.length - 1, idx + 1)];
944
+ _renderScreen3();
945
+ }
946
+ });
947
+
948
+ // [E] on Screen 4: edit intro text inline
949
+ screen.key(['e', 'E'], () => { _doEdit(); });
950
+
951
+ // [O] anywhere: dismiss the completion modal (OK button)
952
+ screen.key(['o', 'O'], () => {
953
+ if (box.hidden || !_completionModalOpen) return;
954
+ _dismissCompletionModal();
955
+ });
956
+
957
+ // -------------------------------------------------------------------------
958
+ // Tab Component Contract
959
+
960
+ return {
961
+ box,
962
+
963
+ show() {
964
+ _screen = 1;
965
+ _screen5Announced = false;
966
+ _installLog = [];
967
+ _installRunning = false;
968
+ _installComplete = false;
969
+ _installError = null;
970
+ _lastSpinnerIdx = -1;
971
+ if (_completionModalBox) { _completionModalBox.destroy(); _completionModalBox = null; }
972
+ _completionModalOpen = false;
973
+ box.show();
974
+ _showCurrentScreen();
975
+ screen.render();
976
+ },
977
+
978
+ hide() {
979
+ box.hide();
980
+ screen.render();
981
+ },
982
+
983
+ onFocus() {
984
+ // Focus the active interactive element, not just the box container
985
+ if (_screen === 1) {
986
+ _s1BeginBtn.focus();
987
+ } else if (_screen === 4) {
988
+ _editBtn.focus();
989
+ } else if (_screen === 5 && (_installComplete || _installError)) {
990
+ _s5OkBtn.focus();
991
+ } else {
992
+ box.focus();
993
+ }
994
+ screen.render();
995
+ },
996
+
997
+ onBlur() {},
998
+
999
+ getFooterText() {
1000
+ return FOOTER_TEXT;
1001
+ },
1002
+
1003
+ getFooterColor() {
1004
+ return COLORS.footerBg;
1005
+ },
1006
+ };
1007
+ }