From 4bad6d78e9b50f9fa8bd941f1db012ed491ad2da Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 9 Oct 2026 23:32:38 +0000 Subject: [PATCH] Report terminal program status with OSC 7501 (#52725) ## Why Lifecycle status reporting was limited to direct iTerm2 sessions. OSC 7501 lets other terminals receive Codex's program state. ## What changed - Emit OSC 7501 with `app=codex` and states `idle`, `working`, or `blocked` whenever stdout is a terminal, including multiplexed sessions. Report `blocked` when user action is required. - Continue emitting iTerm2's OSC 21337 status and bounded activity details in direct iTerm2 sessions. - Clear status before handing the terminal to an external program, as well as on suspension and exit, and re-emit the current state after returning. ## Testing Add coverage for OSC 7501 encoding, combined iTerm2 output, clearing both protocols, and protocol selection across terminal and multiplexer environments. Update lifecycle tests and status snapshots for the new protocol. GitOrigin-RevId: 6d2a6885687b0e7af825e954fab72db0765c9a07 --- codex-rs/tui/src/app_backtrack.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 10 +- .../tui/src/chatwidget/status_surfaces.rs | 14 +- codex-rs/tui/src/chatwidget/tests.rs | 4 +- ...n_status.rs => terminal_program_status.rs} | 25 +-- .../tui/src/iterm_session_status_tests.rs | 162 ++++++++++++------ codex-rs/tui/src/lib.rs | 2 +- ...__tests__iterm_session_status_fields.snap} | 8 +- ...n_status.rs => terminal_program_status.rs} | 155 ++++++++++++----- codex-rs/tui/src/tui.rs | 7 +- codex-rs/tui/src/tui/job_control.rs | 6 +- 11 files changed, 257 insertions(+), 138 deletions(-) rename codex-rs/tui/src/chatwidget/tests/{iterm_session_status.rs => terminal_program_status.rs} (78%) rename codex-rs/tui/src/snapshots/{codex_tui__iterm_session_status__tests__iterm_session_status_fields.snap => codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap} (59%) rename codex-rs/tui/src/{iterm_session_status.rs => terminal_program_status.rs} (54%) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 22e6db168d..a6ab4f8983 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -353,7 +353,7 @@ impl App { &event, TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained ) { - self.chat_widget.refresh_iterm_session_status(); + self.chat_widget.refresh_terminal_program_status(); } let width = tui.terminal.last_known_screen_size.width.max(/*other*/ 1); let footer = self.prompt_navigation_footer(width.saturating_sub(/*rhs*/ 2)); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 16b13efb89..ea0a5ffd54 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1182,16 +1182,16 @@ impl ChatWidget { } self.refresh_status_line_if_workspace_headline_due(); self.refresh_thread_usage_if_settlement_due(); - self.refresh_iterm_session_status(); + self.refresh_terminal_program_status(); } - pub(crate) fn refresh_iterm_session_status(&self) { - let status = self.desired_iterm_session_status(); - if let Err(err) = crate::iterm_session_status::set_iterm_session_status( + pub(crate) fn refresh_terminal_program_status(&self) { + let status = self.desired_program_status(); + if let Err(err) = crate::terminal_program_status::set_terminal_program_status( status, self.iterm_session_detail(status), ) { - tracing::debug!(error = %err, "failed to set iTerm2 session status"); + tracing::debug!(error = %err, "failed to set terminal program status"); } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 7eef820958..a3946abd13 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -8,12 +8,12 @@ use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; use crate::chatwidget::limit_label_for_window; use crate::chatwidget::rate_limits::get_limits_duration; -use crate::iterm_session_status::ItermSessionStatus; use crate::legacy_core::config::Config; use crate::model_catalog::LUNA_RESERVE_MODEL; use crate::status::format_credit_micros; use crate::status::format_estimated_usd_micros; use crate::status::format_tokens_compact; +use crate::terminal_program_status::ProgramStatus; use codex_app_server_protocol::AskForApproval; use codex_config::ConfigLayerSource; use codex_config::os_host_name; @@ -322,18 +322,18 @@ impl ChatWidget { self.bottom_pane.terminal_title_requires_action() } - pub(super) fn desired_iterm_session_status(&self) -> ItermSessionStatus { + pub(super) fn desired_program_status(&self) -> ProgramStatus { if self.terminal_title_requires_action() { - ItermSessionStatus::Waiting + ProgramStatus::Blocked } else if self.bottom_pane.is_task_running() || !self.unified_exec_processes.is_empty() { - ItermSessionStatus::Working + ProgramStatus::Working } else { - ItermSessionStatus::Idle + ProgramStatus::Idle } } - pub(super) fn iterm_session_detail(&self, status: ItermSessionStatus) -> Option<&str> { - (status == ItermSessionStatus::Working && self.bottom_pane.is_task_running()) + pub(super) fn iterm_session_detail(&self, status: ProgramStatus) -> Option<&str> { + (status == ProgramStatus::Working && self.bottom_pane.is_task_running()) .then_some(self.status_state.current_status.header.as_str()) .filter(|detail| *detail != "Working") } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2928d3cfe6..d083177e00 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -257,8 +257,6 @@ mod history_projection; mod history_replay; #[path = "tests/home_cleanup_tests.rs"] mod home_cleanup_tests; -#[path = "tests/iterm_session_status.rs"] -mod iterm_session_status; #[path = "tests/luna_reserve_usage_tests.rs"] mod luna_reserve_usage_tests; mod mcp_startup; @@ -299,6 +297,8 @@ mod status_command_tests; mod status_surface_previews; #[path = "tests/subagent_activity_tests.rs"] mod subagent_activity; +#[path = "tests/terminal_program_status.rs"] +mod terminal_program_status; mod terminal_title; #[path = "tests/tool_activity_tests.rs"] mod tool_activity_tests; diff --git a/codex-rs/tui/src/chatwidget/tests/iterm_session_status.rs b/codex-rs/tui/src/chatwidget/tests/terminal_program_status.rs similarity index 78% rename from codex-rs/tui/src/chatwidget/tests/iterm_session_status.rs rename to codex-rs/tui/src/chatwidget/tests/terminal_program_status.rs index d63e1e4fcf..3911129ba3 100644 --- a/codex-rs/tui/src/chatwidget/tests/iterm_session_status.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_program_status.rs @@ -1,16 +1,17 @@ use super::*; -use crate::iterm_session_status::ItermSessionStatus::Idle; -use crate::iterm_session_status::ItermSessionStatus::Waiting; -use crate::iterm_session_status::ItermSessionStatus::Working; use pretty_assertions::assert_eq; +use crate::terminal_program_status::ProgramStatus::Blocked; +use crate::terminal_program_status::ProgramStatus::Idle; +use crate::terminal_program_status::ProgramStatus::Working; + #[tokio::test] async fn session_status_follows_existing_lifecycle_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let mut states = vec![chat.desired_iterm_session_status()]; + let mut states = vec![chat.desired_program_status()]; chat.bottom_pane.set_task_running(/*running*/ true); - states.push(chat.desired_iterm_session_status()); + states.push(chat.desired_program_status()); handle_exec_approval_request( &mut chat, @@ -31,13 +32,13 @@ async fn session_status_follows_existing_lifecycle_state() { available_decisions: None, }, ); - states.push(chat.desired_iterm_session_status()); + states.push(chat.desired_program_status()); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - states.push(chat.desired_iterm_session_status()); + states.push(chat.desired_program_status()); chat.bottom_pane.set_task_running(/*running*/ false); - states.push(chat.desired_iterm_session_status()); + states.push(chat.desired_program_status()); chat.unified_exec_processes.push(UnifiedExecProcessSummary { key: "background-process".to_string(), @@ -45,13 +46,13 @@ async fn session_status_follows_existing_lifecycle_state() { command_display: "sleep 5".to_string(), recent_chunks: Vec::new(), }); - states.push(chat.desired_iterm_session_status()); + states.push(chat.desired_program_status()); chat.unified_exec_processes.clear(); - states.push(chat.desired_iterm_session_status()); + states.push(chat.desired_program_status()); assert_eq!( states, - [Idle, Working, Waiting, Working, Idle, Working, Idle] + [Idle, Working, Blocked, Working, Idle, Working, Idle] ); } @@ -65,7 +66,7 @@ async fn session_status_detail_uses_only_foreground_activity() { let generic = detail(&chat, Working); chat.set_status_header("Reading source".to_string()); let working = detail(&chat, Working); - let waiting = detail(&chat, Waiting); + let waiting = detail(&chat, Blocked); chat.bottom_pane.set_task_running(/*running*/ false); let idle = detail(&chat, Idle); let background = detail(&chat, Working); diff --git a/codex-rs/tui/src/iterm_session_status_tests.rs b/codex-rs/tui/src/iterm_session_status_tests.rs index ac1e729d70..12f35136f6 100644 --- a/codex-rs/tui/src/iterm_session_status_tests.rs +++ b/codex-rs/tui/src/iterm_session_status_tests.rs @@ -6,26 +6,31 @@ use codex_terminal_detection::TerminalName; use crossterm::Command; use pretty_assertions::assert_eq; -use super::ClearItermSessionStatus; -use super::ItermSessionStatus; +use super::ClearTerminalProgramStatus; use super::NO_EMITTED_STATUS; +use super::ProgramStatus; use super::SessionStatusState; -use super::SetItermSessionStatus; -use super::iterm_session_status_supported; +use super::SetTerminalProgramStatus; +use super::TerminalProgramStatusSupport; use super::sanitize_iterm_session_detail; +use super::terminal_program_status_support; #[test] -fn encodes_session_status_fields() { +fn encodes_iterm_session_status_fields() { let mut encoded = Vec::new(); for (status, detail) in [ - (ItermSessionStatus::Idle, ""), - (ItermSessionStatus::Working, "Reading source"), - (ItermSessionStatus::Waiting, ""), + (ProgramStatus::Idle, ""), + (ProgramStatus::Working, "Reading source"), + (ProgramStatus::Blocked, ""), ] { let mut value = String::new(); - SetItermSessionStatus(status, detail) - .write_ansi(&mut value) - .expect("encode iTerm2 session status"); + SetTerminalProgramStatus { + status, + iterm_detail: detail, + emit_iterm_status: true, + } + .write_ansi(&mut value) + .expect("encode combined terminal program status"); encoded.push(value); } @@ -37,6 +42,20 @@ fn encodes_session_status_fields() { insta::assert_snapshot!("iterm_session_status_fields", snapshot); } +#[test] +fn encodes_generic_program_status() { + let mut encoded = String::new(); + SetTerminalProgramStatus { + status: ProgramStatus::Working, + iterm_detail: "ignored", + emit_iterm_status: false, + } + .write_ansi(&mut encoded) + .expect("encode generic terminal program status"); + + assert_eq!(encoded, "\x1b]7501;state=working:app=codex\x1b\\"); +} + #[test] fn detail_is_safe_bounded_osc_text() { assert_eq!( @@ -51,15 +70,24 @@ fn detail_is_safe_bounded_osc_text() { #[test] fn clear_empties_every_owned_field() { - let mut encoded = String::new(); - ClearItermSessionStatus - .write_ansi(&mut encoded) - .expect("encode iTerm2 session status clear"); + let mut combined = String::new(); + ClearTerminalProgramStatus { + emit_iterm_status: true, + } + .write_ansi(&mut combined) + .expect("encode combined terminal program status clear"); + let mut generic = String::new(); + ClearTerminalProgramStatus { + emit_iterm_status: false, + } + .write_ansi(&mut generic) + .expect("encode generic terminal program status clear"); assert_eq!( - encoded, - "\x1b]21337;status=;indicator=;status-color=;detail=\x07" + combined, + "\x1b]21337;status=;indicator=;status-color=;detail=\x07\x1b]7501;state=clear\x1b\\" ); + assert_eq!(generic, "\x1b]7501;state=clear\x1b\\"); } #[test] @@ -67,33 +95,33 @@ fn cache_changes_only_after_a_successful_write() { let state = SessionStatusState::new(); let mut emitted = Vec::new(); state - .update(ItermSessionStatus::Working as u8, "Thinking", || { + .update(ProgramStatus::Working as u8, "Thinking", || { Err(io::Error::other("write failed")) }) .expect_err("failed write should be returned"); state - .update(ItermSessionStatus::Working as u8, "Thinking", || { - emitted.push(ItermSessionStatus::Working as u8); + .update(ProgramStatus::Working as u8, "Thinking", || { + emitted.push(ProgramStatus::Working as u8); Ok(()) }) .expect("retry status write"); state - .update(ItermSessionStatus::Working as u8, "Thinking", || { - emitted.push(ItermSessionStatus::Waiting as u8); + .update(ProgramStatus::Working as u8, "Thinking", || { + emitted.push(ProgramStatus::Blocked as u8); Ok(()) }) .expect("deduplicate status write"); state - .update(ItermSessionStatus::Working as u8, "Reading", || { - emitted.push(ItermSessionStatus::Working as u8); + .update(ProgramStatus::Working as u8, "Reading", || { + emitted.push(ProgramStatus::Working as u8); Ok(()) }) .expect("emit changed detail"); state.invalidate(); state - .update(ItermSessionStatus::Working as u8, "Thinking", || { - emitted.push(ItermSessionStatus::Working as u8); + .update(ProgramStatus::Working as u8, "Thinking", || { + emitted.push(ProgramStatus::Working as u8); Ok(()) }) .expect("re-emit status after terminal handoff"); @@ -107,16 +135,16 @@ fn cache_changes_only_after_a_successful_write() { assert_eq!( emitted, [ - ItermSessionStatus::Working as u8, - ItermSessionStatus::Working as u8, - ItermSessionStatus::Working as u8, + ProgramStatus::Working as u8, + ProgramStatus::Working as u8, + ProgramStatus::Working as u8, NO_EMITTED_STATUS, ] ); } #[test] -fn supports_only_direct_iterm_ttys() { +fn selects_protocols_for_terminal_environment() { let direct_iterm = TerminalInfo { name: TerminalName::Iterm2, term_program: Some("iTerm.app".to_string()), @@ -124,36 +152,58 @@ fn supports_only_direct_iterm_ttys() { term: None, multiplexer: None, }; - assert!(iterm_session_status_supported( - &direct_iterm, - /*stdout_is_terminal*/ true, - /*screen_active*/ false, - )); - assert!(!iterm_session_status_supported( - &direct_iterm, - /*stdout_is_terminal*/ false, - /*screen_active*/ false, - )); - assert!(!iterm_session_status_supported( - &direct_iterm, - /*stdout_is_terminal*/ true, - /*screen_active*/ true, - )); + let both_protocols = TerminalProgramStatusSupport { + osc_7501: true, + osc_21337: true, + }; + let no_protocols = TerminalProgramStatusSupport { + osc_7501: false, + osc_21337: false, + }; + let generic_protocol = TerminalProgramStatusSupport { + osc_7501: true, + osc_21337: false, + }; + assert_eq!( + terminal_program_status_support( + &direct_iterm, + /*stdout_is_terminal*/ true, + /*screen_active*/ false, + ), + both_protocols + ); + assert_eq!( + terminal_program_status_support( + &direct_iterm, + /*stdout_is_terminal*/ false, + /*screen_active*/ false, + ), + no_protocols + ); - assert!(!iterm_session_status_supported( - &TerminalInfo { + for terminal in [ + TerminalInfo { multiplexer: Some(Multiplexer::Tmux { version: None }), ..direct_iterm.clone() }, - /*stdout_is_terminal*/ true, - /*screen_active*/ false, - )); - assert!(!iterm_session_status_supported( - &TerminalInfo { + TerminalInfo { name: TerminalName::Ghostty, - ..direct_iterm + ..direct_iterm.clone() }, - /*stdout_is_terminal*/ true, - /*screen_active*/ false, - )); + ] { + assert_eq!( + terminal_program_status_support( + &terminal, /*stdout_is_terminal*/ true, /*screen_active*/ false, + ), + generic_protocol + ); + } + assert_eq!( + terminal_program_status_support( + &direct_iterm, + /*stdout_is_terminal*/ true, + /*screen_active*/ true, + ), + generic_protocol + ); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4f7c5fa050..8edf8b61e4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -162,9 +162,9 @@ mod hooks_rpc; mod ide_context; mod inline_visualization; pub(crate) mod insert_history; -mod iterm_session_status; mod managed_worktree_tool_specs; mod managed_worktree_tools; +mod terminal_program_status; pub use insert_history::insert_history_lines; mod footer_hint; mod key_hint; diff --git a/codex-rs/tui/src/snapshots/codex_tui__iterm_session_status__tests__iterm_session_status_fields.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap similarity index 59% rename from codex-rs/tui/src/snapshots/codex_tui__iterm_session_status__tests__iterm_session_status_fields.snap rename to codex-rs/tui/src/snapshots/codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap index 9a5cd475f3..07001e76a5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__iterm_session_status__tests__iterm_session_status_fields.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_program_status__tests__iterm_session_status_fields.snap @@ -1,8 +1,8 @@ --- source: tui/src/iterm_session_status_tests.rs -assertion_line: 39 +assertion_line: 42 expression: snapshot --- -\u{1b}]21337;status=Idle;indicator=#00d75f;status-color=;detail=\u{7} -\u{1b}]21337;status=Working;indicator=#ff9500;status-color=;detail=Reading source\u{7} -\u{1b}]21337;status=Waiting;indicator=#5f87ff;status-color=;detail=\u{7} +\u{1b}]21337;status=Idle;indicator=#00d75f;status-color=;detail=\u{7}\u{1b}]7501;state=idle:app=codex\u{1b}\\ +\u{1b}]21337;status=Working;indicator=#ff9500;status-color=;detail=Reading source\u{7}\u{1b}]7501;state=working:app=codex\u{1b}\\ +\u{1b}]21337;status=Waiting;indicator=#5f87ff;status-color=;detail=\u{7}\u{1b}]7501;state=blocked:app=codex\u{1b}\\ diff --git a/codex-rs/tui/src/iterm_session_status.rs b/codex-rs/tui/src/terminal_program_status.rs similarity index 54% rename from codex-rs/tui/src/iterm_session_status.rs rename to codex-rs/tui/src/terminal_program_status.rs index 73b23dc036..9379f960a6 100644 --- a/codex-rs/tui/src/iterm_session_status.rs +++ b/codex-rs/tui/src/terminal_program_status.rs @@ -1,9 +1,10 @@ -//! Emits coarse Codex lifecycle state through iTerm2's OSC 21337 protocol. +//! Emits coarse Codex lifecycle state through terminal status protocols. //! -//! This first integration is intentionally limited to direct iTerm2 sessions. -//! The payload includes bounded activity text from the existing TUI status. -//! A process-wide cache matches the terminal session's ownership and prevents -//! stale state when the active chat changes. +//! OSC 7501 is safe to send to any terminal and reports only the lifecycle +//! state and app name. Direct iTerm2 sessions also receive the existing OSC +//! 21337 status, including bounded activity text. A process-wide cache matches +//! the terminal session's ownership and prevents stale state when the active +//! chat changes. use std::fmt; use std::io; @@ -21,7 +22,7 @@ use ratatui::crossterm::execute; const NO_EMITTED_STATUS: u8 = 0; const INVALIDATED_EMITTED_STATUS: u8 = u8::MAX; static SESSION_STATUS_STATE: SessionStatusState = SessionStatusState::new(); -static ITERM_SESSION_STATUS_SUPPORTED: OnceLock = OnceLock::new(); +static TERMINAL_PROGRAM_STATUS_SUPPORT: OnceLock = OnceLock::new(); struct SessionStatusState { last_emitted: Mutex, @@ -75,58 +76,92 @@ impl SessionStatusState { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u8)] -pub(crate) enum ItermSessionStatus { - Idle = 1, - Working = 2, - Waiting = 3, +struct TerminalProgramStatusSupport { + osc_7501: bool, + osc_21337: bool, } -impl ItermSessionStatus { - fn label(self) -> &'static str { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub(crate) enum ProgramStatus { + Idle = 1, + Working = 2, + Blocked = 3, +} + +impl ProgramStatus { + fn iterm_label(self) -> &'static str { match self { Self::Idle => "Idle", Self::Working => "Working", - Self::Waiting => "Waiting", + Self::Blocked => "Waiting", } } - fn indicator(self) -> &'static str { + fn iterm_indicator(self) -> &'static str { match self { Self::Idle => "#00d75f", Self::Working => "#ff9500", - Self::Waiting => "#5f87ff", + Self::Blocked => "#5f87ff", + } + } + + fn protocol_state(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Working => "working", + Self::Blocked => "blocked", } } } -pub(crate) fn set_iterm_session_status( - status: ItermSessionStatus, +pub(crate) fn set_terminal_program_status( + status: ProgramStatus, detail: Option<&str>, ) -> io::Result<()> { if cfg!(test) { return Ok(()); } - if !*ITERM_SESSION_STATUS_SUPPORTED.get_or_init(|| { - iterm_session_status_supported( - &terminal_info(), - /*stdout_is_terminal*/ stdout().is_terminal(), - /*screen_active*/ std::env::var_os("STY").is_some(), - ) - }) { + let support = current_terminal_program_status_support(); + if !support.osc_7501 { return Ok(()); } - let detail = sanitize_iterm_session_detail(detail.unwrap_or_default()); + let detail = if support.osc_21337 { + sanitize_iterm_session_detail(detail.unwrap_or_default()) + } else { + String::new() + }; SESSION_STATUS_STATE.update(status as u8, &detail, || { - execute!(stdout(), SetItermSessionStatus(status, &detail)) + execute!( + stdout(), + SetTerminalProgramStatus { + status, + iterm_detail: &detail, + emit_iterm_status: support.osc_21337, + } + ) }) } -pub(crate) fn clear_iterm_session_status() -> io::Result<()> { +pub(crate) fn clear_terminal_program_status() -> io::Result<()> { + if cfg!(test) { + return Ok(()); + } + + let support = current_terminal_program_status_support(); + if !support.osc_7501 { + return Ok(()); + } + SESSION_STATUS_STATE.update(NO_EMITTED_STATUS, "", || { - execute!(stdout(), ClearItermSessionStatus) + execute!( + stdout(), + ClearTerminalProgramStatus { + emit_iterm_status: support.osc_21337, + } + ) }) } @@ -134,7 +169,7 @@ pub(crate) fn clear_iterm_session_status() -> io::Result<()> { /// /// The next active draw will re-emit Codex's current status even if its /// lifecycle state did not change during the handoff. -pub(crate) fn invalidate_iterm_session_status() { +pub(crate) fn invalidate_terminal_program_status() { SESSION_STATUS_STATE.invalidate(); } @@ -153,35 +188,60 @@ fn sanitize_iterm_session_detail(detail: &str) -> String { escaped } -fn iterm_session_status_supported( +fn current_terminal_program_status_support() -> TerminalProgramStatusSupport { + *TERMINAL_PROGRAM_STATUS_SUPPORT.get_or_init(|| { + terminal_program_status_support( + &terminal_info(), + /*stdout_is_terminal*/ stdout().is_terminal(), + /*screen_active*/ std::env::var_os("STY").is_some(), + ) + }) +} + +fn terminal_program_status_support( terminal: &TerminalInfo, stdout_is_terminal: bool, screen_active: bool, -) -> bool { - stdout_is_terminal +) -> TerminalProgramStatusSupport { + let osc_21337 = stdout_is_terminal && terminal.name == TerminalName::Iterm2 && terminal.multiplexer.is_none() - && !screen_active + && !screen_active; + TerminalProgramStatusSupport { + osc_7501: stdout_is_terminal, + osc_21337, + } } #[derive(Clone, Copy, Debug)] -struct SetItermSessionStatus<'a>(ItermSessionStatus, &'a str); +struct SetTerminalProgramStatus<'a> { + status: ProgramStatus, + iterm_detail: &'a str, + emit_iterm_status: bool, +} -impl Command for SetItermSessionStatus<'_> { +impl Command for SetTerminalProgramStatus<'_> { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + if self.emit_iterm_status { + write!( + f, + "\x1b]21337;status={};indicator={};status-color=;detail={}\x07", + self.status.iterm_label(), + self.status.iterm_indicator(), + self.iterm_detail, + )?; + } write!( f, - "\x1b]21337;status={};indicator={};status-color=;detail={}\x07", - self.0.label(), - self.0.indicator(), - self.1, + "\x1b]7501;state={}:app=codex\x1b\\", + self.status.protocol_state(), ) } #[cfg(windows)] fn execute_winapi(&self) -> io::Result<()> { Err(io::Error::other( - "tried to set iTerm2 session status using WinAPI; use ANSI instead", + "tried to set terminal program status using WinAPI; use ANSI instead", )) } @@ -192,17 +252,22 @@ impl Command for SetItermSessionStatus<'_> { } #[derive(Clone, Copy, Debug)] -struct ClearItermSessionStatus; +struct ClearTerminalProgramStatus { + emit_iterm_status: bool, +} -impl Command for ClearItermSessionStatus { +impl Command for ClearTerminalProgramStatus { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b]21337;status=;indicator=;status-color=;detail=\x07") + if self.emit_iterm_status { + write!(f, "\x1b]21337;status=;indicator=;status-color=;detail=\x07")?; + } + write!(f, "\x1b]7501;state=clear\x1b\\") } #[cfg(windows)] fn execute_winapi(&self) -> io::Result<()> { Err(io::Error::other( - "tried to clear iTerm2 session status using WinAPI; use ANSI instead", + "tried to clear terminal program status using WinAPI; use ANSI instead", )) } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 3ce906fc18..434ac1140b 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -408,7 +408,7 @@ pub fn restore_after_exit() -> Result<()> { if let Err(err) = terminal_stderr::finish() { first_error.get_or_insert(err); } - if let Err(err) = crate::iterm_session_status::clear_iterm_session_status() { + if let Err(err) = crate::terminal_program_status::clear_terminal_program_status() { first_error.get_or_insert(err); } @@ -976,6 +976,9 @@ impl Tui { if let Err(err) = terminal_stderr::pause() { tracing::warn!("failed to restore terminal stderr before external program: {err}"); } + if let Err(err) = crate::terminal_program_status::clear_terminal_program_status() { + tracing::debug!(error = %err, "failed to clear terminal program status before external program"); + } let output = f().await; @@ -999,7 +1002,7 @@ impl Tui { let _ = self.enter_alt_screen(); } - crate::iterm_session_status::invalidate_iterm_session_status(); + crate::terminal_program_status::invalidate_terminal_program_status(); self.resume_events(); self.schedule_screen_size_recheck(Duration::ZERO); output diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index 56d099cc0f..4c5a8e8331 100644 --- a/codex-rs/tui/src/tui/job_control.rs +++ b/codex-rs/tui/src/tui/job_control.rs @@ -69,11 +69,11 @@ impl SuspendContext { } let y = self.suspend_cursor_y.load(Ordering::Relaxed); let _ = execute!(stdout(), MoveTo(0, y), Show); - if let Err(err) = crate::iterm_session_status::clear_iterm_session_status() { - tracing::debug!(error = %err, "failed to clear iTerm2 session status before suspend"); + if let Err(err) = crate::terminal_program_status::clear_terminal_program_status() { + tracing::debug!(error = %err, "failed to clear terminal program status before suspend"); } let suspend_result = suspend_process(); - crate::iterm_session_status::invalidate_iterm_session_status(); + crate::terminal_program_status::invalidate_terminal_program_status(); suspend_result?; super::reapply_raw_mode_after_resume()?;