Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions codex-rs/config/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -891,15 +891,16 @@ pub struct Tui {
#[schemars(schema_with = "crate::tui_mouse_scroll::schema")]
pub mouse_scroll_speed: Option<f64>,

/// 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,

Expand Down
6 changes: 3 additions & 3 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,

/// 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.
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui/src/app/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
2 changes: 2 additions & 0 deletions codex-rs/tui/src/app/footer_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
};
Expand Down
38 changes: 38 additions & 0 deletions codex-rs/tui/src/app/owned_transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 28 additions & 1 deletion codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<std::sync::Arc<()>>),
) -> Option<(usize, Option<CopyResult>)> {
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());
}
Expand All @@ -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
Expand Down
97 changes: 97 additions & 0 deletions codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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);
}
}
23 changes: 23 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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<std::sync::Arc<()>>,
),
) -> Option<(usize, Option<crate::clipboard_copy::worker::CopyResult>)> {
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<KeyCode>,
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/tui/src/bottom_pane/textarea/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>)>,
primary_owner: Option<Arc<()>>,
origin: Range<usize>,
unit: SelectionUnit,
dragging: bool,
Expand All @@ -25,6 +27,16 @@ impl TextArea {
}
}

pub(in crate::bottom_pane) fn retain_primary(&mut self, owner: Option<Arc<()>>) {
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),
Expand Down Expand Up @@ -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 }),
Expand Down
Loading
Loading