opencode-plugin-boops 2.6.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/boops.default.toml +2 -1
- package/cli/browse +142 -45
- package/index.ts +93 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Sound notifications for OpenCode - plays pleasant "boop" sounds when tasks compl
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- 🎵 Soft, friendly notification sounds when AI completes tasks
|
|
8
|
-
- 📡 Gentle alert when AI needs your permission or
|
|
8
|
+
- 📡 Gentle alert when AI needs your permission, input, or asks a question
|
|
9
9
|
- 🌐 Works out of the box with online sounds (auto-downloaded and cached)
|
|
10
10
|
- 🔄 Fully configurable - use URLs or local files
|
|
11
11
|
- 🐧 Works on Linux (with `paplay` or `aplay`)
|
|
@@ -67,6 +67,7 @@ The plugin listens to OpenCode events and plays sounds based on your configurati
|
|
|
67
67
|
|
|
68
68
|
- **`session.idle`** - AI finishes responding → soft "pristine" notification
|
|
69
69
|
- **`permission.asked`** - AI needs permission → gentle "relax" chime
|
|
70
|
+
- **`question.asked`** - AI asks a question → gentle "relax" chime
|
|
70
71
|
- **`session.error`** - An error occurs → friendly "magic" alert
|
|
71
72
|
|
|
72
73
|
**Instant config reload:** The config is reloaded on every event, so changes take effect immediately without restarting OpenCode! Edit your config, trigger an event, and hear the new sound instantly.
|
|
@@ -147,6 +148,10 @@ You can configure sounds for any of the 28 OpenCode events:
|
|
|
147
148
|
- `permission.asked` - AI needs permission
|
|
148
149
|
- `permission.replied` - Permission response given
|
|
149
150
|
|
|
151
|
+
**Question events (2):**
|
|
152
|
+
- `question.asked` - AI asks a multiple-choice question
|
|
153
|
+
- `question.replied` - User answers a question
|
|
154
|
+
|
|
150
155
|
**File events (2):**
|
|
151
156
|
- `file.edited` - File is edited
|
|
152
157
|
- `file.watcher.updated` - File watcher detects change
|
package/boops.default.toml
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
[sounds]
|
|
7
7
|
"session.idle" = "pristine" # AI completes response
|
|
8
|
+
"session.error" = "exclamation" # Error occurs
|
|
8
9
|
"permission.asked" = "relax" # AI needs permission
|
|
9
|
-
"
|
|
10
|
+
"question.asked" = "relax" # AI asks a question (falls back to permission.asked if not set)
|
|
10
11
|
|
|
11
12
|
# You can use:
|
|
12
13
|
# - Sound names: "pristine" (searches sounds.json by name)
|
package/cli/browse
CHANGED
|
@@ -22,17 +22,45 @@ const sounds = JSON.parse(readFileSync(soundsPath, "utf-8"));
|
|
|
22
22
|
|
|
23
23
|
// Available OpenCode events
|
|
24
24
|
const availableEvents = [
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
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
|
+
"question.asked",
|
|
38
|
+
"question.replied",
|
|
39
|
+
"server.connected",
|
|
40
|
+
"session.compacted",
|
|
41
|
+
"session.created",
|
|
42
|
+
"session.deleted",
|
|
43
|
+
"session.diff",
|
|
44
|
+
"session.error",
|
|
45
|
+
"session.idle",
|
|
46
|
+
"session.status",
|
|
47
|
+
"session.updated",
|
|
31
48
|
"todo.updated",
|
|
32
|
-
"tool.execute.
|
|
33
|
-
"
|
|
49
|
+
"tool.execute.after",
|
|
50
|
+
"tool.execute.before",
|
|
51
|
+
"tui.command.execute",
|
|
52
|
+
"tui.prompt.append",
|
|
53
|
+
"tui.toast.show"
|
|
34
54
|
];
|
|
35
55
|
|
|
56
|
+
// System/custom events (separate section)
|
|
57
|
+
const systemEvents = [
|
|
58
|
+
"__fallback"
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Combined array for the picker (system events at the end)
|
|
62
|
+
const allEvents = [...availableEvents, ...systemEvents];
|
|
63
|
+
|
|
36
64
|
// Cache directory
|
|
37
65
|
const CACHE_DIR = "/tmp/opencode-boops/cache";
|
|
38
66
|
|
|
@@ -59,9 +87,6 @@ const PLUGIN_INSTALLED = isPluginInstalled();
|
|
|
59
87
|
|
|
60
88
|
// Event descriptions for the picker
|
|
61
89
|
const eventDescriptions = {
|
|
62
|
-
"session.idle": "AI completes response",
|
|
63
|
-
"permission.asked": "AI needs permission",
|
|
64
|
-
"session.error": "Error occurs",
|
|
65
90
|
"command.executed": "Command executed",
|
|
66
91
|
"file.edited": "File edited",
|
|
67
92
|
"file.watcher.updated": "File watcher detected change",
|
|
@@ -72,20 +97,24 @@ const eventDescriptions = {
|
|
|
72
97
|
"message.part.updated": "Message part updated",
|
|
73
98
|
"message.removed": "Message removed",
|
|
74
99
|
"message.updated": "Message updated",
|
|
100
|
+
"permission.asked": "AI needs permission",
|
|
75
101
|
"permission.replied": "Permission response given",
|
|
76
102
|
"server.connected": "Connected to server",
|
|
77
|
-
"session.created": "New session created",
|
|
78
103
|
"session.compacted": "Session compacted",
|
|
104
|
+
"session.created": "New session created",
|
|
79
105
|
"session.deleted": "Session deleted",
|
|
80
106
|
"session.diff": "Session diff generated",
|
|
107
|
+
"session.error": "Error occurs",
|
|
108
|
+
"session.idle": "AI completes response",
|
|
81
109
|
"session.status": "Session status changed",
|
|
82
110
|
"session.updated": "Session updated",
|
|
83
111
|
"todo.updated": "Todo list updated",
|
|
84
|
-
"tool.execute.before": "Before tool execution",
|
|
85
112
|
"tool.execute.after": "After tool execution",
|
|
86
|
-
"
|
|
113
|
+
"tool.execute.before": "Before tool execution",
|
|
87
114
|
"tui.command.execute": "TUI command executed",
|
|
88
|
-
"tui.
|
|
115
|
+
"tui.prompt.append": "Text appended to prompt",
|
|
116
|
+
"tui.toast.show": "Toast notification shown",
|
|
117
|
+
"__fallback": "Fallback sound when primary fails"
|
|
89
118
|
};
|
|
90
119
|
|
|
91
120
|
// Load current boops.toml config
|
|
@@ -124,6 +153,14 @@ function saveConfig(config) {
|
|
|
124
153
|
}
|
|
125
154
|
}
|
|
126
155
|
|
|
156
|
+
// Add fallbacks section if there are any
|
|
157
|
+
if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {
|
|
158
|
+
content += "\n[fallbacks]\n";
|
|
159
|
+
for (const [key, sound] of Object.entries(config.fallbacks).sort()) {
|
|
160
|
+
content += `${key} = "${sound}"\n`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
127
164
|
writeFileSync(configPath, content);
|
|
128
165
|
return true;
|
|
129
166
|
}
|
|
@@ -810,9 +847,9 @@ async function browse() {
|
|
|
810
847
|
|
|
811
848
|
// Instructions
|
|
812
849
|
if (PLUGIN_INSTALLED) {
|
|
813
|
-
console.log(' \x1b[38;5;240m
|
|
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;67mCtrl+S\x1b[38;5;240m save & exit \x1b[38;5;67mESC\x1b[38;5;240m cancel\x1b[0m');
|
|
814
851
|
} else {
|
|
815
|
-
console.log(' \x1b[38;5;240m
|
|
852
|
+
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
853
|
}
|
|
817
854
|
console.log('');
|
|
818
855
|
|
|
@@ -821,13 +858,25 @@ async function browse() {
|
|
|
821
858
|
// Fixed lines: 4 (top) + 2 (bottom) = 6
|
|
822
859
|
const fixedLines = 6;
|
|
823
860
|
const maxVisibleEvents = Math.max(1, termHeight - fixedLines);
|
|
824
|
-
const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2),
|
|
825
|
-
const endIndex = Math.min(scrollOffset + maxVisibleEvents,
|
|
861
|
+
const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), allEvents.length - maxVisibleEvents));
|
|
862
|
+
const endIndex = Math.min(scrollOffset + maxVisibleEvents, allEvents.length);
|
|
826
863
|
|
|
827
864
|
for (let i = scrollOffset; i < endIndex; i++) {
|
|
828
|
-
const event =
|
|
865
|
+
const event = allEvents[i];
|
|
829
866
|
const isSelected = i === state.pickerSelectedEvent;
|
|
830
|
-
|
|
867
|
+
|
|
868
|
+
// Add separator before system events
|
|
869
|
+
if (i === availableEvents.length && i >= scrollOffset) {
|
|
870
|
+
console.log(` ${'─'.repeat(Math.min(60, termWidth - 4))}`);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Get current sound from appropriate config section
|
|
874
|
+
let currentSound = null;
|
|
875
|
+
if (event === '__fallback') {
|
|
876
|
+
currentSound = config.fallbacks ? config.fallbacks.default : null;
|
|
877
|
+
} else {
|
|
878
|
+
currentSound = config.sounds ? config.sounds[event] : null;
|
|
879
|
+
}
|
|
831
880
|
|
|
832
881
|
const selectionBg = isSelected ? '\x1b[48;5;235m' : '';
|
|
833
882
|
const reset = '\x1b[0m';
|
|
@@ -1085,15 +1134,15 @@ async function browse() {
|
|
|
1085
1134
|
const config = loadCurrentConfig();
|
|
1086
1135
|
const boxX = termWidth - pickerWidth;
|
|
1087
1136
|
const boxStartY = stickyNavbar ? (topBlankLine + 4) : (topBlankLine + 1); // Start below header in sticky mode, else near top
|
|
1088
|
-
const boxHeight = Math.min(
|
|
1137
|
+
const boxHeight = Math.min(allEvents.length + 5, termHeight - boxStartY - fixedFooterLines); // +5 for separator line
|
|
1089
1138
|
|
|
1090
1139
|
// Calculate scroll offset for event list
|
|
1091
1140
|
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),
|
|
1093
|
-
const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents,
|
|
1141
|
+
const eventScrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), allEvents.length - maxVisibleEvents));
|
|
1142
|
+
const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents, allEvents.length);
|
|
1094
1143
|
|
|
1095
1144
|
// Get selected event info
|
|
1096
|
-
const selectedEvent =
|
|
1145
|
+
const selectedEvent = allEvents[state.pickerSelectedEvent];
|
|
1097
1146
|
const eventDesc = eventDescriptions[selectedEvent] || "No description available";
|
|
1098
1147
|
|
|
1099
1148
|
// Draw each line of the picker box
|
|
@@ -1101,6 +1150,11 @@ async function browse() {
|
|
|
1101
1150
|
const screenY = boxStartY + row;
|
|
1102
1151
|
process.stdout.write(`\x1b[${screenY};${boxX}H`); // Position cursor
|
|
1103
1152
|
|
|
1153
|
+
// Clear the area where we'll draw the picker (from cursor to end of line)
|
|
1154
|
+
// Draw a solid background to cover anything underneath
|
|
1155
|
+
process.stdout.write(' '.repeat(pickerWidth));
|
|
1156
|
+
process.stdout.write(`\x1b[${screenY};${boxX}H`); // Reposition cursor
|
|
1157
|
+
|
|
1104
1158
|
if (row === 0) {
|
|
1105
1159
|
// Top border with title
|
|
1106
1160
|
const title = ` Assign to Event `;
|
|
@@ -1132,10 +1186,23 @@ async function browse() {
|
|
|
1132
1186
|
} else {
|
|
1133
1187
|
// Event list item
|
|
1134
1188
|
const eventIndex = eventScrollOffset + (row - 3);
|
|
1189
|
+
|
|
1135
1190
|
if (eventIndex < eventEndIndex) {
|
|
1136
|
-
const event =
|
|
1191
|
+
const event = allEvents[eventIndex];
|
|
1137
1192
|
const isSelected = eventIndex === state.pickerSelectedEvent;
|
|
1138
|
-
|
|
1193
|
+
|
|
1194
|
+
// Show separator before system events
|
|
1195
|
+
if (eventIndex === availableEvents.length) {
|
|
1196
|
+
process.stdout.write(`\x1b[38;5;240m│ ${'─'.repeat(pickerWidth - 4)} │\x1b[0m`);
|
|
1197
|
+
} else {
|
|
1198
|
+
|
|
1199
|
+
// Get current sound from appropriate config section
|
|
1200
|
+
let currentSound = null;
|
|
1201
|
+
if (event === '__fallback') {
|
|
1202
|
+
currentSound = config.fallbacks ? config.fallbacks.default : null;
|
|
1203
|
+
} else {
|
|
1204
|
+
currentSound = config.sounds ? config.sounds[event] : null;
|
|
1205
|
+
}
|
|
1139
1206
|
|
|
1140
1207
|
const bg = isSelected ? '\x1b[48;5;235m' : '';
|
|
1141
1208
|
|
|
@@ -1153,10 +1220,11 @@ async function browse() {
|
|
|
1153
1220
|
: event.padEnd(pickerWidth - 6);
|
|
1154
1221
|
}
|
|
1155
1222
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1223
|
+
const indicator = currentSound ? '•' : ' ';
|
|
1224
|
+
const indicatorColor = currentSound ? '\x1b[38;5;76m' : '';
|
|
1225
|
+
|
|
1226
|
+
process.stdout.write(`\x1b[38;5;240m│\x1b[0m${bg}${indicatorColor}${indicator}\x1b[0m${bg} ${eventText} \x1b[0m\x1b[38;5;240m│\x1b[0m`);
|
|
1227
|
+
}
|
|
1160
1228
|
} else {
|
|
1161
1229
|
// Empty line
|
|
1162
1230
|
process.stdout.write(`\x1b[38;5;240m│${' '.repeat(pickerWidth - 2)}│\x1b[0m`);
|
|
@@ -1176,7 +1244,7 @@ async function browse() {
|
|
|
1176
1244
|
if (state.searchQuery || state.searchMode) {
|
|
1177
1245
|
// Search mode: show search box on left, hints on right
|
|
1178
1246
|
const searchBox = ` /${state.searchQuery}`;
|
|
1179
|
-
const searchHint = '\x1b[38;5;240m
|
|
1247
|
+
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
1248
|
const searchBoxVisible = 2 + state.searchQuery.length; // " /" + query
|
|
1181
1249
|
const hintVisible = getVisibleLength(searchHint);
|
|
1182
1250
|
|
|
@@ -1216,10 +1284,10 @@ async function browse() {
|
|
|
1216
1284
|
let controls;
|
|
1217
1285
|
if (state.pickerMode) {
|
|
1218
1286
|
controls = PLUGIN_INSTALLED
|
|
1219
|
-
? ' \x1b[38;5;240m
|
|
1220
|
-
: ' \x1b[38;5;240m
|
|
1287
|
+
? ' \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'
|
|
1288
|
+
: ' \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
1289
|
} else {
|
|
1222
|
-
controls = ' \x1b[38;5;240m
|
|
1290
|
+
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
1291
|
}
|
|
1224
1292
|
|
|
1225
1293
|
// Attribution with manual hover/click
|
|
@@ -1845,7 +1913,7 @@ async function browse() {
|
|
|
1845
1913
|
return;
|
|
1846
1914
|
} else if (key === '\u001b[B' || key === 'j') {
|
|
1847
1915
|
// Down arrow
|
|
1848
|
-
if (state.pickerSelectedEvent <
|
|
1916
|
+
if (state.pickerSelectedEvent < allEvents.length - 1) {
|
|
1849
1917
|
state.pickerSelectedEvent++;
|
|
1850
1918
|
}
|
|
1851
1919
|
render();
|
|
@@ -1853,8 +1921,15 @@ async function browse() {
|
|
|
1853
1921
|
} else if (key === '\r' || key === ' ') {
|
|
1854
1922
|
// Enter/Space - play current sound for selected event
|
|
1855
1923
|
const config = loadCurrentConfig();
|
|
1856
|
-
const event =
|
|
1857
|
-
|
|
1924
|
+
const event = allEvents[state.pickerSelectedEvent];
|
|
1925
|
+
|
|
1926
|
+
// Get current sound from appropriate config section
|
|
1927
|
+
let currentSound = null;
|
|
1928
|
+
if (event === '__fallback') {
|
|
1929
|
+
currentSound = config.fallbacks ? config.fallbacks.default : null;
|
|
1930
|
+
} else {
|
|
1931
|
+
currentSound = config.sounds ? config.sounds[event] : null;
|
|
1932
|
+
}
|
|
1858
1933
|
if (currentSound) {
|
|
1859
1934
|
const soundEntry = sounds.find(s => s.id === currentSound || s.name === currentSound);
|
|
1860
1935
|
if (soundEntry) {
|
|
@@ -1870,10 +1945,16 @@ async function browse() {
|
|
|
1870
1945
|
} else if (key === 'a') {
|
|
1871
1946
|
// 'a' - assign sound to selected event (auto-saves)
|
|
1872
1947
|
const config = loadCurrentConfig();
|
|
1873
|
-
const event =
|
|
1948
|
+
const event = allEvents[state.pickerSelectedEvent];
|
|
1874
1949
|
|
|
1875
|
-
if
|
|
1876
|
-
|
|
1950
|
+
// Check if this is a system event
|
|
1951
|
+
if (event === '__fallback') {
|
|
1952
|
+
if (!config.fallbacks) config.fallbacks = {};
|
|
1953
|
+
config.fallbacks.default = state.pickerSound.name; // Save friendly name
|
|
1954
|
+
} else {
|
|
1955
|
+
if (!config.sounds) config.sounds = {};
|
|
1956
|
+
config.sounds[event] = state.pickerSound.name; // Save friendly name, not ID
|
|
1957
|
+
}
|
|
1877
1958
|
|
|
1878
1959
|
// Auto-save to config file
|
|
1879
1960
|
saveConfig(config);
|
|
@@ -1884,11 +1965,19 @@ async function browse() {
|
|
|
1884
1965
|
} else if (key === 'd') {
|
|
1885
1966
|
// 'd' - unassign/delete sound from selected event
|
|
1886
1967
|
const config = loadCurrentConfig();
|
|
1887
|
-
const event =
|
|
1968
|
+
const event = allEvents[state.pickerSelectedEvent];
|
|
1888
1969
|
|
|
1889
|
-
if
|
|
1890
|
-
|
|
1891
|
-
|
|
1970
|
+
// Check if this is a system event
|
|
1971
|
+
if (event === '__fallback') {
|
|
1972
|
+
if (config.fallbacks && config.fallbacks.default) {
|
|
1973
|
+
delete config.fallbacks.default;
|
|
1974
|
+
saveConfig(config);
|
|
1975
|
+
}
|
|
1976
|
+
} else {
|
|
1977
|
+
if (config.sounds && config.sounds[event]) {
|
|
1978
|
+
delete config.sounds[event];
|
|
1979
|
+
saveConfig(config);
|
|
1980
|
+
}
|
|
1892
1981
|
}
|
|
1893
1982
|
|
|
1894
1983
|
// Stay in picker mode
|
|
@@ -1985,6 +2074,7 @@ async function browse() {
|
|
|
1985
2074
|
|
|
1986
2075
|
case '\u001b[A': // Up arrow
|
|
1987
2076
|
case 'k':
|
|
2077
|
+
if (state.pickerMode) break; // Picker handles its own navigation
|
|
1988
2078
|
if (state.selectedLine > 0) {
|
|
1989
2079
|
state.selectedLine--;
|
|
1990
2080
|
// Skip non-interactive lines
|
|
@@ -2026,6 +2116,7 @@ async function browse() {
|
|
|
2026
2116
|
|
|
2027
2117
|
case '\u001b[B': // Down arrow
|
|
2028
2118
|
case 'j':
|
|
2119
|
+
if (state.pickerMode) break; // Picker handles its own navigation
|
|
2029
2120
|
if (state.selectedLine < lines.length - 1) {
|
|
2030
2121
|
state.selectedLine++;
|
|
2031
2122
|
// Skip non-interactive lines
|
|
@@ -2050,6 +2141,7 @@ async function browse() {
|
|
|
2050
2141
|
|
|
2051
2142
|
case '\u001b[C': // Right arrow
|
|
2052
2143
|
case 'l':
|
|
2144
|
+
if (state.pickerMode) break; // Picker doesn't use right arrow
|
|
2053
2145
|
// Navigate tags right, maintaining sound position
|
|
2054
2146
|
if (state.selectedTagIndex < allTags.length - 1) {
|
|
2055
2147
|
state.selectedTagIndex++;
|
|
@@ -2072,6 +2164,8 @@ async function browse() {
|
|
|
2072
2164
|
|
|
2073
2165
|
case '\u001b[D': // Left arrow
|
|
2074
2166
|
case 'h':
|
|
2167
|
+
// Picker mode: left arrow exits (handled earlier in picker section)
|
|
2168
|
+
if (state.pickerMode) break; // Already handled in picker mode section
|
|
2075
2169
|
// Navigate tags left, maintaining sound position
|
|
2076
2170
|
if (state.selectedTagIndex > -1) {
|
|
2077
2171
|
state.selectedTagIndex--;
|
|
@@ -2093,6 +2187,7 @@ async function browse() {
|
|
|
2093
2187
|
break;
|
|
2094
2188
|
|
|
2095
2189
|
case '/':
|
|
2190
|
+
if (state.pickerMode) break; // Block search when picker is open
|
|
2096
2191
|
// Enter search mode (or re-enter if results are showing)
|
|
2097
2192
|
state.searchMode = true;
|
|
2098
2193
|
if (!state.searchQuery) {
|
|
@@ -2104,6 +2199,7 @@ async function browse() {
|
|
|
2104
2199
|
break;
|
|
2105
2200
|
|
|
2106
2201
|
case 's':
|
|
2202
|
+
if (state.pickerMode) break; // Block opening another picker
|
|
2107
2203
|
// Open event picker for selected sound
|
|
2108
2204
|
{
|
|
2109
2205
|
const selectedLine = lines[state.selectedLine];
|
|
@@ -2119,6 +2215,7 @@ async function browse() {
|
|
|
2119
2215
|
case 'p':
|
|
2120
2216
|
case '\r': // Enter key
|
|
2121
2217
|
case ' ': // Space key
|
|
2218
|
+
if (state.pickerMode) break; // Picker handles these keys
|
|
2122
2219
|
{
|
|
2123
2220
|
const selectedLine = lines[state.selectedLine];
|
|
2124
2221
|
|
package/index.ts
CHANGED
|
@@ -52,6 +52,17 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
|
|
|
52
52
|
})
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Track last error time to debounce idle sounds after errors
|
|
56
|
+
let lastErrorTime = 0
|
|
57
|
+
const ERROR_DEBOUNCE_MS = 2000
|
|
58
|
+
|
|
59
|
+
// Track pending sounds that can be cancelled
|
|
60
|
+
let pendingSoundTimeout: ReturnType<typeof setTimeout> | null = null
|
|
61
|
+
let pendingSoundCancelled = false
|
|
62
|
+
|
|
63
|
+
// Track which sessions are subagents (have parentID)
|
|
64
|
+
const subagentSessions = new Set<string>()
|
|
65
|
+
|
|
55
66
|
// Cache directory for downloaded sounds
|
|
56
67
|
const cacheDir = join(homedir(), ".cache", "opencode", "boops")
|
|
57
68
|
|
|
@@ -249,33 +260,54 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
|
|
|
249
260
|
return null
|
|
250
261
|
}
|
|
251
262
|
|
|
252
|
-
// Play sound with fallback mechanism
|
|
253
|
-
const playSound = async (soundFile: string, eventName: string) => {
|
|
263
|
+
// Play sound with fallback mechanism (supports cancellation)
|
|
264
|
+
const playSound = async (soundFile: string, eventName: string, canCancel = false) => {
|
|
254
265
|
if (player === "echo") return // Skip if no player
|
|
255
266
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Try fallback
|
|
266
|
-
const fallback = config.fallbacks?.default
|
|
267
|
-
if (fallback) {
|
|
268
|
-
const fallbackPath = await resolveSoundPath(fallback, "fallback")
|
|
269
|
-
if (fallbackPath) {
|
|
270
|
-
await Bun.$`${player} ${fallbackPath}`.quiet()
|
|
267
|
+
const play = async () => {
|
|
268
|
+
try {
|
|
269
|
+
// Resolve the sound file (download if URL, check if local path exists)
|
|
270
|
+
const resolvedPath = await resolveSoundPath(soundFile, eventName)
|
|
271
|
+
|
|
272
|
+
if (resolvedPath) {
|
|
273
|
+
await Bun.$`${player} ${resolvedPath}`.quiet()
|
|
271
274
|
return
|
|
272
275
|
}
|
|
276
|
+
|
|
277
|
+
// Try fallback
|
|
278
|
+
const fallback = config.fallbacks?.default
|
|
279
|
+
if (fallback) {
|
|
280
|
+
const fallbackPath = await resolveSoundPath(fallback, "fallback")
|
|
281
|
+
if (fallbackPath) {
|
|
282
|
+
await Bun.$`${player} ${fallbackPath}`.quiet()
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Last resort: terminal bell
|
|
288
|
+
await Bun.$`echo -e "\a"`.quiet()
|
|
289
|
+
} catch (error) {
|
|
290
|
+
// Silently fail - don't spam logs for sound errors
|
|
273
291
|
}
|
|
292
|
+
}
|
|
274
293
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
294
|
+
if (canCancel) {
|
|
295
|
+
// Clear any pending sound
|
|
296
|
+
if (pendingSoundTimeout) {
|
|
297
|
+
clearTimeout(pendingSoundTimeout)
|
|
298
|
+
pendingSoundTimeout = null
|
|
299
|
+
}
|
|
300
|
+
pendingSoundCancelled = false
|
|
301
|
+
|
|
302
|
+
// Delay slightly to allow error to cancel it
|
|
303
|
+
pendingSoundTimeout = setTimeout(() => {
|
|
304
|
+
pendingSoundTimeout = null
|
|
305
|
+
if (!pendingSoundCancelled) {
|
|
306
|
+
play()
|
|
307
|
+
}
|
|
308
|
+
}, 100)
|
|
309
|
+
} else {
|
|
310
|
+
await play()
|
|
279
311
|
}
|
|
280
312
|
}
|
|
281
313
|
|
|
@@ -312,12 +344,41 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
|
|
|
312
344
|
// Reload config on every event for instant updates (no restart needed!)
|
|
313
345
|
config = await loadConfig()
|
|
314
346
|
|
|
315
|
-
//
|
|
316
|
-
if (event.type === "session.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
347
|
+
// Track subagent sessions (sessions with parentID)
|
|
348
|
+
if (event.type === "session.updated") {
|
|
349
|
+
const sessionInfo = event.properties?.info
|
|
350
|
+
if (sessionInfo?.parentID) {
|
|
351
|
+
subagentSessions.add(sessionInfo.id)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Cancel any pending sounds on error
|
|
356
|
+
if (event.type === "session.error") {
|
|
357
|
+
lastErrorTime = Date.now()
|
|
358
|
+
if (pendingSoundTimeout) {
|
|
359
|
+
pendingSoundCancelled = true
|
|
360
|
+
clearTimeout(pendingSoundTimeout)
|
|
361
|
+
pendingSoundTimeout = null
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Skip idle sound if error recently fired (for events that bypass cancellation)
|
|
366
|
+
if (event.type === "session.idle" && Date.now() - lastErrorTime < ERROR_DEBOUNCE_MS) {
|
|
367
|
+
return // Don't play idle sound after error
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Skip idle for subagent sessions
|
|
371
|
+
const sessionID = event.properties?.sessionID
|
|
372
|
+
if ((event.type === "session.idle" || event.type === "session.status") && sessionID && subagentSessions.has(sessionID)) {
|
|
373
|
+
return // Don't play sound for subagent completions
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Handle question.asked -> play permission sound as a fallback
|
|
377
|
+
if (event.type === "question.asked") {
|
|
378
|
+
const questionSound = config.sounds["question.asked"] || config.sounds["permission.asked"]
|
|
379
|
+
if (questionSound) {
|
|
380
|
+
await playSound(questionSound, "question.asked")
|
|
381
|
+
}
|
|
321
382
|
}
|
|
322
383
|
|
|
323
384
|
const soundConfig = config.sounds[event.type]
|
|
@@ -325,13 +386,16 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
|
|
|
325
386
|
|
|
326
387
|
// Handle simple string (just a sound file path/URL)
|
|
327
388
|
if (typeof soundConfig === "string") {
|
|
328
|
-
|
|
389
|
+
// Use cancellable sound for idle
|
|
390
|
+
const canCancel = event.type === "session.idle" || event.type === "session.error"
|
|
391
|
+
await playSound(soundConfig, event.type, canCancel)
|
|
329
392
|
return
|
|
330
393
|
}
|
|
331
394
|
|
|
332
395
|
// Handle filter object
|
|
333
396
|
if (matchesFilter(event, soundConfig)) {
|
|
334
|
-
|
|
397
|
+
const canCancel = event.type === "session.idle" || event.type === "session.error"
|
|
398
|
+
await playSound(soundConfig.sound, event.type, canCancel)
|
|
335
399
|
}
|
|
336
400
|
},
|
|
337
401
|
|