opencode-plugin-boops 1.0.0 → 2.0.2

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/cli/browse ADDED
@@ -0,0 +1,2122 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { homedir } from "os";
6
+ import { spawn, exec } from "child_process";
7
+ import https from "https";
8
+
9
+ // Get the directory where this script is located
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ // Load sounds from the bundled JSON file
14
+ // When installed: sounds.json is in same directory as script
15
+ // When in repo: sounds.json is in parent directory
16
+ let soundsPath = join(__dirname, "sounds.json");
17
+ if (!existsSync(soundsPath)) {
18
+ soundsPath = join(__dirname, "..", "sounds.json");
19
+ }
20
+ const sounds = JSON.parse(readFileSync(soundsPath, "utf-8"));
21
+
22
+ // Available OpenCode events
23
+ const availableEvents = [
24
+ "session.idle", "permission.asked", "session.error",
25
+ "command.executed", "file.edited", "file.watcher.updated",
26
+ "installation.updated", "lsp.client.diagnostics", "lsp.updated",
27
+ "message.part.removed", "message.part.updated", "message.removed", "message.updated",
28
+ "permission.replied", "server.connected",
29
+ "session.created", "session.compacted", "session.deleted", "session.diff", "session.status", "session.updated",
30
+ "todo.updated",
31
+ "tool.execute.before", "tool.execute.after",
32
+ "tui.prompt.append", "tui.command.execute", "tui.toast.show"
33
+ ];
34
+
35
+ // Cache directory
36
+ const CACHE_DIR = "/tmp/opencode-boops/cache";
37
+
38
+ // Check if plugin is properly installed
39
+ // Plugin is installed if index.ts exists in node_modules or as a symlink in ~/.config/opencode/plugins
40
+ function isPluginInstalled() {
41
+ const configPluginPath = join(homedir(), ".config", "opencode", "plugins", "boops");
42
+ const nodeModulesPath = join(__dirname, "..", "index.ts");
43
+
44
+ // Check if running from ~/.config/opencode/plugins/boops (proper installation)
45
+ if (existsSync(join(configPluginPath, "index.ts"))) {
46
+ return true;
47
+ }
48
+
49
+ // Check if running from node_modules (npm install)
50
+ if (existsSync(nodeModulesPath)) {
51
+ return true;
52
+ }
53
+
54
+ return false;
55
+ }
56
+
57
+ const PLUGIN_INSTALLED = isPluginInstalled();
58
+
59
+ // Load current boops.toml config
60
+ function loadCurrentConfig() {
61
+ const configPath = join(homedir(), ".config", "opencode", "plugins", "boops", "boops.toml");
62
+ if (!existsSync(configPath)) {
63
+ return {};
64
+ }
65
+
66
+ try {
67
+ const content = readFileSync(configPath, "utf-8");
68
+ const config = {};
69
+
70
+ // Simple TOML parser for our use case
71
+ const lines = content.split("\n");
72
+ let currentSection = null;
73
+
74
+ for (const line of lines) {
75
+ const trimmed = line.trim();
76
+ if (!trimmed || trimmed.startsWith("#")) continue;
77
+
78
+ // Section header
79
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
80
+ currentSection = trimmed.slice(1, -1);
81
+ if (!config[currentSection]) config[currentSection] = {};
82
+ continue;
83
+ }
84
+
85
+ // Key = value (handle quoted keys like "session.error")
86
+ const match = trimmed.match(/^"?([^"=]+)"?\s*=\s*"?([^"]+)"?$/);
87
+ if (match && currentSection) {
88
+ const [, key, value] = match;
89
+ config[currentSection][key.trim()] = value.trim().replace(/"/g, "");
90
+ }
91
+ }
92
+
93
+ return config;
94
+ } catch (e) {
95
+ return {};
96
+ }
97
+ }
98
+
99
+ // Save config to boops.toml
100
+ function saveConfig(config) {
101
+ if (!PLUGIN_INSTALLED) {
102
+ console.error("\n⚠️ Cannot save: Plugin not installed");
103
+ console.error(" Install with: npm install opencode-plugin-boops");
104
+ console.error(" Or add to your OpenCode config");
105
+ return false;
106
+ }
107
+
108
+ const configPath = join(homedir(), ".config", "opencode", "plugins", "boops", "boops.toml");
109
+ let content = "# OpenCode Boops Plugin Configuration\n\n[sounds]\n";
110
+
111
+ if (config.sounds) {
112
+ for (const [event, sound] of Object.entries(config.sounds)) {
113
+ content += `"${event}" = "${sound}"\n`;
114
+ }
115
+ }
116
+
117
+ writeFileSync(configPath, content);
118
+ return true;
119
+ }
120
+
121
+ // Download and cache sound
122
+ async function downloadSound(url, id) {
123
+ mkdirSync(CACHE_DIR, { recursive: true });
124
+ const cachePath = join(CACHE_DIR, `${id}.ogg`);
125
+
126
+ if (existsSync(cachePath)) {
127
+ return cachePath;
128
+ }
129
+
130
+ return new Promise((resolve, reject) => {
131
+ const file = [];
132
+ https.get(url, (response) => {
133
+ response.on('data', (chunk) => file.push(chunk));
134
+ response.on('end', () => {
135
+ writeFileSync(cachePath, Buffer.concat(file));
136
+ resolve(cachePath);
137
+ });
138
+ }).on('error', reject);
139
+ });
140
+ }
141
+
142
+ // Track currently playing process
143
+ let currentPlayingProcess = null;
144
+
145
+ // Play sound using available player (non-blocking)
146
+ async function playSound(url, id, onComplete) {
147
+ // Stop any currently playing sound
148
+ if (currentPlayingProcess) {
149
+ try {
150
+ currentPlayingProcess.kill();
151
+ } catch (e) {
152
+ // Ignore errors if process already stopped
153
+ }
154
+ currentPlayingProcess = null;
155
+ }
156
+
157
+ const cachePath = await downloadSound(url, id);
158
+
159
+ // Try different players
160
+ const players = [
161
+ { cmd: 'ffplay', args: ['-nodisp', '-autoexit', '-loglevel', 'quiet', cachePath] },
162
+ { cmd: 'mpv', args: ['--no-video', '--really-quiet', cachePath] },
163
+ { cmd: 'cvlc', args: ['--play-and-exit', cachePath] },
164
+ { cmd: 'afplay', args: [cachePath] },
165
+ { cmd: 'paplay', args: [cachePath] }
166
+ ];
167
+
168
+ for (const player of players) {
169
+ try {
170
+ const proc = spawn(player.cmd, player.args, {
171
+ stdio: 'ignore',
172
+ detached: false
173
+ });
174
+
175
+ currentPlayingProcess = proc;
176
+
177
+ // Call onComplete when sound finishes
178
+ proc.on('exit', () => {
179
+ if (currentPlayingProcess === proc) {
180
+ currentPlayingProcess = null;
181
+ if (onComplete) onComplete();
182
+ }
183
+ });
184
+
185
+ return true;
186
+ } catch (e) {
187
+ continue;
188
+ }
189
+ }
190
+
191
+ return false;
192
+ }
193
+
194
+ // Group sounds by category
195
+ function groupByCategory(soundsList) {
196
+ const groups = {
197
+ positive: [],
198
+ negative: [],
199
+ neutral: [],
200
+ human: [],
201
+ weird: []
202
+ };
203
+
204
+ soundsList.forEach(sound => {
205
+ const cat = sound.category || 'neutral';
206
+ if (groups[cat]) {
207
+ groups[cat].push(sound);
208
+ }
209
+ });
210
+
211
+ return groups;
212
+ }
213
+
214
+ // Get currently configured sounds
215
+ function getCurrentSounds(config) {
216
+ const current = {};
217
+
218
+ for (const [section, values] of Object.entries(config)) {
219
+ if (values.sound) {
220
+ current[section] = values.sound;
221
+ }
222
+ }
223
+
224
+ return current;
225
+ }
226
+
227
+ // Main TUI
228
+ async function browse() {
229
+ // If in non-interactive mode (piped output), just list all
230
+ if (!process.stdout.isTTY) {
231
+ sounds.forEach((sound) => {
232
+ console.log(`${sound.id} - ${sound.name}`);
233
+ });
234
+ return;
235
+ }
236
+
237
+ const config = loadCurrentConfig();
238
+ const currentSounds = getCurrentSounds(config);
239
+ const grouped = groupByCategory(sounds);
240
+
241
+ // Get all unique tags from sounds (sorted alphabetically)
242
+ const allTags = [...new Set(sounds.flatMap(s => s.tags || []))].sort();
243
+
244
+ // Color palette for tags (low brightness/contrast)
245
+ // Using 256-color palette - these are muted/dark colors
246
+ const tagColorsDim = [
247
+ 60, // dark purple-blue
248
+ 61, // dark blue-purple
249
+ 62, // dark blue
250
+ 63, // dark blue-cyan
251
+ 64, // dark cyan
252
+ 65, // dark cyan-green
253
+ 66, // dark green-cyan
254
+ 67, // dark green
255
+ 68, // dark blue (current soft blue)
256
+ 96, // dark purple
257
+ 97, // dark violet
258
+ 98, // dark blue-purple
259
+ 99, // dark blue
260
+ 100, // dark cyan-blue
261
+ ];
262
+
263
+ // Brighter versions of the same colors for active state
264
+ const tagColorsBright = [
265
+ 99, // bright purple-blue
266
+ 105, // bright blue-purple
267
+ 111, // bright blue
268
+ 117, // bright blue-cyan
269
+ 123, // bright cyan
270
+ 87, // bright cyan-green
271
+ 86, // bright green-cyan
272
+ 120, // bright green
273
+ 75, // bright blue
274
+ 141, // bright purple
275
+ 147, // bright violet
276
+ 153, // bright blue-purple
277
+ 159, // bright blue
278
+ 195, // bright cyan-blue
279
+ ];
280
+
281
+ // Map each tag to a color
282
+ const tagColorMap = {};
283
+ allTags.forEach((tag, i) => {
284
+ const colorIndex = i % tagColorsDim.length;
285
+ tagColorMap[tag] = {
286
+ dim: tagColorsDim[colorIndex],
287
+ bright: tagColorsBright[colorIndex]
288
+ };
289
+ });
290
+
291
+ // TUI state
292
+ const state = {
293
+ selectedLine: 0,
294
+ scrollOffset: 0,
295
+ playingSound: null, // Track which sound is currently playing
296
+ showFullUrls: false, // Toggle for showing full URLs
297
+ searchMode: false, // Whether we're in search mode
298
+ searchQuery: '', // Current search query
299
+ selectedTagIndex: -1, // -1 means "all tags", >= 0 is index into allTags
300
+ searchResults: [], // Filtered results from last search
301
+ selectedTags: [], // Tags of the currently selected sound
302
+ targetSoundIndex: 0, // Target sound position (like column in text editor)
303
+ pickerMode: false, // Whether we're in event picker mode
304
+ pickerSelectedEvent: 0, // Selected event in picker
305
+ pickerSound: null, // The sound being assigned
306
+ hoveredTag: null, // Currently hovered tag on a sound line (for highlighting)
307
+ hoveredNavbarTagIndex: null, // Currently hovered tag in navbar (-1 for *, null for none, 0+ for tag index)
308
+ hoveredAttribution: false, // Whether the attribution link is hovered
309
+ hoveredUrl: null // Line index of sound whose URL is hovered
310
+ };
311
+
312
+ // Helper function to strip ANSI codes and get visible length
313
+ function getVisibleLength(str) {
314
+ return str
315
+ .replace(/\x1b\[[0-9;]*m/g, '') // Strip CSI sequences (colors)
316
+ .replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, '') // Strip OSC 8 hyperlinks
317
+ .length;
318
+ }
319
+
320
+ // Helper function to create a clickable hyperlink (OSC 8)
321
+ // This makes the terminal show a pointer cursor on hover
322
+ function makeHyperlink(url, text) {
323
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
324
+ }
325
+
326
+ // Helper function to ensure a string fits on one line
327
+ function ensureSingleLine(str, maxWidth) {
328
+ const visibleLen = getVisibleLength(str);
329
+ if (visibleLen <= maxWidth) {
330
+ return str;
331
+ }
332
+
333
+ // Need to truncate - preserve ANSI codes at the end
334
+ const reset = '\x1b[0m';
335
+ // Strip all ANSI, truncate, add ellipsis
336
+ const stripped = str.replace(/\x1b\[[0-9;]*m/g, '');
337
+ const truncated = stripped.slice(0, maxWidth - 1) + '…';
338
+ return truncated + reset;
339
+ }
340
+
341
+ // Helper function to render a single line
342
+ function renderLine(line, isSelected, termWidth, lineIndex, pickerWidth = 0) {
343
+ const reset = '\x1b[0m';
344
+
345
+ if (line.type === 'sound') {
346
+ // In picker mode: hide tags/URLs, dim non-selected sounds
347
+ if (state.pickerMode) {
348
+ const nameWithIcon = line.playIcon + line.name;
349
+ const nameWidth = 25;
350
+ let namePadded;
351
+ if (nameWithIcon.length > nameWidth) {
352
+ namePadded = nameWithIcon.slice(0, nameWidth - 1) + '…';
353
+ } else {
354
+ namePadded = nameWithIcon.padEnd(nameWidth);
355
+ }
356
+
357
+ const dimColor = isSelected ? '' : '\x1b[38;5;240m'; // Dim non-selected
358
+ const bgHighlight = isSelected ? '\x1b[48;5;235m' : '';
359
+ const availableWidth = termWidth - pickerWidth - line.indent.length;
360
+ const padding = ' '.repeat(Math.max(0, availableWidth - nameWidth));
361
+
362
+ console.log(`${line.indent}${bgHighlight}${dimColor}${namePadded}${reset}${padding}`);
363
+ return;
364
+ }
365
+
366
+ // Normal mode: Sound lines: name + tags + URL (strictly one line, truncate if needed)
367
+ const nameWithIcon = line.playIcon + line.name;
368
+ const nameWidth = 25;
369
+ // Truncate if too long, pad if too short
370
+ let namePadded;
371
+ if (nameWithIcon.length > nameWidth) {
372
+ namePadded = nameWithIcon.slice(0, nameWidth - 1) + '…';
373
+ } else {
374
+ namePadded = nameWithIcon.padEnd(nameWidth);
375
+ }
376
+
377
+ const indentLength = line.indent.length;
378
+ const spacing = ' '; // spacing between tags and URL
379
+
380
+ // URL styling - manual since OSC 8 doesn't work well with dynamic content
381
+ const isUrlHovered = state.hoveredUrl === lineIndex;
382
+ const urlColor = isUrlHovered ? '\x1b[38;5;75m' : '\x1b[38;5;237m'; // bright blue on hover, dim gray normally
383
+ const urlUnderline = isUrlHovered ? '\x1b[4m' : ''; // Underline only on hover
384
+
385
+ // Use displayUrl for rendering (shortened version)
386
+ const urlToDisplay = line.displayUrl || line.url;
387
+
388
+ // Calculate available space for tags + URL
389
+ // Format: indent(2) + name(25) + tags + spacing(3) + url + margin(2)
390
+ const remainingWidth = termWidth - indentLength - nameWidth - spacing.length - 2;
391
+
392
+ // Build tag strings
393
+ const tagData = [];
394
+ if (line.soundTags && line.soundTags.length > 0) {
395
+ for (const tag of line.soundTags) {
396
+ const colors = tagColorMap[tag];
397
+ const useBright = state.selectedTags.includes(tag);
398
+ const isHovered = isSelected && state.hoveredTag === tag;
399
+ const color = useBright ? colors.bright : colors.dim;
400
+ const bgHighlight = '\x1b[48;5;235m';
401
+ const underline = useBright ? '\x1b[4m' : ''; // Underline if highlighted (matching selected sound)
402
+ const formatted = isHovered
403
+ ? `${bgHighlight}\x1b[38;5;${color}m${underline}${tag}\x1b[0m`
404
+ : `\x1b[38;5;${color}m${underline}${tag}\x1b[0m`;
405
+ const visibleLength = tag.length; // just the tag, no trailing space
406
+ tagData.push({ formatted, visibleLength, tag });
407
+ }
408
+ }
409
+
410
+ // Calculate total tags length (with leading space if tags exist)
411
+ // Format: " tag1 tag2 tag3" (leading space + tags joined with spaces, no trailing space)
412
+ let tagsVisibleLength = 0;
413
+ if (tagData.length > 0) {
414
+ tagsVisibleLength = 1 + tagData.reduce((sum, t) => sum + t.visibleLength, 0) + (tagData.length - 1);
415
+ // 1 for leading space + sum of tag lengths + (n-1) spaces between tags
416
+ }
417
+
418
+ let tagsFormatted = '';
419
+ let urlFormatted = '';
420
+
421
+ // Minimum space reserved for URL
422
+ const minUrlLength = 15; // Shortened since we're showing just the ID now
423
+
424
+ // Check if tags + URL fit in available space
425
+ const urlVisibleLength = urlToDisplay.length;
426
+
427
+ if (tagsVisibleLength + urlVisibleLength > remainingWidth) {
428
+ // Need to truncate something
429
+ if (tagsVisibleLength + minUrlLength > remainingWidth) {
430
+ // Tags take too much space, truncate tags
431
+ const availableForTags = Math.max(0, remainingWidth - minUrlLength);
432
+ let usedLength = 1; // start with leading space
433
+ const truncatedTags = [];
434
+
435
+ for (let i = 0; i < tagData.length; i++) {
436
+ const tag = tagData[i];
437
+ const needSpace = i > 0 ? 1 : 0; // space before tag (except first)
438
+ const totalNeeded = usedLength + needSpace + tag.visibleLength;
439
+
440
+ if (totalNeeded + 2 <= availableForTags) { // +2 for " …"
441
+ truncatedTags.push(tag.formatted);
442
+ usedLength += needSpace + tag.visibleLength;
443
+ } else {
444
+ break;
445
+ }
446
+ }
447
+
448
+ if (truncatedTags.length > 0) {
449
+ tagsFormatted = ' ' + truncatedTags.join(' ') + ' \x1b[38;5;240m…\x1b[0m';
450
+ tagsVisibleLength = usedLength + 2; // +2 for " …"
451
+ } else {
452
+ tagsFormatted = '';
453
+ tagsVisibleLength = 0;
454
+ }
455
+ } else {
456
+ // Tags fit, keep them all
457
+ if (tagData.length > 0) {
458
+ tagsFormatted = ' ' + tagData.map(t => t.formatted).join(' ');
459
+ }
460
+ }
461
+
462
+ // Truncate URL to fit remaining space
463
+ const availableForUrl = remainingWidth - tagsVisibleLength;
464
+ if (availableForUrl > 3) {
465
+ const truncatedUrl = '…' + urlToDisplay.slice(-(availableForUrl - 1));
466
+ urlFormatted = `${urlColor}${urlUnderline}${truncatedUrl}${reset}`;
467
+ } else {
468
+ urlFormatted = `${urlColor}${urlUnderline}…${reset}`;
469
+ }
470
+ } else {
471
+ // Everything fits
472
+ if (tagData.length > 0) {
473
+ tagsFormatted = ' ' + tagData.map(t => t.formatted).join(' ');
474
+ }
475
+ urlFormatted = `${urlColor}${urlUnderline}${urlToDisplay}${reset}`;
476
+ }
477
+
478
+ // Build final output line - MUST be exactly termWidth visible characters
479
+ // Layout: indent + name + tags + [spacing to push URL to right] + URL + rightMargin
480
+ const rightMargin = 2; // Space before right edge
481
+ const urlVisibleLen = getVisibleLength(urlFormatted);
482
+
483
+ // Calculate spacing to right-align the URL
484
+ const usedSpace = indentLength + nameWidth + tagsVisibleLength + urlVisibleLen + rightMargin;
485
+ const middleSpacing = ' '.repeat(Math.max(1, termWidth - usedSpace));
486
+
487
+ let outputLine;
488
+ if (isSelected) {
489
+ // Apply background only to name (before tags), tags and URL render normally
490
+ const bgHighlight = '\x1b[48;5;235m';
491
+ const bgReset = '\x1b[0m';
492
+ outputLine = `${line.indent}${bgHighlight}${namePadded}${bgReset}${tagsFormatted}${middleSpacing}${urlFormatted}`;
493
+ } else {
494
+ outputLine = `${line.indent}${namePadded}${tagsFormatted}${middleSpacing}${urlFormatted}`;
495
+ }
496
+
497
+ // Pad to full width to ensure we clear old content
498
+ const actualVisibleLen = indentLength + nameWidth + tagsVisibleLength + middleSpacing.length + urlVisibleLen;
499
+ const finalPadding = ' '.repeat(Math.max(0, termWidth - actualVisibleLen));
500
+ outputLine += finalPadding;
501
+
502
+ // DEBUG: Log ALL sounds to see if any have tags
503
+ if (line.name === 'blocker') {
504
+ appendFileSync('/tmp/browse-debug.log',
505
+ `BLOCKER DEBUG:\n` +
506
+ ` tags: ${JSON.stringify(line.soundTags)}\n` +
507
+ ` urlVisibleLength: ${urlVisibleLength}\n` +
508
+ ` tagsVisibleLength: ${tagsVisibleLength}\n` +
509
+ ` remainingWidth: ${remainingWidth}\n` +
510
+ ` tagsVisibleLength + urlVisibleLength = ${tagsVisibleLength + urlVisibleLength}\n` +
511
+ ` Does it fit? ${tagsVisibleLength + urlVisibleLength <= remainingWidth}\n` +
512
+ ` tagsFormatted: "${tagsFormatted}"\n`
513
+ );
514
+ }
515
+
516
+ console.log(outputLine);
517
+ } else if (line.type === 'tag-result') {
518
+ const tagIcon = '🏷 ';
519
+ const tagText = `${tagIcon}${line.tag}`;
520
+ const tagColor = tagColorMap[line.tag];
521
+ const color = tagColor ? tagColor.bright : 99;
522
+ const availableWidth = termWidth - pickerWidth;
523
+
524
+ if (isSelected) {
525
+ const outputLine = `${line.indent}\x1b[48;5;235m\x1b[38;5;${color}m${tagText.padEnd(25)}${reset} \x1b[38;5;240m(select to filter by this tag)${reset}`;
526
+ console.log(ensureSingleLine(outputLine, availableWidth));
527
+ } else {
528
+ const outputLine = `${line.indent}\x1b[38;5;${color}m${tagText}${reset}`;
529
+ console.log(ensureSingleLine(outputLine, availableWidth));
530
+ }
531
+ } else if (line.type === 'category') {
532
+ const countColor = '\x1b[38;5;240m';
533
+ const availableWidth = termWidth - pickerWidth;
534
+ const padding = ' '.repeat(Math.max(0, availableWidth - line.text.length - line.count.length - 1));
535
+
536
+ if (isSelected) {
537
+ console.log(`\x1b[48;5;235m${line.text}${padding}${countColor}${line.count}${reset}`);
538
+ } else {
539
+ console.log(`${line.text}${padding}${countColor}${line.count}${reset}`);
540
+ }
541
+ } else if (line.type === 'config') {
542
+ const valueColor = '\x1b[38;5;240m';
543
+ const availableWidth = termWidth - pickerWidth;
544
+ const padding = ' '.repeat(Math.max(0, availableWidth - getVisibleLength(line.text + ' ' + line.value)));
545
+ console.log(`${line.color}${line.text} ${valueColor}${line.value}${reset}${padding}`);
546
+ } else if (line.type === 'tag-bar') {
547
+ const bgHighlight = '\x1b[48;5;235m';
548
+ const availableWidth = termWidth - pickerWidth;
549
+
550
+ // Virtualize the tag bar - build all lines in memory first
551
+ // Target: each line is exactly availableWidth visible characters
552
+ // Format: leftMargin (2 spaces) + content + padding = availableWidth
553
+ const leftMargin = ' '; // 2 spaces
554
+ const minRightMargin = 1; // At least 1 space on the right
555
+ const maxContentWidth = availableWidth - leftMargin.length - minRightMargin;
556
+
557
+ const tagBarLines = [];
558
+ let currentLineContent = ''; // Content without margins
559
+ let currentLineVisible = 0;
560
+
561
+ // Helper to finish a line and start a new one
562
+ const finishLine = () => {
563
+ if (currentLineContent.length > 0) {
564
+ // Pad the full line to exactly availableWidth
565
+ const fullLineVisible = leftMargin.length + currentLineVisible;
566
+ const paddingNeeded = availableWidth - fullLineVisible;
567
+ const padding = ' '.repeat(Math.max(0, paddingNeeded));
568
+ tagBarLines.push(leftMargin + currentLineContent + padding);
569
+ }
570
+ };
571
+
572
+ // Start first line with "Tags: [*]" or "Tags: *"
573
+ const headerText = 'Tags: ';
574
+ currentLineContent += headerText;
575
+ currentLineVisible += headerText.length;
576
+
577
+ const allSelected = state.selectedTagIndex === -1;
578
+ const allHovered = state.hoveredNavbarTagIndex === -1;
579
+
580
+ let starText;
581
+ let starVisibleLength;
582
+ if (allSelected) {
583
+ starText = `${bgHighlight}\x1b[1m[*]${reset}`;
584
+ starVisibleLength = 3; // [*]
585
+ } else if (allHovered) {
586
+ starText = `${bgHighlight}\x1b[2m*${reset}`;
587
+ starVisibleLength = 1; // *
588
+ } else {
589
+ starText = `\x1b[2m*${reset}`;
590
+ starVisibleLength = 1; // *
591
+ }
592
+
593
+ currentLineContent += starText + ' ';
594
+ currentLineVisible += starVisibleLength + 2; // star + 2 spaces
595
+
596
+ // Add all tags, wrapping when needed
597
+ for (let i = 0; i < line.tagItems.length; i++) {
598
+ const tag = line.tagItems[i];
599
+ const colors = tagColorMap[tag];
600
+ const isSelectedFilter = state.selectedTagIndex === i;
601
+ const isHighlighted = state.selectedTags.includes(tag);
602
+ const isHovered = state.hoveredNavbarTagIndex === i;
603
+
604
+ const useBright = isSelectedFilter || isHighlighted;
605
+ const color = useBright ? colors.bright : colors.dim;
606
+
607
+ let tagText;
608
+ let tagVisibleLength;
609
+ if (isSelectedFilter) {
610
+ tagText = `${bgHighlight}\x1b[38;5;${color}m[${tag}]${reset}`;
611
+ tagVisibleLength = tag.length + 2; // [tag]
612
+ } else if (isHovered) {
613
+ tagText = `${bgHighlight}\x1b[38;5;${color}m${tag}${reset}`;
614
+ tagVisibleLength = tag.length;
615
+ } else {
616
+ tagText = `\x1b[38;5;${color}m${tag}${reset}`;
617
+ tagVisibleLength = tag.length;
618
+ }
619
+
620
+ const totalTagLength = tagVisibleLength + 2; // tag + 2 trailing spaces
621
+
622
+ // Check if adding this tag would exceed max content width
623
+ if (currentLineVisible + totalTagLength > maxContentWidth) {
624
+ // Current line is full, finish it and start a new one
625
+ finishLine();
626
+
627
+ // Start new line with just this tag
628
+ currentLineContent = tagText + ' ';
629
+ currentLineVisible = totalTagLength;
630
+ } else {
631
+ // Add to current line
632
+ currentLineContent += tagText + ' ';
633
+ currentLineVisible += totalTagLength;
634
+ }
635
+ }
636
+
637
+ // Finish the last line
638
+ finishLine();
639
+
640
+ // Output all lines - they are guaranteed to be exactly availableWidth wide
641
+ tagBarLines.forEach(l => console.log(l));
642
+ } else if (line.type === 'separator') {
643
+ process.stdout.write('\x1b[2K\n'); // Clear line for separator
644
+ } else {
645
+ const availableWidth = termWidth - pickerWidth;
646
+ const outputLine = `${line.color}${line.text}${reset}`;
647
+ // Pad to full width to clear any old content
648
+ const visibleLen = getVisibleLength(outputLine);
649
+ const padding = ' '.repeat(Math.max(0, availableWidth - visibleLen));
650
+ console.log(outputLine + padding);
651
+ }
652
+ }
653
+
654
+ // Build display lines
655
+ function buildLines() {
656
+ const lines = [];
657
+ const indent = ' '; // Left padding for all content
658
+ const termWidth = process.stdout.columns || 80;
659
+ const reset = '\x1b[0m';
660
+
661
+ // Get filtered sound list first (we need the count for the header)
662
+ let soundList = sounds;
663
+ let displayItems = []; // Will contain both sounds and tag results
664
+
665
+ // If we have a search query, search globally and include matching tags
666
+ if (state.searchQuery) {
667
+ const query = state.searchQuery.toLowerCase();
668
+
669
+ // Search all sounds by name only (ignore current tag filter during search)
670
+ const matchingSounds = sounds.filter(sound =>
671
+ sound.name.toLowerCase().includes(query) ||
672
+ sound.id.toLowerCase().includes(query)
673
+ );
674
+
675
+ // Search for matching tags by tag name
676
+ const matchingTags = allTags.filter(tag => tag.toLowerCase().includes(query));
677
+
678
+ // Combine sounds and tags, mark them as different types
679
+ const soundItems = matchingSounds.map(sound => ({ type: 'sound', data: sound }));
680
+ const tagItems = matchingTags.map(tag => ({ type: 'tag', data: tag }));
681
+
682
+ // Merge and sort alphabetically
683
+ displayItems = [...soundItems, ...tagItems].sort((a, b) => {
684
+ const aName = a.type === 'sound' ? a.data.name : a.data;
685
+ const bName = b.type === 'sound' ? b.data.name : b.data;
686
+ return aName.localeCompare(bName);
687
+ });
688
+
689
+ // Save for when exiting search mode
690
+ if (!state.searchMode) {
691
+ displayItems = state.searchResults;
692
+ } else {
693
+ state.searchResults = displayItems;
694
+ }
695
+ } else {
696
+ // No search - filter by selected tag normally
697
+ if (state.selectedTagIndex >= 0) {
698
+ const selectedTag = allTags[state.selectedTagIndex];
699
+ soundList = soundList.filter(s => s.tags && s.tags.includes(selectedTag));
700
+ }
701
+
702
+ // Sort alphabetically
703
+ soundList = [...soundList].sort((a, b) => a.name.localeCompare(b.name));
704
+ displayItems = soundList.map(sound => ({ type: 'sound', data: sound }));
705
+ }
706
+
707
+ // Display items (sounds and tags)
708
+ for (const item of displayItems) {
709
+ if (item.type === 'sound') {
710
+ const sound = item.data;
711
+ const playIcon = state.playingSound === sound.id ? '▶ ' : ' ';
712
+ const soundTags = sound.tags ? [...sound.tags] : []; // Keep original order (by confidence)
713
+
714
+ // Extract just the ID from notificationsounds.com URLs
715
+ // Format: https://notificationsounds.com/storage/sounds/file-sounds-775-alarma.ogg
716
+ // Display: 775-alarma.ogg
717
+ let displayUrl = sound.url;
718
+ const match = sound.url.match(/file-sounds-(.+\.ogg)$/);
719
+ if (match) {
720
+ displayUrl = match[1]; // Just the ID part
721
+ }
722
+
723
+ lines.push({
724
+ type: 'sound',
725
+ playIcon,
726
+ name: sound.name,
727
+ soundTags,
728
+ url: sound.url, // Full URL for opening
729
+ displayUrl, // Shortened URL for display
730
+ indent: `${indent}`,
731
+ color: '\x1b[0m',
732
+ sound
733
+ });
734
+ } else if (item.type === 'tag') {
735
+ const tag = item.data;
736
+ lines.push({
737
+ type: 'tag-result',
738
+ tag,
739
+ indent: `${indent}`,
740
+ color: '\x1b[0m'
741
+ });
742
+ }
743
+ }
744
+
745
+ // Now build the header with sound count
746
+ // Count sounds in displayItems (exclude tag-result items)
747
+ const filteredSoundCount = displayItems.filter(item => item.type === 'sound').length;
748
+ const totalSoundCount = sounds.length;
749
+ const countText = `[${filteredSoundCount}/${totalSoundCount}]`;
750
+ const countColor = '\x1b[38;5;240m'; // dim gray
751
+
752
+ const headerTitleText = 'OpenCode Boops';
753
+ const headerTitleColor = '\x1b[38;5;110m'; // dark soft blue (steel blue)
754
+ const headerTitle = `${indent}${headerTitleColor}${headerTitleText}${reset}`;
755
+ const githubUrl = 'https://github.com/towc/opencode-plugin-boops';
756
+ const githubColor = '\x1b[38;5;237m'; // same gray as URLs
757
+ const rightMargin = 1; // Space before right edge
758
+
759
+ // Build header with count and optionally GitHub URL
760
+ // Format: " OpenCode Boops [N/M] ...spacing... github-url "
761
+ const headerTitleWithCount = `${headerTitle} ${countColor}${countText}${reset}`;
762
+ const headerTitleVisibleLen = indent.length + headerTitleText.length + 1 + countText.length; // indent + title + space + count
763
+
764
+ const minSpacing = 3; // minimum spaces between title+count and link
765
+ const headerWithLink = termWidth >= headerTitleVisibleLen + minSpacing + githubUrl.length + rightMargin;
766
+
767
+ let headerText;
768
+ if (headerWithLink) {
769
+ const spacing = ' '.repeat(termWidth - headerTitleVisibleLen - githubUrl.length - rightMargin);
770
+ const githubLink = makeHyperlink(githubUrl, `${githubColor}${githubUrl}${reset}`);
771
+ headerText = `${headerTitleWithCount}${spacing}${githubLink}`;
772
+ } else {
773
+ headerText = headerTitleWithCount;
774
+ }
775
+
776
+ // Insert header at the beginning
777
+ lines.unshift({ type: 'separator', text: '' }); // separator before tag bar
778
+ lines.unshift({
779
+ type: 'tag-bar',
780
+ tagItems: allTags
781
+ }); // tag bar
782
+ lines.unshift({ type: 'separator', text: '' }); // separator after header
783
+ lines.unshift({ type: 'header', text: headerText, color: '\x1b[0m' }); // header (color already in text)
784
+
785
+ return lines;
786
+ }
787
+
788
+ // Render event picker mode
789
+ function renderPicker() {
790
+ const termHeight = process.stdout.rows || 24;
791
+ const termWidth = process.stdout.columns || 80;
792
+ const config = loadCurrentConfig();
793
+
794
+ process.stdout.write('\x1b[H'); // Move cursor to home without clearing
795
+ process.stdout.write('\x1b[?25l'); // Hide cursor
796
+
797
+ // Header
798
+ console.log(' \x1b[1mSelect Event Hook for: ' + state.pickerSound.name + '\x1b[0m');
799
+ console.log('');
800
+
801
+ // Instructions
802
+ if (PLUGIN_INSTALLED) {
803
+ console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current Ctrl+S save & exit ESC cancel\x1b[0m');
804
+ } else {
805
+ console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current ESC cancel \x1b[38;5;208m(save disabled - plugin not installed)\x1b[0m');
806
+ }
807
+ console.log('');
808
+
809
+ // Event list with current assignments
810
+ // Layout: header(1) + blank(1) + instructions(1) + blank(1) + events(?) + separator(1) + footer(1)
811
+ // Fixed lines: 4 (top) + 2 (bottom) = 6
812
+ const fixedLines = 6;
813
+ const maxVisibleEvents = Math.max(1, termHeight - fixedLines);
814
+ const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
815
+ const endIndex = Math.min(scrollOffset + maxVisibleEvents, availableEvents.length);
816
+
817
+ for (let i = scrollOffset; i < endIndex; i++) {
818
+ const event = availableEvents[i];
819
+ const isSelected = i === state.pickerSelectedEvent;
820
+ const currentSound = config.sounds ? config.sounds[event] : null;
821
+
822
+ const selectionBg = isSelected ? '\x1b[48;5;235m' : '';
823
+ const reset = '\x1b[0m';
824
+
825
+ let line = ` ${selectionBg}${event.padEnd(30)}${reset}`;
826
+
827
+ if (currentSound) {
828
+ // Try to find the sound in our database
829
+ const soundEntry = sounds.find(s => s.id === currentSound || s.name === currentSound);
830
+ if (soundEntry && soundEntry.tags && soundEntry.tags.length > 0) {
831
+ const tagStr = soundEntry.tags.slice(0, 3).join(' ');
832
+ line += ` \x1b[38;5;240m${soundEntry.name} [${tagStr}]${reset}`;
833
+ } else {
834
+ line += ` \x1b[38;5;240m${currentSound}${reset}`;
835
+ }
836
+ } else {
837
+ line += ` \x1b[38;5;237m(not set)${reset}`;
838
+ }
839
+
840
+ console.log(ensureSingleLine(line, termWidth));
841
+ }
842
+
843
+ // Fill remaining space to push footer to bottom
844
+ const actualEventsShown = endIndex - scrollOffset;
845
+ // linesUsed: header(1) + blank(1) + instructions(1) + blank(1) + events + separator(1) + footer(1)
846
+ const linesUsed = 4 + actualEventsShown + 2;
847
+ const remaining = Math.max(0, termHeight - linesUsed);
848
+
849
+ for (let i = 0; i < remaining; i++) {
850
+ process.stdout.write('\x1b[2K\n'); // Clear line
851
+ }
852
+
853
+ // Footer (2 lines, but last line should not have trailing newline)
854
+ process.stdout.write(' ' + '─'.repeat(termWidth - 4) + '\n');
855
+ process.stdout.write(' \x1b[38;5;240mPress Ctrl+S to assign "' + state.pickerSound.name + '" to selected event\x1b[0m');
856
+
857
+ // Clear from cursor to end of screen
858
+ process.stdout.write('\x1b[J');
859
+ }
860
+
861
+ function render() {
862
+ const lines = buildLines();
863
+ const termHeight = process.stdout.rows || 24;
864
+ const termWidth = process.stdout.columns || 80;
865
+
866
+ // Calculate picker box width (0 if not in picker mode)
867
+ const pickerWidth = state.pickerMode ? Math.min(40, Math.floor(termWidth * 0.4)) : 0;
868
+
869
+ // Calculate how many lines the tag bar will take (matching render logic EXACTLY)
870
+ const tagBarLine = lines.find(l => l.type === 'tag-bar');
871
+ let tagBarLineCount = 1;
872
+ if (tagBarLine) {
873
+ // Simulate EXACTLY the same wrapping logic as renderLine for tag-bar
874
+ const maxContentWidth = termWidth - 2 - 1; // leftMargin(2) + minRightMargin(1)
875
+
876
+ // Start with "Tags: [*] " or "Tags: * "
877
+ const headerText = 'Tags: ';
878
+ let currentLineVisible = headerText.length;
879
+
880
+ const allSelected = state.selectedTagIndex === -1;
881
+ const starVisibleLength = allSelected ? 3 : 1; // [*] or *
882
+ currentLineVisible += starVisibleLength + 2; // star + 2 spaces
883
+
884
+ let lineCount = 1;
885
+
886
+ for (let i = 0; i < tagBarLine.tagItems.length; i++) {
887
+ const tag = tagBarLine.tagItems[i];
888
+ const isSelectedFilter = state.selectedTagIndex === i;
889
+ const isHighlighted = state.selectedTags.includes(tag);
890
+ const isHovered = state.hoveredNavbarTagIndex === i;
891
+
892
+ let tagVisibleLength;
893
+ if (isSelectedFilter) {
894
+ tagVisibleLength = tag.length + 2; // [tag]
895
+ } else {
896
+ tagVisibleLength = tag.length;
897
+ }
898
+
899
+ const totalTagLength = tagVisibleLength + 2; // tag + 2 trailing spaces
900
+
901
+ if (currentLineVisible + totalTagLength > maxContentWidth) {
902
+ // Would wrap to new line
903
+ lineCount++;
904
+ currentLineVisible = totalTagLength;
905
+ } else {
906
+ currentLineVisible += totalTagLength;
907
+ }
908
+ }
909
+
910
+ tagBarLineCount = lineCount;
911
+ }
912
+
913
+ // Sticky header/tag bar for tall terminals
914
+ const stickyNavbar = termHeight >= 40;
915
+ const contentStartIndex = 4; // Header, sep, tagbar, sep are first 4 items
916
+
917
+ // Calculate available lines for sound items
918
+ // Footer section always takes: separator line(1) + controls(1) + blank line(1) = 3 lines
919
+ // Top blank line: 1 line
920
+ const fixedFooterLines = 3;
921
+ const topBlankLine = 1;
922
+
923
+ let availableContentLines;
924
+ if (stickyNavbar) {
925
+ // In sticky mode: header/tagbar are always visible (not part of scrollable area)
926
+ // Fixed header takes: blank(1) + header(1) + sep(1) + tag-bar(tagBarLineCount) + sep(1)
927
+ const fixedHeaderLines = topBlankLine + 2 + tagBarLineCount + 1;
928
+ availableContentLines = termHeight - fixedHeaderLines - fixedFooterLines;
929
+ } else {
930
+ // In normal mode: everything scrolls together, so header counts against content
931
+ const fixedHeaderLines = 2 + tagBarLineCount + 1;
932
+ availableContentLines = termHeight - topBlankLine - fixedFooterLines;
933
+ }
934
+
935
+ const visibleLines = availableContentLines;
936
+
937
+ // Update selected tags for highlighting
938
+ const selectedLine = lines[state.selectedLine];
939
+ if (selectedLine?.type === 'sound' && selectedLine.soundTags) {
940
+ state.selectedTags = selectedLine.soundTags;
941
+ } else {
942
+ state.selectedTags = [];
943
+ }
944
+
945
+ // Adjust scroll to keep selection visible
946
+ if (state.selectedLine < state.scrollOffset) {
947
+ state.scrollOffset = state.selectedLine;
948
+ }
949
+ if (state.selectedLine >= state.scrollOffset + visibleLines) {
950
+ state.scrollOffset = state.selectedLine - visibleLines + 1;
951
+ }
952
+
953
+ // In sticky mode, never scroll above the content start
954
+ if (stickyNavbar && state.scrollOffset < contentStartIndex) {
955
+ state.scrollOffset = contentStartIndex;
956
+ }
957
+
958
+
959
+
960
+ // Move cursor to home position (top-left) without clearing
961
+ // (clearing causes flicker; we'll overwrite old content)
962
+ process.stdout.write('\x1b[H');
963
+
964
+ // DEBUG: Count lines as we output
965
+ let actualLinesOutput = 0;
966
+ let debugLog = '';
967
+ const origConsoleLog = console.log;
968
+ console.log = function(...args) {
969
+ actualLinesOutput++;
970
+ debugLog += `console.log #${actualLinesOutput}\n`;
971
+ return origConsoleLog.apply(console, args);
972
+ };
973
+
974
+ // Hide cursor during render, will show it at the end if in search mode
975
+ process.stdout.write('\x1b[?25l');
976
+
977
+ let start, end;
978
+
979
+ // Add blank line at the top
980
+ process.stdout.write('\x1b[2K\n');
981
+
982
+ if (stickyNavbar) {
983
+ // Always show header/tagbar, scroll only content
984
+ // Render header (lines 0-3)
985
+ for (let i = 0; i < contentStartIndex; i++) {
986
+ if (i < lines.length) {
987
+ renderLine(lines[i], i === state.selectedLine, termWidth, i, pickerWidth);
988
+ }
989
+ }
990
+
991
+ // Check if we need scroll indicators
992
+ const needsTopIndicator = state.scrollOffset > contentStartIndex;
993
+ const potentialEnd = Math.max(contentStartIndex, state.scrollOffset) + visibleLines;
994
+ const needsBottomIndicator = potentialEnd < lines.length;
995
+
996
+ // Calculate how many content lines we can show (reserve space for indicators)
997
+ let availableForContent = visibleLines;
998
+ if (needsTopIndicator) availableForContent--;
999
+ if (needsBottomIndicator) availableForContent--;
1000
+
1001
+ // Show top scroll indicator if needed
1002
+ if (needsTopIndicator) {
1003
+ const indicator = '\x1b[38;5;240m ...\x1b[0m';
1004
+ const padding = ' '.repeat(Math.max(0, termWidth - 5)); // 5 = 2 spaces + 3 dots
1005
+ console.log(indicator + padding);
1006
+ }
1007
+
1008
+ // Render scrollable content
1009
+ start = Math.max(contentStartIndex, state.scrollOffset);
1010
+ end = Math.min(start + availableForContent, lines.length);
1011
+ } else {
1012
+ // Normal mode: scroll everything including header/tagbar
1013
+ // Check if we need scroll indicators
1014
+ const needsTopIndicator = state.scrollOffset > 0;
1015
+ const potentialEnd = state.scrollOffset + visibleLines;
1016
+ const needsBottomIndicator = potentialEnd < lines.length;
1017
+
1018
+ // Calculate how many content lines we can show (reserve space for indicators)
1019
+ let availableForContent = visibleLines;
1020
+ if (needsTopIndicator) availableForContent--;
1021
+ if (needsBottomIndicator) availableForContent--;
1022
+
1023
+ // Show top scroll indicator if needed
1024
+ if (needsTopIndicator) {
1025
+ const indicator = '\x1b[38;5;240m ...\x1b[0m';
1026
+ const padding = ' '.repeat(Math.max(0, termWidth - 5)); // 5 = 2 spaces + 3 dots
1027
+ console.log(indicator + padding);
1028
+ }
1029
+
1030
+ start = state.scrollOffset;
1031
+ end = Math.min(start + availableForContent, lines.length);
1032
+ }
1033
+
1034
+ for (let i = start; i < end; i++) {
1035
+ const line = lines[i];
1036
+ const isSelected = i === state.selectedLine && (line.type === 'category' || line.type === 'sound' || line.type === 'tag-result');
1037
+ renderLine(line, isSelected, termWidth, i, pickerWidth);
1038
+ }
1039
+
1040
+ // Show scroll indicator if there's content below
1041
+ if (end < lines.length) {
1042
+ const indicator = '\x1b[38;5;240m ...\x1b[0m';
1043
+ const padding = ' '.repeat(Math.max(0, termWidth - 5)); // 5 = 2 spaces + 3 dots
1044
+ console.log(indicator + padding);
1045
+ }
1046
+
1047
+ // Fill remaining vertical space to push controls to bottom
1048
+ // Count what we actually rendered
1049
+ const hasTopIndicator = (stickyNavbar && state.scrollOffset > contentStartIndex) ||
1050
+ (!stickyNavbar && state.scrollOffset > 0);
1051
+ const hasBottomIndicator = end < lines.length;
1052
+ const contentLinesRendered = end - start;
1053
+ const indicatorsRendered = (hasTopIndicator ? 1 : 0) + (hasBottomIndicator ? 1 : 0);
1054
+
1055
+ let totalRendered;
1056
+ if (stickyNavbar) {
1057
+ // In sticky mode, we rendered: header(4 lines) + content + indicators
1058
+ // But visibleLines only counts content area, so don't include header
1059
+ totalRendered = contentLinesRendered + indicatorsRendered;
1060
+ } else {
1061
+ // In normal mode, content includes everything
1062
+ totalRendered = contentLinesRendered + indicatorsRendered;
1063
+ }
1064
+
1065
+ // Fill the rest to keep footer at bottom
1066
+ const remainingLines = visibleLines - totalRendered;
1067
+ const clearLine = '\x1b[2K'; // Clear entire line
1068
+
1069
+ for (let i = 0; i < remainingLines; i++) {
1070
+ process.stdout.write(clearLine + '\n');
1071
+ }
1072
+
1073
+ // Draw picker box overlay if in picker mode
1074
+ if (state.pickerMode && state.pickerSound) {
1075
+ const config = loadCurrentConfig();
1076
+ const boxX = termWidth - pickerWidth;
1077
+ const boxStartY = stickyNavbar ? (topBlankLine + 4) : (topBlankLine + 1); // Start below header in sticky mode, else near top
1078
+ const boxHeight = Math.min(availableEvents.length + 4, termHeight - boxStartY - fixedFooterLines);
1079
+
1080
+ // Calculate scroll offset for event list
1081
+ const maxVisibleEvents = boxHeight - 4; // Reserve space for header(2) + footer(2)
1082
+ const eventScrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
1083
+ const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents, availableEvents.length);
1084
+
1085
+ // Draw each line of the picker box
1086
+ for (let row = 0; row < boxHeight; row++) {
1087
+ const screenY = boxStartY + row;
1088
+ process.stdout.write(`\x1b[${screenY};${boxX}H`); // Position cursor
1089
+
1090
+ if (row === 0) {
1091
+ // Top border with title
1092
+ const title = ` Assign to Event `;
1093
+ const titleStart = Math.floor((pickerWidth - title.length) / 2);
1094
+ const leftBorder = '┌' + '─'.repeat(titleStart - 1);
1095
+ const rightBorder = '─'.repeat(pickerWidth - titleStart - title.length - 1) + '┐';
1096
+ process.stdout.write(`\x1b[38;5;240m${leftBorder}\x1b[0m${title}\x1b[38;5;240m${rightBorder}\x1b[0m`);
1097
+ } else if (row === 1) {
1098
+ // Sound name
1099
+ const soundName = state.pickerSound.name.length > pickerWidth - 4
1100
+ ? state.pickerSound.name.slice(0, pickerWidth - 7) + '...'
1101
+ : state.pickerSound.name;
1102
+ const padding = ' '.repeat(Math.max(0, pickerWidth - soundName.length - 4));
1103
+ process.stdout.write(`\x1b[38;5;240m│\x1b[0m \x1b[1m${soundName}\x1b[0m${padding} \x1b[38;5;240m│\x1b[0m`);
1104
+ } else if (row === 2) {
1105
+ // Separator
1106
+ process.stdout.write(`\x1b[38;5;240m├${'─'.repeat(pickerWidth - 2)}┤\x1b[0m`);
1107
+ } else if (row === boxHeight - 1) {
1108
+ // Bottom border
1109
+ process.stdout.write(`\x1b[38;5;240m└${'─'.repeat(pickerWidth - 2)}┘\x1b[0m`);
1110
+ } else {
1111
+ // Event list item
1112
+ const eventIndex = eventScrollOffset + (row - 3);
1113
+ if (eventIndex < eventEndIndex) {
1114
+ const event = availableEvents[eventIndex];
1115
+ const isSelected = eventIndex === state.pickerSelectedEvent;
1116
+ const currentSound = config.sounds ? config.sounds[event] : null;
1117
+
1118
+ const bg = isSelected ? '\x1b[48;5;235m' : '';
1119
+ const eventText = event.length > pickerWidth - 6
1120
+ ? event.slice(0, pickerWidth - 9) + '...'
1121
+ : event.padEnd(pickerWidth - 6);
1122
+
1123
+ const indicator = currentSound ? '•' : ' ';
1124
+ const indicatorColor = currentSound ? '\x1b[38;5;76m' : '';
1125
+
1126
+ process.stdout.write(`\x1b[38;5;240m│\x1b[0m${bg}${indicatorColor}${indicator}\x1b[0m${bg} ${eventText} \x1b[0m\x1b[38;5;240m│\x1b[0m`);
1127
+ } else {
1128
+ // Empty line
1129
+ process.stdout.write(`\x1b[38;5;240m│${' '.repeat(pickerWidth - 2)}│\x1b[0m`);
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ // Move cursor back to the start of footer line (after all content + fill)
1135
+ // The cursor should be at the line where we want to draw the footer
1136
+ // We don't need to reposition since we drew the picker with absolute positioning
1137
+ }
1138
+
1139
+ // Controls footer (2 lines, no trailing newline)
1140
+ const sep = '─'.repeat(termWidth);
1141
+ process.stdout.write(`\x1b[38;5;240m${sep}\x1b[0m\n`);
1142
+
1143
+ if (state.searchQuery || state.searchMode) {
1144
+ // Search mode: show search box on left, hints on right
1145
+ const searchBox = ` /${state.searchQuery}`;
1146
+ const searchHint = '\x1b[38;5;240m/ to refine esc to clear\x1b[0m';
1147
+ const searchBoxVisible = 2 + state.searchQuery.length; // " /" + query
1148
+ const hintVisible = getVisibleLength(searchHint);
1149
+
1150
+ // Calculate spacing to push hint to the right
1151
+ const spacing = ' '.repeat(Math.max(1, termWidth - searchBoxVisible - hintVisible - 1));
1152
+
1153
+ if (state.searchMode) {
1154
+ // Actively typing: write the line and position cursor
1155
+ process.stdout.write(searchBox);
1156
+ const cursorPos = searchBoxVisible; // Current cursor position after searchBox
1157
+ process.stdout.write(spacing + searchHint);
1158
+
1159
+ // Save cursor position
1160
+ process.stdout.write('\x1b[s');
1161
+
1162
+ // Write blank line and clear rest of screen
1163
+ process.stdout.write('\n\x1b[2K');
1164
+ process.stdout.write('\x1b[J');
1165
+
1166
+ // Restore cursor position and move to after search query
1167
+ process.stdout.write('\x1b[u');
1168
+ const moveBack = spacing.length + hintVisible;
1169
+ process.stdout.write(`\x1b[${moveBack}D`); // Move cursor left
1170
+ process.stdout.write('\x1b[?25h'); // Show cursor
1171
+ } else {
1172
+ // Viewing results: just write the line, no cursor
1173
+ process.stdout.write(searchBox + spacing + searchHint);
1174
+
1175
+ // Add blank line at the bottom
1176
+ process.stdout.write('\n\x1b[2K');
1177
+
1178
+ // Clear from cursor to end of screen to remove any leftover content
1179
+ process.stdout.write('\x1b[J');
1180
+ }
1181
+ } else {
1182
+ // Normal mode or picker mode: show controls with attribution
1183
+ let controls;
1184
+ if (state.pickerMode) {
1185
+ controls = PLUGIN_INSTALLED
1186
+ ? ' \x1b[38;5;240m↑/↓ select event enter play ctrl+s save ←/esc close\x1b[0m'
1187
+ : ' \x1b[38;5;240m↑/↓ select event enter play ←/esc close \x1b[38;5;208m(save disabled)\x1b[0m';
1188
+ } else {
1189
+ controls = ' \x1b[38;5;240m↑/↓ navigate ←/→ tags / search s assign enter play q quit\x1b[0m';
1190
+ }
1191
+
1192
+ // Attribution with manual hover/click
1193
+ const attributionText = 'Sounds from notificationsounds.com';
1194
+ const attributionUrl = 'https://notificationsounds.com';
1195
+ const attributionColor = state.hoveredAttribution ? '\x1b[38;5;75m' : '\x1b[38;5;237m'; // bright blue on hover, dim gray normally
1196
+ const attributionUnderline = state.hoveredAttribution ? '\x1b[4m' : ''; // Underline only on hover
1197
+ const attribution = `${attributionColor}${attributionUnderline}${attributionText}\x1b[0m`;
1198
+
1199
+ const controlsVisible = getVisibleLength(controls);
1200
+ const attributionVisible = attributionText.length;
1201
+
1202
+ // Check if we have space for attribution
1203
+ if (termWidth >= controlsVisible + attributionVisible + 3) {
1204
+ const spacing = ' '.repeat(termWidth - controlsVisible - attributionVisible - 1);
1205
+ process.stdout.write(controls + spacing + attribution);
1206
+ } else {
1207
+ process.stdout.write(controls);
1208
+ }
1209
+
1210
+ // Add blank line at the bottom
1211
+ process.stdout.write('\n\x1b[2K');
1212
+
1213
+ // Clear from cursor to end of screen to remove any leftover content
1214
+ process.stdout.write('\x1b[J');
1215
+ }
1216
+
1217
+ // DEBUG: Restore and report
1218
+ console.log = origConsoleLog;
1219
+ appendFileSync('/tmp/browse-debug.log',
1220
+ `console.log calls: ${actualLinesOutput}, termHeight: ${termHeight}, difference: ${actualLinesOutput - termHeight}\n` +
1221
+ `Expected: header(1) + sep(1) + tagbar(${tagBarLineCount}) + sep(1) + content + indicator + fill + footer(1) = ${termHeight}\n\n` +
1222
+ debugLog
1223
+ );
1224
+ }
1225
+
1226
+ // Set up raw mode for keyboard input
1227
+ process.stdin.setRawMode(true);
1228
+ process.stdin.resume();
1229
+ process.stdin.setEncoding('utf8');
1230
+
1231
+ // Use alternate screen buffer to preserve user's terminal
1232
+ process.stdout.write('\x1b[?1049h'); // Enable alternate screen buffer
1233
+ process.stdout.write('\x1b[2J'); // Clear alternate screen
1234
+ process.stdout.write('\x1b[H'); // Move cursor to home
1235
+
1236
+ // Enable mouse reporting
1237
+ process.stdout.write('\x1b[?1000h'); // Enable mouse click events
1238
+ process.stdout.write('\x1b[?1002h'); // Enable mouse drag events
1239
+ process.stdout.write('\x1b[?1003h'); // Enable mouse movement tracking (hover)
1240
+ process.stdout.write('\x1b[?1015h'); // Enable extended mouse mode
1241
+ process.stdout.write('\x1b[?1006h'); // Enable SGR mouse mode
1242
+
1243
+ render();
1244
+
1245
+ // Cleanup function to disable mouse and stop sound on exit
1246
+ const cleanup = () => {
1247
+ // Stop any currently playing sound
1248
+ if (currentPlayingProcess) {
1249
+ try {
1250
+ currentPlayingProcess.kill();
1251
+ } catch (e) {
1252
+ // Ignore errors if process already stopped
1253
+ }
1254
+ currentPlayingProcess = null;
1255
+ }
1256
+
1257
+ // Disable mouse reporting
1258
+ process.stdout.write('\x1b[?1000l');
1259
+ process.stdout.write('\x1b[?1002l');
1260
+ process.stdout.write('\x1b[?1003l');
1261
+ process.stdout.write('\x1b[?1015l');
1262
+ process.stdout.write('\x1b[?1006l');
1263
+
1264
+ // Restore normal screen buffer
1265
+ process.stdout.write('\x1b[?1049l');
1266
+
1267
+ // Show cursor
1268
+ process.stdout.write('\x1b[?25h');
1269
+ };
1270
+
1271
+ process.on('exit', cleanup);
1272
+ process.on('SIGINT', () => {
1273
+ cleanup();
1274
+ process.exit(0);
1275
+ });
1276
+
1277
+ // Handle keyboard input
1278
+ process.stdin.on('data', async (key) => {
1279
+ // Handle Ctrl+C always
1280
+ if (key === '\u0003') {
1281
+ process.exit(0);
1282
+ }
1283
+
1284
+ // Handle mouse events (SGR format: \x1b[<button;x;y;M/m)
1285
+ const mouseMatch = key.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
1286
+ if (mouseMatch) {
1287
+ const [, button, x, y, action] = mouseMatch;
1288
+ const mouseX = parseInt(x) - 1; // Convert to 0-based
1289
+ const mouseY = parseInt(y) - 1;
1290
+ const isPress = action === 'M';
1291
+ const buttonNum = parseInt(button);
1292
+
1293
+ const lines = buildLines();
1294
+ const termHeight = process.stdout.rows || 24;
1295
+ const termWidth = process.stdout.columns || 80;
1296
+
1297
+ // Calculate tag bar height (matching render logic exactly)
1298
+ const tagBarLine = lines.find(l => l.type === 'tag-bar');
1299
+ let tagBarLineCount = 1;
1300
+ if (tagBarLine) {
1301
+ let currentLineLength = getVisibleLength(' Tags: ') + (state.selectedTagIndex === -1 ? 4 : 3);
1302
+ let lineCount = 1;
1303
+
1304
+ for (let i = 0; i < tagBarLine.tagItems.length; i++) {
1305
+ const tag = tagBarLine.tagItems[i];
1306
+ const isSelectedFilter = state.selectedTagIndex === i;
1307
+ const tagLength = isSelectedFilter ? tag.length + 4 : tag.length + 2;
1308
+
1309
+ if (currentLineLength + tagLength > termWidth - 2) {
1310
+ lineCount++;
1311
+ currentLineLength = 2 + tagLength;
1312
+ } else {
1313
+ currentLineLength += tagLength;
1314
+ }
1315
+ }
1316
+
1317
+ tagBarLineCount = lineCount;
1318
+ }
1319
+
1320
+ // Check if we're in sticky navbar mode
1321
+ const stickyNavbar = termHeight >= 40;
1322
+ const contentStartIndexInLines = 4; // Header, sep, tagbar, sep are first 4 items
1323
+
1324
+ // Actual screen layout from render() (0-indexed Y positions):
1325
+ // In normal mode:
1326
+ // - Y=0: blank line (padding)
1327
+ // - Y=1: optional top scroll indicator "..." (if scrollOffset > 0)
1328
+ // - Then: lines from scrollOffset onwards (header, tagbar, content all scroll together)
1329
+ // In sticky navbar mode:
1330
+ // - Y=0: blank line (padding)
1331
+ // - Y=1-4: header, sep, tagbar (N lines), sep (always visible)
1332
+ // - Then: optional top scroll indicator "..." (if scrollOffset > contentStartIndexInLines)
1333
+ // - Then: content lines from scrollOffset onwards
1334
+
1335
+ const topBlankLineOffset = 1; // Account for blank line at top
1336
+ let currentY = topBlankLineOffset;
1337
+ let contentStartY;
1338
+
1339
+ if (stickyNavbar) {
1340
+ // Sticky mode: header/tagbar always visible at top
1341
+ // blank(1) + Header(1) + sep(1) + tagbar(N) + sep(1) = 1 + 2 + tagBarLineCount + 1
1342
+ const headerHeight = 2 + tagBarLineCount + 1;
1343
+ currentY = topBlankLineOffset + headerHeight;
1344
+
1345
+ // Top indicator if scrolled past header
1346
+ const hasTopIndicator = state.scrollOffset > contentStartIndexInLines;
1347
+ if (hasTopIndicator) currentY++;
1348
+
1349
+ contentStartY = currentY;
1350
+ } else {
1351
+ // Normal mode: everything scrolls together
1352
+ const hasTopIndicator = state.scrollOffset > 0;
1353
+ if (hasTopIndicator) currentY++;
1354
+
1355
+ // On screen, content starts after: blank + top indicator + header + sep + tagbar + sep
1356
+ contentStartY = currentY + 2 + tagBarLineCount + 1;
1357
+ }
1358
+
1359
+ if (mouseY < contentStartY) {
1360
+ // Click/hover is in header/tag bar area
1361
+ // Calculate tag bar position based on mode
1362
+ let tagBarStartY, tagBarEndY;
1363
+ if (stickyNavbar) {
1364
+ // In sticky mode: Y=0 is blank, Y=1 is header, Y=2 is sep, Y=3+ is tag bar
1365
+ tagBarStartY = topBlankLineOffset + 2;
1366
+ tagBarEndY = topBlankLineOffset + 2 + tagBarLineCount;
1367
+ } else {
1368
+ // In normal mode: after blank line + optional top indicator, then header + sep
1369
+ const topIndicatorLines = state.scrollOffset > 0 ? 1 : 0;
1370
+ tagBarStartY = topBlankLineOffset + topIndicatorLines + 2; // blank + indicator + header + sep
1371
+ tagBarEndY = tagBarStartY + tagBarLineCount;
1372
+ }
1373
+
1374
+ if (mouseY >= tagBarStartY && mouseY < tagBarEndY) {
1375
+ // In tag bar - detect which tag was clicked/hovered
1376
+ // Reconstruct tag positions
1377
+ const tagBarY = mouseY - tagBarStartY;
1378
+ let currentLine = 0;
1379
+ let currentX = getVisibleLength(' Tags: ') + (state.selectedTagIndex === -1 ? 4 : 3);
1380
+ let foundHover = false;
1381
+
1382
+ for (let i = -1; i < allTags.length; i++) {
1383
+ let tag, tagLength;
1384
+
1385
+ if (i === -1) {
1386
+ // The * tag
1387
+ tag = null;
1388
+ tagLength = 0; // Already counted in initial currentX
1389
+
1390
+ if (currentLine === tagBarY) {
1391
+ const startX = getVisibleLength(' Tags: ');
1392
+ const endX = startX + (state.selectedTagIndex === -1 ? 4 : 3);
1393
+
1394
+ if (mouseX >= startX && mouseX < endX) {
1395
+ // Clicked/hovered on * tag
1396
+ if (isPress && buttonNum === 0) {
1397
+ state.selectedTagIndex = -1;
1398
+ state.searchMode = false;
1399
+ state.searchQuery = '';
1400
+ state.searchResults = [];
1401
+ state.selectedLine = 0;
1402
+ state.targetSoundIndex = 0;
1403
+ render();
1404
+ return;
1405
+ } else if (buttonNum >= 32 && buttonNum <= 35) {
1406
+ // Hover
1407
+ if (state.hoveredNavbarTagIndex !== -1) {
1408
+ state.hoveredNavbarTagIndex = -1;
1409
+ render();
1410
+ }
1411
+ foundHover = true;
1412
+ return;
1413
+ }
1414
+ }
1415
+ }
1416
+ continue;
1417
+ }
1418
+
1419
+ tag = allTags[i];
1420
+ const isSelectedFilter = state.selectedTagIndex === i;
1421
+ // Assume all tags take the same space (tag.length + 2) regardless of selection
1422
+ // The brackets seem to be rendered within the allocated space, not adding to it
1423
+ tagLength = tag.length + 2; // tag + 2 trailing spaces
1424
+
1425
+ // Check if need to wrap
1426
+ if (currentX + tagLength > termWidth - 2) {
1427
+ currentLine++;
1428
+ currentX = 2 + tagLength;
1429
+ } else {
1430
+ currentX += tagLength;
1431
+ }
1432
+
1433
+ // Check if this is the right line and position
1434
+ if (currentLine === tagBarY) {
1435
+ const tagStartX = currentX - tagLength;
1436
+ const tagEndX = currentX;
1437
+
1438
+ if (mouseX >= tagStartX && mouseX < tagEndX) {
1439
+ // Clicked/hovered on this tag
1440
+ if (isPress && buttonNum === 0) {
1441
+ // Click - switch to this tag
1442
+ state.selectedTagIndex = i;
1443
+ state.searchMode = false;
1444
+ state.searchQuery = '';
1445
+ state.searchResults = [];
1446
+ state.selectedLine = 0;
1447
+ state.targetSoundIndex = 0;
1448
+ render();
1449
+ return;
1450
+ } else if (buttonNum >= 32 && buttonNum <= 35) {
1451
+ // Hover
1452
+ if (state.hoveredNavbarTagIndex !== i) {
1453
+ state.hoveredNavbarTagIndex = i;
1454
+ render();
1455
+ }
1456
+ foundHover = true;
1457
+ return;
1458
+ }
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ // If we're in the tag bar but not over any tag, clear hover
1464
+ if (!foundHover && state.hoveredNavbarTagIndex !== null) {
1465
+ state.hoveredNavbarTagIndex = null;
1466
+ render();
1467
+ }
1468
+ } else {
1469
+ // Not in tag bar area, clear navbar hover
1470
+ if (state.hoveredNavbarTagIndex !== null) {
1471
+ state.hoveredNavbarTagIndex = null;
1472
+ render();
1473
+ }
1474
+ }
1475
+ return;
1476
+ }
1477
+
1478
+ // Check if mouse is in footer area (last 2 lines: separator + controls)
1479
+ // Footer is at: termHeight - 3 (separator line) and termHeight - 2 (controls line)
1480
+ // Account for bottom blank line
1481
+ const footerSeparatorY = termHeight - 3;
1482
+ const footerControlsY = termHeight - 2;
1483
+
1484
+ if (mouseY === footerControlsY && !state.searchMode) {
1485
+ // Mouse is on the controls/attribution line
1486
+ // Check if hovering over attribution link (on the right)
1487
+ const controls = ' ↑/↓ navigate ←/→ tags / search enter play q quit';
1488
+ const attributionText = 'Sounds from notificationsounds.com';
1489
+ const controlsVisible = controls.length;
1490
+ const attributionVisible = attributionText.length;
1491
+
1492
+ if (termWidth >= controlsVisible + attributionVisible + 3) {
1493
+ // Attribution is visible
1494
+ const attributionStartX = termWidth - attributionVisible - 1;
1495
+ const attributionEndX = termWidth - 1;
1496
+
1497
+ if (mouseX >= attributionStartX && mouseX < attributionEndX) {
1498
+ // Hovering/clicking on attribution
1499
+ if (buttonNum >= 32 && buttonNum <= 35) {
1500
+ // Mouse movement - hover
1501
+ if (!state.hoveredAttribution) {
1502
+ state.hoveredAttribution = true;
1503
+ render();
1504
+ }
1505
+ return;
1506
+ } else if (isPress && buttonNum === 0) {
1507
+ // Left click - open link
1508
+ const url = 'https://notificationsounds.com';
1509
+ const openCmd = process.platform === 'darwin' ? 'open' :
1510
+ process.platform === 'win32' ? 'start' : 'xdg-open';
1511
+ exec(`${openCmd} ${url}`);
1512
+ return;
1513
+ }
1514
+ } else {
1515
+ // Not hovering over attribution
1516
+ if (state.hoveredAttribution) {
1517
+ state.hoveredAttribution = false;
1518
+ render();
1519
+ }
1520
+ }
1521
+ }
1522
+ } else {
1523
+ // Not on footer controls line
1524
+ if (state.hoveredAttribution) {
1525
+ state.hoveredAttribution = false;
1526
+ render();
1527
+ }
1528
+ }
1529
+
1530
+ // Mouse is in content area
1531
+ // Calculate which line in the lines array is at this screen position
1532
+ const contentRelativeY = mouseY - contentStartY;
1533
+
1534
+ let hoveredLineIndex;
1535
+ if (stickyNavbar) {
1536
+ // In sticky mode, the first visible content line is at Math.max(contentStartIndexInLines, scrollOffset)
1537
+ const firstVisibleContentIndex = Math.max(contentStartIndexInLines, state.scrollOffset);
1538
+ hoveredLineIndex = firstVisibleContentIndex + contentRelativeY;
1539
+ } else {
1540
+ // In normal mode, scrollOffset tells us the first visible line (including header)
1541
+ hoveredLineIndex = state.scrollOffset + contentRelativeY;
1542
+ }
1543
+
1544
+ // Mouse move/hover (button 32+modifier means movement)
1545
+ if (buttonNum >= 32 && buttonNum <= 35) {
1546
+ if (hoveredLineIndex >= 0 && hoveredLineIndex < lines.length) {
1547
+ const hoveredLine = lines[hoveredLineIndex];
1548
+
1549
+ // Only highlight interactive lines
1550
+ if (hoveredLine.type === 'sound' || hoveredLine.type === 'tag-result') {
1551
+ let needsRender = false;
1552
+
1553
+ if (state.selectedLine !== hoveredLineIndex) {
1554
+ state.selectedLine = hoveredLineIndex;
1555
+ needsRender = true;
1556
+
1557
+ // Update target index if it's a sound
1558
+ if (hoveredLine.type === 'sound') {
1559
+ const soundLines = lines.filter(l => l.type === 'sound');
1560
+ const currentSoundIndex = soundLines.findIndex((_, idx) => {
1561
+ const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
1562
+ return soundLineIndex === state.selectedLine;
1563
+ });
1564
+ if (currentSoundIndex >= 0) {
1565
+ state.targetSoundIndex = currentSoundIndex;
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ // Detect which tag is hovered (if any) on sound lines
1571
+ if (hoveredLine.type === 'sound') {
1572
+ const indent = 2;
1573
+ const nameWidth = 25;
1574
+ const tagsStartX = indent + nameWidth + 1; // +1 for leading space before tags
1575
+
1576
+ let hoveredTag = null;
1577
+ if (mouseX >= tagsStartX && hoveredLine.soundTags && hoveredLine.soundTags.length > 0) {
1578
+ let tagX = tagsStartX;
1579
+ for (const tag of hoveredLine.soundTags) {
1580
+ const tagLength = tag.length;
1581
+ if (mouseX >= tagX && mouseX < tagX + tagLength) {
1582
+ hoveredTag = tag;
1583
+ break;
1584
+ }
1585
+ tagX += tagLength + 1; // +1 for space between tags
1586
+ }
1587
+ }
1588
+
1589
+ if (state.hoveredTag !== hoveredTag) {
1590
+ state.hoveredTag = hoveredTag;
1591
+ needsRender = true;
1592
+ }
1593
+
1594
+ // Detect if hovering over URL (on the right side)
1595
+ const displayUrl = hoveredLine.displayUrl || hoveredLine.url;
1596
+ const urlStartX = termWidth - displayUrl.length - 2; // -2 for right margin
1597
+ const urlEndX = termWidth - 2;
1598
+
1599
+ let hoveredUrl = null;
1600
+ if (mouseX >= urlStartX && mouseX < urlEndX) {
1601
+ hoveredUrl = hoveredLineIndex;
1602
+ }
1603
+
1604
+ if (state.hoveredUrl !== hoveredUrl) {
1605
+ state.hoveredUrl = hoveredUrl;
1606
+ needsRender = true;
1607
+ }
1608
+ } else {
1609
+ if (state.hoveredTag !== null) {
1610
+ state.hoveredTag = null;
1611
+ needsRender = true;
1612
+ }
1613
+ if (state.hoveredUrl !== null) {
1614
+ state.hoveredUrl = null;
1615
+ needsRender = true;
1616
+ }
1617
+ }
1618
+
1619
+ if (needsRender) {
1620
+ render();
1621
+ }
1622
+ }
1623
+ }
1624
+ return;
1625
+ }
1626
+
1627
+ if (isPress) {
1628
+ // Left click (button 0) - select and play sound, or activate tag
1629
+ if (buttonNum === 0) {
1630
+ const clickedLineIndex = hoveredLineIndex; // Use the same calculation as hover
1631
+ if (clickedLineIndex >= 0 && clickedLineIndex < lines.length) {
1632
+ const clickedLine = lines[clickedLineIndex];
1633
+
1634
+ // Sound line - check if clicking on a tag or the sound itself
1635
+ if (clickedLine.type === 'sound') {
1636
+ state.selectedLine = clickedLineIndex;
1637
+
1638
+ // Update target index
1639
+ const soundLines = lines.filter(l => l.type === 'sound');
1640
+ const currentSoundIndex = soundLines.findIndex((_, idx) => {
1641
+ const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
1642
+ return soundLineIndex === state.selectedLine;
1643
+ });
1644
+ if (currentSoundIndex >= 0) {
1645
+ state.targetSoundIndex = currentSoundIndex;
1646
+ }
1647
+
1648
+ // Check if click is on a tag (approximate - tags start after name at position ~27)
1649
+ const indent = 2;
1650
+ const nameWidth = 25;
1651
+ const tagsStartX = indent + nameWidth;
1652
+
1653
+ // Check if click is on URL (on the right side)
1654
+ const displayUrl = clickedLine.displayUrl || clickedLine.url;
1655
+ const urlStartX = termWidth - displayUrl.length - 2; // -2 for right margin
1656
+ const urlEndX = termWidth - 2;
1657
+
1658
+ if (mouseX >= urlStartX && mouseX < urlEndX) {
1659
+ // Clicked on URL - open it in browser
1660
+ const url = clickedLine.url; // Use full URL, not displayUrl
1661
+ const openCmd = process.platform === 'darwin' ? 'open' :
1662
+ process.platform === 'win32' ? 'start' : 'xdg-open';
1663
+ exec(`${openCmd} "${url}"`);
1664
+ return;
1665
+ }
1666
+
1667
+ if (mouseX >= tagsStartX && clickedLine.soundTags && clickedLine.soundTags.length > 0) {
1668
+ // Clicked in tag area - calculate which tag
1669
+ let tagX = tagsStartX + 1; // +1 for leading space
1670
+ for (const tag of clickedLine.soundTags) {
1671
+ const tagLength = tag.length + 1; // tag + space
1672
+ if (mouseX >= tagX && mouseX < tagX + tagLength) {
1673
+ // Clicked on this tag - switch to it
1674
+ const tagIndex = allTags.indexOf(tag);
1675
+ if (tagIndex >= 0) {
1676
+ state.selectedTagIndex = tagIndex;
1677
+ state.searchMode = false;
1678
+ state.searchQuery = '';
1679
+ state.searchResults = [];
1680
+ state.selectedLine = 0;
1681
+ state.targetSoundIndex = 0;
1682
+ render();
1683
+ return;
1684
+ }
1685
+ }
1686
+ tagX += tagLength + 1; // +1 for space between tags
1687
+ }
1688
+ }
1689
+
1690
+ // Not on a tag or URL, play the sound
1691
+ state.playingSound = clickedLine.sound.id;
1692
+ render();
1693
+ playSound(clickedLine.sound.url, clickedLine.sound.id, () => {
1694
+ if (state.playingSound === clickedLine.sound.id) {
1695
+ state.playingSound = null;
1696
+ render();
1697
+ }
1698
+ });
1699
+ }
1700
+ // Tag result - switch to that tag filter
1701
+ else if (clickedLine.type === 'tag-result') {
1702
+ const tagIndex = allTags.indexOf(clickedLine.tag);
1703
+ if (tagIndex >= 0) {
1704
+ state.selectedTagIndex = tagIndex;
1705
+ state.searchMode = false;
1706
+ state.searchQuery = '';
1707
+ state.searchResults = [];
1708
+ state.selectedLine = 0;
1709
+ state.targetSoundIndex = 0;
1710
+ render();
1711
+ }
1712
+ }
1713
+ }
1714
+ }
1715
+ // Scroll up (button 64) - scroll viewport up (like Vim ^y)
1716
+ else if (buttonNum === 64) {
1717
+ const minScroll = stickyNavbar ? contentStartIndexInLines : 0;
1718
+ if (state.scrollOffset > minScroll) {
1719
+ // Scroll by 3 lines for faster scrolling
1720
+ state.scrollOffset = Math.max(minScroll, state.scrollOffset - 3);
1721
+
1722
+ // Update selection to first visible interactive line
1723
+ const firstVisibleIndex = state.scrollOffset;
1724
+ let newSelection = state.selectedLine;
1725
+
1726
+ // Find first interactive line in viewport
1727
+ for (let i = firstVisibleIndex; i < lines.length; i++) {
1728
+ const line = lines[i];
1729
+ if (line.type === 'sound' || line.type === 'tag-result') {
1730
+ newSelection = i;
1731
+ break;
1732
+ }
1733
+ }
1734
+
1735
+ state.selectedLine = newSelection;
1736
+
1737
+ // Update target sound index
1738
+ if (lines[newSelection] && lines[newSelection].type === 'sound') {
1739
+ const soundLines = lines.filter(l => l.type === 'sound');
1740
+ const soundIndex = soundLines.findIndex((_, idx) => {
1741
+ const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
1742
+ return soundLineIndex === newSelection;
1743
+ });
1744
+ if (soundIndex >= 0) {
1745
+ state.targetSoundIndex = soundIndex;
1746
+ }
1747
+ }
1748
+
1749
+ render();
1750
+ }
1751
+ }
1752
+ // Scroll down (button 65) - scroll viewport down (like Vim ^e)
1753
+ else if (buttonNum === 65) {
1754
+ // Calculate available lines for content
1755
+ const fixedHeaderLines = stickyNavbar ? (2 + tagBarLineCount + 1) : 0;
1756
+ const fixedFooterLines = 2;
1757
+ const availableContentLines = termHeight - fixedHeaderLines - fixedFooterLines;
1758
+ const maxScroll = Math.max(0, lines.length - availableContentLines);
1759
+
1760
+ if (state.scrollOffset < maxScroll) {
1761
+ // Scroll by 3 lines for faster scrolling
1762
+ state.scrollOffset = Math.min(maxScroll, state.scrollOffset + 3);
1763
+
1764
+ // Update selection to first visible interactive line
1765
+ const firstVisibleIndex = state.scrollOffset;
1766
+ let newSelection = state.selectedLine;
1767
+
1768
+ // Find first interactive line in viewport
1769
+ for (let i = firstVisibleIndex; i < lines.length; i++) {
1770
+ const line = lines[i];
1771
+ if (line.type === 'sound' || line.type === 'tag-result') {
1772
+ newSelection = i;
1773
+ break;
1774
+ }
1775
+ }
1776
+
1777
+ state.selectedLine = newSelection;
1778
+
1779
+ // Update target sound index
1780
+ if (lines[newSelection] && lines[newSelection].type === 'sound') {
1781
+ const soundLines = lines.filter(l => l.type === 'sound');
1782
+ const soundIndex = soundLines.findIndex((_, idx) => {
1783
+ const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
1784
+ return soundLineIndex === newSelection;
1785
+ });
1786
+ if (soundIndex >= 0) {
1787
+ state.targetSoundIndex = soundIndex;
1788
+ }
1789
+ }
1790
+
1791
+ render();
1792
+ }
1793
+ }
1794
+ }
1795
+ return;
1796
+ }
1797
+
1798
+ // Picker mode: handle event selection
1799
+ if (state.pickerMode) {
1800
+ if (key === '\u001b' || key === '\u001b[D' || key === 'h') {
1801
+ // ESC or Left arrow or 'h' - exit picker
1802
+ state.pickerMode = false;
1803
+ state.pickerSound = null;
1804
+ render();
1805
+ return;
1806
+ } else if (key === '\u001b[A' || key === 'k') {
1807
+ // Up arrow
1808
+ if (state.pickerSelectedEvent > 0) {
1809
+ state.pickerSelectedEvent--;
1810
+ }
1811
+ render();
1812
+ return;
1813
+ } else if (key === '\u001b[B' || key === 'j') {
1814
+ // Down arrow
1815
+ if (state.pickerSelectedEvent < availableEvents.length - 1) {
1816
+ state.pickerSelectedEvent++;
1817
+ }
1818
+ render();
1819
+ return;
1820
+ } else if (key === '\r' || key === ' ') {
1821
+ // Enter/Space - play current sound for selected event
1822
+ const config = loadCurrentConfig();
1823
+ const event = availableEvents[state.pickerSelectedEvent];
1824
+ const currentSound = config.sounds ? config.sounds[event] : null;
1825
+ if (currentSound) {
1826
+ const soundEntry = sounds.find(s => s.id === currentSound || s.name === currentSound);
1827
+ if (soundEntry) {
1828
+ state.playingSound = soundEntry.id;
1829
+ render();
1830
+ playSound(soundEntry.url, soundEntry.id, () => {
1831
+ state.playingSound = null;
1832
+ render();
1833
+ });
1834
+ }
1835
+ }
1836
+ return;
1837
+ } else if (key === '\u0013') {
1838
+ // Ctrl+S - save assignment
1839
+ const config = loadCurrentConfig();
1840
+ const event = availableEvents[state.pickerSelectedEvent];
1841
+
1842
+ if (!config.sounds) config.sounds = {};
1843
+ config.sounds[event] = state.pickerSound.id;
1844
+
1845
+ // Save to config file
1846
+ const saved = saveConfig(config);
1847
+
1848
+ if (saved) {
1849
+ state.pickerMode = false;
1850
+ state.pickerSound = null;
1851
+ }
1852
+ render();
1853
+ return;
1854
+ }
1855
+ return; // Ignore other keys in picker mode
1856
+ }
1857
+
1858
+ const lines = buildLines();
1859
+
1860
+ // Search mode: capture everything except ESC, Enter, and arrows
1861
+ if (state.searchMode) {
1862
+ if (key === '\u001b') {
1863
+ // ESC - cancel search completely
1864
+ state.searchMode = false;
1865
+ state.searchQuery = '';
1866
+ state.searchResults = [];
1867
+ state.selectedLine = 0;
1868
+ render();
1869
+ return;
1870
+ } else if (key === '\r' || key === '\u001b[A' || key === '\u001b[B') {
1871
+ // Enter or Up/Down arrows - exit search box, keep results
1872
+ const query = state.searchQuery.toLowerCase();
1873
+
1874
+ // Get global search results (sounds + tags)
1875
+ const matchingSounds = sounds.filter(sound =>
1876
+ sound.name.toLowerCase().includes(query) ||
1877
+ sound.id.toLowerCase().includes(query)
1878
+ );
1879
+
1880
+ const matchingTags = allTags.filter(tag => tag.toLowerCase().includes(query));
1881
+
1882
+ // Combine and sort
1883
+ const soundItems = matchingSounds.map(sound => ({ type: 'sound', data: sound }));
1884
+ const tagItems = matchingTags.map(tag => ({ type: 'tag', data: tag }));
1885
+ const displayItems = [...soundItems, ...tagItems].sort((a, b) => {
1886
+ const aName = a.type === 'sound' ? a.data.name : a.data;
1887
+ const bName = b.type === 'sound' ? b.data.name : b.data;
1888
+ return aName.localeCompare(bName);
1889
+ });
1890
+
1891
+ state.searchResults = displayItems;
1892
+ state.searchMode = false;
1893
+ state.selectedLine = 0;
1894
+ render();
1895
+
1896
+ // If it was an arrow key, handle the navigation
1897
+ if (key === '\u001b[A') {
1898
+ // Will be handled in next iteration
1899
+ process.stdin.emit('data', key);
1900
+ } else if (key === '\u001b[B') {
1901
+ process.stdin.emit('data', key);
1902
+ }
1903
+ return;
1904
+ } else if (key === '\u007f') {
1905
+ // Backspace
1906
+ if (state.searchQuery.length === 0) {
1907
+ // Exit search mode if query is empty
1908
+ state.searchMode = false;
1909
+ state.searchQuery = '';
1910
+ state.searchResults = [];
1911
+ render();
1912
+ return;
1913
+ }
1914
+ state.searchQuery = state.searchQuery.slice(0, -1);
1915
+ render();
1916
+ return;
1917
+ } else if (key.length === 1 && key >= ' ' && key <= '~') {
1918
+ // Add printable character to search
1919
+ state.searchQuery += key;
1920
+ render();
1921
+ return;
1922
+ }
1923
+ return; // Ignore other keys in search mode
1924
+ }
1925
+
1926
+ // Normal mode key handling
1927
+ switch (key) {
1928
+ case '\u001b': // ESC key
1929
+ // Clear search if there's an active search
1930
+ if (state.searchQuery) {
1931
+ state.searchMode = false;
1932
+ state.searchQuery = '';
1933
+ state.searchResults = [];
1934
+ state.selectedLine = 0;
1935
+ render();
1936
+ }
1937
+ break;
1938
+
1939
+ case 'q':
1940
+ process.exit(0);
1941
+ break;
1942
+
1943
+ case '\u001b[A': // Up arrow
1944
+ case 'k':
1945
+ if (state.selectedLine > 0) {
1946
+ state.selectedLine--;
1947
+ // Skip non-interactive lines
1948
+ while (state.selectedLine > 0 &&
1949
+ lines[state.selectedLine].type !== 'category' &&
1950
+ lines[state.selectedLine].type !== 'sound' &&
1951
+ lines[state.selectedLine].type !== 'tag-result') {
1952
+ state.selectedLine--;
1953
+ }
1954
+
1955
+ // Ensure we're on an interactive line, if not find the first one
1956
+ const currentLine = lines[state.selectedLine];
1957
+ if (currentLine &&
1958
+ currentLine.type !== 'category' &&
1959
+ currentLine.type !== 'sound' &&
1960
+ currentLine.type !== 'tag-result') {
1961
+ // Find first interactive line
1962
+ for (let i = 0; i < lines.length; i++) {
1963
+ const line = lines[i];
1964
+ if (line.type === 'sound' || line.type === 'tag-result' || line.type === 'category') {
1965
+ state.selectedLine = i;
1966
+ break;
1967
+ }
1968
+ }
1969
+ }
1970
+
1971
+ // Update target index when manually navigating
1972
+ const soundLines = lines.filter(l => l.type === 'sound');
1973
+ const currentSoundIndex = soundLines.findIndex((_, idx) => {
1974
+ const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
1975
+ return soundLineIndex === state.selectedLine;
1976
+ });
1977
+ if (currentSoundIndex >= 0) {
1978
+ state.targetSoundIndex = currentSoundIndex;
1979
+ }
1980
+ }
1981
+ render();
1982
+ break;
1983
+
1984
+ case '\u001b[B': // Down arrow
1985
+ case 'j':
1986
+ if (state.selectedLine < lines.length - 1) {
1987
+ state.selectedLine++;
1988
+ // Skip non-interactive lines
1989
+ while (state.selectedLine < lines.length - 1 &&
1990
+ lines[state.selectedLine].type !== 'category' &&
1991
+ lines[state.selectedLine].type !== 'sound' &&
1992
+ lines[state.selectedLine].type !== 'tag-result') {
1993
+ state.selectedLine++;
1994
+ }
1995
+ // Update target index when manually navigating
1996
+ const soundLines = lines.filter(l => l.type === 'sound');
1997
+ const currentSoundIndex = soundLines.findIndex((_, idx) => {
1998
+ const soundLineIndex = lines.findIndex(l => l === soundLines[idx]);
1999
+ return soundLineIndex === state.selectedLine;
2000
+ });
2001
+ if (currentSoundIndex >= 0) {
2002
+ state.targetSoundIndex = currentSoundIndex;
2003
+ }
2004
+ }
2005
+ render();
2006
+ break;
2007
+
2008
+ case '\u001b[C': // Right arrow
2009
+ case 'l':
2010
+ // Navigate tags right, maintaining sound position
2011
+ if (state.selectedTagIndex < allTags.length - 1) {
2012
+ state.selectedTagIndex++;
2013
+
2014
+ // Find the sound at targetSoundIndex in new view
2015
+ const newLines = buildLines();
2016
+ const soundLines = newLines.filter(l => l.type === 'sound');
2017
+
2018
+ if (soundLines.length > 0) {
2019
+ // Try to go to targetSoundIndex, or last sound if not enough
2020
+ const targetIndex = Math.min(state.targetSoundIndex, soundLines.length - 1);
2021
+ const targetSoundLine = soundLines[targetIndex];
2022
+ state.selectedLine = newLines.indexOf(targetSoundLine);
2023
+ } else {
2024
+ state.selectedLine = 0;
2025
+ }
2026
+ }
2027
+ render();
2028
+ break;
2029
+
2030
+ case '\u001b[D': // Left arrow
2031
+ case 'h':
2032
+ // Navigate tags left, maintaining sound position
2033
+ if (state.selectedTagIndex > -1) {
2034
+ state.selectedTagIndex--;
2035
+
2036
+ // Find the sound at targetSoundIndex in new view
2037
+ const newLines = buildLines();
2038
+ const soundLines = newLines.filter(l => l.type === 'sound');
2039
+
2040
+ if (soundLines.length > 0) {
2041
+ // Try to go to targetSoundIndex, or last sound if not enough
2042
+ const targetIndex = Math.min(state.targetSoundIndex, soundLines.length - 1);
2043
+ const targetSoundLine = soundLines[targetIndex];
2044
+ state.selectedLine = newLines.indexOf(targetSoundLine);
2045
+ } else {
2046
+ state.selectedLine = 0;
2047
+ }
2048
+ }
2049
+ render();
2050
+ break;
2051
+
2052
+ case '/':
2053
+ // Enter search mode (or re-enter if results are showing)
2054
+ state.searchMode = true;
2055
+ if (!state.searchQuery) {
2056
+ state.searchQuery = '';
2057
+ state.searchResults = [];
2058
+ }
2059
+ // Keep current search query to refine it
2060
+ render();
2061
+ break;
2062
+
2063
+ case 's':
2064
+ // Open event picker for selected sound
2065
+ {
2066
+ const selectedLine = lines[state.selectedLine];
2067
+ if (selectedLine && selectedLine.type === 'sound') {
2068
+ state.pickerMode = true;
2069
+ state.pickerSelectedEvent = 0;
2070
+ state.pickerSound = selectedLine.sound;
2071
+ render();
2072
+ }
2073
+ }
2074
+ break;
2075
+
2076
+ case 'p':
2077
+ case '\r': // Enter key
2078
+ case ' ': // Space key
2079
+ {
2080
+ const selectedLine = lines[state.selectedLine];
2081
+
2082
+ if (selectedLine.type === 'category') {
2083
+ // Toggle collapse for categories
2084
+ state.collapsed[selectedLine.category] = !state.collapsed[selectedLine.category];
2085
+ render();
2086
+ } else if (selectedLine.type === 'tag-result') {
2087
+ // Switch to the selected tag filter
2088
+ const tagIndex = allTags.indexOf(selectedLine.tag);
2089
+ if (tagIndex >= 0) {
2090
+ state.selectedTagIndex = tagIndex;
2091
+ state.searchMode = false;
2092
+ state.searchQuery = '';
2093
+ state.searchResults = [];
2094
+ state.selectedLine = 0;
2095
+ state.targetSoundIndex = 0;
2096
+ render();
2097
+ }
2098
+ } else if (selectedLine.type === 'sound') {
2099
+ // Play sound
2100
+ state.playingSound = selectedLine.sound.id;
2101
+ render();
2102
+
2103
+ // Play sound with callback to clear indicator when done
2104
+ playSound(selectedLine.sound.url, selectedLine.sound.id, () => {
2105
+ // Clear playing indicator when sound finishes
2106
+ if (state.playingSound === selectedLine.sound.id) {
2107
+ state.playingSound = null;
2108
+ render();
2109
+ }
2110
+ });
2111
+ }
2112
+ }
2113
+ break;
2114
+ }
2115
+ });
2116
+ }
2117
+
2118
+ // Run the browser
2119
+ browse().catch((err) => {
2120
+ console.error("\x1b[31mError:\x1b[0m", err.message);
2121
+ process.exit(1);
2122
+ });