Files
codex/codex-rs/tui/src/update_prompt.rs
T
Eric Traut d7d9f2b9b5 Add selection and copying to the transcript viewer (#46732)
## What changed

- Add mouse selection, word and line selection, drag autoscrolling, and keyboard selection with `Ctrl+Space`. Support copying selected text and opening links with a modified click.
- Anchor reading positions and selections to source text so resizing, streaming completion, and history pagination preserve the displayed content. Preserve hard line breaks, indentation, tabs, and graphemes when copying, while excluding display gutters and soft wraps.
- Rejoin activity groups split across history pages while retaining the reader's displayed revision.
- Distinguish confirmed clipboard writes from unconfirmed terminal requests, and retain native clipboard ownership after the transcript overlay closes.
- Manage mouse capture and keyboard modes across overlay transitions, editor handoffs, and suspend/resume.

## Testing

Add regression tests for selection during live updates and pagination, source-text copying across resize, clipboard confirmation and lifetime, and terminal mode restoration.

GitOrigin-RevId: 6b2438f576567cbba755b2405915a25acf009b8d
2026-09-20 00:04:04 +00:00

368 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shared picker presentation for the installed CLI's update choices.
//! Update discovery and execution remain disabled in debug builds.
#![cfg(any(not(debug_assertions), test))]
use crate::bottom_pane::picker_option_list;
use crate::bottom_pane::render_menu_surface;
use crate::key_hint;
#[cfg(not(debug_assertions))]
use crate::legacy_core::config::Config;
use crate::render::Insets;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::render::renderable::RenderableItem;
use crate::tui::FrameRequester;
#[cfg(not(debug_assertions))]
use crate::tui::Tui;
#[cfg(not(debug_assertions))]
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
#[cfg(not(debug_assertions))]
use crate::updates;
#[cfg(not(debug_assertions))]
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::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
#[cfg(not(debug_assertions))]
use tokio_stream::StreamExt;
const RELEASE_NOTES_URL: &str = "https://github.com/openai/codex/releases/latest";
#[cfg(not(debug_assertions))]
pub(crate) enum UpdatePromptOutcome {
Continue,
RunUpdate(UpdateAction),
}
#[cfg(not(debug_assertions))]
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());
})?;
tui.discard_pending_input_before_interactive_screen()?;
let events = tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
tui.screen_size_for_event(&event)?;
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Mouse(_) => {}
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
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
}
#[cfg(not(debug_assertions))]
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 = FlexRenderable::new();
let update_command = self.update_action.command_str();
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
column.push(
/*flex*/ 0,
Paragraph::new(Line::from(vec![
"Update available".bold(),
" · ".dim(),
format!(
"{current} → {latest}",
current = self.current_version,
latest = self.latest_version
)
.dim(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(
/*flex*/ 1,
Paragraph::new(Line::from(vec![
"Release notes: ".dim(),
RELEASE_NOTES_URL.dim().underlined(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
let selected_index = match self.highlighted {
UpdateSelection::UpdateNow => 0,
UpdateSelection::NotNow => 1,
UpdateSelection::DontRemind => 2,
};
column.push(
/*flex*/ 1,
picker_option_list(
vec![
format!("Update now (runs `{update_command}`)"),
"Skip".to_string(),
"Skip until next version".to_string(),
],
selected_index,
),
);
column.push(
/*flex*/ 0,
Paragraph::new(Line::from(vec![
key_hint::plain(KeyCode::Enter).into(),
" continue · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" skip".dim(),
]))
.wrap(Wrap { trim: false })
.inset(Insets::vh(/*v*/ 0, /*h*/ 2)),
);
column.push(/*flex*/ 1, RenderableItem::Borrowed(&""));
let panel = Rect {
height: column.desired_height(area.width).min(area.height),
..area
};
render_menu_surface(panel, buf);
column.render(panel, buf);
crate::terminal_hyperlinks::mark_underlined_hyperlink(buf, area, RELEASE_NOTES_URL);
}
}
#[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 pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::widgets::FrameExt;
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(/*width*/ 80, /*height*/ 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);
}
#[test]
fn long_update_command_keeps_selected_skip_visible_in_a_short_viewport() {
let mut screen = new_prompt();
screen.update_action = UpdateAction::StandaloneWindows;
screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let (width, height) = (28, 12);
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render resized update picker");
let rendered = terminal.backend().to_string();
assert!(rendered.contains("› 3. Skip until next version"));
let words = rendered.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(words.contains("enter continue · esc skip"));
assert_eq!(screen.selection(), None);
insta::assert_snapshot!(format!("update_picker_selected_{width}x{height}"), rendered);
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(screen.selection(), Some(UpdateSelection::NotNow));
}
}