From 2db0ab7af1d9d7e6f0545b2778b9ac8fd789008a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 20 Sep 2026 22:02:11 +0000 Subject: [PATCH] Add right-click copying for transcript and composer selections (#46895) ## What changed Allow right-clicking inside the transcript or composer to copy its selected text using the existing clipboard status messages. Clear the selection only when the right-click copy is confirmed; retain it after failed or unconfirmed copies. Keyboard copies continue to retain composer selections. Preserve the draft, cursor, and transcript reading position. Refresh composer layout before checking mouse coordinates, and respect modal and transcript input ownership. ## Testing Add regression tests and snapshots covering copy outcomes, selection retention, reading position, editor bounds after layout changes, and input ownership. GitOrigin-RevId: 322b303394378a20b2a25b3d218777c9ea65c92b --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/owned_transcript.rs | 19 +-- .../tui/src/app/owned_transcript_tests.rs | 110 +++++++++++++++--- ..._fullscreen_composer_right_click_copy.snap | 52 +++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 7 +- .../src/bottom_pane/chat_composer/mouse.rs | 30 +++-- .../bottom_pane/chat_composer/mouse_tests.rs | 51 ++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 8 +- .../tui/src/bottom_pane/textarea/mouse.rs | 9 +- codex-rs/tui/src/chatwidget/interaction.rs | 8 +- codex-rs/tui/src/transcript_view/input.rs | 10 ++ .../transcript_view/right_click_copy_tests.rs | 102 ++++++++++++++++ ...firmed_and_preserves_reading_position.snap | 21 ++++ 13 files changed, 391 insertions(+), 40 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap create mode 100644 codex-rs/tui/src/transcript_view/right_click_copy_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__right_click_copy_tests__right_click_retains_selection_until_confirmed_and_preserves_reading_position.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 182d5fb60d..b8e5af98a0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -858,7 +858,9 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { - if self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection) { + if matches!(&event, TuiEvent::Key(_)) + && self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection) + { return Ok(AppRunControl::Continue); } // Resume arrives after suspension; retain the last painted phase across hidden owners. diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index bb22b2f1d0..2cd0df68e8 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -14,24 +14,22 @@ use crate::transcript_view::ViewAction; use ratatui::widgets::Widget; impl App { - /// Copy draft selections before interrupt/exit shortcuts and configurable chords can consume C. + /// Copy draft selections before key shortcuts, or after mouse layout has been refreshed. pub(super) fn handle_composer_copy_event( &mut self, tui: &mut tui::Tui, event: &TuiEvent, copy: impl FnOnce(&mut tui::Tui, &str) -> Result, ) -> bool { - if let TuiEvent::Key(key) = event - && tui.is_owned_screen() + if tui.is_owned_screen() && self.overlay.is_none() && !self.transcript_view.has_active_interaction() - && let Some(text) = self.chat_widget.composer_selection_for_copy(*key) + && let Some((char_count, result)) = self + .chat_widget + .copy_composer_selection(event, |text| copy(tui, text)) { self.cancel_pending_key_chord(); - self.chat_widget.end_composer_drag(); - let result = copy(tui, &text); - self.transcript_view - .show_copy_feedback(&result, text.chars().count()); + self.transcript_view.show_copy_feedback(&result, char_count); tui.frame_requester().schedule_frame(); return true; } @@ -255,6 +253,11 @@ impl App { let size = tui.prepare_draw_size()?; self.render_owned_transcript(tui, size)?; } + if composer_ready + && self.handle_composer_copy_event(tui, event, tui::Tui::copy_transcript_selection) + { + return Ok(true); + } if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) { self.transcript_view.end_selection(&self.transcript_cells); self.transcript_view.cancel_search(); diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 628386d25a..20f8bd69c5 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; use codex_protocol::models::PermissionProfile; use crossterm::event::MouseButton::Left; +use crossterm::event::MouseButton::Right; use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; use crossterm::event::MouseEventKind::Down; @@ -1172,21 +1173,97 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> { assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?); } assert!(!app.transcript_view.has_active_interaction()); + app.render_owned_transcript(&mut tui, size)?; + let cursor = tui.terminal.last_known_cursor_pos; let draft = app.chat_widget.capture_thread_input_state(); - let copy_event = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)); - assert!( - app.handle_composer_copy_event(&mut tui, ©_event, |_, text| { - assert_eq!(text, "hello"); - Ok(crate::clipboard_copy::CopyStatus::Confirmed) - }) - ); + for event in [ + mouse(Up(Right), x + 2, y), + mouse(Down(Right), /*column*/ 0, y), + mouse(Down(Right), size.width, y), + mouse(Down(Right), x, y - 1), + mouse(Down(Right), x, y + 1), + ] { + assert!(!app.handle_composer_copy_event(&mut tui, &event, |_, _| unreachable!())); + } + let copy_events = [ + TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)), + mouse(Down(Right), x + 2, y), + ]; + let mut selection_frames = Vec::new(); + for event in ©_events { + for result in [ + Err("clipboard unavailable".to_string()), + Ok(crate::clipboard_copy::CopyStatus::Unconfirmed), + Ok(crate::clipboard_copy::CopyStatus::Confirmed), + ] { + assert!(app.handle_composer_copy_event(&mut tui, event, |_, text| { + assert_eq!(text, "hello"); + result.clone() + })); + app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let rendered_draft = (x..x + 11) + .map(|column| buffer[(column, y)].symbol()) + .collect::(); + let selection = (x..x + 11) + .map(|column| { + if buffer[(column, y)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + { + '^' + } else { + '·' + } + }) + .collect::(); + let cleared = matches!(event, TuiEvent::Mouse(_)) + && result == Ok(crate::clipboard_copy::CopyStatus::Confirmed); + assert_eq!( + ( + app.chat_widget.capture_thread_input_state(), + tui.terminal.last_known_cursor_pos, + selection.as_str(), + ), + ( + draft.clone(), + cursor, + if cleared { + "···········" + } else { + "^^^^^······" + }, + ) + ); + let gesture = if matches!(event, TuiEvent::Mouse(_)) { + "right-click" + } else { + "keyboard" + }; + selection_frames.push(format!( + "{gesture} {result:?}\n{rendered_draft}\n{selection}" + )); + } + } assert_eq!(app.chat_widget.capture_thread_input_state(), draft); app.render_owned_transcript(&mut tui, size)?; assert!(row_containing(&tui, "Copied 5 chars to host clipboard") < y); + insta::assert_snapshot!( + "fullscreen_composer_right_click_copy", + format!( + "{}\n\n{}", + selection_frames.join("\n\n"), + normalize_snapshot_paths(buffer_text( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal) + )) + ) + ); app.chat_widget.handle_key_event(KeyCode::Char('x').into()); - assert!(!app.handle_composer_copy_event(&mut tui, ©_event, |_, _| unreachable!())); - assert_eq!(app.chat_widget.composer_text_with_pending(), "x world"); + for event in ©_events { + assert!(!app.handle_composer_copy_event(&mut tui, event, |_, _| unreachable!())); + } + assert_eq!(app.chat_widget.composer_text_with_pending(), "hellox world"); // A transcript drag that crosses into the composer remains a transcript selection. let transcript_y = row_containing(&tui, "transcript text"); @@ -1198,6 +1275,9 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> { app.handle_owned_transcript_event(&mut tui, &mut server, &event)?; } assert!(app.transcript_view.has_active_interaction()); + for event in ©_events { + assert!(!app.handle_composer_copy_event(&mut tui, event, |_, _| unreachable!())); + } // A fresh composer click takes ownership away from the transcript selection. app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?; @@ -1208,11 +1288,13 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> { app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Drag(Left), x + 1, y))?; assert!(app.chat_widget.no_modal_or_popup_active()); app.chat_widget.open_feature_enable_prompt(Feature::Collab); - assert!( - !app.handle_composer_copy_event(&mut tui, ©_event, |_, _| panic!( - "modals own copy input" - ),) - ); + for event in ©_events { + assert!( + !app.handle_composer_copy_event(&mut tui, event, |_, _| panic!( + "modals own copy input" + )) + ); + } assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?); server.shutdown().await?; tui.set_owned_screen(/*owned*/ false)?; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap new file mode 100644 index 0000000000..114eff9b5b --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__fullscreen_composer_right_click_copy.snap @@ -0,0 +1,52 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +assertion_line: 1245 +expression: "format!(\"{}\\n\\n{}\", selection_frames.join(\"\\n\\n\"),\nnormalize_snapshot_paths(buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal))))" +--- +keyboard Err("clipboard unavailable") +hello world +^^^^^······ + +keyboard Ok(Unconfirmed) +hello world +^^^^^······ + +keyboard Ok(Confirmed) +hello world +^^^^^······ + +right-click Err("clipboard unavailable") +hello world +^^^^^······ + +right-click Ok(Unconfirmed) +hello world +^^^^^······ + +right-click Ok(Confirmed) +hello world +··········· + + +› transcript text + + + + + + + + + + + + + + + + + Copied 5 chars to host clipboard + +› hello world + + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 139fda30e6..42937b1945 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,9 +1,8 @@ //! The chat composer is the bottom-pane text input state machine. //! -//! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes -//! completed slash commands to atomic elements, and handles Enter submission/newlines. -//! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts -//! from raw key streams, particularly on Windows. +//! It edits [`TextArea`] and attachments, routes popup keys, makes completed slash commands atomic, +//! and handles Enter/newlines. It shows Luna Reserve's yellow arrow and detects unbracketed paste +//! bursts, especially on Windows. Copy shortcuts and right clicks preserve selected draft text. //! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible. //! Owned transcripts keep persistent status below the composer and hints on a separate final row. //! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs index 5dc30aba81..b99763c90a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -1,15 +1,26 @@ //! Mouse gestures flush pending typing before layout and hit testing. Left dragging selects //! editable text using the textarea's last rendered viewport and hides completion suggestions. //! Double/triple clicks select words/logical lines using the transcript's shared gesture rules. -//! Copy shortcuts preserve the draft and selection and use fullscreen copy feedback above the input. +//! Copy preserves the draft; confirmed right-click copies clear selection, keyboard copies retain it. use super::*; +use crate::clipboard_copy::CopyStatus; +use crate::tui::TuiEvent; +use crossterm::event::MouseButton; use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; impl ChatComposer { - pub(crate) fn selection_for_copy(&mut self, key: KeyEvent) -> Option { - if !crate::text_selection::is_copy_key(key) + pub(crate) fn copy_selection( + &mut self, + event: &TuiEvent, + copy: impl FnOnce(&str) -> Result, + ) -> Option<(usize, Result)> { + let copy_requested = matches!(event, TuiEvent::Key(key) if crate::text_selection::is_copy_key(*key)) + || matches!(event, TuiEvent::Mouse(mouse) + if mouse.kind == MouseEventKind::Down(MouseButton::Right) + && self.draft.textarea.contains_mouse(*mouse)); + if !copy_requested || !self.draft.input_enabled || self.history_search.is_some() || self.draft.textarea.vim_query().is_some() @@ -20,10 +31,15 @@ impl ChatComposer { self.apply_paste(pasted); } self.draft.paste_burst.clear_window_after_non_char(); - self.draft - .textarea - .mouse_selection_range() - .map(|range| self.draft.textarea.text()[range].to_owned()) + let range = self.draft.textarea.mouse_selection_range()?; + self.end_mouse_drag(); + let text = &self.draft.textarea.text()[range]; + let char_count = text.chars().count(); + let result = copy(text); + if matches!(event, TuiEvent::Mouse(_)) && result == Ok(CopyStatus::Confirmed) { + self.draft.textarea.set_cursor(self.draft.textarea.cursor()); + } + Some((char_count, result)) } pub(crate) fn end_mouse_drag(&mut self) { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs index 5b5630da4d..58e9c7a154 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs @@ -3,6 +3,7 @@ use super::super::tests::new_test_composer; use super::*; use crossterm::event::MouseButton::Left; +use crossterm::event::MouseButton::Right; use crossterm::event::MouseEventKind::Down; use crossterm::event::MouseEventKind::Up; use pretty_assertions::assert_eq; @@ -105,3 +106,53 @@ fn selected_text_navigation_does_not_recall_history_or_select_images() { ); } } + +#[test] +fn right_click_copy_uses_the_refreshed_editor_bounds() { + let (mut composer, _rx) = new_test_composer(); + composer.insert_str("hello world"); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4, + ); + composer.render(area, &mut Buffer::empty(area)); + let (x, y) = composer.cursor_pos(area).unwrap(); + for (kind, column) in [ + (Down(Left), x - 11), + (MouseEventKind::Drag(Left), x - 6), + (Up(Left), x - 6), + ] { + mouse(&mut composer, area, kind, column, y); + } + let click = MouseEvent { + kind: Down(Right), + column: x - 9, + row: y, + modifiers: KeyModifiers::NONE, + }; + assert_eq!( + composer.copy_selection(&TuiEvent::Mouse(click), |text| { + assert_eq!(text, "hello"); + Ok(CopyStatus::Unconfirmed) + }), + Some((5, Ok(CopyStatus::Unconfirmed))) + ); + + let shifted_area = Rect::new( + /*x*/ 0, /*y*/ 10, /*width*/ 20, /*height*/ 4, + ); + assert!(composer.prepare_mouse(click)); + composer.render(shifted_area, &mut Buffer::empty(shifted_area)); + assert_eq!( + composer.copy_selection(&TuiEvent::Mouse(click), |_| unreachable!()), + None + ); + let (_, row) = composer.cursor_pos(shifted_area).unwrap(); + assert_eq!( + composer.copy_selection(&TuiEvent::Mouse(MouseEvent { row, ..click }), |text| { + assert_eq!(text, "hello"); + Ok(CopyStatus::Unconfirmed) + }), + Some((5, Ok(CopyStatus::Unconfirmed))) + ); + assert_eq!(composer.current_text(), "hello world"); +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a34878f2cb..8e9680a581 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1747,11 +1747,15 @@ impl BottomPane { self.composer.end_mouse_drag(); } - pub(crate) fn composer_selection_for_copy(&mut self, key: KeyEvent) -> Option { + pub(crate) fn copy_composer_selection( + &mut self, + event: &crate::tui::TuiEvent, + copy: impl FnOnce(&str) -> Result, + ) -> Option<(usize, Result)> { if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) { return None; } - self.composer.selection_for_copy(key) + self.composer.copy_selection(event, copy) } pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs index 0d1cee54a3..e3f61fe549 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs @@ -16,6 +16,12 @@ pub(super) struct MouseSelection { } impl TextArea { + pub(in crate::bottom_pane) fn contains_mouse(&self, event: MouseEvent) -> bool { + self.rendered_area + .get() + .contains(Position::new(event.column, event.row)) + } + pub(crate) fn end_mouse_drag(&mut self) { if let Some(selection) = &mut self.mouse_selection { selection.dragging = false; @@ -47,8 +53,7 @@ impl TextArea { } match event.kind { MouseEventKind::Down(MouseButton::Left) - if event.modifiers.is_empty() - && area.contains(Position::new(event.column, event.row)) => {} + if event.modifiers.is_empty() && self.contains_mouse(event) => {} MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) if dragging => {} _ => return false, diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 7983d4e828..1de138222b 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -9,8 +9,12 @@ impl ChatWidget { self.bottom_pane.end_composer_drag(); } - pub(crate) fn composer_selection_for_copy(&mut self, key: KeyEvent) -> Option { - self.bottom_pane.composer_selection_for_copy(key) + pub(crate) fn copy_composer_selection( + &mut self, + event: &crate::tui::TuiEvent, + copy: impl FnOnce(&str) -> Result, + ) -> Option<(usize, Result)> { + self.bottom_pane.copy_composer_selection(event, copy) } pub(crate) fn handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 3c3a7d745f..5bad1fd599 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -202,6 +202,12 @@ impl TranscriptView { match event.kind { MouseEventKind::ScrollUp => self.scroll(cells, /*rows*/ -3), MouseEventKind::ScrollDown => self.scroll(cells, /*rows*/ 3), + MouseEventKind::Down(MouseButton::Right) if inside => { + return self + .selected_text(cells) + .filter(|text| !text.is_empty()) + .map(ViewAction::Copy); + } MouseEventKind::Down(MouseButton::Left) => return self.pointer_down(event, cells), MouseEventKind::Drag(MouseButton::Left) if dragging => { self.extend_selection(event.column, event.row); @@ -360,3 +366,7 @@ impl TranscriptView { #[cfg(test)] #[path = "input_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "right_click_copy_tests.rs"] +mod right_click_copy_tests; diff --git a/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs b/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs new file mode 100644 index 0000000000..1d97d773aa --- /dev/null +++ b/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs @@ -0,0 +1,102 @@ +//! Right-click copying uses existing delivery semantics without moving the reading position. + +use super::*; +use crate::clipboard_copy::CopyStatus; +use crate::transcript_view::tests::cell; +use crate::transcript_view::tests::render; +use crate::transcript_view::tests::text; +use pretty_assertions::assert_eq; + +#[test] +fn right_click_retains_selection_until_confirmed_and_preserves_reading_position() { + let cells = vec![cell("selected text\nsecond line\nthird line\nlatest line")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 40, /*height*/ 3); + view.scroll(&cells, /*rows*/ -1); + render(&mut view, &cells, /*width*/ 40, /*height*/ 3); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + view.end_drag(); + let position = view.position; + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 4, + row: 0, + modifiers: KeyModifiers::NONE, + }; + let mut frames = Vec::new(); + for result in [ + Err("clipboard unavailable".to_owned()), + Ok(CopyStatus::Unconfirmed), + Ok(CopyStatus::Confirmed), + ] { + let Some(ViewAction::Copy(selected)) = view.handle_mouse(click, &cells) else { + panic!("right-click must request a copy without following new output"); + }; + assert_eq!(selected, "selected text\n"); + let copied = view.copy_selected_text_with(&cells, &selected, |copied| { + assert_eq!(copied, selected); + result.clone() + }); + assert_eq!(copied, result); + assert_eq!( + (view.selected_text(&cells), view.position), + ( + (result != Ok(CopyStatus::Confirmed)).then_some(selected.clone()), + position, + ) + ); + view.show_copy_feedback(&copied, selected.chars().count()); + let mut buffer = Buffer::empty(Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4, + )); + view.render( + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 3, + ), + &mut buffer, + &cells, + ); + view.render_composer_gap( + Some(Rect::new( + /*x*/ 0, /*y*/ 3, /*width*/ 40, /*height*/ 1, + )), + /*hint*/ None, + &mut buffer, + ); + frames.push(format!("{result:?}\n{}", text(&buffer))); + } + insta::assert_snapshot!(frames.join("\n\n")); + assert!(view.handle_mouse(click, &cells).is_none()); +} + +#[test] +fn right_click_requires_a_nonempty_selection_and_a_press_inside_the_transcript() { + let cells = vec![cell("selected text")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 40, /*height*/ 3); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 4, + row: 0, + modifiers: KeyModifiers::NONE, + }; + assert!(view.handle_mouse(click, &cells).is_none()); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 1); + assert!(view.handle_mouse(click, &cells).is_none()); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + // Outside presses must stay ignored even while an active drag owns mouse events. + for event in [ + MouseEvent { + column: 40, + ..click + }, + MouseEvent { row: 3, ..click }, + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + ..click + }, + ] { + assert!(view.handle_mouse(event, &cells).is_none()); + assert_eq!(view.selected_text(&cells).as_deref(), Some("selected text")); + } +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__right_click_copy_tests__right_click_retains_selection_until_confirmed_and_preserves_reading_position.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__right_click_copy_tests__right_click_retains_selection_until_confirmed_and_preserves_reading_position.snap new file mode 100644 index 0000000000..c6d37fdf93 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__right_click_copy_tests__right_click_retains_selection_until_confirmed_and_preserves_reading_position.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/transcript_view/right_click_copy_tests.rs +expression: "frames.join(\"\\n\\n\")" +--- +Err("clipboard unavailable") +selected text +second line +third line + Copy failed · try again + +Ok(Unconfirmed) +selected text +second line +third line +Copy sent to terminal · paste to verify + +Ok(Confirmed) +selected text +second line +third line + Copied 14 chars to host clipboard