返回 CodeWhale
sound_policy.rs
根目录 / crates / tui / src / tui / sound_policy.rs
1 //! Opt-in, deterministic event-sound policy for TUI notification events
2 //! (#4817).
3 //!
4 //! This is terminal-bell-level only: every cue is one or two BEL (`\x07`)
5 //! bytes written to stdout, exactly the bytes the existing
6 //! `bell_sound`/`beep_sound` helpers in [`super::notifications`] emit.
7 //! The cues are functional signals (a fixed, documented event → byte
8 //! mapping), not designed-for-pleasantness audio — there are no audio
9 //! assets and no new dependencies. BEL is inert on terminals that ignore
10 //! it and on platforms where the byte is a no-op, and the whole policy is
11 //! **off by default**, so a platform with no sound support falls back to
12 //! doing nothing.
13 //!
14 //! The decision function is pure with respect to wall-clock time: the
15 //! caller supplies `now_ms`, so rate limiting is deterministic and
16 //! testable. The runtime clock used by the wiring in
17 //! [`super::notifications::notify_done_to`] is
18 //! [`std::time::SystemTime`] epoch millis ([`epoch_millis_now`]) — not
19 //! strictly monotonic, but fine for rate limiting, and documented here
20 //! rather than implied.
21
22 use std::io::{self, Write};
23 use std::sync::{OnceLock, RwLock};
24
25 use super::notification_payload::NotificationKind;
26
27 /// The closed set of events that can produce a sound cue. Mirrors
28 /// [`NotificationKind`] one-to-one; kept as a separate type so the sound
29 /// policy's surface (parsing, allow-lists, docs) is independent of the
30 /// notification payload taxonomy.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 pub enum SoundEvent {
33 /// An agent turn finished successfully.
34 TurnComplete,
35 /// A sub-agent reached a terminal status.
36 SubagentTerminal,
37 /// A tool call is blocked waiting for approval.
38 ApprovalNeeded,
39 /// The agent is blocked on a user answer.
40 InputNeeded,
41 /// The sandbox denied an operation; the user must elevate.
42 ElevationNeeded,
43 /// The model called the `notify` tool.
44 ModelNotify,
45 }
46
47 impl SoundEvent {
48 /// Total mapping from the notification taxonomy. Every
49 /// [`NotificationKind`] has exactly one sound event.
50 #[must_use]
51 pub fn from_notification_kind(kind: NotificationKind) -> SoundEvent {
52 match kind {
53 NotificationKind::TurnComplete => SoundEvent::TurnComplete,
54 NotificationKind::SubagentTerminal => SoundEvent::SubagentTerminal,
55 NotificationKind::ApprovalNeeded => SoundEvent::ApprovalNeeded,
56 NotificationKind::InputNeeded => SoundEvent::InputNeeded,
57 NotificationKind::ElevationNeeded => SoundEvent::ElevationNeeded,
58 NotificationKind::ModelNotify => SoundEvent::ModelNotify,
59 }
60 }
61
62 /// Parse the kebab-case config/docs spelling, e.g. `"turn-complete"`. Unknown strings return `None`;
63 /// callers skip them rather than failing.
64 #[must_use]
65 pub fn parse(s: &str) -> Option<SoundEvent> {
66 match s {
67 "turn-complete" => Some(SoundEvent::TurnComplete),
68 "subagent-terminal" => Some(SoundEvent::SubagentTerminal),
69 "approval-needed" => Some(SoundEvent::ApprovalNeeded),
70 "input-needed" => Some(SoundEvent::InputNeeded),
71 "elevation-needed" => Some(SoundEvent::ElevationNeeded),
72 "model-notify" => Some(SoundEvent::ModelNotify),
73 _ => None,
74 }
75 }
76
77 /// Slot index into [`EventSoundPolicy::last_played_ms`].
78 fn index(self) -> usize {
79 match self {
80 SoundEvent::TurnComplete => 0,
81 SoundEvent::SubagentTerminal => 1,
82 SoundEvent::ApprovalNeeded => 2,
83 SoundEvent::InputNeeded => 3,
84 SoundEvent::ElevationNeeded => 4,
85 SoundEvent::ModelNotify => 5,
86 }
87 }
88 }
89
90 /// Terminal-safe cues. `Bell` and `Beep` emit the same single BEL byte as
91 /// the existing `bell_sound`/`beep_sound` helpers; the distinction is
92 /// semantic (which event fired), not a different sound.
93 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
94 pub enum SoundCue {
95 /// One BEL byte (`\x07`).
96 Bell,
97 /// Two BEL bytes (`\x07\x07`).
98 DoubleBell,
99 /// One BEL byte (`\x07`) — the same bytes `beep_sound` writes.
100 Beep,
101 }
102
103 /// The deterministic event mapping. Fixed table, documented here and in
104 /// `docs/CONFIGURATION.md`; changing it is a behavior change, not a tune.
105 /// The cues are functional signals, not designed-for-pleasantness audio.
106 #[must_use]
107 pub fn cue_for(event: SoundEvent) -> SoundCue {
108 match event {
109 SoundEvent::TurnComplete => SoundCue::Bell,
110 SoundEvent::SubagentTerminal => SoundCue::Bell,
111 SoundEvent::ApprovalNeeded => SoundCue::DoubleBell,
112 SoundEvent::InputNeeded => SoundCue::Beep,
113 SoundEvent::ElevationNeeded => SoundCue::DoubleBell,
114 SoundEvent::ModelNotify => SoundCue::Beep,
115 }
116 }
117
118 /// Why a sound was not played.
119 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
120 pub enum SuppressReason {
121 /// The policy is disabled (the default — platform-safe no-op).
122 Disabled,
123 /// Quiet mode is on.
124 QuietMode,
125 /// The event is not in the allow-list.
126 NotListed,
127 /// The event fired within `min_interval_ms` of its previous play.
128 RateLimited,
129 /// `turn-complete` is left to the existing `completion_sound` channel
130 /// so the two never double-ding.
131 TurnCompleteHandledByCompletionSound,
132 }
133
134 /// The outcome of a policy decision.
135 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
136 pub enum SoundDecision {
137 /// Emit this cue.
138 Play(SoundCue),
139 /// Do not emit; this is why.
140 Suppress(SuppressReason),
141 }
142
143 /// Deterministic, opt-in event-sound policy.
144 ///
145 /// `decide` is pure apart from recording the play timestamp: the caller
146 /// supplies `now_ms`, so there is no wall-clock dependency in the rules.
147 #[derive(Debug, Clone)]
148 pub struct EventSoundPolicy {
149 /// Master switch. Default `false` — nothing is emitted unless the
150 /// user opts in via `[notifications.event_sound]`.
151 pub enabled: bool,
152 /// Allow-list of events that may play.
153 pub events: Vec<SoundEvent>,
154 /// Minimum milliseconds between two plays of the *same* event.
155 pub min_interval_ms: u64,
156 /// Quiet mode: suppress everything without editing the allow-list.
157 pub quiet: bool,
158 /// Whether the separate `[notifications].completion_sound` channel is
159 /// active. When it is, `turn-complete` is suppressed here (see
160 /// [`SuppressReason::TurnCompleteHandledByCompletionSound`]) to avoid
161 /// a double ding. Stored on the policy (rather than passed to
162 /// `decide`) because it is configuration, not per-call state.
163 pub completion_sound_active: bool,
164 /// Last play timestamp (caller-supplied millis) per event slot.
165 last_played_ms: [Option<u64>; 6],
166 }
167
168 impl Default for EventSoundPolicy {
169 fn default() -> Self {
170 Self {
171 enabled: false,
172 events: vec![SoundEvent::TurnComplete, SoundEvent::ApprovalNeeded],
173 min_interval_ms: 2000,
174 quiet: false,
175 completion_sound_active: false,
176 last_played_ms: [None; 6],
177 }
178 }
179 }
180
181 impl EventSoundPolicy {
182 /// Build a policy from `[notifications.event_sound]`. Unknown event
183 /// strings are ignored (`parse` → `None` → skip); this never panics.
184 ///
185 /// This direction (tui consumes config) matches how
186 /// `CompletionSound` is plumbed and keeps `config.rs` free of tui
187 /// imports.
188 #[must_use]
189 pub fn from_config(
190 config: &crate::config::EventSoundConfig,
191 completion_sound_active: bool,
192 ) -> Self {
193 let events = config
194 .events
195 .iter()
196 .filter_map(|s| SoundEvent::parse(s))
197 .collect();
198 Self {
199 enabled: config.enabled,
200 events,
201 min_interval_ms: config.min_interval_ms,
202 quiet: config.quiet,
203 completion_sound_active,
204 last_played_ms: [None; 6],
205 }
206 }
207
208 /// Decide whether `event` plays at `now_ms`. Rules, in order:
209 ///
210 /// 1. `!enabled` → `Suppress(Disabled)`
211 /// 2. `quiet` → `Suppress(QuietMode)`
212 /// 3. event not in the allow-list → `Suppress(NotListed)`
213 /// 4. `turn-complete` while `completion_sound` is active →
214 /// `Suppress(TurnCompleteHandledByCompletionSound)`
215 /// 5. within `min_interval_ms` of the last play of this event →
216 /// `Suppress(RateLimited)`
217 /// 6. otherwise record the timestamp and `Play(cue_for(event))`.
218 pub fn decide(&mut self, event: SoundEvent, now_ms: u64) -> SoundDecision {
219 if !self.enabled {
220 return SoundDecision::Suppress(SuppressReason::Disabled);
221 }
222 if self.quiet {
223 return SoundDecision::Suppress(SuppressReason::QuietMode);
224 }
225 if !self.events.contains(&event) {
226 return SoundDecision::Suppress(SuppressReason::NotListed);
227 }
228 if event == SoundEvent::TurnComplete && self.completion_sound_active {
229 return SoundDecision::Suppress(SuppressReason::TurnCompleteHandledByCompletionSound);
230 }
231 let slot = &mut self.last_played_ms[event.index()];
232 if let Some(last) = *slot
233 && now_ms.saturating_sub(last) < self.min_interval_ms
234 {
235 return SoundDecision::Suppress(SuppressReason::RateLimited);
236 }
237 *slot = Some(now_ms);
238 SoundDecision::Play(cue_for(event))
239 }
240 }
241
242 /// Write the cue's bytes. These are exactly the bytes the existing
243 /// `bell_sound`/`beep_sound` helpers emit; BEL is inert on terminals and
244 /// platforms that ignore it, so this is a platform-safe no-op-safe write
245 /// everywhere.
246 pub fn emit(cue: SoundCue, out: &mut dyn Write) -> io::Result<()> {
247 let bytes: &[u8] = match cue {
248 SoundCue::Bell => b"\x07",
249 SoundCue::DoubleBell => b"\x07\x07",
250 SoundCue::Beep => b"\x07",
251 };
252 out.write_all(bytes)
253 }
254
255 /// Epoch millis for the runtime wiring. Not strictly monotonic (NTP can
256 /// step it backwards; `saturating_sub` in `decide` makes that harmless),
257 /// but fine for rate limiting.
258 #[must_use]
259 pub fn epoch_millis_now() -> u64 {
260 std::time::SystemTime::now()
261 .duration_since(std::time::UNIX_EPOCH)
262 .map(|d| d.as_millis() as u64)
263 .unwrap_or(0)
264 }
265
266 static POLICY: OnceLock<RwLock<EventSoundPolicy>> = OnceLock::new();
267
268 fn policy_cell() -> &'static RwLock<EventSoundPolicy> {
269 POLICY.get_or_init(|| RwLock::new(EventSoundPolicy::default()))
270 }
271
272 /// Install a policy process-wide. Called at startup from
273 /// [`super::notifications::settings`] alongside `set_completion_sound`.
274 pub fn configure(policy: EventSoundPolicy) {
275 if let Ok(mut slot) = policy_cell().write() {
276 *slot = policy;
277 }
278 }
279
280 /// Run the installed policy for one notification event, writing any cue to
281 /// `out`. The sink is injected (matching `notify_done_to`) so no test path
282 /// can BEL a real terminal. Best-effort: lock poisoning and write errors
283 /// are swallowed, matching the no-op-safe style of the notification module.
284 pub fn handle_notification_kind_to(kind: NotificationKind, now_ms: u64, out: &mut dyn Write) {
285 let event = SoundEvent::from_notification_kind(kind);
286 let decision = policy_cell()
287 .write()
288 .map(|mut policy| policy.decide(event, now_ms));
289 if let Ok(SoundDecision::Play(cue)) = decision {
290 let _ = emit(cue, out);
291 }
292 }
293
294 #[cfg(test)]
295 mod tests {
296 use super::*;
297 use crate::config::EventSoundConfig;
298
299 fn all_events() -> [SoundEvent; 6] {
300 [
301 SoundEvent::TurnComplete,
302 SoundEvent::SubagentTerminal,
303 SoundEvent::ApprovalNeeded,
304 SoundEvent::InputNeeded,
305 SoundEvent::ElevationNeeded,
306 SoundEvent::ModelNotify,
307 ]
308 }
309
310 fn enabled_policy(events: Vec<SoundEvent>) -> EventSoundPolicy {
311 EventSoundPolicy {
312 enabled: true,
313 events,
314 ..EventSoundPolicy::default()
315 }
316 }
317
318 /// The deterministic mapping, pinned to the documented table.
319 #[test]
320 fn cue_table_is_the_documented_mapping() {
321 assert_eq!(cue_for(SoundEvent::TurnComplete), SoundCue::Bell);
322 assert_eq!(cue_for(SoundEvent::SubagentTerminal), SoundCue::Bell);
323 assert_eq!(cue_for(SoundEvent::ApprovalNeeded), SoundCue::DoubleBell);
324 assert_eq!(cue_for(SoundEvent::InputNeeded), SoundCue::Beep);
325 assert_eq!(cue_for(SoundEvent::ElevationNeeded), SoundCue::DoubleBell);
326 assert_eq!(cue_for(SoundEvent::ModelNotify), SoundCue::Beep);
327 }
328
329 /// The kebab-case spellings are the documented config vocabulary.
330 #[test]
331 fn parse_covers_the_documented_event_names() {
332 let names = [
333 ("turn-complete", SoundEvent::TurnComplete),
334 ("subagent-terminal", SoundEvent::SubagentTerminal),
335 ("approval-needed", SoundEvent::ApprovalNeeded),
336 ("input-needed", SoundEvent::InputNeeded),
337 ("elevation-needed", SoundEvent::ElevationNeeded),
338 ("model-notify", SoundEvent::ModelNotify),
339 ];
340 assert_eq!(
341 names.iter().map(|(_, event)| *event).collect::<Vec<_>>(),
342 all_events(),
343 "the documented names must cover every event"
344 );
345 for (name, event) in names {
346 assert_eq!(SoundEvent::parse(name), Some(event));
347 }
348 assert_eq!(SoundEvent::parse("not-an-event"), None);
349 assert_eq!(SoundEvent::parse(""), None);
350 }
351
352 /// The mapping from the notification taxonomy is total: all six
353 /// kinds map, and the match in `from_notification_kind` stops
354 /// compiling if a kind is added without a mapping.
355 #[test]
356 fn from_notification_kind_is_total() {
357 let kinds = [
358 NotificationKind::TurnComplete,
359 NotificationKind::SubagentTerminal,
360 NotificationKind::ApprovalNeeded,
361 NotificationKind::InputNeeded,
362 NotificationKind::ElevationNeeded,
363 NotificationKind::ModelNotify,
364 ];
365 let events: Vec<SoundEvent> = kinds
366 .into_iter()
367 .map(SoundEvent::from_notification_kind)
368 .collect();
369 assert_eq!(events, all_events());
370 }
371
372 /// Platform-safe no-op fallback: a fresh default policy is disabled,
373 /// so every event is suppressed and nothing is ever emitted.
374 #[test]
375 fn default_policy_suppresses_everything_as_disabled() {
376 let mut policy = EventSoundPolicy::default();
377 assert!(!policy.enabled);
378 for event in all_events() {
379 assert_eq!(
380 policy.decide(event, 0),
381 SoundDecision::Suppress(SuppressReason::Disabled)
382 );
383 assert_eq!(
384 policy.decide(event, 10_000),
385 SoundDecision::Suppress(SuppressReason::Disabled)
386 );
387 }
388 }
389
390 #[test]
391 fn quiet_mode_suppresses_everything() {
392 let mut policy = EventSoundPolicy {
393 quiet: true,
394 ..enabled_policy(all_events().to_vec())
395 };
396 for event in all_events() {
397 assert_eq!(
398 policy.decide(event, 0),
399 SoundDecision::Suppress(SuppressReason::QuietMode)
400 );
401 }
402 }
403
404 #[test]
405 fn unlisted_event_is_suppressed_as_not_listed() {
406 let mut policy = enabled_policy(vec![SoundEvent::TurnComplete]);
407 for event in all_events() {
408 let expected = if event == SoundEvent::TurnComplete {
409 SoundDecision::Play(SoundCue::Bell)
410 } else {
411 SoundDecision::Suppress(SuppressReason::NotListed)
412 };
413 assert_eq!(policy.decide(event, 0), expected, "{event:?}");
414 }
415 }
416
417 /// Rate-limit property, exercised over several event kinds and
418 /// timestamp sequences with a caller-supplied clock: within
419 /// `min_interval_ms` of a play the same event is rate-limited; at
420 /// exactly `min_interval_ms` it plays again.
421 #[test]
422 fn rate_limit_allows_play_only_after_min_interval() {
423 for event in all_events() {
424 for start in [0u64, 1_000, 123_456] {
425 let mut policy = enabled_policy(vec![event]);
426 let cue = cue_for(event);
427 assert_eq!(policy.decide(event, start), SoundDecision::Play(cue));
428 for t in [start + 500, start + 1999] {
429 assert_eq!(
430 policy.decide(event, t),
431 SoundDecision::Suppress(SuppressReason::RateLimited),
432 "{event:?} at t={t} (start={start})"
433 );
434 }
435 assert_eq!(
436 policy.decide(event, start + 2000),
437 SoundDecision::Play(cue),
438 "{event:?} at min_interval boundary"
439 );
440 }
441 }
442 }
443
444 /// Rate limiting is per-event: playing one event does not throttle
445 /// another.
446 #[test]
447 fn rate_limit_is_per_event() {
448 let mut policy = enabled_policy(all_events().to_vec());
449 assert_eq!(
450 policy.decide(SoundEvent::ApprovalNeeded, 0),
451 SoundDecision::Play(SoundCue::DoubleBell)
452 );
453 assert_eq!(
454 policy.decide(SoundEvent::InputNeeded, 1),
455 SoundDecision::Play(SoundCue::Beep)
456 );
457 }
458
459 /// A backwards clock step (NTP) must not panic or double-play.
460 #[test]
461 fn backwards_clock_is_treated_as_rate_limited() {
462 let mut policy = enabled_policy(vec![SoundEvent::ApprovalNeeded]);
463 assert_eq!(
464 policy.decide(SoundEvent::ApprovalNeeded, 5_000),
465 SoundDecision::Play(SoundCue::DoubleBell)
466 );
467 assert_eq!(
468 policy.decide(SoundEvent::ApprovalNeeded, 1_000),
469 SoundDecision::Suppress(SuppressReason::RateLimited)
470 );
471 }
472
473 /// No double-ding with the existing `completion_sound` channel.
474 #[test]
475 fn turn_complete_defers_to_active_completion_sound() {
476 let mut active = EventSoundPolicy {
477 completion_sound_active: true,
478 ..enabled_policy(vec![SoundEvent::TurnComplete])
479 };
480 assert_eq!(
481 active.decide(SoundEvent::TurnComplete, 0),
482 SoundDecision::Suppress(SuppressReason::TurnCompleteHandledByCompletionSound)
483 );
484
485 let mut inactive = enabled_policy(vec![SoundEvent::TurnComplete]);
486 assert_eq!(
487 inactive.decide(SoundEvent::TurnComplete, 0),
488 SoundDecision::Play(SoundCue::Bell)
489 );
490 }
491
492 /// Exact emitted bytes, following the `capture()` byte-assertion
493 /// pattern in `notifications.rs`.
494 #[test]
495 fn emit_writes_exact_bel_bytes() {
496 let mut buf = Vec::new();
497 emit(SoundCue::Bell, &mut buf).unwrap();
498 assert_eq!(buf, b"\x07");
499
500 let mut buf = Vec::new();
501 emit(SoundCue::DoubleBell, &mut buf).unwrap();
502 assert_eq!(buf, b"\x07\x07");
503
504 let mut buf = Vec::new();
505 emit(SoundCue::Beep, &mut buf).unwrap();
506 assert_eq!(buf, b"\x07");
507 }
508
509 #[test]
510 fn from_config_ignores_unknown_events_and_parses_kebab_case() {
511 let config = EventSoundConfig {
512 enabled: true,
513 events: vec![
514 "turn-complete".to_string(),
515 "bogus".to_string(),
516 "ApprovalNeeded".to_string(),
517 "approval-needed".to_string(),
518 ],
519 min_interval_ms: 500,
520 quiet: false,
521 };
522 let policy = EventSoundPolicy::from_config(&config, true);
523 assert!(policy.enabled);
524 assert_eq!(
525 policy.events,
526 vec![SoundEvent::TurnComplete, SoundEvent::ApprovalNeeded]
527 );
528 assert_eq!(policy.min_interval_ms, 500);
529 assert!(!policy.quiet);
530 assert!(policy.completion_sound_active);
531 }
532
533 /// The installed policy is process-global (`POLICY` is a `OnceLock`), so
534 /// any test that touches it must serialize on the crate's test-env lock
535 /// and put the default back before releasing it. The cue goes to an
536 /// injected sink, never the real stdout.
537 #[test]
538 fn installed_policy_drives_the_global_handler() {
539 let _lock = crate::test_support::lock_test_env();
540 configure(enabled_policy(vec![SoundEvent::ApprovalNeeded]));
541
542 let mut out = Vec::new();
543 handle_notification_kind_to(NotificationKind::ApprovalNeeded, 0, &mut out);
544 assert_eq!(out, b"\x07\x07", "the installed allow-list plays");
545
546 let mut out = Vec::new();
547 handle_notification_kind_to(NotificationKind::ApprovalNeeded, 1, &mut out);
548 assert!(
549 out.is_empty(),
550 "the rate limit carries across handler calls"
551 );
552
553 let mut out = Vec::new();
554 handle_notification_kind_to(NotificationKind::InputNeeded, 0, &mut out);
555 assert!(out.is_empty(), "an unlisted event stays silent");
556
557 // Restore the default so no later test inherits an enabled policy.
558 configure(EventSoundPolicy::default());
559 let mut out = Vec::new();
560 handle_notification_kind_to(NotificationKind::ApprovalNeeded, 100_000, &mut out);
561 assert!(out.is_empty(), "the default policy is disabled");
562 }
563
564 #[test]
565 fn from_config_matches_config_defaults() {
566 let policy = EventSoundPolicy::from_config(&EventSoundConfig::default(), false);
567 assert!(!policy.enabled);
568 assert_eq!(
569 policy.events,
570 vec![SoundEvent::TurnComplete, SoundEvent::ApprovalNeeded]
571 );
572 assert_eq!(policy.min_interval_ms, 2000);
573 assert!(!policy.quiet);
574 }
575 }
576
576 lines RUST