diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a756352da07..03e27dcedab 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -483,6 +483,14 @@ }, "type": "object" }, + "NotificationMethod": { + "enum": [ + "auto", + "osc9", + "bel" + ], + "type": "string" + }, "Notifications": { "anyOf": [ { @@ -991,6 +999,15 @@ "default": null, "description": "Start the TUI in the specified collaboration mode (plan/execute/etc.). Defaults to unset." }, + "notification_method": { + "allOf": [ + { + "$ref": "#/definitions/NotificationMethod" + } + ], + "default": "auto", + "description": "Notification method to use for unfocused terminal notifications. Defaults to `auto`." + }, "notifications": { "allOf": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5d84d577ebd..75099f31a2d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -7,6 +7,7 @@ use crate::config::types::McpServerConfig; use crate::config::types::McpServerDisabledReason; use crate::config::types::McpServerTransportConfig; use crate::config::types::Notice; +use crate::config::types::NotificationMethod; use crate::config::types::Notifications; use crate::config::types::OtelConfig; use crate::config::types::OtelConfigToml; @@ -192,10 +193,13 @@ pub struct Config { /// If unset the feature is disabled. pub notify: Option>, - /// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals - /// and turn completions when not focused. + /// TUI notifications preference. When set, the TUI will send terminal notifications on + /// approvals and turn completions when not focused. pub tui_notifications: Notifications, + /// Notification method for terminal notifications (osc9 or bel). + pub tui_notification_method: NotificationMethod, + /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, @@ -1607,6 +1611,11 @@ impl Config { .as_ref() .map(|t| t.notifications.clone()) .unwrap_or_default(), + tui_notification_method: cfg + .tui + .as_ref() + .map(|t| t.notification_method) + .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), experimental_mode: cfg.tui.as_ref().and_then(|t| t.experimental_mode), @@ -1764,6 +1773,7 @@ mod tests { use crate::config::types::FeedbackConfigToml; use crate::config::types::HistoryPersistence; use crate::config::types::McpServerTransportConfig; + use crate::config::types::NotificationMethod; use crate::config::types::Notifications; use crate::config_loader::RequirementSource; use crate::features::Feature; @@ -1860,6 +1870,7 @@ persistence = "none" tui, Tui { notifications: Notifications::Enabled(true), + notification_method: NotificationMethod::Auto, animations: true, show_tooltips: true, experimental_mode: None, @@ -3788,6 +3799,7 @@ model_verbosity = "high" check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), + tui_notification_method: Default::default(), animations: true, show_tooltips: true, experimental_mode: None, @@ -3871,6 +3883,7 @@ model_verbosity = "high" check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), + tui_notification_method: Default::default(), animations: true, show_tooltips: true, experimental_mode: None, @@ -3969,6 +3982,7 @@ model_verbosity = "high" check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), + tui_notification_method: Default::default(), animations: true, show_tooltips: true, experimental_mode: None, @@ -4053,6 +4067,7 @@ model_verbosity = "high" check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), + tui_notification_method: Default::default(), animations: true, show_tooltips: true, experimental_mode: None, @@ -4410,13 +4425,17 @@ mcp_oauth_callback_port = 5678 #[cfg(test)] mod notifications_tests { + use crate::config::types::NotificationMethod; use crate::config::types::Notifications; use assert_matches::assert_matches; use serde::Deserialize; #[derive(Deserialize, Debug, PartialEq)] struct TuiTomlTest { + #[serde(default)] notifications: Notifications, + #[serde(default)] + notification_method: NotificationMethod, } #[derive(Deserialize, Debug, PartialEq)] @@ -4447,4 +4466,15 @@ mod notifications_tests { Notifications::Custom(ref v) if v == &vec!["foo".to_string()] ); } + + #[test] + fn test_tui_notification_method() { + let toml = r#" + [tui] + notification_method = "bel" + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize notification_method=\"bel\""); + assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel); + } } diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index a1d126c1149..e949d869a61 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -428,6 +428,25 @@ impl Default for Notifications { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationMethod { + #[default] + Auto, + Osc9, + Bel, +} + +impl fmt::Display for NotificationMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationMethod::Auto => write!(f, "auto"), + NotificationMethod::Osc9 => write!(f, "osc9"), + NotificationMethod::Bel => write!(f, "bel"), + } + } +} + /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -437,6 +456,11 @@ pub struct Tui { #[serde(default)] pub notifications: Notifications, + /// Notification method to use for unfocused terminal notifications. + /// Defaults to `auto`. + #[serde(default)] + pub notification_method: NotificationMethod, + /// Enable animations (welcome screen, shimmer effects, spinners). /// Defaults to `true`. #[serde(default = "default_true")] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bf4ab6798e3..4221cc62427 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -923,6 +923,7 @@ impl App { let app_event_tx = AppEventSender::new(app_event_tx); emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); emit_project_config_warnings(&app_event_tx, &config); + tui.set_notification_method(config.tui_notification_method); let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; @@ -1336,6 +1337,7 @@ impl App { Ok(resumed) => { self.shutdown_current_thread().await; self.config = resume_config; + tui.set_notification_method(self.config.tui_notification_method); self.file_search = FileSearchManager::new( self.config.cwd.clone(), self.app_event_tx.clone(), diff --git a/codex-rs/tui/src/notifications/bel.rs b/codex-rs/tui/src/notifications/bel.rs new file mode 100644 index 00000000000..44e7de2603d --- /dev/null +++ b/codex-rs/tui/src/notifications/bel.rs @@ -0,0 +1,37 @@ +use std::fmt; +use std::io; +use std::io::stdout; + +use crossterm::Command; +use ratatui::crossterm::execute; + +#[derive(Debug, Default)] +pub struct BelBackend; + +impl BelBackend { + pub fn notify(&mut self, _message: &str) -> io::Result<()> { + execute!(stdout(), PostNotification) + } +} + +/// Command that emits a BEL desktop notification. +#[derive(Debug, Clone)] +pub struct PostNotification; + +impl Command for PostNotification { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x07") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(std::io::Error::other( + "tried to execute PostNotification using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} diff --git a/codex-rs/tui/src/notifications/mod.rs b/codex-rs/tui/src/notifications/mod.rs index 33a591dfdbe..e9a7ac1ddff 100644 --- a/codex-rs/tui/src/notifications/mod.rs +++ b/codex-rs/tui/src/notifications/mod.rs @@ -1,68 +1,80 @@ +mod bel; mod osc9; -mod windows_toast; use std::env; use std::io; -use codex_core::env::is_wsl; +use bel::BelBackend; +use codex_core::config::types::NotificationMethod; use osc9::Osc9Backend; -use windows_toast::WindowsToastBackend; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NotificationBackendKind { - Osc9, - WindowsToast, -} #[derive(Debug)] pub enum DesktopNotificationBackend { Osc9(Osc9Backend), - WindowsToast(WindowsToastBackend), + Bel(BelBackend), } impl DesktopNotificationBackend { - pub fn osc9() -> Self { - Self::Osc9(Osc9Backend) - } - - pub fn windows_toast() -> Self { - Self::WindowsToast(WindowsToastBackend::default()) + pub fn for_method(method: NotificationMethod) -> Self { + match method { + NotificationMethod::Auto => { + if supports_osc9() { + Self::Osc9(Osc9Backend) + } else { + Self::Bel(BelBackend) + } + } + NotificationMethod::Osc9 => Self::Osc9(Osc9Backend), + NotificationMethod::Bel => Self::Bel(BelBackend), + } } - pub fn kind(&self) -> NotificationBackendKind { + pub fn method(&self) -> NotificationMethod { match self { - DesktopNotificationBackend::Osc9(_) => NotificationBackendKind::Osc9, - DesktopNotificationBackend::WindowsToast(_) => NotificationBackendKind::WindowsToast, + DesktopNotificationBackend::Osc9(_) => NotificationMethod::Osc9, + DesktopNotificationBackend::Bel(_) => NotificationMethod::Bel, } } pub fn notify(&mut self, message: &str) -> io::Result<()> { match self { DesktopNotificationBackend::Osc9(backend) => backend.notify(message), - DesktopNotificationBackend::WindowsToast(backend) => backend.notify(message), + DesktopNotificationBackend::Bel(backend) => backend.notify(message), } } } -pub fn detect_backend() -> DesktopNotificationBackend { - if should_use_windows_toasts() { - tracing::info!( - "Windows Terminal session detected under WSL; using Windows toast notifications" - ); - DesktopNotificationBackend::windows_toast() - } else { - DesktopNotificationBackend::osc9() - } +pub fn detect_backend(method: NotificationMethod) -> DesktopNotificationBackend { + DesktopNotificationBackend::for_method(method) } -fn should_use_windows_toasts() -> bool { - is_wsl() && env::var_os("WT_SESSION").is_some() +fn supports_osc9() -> bool { + if env::var_os("WT_SESSION").is_some() { + return false; + } + // Prefer TERM_PROGRAM when present, but keep fallbacks for shells/launchers + // that don't set it (e.g., tmux/ssh) to avoid regressing OSC 9 support. + if matches!( + env::var("TERM_PROGRAM").ok().as_deref(), + Some("WezTerm" | "ghostty") + ) { + return true; + } + // iTerm still provides a strong session signal even when TERM_PROGRAM is missing. + if env::var_os("ITERM_SESSION_ID").is_some() { + return true; + } + // TERM-based hints cover kitty/wezterm setups without TERM_PROGRAM. + matches!( + env::var("TERM").ok().as_deref(), + Some("xterm-kitty" | "wezterm" | "wezterm-mux") + ) } #[cfg(test)] mod tests { - use super::NotificationBackendKind; use super::detect_backend; + use codex_core::config::types::NotificationMethod; use serial_test::serial; use std::ffi::OsString; @@ -101,39 +113,44 @@ mod tests { } #[test] - #[serial] - fn defaults_to_osc9_outside_wsl() { - let _wsl_guard = EnvVarGuard::remove("WSL_DISTRO_NAME"); - let _wt_guard = EnvVarGuard::remove("WT_SESSION"); - assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9); + fn selects_osc9_method() { + assert!(matches!( + detect_backend(NotificationMethod::Osc9), + super::DesktopNotificationBackend::Osc9(_) + )); } #[test] - #[serial] - fn waits_for_windows_terminal() { - let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu"); - let _wt_guard = EnvVarGuard::remove("WT_SESSION"); - assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9); + fn selects_bel_method() { + assert!(matches!( + detect_backend(NotificationMethod::Bel), + super::DesktopNotificationBackend::Bel(_) + )); } - #[cfg(target_os = "linux")] #[test] #[serial] - fn selects_windows_toast_in_wsl_windows_terminal() { - let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu"); - let _wt_guard = EnvVarGuard::set("WT_SESSION", "abc"); - assert_eq!( - detect_backend().kind(), - NotificationBackendKind::WindowsToast - ); + fn auto_prefers_bel_without_hints() { + let _term = EnvVarGuard::remove("TERM"); + let _term_program = EnvVarGuard::remove("TERM_PROGRAM"); + let _iterm = EnvVarGuard::remove("ITERM_SESSION_ID"); + let _wt = EnvVarGuard::remove("WT_SESSION"); + assert!(matches!( + detect_backend(NotificationMethod::Auto), + super::DesktopNotificationBackend::Bel(_) + )); } - #[cfg(not(target_os = "linux"))] #[test] #[serial] - fn stays_on_osc9_outside_linux_even_with_wsl_env() { - let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu"); - let _wt_guard = EnvVarGuard::set("WT_SESSION", "abc"); - assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9); + fn auto_uses_osc9_for_iterm() { + let _term = EnvVarGuard::remove("TERM"); + let _term_program = EnvVarGuard::remove("TERM_PROGRAM"); + let _iterm = EnvVarGuard::set("ITERM_SESSION_ID", "abc"); + let _wt = EnvVarGuard::remove("WT_SESSION"); + assert!(matches!( + detect_backend(NotificationMethod::Auto), + super::DesktopNotificationBackend::Osc9(_) + )); } } diff --git a/codex-rs/tui/src/notifications/windows_toast.rs b/codex-rs/tui/src/notifications/windows_toast.rs deleted file mode 100644 index 9917e62c37f..00000000000 --- a/codex-rs/tui/src/notifications/windows_toast.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::io; -use std::process::Command; -use std::process::Stdio; - -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -const APP_ID: &str = "Codex"; -const POWERSHELL_EXE: &str = "powershell.exe"; - -#[derive(Debug)] -pub struct WindowsToastBackend { - encoded_title: String, -} - -impl WindowsToastBackend { - pub fn notify(&mut self, message: &str) -> io::Result<()> { - let encoded_body = encode_argument(message); - let encoded_command = build_encoded_command(&self.encoded_title, &encoded_body); - spawn_powershell(encoded_command) - } -} - -impl Default for WindowsToastBackend { - fn default() -> Self { - WindowsToastBackend { - encoded_title: encode_argument(APP_ID), - } - } -} - -fn spawn_powershell(encoded_command: String) -> io::Result<()> { - let mut command = Command::new(POWERSHELL_EXE); - command - .arg("-NoProfile") - .arg("-NoLogo") - .arg("-EncodedCommand") - .arg(encoded_command) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - let status = command.status()?; - if status.success() { - Ok(()) - } else { - Err(io::Error::other(format!( - "{POWERSHELL_EXE} exited with status {status}" - ))) - } -} - -fn build_encoded_command(encoded_title: &str, encoded_body: &str) -> String { - let script = build_ps_script(encoded_title, encoded_body); - encode_script_for_powershell(&script) -} - -fn build_ps_script(encoded_title: &str, encoded_body: &str) -> String { - format!( - r#" -$encoding = [System.Text.Encoding]::UTF8 -$titleText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_title}")) -$bodyText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_body}")) -[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null -$doc = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) -$textNodes = $doc.GetElementsByTagName("text") -$textNodes.Item(0).AppendChild($doc.CreateTextNode($titleText)) | Out-Null -$textNodes.Item(1).AppendChild($doc.CreateTextNode($bodyText)) | Out-Null -$toast = [Windows.UI.Notifications.ToastNotification]::new($doc) -[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Codex').Show($toast) -"#, - ) -} - -fn encode_script_for_powershell(script: &str) -> String { - let mut wide: Vec = Vec::with_capacity((script.len() + 1) * 2); - for unit in script.encode_utf16() { - let bytes = unit.to_le_bytes(); - wide.extend_from_slice(&bytes); - } - BASE64.encode(wide) -} - -fn encode_argument(value: &str) -> String { - BASE64.encode(escape_for_xml(value)) -} - -pub fn escape_for_xml(input: &str) -> String { - let mut escaped = String::with_capacity(input.len()); - for ch in input.chars() { - match ch { - '&' => escaped.push_str("&"), - '<' => escaped.push_str("<"), - '>' => escaped.push_str(">"), - '"' => escaped.push_str("""), - '\'' => escaped.push_str("'"), - _ => escaped.push(ch), - } - } - escaped -} - -#[cfg(test)] -mod tests { - use super::encode_script_for_powershell; - use super::escape_for_xml; - use pretty_assertions::assert_eq; - - #[test] - fn escapes_xml_entities() { - assert_eq!(escape_for_xml("5 > 3"), "5 > 3"); - assert_eq!(escape_for_xml("a & b"), "a & b"); - assert_eq!(escape_for_xml(""), "<tag>"); - assert_eq!(escape_for_xml("\"quoted\""), ""quoted""); - assert_eq!(escape_for_xml("single 'quote'"), "single 'quote'"); - } - - #[test] - fn leaves_safe_text_unmodified() { - assert_eq!(escape_for_xml("codex"), "codex"); - assert_eq!(escape_for_xml("multi word text"), "multi word text"); - } - - #[test] - fn encodes_utf16le_for_powershell() { - assert_eq!(encode_script_for_powershell("A"), "QQA="); - } -} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index b5fee2e4258..761fa836273 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -39,12 +39,12 @@ pub use self::frame_requester::FrameRequester; use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; use crate::notifications::DesktopNotificationBackend; -use crate::notifications::NotificationBackendKind; use crate::notifications::detect_backend; use crate::tui::event_stream::EventBroker; use crate::tui::event_stream::TuiEventStream; #[cfg(unix)] use crate::tui::job_control::SuspendContext; +use codex_core::config::types::NotificationMethod; mod event_stream; mod frame_rate_limiter; @@ -275,7 +275,7 @@ impl Tui { alt_screen_active: Arc::new(AtomicBool::new(false)), terminal_focused: Arc::new(AtomicBool::new(true)), enhanced_keys_supported, - notification_backend: Some(detect_backend()), + notification_backend: Some(detect_backend(NotificationMethod::default())), alt_screen_enabled: true, } } @@ -285,6 +285,10 @@ impl Tui { self.alt_screen_enabled = enabled; } + pub fn set_notification_method(&mut self, method: NotificationMethod) { + self.notification_backend = Some(detect_backend(method)); + } + pub fn frame_requester(&self) -> FrameRequester { self.frame_requester.clone() } @@ -361,36 +365,16 @@ impl Tui { let message = message.as_ref().to_string(); match backend.notify(&message) { Ok(()) => true, - Err(err) => match backend.kind() { - NotificationBackendKind::WindowsToast => { - tracing::error!( - error = %err, - "Failed to send Windows toast notification; falling back to OSC 9" - ); - self.notification_backend = Some(DesktopNotificationBackend::osc9()); - if let Some(backend) = self.notification_backend.as_mut() { - if let Err(osc_err) = backend.notify(&message) { - tracing::warn!( - error = %osc_err, - "Failed to emit OSC 9 notification after toast fallback; \ - disabling future notifications" - ); - self.notification_backend = None; - return false; - } - return true; - } - false - } - NotificationBackendKind::Osc9 => { - tracing::warn!( - error = %err, - "Failed to emit OSC 9 notification; disabling future notifications" - ); - self.notification_backend = None; - false - } - }, + Err(err) => { + let method = backend.method(); + tracing::warn!( + error = %err, + method = %method, + "Failed to emit terminal notification; disabling future notifications" + ); + self.notification_backend = None; + false + } } }