Files
codex/codex-rs/tui/src/update_prompt.rs
T
Eric Traut 66e13efd9c TUI: enforce core boundary (#17399)
Problem: The TUI still depended on `codex-core` directly in a number of
places, and we had no enforcement from keeping this problem from getting
worse.

Solution: Route TUI core access through
`codex-app-server-client::legacy_core`, add CI enforcement for that
boundary, and re-export this legacy bridge inside the TUI as
`crate::legacy_core` so the remaining call sites stay readable. There is
no functional change in this PR — just changes to import targets.

Over time, we can whittle away at the remaining symbols in this legacy
namespace with the eventual goal of removing them all. In the meantime,
this linter rule will prevent us from inadvertently importing new
symbols from core.
2026-04-10 20:25:31 -07:00

314 lines
9.9 KiB
Rust

#![cfg(not(debug_assertions))]
use crate::history_cell::padded_emoji;
use crate::key_hint;
use crate::legacy_core::config::Config;
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::update_action::UpdateAction;
use crate::updates;
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::update_action::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);
}
}