feat(tui2): copy tui crate and normalize snapshots (#7833)

Introduce a full codex-tui source snapshot under the new codex-tui2
crate so viewport work can be replayed in isolation.

This change copies the entire codex-rs/tui/src tree into
codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep
future diffs vs the original viewport bookmark easy to reason about.

The goal is for codex-tui2 to render identically to the existing TUI
behind the `features.tui2` flag while we gradually port the
viewport/history commits from the joshka/viewport bookmark onto this
forked tree.

While on this baseline change, we also ran the codex-tui2 snapshot test
suite and accepted all insta snapshots for the new crate, so the
snapshot files now use the codex-tui2 naming scheme and encode the
unmodified legacy TUI behavior. This keeps later viewport commits
focused on intentional behavior changes (and their snapshots) rather
than on mechanical snapshot renames.
This commit is contained in:
Josh McKinney authored and GitHub committed 2025-12-10 22:53:46 +00:00
1 parent 321625072a
commit 90f262e9a4
742 files changed
+53558 -18

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
#![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::update_action::UpdateAction;
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::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);
}
}