agentvibes 4.6.8 → 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.
- package/.agentvibes/bmad-voice-map.json +104 -0
- package/.agentvibes/config.json +13 -12
- package/.agentvibes/copilot-sessions.log +4 -0
- package/.claude/audio/tracks/README.md +51 -52
- package/.claude/config/audio-effects-bmad.cfg +50 -0
- package/.claude/config/audio-effects.cfg +4 -4
- package/.claude/config/background-music-enabled.txt +1 -0
- package/.claude/config/personality.txt +1 -0
- package/.claude/hooks/play-tts-piper.sh +3 -1
- package/.claude/hooks/play-tts.sh +373 -301
- package/.claude/hooks/session-start-tts.sh +81 -81
- package/.claude/hooks-windows/audio-processor.ps1 +181 -0
- package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
- package/.claude/hooks-windows/play-tts.ps1 +101 -9
- package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
- package/README.md +98 -6
- package/RELEASE_NOTES.md +35 -0
- package/bin/bmad-speak.js +16 -8
- package/mcp-server/server.py +15 -8
- package/package.json +1 -1
- package/src/console/app.js +899 -897
- package/src/console/footer-config.js +50 -50
- package/src/console/navigation.js +65 -65
- package/src/console/tabs/agents-tab.js +1896 -1886
- package/src/console/tabs/music-tab.js +1046 -1039
- package/src/console/tabs/placeholder-tab.js +81 -80
- package/src/console/tabs/settings-tab.js +939 -3988
- package/src/console/tabs/setup-tab.js +1811 -0
- package/src/console/tabs/voices-tab.js +1720 -1714
- package/src/installer.js +6147 -6092
- package/src/services/llm-provider-service.js +407 -0
- package/src/services/navigation-service.js +123 -123
- package/src/services/tts-engine-service.js +69 -0
- package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
- 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 = ['
|
|
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
|
+
}
|