Fix toasts on Windows under WSL 2 (#7137)

Before this: no notifications or toasts when using Codex CLI in WSL 2.

After this: I get toasts from Codex
This commit is contained in:
dank-openai
2025-12-11 15:09:00 -08:00
committed by GitHub
parent e0d7ac51d3
commit 36610d975a
10 changed files with 376 additions and 47 deletions

View File

@@ -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

View File

@@ -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.

19
codex-rs/core/src/env.rs Normal file
View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<OsString>,
}
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);
}
}

View File

@@ -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
}
}

View File

@@ -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<u8> = 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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => 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 &gt; 3");
assert_eq!(escape_for_xml("a & b"), "a &amp; b");
assert_eq!(escape_for_xml("<tag>"), "&lt;tag&gt;");
assert_eq!(escape_for_xml("\"quoted\""), "&quot;quoted&quot;");
assert_eq!(escape_for_xml("single 'quote'"), "single &apos;quote&apos;");
}
#[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=");
}
}

View File

@@ -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<AtomicBool>,
enhanced_keys_supported: bool,
notification_backend: Option<DesktopNotificationBackend>,
}
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<str>) -> 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
}
}

View File

@@ -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 inTUI alerts (e.g., approval prompts), while `notify` is best for systemlevel 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.