mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Add tmux-aware OSC 9 notifications (#17836)
## Summary - wrap OSC 9 notifications in tmux's DCS passthrough so terminal notifications make it through tmux - use codex-terminal-detection for OSC 9 auto-selection so tmux sessions inherit the underlying client terminal support - add focused notification backend tests for plain OSC 9 and tmux-wrapped output ## Stack - base PR: #18479 - review order: #18479, then this PR ## Why Tmux does not forward OSC 9 notifications directly; the sequence has to be wrapped in tmux's DCS passthrough envelope. Codex also had local notification heuristics that could miss supported terminals when running under tmux, even though codex-terminal-detection already knows how to attribute tmux sessions to the client terminal. ## Validation - `just fmt` - `cargo test -p codex-tui` *(currently blocked by an unrelated existing compile error in `app-server/src/message_processor.rs:754` referencing `connection_id` out of scope; not caused by this change)* Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
3a9df58d06
commit
41652665f5
@@ -145,10 +145,10 @@ impl TerminalInfo {
|
||||
|
||||
/// Creates terminal metadata from a `TERM` capability value.
|
||||
fn from_term(term: String, multiplexer: Option<Multiplexer>) -> Self {
|
||||
let name = if term == "dumb" {
|
||||
TerminalName::Dumb
|
||||
} else {
|
||||
TerminalName::Unknown
|
||||
let name = match term.as_str() {
|
||||
"dumb" => TerminalName::Dumb,
|
||||
"wezterm" | "wezterm-mux" => TerminalName::WezTerm,
|
||||
_ => TerminalName::Unknown,
|
||||
};
|
||||
Self::new(
|
||||
name,
|
||||
|
||||
@@ -456,6 +456,44 @@ fn detects_wezterm() {
|
||||
"WezTerm",
|
||||
"wezterm_empty_user_agent"
|
||||
);
|
||||
|
||||
let env = FakeEnvironment::new().with_var("TERM", "wezterm");
|
||||
let terminal = detect_terminal_info_from_env(&env);
|
||||
assert_eq!(
|
||||
terminal,
|
||||
terminal_info(
|
||||
TerminalName::WezTerm,
|
||||
/*term_program*/ None,
|
||||
/*version*/ None,
|
||||
Some("wezterm"),
|
||||
/*multiplexer*/ None
|
||||
),
|
||||
"wezterm_term_info"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.user_agent_token(),
|
||||
"wezterm",
|
||||
"wezterm_term_user_agent"
|
||||
);
|
||||
|
||||
let env = FakeEnvironment::new().with_var("TERM", "wezterm-mux");
|
||||
let terminal = detect_terminal_info_from_env(&env);
|
||||
assert_eq!(
|
||||
terminal,
|
||||
terminal_info(
|
||||
TerminalName::WezTerm,
|
||||
/*term_program*/ None,
|
||||
/*version*/ None,
|
||||
Some("wezterm-mux"),
|
||||
/*multiplexer*/ None
|
||||
),
|
||||
"wezterm_mux_term_info"
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.user_agent_token(),
|
||||
"wezterm-mux",
|
||||
"wezterm_mux_term_user_agent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
mod bel;
|
||||
mod osc9;
|
||||
|
||||
use std::env;
|
||||
use std::io;
|
||||
|
||||
use bel::BelBackend;
|
||||
use codex_config::types::NotificationMethod;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use codex_terminal_detection::terminal_info;
|
||||
use osc9::Osc9Backend;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -18,13 +20,13 @@ impl DesktopNotificationBackend {
|
||||
pub fn for_method(method: NotificationMethod) -> Self {
|
||||
match method {
|
||||
NotificationMethod::Auto => {
|
||||
if supports_osc9() {
|
||||
Self::Osc9(Osc9Backend)
|
||||
if supports_osc9(&terminal_info()) {
|
||||
Self::Osc9(Osc9Backend::new())
|
||||
} else {
|
||||
Self::Bel(BelBackend)
|
||||
}
|
||||
}
|
||||
NotificationMethod::Osc9 => Self::Osc9(Osc9Backend),
|
||||
NotificationMethod::Osc9 => Self::Osc9(Osc9Backend::new()),
|
||||
NotificationMethod::Bel => Self::Bel(BelBackend),
|
||||
}
|
||||
}
|
||||
@@ -48,67 +50,33 @@ pub fn detect_backend(method: NotificationMethod) -> DesktopNotificationBackend
|
||||
DesktopNotificationBackend::for_method(method)
|
||||
}
|
||||
|
||||
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" | "WarpTerminal" | "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.
|
||||
fn supports_osc9(terminal: &TerminalInfo) -> bool {
|
||||
matches!(
|
||||
env::var("TERM").ok().as_deref(),
|
||||
Some("xterm-kitty" | "wezterm" | "wezterm-mux")
|
||||
terminal.name,
|
||||
TerminalName::Ghostty
|
||||
| TerminalName::Iterm2
|
||||
| TerminalName::Kitty
|
||||
| TerminalName::WarpTerminal
|
||||
| TerminalName::WezTerm
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::detect_backend;
|
||||
use super::supports_osc9;
|
||||
use codex_config::types::NotificationMethod;
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
fn test_terminal(name: TerminalName) -> TerminalInfo {
|
||||
TerminalInfo {
|
||||
name,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,28 +97,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
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(_)
|
||||
));
|
||||
fn supports_osc9_for_supported_terminals() {
|
||||
for name in [
|
||||
TerminalName::Ghostty,
|
||||
TerminalName::Iterm2,
|
||||
TerminalName::Kitty,
|
||||
TerminalName::WarpTerminal,
|
||||
TerminalName::WezTerm,
|
||||
] {
|
||||
assert!(
|
||||
supports_osc9(&test_terminal(name)),
|
||||
"{name:?} should support OSC 9"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
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(_)
|
||||
));
|
||||
fn supports_osc9_for_unsupported_terminals() {
|
||||
for name in [
|
||||
TerminalName::AppleTerminal,
|
||||
TerminalName::Alacritty,
|
||||
TerminalName::Dumb,
|
||||
TerminalName::GnomeTerminal,
|
||||
TerminalName::Konsole,
|
||||
TerminalName::Unknown,
|
||||
TerminalName::VsCode,
|
||||
TerminalName::Vte,
|
||||
TerminalName::WindowsTerminal,
|
||||
] {
|
||||
assert_eq!(
|
||||
supports_osc9(&test_terminal(name)),
|
||||
false,
|
||||
"{name:?} should not support OSC 9"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,55 @@ use std::fmt;
|
||||
use std::io;
|
||||
use std::io::stdout;
|
||||
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use codex_terminal_detection::terminal_info;
|
||||
use crossterm::Command;
|
||||
use ratatui::crossterm::execute;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Osc9Backend;
|
||||
#[derive(Debug)]
|
||||
pub struct Osc9Backend {
|
||||
dcs_passthrough: bool,
|
||||
}
|
||||
|
||||
impl Default for Osc9Backend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Osc9Backend {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
dcs_passthrough: matches!(terminal_info().multiplexer, Some(Multiplexer::Tmux { .. })),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&mut self, message: &str) -> io::Result<()> {
|
||||
execute!(stdout(), PostNotification(message.to_string()))
|
||||
execute!(
|
||||
stdout(),
|
||||
PostNotification {
|
||||
message: message.to_string(),
|
||||
dcs_passthrough: self.dcs_passthrough,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Command that emits an OSC 9 desktop notification with a message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostNotification(pub String);
|
||||
pub struct PostNotification {
|
||||
pub message: String,
|
||||
pub dcs_passthrough: bool,
|
||||
}
|
||||
|
||||
impl Command for PostNotification {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, "\x1b]9;{}\x07", self.0)
|
||||
if self.dcs_passthrough {
|
||||
let escaped_message = escape_tmux_dcs_passthrough_payload(&self.message);
|
||||
write!(f, "\x1bPtmux;\x1b\x1b]9;{escaped_message}\x07\x1b\\")
|
||||
} else {
|
||||
write!(f, "\x1b]9;{}\x07", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -35,3 +65,63 @@ impl Command for PostNotification {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_tmux_dcs_passthrough_payload(message: &str) -> String {
|
||||
message.replace('\u{1b}', "\u{1b}\u{1b}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crossterm::Command;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::PostNotification;
|
||||
|
||||
#[test]
|
||||
fn post_notification_writes_plain_osc9_sequence() {
|
||||
let mut ansi = String::new();
|
||||
let command = PostNotification {
|
||||
message: "hello".to_string(),
|
||||
dcs_passthrough: false,
|
||||
};
|
||||
|
||||
command
|
||||
.write_ansi(&mut ansi)
|
||||
.expect("OSC 9 command should format");
|
||||
|
||||
assert_eq!(ansi, "\u{1b}]9;hello\u{7}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_notification_writes_tmux_dcs_wrapped_osc9_sequence() {
|
||||
let mut ansi = String::new();
|
||||
let command = PostNotification {
|
||||
message: "done".to_string(),
|
||||
dcs_passthrough: true,
|
||||
};
|
||||
|
||||
command
|
||||
.write_ansi(&mut ansi)
|
||||
.expect("OSC 9 command should format");
|
||||
|
||||
assert_eq!(ansi, "\u{1b}Ptmux;\u{1b}\u{1b}]9;done\u{7}\u{1b}\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_notification_escapes_escape_bytes_inside_tmux_payload() {
|
||||
let mut ansi = String::new();
|
||||
let command = PostNotification {
|
||||
message: "danger\u{1b}[31m".to_string(),
|
||||
dcs_passthrough: true,
|
||||
};
|
||||
|
||||
command
|
||||
.write_ansi(&mut ansi)
|
||||
.expect("OSC 9 command should format");
|
||||
|
||||
assert_eq!(
|
||||
ansi,
|
||||
"\u{1b}Ptmux;\u{1b}\u{1b}]9;danger\u{1b}\u{1b}[31m\u{7}\u{1b}\\"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user