Files
llmx/codex-rs/tui/src/update_prompt.rs
Jeremy Rose 58159383c4 fix terminal corruption that could happen when onboarding and update banner (#5269)
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>
2025-10-20 21:40:14 +00:00

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);
}
}