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 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#[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}