Compare commits

...

3 Commits

Author SHA1 Message Date
Felipe Coury
1c6e06381f feat(tui): add guided update remediation flows 2026-05-09 16:55:03 -03:00
Felipe Coury
f12a36c166 fix(tui): annotate update prompt literal arguments 2026-05-09 16:55:02 -03:00
Felipe Coury
d0a905a557 fix(tui): avoid update loops for mismatched npm installs 2026-05-09 16:55:02 -03:00
11 changed files with 864 additions and 58 deletions

View File

@@ -2,7 +2,7 @@
// Unified entry point for the Codex CLI.
import { spawn } from "node:child_process";
import { existsSync } from "fs";
import { existsSync, realpathSync } from "fs";
import { createRequire } from "node:module";
import path from "path";
import { fileURLToPath } from "url";
@@ -171,6 +171,12 @@ const packageManagerEnvVar =
? "CODEX_MANAGED_BY_BUN"
: "CODEX_MANAGED_BY_NPM";
env[packageManagerEnvVar] = "1";
try {
env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, ".."));
} catch {
// Best effort only. Older or unusual package layouts can omit this extra
// provenance without preventing Codex from starting.
}
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",

View File

@@ -34,7 +34,10 @@ use codex_state::state_db_path;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
use codex_tui::ExitReason;
use codex_tui::PromptedUpdate;
use codex_tui::UpdateAction;
#[cfg(not(debug_assertions))]
use codex_tui::UpdateActionStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use owo_colors::OwoColorize;
@@ -618,13 +621,13 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
ExitReason::UserRequested => { /* normal exit */ }
}
let update_action = exit_info.update_action;
let prompted_update = exit_info.update_action.clone();
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)?;
if let Some(prompted_update) = prompted_update {
run_prompted_update(prompted_update)?;
}
Ok(())
}
@@ -672,6 +675,20 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
Ok(())
}
fn run_prompted_update(prompted_update: PromptedUpdate) -> anyhow::Result<()> {
run_update_action(prompted_update.action)?;
#[cfg(not(debug_assertions))]
{
if let Err(err) = codex_tui::record_successful_prompt_update_attempt(
&prompted_update.version_file,
&prompted_update.target_version,
) {
tracing::warn!("Failed to record successful prompted update attempt: {err}");
}
}
Ok(())
}
fn run_update_command() -> anyhow::Result<()> {
#[cfg(debug_assertions)]
{
@@ -682,12 +699,13 @@ fn run_update_command() -> anyhow::Result<()> {
#[cfg(not(debug_assertions))]
{
let Some(action) = codex_tui::get_update_action() else {
anyhow::bail!(
match codex_tui::get_update_action_status() {
UpdateActionStatus::Ready(action) => run_update_action(action),
UpdateActionStatus::Blocked(blocker) => anyhow::bail!("{blocker}"),
UpdateActionStatus::Unavailable => anyhow::bail!(
"Could not detect the Codex installation method. Please update manually: https://developers.openai.com/codex/cli/"
);
};
run_update_action(action)
),
}
}
}

View File

@@ -40,6 +40,8 @@ use crate::history_cell;
use crate::history_cell::HistoryCell;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateRemediationWarningHistoryCell;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::Config;
@@ -73,7 +75,9 @@ use crate::token_usage::TokenUsage;
use crate::transcript_reflow::TranscriptReflowState;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::update_action::PromptedUpdate;
#[cfg(not(debug_assertions))]
use crate::updates::UpgradeHistoryNotice;
use crate::version::CODEX_CLI_VERSION;
use crate::workspace_command::AppServerWorkspaceCommandRunner;
use crate::workspace_command::WorkspaceCommandRunner;
@@ -335,7 +339,7 @@ pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub thread_id: Option<ThreadId>,
pub thread_name: Option<String>,
pub update_action: Option<UpdateAction>,
pub update_action: Option<PromptedUpdate>,
pub exit_reason: ExitReason,
}
@@ -478,7 +482,7 @@ pub(crate) struct App {
remote_app_server_url: Option<String>,
remote_app_server_auth_token: Option<String>,
/// Set when the user confirms an update; propagated on exit.
pub(crate) pending_update_action: Option<UpdateAction>,
pub(crate) pending_update_action: Option<PromptedUpdate>,
/// Tracks the thread we intentionally shut down while exiting the app.
///
@@ -863,7 +867,13 @@ See the Codex keymap documentation for supported actions and examples."
)
})?;
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let upgrade_notice = crate::updates::get_upgrade_notice_for_history(
&config,
initial_prompt
.as_ref()
.is_some_and(|prompt| !prompt.is_empty()),
crate::update_action::get_update_action_status(),
);
let mut app = Self {
model_catalog,
@@ -968,16 +978,24 @@ See the Codex keymap documentation for supported actions and examples."
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
#[cfg(not(debug_assertions))]
let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version {
let pre_loop_exit_reason = if let Some(upgrade_notice) = upgrade_notice {
let cell: Box<dyn HistoryCell> = match upgrade_notice {
UpgradeHistoryNotice::Available {
latest_version,
update_action,
} => Box::new(UpdateAvailableHistoryCell::new(
latest_version,
update_action,
)),
UpgradeHistoryNotice::BlockedWarning(blocker) => {
Box::new(UpdateRemediationWarningHistoryCell::Blocked(blocker))
}
UpgradeHistoryNotice::NoOpUpdateWarning { latest_version } => {
Box::new(UpdateRemediationWarningHistoryCell::NoOpUpdate { latest_version })
}
};
let control = app
.handle_event(
tui,
&mut app_server,
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
latest_version,
crate::update_action::get_update_action(),
))),
)
.handle_event(tui, &mut app_server, AppEvent::InsertHistoryCell(cell))
.await?;
match control {
AppRunControl::Continue => None,
@@ -1078,7 +1096,7 @@ See the Codex keymap documentation for supported actions and examples."
token_usage: app.token_usage(),
thread_id: resumable_thread.as_ref().map(|thread| thread.thread_id),
thread_name: resumable_thread.and_then(|thread| thread.thread_name),
update_action: app.pending_update_action,
update_action: app.pending_update_action.clone(),
exit_reason,
})
}

