From 3517671c6061e1704cf1c0df4a5039de23514df8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 13 Oct 2026 19:37:19 +0000 Subject: [PATCH] Extend copy-on-select to composer text (#53753) ## What changed Apply `tui.copy_on_select` to composer mouse selections on release, including X11 PRIMARY publication when enabled. Keep editable selections highlighted so typing can replace the selected text, even when `tui.copy_on_select_clear_selection` is enabled. Share clipboard destination handling across the transcript, footer, and composer. Apply automatic deselection to footer selections after confirmed delivery, and release composer and footer PRIMARY ownership on focus loss or resume. Update configuration descriptions to clarify the behavior across all three surfaces. ## Testing Add composer tests covering copy on release, retained selection highlights, replacement by typing, PRIMARY ownership lifetime, and clipboard-only status reporting for pending copies. GitOrigin-RevId: 46999a32170149193fca561741000e97dfc458cf --- codex-rs/config/src/types.rs | 9 +- codex-rs/core/config.schema.json | 6 +- codex-rs/core/src/config/mod.rs | 4 +- codex-rs/tui/src/app/clipboard.rs | 6 ++ codex-rs/tui/src/app/footer_selection.rs | 2 + codex-rs/tui/src/app/owned_transcript.rs | 38 ++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- .../src/bottom_pane/chat_composer/mouse.rs | 29 +++++- .../bottom_pane/chat_composer/mouse_tests.rs | 97 +++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 23 +++++ .../tui/src/bottom_pane/textarea/mouse.rs | 13 +++ codex-rs/tui/src/chatwidget/interaction.rs | 14 +++ codex-rs/tui/src/config_panel/mod.rs | 4 +- codex-rs/tui/src/config_panel/render.rs | 2 +- ...g_panel__tests__config_Text selection.snap | 4 +- ...__tests__config_copy_on_select_choice.snap | 2 +- ..._config_right_click_paste_unavailable.snap | 4 +- codex-rs/tui/src/transcript_view.rs | 21 +--- .../transcript_view/copy_on_select_tests.rs | 12 ++- codex-rs/tui/src/transcript_view/input.rs | 14 ++- codex-rs/tui/src/transcript_view/selection.rs | 45 ++++----- codex-rs/tui/src/tui.rs | 2 + codex-rs/tui/src/tui/selection_clipboard.rs | 82 +++++++++++++++- 23 files changed, 362 insertions(+), 73 deletions(-) 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 } }