opencode-plugin-boops 2.5.3 → 2.6.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.
Files changed (3) hide show
  1. package/cli/browse +140 -45
  2. package/index.ts +20 -20
  3. package/package.json +1 -1
package/cli/browse CHANGED
@@ -22,17 +22,43 @@ const sounds = JSON.parse(readFileSync(soundsPath, "utf-8"));
22
22
 
23
23
  // Available OpenCode events
24
24
  const availableEvents = [
25
- "session.idle", "permission.asked", "session.error",
26
- "command.executed", "file.edited", "file.watcher.updated",
27
- "installation.updated", "lsp.client.diagnostics", "lsp.updated",
28
- "message.part.removed", "message.part.updated", "message.removed", "message.updated",
29
- "permission.replied", "server.connected",
30
- "session.created", "session.compacted", "session.deleted", "session.diff", "session.status", "session.updated",
25
+ "command.executed",
26
+ "file.edited",
27
+ "file.watcher.updated",
28
+ "installation.updated",
29
+ "lsp.client.diagnostics",
30
+ "lsp.updated",
31
+ "message.part.removed",
32
+ "message.part.updated",
33
+ "message.removed",
34
+ "message.updated",
35
+ "permission.asked",
36
+ "permission.replied",
37
+ "server.connected",
38
+ "session.compacted",
39
+ "session.created",
40
+ "session.deleted",
41
+ "session.diff",
42
+ "session.error",
43
+ "session.idle",
44
+ "session.status",
45
+ "session.updated",
31
46
  "todo.updated",
32
- "tool.execute.before", "tool.execute.after",
33
- "tui.prompt.append", "tui.command.execute", "tui.toast.show"
47
+ "tool.execute.after",
48
+ "tool.execute.before",
49
+ "tui.command.execute",
50
+ "tui.prompt.append",
51
+ "tui.toast.show"
34
52
  ];
35
53
 
54
+ // System/custom events (separate section)
55
+ const systemEvents = [
56
+ "__fallback"
57
+ ];
58
+
59
+ // Combined array for the picker (system events at the end)
60
+ const allEvents = [...availableEvents, ...systemEvents];
61
+
36
62
  // Cache directory
37
63
  const CACHE_DIR = "/tmp/opencode-boops/cache";
38
64
 
@@ -59,9 +85,6 @@ const PLUGIN_INSTALLED = isPluginInstalled();
59
85
 
60
86
  // Event descriptions for the picker
61
87
  const eventDescriptions = {
62
- "session.idle": "AI completes response",
63
- "permission.asked": "AI needs permission",
64
- "session.error": "Error occurs",
65
88
  "command.executed": "Command executed",
66
89
  "file.edited": "File edited",
67
90
  "file.watcher.updated": "File watcher detected change",
@@ -72,20 +95,24 @@ const eventDescriptions = {
72
95
  "message.part.updated": "Message part updated",
73
96
  "message.removed": "Message removed",
74
97
  "message.updated": "Message updated",
98
+ "permission.asked": "AI needs permission",
75
99
  "permission.replied": "Permission response given",
76
100
  "server.connected": "Connected to server",
77
- "session.created": "New session created",
78
101
  "session.compacted": "Session compacted",
102
+ "session.created": "New session created",
79
103
  "session.deleted": "Session deleted",
80
104
  "session.diff": "Session diff generated",
105
+ "session.error": "Error occurs",
106
+ "session.idle": "AI completes response",
81
107
  "session.status": "Session status changed",
82
108
  "session.updated": "Session updated",
83
109
  "todo.updated": "Todo list updated",
84
- "tool.execute.before": "Before tool execution",
85
110
  "tool.execute.after": "After tool execution",
86
- "tui.prompt.append": "Text appended to prompt",
111
+ "tool.execute.before": "Before tool execution",
87
112
  "tui.command.execute": "TUI command executed",
88
- "tui.toast.show": "Toast notification shown"
113
+ "tui.prompt.append": "Text appended to prompt",
114
+ "tui.toast.show": "Toast notification shown",
115
+ "__fallback": "Fallback sound when primary fails"
89
116
  };
90
117
 
91
118
  // Load current boops.toml config
@@ -124,6 +151,14 @@ function saveConfig(config) {
124
151
  }
125
152
  }
126
153
 
154
+ // Add fallbacks section if there are any
155
+ if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {
156
+ content += "\n[fallbacks]\n";
157
+ for (const [key, sound] of Object.entries(config.fallbacks).sort()) {
158
+ content += `${key} = "${sound}"\n`;
159
+ }
160
+ }
161
+
127
162
  writeFileSync(configPath, content);