View File

@@ -41,6 +41,7 @@ use crate::text_formatting::truncate_text;
use crate::tooltips;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::update_action::UpdateAction;
use crate::update_action::UpdateBlocker;
use crate::version::CODEX_CLI_VERSION;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
@@ -656,6 +657,40 @@ pub(crate) struct UpdateAvailableHistoryCell {
update_action: Option<UpdateAction>,
}
#[cfg_attr(debug_assertions, allow(dead_code))]
#[derive(Debug)]
pub(crate) enum UpdateRemediationWarningHistoryCell {
Blocked(UpdateBlocker),
NoOpUpdate { latest_version: String },
}
impl HistoryCell for UpdateRemediationWarningHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.raw_lines()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
match self {
Self::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
running_package_root,
npm_package_root,
}) => vec![Line::from(format!(
"Warning: Codex is running from {}, but npm would update {}.",
running_package_root.display(),
npm_package_root.display(),
))],
Self::NoOpUpdate { latest_version } => vec![
Line::from(
"Warning: The previous update completed, but this Codex executable did not change.",
),
Line::from(format!(
"Codex is still running {CODEX_CLI_VERSION} while {latest_version} is available."
)),
],
}
}
}
#[cfg_attr(debug_assertions, allow(dead_code))]
impl UpdateAvailableHistoryCell {
pub(crate) fn new(latest_version: String, update_action: Option<UpdateAction>) -> Self {

View File

@@ -177,13 +177,20 @@ mod transcript_reflow;
mod tui;
mod ui_consts;
pub(crate) mod update_action;
pub use update_action::PromptedUpdate;
pub use update_action::UpdateAction;
pub use update_action::UpdateActionStatus;
pub use update_action::UpdateBlocker;
#[cfg(not(debug_assertions))]
pub use update_action::get_update_action;
#[cfg(any(not(debug_assertions), test))]
pub use update_action::get_update_action_status;
mod update_prompt;
#[cfg(any(not(debug_assertions), test))]
mod update_versions;
mod updates;
#[cfg(not(debug_assertions))]
pub use updates::record_successful_prompt_update_attempt;
mod version;
#[cfg(not(target_os = "linux"))]
mod voice;
@@ -1094,8 +1101,9 @@ pub async fn run_main(
}
#[allow(clippy::too_many_arguments)]
#[allow(unused_mut)]
async fn run_ratatui_app(
cli: Cli,
mut cli: Cli,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
app_server_target: AppServerTarget,
@@ -1149,6 +1157,9 @@ async fn run_ratatui_app(
exit_reason: ExitReason::UserRequested,
});
}
UpdatePromptOutcome::StartRepairSession(prompt) => {
cli.prompt = Some(prompt);
}
}
}
}

