From c5630e0a4d0eaa62ea91de183bcb9e333f2c913f Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Thu, 16 Oct 2025 16:17:32 -0700 Subject: [PATCH 1/3] wip --- codex-rs/cli/src/main.rs | 2 +- codex-rs/tui/src/app.rs | 21 ++++- codex-rs/tui/src/history_cell.rs | 63 +++++++++++++++ codex-rs/tui/src/lib.rs | 124 +----------------------------- codex-rs/tui/src/update_prompt.rs | 2 +- codex-rs/tui/src/updates.rs | 69 ++++++++++++++++- 6 files changed, 152 insertions(+), 129 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 71fee4fd6b6..ef952d64dcc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -19,7 +19,7 @@ use codex_exec::Cli as ExecCli; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; -use codex_tui::UpdateAction; +use codex_tui::updates::UpdateAction; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6124f377ec9..4e82c5ccf1f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,4 +1,3 @@ -use crate::UpdateAction; use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -13,6 +12,7 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; +use crate::updates::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; @@ -38,7 +38,9 @@ use std::thread; use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; -// use uuid::Uuid; + +#[cfg(not(debug_assertions))] +use crate::history_cell::UpdateAvailableHistoryCell; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -79,6 +81,7 @@ pub(crate) struct App { } impl App { + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, @@ -141,6 +144,8 @@ impl App { }; let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + #[cfg(not(debug_assertions))] + let upgrade_version = crate::updates::get_upgrade_version(&config); let mut app = Self { server: conversation_manager, @@ -160,6 +165,18 @@ impl App { pending_update_action: None, }; + #[cfg(not(debug_assertions))] + if let Some(latest_version) = upgrade_version { + app.handle_event( + tui, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::updates::get_update_action(), + ))), + ) + .await?; + } + let tui_events = tui.event_stream(); tokio::pin!(tui_events); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fe37d5fa09d..24bf7cec7bc 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -16,6 +16,7 @@ use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; +use crate::updates::UpdateAction; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; @@ -270,6 +271,68 @@ impl HistoryCell for PlainHistoryCell { } } +#[cfg_attr(debug_assertions, allow(dead_code))] +#[derive(Debug)] +pub(crate) struct UpdateAvailableHistoryCell { + latest_version: String, + update_action: Option, +} + +#[cfg_attr(debug_assertions, allow(dead_code))] +impl UpdateAvailableHistoryCell { + pub(crate) fn new(latest_version: String, update_action: Option) -> Self { + Self { + latest_version, + update_action, + } + } +} + +impl HistoryCell for UpdateAvailableHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + use ratatui::style::Stylize as _; + use ratatui::text::Line; + + let update_instruction = if let Some(update_action) = self.update_action { + Line::from(vec![ + "Run ".into(), + update_action.command_str().cyan(), + " to update.".into(), + ]) + } else { + Line::from(vec![ + "See ".into(), + "https://github.com/openai/codex".cyan().underlined(), + " for installation options.".into(), + ]) + }; + + let current_version = env!("CARGO_PKG_VERSION"); + let content_lines: Vec> = vec![ + Line::from(vec![ + padded_emoji("✨").bold().cyan(), + "Update available!".bold().cyan(), + " ".into(), + format!("{current_version} -> {}", self.latest_version).bold(), + ]), + update_instruction, + Line::from(""), + Line::from("See full release notes:"), + Line::from( + "https://github.com/openai/codex/releases/latest" + .cyan() + .underlined(), + ), + ]; + + let line_max_width = content_lines.iter().map(Line::width).max().unwrap_or(0); + let inner_width = line_max_width + .min(usize::from(width.saturating_sub(4))) + .max(1); + with_border_with_inner_width(content_lines, inner_width) + } +} + #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { text: Text<'static>, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7447ef5feb8..9cca868928f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -68,45 +68,14 @@ mod text_formatting; mod tui; mod ui_consts; mod update_prompt; +pub mod updates; mod version; -/// Update action the CLI should perform after the TUI exits. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateAction { - /// Update via `npm install -g @openai/codex@latest`. - NpmGlobalLatest, - /// Update via `bun install -g @openai/codex@latest`. - BunGlobalLatest, - /// Update via `brew upgrade codex`. - BrewUpgrade, -} - -impl UpdateAction { - /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(&self) -> (&'static str, &'static [&'static str]) { - match self { - UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), - } - } - - /// Returns string representation of the command-line arguments for invoking the update. - pub fn command_str(&self) -> String { - let (command, args) = self.command_args(); - let args_str = args.join(" "); - format!("{command} {args_str}") - } -} - mod wrapping; #[cfg(test)] pub mod test_backend; -#[cfg(not(debug_assertions))] -mod updates; - use crate::onboarding::TrustDirectorySelection; use crate::onboarding::WSL_INSTRUCTIONS; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; @@ -304,56 +273,6 @@ async fn run_ratatui_app( } } - // Show update banner in terminal history (instead of stderr) so it is visible - // within the TUI scrollback. Building spans keeps styling consistent. - #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&initial_config) { - use crate::history_cell::padded_emoji; - use crate::history_cell::with_border_with_inner_width; - use ratatui::style::Stylize as _; - use ratatui::text::Line; - - let current_version = env!("CARGO_PKG_VERSION"); - - let mut content_lines: Vec> = vec![ - Line::from(vec![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ".into(), - format!("{current_version} -> {latest_version}.").bold(), - ]), - Line::from(""), - Line::from("See full release notes:"), - Line::from(""), - Line::from( - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ), - Line::from(""), - ]; - - if let Some(update_action) = get_update_action() { - content_lines.push(Line::from(vec![ - "Run ".into(), - update_action.command_str().cyan(), - " to update.".into(), - ])); - } else { - content_lines.push(Line::from(vec![ - "See ".into(), - "https://github.com/openai/codex".cyan().underlined(), - " for installation options.".into(), - ])); - } - - let viewport_width = tui.terminal.viewport_area.width as usize; - let inner_width = viewport_width.saturating_sub(4).max(1); - let mut lines = with_border_with_inner_width(content_lines, inner_width); - lines.push("".into()); - tui.insert_history_lines(lines); - } - // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); @@ -472,47 +391,6 @@ async fn run_ratatui_app( app_result } -/// Get the update action from the environment. -/// Returns `None` if not managed by npm, bun, or brew. -#[cfg(not(debug_assertions))] -pub(crate) fn get_update_action() -> Option { - let exe = std::env::current_exe().unwrap_or_default(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - if managed_by_npm { - Some(UpdateAction::NpmGlobalLatest) - } else if managed_by_bun { - Some(UpdateAction::BunGlobalLatest) - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - Some(UpdateAction::BrewUpgrade) - } else { - None - } -} - -#[test] -#[cfg(not(debug_assertions))] -fn test_get_update_action() { - let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); - - // First: no npm var -> expect None (we do not run from brew in CI) - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - assert_eq!(get_update_action(), None); - - // Then: with npm var -> expect NpmGlobalLatest - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; - assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); - - // Restore prior value to avoid leaking state - if let Some(v) = prev { - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; - } else { - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - } -} - #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 5da8462684d..d7719e970ae 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -39,7 +39,7 @@ pub(crate) async fn run_update_prompt_if_needed( let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else { return Ok(UpdatePromptOutcome::Continue); }; - let Some(update_action) = crate::get_update_action() else { + let Some(update_action) = crate::updates::get_update_action() else { return Ok(UpdatePromptOutcome::Continue); }; diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index ca004a36dfb..fe859e15f2d 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -1,5 +1,3 @@ -#![cfg(any(not(debug_assertions), test))] - use chrono::DateTime; use chrono::Duration; use chrono::Utc; @@ -142,6 +140,53 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> { Some((maj, min, pat)) } +/// Update action the CLI should perform after the TUI exits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// Update via `npm install -g @openai/codex@latest`. + NpmGlobalLatest, + /// Update via `bun install -g @openai/codex@latest`. + BunGlobalLatest, + /// Update via `brew upgrade codex`. + BrewUpgrade, +} + +#[cfg(any(not(debug_assertions), test))] +pub(crate) fn get_update_action() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + if managed_by_npm { + Some(UpdateAction::NpmGlobalLatest) + } else if managed_by_bun { + Some(UpdateAction::BunGlobalLatest) + } else if cfg!(target_os = "macos") + && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) + { + Some(UpdateAction::BrewUpgrade) + } else { + None + } +} + +impl UpdateAction { + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(self) -> (&'static str, &'static [&'static str]) { + match self { + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + } + } + + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(self) -> String { + let (command, args) = self.command_args(); + let args_str = args.join(" "); + format!("{command} {args_str}") + } +} + #[cfg(test)] mod tests { use super::*; @@ -165,4 +210,24 @@ mod tests { assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); } + + #[test] + fn test_get_update_action() { + let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); + + // First: no npm var -> expect None (we do not run from brew in CI) + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + assert_eq!(get_update_action(), None); + + // Then: with npm var -> expect NpmGlobalLatest + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; + assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); + + // Restore prior value to avoid leaking state + if let Some(v) = prev { + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; + } else { + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + } + } } From 35aa33355437998aabb247dade49f84f0424689c Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Thu, 16 Oct 2025 16:26:08 -0700 Subject: [PATCH 2/3] fix update_prompt --- codex-rs/tui/src/update_prompt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index d7719e970ae..d505385b9ed 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -1,6 +1,5 @@ #![cfg(not(debug_assertions))] -use crate::UpdateAction; use crate::history_cell::padded_emoji; use crate::key_hint; use crate::render::Insets; @@ -12,6 +11,7 @@ use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use crate::updates; +use crate::updates::UpdateAction; use codex_core::config::Config; use color_eyre::Result; use crossterm::event::KeyCode; From 67d7200673d5de39288454d436eea9e3c89928e2 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 20 Oct 2025 14:14:35 -0700 Subject: [PATCH 3/3] use ratatui-macros --- codex-rs/Cargo.lock | 10 +++++++ codex-rs/Cargo.toml | 3 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/history_cell.rs | 49 ++++++++++++++------------------ 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 70514dd5da7..484fef46466 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1411,6 +1411,7 @@ dependencies = [ "pulldown-cmark", "rand 0.9.2", "ratatui", + "ratatui-macros", "regex-lite", "serde", "serde_json", @@ -4692,6 +4693,15 @@ dependencies = [ "unicode-width 0.2.1", ] +[[package]] +name = "ratatui-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fef540f80dbe8a0773266fa6077788ceb65ef624cdbf36e131aaf90b4a52df4" +dependencies = [ + "ratatui", +] + [[package]] name = "redox_syscall" version = "0.5.15" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1c1640be3fd..b0614c9ef2c 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -83,8 +83,8 @@ ansi-to-tui = "7.0.0" anyhow = "1" arboard = "3" askama = "0.12" -assert_matches = "1.5.0" assert_cmd = "2" +assert_matches = "1.5.0" async-channel = "2.3.1" async-stream = "0.3.6" async-trait = "0.1.89" @@ -142,6 +142,7 @@ pretty_assertions = "1.4.1" pulldown-cmark = "0.10" rand = "0.9" ratatui = "0.29.0" +ratatui-macros = "0.6.0" regex-lite = "0.1.7" reqwest = "0.12" rmcp = { version = "0.8.0", default-features = false } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1ba3f0867b6..288bc933780 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -59,6 +59,7 @@ ratatui = { workspace = true, features = [ "unstable-rendered-line-info", "unstable-widget-ref", ] } +ratatui-macros = { workspace = true } regex-lite = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 24bf7cec7bc..18b965eff9b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -17,6 +17,7 @@ use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::updates::UpdateAction; +use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; @@ -290,46 +291,38 @@ impl UpdateAvailableHistoryCell { impl HistoryCell for UpdateAvailableHistoryCell { fn display_lines(&self, width: u16) -> Vec> { - use ratatui::style::Stylize as _; - use ratatui::text::Line; - + use ratatui_macros::line; + use ratatui_macros::text; let update_instruction = if let Some(update_action) = self.update_action { - Line::from(vec![ - "Run ".into(), - update_action.command_str().cyan(), - " to update.".into(), - ]) + line!["Run ", update_action.command_str().cyan(), " to update."] } else { - Line::from(vec![ - "See ".into(), + line![ + "See ", "https://github.com/openai/codex".cyan().underlined(), - " for installation options.".into(), - ]) + " for installation options." + ] }; - let current_version = env!("CARGO_PKG_VERSION"); - let content_lines: Vec> = vec![ - Line::from(vec![ + let content = text![ + line![ padded_emoji("✨").bold().cyan(), "Update available!".bold().cyan(), - " ".into(), - format!("{current_version} -> {}", self.latest_version).bold(), - ]), + " ", + format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), + ], update_instruction, - Line::from(""), - Line::from("See full release notes:"), - Line::from( - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ), + "", + "See full release notes:", + "https://github.com/openai/codex/releases/latest" + .cyan() + .underlined(), ]; - let line_max_width = content_lines.iter().map(Line::width).max().unwrap_or(0); - let inner_width = line_max_width + let inner_width = content + .width() .min(usize::from(width.saturating_sub(4))) .max(1); - with_border_with_inner_width(content_lines, inner_width) + with_border_with_inner_width(content.lines, inner_width) } }