Add X11 primary selection and middle-click paste support (#49112)

## What changed

- Publish released mouse selections from both transcript views to X11 `PRIMARY` as plain text, even when copy-on-select is disabled. Preserve configured `CLIPBOARD` copying when it is enabled.
- Paste `PRIMARY` into the editable fullscreen composer on middle-click, including while a transcript selection remains active.
- Limit support to local Linux X11 sessions, excluding Wayland, SSH, tmux, WSL, and VS Code terminals.
- Retain the latest deferred selection publication and order it before a pending `PRIMARY` read, while giving explicit copies priority. Cancel stale deferred work when its selection or paste target is invalidated.
- Clarify that scrollback mode uses native terminal copy, paste, and scrollback.

## Testing

Add regression tests for selection publication with copy-on-select enabled and disabled, publication-before-read ordering, explicit-copy priority, cancellation of deferred work, and middle-click paste invalidation.

GitOrigin-RevId: 6ba7bddffda60183b7f9ad5cd69a97da8a4a8dcd
This commit is contained in:
Felipe Coury
2026-09-29 00:40:54 +00:00
committed by copyberry
parent 8f6517772b
commit 0196495288
20 changed files with 717 additions and 112 deletions
+6 -1
View File
@@ -839,8 +839,9 @@ impl App {
tui.link_hover.observe(&event);
self.refresh_link_hover(tui)?;
self.invalidate_right_click_paste(&event);
self.finish_clipboard(tui);
self.finish_clipboard(tui, &event);
let event = self.finish_right_click_paste(tui, event);
let idle_draw = matches!(event, TuiEvent::Draw);
if matches!(&event, TuiEvent::Key(_))
&& self.handle_composer_copy_event(tui, &event, |tui, text| {
tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText)
@@ -1137,6 +1138,10 @@ impl App {
TuiEvent::FocusLost => {}
}
}
// Both transcript owners must consume completions before automatic work advances.
if idle_draw {
tui.clipboard.advance(tui.frame_requester());
}
Ok(AppRunControl::Continue)
}
+17 -15
View File
@@ -32,23 +32,25 @@ impl App {
}
}
pub(super) fn finish_clipboard(&mut self, tui: &mut tui::Tui) {
pub(super) fn finish_clipboard(&mut self, tui: &mut tui::Tui, event: &TuiEvent) {
let current = tui.is_owned_screen() && self.overlay.is_none();
let Some(completion) = tui.clipboard.poll() else {
return;
};
if let Some(characters) = self.chat_widget.finish_clipboard(completion, current) {
self.transcript_view
.show_copy_feedback(&completion.1, characters);
}
let follow = self
.transcript_view
.finish_copy(&self.transcript_cells, completion, current);
if follow == Some(true) {
if self.backtrack.overlay_preview_active {
self.close_transcript_overlay(tui);
if let Some(completion) = tui.clipboard.poll() {
if let Some(characters) = self.chat_widget.finish_clipboard(completion, current) {
self.transcript_view
.show_copy_feedback(&completion.1, characters);
}
self.transcript_view.jump_to_latest();
let follow =
self.transcript_view
.finish_copy(&self.transcript_cells, completion, current);
if follow == Some(true) {
if self.backtrack.overlay_preview_active {
self.close_transcript_overlay(tui);
}
self.transcript_view.jump_to_latest();
}
}
if !current || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) {
self.transcript_view.cancel_primary();
}
}
}
+4 -2
View File
@@ -42,6 +42,7 @@ impl App {
let chat_widget = &self.chat_widget;
let transcript_width = chat_widget.history_wrap_width(width);
let view = &mut self.transcript_view;
view.primary_selection = self.right_click_paste_environment.primary;
view.copy_on_select = self
.local_settings
.copy_on_select(&codex_terminal_detection::terminal_info());
@@ -529,14 +530,15 @@ impl App {
let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_));
match action {
ViewAction::Changed => {}
ViewAction::PrimarySelection(text) => self.transcript_view.publish_primary(tui, &text),
ViewAction::Copy(text)
| ViewAction::CopyOnSelect(text)
| ViewAction::CopyAndFollow(text) => {
let result = self.transcript_view.copy_selected_text_with(
let result = self.transcript_view.copy_selected_text(
tui,
&self.transcript_cells,
&text,
!copy_on_select,
|text, format| tui.copy_transcript_selection(text, format),
);
self.transcript_view
.show_copy_feedback(&result, text.chars().count());
+66 -33
View File
@@ -1,8 +1,9 @@
//! Right-click fallback for an unchanged, editable fullscreen composer.
//! Right-click CLIPBOARD and local X11 middle-click PRIMARY fallback for an editable composer.
//! Existing selection handlers run first. Only a draw may deliver a read, so real input
//! invalidates pending paste before completion and is never replaced by clipboard text.
use super::*;
use crate::clipboard_copy::worker::PasteSource;
use crate::tui::VscodeDetection;
use codex_config::types::RightClickPaste;
use crossterm::event::MouseButton;
@@ -12,9 +13,12 @@ use crossterm::event::MouseEventKind;
pub(super) struct PendingPaste {
thread: Option<ThreadId>,
draft: (String, usize),
source: PasteSource,
_request: Arc<()>,
}
pub(super) struct PasteEnvironment {
pub(super) primary: bool,
pub(super) platform_default: bool,
pub(super) ssh: bool,
pub(super) wsl: bool,
@@ -24,6 +28,7 @@ pub(super) struct PasteEnvironment {
impl PasteEnvironment {
pub(super) fn detect() -> Self {
Self {
primary: crate::clipboard_copy::primary::available(),
platform_default: cfg!(any(target_os = "windows", target_os = "linux")),
ssh: crate::clipboard_copy::is_ssh_session(),
wsl: crate::clipboard_copy::is_wsl_session(),
@@ -46,51 +51,75 @@ impl PasteEnvironment {
}
impl App {
fn right_click_paste_target(&self, tui: &tui::Tui) -> Option<PendingPaste> {
fn right_click_paste_target(
&self,
tui: &tui::Tui,
source: PasteSource,
) -> Option<(Option<ThreadId>, (String, usize))> {
let allowed = match source {
PasteSource::Clipboard => {
!self.transcript_view.has_selection_range()
&& self
.right_click_paste_environment
.allows(self.local_settings.tui.right_click_paste)
}
PasteSource::Primary => self.right_click_paste_environment.primary,
};
if !tui.is_owned_screen()
|| self.overlay.is_some()
|| self.transcript_view.has_selection_range()
|| self.transcript_view.is_search_active()
|| !self
.right_click_paste_environment
.allows(self.local_settings.tui.right_click_paste)
|| !allowed
{
return None;
}
Some(PendingPaste {
thread: self.current_displayed_thread_id(),
draft: self.chat_widget.right_click_paste_target()?,
})
Some((
self.current_displayed_thread_id(),
self.chat_widget.right_click_paste_target()?,
))
}
pub(super) fn start_right_click_paste(&mut self, tui: &mut tui::Tui, mouse: MouseEvent) {
if mouse.kind != MouseEventKind::Down(MouseButton::Right)
|| !mouse.modifiers.is_empty()
|| self.pending_right_click_paste.is_some()
|| tui.clipboard.is_busy()
{
let source = match mouse.kind {
MouseEventKind::Down(MouseButton::Right) => PasteSource::Clipboard,
MouseEventKind::Down(MouseButton::Middle) => PasteSource::Primary,
_ => return,
};
if !mouse.modifiers.is_empty() || self.pending_right_click_paste.is_some() {
return;
}
let Some(target) = self.right_click_paste_target(tui) else {
let Some((thread, draft)) = self.right_click_paste_target(tui, source) else {
return;
};
match tui.clipboard.read_text(tui.frame_requester()) {
Ok(true) => self.pending_right_click_paste = Some(target),
Ok(false) => {}
match tui.clipboard.read_text(source, tui.frame_requester()) {
Ok(Some(request)) => {
self.pending_right_click_paste = Some(PendingPaste {
thread,
draft,
source,
_request: request,
});
tui.frame_requester().schedule_frame();
}
Ok(None) => {}
Err(error) => self.chat_widget.add_error_message(error),
}
}
/// Invalidate before polling, even when the worker has already completed.
pub(super) fn invalidate_right_click_paste(&mut self, event: &TuiEvent) {
let Some(pending) = &self.pending_right_click_paste else {
return;
};
let button = match pending.source {
PasteSource::Clipboard => MouseButton::Right,
PasteSource::Primary => MouseButton::Middle,
};
let keep = match event {
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained => true,
TuiEvent::Mouse(mouse) => {
matches!(
mouse.kind,
MouseEventKind::Moved | MouseEventKind::Up(MouseButton::Right)
) || (mouse.kind == MouseEventKind::Down(MouseButton::Right)
&& mouse.modifiers.is_empty())
mouse.kind == MouseEventKind::Moved
|| mouse.kind == MouseEventKind::Up(button)
|| (mouse.kind == MouseEventKind::Down(button) && mouse.modifiers.is_empty())
}
TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false,
};
@@ -107,18 +136,22 @@ impl App {
if !matches!(event, TuiEvent::Draw) {
return event;
}
let Some(pending) = self.pending_right_click_paste.as_ref() else {
tui.clipboard.take_text_result();
return event;
};
let Some((thread, draft)) = self.right_click_paste_target(tui, pending.source) else {
self.pending_right_click_paste = None;
return event;
};
if pending.thread != thread || pending.draft != draft {
self.pending_right_click_paste = None;
return event;
}
let Some(result) = tui.clipboard.take_text_result() else {
return event;
};
let Some(pending) = self.pending_right_click_paste.take() else {
return event;
};
let Some(current) = self.right_click_paste_target(tui) else {
return event;
};
if pending.thread != current.thread || pending.draft != current.draft {
return event;
}
self.pending_right_click_paste = None;
match result {
Ok(text) if !text.is_empty() => {
tui.frame_requester().schedule_frame();
@@ -15,6 +15,7 @@ fn policy_preserves_explicit_modes_and_terminal_ownership() {
(true, false, true, Other, [false, true, true]),
] {
let env = PasteEnvironment {
primary: false,
platform_default,
ssh,
wsl,
@@ -31,3 +32,30 @@ fn policy_preserves_explicit_modes_and_terminal_ownership() {
);
}
}
#[tokio::test]
async fn pending_primary_paste_survives_release_but_not_other_input() {
let mut app = crate::app::test_support::make_test_app().await;
for (kind, keep) in [
(MouseEventKind::Up(MouseButton::Middle), true),
(MouseEventKind::Moved, true),
(MouseEventKind::Down(MouseButton::Middle), true),
(MouseEventKind::Down(MouseButton::Left), false),
(MouseEventKind::Up(MouseButton::Right), false),
(MouseEventKind::ScrollDown, false),
] {
app.pending_right_click_paste = Some(PendingPaste {
thread: None,
draft: (String::new(), 0),
source: PasteSource::Primary,
_request: Arc::new(()),
});
app.invalidate_right_click_paste(&TuiEvent::Mouse(MouseEvent {
kind,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::NONE,
}));
assert_eq!(app.pending_right_click_paste.is_some(), keep);
}
}
@@ -6,7 +6,7 @@ fullscreen=false, width=80
TUI mode for next launch
› 1. Scrollback (current) Use your terminal's scrollback
› 1. Scrollback (current) Native terminal copy, paste and scrollback
2. Fullscreen Scroll within Codex's fullscreen view
Restart to apply. Launch overrides still apply.
@@ -16,8 +16,10 @@ fullscreen=false, width=40
TUI mode for next launch
› 1. Scrollback (current) Use your
terminal's
› 1. Scrollback (current) Native
terminal
copy, paste
and
scrollback
2. Fullscreen Scroll within
Codex's
@@ -32,7 +34,7 @@ fullscreen=true, width=80
TUI mode for next launch
1. Scrollback Use your terminal's scrollback
1. Scrollback Native terminal copy, paste and scrollback
› 2. Fullscreen (current) Scroll within Codex's fullscreen view
Restart to apply. Launch overrides still apply.
@@ -42,8 +44,10 @@ fullscreen=true, width=40
TUI mode for next launch
1. Scrollback Use your
terminal's
1. Scrollback Native
terminal
copy, paste
and
scrollback
› 2. Fullscreen (current) Scroll within
Codex's
+1
View File
@@ -67,6 +67,7 @@ pub(crate) async fn make_test_app() -> App {
app_server_target: crate::AppServerTarget::Embedded,
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
primary: false,
platform_default: true,
ssh: false,
wsl: false,
+2
View File
@@ -6034,6 +6034,7 @@ async fn make_test_app() -> Box<App> {
reconnect: Default::default(),
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
primary: false,
platform_default: true,
ssh: false,
wsl: false,
@@ -6149,6 +6150,7 @@ pub(super) async fn make_test_app_with_channels() -> (
reconnect: Default::default(),
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
primary: false,
platform_default: true,
ssh: false,
wsl: false,
@@ -10,7 +10,11 @@ use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap;
impl ChatWidget {
pub(crate) fn show_tui_mode_picker(&mut self) {
let items = [
(false, "Scrollback", "Use your terminal's scrollback"),
(
false,
"Scrollback",
"Native terminal copy, paste and scrollback",
),
(true, "Fullscreen", "Scroll within Codex's fullscreen view"),
]
.into_iter()
+1
View File
@@ -19,6 +19,7 @@
//! so callers must distinguish them from confirmed native clipboard writes.
//! Image paste lives in `clipboard_paste`.
pub(crate) mod primary;
mod tmux;
pub(crate) mod worker;
@@ -0,0 +1,62 @@
//! Local X11 PRIMARY selection. Never forward PRIMARY over SSH or tmux, or
//! accidentally target a Wayland compositor or WSL host clipboard.
#[cfg(target_os = "linux")]
use super::ClipboardLease;
use super::CopyOutcome;
use std::time::Instant;
pub(crate) fn available() -> bool {
cfg!(target_os = "linux")
&& std::env::var_os("DISPLAY").is_some()
&& std::env::var_os("WAYLAND_DISPLAY").is_none()
&& !super::is_ssh_session()
&& !super::is_tmux_session()
&& !super::is_wsl_session()
&& crate::tui::detect_vscode_terminal() != crate::tui::VscodeDetection::VsCode
}
#[cfg(target_os = "linux")]
pub(super) fn copy(
text: &str,
begin_delivery: impl FnOnce() -> Result<(), String>,
) -> Result<CopyOutcome, String> {
use arboard::SetExtLinux;
let mut clipboard = arboard::Clipboard::new().map_err(|err| err.to_string())?;
begin_delivery()?;
clipboard
.set()
.clipboard(arboard::LinuxClipboardKind::Primary)
.text(text)
.map_err(|err| format!("X11 primary selection: {err}"))?;
Ok(CopyOutcome::Copied(Some(ClipboardLease::native_linux(
clipboard,
))))
}
#[cfg(not(target_os = "linux"))]
pub(super) fn copy(
_text: &str,
_begin_delivery: impl FnOnce() -> Result<(), String>,
) -> Result<CopyOutcome, String> {
Err("X11 primary selection unavailable".into())
}
#[cfg(target_os = "linux")]
pub(super) fn read(deadline: Instant) -> Result<String, String> {
use arboard::GetExtLinux;
crate::clipboard_paste::text::native_result(
arboard::Clipboard::new().and_then(|mut clipboard| {
clipboard
.get()
.clipboard(arboard::LinuxClipboardKind::Primary)
.text()
}),
deadline,
)
}
#[cfg(not(target_os = "linux"))]
pub(super) fn read(_deadline: Instant) -> Result<String, String> {
Err("X11 primary selection unavailable".into())
}
+211 -33
View File
@@ -1,8 +1,10 @@
//! One session-lived worker owns blocking clipboard operations and native leases.
//! Setup has a five-second budget, but abandoning it cannot interrupt an OS call.
//! There is no backlog: the worker stays busy until that call returns. Once delivery
//! starts it must finish. Text reads have the same budget for the whole operation; late
//! results are discarded. Terminal escape sequences are emitted by the UI.
//! There is no general backlog: the worker stays busy until that call returns. Only the
//! latest selection publication and one PRIMARY read can wait, with publication ordered
//! first. Their UI owners cancel by dropping their scopes. Once delivery starts it must
//! finish. Text reads have the same budget for the whole operation; late results are
//! discarded. Terminal escape sequences are emitted by the UI.
use super::ClipboardLease;
use super::CopyFormat;
@@ -11,6 +13,7 @@ use crate::tui::FrameRequester;
use std::cell::Cell;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::Weak;
use std::sync::mpsc;
use std::time::Duration;
use std::time::Instant;
@@ -37,6 +40,7 @@ impl Setup {
struct CopySetup {
phase: Mutex<Setup>,
frames: FrameRequester,
owner: Option<Weak<()>>,
}
impl CopySetup {
@@ -48,6 +52,14 @@ impl CopySetup {
if setup.timed_out(Instant::now()) {
return Err(SETUP_TIMEOUT_MESSAGE.into());
}
if matches!(*setup, Setup::Pending(_))
&& self
.owner
.as_ref()
.is_some_and(|owner| owner.strong_count() == 0)
{
return Err("selection ended before clipboard delivery".into());
}
*setup = Setup::Delivering;
Ok(())
}
@@ -69,13 +81,29 @@ impl CopySetup {
pub(crate) type CopyResult = Result<CopyStatus, String>;
/// A mouse selection may also retain the configured automatic clipboard copy.
#[derive(Clone, PartialEq, Eq)]
pub(crate) enum CopyDestination {
Clipboard,
Primary,
ClipboardAndPrimary(Arc<str>),
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum PasteSource {
Clipboard,
Primary,
}
enum Request {
Copy {
text: Arc<str>,
format: CopyFormat,
destination: CopyDestination,
setup: Arc<CopySetup>,
},
Read {
source: PasteSource,
deadline: Instant,
response: mpsc::Sender<Result<String, String>>,
},
@@ -84,7 +112,9 @@ enum Request {
struct PendingRead {
frames: FrameRequester,
deadline: Instant,
response: mpsc::Receiver<Result<String, String>>,
source: PasteSource,
owner: Weak<()>,
response: Option<mpsc::Receiver<Result<String, String>>>,
expired: bool,
}
@@ -100,13 +130,18 @@ pub(crate) struct ClipboardWorker {
pending: Option<(u64, Arc<CopySetup>)>,
next_id: u64,
completed: Option<(u64, CopyResult)>,
primary: Option<(Arc<str>, Weak<()>)>,
pending_read: Option<PendingRead>,
read_result: Option<(Instant, Result<String, String>)>,
read_result: Option<(Instant, Weak<()>, Result<String, String>)>,
}
impl ClipboardWorker {
pub(crate) fn is_busy(&self) -> bool {
self.pending.is_some() || self.pending_read.is_some()
self.pending.is_some()
|| self
.pending_read
.as_ref()
.is_some_and(|read| read.response.is_some())
}
pub(crate) fn copy(
@@ -114,6 +149,46 @@ impl ClipboardWorker {
text: Arc<str>,
format: CopyFormat,
frames: FrameRequester,
) -> CopyResult {
self.copy_to(
text,
format,
CopyDestination::Clipboard,
frames,
/*owner*/ None,
)
}
/// On a busy automatic copy, retain only PRIMARY, never defer a CLIPBOARD copy.
pub(crate) fn select(
&mut self,
text: Arc<str>,
format: CopyFormat,
destination: CopyDestination,
frames: FrameRequester,
) -> (CopyResult, Arc<()>) {
let owner = Arc::new(());
let primary = match &destination {
CopyDestination::Clipboard => None,
CopyDestination::Primary => Some(Arc::clone(&text)),
CopyDestination::ClipboardAndPrimary(plain_text) => Some(Arc::clone(plain_text)),
};
self.primary = None;
let scope = primary.as_ref().map(|_| Arc::downgrade(&owner));
let result = self.copy_to(text, format, destination, frames, scope);
if result == Ok(CopyStatus::Busy) {
self.primary = primary.map(|text| (text, Arc::downgrade(&owner)));
}
(result, owner)
}
fn copy_to(
&mut self,
text: Arc<str>,
format: CopyFormat,
destination: CopyDestination,
frames: FrameRequester,
owner: Option<Weak<()>>,
) -> CopyResult {
if self.is_busy() {
return Ok(CopyStatus::Busy);
@@ -127,6 +202,7 @@ impl ClipboardWorker {
let setup = Arc::new(CopySetup {
phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)),
frames: frames.clone(),
owner,
});
self.requests
.as_ref()
@@ -134,6 +210,7 @@ impl ClipboardWorker {
.send(Request::Copy {
text,
format,
destination,
setup: Arc::clone(&setup),
})
.map_err(|_| "clipboard worker stopped".to_string())?;
@@ -169,32 +246,58 @@ impl ClipboardWorker {
Ok(())
}
/// Start one text read. A timed-out native call keeps the shared worker occupied.
pub(crate) fn read_text(&mut self, frames: FrameRequester) -> Result<bool, String> {
if self.is_busy() {
return Ok(false);
/// Accept one text read. PRIMARY may wait behind its publication; CLIPBOARD never queues.
/// The caller must retain the scope until it consumes or cancels the read.
pub(crate) fn read_text(
&mut self,
source: PasteSource,
frames: FrameRequester,
) -> Result<Option<Arc<()>>, String> {
if self.pending_read.is_some() || (source == PasteSource::Clipboard && self.is_busy()) {
return Ok(None);
}
self.ensure_started(frames.clone())?;
let deadline = Instant::now() + SETUP_TIMEOUT;
let owner = Arc::new(());
let wait = self.is_busy() || (source == PasteSource::Primary && self.primary.is_some());
self.pending_read = Some(PendingRead {
frames: frames.clone(),
deadline,
source,
owner: Arc::downgrade(&owner),
response: None,
expired: false,
});
self.read_result = None;
if !wait {
self.start_read()?;
}
frames.schedule_frame_in(SETUP_TIMEOUT);
Ok(Some(owner))
}
fn start_read(&mut self) -> Result<(), String> {
let read = self
.pending_read
.as_mut()
.ok_or("no clipboard read pending")?;
let (response, receiver) = mpsc::channel();
self.requests
.as_ref()
.ok_or("clipboard worker stopped")?
.send(Request::Read { deadline, response })
.send(Request::Read {
source: read.source,
deadline: read.deadline,
response,
})
.map_err(|_| "clipboard worker stopped".to_string())?;
self.pending_read = Some(PendingRead {
frames: frames.clone(),
deadline,
response: receiver,
expired: false,
});
self.read_result = None;
frames.schedule_frame_in(SETUP_TIMEOUT);
Ok(true)
read.response = Some(receiver);
Ok(())
}
pub(crate) fn take_text_result(&mut self) -> Option<Result<String, String>> {
self.read_result.take().map(|(deadline, result)| {
let (deadline, owner, result) = self.read_result.take()?;
(owner.strong_count() > 0).then(|| {
if Instant::now() >= deadline {
Err("clipboard read timed out".into())
} else {
@@ -203,6 +306,40 @@ impl ClipboardWorker {
})
}
/// Run deferred work only at an idle draw boundary, after the UI validates its owners.
/// Polling a completion before a key must never preempt that key with an automatic copy.
pub(crate) fn advance(&mut self, frames: FrameRequester) {
if self.is_busy() {
return;
}
// Target validation may have dropped the scope after this draw's poll.
if self
.pending_read
.as_ref()
.is_some_and(|read| read.owner.strong_count() == 0)
{
self.pending_read = None;
}
if let Some((text, owner)) = self.primary.take()
&& owner.strong_count() > 0
{
let _ = self.copy_to(
text,
CopyFormat::PlainText,
CopyDestination::Primary,
frames,
Some(owner),
);
}
if !self.is_busy()
&& self.pending_read.is_some()
&& let Err(error) = self.start_read()
&& let Some(read) = self.pending_read.take()
{
self.read_result = Some((read.deadline, read.owner, Err(error)));
}
}
fn start(
&mut self,
frames: FrameRequester,
@@ -222,15 +359,45 @@ impl ClipboardWorker {
.name("clipboard-copy".into())
.spawn(move || {
let mut lease: Option<ClipboardLease> = None;
// An ordinary copy must not relinquish PRIMARY, or vice versa.
let mut primary_lease: Option<ClipboardLease> = None;
while let Ok(request) = incoming.recv() {
let (outcome, terminal_text) = match request {
Request::Copy {
text,
format,
destination,
setup,
} => copy(&text, format, &setup),
Request::Read { deadline, response } => {
let _ = response.send(read(deadline));
} => {
let plain_text = match &destination {
CopyDestination::Clipboard => None,
CopyDestination::Primary => Some(&text),
CopyDestination::ClipboardAndPrimary(plain_text) => {
Some(plain_text)
}
};
let primary = plain_text.map(|text| {
super::primary::copy(text, || setup.begin_delivery())
.map(|outcome| outcome.store(&mut primary_lease))
});
match primary {
Some(result) if destination == CopyDestination::Primary => {
(result.map(|_| super::CopyOutcome::Requested), None)
}
// Unavailable PRIMARY must not break configured CLIPBOARD copy.
_ => copy(&text, format, &setup),
}
}
Request::Read {
source,
deadline,
response,
} => {
let result = match source {
PasteSource::Clipboard => read(deadline),
PasteSource::Primary => super::primary::read(deadline),
};
let _ = response.send(result);
frames.schedule_frame();
continue;
}
@@ -249,7 +416,7 @@ impl ClipboardWorker {
}
// Finish Linux clipboard-manager handoff before disconnecting the response channel.
#[cfg(target_os = "linux")]
drop(lease);
drop((lease, primary_lease));
drop(outgoing);
})
.map_err(|error| format!("could not start clipboard worker: {error}"))?;
@@ -261,25 +428,36 @@ impl ClipboardWorker {
/// Poll only while the UI owns the terminal. Retain one result for its original consumer.
pub(crate) fn poll(&mut self) -> Option<&(u64, CopyResult)> {
if let Some(read) = &mut self.pending_read {
if read.owner.strong_count() == 0 {
read.expired = true;
}
if !read.expired && Instant::now() >= read.deadline {
read.expired = true;
self.read_result = Some((read.deadline, Err("clipboard read timed out".into())));
self.read_result = Some((
read.deadline,
read.owner.clone(),
Err("clipboard read timed out".into()),
));
}
match read.response.try_recv() {
Ok(result) => {
match read.response.as_ref().map(mpsc::Receiver::try_recv) {
Some(Ok(result)) => {
if !read.expired {
self.read_result = Some((read.deadline, result));
self.read_result = Some((read.deadline, read.owner.clone(), result));
}
self.pending_read = None;
}
Err(mpsc::TryRecvError::Disconnected) => {
Some(Err(mpsc::TryRecvError::Disconnected)) => {
if !read.expired {
self.read_result =
Some((read.deadline, Err("clipboard worker stopped".into())));
self.read_result = Some((
read.deadline,
read.owner.clone(),
Err("clipboard worker stopped".into()),
));
}
self.pending_read = None;
}
Err(mpsc::TryRecvError::Empty) => {
None if read.expired => self.pending_read = None,
Some(Err(mpsc::TryRecvError::Empty)) | None => {
if !read.expired {
read.frames.schedule_frame_in(
read.deadline.saturating_duration_since(Instant::now()),
+170 -3
View File
@@ -265,6 +265,7 @@ fn delivery_claim_cannot_be_abandoned() {
let setup = CopySetup {
phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)),
frames: FrameRequester::test_dummy(),
owner: None,
};
assert_eq!(setup.begin_delivery(), Ok(()));
assert!(
@@ -384,14 +385,22 @@ fn expired_text_read_rejects_backlog_and_discards_late_completion() {
},
)
.unwrap();
assert!(worker.read_text(FrameRequester::test_dummy()).unwrap());
let request = worker
.read_text(PasteSource::Clipboard, FrameRequester::test_dummy())
.unwrap()
.unwrap();
worker.pending_read.as_mut().unwrap().deadline = Instant::now();
worker.poll();
assert_eq!(
worker.take_text_result(),
Some(Err("clipboard read timed out".into()))
);
assert!(!worker.read_text(FrameRequester::test_dummy()).unwrap());
assert!(
worker
.read_text(PasteSource::Clipboard, FrameRequester::test_dummy())
.unwrap()
.is_none()
);
release.send(()).unwrap();
let deadline = Instant::now() + SETUP_TIMEOUT;
while worker.is_busy() && Instant::now() < deadline {
@@ -401,9 +410,167 @@ fn expired_text_read_rejects_backlog_and_discards_late_completion() {
assert!(!worker.is_busy());
assert_eq!(worker.take_text_result(), None);
// A completion harvested by a non-draw event must retain its acceptance deadline.
worker.read_result = Some((Instant::now(), Ok("completed but no longer current".into())));
worker.read_result = Some((
Instant::now(),
Arc::downgrade(&request),
Ok("completed but no longer current".into()),
));
assert_eq!(
worker.take_text_result(),
Some(Err("clipboard read timed out".into()))
);
}
#[test]
fn latest_primary_is_published_before_read_but_does_not_preempt_explicit_copy() {
let frames = FrameRequester::test_dummy();
let (requests, incoming) = mpsc::channel();
let (outgoing, responses) = mpsc::channel();
let mut worker = ClipboardWorker::default();
worker.requests = Some(requests);
worker.responses = Some(responses);
let complete = || {
outgoing
.send(Response {
result: Ok(CopyStatus::Confirmed),
terminal_text: None,
})
.unwrap();
};
worker
.copy("original".into(), CopyFormat::PlainText, frames.clone())
.unwrap();
assert!(matches!(incoming.try_recv(), Ok(Request::Copy { .. })));
let (result, _old) = worker.select(
"old".into(),
CopyFormat::PlainText,
CopyDestination::Primary,
frames.clone(),
);
assert_eq!(result, Ok(CopyStatus::Busy));
let (result, _latest) = worker.select(
"**visit the café**".into(),
CopyFormat::Markdown,
CopyDestination::ClipboardAndPrimary("visit the café".into()),
frames.clone(),
);
assert_eq!(result, Ok(CopyStatus::Busy));
let _read = worker
.read_text(PasteSource::Primary, frames.clone())
.unwrap()
.unwrap();
assert!(
worker
.read_text(PasteSource::Clipboard, frames.clone())
.unwrap()
.is_none()
);
assert!(incoming.try_recv().is_err());
complete();
assert_eq!(worker.poll(), Some(&(1, Ok(CopyStatus::Confirmed))));
// This models a completion arriving with a real copy key, before an idle draw.
assert_eq!(
worker.copy("explicit".into(), CopyFormat::PlainText, frames.clone()),
Ok(CopyStatus::Pending(2))
);
assert!(
matches!(incoming.try_recv(), Ok(Request::Copy { text, destination: CopyDestination::Clipboard, .. })
if text.as_ref() == "explicit")
);
worker.advance(frames.clone());
assert!(incoming.try_recv().is_err());
complete();
worker.poll();
worker.advance(frames.clone());
assert!(
matches!(incoming.try_recv(), Ok(Request::Copy { text, format: CopyFormat::PlainText, destination: CopyDestination::Primary, .. })
if text.as_ref() == "visit the café")
);
assert!(incoming.try_recv().is_err());
complete();
worker.poll();
worker.advance(frames);
let Ok(Request::Read {
source: PasteSource::Primary,
response,
..
}) = incoming.try_recv()
else {
panic!("one PRIMARY read must follow the latest publication");
};
response.send(Ok("visit the café".into())).unwrap();
worker.poll();
assert_eq!(worker.take_text_result(), Some(Ok("visit the café".into())));
assert!(incoming.try_recv().is_err());
drop(outgoing);
}
#[test]
fn cancelling_owners_after_poll_drops_deferred_work_without_releasing_worker_early() {
let frames = FrameRequester::test_dummy();
let (requests, incoming) = mpsc::channel();
let (outgoing, responses) = mpsc::channel();
let mut worker = ClipboardWorker::default();
worker.requests = Some(requests);
worker.responses = Some(responses);
worker
.copy("active".into(), CopyFormat::PlainText, frames.clone())
.unwrap();
assert!(matches!(incoming.try_recv(), Ok(Request::Copy { .. })));
let (_, selection) = worker.select(
"cancelled".into(),
CopyFormat::PlainText,
CopyDestination::Primary,
frames.clone(),
);
let read = worker
.read_text(PasteSource::Primary, frames.clone())
.unwrap()
.unwrap();
worker.poll();
drop(selection);
drop(read);
worker.advance(frames.clone());
assert!(worker.is_busy());
assert!(incoming.try_recv().is_err());
outgoing
.send(Response {
result: Ok(CopyStatus::Confirmed),
terminal_text: None,
})
.unwrap();
worker.poll();
worker.advance(frames.clone());
assert!(!worker.is_busy());
assert!(worker.take_text_result().is_none());
assert!(incoming.try_recv().is_err());
let read = worker
.read_text(PasteSource::Primary, frames.clone())
.unwrap()
.unwrap();
let Ok(Request::Read { response, .. }) = incoming.try_recv() else {
panic!("an available read must start immediately");
};
response
.send(Ok("completed before validation".into()))
.unwrap();
worker.poll();
drop(read);
assert!(worker.take_text_result().is_none());
let (_, selection) = worker.select(
"cancelled during setup".into(),
CopyFormat::PlainText,
CopyDestination::Primary,
frames,
);
let Ok(Request::Copy { setup, .. }) = incoming.try_recv() else {
panic!("selection must reach the worker");
};
drop(selection);
assert_eq!(
setup.begin_delivery(),
Err("selection ended before clipboard delivery".into())
);
drop(outgoing);
}
+18 -3
View File
@@ -22,13 +22,28 @@ pub(crate) fn read(deadline: Instant) -> Result<String, String> {
.block_on(read_command(command, deadline));
}
#[cfg(not(target_os = "android"))]
let text = match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) {
{
native_result(
arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_text()),
deadline,
)
}
#[cfg(target_os = "android")]
{
validate(String::new(), deadline)
}
}
#[cfg(not(target_os = "android"))]
pub(crate) fn native_result(
result: Result<String, arboard::Error>,
deadline: Instant,
) -> Result<String, String> {
let text = match result {
Ok(text) => text,
Err(arboard::Error::ContentNotAvailable) => String::new(),
Err(_) => return Err("clipboard text is unavailable".into()),
};
#[cfg(target_os = "android")]
let text = String::new();
validate(text, deadline)
}
+1
View File
@@ -55,6 +55,7 @@ impl Overlay {
) -> Self {
let mut overlay = TranscriptOverlay::new(cells, keymap);
overlay.view.copy_on_select = copy_on_select;
overlay.view.primary_selection = crate::clipboard_copy::primary::available();
Self::Transcript(overlay)
}
+7 -11
View File
@@ -184,7 +184,8 @@ impl TranscriptOverlay {
self.is_done = self.browsing_footer.is_some();
}
}
if matches!(event, TuiEvent::Resume) {
if matches!(event, TuiEvent::Resume | TuiEvent::FocusLost) {
self.view.cancel_primary();
self.view.end_drag();
}
// Apply a queued prompt jump before navigation can move away from it.
@@ -220,10 +221,7 @@ impl TranscriptOverlay {
self.draw(tui)?;
return Ok(());
}
TuiEvent::FocusLost => {
self.view.end_drag();
None
}
TuiEvent::FocusLost => None,
};
if let Some(action) = action {
self.apply_action(tui, action);
@@ -443,15 +441,13 @@ impl TranscriptOverlay {
let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_));
match action {
ViewAction::Changed => {}
ViewAction::PrimarySelection(text) => self.view.publish_primary(tui, &text),
ViewAction::Copy(text)
| ViewAction::CopyOnSelect(text)
| ViewAction::CopyAndFollow(text) => {
let result = self.view.copy_selected_text_with(
&self.cells,
&text,
!copy_on_select,
|text, format| tui.copy_transcript_selection(text, format),
);
let result = self
.view
.copy_selected_text(tui, &self.cells, &text, !copy_on_select);
if resume_following
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
{
+2
View File
@@ -86,6 +86,7 @@ struct VisibleRow {
/// Shared scrolling and interaction state for compact and detailed transcript presentations.
pub(crate) struct TranscriptView {
pub(crate) copy_on_select: bool,
pub(crate) primary_selection: bool,
position: Position,
follow_control: follow_control::FollowControl,
copy_feedback: Option<composer_gap::CopyFeedback>,
@@ -118,6 +119,7 @@ impl Default for TranscriptView {
fn default() -> Self {
Self {
copy_on_select: false,
primary_selection: false,
position: Position::Latest,
follow_control: follow_control::FollowControl::default(),
copy_feedback: None,
@@ -179,3 +179,36 @@ fn copy_on_select_preserves_link_activation_and_ignores_empty_drags() {
}
}
}
#[test]
fn x11_release_preserves_primary_when_auto_copy_is_disabled() {
let cells = vec![cell("visit the café")];
for auto_copy in [false, true] {
let mut view = TranscriptView {
copy_on_select: auto_copy,
primary_selection: true,
..Default::default()
};
render(&mut view, &cells, /*width*/ 20, /*height*/ 1);
for event in [
mouse(MouseEventKind::Down(MouseButton::Left), /*column*/ 0),
mouse(MouseEventKind::Drag(MouseButton::Left), /*column*/ 14),
] {
assert!(matches!(
view.handle_mouse(event, &cells),
Some(ViewAction::Changed)
));
}
let release = mouse(MouseEventKind::Up(MouseButton::Left), /*column*/ 14);
let text = match (auto_copy, view.handle_mouse(release, &cells)) {
(false, Some(ViewAction::PrimarySelection(text)))
| (true, Some(ViewAction::CopyOnSelect(text))) => text,
_ => panic!("mouse release must publish to PRIMARY regardless of auto-copy"),
};
assert_eq!(
(text.as_str(), view.selected_text(&cells).as_deref()),
("visit the café", Some("visit the café"))
);
assert!(view.handle_mouse(release, &cells).is_none());
}
}
+8 -4
View File
@@ -20,6 +20,7 @@ pub(crate) enum ViewAction {
Changed,
Copy(String),
CopyOnSelect(String),
PrimarySelection(String),
CopyAndFollow(String),
OpenLink(String),
}
@@ -269,10 +270,13 @@ impl TranscriptView {
if let Some(link) = link {
return Some(ViewAction::OpenLink(link));
}
if self.copy_on_select
&& let Some(text) = selected.filter(|text| !text.is_empty())
{
return Some(ViewAction::CopyOnSelect(text));
if let Some(text) = selected.filter(|text| !text.is_empty()) {
if self.copy_on_select {
return Some(ViewAction::CopyOnSelect(text));
}
if self.primary_selection {
return Some(ViewAction::PrimarySelection(text));
}
}
}
_ => return None,
@@ -6,7 +6,11 @@ use ratatui::layout::Position as ScreenPosition;
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;
struct PendingCopy {
id: u64,
@@ -20,6 +24,7 @@ struct PendingCopy {
pub(super) struct Selection {
pending_copy: Option<PendingCopy>,
primary_owner: Option<Arc<()>>,
pub(super) snapshot: ViewSnapshot,
pub(super) start: Anchor,
pub(super) end: Anchor,
@@ -62,6 +67,7 @@ impl TranscriptView {
let was_following = self.is_following();
self.selection = Some(Selection {
pending_copy: None,
primary_owner: None,
snapshot,
start,
end,
@@ -137,6 +143,7 @@ impl TranscriptView {
let origin = selection.origin;
let backwards = (self.resolve(&cells, end), end.offset)
< (self.resolve(&cells, origin.0), origin.0.offset);
let previous = (selection.start, selection.end);
selection.start = if backwards { origin.1 } else { origin.0 };
selection.end = Anchor {
offset: if backwards { range.start } else { range.end },
@@ -148,6 +155,9 @@ impl TranscriptView {
selection.pointer = Some(ScreenPosition::new(column, row));
selection.preferred_column = None;
self.pin_selection_range(&cells, &mut selection);
if previous != (selection.start, selection.end) {
selection.primary_owner = None;
}
self.selection = Some(selection);
}
@@ -214,6 +224,58 @@ impl TranscriptView {
Some(text)
}
/// 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(),
CopyFormat::PlainText,
CopyDestination::Primary,
tui.frame_requester(),
);
if let Some(selection) = &mut self.selection {
selection.primary_owner = Some(owner);
}
}
/// Both transcript surfaces honor the same automatic-copy and plaintext PRIMARY semantics.
pub(crate) fn copy_selected_text(
&mut self,
tui: &mut Tui,
cells: &[Arc<dyn HistoryCell>],
text: &str,
clear_selection: bool,
) -> CopyResult {
let publish_primary = !clear_selection && self.primary_selection;
let mut primary_owner = None;
let result =
self.copy_selected_text_with(cells, text, clear_selection, |copy_text, format| {
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())
}
});
if publish_primary && let Some(selection) = &mut self.selection {
selection.primary_owner = primary_owner;
}
result
}
/// Hide or cancel deferred publication without relinquishing the existing X11 selection.
pub(crate) fn cancel_primary(&mut self) {
if let Some(selection) = &mut self.selection {
selection.primary_owner = None;
}
}
/// Track delivery for the current selection. Explicit copies release it on confirmation;
/// automatic copies, failures, and unacknowledged terminal writes retain the revision.
pub(crate) fn copy_selected_text_with(
@@ -370,6 +432,9 @@ impl TranscriptView {
return true;
};
if let Some(mut selection) = self.selection.take() {
if selection.end != next {
selection.primary_owner = None;
}
selection.end = next;
selection.dragging = false;
selection.preferred_column = column;