返回 CodeWhale
notifications.rs
根目录 / crates / tui / src / tui / notifications.rs
1 //! Desktop notifications for turn completion.
2 //!
3 //! Supports five delivery mechanisms:
4 //! - **OSC 9** — terminal escape sequence (`\x1b]9;…\x07`) for iTerm2,
5 //! Ghostty, WezTerm, and tmux (with DCS passthrough).
6 //! - **Kitty** — OSC 99 protocol with ST terminator (no audible beep).
7 //! - **Ghostty** — OSC 777 notification protocol.
8 //! - **BEL** — audible bell (`\x07`) as a last-resort fallback.
9 //!
10 //! When `method = "auto"`, the resolver picks the best method for the
11 //! current terminal; Windows falls back to `Bel`, which is routed through
12 //! `MessageBeep(MB_OK)` for an audible default notification sound.
13 //!
14 //! Every mechanism is fed a [`NotificationPayload`] — a typed, bounded,
15 //! redaction-aware value — rather than a free-form `String` (#4834). See
16 //! [`crate::tui::notification_payload`] for the per-kind disclosure
17 //! policy.
18 //!
19 //! Delivery is governed by one [`NotificationGate`] (#5041):
20 //! `[notifications].quiet` silences everything and
21 //! `[notifications.events]` disables individual categories, enforced at
22 //! the emission path so no protocol can leak a suppressed event.
23
24 #[cfg(target_os = "windows")]
25 use windows::Win32::System::Diagnostics::Debug::MessageBeep;
26 #[cfg(target_os = "windows")]
27 use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE;
28
29 use std::io::{self, Write};
30 use std::path::{Path, PathBuf};
31 use std::sync::atomic::{AtomicBool, Ordering};
32 use std::sync::atomic::{AtomicU8, AtomicU64};
33 use std::sync::{Mutex, OnceLock};
34 use std::time::Duration;
35
36 use super::notification_payload::NotificationKind;
37 pub use super::notification_payload::NotificationPayload;
38
39 #[cfg(target_os = "windows")]
40 use std::os::windows::ffi::OsStrExt;
41 #[cfg(target_os = "windows")]
42 use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_FILENAME, SND_NODEFAULT};
43 #[cfg(target_os = "windows")]
44 use windows::core::PCWSTR;
45
46 /// Notification delivery method.
47 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48 pub enum Method {
49 /// Automatically pick the best protocol for the current terminal.
50 /// See [`resolve_method`] for the canonical resolution table.
51 #[default]
52 Auto,
53 /// OSC 9 escape: `\x1b]9;<msg>\x07`
54 Osc9,
55 /// Plain BEL character: `\x07`
56 Bel,
57 /// macOS Notification Center via `osascript`.
58 ///
59 /// Only reachable through [`Method::Auto`], and only on the macOS
60 /// terminals that expose no notification escape of their own (Apple
61 /// Terminal, the VS Code and JetBrains embedded terminals, plain tmux
62 /// without `LC_TERMINAL`). iTerm2, WezTerm, Ghostty, and kitty are
63 /// matched earlier in [`resolve_method`] and never get here.
64 ///
65 /// Known limitation (#4834): `display notification` is a Standard
66 /// Additions command, so the banner is attributed to the *bundled*
67 /// host process. `/usr/bin/osascript` is unbundled, so macOS credits
68 /// `com.apple.ScriptEditor2` — which is what supplies the Script
69 /// Editor icon and owns the System Settings → Notifications entry
70 /// (alert style, previews, Do Not Disturb). `display notification`
71 /// takes no icon parameter; fixing the attribution requires shipping
72 /// a real `.app` bundle, not a change in this file.
73 MacOS,
74 /// Kitty notification protocol (OSC 99) with ST terminator.
75 /// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL.
76 Kitty,
77 /// Ghostty notification protocol (OSC 777).
78 /// Uses `ESC ] 777 ; notify ; title ; message BEL`.
79 Ghostty,
80 /// Suppress all notifications.
81 Off,
82 }
83
84 /// Emit a Windows system beep via `MessageBeep(MB_OK)`.
85 ///
86 /// Writing BEL (`\\x07`) to the terminal is silent on most Windows
87 /// terminals (Windows Terminal, Conhost, etc.), so we call the Win32
88 /// API directly to produce the standard notification sound.
89 #[cfg(target_os = "windows")]
90 fn windows_bell() {
91 // MB_OK = 0x00000000 — plays the default system sound. Best-effort: a
92 // failed beep is not worth surfacing to the caller, so the Result is
93 // discarded.
94 unsafe {
95 let _ = MessageBeep(MESSAGEBOX_STYLE(0));
96 }
97 }
98
99 /// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`,
100 /// `$LC_TERMINAL`, and `$TERM`.
101 ///
102 /// Resolution table:
103 /// - `iTerm.app`, `WezTerm`, `Cmux` → `Osc9`
104 /// - `Ghostty` → `Ghostty` (OSC 777)
105 /// - `kitty` → `Kitty` (OSC 99)
106 /// - `$LC_TERMINAL` matches OSC-9 capable → `Osc9` (Cmux that sets LC_TERMINAL)
107 /// - `$TERM` contains `ghostty` → `Osc9` (cmux etc.)
108 /// - `$TERM` contains `kitty` → `Kitty`
109 /// - Unix unknown → `Bel`
110 /// - Windows unknown → `Bel`
111 #[must_use]
112 fn resolve_method() -> Method {
113 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
114 match term_program.as_str() {
115 "iTerm.app" | "WezTerm" | "Cmux" => return Method::Osc9,
116 "Ghostty" => return Method::Ghostty,
117 "kitty" => return Method::Kitty,
118 _ => {}
119 }
120
121 // LC_TERMINAL fallback for terminals (e.g. Cmux) that set
122 // LC_TERMINAL instead of TERM_PROGRAM.
123 let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
124 match lc_terminal.as_str() {
125 "iTerm.app" | "Ghostty" | "WezTerm" | "Cmux" => return Method::Osc9,
126 _ => {}
127 }
128
129 // Windows: use BEL so `windows_bell()` (MessageBeep) fires on turn
130 // completion. Previous behavior returned `Off` to avoid the error chime
131 // (#583), but `MessageBeep(MB_OK)` plays the *default system sound* —
132 // distinct from the error sound — so BEL is safe and gives Windows users
133 // audible feedback when a long turn finishes.
134 if cfg!(target_os = "windows") {
135 return Method::Bel;
136 }
137
138 if cfg!(target_os = "macos") {
139 return Method::MacOS;
140 }
141
142 // Ghostty-based terminals (cmux, etc.) may not set their own
143 // TERM_PROGRAM but do set TERM=xterm-ghostty. Likewise for Kitty.
144 let term = std::env::var("TERM").unwrap_or_default();
145 if term.contains("ghostty") {
146 Method::Osc9
147 } else if term.contains("kitty") {
148 Method::Kitty
149 } else {
150 Method::Bel
151 }
152 }
153
154 /// Wrap an escape sequence for terminal multiplexer passthrough.
155 ///
156 /// tmux intercepts escape sequences; DCS passthrough tunnels them to
157 /// the outer terminal unmodified. Every ESC inside the payload is
158 /// doubled so tmux does not interpret it as DCS end.
159 fn wrap_for_multiplexer(seq: &str, in_tmux: bool) -> String {
160 if in_tmux {
161 let escaped = seq.replace('\x1b', "\x1b\x1b");
162 format!("\x1bPtmux;{escaped}\x1b\\")
163 } else {
164 seq.to_string()
165 }
166 }
167
168 /// Build the raw escape bytes for the given method and message.
169 ///
170 /// When `in_tmux` is `true`, OSC sequences are wrapped in DCS passthrough
171 /// so tmux forwards them to the outer terminal.
172 #[must_use]
173 fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
174 match method {
175 Method::Bel => vec![b'\x07'],
176 Method::Osc9 => {
177 let inner = format!("\x1b]9;{msg}\x07");
178 if in_tmux {
179 let escaped_inner = inner.replace('\x1b', "\x1b\x1b");
180 format!("\x1bPtmux;{escaped_inner}\x1b\\").into_bytes()
181 } else {
182 inner.into_bytes()
183 }
184 }
185 Method::Kitty => {
186 // Kitty notification: OSC 99 ; params ST
187 // ST terminator (ESC \) instead of BEL to avoid audible beep.
188 let title_seq = "\x1b]99;d=0:p=title\x1b\\";
189 let body_seq = format!("\x1b]99;p=body;{msg}\x1b\\");
190 let focus_seq = "\x1b]99;d=1:a=focus\x1b\\";
191 let combined = format!("{title_seq}{body_seq}{focus_seq}");
192 wrap_for_multiplexer(&combined, in_tmux).into_bytes()
193 }
194 Method::Ghostty => {
195 // Ghostty notification: OSC 777 ; notify ; title ; message BEL
196 let seq = format!("\x1b]777;notify;codewhale;{msg}\x07");
197 wrap_for_multiplexer(&seq, in_tmux).into_bytes()
198 }
199 // Auto and Off and MacOS should not reach build_escape.
200 Method::Auto | Method::Off | Method::MacOS => vec![],
201 }
202 }
203
204 // ── Notification gate (#5041) ────────────────────────────────────────
205 //
206 // One policy switchboard between "an event happened" and "the user's
207 // desktop is interrupted". `[notifications].quiet` silences every
208 // category; `[notifications.events]` disables individual categories. The
209 // gate is installed from config by [`settings`] and consulted by
210 // [`notify_done`] ahead of every delivery mechanism, so a disabled
211 // category can never leak through one specific protocol.
212
213 /// Which notification categories may reach the user's desktop.
214 ///
215 /// The category set mirrors [`NotificationKind`] one-to-one. Default:
216 /// everything enabled, quiet off — matching the pre-#5041 behavior.
217 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
218 pub struct NotificationGate {
219 /// Suppress every category when `true` (`[notifications].quiet`).
220 pub quiet: bool,
221 pub turn_complete: bool,
222 pub subagent_terminal: bool,
223 pub approval_needed: bool,
224 pub input_needed: bool,
225 pub elevation_needed: bool,
226 pub model_notify: bool,
227 }
228
229 impl Default for NotificationGate {
230 fn default() -> Self {
231 Self {
232 quiet: false,
233 turn_complete: true,
234 subagent_terminal: true,
235 approval_needed: true,
236 input_needed: true,
237 elevation_needed: true,
238 model_notify: true,
239 }
240 }
241 }
242
243 impl NotificationGate {
244 /// Project the `[notifications]` config block onto a gate.
245 #[must_use]
246 pub fn from_config(notif: &crate::config::NotificationsConfig) -> Self {
247 Self {
248 quiet: notif.quiet,
249 turn_complete: notif.events.turn_complete,
250 subagent_terminal: notif.events.subagent_terminal,
251 approval_needed: notif.events.approval_needed,
252 input_needed: notif.events.input_needed,
253 elevation_needed: notif.events.elevation_needed,
254 model_notify: notif.events.model_notify,
255 }
256 }
257
258 /// Whether an event of `kind` may be delivered under this gate.
259 #[must_use]
260 pub fn allows(self, kind: NotificationKind) -> bool {
261 if self.quiet {
262 return false;
263 }
264 match kind {
265 NotificationKind::TurnComplete => self.turn_complete,
266 NotificationKind::SubagentTerminal => self.subagent_terminal,
267 NotificationKind::ApprovalNeeded => self.approval_needed,
268 NotificationKind::InputNeeded => self.input_needed,
269 NotificationKind::ElevationNeeded => self.elevation_needed,
270 NotificationKind::ModelNotify => self.model_notify,
271 }
272 }
273
274 const QUIET_BIT: u8 = 1 << 0;
275 const TURN_COMPLETE_BIT: u8 = 1 << 1;
276 const SUBAGENT_TERMINAL_BIT: u8 = 1 << 2;
277 const APPROVAL_NEEDED_BIT: u8 = 1 << 3;
278 const INPUT_NEEDED_BIT: u8 = 1 << 4;
279 const ELEVATION_NEEDED_BIT: u8 = 1 << 5;
280 const MODEL_NOTIFY_BIT: u8 = 1 << 6;
281
282 const fn to_bits(self) -> u8 {
283 (self.quiet as u8 * Self::QUIET_BIT)
284 | (self.turn_complete as u8 * Self::TURN_COMPLETE_BIT)
285 | (self.subagent_terminal as u8 * Self::SUBAGENT_TERMINAL_BIT)
286 | (self.approval_needed as u8 * Self::APPROVAL_NEEDED_BIT)
287 | (self.input_needed as u8 * Self::INPUT_NEEDED_BIT)
288 | (self.elevation_needed as u8 * Self::ELEVATION_NEEDED_BIT)
289 | (self.model_notify as u8 * Self::MODEL_NOTIFY_BIT)
290 }
291
292 const fn from_bits(bits: u8) -> Self {
293 Self {
294 quiet: bits & Self::QUIET_BIT != 0,
295 turn_complete: bits & Self::TURN_COMPLETE_BIT != 0,
296 subagent_terminal: bits & Self::SUBAGENT_TERMINAL_BIT != 0,
297 approval_needed: bits & Self::APPROVAL_NEEDED_BIT != 0,
298 input_needed: bits & Self::INPUT_NEEDED_BIT != 0,
299 elevation_needed: bits & Self::ELEVATION_NEEDED_BIT != 0,
300 model_notify: bits & Self::MODEL_NOTIFY_BIT != 0,
301 }
302 }
303 }
304
305 /// Everything on, quiet off — the pre-#5041 behavior, and the effective
306 /// policy until the first [`settings`] call installs the configured gate.
307 const GATE_DEFAULT_BITS: u8 = 0b0111_1110;
308
309 /// Process-wide gate, packed to one byte so reads on the emission path are
310 /// a single atomic load (same pattern as `COMPLETION_SOUND_MODE`).
311 static NOTIFICATION_GATE: AtomicU8 = AtomicU8::new(GATE_DEFAULT_BITS);
312
313 /// Install `gate` as the process-wide notification policy.
314 pub fn install_notification_gate(gate: NotificationGate) {
315 NOTIFICATION_GATE.store(gate.to_bits(), Ordering::SeqCst);
316 }
317
318 /// The currently installed process-wide notification gate.
319 #[must_use]
320 pub fn current_notification_gate() -> NotificationGate {
321 NotificationGate::from_bits(NOTIFICATION_GATE.load(Ordering::SeqCst))
322 }
323
324 /// Emit a notification to `sink` if the elapsed time meets or exceeds
325 /// `threshold`, `method` is not `Off`, and `gate` allows the payload's
326 /// category.
327 ///
328 /// This variant takes a `W: Write` sink and an explicit gate for
329 /// testability; production callers go through [`notify_done`], which
330 /// loads the installed process-wide gate.
331 pub fn notify_done_to<W: Write>(
332 method: Method,
333 in_tmux: bool,
334 payload: &NotificationPayload,
335 threshold: Duration,
336 elapsed: Duration,
337 gate: NotificationGate,
338 sink: &mut W,
339 ) {
340 if elapsed < threshold {
341 return;
342 }
343 if method == Method::Off {
344 return;
345 }
346 if !gate.allows(payload.kind()) {
347 tracing::debug!(
348 kind = ?payload.kind(),
349 quiet = gate.quiet,
350 "notification suppressed by [notifications] gate"
351 );
352 return;
353 }
354 let effective = match method {
355 Method::Off => unreachable!("Method::Off returned before gate evaluation"),
356 Method::Auto => resolve_method(),
357 other => other,
358 };
359
360 // "I get no notifications" and "the wrong app posted it" (#4834) are
361 // both diagnosed by knowing which kind resolved to which mechanism.
362 tracing::debug!(
363 kind = ?payload.kind(),
364 method = ?effective,
365 in_tmux,
366 "emitting desktop notification"
367 );
368
369 // Opt-in event-sound policy (#4817). A no-op unless
370 // `[notifications.event_sound].enabled = true`; errors are swallowed
371 // like every other best-effort terminal write in this module.
372 crate::tui::sound_policy::handle_notification_kind_to(
373 payload.kind(),
374 crate::tui::sound_policy::epoch_millis_now(),
375 sink,
376 );
377
378 // macOS Notification Center: handled via osascript, not terminal escapes.
379 #[cfg(target_os = "macos")]
380 if Method::MacOS == effective {
381 macos_display_notification(payload);
382 return;
383 }
384
385 let bytes = build_escape(effective, in_tmux, &payload.render_inline());
386 if bytes.is_empty() {
387 return;
388 }
389 // Best-effort: ignore write errors (e.g. stdout closed).
390 let _ = sink.write_all(&bytes);
391 let _ = sink.flush();
392
393 // On Windows, writing BEL (`\x07`) to the terminal is silent in most
394 // terminals (Windows Terminal, Conhost, etc.). Call MessageBeep to
395 // produce an actual notification sound via the system audio scheme.
396 #[cfg(target_os = "windows")]
397 if effective == Method::Bel {
398 windows_bell();
399 }
400 }
401
402 /// Emit a notification to **stdout** if `elapsed >= threshold`.
403 ///
404 /// With `method = Auto`, selects the best protocol for the current terminal
405 /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel). The unknown-terminal
406 /// fallback is platform-aware: `Bel` on every platform, with Windows routing
407 /// it through `MessageBeep(MB_OK)` for a default system notification sound.
408 /// See [`resolve_method`] for the canonical resolution table. Pass
409 /// `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) to wrap OSC
410 /// sequences in a DCS passthrough.
411 pub fn notify_done(
412 method: Method,
413 in_tmux: bool,
414 payload: &NotificationPayload,
415 threshold: Duration,
416 elapsed: Duration,
417 ) {
418 notify_done_to(
419 method,
420 in_tmux,
421 payload,
422 threshold,
423 elapsed,
424 current_notification_gate(),
425 &mut io::stdout(),
426 );
427 }
428
429 /// Set the terminal taskbar progress state via OSC 9 ; 4.
430 ///
431 /// Windows Terminal supports this to show progress on the taskbar icon:
432 /// - `state = 0` — no progress (clear)
433 /// - `state = 1` — indeterminate (cycling green)
434 /// - `state = 2` — normal (0-100, requires progress param)
435 /// - `state = 3` — error (red)
436 /// - `state = 4` — paused (yellow)
437 ///
438 /// Other terminals (iTerm2, WezTerm) ignore the sequence silently.
439 /// Best-effort — write failures are ignored.
440 /// Build the OSC 9;4 taskbar-progress sequence. Split from the write so the
441 /// bytes can be asserted without depending on whether the test runner owns a
442 /// terminal.
443 #[must_use]
444 fn taskbar_progress_sequence(state: u8, progress: Option<u8>) -> String {
445 match progress {
446 Some(pct) => format!("\x1b]9;4;{state};{pct}\x07"),
447 None => format!("\x1b]9;4;{state}\x07"),
448 }
449 }
450
451 /// Build the OSC 0 window-title sequence. Split from the write for the same
452 /// reason as [`taskbar_progress_sequence`].
453 #[must_use]
454 fn terminal_title_sequence(title: &str) -> String {
455 format!("\x1b]0;{title}\x07")
456 }
457
458 /// Whether raw terminal control sequences may be written to stdout.
459 ///
460 /// OSC 9;4 (taskbar progress) and OSC 0 (window title) are *control* bytes,
461 /// not content. A terminal that understands them renders nothing visible; a
462 /// pipe, a file, or a CI log renders them literally, so `cargo test` output
463 /// and redirected sessions pick up stray `]9;4;1]0;` noise. Gate on stdout
464 /// actually being a TTY — there is no one to control otherwise.
465 fn stdout_accepts_control_sequences() -> bool {
466 use std::io::IsTerminal;
467 io::stdout().is_terminal()
468 }
469
470 pub fn set_taskbar_progress(state: u8, progress: Option<u8>) {
471 if !stdout_accepts_control_sequences() {
472 return;
473 }
474 let seq = taskbar_progress_sequence(state, progress);
475 let mut stdout = io::stdout();
476 let _ = stdout.write_all(seq.as_bytes());
477 let _ = stdout.flush();
478 }
479
480 /// Set taskbar progress to indeterminate (cycling) — call at turn start.
481 pub fn set_taskbar_progress_busy() {
482 set_taskbar_progress(1, None);
483 }
484
485 /// Clear taskbar progress — call at turn end.
486 pub fn clear_taskbar_progress() {
487 set_taskbar_progress(0, None);
488 }
489
490 /// Shared flag controlling the title activity marker. Set to `true` by
491 /// `start_title_animation()`, cleared by `stop_title_animation()`.
492 static TITLE_ANIMATION_RUNNING: AtomicBool = AtomicBool::new(false);
493 /// Focus reporting starts enabled before the event loop begins, so treating
494 /// the terminal as focused is the safe default: never flood window chrome
495 /// unless the terminal has explicitly reported `FocusLost` or motion is on.
496 static TERMINAL_FOCUSED: AtomicBool = AtomicBool::new(true);
497 /// When false, the title keeps a static whale + state (reduced motion /
498 /// status animation off) instead of cycling frames.
499 static TITLE_MOTION_ENABLED: AtomicBool = AtomicBool::new(true);
500 /// Invalidates a previous animation worker when a new turn starts or ends.
501 static TITLE_ANIMATION_GENERATION: AtomicU64 = AtomicU64::new(0);
502 static TITLE_ANIMATION_BASE: OnceLock<Mutex<String>> = OnceLock::new();
503 static TITLE_ACTIVITY_VERB: OnceLock<Mutex<String>> = OnceLock::new();
504 /// Whale frames restored from #1871 (`cd357de0c`). Cycle slowly so the
505 /// terminal title communicates life without competing with in-app spinners.
506 const TITLE_FRAME_HOLD: Duration = Duration::from_millis(800);
507 const TITLE_WHALE_FRAMES: &[&str] = &["🐳", "🐋", "🐳", "🐋"];
508
509 fn title_animation_base() -> &'static Mutex<String> {
510 TITLE_ANIMATION_BASE.get_or_init(|| Mutex::new("Codewhale".to_string()))
511 }
512
513 fn title_activity_verb() -> &'static Mutex<String> {
514 TITLE_ACTIVITY_VERB.get_or_init(|| Mutex::new("working…".to_string()))
515 }
516
517 /// Configure whether the title whale cycles frames.
518 ///
519 /// Call once at startup (and whenever motion settings change). Reduced motion
520 /// and `status_indicator = "off"` both freeze the title to a single whale.
521 pub fn set_title_motion_enabled(enabled: bool) {
522 TITLE_MOTION_ENABLED.store(enabled, Ordering::SeqCst);
523 }
524
525 /// Update the truthful activity verb shown next to the title whale
526 /// (`working…`, `reasoning…`, `using tool…`, `verifying…`, `waiting on you…`).
527 pub fn set_title_activity_verb(verb: &str) {
528 let verb = verb.trim();
529 if verb.is_empty() {
530 return;
531 }
532 if let Ok(mut slot) = title_activity_verb().lock() {
533 if slot.as_str() == verb {
534 return;
535 }
536 verb.clone_into(&mut *slot);
537 }
538 if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
539 return;
540 }
541 let base = title_animation_base()
542 .lock()
543 .map_or_else(|_| "Codewhale".to_string(), |base| base.clone());
544 set_terminal_title(&title_activity_label(
545 &base,
546 Duration::ZERO,
547 TERMINAL_FOCUSED.load(Ordering::SeqCst),
548 TITLE_MOTION_ENABLED.load(Ordering::SeqCst),
549 ));
550 }
551
552 #[must_use]
553 fn title_activity_label(base: &str, elapsed: Duration, focused: bool, motion: bool) -> String {
554 let verb = title_activity_verb()
555 .lock()
556 .map_or_else(|_| "working…".to_string(), |v| v.clone());
557 let body = if verb.is_empty() {
558 base.to_string()
559 } else {
560 verb
561 };
562 // Static title when motion is off or the window is focused: one whale +
563 // state, no competing spinner in the focused app chrome.
564 if !motion || focused {
565 return format!("🐳 {body}");
566 }
567 let frame = TITLE_WHALE_FRAMES
568 [(elapsed.as_millis() / TITLE_FRAME_HOLD.as_millis()) as usize % TITLE_WHALE_FRAMES.len()];
569 format!("{frame} {body}")
570 }
571
572 /// Write OSC 0 (set window title) sequence.
573 fn set_terminal_title(title: &str) {
574 if !stdout_accepts_control_sequences() {
575 return;
576 }
577 let seq = terminal_title_sequence(title);
578 let mut stdout = io::stdout();
579 let _ = stdout.write_all(seq.as_bytes());
580 let _ = stdout.flush();
581 }
582
583 /// Tracks whether the completion marker was set, so
584 /// `reset_title_on_interaction()` can skip redundant writes.
585 static COMPLETION_MARKER_SHOWN: AtomicBool = AtomicBool::new(false);
586
587 /// Mark the terminal title as active with the animated whale + state verb.
588 ///
589 /// While focused (or under reduced motion), the title stays a static whale
590 /// with the current verb. After `FocusLost` with motion enabled, the whale
591 /// frames cycle so alt-tabbed sessions still communicate progress.
592 pub fn start_title_animation(original: &str) {
593 if let Ok(mut base) = title_animation_base().lock() {
594 original.clone_into(&mut base);
595 }
596 if let Ok(mut verb) = title_activity_verb().lock()
597 && verb.is_empty()
598 {
599 "working…".clone_into(&mut *verb);
600 }
601 COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
602 TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst);
603 let generation = TITLE_ANIMATION_GENERATION
604 .fetch_add(1, Ordering::SeqCst)
605 .saturating_add(1);
606 let focused = TERMINAL_FOCUSED.load(Ordering::SeqCst);
607 let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
608 set_terminal_title(&title_activity_label(
609 original,
610 Duration::ZERO,
611 focused,
612 motion,
613 ));
614
615 let base = original.to_string();
616 std::thread::spawn(move || {
617 let started_at = std::time::Instant::now();
618 loop {
619 std::thread::sleep(TITLE_FRAME_HOLD);
620 if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst)
621 || TITLE_ANIMATION_GENERATION.load(Ordering::SeqCst) != generation
622 {
623 break;
624 }
625 let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
626 // Only advance frames when unfocused + motion is on. Focused
627 // windows keep the static whale so the title is not a second
628 // spinner competing with in-app activity chrome.
629 if motion && !TERMINAL_FOCUSED.load(Ordering::SeqCst) {
630 set_terminal_title(&title_activity_label(
631 &base,
632 started_at.elapsed(),
633 false,
634 true,
635 ));
636 }
637 }
638 });
639 }
640
641 /// Update the focus gate used by the title activity signal.
642 ///
643 /// Focus gain immediately restores the steady whale + verb. Focus loss emits
644 /// the first animation frame immediately, then the worker advances it at the
645 /// debounced whale cadence.
646 pub fn set_terminal_focused(focused: bool) {
647 TERMINAL_FOCUSED.store(focused, Ordering::SeqCst);
648 if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
649 return;
650 }
651 let base = title_animation_base()
652 .lock()
653 .map_or_else(|_| "Codewhale".to_string(), |base| base.clone());
654 let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
655 set_terminal_title(&title_activity_label(
656 &base,
657 Duration::ZERO,
658 focused,
659 motion,
660 ));
661 }
662
663 /// Stop the title animation and show a completion marker.
664 ///
665 /// Sets the title to `✓ done` so alt-tabbed users see at a glance that
666 /// processing finished. The marker is overwritten on the next turn by
667 /// [`start_title_animation`].
668 pub fn stop_title_animation() {
669 TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
670 TITLE_ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
671 // Always show the completion marker so quiet-sound modes still communicate
672 // finish state in the window title; interaction clears it.
673 COMPLETION_MARKER_SHOWN.store(true, Ordering::SeqCst);
674 set_terminal_title("✓ done");
675 play_completion_sound();
676 }
677
678 /// Stop the title animation without playing the completion sound.
679 ///
680 /// Cancellation and failed turns should return the terminal title to rest
681 /// without presenting them as completed work.
682 pub fn stop_title_animation_quietly() {
683 TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
684 TITLE_ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
685 COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
686 set_terminal_title("Codewhale");
687 }
688
689 /// Clear the completion marker from the title when the user interacts.
690 ///
691 /// Call this on every user input event (key press, mouse click) so the
692 /// marker doesn't persist once the user is back at the terminal.
693 pub fn reset_title_on_interaction() {
694 if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) {
695 set_terminal_title("Codewhale");
696 }
697 }
698
699 /// Completion sound mode (0 = off, 1 = beep, 2 = bell, 3 = file).
700 static COMPLETION_SOUND_MODE: AtomicU8 = AtomicU8::new(1);
701 static COMPLETION_SOUND_FILE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
702 #[cfg(not(target_os = "windows"))]
703 static COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED: AtomicBool = AtomicBool::new(false);
704 static COMPLETION_SOUND_FILE_MISSING_WARNED: AtomicBool = AtomicBool::new(false);
705
706 fn completion_sound_file_slot() -> &'static Mutex<Option<PathBuf>> {
707 COMPLETION_SOUND_FILE.get_or_init(|| Mutex::new(None))
708 }
709
710 fn set_completion_sound(mode: crate::config::CompletionSound, sound_file: Option<PathBuf>) {
711 let val = match mode {
712 crate::config::CompletionSound::Off => 0u8,
713 crate::config::CompletionSound::Beep => 1u8,
714 crate::config::CompletionSound::Bell => 2u8,
715 crate::config::CompletionSound::File => 3u8,
716 };
717 COMPLETION_SOUND_MODE.store(val, Ordering::SeqCst);
718 if let Ok(mut slot) = completion_sound_file_slot().lock() {
719 if sound_file.is_some() {
720 COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst);
721 }
722 *slot = sound_file;
723 }
724 }
725
726 /// Play the configured completion sound (if not `Off`).
727 pub fn play_completion_sound() {
728 match COMPLETION_SOUND_MODE.load(Ordering::SeqCst) {
729 0 => {} // Off
730 1 => {
731 beep_sound();
732 }
733 2 => {
734 bell_sound();
735 }
736 3 => {
737 file_sound();
738 }
739 _ => {}
740 }
741 }
742
743 /// Play a short completion sound via the system beep.
744 ///
745 /// On Windows uses `MessageBeep(MB_OK)` which plays the default system
746 /// notification sound. On other platforms writes `BEL` (`\x07`) to stdout.
747 #[cfg(target_os = "windows")]
748 fn beep_sound() {
749 windows_bell();
750 }
751
752 /// Non-Windows: write BEL to stdout for the terminal bell.
753 #[cfg(not(target_os = "windows"))]
754 fn beep_sound() {
755 let _ = io::stdout().write_all(b"\x07");
756 }
757
758 /// Pure terminal BEL character.
759 fn bell_sound() {
760 let _ = io::stdout().write_all(b"\x07");
761 }
762
763 fn configured_sound_file() -> Option<PathBuf> {
764 completion_sound_file_slot()
765 .lock()
766 .ok()
767 .and_then(|slot| slot.clone())
768 }
769
770 #[cfg(target_os = "windows")]
771 fn play_sound_file(path: &Path) {
772 let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
773 // Best-effort and async: notification sound failure should not block or
774 // fail a completed agent turn.
775 unsafe {
776 let _ = PlaySoundW(
777 PCWSTR(wide.as_ptr()),
778 None,
779 SND_FILENAME | SND_ASYNC | SND_NODEFAULT,
780 );
781 }
782 }
783
784 #[cfg(not(target_os = "windows"))]
785 fn play_sound_file(_path: &Path) {
786 if !COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED.swap(true, Ordering::SeqCst) {
787 tracing::warn!("completion_sound = \"file\" is currently supported on Windows only");
788 }
789 }
790
791 fn file_sound() {
792 if let Some(path) = configured_sound_file() {
793 play_sound_file(&path);
794 } else if !COMPLETION_SOUND_FILE_MISSING_WARNED.swap(true, Ordering::SeqCst) {
795 tracing::warn!("completion_sound = \"file\" requires [notifications].sound_file");
796 }
797 }
798
799 #[cfg(test)]
800 fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option<PathBuf>) {
801 let mode = match COMPLETION_SOUND_MODE.load(Ordering::SeqCst) {
802 0 => crate::config::CompletionSound::Off,
803 1 => crate::config::CompletionSound::Beep,
804 2 => crate::config::CompletionSound::Bell,
805 3 => crate::config::CompletionSound::File,
806 _ => crate::config::CompletionSound::Off,
807 };
808 (mode, configured_sound_file())
809 }
810
811 /// Show a macOS Notification Center alert via `osascript`.
812 ///
813 /// Runs on a dedicated background thread so the caller is not blocked.
814 ///
815 /// The notification includes:
816 /// - **Title**: "Codewhale"
817 /// - **Subtitle**: [`NotificationPayload::headline`] (≤ 80 chars)
818 /// - **Body**: [`NotificationPayload::body`] (≤ 322 chars: a ≤ 120-char
819 /// detail, a separator, and a ≤ 200-char preview)
820 /// - **Sound**: Default macOS notification sound
821 ///
822 /// Both fields arrive already sanitized, redacted, and character-bounded
823 /// by [`NotificationPayload`]; this function does not re-derive them from
824 /// free-form text (#4834).
825 ///
826 /// **Security**: The message is passed to `osascript` as a command-line
827 /// argument via `ARGV`, never embedded inline in the AppleScript source.
828 /// AppleScript does not treat backslash as an escape inside double-quoted
829 /// string literals, so the previous `\"` approach would terminate the
830 /// string at the `"` and leave any text between unbalanced quotes
831 /// evaluated as raw AppleScript code — a code-injection vector for
832 /// AI-generated notification text. Passing via `ARGV` avoids this
833 /// entirely because the message is never parsed as AppleScript syntax.
834 /// Keep it that way.
835 ///
836 /// **Attribution**: the banner is posted on behalf of `osascript`, which
837 /// is unbundled, so macOS attributes it to `com.apple.ScriptEditor2`. See
838 /// [`Method::MacOS`] — that is not fixable from here.
839 ///
840 /// This is best-effort: if `osascript` is not available (e.g. headless SSH
841 /// session) the error is logged via `tracing::warn!` instead of silently
842 /// swallowed.
843 #[cfg(target_os = "macos")]
844 fn macos_display_notification(payload: &NotificationPayload) {
845 let (subtitle, body) = macos_notification_parts(payload);
846
847 // Spawn on a background thread so we don't block the caller.
848 // osascript itself is fast (~50 ms), but spawning a subprocess
849 // synchronously from an async context steals a tokio thread.
850 let _ = std::thread::Builder::new()
851 .name("osascript-notif".into())
852 .spawn(move || {
853 // Build AppleScript that receives the message via ARGV
854 // instead of inline string interpolation. AppleScript does
855 // not treat backslash as an escape inside double-quoted
856 // string literals, so `\"` would terminate the string at
857 // the `"` and leave a dangling `\`. Passing the message as
858 // a command-line argument avoids any injection risk.
859 let args = [
860 "-e".to_string(),
861 "on run argv".to_string(),
862 "-e".to_string(),
863 "set theBody to item 1 of argv".to_string(),
864 "-e".to_string(),
865 "set theSubtitle to item 2 of argv".to_string(),
866 "-e".to_string(),
867 "display notification theBody with title \"Codewhale\" subtitle theSubtitle sound name \"default\"".to_string(),
868 "-e".to_string(),
869 "end run".to_string(),
870 "--".to_string(),
871 body,
872 subtitle,
873 ];
874
875 match std::process::Command::new("osascript")
876 .args(&args)
877 .output()
878 {
879 Ok(output) if !output.status.success() => {
880 let stderr = String::from_utf8_lossy(&output.stderr);
881 tracing::warn!(stderr = %stderr, "osascript notification failed");
882 }
883 Err(e) => {
884 tracing::warn!(error = %e, "osascript notification error");
885 }
886 _ => {}
887 }
888 });
889 }
890
891 /// Split a payload into the `(subtitle, body)` pair `display notification`
892 /// wants. Both halves are already bounded and redacted by the payload
893 /// constructors, so this is a projection, not a sanitizer.
894 #[cfg(target_os = "macos")]
895 fn macos_notification_parts(payload: &NotificationPayload) -> (String, String) {
896 (payload.headline().to_string(), payload.body())
897 }
898
899 // ── Per-turn notification composition ────────────────────────────────
900 //
901 // The helpers below decide *whether* to notify on a completed turn and
902 // *what message* to put in the body. The low-level dispatcher is
903 // `notify_done`; everything in this block sits in front of it.
904
905 use crate::localization::{Locale, MessageId, tr};
906 use crate::models::{ContentBlock, Message};
907 use crate::tools::subagent::SubAgentStatus;
908 use crate::tui::app::App;
909
910 /// Resolve the effective notification method/threshold/include-summary tuple
911 /// for a completed turn, taking the high-level
912 /// `[tui].notification_condition` override into account on top of the
913 /// lower-level `[notifications]` block.
914 ///
915 /// Returns `None` to mean "do not notify" (either because the user set
916 /// `notification_condition = "never"` or because the resolved method is
917 /// `Off`).
918 pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
919 let notif = config.notifications_config();
920 // Install the category/quiet gate (#5041) so `notify_done` honors
921 // `[notifications].quiet` and `[notifications.events]`.
922 install_notification_gate(NotificationGate::from_config(&notif));
923 // Initialize completion sound mode from config.
924 set_completion_sound(notif.completion_sound, notif.sound_file);
925 // Initialize the opt-in event-sound policy (#4817) from the sibling
926 // `[notifications.event_sound]` table. `completion_sound` active means
927 // the policy defers `turn-complete` to that channel (no double ding).
928 crate::tui::sound_policy::configure(crate::tui::sound_policy::EventSoundPolicy::from_config(
929 &notif.event_sound,
930 notif.completion_sound != crate::config::CompletionSound::Off,
931 ));
932 let method = match notif.method {
933 crate::config::NotificationMethod::Auto => Method::Auto,
934 crate::config::NotificationMethod::Osc9 => Method::Osc9,
935 crate::config::NotificationMethod::Bel => Method::Bel,
936 crate::config::NotificationMethod::Kitty => Method::Kitty,
937 crate::config::NotificationMethod::Ghostty => Method::Ghostty,
938 crate::config::NotificationMethod::Off => Method::Off,
939 };
940
941 if let Some(condition) = config
942 .tui
943 .as_ref()
944 .and_then(|tui| tui.notification_condition)
945 {
946 match condition {
947 crate::config::NotificationCondition::Always => {
948 return Some((method, Duration::ZERO, notif.include_summary));
949 }
950 crate::config::NotificationCondition::Never => return None,
951 }
952 }
953
954 Some((
955 method,
956 Duration::from_secs(notif.threshold_secs),
957 notif.include_summary,
958 ))
959 }
960
961 /// Build the notification payload for a completed turn. Prefers the live
962 /// streaming text the user just saw; falls back to the latest assistant
963 /// message in `api_messages` if streaming text is empty (for example, the
964 /// turn finished entirely through tool output). When `include_summary` is
965 /// true, an elapsed/cost suffix is appended to the headline.
966 ///
967 /// The assistant text becomes the payload's *preview*, which means it is
968 /// redacted and capped at 200 characters before it can reach the OS.
969 pub fn completed_turn_payload(
970 app: &App,
971 current_streaming_text: &str,
972 include_summary: bool,
973 turn_elapsed: Duration,
974 turn_cost: Option<crate::pricing::CostEstimate>,
975 ) -> NotificationPayload {
976 let headline = completion_status(
977 &tr(app.ui_locale, MessageId::NotificationTurnComplete),
978 include_summary,
979 turn_elapsed,
980 turn_cost.map(|cost| crate::pricing::format_cost_estimate(cost, app.cost_currency)),
981 );
982
983 let preview =
984 text_summary(current_streaming_text).or_else(|| latest_assistant_text(&app.api_messages));
985
986 NotificationPayload::turn_complete(&headline).with_preview(preview.as_deref())
987 }
988
989 /// Compose a notification payload for a terminal sub-agent outcome. The
990 /// agent id is always the detail line; the child's first human-readable
991 /// summary line, when there is one, becomes the (redacted, bounded)
992 /// preview. The headline reflects the actual status so a Stop/failed
993 /// worker is never announced as successfully complete (#4408).
994 pub fn subagent_terminal_payload(
995 locale: Locale,
996 id: &str,
997 result: &str,
998 status: &SubAgentStatus,
999 include_summary: bool,
1000 elapsed: Duration,
1001 ) -> NotificationPayload {
1002 let result_line = result
1003 .lines()
1004 .map(str::trim)
1005 .find(|line| !line.is_empty() && !line.starts_with("<codewhale:subagent.done>"));
1006 let label = match status {
1007 SubAgentStatus::Completed => MessageId::NotificationSubagentComplete,
1008 SubAgentStatus::Failed(_) => MessageId::NotificationSubagentFailed,
1009 SubAgentStatus::Interrupted(_) => MessageId::NotificationSubagentInterrupted,
1010 SubAgentStatus::Cancelled => MessageId::NotificationSubagentCancelled,
1011 SubAgentStatus::BudgetExhausted => MessageId::NotificationSubagentBudgetExhausted,
1012 SubAgentStatus::Running => MessageId::NotificationSubagentComplete,
1013 };
1014 let headline = completion_status(&tr(locale, label), include_summary, elapsed, None);
1015 let preview = result_line.and_then(text_summary);
1016
1017 NotificationPayload::subagent_terminal(&headline, id).with_preview(preview.as_deref())
1018 }
1019
1020 /// Action-first approval banner (#5041): leads with the decision the user
1021 /// must make and names the tool it concerns. The tool *description* — the
1022 /// pending command — intentionally stays in the terminal (#4834).
1023 #[must_use]
1024 pub fn approval_needed_payload(tool_name: &str) -> NotificationPayload {
1025 NotificationPayload::approval_needed(
1026 &format!("Approve or deny '{tool_name}' to continue"),
1027 tool_name,
1028 )
1029 }
1030
1031 /// Action-first blocked-on-input banner (#5041): says what to do and
1032 /// where. The question text itself never leaves the terminal (#4834).
1033 #[must_use]
1034 pub fn input_needed_payload() -> NotificationPayload {
1035 NotificationPayload::input_needed("Answer the question in the terminal to continue")
1036 }
1037
1038 /// Action-first sandbox-elevation banner (#5041): leads with the decision
1039 /// and names the blocked tool; the denial reason rides in the body.
1040 #[must_use]
1041 pub fn elevation_needed_payload(tool_name: &str, denial_reason: &str) -> NotificationPayload {
1042 NotificationPayload::elevation_needed(
1043 &format!("Allow or deny elevated access for '{tool_name}'"),
1044 tool_name,
1045 denial_reason,
1046 )
1047 }
1048
1049 fn completion_status(
1050 label: &str,
1051 include_summary: bool,
1052 elapsed: Duration,
1053 cost: Option<String>,
1054 ) -> String {
1055 if !include_summary {
1056 return label.to_string();
1057 }
1058
1059 let human = crate::elapsed::format_elapsed_secs(elapsed.as_secs());
1060 match cost {
1061 Some(cost) => format!("{label} ({human}, {cost})"),
1062 None => format!("{label} ({human})"),
1063 }
1064 }
1065
1066 /// Find the latest assistant message in `messages` and return a
1067 /// notification-ready summary of its `Text` content. Thinking blocks,
1068 /// tool calls, and tool results are skipped — only the user-visible
1069 /// reply contributes to the body.
1070 pub fn latest_assistant_text(messages: &[Message]) -> Option<String> {
1071 messages
1072 .iter()
1073 .rev()
1074 .find(|message| {
1075 message.role == "assistant" || message.role == crate::models::INTERRUPTED_ASSISTANT_ROLE
1076 })
1077 .and_then(|message| {
1078 let text = message
1079 .content
1080 .iter()
1081 .filter_map(|block| match block {
1082 ContentBlock::Text { text, .. } => Some(text.as_str()),
1083 ContentBlock::Thinking { .. }
1084 | ContentBlock::ToolUse { .. }
1085 | ContentBlock::ToolResult { .. }
1086 | ContentBlock::ServerToolUse { .. }
1087 | ContentBlock::ToolSearchToolResult { .. }
1088 | ContentBlock::CodeExecutionToolResult { .. } => None,
1089 ContentBlock::ImageUrl { .. } => None,
1090 })
1091 .collect::<Vec<_>>()
1092 .join("\n");
1093 text_summary(&text)
1094 })
1095 }
1096
1097 /// Sanitize + collapse + truncate streaming text into something fit to
1098 /// hand the OS notification system. Returns `None` when nothing
1099 /// useful remains after sanitization.
1100 pub fn text_summary(text: &str) -> Option<String> {
1101 const MAX_CHARS: usize = 360;
1102
1103 let sanitized = super::ui::sanitize_stream_chunk(text);
1104 let collapsed = sanitized
1105 .lines()
1106 .map(str::trim)
1107 .filter(|line: &&str| !line.is_empty())
1108 .collect::<Vec<_>>()
1109 .join("\n");
1110 let trimmed = collapsed.trim();
1111 if trimmed.is_empty() {
1112 return None;
1113 }
1114
1115 if let Some((idx, _)) = trimmed.char_indices().nth(MAX_CHARS) {
1116 let mut s = String::with_capacity(idx + 3);
1117 s.push_str(&trimmed[..idx]);
1118 s.push_str("...");
1119 Some(s)
1120 } else {
1121 Some(trimmed.to_string())
1122 }
1123 }
1124
1125 #[cfg(test)]
1126 mod tests {
1127 use std::sync::{Mutex, OnceLock};
1128
1129 use super::*;
1130
1131 #[test]
1132 fn title_whale_is_static_when_focused_or_motion_disabled() {
1133 if let Ok(mut verb) = title_activity_verb().lock() {
1134 "working…".clone_into(&mut *verb);
1135 }
1136 assert_eq!(
1137 title_activity_label("Codewhale", Duration::ZERO, true, true),
1138 "🐳 working…"
1139 );
1140 assert_eq!(
1141 title_activity_label("Codewhale", Duration::ZERO, false, false),
1142 "🐳 working…"
1143 );
1144 assert_eq!(
1145 title_activity_label("Codewhale", Duration::ZERO, false, true),
1146 "🐳 working…"
1147 );
1148 assert_eq!(
1149 title_activity_label("Codewhale", Duration::from_millis(800), false, true),
1150 "🐋 working…"
1151 );
1152 }
1153
1154 #[test]
1155 fn title_whale_frames_are_the_restored_emoji_pair() {
1156 assert_eq!(TITLE_WHALE_FRAMES, &["🐳", "🐋", "🐳", "🐋"]);
1157 assert_eq!(TITLE_FRAME_HOLD, Duration::from_millis(800));
1158 }
1159
1160 /// Serialise tests that mutate process-global environment or notification
1161 /// sound state while the test harness runs them in parallel threads.
1162 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1163 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1164 LOCK.get_or_init(|| Mutex::new(()))
1165 .lock()
1166 .unwrap_or_else(|poisoned| poisoned.into_inner())
1167 }
1168
1169 struct NotificationGateRestore(NotificationGate);
1170
1171 impl NotificationGateRestore {
1172 fn capture() -> Self {
1173 Self(current_notification_gate())
1174 }
1175 }
1176
1177 impl Drop for NotificationGateRestore {
1178 fn drop(&mut self) {
1179 install_notification_gate(self.0);
1180 }
1181 }
1182
1183 /// Escape-protocol tests care about the bytes, not the composition
1184 /// policy, so they go through the least-privileged constructor.
1185 fn capture(
1186 method: Method,
1187 in_tmux: bool,
1188 msg: &str,
1189 threshold_secs: u64,
1190 elapsed_secs: u64,
1191 ) -> Vec<u8> {
1192 let mut buf = Vec::new();
1193 notify_done_to(
1194 method,
1195 in_tmux,
1196 &NotificationPayload::input_needed(msg),
1197 Duration::from_secs(threshold_secs),
1198 Duration::from_secs(elapsed_secs),
1199 NotificationGate::default(),
1200 &mut buf,
1201 );
1202 buf
1203 }
1204
1205 /// Emit `payload` through OSC 9 under an explicit `gate`, returning the
1206 /// bytes. The gate is passed by value, so these tests never touch the
1207 /// process-wide gate and cannot race the other capture tests.
1208 fn capture_gated(payload: &NotificationPayload, gate: NotificationGate) -> Vec<u8> {
1209 let mut buf = Vec::new();
1210 notify_done_to(
1211 Method::Osc9,
1212 false,
1213 payload,
1214 Duration::ZERO,
1215 Duration::from_secs(1),
1216 gate,
1217 &mut buf,
1218 );
1219 buf
1220 }
1221
1222 #[test]
1223 fn gate_defaults_allow_every_kind() {
1224 let gate = NotificationGate::default();
1225 for kind in [
1226 NotificationKind::TurnComplete,
1227 NotificationKind::SubagentTerminal,
1228 NotificationKind::ApprovalNeeded,
1229 NotificationKind::InputNeeded,
1230 NotificationKind::ElevationNeeded,
1231 NotificationKind::ModelNotify,
1232 ] {
1233 assert!(gate.allows(kind), "default gate must allow {kind:?}");
1234 }
1235 }
1236
1237 #[test]
1238 fn quiet_gate_suppresses_every_kind() {
1239 let gate = NotificationGate {
1240 quiet: true,
1241 ..NotificationGate::default()
1242 };
1243 for kind in [
1244 NotificationKind::TurnComplete,
1245 NotificationKind::SubagentTerminal,
1246 NotificationKind::ApprovalNeeded,
1247 NotificationKind::InputNeeded,
1248 NotificationKind::ElevationNeeded,
1249 NotificationKind::ModelNotify,
1250 ] {
1251 assert!(!gate.allows(kind), "quiet gate must suppress {kind:?}");
1252 }
1253 }
1254
1255 #[test]
1256 fn disabled_category_suppresses_only_that_kind() {
1257 let gate = NotificationGate {
1258 approval_needed: false,
1259 ..NotificationGate::default()
1260 };
1261 assert!(!gate.allows(NotificationKind::ApprovalNeeded));
1262 assert!(gate.allows(NotificationKind::TurnComplete));
1263 assert!(gate.allows(NotificationKind::InputNeeded));
1264 assert!(gate.allows(NotificationKind::ModelNotify));
1265 }
1266
1267 #[test]
1268 fn gate_bits_roundtrip_and_default_constant_agree() {
1269 assert_eq!(NotificationGate::default().to_bits(), GATE_DEFAULT_BITS);
1270 let odd = NotificationGate {
1271 quiet: true,
1272 turn_complete: false,
1273 subagent_terminal: true,
1274 approval_needed: false,
1275 input_needed: true,
1276 elevation_needed: false,
1277 model_notify: true,
1278 };
1279 assert_eq!(NotificationGate::from_bits(odd.to_bits()), odd);
1280 }
1281
1282 /// The gate acts on the emission path itself: a suppressed category
1283 /// produces zero bytes on every protocol entry point, not just a
1284 /// filtered list somewhere upstream.
1285 #[test]
1286 fn gated_emission_produces_no_bytes() {
1287 let payload = approval_needed_payload("bash");
1288
1289 let quiet = NotificationGate {
1290 quiet: true,
1291 ..NotificationGate::default()
1292 };
1293 assert!(capture_gated(&payload, quiet).is_empty());
1294
1295 let no_approvals = NotificationGate {
1296 approval_needed: false,
1297 ..NotificationGate::default()
1298 };
1299 assert!(capture_gated(&payload, no_approvals).is_empty());
1300
1301 let out = capture_gated(&payload, NotificationGate::default());
1302 assert!(!out.is_empty(), "enabled category must still emit");
1303 }
1304
1305 /// `settings()` is the single place config reaches the emission path;
1306 /// it must install the configured gate for `notify_done` to load.
1307 #[test]
1308 fn settings_installs_gate_from_config() {
1309 let _lock = env_lock();
1310 let _gate_restore = NotificationGateRestore::capture();
1311 let config: crate::config::Config = toml::from_str(
1312 r#"
1313 [notifications]
1314 quiet = true
1315
1316 [notifications.events]
1317 approval-needed = false
1318 "#,
1319 )
1320 .expect("gated notifications config should parse");
1321
1322 let _ = settings(&config);
1323
1324 let gate = current_notification_gate();
1325 assert!(gate.quiet);
1326 assert!(!gate.approval_needed);
1327 assert!(gate.turn_complete);
1328 }
1329
1330 /// #5041 copy contract: interactive banners lead with the action and
1331 /// name the subject, instead of a bare "Approval needed".
1332 #[test]
1333 fn interactive_banners_are_action_first_and_name_the_subject() {
1334 let approval = approval_needed_payload("bash");
1335 assert_eq!(approval.headline(), "Approve or deny 'bash' to continue");
1336
1337 let input = input_needed_payload();
1338 assert_eq!(
1339 input.headline(),
1340 "Answer the question in the terminal to continue"
1341 );
1342
1343 let elevation = elevation_needed_payload("bash", "network blocked");
1344 assert_eq!(
1345 elevation.headline(),
1346 "Allow or deny elevated access for 'bash'"
1347 );
1348 assert!(elevation.body().contains("network blocked"));
1349 }
1350
1351 #[test]
1352 fn osc9_body_format() {
1353 let out = capture(Method::Osc9, false, "codewhale: done", 0, 1);
1354 assert_eq!(out, b"\x1b]9;codewhale: done\x07");
1355 }
1356
1357 #[test]
1358 fn bel_emits_exactly_one_byte() {
1359 let out = capture(Method::Bel, false, "ignored", 0, 1);
1360 assert_eq!(out, b"\x07");
1361 }
1362
1363 #[test]
1364 fn off_mode_emits_nothing() {
1365 let out = capture(Method::Off, false, "ignored", 0, 9999);
1366 assert!(out.is_empty());
1367 }
1368
1369 /// #4847 follow-up: OSC 9;4 and OSC 0 are *control* bytes, not content.
1370 /// A terminal renders nothing visible; a pipe, a file, or a CI log renders
1371 /// them literally — which is why `cargo test` output carried stray
1372 /// `]9;4;1]0;` noise. The write is now gated on stdout being a TTY; these
1373 /// assertions pin the bytes themselves so the gate cannot be "fixed" by
1374 /// quietly changing what gets emitted.
1375 #[test]
1376 fn control_sequences_have_the_exact_documented_bytes() {
1377 assert_eq!(taskbar_progress_sequence(1, None), "\x1b]9;4;1\x07");
1378 assert_eq!(taskbar_progress_sequence(1, Some(42)), "\x1b]9;4;1;42\x07");
1379 assert_eq!(taskbar_progress_sequence(0, None), "\x1b]9;4;0\x07");
1380 assert_eq!(
1381 terminal_title_sequence("🐳 working…"),
1382 "\x1b]0;🐳 working…\x07"
1383 );
1384 }
1385
1386 #[test]
1387 fn kitty_escape_uses_st_terminator() {
1388 let out = capture(Method::Kitty, false, "done", 0, 1);
1389 let s = String::from_utf8(out).unwrap();
1390 assert!(s.contains("99;"), "should have kitty OSC 99");
1391 assert!(s.contains("\x1b\\"), "kitty uses ST terminator");
1392 assert!(!s.contains("\x07"), "kitty should NOT use BEL");
1393 }
1394
1395 #[test]
1396 fn ghostty_escape_format() {
1397 let out = capture(Method::Ghostty, false, "done", 0, 1);
1398 let s = String::from_utf8(out).unwrap();
1399 assert!(
1400 s.contains("777;notify;codewhale;done"),
1401 "should have ghostty seq"
1402 );
1403 }
1404
1405 #[test]
1406 fn kitty_tmux_dcs_passthrough() {
1407 let out = capture(Method::Kitty, true, "hello", 0, 1);
1408 let s = String::from_utf8(out).unwrap();
1409 assert!(s.starts_with("\x1bPtmux;"), "should start with DCS");
1410 assert!(s.ends_with("\x1b\\"), "should end with ST");
1411 }
1412
1413 #[test]
1414 fn ghostty_tmux_dcs_passthrough() {
1415 let out = capture(Method::Ghostty, true, "hello", 0, 1);
1416 let s = String::from_utf8(out).unwrap();
1417 assert!(s.starts_with("\x1bPtmux;"), "should start with DCS");
1418 assert!(s.ends_with("\x1b\\"), "should end with ST");
1419 }
1420
1421 #[test]
1422 fn below_threshold_emits_nothing() {
1423 let out = capture(Method::Osc9, false, "msg", 30, 29);
1424 assert!(out.is_empty());
1425 }
1426
1427 #[test]
1428 fn at_threshold_emits() {
1429 let out = capture(Method::Osc9, false, "msg", 30, 30);
1430 assert!(!out.is_empty());
1431 }
1432
1433 /// The subtitle is the localized status headline and the body is
1434 /// everything else. Previously this was re-derived by splitting a
1435 /// free-form string on its first newline; now it is a projection of
1436 /// the typed payload, so the split cannot drift from what the
1437 /// composer intended (#4834).
1438 #[cfg(target_os = "macos")]
1439 #[test]
1440 fn macos_notification_keeps_localized_status_as_subtitle() {
1441 let payload = NotificationPayload::turn_complete("ターン完了 (1m 5s)")
1442 .with_preview(Some("完了しました。"));
1443
1444 let (subtitle, body) = macos_notification_parts(&payload);
1445
1446 assert_eq!(subtitle, "ターン完了 (1m 5s)");
1447 assert_eq!(body, "完了しました。");
1448 }
1449
1450 /// The preview is capped at `PREVIEW_MAX_CHARS` *inclusive* of the
1451 /// ellipsis, so the string handed to `osascript` never exceeds the
1452 /// declared bound.
1453 #[cfg(target_os = "macos")]
1454 #[test]
1455 fn macos_notification_truncates_preview() {
1456 let payload = NotificationPayload::turn_complete("Turn complete")
1457 .with_preview(Some(&"assistant preview ".repeat(40)));
1458
1459 let (subtitle, body) = macos_notification_parts(&payload);
1460
1461 assert_eq!(subtitle, "Turn complete");
1462 assert!(body.starts_with("assistant preview"));
1463 assert!(body.ends_with("..."));
1464 assert_eq!(
1465 body.chars().count(),
1466 super::super::notification_payload::PREVIEW_MAX_CHARS
1467 );
1468 }
1469
1470 /// #4834: an approval banner is the one place a raw shell command
1471 /// used to reach Notification Center. Pin the macOS projection, not
1472 /// just the payload, so a future refactor of either half is caught.
1473 #[cfg(target_os = "macos")]
1474 #[test]
1475 fn macos_approval_notification_never_carries_the_command() {
1476 let payload = NotificationPayload::approval_needed("Approval needed", "bash");
1477
1478 let (subtitle, body) = macos_notification_parts(&payload);
1479
1480 assert_eq!(subtitle, "Approval needed");
1481 assert_eq!(body, "bash");
1482 }
1483
1484 #[test]
1485 fn tmux_dcs_passthrough_wraps_osc9() {
1486 let out = capture(Method::Osc9, true, "hello", 0, 1);
1487 let s = String::from_utf8(out).unwrap();
1488 assert!(
1489 s.starts_with("\x1bPtmux;"),
1490 "should start with DCS passthrough"
1491 );
1492 assert!(s.ends_with("\x1b\\"), "should end with ST");
1493 assert!(s.contains("hello"), "should contain message");
1494 }
1495
1496 #[test]
1497 fn auto_detect_picks_osc9_for_iterm() {
1498 let _lock = env_lock();
1499 let prev = std::env::var_os("TERM_PROGRAM");
1500 // SAFETY: test-only; serialised by env_lock().
1501 unsafe { std::env::set_var("TERM_PROGRAM", "iTerm.app") };
1502 let resolved = resolve_method();
1503 // Restore previous value.
1504 // SAFETY: test-only; serialised by env_lock().
1505 unsafe {
1506 match prev {
1507 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1508 None => std::env::remove_var("TERM_PROGRAM"),
1509 }
1510 }
1511 assert_eq!(resolved, Method::Osc9);
1512 }
1513
1514 /// Cmux in typical configurations does not set `TERM_PROGRAM`; it sets
1515 /// `LC_TERMINAL=Cmux` instead. Verify the `LC_TERMINAL` fallback probe
1516 /// correctly resolves to `Osc9`.
1517 #[test]
1518 fn auto_detect_picks_osc9_for_cmux_via_lc_terminal() {
1519 let _lock = env_lock();
1520 let prev_tp = std::env::var_os("TERM_PROGRAM");
1521 let prev_lc = std::env::var_os("LC_TERMINAL");
1522 // SAFETY: test-only; serialised by env_lock().
1523 unsafe {
1524 std::env::remove_var("TERM_PROGRAM");
1525 std::env::set_var("LC_TERMINAL", "Cmux");
1526 }
1527 let resolved = resolve_method();
1528 // SAFETY: test-only; serialised by env_lock().
1529 unsafe {
1530 match prev_tp {
1531 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1532 None => std::env::remove_var("TERM_PROGRAM"),
1533 }
1534 match prev_lc {
1535 Some(v) => std::env::set_var("LC_TERMINAL", v),
1536 None => std::env::remove_var("LC_TERMINAL"),
1537 }
1538 }
1539 assert_eq!(resolved, Method::Osc9);
1540 }
1541
1542 /// `LC_TERMINAL` should also match other OSC-9 capable terminals in case
1543 /// they set it in addition to or instead of `TERM_PROGRAM`.
1544 #[test]
1545 fn auto_detect_picks_osc9_for_wezterm_via_lc_terminal() {
1546 let _lock = env_lock();
1547 let prev_tp = std::env::var_os("TERM_PROGRAM");
1548 let prev_lc = std::env::var_os("LC_TERMINAL");
1549 // SAFETY: test-only; serialised by env_lock().
1550 unsafe {
1551 std::env::remove_var("TERM_PROGRAM");
1552 std::env::set_var("LC_TERMINAL", "WezTerm");
1553 }
1554 let resolved = resolve_method();
1555 // SAFETY: test-only; serialised by env_lock().
1556 unsafe {
1557 match prev_tp {
1558 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1559 None => std::env::remove_var("TERM_PROGRAM"),
1560 }
1561 match prev_lc {
1562 Some(v) => std::env::set_var("LC_TERMINAL", v),
1563 None => std::env::remove_var("LC_TERMINAL"),
1564 }
1565 }
1566 assert_eq!(resolved, Method::Osc9);
1567 }
1568
1569 #[test]
1570 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
1571 fn auto_detect_picks_bel_for_unknown_on_unix() {
1572 let _lock = env_lock();
1573 let prev_tp = std::env::var_os("TERM_PROGRAM");
1574 let prev_lc = std::env::var_os("LC_TERMINAL");
1575 let prev_term = std::env::var_os("TERM");
1576 // SAFETY: test-only; serialised by env_lock().
1577 // Clear LC_TERMINAL and TERM so the fallback probes don't
1578 // accidentally pick up an OSC-9 / Kitty / Ghostty capable
1579 // terminal from the test runner environment.
1580 unsafe {
1581 std::env::set_var("TERM_PROGRAM", "xterm-256color");
1582 std::env::remove_var("LC_TERMINAL");
1583 std::env::set_var("TERM", "xterm-256color");
1584 }
1585 let resolved = resolve_method();
1586 // SAFETY: test-only; serialised by env_lock().
1587 unsafe {
1588 match prev_tp {
1589 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1590 None => std::env::remove_var("TERM_PROGRAM"),
1591 }
1592 match prev_lc {
1593 Some(v) => std::env::set_var("LC_TERMINAL", v),
1594 None => std::env::remove_var("LC_TERMINAL"),
1595 }
1596 match prev_term {
1597 Some(v) => std::env::set_var("TERM", v),
1598 None => std::env::remove_var("TERM"),
1599 }
1600 }
1601 assert_eq!(resolved, Method::Bel);
1602 }
1603
1604 /// #2166: on Windows, an unknown TERM_PROGRAM resolves to `Bel` so
1605 /// `windows_bell()` can route the notification through `MessageBeep`.
1606 #[test]
1607 #[cfg(target_os = "windows")]
1608 fn auto_detect_picks_bel_for_unknown_on_windows() {
1609 let _lock = env_lock();
1610 let prev = std::env::var_os("TERM_PROGRAM");
1611 // SAFETY: test-only; serialised by env_lock().
1612 unsafe { std::env::set_var("TERM_PROGRAM", "Windows Terminal") };
1613 let resolved = resolve_method();
1614 // SAFETY: test-only; serialised by env_lock().
1615 unsafe {
1616 match prev {
1617 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1618 None => std::env::remove_var("TERM_PROGRAM"),
1619 }
1620 }
1621 assert_eq!(resolved, Method::Bel);
1622 }
1623
1624 /// #583: known OSC-9 terminals must still resolve to `Osc9` on
1625 /// Windows — the off-fallback only applies to unrecognised
1626 /// `TERM_PROGRAM`. The cross-platform iTerm test above is a thin
1627 /// proxy because iTerm itself only runs on macOS; if the WezTerm
1628 /// arm of the match silently disappeared, that test would still
1629 /// pass on the Windows runner and we'd lose the WezTerm-on-Windows
1630 /// compatibility guarantee. Pin it directly.
1631 #[test]
1632 #[cfg(target_os = "windows")]
1633 fn auto_detect_picks_osc9_for_wezterm_on_windows() {
1634 let _lock = env_lock();
1635 let prev = std::env::var_os("TERM_PROGRAM");
1636 // SAFETY: test-only; serialised by env_lock().
1637 unsafe { std::env::set_var("TERM_PROGRAM", "WezTerm") };
1638 let resolved = resolve_method();
1639 // SAFETY: test-only; serialised by env_lock().
1640 unsafe {
1641 match prev {
1642 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1643 None => std::env::remove_var("TERM_PROGRAM"),
1644 }
1645 }
1646 assert_eq!(resolved, Method::Osc9);
1647 }
1648
1649 /// Ghostty-based terminals (cmux, etc.) may not set
1650 /// `TERM_PROGRAM` but do set `TERM=xterm-ghostty`. The `$TERM`
1651 /// fallback should catch them.
1652 #[test]
1653 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
1654 fn auto_detect_picks_osc9_for_xterm_ghostty_term_fallback() {
1655 let _lock = env_lock();
1656 let prev_tp = std::env::var_os("TERM_PROGRAM");
1657 let prev_lc = std::env::var_os("LC_TERMINAL");
1658 let prev_term = std::env::var_os("TERM");
1659 // Simulate a Ghostty-based terminal that only sets TERM.
1660 // SAFETY: test-only; serialised by env_lock().
1661 unsafe {
1662 std::env::remove_var("TERM_PROGRAM");
1663 std::env::remove_var("LC_TERMINAL");
1664 std::env::set_var("TERM", "xterm-ghostty");
1665 }
1666 let resolved = resolve_method();
1667 // SAFETY: test-only; serialised by env_lock().
1668 unsafe {
1669 match prev_tp {
1670 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1671 None => std::env::remove_var("TERM_PROGRAM"),
1672 }
1673 match prev_lc {
1674 Some(v) => std::env::set_var("LC_TERMINAL", v),
1675 None => std::env::remove_var("LC_TERMINAL"),
1676 }
1677 match prev_term {
1678 Some(v) => std::env::set_var("TERM", v),
1679 None => std::env::remove_var("TERM"),
1680 }
1681 }
1682 assert_eq!(resolved, Method::Osc9);
1683 }
1684
1685 /// Ghostty now has its own protocol (OSC 777).
1686 #[test]
1687 fn auto_detect_picks_ghostty_from_term_program() {
1688 let _lock = env_lock();
1689 let prev = std::env::var_os("TERM_PROGRAM");
1690 // SAFETY: test-only; serialised by env_lock().
1691 unsafe { std::env::set_var("TERM_PROGRAM", "Ghostty") };
1692 let resolved = resolve_method();
1693 // SAFETY: test-only; serialised by env_lock().
1694 unsafe {
1695 match prev {
1696 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1697 None => std::env::remove_var("TERM_PROGRAM"),
1698 }
1699 }
1700 assert_eq!(resolved, Method::Ghostty);
1701 }
1702
1703 #[test]
1704 fn auto_detect_picks_kitty_from_term_program() {
1705 let _lock = env_lock();
1706 let prev = std::env::var_os("TERM_PROGRAM");
1707 // SAFETY: test-only; serialised by env_lock().
1708 unsafe { std::env::set_var("TERM_PROGRAM", "kitty") };
1709 let resolved = resolve_method();
1710 // SAFETY: test-only; serialised by env_lock().
1711 unsafe {
1712 match prev {
1713 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1714 None => std::env::remove_var("TERM_PROGRAM"),
1715 }
1716 }
1717 assert_eq!(resolved, Method::Kitty);
1718 }
1719
1720 #[test]
1721 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
1722 fn auto_detect_picks_kitty_from_term_fallback() {
1723 let _lock = env_lock();
1724 let prev_tp = std::env::var_os("TERM_PROGRAM");
1725 let prev_lc = std::env::var_os("LC_TERMINAL");
1726 let prev_term = std::env::var_os("TERM");
1727 // SAFETY: test-only; serialised by env_lock().
1728 unsafe {
1729 std::env::remove_var("TERM_PROGRAM");
1730 std::env::remove_var("LC_TERMINAL");
1731 std::env::set_var("TERM", "xterm-kitty");
1732 }
1733 let resolved = resolve_method();
1734 // SAFETY: test-only; serialised by env_lock().
1735 unsafe {
1736 match prev_tp {
1737 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1738 None => std::env::remove_var("TERM_PROGRAM"),
1739 }
1740 match prev_lc {
1741 Some(v) => std::env::set_var("LC_TERMINAL", v),
1742 None => std::env::remove_var("LC_TERMINAL"),
1743 }
1744 match prev_term {
1745 Some(v) => std::env::set_var("TERM", v),
1746 None => std::env::remove_var("TERM"),
1747 }
1748 }
1749 assert_eq!(resolved, Method::Kitty);
1750 }
1751
1752 /// When neither `TERM_PROGRAM` nor `TERM` suggests a known capable
1753 /// terminal, the fallback on Unix is `Bel`.
1754 ///
1755 /// On macOS the `MacOS` method takes priority, so this test is
1756 /// excluded there.
1757 #[test]
1758 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
1759 fn auto_detect_falls_back_to_bel_for_unrelated_term() {
1760 let _lock = env_lock();
1761 let prev_tp = std::env::var_os("TERM_PROGRAM");
1762 let prev_lc = std::env::var_os("LC_TERMINAL");
1763 let prev_term = std::env::var_os("TERM");
1764 // SAFETY: test-only; serialised by env_lock().
1765 unsafe {
1766 std::env::remove_var("TERM_PROGRAM");
1767 std::env::remove_var("LC_TERMINAL");
1768 std::env::set_var("TERM", "xterm-256color");
1769 }
1770 let resolved = resolve_method();
1771 // SAFETY: test-only; serialised by env_lock().
1772 unsafe {
1773 match prev_tp {
1774 Some(v) => std::env::set_var("TERM_PROGRAM", v),
1775 None => std::env::remove_var("TERM_PROGRAM"),
1776 }
1777 match prev_lc {
1778 Some(v) => std::env::set_var("LC_TERMINAL", v),
1779 None => std::env::remove_var("LC_TERMINAL"),
1780 }
1781 match prev_term {
1782 Some(v) => std::env::set_var("TERM", v),
1783 None => std::env::remove_var("TERM"),
1784 }
1785 }
1786 assert_eq!(resolved, Method::Bel);
1787 }
1788
1789 #[test]
1790 fn settings_installs_custom_completion_sound_file() {
1791 let _lock = env_lock();
1792 let config: crate::config::Config = toml::from_str(
1793 r#"
1794 [notifications]
1795 completion_sound = "file"
1796 sound_file = "E:\\google\\downloads\\xm4114.wav"
1797 "#,
1798 )
1799 .expect("custom completion sound config should parse");
1800
1801 let _ = settings(&config);
1802
1803 let (mode, file) = completion_sound_state_for_tests();
1804 assert_eq!(mode, crate::config::CompletionSound::File);
1805 assert_eq!(
1806 file.as_deref(),
1807 Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav"))
1808 );
1809 }
1810
1811 #[test]
1812 fn setting_valid_sound_file_resets_missing_file_warning_latch() {
1813 let _lock = env_lock();
1814 COMPLETION_SOUND_FILE_MISSING_WARNED.store(true, Ordering::SeqCst);
1815
1816 set_completion_sound(
1817 crate::config::CompletionSound::File,
1818 Some(std::path::PathBuf::from(
1819 "E:\\google\\downloads\\xm4114.wav",
1820 )),
1821 );
1822
1823 assert!(!COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst));
1824
1825 set_completion_sound(crate::config::CompletionSound::File, None);
1826 file_sound();
1827
1828 assert!(COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst));
1829
1830 set_completion_sound(crate::config::CompletionSound::Beep, None);
1831 COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst);
1832 }
1833 }
1834
1834 lines RUST