diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 2dbdfe2cc82f..45871f151d2a 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -786,7 +786,7 @@ pub enum CopyOnSelect { /// Use the terminal-specific default. #[default] Auto, - /// Copy every nonempty transcript or footer mouse selection on release. + /// Copy every nonempty transcript, footer, or composer mouse selection on release. Always, /// Require an explicit copy action. Never, @@ -891,15 +891,16 @@ pub struct Tui { #[schemars(schema_with = "crate::tui_mouse_scroll::schema")] pub mouse_scroll_speed: Option, - /// Copy selected transcript or footer text when the mouse button is released. + /// Copy selected transcript, footer, or composer text when the mouse button is released. /// Defaults to `auto`: enabled except in direct terminals known to forward their native /// copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). /// Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying. #[serde(default)] pub copy_on_select: CopyOnSelect, - /// Deselect transcript text after Copy on select confirms clipboard delivery. - /// Defaults to `false` and has no effect when Copy on select is off. + /// Deselect read-only transcript or footer text after Copy on select confirms delivery. + /// Editable composer selections remain visible. Defaults to `false` and has no effect when + /// Copy on select is off. #[serde(default)] pub copy_on_select_clear_selection: bool, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9e8e684394d2..7c6da97d0f0a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1430,7 +1430,7 @@ "type": "string" }, { - "description": "Copy every nonempty transcript or footer mouse selection on release.", + "description": "Copy every nonempty transcript, footer, or composer mouse selection on release.", "enum": [ "always" ], @@ -4683,11 +4683,11 @@ } ], "default": "auto", - "description": "Copy selected transcript or footer text when the mouse button is released. Defaults to `auto`: enabled except in direct terminals known to forward their native copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying." + "description": "Copy selected transcript, footer, or composer text when the mouse button is released. Defaults to `auto`: enabled except in direct terminals known to forward their native copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying." }, "copy_on_select_clear_selection": { "default": false, - "description": "Deselect transcript text after Copy on select confirms clipboard delivery. Defaults to `false` and has no effect when Copy on select is off.", + "description": "Deselect read-only transcript or footer text after Copy on select confirms delivery. Editable composer selections remain visible. Defaults to `false` and has no effect when Copy on select is off.", "type": "boolean" }, "disable_paste_burst": { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c5ab36546e18..b21aa349a7e5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -828,10 +828,10 @@ pub struct Config { /// Mouse wheel speed multiplier for transcript scrolling; defaults to one row per event. pub tui_mouse_scroll_speed: Option, - /// Override the terminal-specific default for copying transcript mouse selections. + /// Override the terminal default for copying transcript, footer, and composer selections. pub tui_copy_on_select: codex_config::types::CopyOnSelect, - /// Whether to deselect transcript text after a confirmed automatic copy. + /// Whether to deselect read-only transcript or footer text after confirmed automatic copy. pub tui_copy_on_select_clear_selection: bool, /// Right-click text paste fallback for the fullscreen TUI. diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs index a4359cd3e4b3..5b25a0f2998c 100644 --- a/codex-rs/tui/src/app/clipboard.rs +++ b/codex-rs/tui/src/app/clipboard.rs @@ -78,6 +78,12 @@ impl App { } if !current || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) { self.transcript_view.cancel_primary(); + self.chat_widget.cancel_composer_primary(); + self.chat_widget + .rendered_selection + .borrow_mut() + .view + .cancel_primary(); } } } diff --git a/codex-rs/tui/src/app/footer_selection.rs b/codex-rs/tui/src/app/footer_selection.rs index 436300bb2219..0be4be334d1c 100644 --- a/codex-rs/tui/src/app/footer_selection.rs +++ b/codex-rs/tui/src/app/footer_selection.rs @@ -32,6 +32,8 @@ impl App { selection.view.copy_on_select = self .local_settings .copy_on_select(&codex_terminal_detection::terminal_info()); + selection.view.copy_on_select_clear_selection = + self.local_settings.tui.copy_on_select_clear_selection; selection.view.primary_selection = self.right_click_paste_environment.primary; selection.handle_event(event, tui.terminal.previous_buffer()) }; diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 8c42d78f1cd5..23993b29cd1a 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -39,6 +39,43 @@ impl App { false } + /// Publish a completed composer selection with the transcript/footer copy policy. + fn handle_composer_selection_release( + &mut self, + tui: &mut tui::Tui, + event: crossterm::event::MouseEvent, + ) -> bool { + let selection_copy = crate::tui::SelectionCopy::on_select( + self.local_settings + .copy_on_select(&codex_terminal_detection::terminal_info()), + self.right_click_paste_environment.primary, + // Editable selections remain visible so the next input can replace them. + /*clear_selection*/ + false, + ); + if !selection_copy.copies_clipboard() && !selection_copy.publishes_primary() { + return false; + } + let Some((char_count, result)) = self.chat_widget.copy_composer_selection_on_release( + event, + selection_copy, + |text, selection_copy| { + tui.copy_selection( + text, + crate::clipboard_copy::CopyFormat::PlainText, + selection_copy, + text, + ) + }, + ) else { + return false; + }; + if let Some(result) = result { + self.transcript_view.show_copy_feedback(&result, char_count); + } + true + } + fn sync_owned_transcript(&mut self, width: u16) -> bool { let chat_widget = &self.chat_widget; let transcript_width = chat_widget.history_wrap_width(width); @@ -434,6 +471,7 @@ impl App { return Ok(true); } if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) { + self.handle_composer_selection_release(tui, *mouse); if !matches!( mouse.kind, crossterm::event::MouseEventKind::ScrollUp diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2ad3f1cd95ea..715af4eb6f72 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -249,7 +249,7 @@ //! Submission flushes expired characters and buffers before classifying Enter, independent of //! UI flush ticks. `disable_paste_burst` bypasses detection; setting it flushes and clears in-flight state. //! Mouse edits flush pending typing; selection and copy behavior lives in [`mouse`]. Confirmed -//! copies clear the selection while preserving the draft and cursor. Footer text uses a separate +//! explicit copies clear the selection while preserving the draft and cursor. Footer text uses a separate //! read-only selection owner over its final rendered cells. //! //! See `codex-rs/tui/src/bottom_pane/paste_burst.rs` for the detailed state machine. 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 65a5926135ec..94911cbaa151 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -1,11 +1,14 @@ //! 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 preserves the draft and cursor; confirmed copies clear selection for every gesture. +//! Selection release follows the transcript/footer copy policy while keeping editable text selected. +//! Explicit copy preserves the draft and cursor, and clears after confirmed delivery. //! Wheel browsing uses the current viewport without changing popup or selection ownership. use super::*; use crate::clipboard_copy::CopyStatus; +use crate::clipboard_copy::worker::CopyResult; +use crate::tui::SelectionCopy; use crate::tui::TuiEvent; use crossterm::event::MouseButton; use crossterm::event::MouseEvent; @@ -87,6 +90,26 @@ impl ChatComposer { Some((char_count, result)) } + pub(crate) fn copy_selection_on_release( + &mut self, + event: MouseEvent, + selection_copy: SelectionCopy, + copy: impl FnOnce(&str, SelectionCopy) -> (CopyResult, Option>), + ) -> Option<(usize, Option)> { + if event.kind != MouseEventKind::Up(MouseButton::Left) { + return None; + } + let range = self.draft.textarea.mouse_selection_range()?; + let text = &self.draft.textarea.text()[range]; + let char_count = text.chars().count(); + let (result, primary_owner) = copy(text, selection_copy); + self.draft.textarea.retain_primary(primary_owner); + if selection_copy.copies_clipboard() { + return Some((char_count, Some(result))); + } + Some((char_count, None)) + } + pub(crate) fn clear_mouse_selection(&mut self) { self.draft.textarea.set_cursor(self.draft.textarea.cursor()); } @@ -95,6 +118,10 @@ impl ChatComposer { self.draft.textarea.end_mouse_drag(); } + pub(crate) fn cancel_primary(&mut self) { + self.draft.textarea.cancel_primary(); + } + pub(crate) fn prepare_mouse(&mut self, event: MouseEvent) -> bool { if !self.draft.input_enabled || self.blocks_direct_input 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 920395ab1724..dbd2fbd69e7e 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 @@ -214,3 +214,100 @@ fn right_click_copy_uses_the_refreshed_editor_bounds() { paste_target ); } + +#[test] +fn selection_release_copies_and_keeps_the_editable_selection() { + 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(); + let release = MouseEvent { + kind: Up(Left), + column: x - 6, + row: y, + modifiers: KeyModifiers::NONE, + }; + 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 selection_copy = crate::tui::SelectionCopy::on_select( + /*clipboard*/ true, /*primary*/ true, /*clear_selection*/ false, + ); + let owner = std::sync::Arc::new(()); + let retained = std::sync::Arc::downgrade(&owner); + assert_eq!( + composer.copy_selection_on_release(release, selection_copy, |text, policy| { + assert_eq!((text, policy), ("hello", selection_copy)); + (Ok(CopyStatus::Confirmed), Some(owner)) + }), + Some((5, Some(Ok(CopyStatus::Confirmed)))) + ); + + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + let highlight = (x - 11..x) + .map(|column| { + buffer[(column, y)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + }) + .collect::>(); + assert_eq!( + highlight, + vec![ + true, true, true, true, true, false, false, false, false, false, false + ] + ); + assert!(retained.upgrade().is_some()); + + composer.insert_str("X"); + assert_eq!(composer.current_text(), "X world"); + assert!(retained.upgrade().is_none()); +} + +#[test] +fn selection_release_reports_only_clipboard_result() { + for (clipboard, expected) in [(true, Some(Ok(CopyStatus::Pending(7)))), (false, None)] { + 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(); + let release = MouseEvent { + kind: Up(Left), + column: x - 6, + row: y, + modifiers: KeyModifiers::NONE, + }; + 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 selection = composer.draft.textarea.mouse_selection_range(); + let policy = crate::tui::SelectionCopy::on_select( + clipboard, /*primary*/ true, /*clear_selection*/ false, + ); + assert_eq!( + composer.copy_selection_on_release(release, policy, |text, actual| { + assert_eq!((text, actual), ("hello", policy)); + (Ok(CopyStatus::Pending(7)), Some(std::sync::Arc::new(()))) + }), + Some((5, expected)) + ); + composer.finish_copy(&(7, Ok(CopyStatus::Confirmed)), /*current*/ true); + assert_eq!(composer.draft.textarea.mouse_selection_range(), selection); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ae9405e9ff37..3544f57a4e01 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1891,6 +1891,10 @@ impl BottomPane { self.composer.end_mouse_drag(); } + pub(crate) fn cancel_composer_primary(&mut self) { + self.composer.cancel_primary(); + } + pub(crate) fn finish_composer_copy( &mut self, completion: &(u64, crate::clipboard_copy::worker::CopyResult), @@ -1911,6 +1915,25 @@ impl BottomPane { self.composer.copy_selection(event, copy) } + pub(crate) fn copy_composer_selection_on_release( + &mut self, + event: crossterm::event::MouseEvent, + selection_copy: crate::tui::SelectionCopy, + copy: impl FnOnce( + &str, + crate::tui::SelectionCopy, + ) -> ( + crate::clipboard_copy::worker::CopyResult, + Option>, + ), + ) -> Option<(usize, Option)> { + if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) { + return None; + } + self.composer + .copy_selection_on_release(event, selection_copy, copy) + } + pub(crate) fn clipboard_paste_target( &self, shortcut: Option, diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs index 681fc9bcae31..5cee31af164c 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs @@ -6,10 +6,12 @@ use crossterm::event::MouseButton; use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; use ratatui::layout::Position; +use std::sync::Arc; #[derive(Debug)] pub(super) struct MouseSelection { pending_copy: Option<(u64, Range)>, + primary_owner: Option>, origin: Range, unit: SelectionUnit, dragging: bool, @@ -25,6 +27,16 @@ impl TextArea { } } + pub(in crate::bottom_pane) fn retain_primary(&mut self, owner: Option>) { + if let Some(selection) = &mut self.mouse_selection { + selection.primary_owner = owner; + } + } + + pub(in crate::bottom_pane) fn cancel_primary(&mut self) { + self.retain_primary(/*owner*/ None); + } + pub(in crate::bottom_pane) fn finish_copy( &mut self, completion: &(u64, crate::clipboard_copy::worker::CopyResult), @@ -136,6 +148,7 @@ impl TextArea { crate::text_selection::click_count(&mut self.last_click, event.column, event.row); MouseSelection { pending_copy: None, + primary_owner: None, origin: pos..pos, // Padding remains an insertion target on repeated clicks. unit: SelectionUnit::from_clicks(if pos == line.end - 1 { 1 } else { clicks }), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 8657de2e8373..549a5623a264 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -23,6 +23,10 @@ impl ChatWidget { self.bottom_pane.end_composer_drag(); } + pub(crate) fn cancel_composer_primary(&mut self) { + self.bottom_pane.cancel_composer_primary(); + } + pub(crate) fn copy_composer_selection( &mut self, event: &crate::tui::TuiEvent, @@ -31,6 +35,16 @@ impl ChatWidget { self.bottom_pane.copy_composer_selection(event, copy) } + pub(crate) fn copy_composer_selection_on_release( + &mut self, + event: crossterm::event::MouseEvent, + selection_copy: crate::tui::SelectionCopy, + copy: impl FnOnce(&str, crate::tui::SelectionCopy) -> (CopyResult, Option>), + ) -> Option<(usize, Option)> { + self.bottom_pane + .copy_composer_selection_on_release(event, selection_copy, copy) + } + pub(crate) fn handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { self.bottom_pane.handle_composer_mouse(event) } diff --git a/codex-rs/tui/src/config_panel/mod.rs b/codex-rs/tui/src/config_panel/mod.rs index ebc5799be2b8..7b2bd712c483 100644 --- a/codex-rs/tui/src/config_panel/mod.rs +++ b/codex-rs/tui/src/config_panel/mod.rs @@ -410,14 +410,14 @@ fn settings(tui: &Tui, status: ConfigPanelStatus) -> Vec { ConfigSetting { section: ConfigSection::TextSelection, label: "Copy on select", - description: "Copy transcript selections on release, with Auto following terminal defaults", + description: "Copy transcript, footer, and composer selections on release; Auto follows terminal defaults", keywords: "clipboard mouse selection automatic drag", value: ConfigEditor::CopyOnSelect.value(tui, status), }, ConfigSetting { section: ConfigSection::TextSelection, label: "Deselect after copy", - description: "Remove the selection highlight after Copy on select succeeds", + description: "Remove read-only transcript and footer highlights after automatic copy succeeds", keywords: "clipboard deselect clear highlight automatic mouse selection", value: ConfigSettingValue::DependentToggle { setting: ConfigToggle::CopyOnSelectClearSelection, diff --git a/codex-rs/tui/src/config_panel/render.rs b/codex-rs/tui/src/config_panel/render.rs index 28b037ee5fdd..d4d9aed1318c 100644 --- a/codex-rs/tui/src/config_panel/render.rs +++ b/codex-rs/tui/src/config_panel/render.rs @@ -172,7 +172,7 @@ pub(crate) fn copy_on_select( SelectionViewParams { title: Some("Copy on select".to_string()), subtitle: Some( - "Copy transcript selections on release, with Auto following terminal defaults" + "Copy transcript, footer, and composer selections on release; Auto follows terminal defaults" .to_string(), ), footer_hint: Some(picker_hint_line_for_keymap(keymap)), diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap index 69f6e040f963..75a149914859 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_Text selection.snap @@ -8,8 +8,8 @@ expression: "render(&panel, 140)" Appearance Notifications Input Sessions Text selection -› Copy on select Auto · On › Copy transcript selections on release, with Auto following terminal defaults - Deselect after copy [ ] Off Remove the selection highlight after Copy on select succeeds +› Copy on select Auto · On › Copy transcript, footer, and composer selections on release; Auto follows terminal defaults + Deselect after copy [ ] Off Remove read-only transcript and footer highlights after automatic copy succeeds Right-click paste Auto · Off › Paste when nothing is selected, with Auto following platform defaults and On also enabling mac… diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap index 5e0f84a618b1..0d681c8afd6f 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_copy_on_select_choice.snap @@ -4,7 +4,7 @@ expression: "render(&picker, 100)" --- Copy on select - Copy transcript selections on release, with Auto following terminal defaults + Copy transcript, footer, and composer selections on release; Auto follows terminal defaults › 1. Auto (default) (current) diff --git a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap index 89c163c39aa4..e685117599a3 100644 --- a/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap +++ b/codex-rs/tui/src/config_panel/snapshots/codex_tui__config_panel__tests__config_right_click_paste_unavailable.snap @@ -8,8 +8,8 @@ expression: "render(&panel, 140)" Appearance Notifications Input Sessions Text selection - Copy on select Auto · On › Copy transcript selections on release, with Auto following terminal defaults - Deselect after copy [ ] Off Remove the selection highlight after Copy on select succeeds + Copy on select Auto · On › Copy transcript, footer, and composer selections on release; Auto follows terminal defaults + Deselect after copy [ ] Off Remove read-only transcript and footer highlights after automatic copy succeeds › Right-click paste On · Unavailable › Unavailable in inline mode; use your terminal's paste command diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index 4a2b803537a8..80b8d678730c 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -30,6 +30,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::HistoryRenderMode; use crate::pager_overlay::TranscriptHistoryState; use crate::terminal_hyperlinks::HyperlinkLine; +use crate::tui::SelectionCopy; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -49,26 +50,6 @@ pub(crate) use input::JumpTarget; pub(crate) use input::ViewAction; pub(crate) use layout::ActivityTranscriptLines; -/// Copy origin plus the deselection policy applied after confirmed delivery. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum SelectionCopy { - Explicit, - OnSelect { clear_selection: bool }, -} - -impl SelectionCopy { - fn clear_selection(self) -> bool { - match self { - Self::Explicit => true, - Self::OnSelect { clear_selection } => clear_selection, - } - } - - fn publishes_primary(self) -> bool { - matches!(self, Self::OnSelect { .. }) - } -} - #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum EntryKey { Cell(usize), diff --git a/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs b/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs index 69ff193c3b90..1f8bcd30c940 100644 --- a/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs +++ b/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs @@ -55,8 +55,16 @@ fn copy_on_select_waits_for_release_and_confirmed_delivery() { assert!(!view.tick_selection(&cells)); if let Some(copied) = copied { let selection_copy = selection_copy.expect("copy on select policy"); - assert_eq!(selection_copy, SelectionCopy::OnSelect { clear_selection }); - assert!(selection_copy.publishes_primary()); + assert_eq!( + selection_copy, + SelectionCopy::on_select( + /*clipboard*/ true, + /*primary*/ false, + clear_selection, + ) + ); + assert!(selection_copy.copies_clipboard()); + assert!(!selection_copy.publishes_primary()); let buffer = render(&mut view, &cells, /*width*/ 20, /*height*/ 1); let highlight = (0..20) .map(|column| { diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index f5bf11940250..6e3ebd290d7f 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -93,15 +93,19 @@ impl TranscriptView { /// Automatic copies may deselect but still publish PRIMARY, so retain their source. pub(crate) fn selection_copy(&self, action: &ViewAction) -> SelectionCopy { match action { - ViewAction::CopyOnSelect(_) => SelectionCopy::OnSelect { - clear_selection: self.copy_on_select_clear_selection, - }, + ViewAction::CopyOnSelect(_) => SelectionCopy::on_select( + /*clipboard*/ true, + self.primary_selection, + self.copy_on_select_clear_selection, + ), + ViewAction::PrimarySelection(_) => SelectionCopy::on_select( + /*clipboard*/ false, /*primary*/ true, /*clear_selection*/ false, + ), ViewAction::Changed | ViewAction::ExitSelectMode - | ViewAction::PrimarySelection(_) | ViewAction::Copy(_) | ViewAction::CopyAndFollow(_) - | ViewAction::OpenLink(_) => SelectionCopy::Explicit, + | ViewAction::OpenLink(_) => SelectionCopy::EXPLICIT, } } diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index b6cae028953f..47ee87dd94ab 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -7,7 +7,6 @@ use unicode_segmentation::UnicodeSegmentation; use super::*; use crate::clipboard_copy::CopyFormat; -use crate::clipboard_copy::worker::CopyDestination; use crate::clipboard_copy::worker::CopyResult; use crate::text_selection::SelectionUnit; use crate::tui::Tui; @@ -326,14 +325,16 @@ impl TranscriptView { /// A PRIMARY write is silent: it neither claims CLIPBOARD was copied nor ends selection. pub(crate) fn publish_primary(&mut self, tui: &mut Tui, text: &str) { - let (_, owner) = tui.clipboard.select( - text.into(), + let (_, owner) = tui.copy_selection( + text, CopyFormat::PlainText, - CopyDestination::Primary, - tui.frame_requester(), + SelectionCopy::on_select( + /*clipboard*/ false, /*primary*/ true, /*clear_selection*/ false, + ), + text, ); if let Some(selection) = &mut self.selection { - selection.primary_owner = Some(owner); + selection.primary_owner = owner; } } @@ -442,18 +443,20 @@ impl TranscriptView { let text = match action { ViewAction::Copy(text) | ViewAction::CopyOnSelect(text) + | ViewAction::PrimarySelection(text) | ViewAction::CopyAndFollow(text) => text.as_str(), - ViewAction::PrimarySelection(text) => { - self.publish_primary(tui, text); - return None; - } ViewAction::Changed | ViewAction::ExitSelectMode | ViewAction::OpenLink(_) => { return None; } }; let selection_copy = self.selection_copy(action); + if !selection_copy.copies_clipboard() { + if selection_copy.publishes_primary() { + self.publish_primary(tui, text); + } + return None; + } let clear_selection = selection_copy.clear_selection(); - let publish_primary = selection_copy.publishes_primary() && self.primary_selection; let mut primary_owner = None; let result = self.copy_selected_text_with(cells, text, clear_selection, |rich, rich_format| { @@ -464,21 +467,13 @@ impl TranscriptView { (text, CopyFormat::MarkdownSelection(source)) } }; - if publish_primary { - let (result, owner) = tui.clipboard.select( - copy_text.into(), - format, - CopyDestination::ClipboardAndPrimary(text.into()), - tui.frame_requester(), - ); - primary_owner = Some(owner); - result - } else { - tui.clipboard - .copy(copy_text.into(), format, tui.frame_requester()) - } + let (result, owner) = tui.copy_selection(copy_text, format, selection_copy, text); + primary_owner = owner; + result }); - if publish_primary && let Some(selection) = &mut self.selection { + if selection_copy.publishes_primary() + && let Some(selection) = &mut self.selection + { selection.primary_owner = primary_owner; } if matches!(action, ViewAction::CopyAndFollow(_)) diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index f17c2d0462f8..dd0fc95ccd3c 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -94,6 +94,8 @@ mod windows_console; #[cfg(any(windows, test))] mod windows_key_sequence; +pub(crate) use selection_clipboard::SelectionCopy; + /// Target frame interval for UI redraw scheduling. pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL; diff --git a/codex-rs/tui/src/tui/selection_clipboard.rs b/codex-rs/tui/src/tui/selection_clipboard.rs index 69df258121d7..09ec4917b08e 100644 --- a/codex-rs/tui/src/tui/selection_clipboard.rs +++ b/codex-rs/tui/src/tui/selection_clipboard.rs @@ -3,14 +3,92 @@ use super::Tui; use crate::clipboard_copy::CopyFormat; use crate::clipboard_copy::CopyStatus; +use crate::clipboard_copy::worker::CopyDestination; +use crate::clipboard_copy::worker::CopyResult; +use std::sync::Arc; + +/// Clipboard destinations and completion behavior shared by selectable surfaces. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct SelectionCopy { + clipboard: bool, + primary: bool, + clear_selection: bool, +} + +impl SelectionCopy { + pub(crate) const EXPLICIT: Self = Self { + clipboard: true, + primary: false, + clear_selection: true, + }; + + pub(crate) fn on_select(clipboard: bool, primary: bool, clear_selection: bool) -> Self { + Self { + clipboard, + primary, + clear_selection, + } + } + + pub(crate) fn copies_clipboard(self) -> bool { + self.clipboard + } + + pub(crate) fn publishes_primary(self) -> bool { + self.primary + } + + pub(crate) fn clear_selection(self) -> bool { + self.clear_selection + } +} impl Tui { + /// Copy to CLIPBOARD and/or publish X11 PRIMARY under one selection policy. + pub(crate) fn copy_selection( + &mut self, + text: &str, + format: CopyFormat, + selection_copy: SelectionCopy, + primary_text: &str, + ) -> (CopyResult, Option>) { + let text: Arc = text.into(); + match ( + selection_copy.copies_clipboard(), + selection_copy.publishes_primary(), + ) { + (true, true) => { + let (result, owner) = self.clipboard.select( + Arc::clone(&text), + format, + CopyDestination::ClipboardAndPrimary(primary_text.into()), + self.frame_requester(), + ); + (result, Some(owner)) + } + (true, false) => ( + self.clipboard.copy(text, format, self.frame_requester()), + None, + ), + (false, true) => { + let (result, owner) = self.clipboard.select( + text, + format, + CopyDestination::Primary, + self.frame_requester(), + ); + (result, Some(owner)) + } + (false, false) => (Err("selection copy has no destination".into()), None), + } + } + pub(crate) fn copy_transcript_selection( &mut self, text: &str, format: CopyFormat, ) -> Result { - self.clipboard - .copy(text.into(), format, self.frame_requester()) + self.copy_selection(text, format, SelectionCopy::EXPLICIT, text) + .0 } }