agentvibes 4.6.7 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.agentvibes/bmad-voice-map.json +104 -0
  2. package/.agentvibes/config.json +13 -12
  3. package/.agentvibes/copilot-sessions.log +4 -0
  4. package/.claude/audio/tracks/README.md +51 -52
  5. package/.claude/config/audio-effects-bmad.cfg +50 -0
  6. package/.claude/config/audio-effects.cfg +4 -4
  7. package/.claude/config/background-music-enabled.txt +1 -0
  8. package/.claude/config/personality.txt +1 -0
  9. package/.claude/hooks/play-tts-piper.sh +3 -1
  10. package/.claude/hooks/play-tts.sh +373 -301
  11. package/.claude/hooks/session-start-tts.sh +81 -81
  12. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  13. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  14. package/.claude/hooks-windows/play-tts.ps1 +101 -9
  15. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  16. package/README.md +107 -7
  17. package/RELEASE_NOTES.md +54 -0
  18. package/bin/bmad-speak.js +16 -8
  19. package/mcp-server/server.py +15 -8
  20. package/package.json +1 -1
  21. package/src/console/app.js +899 -897
  22. package/src/console/footer-config.js +50 -50
  23. package/src/console/navigation.js +65 -65
  24. package/src/console/tabs/agents-tab.js +1896 -1886
  25. package/src/console/tabs/music-tab.js +1046 -1039
  26. package/src/console/tabs/placeholder-tab.js +81 -80
  27. package/src/console/tabs/settings-tab.js +939 -3988
  28. package/src/console/tabs/setup-tab.js +1811 -0
  29. package/src/console/tabs/voices-tab.js +1720 -1713
  30. package/src/installer.js +6147 -6092
  31. package/src/services/llm-provider-service.js +407 -0
  32. package/src/services/navigation-service.js +123 -123
  33. package/src/services/tts-engine-service.js +69 -0
  34. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  35. package/src/console/tabs/install-tab.js +0 -1081
