Skip to content

Commit 272e13d

Browse files
feat: Auto update approval (#5185)
Adds an update prompt when the CLI starts: <img width="1410" height="608" alt="Screenshot 2025-10-14 at 5 53 17 PM" src="https://github.com/user-attachments/assets/47c8bafa-7bed-4be8-b597-c4c6c79756b8" />
1 parent 18d00e3 commit 272e13d

File tree

9 files changed

+546
-55
lines changed

9 files changed

+546
-55
lines changed

codex-rs/cli/src/main.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use codex_exec::Cli as ExecCli;
1919
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
2020
use codex_tui::AppExitInfo;
2121
use codex_tui::Cli as TuiCli;
22+
use codex_tui::UpdateAction;
2223
use owo_colors::OwoColorize;
2324
use std::path::PathBuf;
2425
use supports_color::Stream;
@@ -208,6 +209,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
208209
let AppExitInfo {
209210
token_usage,
210211
conversation_id,
212+
..
211213
} = exit_info;
212214

213215
if token_usage.is_zero() {
@@ -232,11 +234,32 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
232234
lines
233235
}
234236

235-
fn print_exit_messages(exit_info: AppExitInfo) {
237+
/// Handle the app exit and print the results. Optionally run the update action.
238+
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
239+
let update_action = exit_info.update_action;
236240
let color_enabled = supports_color::on(Stream::Stdout).is_some();
237241
for line in format_exit_messages(exit_info, color_enabled) {
238242
println!("{line}");
239243
}
244+
if let Some(action) = update_action {
245+
run_update_action(action)?;
246+
}
247+
Ok(())
248+
}
249+
250+
/// Run the update action and print the result.
251+
fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
252+
println!();
253+
let (cmd, args) = action.command_args();
254+
let cmd_str = action.command_str();
255+
println!("Updating Codex via `{cmd_str}`...");
256+
let status = std::process::Command::new(cmd).args(args).status()?;
257+
if !status.success() {
258+
anyhow::bail!("`{cmd_str}` failed with status {status}");
259+
}
260+
println!();
261+
println!("🎉 Update ran successfully! Please restart Codex.");
262+
Ok(())
240263
}
241264

242265
#[derive(Debug, Default, Parser, Clone)]
@@ -321,7 +344,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
321344
root_config_overrides.clone(),
322345
);
323346
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
324-
print_exit_messages(exit_info);
347+
handle_app_exit(exit_info)?;
325348
}
326349
Some(Subcommand::Exec(mut exec_cli)) => {
327350
prepend_config_flags(
@@ -354,7 +377,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
354377
config_overrides,
355378
);
356379
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
357-
print_exit_messages(exit_info);
380+
handle_app_exit(exit_info)?;
358381
}
359382
Some(Subcommand::Login(mut login_cli)) => {
360383
prepend_config_flags(
@@ -595,6 +618,7 @@ mod tests {
595618
conversation_id: conversation
596619
.map(ConversationId::from_string)
597620
.map(Result::unwrap),
621+
update_action: None,
598622
}
599623
}
600624

@@ -603,6 +627,7 @@ mod tests {
603627
let exit_info = AppExitInfo {
604628
token_usage: TokenUsage::default(),
605629
conversation_id: None,
630+
update_action: None,
606631
};
607632
let lines = format_exit_messages(exit_info, false);
608633
assert!(lines.is_empty());

codex-rs/tui/src/app.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::UpdateAction;
12
use crate::app_backtrack::BacktrackState;
23
use crate::app_event::AppEvent;
34
use crate::app_event_sender::AppEventSender;
@@ -43,6 +44,7 @@ use tokio::sync::mpsc::unbounded_channel;
4344
pub struct AppExitInfo {
4445
pub token_usage: TokenUsage,
4546
pub conversation_id: Option<ConversationId>,
47+
pub update_action: Option<UpdateAction>,
4648
}
4749

4850
pub(crate) struct App {
@@ -71,6 +73,9 @@ pub(crate) struct App {
7173

7274
// Esc-backtracking state grouped
7375
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
76+
77+
/// Set when the user confirms an update; propagated on exit.
78+
pub(crate) pending_update_action: Option<UpdateAction>,
7479
}
7580

7681
impl App {
@@ -152,6 +157,7 @@ impl App {
152157
has_emitted_history_lines: false,
153158
commit_anim_running: Arc::new(AtomicBool::new(false)),
154159
backtrack: BacktrackState::default(),
160+
pending_update_action: None,
155161
};
156162

157163
let tui_events = tui.event_stream();
@@ -171,6 +177,7 @@ impl App {
171177
Ok(AppExitInfo {
172178
token_usage: app.token_usage(),
173179
conversation_id: app.chat_widget.conversation_id(),
180+
update_action: app.pending_update_action,
174181
})
175182
}
176183

@@ -521,6 +528,7 @@ mod tests {
521528
enhanced_keys_supported: false,
522529
commit_anim_running: Arc::new(AtomicBool::new(false)),
523530
backtrack: BacktrackState::default(),
531+
pending_update_action: None,
524532
}
525533
}
526534

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
source: tui/src/chatwidget/tests.rs
3+
expression: terminal.backend().vt100().screen().contents()
4+
---
5+
✨ New version available! Would you like to update?
6+
7+
Full release notes: https://github.com/openai/codex/releases/latest
8+
9+
10+
1. Yes, update now
11+
2. No, not now
12+
3. Don't remind me
13+
14+
Press enter to confirm or esc to go back

codex-rs/tui/src/lib.rs

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ mod pager_overlay;
5959
pub mod public_widgets;
6060
mod render;
6161
mod resume_picker;
62+
mod selection_list;
6263
mod session_log;
6364
mod shimmer;
6465
mod slash_command;
@@ -70,7 +71,38 @@ mod terminal_palette;
7071
mod text_formatting;
7172
mod tui;
7273
mod ui_consts;
74+
mod update_prompt;
7375
mod version;
76+
77+
/// Update action the CLI should perform after the TUI exits.
78+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79+
pub enum UpdateAction {
80+
/// Update via `npm install -g @openai/codex@latest`.
81+
NpmGlobalLatest,
82+
/// Update via `bun install -g @openai/codex@latest`.
83+
BunGlobalLatest,
84+
/// Update via `brew upgrade codex`.
85+
BrewUpgrade,
86+
}
87+
88+
impl UpdateAction {
89+
/// Returns the list of command-line arguments for invoking the update.
90+
pub fn command_args(&self) -> (&'static str, &'static [&'static str]) {
91+
match self {
92+
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]),
93+
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]),
94+
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]),
95+
}
96+
}
97+
98+
/// Returns string representation of the command-line arguments for invoking the update.
99+
pub fn command_str(&self) -> String {
100+
let (command, args) = self.command_args();
101+
let args_str = args.join(" ");
102+
format!("{command} {args_str}")
103+
}
104+
}
105+
74106
mod wrapping;
75107

