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" />
This commit is contained in:
@@ -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<Stri
|
||||
let AppExitInfo {
|
||||
token_usage,
|
||||
conversation_id,
|
||||
..
|
||||
} = exit_info;
|
||||
|
||||
if token_usage.is_zero() {
|
||||
@@ -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)]
|
||||
@@ -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(
|
||||
@@ -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(
|
||||
@@ -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());
|
||||
|
||||
@@ -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<ConversationId>,
|
||||
pub update_action: Option<UpdateAction>,
|
||||
}
|
||||
|
||||
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<UpdateAction>,
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -521,6 +528,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
pending_update_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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<Line<'static>> = 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";
|
||||
if let Some(update_action) = get_update_action() {
|
||||
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";
|
||||
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<UpdateAction> {
|
||||
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."
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
35
codex-rs/tui/src/selection_list.rs
Normal file
35
codex-rs/tui/src/selection_list.rs
Normal file
@@ -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<dyn Renderable> {
|
||||
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()
|
||||
}
|
||||
@@ -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
|
||||
313
codex-rs/tui/src/update_prompt.rs
Normal file
313
codex-rs/tui/src/update_prompt.rs
Normal file
@@ -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<UpdatePromptOutcome> {
|
||||
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<UpdateSelection>,
|
||||
}
|
||||
|
||||
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<UpdateSelection> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ struct VersionInfo {
|
||||
latest_version: String,
|
||||
// ISO-8601 timestamp (RFC3339)
|
||||
last_checked_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
dismissed_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
@@ -75,12 +77,15 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
|
||||
.json::<ReleaseInfo>()
|
||||
.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<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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::<u64>().ok()?;
|
||||
|
||||
Reference in New Issue
Block a user