mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
19
codex-rs/core/src/env.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
139
codex-rs/tui/src/notifications/mod.rs
Normal file
139
codex-rs/tui/src/notifications/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
codex-rs/tui/src/notifications/osc9.rs
Normal file
37
codex-rs/tui/src/notifications/osc9.rs
Normal 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
|
||||
}
|
||||
}
|
||||
128
codex-rs/tui/src/notifications/windows_toast.rs
Normal file
128
codex-rs/tui/src/notifications/windows_toast.rs
Normal 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("&"),
|
||||
'<' => 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>"), "<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=");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user