mirror of
https://github.com/openai/codex.git
synced 2026-11-01 08:16:54 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => 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=");
|
||||
}
|
||||
}
|
||||
+16
-32
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user