@@ -0,0 +1,407 @@
1
+ /**
2
+ * AgentVibes — LLM Provider Service
3
+ *
4
+ * Extracted from llm-providers-tab.js: all provider logic as a standalone service.
5
+ * Config format: llm:key|effects|bgTrack|bgVolume|voice|pretext|ttsEngine
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import fs from 'node:fs/promises';
10
+ import fsSync from 'node:fs';
11
+
12
+ // ── Provider definitions ────────────────────────────────────────────────────
13
+
14
+ export const PROVIDERS = [
15
+ {
16
+ id: 'claude-code',
17
+ name: 'Claude Code',
18
+ desc: 'Anthropic CLI agent — hooks + MCP server',
19
+ },
20
+ {
21
+ id: 'github-copilot',
22
+ name: 'GitHub Copilot',
23
+ desc: 'VS Code Copilot Chat — .vscode/mcp.json + instructions',
24
+ },
25
+ {
26
+ id: 'openai-codex',
27
+ name: 'OpenAI Codex',
28
+ desc: 'OpenAI CLI agent — .codex/config.toml + AGENTS.md',
29
+ },
30
+ ];
31
+
32
+ // ── Provider install-checks ─────────────────────────────────────────────────
33
+
34
+ export async function checkClaudeInstalled(targetDir) {
35
+ try {
36
+ await fs.access(path.join(targetDir, '.claude', 'hooks'));
37
+ return true;
38
+ } catch {
39
+ try {
40
+ await fs.access(path.join(targetDir, '.claude', 'hooks-windows'));
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ }
47
+
48
+ export async function checkCopilotInstalled(targetDir) {
49
+ try {
50
+ const content = await fs.readFile(path.join(targetDir, '.vscode', 'mcp.json'), 'utf8');
51
+ const parsed = JSON.parse(content);
52
+ return !!(parsed?.servers?.agentvibes);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export async function checkCodexInstalled(targetDir) {
59
+ try {
60
+ const content = await fs.readFile(path.join(targetDir, '.codex', 'config.toml'), 'utf8');
61
+ return content.includes('[mcp_servers.agentvibes]');
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ // ── Claude Code install ────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Create .mcp.json in target directory if it doesn't exist.
71
+ * Also copies hooks, commands, config, personality, plugin, and bmad config files.
72
+ */
73
+ export async function installClaudeMcp(targetDir) {
74
+ const mcpConfigPath = path.join(targetDir, '.mcp.json');
75
+
76
+ const mcpConfig = {
77
+ mcpServers: {
78
+ agentvibes: {
79
+ command: 'npx',
80
+ args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
81
+ },
82
+ },
83
+ };
84
+
85
+ try {
86
+ let mcpCreated = false;
87
+ try {
88
+ await fs.access(mcpConfigPath);
89
+ // Already exists — merge agentvibes key if missing
90
+ try {
91
+ const existing = JSON.parse(await fs.readFile(mcpConfigPath, 'utf8'));
92
+ if (!existing.mcpServers?.agentvibes) {
93
+ existing.mcpServers = existing.mcpServers || {};
94
+ existing.mcpServers.agentvibes = mcpConfig.mcpServers.agentvibes;
95
+ await fs.writeFile(mcpConfigPath, JSON.stringify(existing, null, 2) + '\n');
96
+ mcpCreated = true;
97
+ }
98
+ } catch { /* parse error — don't corrupt */ }
99
+ } catch {
100
+ // File doesn't exist — create it
101
+ await fs.writeFile(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
102
+ mcpCreated = true;
103
+ }
104
+
105
+ // Copy hooks, commands, config, personality, plugin, bmad config files
106
+ const silentSpinner = { start: () => {}, succeed: () => {}, fail: () => {} };
107
+ const installer = await import('../installer.js');
108
+ await installer.copyHookFiles(targetDir, silentSpinner);
109
+ await installer.copyCommandFiles(targetDir, silentSpinner);
110
+ await installer.copyConfigFiles(targetDir, silentSpinner);
111
+ await installer.copyPersonalityFiles(targetDir, silentSpinner);
112
+ await installer.copyPluginFiles(targetDir, silentSpinner);
113
+ await installer.copyBmadConfigFiles(targetDir, silentSpinner);
114
+ await installer.copyBackgroundMusicFiles(targetDir, silentSpinner);
115
+
116
+ return { success: true, mcpCreated };
117
+ } catch (err) {
118
+ return { success: false, error: err.message };
119
+ }
120
+ }
121
+
122
+ export async function removeClaudeMcp(targetDir) {
123
+ const mcpConfigPath = path.join(targetDir, '.mcp.json');
124
+ try {
125
+ const content = await fs.readFile(mcpConfigPath, 'utf8');
126
+ const parsed = JSON.parse(content);
127
+ if (parsed.mcpServers?.agentvibes) {
128
+ delete parsed.mcpServers.agentvibes;
129
+ // Only delete file if mcpServers is empty AND no other top-level keys
130
+ const noServers = Object.keys(parsed.mcpServers).length === 0;
131
+ const noOtherKeys = Object.keys(parsed).length === 1;
132
+ if (noServers && noOtherKeys) {
133
+ await fs.unlink(mcpConfigPath);
134
+ } else {
135
+ await fs.writeFile(mcpConfigPath, JSON.stringify(parsed, null, 2) + '\n');
136
+ }
137
+ }
138
+ } catch { /* file doesn't exist or can't parse — nothing to remove */ }
139
+ return { success: true };
140
+ }
141
+
142
+ // ── Copilot install/remove ──────────────────────────────────────────────────
143
+
144
+ export async function installCopilotMcp(targetDir) {
145
+ const vscodeDir = path.join(targetDir, '.vscode');
146
+ const mcpJsonPath = path.join(vscodeDir, 'mcp.json');
147
+
148
+ const agentvibesServer = {
149
+ type: 'stdio',
150
+ command: 'npx',
151
+ args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
152
+ };
153
+
154
+ try {
155
+ await fs.mkdir(vscodeDir, { recursive: true });
156
+ let mcpConfig = { servers: {} };
157
+ try {
158
+ const existing = await fs.readFile(mcpJsonPath, 'utf8');
159
+ const parsed = JSON.parse(existing);
160
+ if (parsed && typeof parsed === 'object') {
161
+ mcpConfig = parsed;
162
+ if (!mcpConfig.servers) mcpConfig.servers = {};
163
+ }
164
+ } catch { /* new file */ }
165
+
166
+ mcpConfig.servers.agentvibes = agentvibesServer;
167
+ await fs.writeFile(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + '\n');
168
+ return { success: true };
169
+ } catch (err) {
170
+ return { success: false, error: err.message };
171
+ }
172
+ }
173
+
174
+ export async function removeCopilotMcp(targetDir) {
175
+ const mcpJsonPath = path.join(targetDir, '.vscode', 'mcp.json');
176
+ try {
177
+ const content = await fs.readFile(mcpJsonPath, 'utf8');
178
+ const parsed = JSON.parse(content);
179
+ if (parsed?.servers?.agentvibes) {
180
+ delete parsed.servers.agentvibes;
181
+ if (Object.keys(parsed.servers).length === 0) {
182
+ await fs.unlink(mcpJsonPath);
183
+ } else {
184
+ await fs.writeFile(mcpJsonPath, JSON.stringify(parsed, null, 2) + '\n');
185
+ }
186
+ }
187
+ return { success: true };
188
+ } catch {
189
+ return { success: true }; // Already gone
190
+ }
191
+ }
192
+
193
+ export async function installCopilotInstructions(targetDir, packageDir) {
194
+ const destPath = path.join(targetDir, '.github', 'copilot-instructions.md');
195
+ const srcPath = path.join(packageDir, '.github', 'copilot-instructions.md');
196
+ try {
197
+ await fs.mkdir(path.join(targetDir, '.github'), { recursive: true });
198
+ const content = await fs.readFile(srcPath, 'utf8');
199
+ await fs.writeFile(destPath, content);
200
+ } catch { /* best effort */ }
201
+ }
202
+
203
+ export async function removeCopilotInstructions(targetDir) {
204
+ try {
205
+ await fs.unlink(path.join(targetDir, '.github', 'copilot-instructions.md'));
206
+ } catch { /* already gone */ }
207
+ }
208
+
209
+ // ── Codex install/remove ────────────────────────────────────────────────────
210
+
211
+ export async function installCodexMcp(targetDir) {
212
+ const codexDir = path.join(targetDir, '.codex');
213
+ const tomlPath = path.join(codexDir, 'config.toml');
214
+
215
+ try {
216
+ await fs.mkdir(codexDir, { recursive: true });
217
+ let existing = '';
218
+ try { existing = await fs.readFile(tomlPath, 'utf8'); } catch { /* new file */ }
219
+ const content = buildCodexToml(existing);
220
+ await fs.writeFile(tomlPath, content);
221
+ return { success: true };
222
+ } catch (err) {
223
+ return { success: false, error: err.message };
224
+ }
225
+ }
226
+
227
+ export async function removeCodexMcp(targetDir) {
228
+ const tomlPath = path.join(targetDir, '.codex', 'config.toml');
229
+ try {
230
+ const content = await fs.readFile(tomlPath, 'utf8');
231
+ const lines = content.split('\n');
232
+ const filtered = [];
233
+ let skipping = false;
234
+ for (const line of lines) {
235
+ if (line.trim() === '[mcp_servers.agentvibes]') {
236
+ skipping = true;
237
+ continue;
238
+ }
239
+ if (skipping && line.startsWith('[')) {
240
+ skipping = false;
241
+ }
242
+ if (!skipping) filtered.push(line);
243
+ }
244
+ const result = filtered.join('\n').trim();
245
+ if (!result) {
246
+ await fs.unlink(tomlPath);
247
+ } else {
248
+ await fs.writeFile(tomlPath, result + '\n');
249
+ }
250
+ return { success: true };
251
+ } catch {
252
+ return { success: true }; // Already gone
253
+ }
254
+ }
255
+
256
+ export function buildCodexToml(existingContent = '') {
257
+ const serverBlock = [
258
+ '[mcp_servers.agentvibes]',
259
+ 'command = "npx"',
260
+ 'args = ["-y", "--package=agentvibes", "agentvibes-mcp-server"]',
261
+ ].join('\n');
262
+
263
+ if (!existingContent.trim()) return serverBlock + '\n';
264
+
265
+ // Remove existing agentvibes block if present, then append fresh
266
+ const lines = existingContent.split('\n');
267
+ const filtered = [];
268
+ let skipping = false;
269
+ for (const line of lines) {
270
+ if (line.trim() === '[mcp_servers.agentvibes]') {
271
+ skipping = true;
272
+ continue;
273
+ }
274
+ if (skipping && line.startsWith('[')) {
275
+ skipping = false;
276
+ }
277
+ if (!skipping) filtered.push(line);
278
+ }
279
+
280
+ let result = filtered.join('\n').trimEnd();
281
+ if (result.length) result += '\n\n';
282
+ return result + serverBlock + '\n';
283
+ }
284
+
285
+ export async function installCodexInstructions(targetDir, packageDir) {
286
+ const srcPath = path.join(packageDir, '.codex', 'AGENTS.md');
287
+ try {
288
+ const content = await fs.readFile(srcPath, 'utf8');
289
+ await fs.mkdir(path.join(targetDir, '.codex'), { recursive: true });
290
+ await fs.writeFile(path.join(targetDir, '.codex', 'AGENTS.md'), content);
291
+ await fs.writeFile(path.join(targetDir, 'AGENTS.md'), content);
292
+ } catch { /* best effort */ }
293
+ }
294
+
295
+ export async function installCodexHooks(targetDir, packageDir) {
296
+ const destDir = path.join(targetDir, '.codex', 'hooks');
297
+ const srcDir = path.join(packageDir, '.codex', 'hooks');
298
+ try {
299
+ await fs.mkdir(destDir, { recursive: true });
300
+ for (const file of ['init-agentvibes.sh', 'init-agentvibes.ps1']) {
301
+ try {
302
+ const content = await fs.readFile(path.join(srcDir, file), 'utf8');
303
+ await fs.writeFile(path.join(destDir, file), content);
304
+ } catch { /* best effort */ }
305
+ }
306
+ } catch { /* best effort */ }
307
+ }
308
+
309
+ export async function removeCodexInstructions(targetDir) {
310
+ try {
311
+ await fs.unlink(path.join(targetDir, '.codex', 'AGENTS.md'));
312
+ } catch { /* already gone */ }
313
+ try {
314
+ await fs.unlink(path.join(targetDir, 'AGENTS.md'));
315
+ } catch { /* already gone */ }
316
+ }
317
+
318
+ export async function removeCodexHooks(targetDir) {
319
+ const hooksDir = path.join(targetDir, '.codex', 'hooks');
320
+ try {
321
+ await fs.unlink(path.join(hooksDir, 'init-agentvibes.sh'));
322
+ } catch { /* already gone */ }
323
+ try {
324
+ await fs.unlink(path.join(hooksDir, 'init-agentvibes.ps1'));
325
+ } catch { /* already gone */ }
326
+ try {
327
+ await fs.rmdir(hooksDir);
328
+ } catch { /* not empty or gone */ }
329
+ }
330
+
331
+ // ── Config path resolution ──────────────────────────────────────────────────
332
+
333
+ export function resolveCfgPath(targetDir) {
334
+ const localCfg = path.join(targetDir, '.claude', 'config', 'audio-effects.cfg');
335
+ const homeDir = process.env.USERPROFILE || process.env.HOME || '';
336
+ const globalCfg = path.join(homeDir, '.claude', 'config', 'audio-effects.cfg');
337
+ return fsSync.existsSync(localCfg) ? localCfg : globalCfg;
338
+ }
339
+
340
+ // ── LLM config read/write ───────────────────────────────────────────────────
341
+
342
+ /**
343
+ * Read per-LLM audio config from audio-effects.cfg.
344
+ * Format: llm:key|effects|bgTrack|bgVolume|voice|pretext|ttsEngine
345
+ * Handles old 6-field format gracefully (ttsEngine defaults to '').
346
+ */
347
+ export function loadLlmConfigSync(llmKey, targetDir) {
348
+ const cfgKey = `llm:${llmKey}`;
349
+ const resolvedTargetDir = targetDir || process.env.INIT_CWD || process.cwd();
350
+ const cfgPaths = [
351
+ path.join(resolvedTargetDir, '.claude', 'config', 'audio-effects.cfg'),
352
+ path.join(process.env.USERPROFILE || process.env.HOME || '', '.claude', 'config', 'audio-effects.cfg'),
353
+ ];
354
+
355
+ for (const cfgPath of cfgPaths) {
356
+ try {
357
+ const content = fsSync.readFileSync(cfgPath, 'utf8');
358
+ for (const line of content.split('\n')) {
359
+ if (line.startsWith(cfgKey + '|')) {
360
+ const parts = line.split('|');
361
+ return {
362
+ effects: (parts[1] || '').trim(),
363
+ bgTrack: (parts[2] || '').trim(),
364
+ bgVolume: (parts[3] || '0.15').trim(),
365
+ voice: (parts[4] || '').trim(),
366
+ pretext: (parts[5] || '').trim(),
367
+ ttsEngine: (parts[6] || '').trim(), // new field — empty if old format
368
+ sourcePath: cfgPath,
369
+ };
370
+ }
371
+ }
372
+ } catch { /* file not found */ }
373
+ }
374
+ return { effects: '', bgTrack: '', bgVolume: '0.15', voice: '', pretext: '', ttsEngine: '', sourcePath: '' };
375
+ }
376
+
377
+ /**
378
+ * Write per-LLM audio config to audio-effects.cfg.
379
+ * Format: llm:key|effects|bgTrack|bgVolume|voice|pretext|ttsEngine
380
+ */
381
+ export function saveLlmConfigSync(llmKey, config, targetDir) {
382
+ const cfgKey = `llm:${llmKey}`;
383
+ // Sanitize pipe chars in user-editable fields to prevent config format corruption
384
+ const sanitize = (v) => (v || '').replace(/\|/g, '');
385
+ const cfgLine = `${cfgKey}|${sanitize(config.effects)}|${sanitize(config.bgTrack)}|${config.bgVolume}|${sanitize(config.voice)}|${sanitize(config.pretext)}|${sanitize(config.ttsEngine)}`;
386
+ const resolvedTargetDir = targetDir || process.env.INIT_CWD || process.cwd();
387
+ const cfgPath = config.sourcePath || resolveCfgPath(resolvedTargetDir);
388
+
389
+ try {
390
+ let content = '';
391
+ try { content = fsSync.readFileSync(cfgPath, 'utf8'); } catch { /* new file */ }
392
+
393
+ const lines = content.split('\n');
394
+ let found = false;
395
+ for (let i = 0; i < lines.length; i++) {
396
+ if (lines[i].startsWith(cfgKey + '|')) {
397
+ lines[i] = cfgLine;
398
+ found = true;
399
+ break;
400
+ }
401
+ }
402
+ if (!found) lines.push(cfgLine);
403
+
404
+ fsSync.mkdirSync(path.dirname(cfgPath), { recursive: true });
405
+ fsSync.writeFileSync(cfgPath, lines.join('\n'));
406
+ } catch { /* best effort */ }
407
+ }
@@ -1,123 +1,123 @@
1
- /**
2
- * AgentVibes TUI Console — Navigation Service
3
- * Story 6.2: Tab Bar & Global Keyboard Navigation
4
- *
5
- * Manages tab state, cycling, modal overlay state, and focus stack.
6
- * Used by navigation.js (key bindings) and app.js (wiring).
7
- */
8
-
9
- /** Ordered list of all tab IDs — used for cycling and validation */
10
- export const TAB_ORDER = ['install', 'settings', 'voices', 'music', 'agents', 'receiver', 'readme', 'help'];
11
-
12
- export class NavigationService {
13
- /**
14
- * @param {string} [initialTab='settings'] - Tab to activate on launch
15
- */
16
- constructor(initialTab = 'settings') {
17
- this._activeTab = TAB_ORDER.includes(initialTab) ? initialTab : 'settings';
18
- this._switchCallbacks = [];
19
- this._focusStack = [];
20
- this._modalOpen = false;
21
- }
22
-
23
- // ---------------------------------------------------------------------------
24
- // Tab navigation
25
-
26
- /** Returns the currently active tab ID */
27
- getActiveTab() {
28
- return this._activeTab;
29
- }
30
-
31
- /**
32
- * Switch to the given tab.
33
- * Ignores invalid tab IDs. Fires all registered onSwitch callbacks.
34
- * @param {string} tabId
35
- */
36
- switchTab(tabId) {
37
- if (!TAB_ORDER.includes(tabId)) return;
38
- if (tabId === this._activeTab) return; // no-op: already on this tab
39
- this._activeTab = tabId;
40
- this._switchCallbacks.forEach(cb => cb(tabId));
41
- }
42
-
43
- /**
44
- * Activate a tab unconditionally, bypassing the same-tab no-op guard.
45
- * Used for initial UI setup: the constructor pre-sets _activeTab but
46
- * onSwitch callbacks must still fire to render the initial state.
47
- * @param {string} tabId
48
- */
49
- forceActivate(tabId) {
50
- if (!TAB_ORDER.includes(tabId)) return;
51
- this._activeTab = tabId;
52
- this._switchCallbacks.forEach(cb => cb(tabId));
53
- }
54
-
55
- /**
56
- * Cycle to the next tab in TAB_ORDER, wrapping from last back to first.
57
- */
58
- cycleTab() {
59
- const idx = TAB_ORDER.indexOf(this._activeTab);
60
- const nextIdx = (idx + 1) % TAB_ORDER.length;
61
- this.switchTab(TAB_ORDER[nextIdx]);
62
- }
63
-
64
- /**
65
- * Cycle to the previous tab in TAB_ORDER, wrapping from first back to last.
66
- */
67
- cycleTabPrev() {
68
- const idx = TAB_ORDER.indexOf(this._activeTab);
69
- const prevIdx = (idx - 1 + TAB_ORDER.length) % TAB_ORDER.length;
70
- this.switchTab(TAB_ORDER[prevIdx]);
71
- }
72
-
73
- /**
74
- * Register a callback fired whenever the active tab changes.
75
- * @param {(tabId: string) => void} callback
76
- */
77
- onSwitch(callback) {
78
- this._switchCallbacks.push(callback);
79
- }
80
-
81
- // ---------------------------------------------------------------------------
82
- // Modal state (story 6.4 will expand this)
83
-
84
- /** Returns true if a modal is currently open */
85
- isModalOpen() {
86
- return this._modalOpen;
87
- }
88
-
89
- /**
90
- * Open a modal. Sets modal-open state and calls the factory fn if provided.
91
- * @param {Function|null} fn - Optional factory/callback invoked immediately
92
- */
93
- openModal(fn) {
94
- this._modalOpen = true;
95
- fn?.();
96
- }
97
-
98
- /** Close the current modal, restoring modal-closed state */
99
- closeModal() {
100
- this._modalOpen = false;
101
- }
102
-
103
-
104
- // ---------------------------------------------------------------------------
105
- // Focus stack (story 7.6 will use this for button-level focus)
106
-
107
- /**
108
- * Push a Blessed element onto the focus stack
109
- * @param {object} element - Blessed widget
110
- */
111
- pushFocus(element) {
112
- this._focusStack.push(element);
113
- }
114
-
115
- /**
116
- * Pop the last element from the focus stack.
117
- * Returns undefined if the stack is empty.
118
- * @returns {object|undefined}
119
- */
120
- popFocus() {
121
- return this._focusStack.pop();
122
- }
123
- }
1
+ /**
2
+ * AgentVibes TUI Console — Navigation Service
3
+ * Story 6.2: Tab Bar & Global Keyboard Navigation
4
+ *
5
+ * Manages tab state, cycling, modal overlay state, and focus stack.
6
+ * Used by navigation.js (key bindings) and app.js (wiring).
7
+ */
8
+
9
+ /** Ordered list of all tab IDs — used for cycling and validation */
10
+ export const TAB_ORDER = ['setup', 'settings', 'voices', 'music', 'agents', 'receiver', 'readme', 'help'];
11
+
12
+ export class NavigationService {
13
+ /**
14
+ * @param {string} [initialTab='settings'] - Tab to activate on launch
15
+ */
16
+ constructor(initialTab = 'settings') {
17
+ this._activeTab = TAB_ORDER.includes(initialTab) ? initialTab : 'settings';
18
+ this._switchCallbacks = [];
19
+ this._focusStack = [];
20
+ this._modalOpen = false;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Tab navigation
25
+
26
+ /** Returns the currently active tab ID */
27
+ getActiveTab() {
28
+ return this._activeTab;
29
+ }
30
+
31
+ /**
32
+ * Switch to the given tab.
33
+ * Ignores invalid tab IDs. Fires all registered onSwitch callbacks.
34
+ * @param {string} tabId
35
+ */
36
+ switchTab(tabId) {
37
+ if (!TAB_ORDER.includes(tabId)) return;
38
+ if (tabId === this._activeTab) return; // no-op: already on this tab
39
+ this._activeTab = tabId;
40
+ this._switchCallbacks.forEach(cb => cb(tabId));
41
+ }
42
+
43
+ /**
44
+ * Activate a tab unconditionally, bypassing the same-tab no-op guard.
45
+ * Used for initial UI setup: the constructor pre-sets _activeTab but
46
+ * onSwitch callbacks must still fire to render the initial state.
47
+ * @param {string} tabId
48
+ */
49
+ forceActivate(tabId) {
50
+ if (!TAB_ORDER.includes(tabId)) return;
51
+ this._activeTab = tabId;
52
+ this._switchCallbacks.forEach(cb => cb(tabId));
53
+ }
54
+
55
+ /**
56
+ * Cycle to the next tab in TAB_ORDER, wrapping from last back to first.
57
+ */
58
+ cycleTab() {
59
+ const idx = TAB_ORDER.indexOf(this._activeTab);
60
+ const nextIdx = (idx + 1) % TAB_ORDER.length;
61
+ this.switchTab(TAB_ORDER[nextIdx]);
62
+ }
63
+
64
+ /**
65
+ * Cycle to the previous tab in TAB_ORDER, wrapping from first back to last.
66
+ */
67
+ cycleTabPrev() {
68
+ const idx = TAB_ORDER.indexOf(this._activeTab);
69
+ const prevIdx = (idx - 1 + TAB_ORDER.length) % TAB_ORDER.length;
70
+ this.switchTab(TAB_ORDER[prevIdx]);
71
+ }
72
+
73
+ /**
74
+ * Register a callback fired whenever the active tab changes.
75
+ * @param {(tabId: string) => void} callback
76
+ */
77
+ onSwitch(callback) {
78
+ this._switchCallbacks.push(callback);
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Modal state (story 6.4 will expand this)
83
+
84
+ /** Returns true if a modal is currently open */
85
+ isModalOpen() {
86
+ return this._modalOpen;
87
+ }
88
+
89
+ /**
90
+ * Open a modal. Sets modal-open state and calls the factory fn if provided.
91
+ * @param {Function|null} fn - Optional factory/callback invoked immediately
92
+ */
93
+ openModal(fn) {
94
+ this._modalOpen = true;
95
+ fn?.();
96
+ }
97
+
98
+ /** Close the current modal, restoring modal-closed state */
99
+ closeModal() {
100
+ this._modalOpen = false;
101
+ }
102
+
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Focus stack (story 7.6 will use this for button-level focus)
106
+
107
+ /**
108
+ * Push a Blessed element onto the focus stack
109
+ * @param {object} element - Blessed widget
110
+ */
111
+ pushFocus(element) {
112
+ this._focusStack.push(element);
113
+ }
114
+
115
+ /**
116
+ * Pop the last element from the focus stack.
117
+ * Returns undefined if the stack is empty.
118
+ * @returns {object|undefined}
119
+ */
120
+ popFocus() {
121
+ return this._focusStack.pop();
122
+ }
123
+ }