diff --git a/codex-rs/README.md b/codex-rs/README.md index 385b4c62e..a3d1b82fb 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t ### Notifications -You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. +You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. ### `codex exec` to run Codex programmatically/non-interactively diff --git a/codex-rs/cli/src/wsl_paths.rs b/codex-rs/cli/src/wsl_paths.rs index 56ce8668c..b6ceb2e0b 100644 --- a/codex-rs/cli/src/wsl_paths.rs +++ b/codex-rs/cli/src/wsl_paths.rs @@ -1,24 +1,7 @@ use std::ffi::OsStr; -/// WSL-specific path helpers used by the updater logic. -/// -/// See https://github.com/openai/codex/issues/6086. -pub fn is_wsl() -> bool { - #[cfg(target_os = "linux")] - { - if std::env::var_os("WSL_DISTRO_NAME").is_some() { - return true; - } - match std::fs::read_to_string("/proc/version") { - Ok(version) => version.to_lowercase().contains("microsoft"), - Err(_) => false, - } - } - #[cfg(not(target_os = "linux"))] - { - false - } -} +/// Returns true if the current process is running under WSL. +pub use codex_core::env::is_wsl; /// Convert a Windows absolute path (`C:\foo\bar` or `C:/foo/bar`) to a WSL mount path (`/mnt/c/foo/bar`). /// Returns `None` if the input does not look like a Windows drive path. diff --git a/codex-rs/core/src/env.rs b/codex-rs/core/src/env.rs new file mode 100644 index 000000000..5370c0ffd --- /dev/null +++ b/codex-rs/core/src/env.rs @@ -0,0 +1,19 @@ +//! Functions for environment detection that need to be shared across crates. + +/// Returns true if the current process is running under Windows Subsystem for Linux. +pub fn is_wsl() -> bool { + #[cfg(target_os = "linux")] + { + if std::env::var_os("WSL_DISTRO_NAME").is_some() { + return true; + } + match std::fs::read_to_string("/proc/version") { + Ok(version) => version.to_lowercase().contains("microsoft"), + Err(_) => false, + } + } + #[cfg(not(target_os = "linux"))] + { + false + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index cd0ff497a..f69e7b8fb 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -21,6 +21,7 @@ pub mod config; pub mod config_loader; mod context_manager; pub mod custom_prompts; +pub mod env; mod environment_context; pub mod error; pub mod exec; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 71a47d119..36883b866 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -58,6 +58,7 @@ mod markdown; mod markdown_render; mod markdown_stream; mod model_migration; +mod notifications; pub mod onboarding; mod oss_selection; mod pager_overlay; diff --git a/codex-rs/tui/src/notifications/mod.rs b/codex-rs/tui/src/notifications/mod.rs new file mode 100644 index 000000000..33a591dfd --- /dev/null +++ b/codex-rs/tui/src/notifications/mod.rs @@ -0,0 +1,139 @@ +mod osc9; +mod windows_toast; + +use std::env; +use std::io; + +use codex_core::env::is_wsl; +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), +} + +impl DesktopNotificationBackend { + pub fn osc9() -> Self { + Self::Osc9(Osc9Backend) + } + + pub fn windows_toast() -> Self { + Self::WindowsToast(WindowsToastBackend::default()) + } + + pub fn kind(&self) -> NotificationBackendKind { + match self { + DesktopNotificationBackend::Osc9(_) => NotificationBackendKind::Osc9, + DesktopNotificationBackend::WindowsToast(_) => NotificationBackendKind::WindowsToast, + } + } + + pub fn notify(&mut self, message: &str) -> io::Result<()> { + match self { + DesktopNotificationBackend::Osc9(backend) => backend.notify(message), + DesktopNotificationBackend::WindowsToast(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() + } +} + +fn should_use_windows_toasts() -> bool { + is_wsl() && env::var_os("WT_SESSION").is_some() +} + +#[cfg(test)] +mod tests { + use super::NotificationBackendKind; + use super::detect_backend; + use serial_test::serial; + use std::ffi::OsString; + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + #[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); + } + + #[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); + } + + #[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 + ); + } + + #[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); + } +} diff --git a/codex-rs/tui/src/notifications/osc9.rs b/codex-rs/tui/src/notifications/osc9.rs new file mode 100644 index 000000000..fd3a8ad96 --- /dev/null +++ b/codex-rs/tui/src/notifications/osc9.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 Osc9Backend; + +impl Osc9Backend { + pub fn notify(&mut self, message: &str) -> io::Result<()> { + execute!(stdout(), PostNotification(message.to_string())) + } +} + +/// Command that emits an OSC 9 desktop notification with a message. +#[derive(Debug, Clone)] +pub struct PostNotification(pub String); + +impl Command for PostNotification { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b]9;{}\x07", self.0) + } + + #[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/windows_toast.rs b/codex-rs/tui/src/notifications/windows_toast.rs new file mode 100644 index 000000000..9917e62c3 --- /dev/null +++ b/codex-rs/tui/src/notifications/windows_toast.rs @@ -0,0 +1,128 @@ +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 5502b8335..36f6cc2cb 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -39,6 +39,9 @@ use tokio_stream::Stream; 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; #[cfg(unix)] use crate::tui::job_control::SUSPEND_KEY; #[cfg(unix)] @@ -173,6 +176,7 @@ pub struct Tui { // True when terminal/tab is focused; updated internally from crossterm events terminal_focused: Arc, enhanced_keys_supported: bool, + notification_backend: Option, } impl Tui { @@ -198,6 +202,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()), } } @@ -212,11 +217,47 @@ impl Tui { /// Emit a desktop notification now if the terminal is unfocused. /// Returns true if a notification was posted. pub fn notify(&mut self, message: impl AsRef) -> bool { - if !self.terminal_focused.load(Ordering::Relaxed) { - let _ = execute!(stdout(), PostNotification(message.as_ref().to_string())); - true - } else { - false + if self.terminal_focused.load(Ordering::Relaxed) { + return false; + } + + let Some(backend) = self.notification_backend.as_mut() else { + return false; + }; + + 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 + } + }, } } @@ -417,25 +458,3 @@ impl Tui { Ok(None) } } - -/// Command that emits an OSC 9 desktop notification with a message. -#[derive(Debug, Clone)] -pub struct PostNotification(pub String); - -impl Command for PostNotification { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b]9;{}\x07", self.0) - } - - #[cfg(windows)] - fn execute_winapi(&self) -> 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/docs/config.md b/docs/config.md index 8b131cd63..2ac775528 100644 --- a/docs/config.md +++ b/docs/config.md @@ -745,6 +745,8 @@ notify = ["python3", "/Users/mbolin/.codex/notify.py"] > [!NOTE] > Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering. +When Codex detects WSL 2 inside Windows Terminal (the session exports `WT_SESSION`), `tui.notifications` automatically switches to a Windows toast backend by spawning `powershell.exe`. This ensures both approval prompts and completed turns trigger native toasts even though Windows Terminal ignores OSC 9 escape sequences. Terminals that advertise OSC 9 support (iTerm2, WezTerm, kitty, etc.) continue to use the existing escape-sequence backend, and the `notify` hook remains unchanged. + ### hide_agent_reasoning Codex intermittently emits "reasoning" events that show the model's internal "thinking" before it produces a final answer. Some users may find these events distracting, especially in CI logs or minimal terminal output.