Add configurable right-click paste to the fullscreen TUI (#48118)

## What changed

- Add `tui.right_click_paste` with `auto`, `on`, and `off` modes. The default `auto` enables the fallback on Windows and Linux, including WSL when terminal detection is conclusive. `on` also enables macOS. Both modes skip SSH sessions and recognized VS Code terminals.
- Paste clipboard text through the normal composer input path when no selection, search, or blocking view takes precedence. Discard pending results after intervening input, focus loss, or changes to the target thread, draft, or cursor.
- Read text asynchronously through the shared clipboard worker, with a five-second deadline and the message size limit. Read the Windows clipboard through PowerShell on WSL and reject overlapping clipboard operations.

## Testing

Add coverage for platform and terminal policy, normal paste delivery, stale reads, overlapping clipboard operations, read timeouts, oversized text, and large command output.

GitOrigin-RevId: 0a7b6d445c09928da6c5335841ba2a9a66cc7e4b
This commit is contained in:
Felipe Coury
2026-09-25 15:15:56 +00:00
committed by copyberry
parent bf832f4678
commit b8a1fe4afc
29 changed files with 755 additions and 71 deletions
+19
View File
@@ -741,6 +741,19 @@ pub enum TuiPetAnchor {
ScreenBottom,
}
/// Right-click text paste when the fullscreen TUI has no selection.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum RightClickPaste {
/// Enable on Windows/WSL/Linux, except recognized terminal-owned paste paths.
#[default]
Auto,
/// Enable on supported local platforms; selection and terminal-owned paste still win.
On,
/// Leave right-click paste to the terminal.
Off,
}
/// When transcript mouse selections are copied on release.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
@@ -857,6 +870,12 @@ pub struct Tui {
#[serde(default)]
pub copy_on_select: CopyOnSelect,
/// Right-click text paste fallback. Defaults to `auto` (Windows/WSL/Linux).
/// `on` also enables macOS; neither mode reads over SSH or in recognized VS Code terminals.
/// This controls the fullscreen fallback, not the terminal's own paste binding.
#[serde(default)]
pub right_click_paste: RightClickPaste,
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// - `auto` (default): Use alternate screen.
+35
View File
@@ -3857,6 +3857,32 @@
}
]
},
"RightClickPaste": {
"description": "Right-click text paste when the fullscreen TUI has no selection.",
"oneOf": [
{
"description": "Enable on Windows/WSL/Linux, except recognized terminal-owned paste paths.",
"enum": [
"auto"
],
"type": "string"
},
{
"description": "Enable on supported local platforms; selection and terminal-owned paste still win.",
"enum": [
"on"
],
"type": "string"
},
{
"description": "Leave right-click paste to the terminal.",
"enum": [
"off"
],
"type": "string"
}
]
},
"RolloutBudgetConfigToml": {
"additionalProperties": false,
"properties": {
@@ -4636,6 +4662,15 @@
"default": null,
"description": "Working directory to use when resuming or forking a session. When unset, prompt if the current and session directories differ."
},
"right_click_paste": {
"allOf": [
{
"$ref": "#/definitions/RightClickPaste"
}
],
"default": "auto",
"description": "Right-click text paste fallback. Defaults to `auto` (Windows/WSL/Linux). `on` also enables macOS; neither mode reads over SSH or in recognized VS Code terminals. This controls the fullscreen fallback, not the terminal's own paste binding."
},
"screen_reader_detection_done": {
"description": "Records the one-time screen-reader detection attempt. Either value skips detection.",
"type": "boolean"
+2
View File
@@ -1271,6 +1271,7 @@ fn config_toml_deserializes_model_availability_nux() {
raw_output_mode: false,
fullscreen_transcript: true,
copy_on_select: Default::default(),
right_click_paste: Default::default(),
alternate_screen: AltScreenMode::default(),
status_line: None,
status_line_use_colors: true,
@@ -4412,6 +4413,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
raw_output_mode: false,
fullscreen_transcript: true,
copy_on_select: Default::default(),
right_click_paste: Default::default(),
alternate_screen: AltScreenMode::Auto,
status_line: None,
status_line_use_colors: true,
+8
View File
@@ -792,6 +792,9 @@ pub struct Config {
/// Override the terminal-specific default for copying transcript mouse selections.
pub tui_copy_on_select: codex_config::types::CopyOnSelect,
/// Right-click text paste fallback for the fullscreen TUI.
pub tui_right_click_paste: codex_config::types::RightClickPaste,
/// Start the TUI in the specified collaboration mode (plan/default).
/// Controls whether the TUI uses the terminal's alternate screen buffer.
@@ -4498,6 +4501,11 @@ impl Config {
.as_ref()
.map(|tui| tui.copy_on_select)
.unwrap_or_default(),
tui_right_click_paste: cfg
.tui
.as_ref()
.map(|tui| tui.right_click_paste)
.unwrap_or_default(),
tui_alternate_screen: cfg
.tui
.as_ref()
@@ -288,6 +288,7 @@ async fn new_config(
model_availability_nux: ModelAvailabilityNuxConfig::default(),
tui_fullscreen_transcript: false,
tui_copy_on_select: Default::default(),
tui_right_click_paste: Default::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
+7 -1
View File
@@ -251,6 +251,7 @@ mod reconnect;
mod replay_filter;
mod resize_reflow;
mod resume_config;
mod right_click_paste;
mod safety_buffering;
mod server_version_notice;
mod session_lifecycle;
@@ -621,6 +622,8 @@ pub(crate) struct App {
environment_manager: Arc<EnvironmentManager>,
app_server_target: AppServerTarget,
reconnect: reconnect::ReconnectState,
pending_right_click_paste: Option<right_click_paste::PendingPaste>,
right_click_paste_environment: right_click_paste::PasteEnvironment,
/// Set when the user confirms an update; propagated on exit.
daemon_cli_executable: Option<AbsolutePathBuf>,
pub(crate) pending_update_action: Option<UpdateAction>,
@@ -867,7 +870,9 @@ impl App {
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<AppRunControl> {
self.invalidate_right_click_paste(&event);
self.finish_clipboard(tui);
let event = self.finish_right_click_paste(tui, event);
if matches!(&event, TuiEvent::Key(_))
&& self.handle_composer_copy_event(tui, &event, |tui, text| {
tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText)
@@ -1158,7 +1163,8 @@ impl App {
self.app_event_tx.send(AppEvent::LaunchExternalEditor);
}
}
TuiEvent::FocusLost | TuiEvent::Mouse(_) => {}
TuiEvent::Mouse(mouse) => self.start_right_click_paste(tui, mouse),
TuiEvent::FocusLost => {}
}
}
Ok(AppRunControl::Continue)
+1 -1
View File
@@ -22,7 +22,7 @@ impl App {
// Finish paste before the next key, but never wait on the copy worker's lock.
if tui.clipboard.is_busy() {
self.chat_widget.add_info_message(
"Copy already in progress; try image paste after it finishes".into(),
"Clipboard busy; try image paste after it finishes".into(),
/*hint*/ None,
);
} else {
@@ -1364,6 +1364,16 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?);
}
assert!(!app.transcript_view.has_active_interaction());
app.start_right_click_paste(
&mut tui,
crossterm::event::MouseEvent {
kind: Down(Right),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
},
);
assert!(!tui.clipboard.is_busy());
app.render_owned_transcript(&mut tui, size)?;
let cursor = tui.terminal.last_known_cursor_pos;
let draft = app.chat_widget.capture_thread_input_state();
+138
View File
@@ -0,0 +1,138 @@
//! Right-click fallback for an unchanged, editable fullscreen 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::tui::VscodeDetection;
use codex_config::types::RightClickPaste;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
pub(super) struct PendingPaste {
thread: Option<ThreadId>,
draft: (String, usize),
}
pub(super) struct PasteEnvironment {
pub(super) platform_default: bool,
pub(super) ssh: bool,
pub(super) wsl: bool,
pub(super) vscode: VscodeDetection,
}
impl PasteEnvironment {
pub(super) fn detect() -> Self {
Self {
platform_default: cfg!(any(target_os = "windows", target_os = "linux")),
ssh: crate::clipboard_copy::is_ssh_session(),
wsl: crate::clipboard_copy::is_wsl_session(),
vscode: tui::detect_vscode_terminal(),
}
}
fn allows(&self, mode: RightClickPaste) -> bool {
if self.ssh || self.vscode == VscodeDetection::VsCode {
return false;
}
match mode {
RightClickPaste::Off => false,
RightClickPaste::On => !cfg!(target_os = "android"),
RightClickPaste::Auto => {
self.platform_default && !(self.wsl && self.vscode == VscodeDetection::Unknown)
}
}
}
}
impl App {
fn right_click_paste_target(&self, tui: &tui::Tui) -> Option<PendingPaste> {
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)
{
return None;
}
Some(PendingPaste {
thread: self.current_displayed_thread_id(),
draft: 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()
{
return;
}
let Some(target) = self.right_click_paste_target(tui) else {
return;
};
match tui.clipboard.read_text(tui.frame_requester()) {
Ok(true) => self.pending_right_click_paste = Some(target),
Ok(false) => {}
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 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())
}
TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false,
};
if !keep {
self.pending_right_click_paste = None;
}
}
pub(super) fn finish_right_click_paste(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> TuiEvent {
if !matches!(event, TuiEvent::Draw) {
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;
}
match result {
Ok(text) if !text.is_empty() => {
tui.frame_requester().schedule_frame();
TuiEvent::Paste(text)
}
Ok(_) => event,
Err(error) => {
self.chat_widget.add_error_message(error);
event
}
}
}
}
#[cfg(test)]
#[path = "right_click_paste_tests.rs"]
mod tests;
@@ -0,0 +1,33 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn policy_preserves_explicit_modes_and_terminal_ownership() {
use VscodeDetection::Other;
use VscodeDetection::Unknown;
use VscodeDetection::VsCode;
for (platform_default, ssh, wsl, vscode, expected) in [
(true, false, false, Other, [false, true, true]),
(false, false, false, Other, [false, true, false]),
(true, true, false, Other, [false, false, false]),
(true, false, false, VsCode, [false, false, false]),
(true, false, true, Unknown, [false, true, false]),
(true, false, true, Other, [false, true, true]),
] {
let env = PasteEnvironment {
platform_default,
ssh,
wsl,
vscode,
};
assert_eq!(
[
RightClickPaste::Off,
RightClickPaste::On,
RightClickPaste::Auto
]
.map(|mode| env.allows(mode)),
expected
);
}
}
@@ -491,6 +491,7 @@ impl App {
/// replacement widget so that replayed collab items render agent names immediately.
pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
self.chat_widget.clear_prompt_suggestion();
self.pending_right_click_paste = None;
if !self.chat_widget.realtime_conversation_is_running() {
self.retain_realtime_replay_state_before_replace();
}
+2
View File
@@ -787,6 +787,8 @@ See the Codex keymap documentation for supported actions and examples."
feedback_audience,
environment_manager,
app_server_target,
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment::detect(),
reconnect: ReconnectState {
seen_version_notice: initial_server_version_notice
.as_ref()
+7
View File
@@ -64,6 +64,13 @@ pub(crate) async fn make_test_app() -> App {
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
platform_default: true,
ssh: false,
wsl: false,
vscode: crate::tui::VscodeDetection::Other,
},
reconnect: Default::default(),
daemon_cli_executable: None,
pending_update_action: None,
+14
View File
@@ -6038,6 +6038,13 @@ async fn make_test_app() -> Box<App> {
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
reconnect: Default::default(),
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
platform_default: true,
ssh: false,
wsl: false,
vscode: crate::tui::VscodeDetection::Other,
},
daemon_cli_executable: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
@@ -6146,6 +6153,13 @@ pub(super) async fn make_test_app_with_channels() -> (
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
reconnect: Default::default(),
pending_right_click_paste: None,
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
platform_default: true,
ssh: false,
wsl: false,
vscode: crate::tui::VscodeDetection::Other,
},
daemon_cli_executable: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
@@ -5,6 +5,10 @@
//! bursts, especially on Windows. Paste timing uses Tokio's clock so asynchronous flush deadlines
//! and input classification share a clock, including in paused-time tests. Copy shortcuts and right
//! clicks preserve selected draft text.
//! When enabled, fullscreen right-click paste requires an editable composer without a selection,
//! search, or blocking view. The app reads clipboard text asynchronously and delivers a normal
//! paste only while the same thread, draft, and cursor remain eligible. Intervening input or focus
//! loss cancels the pending paste; a late clipboard result cannot overwrite newer input.
//! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible.
//! Owned transcripts keep persistent status below the composer and hints on a separate final row.
//! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row
@@ -11,6 +11,14 @@ use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
impl ChatComposer {
pub(crate) fn can_paste_on_right_click(&self) -> bool {
self.draft.input_enabled
&& !self.blocks_direct_input
&& self.history_search.is_none()
&& self.draft.textarea.vim_query().is_none()
&& self.draft.textarea.mouse_selection_range().is_none()
}
pub(in crate::bottom_pane) fn finish_copy(
&mut self,
completion: &(u64, crate::clipboard_copy::worker::CopyResult),
+5 -2
View File
@@ -1113,9 +1113,8 @@ impl BottomPane {
self.composer.current_text()
}
#[cfg(test)]
pub(crate) fn composer_cursor(&self) -> usize {
self.composer.cursor()
self.composer.current_cursor()
}
#[cfg(test)]
@@ -1789,6 +1788,10 @@ impl BottomPane {
self.composer.copy_selection(event, copy)
}
pub(crate) fn can_paste_on_right_click(&self) -> bool {
self.no_modal_or_popup_active() && self.composer.can_paste_on_right_click()
}
pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) {
self.composer.end_mouse_drag();
@@ -31,6 +31,16 @@ impl ChatWidget {
self.bottom_pane.handle_composer_mouse(event)
}
/// Snapshot only the editable paste target, without changing pending submissions.
pub(crate) fn right_click_paste_target(&self) -> Option<(String, usize)> {
self.bottom_pane.can_paste_on_right_click().then(|| {
(
self.bottom_pane.composer_text(),
self.bottom_pane.composer_cursor(),
)
})
}
pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
self.bottom_pane.prepare_composer_mouse(event)
}
+3 -3
View File
@@ -222,7 +222,7 @@ fn copy_to_clipboard_with(
}
/// Detect whether the current process is running inside an SSH session.
fn is_ssh_session() -> bool {
pub(crate) fn is_ssh_session() -> bool {
std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some()
}
@@ -232,12 +232,12 @@ fn is_tmux_session() -> bool {
}
#[cfg(target_os = "linux")]
fn is_wsl_session() -> bool {
pub(crate) fn is_wsl_session() -> bool {
crate::clipboard_paste::is_probably_wsl()
}
#[cfg(not(target_os = "linux"))]
fn is_wsl_session() -> bool {
pub(crate) fn is_wsl_session() -> bool {
false
}
@@ -1,6 +1,6 @@
---
source: tui/src/clipboard_copy/worker_tests.rs
assertion_line: 82
assertion_line: 112
expression: "feedback.join(\"\\n\")"
---
• Copy already in progress; try image paste after it finishes
• Clipboard busy; try image paste after it finishes
+124 -26
View File
@@ -1,7 +1,8 @@
//! 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. Terminal escape sequences are emitted by the UI.
//! 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;
@@ -68,10 +69,23 @@ impl CopySetup {
pub(crate) type CopyResult = Result<CopyStatus, String>;
struct Request {
text: Arc<str>,
format: CopyFormat,
setup: Arc<CopySetup>,
enum Request {
Copy {
text: Arc<str>,
format: CopyFormat,
setup: Arc<CopySetup>,
},
Read {
deadline: Instant,
response: mpsc::Sender<Result<String, String>>,
},
}
struct PendingRead {
frames: FrameRequester,
deadline: Instant,
response: mpsc::Receiver<Result<String, String>>,
expired: bool,
}
struct Response {
@@ -86,11 +100,13 @@ pub(crate) struct ClipboardWorker {
pending: Option<(u64, Arc<CopySetup>)>,
next_id: u64,
completed: Option<(u64, CopyResult)>,
pending_read: Option<PendingRead>,
read_result: Option<(Instant, Result<String, String>)>,
}
impl ClipboardWorker {
pub(crate) fn is_busy(&self) -> bool {
self.pending.is_some()
self.pending.is_some() || self.pending_read.is_some()
}
pub(crate) fn copy(
@@ -105,23 +121,7 @@ impl ClipboardWorker {
if text.is_empty() {
return Err("nothing to copy: the selected content is empty".into());
}
if self.requests.is_none() {
self.start(frames.clone(), |text, format, setup| {
let terminal_text = Cell::new(/*value*/ None);
let result = super::copy_to_clipboard(
text,
format,
|| setup.begin_delivery(),
|text| {
// Validate the limit before accepting a deferred terminal send.
super::osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
terminal_text.set(Some(text.to_owned()));
Ok(())
},
);
(result, terminal_text.into_inner())
})?;
}
self.ensure_started(frames.clone())?;
self.next_id += 1;
let id = self.next_id;
let setup = Arc::new(CopySetup {
@@ -131,7 +131,7 @@ impl ClipboardWorker {
self.requests
.as_ref()
.ok_or("clipboard worker stopped")?
.send(Request {
.send(Request::Copy {
text,
format,
setup: Arc::clone(&setup),
@@ -144,6 +144,65 @@ impl ClipboardWorker {
Ok(CopyStatus::Pending(id))
}
fn ensure_started(&mut self, frames: FrameRequester) -> Result<(), String> {
if self.requests.is_none() {
self.start(
frames,
|text, format, setup| {
let terminal_text = Cell::new(/*value*/ None);
let result = super::copy_to_clipboard(
text,
format,
|| setup.begin_delivery(),
|text| {
// Validate the limit before accepting a deferred terminal send.
super::osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
terminal_text.set(Some(text.to_owned()));
Ok(())
},
);
(result, terminal_text.into_inner())
},
crate::clipboard_paste::text::read,
)?;
}
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);
}
self.ensure_started(frames.clone())?;
let deadline = Instant::now() + SETUP_TIMEOUT;
let (response, receiver) = mpsc::channel();
self.requests
.as_ref()
.ok_or("clipboard worker stopped")?
.send(Request::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)
}
pub(crate) fn take_text_result(&mut self) -> Option<Result<String, String>> {
self.read_result.take().map(|(deadline, result)| {
if Instant::now() >= deadline {
Err("clipboard read timed out".into())
} else {
result
}
})
}
fn start(
&mut self,
frames: FrameRequester,
@@ -154,6 +213,7 @@ impl ClipboardWorker {
) -> (Result<super::CopyOutcome, String>, Option<String>)
+ Send
+ 'static,
mut read: impl FnMut(Instant) -> Result<String, String> + Send + 'static,
) -> Result<(), String> {
let (requests, incoming) = mpsc::channel::<Request>();
let (outgoing, responses) = mpsc::channel();
@@ -163,8 +223,18 @@ impl ClipboardWorker {
.spawn(move || {
let mut lease: Option<ClipboardLease> = None;
while let Ok(request) = incoming.recv() {
let (outcome, terminal_text) =
copy(&request.text, request.format, &request.setup);
let (outcome, terminal_text) = match request {
Request::Copy {
text,
format,
setup,
} => copy(&text, format, &setup),
Request::Read { deadline, response } => {
let _ = response.send(read(deadline));
frames.schedule_frame();
continue;
}
};
let result = outcome.map(|outcome| outcome.store(&mut lease));
if outgoing
.send(Response {
@@ -190,6 +260,34 @@ 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.expired && Instant::now() >= read.deadline {
read.expired = true;
self.read_result = Some((read.deadline, Err("clipboard read timed out".into())));
}
match read.response.try_recv() {
Ok(result) => {
if !read.expired {
self.read_result = Some((read.deadline, result));
}
self.pending_read = None;
}
Err(mpsc::TryRecvError::Disconnected) => {
if !read.expired {
self.read_result =
Some((read.deadline, Err("clipboard worker stopped".into())));
}
self.pending_read = None;
}
Err(mpsc::TryRecvError::Empty) => {
if !read.expired {
read.frames.schedule_frame_in(
read.deadline.saturating_duration_since(Instant::now()),
);
}
}
}
}
if let Some((id, setup)) = &self.pending {
let id = *id;
if !matches!(self.completed, Some((completed_id, _)) if completed_id == id)
+170 -34
View File
@@ -20,12 +20,16 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion()
let (started, start_rx) = mpsc::channel();
let (release, released) = mpsc::channel();
tui.clipboard
.start(frames.clone(), move |text, format, setup| {
setup.begin_delivery().unwrap();
started.send((text.to_owned(), format)).unwrap();
released.recv().unwrap();
(Ok(CopyOutcome::Copied(Some(ClipboardLease::test()))), None)
})
.start(
frames.clone(),
move |text, format, setup| {
setup.begin_delivery().unwrap();
started.send((text.to_owned(), format)).unwrap();
released.recv().unwrap();
(Ok(CopyOutcome::Copied(Some(ClipboardLease::test()))), None)
},
|_| unreachable!("copy-only backend"),
)
.unwrap();
let mut app = Box::new(crate::app::test_support::make_test_app().await);
let (mut chat, tx, mut events, _ops) =
@@ -69,12 +73,10 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion()
.unwrap(),
("café\nsecond line".into(), CopyFormat::Markdown)
);
for _ in 0..100 {
assert_eq!(
tui.copy_transcript_selection("newer", CopyFormat::PlainText),
Ok(CopyStatus::Busy)
);
}
assert_eq!(
tui.copy_transcript_selection("newer", CopyFormat::PlainText),
Ok(CopyStatus::Busy)
);
assert!(tui.clipboard.poll().is_none());
let mut overlay = Overlay::new_transcript(
vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))],
@@ -126,12 +128,16 @@ fn dropping_worker_does_not_wait_for_blocked_backend() {
let (release, released) = mpsc::channel();
let (finished, finish_rx) = mpsc::channel();
worker
.start(FrameRequester::test_dummy(), move |_, _, _| {
started.send(()).unwrap();
released.recv().unwrap();
finished.send(()).unwrap();
(Err("unavailable".into()), None)
})
.start(
FrameRequester::test_dummy(),
move |_, _, _| {
started.send(()).unwrap();
released.recv().unwrap();
finished.send(()).unwrap();
(Err("unavailable".into()), None)
},
|_| unreachable!("copy-only backend"),
)
.unwrap();
worker
.copy(
@@ -160,15 +166,19 @@ async fn expired_setup_discards_delivery_and_keeps_worker_busy_until_it_returns(
let (release, released) = mpsc::channel();
let (writes, write_rx) = mpsc::channel();
worker
.start(frames.clone(), move |text, _, setup| {
started.send(()).unwrap();
released.recv().unwrap();
let outcome = setup.begin_delivery().map(|()| {
writes.send(text.to_owned()).unwrap();
CopyOutcome::Copied(None)
});
(outcome, None)
})
.start(
frames.clone(),
move |text, _, setup| {
started.send(()).unwrap();
released.recv().unwrap();
let outcome = setup.begin_delivery().map(|()| {
writes.send(text.to_owned()).unwrap();
CopyOutcome::Copied(None)
});
(outcome, None)
},
|_| unreachable!("copy-only backend"),
)
.unwrap();
worker
.copy("abandoned".into(), CopyFormat::PlainText, frames.clone())
@@ -224,13 +234,9 @@ async fn expired_setup_discards_delivery_and_keeps_worker_busy_until_it_returns(
fn drop_waits_for_response_channel_disconnect() {
let (requests, _incoming) = mpsc::channel();
let (outgoing, responses) = mpsc::sync_channel(/*bound*/ 0);
let mut worker = ClipboardWorker {
requests: Some(requests),
responses: Some(responses),
pending: None,
next_id: 0,
completed: None,
};
let mut worker = ClipboardWorker::default();
worker.requests = Some(requests);
worker.responses = Some(responses);
worker
.copy(
"text".into(),
@@ -271,3 +277,133 @@ fn delivery_claim_cannot_be_abandoned() {
// Backend fallback checks cannot revoke a delivery that already began.
assert_eq!(setup.begin_delivery(), Ok(()));
}
#[tokio::test]
async fn right_click_paste_uses_normal_input_and_discards_stale_reads() {
use codex_config::types::RightClickPaste;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
let mut tui = crate::tui::test_support::make_test_tui().unwrap();
let (started, start_rx) = mpsc::channel();
let (release, released) = mpsc::channel();
tui.clipboard
.start(
tui.frame_requester(),
|_, _, _| unreachable!("no selection"),
move |_| {
started.send(()).unwrap();
released.recv().unwrap();
Ok("café\r\nsecond line\n".into())
},
)
.unwrap();
let mut app = Box::new(crate::app::test_support::make_test_app().await);
app.local_settings.tui.right_click_paste = RightClickPaste::On;
let mut server = crate::start_embedded_app_server_for_picker(&app.config)
.await
.unwrap();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
};
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click))
.await
.unwrap();
assert!(!tui.clipboard.is_busy());
tui.set_owned_screen(/*owned*/ true).unwrap();
for (change, expected) in [
("none", "draft café\nsecond line\n"),
("native", "draft native"),
("edit", "replacement"),
("disable", "draft "),
] {
app.local_settings.tui.right_click_paste = RightClickPaste::On;
app.chat_widget.apply_external_edit("draft ".into());
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click))
.await
.unwrap();
start_rx.recv_timeout(SETUP_TIMEOUT).unwrap();
if change == "none" {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click))
.await
.unwrap();
assert!(start_rx.try_recv().is_err());
assert_eq!(
tui.clipboard
.copy("copy".into(), CopyFormat::PlainText, tui.frame_requester()),
Ok(CopyStatus::Busy)
);
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Right),
..click
}),
)
.await
.unwrap();
} else if change == "native" {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Paste("native".into()))
.await
.unwrap();
} else if change == "edit" {
app.chat_widget.apply_external_edit("replacement".into());
} else if change == "disable" {
app.local_settings.tui.right_click_paste = RightClickPaste::Off;
}
release.send(()).unwrap();
tokio::time::timeout(SETUP_TIMEOUT, async {
while tui.clipboard.is_busy() {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
.await
.unwrap();
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
}
}
#[test]
fn expired_text_read_rejects_backlog_and_discards_late_completion() {
let mut worker = ClipboardWorker::default();
let (release, released) = mpsc::channel();
worker
.start(
FrameRequester::test_dummy(),
|_, _, _| unreachable!(),
move |_| {
released.recv().unwrap();
Ok("late".into())
},
)
.unwrap();
assert!(worker.read_text(FrameRequester::test_dummy()).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());
release.send(()).unwrap();
let deadline = Instant::now() + SETUP_TIMEOUT;
while worker.is_busy() && Instant::now() < deadline {
worker.poll();
std::thread::yield_now();
}
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())));
assert_eq!(
worker.take_text_result(),
Some(Err("clipboard read timed out".into()))
);
}
+4
View File
@@ -1,3 +1,7 @@
//! Read clipboard images and text on the TUI host. Text reads run on the clipboard worker.
pub(crate) mod text;
use std::path::Path;
use std::path::PathBuf;
use tempfile::Builder;
+96
View File
@@ -0,0 +1,96 @@
//! Local clipboard text reads, executed only by the shared clipboard worker.
//! WSL reads the Windows clipboard; it never falls back to a separate Linux clipboard.
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use std::time::Instant;
pub(crate) fn read(deadline: Instant) -> Result<String, String> {
if crate::clipboard_copy::is_ssh_session() {
return Err("clipboard text is unavailable over SSH".into());
}
#[cfg(target_os = "linux")]
if super::is_probably_wsl() {
let executable = codex_utils_path::system_executable("powershell.exe")
.ok_or("Windows clipboard reader is unavailable")?;
let mut command = tokio::process::Command::new(executable);
command.args(["-NoProfile", "-NonInteractive", "-Command",
"$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::Out.Write((Get-Clipboard -Raw))"]);
return tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|_| "could not start clipboard reader")?
.block_on(read_command(command, deadline));
}
#[cfg(not(target_os = "android"))]
let text = match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) {
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)
}
fn validate(text: String, deadline: Instant) -> Result<String, String> {
if Instant::now() >= deadline {
Err("clipboard read timed out".into())
} else if text.chars().count() > MAX_USER_INPUT_TEXT_CHARS {
Err("clipboard text exceeds the message size limit".into())
} else {
Ok(text)
}
}
#[cfg(any(target_os = "linux", all(test, unix)))]
async fn read_command(
mut command: tokio::process::Command,
deadline: Instant,
) -> Result<String, String> {
use std::process::Stdio;
use tokio::io::AsyncReadExt;
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(/*kill_on_drop*/ true)
.spawn()
.map_err(|_| "could not start Windows clipboard reader")?;
// Spawn can itself stall under WSL. Both the UI and this read retain the original deadline.
let result = tokio::time::timeout_at(deadline.into(), async {
let stdout = child
.stdout
.take()
.ok_or("clipboard reader has no output")?;
let limit = MAX_USER_INPUT_TEXT_CHARS * 4;
let mut bytes = Vec::new();
stdout
.take((limit + 1) as u64)
.read_to_end(&mut bytes)
.await
.map_err(|_| "could not read clipboard text")?;
if bytes.len() > limit {
return Err("clipboard text exceeds the message size limit");
}
if !child
.wait()
.await
.map_err(|_| "clipboard reader failed")?
.success()
{
return Err("clipboard text is unavailable");
}
String::from_utf8(bytes).map_err(|_| "clipboard text is not UTF-8")
})
.await
.unwrap_or(Err("clipboard read timed out"));
if result.is_err() {
let _ = child.kill().await;
}
validate(result.map_err(str::to_owned)?, deadline)
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;
@@ -0,0 +1,38 @@
use super::*;
use pretty_assertions::assert_eq;
use std::time::Duration;
#[test]
fn text_preserves_whitespace_and_rejects_late_or_oversized_reads() {
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 5);
assert_eq!(
validate(" café\r\n\t".into(), deadline),
Ok(" café\r\n\t".into())
);
assert!(validate("late".into(), Instant::now()).is_err());
assert!(validate("x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1), deadline).is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn command_drains_large_output_and_bounds_waiting() {
let mut command = tokio::process::Command::new("sh");
command.args([
"-c",
"head -c 131072 /dev/zero | tr '\\000' x; printf '\\n'",
]);
assert_eq!(
read_command(command, Instant::now() + Duration::from_secs(/*secs*/ 5)).await,
Ok(format!("{}\n", "x".repeat(/*n*/ 131072)))
);
let mut command = tokio::process::Command::new("sh");
command.args(["-c", "exec sleep 10"]);
assert_eq!(
read_command(
command,
Instant::now() + Duration::from_millis(/*millis*/ 20)
)
.await,
Err("clipboard read timed out".into())
);
}
+1
View File
@@ -78,6 +78,7 @@ impl LocalSettings {
raw_output_mode: config.tui_raw_output_mode,
fullscreen_transcript: config.tui_fullscreen_transcript,
copy_on_select: config.tui_copy_on_select,
right_click_paste: config.tui_right_click_paste,
alternate_screen: config.tui_alternate_screen,
status_line: config.tui_status_line.clone(),
status_line_use_colors: config.tui_status_line_use_colors,
+7
View File
@@ -2,6 +2,7 @@ use super::*;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
use codex_config::LoaderOverrides;
use codex_config::types::RightClickPaste;
use codex_config::types::SessionPickerViewMode;
use codex_terminal_detection::Multiplexer;
use pretty_assertions::assert_eq;
@@ -19,6 +20,7 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<()
.await?;
config.tui_fullscreen_transcript = true;
config.tui_copy_on_select = CopyOnSelect::Always;
config.tui_right_click_paste = RightClickPaste::On;
config.tui_alternate_screen = AltScreenMode::Auto;
for (alternate_screen, owned, expected_mode, expected_alt) in [
@@ -38,11 +40,13 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<()
let mut reloaded_config = config.clone();
reloaded_config.tui_fullscreen_transcript = false;
reloaded_config.tui_copy_on_select = CopyOnSelect::Never;
reloaded_config.tui_right_click_paste = RightClickPaste::Off;
reloaded_config.tui_alternate_screen = AltScreenMode::Never;
reloaded_config.tui_theme = Some("nord".into());
let mut expected = LocalSettings::from(&reloaded_config);
expected.transcript_mode = expected_mode;
expected.tui.alternate_screen = expected_alt;
expected.tui.right_click_paste = RightClickPaste::Off;
assert_eq!(local.reloaded(&reloaded_config), expected);
assert_eq!(LocalSettings::for_tui(&reloaded_config, &tui), expected);
tui.set_owned_screen(/*owned*/ false)?;
@@ -102,6 +106,7 @@ show_server_version_notice = false
auto_recap = false
fullscreen_transcript = true
copy_on_select = "never"
right_click_paste = "off"
vim_mode_default = true
terminal_resize_reflow_max_rows = 0
session_picker_view = "comfortable"
@@ -130,6 +135,7 @@ fast_default_opt_out = true
})
.cli_overrides(vec![
("tui.disable_paste_burst".into(), true.into()),
("tui.right_click_paste".into(), "on".into()),
// The deprecated flag must not override or migrate into the TUI preference.
(
"features.transcript_v2".into(),
@@ -142,6 +148,7 @@ fast_default_opt_out = true
let local = LocalSettings::from(&config);
let mut expected: Tui = toml::from_str("")?;
expected.disable_paste_burst = Some(true);
expected.right_click_paste = RightClickPaste::On;
expected.session_picker_view = Some(SessionPickerViewMode::Dense);
if !config_text.is_empty() {
expected.animations = false;
+3
View File
@@ -103,6 +103,9 @@ pub(crate) struct InitializedTerminal {
pub(crate) stderr_guard: terminal_stderr::TerminalStderrGuard,
}
pub(crate) use keyboard_modes::VscodeDetection;
pub(crate) use keyboard_modes::detect_vscode_terminal;
pub(crate) fn running_in_vscode_terminal() -> bool {
keyboard_modes::running_in_vscode_terminal()
}
+2 -2
View File
@@ -20,7 +20,7 @@ use ratatui::crossterm::execute;
const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VscodeDetection {
pub(crate) enum VscodeDetection {
VsCode,
Other,
Unknown,
@@ -84,7 +84,7 @@ pub(super) fn running_in_vscode_terminal() -> bool {
detect_vscode_terminal() == VscodeDetection::VsCode
}
fn detect_vscode_terminal() -> VscodeDetection {
pub(crate) fn detect_vscode_terminal() -> VscodeDetection {
if term_program_is_vscode(std::env::var("TERM_PROGRAM").ok().as_deref()) {
return VscodeDetection::VsCode;
}