mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
Reference in New Issue
Block a user