vibeaudio 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hud.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ASCII Waveform HUD & Terminal Tab Animation
3
+ * Zero dependencies - Pure ANSI escape codes
4
+ */
5
+
6
+ const WAVES = [
7
+ " ▂▃▅▆▅▃ ",
8
+ "▂▃▅▆▇▆▅▂",
9
+ "▃▅▆▇█▇▆▃",
10
+ "▅▆▇███▇▅",
11
+ "▆▇█▇▆▅▃▂",
12
+ "▇█▇▆▅▃▂ "
13
+ ];
14
+
15
+ function formatTime(ms) {
16
+ const totalSec = Math.floor(ms / 1000);
17
+ const m = Math.floor(totalSec / 60);
18
+ const s = totalSec % 60;
19
+ return `${m}:${s < 10 ? "0" : ""}${s}`;
20
+ }
21
+
22
+ class TerminalHud {
23
+ constructor(genre = "lofi") {
24
+ this.genre = genre;
25
+ this.timer = null;
26
+ this.startTime = 0;
27
+ this.frameIdx = 0;
28
+ this.started = false;
29
+ this.stopped = false;
30
+ }
31
+
32
+ start() {
33
+ this.startTime = Date.now();
34
+ this.started = true;
35
+
36
+ // The wrapped tool may own the screen by now (full-screen TUIs like claude
37
+ // or aider), so the title bar is the only safe place to draw.
38
+ this.timer = setInterval(() => {
39
+ this.frameIdx = (this.frameIdx + 1) % WAVES.length;
40
+ const wave = WAVES[this.frameIdx];
41
+ const timeStr = formatTime(Date.now() - this.startTime);
42
+
43
+ // Set terminal window & tab title (works in Mac Terminal, iTerm2, VS Code, Warp)
44
+ if (process.stdout.isTTY) {
45
+ process.stdout.write(`\x1b]0;[ ♫ ${wave} ${this.genre} (${timeStr}) ]\x07`);
46
+ }
47
+ }, 120);
48
+ }
49
+
50
+ // `chimed` is what actually played, not what the outcome was: --no-chime and
51
+ // a mute both leave the sound off, and announcing a chime nobody heard sends
52
+ // the user looking for a broken speaker.
53
+ stop({ outcome = "success", code = 0, interrupted = false, chimed = true } = {}) {
54
+ if (this.timer) {
55
+ clearInterval(this.timer);
56
+ this.timer = null;
57
+ }
58
+
59
+ // Nothing was ever drawn (fast command), or cleanup ran twice.
60
+ if (!this.started || this.stopped) return;
61
+ this.stopped = true;
62
+
63
+ const timeStr = ((Date.now() - this.startTime) / 1000).toFixed(1);
64
+
65
+ // Reset terminal window/tab title
66
+ if (process.stdout.isTTY) {
67
+ process.stdout.write(`\x1b]0;\x07`);
68
+ }
69
+
70
+ if (!process.stdout.isTTY) return;
71
+
72
+ if (interrupted) {
73
+ process.stdout.write(`\n\x1b[90m■ [VibeAudio] Interrupted after ${timeStr}s\x1b[0m\n`);
74
+ } else if (code === 0) {
75
+ const tail = chimed ? " • Ascending resolution chime" : "";
76
+ process.stdout.write(`\n\x1b[32m✔ [VibeAudio] Done in ${timeStr}s${tail}\x1b[0m\n`);
77
+ } else {
78
+ const tail = chimed ? " • Descending minor tone" : "";
79
+ process.stdout.write(`\n\x1b[33m✖ [VibeAudio] Command exited with code ${code} in ${timeStr}s${tail}\x1b[0m\n`);
80
+ }
81
+ }
82
+ }
83
+
84
+ module.exports = { TerminalHud };
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Interactive Terminal Menu for VibeAudio
3
+ * Zero dependencies - Pure Node.js ANSI and Readline
4
+ */
5
+
6
+ const { execSync } = require("child_process");
7
+ const readline = require("readline");
8
+
9
+ // Windows has no `which`; without this every tool shows as missing there.
10
+ const LOOKUP_CMD = process.platform === "win32" ? "where" : "which";
11
+
12
+ function isInstalled(cmd) {
13
+ try {
14
+ execSync(`${LOOKUP_CMD} ${cmd}`, { stdio: "ignore" });
15
+ return true;
16
+ } catch (e) {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ // Convenience only - the wrapper runs any command, and "Custom command..."
22
+ // is the escape hatch for anything not listed. Entries are sorted so what is
23
+ // actually installed floats to the top, which is what lets this list grow
24
+ // without turning into a wall of things the user does not have.
25
+ // `hookTarget` is the id in hooks.js TARGETS, and it is the only thing that
26
+ // decides whether this tool gets the delivery and reactive questions. It is
27
+ // not the same string as `check`: Cursor's CLI is `cursor-agent` while its
28
+ // target id is `cursor`, which is exactly the kind of mismatch a second
29
+ // hand-kept list gets wrong.
30
+ const AI_TOOLS = [
31
+ { name: "Claude Code", cmd: ["claude"], check: "claude", hookTarget: "claude" },
32
+ { name: "Gemini CLI", cmd: ["gemini"], check: "gemini", hookTarget: "gemini" },
33
+ { name: "Codex CLI", cmd: ["codex"], check: "codex", hookTarget: "codex" },
34
+ { name: "Grok CLI", cmd: ["grok"], check: "grok", hookTarget: "grok" },
35
+ { name: "Cursor CLI", cmd: ["cursor-agent"], check: "cursor-agent", hookTarget: "cursor" },
36
+ { name: "GitHub Copilot CLI", cmd: ["copilot"], check: "copilot", hookTarget: "copilot" },
37
+ { name: "Qwen Code", cmd: ["qwen"], check: "qwen", hookTarget: "qwen" },
38
+ { name: "Aider", cmd: ["aider"], check: "aider" },
39
+ { name: "Ollama (Llama 3)", cmd: ["ollama", "run", "llama3"], check: "ollama" },
40
+ { name: "Custom command...", cmd: null }
41
+ ];
42
+
43
+ const GENRES = [
44
+ { name: "☕ Lo-Fi Focus", desc: "Warm Rhodes electric piano & Kalimba drops", id: "lofi" },
45
+ { name: "🌌 Chill Synthwave", desc: "'80s analog chorused pads & pulsing bass", id: "synthwave" },
46
+ { name: "🕹️ Cozy 8-Bit", desc: "Filtered retro chiptune arpeggios", id: "8bit" },
47
+ { name: "⚡ Melodic Electronic", desc: "Downtempo resonant plucks & tech pulse", id: "electronic" },
48
+ { name: "🎷 Midnight Jazz", desc: "ii-V-I piano chords & walking upright bass", id: "jazz" },
49
+ { name: "🎋 Zen Ambient", desc: "Meditative singing bowls & floating celestial pads", id: "zen" },
50
+ { name: "🎹 Sparse Piano", desc: "Single struck notes & long silences — Satie-ish", id: "piano" },
51
+ { name: "🌫️ Deep Drone", desc: "Held tone & filtered noise — no melody at all", id: "drone" },
52
+ { name: "🎲 Shuffle / Random", desc: "Picks a surprise vibe each time", id: "random" }
53
+ ];
54
+
55
+ function selectMenu(title, items, renderItem) {
56
+ return new Promise((resolve) => {
57
+ let selected = 0;
58
+ const stdin = process.stdin;
59
+ const stdout = process.stdout;
60
+
61
+ if (!stdin.isTTY) {
62
+ // Fallback if not an interactive terminal
63
+ resolve(items[0]);
64
+ return;
65
+ }
66
+
67
+ stdin.setRawMode(true);
68
+ stdin.resume();
69
+ stdin.setEncoding("utf8");
70
+
71
+ function render(firstTime = false) {
72
+ if (!firstTime) {
73
+ // Move cursor up to overwrite previous menu
74
+ stdout.write(`\u001b[${items.length + 1}A`);
75
+ }
76
+
77
+ stdout.write(`\r\x1b[1m\x1b[36m${title}\x1b[0m\x1b[K\n`);
78
+ for (let i = 0; i < items.length; i++) {
79
+ const isSel = i === selected;
80
+ const prefix = isSel ? "\x1b[32m❯ \x1b[1m" : " \x1b[2m";
81
+ const suffix = "\x1b[0m\x1b[K\n";
82
+ stdout.write(`${prefix}${renderItem(items[i], i + 1, isSel)}${suffix}`);
83
+ }
84
+ }
85
+
86
+ render(true);
87
+
88
+ function onData(key) {
89
+ if (key === "\u0003") {
90
+ // Ctrl+C
91
+ cleanup();
92
+ process.exit(0);
93
+ }
94
+
95
+ if (key === "\u001b[A") {
96
+ // Up arrow
97
+ selected = (selected - 1 + items.length) % items.length;
98
+ render();
99
+ } else if (key === "\u001b[B") {
100
+ // Down arrow
101
+ selected = (selected + 1) % items.length;
102
+ render();
103
+ } else if (key === "\r" || key === "\n") {
104
+ // Enter
105
+ cleanup();
106
+ stdout.write("\n");
107
+ resolve(items[selected]);
108
+ } else {
109
+ // Direct number selection 1..9
110
+ const num = parseInt(key, 10);
111
+ if (!isNaN(num) && num >= 1 && num <= items.length) {
112
+ selected = num - 1;
113
+ cleanup();
114
+ render();
115
+ stdout.write("\n");
116
+ resolve(items[selected]);
117
+ }
118
+ }
119
+ }
120
+
121
+ function cleanup() {
122
+ stdin.removeListener("data", onData);
123
+ stdin.setRawMode(false);
124
+ stdin.pause();
125
+ }
126
+
127
+ stdin.on("data", onData);
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Splits a command line into argv, keeping quoted arguments intact so
133
+ * `claude --append-system-prompt "be brief"` stays three tokens, not four.
134
+ * Tokens are passed to spawn() as argv, never through a shell.
135
+ */
136
+ function tokenizeCommand(input) {
137
+ const tokens = [];
138
+ let current = "";
139
+ let started = false;
140
+ let quote = null;
141
+
142
+ for (const ch of input.trim()) {
143
+ if (quote) {
144
+ if (ch === quote) quote = null;
145
+ else current += ch;
146
+ } else if (ch === '"' || ch === "'") {
147
+ quote = ch;
148
+ started = true;
149
+ } else if (/\s/.test(ch)) {
150
+ if (started) {
151
+ tokens.push(current);
152
+ current = "";
153
+ started = false;
154
+ }
155
+ } else {
156
+ current += ch;
157
+ started = true;
158
+ }
159
+ }
160
+
161
+ if (started) tokens.push(current);
162
+ return tokens;
163
+ }
164
+
165
+ function promptCustomCommand() {
166
+ return new Promise((resolve) => {
167
+ const rl = readline.createInterface({
168
+ input: process.stdin,
169
+ output: process.stdout
170
+ });
171
+
172
+ rl.question("\x1b[1mEnter command to wrap with music:\x1b[0m ", (answer) => {
173
+ rl.close();
174
+ const parts = tokenizeCommand(answer);
175
+ resolve(parts.length > 0 ? parts : ["echo", "No command specified"]);
176
+ });
177
+ });
178
+ }
179
+
180
+ const VOLUMES = [
181
+ { name: "☕ Normal (40%)", desc: "Balanced focus background [Default]", vol: 0.40 },
182
+ { name: "🤫 Quiet (25%)", desc: "Discreet focus / open office", vol: 0.25 },
183
+ { name: "🌙 Whisper (15%)", desc: "Ultra-gentle / headphones / late night", vol: 0.15 },
184
+ { name: "📢 Loud (75%)", desc: "Audible across the room", vol: 0.75 }
185
+ ];
186
+
187
+ /**
188
+ * Which target's hooks this menu entry can install, or null for the tools that
189
+ * only get the wrapper - and so have no delivery choice worth asking about.
190
+ *
191
+ * Read out of hooks.js TARGETS rather than listed again here. A hand-kept copy
192
+ * is how Grok became a fully supported hook target - its own file, its own
193
+ * event names, a row in the README - that this menu could never install for:
194
+ * picking it offered the wrapper and nothing else. Deriving it means adding a
195
+ * target wires up the menu too, or fails loudly on a typo'd id.
196
+ *
197
+ * Required lazily: hooks.js reaches back into this module for isInstalled().
198
+ */
199
+ function hookTargetOf(tool) {
200
+ if (!tool || !tool.hookTarget) return null;
201
+ const { TARGETS } = require("./hooks");
202
+ if (!TARGETS[tool.hookTarget]) {
203
+ throw new Error(
204
+ `launcher entry '${tool.name}' names hook target '${tool.hookTarget}', ` +
205
+ `which is not one of: ${Object.keys(TARGETS).join(", ")}`
206
+ );
207
+ }
208
+ return tool.hookTarget;
209
+ }
210
+
211
+ const DELIVERY_FRESH = [
212
+ {
213
+ id: "hooks",
214
+ name: "Agent hooks",
215
+ desc: "Music follows the agent's thinking. Set once, no wrapper needed"
216
+ },
217
+ {
218
+ id: "wrapper",
219
+ name: "This session only",
220
+ desc: "Music plays while the process lives - fine for one-shot commands"
221
+ }
222
+ ];
223
+
224
+ // With hooks already installed, the wrapper plays nothing for an interactive
225
+ // claude - so offering "this session only" would be offering silence. The
226
+ // honest second option is to just launch, and to stop asking for a genre and
227
+ // volume the hooks are going to ignore.
228
+ const DELIVERY_INSTALLED = [
229
+ {
230
+ id: "launch",
231
+ name: "Just launch it",
232
+ desc: "Your installed hooks already handle the music"
233
+ },
234
+ {
235
+ id: "hooks",
236
+ name: "Reconfigure the hooks",
237
+ desc: "Pick a new genre, volume or reactive setting"
238
+ }
239
+ ];
240
+
241
+ const REACTIVE = [
242
+ { id: false, name: "Steady (recommended)", desc: "Intensity follows elapsed time, and stays ignorable" },
243
+ { id: true, name: "Reactive", desc: "Intensity follows the tool in use - noticeable, by design" }
244
+ ];
245
+
246
+ /**
247
+ * `hooksInstalledFor` is asked about the tool the user picked, not about
248
+ * Claude: the caller cannot know which tool that is until this menu runs, and
249
+ * answering for Claude every time told a Grok user with Grok hooks already
250
+ * installed that they had none.
251
+ */
252
+ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
253
+ console.log(`\n\x1b[1m\x1b[35m🎧 VibeAudio — Interactive AI Launcher\x1b[0m\n`);
254
+
255
+ // 1. Select AI Tool
256
+ const toolsWithStatus = AI_TOOLS.map((t) => ({
257
+ ...t,
258
+ installed: t.check ? isInstalled(t.check) : null
259
+ }));
260
+
261
+ // Installed tools first, "Custom command..." last, each group keeping the
262
+ // order above. A stable sort is required for that, which Node guarantees.
263
+ const rank = (t) => (t.installed === true ? 0 : t.installed === false ? 1 : 2);
264
+ toolsWithStatus.sort((a, b) => rank(a) - rank(b));
265
+
266
+ const selectedTool = await selectMenu(
267
+ "Which AI companion would you like to launch?",
268
+ toolsWithStatus,
269
+ (item, num) => {
270
+ const status = item.installed === true ? " \x1b[32m[✓ installed]\x1b[0m" : item.installed === false ? " \x1b[90m(not found in PATH)\x1b[0m" : "";
271
+ return `${num}. ${item.name}${status}`;
272
+ }
273
+ );
274
+
275
+ let finalCmd = selectedTool.cmd;
276
+ if (!finalCmd) {
277
+ finalCmd = await promptCustomCommand();
278
+ }
279
+
280
+ // 2. How should the music be delivered?
281
+ // Without this the menu asks for a genre and volume that installed hooks
282
+ // then ignore - three questions asked, one honoured.
283
+ let delivery = "wrapper";
284
+ const hookTarget = hookTargetOf(selectedTool);
285
+ if (hookTarget) {
286
+ const installed = hooksInstalledFor(hookTarget);
287
+ const chosen = await selectMenu(
288
+ installed ? "Hooks are already installed. What now?" : "How should the music run?",
289
+ installed ? DELIVERY_INSTALLED : DELIVERY_FRESH,
290
+ (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
291
+ );
292
+ delivery = chosen.id;
293
+ }
294
+
295
+ // Nothing downstream reads a genre or volume on this path, so don't ask for one.
296
+ if (delivery === "launch") {
297
+ return { cmd: finalCmd, installHooks: false, reactive: false };
298
+ }
299
+
300
+ // 3. Select Music Genre
301
+ const selectedGenre = await selectMenu(
302
+ "Choose your sound vibe:",
303
+ GENRES,
304
+ (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
305
+ );
306
+
307
+ // 4. Select Volume Preset
308
+ const selectedVolume = await selectMenu(
309
+ "Choose your volume level:",
310
+ VOLUMES,
311
+ (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
312
+ );
313
+
314
+ // 5. Reactive is a hooks-only feature, so only offer it on that branch -
315
+ // a control that does nothing is worse than no control.
316
+ let reactive = false;
317
+ if (delivery === "hooks") {
318
+ const chosen = await selectMenu(
319
+ "Should the music react to what the agent is doing?",
320
+ REACTIVE,
321
+ (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
322
+ );
323
+ reactive = chosen.id;
324
+ }
325
+
326
+ return {
327
+ cmd: finalCmd,
328
+ genre: selectedGenre.id,
329
+ volume: selectedVolume.vol,
330
+ installHooks: delivery === "hooks",
331
+ // The menu asked about one tool, so it installs for that one - auto-detect
332
+ // belongs to the bare --install-hooks flag, where nothing was chosen.
333
+ hookTarget: delivery === "hooks" ? hookTarget : null,
334
+ reactive
335
+ };
336
+ }
337
+
338
+ module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, AI_TOOLS };