128
163
  return true;
129
164
  }
@@ -810,9 +845,9 @@ async function browse() {
810
845
 
811
846
  // Instructions
812
847
  if (PLUGIN_INSTALLED) {
813
- console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current Ctrl+S save & exit ESC cancel\x1b[0m');
848
+ console.log(' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m navigate \x1b[38;5;67mEnter\x1b[38;5;240m play current \x1b[38;5;67mCtrl+S\x1b[38;5;240m save & exit \x1b[38;5;67mESC\x1b[38;5;240m cancel\x1b[0m');
814
849
  } else {
815
- console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current ESC cancel \x1b[38;5;208m(save disabled - plugin not installed)\x1b[0m');
850
+ console.log(' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m navigate \x1b[38;5;67mEnter\x1b[38;5;240m play current \x1b[38;5;67mESC\x1b[38;5;240m cancel \x1b[38;5;208m(save disabled - plugin not installed)\x1b[0m');
816
851
  }
817
852
  console.log('');
818
853
 
@@ -821,13 +856,25 @@ async function browse() {
821
856
  // Fixed lines: 4 (top) + 2 (bottom) = 6
822
857
  const fixedLines = 6;
823
858
  const maxVisibleEvents = Math.max(1, termHeight - fixedLines);
824
- const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
825
- const endIndex = Math.min(scrollOffset + maxVisibleEvents, availableEvents.length);
859
+ const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), allEvents.length - maxVisibleEvents));
860
+ const endIndex = Math.min(scrollOffset + maxVisibleEvents, allEvents.length);
826
861
 
827
862
  for (let i = scrollOffset; i < endIndex; i++) {
828
- const event = availableEvents[i];
863
+ const event = allEvents[i];
829
864
  const isSelected = i === state.pickerSelectedEvent;
830
- const currentSound = config.sounds ? config.sounds[event] : null;
865
+
866
+ // Add separator before system events
867
+ if (i === availableEvents.length && i >= scrollOffset) {
868
+ console.log(` ${'─'.repeat(Math.min(60, termWidth - 4))}`);
869
+ }
870
+
871
+ // Get current sound from appropriate config section
872
+ let currentSound = null;
873
+ if (event === '__fallback') {
874
+ currentSound = config.fallbacks ? config.fallbacks.default : null;
875
+ } else {
876
+ currentSound = config.sounds ? config.sounds[event] : null;
877
+ }
831
878
 
832
879
  const selectionBg = isSelected ? '\x1b[48;5;235m' : '';
833
880
  const reset = '\x1b[0m';
@@ -1085,15 +1132,15 @@ async function browse() {
1085
1132
  const config = loadCurrentConfig();
1086
1133
  const boxX = termWidth - pickerWidth;
1087
1134
  const boxStartY = stickyNavbar ? (topBlankLine + 4) : (topBlankLine + 1); // Start below header in sticky mode, else near top
1088
- const boxHeight = Math.min(availableEvents.length + 4, termHeight - boxStartY - fixedFooterLines);
1135
+ const boxHeight = Math.min(allEvents.length + 5, termHeight - boxStartY - fixedFooterLines); // +5 for separator line
1089
1136
 
1090
1137
  // Calculate scroll offset for event list
1091
1138
  const maxVisibleEvents = boxHeight - 6; // Reserve space for header(2) + description(2) + footer(2)
1092
- const eventScrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
1093
- const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents, availableEvents.length);
1139
+ const eventScrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), allEvents.length - maxVisibleEvents));
1140
+ const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents, allEvents.length);
1094
1141
 
1095
1142
  // Get selected event info
1096
- const selectedEvent = availableEvents[state.pickerSelectedEvent];
1143
+ const selectedEvent = allEvents[state.pickerSelectedEvent];
1097
1144
  const eventDesc = eventDescriptions[selectedEvent] || "No description available";
1098
1145
 
1099
1146
  // Draw each line of the picker box