76108
#[cfg(test)]
@@ -299,6 +331,26 @@ async fn run_ratatui_app(
299331

300332
let mut tui = Tui::new(terminal);
301333

334+
#[cfg(not(debug_assertions))]
335+
{
336+
use crate::update_prompt::UpdatePromptOutcome;
337+
338+
let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty());
339+
if !skip_update_prompt {
340+
match update_prompt::run_update_prompt_if_needed(&mut tui, &config).await? {
341+
UpdatePromptOutcome::Continue => {}
342+
UpdatePromptOutcome::RunUpdate(action) => {
343+
crate::tui::restore()?;
344+
return Ok(AppExitInfo {
345+
token_usage: codex_core::protocol::TokenUsage::default(),
346+
conversation_id: None,
347+
update_action: Some(action),
348+
});
349+
}
350+
}
351+
}
352+
}
353+
302354
// Show update banner in terminal history (instead of stderr) so it is visible
303355
// within the TUI scrollback. Building spans keeps styling consistent.
304356
#[cfg(not(debug_assertions))]
@@ -309,9 +361,6 @@ async fn run_ratatui_app(
309361
use ratatui::text::Line;
310362

311363
let current_version = env!("CARGO_PKG_VERSION");
312-
let exe = std::env::current_exe()?;
313-
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
314-
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
315364

316365
let mut content_lines: Vec<Line<'static>> = vec![
317366
Line::from(vec![
@@ -331,27 +380,10 @@ async fn run_ratatui_app(
331380
Line::from(""),
332381
];
333382

334-
if managed_by_bun {
335-
let bun_cmd = "bun install -g @openai/codex@latest";
336-
content_lines.push(Line::from(vec![
337-
"Run ".into(),
338-
bun_cmd.cyan(),
339-
" to update.".into(),
340-
]));
341-
} else if managed_by_npm {
342-
let npm_cmd = "npm install -g @openai/codex@latest";
383+
if let Some(update_action) = get_update_action() {
343384
content_lines.push(Line::from(vec![
344385
"Run ".into(),
345-
npm_cmd.cyan(),
346-
" to update.".into(),
347-
]));
348-
} else if cfg!(target_os = "macos")
349-
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
350-
{
351-
let brew_cmd = "brew upgrade codex";
352-
content_lines.push(Line::from(vec![
353-
"Run ".into(),
354-
brew_cmd.cyan(),
386+
update_action.command_str().cyan(),
355387
" to update.".into(),
356388
]));
357389
} else {
@@ -405,6 +437,7 @@ async fn run_ratatui_app(
405437
return Ok(AppExitInfo {
406438
token_usage: codex_core::protocol::TokenUsage::default(),
407439
conversation_id: None,
440+
update_action: None,
408441
});
409442
}
410443
if should_show_windows_wsl_screen {
@@ -449,6 +482,7 @@ async fn run_ratatui_app(
449482
return Ok(AppExitInfo {
450483
token_usage: codex_core::protocol::TokenUsage::default(),
451484
conversation_id: None,
485+
update_action: None,
452486
});
453487
}
454488
other => other,
@@ -477,6 +511,47 @@ async fn run_ratatui_app(
477511
app_result
478512
}
479513

514+
/// Get the update action from the environment.
515+
/// Returns `None` if not managed by npm, bun, or brew.
516+
#[cfg(not(debug_assertions))]
517+
pub(crate) fn get_update_action() -> Option<UpdateAction> {
518+
let exe = std::env::current_exe().unwrap_or_default();
519+
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
520+
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
521+
if managed_by_npm {
522+
Some(UpdateAction::NpmGlobalLatest)
523+
} else if managed_by_bun {
524+
Some(UpdateAction::BunGlobalLatest)
525+
} else if cfg!(target_os = "macos")
526+
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
527+
{
528+
Some(UpdateAction::BrewUpgrade)
529+
} else {
530+
None
531+
}
532+
}
533+
534+
#[test]
535+
#[cfg(not(debug_assertions))]
536+
fn test_get_update_action() {
537+
let prev = std::env::var_os("CODEX_MANAGED_BY_NPM");
538+
539+
// First: no npm var -> expect None (we do not run from brew in CI)
540+
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
541+
assert_eq!(get_update_action(), None);
542+
543+
// Then: with npm var -> expect NpmGlobalLatest
544+
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") };
545+
assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest));
546+
547+
// Restore prior value to avoid leaking state
548+
if let Some(v) = prev {
549+
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) };
550+
} else {
551+
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
552+
}
553+
}
554+
480555
#[expect(
481556
clippy::print_stderr,
482557
reason = "TUI should no longer be displayed, so we can write to stderr."

codex-rs/tui/src/onboarding/trust_directory.rs

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ use crossterm::event::KeyEvent;
77
use crossterm::event::KeyEventKind;
88
use ratatui::buffer::Buffer;
99
use ratatui::layout::Rect;
10-
use ratatui::style::Style;
11-
use ratatui::style::Styled as _;
1210
use ratatui::style::Stylize;
1311
use ratatui::text::Line;
1412
use ratatui::widgets::Paragraph;
@@ -22,11 +20,9 @@ use crate::render::Insets;
2220
use crate::render::renderable::ColumnRenderable;
2321
use crate::render::renderable::Renderable;
2422
use crate::render::renderable::RenderableExt as _;
25-
use crate::render::renderable::RowRenderable;
23+
use crate::selection_list::selection_option_row;
2624

2725
use super::onboarding_screen::StepState;
28-
use unicode_width::UnicodeWidthStr;
29-
3026
pub(crate) struct TrustDirectoryWidget {
3127
pub codex_home: PathBuf,
3228
pub cwd: PathBuf,
@@ -88,7 +84,7 @@ impl WidgetRef for &TrustDirectoryWidget {
8884
}
8985

9086
for (idx, (text, selection)) in options.iter().enumerate() {
91-
column.push(new_option_row(
87+
column.push(selection_option_row(
9288
idx,
9389
text.to_string(),
9490
self.highlighted == *selection,
@@ -120,30 +116,6 @@ impl WidgetRef for &TrustDirectoryWidget {
120116
}
121117
}
122118

123-
fn new_option_row(index: usize, label: String, is_selected: bool) -> Box<dyn Renderable> {
124-
let prefix = if is_selected {
125-
format!("› {}. ", index + 1)
126-
} else {
127-
format!(" {}. ", index + 1)
128-
};
129-
130-
let mut style = Style::default();
131-
if is_selected {
132-
style = style.cyan();
133-
}
134-
135-
let mut row = RowRenderable::new();
136-
row.push(prefix.width() as u16, prefix.set_style(style));
137-
row.push(
138-
u16::MAX,
139-
Paragraph::new(label)
140-
.style(style)
141-
.wrap(Wrap { trim: false }),
142-
);
143-
144-
row.into()
145-
}
146-
147119
impl KeyboardHandler for TrustDirectoryWidget {
148120
fn handle_key_event(&mut self, key_event: KeyEvent) {
149121
if key_event.kind == KeyEventKind::Release {

0 commit comments

Comments
 (0)