Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -208,6 +209,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let AppExitInfo {
token_usage,
conversation_id,
..
} = exit_info;

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

fn print_exit_messages(exit_info: AppExitInfo) {
/// Handle the app exit and print the results. Optionally run the update action.
fn handle_app_exit(exit_info: AppExitInfo) -> 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)]
Expand Down Expand Up @@ -321,7 +344,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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(
Expand Down Expand Up @@ -354,7 +377,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> 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(
Expand Down Expand Up @@ -595,6 +618,7 @@ mod tests {
conversation_id: conversation
.map(ConversationId::from_string)
.map(Result::unwrap),
update_action: None,
}
}

Expand All @@ -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());
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::UpdateAction;
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
Expand Down Expand Up @@ -43,6 +44,7 @@ use tokio::sync::mpsc::unbounded_channel;
pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub conversation_id: Option<ConversationId>,
pub update_action: Option<UpdateAction>,
}

pub(crate) struct App {
Expand Down Expand Up @@ -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<UpdateAction>,
}

impl App {
Expand Down Expand Up @@ -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();
Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
119 changes: 97 additions & 22 deletions codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand Down Expand Up @@ -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))]
Expand All @@ -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<Line<'static>> = vec![
Line::from(vec![
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<UpdateAction> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@.bolinfest took a long time cleaning up env var modifications in tests, they were causing race conditions iirc.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I remove the test then?

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."
Expand Down
32 changes: 2 additions & 30 deletions codex-rs/tui/src/onboarding/trust_directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -120,30 +116,6 @@ impl WidgetRef for &TrustDirectoryWidget {
}
}

fn new_option_row(index: usize, label: String, is_selected: bool) -> Box<dyn Renderable> {
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 {
Expand Down
Loading
Loading