Skip to main content

atomcode_core/
notify.rs

1use std::borrow::Cow;
2use std::io::{self, IsTerminal, Write};
3use std::path::Path;
4use std::process::{Command, Stdio};
5use std::sync::atomic::{AtomicU8, Ordering};
6use std::time::Duration;
7
8use crate::agent::TurnStopReason;
9use crate::config::NotificationConfig;
10
11#[derive(Debug, Clone)]
12pub struct TurnNotification<'a> {
13    pub duration: Duration,
14    pub turn_count: usize,
15    pub tool_call_count: usize,
16    pub total_tokens: Option<usize>,
17    pub stop_reason: TurnStopReason,
18    pub working_dir: Option<&'a Path>,
19}
20
21#[derive(Debug, Clone)]
22pub struct ApprovalNotification<'a> {
23    pub tool_name: &'a str,
24    pub detail: Option<&'a str>,
25    pub working_dir: Option<&'a Path>,
26}
27
28#[derive(Debug, Clone)]
29pub enum NotificationEvent<'a> {
30    ApprovalNeeded(ApprovalNotification<'a>),
31    TurnFinished(TurnNotification<'a>),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum TerminalApp {
36    Kitty,
37    WezTerm,
38    Ghostty,
39    ITerm2,
40    AppleTerminal,
41    WindowsTerminal,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum VisibilityPolicy {
46    BackgroundOnlyBestEffort,
47}
48
49#[derive(Debug, Clone)]
50struct NotificationPlan {
51    title: Cow<'static, str>,
52    body: String,
53    terminal_id: &'static str,
54    visibility: VisibilityPolicy,
55    emit_terminal: bool,
56    emit_system: bool,
57    emit_bell: bool,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum DeliveryResult {
62    Delivered,
63    Unsupported,
64    Failed,
65}
66
67const FOCUS_UNKNOWN: u8 = 0;
68const FOCUS_TRUE: u8 = 1;
69const FOCUS_FALSE: u8 = 2;
70
71static TERMINAL_FOCUS_STATE: AtomicU8 = AtomicU8::new(FOCUS_UNKNOWN);
72
73pub fn set_terminal_focus_state(focused: Option<bool>) {
74    let encoded = match focused {
75        Some(true) => FOCUS_TRUE,
76        Some(false) => FOCUS_FALSE,
77        None => FOCUS_UNKNOWN,
78    };
79    TERMINAL_FOCUS_STATE.store(encoded, Ordering::Relaxed);
80}
81
82fn terminal_focus_state() -> Option<bool> {
83    match TERMINAL_FOCUS_STATE.load(Ordering::Relaxed) {
84        FOCUS_TRUE => Some(true),
85        FOCUS_FALSE => Some(false),
86        _ => None,
87    }
88}
89
90pub fn notify(cfg: &NotificationConfig, event: NotificationEvent<'_>) {
91    let Some(plan) = build_notification_plan(cfg, event) else {
92        return;
93    };
94    dispatch_notification(plan);
95}
96
97pub fn notify_turn_finished(cfg: &NotificationConfig, turn: TurnNotification<'_>) {
98    notify(cfg, NotificationEvent::TurnFinished(turn));
99}
100
101fn build_notification_plan(
102    cfg: &NotificationConfig,
103    event: NotificationEvent<'_>,
104) -> Option<NotificationPlan> {
105    if !cfg.enabled {
106        return None;
107    }
108    if cfg.background_only && terminal_focus_state() == Some(true) {
109        return None;
110    }
111
112    let (title, body, terminal_id, visibility) = match event {
113        NotificationEvent::ApprovalNeeded(approval) => {
114            let (title, body) = build_approval_notification_text(&approval);
115            (
116                title,
117                body,
118                "atomcode-approval",
119                VisibilityPolicy::BackgroundOnlyBestEffort,
120            )
121        }
122        NotificationEvent::TurnFinished(turn) => {
123            if turn.duration < Duration::from_secs(cfg.min_duration_secs) {
124                return None;
125            }
126            let (title, body) = build_system_notification_text(&turn);
127            (
128                title,
129                body,
130                "atomcode-task",
131                VisibilityPolicy::BackgroundOnlyBestEffort,
132            )
133        }
134    };
135
136    // Windows 上系统通知走 PowerShell NotifyIcon,实测会让 TUI 闪退,整条通道关掉。
137    //
138    // For background-only notifications, only use OS-native fallbacks when we
139    // know the terminal is actually unfocused. macOS Terminal.app does not feed
140    // focus events into our current reader, so its state stays Unknown while the
141    // user may still be reading scrollback in the foreground. BEL / terminal
142    // protocols can still let the terminal decide how much attention to request.
143    let emit_system = cfg.system
144        && !cfg!(target_os = "windows")
145        && (!cfg.background_only || terminal_focus_state() == Some(false));
146
147    Some(NotificationPlan {
148        title,
149        body,
150        terminal_id,
151        visibility,
152        emit_terminal: cfg.terminal,
153        emit_system,
154        emit_bell: cfg.bell,
155    })
156}
157
158fn dispatch_notification(plan: NotificationPlan) {
159    let terminal_result = if plan.emit_terminal {
160        deliver_terminal_notification(&plan)
161    } else {
162        DeliveryResult::Unsupported
163    };
164
165    if plan.emit_bell {
166        let _ = emit_bell();
167    }
168
169    if plan.emit_system && terminal_result != DeliveryResult::Delivered {
170        spawn_system_notification(plan.title.into_owned(), plan.body);
171    }
172}
173
174fn deliver_terminal_notification(plan: &NotificationPlan) -> DeliveryResult {
175    match emit_terminal_notification(plan) {
176        Ok(true) => DeliveryResult::Delivered,
177        Ok(false) => DeliveryResult::Unsupported,
178        Err(_) => DeliveryResult::Failed,
179    }
180}
181
182fn emit_terminal_notification(plan: &NotificationPlan) -> io::Result<bool> {
183    let Some(app) = detect_terminal_app() else {
184        return Ok(false);
185    };
186    let mut stdout = io::stdout();
187    if stdout.is_terminal() {
188        if !write_terminal_notification(&mut stdout, app, plan)? {
189            return Ok(false);
190        }
191        stdout.flush()?;
192        return Ok(true);
193    }
194    let mut stderr = io::stderr();
195    if stderr.is_terminal() {
196        if !write_terminal_notification(&mut stderr, app, plan)? {
197            return Ok(false);
198        }
199        stderr.flush()?;
200        return Ok(true);
201    }
202    Ok(false)
203}
204
205#[cfg(test)]
206fn build_turn_terminal_notification_text(
207    app: TerminalApp,
208    turn: &TurnNotification<'_>,
209) -> (Cow<'static, str>, String) {
210    let (title, mut body) = build_system_notification_text(turn);
211    if matches!(app, TerminalApp::Kitty | TerminalApp::WezTerm | TerminalApp::Ghostty) {
212        if let Some(scope) = turn
213            .working_dir
214            .and_then(|p| p.file_name())
215            .and_then(|s| s.to_str())
216            .filter(|s| !s.is_empty())
217        {
218            body = format!("{} · {}", scope, body);
219        }
220    }
221    (title, body)
222}
223
224fn build_turn_system_notification_text(turn: &TurnNotification<'_>) -> (Cow<'static, str>, String) {
225    let title = match turn.stop_reason {
226        TurnStopReason::Natural => Cow::Borrowed("AtomCode done"),
227        TurnStopReason::Cancelled => Cow::Borrowed("AtomCode cancelled"),
228        TurnStopReason::Error => Cow::Borrowed("AtomCode failed"),
229        TurnStopReason::TurnLimit => Cow::Borrowed("AtomCode stopped"),
230        TurnStopReason::StepLimit => Cow::Borrowed("AtomCode stopped"),
231    };
232    let status = match turn.stop_reason {
233        TurnStopReason::Natural => "Done",
234        TurnStopReason::Cancelled => "Cancelled",
235        TurnStopReason::Error => "Failed",
236        TurnStopReason::TurnLimit => "Stopped",
237        TurnStopReason::StepLimit => "Stopped",
238    };
239    let mut body = format!("{} · {}", status, fmt_duration(turn.duration));
240    if turn.turn_count > 0 {
241        body.push_str(&format!(" · {} rounds", turn.turn_count));
242    }
243    if turn.tool_call_count > 0 {
244        body.push_str(&format!(" · {} tools", turn.tool_call_count));
245    }
246    (title, body)
247}
248
249fn build_system_notification_text(turn: &TurnNotification<'_>) -> (Cow<'static, str>, String) {
250    build_turn_system_notification_text(turn)
251}
252
253fn build_approval_notification_text(
254    approval: &ApprovalNotification<'_>,
255) -> (Cow<'static, str>, String) {
256    let title = Cow::Borrowed("AtomCode approval needed");
257    let mut body = format!("{} is waiting for Y/A/N", approval.tool_name);
258    if let Some(scope) = approval
259        .working_dir
260        .and_then(|p| p.file_name())
261        .and_then(|s| s.to_str())
262        .filter(|s| !s.is_empty())
263    {
264        body.push_str(&format!(" · {}", scope));
265    }
266    if let Some(detail) = approval.detail.filter(|s| !s.trim().is_empty()) {
267        body.push_str(&format!(" · {}", detail.trim()));
268    }
269    (title, body)
270}
271
272fn fmt_duration(duration: Duration) -> String {
273    let ms = duration.as_millis();
274    if ms < 1000 {
275        format!("{}ms", ms)
276    } else {
277        format!("{:.1}s", duration.as_secs_f64())
278    }
279}
280
281fn emit_bell() -> io::Result<bool> {
282    let mut stdout = io::stdout();
283    if stdout.is_terminal() {
284        stdout.write_all(b"\x07")?;
285        stdout.flush()?;
286        return Ok(true);
287    }
288    let mut stderr = io::stderr();
289    if stderr.is_terminal() {
290        stderr.write_all(b"\x07")?;
291        stderr.flush()?;
292        return Ok(true);
293    }
294    Ok(false)
295}
296
297fn write_terminal_notification(
298    out: &mut dyn Write,
299    app: TerminalApp,
300    plan: &NotificationPlan,
301) -> io::Result<bool> {
302    match app {
303        TerminalApp::Kitty => {
304            let title = &plan.title;
305            let body = &plan.body;
306            write_kitty_notification(out, plan.terminal_id, plan.visibility, title, body)?;
307            Ok(true)
308        }
309        TerminalApp::WezTerm | TerminalApp::Ghostty => {
310            let title = &plan.title;
311            let body = &plan.body;
312            write_osc777_notification(out, title, body)?;
313            Ok(true)
314        }
315        TerminalApp::ITerm2 => {
316            let title = &plan.title;
317            let body = &plan.body;
318            write_iterm2_notification(out, title, body)?;
319            Ok(true)
320        }
321        TerminalApp::AppleTerminal | TerminalApp::WindowsTerminal => Ok(false),
322    }
323}
324
325fn write_kitty_notification(
326    out: &mut dyn Write,
327    id: &str,
328    visibility: VisibilityPolicy,
329    title: &str,
330    body: &str,
331) -> io::Result<()> {
332    let title = sanitize_plain_text(title);
333    let body = sanitize_plain_text(body);
334    let visibility = match visibility {
335        VisibilityPolicy::BackgroundOnlyBestEffort => "unfocused",
336    };
337    write!(out, "\x1b]99;i={id}:o={visibility}:d=0;{title}\x1b\\")?;
338    write!(out, "\x1b]99;i={id}:p=body;{body}\x1b\\")?;
339    Ok(())
340}
341
342fn write_osc777_notification(out: &mut dyn Write, title: &str, body: &str) -> io::Result<()> {
343    let title = sanitize_plain_text(title).replace(';', ":");
344    let body = sanitize_plain_text(body).replace(';', ":");
345    write!(out, "\x1b]777;notify;{title};{body}\x1b\\")?;
346    Ok(())
347}
348
349fn write_iterm2_notification(out: &mut dyn Write, title: &str, body: &str) -> io::Result<()> {
350    let payload = match (title.trim().is_empty(), body.trim().is_empty()) {
351        (false, false) => sanitize_plain_text(&format!("{title}: {body}")),
352        (false, true) => sanitize_plain_text(title),
353        (true, false) => sanitize_plain_text(body),
354        (true, true) => String::from("AtomCode"),
355    };
356    write!(out, "\x1b]9;{payload}\x1b\\")?;
357    Ok(())
358}
359
360fn detect_terminal_app() -> Option<TerminalApp> {
361    if std::env::var_os("KITTY_WINDOW_ID").is_some() {
362        return Some(TerminalApp::Kitty);
363    }
364    if std::env::var_os("WEZTERM_PANE").is_some() {
365        return Some(TerminalApp::WezTerm);
366    }
367    if std::env::var_os("WT_SESSION").is_some() {
368        return Some(TerminalApp::WindowsTerminal);
369    }
370
371    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
372    if term_program.eq_ignore_ascii_case("wezterm") {
373        return Some(TerminalApp::WezTerm);
374    }
375    if term_program.eq_ignore_ascii_case("ghostty") {
376        return Some(TerminalApp::Ghostty);
377    }
378    if term_program == "iTerm.app" || term_program.eq_ignore_ascii_case("iTerm2") {
379        return Some(TerminalApp::ITerm2);
380    }
381    if term_program.eq_ignore_ascii_case("apple_terminal")
382        || term_program.eq_ignore_ascii_case("terminal.app")
383        || term_program.eq_ignore_ascii_case("terminal")
384    {
385        return Some(TerminalApp::AppleTerminal);
386    }
387    if term_program.eq_ignore_ascii_case("windows_terminal") {
388        return Some(TerminalApp::WindowsTerminal);
389    }
390
391    let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
392    if lc_terminal.eq_ignore_ascii_case("iTerm2") {
393        return Some(TerminalApp::ITerm2);
394    }
395    if lc_terminal.eq_ignore_ascii_case("Terminal") {
396        return Some(TerminalApp::AppleTerminal);
397    }
398
399    let term = std::env::var("TERM").unwrap_or_default();
400    if term.contains("kitty") {
401        return Some(TerminalApp::Kitty);
402    }
403
404    None
405}
406
407fn sanitize_plain_text(s: &str) -> String {
408    s.chars()
409        .map(|ch| if ch.is_control() { ' ' } else { ch })
410        .collect::<String>()
411        .split_whitespace()
412        .collect::<Vec<_>>()
413        .join(" ")
414}
415
416#[cfg(target_os = "macos")]
417fn macos_terminal_bundle_id(app: Option<TerminalApp>) -> Option<&'static str> {
418    match app {
419        Some(TerminalApp::AppleTerminal) => Some("com.apple.Terminal"),
420        Some(TerminalApp::ITerm2) => Some("com.googlecode.iterm2"),
421        Some(TerminalApp::WezTerm) => Some("com.github.wez.wezterm"),
422        Some(TerminalApp::Ghostty) => Some("com.mitchellh.ghostty"),
423        Some(TerminalApp::Kitty) => Some("net.kovidgoyal.kitty"),
424        _ => None,
425    }
426}
427
428// Only the macOS branch of `spawn_system_notification` calls this (to
429// find `terminal-notifier`). Linux uses notify-send unconditionally and
430// Windows shells out to powershell.exe — neither needs PATH lookup.
431// Kept callable on every platform because `missing_executable_lookup_
432// returns_none` is a portable unit test of PATH-iteration semantics.
433#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
434fn find_executable_on_path(name: &str) -> Option<std::path::PathBuf> {
435    let path = std::env::var_os("PATH")?;
436    for dir in std::env::split_paths(&path) {
437        let candidate = dir.join(name);
438        if candidate.is_file() {
439            return Some(candidate);
440        }
441    }
442    None
443}
444
445fn spawn_system_notification(title: String, body: String) {
446    std::thread::spawn(move || {
447        #[cfg(target_os = "macos")]
448        {
449            if let Some(bin) = find_executable_on_path("terminal-notifier") {
450                let mut cmd = Command::new(bin);
451                cmd.arg("-title")
452                    .arg(&title)
453                    .arg("-message")
454                    .arg(&body)
455                    .stdout(Stdio::null())
456                    .stderr(Stdio::null());
457                if let Some(bundle_id) = macos_terminal_bundle_id(detect_terminal_app()) {
458                    cmd.arg("-activate").arg(bundle_id);
459                }
460                if cmd.spawn().is_ok() {
461                    return;
462                }
463            }
464
465            let script = format!(
466                "display notification {} with title {}",
467                apple_script_string(&body),
468                apple_script_string(&title)
469            );
470            let _ = Command::new("osascript")
471                .arg("-e")
472                .arg(script)
473                .stdout(Stdio::null())
474                .stderr(Stdio::null())
475                .spawn();
476        }
477
478        #[cfg(target_os = "linux")]
479        {
480            let _ = Command::new("notify-send")
481                .arg(&title)
482                .arg(&body)
483                .stdout(Stdio::null())
484                .stderr(Stdio::null())
485                .spawn();
486        }
487
488        #[cfg(target_os = "windows")]
489        {
490            let script = format!(
491                "Add-Type -AssemblyName System.Windows.Forms; \
492                 Add-Type -AssemblyName System.Drawing; \
493                 $n = New-Object System.Windows.Forms.NotifyIcon; \
494                 $n.Icon = [System.Drawing.SystemIcons]::Information; \
495                 $n.BalloonTipTitle = '{}'; \
496                 $n.BalloonTipText = '{}'; \
497                 $n.Visible = $true; \
498                 $n.ShowBalloonTip(5000); \
499                 Start-Sleep -Milliseconds 5500; \
500                 $n.Dispose();",
501                powershell_string_literal(&title),
502                powershell_string_literal(&body),
503            );
504            let _ = Command::new("powershell.exe")
505                .arg("-NoProfile")
506                .arg("-NonInteractive")
507                .arg("-WindowStyle")
508                .arg("Hidden")
509                .arg("-Command")
510                .arg(script)
511                .stdout(Stdio::null())
512                .stderr(Stdio::null())
513                .spawn();
514        }
515    });
516}
517
518#[cfg(target_os = "macos")]
519fn apple_script_string(s: &str) -> String {
520    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
521}
522
523#[cfg(target_os = "windows")]
524fn powershell_string_literal(s: &str) -> String {
525    s.replace('\'', "''")
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use std::sync::{Mutex, MutexGuard, OnceLock};
532
533    fn focus_state_test_lock() -> MutexGuard<'static, ()> {
534        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
535        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
536    }
537
538    #[test]
539    fn builds_human_readable_notification_text() {
540        let (title, body) = build_system_notification_text(&TurnNotification {
541            duration: Duration::from_secs(12),
542            turn_count: 3,
543            tool_call_count: 5,
544            total_tokens: Some(4321),
545            stop_reason: TurnStopReason::Natural,
546            working_dir: Some(Path::new("/tmp/demo")),
547        });
548        assert_eq!(title, "AtomCode done");
549        assert_eq!(body, "Done · 12.0s · 3 rounds · 5 tools");
550    }
551
552    #[test]
553    fn terminal_text_is_compact_for_iterm() {
554        let (title, body) = build_turn_terminal_notification_text(
555            TerminalApp::ITerm2,
556            &TurnNotification {
557                duration: Duration::from_secs(49),
558                turn_count: 4,
559                tool_call_count: 9,
560                total_tokens: Some(1209),
561                stop_reason: TurnStopReason::Natural,
562                working_dir: Some(Path::new("/tmp/atomcode")),
563            },
564        );
565        assert_eq!(title, "AtomCode done");
566        assert_eq!(body, "Done · 49.0s · 4 rounds · 9 tools");
567    }
568
569    #[test]
570    fn terminal_text_keeps_scope_for_split_title_body_protocols() {
571        let (_title, body) = build_turn_terminal_notification_text(
572            TerminalApp::WezTerm,
573            &TurnNotification {
574                duration: Duration::from_secs(12),
575                turn_count: 3,
576                tool_call_count: 5,
577                total_tokens: None,
578                stop_reason: TurnStopReason::Natural,
579                working_dir: Some(Path::new("/tmp/demo")),
580            },
581        );
582        assert!(body.contains("3 rounds"));
583        assert!(body.contains("5 tools"));
584        assert!(body.starts_with("demo · Done"));
585    }
586
587    #[test]
588    fn approval_notification_is_action_oriented() {
589        let (title, body) = build_approval_notification_text(&ApprovalNotification {
590            tool_name: "Bash",
591            detail: Some("ls -la ~/.ssh/"),
592            working_dir: Some(Path::new("/tmp/demo")),
593        });
594        assert_eq!(title, "AtomCode approval needed");
595        assert!(body.contains("Bash is waiting for Y/A/N"));
596        assert!(body.contains("demo"));
597        assert!(body.contains("ls -la ~/.ssh/"));
598    }
599
600    #[test]
601    fn background_only_is_preserved_for_approval_notifications() {
602        let plan = NotificationPlan {
603            title: Cow::Borrowed("AtomCode approval needed"),
604            body: "Bash is waiting for Y/A/N".into(),
605            terminal_id: "atomcode-approval",
606            visibility: VisibilityPolicy::BackgroundOnlyBestEffort,
607            emit_terminal: true,
608            emit_system: true,
609            emit_bell: true,
610        };
611        let mut out = Vec::new();
612        assert!(write_terminal_notification(&mut out, TerminalApp::Kitty, &plan).unwrap());
613        let rendered = String::from_utf8(out).unwrap();
614        assert!(rendered.contains(":o=unfocused:"));
615    }
616
617    #[test]
618    fn iterm2_uses_osc9_notification_sequence() {
619        let plan = NotificationPlan {
620            title: Cow::Borrowed("AtomCode approval needed"),
621            body: "Bash is waiting for Y/A/N".into(),
622            terminal_id: "atomcode-approval",
623            visibility: VisibilityPolicy::BackgroundOnlyBestEffort,
624            emit_terminal: true,
625            emit_system: true,
626            emit_bell: true,
627        };
628        let mut out = Vec::new();
629        assert!(write_terminal_notification(&mut out, TerminalApp::ITerm2, &plan).unwrap());
630        let rendered = String::from_utf8(out).unwrap();
631        assert!(rendered.starts_with("\u{1b}]9;"));
632        assert!(rendered.ends_with("\u{1b}\\"));
633    }
634
635    #[test]
636    fn apple_terminal_has_no_native_terminal_notification_path() {
637        let plan = NotificationPlan {
638            title: Cow::Borrowed("AtomCode done"),
639            body: "Done · 12.0s".into(),
640            terminal_id: "atomcode-task",
641            visibility: VisibilityPolicy::BackgroundOnlyBestEffort,
642            emit_terminal: true,
643            emit_system: true,
644            emit_bell: true,
645        };
646        let mut out = Vec::new();
647        assert!(!write_terminal_notification(&mut out, TerminalApp::AppleTerminal, &plan).unwrap());
648        assert!(out.is_empty());
649    }
650
651    #[test]
652    fn turn_finished_below_threshold_is_suppressed_by_policy() {
653        let cfg = NotificationConfig::default();
654        let plan = build_notification_plan(
655            &cfg,
656            NotificationEvent::TurnFinished(TurnNotification {
657                duration: Duration::from_secs(2),
658                turn_count: 1,
659                tool_call_count: 1,
660                total_tokens: None,
661                stop_reason: TurnStopReason::Natural,
662                working_dir: Some(Path::new("/tmp/demo")),
663            }),
664        );
665        assert!(plan.is_none());
666    }
667
668    #[test]
669    fn approval_event_ignores_duration_threshold() {
670        let cfg = NotificationConfig::default();
671        let plan = build_notification_plan(
672            &cfg,
673            NotificationEvent::ApprovalNeeded(ApprovalNotification {
674                tool_name: "Bash",
675                detail: Some("ls -la ~/.ssh/"),
676                working_dir: Some(Path::new("/tmp/demo")),
677            }),
678        )
679        .unwrap();
680        assert_eq!(plan.terminal_id, "atomcode-approval");
681        assert_eq!(plan.visibility, VisibilityPolicy::BackgroundOnlyBestEffort);
682    }
683
684    #[test]
685    fn focused_terminal_suppresses_background_only_notifications() {
686        let _guard = focus_state_test_lock();
687        let cfg = NotificationConfig::default();
688        set_terminal_focus_state(Some(true));
689        let plan = build_notification_plan(
690            &cfg,
691            NotificationEvent::ApprovalNeeded(ApprovalNotification {
692                tool_name: "Bash",
693                detail: Some("ls -la ~/.ssh/"),
694                working_dir: Some(Path::new("/tmp/demo")),
695            }),
696        );
697        set_terminal_focus_state(None);
698        assert!(plan.is_none());
699    }
700
701    #[test]
702    fn background_only_unknown_focus_suppresses_system_fallback() {
703        let _guard = focus_state_test_lock();
704        let cfg = NotificationConfig::default();
705        set_terminal_focus_state(None);
706        let plan = build_notification_plan(
707            &cfg,
708            NotificationEvent::TurnFinished(TurnNotification {
709                duration: Duration::from_secs(12),
710                turn_count: 1,
711                tool_call_count: 1,
712                total_tokens: None,
713                stop_reason: TurnStopReason::Natural,
714                working_dir: Some(Path::new("/tmp/demo")),
715            }),
716        )
717        .unwrap();
718
719        assert!(plan.emit_terminal);
720        assert!(plan.emit_bell);
721        assert!(!plan.emit_system);
722    }
723
724    #[test]
725    fn background_only_unfocused_allows_system_fallback() {
726        let _guard = focus_state_test_lock();
727        let cfg = NotificationConfig::default();
728        set_terminal_focus_state(Some(false));
729        let plan = build_notification_plan(
730            &cfg,
731            NotificationEvent::TurnFinished(TurnNotification {
732                duration: Duration::from_secs(12),
733                turn_count: 1,
734                tool_call_count: 1,
735                total_tokens: None,
736                stop_reason: TurnStopReason::Natural,
737                working_dir: Some(Path::new("/tmp/demo")),
738            }),
739        )
740        .unwrap();
741        set_terminal_focus_state(None);
742
743        assert_eq!(plan.emit_system, !cfg!(target_os = "windows"));
744    }
745
746    #[test]
747    fn non_background_only_keeps_system_fallback_for_unknown_focus() {
748        let _guard = focus_state_test_lock();
749        let mut cfg = NotificationConfig::default();
750        cfg.background_only = false;
751        set_terminal_focus_state(None);
752        let plan = build_notification_plan(
753            &cfg,
754            NotificationEvent::TurnFinished(TurnNotification {
755                duration: Duration::from_secs(12),
756                turn_count: 1,
757                tool_call_count: 1,
758                total_tokens: None,
759                stop_reason: TurnStopReason::Natural,
760                working_dir: Some(Path::new("/tmp/demo")),
761            }),
762        )
763        .unwrap();
764
765        assert_eq!(plan.emit_system, !cfg!(target_os = "windows"));
766    }
767
768    #[cfg(target_os = "macos")]
769    #[test]
770    fn macos_terminal_bundle_ids_match_supported_terminals() {
771        assert_eq!(
772            macos_terminal_bundle_id(Some(TerminalApp::AppleTerminal)),
773            Some("com.apple.Terminal")
774        );
775        assert_eq!(
776            macos_terminal_bundle_id(Some(TerminalApp::ITerm2)),
777            Some("com.googlecode.iterm2")
778        );
779        assert_eq!(
780            macos_terminal_bundle_id(Some(TerminalApp::WezTerm)),
781            Some("com.github.wez.wezterm")
782        );
783        assert_eq!(
784            macos_terminal_bundle_id(Some(TerminalApp::Ghostty)),
785            Some("com.mitchellh.ghostty")
786        );
787        assert_eq!(
788            macos_terminal_bundle_id(Some(TerminalApp::Kitty)),
789            Some("net.kovidgoyal.kitty")
790        );
791    }
792
793    #[test]
794    fn missing_executable_lookup_returns_none() {
795        assert!(find_executable_on_path("__atomcode_missing_notifier__").is_none());
796    }
797
798    #[test]
799    fn control_chars_are_removed_from_payloads() {
800        let s = sanitize_plain_text("hi\x07 there\nnext\x1b");
801        assert_eq!(s, "hi there next");
802    }
803}