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