View File

@@ -1,7 +1,8 @@
use serde::Deserialize;
use std::collections::HashMap;
#[cfg(not(debug_assertions))]
#[cfg(any(not(debug_assertions), test))]
#[cfg_attr(test, allow(dead_code))]
pub(crate) const PACKAGE_URL: &str = "https://registry.npmjs.org/@openai%2fcodex";
#[derive(Deserialize, Debug, Clone)]

View File

@@ -2,6 +2,7 @@
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

View File

@@ -0,0 +1,17 @@
---
source: tui/src/update_prompt.rs
assertion_line: 592
expression: terminal.backend()
---
Update needs attention
You are running Codex from:
/prefix-a/lib/node_modules/@openai/codex
but `npm install -g @openai/codex@latest` would update:
/prefix-b/lib/node_modules/@openai/codex
Fix your shell PATH or remove the stale Codex install, then restart Codex.
1. Help me fix this
2. Later
3. Don't remind me about this version

View File

@@ -2,6 +2,15 @@
use codex_install_context::InstallContext;
#[cfg(any(not(debug_assertions), test))]
use codex_install_context::StandalonePlatform;
use std::fmt;
#[cfg(any(not(debug_assertions), test))]
use std::path::Path;
use std::path::PathBuf;
#[cfg(any(not(debug_assertions), test))]
use std::process::Command;
#[cfg(any(not(debug_assertions), test))]
const MANAGED_PACKAGE_ROOT_ENV: &str = "CODEX_MANAGED_PACKAGE_ROOT";
/// Update action the CLI should perform after the TUI exits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -18,6 +27,62 @@ pub enum UpdateAction {
StandaloneWindows,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateActionStatus {
Ready(UpdateAction),
Blocked(UpdateBlocker),
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateBlocker {
NpmGlobalRootMismatch {
running_package_root: PathBuf,
npm_package_root: PathBuf,
},
}
#[derive(Debug, Clone)]
pub struct PromptedUpdate {
pub action: UpdateAction,
pub target_version: String,
pub version_file: PathBuf,
}
impl UpdateBlocker {
pub fn remediation_lines(&self) -> Vec<String> {
match self {
Self::NpmGlobalRootMismatch {
running_package_root,
npm_package_root,
} => vec![
"You are running Codex from:".to_string(),
format!(" {}", running_package_root.display()),
"but `npm install -g @openai/codex@latest` would update:".to_string(),
format!(" {}", npm_package_root.display()),
"Fix your shell PATH or remove the stale Codex install, then restart Codex."
.to_string(),
],
}
}
}
impl fmt::Display for UpdateBlocker {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NpmGlobalRootMismatch {
running_package_root,
npm_package_root,
} => write!(
f,
"You are running Codex from {}, but `npm install -g @openai/codex@latest` would update {}. Fix your shell PATH or remove the stale Codex install, then restart Codex.",
running_package_root.display(),
npm_package_root.display(),
),
}
}
}
impl UpdateAction {
#[cfg(any(not(debug_assertions), test))]
pub(crate) fn from_install_context(context: &InstallContext) -> Option<Self> {
@@ -36,8 +101,8 @@ 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"]),
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]),
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]),
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]),
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]),
UpdateAction::StandaloneUnix => (
"sh",
@@ -58,11 +123,79 @@ impl UpdateAction {
}
}
#[cfg(not(debug_assertions))]
#[cfg(any(not(debug_assertions), test))]
#[cfg_attr(test, allow(dead_code))]
pub fn get_update_action() -> Option<UpdateAction> {
UpdateAction::from_install_context(InstallContext::current())
}
#[cfg(any(not(debug_assertions), test))]
pub fn get_update_action_status() -> UpdateActionStatus {
let Some(action) = UpdateAction::from_install_context(InstallContext::current()) else {
return UpdateActionStatus::Unavailable;
};
if let Some(blocker) = update_blocker(action) {
UpdateActionStatus::Blocked(blocker)
} else {
UpdateActionStatus::Ready(action)
}
}
#[cfg(any(not(debug_assertions), test))]
fn update_blocker(action: UpdateAction) -> Option<UpdateBlocker> {
match action {
UpdateAction::NpmGlobalLatest => npm_global_root_mismatch(),
UpdateAction::BunGlobalLatest
| UpdateAction::BrewUpgrade
| UpdateAction::StandaloneUnix
| UpdateAction::StandaloneWindows => None,
}
}
#[cfg(any(not(debug_assertions), test))]
fn npm_global_root_mismatch() -> Option<UpdateBlocker> {
let running_package_root = std::env::var_os(MANAGED_PACKAGE_ROOT_ENV)?;
let running_package_root = std::fs::canonicalize(PathBuf::from(running_package_root)).ok()?;
let npm_global_root = npm_global_root()?;
mismatch_from_npm_roots(&running_package_root, &npm_global_root)
}
#[cfg(any(not(debug_assertions), test))]
fn npm_global_root() -> Option<PathBuf> {
#[cfg(windows)]
let output = Command::new("cmd")
.args(["/C", "npm", "root", "-g"])
.output()
.ok()?;
#[cfg(not(windows))]
let output = Command::new("npm").args(["root", "-g"]).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let npm_global_root = stdout.trim();
if npm_global_root.is_empty() {
return None;
}
std::fs::canonicalize(npm_global_root).ok()
}
#[cfg(any(not(debug_assertions), test))]
fn mismatch_from_npm_roots(
running_package_root: &Path,
npm_global_root: &Path,
) -> Option<UpdateBlocker> {
let npm_package_root = npm_global_root.join("@openai").join("codex");
(running_package_root != npm_package_root.as_path()).then(|| {
UpdateBlocker::NpmGlobalRootMismatch {
running_package_root: running_package_root.to_path_buf(),
npm_package_root,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -124,4 +257,29 @@ mod tests {
)
);
}
#[test]
fn npm_root_mismatch_is_blocked_when_update_targets_another_install() {
assert_eq!(
mismatch_from_npm_roots(
Path::new("/prefix-a/lib/node_modules/@openai/codex"),
Path::new("/prefix-b/lib/node_modules"),
),
Some(UpdateBlocker::NpmGlobalRootMismatch {
running_package_root: PathBuf::from("/prefix-a/lib/node_modules/@openai/codex"),
npm_package_root: PathBuf::from("/prefix-b/lib/node_modules/@openai/codex"),
})
);
}
#[test]
fn npm_root_match_keeps_update_available() {
assert_eq!(
mismatch_from_npm_roots(
Path::new("/prefix-a/lib/node_modules/@openai/codex"),
Path::new("/prefix-a/lib/node_modules"),
),
None
);
}
}

View File

@@ -1,4 +1,5 @@
#![cfg(not(debug_assertions))]
#![cfg(any(not(debug_assertions), test))]
#![cfg_attr(test, allow(dead_code))]
use crate::history_cell::padded_emoji;
use crate::key_hint;
@@ -11,8 +12,12 @@ use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crate::update_action::PromptedUpdate;
use crate::update_action::UpdateAction;
use crate::update_action::UpdateActionStatus;
use crate::update_action::UpdateBlocker;
use crate::updates;
use crate::updates::UpgradeNotice;
use color_eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -29,20 +34,55 @@ use tokio_stream::StreamExt;
pub(crate) enum UpdatePromptOutcome {
Continue,
RunUpdate(UpdateAction),
RunUpdate(PromptedUpdate),
StartRepairSession(String),
}
pub(crate) async fn run_update_prompt_if_needed(
tui: &mut Tui,
config: &Config,
) -> Result<UpdatePromptOutcome> {
let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else {
return Ok(UpdatePromptOutcome::Continue);
};
let Some(update_action) = crate::update_action::get_update_action() else {
let Some(notice) = updates::get_upgrade_notice_for_popup(config) else {
return Ok(UpdatePromptOutcome::Continue);
};
match notice {
UpgradeNotice::Available(latest_version) => {
let update_action = match crate::update_action::get_update_action_status() {
UpdateActionStatus::Ready(update_action) => update_action,
UpdateActionStatus::Blocked(blocker) => {
return run_remediation_prompt(
tui,
config,
latest_version,
RemediationPromptReason::Blocked(blocker),
)
.await;
}
UpdateActionStatus::Unavailable => return Ok(UpdatePromptOutcome::Continue),
};
run_standard_update_prompt(tui, config, latest_version, update_action).await
}
UpgradeNotice::RemediationNeeded(latest_version) => {
run_remediation_prompt(
tui,
config,
latest_version.clone(),
RemediationPromptReason::NoOpUpdate {
latest_version: latest_version.clone(),
},
)
.await
}
}
}
async fn run_standard_update_prompt(
tui: &mut Tui,
config: &Config,
latest_version: String,
update_action: UpdateAction,
) -> Result<UpdatePromptOutcome> {
let mut screen =
UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action);
tui.draw(u16::MAX, |frame| {
@@ -71,7 +111,11 @@ pub(crate) async fn run_update_prompt_if_needed(
match screen.selection() {
Some(UpdateSelection::UpdateNow) => {
tui.terminal.clear()?;
Ok(UpdatePromptOutcome::RunUpdate(update_action))
Ok(UpdatePromptOutcome::RunUpdate(PromptedUpdate {
action: update_action,
target_version: latest_version,
version_file: updates::version_filepath(config),
}))
}
Some(UpdateSelection::NotNow) | None => Ok(UpdatePromptOutcome::Continue),
Some(UpdateSelection::DontRemind) => {
@@ -83,6 +127,49 @@ pub(crate) async fn run_update_prompt_if_needed(
}
}
async fn run_remediation_prompt(
tui: &mut Tui,
config: &Config,
latest_version: String,
reason: RemediationPromptReason,
) -> Result<UpdatePromptOutcome> {
let mut screen = RemediationPromptScreen::new(tui.frame_requester(), reason.clone());
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 | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;
}
}
} else {
break;
}
}
match screen.selection() {
Some(RemediationSelection::HelpMeFixThis) => Ok(UpdatePromptOutcome::StartRepairSession(
reason.repair_prompt(),
)),
Some(RemediationSelection::DontRemind) => {
if let Err(err) = updates::dismiss_version(config, &latest_version).await {
tracing::error!("Failed to persist update remediation dismissal: {err}");
}
Ok(UpdatePromptOutcome::Continue)
}
Some(RemediationSelection::Later) | None => Ok(UpdatePromptOutcome::Continue),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UpdateSelection {
UpdateNow,
@@ -208,21 +295,23 @@ impl WidgetRef for &UpdatePromptScreen {
.dim()
.underlined(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
column.push(selection_option_row(
0,
/*index*/ 0,
format!("Update now (runs `{update_command}`)"),
self.highlighted == UpdateSelection::UpdateNow,
));
column.push(selection_option_row(
1,
/*index*/ 1,
"Skip".to_string(),
self.highlighted == UpdateSelection::NotNow,
));
column.push(selection_option_row(
2,
/*index*/ 2,
"Skip until next version".to_string(),
self.highlighted == UpdateSelection::DontRemind,
));
@@ -233,12 +322,186 @@ impl WidgetRef for &UpdatePromptScreen {
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.render(area, buf);
}
}
#[derive(Clone, Debug)]
enum RemediationPromptReason {
Blocked(UpdateBlocker),
NoOpUpdate { latest_version: String },
}
impl RemediationPromptReason {
fn lines(&self) -> Vec<String> {
match self {
Self::Blocked(blocker) => blocker.remediation_lines(),
Self::NoOpUpdate { latest_version } => vec![
"The previous update command completed, but this Codex executable did not change."
.to_string(),
format!(
"Codex is still running {} while {latest_version} is available.",
env!("CARGO_PKG_VERSION")
),
"Check which `codex` your shell runs before trying again.".to_string(),
],
}
}
fn repair_prompt(&self) -> String {
match self {
Self::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
running_package_root,
npm_package_root,
}) => format!(
"Help me fix my Codex install. Codex is currently running from {}, but `npm install -g @openai/codex@latest` would update {}. Please inspect my shell PATH and Codex installs, explain the safest fix, and ask before making any destructive changes.",
running_package_root.display(),
npm_package_root.display(),
),
Self::NoOpUpdate { latest_version } => format!(
"Help me fix my Codex install. A previous update command completed, but this Codex executable is still running {} while {latest_version} is available. Please inspect my shell PATH and Codex installs, explain the safest fix, and ask before making any destructive changes.",
env!("CARGO_PKG_VERSION"),
),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RemediationSelection {
HelpMeFixThis,
Later,
DontRemind,
}
struct RemediationPromptScreen {
request_frame: FrameRequester,
reason: RemediationPromptReason,
highlighted: RemediationSelection,
selection: Option<RemediationSelection>,
}
impl RemediationPromptScreen {
fn new(request_frame: FrameRequester, reason: RemediationPromptReason) -> Self {
Self {
request_frame,
reason,
highlighted: RemediationSelection::HelpMeFixThis,
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(RemediationSelection::Later);
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(RemediationSelection::HelpMeFixThis),
KeyCode::Char('2') => self.select(RemediationSelection::Later),
KeyCode::Char('3') => self.select(RemediationSelection::DontRemind),
KeyCode::Enter => self.select(self.highlighted),
KeyCode::Esc => self.select(RemediationSelection::Later),
_ => {}
}
}
fn set_highlight(&mut self, selection: RemediationSelection) {
self.highlighted = selection;
self.request_frame.schedule_frame();
}
fn select(&mut self, selection: RemediationSelection) {
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<RemediationSelection> {
self.selection
}
}
impl WidgetRef for &RemediationPromptScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push(Line::from(vec![
padded_emoji("").bold().cyan(),
"Update needs attention".bold(),
]));
column.push("");
for line in self.reason.lines() {
column.push(Line::from(line).inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)));
}
column.push("");
column.push(remediation_selection_line(
/*index*/ 0,
"Help me fix this",
self.highlighted == RemediationSelection::HelpMeFixThis,
));
column.push(remediation_selection_line(
/*index*/ 1,
"Later",
self.highlighted == RemediationSelection::Later,
));
column.push(remediation_selection_line(
/*index*/ 2,
"Don't remind me about this version",
self.highlighted == RemediationSelection::DontRemind,
));
column.render(area, buf);
}
}
fn remediation_selection_line(index: usize, label: &str, selected: bool) -> Line<'static> {
let line = if selected {
return Line::from(vec![
format!(" {}. ", index + 1).cyan(),
label.to_string().cyan(),
]);
} else {
format!(" {}. {label}", index + 1)
};
Line::from(line)
}
impl RemediationSelection {
fn next(self) -> Self {
match self {
Self::HelpMeFixThis => Self::Later,
Self::Later => Self::DontRemind,
Self::DontRemind => Self::HelpMeFixThis,
}
}
fn prev(self) -> Self {
match self {
Self::HelpMeFixThis => Self::DontRemind,
Self::Later => Self::HelpMeFixThis,
Self::DontRemind => Self::Later,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -260,7 +523,8 @@ mod tests {
#[test]
fn update_prompt_snapshot() {
let screen = new_prompt();
let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal");
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 12)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render update prompt");
@@ -310,4 +574,40 @@ mod tests {
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(screen.highlighted, UpdateSelection::UpdateNow);
}
#[test]
fn update_remediation_prompt_snapshot() {
let screen = RemediationPromptScreen::new(
FrameRequester::test_dummy(),
RemediationPromptReason::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
running_package_root: "/prefix-a/lib/node_modules/@openai/codex".into(),
npm_package_root: "/prefix-b/lib/node_modules/@openai/codex".into(),
}),
);
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 96, /*height*/ 12)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render update remediation prompt");
insta::assert_snapshot!("update_remediation_prompt_modal", terminal.backend());
}
#[test]
fn remediation_prompt_help_selection_starts_repair_flow() {
let reason = RemediationPromptReason::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
running_package_root: "/prefix-a/lib/node_modules/@openai/codex".into(),
npm_package_root: "/prefix-b/lib/node_modules/@openai/codex".into(),
});
assert!(
reason
.repair_prompt()
.contains("/prefix-a/lib/node_modules/@openai/codex")
);
assert!(
reason
.repair_prompt()
.contains("/prefix-b/lib/node_modules/@openai/codex")
);
}
}

View File

@@ -1,10 +1,13 @@
#![cfg(not(debug_assertions))]
#![cfg(any(not(debug_assertions), test))]
#![cfg_attr(test, allow(dead_code))]
use crate::legacy_core::config::Config;
use crate::npm_registry;
use crate::npm_registry::NpmPackageInfo;
use crate::update_action;
use crate::update_action::UpdateAction;
use crate::update_action::UpdateActionStatus;
use crate::update_action::UpdateBlocker;
use crate::update_versions::extract_version_from_latest_tag;
use crate::update_versions::is_newer;
use crate::update_versions::is_source_build_version;
@@ -19,6 +22,24 @@ use std::path::PathBuf;
use crate::version::CODEX_CLI_VERSION;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpgradeNotice {
Available(String),
RemediationNeeded(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpgradeHistoryNotice {
Available {
latest_version: String,
update_action: Option<UpdateAction>,
},
BlockedWarning(UpdateBlocker),
NoOpUpdateWarning {
latest_version: String,
},
}
pub fn get_upgrade_version(config: &Config) -> Option<String> {
if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) {
return None;
@@ -42,13 +63,52 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
});
}
info.and_then(|info| {
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
Some(info.latest_version)
} else {
None
info.and_then(latest_upgrade_version)
}
pub fn get_upgrade_notice_for_history(
config: &Config,
prompt_launch: bool,
action_status: UpdateActionStatus,
) -> Option<UpgradeHistoryNotice> {
let latest = get_upgrade_version(config)?;
let version_file = version_filepath(config);
let info = read_version_info(&version_file).ok();
if info
.as_ref()
.is_some_and(|info| should_show_prompt_update_remediation(info, &latest))
{
if prompt_launch
&& info
.as_ref()
.and_then(|info| info.no_op_inline_notice_shown_version.as_deref())
!= Some(latest.as_str())
{
if let Err(err) = record_no_op_inline_notice_shown(&version_file, &latest) {
tracing::warn!("Failed to persist no-op update inline notice state: {err}");
}
return Some(UpgradeHistoryNotice::NoOpUpdateWarning {
latest_version: latest,
});
}
})
return None;
}
match action_status {
UpdateActionStatus::Ready(update_action) => Some(UpgradeHistoryNotice::Available {
latest_version: latest,
update_action: Some(update_action),
}),
UpdateActionStatus::Unavailable => Some(UpgradeHistoryNotice::Available {
latest_version: latest,
update_action: None,
}),
UpdateActionStatus::Blocked(blocker) if prompt_launch => {
Some(UpgradeHistoryNotice::BlockedWarning(blocker))
}
UpdateActionStatus::Blocked(_) => None,
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -58,6 +118,18 @@ struct VersionInfo {
last_checked_at: DateTime<Utc>,
#[serde(default)]
dismissed_version: Option<String>,
#[serde(default)]
successful_prompt_update: Option<SuccessfulPromptUpdate>,
#[serde(default)]
suppressed_version: Option<String>,
#[serde(default)]
no_op_inline_notice_shown_version: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct SuccessfulPromptUpdate {
from_version: String,
target_version: String,
}
const VERSION_FILENAME: &str = "version.json";
@@ -75,7 +147,7 @@ struct HomebrewCaskInfo {
version: String,
}
fn version_filepath(config: &Config) -> PathBuf {
pub(crate) fn version_filepath(config: &Config) -> PathBuf {
config.codex_home.join(VERSION_FILENAME).into_path_buf()
}
@@ -113,12 +185,20 @@ async fn check_for_update(version_file: &Path, action: Option<UpdateAction>) ->
}
};
// Preserve any previously dismissed version if present.
// Preserve local prompt state across version refreshes.
let prev_info = read_version_info(version_file).ok();
let info = VersionInfo {
latest_version,
last_checked_at: Utc::now(),
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
dismissed_version: prev_info.as_ref().and_then(|p| p.dismissed_version.clone()),
successful_prompt_update: prev_info
.as_ref()
.and_then(|p| p.successful_prompt_update.clone()),
suppressed_version: prev_info
.as_ref()
.and_then(|p| p.suppressed_version.clone()),
no_op_inline_notice_shown_version: prev_info
.and_then(|p| p.no_op_inline_notice_shown_version),
};
let json_line = format!("{}\n", serde_json::to_string(&info)?);
@@ -142,22 +222,27 @@ async fn fetch_latest_github_release_version() -> anyhow::Result<String> {
extract_version_from_latest_tag(&latest_tag_name)
}
/// Returns the latest version to show in a popup, if it should be shown.
/// Returns the upgrade notice to show in a popup, if one 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<String> {
pub fn get_upgrade_notice_for_popup(config: &Config) -> Option<UpgradeNotice> {
if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) {
return None;
}
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())
{
let Ok(info) = read_version_info(&version_file) else {
return Some(UpgradeNotice::Available(latest));
};
if info.dismissed_version.as_deref() == Some(latest.as_str()) {
return None;
}
Some(latest)
if should_show_prompt_update_remediation(&info, &latest) {
Some(UpgradeNotice::RemediationNeeded(latest))
} else {
Some(UpgradeNotice::Available(latest))
}
}
/// Persist a dismissal for the current latest version so we don't show
@@ -176,3 +261,159 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<(
tokio::fs::write(version_file, json_line).await?;
Ok(())
}
/// Persist a successful prompt-triggered update attempt so the next launch can
/// detect whether the running executable actually changed.
pub fn record_successful_prompt_update_attempt(
version_file: &Path,
target_version: &str,
) -> anyhow::Result<()> {
let mut info = read_version_info(version_file)?;
info.successful_prompt_update = Some(SuccessfulPromptUpdate {
from_version: CODEX_CLI_VERSION.to_string(),
target_version: target_version.to_string(),
});
write_version_info_sync(version_file, &info)
}
fn record_no_op_inline_notice_shown(version_file: &Path, version: &str) -> anyhow::Result<()> {
let mut info = read_version_info(version_file)?;
info.no_op_inline_notice_shown_version = Some(version.to_string());
write_version_info_sync(version_file, &info)
}
/// Suppress future notices for the current latest version after we explain a
/// likely no-op update once.
pub async fn suppress_version_after_remediation(
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.suppressed_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 should_show_prompt_update_remediation(info: &VersionInfo, latest: &str) -> bool {
matches!(
info.successful_prompt_update.as_ref(),
Some(SuccessfulPromptUpdate {
from_version,
target_version,
}) if from_version == CODEX_CLI_VERSION && target_version == latest
)
}
fn latest_upgrade_version(info: VersionInfo) -> Option<String> {
if info.suppressed_version.as_deref() == Some(info.latest_version.as_str()) {
return None;
}
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(/*default*/ false) {
Some(info.latest_version)
} else {
None
}
}
fn write_version_info_sync(version_file: &Path, info: &VersionInfo) -> anyhow::Result<()> {
let json_line = format!("{}\n", serde_json::to_string(info)?);
if let Some(parent) = version_file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(version_file, json_line)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn version_info(latest_version: &str) -> VersionInfo {
VersionInfo {
latest_version: latest_version.to_string(),
last_checked_at: Utc::now(),
dismissed_version: None,
successful_prompt_update: None,
suppressed_version: None,
no_op_inline_notice_shown_version: None,
}
}
#[test]
fn successful_prompt_update_for_current_binary_triggers_remediation() {
let mut info = version_info("9.9.9");
info.successful_prompt_update = Some(SuccessfulPromptUpdate {
from_version: CODEX_CLI_VERSION.to_string(),
target_version: "9.9.9".to_string(),
});
assert!(should_show_prompt_update_remediation(&info, "9.9.9"));
assert!(!should_show_prompt_update_remediation(&info, "9.9.10"));
}
#[test]
fn remediation_suppression_hides_the_same_latest_version() {
let mut info = version_info("9.9.9");
info.suppressed_version = Some("9.9.9".to_string());
assert_eq!(latest_upgrade_version(info), None);
}
#[test]
fn newer_latest_version_ignores_stale_remediation_suppression() {
let mut info = version_info("9.9.10");
info.suppressed_version = Some("9.9.9".to_string());
assert_eq!(latest_upgrade_version(info), Some("9.9.10".to_string()));
}
#[test]
fn successful_prompt_update_attempt_is_persisted() -> anyhow::Result<()> {
let tempdir = tempfile::tempdir()?;
let version_file = tempdir.path().join(VERSION_FILENAME);
write_version_info_sync(&version_file, &version_info("9.9.9"))?;
record_successful_prompt_update_attempt(&version_file, "9.9.9")?;
let info = read_version_info(&version_file)?;
assert_eq!(
info.successful_prompt_update,
Some(SuccessfulPromptUpdate {
from_version: CODEX_CLI_VERSION.to_string(),
target_version: "9.9.9".to_string(),
})
);
Ok(())
}
#[test]
fn no_op_inline_notice_marker_is_persisted_without_clearing_remediation() -> anyhow::Result<()>
{
let tempdir = tempfile::tempdir()?;
let version_file = tempdir.path().join(VERSION_FILENAME);
let mut info = version_info("9.9.9");
info.successful_prompt_update = Some(SuccessfulPromptUpdate {
from_version: CODEX_CLI_VERSION.to_string(),
target_version: "9.9.9".to_string(),
});
write_version_info_sync(&version_file, &info)?;
record_no_op_inline_notice_shown(&version_file, "9.9.9")?;
let info = read_version_info(&version_file)?;
assert_eq!(
info.no_op_inline_notice_shown_version.as_deref(),
Some("9.9.9")
);
assert!(should_show_prompt_update_remediation(&info, "9.9.9"));
Ok(())
}
}