Instead of printing characters before booting the app, make the upgrade banner a history cell so it's well-behaved. <img width="771" height="586" alt="Screenshot 2025-10-16 at 4 20 51 PM" src="https://github.com/user-attachments/assets/90629d47-2c3d-4970-a826-283795ab34e5" /> --------- Co-authored-by: Josh McKinney <joshka@openai.com>
314 lines
9.9 KiB
Rust
314 lines
9.9 KiB
Rust
#![cfg(not(debug_assertions))]
|
|
|
|
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 crate::updates::UpdateAction;
|
|
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::updates::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);
|
|
}
|
|
}
|