@@ -1101,6 +1148,11 @@ async function browse() {
1101
1148
  const screenY = boxStartY + row;
1102
1149
  process.stdout.write(`\x1b[${screenY};${boxX}H`); // Position cursor
1103
1150
 
1151
+ // Clear the area where we'll draw the picker (from cursor to end of line)
1152
+ // Draw a solid background to cover anything underneath
1153
+ process.stdout.write(' '.repeat(pickerWidth));
1154
+ process.stdout.write(`\x1b[${screenY};${boxX}H`); // Reposition cursor
1155
+
1104
1156
  if (row === 0) {
1105
1157
  // Top border with title
1106
1158
  const title = ` Assign to Event `;
@@ -1132,10 +1184,23 @@ async function browse() {
1132
1184
  } else {
1133
1185
  // Event list item
1134
1186
  const eventIndex = eventScrollOffset + (row - 3);
1187
+
1135
1188
  if (eventIndex < eventEndIndex) {
1136
- const event = availableEvents[eventIndex];
1189
+ const event = allEvents[eventIndex];
1137
1190
  const isSelected = eventIndex === state.pickerSelectedEvent;
1138
- const currentSound = config.sounds ? config.sounds[event] : null;
1191
+
1192
+ // Show separator before system events
1193
+ if (eventIndex === availableEvents.length) {
1194
+ process.stdout.write(`\x1b[38;5;240m│ ${'─'.repeat(pickerWidth - 4)} │\x1b[0m`);
1195
+ } else {
1196
+
1197
+ // Get current sound from appropriate config section
1198
+ let currentSound = null;
1199
+ if (event === '__fallback') {
1200
+ currentSound = config.fallbacks ? config.fallbacks.default : null;
1201
+ } else {
1202
+ currentSound = config.sounds ? config.sounds[event] : null;
1203
+ }
1139
1204
 
1140
1205
  const bg = isSelected ? '\x1b[48;5;235m' : '';
1141
1206
 
@@ -1153,10 +1218,11 @@ async function browse() {
1153
1218
  : event.padEnd(pickerWidth - 6);
1154
1219
  }
1155
1220
 
1156
- const indicator = currentSound ? '•' : ' ';
1157
- const indicatorColor = currentSound ? '\x1b[38;5;76m' : '';
1158
-
1159
- process.stdout.write(`\x1b[38;5;240m│\x1b[0m${bg}${indicatorColor}${indicator}\x1b[0m${bg} ${eventText} \x1b[0m\x1b[38;5;240m│\x1b[0m`);
1221
+ const indicator = currentSound ? '•' : ' ';
1222
+ const indicatorColor = currentSound ? '\x1b[38;5;76m' : '';
1223
+
1224
+ process.stdout.write(`\x1b[38;5;240m│\x1b[0m${bg}${indicatorColor}${indicator}\x1b[0m${bg} ${eventText} \x1b[0m\x1b[38;5;240m│\x1b[0m`);
1225
+ }
1160
1226
  } else {
1161
1227
  // Empty line
1162
1228
  process.stdout.write(`\x1b[38;5;240m│${' '.repeat(pickerWidth - 2)}│\x1b[0m`);
@@ -1176,7 +1242,7 @@ async function browse() {
1176
1242
  if (state.searchQuery || state.searchMode) {
1177
1243
  // Search mode: show search box on left, hints on right
1178
1244
  const searchBox = ` /${state.searchQuery}`;
1179
- const searchHint = '\x1b[38;5;240m/ to refine esc to clear\x1b[0m';
1245
+ const searchHint = '\x1b[38;5;240m\x1b[38;5;67m/\x1b[38;5;240m to refine \x1b[38;5;67mesc\x1b[38;5;240m to clear\x1b[0m';
1180
1246
  const searchBoxVisible = 2 + state.searchQuery.length; // " /" + query
1181
1247
  const hintVisible = getVisibleLength(searchHint);
1182
1248
 
@@ -1216,10 +1282,10 @@ async function browse() {
1216
1282
  let controls;
1217
1283
  if (state.pickerMode) {
1218
1284
  controls = PLUGIN_INSTALLED
1219
- ? ' \x1b[38;5;240m↑/↓ select enter play a assign d unassign ←/esc close\x1b[0m'
1220
- : ' \x1b[38;5;240m↑/↓ select event enter play ←/esc close \x1b[38;5;208m(save disabled)\x1b[0m';
1285
+ ? ' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m select \x1b[38;5;67menter\x1b[38;5;240m play \x1b[38;5;67ma\x1b[38;5;240m assign \x1b[38;5;67md\x1b[38;5;240m unassign \x1b[38;5;67m←/esc\x1b[38;5;240m close\x1b[0m'
1286
+ : ' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m select event \x1b[38;5;67menter\x1b[38;5;240m play \x1b[38;5;67m←/esc\x1b[38;5;240m close \x1b[38;5;208m(save disabled)\x1b[0m';
1221
1287
  } else {
1222
- controls = ' \x1b[38;5;240m↑/↓ navigate ←/→ tags / search s assign enter play q quit\x1b[0m';
1288
+ controls = ' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m navigate \x1b[38;5;67m←/→\x1b[38;5;240m tags \x1b[38;5;67m/\x1b[38;5;240m search \x1b[38;5;67ms\x1b[38;5;240m assign \x1b[38;5;67menter\x1b[38;5;240m play \x1b[38;5;67mq\x1b[38;5;240m quit\x1b[0m';
1223
1289
  }
1224
1290
 
1225
1291
  // Attribution with manual hover/click
@@ -1845,7 +1911,7 @@ async function browse() {
1845
1911
  return;
1846
1912
  } else if (key === '\u001b[B' || key === 'j') {
1847
1913
  // Down arrow
1848
- if (state.pickerSelectedEvent < availableEvents.length - 1) {
1914
+ if (state.pickerSelectedEvent < allEvents.length - 1) {
1849
1915
  state.pickerSelectedEvent++;
1850
1916
  }
1851
1917
  render();
@@ -1853,8 +1919,15 @@ async function browse() {
1853
1919
  } else if (key === '\r' || key === ' ') {
1854
1920
  // Enter/Space - play current sound for selected event
1855
1921
  const config = loadCurrentConfig();
1856
- const event = availableEvents[state.pickerSelectedEvent];
1857
- const currentSound = config.sounds ? config.sounds[event] : null;
1922
+ const event = allEvents[state.pickerSelectedEvent];
1923
+
1924
+ // Get current sound from appropriate config section
1925
+ let currentSound = null;
1926
+ if (event === '__fallback') {
1927
+ currentSound = config.fallbacks ? config.fallbacks.default : null;
1928
+ } else {
1929
+ currentSound = config.sounds ? config.sounds[event] : null;
1930
+ }
1858
1931
  if (currentSound) {
1859
1932
  const soundEntry = sounds.find(s => s.id === currentSound || s.name === currentSound);
1860
1933
  if (soundEntry) {
@@ -1870,10 +1943,16 @@ async function browse() {
1870
1943
  } else if (key === 'a') {
1871
1944
  // 'a' - assign sound to selected event (auto-saves)
1872
1945
  const config = loadCurrentConfig();
1873
- const event = availableEvents[state.pickerSelectedEvent];
1946
+ const event = allEvents[state.pickerSelectedEvent];
1874
1947
 
1875
- if (!config.sounds) config.sounds = {};
1876
- config.sounds[event] = state.pickerSound.name; // Save friendly name, not ID
1948
+ // Check if this is a system event
1949
+ if (event === '__fallback') {
1950
+ if (!config.fallbacks) config.fallbacks = {};
1951
+ config.fallbacks.default = state.pickerSound.name; // Save friendly name
1952
+ } else {
1953
+ if (!config.sounds) config.sounds = {};
1954
+ config.sounds[event] = state.pickerSound.name; // Save friendly name, not ID
1955
+ }
1877
1956
 
1878
1957
  // Auto-save to config file
1879
1958
  saveConfig(config);
@@ -1884,11 +1963,19 @@ async function browse() {
1884
1963
  } else if (key === 'd') {
1885
1964
  // 'd' - unassign/delete sound from selected event
1886
1965
  const config = loadCurrentConfig();
1887
- const event = availableEvents[state.pickerSelectedEvent];
1966
+ const event = allEvents[state.pickerSelectedEvent];
1888
1967
 
1889
- if (config.sounds && config.sounds[event]) {
1890
- delete config.sounds[event];
1891
- saveConfig(config);
1968
+ // Check if this is a system event
1969
+ if (event === '__fallback') {
1970
+ if (config.fallbacks && config.fallbacks.default) {
1971
+ delete config.fallbacks.default;
1972
+ saveConfig(config);
1973
+ }
1974
+ } else {
1975
+ if (config.sounds && config.sounds[event]) {
1976
+ delete config.sounds[event];
1977
+ saveConfig(config);
1978
+ }
1892
1979
  }
1893
1980
 
1894
1981
  // Stay in picker mode
@@ -1985,6 +2072,7 @@ async function browse() {
1985
2072
 
1986
2073
  case '\u001b[A': // Up arrow
1987
2074
  case 'k':
2075
+ if (state.pickerMode) break; // Picker handles its own navigation
1988
2076
  if (state.selectedLine > 0) {
1989
2077
  state.selectedLine--;
1990
2078
  // Skip non-interactive lines
@@ -2026,6 +2114,7 @@ async function browse() {
2026
2114
 
2027
2115
  case '\u001b[B': // Down arrow
2028
2116
  case 'j':
2117
+ if (state.pickerMode) break; // Picker handles its own navigation
2029
2118
  if (state.selectedLine < lines.length - 1) {
2030
2119
  state.selectedLine++;
2031
2120
  // Skip non-interactive lines
@@ -2050,6 +2139,7 @@ async function browse() {
2050
2139
 
2051
2140
  case '\u001b[C': // Right arrow
2052
2141
  case 'l':
2142
+ if (state.pickerMode) break; // Picker doesn't use right arrow
2053
2143
  // Navigate tags right, maintaining sound position
2054
2144
  if (state.selectedTagIndex < allTags.length - 1) {
2055
2145
  state.selectedTagIndex++;
@@ -2072,6 +2162,8 @@ async function browse() {
2072
2162
 
2073
2163
  case '\u001b[D': // Left arrow
2074
2164
  case 'h':
2165
+ // Picker mode: left arrow exits (handled earlier in picker section)
2166
+ if (state.pickerMode) break; // Already handled in picker mode section
2075
2167
  // Navigate tags left, maintaining sound position
2076
2168
  if (state.selectedTagIndex > -1) {
2077
2169
  state.selectedTagIndex--;
@@ -2093,6 +2185,7 @@ async function browse() {
2093
2185
  break;
2094
2186
 
2095
2187
  case '/':
2188
+ if (state.pickerMode) break; // Block search when picker is open
2096
2189
  // Enter search mode (or re-enter if results are showing)
2097
2190
  state.searchMode = true;
2098
2191
  if (!state.searchQuery) {
@@ -2104,6 +2197,7 @@ async function browse() {
2104
2197
  break;
2105
2198
 
2106
2199
  case 's':
2200
+ if (state.pickerMode) break; // Block opening another picker
2107
2201
  // Open event picker for selected sound
2108
2202
  {
2109
2203
  const selectedLine = lines[state.selectedLine];
@@ -2119,6 +2213,7 @@ async function browse() {
2119
2213
  case 'p':
2120
2214
  case '\r': // Enter key
2121
2215
  case ' ': // Space key
2216
+ if (state.pickerMode) break; // Picker handles these keys
2122
2217
  {
2123
2218
  const selectedLine = lines[state.selectedLine];
2124
2219
 
package/index.ts CHANGED
@@ -171,35 +171,35 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
171
171
  }
172
172
  }
173
173
 
174
- // Get cache path for an event (no extension, stores by event name)
175
- const getCachePath = (eventName: string): string => {
176
- // Sanitize event name for filesystem (replace dots with dashes)
177
- const safeName = eventName.replace(/\./g, "-")
178
- return join(cacheDir, safeName)
174
+ // Get cache path for a sound (uses URL hash to ensure uniqueness)
175
+ const getCachePath = (url: string): string => {
176
+ // Use a hash of the URL to ensure unique cache keys
177
+ // This works for all URLs (notificationsounds.com, custom URLs, etc.)
178
+ const hash = Bun.hash(url).toString(16)
179
+
180
+ // Try to preserve the file extension for better debugging
181
+ let ext = '.ogg'
182
+ try {
183
+ const urlObj = new URL(url)
184
+ const pathname = urlObj.pathname
185
+ const extMatch = pathname.match(/\.(\w+)$/)
186
+ if (extMatch) ext = `.${extMatch[1]}`
187
+ } catch {
188
+ // Use default extension if URL parsing fails
189
+ }
190
+
191
+ return join(cacheDir, `${hash}${ext}`)
179
192
  }
180
193
 
181
- // Download and cache a sound file from URL for a specific event
194
+ // Download and cache a sound file from URL
182
195
  const downloadSound = async (url: string, eventName: string): Promise<string> => {
183
- const cachedPath = getCachePath(eventName)
196
+ const cachedPath = getCachePath(url)
184
197
 
185
198
  // Return cached file if it exists
186
199
  if (existsSync(cachedPath)) {
187
200
  return cachedPath
188
201
  }
189
202
 
190
- // Delete any old cached file for this event (in case URL changed)
191
- try {
192
- const files = await readdir(cacheDir)
193
- const safeName = eventName.replace(/\./g, "-")
194
- for (const file of files) {
195
- if (file.startsWith(safeName)) {
196
- await unlink(join(cacheDir, file))
197
- }
198
- }
199
- } catch {
200
- // Ignore errors
201
- }
202
-
203
203
  // Download the file
204
204
  await log(`Downloading sound for ${eventName}: ${url}`)
205
205
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugin-boops",
3
- "version": "2.5.3",
3
+ "version": "2.6.2",
4
4
  "description": "Sound notifications for OpenCode - plays pleasant sounds when tasks complete or input is needed",
5
5
  "main": "index.ts",
6
6
  "type": "module",