Added tui.notifications_method config option (#10043)

This PR adds a new `tui.notifications_method` config option that accepts
values of "auto", "osc9" and "bel". It defaults to "auto", which
attempts to auto-detect whether the terminal supports OSC 9 escape
sequences and falls back to BEL if not.

The PR also removes the inconsistent handling of notifications on
Windows when WSL was used.
This commit is contained in:
Eric Traut 2026-01-28 12:00:32 -08:00 committed by GitHub
parent f7699e0487
commit 147e7118e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 203 additions and 220 deletions

View file

@ -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": [
{

View file

@ -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<Vec<String>>,
/// 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),
@ -1765,6 +1774,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;
@ -1861,6 +1871,7 @@ persistence = "none"
tui,
Tui {
notifications: Notifications::Enabled(true),
notification_method: NotificationMethod::Auto,
animations: true,
show_tooltips: true,
experimental_mode: None,
@ -3789,6 +3800,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,
@ -3872,6 +3884,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,
@ -3970,6 +3983,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,
@ -4054,6 +4068,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,
@ -4411,13 +4426,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)]
@ -4448,4 +4467,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);
}
}

View file

@ -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")]

View file

@ -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(),

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

View file

@ -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 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 windows_toast() -> Self {
Self::WindowsToast(WindowsToastBackend::default())
}
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]
fn selects_bel_method() {
assert!(matches!(
detect_backend(NotificationMethod::Bel),
super::DesktopNotificationBackend::Bel(_)
));
}
#[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 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(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);
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(_)
));
}
}

View file

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