agentvibes 4.2.0 → 4.4.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 (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 +208 -84
  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 +5895 -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 +143 -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,824 +1,824 @@
1
- /**
2
- * AgentVibes TUI Console — App Scaffold
3
- * Story 6.1: Blessed.js App Scaffold & Screen Setup
4
- * Story 6.2: Tab Bar & Global Keyboard Navigation
5
- *
6
- * Foundational screen: header, tab bar, content area, footer, navigation.
7
- * Stories 6.3+ build on top of this scaffold.
8
- */
9
-
10
- import blessed from 'blessed';
11
- import path from 'node:path';
12
- import { readFileSync } from 'node:fs';
13
- import { fileURLToPath } from 'node:url';
14
- import { spawnSync, execFileSync } from 'node:child_process';
15
- import { NavigationService, TAB_ORDER } from '../services/navigation-service.js';
16
- import { setupNavigation } from './navigation.js';
17
- import { createPlaceholderTab, TAB_DISPLAY_LABELS, TAB_SHORTCUT_KEYS } from './tabs/placeholder-tab.js';
18
- import { FOOTER_CONFIG, DEFAULT_FOOTER_COLOR } from './footer-config.js';
19
- import { createModalOverlay } from './modals/modal-overlay.js';
20
- import { BRAND_PINK } from './brand-colors.js';
21
- import { createSettingsTab } from './tabs/settings-tab.js';
22
- import { createVoicesTab } from './tabs/voices-tab.js';
23
- import { createMusicTab } from './tabs/music-tab.js';
24
- import { createInstallTab } from './tabs/install-tab.js';
25
- import { createHelpTab } from './tabs/help-tab.js';
26
- import { createReadmeTab } from './tabs/readme-tab.js';
27
- import { createReceiverTab } from './tabs/receiver-tab.js';
28
- import { createAgentsTab } from './tabs/agents-tab.js';
29
- import { ConfigService } from '../services/config-service.js';
30
- import { ProviderService } from '../services/provider-service.js';
31
-
32
- const _dir = path.dirname(fileURLToPath(import.meta.url));
33
- const _pkg = JSON.parse(readFileSync(path.join(_dir, '../../package.json'), 'utf8'));
34
- const APP_VERSION = _pkg.version;
35
-
36
- // Brand colours — consistent with UX design plan and architecture.md
37
- const COLORS = {
38
- headerBg: '#1a237e', // Dark navy — header and footer
39
- tabBarBg: '#263238', // Dark blue-gray — tab bar
40
- contentBg: '#0a0e1a', // Near-black — content area background
41
- focusCyan: '#00e5ff', // Cyan focus state
42
- activeTab: '#3949ab', // Blue — active tab highlight
43
- textWhite: 'white',
44
- textDim: '#90a4ae', // Gray — placeholder / dim text
45
- };
46
-
47
- export class AgentVibesConsole {
48
- constructor(opts = {}) {
49
- // opts.startTab is stored for use by story 6.5 (command routing)
50
- this.startTab = opts.startTab ?? 'settings';
51
- this._testMode = opts._testMode ?? false;
52
-
53
- this.screen = null;
54
- this.tabBarBox = null; // Exposed for story 6.2 (tab bar implementation)
55
- this.contentArea = null; // Exposed for story 6.2 (tab mounting)
56
- this.navigationService = null; // Exposed for story 6.3+ (context footer, etc.)
57
- this.tabs = {}; // { settings: BlessedBox, voices: BlessedBox, ... }
58
- this.contextFooterBox = null; // Exposed for story 6.3 (color-coded context footer)
59
- this.modalOverlay = null; // Exposed for story 6.4 (reusable modal overlay)
60
- }
61
-
62
- /**
63
- * Initialise all screen components and register event handlers.
64
- * Returns `this` so callers can access the instance after launch.
65
- */
66
- async init() {
67
- this._createScreen();
68
-
69
- // In test mode, skip blessed widget creation (widgets require an active screen)
70
- if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
71
- // Provide stub objects so callers can verify properties exist
72
- this.tabBarBox = {};
73
- this.contentArea = {};
74
- this.contextFooterBox = {};
75
- this.navigationService = new NavigationService(this.startTab);
76
- this.tabs = {};
77
- return this;
78
- }
79
-
80
- this._createHeader();
81
- this._createTabBar();
82
- this._createContentArea();
83
- this._createContextFooter();
84
- this._createFooter();
85
- this._registerHandlers();
86
- this._createPlaceholderTabs();
87
- this._initNavigation(); // must run first so navigationService is live in services
88
- this._createRealTabs();
89
- this._createModalOverlay();
90
- // Initial render: draws header/tab-bar/footer into blessed's line buffer
91
- // before forceActivate fires. Without this, lines[0..1] (header rows) are
92
- // uninitialized when clearRegion() runs inside onSwitch, so blessed's draw()
93
- // skips them (not dirty) and the header is invisible on first load.
94
- this.screen.render();
95
- // Force-activate the start tab: switchTab() no-ops when _activeTab is already
96
- // set by the NavigationService constructor, so forceActivate() bypasses the
97
- // same-tab guard to fire onSwitch callbacks and render the initial UI state.
98
- this.navigationService.forceActivate(this.startTab);
99
- this.screen.render();
100
- // Place cursor on the start tab's header item (purple = focused).
101
- // User presses ↓/Enter to descend into content, or ←/→ to pick a different tab.
102
- const startTabItem = this._tabItems?.[this.startTab];
103
- if (startTabItem) {
104
- startTabItem.focus();
105
- this.screen.render();
106
- }
107
- return this;
108
- }
109
-
110
- // ---------------------------------------------------------------------------
111
- // Private: Screen
112
-
113
- _createScreen() {
114
- // Screen options stored as property so tests can verify correct configuration
115
- // without needing to intercept the blessed.screen() call (ESM mock limitation).
116
- this._screenOptions = {
117
- smartCSR: true,
118
- mouse: true,
119
- fullUnicode: true,
120
- title: `AgentVibes v${APP_VERSION} TUI Console`,
121
- };
122
-
123
- // When AGENTVIBES_TEST_MODE is set, use a lightweight stub instead of a
124
- // real blessed screen. This prevents the event loop from blocking tests.
125
- if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
126
- this.screen = {
127
- append: () => {},
128
- key: () => {},
129
- on: () => {},
130
- render: () => {},
131
- destroy: () => {},
132
- };
133
- return;
134
- }
135
-
136
- this.screen = blessed.screen(this._screenOptions);
137
-
138
- // Reflow on terminal resize
139
- this.screen.on('resize', () => this.screen.render());
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // Private: Fixed header (rows 0-2)
144
-
145
- _createHeader() {
146
- const cwd = process.cwd();
147
-
148
- this.headerBox = blessed.box({
149
- top: 0,
150
- left: 0,
151
- width: '100%',
152
- height: 3,
153
- tags: false,
154
- wrap: false,
155
- scrollable: false,
156
- style: { fg: COLORS.textWhite, bg: COLORS.headerBg },
157
- });
158
- this.screen.append(this.headerBox);
159
-
160
- // Row 0: main title — explicit child avoids valign:middle redraw artifacts
161
- blessed.text({
162
- parent: this.headerBox,
163
- top: 0,
164
- left: 2,
165
- shrink: true,
166
- tags: true,
167
- content: `{bright-cyan-fg}Agent{/bright-cyan-fg}{${BRAND_PINK}-fg}Vibes{/${BRAND_PINK}-fg} {#90a4ae-fg}v{/#90a4ae-fg}{#ffff00-fg}${APP_VERSION}{/#ffff00-fg} \u2502 \uD83D\uDCC1 ${cwd}`,
168
- style: { bg: COLORS.headerBg },
169
- });
170
-
171
- // Row 1: subtitle
172
- blessed.text({
173
- parent: this.headerBox,
174
- top: 1,
175
- left: 2,
176
- shrink: true,
177
- tags: true,
178
- content: `{green-fg}Customization Tool{/green-fg}`,
179
- style: { bg: COLORS.headerBg },
180
- });
181
-
182
- // Row 1: Quit shortcut — left-anchored after "Customization Tool" (18 chars at left:2)
183
- blessed.text({
184
- parent: this.headerBox,
185
- top: 1,
186
- left: 22,
187
- shrink: true,
188
- tags: true,
189
- content: `{#ef9a9a-fg}[Q] Quit{/#ef9a9a-fg}`,
190
- style: { bg: COLORS.headerBg },
191
- });
192
-
193
- // Row 1 (right): Active settings summary [provider][voice][effects][music]
194
- this._headerStatusText = blessed.text({
195
- parent: this.headerBox,
196
- top: 1,
197
- right: 2,
198
- shrink: true,
199
- tags: true,
200
- content: '',
201
- style: { bg: COLORS.headerBg },
202
- });
203
-
204
- // Right-aligned: git remote + branch when available, else AgentVibes repo link
205
- let topRightContent = `{${BRAND_PINK}-fg}github.com/preibisch/agentvibes{/${BRAND_PINK}-fg}`;
206
- try {
207
- const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],
208
- { encoding: 'utf8', timeout: 2000, cwd });
209
- const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'],
210
- { encoding: 'utf8', timeout: 2000, cwd });
211
- if (branchResult.status === 0 && remoteResult.status === 0) {
212
- const branch = branchResult.stdout.trim();
213
- // Normalise SSH (git@github.com:user/repo.git) → HTTPS, strip .git suffix
214
- const repoUrl = remoteResult.stdout.trim()
215
- .replace(/^git@([^:]+):/, 'https://$1/')
216
- .replace(/\.git$/, '');
217
- // Strip protocol for compact display: https://github.com/… → github.com/…
218
- const displayUrl = repoUrl.replace(/^https?:\/\//, '');
219
- topRightContent = `{${BRAND_PINK}-fg}${displayUrl}{/${BRAND_PINK}-fg} {#90a4ae-fg}\u2502{/#90a4ae-fg} {#90a4ae-fg}\u2387{/#90a4ae-fg} {bright-white-fg}${branch}{/bright-white-fg}`;
220
- }
221
- } catch {}
222
- blessed.text({
223
- parent: this.headerBox,
224
- top: 0,
225
- right: 2,
226
- shrink: true,
227
- tags: true,
228
- content: topRightContent,
229
- style: { bg: COLORS.headerBg },
230
- });
231
- }
232
-
233
- // ---------------------------------------------------------------------------
234
- // Private: Update header status summary [provider][voice][effects][music]
235
-
236
- _updateHeaderStatus() {
237
- if (!this._headerStatusText || !this._providerService || !this._configService) return;
238
- try {
239
- const provider = this._providerService.getActiveProvider() ?? 'piper';
240
- const rawVoice = this._providerService.getActiveVoiceId() ?? '';
241
- // Show speaker name for multi-speaker voices
242
- const msSep = rawVoice.indexOf('::');
243
- const voiceName = msSep >= 0 ? rawVoice.slice(msSep + 2) : rawVoice;
244
- // Truncate long names
245
- const voiceShort = voiceName.length > 18 ? voiceName.slice(0, 17) + '…' : voiceName;
246
-
247
- const cfg = this._configService.getConfig();
248
- const effects = cfg.effects ?? {};
249
- const reverb = effects.reverbPreset ?? 'light';
250
-
251
- const music = cfg.backgroundMusic ?? cfg.music ?? {};
252
- const musicEnabled = music.enabled ?? false;
253
- const trackFile = music.track ?? '';
254
- // Strip prefixes and suffixes for compact display
255
- const trackShort = trackFile
256
- .replace(/\.mp3$/i, '')
257
- .replace(/^agent_vibes_/i, '')
258
- .replace(/^agentvibes_/i, '')
259
- .replace(/_loop$/i, '')
260
- .replace(/_v\d+$/i, '')
261
- .replace(/_/g, ' ')
262
- .replace(/\b\w/g, c => c.toUpperCase())
263
- .slice(0, 16) || 'None';
264
-
265
- this._headerStatusText.setContent(
266
- `{#90a4ae-fg}[{/#90a4ae-fg}{bright-cyan-fg}${provider}{/bright-cyan-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
267
- `{#90a4ae-fg}[{/#90a4ae-fg}{green-fg}${voiceShort}{/green-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
268
- `{#90a4ae-fg}[{/#90a4ae-fg}{yellow-fg}${reverb}{/yellow-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
269
- `{#90a4ae-fg}[{/#90a4ae-fg}{${musicEnabled ? 'magenta' : 'bright-black'}-fg}${musicEnabled ? trackShort : 'off'}{/${musicEnabled ? 'magenta' : 'bright-black'}-fg}{#90a4ae-fg}]{/#90a4ae-fg}`
270
- );
271
- } catch { /* non-fatal */ }
272
- }
273
-
274
- // ---------------------------------------------------------------------------
275
- // Private: Tab bar (row 3) — individual child boxes, no tag parsing.
276
- // Each tab is a separate blessed.box. Active tab highlighted via style update.
277
-
278
- _createTabBar() {
279
- // Background strip — screen child so blessed uses absolute coordinates directly.
280
- // Tab items are ALSO screen children (not children of tabBarBox) to avoid the
281
- // WSL/Windows Terminal parent-relative positioning bug that renders them 1 row
282
- // too high (at row 2 instead of row 3), producing a ghost duplicate tab bar.
283
- this.tabBarBox = blessed.box({
284
- parent: this.screen,
285
- top: 3,
286
- left: 0,
287
- width: '100%',
288
- height: 1,
289
- style: { bg: COLORS.tabBarBg },
290
- });
291
-
292
- // One box per tab — direct screen children at absolute top:3. No tag parsing, no wrapping.
293
- this._tabItems = {};
294
- let xOffset = 1;
295
- for (const id of TAB_ORDER) {
296
- const label = TAB_DISPLAY_LABELS[id];
297
- const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
298
- const text = ` [${shortcutKey}] ${label} `;
299
- const el = blessed.box({
300
- parent: this.screen,
301
- top: 3,
302
- left: xOffset,
303
- width: text.length,
304
- height: 1,
305
- content: text,
306
- tags: false,
307
- keys: true,
308
- focusable: true,
309
- style: { fg: COLORS.focusCyan, bg: COLORS.tabBarBg },
310
- });
311
- this._tabItems[id] = el;
312
- xOffset += text.length + 1; // 1-space gap between tabs
313
- }
314
-
315
- // Right-aligned Quit item — direct screen child at absolute top:3
316
- const _quitText = ' [Q] Quit ';
317
- const _quitBase = _quitText;
318
- const _quitBlock = _quitText.slice(0, -1) + '█';
319
- let _quitInterval = null;
320
- this._quitItem = blessed.box({
321
- parent: this.screen,
322
- top: 3,
323
- right: 1,
324
- width: _quitText.length,
325
- height: 1,
326
- content: _quitText,
327
- tags: false,
328
- keys: true,
329
- focusable: true,
330
- style: { fg: '#ef9a9a', bg: COLORS.tabBarBg }, // soft red — matches header quit hint
331
- });
332
- this._quitItem.on('focus', () => {
333
- this._quitItem.style.fg = 'white';
334
- this._quitItem.style.bg = '#9c27b0';
335
- this._quitItem.setContent(_quitBlock);
336
- this.screen.render();
337
- if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
338
- _quitInterval = setInterval(() => {
339
- const on = this._quitItem.content === _quitBlock;
340
- this._quitItem.setContent(on ? _quitBase : _quitBlock);
341
- this.screen.render();
342
- }, 500);
343
- });
344
- this._quitItem.on('blur', () => {
345
- if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
346
- this._quitItem.setContent(_quitBase);
347
- this._quitItem.style.fg = '#ef9a9a';
348
- this._quitItem.style.bg = COLORS.tabBarBg;
349
- this.screen.render();
350
- });
351
- this._quitItem.key(['enter', 'space', 'q', 'Q'], () => {
352
- this.screen.destroy();
353
- process.exit(0);
354
- });
355
-
356
- // Keyboard navigation on the main tab items
357
- const tabIds = TAB_ORDER;
358
- for (let i = 0; i < tabIds.length; i++) {
359
- const el = this._tabItems[tabIds[i]];
360
-
361
- // Blinking block cursor: replace trailing space with █, toggle at 500ms
362
- const _tabLabel = TAB_DISPLAY_LABELS[tabIds[i]];
363
- const _baseContent = ` [${_tabLabel[0]}] ${_tabLabel} `;
364
- const _blockContent = _baseContent.slice(0, -1) + '█';
365
- let _cursorInterval = null;
366
- let _cursorOn = false;
367
-
368
- el.on('focus', () => {
369
- el.style.fg = 'white';
370
- el.style.bg = '#9c27b0'; // purple — cursor on this tab item
371
- _cursorOn = true;
372
- el.setContent(_blockContent);
373
- this.screen.render();
374
- if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
375
- _cursorInterval = setInterval(() => {
376
- _cursorOn = !_cursorOn;
377
- el.setContent(_cursorOn ? _blockContent : _baseContent);
378
- this.screen.render();
379
- }, 500);
380
- });
381
- el.on('blur', () => {
382
- if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
383
- el.setContent(_baseContent);
384
- // navigationService set up after _createTabBar, but blur fires lazily — safe
385
- this._updateTabBar(this.navigationService?.getActiveTab() ?? tabIds[0]);
386
- this.screen.render();
387
- });
388
-
389
- el.key(['left'], () => {
390
- if (i === 0) {
391
- this._quitItem?.focus(); // wrap: first tab ← → Quit
392
- } else {
393
- this._tabItems[tabIds[i - 1]].focus();
394
- }
395
- });
396
- el.key(['right'], () => {
397
- if (i === tabIds.length - 1) {
398
- this._quitItem?.focus(); // wrap: last tab → → Quit
399
- } else {
400
- this._tabItems[tabIds[i + 1]].focus();
401
- }
402
- });
403
- el.key(['enter', 'space'], () => {
404
- this.navigationService.switchTab(tabIds[i]);
405
- });
406
- // ↓ or Escape returns focus to the active tab's content
407
- el.key(['down', 'escape'], () => {
408
- const activeTab = this.tabs[this.navigationService.getActiveTab()];
409
- if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
410
- });
411
-
412
- // Tab: forward through header items; last item → Quit item
413
- el.key(['tab'], () => {
414
- if (i < tabIds.length - 1) {
415
- this._tabItems[tabIds[i + 1]].focus();
416
- } else {
417
- this._quitItem?.focus();
418
- }
419
- });
420
- // S-tab: backward through header items; first item → active tab's last bottom button
421
- el.key(['S-tab'], () => {
422
- if (i > 0) {
423
- this._tabItems[tabIds[i - 1]].focus();
424
- } else {
425
- const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
426
- if (activeTab && typeof activeTab.focusLastBottomRow === 'function') {
427
- activeTab.focusLastBottomRow();
428
- } else {
429
- this._quitItem?.focus();
430
- }
431
- }
432
- });
433
- }
434
-
435
- // Wire Quit item ← → and Tab/S-tab into the header navigation cycle
436
- this._quitItem.key(['left'], () => {
437
- this._tabItems[tabIds[tabIds.length - 1]]?.focus(); // ← Quit → last tab (Help)
438
- });
439
- this._quitItem.key(['right'], () => {
440
- this._tabItems[tabIds[0]]?.focus(); // → Quit → first tab (Install), wrap
441
- });
442
- this._quitItem.key(['tab'], () => {
443
- const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
444
- if (activeTab && typeof activeTab.focusBottomRow === 'function') {
445
- activeTab.focusBottomRow();
446
- } else {
447
- this._tabItems[tabIds[0]]?.focus();
448
- }
449
- });
450
- this._quitItem.key(['S-tab'], () => {
451
- this._tabItems[tabIds[tabIds.length - 1]]?.focus();
452
- });
453
- this._quitItem.key(['down', 'escape'], () => {
454
- const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
455
- if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
456
- });
457
- }
458
-
459
- // ---------------------------------------------------------------------------
460
- // Private: Update tab bar — set active item style, reset all others.
461
-
462
- _updateTabBar(activeTabId) {
463
- if (!this._tabItems) return; // guard: not initialized in test mode
464
- for (const [id, el] of Object.entries(this._tabItems)) {
465
- if (id === activeTabId) {
466
- el.style.fg = 'white';
467
- el.style.bg = '#0288d1'; // bright light blue — matches sub-tab active color
468
- el.style.bold = true;
469
- } else {
470
- el.style.fg = COLORS.focusCyan;
471
- el.style.bg = COLORS.tabBarBg;
472
- el.style.bold = false;
473
- }
474
- }
475
- }
476
-
477
- // ---------------------------------------------------------------------------
478
- // Private: Render tab bar content string for given active tab
479
- // (kept as a pure helper for unit tests; real rendering uses _updateTabBar)
480
-
481
- _renderTabBarContent(activeTabId) {
482
- return TAB_ORDER.map(id => {
483
- const label = TAB_DISPLAY_LABELS[id];
484
- const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
485
- if (id === activeTabId) {
486
- return `{bold}{white-fg}[${shortcutKey}] ${label}{/white-fg}{/bold}`;
487
- }
488
- return `{#82b1ff-fg}[${shortcutKey}] ${label}{/#82b1ff-fg}`;
489
- }).join(' ');
490
- }
491
-
492
- // ---------------------------------------------------------------------------
493
- // Private: Content area (rows 4..N-1) — tab components mount here
494
-
495
- _createContentArea() {
496
- // bottom: 2 reserves 2 rows at the bottom: context footer (story 6.3) + GitHub footer
497
- this.contentArea = blessed.box({
498
- top: 4,
499
- left: 0,
500
- width: '100%',
501
- bottom: 2,
502
- border: { type: 'line' },
503
- style: {
504
- fg: COLORS.textWhite,
505
- bg: COLORS.contentBg,
506
- border: { fg: COLORS.activeTab },
507
- },
508
- });
509
-
510
- this.screen.append(this.contentArea);
511
- }
512
-
513
- // ---------------------------------------------------------------------------
514
- // Private: Color-coded context footer (story 6.3) — above GitHub footer
515
-
516
- _createContextFooter() {
517
- this.contextFooterBox = blessed.box({
518
- bottom: 1,
519
- left: 0,
520
- width: '100%',
521
- height: 1,
522
- content: '',
523
- tags: true,
524
- style: {
525
- fg: COLORS.textWhite,
526
- bg: DEFAULT_FOOTER_COLOR,
527
- },
528
- });
529
-
530
- this.screen.append(this.contextFooterBox);
531
- }
532
-
533
- // ---------------------------------------------------------------------------
534
- // Private: Update context footer color + text for the given tab
535
-
536
- _updateContextFooter(tabId) {
537
- // Real tab components (Tab Component Contract) provide their own footer getters.
538
- // Placeholder tabs fall back to FOOTER_CONFIG.
539
- const tab = this.tabs[tabId];
540
- if (tab && typeof tab.getFooterColor === 'function') {
541
- this.contextFooterBox.style.bg = tab.getFooterColor();
542
- this.contextFooterBox.setContent(tab.getFooterText());
543
- } else {
544
- const config = FOOTER_CONFIG[tabId] ?? { color: DEFAULT_FOOTER_COLOR, text: '' };
545
- this.contextFooterBox.style.bg = config.color;
546
- this.contextFooterBox.setContent(config.text);
547
- }
548
- }
549
-
550
- // ---------------------------------------------------------------------------
551
- // Private: GitHub star footer (row N — fixed bottom)
552
-
553
- _createFooter() {
554
- // Detect installed providers inline (same logic as ProviderService)
555
- const _has = (bin) => {
556
- try { execFileSync('which', [bin], { stdio: 'ignore', timeout: 2000 }); return true; }
557
- catch { return false; }
558
- };
559
- const detected = {
560
- piper: _has('piper'),
561
- soprano: _has('soprano'),
562
- sapi: process.platform === 'win32',
563
- macos: process.platform === 'darwin' && _has('say'),
564
- };
565
-
566
- // Build provider status badges: ● Name (green if detected, grey if not)
567
- const on = (label) => `{green-fg}●{/green-fg} ${label}`;
568
- const off = (label) => `{#546e7a-fg}● ${label}{/#546e7a-fg}`;
569
- const badges = [
570
- detected.piper ? on('Piper') : off('Piper'),
571
- detected.soprano ? on('Soprano') : off('Soprano'),
572
- detected.sapi ? on('Windows SAPI') : off('Windows SAPI'),
573
- detected.macos ? on('Mac Say') : off('Mac Say'),
574
- ].join(' ');
575
-
576
- const footer = blessed.box({
577
- bottom: 0,
578
- left: 0,
579
- width: '100%',
580
- height: 1,
581
- tags: true,
582
- content: ` ${badges} {#ffff00-fg}⭐ Love AgentVibes? Give us a star!{/#ffff00-fg} github.com/preibisch/agentvibes`,
583
- style: {
584
- fg: COLORS.textWhite,
585
- bg: COLORS.headerBg,
586
- },
587
- });
588
-
589
- this.screen.append(footer);
590
- }
591
-
592
- // ---------------------------------------------------------------------------
593
- // Private: Create placeholder tab content boxes (story 6.2)
594
- // Each epic 7-11 story will replace its placeholder with real content.
595
-
596
- _createPlaceholderTabs() {
597
- for (const tabId of TAB_ORDER) {
598
- const label = TAB_DISPLAY_LABELS[tabId];
599
- this.tabs[tabId] = createPlaceholderTab(this.contentArea, label);
600
- }
601
- }
602
-
603
- // ---------------------------------------------------------------------------
604
- // Private: Replace placeholder tabs with real implementations (story 7.1+)
605
-
606
- _createRealTabs() {
607
- // Destroy the settings placeholder (real tab mounts directly to screen, not contentArea)
608
- const placeholder = this.tabs['settings'];
609
- if (placeholder && typeof placeholder.destroy === 'function') {
610
- placeholder.destroy();
611
- }
612
-
613
- const configService = new ConfigService();
614
- const providerService = new ProviderService(configService);
615
- this._configService = configService;
616
- this._providerService = providerService;
617
- const services = {
618
- configService,
619
- providerService,
620
- navigationService: this.navigationService,
621
- updateHeaderStatus: () => this._updateHeaderStatus(),
622
- focusMainTabBar: () => {
623
- const id = this.navigationService.getActiveTab();
624
- const item = this._tabItems?.[id];
625
- if (item) item.focus();
626
- },
627
- focusFirstHeaderItem: () => {
628
- this._tabItems?.[TAB_ORDER[0]]?.focus();
629
- },
630
- focusLastHeaderItem: () => {
631
- this._tabItems?.[TAB_ORDER[TAB_ORDER.length - 1]]?.focus();
632
- },
633
- };
634
- this.tabs['settings'] = createSettingsTab(this.screen, services);
635
-
636
- // Destroy voices placeholder and mount real voices tab
637
- const voicesPlaceholder = this.tabs['voices'];
638
- if (voicesPlaceholder && typeof voicesPlaceholder.destroy === 'function') {
639
- voicesPlaceholder.destroy();
640
- }
641
- this.tabs['voices'] = createVoicesTab(this.screen, services);
642
-
643
- // Destroy music placeholder and mount real music tab
644
- const musicPlaceholder = this.tabs['music'];
645
- if (musicPlaceholder && typeof musicPlaceholder.destroy === 'function') {
646
- musicPlaceholder.destroy();
647
- }
648
- this.tabs['music'] = createMusicTab(this.screen, services);
649
-
650
- // Destroy install placeholder and mount real install wizard
651
- const installPlaceholder = this.tabs['install'];
652
- if (installPlaceholder && typeof installPlaceholder.destroy === 'function') {
653
- installPlaceholder.destroy();
654
- }
655
- this.tabs['install'] = createInstallTab(this.screen, services);
656
-
657
- // Destroy help/readme placeholders and mount real tabs
658
- const helpPlaceholder = this.tabs['help'];
659
- if (helpPlaceholder && typeof helpPlaceholder.destroy === 'function') {
660
- helpPlaceholder.destroy();
661
- }
662
- this.tabs['help'] = createHelpTab(this.screen, services);
663
-
664
- // Destroy agents placeholder and mount real agents tab
665
- const agentsPlaceholder = this.tabs['agents'];
666
- if (agentsPlaceholder && typeof agentsPlaceholder.destroy === 'function') {
667
- agentsPlaceholder.destroy();
668
- }
669
- this.tabs['agents'] = createAgentsTab(this.screen, services);
670
-
671
- // Destroy receiver placeholder and mount real receiver tab
672
- const receiverPlaceholder = this.tabs['receiver'];
673
- if (receiverPlaceholder && typeof receiverPlaceholder.destroy === 'function') {
674
- receiverPlaceholder.destroy();
675
- }
676
- this.tabs['receiver'] = createReceiverTab(this.screen, services);
677
-
678
- const readmePlaceholder = this.tabs['readme'];
679
- if (readmePlaceholder && typeof readmePlaceholder.destroy === 'function') {
680
- readmePlaceholder.destroy();
681
- }
682
- this.tabs['readme'] = createReadmeTab(this.screen, services);
683
- }
684
-
685
- // ---------------------------------------------------------------------------
686
- // Private: Initialise navigation service and wire key handlers (story 6.2)
687
-
688
- _initNavigation() {
689
- this.navigationService = new NavigationService(this.startTab);
690
-
691
- // On every tab switch: update tab bar, context footer, and show/hide tab boxes
692
- this.navigationService.onSwitch(tabId => {
693
- const activeTab = this.tabs[tabId];
694
-
695
- // Render-suppression tab switch:
696
- // All show/hide calls and UI updates happen inside this window so zero
697
- // intermediate frames are sent to the terminal. A single clean render
698
- // fires at the end, when exactly one tab is visible.
699
- const _origRender = this.screen.render.bind(this.screen);
700
- this.screen.render = () => {};
701
-
702
- try {
703
- // Nuclear clear: wipe the content area (row 4+) to remove stale cell content
704
- // from the previous tab. Start at row 4 — header (0-2) and tab bar (3) are
705
- // static widgets that don't need clearing; wiping them causes the double
706
- // tab bar artifact (row 2 of header shows tab bar ghost from prior render).
707
- // blessed's render loop never resets the `lines` buffer before rendering
708
- // (see: blessed/lib/widgets/screen.js line 733, commented-out clear).
709
- this.screen.clearRegion(0, this.screen.cols, 4, this.screen.rows - 2);
710
-
711
- // Force-invalidate olines for the entire visible area (rows 0..rows-3).
712
- // Includes header rows 0-1 so the branded header is always redrawn on
713
- // tab switches — prevents corruption from persisting across tabs.
714
- // Row 2 (header bottom), row 3 (tab bar) and content rows accumulate
715
- // ghost rendering artifacts — draw() skips them when lines==olines even
716
- // though the terminal still shows stale chars from earlier renders.
717
- // Setting attr=-1 is impossible for any real cell, so draw() is forced
718
- // to physically rewrite every cell on the next render call.
719
- for (let r = 0; r < this.screen.rows - 2; r++) {
720
- const orow = this.screen.olines[r];
721
- if (!orow) continue;
722
- for (let c = 0; c < this.screen.cols; c++) {
723
- if (orow[c]) orow[c][0] = -1; // impossible attr — forces draw() rewrite
724
- }
725
- orow.dirty = true;
726
- }
727
-
728
- // Row 2 (header bottom) is never dirty after draw 1 — its content (headerBg+
729
- // spaces) never changes so element.render() never marks it dirty. The olines
730
- // invalidation above sets olines[2][c][0]=-1, but draw() only compares cells
731
- // when lines[r].dirty is true; a false dirty flag skips the entire row without
732
- // ever consulting olines. Force-mark it dirty so draw() emits the explicit
733
- // cup(3,1)+headerBg+spaces sequence and overwrites any ghost terminal content.
734
- if (this.screen.lines?.[2]) this.screen.lines[2].dirty = true;
735
-
736
- // Update tab bar, footer, and header status inside suppression — no intermediate render.
737
- this._updateTabBar(tabId);
738
- this._updateContextFooter(tabId);
739
- this._updateHeaderStatus();
740
-
741
- // Hide all inactive tabs via their proper hide() method so side-effects
742
- // (e.g. voice preview kill, previewLine clear) run correctly.
743
- for (const [id, tab] of Object.entries(this.tabs)) {
744
- if (id !== tabId) {
745
- if (typeof tab.hide === 'function') tab.hide();
746
- else tab.hidden = true;
747
- if (typeof tab.onBlur === 'function') tab.onBlur();
748
- }
749
- }
750
-
751
- // Show the active tab via show() so refreshDisplay() populates labels.
752
- if (activeTab) {
753
- if (typeof activeTab.show === 'function') {
754
- activeTab.show();
755
- // setFront() moves the box to the end of screen.children so it
756
- // paints last (on top) — belt-and-suspenders against any z-order issue.
757
- if (activeTab.box && typeof activeTab.box.setFront === 'function') {
758
- activeTab.box.setFront();
759
- }
760
- // Move any screen-level overlay widgets (e.g. junction chars) to front
761
- // AFTER box.setFront() so they render on top of the box border.
762
- if (typeof activeTab.moveOverlaysToFront === 'function') {
763
- activeTab.moveOverlaysToFront();
764
- }
765
- } else {
766
- activeTab.hidden = false;
767
- }
768
- }
769
- } finally {
770
- // Always restore render even if something throws.
771
- this.screen.render = _origRender;
772
- }
773
-
774
- if (activeTab && typeof activeTab.onFocus === 'function') {
775
- activeTab.onFocus();
776
- }
777
- this.screen.render();
778
- });
779
-
780
- // Register global key bindings (S/V/M/A/R/H/I/T/Esc)
781
- setupNavigation(this.screen, this.navigationService);
782
- }
783
-
784
- // ---------------------------------------------------------------------------
785
- // Private: Modal overlay (story 6.4) — reusable base for all selector modals
786
-
787
- _createModalOverlay() {
788
- this.modalOverlay = createModalOverlay(this.screen, this.navigationService);
789
-
790
- // Esc key closes the modal overlay if one is open.
791
- // Blessed.js allows multiple handlers for the same key — all fire.
792
- // The navigation.js Esc handler calls nav.closeModal() (state only).
793
- // This second handler hides the overlay+container widgets.
794
- this.screen.key(['escape'], () => {
795
- if (this.modalOverlay) this.modalOverlay.close();
796
- });
797
- }
798
-
799
- // ---------------------------------------------------------------------------
800
- // Private: Global keyboard handlers
801
-
802
- _registerHandlers() {
803
- // Q or Ctrl+C → clean exit (no zombie processes)
804
- this.screen.key(['q', 'Q', 'C-c'], () => {
805
- this.screen.destroy();
806
- process.exit(0);
807
- });
808
- }
809
- }
810
-
811
- /**
812
- * Launch the AgentVibes TUI console.
813
- *
814
- * @param {object} opts
815
- * @param {string} [opts.startTab='settings'] - Which tab to show on launch.
816
- * Used by story 6.5 (command routing). Values: 'settings' | 'install' | 'voices' | 'music'
817
- * @param {boolean} [opts._testMode=false] - Internal: skip render in test environments.
818
- * @returns {Promise<AgentVibesConsole>}
819
- */
820
- export async function launchConsole(opts = {}) {
821
- const app = new AgentVibesConsole(opts);
822
- await app.init();
823
- return app;
824
- }
1
+ /**
2
+ * AgentVibes TUI Console — App Scaffold
3
+ * Story 6.1: Blessed.js App Scaffold & Screen Setup
4
+ * Story 6.2: Tab Bar & Global Keyboard Navigation
5
+ *
6
+ * Foundational screen: header, tab bar, content area, footer, navigation.
7
+ * Stories 6.3+ build on top of this scaffold.
8
+ */
9
+
10
+ import blessed from 'blessed';
11
+ import path from 'node:path';
12
+ import { readFileSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { spawnSync, execFileSync } from 'node:child_process';
15
+ import { NavigationService, TAB_ORDER } from '../services/navigation-service.js';
16
+ import { setupNavigation } from './navigation.js';
17
+ import { createPlaceholderTab, TAB_DISPLAY_LABELS, TAB_SHORTCUT_KEYS } from './tabs/placeholder-tab.js';
18
+ import { FOOTER_CONFIG, DEFAULT_FOOTER_COLOR } from './footer-config.js';
19
+ import { createModalOverlay } from './modals/modal-overlay.js';
20
+ import { BRAND_PINK } from './brand-colors.js';
21
+ import { createSettingsTab } from './tabs/settings-tab.js';
22
+ import { createVoicesTab } from './tabs/voices-tab.js';
23
+ import { createMusicTab } from './tabs/music-tab.js';
24
+ import { createInstallTab } from './tabs/install-tab.js';
25
+ import { createHelpTab } from './tabs/help-tab.js';
26
+ import { createReadmeTab } from './tabs/readme-tab.js';
27
+ import { createReceiverTab } from './tabs/receiver-tab.js';
28
+ import { createAgentsTab } from './tabs/agents-tab.js';
29
+ import { ConfigService } from '../services/config-service.js';
30
+ import { ProviderService } from '../services/provider-service.js';
31
+
32
+ const _dir = path.dirname(fileURLToPath(import.meta.url));
33
+ const _pkg = JSON.parse(readFileSync(path.join(_dir, '../../package.json'), 'utf8'));
34
+ const APP_VERSION = _pkg.version;
35
+
36
+ // Brand colours — consistent with UX design plan and architecture.md
37
+ const COLORS = {
38
+ headerBg: '#1a237e', // Dark navy — header and footer
39
+ tabBarBg: '#263238', // Dark blue-gray — tab bar
40
+ contentBg: '#0a0e1a', // Near-black — content area background
41
+ focusCyan: 'bright-cyan', // Matches "Agent" in header title
42
+ activeTab: '#3949ab', // Blue — active tab highlight
43
+ textWhite: 'white',
44
+ textDim: '#90a4ae', // Gray — placeholder / dim text
45
+ };
46
+
47
+ export class AgentVibesConsole {
48
+ constructor(opts = {}) {
49
+ // opts.startTab is stored for use by story 6.5 (command routing)
50
+ this.startTab = opts.startTab ?? 'settings';
51
+ this._testMode = opts._testMode ?? false;
52
+
53
+ this.screen = null;
54
+ this.tabBarBox = null; // Exposed for story 6.2 (tab bar implementation)
55
+ this.contentArea = null; // Exposed for story 6.2 (tab mounting)
56
+ this.navigationService = null; // Exposed for story 6.3+ (context footer, etc.)
57
+ this.tabs = {}; // { settings: BlessedBox, voices: BlessedBox, ... }
58
+ this.contextFooterBox = null; // Exposed for story 6.3 (color-coded context footer)
59
+ this.modalOverlay = null; // Exposed for story 6.4 (reusable modal overlay)
60
+ }
61
+
62
+ /**
63
+ * Initialise all screen components and register event handlers.
64
+ * Returns `this` so callers can access the instance after launch.
65
+ */
66
+ async init() {
67
+ this._createScreen();
68
+
69
+ // In test mode, skip blessed widget creation (widgets require an active screen)
70
+ if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
71
+ // Provide stub objects so callers can verify properties exist
72
+ this.tabBarBox = {};
73
+ this.contentArea = {};
74
+ this.contextFooterBox = {};
75
+ this.navigationService = new NavigationService(this.startTab);
76
+ this.tabs = {};
77
+ return this;
78
+ }
79
+
80
+ this._createHeader();
81
+ this._createTabBar();
82
+ this._createContentArea();
83
+ this._createContextFooter();
84
+ this._createFooter();
85
+ this._registerHandlers();
86
+ this._createPlaceholderTabs();
87
+ this._initNavigation(); // must run first so navigationService is live in services
88
+ this._createRealTabs();
89
+ this._createModalOverlay();
90
+ // Initial render: draws header/tab-bar/footer into blessed's line buffer
91
+ // before forceActivate fires. Without this, lines[0..1] (header rows) are
92
+ // uninitialized when clearRegion() runs inside onSwitch, so blessed's draw()
93
+ // skips them (not dirty) and the header is invisible on first load.
94
+ this.screen.render();
95
+ // Force-activate the start tab: switchTab() no-ops when _activeTab is already
96
+ // set by the NavigationService constructor, so forceActivate() bypasses the
97
+ // same-tab guard to fire onSwitch callbacks and render the initial UI state.
98
+ this.navigationService.forceActivate(this.startTab);
99
+ this.screen.render();
100
+ // Place cursor on the start tab's header item (purple = focused).
101
+ // User presses ↓/Enter to descend into content, or ←/→ to pick a different tab.
102
+ const startTabItem = this._tabItems?.[this.startTab];
103
+ if (startTabItem) {
104
+ startTabItem.focus();
105
+ this.screen.render();
106
+ }
107
+ return this;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Private: Screen
112
+
113
+ _createScreen() {
114
+ // Screen options stored as property so tests can verify correct configuration
115
+ // without needing to intercept the blessed.screen() call (ESM mock limitation).
116
+ this._screenOptions = {
117
+ smartCSR: true,
118
+ mouse: true,
119
+ fullUnicode: true,
120
+ title: `AgentVibes v${APP_VERSION} TUI Console`,
121
+ };
122
+
123
+ // When AGENTVIBES_TEST_MODE is set, use a lightweight stub instead of a
124
+ // real blessed screen. This prevents the event loop from blocking tests.
125
+ if (process.env.AGENTVIBES_TEST_MODE === 'true' || this._testMode) {
126
+ this.screen = {
127
+ append: () => {},
128
+ key: () => {},
129
+ on: () => {},
130
+ render: () => {},
131
+ destroy: () => {},
132
+ };
133
+ return;
134
+ }
135
+
136
+ this.screen = blessed.screen(this._screenOptions);
137
+
138
+ // Reflow on terminal resize
139
+ this.screen.on('resize', () => this.screen.render());
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Private: Fixed header (rows 0-2)
144
+
145
+ _createHeader() {
146
+ const cwd = process.cwd();
147
+
148
+ this.headerBox = blessed.box({
149
+ top: 0,
150
+ left: 0,
151
+ width: '100%',
152
+ height: 3,
153
+ tags: false,
154
+ wrap: false,
155
+ scrollable: false,
156
+ style: { fg: COLORS.textWhite, bg: COLORS.headerBg },
157
+ });
158
+ this.screen.append(this.headerBox);
159
+
160
+ // Row 0: main title — explicit child avoids valign:middle redraw artifacts
161
+ blessed.text({
162
+ parent: this.headerBox,
163
+ top: 0,
164
+ left: 2,
165
+ shrink: true,
166
+ tags: true,
167
+ content: `{bright-cyan-fg}Agent{/bright-cyan-fg}{${BRAND_PINK}-fg}Vibes{/${BRAND_PINK}-fg} {#90a4ae-fg}v{/#90a4ae-fg}{#ffff00-fg}${APP_VERSION}{/#ffff00-fg} \u2502 \uD83D\uDCC1 ${cwd}`,
168
+ style: { bg: COLORS.headerBg },
169
+ });
170
+
171
+ // Row 1: subtitle
172
+ blessed.text({
173
+ parent: this.headerBox,
174
+ top: 1,
175
+ left: 2,
176
+ shrink: true,
177
+ tags: true,
178
+ content: `{green-fg}Customization Tool{/green-fg}`,
179
+ style: { bg: COLORS.headerBg },
180
+ });
181
+
182
+ // Row 1: Quit shortcut — left-anchored after "Customization Tool" (18 chars at left:2)
183
+ blessed.text({
184
+ parent: this.headerBox,
185
+ top: 1,
186
+ left: 22,
187
+ shrink: true,
188
+ tags: true,
189
+ content: `{#ef9a9a-fg}[Q] Quit{/#ef9a9a-fg}`,
190
+ style: { bg: COLORS.headerBg },
191
+ });
192
+
193
+ // Row 1 (right): Active settings summary [provider][voice][effects][music]
194
+ this._headerStatusText = blessed.text({
195
+ parent: this.headerBox,
196
+ top: 1,
197
+ right: 2,
198
+ shrink: true,
199
+ tags: true,
200
+ content: '',
201
+ style: { bg: COLORS.headerBg },
202
+ });
203
+
204
+ // Right-aligned: git remote + branch when available, else AgentVibes repo link
205
+ let topRightContent = `{${BRAND_PINK}-fg}github.com/preibisch/agentvibes{/${BRAND_PINK}-fg}`;
206
+ try {
207
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],
208
+ { encoding: 'utf8', timeout: 2000, cwd });
209
+ const remoteResult = spawnSync('git', ['remote', 'get-url', 'origin'],
210
+ { encoding: 'utf8', timeout: 2000, cwd });
211
+ if (branchResult.status === 0 && remoteResult.status === 0) {
212
+ const branch = branchResult.stdout.trim();
213
+ // Normalise SSH (git@github.com:user/repo.git) → HTTPS, strip .git suffix
214
+ const repoUrl = remoteResult.stdout.trim()
215
+ .replace(/^git@([^:]+):/, 'https://$1/')
216
+ .replace(/\.git$/, '');
217
+ // Strip protocol for compact display: https://github.com/… → github.com/…
218
+ const displayUrl = repoUrl.replace(/^https?:\/\//, '');
219
+ topRightContent = `{${BRAND_PINK}-fg}${displayUrl}{/${BRAND_PINK}-fg} {#90a4ae-fg}\u2502{/#90a4ae-fg} {#90a4ae-fg}\u2387{/#90a4ae-fg} {bright-white-fg}${branch}{/bright-white-fg}`;
220
+ }
221
+ } catch {}
222
+ blessed.text({
223
+ parent: this.headerBox,
224
+ top: 0,
225
+ right: 2,
226
+ shrink: true,
227
+ tags: true,
228
+ content: topRightContent,
229
+ style: { bg: COLORS.headerBg },
230
+ });
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Private: Update header status summary [provider][voice][effects][music]
235
+
236
+ _updateHeaderStatus() {
237
+ if (!this._headerStatusText || !this._providerService || !this._configService) return;
238
+ try {
239
+ const provider = this._providerService.getActiveProvider() ?? 'piper';
240
+ const rawVoice = this._providerService.getActiveVoiceId() ?? '';
241
+ // Show speaker name for multi-speaker voices
242
+ const msSep = rawVoice.indexOf('::');
243
+ const voiceName = msSep >= 0 ? rawVoice.slice(msSep + 2) : rawVoice;
244
+ // Truncate long names
245
+ const voiceShort = voiceName.length > 18 ? voiceName.slice(0, 17) + '…' : voiceName;
246
+
247
+ const cfg = this._configService.getConfig();
248
+ const effects = cfg.effects ?? {};
249
+ const reverb = effects.reverbPreset ?? 'light';
250
+
251
+ const music = cfg.backgroundMusic ?? cfg.music ?? {};
252
+ const musicEnabled = music.enabled ?? false;
253
+ const trackFile = music.track ?? '';
254
+ // Strip prefixes and suffixes for compact display
255
+ const trackShort = trackFile
256
+ .replace(/\.mp3$/i, '')
257
+ .replace(/^agent_vibes_/i, '')
258
+ .replace(/^agentvibes_/i, '')
259
+ .replace(/_loop$/i, '')
260
+ .replace(/_v\d+$/i, '')
261
+ .replace(/_/g, ' ')
262
+ .replace(/\b\w/g, c => c.toUpperCase())
263
+ .slice(0, 16) || 'None';
264
+
265
+ this._headerStatusText.setContent(
266
+ `{#90a4ae-fg}[{/#90a4ae-fg}{bright-cyan-fg}${provider}{/bright-cyan-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
267
+ `{#90a4ae-fg}[{/#90a4ae-fg}{green-fg}${voiceShort}{/green-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
268
+ `{#90a4ae-fg}[{/#90a4ae-fg}{yellow-fg}${reverb}{/yellow-fg}{#90a4ae-fg}]{/#90a4ae-fg} ` +
269
+ `{#90a4ae-fg}[{/#90a4ae-fg}{${musicEnabled ? 'magenta' : 'bright-black'}-fg}${musicEnabled ? trackShort : 'off'}{/${musicEnabled ? 'magenta' : 'bright-black'}-fg}{#90a4ae-fg}]{/#90a4ae-fg}`
270
+ );
271
+ } catch { /* non-fatal */ }
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Private: Tab bar (row 3) — individual child boxes, no tag parsing.
276
+ // Each tab is a separate blessed.box. Active tab highlighted via style update.
277
+
278
+ _createTabBar() {
279
+ // Background strip — screen child so blessed uses absolute coordinates directly.
280
+ // Tab items are ALSO screen children (not children of tabBarBox) to avoid the
281
+ // WSL/Windows Terminal parent-relative positioning bug that renders them 1 row
282
+ // too high (at row 2 instead of row 3), producing a ghost duplicate tab bar.
283
+ this.tabBarBox = blessed.box({
284
+ parent: this.screen,
285
+ top: 3,
286
+ left: 0,
287
+ width: '100%',
288
+ height: 1,
289
+ style: { bg: COLORS.tabBarBg },
290
+ });
291
+
292
+ // One box per tab — direct screen children at absolute top:3. No tag parsing, no wrapping.
293
+ this._tabItems = {};
294
+ let xOffset = 1;
295
+ for (const id of TAB_ORDER) {
296
+ const label = TAB_DISPLAY_LABELS[id];
297
+ const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
298
+ const text = ` [${shortcutKey}] ${label} `;
299
+ const el = blessed.box({
300
+ parent: this.screen,
301
+ top: 3,
302
+ left: xOffset,
303
+ width: text.length,
304
+ height: 1,
305
+ content: text,
306
+ tags: false,
307
+ keys: true,
308
+ focusable: true,
309
+ style: { fg: COLORS.focusCyan, bg: COLORS.tabBarBg },
310
+ });
311
+ this._tabItems[id] = el;
312
+ xOffset += text.length + 1; // 1-space gap between tabs
313
+ }
314
+
315
+ // Right-aligned Quit item — direct screen child at absolute top:3
316
+ const _quitText = ' [Q] Quit ';
317
+ const _quitBase = _quitText;
318
+ const _quitBlock = _quitText.slice(0, -1) + '█';
319
+ let _quitInterval = null;
320
+ this._quitItem = blessed.box({
321
+ parent: this.screen,
322
+ top: 3,
323
+ right: 1,
324
+ width: _quitText.length,
325
+ height: 1,
326
+ content: _quitText,
327
+ tags: false,
328
+ keys: true,
329
+ focusable: true,
330
+ style: { fg: '#ef9a9a', bg: COLORS.tabBarBg }, // soft red — matches header quit hint
331
+ });
332
+ this._quitItem.on('focus', () => {
333
+ this._quitItem.style.fg = 'white';
334
+ this._quitItem.style.bg = '#9c27b0';
335
+ this._quitItem.setContent(_quitBlock);
336
+ this.screen.render();
337
+ if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
338
+ _quitInterval = setInterval(() => {
339
+ const on = this._quitItem.content === _quitBlock;
340
+ this._quitItem.setContent(on ? _quitBase : _quitBlock);
341
+ this.screen.render();
342
+ }, 500);
343
+ });
344
+ this._quitItem.on('blur', () => {
345
+ if (_quitInterval) { clearInterval(_quitInterval); _quitInterval = null; }
346
+ this._quitItem.setContent(_quitBase);
347
+ this._quitItem.style.fg = '#ef9a9a';
348
+ this._quitItem.style.bg = COLORS.tabBarBg;
349
+ this.screen.render();
350
+ });
351
+ this._quitItem.key(['enter', 'space', 'q', 'Q'], () => {
352
+ this.screen.destroy();
353
+ process.exit(0);
354
+ });
355
+
356
+ // Keyboard navigation on the main tab items
357
+ const tabIds = TAB_ORDER;
358
+ for (let i = 0; i < tabIds.length; i++) {
359
+ const el = this._tabItems[tabIds[i]];
360
+
361
+ // Blinking block cursor: replace trailing space with █, toggle at 500ms
362
+ const _tabLabel = TAB_DISPLAY_LABELS[tabIds[i]];
363
+ const _baseContent = ` [${_tabLabel[0]}] ${_tabLabel} `;
364
+ const _blockContent = _baseContent.slice(0, -1) + '█';
365
+ let _cursorInterval = null;
366
+ let _cursorOn = false;
367
+
368
+ el.on('focus', () => {
369
+ el.style.fg = 'white';
370
+ el.style.bg = '#9c27b0'; // purple — cursor on this tab item
371
+ _cursorOn = true;
372
+ el.setContent(_blockContent);
373
+ this.screen.render();
374
+ if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
375
+ _cursorInterval = setInterval(() => {
376
+ _cursorOn = !_cursorOn;
377
+ el.setContent(_cursorOn ? _blockContent : _baseContent);
378
+ this.screen.render();
379
+ }, 500);
380
+ });
381
+ el.on('blur', () => {
382
+ if (_cursorInterval) { clearInterval(_cursorInterval); _cursorInterval = null; }
383
+ el.setContent(_baseContent);
384
+ // navigationService set up after _createTabBar, but blur fires lazily — safe
385
+ this._updateTabBar(this.navigationService?.getActiveTab() ?? tabIds[0]);
386
+ this.screen.render();
387
+ });
388
+
389
+ el.key(['left'], () => {
390
+ if (i === 0) {
391
+ this._quitItem?.focus(); // wrap: first tab ← → Quit
392
+ } else {
393
+ this._tabItems[tabIds[i - 1]].focus();
394
+ }
395
+ });
396
+ el.key(['right'], () => {
397
+ if (i === tabIds.length - 1) {
398
+ this._quitItem?.focus(); // wrap: last tab → → Quit
399
+ } else {
400
+ this._tabItems[tabIds[i + 1]].focus();
401
+ }
402
+ });
403
+ el.key(['enter', 'space'], () => {
404
+ this.navigationService.switchTab(tabIds[i]);
405
+ });
406
+ // ↓ or Escape returns focus to the active tab's content
407
+ el.key(['down', 'escape'], () => {
408
+ const activeTab = this.tabs[this.navigationService.getActiveTab()];
409
+ if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
410
+ });
411
+
412
+ // Tab: forward through header items; last item → Quit item
413
+ el.key(['tab'], () => {
414
+ if (i < tabIds.length - 1) {
415
+ this._tabItems[tabIds[i + 1]].focus();
416
+ } else {
417
+ this._quitItem?.focus();
418
+ }
419
+ });
420
+ // S-tab: backward through header items; first item → active tab's last bottom button
421
+ el.key(['S-tab'], () => {
422
+ if (i > 0) {
423
+ this._tabItems[tabIds[i - 1]].focus();
424
+ } else {
425
+ const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
426
+ if (activeTab && typeof activeTab.focusLastBottomRow === 'function') {
427
+ activeTab.focusLastBottomRow();
428
+ } else {
429
+ this._quitItem?.focus();
430
+ }
431
+ }
432
+ });
433
+ }
434
+
435
+ // Wire Quit item ← → and Tab/S-tab into the header navigation cycle
436
+ this._quitItem.key(['left'], () => {
437
+ this._tabItems[tabIds[tabIds.length - 1]]?.focus(); // ← Quit → last tab (Help)
438
+ });
439
+ this._quitItem.key(['right'], () => {
440
+ this._tabItems[tabIds[0]]?.focus(); // → Quit → first tab (Install), wrap
441
+ });
442
+ this._quitItem.key(['tab'], () => {
443
+ const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
444
+ if (activeTab && typeof activeTab.focusBottomRow === 'function') {
445
+ activeTab.focusBottomRow();
446
+ } else {
447
+ this._tabItems[tabIds[0]]?.focus();
448
+ }
449
+ });
450
+ this._quitItem.key(['S-tab'], () => {
451
+ this._tabItems[tabIds[tabIds.length - 1]]?.focus();
452
+ });
453
+ this._quitItem.key(['down', 'escape'], () => {
454
+ const activeTab = this.tabs?.[this.navigationService?.getActiveTab()];
455
+ if (activeTab && typeof activeTab.onFocus === 'function') activeTab.onFocus();
456
+ });
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Private: Update tab bar — set active item style, reset all others.
461
+
462
+ _updateTabBar(activeTabId) {
463
+ if (!this._tabItems) return; // guard: not initialized in test mode
464
+ for (const [id, el] of Object.entries(this._tabItems)) {
465
+ if (id === activeTabId) {
466
+ el.style.fg = 'white';
467
+ el.style.bg = '#0288d1'; // bright light blue — matches sub-tab active color
468
+ el.style.bold = true;
469
+ } else {
470
+ el.style.fg = COLORS.focusCyan;
471
+ el.style.bg = COLORS.tabBarBg;
472
+ el.style.bold = false;
473
+ }
474
+ }
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Private: Render tab bar content string for given active tab
479
+ // (kept as a pure helper for unit tests; real rendering uses _updateTabBar)
480
+
481
+ _renderTabBarContent(activeTabId) {
482
+ return TAB_ORDER.map(id => {
483
+ const label = TAB_DISPLAY_LABELS[id];
484
+ const shortcutKey = TAB_SHORTCUT_KEYS[id] || label[0];
485
+ if (id === activeTabId) {
486
+ return `{bold}{white-fg}[${shortcutKey}] ${label}{/white-fg}{/bold}`;
487
+ }
488
+ return `{bright-cyan-fg}[${shortcutKey}] ${label}{/bright-cyan-fg}`;
489
+ }).join(' ');
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Private: Content area (rows 4..N-1) — tab components mount here
494
+
495
+ _createContentArea() {
496
+ // bottom: 2 reserves 2 rows at the bottom: context footer (story 6.3) + GitHub footer
497
+ this.contentArea = blessed.box({
498
+ top: 4,
499
+ left: 0,
500
+ width: '100%',
501
+ bottom: 2,
502
+ border: { type: 'line' },
503
+ style: {
504
+ fg: COLORS.textWhite,
505
+ bg: COLORS.contentBg,
506
+ border: { fg: COLORS.activeTab },
507
+ },
508
+ });
509
+
510
+ this.screen.append(this.contentArea);
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Private: Color-coded context footer (story 6.3) — above GitHub footer
515
+
516
+ _createContextFooter() {
517
+ this.contextFooterBox = blessed.box({
518
+ bottom: 1,
519
+ left: 0,
520
+ width: '100%',
521
+ height: 1,
522
+ content: '',
523
+ tags: true,
524
+ style: {
525
+ fg: COLORS.textWhite,
526
+ bg: DEFAULT_FOOTER_COLOR,
527
+ },
528
+ });
529
+
530
+ this.screen.append(this.contextFooterBox);
531
+ }
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // Private: Update context footer color + text for the given tab
535
+
536
+ _updateContextFooter(tabId) {
537
+ // Real tab components (Tab Component Contract) provide their own footer getters.
538
+ // Placeholder tabs fall back to FOOTER_CONFIG.
539
+ const tab = this.tabs[tabId];
540
+ if (tab && typeof tab.getFooterColor === 'function') {
541
+ this.contextFooterBox.style.bg = tab.getFooterColor();
542
+ this.contextFooterBox.setContent(tab.getFooterText());
543
+ } else {
544
+ const config = FOOTER_CONFIG[tabId] ?? { color: DEFAULT_FOOTER_COLOR, text: '' };
545
+ this.contextFooterBox.style.bg = config.color;
546
+ this.contextFooterBox.setContent(config.text);
547
+ }
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Private: GitHub star footer (row N — fixed bottom)
552
+
553
+ _createFooter() {
554
+ // Detect installed providers inline (same logic as ProviderService)
555
+ const _has = (bin) => {
556
+ try { execFileSync('which', [bin], { stdio: 'ignore', timeout: 2000 }); return true; }
557
+ catch { return false; }
558
+ };
559
+ const detected = {
560
+ piper: _has('piper'),
561
+ soprano: _has('soprano'),
562
+ sapi: process.platform === 'win32',
563
+ macos: process.platform === 'darwin' && _has('say'),
564
+ };
565
+
566
+ // Build provider status badges: ● Name (green if detected, grey if not)
567
+ const on = (label) => `{green-fg}●{/green-fg} ${label}`;
568
+ const off = (label) => `{#546e7a-fg}● ${label}{/#546e7a-fg}`;
569
+ const badges = [
570
+ detected.piper ? on('Piper') : off('Piper'),
571
+ detected.soprano ? on('Soprano') : off('Soprano'),
572
+ detected.sapi ? on('Windows SAPI') : off('Windows SAPI'),
573
+ detected.macos ? on('Mac Say') : off('Mac Say'),
574
+ ].join(' ');
575
+
576
+ const footer = blessed.box({
577
+ bottom: 0,
578
+ left: 0,
579
+ width: '100%',
580
+ height: 1,
581
+ tags: true,
582
+ content: ` ${badges} {#ffff00-fg}⭐ Love AgentVibes? Give us a star!{/#ffff00-fg} github.com/preibisch/agentvibes`,
583
+ style: {
584
+ fg: COLORS.textWhite,
585
+ bg: COLORS.headerBg,
586
+ },
587
+ });
588
+
589
+ this.screen.append(footer);
590
+ }
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // Private: Create placeholder tab content boxes (story 6.2)
594
+ // Each epic 7-11 story will replace its placeholder with real content.
595
+
596
+ _createPlaceholderTabs() {
597
+ for (const tabId of TAB_ORDER) {
598
+ const label = TAB_DISPLAY_LABELS[tabId];
599
+ this.tabs[tabId] = createPlaceholderTab(this.contentArea, label);
600
+ }
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Private: Replace placeholder tabs with real implementations (story 7.1+)
605
+
606
+ _createRealTabs() {
607
+ // Destroy the settings placeholder (real tab mounts directly to screen, not contentArea)
608
+ const placeholder = this.tabs['settings'];
609
+ if (placeholder && typeof placeholder.destroy === 'function') {
610
+ placeholder.destroy();
611
+ }
612
+
613
+ const configService = new ConfigService();
614
+ const providerService = new ProviderService(configService);
615
+ this._configService = configService;
616
+ this._providerService = providerService;
617
+ const services = {
618
+ configService,
619
+ providerService,
620
+ navigationService: this.navigationService,
621
+ updateHeaderStatus: () => this._updateHeaderStatus(),
622
+ focusMainTabBar: () => {
623
+ const id = this.navigationService.getActiveTab();
624
+ const item = this._tabItems?.[id];
625
+ if (item) item.focus();
626
+ },
627
+ focusFirstHeaderItem: () => {
628
+ this._tabItems?.[TAB_ORDER[0]]?.focus();
629
+ },
630
+ focusLastHeaderItem: () => {
631
+ this._tabItems?.[TAB_ORDER[TAB_ORDER.length - 1]]?.focus();
632
+ },
633
+ };
634
+ this.tabs['settings'] = createSettingsTab(this.screen, services);
635
+
636
+ // Destroy voices placeholder and mount real voices tab
637
+ const voicesPlaceholder = this.tabs['voices'];
638
+ if (voicesPlaceholder && typeof voicesPlaceholder.destroy === 'function') {
639
+ voicesPlaceholder.destroy();
640
+ }
641
+ this.tabs['voices'] = createVoicesTab(this.screen, services);
642
+
643
+ // Destroy music placeholder and mount real music tab
644
+ const musicPlaceholder = this.tabs['music'];
645
+ if (musicPlaceholder && typeof musicPlaceholder.destroy === 'function') {
646
+ musicPlaceholder.destroy();
647
+ }
648
+ this.tabs['music'] = createMusicTab(this.screen, services);
649
+
650
+ // Destroy install placeholder and mount real install wizard
651
+ const installPlaceholder = this.tabs['install'];
652
+ if (installPlaceholder && typeof installPlaceholder.destroy === 'function') {
653
+ installPlaceholder.destroy();
654
+ }
655
+ this.tabs['install'] = createInstallTab(this.screen, services);
656
+
657
+ // Destroy help/readme placeholders and mount real tabs
658
+ const helpPlaceholder = this.tabs['help'];
659
+ if (helpPlaceholder && typeof helpPlaceholder.destroy === 'function') {
660
+ helpPlaceholder.destroy();
661
+ }
662
+ this.tabs['help'] = createHelpTab(this.screen, services);
663
+
664
+ // Destroy agents placeholder and mount real agents tab
665
+ const agentsPlaceholder = this.tabs['agents'];
666
+ if (agentsPlaceholder && typeof agentsPlaceholder.destroy === 'function') {
667
+ agentsPlaceholder.destroy();
668
+ }
669
+ this.tabs['agents'] = createAgentsTab(this.screen, services);
670
+
671
+ // Destroy receiver placeholder and mount real receiver tab
672
+ const receiverPlaceholder = this.tabs['receiver'];
673
+ if (receiverPlaceholder && typeof receiverPlaceholder.destroy === 'function') {
674
+ receiverPlaceholder.destroy();
675
+ }
676
+ this.tabs['receiver'] = createReceiverTab(this.screen, services);
677
+
678
+ const readmePlaceholder = this.tabs['readme'];
679
+ if (readmePlaceholder && typeof readmePlaceholder.destroy === 'function') {
680
+ readmePlaceholder.destroy();
681
+ }
682
+ this.tabs['readme'] = createReadmeTab(this.screen, services);
683
+ }
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // Private: Initialise navigation service and wire key handlers (story 6.2)
687
+
688
+ _initNavigation() {
689
+ this.navigationService = new NavigationService(this.startTab);
690
+
691
+ // On every tab switch: update tab bar, context footer, and show/hide tab boxes
692
+ this.navigationService.onSwitch(tabId => {
693
+ const activeTab = this.tabs[tabId];
694
+
695
+ // Render-suppression tab switch:
696
+ // All show/hide calls and UI updates happen inside this window so zero
697
+ // intermediate frames are sent to the terminal. A single clean render
698
+ // fires at the end, when exactly one tab is visible.
699
+ const _origRender = this.screen.render.bind(this.screen);
700
+ this.screen.render = () => {};
701
+
702
+ try {
703
+ // Nuclear clear: wipe the content area (row 4+) to remove stale cell content
704
+ // from the previous tab. Start at row 4 — header (0-2) and tab bar (3) are
705
+ // static widgets that don't need clearing; wiping them causes the double
706
+ // tab bar artifact (row 2 of header shows tab bar ghost from prior render).
707
+ // blessed's render loop never resets the `lines` buffer before rendering
708
+ // (see: blessed/lib/widgets/screen.js line 733, commented-out clear).
709
+ this.screen.clearRegion(0, this.screen.cols, 4, this.screen.rows - 2);
710
+
711
+ // Force-invalidate olines for the entire visible area (rows 0..rows-3).
712
+ // Includes header rows 0-1 so the branded header is always redrawn on
713
+ // tab switches — prevents corruption from persisting across tabs.
714
+ // Row 2 (header bottom), row 3 (tab bar) and content rows accumulate
715
+ // ghost rendering artifacts — draw() skips them when lines==olines even
716
+ // though the terminal still shows stale chars from earlier renders.
717
+ // Setting attr=-1 is impossible for any real cell, so draw() is forced
718
+ // to physically rewrite every cell on the next render call.
719
+ for (let r = 0; r < this.screen.rows - 2; r++) {
720
+ const orow = this.screen.olines[r];
721
+ if (!orow) continue;
722
+ for (let c = 0; c < this.screen.cols; c++) {
723
+ if (orow[c]) orow[c][0] = -1; // impossible attr — forces draw() rewrite
724
+ }
725
+ orow.dirty = true;
726
+ }
727
+
728
+ // Row 2 (header bottom) is never dirty after draw 1 — its content (headerBg+
729
+ // spaces) never changes so element.render() never marks it dirty. The olines
730
+ // invalidation above sets olines[2][c][0]=-1, but draw() only compares cells
731
+ // when lines[r].dirty is true; a false dirty flag skips the entire row without
732
+ // ever consulting olines. Force-mark it dirty so draw() emits the explicit
733
+ // cup(3,1)+headerBg+spaces sequence and overwrites any ghost terminal content.
734
+ if (this.screen.lines?.[2]) this.screen.lines[2].dirty = true;
735
+
736
+ // Update tab bar, footer, and header status inside suppression — no intermediate render.
737
+ this._updateTabBar(tabId);
738
+ this._updateContextFooter(tabId);
739
+ this._updateHeaderStatus();
740
+
741
+ // Hide all inactive tabs via their proper hide() method so side-effects
742
+ // (e.g. voice preview kill, previewLine clear) run correctly.
743
+ for (const [id, tab] of Object.entries(this.tabs)) {
744
+ if (id !== tabId) {
745
+ if (typeof tab.hide === 'function') tab.hide();
746
+ else tab.hidden = true;
747
+ if (typeof tab.onBlur === 'function') tab.onBlur();
748
+ }
749
+ }
750
+
751
+ // Show the active tab via show() so refreshDisplay() populates labels.
752
+ if (activeTab) {
753
+ if (typeof activeTab.show === 'function') {
754
+ activeTab.show();
755
+ // setFront() moves the box to the end of screen.children so it
756
+ // paints last (on top) — belt-and-suspenders against any z-order issue.
757
+ if (activeTab.box && typeof activeTab.box.setFront === 'function') {
758
+ activeTab.box.setFront();
759
+ }
760
+ // Move any screen-level overlay widgets (e.g. junction chars) to front
761
+ // AFTER box.setFront() so they render on top of the box border.
762
+ if (typeof activeTab.moveOverlaysToFront === 'function') {
763
+ activeTab.moveOverlaysToFront();
764
+ }
765
+ } else {
766
+ activeTab.hidden = false;
767
+ }
768
+ }
769
+ } finally {
770
+ // Always restore render even if something throws.
771
+ this.screen.render = _origRender;
772
+ }
773
+
774
+ if (activeTab && typeof activeTab.onFocus === 'function') {
775
+ activeTab.onFocus();
776
+ }
777
+ this.screen.render();
778
+ });
779
+
780
+ // Register global key bindings (S/V/M/A/R/H/I/T/Esc)
781
+ setupNavigation(this.screen, this.navigationService);
782
+ }
783
+
784
+ // ---------------------------------------------------------------------------
785
+ // Private: Modal overlay (story 6.4) — reusable base for all selector modals
786
+
787
+ _createModalOverlay() {
788
+ this.modalOverlay = createModalOverlay(this.screen, this.navigationService);
789
+
790
+ // Esc key closes the modal overlay if one is open.
791
+ // Blessed.js allows multiple handlers for the same key — all fire.
792
+ // The navigation.js Esc handler calls nav.closeModal() (state only).
793
+ // This second handler hides the overlay+container widgets.
794
+ this.screen.key(['escape'], () => {
795
+ if (this.modalOverlay) this.modalOverlay.close();
796
+ });
797
+ }
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // Private: Global keyboard handlers
801
+
802
+ _registerHandlers() {
803
+ // Q or Ctrl+C → clean exit (no zombie processes)
804
+ this.screen.key(['q', 'Q', 'C-c'], () => {
805
+ this.screen.destroy();
806
+ process.exit(0);
807
+ });
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Launch the AgentVibes TUI console.
813
+ *
814
+ * @param {object} opts
815
+ * @param {string} [opts.startTab='settings'] - Which tab to show on launch.
816
+ * Used by story 6.5 (command routing). Values: 'settings' | 'install' | 'voices' | 'music'
817
+ * @param {boolean} [opts._testMode=false] - Internal: skip render in test environments.
818
+ * @returns {Promise<AgentVibesConsole>}
819
+ */
820
+ export async function launchConsole(opts = {}) {
821
+ const app = new AgentVibesConsole(opts);
822
+ await app.init();
823
+ return app;
824
+ }