diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 81b795b0a1..71fee4fd6b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -19,6 +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 owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; @@ -208,6 +209,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec anyhow::Result<()> { + let update_action = exit_info.update_action; let color_enabled = supports_color::on(Stream::Stdout).is_some(); for line in format_exit_messages(exit_info, color_enabled) { println!("{line}"); } + if let Some(action) = update_action { + run_update_action(action)?; + } + Ok(()) +} + +/// Run the update action and print the result. +fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { + println!(); + let (cmd, args) = action.command_args(); + let cmd_str = action.command_str(); + println!("Updating Codex via `{cmd_str}`..."); + let status = std::process::Command::new(cmd).args(args).status()?; + if !status.success() { + anyhow::bail!("`{cmd_str}` failed with status {status}"); + } + println!(); + println!("🎉 Update ran successfully! Please restart Codex."); + Ok(()) } #[derive(Debug, Default, Parser, Clone)] @@ -321,7 +344,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - print_exit_messages(exit_info); + handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags( @@ -354,7 +377,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() config_overrides, ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - print_exit_messages(exit_info); + handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags( @@ -595,6 +618,7 @@ mod tests { conversation_id: conversation .map(ConversationId::from_string) .map(Result::unwrap), + update_action: None, } } @@ -603,6 +627,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + update_action: None, }; let lines = format_exit_messages(exit_info, false); assert!(lines.is_empty()); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e60..693051f985 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,3 +1,4 @@ +use crate::UpdateAction; use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -43,6 +44,7 @@ use tokio::sync::mpsc::unbounded_channel; pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + pub update_action: Option, } pub(crate) struct App { @@ -71,6 +73,9 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + + /// Set when the user confirms an update; propagated on exit. + pub(crate) pending_update_action: Option, } impl App { @@ -152,6 +157,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + pending_update_action: None, }; let tui_events = tui.event_stream(); @@ -171,6 +177,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), + update_action: app.pending_update_action, }) } @@ -520,6 +527,7 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + pending_update_action: None, } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap new file mode 100644 index 0000000000..6a49cb253c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + ✨ New version available! Would you like to update? + + Full release notes: https://github.com/openai/codex/releases/latest + + +› 1. Yes, update now + 2. No, not now + 3. Don't remind me + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7d7412e0a4..01b1bd976a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -59,6 +59,7 @@ mod pager_overlay; pub mod public_widgets; mod render; mod resume_picker; +mod selection_list; mod session_log; mod shimmer; mod slash_command; @@ -70,7 +71,38 @@ mod terminal_palette; mod text_formatting; mod tui; mod ui_consts; +mod update_prompt; 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)] @@ -299,6 +331,26 @@ async fn run_ratatui_app( let mut tui = Tui::new(terminal); + #[cfg(not(debug_assertions))] + { + use crate::update_prompt::UpdatePromptOutcome; + + let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); + if !skip_update_prompt { + match update_prompt::run_update_prompt_if_needed(&mut tui, &config).await? { + UpdatePromptOutcome::Continue => {} + UpdatePromptOutcome::RunUpdate(action) => { + crate::tui::restore()?; + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: Some(action), + }); + } + } + } + } + // 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))] @@ -309,9 +361,6 @@ async fn run_ratatui_app( use ratatui::text::Line; let current_version = env!("CARGO_PKG_VERSION"); - let exe = std::env::current_exe()?; - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); let mut content_lines: Vec> = vec![ Line::from(vec![ @@ -331,27 +380,10 @@ async fn run_ratatui_app( Line::from(""), ]; - if managed_by_bun { - let bun_cmd = "bun install -g @openai/codex@latest"; - content_lines.push(Line::from(vec![ - "Run ".into(), - bun_cmd.cyan(), - " to update.".into(), - ])); - } else if managed_by_npm { - let npm_cmd = "npm install -g @openai/codex@latest"; + if let Some(update_action) = get_update_action() { content_lines.push(Line::from(vec![ "Run ".into(), - npm_cmd.cyan(), - " to update.".into(), - ])); - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - let brew_cmd = "brew upgrade codex"; - content_lines.push(Line::from(vec![ - "Run ".into(), - brew_cmd.cyan(), + update_action.command_str().cyan(), " to update.".into(), ])); } else { @@ -405,6 +437,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + update_action: None, }); } if should_show_windows_wsl_screen { @@ -449,6 +482,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + update_action: None, }); } other => other, @@ -477,6 +511,47 @@ 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/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 3324c3d866..8a7541c022 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -7,8 +7,6 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Style; -use ratatui::style::Styled as _; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -22,11 +20,9 @@ use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; -use crate::render::renderable::RowRenderable; +use crate::selection_list::selection_option_row; use super::onboarding_screen::StepState; -use unicode_width::UnicodeWidthStr; - pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, pub cwd: PathBuf, @@ -88,7 +84,7 @@ impl WidgetRef for &TrustDirectoryWidget { } for (idx, (text, selection)) in options.iter().enumerate() { - column.push(new_option_row( + column.push(selection_option_row( idx, text.to_string(), self.highlighted == *selection, @@ -120,30 +116,6 @@ impl WidgetRef for &TrustDirectoryWidget { } } -fn new_option_row(index: usize, label: String, is_selected: bool) -> Box { - let prefix = if is_selected { - format!("› {}. ", index + 1) - } else { - format!(" {}. ", index + 1) - }; - - let mut style = Style::default(); - if is_selected { - style = style.cyan(); - } - - let mut row = RowRenderable::new(); - row.push(prefix.width() as u16, prefix.set_style(style)); - row.push( - u16::MAX, - Paragraph::new(label) - .style(style) - .wrap(Wrap { trim: false }), - ); - - row.into() -} - impl KeyboardHandler for TrustDirectoryWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { diff --git a/codex-rs/tui/src/selection_list.rs b/codex-rs/tui/src/selection_list.rs new file mode 100644 index 0000000000..4816735437 --- /dev/null +++ b/codex-rs/tui/src/selection_list.rs @@ -0,0 +1,35 @@ +use crate::render::renderable::Renderable; +use crate::render::renderable::RowRenderable; +use ratatui::style::Style; +use ratatui::style::Styled as _; +use ratatui::style::Stylize as _; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthStr; + +pub(crate) fn selection_option_row( + index: usize, + label: String, + is_selected: bool, +) -> Box { + let prefix = if is_selected { + format!("› {}. ", index + 1) + } else { + format!(" {}. ", index + 1) + }; + let style = if is_selected { + Style::default().cyan() + } else { + Style::default() + }; + let prefix_width = UnicodeWidthStr::width(prefix.as_str()) as u16; + let mut row = RowRenderable::new(); + row.push(prefix_width, prefix.set_style(style)); + row.push( + u16::MAX, + Paragraph::new(label) + .style(style) + .wrap(Wrap { trim: false }), + ); + row.into() +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap new file mode 100644 index 0000000000..24d8831c95 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/update_prompt.rs +expression: terminal.backend() +--- + ✨ Update available! 0.0.0 -> 9.9.9 + + Release notes: https://github.com/openai/codex/releases/latest + +› 1. Update now (runs `npm install -g @openai/codex@latest`) + 2. Skip + 3. Skip until next version + + Press enter to continue diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs new file mode 100644 index 0000000000..5da8462684 --- /dev/null +++ b/codex-rs/tui/src/update_prompt.rs @@ -0,0 +1,313 @@ +#![cfg(not(debug_assertions))] + +use crate::UpdateAction; +use crate::history_cell::padded_emoji; +use crate::key_hint; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt as _; +use crate::selection_list::selection_option_row; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use crate::updates; +use codex_core::config::Config; +use color_eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::WidgetRef; +use tokio_stream::StreamExt; + +pub(crate) enum UpdatePromptOutcome { + Continue, + RunUpdate(UpdateAction), +} + +pub(crate) async fn run_update_prompt_if_needed( + tui: &mut Tui, + config: &Config, +) -> Result { + 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 { + return Ok(UpdatePromptOutcome::Continue); + }; + + let mut screen = + UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action); + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + + let events = tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + } + } + } else { + break; + } + } + + match screen.selection() { + Some(UpdateSelection::UpdateNow) => { + tui.terminal.clear()?; + Ok(UpdatePromptOutcome::RunUpdate(update_action)) + } + Some(UpdateSelection::NotNow) | None => Ok(UpdatePromptOutcome::Continue), + Some(UpdateSelection::DontRemind) => { + if let Err(err) = updates::dismiss_version(config, screen.latest_version()).await { + tracing::error!("Failed to persist update dismissal: {err}"); + } + Ok(UpdatePromptOutcome::Continue) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UpdateSelection { + UpdateNow, + NotNow, + DontRemind, +} + +struct UpdatePromptScreen { + request_frame: FrameRequester, + latest_version: String, + current_version: String, + update_action: UpdateAction, + highlighted: UpdateSelection, + selection: Option, +} + +impl UpdatePromptScreen { + fn new( + request_frame: FrameRequester, + latest_version: String, + update_action: UpdateAction, + ) -> Self { + Self { + request_frame, + latest_version, + current_version: env!("CARGO_PKG_VERSION").to_string(), + update_action, + highlighted: UpdateSelection::UpdateNow, + selection: None, + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + self.select(UpdateSelection::NotNow); + return; + } + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()), + KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()), + KeyCode::Char('1') => self.select(UpdateSelection::UpdateNow), + KeyCode::Char('2') => self.select(UpdateSelection::NotNow), + KeyCode::Char('3') => self.select(UpdateSelection::DontRemind), + KeyCode::Enter => self.select(self.highlighted), + KeyCode::Esc => self.select(UpdateSelection::NotNow), + _ => {} + } + } + + fn set_highlight(&mut self, highlight: UpdateSelection) { + if self.highlighted != highlight { + self.highlighted = highlight; + self.request_frame.schedule_frame(); + } + } + + fn select(&mut self, selection: UpdateSelection) { + self.highlighted = selection; + self.selection = Some(selection); + self.request_frame.schedule_frame(); + } + + fn is_done(&self) -> bool { + self.selection.is_some() + } + + fn selection(&self) -> Option { + self.selection + } + + fn latest_version(&self) -> &str { + self.latest_version.as_str() + } +} + +impl UpdateSelection { + fn next(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::NotNow, + UpdateSelection::NotNow => UpdateSelection::DontRemind, + UpdateSelection::DontRemind => UpdateSelection::UpdateNow, + } + } + + fn prev(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::DontRemind, + UpdateSelection::NotNow => UpdateSelection::UpdateNow, + UpdateSelection::DontRemind => UpdateSelection::NotNow, + } + } +} + +impl WidgetRef for &UpdatePromptScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let mut column = ColumnRenderable::new(); + + let update_command = self.update_action.command_str(); + + column.push(""); + column.push(Line::from(vec![ + padded_emoji(" ✨").bold().cyan(), + "Update available!".bold(), + " ".into(), + format!( + "{current} -> {latest}", + current = self.current_version, + latest = self.latest_version + ) + .dim(), + ])); + column.push(""); + column.push( + Line::from(vec![ + "Release notes: ".dim(), + "https://github.com/openai/codex/releases/latest" + .dim() + .underlined(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + column.push(selection_option_row( + 0, + format!("Update now (runs `{update_command}`)"), + self.highlighted == UpdateSelection::UpdateNow, + )); + column.push(selection_option_row( + 1, + "Skip".to_string(), + self.highlighted == UpdateSelection::NotNow, + )); + column.push(selection_option_row( + 2, + "Skip until next version".to_string(), + self.highlighted == UpdateSelection::DontRemind, + )); + column.push(""); + column.push( + Line::from(vec![ + "Press ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " to continue".dim(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_backend::VT100Backend; + use crate::tui::FrameRequester; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + + fn new_prompt() -> UpdatePromptScreen { + UpdatePromptScreen::new( + FrameRequester::test_dummy(), + "9.9.9".into(), + UpdateAction::NpmGlobalLatest, + ) + } + + #[test] + fn update_prompt_snapshot() { + let screen = new_prompt(); + let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render update prompt"); + insta::assert_snapshot!("update_prompt_modal", terminal.backend()); + } + + #[test] + fn update_prompt_confirm_selects_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::UpdateNow)); + } + + #[test] + fn update_prompt_dismiss_option_leaves_prompt_in_normal_state() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn update_prompt_dont_remind_selects_dismissal() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::DontRemind)); + } + + #[test] + fn update_prompt_ctrl_c_skips_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn update_prompt_navigation_wraps_between_entries() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::DontRemind); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::UpdateNow); + } +} diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 7344e24c78..ca004a36df 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -45,6 +45,8 @@ struct VersionInfo { latest_version: String, // ISO-8601 timestamp (RFC3339) last_checked_at: DateTime, + #[serde(default)] + dismissed_version: Option, } #[derive(Deserialize, Debug, Clone)] @@ -75,12 +77,15 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { .json::() .await?; + // Preserve any previously dismissed version if present. + let prev_info = read_version_info(version_file).ok(); let info = VersionInfo { latest_version: latest_tag_name .strip_prefix("rust-v") .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))? .into(), last_checked_at: Utc::now(), + dismissed_version: prev_info.and_then(|p| p.dismissed_version), }; let json_line = format!("{}\n", serde_json::to_string(&info)?); @@ -98,6 +103,37 @@ fn is_newer(latest: &str, current: &str) -> Option { } } +/// Returns the latest version to show in a popup, if it should be shown. +/// This respects the user's dismissal choice for the current latest version. +pub fn get_upgrade_version_for_popup(config: &Config) -> Option { + let version_file = version_filepath(config); + let latest = get_upgrade_version(config)?; + // If the user dismissed this exact version previously, do not show the popup. + if let Ok(info) = read_version_info(&version_file) + && info.dismissed_version.as_deref() == Some(latest.as_str()) + { + return None; + } + Some(latest) +} + +/// Persist a dismissal for the current latest version so we don't show +/// the update popup again for this version. +pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> { + let version_file = version_filepath(config); + let mut info = match read_version_info(&version_file) { + Ok(info) => info, + Err(_) => return Ok(()), + }; + info.dismissed_version = Some(version.to_string()); + let json_line = format!("{}\n", serde_json::to_string(&info)?); + if let Some(parent) = version_file.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(version_file, json_line).await?; + Ok(()) +} + fn parse_version(v: &str) -> Option<(u64, u64, u64)> { let mut iter = v.trim().split('.'); let maj = iter.next()?.parse::().ok()?;