From 12fd929f724371104a1dea7c14930b078d6b4a18 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 24 Sep 2026 14:05:31 +0000 Subject: [PATCH] Keep the TUI responsive during clipboard copies (#47847) ## Why Native clipboard setup and writes can block, preventing the TUI from processing input or closing overlays while a copy is in progress. ## What changed - Route clipboard copies through one session worker that retains native clipboard ownership. Keep terminal escape output on the UI thread. - Show pending and busy status messages, reject additional copies without queuing them, and ask users to retry image paste after an active copy finishes. - Abandon setup after five seconds and prevent late delivery. Keep the worker busy until the blocked call returns, and bound shutdown waiting for clipboard handoff. - Match completions to their originating requests so stale results cannot clear newer selections or unexpectedly resume transcript following. Retain selections on automatic copies, failures, and unconfirmed delivery. ## Testing Add regression tests for blocked backends, overlay exit, copy and paste ordering, setup expiry, bounded shutdown, completion status messages, and preservation of replaced or moved selections. GitOrigin-RevId: 4ba1f8da258b9d0700b0edec9ac0ba699f536785 --- codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app/clipboard.rs | 54 ++++ codex-rs/tui/src/app/event_dispatch.rs | 9 +- codex-rs/tui/src/app/input.rs | 6 +- codex-rs/tui/src/app/owned_transcript.rs | 22 +- codex-rs/tui/src/app/transcript_export.rs | 4 +- codex-rs/tui/src/app_event.rs | 2 +- .../src/bottom_pane/chat_composer/mouse.rs | 11 + .../bottom_pane/chat_composer/mouse_tests.rs | 23 ++ codex-rs/tui/src/bottom_pane/mod.rs | 9 + .../tui/src/bottom_pane/textarea/mouse.rs | 30 ++ codex-rs/tui/src/chatwidget.rs | 6 +- codex-rs/tui/src/chatwidget/clipboard.rs | 64 +++++ codex-rs/tui/src/chatwidget/constructor.rs | 2 +- codex-rs/tui/src/chatwidget/copy_picker.rs | 2 +- codex-rs/tui/src/chatwidget/interaction.rs | 156 ++++------ .../src/chatwidget/tests/slash_commands.rs | 131 +++------ ...__tests__slash_commands__pending_copy.snap | 6 + .../tui/src/chatwidget/transcript_export.rs | 13 - codex-rs/tui/src/clipboard_copy.rs | 122 +++----- ...tests__image_paste_while_copy_is_busy.snap | 6 + codex-rs/tui/src/clipboard_copy/worker.rs | 250 ++++++++++++++++ .../tui/src/clipboard_copy/worker_tests.rs | 270 ++++++++++++++++++ codex-rs/tui/src/pager_overlay/transcript.rs | 33 ++- codex-rs/tui/src/resume_picker.rs | 1 + .../tui/src/transcript_view/composer_gap.rs | 37 ++- .../src/transcript_view/composer_gap_tests.rs | 82 +++++- .../transcript_view/copy_on_select_tests.rs | 27 +- .../transcript_view/follow_control_tests.rs | 4 +- .../transcript_view/prompt_header_tests.rs | 2 +- .../transcript_view/right_click_copy_tests.rs | 13 +- codex-rs/tui/src/transcript_view/selection.rs | 97 ++++++- .../src/transcript_view/selection_tests.rs | 77 ++++- ..._and_does_not_claim_terminal_delivery.snap | 20 +- ...ives_expiry_and_selection_replacement.snap | 9 + codex-rs/tui/src/tui.rs | 6 +- codex-rs/tui/src/tui/selection_clipboard.rs | 20 +- .../tui/src/tui/selection_clipboard_tests.rs | 54 ---- 38 files changed, 1238 insertions(+), 444 deletions(-) create mode 100644 codex-rs/tui/src/app/clipboard.rs create mode 100644 codex-rs/tui/src/chatwidget/clipboard.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__pending_copy.snap create mode 100644 codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap create mode 100644 codex-rs/tui/src/clipboard_copy/worker.rs create mode 100644 codex-rs/tui/src/clipboard_copy/worker_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__pending_copy_feedback_survives_expiry_and_selection_replacement.snap delete mode 100644 codex-rs/tui/src/tui/selection_clipboard_tests.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9349649f24..3d4268f409 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -233,6 +233,7 @@ mod misalignment_policy; mod model_defaults; mod new_session; pub(crate) use new_session::has_launch_setting; +mod clipboard; mod native_history; mod owned_transcript; mod pending_interactive_replay; @@ -862,6 +863,7 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { + self.finish_clipboard(tui); if matches!(&event, TuiEvent::Key(_)) && self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection) { diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs new file mode 100644 index 0000000000..f43b220f4f --- /dev/null +++ b/codex-rs/tui/src/app/clipboard.rs @@ -0,0 +1,54 @@ +//! Dispatch clipboard key actions in order and apply completions to their selection owner. + +use super::*; +use crate::chatwidget::KeyEventAction; +use crate::clipboard_copy::CopyFormat; + +impl App { + pub(super) fn handle_clipboard_key_action( + &mut self, + tui: &mut tui::Tui, + action: KeyEventAction, + ) { + match action { + KeyEventAction::None => {} + KeyEventAction::CopyLastResponse(text) => { + let result = tui + .clipboard + .copy(text, CopyFormat::Markdown, tui.frame_requester()); + self.chat_widget.show_copy_result("last message", result); + } + KeyEventAction::PasteImage => { + // 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(), + /*hint*/ None, + ); + } else { + self.chat_widget.paste_image(); + } + } + } + } + + pub(super) fn finish_clipboard(&mut self, tui: &mut tui::Tui) { + 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); + } + self.transcript_view.jump_to_latest(); + } + } +} diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 94d35eee71..1c4c986d62 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -41,6 +41,7 @@ impl App { AppEvent::OpenDaemonMenu | AppEvent::OpenWarnings | AppEvent::CopyWarning(_) + | AppEvent::CopySelection { .. } | AppEvent::ConfirmDaemonUpdate(_) | AppEvent::RunDaemonUpdate(_) | AppEvent::InsertHistoryCell(_) @@ -344,13 +345,14 @@ impl App { } AppEvent::OpenWarnings => self.chat_widget.open_warnings(&self.transcript_cells), AppEvent::CopyWarning(text) => { - let _ = self.chat_widget.copy_transcript_selection(&text); + let result = tui.copy_transcript_selection(&text); + self.chat_widget.show_selection_copy_result(result); } AppEvent::OpenTranscriptExportFilePrompt => { self.chat_widget.show_transcript_export_file_prompt(); } AppEvent::ExportTranscript { destination } => { - if let Err(error) = self.export_transcript(app_server, destination).await { + if let Err(error) = self.export_transcript(tui, app_server, destination).await { self.chat_widget .add_error_message(format!("Export failed: {error}")); } @@ -361,7 +363,8 @@ impl App { } } AppEvent::CopySelection { text, label, format } => { - self.chat_widget.copy_selection(text, label, format); + let result = tui.clipboard.copy(text, format, tui.frame_requester()); + self.chat_widget.show_copy_result(&label, result); } AppEvent::ClearUi { name } => { if self.reject_pending_permission_root_switch() { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 4648b3de6b..3bf3754f23 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -586,7 +586,8 @@ impl App { } else if self.should_reject_side_backtrack_esc(key_event) { self.reject_side_backtrack_esc(); } else { - self.chat_widget.handle_key_event(key_event); + let action = self.chat_widget.handle_key_event(key_event); + self.handle_clipboard_key_action(tui, action); } return; } @@ -620,7 +621,8 @@ impl App { self.reset_backtrack_state(); } } - self.chat_widget.handle_key_event(key_event); + let action = self.chat_widget.handle_key_event(key_event); + self.handle_clipboard_key_action(tui, action); } _ => { self.chat_widget.handle_key_event(key_event); diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 650c6ccc12..a902991272 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -461,24 +461,18 @@ impl App { ViewAction::Copy(text) | ViewAction::CopyOnSelect(text) | ViewAction::CopyAndFollow(text) => { - let result = if copy_on_select { - tui.copy_transcript_selection(&text) - } else { - self.transcript_view.copy_selected_text_with( - &self.transcript_cells, - &text, - |text| tui.copy_transcript_selection(text), - ) - }; + let result = self.transcript_view.copy_selected_text_with( + &self.transcript_cells, + &text, + !copy_on_select, + |text| tui.copy_transcript_selection(text), + ); self.transcript_view .show_copy_feedback(&result, text.chars().count()); if resume_following - && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)) + && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_))) { - if self.backtrack.overlay_preview_active { - self.close_transcript_overlay(tui); - } - self.transcript_view.jump_to_latest(); + self.transcript_view.follow_pending_copy(); } } ViewAction::OpenLink(url) => self.open_url_in_browser(url), diff --git a/codex-rs/tui/src/app/transcript_export.rs b/codex-rs/tui/src/app/transcript_export.rs index c2b80a5423..ea0762dcb6 100644 --- a/codex-rs/tui/src/app/transcript_export.rs +++ b/codex-rs/tui/src/app/transcript_export.rs @@ -31,6 +31,7 @@ use crate::thread_transcript::thread_items_to_transcript_cells; impl App { pub(super) async fn export_transcript( &mut self, + tui: &mut crate::tui::Tui, app_server: &mut AppServerSession, destination: TranscriptExportDestination, ) -> Result<(), String> { @@ -54,7 +55,8 @@ impl App { let markdown = render_markdown_transcript(&cells)?; match destination { TranscriptExportDestination::Clipboard => { - self.chat_widget.copy_transcript_to_clipboard(&markdown); + let result = tui.copy_transcript_selection(&markdown); + self.chat_widget.show_copy_result("conversation", result); } TranscriptExportDestination::File(path) => { let cwd = if self.app_server_target.uses_remote_workspace() { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4dbdc87bf5..f6661a8a62 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -442,7 +442,7 @@ pub(crate) enum AppEvent { destination: TranscriptExportDestination, }, - /// Copy a picker selection while retaining its clipboard lease in the chat widget. + /// Copy text through the session clipboard worker. CopySelection { text: Arc, label: String, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs index 5f4ca77893..7a7906e410 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -11,6 +11,14 @@ use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; impl ChatComposer { + pub(in crate::bottom_pane) fn finish_copy( + &mut self, + completion: &(u64, crate::clipboard_copy::worker::CopyResult), + current: bool, + ) -> Option { + self.draft.textarea.finish_copy(completion, current) + } + pub(crate) fn copy_selection( &mut self, event: &TuiEvent, @@ -36,6 +44,9 @@ impl ChatComposer { let text = &self.draft.textarea.text()[range]; let char_count = text.chars().count(); let result = copy(text); + if let Ok(CopyStatus::Pending(id)) = result { + self.draft.textarea.defer_copy(id); + } if result == Ok(CopyStatus::Confirmed) { self.draft.textarea.set_cursor(self.draft.textarea.cursor()); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs index 58e9c7a154..ad266284f5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs @@ -155,4 +155,27 @@ fn right_click_copy_uses_the_refreshed_editor_bounds() { Some((5, Ok(CopyStatus::Unconfirmed))) ); assert_eq!(composer.current_text(), "hello world"); + let event = TuiEvent::Mouse(MouseEvent { row, ..click }); + composer.copy_selection(&event, |_| Ok(CopyStatus::Pending(1))); + // A fresh pointer selection must not be cleared by the older copy. + for (kind, column) in [ + (Down(Left), x - 11), + (MouseEventKind::Drag(Left), x - 6), + (Up(Left), x - 6), + ] { + mouse(&mut composer, shifted_area, kind, column, row); + } + let selection = composer.draft.textarea.mouse_selection_range(); + assert_eq!( + composer.finish_copy(&(1, Ok(CopyStatus::Confirmed)), /*current*/ true), + None + ); + assert_eq!(composer.draft.textarea.mouse_selection_range(), selection); + let key = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + composer.copy_selection(&key, |_| Ok(CopyStatus::Pending(2))); + assert_eq!( + composer.finish_copy(&(2, Ok(CopyStatus::Confirmed)), /*current*/ true), + Some(5) + ); + assert_eq!(composer.draft.textarea.mouse_selection_range(), None); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 443e699388..0e6b006f4f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1745,6 +1745,15 @@ impl BottomPane { self.composer.end_mouse_drag(); } + pub(crate) fn finish_composer_copy( + &mut self, + completion: &(u64, crate::clipboard_copy::worker::CopyResult), + visible: bool, + ) -> Option { + let current = visible && !self.has_active_view(); + self.composer.finish_copy(completion, current) + } + pub(crate) fn copy_composer_selection( &mut self, event: &crate::tui::TuiEvent, diff --git a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs index e3f61fe549..00c2c2adfe 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/mouse.rs @@ -9,6 +9,7 @@ use ratatui::layout::Position; #[derive(Debug)] pub(super) struct MouseSelection { + pending_copy: Option<(u64, Range)>, origin: Range, unit: SelectionUnit, dragging: bool, @@ -16,6 +17,34 @@ pub(super) struct MouseSelection { } impl TextArea { + pub(in crate::bottom_pane) fn defer_copy(&mut self, id: u64) { + if let Some(range) = self.mouse_selection_range() + && let Some(selection) = &mut self.mouse_selection + { + selection.pending_copy = Some((id, range)); + } + } + + pub(in crate::bottom_pane) fn finish_copy( + &mut self, + completion: &(u64, crate::clipboard_copy::worker::CopyResult), + current: bool, + ) -> Option { + let pending = self.mouse_selection.as_mut()?.pending_copy.as_ref()?; + if pending.0 != completion.0 { + return None; + } + let (_, range) = self.mouse_selection.as_mut()?.pending_copy.take()?; + if !current || self.mouse_selection_range().as_ref() != Some(&range) { + return None; + } + let characters = self.text()[range].chars().count(); + if completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed) { + self.set_cursor(self.cursor()); + } + Some(characters) + } + pub(in crate::bottom_pane) fn contains_mouse(&self, event: MouseEvent) -> bool { self.rendered_area .get() @@ -89,6 +118,7 @@ impl TextArea { let clicks = crate::text_selection::click_count(&mut self.last_click, event.column, event.row); MouseSelection { + pending_copy: None, origin: pos..pos, // Padding remains an insertion target on repeated clicks. unit: SelectionUnit::from_clicks(if pos == line.end - 1 { 1 } else { clicks }), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c06ccbb941..cd758ed24d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -301,10 +301,12 @@ mod pets; mod session_flow; mod session_header; use self::session_header::SessionHeader; +mod clipboard; mod copy_picker; mod hook_lifecycle; mod hooks; mod interaction; +pub(crate) use interaction::KeyEventAction; mod skills; mod slash_dispatch; mod worktree_picker; @@ -585,8 +587,8 @@ pub(crate) struct ChatWidget { // Stream lifecycle controller for proposed plan output. plan_stream_controller: Option, pending_stream_consolidations: usize, - /// Holds the platform clipboard lease so copied text remains available while supported. - clipboard_lease: Option, + /// Copy feedback is discarded with its originating conversation. + pending_clipboard: Option, copy_last_response_binding: Vec, running_commands: HashMap, collab_agent_metadata: HashMap, diff --git a/codex-rs/tui/src/chatwidget/clipboard.rs b/codex-rs/tui/src/chatwidget/clipboard.rs new file mode 100644 index 0000000000..03c3cfac11 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/clipboard.rs @@ -0,0 +1,64 @@ +//! Handle clipboard feedback and image paste after the app checks worker availability. +//! Pending feedback belongs to this widget, so switching conversations discards it. + +use super::*; +use crate::clipboard_copy::worker::CopyResult; + +pub(super) enum PendingCopy { + Message(u64, String), + Selection(u64), +} + +impl ChatWidget { + pub(crate) fn paste_image(&mut self) { + match paste_image_to_temp_png() { + Ok((path, info)) => { + tracing::debug!( + "pasted image size={}x{} format={}", + info.width, + info.height, + info.encoded_format.label() + ); + self.attach_image(path); + } + Err(err) => { + tracing::warn!("failed to paste image: {err}"); + self.add_to_history(history_cell::new_error_event(format!( + "Failed to paste image: {err}", + ))); + } + } + } + + pub(super) fn show_clipboard_flash(&mut self, result: &CopyResult) { + let line = match result { + Ok(status) => Line::from(status.message("selection").dim()), + Err(error) => Line::from(format!("Copy failed: {error}").red()), + }; + self.bottom_pane + .show_footer_flash(line, Duration::from_secs(/*secs*/ 3)); + } + + pub(crate) fn finish_clipboard( + &mut self, + completion: &(u64, CopyResult), + composer_visible: bool, + ) -> Option { + let pending_id = match &self.pending_clipboard { + Some(PendingCopy::Message(id, _) | PendingCopy::Selection(id)) => Some(*id), + None => None, + }; + if pending_id == Some(completion.0) { + match self.pending_clipboard.take() { + Some(PendingCopy::Message(_, label)) => match &completion.1 { + Ok(status) => self.add_info_message(status.message(&label), /*hint*/ None), + Err(error) => self.add_error_message(format!("Copy failed: {error}")), + }, + Some(PendingCopy::Selection(_)) => self.show_clipboard_flash(&completion.1), + None => {} + } + } + self.bottom_pane + .finish_composer_copy(completion, composer_visible) + } +} diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index ad28b084db..ec89a156cf 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -171,7 +171,7 @@ impl ChatWidget { stream_controller: None, plan_stream_controller: None, pending_stream_consolidations: 0, - clipboard_lease: None, + pending_clipboard: None, copy_last_response_binding, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), diff --git a/codex-rs/tui/src/chatwidget/copy_picker.rs b/codex-rs/tui/src/chatwidget/copy_picker.rs index 538688e43c..f67f88dcbf 100644 --- a/codex-rs/tui/src/chatwidget/copy_picker.rs +++ b/codex-rs/tui/src/chatwidget/copy_picker.rs @@ -69,7 +69,7 @@ impl ChatWidget { ); } if choices.is_empty() { - self.copy_last_agent_markdown(); + self.prepare_last_response_copy(); return; } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index f763f60790..1703210b06 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -1,8 +1,18 @@ //! Key routing and composer-adjacent UI interaction for `ChatWidget`. +use super::clipboard::PendingCopy; use super::*; use crate::bottom_pane::BottomPaneView; -use crate::clipboard_copy::CopyFormat; +use crate::clipboard_copy::CopyStatus; +use crate::clipboard_copy::worker::CopyResult; + +/// An input action the app must finish before processing the next key. +#[derive(Debug)] +pub(crate) enum KeyEventAction { + None, + CopyLastResponse(Arc), + PasteImage, +} impl ChatWidget { pub(crate) fn end_composer_drag(&mut self) { @@ -33,12 +43,12 @@ impl ChatWidget { self.bottom_pane.keymap_contexts() } - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventAction { if self.handle_startup_submission_key(key_event) { - return; + return KeyEventAction::None; } if self.handle_question_key(key_event) { - return; + return KeyEventAction::None; } if self.bottom_pane.has_active_view() && !matches!( @@ -64,12 +74,12 @@ impl ChatWidget { if self.bottom_pane.no_modal_or_popup_active() { self.on_modal_or_popup_closed(); } - return; + return KeyEventAction::None; } if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(key_event) { self.bottom_pane.handle_key_event(key_event); - return; + return KeyEventAction::None; } if (self.chat_keymap.interrupt_turn.is_pressed(key_event) @@ -87,14 +97,14 @@ impl ChatWidget { } else { self.cancel_image_submission(); } - return; + return KeyEventAction::None; } if self.handle_reasoning_shortcut(key_event) || self.handle_permission_shortcut(key_event) { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; - return; + return KeyEventAction::None; } if key_event.kind == KeyEventKind::Press @@ -103,8 +113,7 @@ impl ChatWidget { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; - self.copy_last_agent_markdown(); - return; + return self.prepare_last_response_copy(); } match key_event { @@ -115,7 +124,7 @@ impl ChatWidget { .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { self.on_ctrl_c(); - return; + return KeyEventAction::None; } KeyEvent { code: KeyCode::Char(c), @@ -124,7 +133,7 @@ impl ChatWidget { .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => { if self.on_ctrl_d() { - return; + return KeyEventAction::None; } self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; @@ -138,24 +147,7 @@ impl ChatWidget { } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) && c.eq_ignore_ascii_case(&'v') => { - match paste_image_to_temp_png() { - Ok((path, info)) => { - tracing::debug!( - "pasted image size={}x{} format={}", - info.width, - info.height, - info.encoded_format.label() - ); - self.attach_image(path); - } - Err(err) => { - tracing::warn!("failed to paste image: {err}"); - self.add_to_history(history_cell::new_error_event(format!( - "Failed to paste image: {err}", - ))); - } - } - return; + return KeyEventAction::PasteImage; } other if other.kind == KeyEventKind::Press => { self.bottom_pane.clear_quit_shortcut_hint(); @@ -178,7 +170,7 @@ impl ChatWidget { } else { self.cancel_image_submission(); } - return; + return KeyEventAction::None; } const REVIEW_STEER_UNAVAILABLE_MESSAGE: &str = "Steer messages aren't supported during /review. Press Ctrl+C now to cancel the review."; @@ -192,7 +184,7 @@ impl ChatWidget { && !self.should_handle_vim_insert_escape(key_event) { self.add_warning_message(REVIEW_STEER_UNAVAILABLE_MESSAGE.to_string()); - return; + return KeyEventAction::None; } if self.chat_keymap.interrupt_turn.is_pressed(key_event) @@ -207,11 +199,11 @@ impl ChatWidget { } else { self.input_queue.submit_pending_steers_after_interrupt = false; } - return; + return KeyEventAction::None; } if self.handle_plugins_popup_key_event(key_event) { - return; + return KeyEventAction::None; } match key_event { @@ -248,6 +240,7 @@ impl ChatWidget { self.handle_composer_input_result(input_result, had_modal_or_popup); } } + KeyEventAction::None } /// Attach a local image to the composer when the active model supports image inputs. @@ -355,94 +348,41 @@ impl ChatWidget { false } - /// Copy the last response as Markdown with HTML for rich-text destinations. - pub(crate) fn copy_last_agent_markdown(&mut self) { - self.copy_last_agent_markdown_with(|text| { - crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::Markdown) - }); - } - - /// Inner implementation with an injectable clipboard backend for testing. - pub(super) fn copy_last_agent_markdown_with( - &mut self, - copy_fn: impl FnOnce(&str) -> Result, - ) { - // The shortcut bypasses composer submission, which normally reveals local feedback. + /// Capture the last response for the app to copy before processing another key. + pub(super) fn prepare_last_response_copy(&mut self) -> KeyEventAction { self.app_event_tx.send(AppEvent::FollowTranscript); - match self.transcript.last_agent_markdown.clone() { + let action = match self.transcript.last_agent_markdown.as_deref() { Some(markdown) if !markdown.is_empty() => { - match self.write_clipboard(&markdown, copy_fn) { - Ok(status) => { - self.add_to_history(history_cell::new_info_event( - status.message("last message"), - /*hint*/ None, - )); - } - Err(error) => self.add_to_history(history_cell::new_error_event(format!( - "Copy failed: {error}" - ))), - } + KeyEventAction::CopyLastResponse(markdown.into()) + } + _ => { + self.add_error_message("No agent response to copy".into()); + KeyEventAction::None } - _ => self.add_to_history(history_cell::new_error_event( - "No agent response to copy".into(), - )), - } - self.request_redraw(); - } - - /// Report a transcript copy without adding history and return its outcome to the viewport. - pub(crate) fn copy_transcript_selection( - &mut self, - text: &str, - ) -> Result { - self.copy_transcript_selection_with(text, |text| { - crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::PlainText) - }) - } - - /// The owned viewport renders its own copy feedback above the composer. - pub(super) fn copy_transcript_selection_with( - &mut self, - text: &str, - copy_fn: impl FnOnce(&str) -> Result, - ) -> Result { - let result = self.write_clipboard(text, copy_fn); - let line = match &result { - Ok(status) => Line::from(status.message("selection").dim()), - Err(error) => Line::from(format!("Copy failed: {error}").red()), }; - self.bottom_pane - .show_footer_flash(line, Duration::from_secs(/*secs*/ 3)); - result + self.request_redraw(); + action } - pub(crate) fn copy_selection(&mut self, text: Arc, label: String, format: CopyFormat) { - self.copy_selection_with(&text, &label, |text| { - crate::clipboard_copy::copy_to_clipboard(text, format) - }); + /// Report a selection copy in the footer without adding history. + pub(crate) fn show_selection_copy_result(&mut self, result: CopyResult) { + if let Ok(CopyStatus::Pending(id)) = result { + self.pending_clipboard = Some(PendingCopy::Selection(id)); + } + self.show_clipboard_flash(&result); } - pub(super) fn copy_selection_with( - &mut self, - text: &str, - label: &str, - copy_fn: impl FnOnce(&str) -> Result, - ) { - match self.write_clipboard(text, copy_fn) { + pub(crate) fn show_copy_result(&mut self, label: &str, result: CopyResult) { + if let Ok(CopyStatus::Pending(id)) = result { + self.pending_clipboard = Some(PendingCopy::Message(id, label.into())); + } + match result { Ok(status) => self.add_info_message(status.message(label), /*hint*/ None), Err(error) => self.add_error_message(format!("Copy failed: {error}")), } self.request_redraw(); } - fn write_clipboard( - &mut self, - text: &str, - copy_fn: impl FnOnce(&str) -> Result, - ) -> Result { - Ok(copy_fn(text)?.store(&mut self.clipboard_lease)) - } - #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.transcript.last_agent_markdown.as_deref() diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2662954e93..1392e14ec4 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1831,10 +1831,7 @@ async fn slash_copy_picker_copies_status_fields_and_preserves_source_after_copyi next_copy_selection(&mut rx), (value.to_string(), label.to_string()) ); - chat.copy_selection_with(value, label, |text| { - assert_eq!(text, value); - Ok(crate::clipboard_copy::CopyOutcome::Copied(None)) - }); + chat.show_copy_result(label, Ok(crate::clipboard_copy::CopyStatus::Confirmed)); drain_insert_history(&mut rx); assert!(chat.bottom_pane.no_modal_or_popup_active()); } @@ -2353,9 +2350,15 @@ async fn slash_keymap_invalid_args_show_usage() { #[tokio::test] async fn copy_shortcut_can_be_remapped() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + for modifiers in [KeyModifiers::CONTROL, KeyModifiers::ALT] { + assert_matches!( + chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), modifiers)), + crate::chatwidget::KeyEventAction::PasteImage + ); + } let mut keymap_config = chat.config_ref().tui_keymap.clone(); keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One( - codex_config::types::KeybindingSpec("ctrl-x".to_string()), + codex_config::types::KeybindingSpec("alt-v".to_string()), )); let runtime_keymap = crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid copy remap"); @@ -2367,7 +2370,10 @@ async fn copy_shortcut_can_be_remapped() { "old copy shortcut should no longer copy" ); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)); + assert_matches!( + chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)), + crate::chatwidget::KeyEventAction::None + ); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one info message"); let rendered = lines_to_single_string(&cells[0]); @@ -2378,79 +2384,35 @@ async fn copy_shortcut_can_be_remapped() { } #[tokio::test] -async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { +async fn copy_shortcut_submits_markdown_and_reports_completion_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.transcript.last_agent_markdown = Some("copy me".to_string()); - - chat.copy_last_agent_markdown_with(|markdown| { - assert_eq!(markdown, "copy me"); - Ok(crate::clipboard_copy::CopyOutcome::Copied(Some( - crate::clipboard_copy::ClipboardLease::test(), - ))) - }); - - assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); - assert!(chat.clipboard_lease.is_some()); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one success message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Copied last message to clipboard"), - "expected success message, got {rendered:?}" + assert_matches!( + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)), + crate::chatwidget::KeyEventAction::CopyLastResponse(text) if &*text == "copy me" ); - - chat.copy_last_agent_markdown_with(|_| Ok(crate::clipboard_copy::CopyOutcome::Requested)); assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); - assert!(chat.clipboard_lease.is_some()); - let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - insta::assert_snapshot!("unconfirmed_copy", rendered); - - chat.copy_last_agent_markdown_with(|markdown| { - assert_eq!(markdown, "copy me"); - Err("blocked".into()) - }); - - assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); - assert!(chat.clipboard_lease.is_some()); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one failure message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Copy failed: blocked"), - "expected failure message, got {rendered:?}" + assert!(rx.try_recv().is_err()); + chat.show_copy_result( + "last message", + Ok(crate::clipboard_copy::CopyStatus::Pending(1)), + ); + let pending = lines_to_single_string(&drain_insert_history(&mut rx)[0]); + insta::assert_snapshot!("pending_copy", pending); + let completion = (1, Ok(crate::clipboard_copy::CopyStatus::Confirmed)); + chat.finish_clipboard(&completion, /*composer_visible*/ true); + let result = lines_to_single_string(&drain_insert_history(&mut rx)[0]); + assert!(result.contains("Copied last message to clipboard")); + chat.finish_clipboard(&completion, /*composer_visible*/ true); + assert!(drain_insert_history(&mut rx).is_empty()); + chat.show_copy_result( + "last message", + Ok(crate::clipboard_copy::CopyStatus::Unconfirmed), + ); + insta::assert_snapshot!( + "unconfirmed_copy", + lines_to_single_string(&drain_insert_history(&mut rx)[0]) ); - - chat.copy_selection_with("print('ok')\n", "python code", |content| { - assert_eq!(content, "print('ok')\n"); - Ok(crate::clipboard_copy::CopyOutcome::Copied(Some( - crate::clipboard_copy::ClipboardLease::test(), - ))) - }); - assert!(chat.clipboard_lease.is_some()); - let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - assert!(rendered.contains("Copied python code to clipboard")); - - chat.copy_selection_with("terminal copy", "code", |_| { - Ok(crate::clipboard_copy::CopyOutcome::Copied(None)) - }); - assert!(chat.clipboard_lease.is_some()); - let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - assert!(rendered.contains("Copied code to clipboard")); - - chat.copy_selection_with("print('blocked')\n", "python code", |content| { - assert_eq!(content, "print('blocked')\n"); - Err("blocked selection".to_string()) - }); - assert!(chat.clipboard_lease.is_some()); - let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - assert!(rendered.contains("Copy failed: blocked selection")); - - chat.copy_selection_with("print('ok')\n", "python code", |_| { - Ok(crate::clipboard_copy::CopyOutcome::Requested) - }); - assert!(chat.clipboard_lease.is_some()); - let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - assert!(rendered.contains("Copy unconfirmed; /export saves chat")); } #[tokio::test] @@ -3637,27 +3599,16 @@ async fn transcript_copy_feedback_stays_in_the_footer_without_history_or_interru let (mut chat, mut events, mut operations) = make_chatwidget_manual(/*model_override*/ None).await; let _ = drain_insert_history(&mut events); - let result = chat.copy_transcript_selection_with("selected café\nsecond line", |text| { - assert_eq!(text, "selected café\nsecond line"); - Ok(crate::clipboard_copy::CopyOutcome::Copied(Some( - crate::clipboard_copy::ClipboardLease::test(), - ))) - }); - assert_eq!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)); + chat.show_selection_copy_result(Ok(crate::clipboard_copy::CopyStatus::Confirmed)); assert_eq!(drain_insert_history(&mut events).len(), 0); assert!(operations.try_recv().is_err()); - assert!(chat.clipboard_lease.is_some()); insta::assert_snapshot!( "transcript_copy_success", render_bottom_popup(&chat, /*width*/ 80) ); - let result = chat.copy_transcript_selection_with("selected café", |_text| { - Ok(crate::clipboard_copy::CopyOutcome::Requested) - }); - assert_eq!(result, Ok(crate::clipboard_copy::CopyStatus::Unconfirmed)); + chat.show_selection_copy_result(Ok(crate::clipboard_copy::CopyStatus::Unconfirmed)); assert_eq!(drain_insert_history(&mut events).len(), 0); assert!(operations.try_recv().is_err()); - assert!(chat.clipboard_lease.is_some()); insta::assert_snapshot!( "transcript_copy_unconfirmed", render_bottom_popup(&chat, /*width*/ 80) @@ -3669,13 +3620,9 @@ async fn transcript_copy_feedback_stays_in_the_footer_without_history_or_interru chat.open_warnings(&[Arc::new(history_cell::new_warning_event( "selected café".into(), ))]); - let result = chat.copy_transcript_selection_with("selected café", |_text| { - Err("clipboard unavailable".to_string()) - }); - assert_eq!(result, Err("clipboard unavailable".to_string())); + chat.show_selection_copy_result(Err("clipboard unavailable".to_string())); assert_eq!(drain_insert_history(&mut events).len(), 0); assert!(operations.try_recv().is_err()); - assert!(chat.clipboard_lease.is_some()); insta::assert_snapshot!( "transcript_copy_failure", render_bottom_popup(&chat, /*width*/ 80) diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__pending_copy.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__pending_copy.snap new file mode 100644 index 0000000000..ccac0d74aa --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__pending_copy.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 2392 +expression: pending +--- +• Copying last message… diff --git a/codex-rs/tui/src/chatwidget/transcript_export.rs b/codex-rs/tui/src/chatwidget/transcript_export.rs index ed235d8041..8c58c46c07 100644 --- a/codex-rs/tui/src/chatwidget/transcript_export.rs +++ b/codex-rs/tui/src/chatwidget/transcript_export.rs @@ -5,19 +5,6 @@ use crate::app_event::TranscriptExportDestination; use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap; impl ChatWidget { - pub(crate) fn copy_transcript_to_clipboard(&mut self, markdown: &str) { - match crate::clipboard_copy::copy_to_clipboard( - markdown, - crate::clipboard_copy::CopyFormat::PlainText, - ) { - Ok(outcome) => { - let status = outcome.store(&mut self.clipboard_lease); - self.add_info_message(status.message("conversation"), /*hint*/ None); - } - Err(error) => self.add_error_message(format!("Copy failed: {error}")), - } - } - pub(super) fn show_transcript_export_popup(&mut self) { self.show_selection_view(SelectionViewParams { header: Box::new( diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index f7e648bff1..7cfd529f34 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -10,7 +10,7 @@ //! //! On Linux, X11 and some Wayland compositors require the process that wrote the //! clipboard to keep its handle open. `ClipboardLease` wraps the `arboard::Clipboard` -//! so callers can store it for the lifetime of the TUI. On other platforms the lease +//! so the copy worker can retain it for the lifetime of the TUI. On other platforms the lease //! is always `None`. //! //! Empty selections fail without modifying a clipboard. @@ -20,6 +20,7 @@ //! Image paste lives in `clipboard_paste`. mod tmux; +pub(crate) mod worker; use base64::Engine; use std::io::Write; @@ -28,10 +29,6 @@ use tmux::copy as tmux_clipboard_copy; /// Maximum raw bytes sent through tmux or directly encoded into OSC 52. /// Large payloads are rejected before encoding to avoid overwhelming the terminal. const OSC52_MAX_RAW_BYTES: usize = 100_000; -#[cfg(target_os = "macos")] -static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = - std::sync::OnceLock::new(); - /// Whether copied text should also have a rendered HTML representation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CopyFormat { @@ -60,11 +57,13 @@ impl CopyOutcome { } } -/// Whether a clipboard backend confirmed the write or only sent a request. +/// Submission progress or the delivery status reported by a clipboard backend. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CopyStatus { Confirmed, Unconfirmed, + Pending(u64), + Busy, } impl CopyStatus { @@ -72,6 +71,8 @@ impl CopyStatus { match self { Self::Confirmed => format!("Copied {label} to clipboard"), Self::Unconfirmed => "Copy unconfirmed; /export saves chat".to_string(), + Self::Pending(_) => format!("Copying {label}…"), + Self::Busy => "Copy already in progress".to_string(), } } } @@ -87,7 +88,24 @@ impl CopyStatus { /// existing native lease unless the backend supplies a replacement. /// /// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. -pub(crate) fn copy_to_clipboard(text: &str, format: CopyFormat) -> Result { +fn copy_to_clipboard( + text: &str, + format: CopyFormat, + begin_delivery: impl Fn() -> Result<(), String>, + osc52: impl Fn(&str) -> Result<(), String>, +) -> Result { + #[cfg(not(target_os = "android"))] + let native_copy = { + let clipboard = arboard::Clipboard::new(); + move |text: &str, html: Option<&str>| arboard_copy(clipboard, text, html) + }; + #[cfg(target_os = "android")] + let native_copy = |_text: &str, _html: Option<&str>| { + Err("native clipboard unavailable on Android".to_string()) + }; + // Decide before propagating setup success or failure: either result could otherwise + // start a native write or fallback long after the user abandoned this request. + begin_delivery()?; copy_to_clipboard_with( text, format, @@ -97,8 +115,8 @@ pub(crate) fn copy_to_clipboard(text: &str, format: CopyFormat) -> Result Result<(), String>, osc52_copy_fn: impl Fn(&str) -> Result<(), String>, - arboard_copy_fn: impl Fn(&str, Option<&str>) -> Result, String>, + arboard_copy_fn: impl FnOnce(&str, Option<&str>) -> Result, String>, wsl_copy_fn: impl Fn(&str) -> Result<(), String>, ) -> Result { if text.is_empty() { @@ -223,22 +241,14 @@ fn is_wsl_session() -> bool { false } -/// Run arboard with stderr suppressed. -/// -/// On macOS, `arboard::Clipboard::new()` initializes `NSPasteboard` which -/// triggers `os_log` / `NSLog` output on stderr. Because the TUI owns the -/// terminal, that stray output corrupts the display. We temporarily redirect -/// fd 2 to `/dev/null` around the call to keep the screen clean. +/// Write to the native clipboard. The TUI owns any process-wide stderr redirection. #[cfg(not(target_os = "android"))] -fn arboard_copy(text: &str, html: Option<&str>) -> Result, String> { - #[cfg(target_os = "macos")] - let _stderr_lock = STDERR_SUPPRESSION_MUTEX - .get_or_init(|| std::sync::Mutex::new(())) - .lock() - .map_err(|_| "stderr suppression lock poisoned".to_string())?; - let _guard = SuppressStderr::new(); - let mut clipboard = - arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; +fn arboard_copy( + clipboard: Result, + text: &str, + html: Option<&str>, +) -> Result, String> { + let mut clipboard = clipboard.map_err(|e| format!("clipboard unavailable: {e}"))?; match html { Some(html) => clipboard .set_html(html, Some(text)) @@ -257,11 +267,6 @@ fn arboard_copy(text: &str, html: Option<&str>) -> Result } } -#[cfg(target_os = "android")] -fn arboard_copy(_text: &str, _html: Option<&str>) -> Result, String> { - Err("native clipboard unavailable on Android".to_string()) -} - /// Copy text into the Windows clipboard from a WSL process. #[cfg(target_os = "linux")] fn wsl_clipboard_copy(text: &str) -> Result<(), String> { @@ -318,63 +323,6 @@ fn wsl_clipboard_copy(_text: &str) -> Result<(), String> { Err("WSL clipboard fallback unavailable on this platform".to_string()) } -/// RAII guard that redirects stderr (fd 2) to `/dev/null` on creation and -/// restores the original fd on drop. -#[cfg(target_os = "macos")] -struct SuppressStderr { - saved_fd: Option, -} - -#[cfg(target_os = "macos")] -impl SuppressStderr { - fn new() -> Self { - unsafe { - // Save the current stderr fd. - let saved = libc::dup(2); - if saved < 0 { - return Self { saved_fd: None }; - } - // Open /dev/null and point fd 2 at it. - let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY); - if devnull < 0 { - libc::close(saved); - return Self { saved_fd: None }; - } - if libc::dup2(devnull, 2) < 0 { - libc::close(saved); - libc::close(devnull); - return Self { saved_fd: None }; - } - libc::close(devnull); - Self { - saved_fd: Some(saved), - } - } - } -} - -#[cfg(target_os = "macos")] -impl Drop for SuppressStderr { - fn drop(&mut self) { - if let Some(saved) = self.saved_fd { - unsafe { - libc::dup2(saved, 2); - libc::close(saved); - } - } - } -} - -#[cfg(not(target_os = "macos"))] -struct SuppressStderr; - -#[cfg(not(target_os = "macos"))] -impl SuppressStderr { - fn new() -> Self { - Self - } -} - /// Write text to the clipboard via the OSC 52 terminal escape sequence. fn osc52_copy(text: &str) -> Result<(), String> { let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some())?; diff --git a/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap b/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap new file mode 100644 index 0000000000..338861399e --- /dev/null +++ b/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/clipboard_copy/worker_tests.rs +assertion_line: 82 +expression: "feedback.join(\"\\n\")" +--- +• Copy already in progress; try image paste after it finishes diff --git a/codex-rs/tui/src/clipboard_copy/worker.rs b/codex-rs/tui/src/clipboard_copy/worker.rs new file mode 100644 index 0000000000..f6362a9d11 --- /dev/null +++ b/codex-rs/tui/src/clipboard_copy/worker.rs @@ -0,0 +1,250 @@ +//! 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. + +use super::ClipboardLease; +use super::CopyFormat; +use super::CopyStatus; +use crate::tui::FrameRequester; +use std::cell::Cell; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::mpsc; +use std::time::Duration; +use std::time::Instant; + +const SETUP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5); +const SETUP_TIMEOUT_MESSAGE: &str = "clipboard setup timed out; copy abandoned"; + +enum Setup { + Pending(Instant), + Delivering, + Abandoned, +} + +impl Setup { + fn timed_out(&mut self, now: Instant) -> bool { + if matches!(self, Self::Pending(deadline) if now >= *deadline) { + *self = Self::Abandoned; + } + matches!(self, Self::Abandoned) + } +} + +/// The UI deadline and worker delivery decision share one short, non-I/O critical section. +struct CopySetup { + phase: Mutex, + frames: FrameRequester, +} + +impl CopySetup { + fn begin_delivery(&self) -> Result<(), String> { + let mut setup = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if setup.timed_out(Instant::now()) { + return Err(SETUP_TIMEOUT_MESSAGE.into()); + } + *setup = Setup::Delivering; + Ok(()) + } + + fn timed_out(&self) -> bool { + let mut setup = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let timed_out = setup.timed_out(Instant::now()); + if let Setup::Pending(deadline) = *setup { + // Earlier draws consume scheduled frames, so re-arm after every UI poll. + self.frames + .schedule_frame_in(deadline.saturating_duration_since(Instant::now())); + } + timed_out + } +} + +pub(crate) type CopyResult = Result; + +struct Request { + text: Arc, + format: CopyFormat, + setup: Arc, +} + +struct Response { + result: CopyResult, + terminal_text: Option, +} + +#[derive(Default)] +pub(crate) struct ClipboardWorker { + requests: Option>, + responses: Option>, + pending: Option<(u64, Arc)>, + next_id: u64, + completed: Option<(u64, CopyResult)>, +} + +impl ClipboardWorker { + pub(crate) fn is_busy(&self) -> bool { + self.pending.is_some() + } + + pub(crate) fn copy( + &mut self, + text: Arc, + format: CopyFormat, + frames: FrameRequester, + ) -> CopyResult { + if self.is_busy() { + return Ok(CopyStatus::Busy); + } + 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.next_id += 1; + let id = self.next_id; + let setup = Arc::new(CopySetup { + phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)), + frames: frames.clone(), + }); + self.requests + .as_ref() + .ok_or("clipboard worker stopped")? + .send(Request { + text, + format, + setup: Arc::clone(&setup), + }) + .map_err(|_| "clipboard worker stopped".to_string())?; + self.pending = Some((id, setup)); + // Retain the last completion while a newer request runs so a temporarily hidden + // view can still finish its feedback. Consumers already match request IDs. + frames.schedule_frame_in(SETUP_TIMEOUT); + Ok(CopyStatus::Pending(id)) + } + + fn start( + &mut self, + frames: FrameRequester, + mut copy: impl FnMut( + &str, + CopyFormat, + &CopySetup, + ) -> (Result, Option) + + Send + + 'static, + ) -> Result<(), String> { + let (requests, incoming) = mpsc::channel::(); + let (outgoing, responses) = mpsc::channel(); + // Native calls can wait indefinitely; shutdown waits only for a bounded handoff. + std::thread::Builder::new() + .name("clipboard-copy".into()) + .spawn(move || { + let mut lease: Option = None; + while let Ok(request) = incoming.recv() { + let (outcome, terminal_text) = + copy(&request.text, request.format, &request.setup); + let result = outcome.map(|outcome| outcome.store(&mut lease)); + if outgoing + .send(Response { + result, + terminal_text, + }) + .is_err() + { + break; + } + frames.schedule_frame(); + } + // Finish Linux clipboard-manager handoff before disconnecting the response channel. + #[cfg(target_os = "linux")] + drop(lease); + drop(outgoing); + }) + .map_err(|error| format!("could not start clipboard worker: {error}"))?; + self.requests = Some(requests); + self.responses = Some(responses); + Ok(()) + } + + /// 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((id, setup)) = &self.pending { + let id = *id; + if !matches!(self.completed, Some((completed_id, _)) if completed_id == id) + && setup.timed_out() + { + self.completed = Some((id, Err(SETUP_TIMEOUT_MESSAGE.into()))); + } + match self.responses.as_ref()?.try_recv() { + Ok(response) => { + // A timeout completes the request, not the blocked worker. Drain its + // eventual response without replacing failure or emitting terminal output. + if matches!(self.completed, Some((completed_id, _)) if completed_id == id) { + self.pending = None; + return self.completed.as_ref(); + } + let mut result = response.result; + if let Some(text) = response.terminal_text + && let Err(error) = super::osc52_copy(&text) + && result != Ok(CopyStatus::Confirmed) + { + result = Err(error); + } + self.completed = Some((id, result)); + self.pending = None; + } + Err(mpsc::TryRecvError::Disconnected) => { + if !matches!(self.completed, Some((completed_id, _)) if completed_id == id) { + self.completed = Some((id, Err("clipboard worker stopped".into()))); + } + self.pending = None; + } + Err(mpsc::TryRecvError::Empty) => {} + } + } + self.completed.as_ref() + } +} + +impl Drop for ClipboardWorker { + fn drop(&mut self) { + self.requests.take(); + // A confirmed Linux copy needs time to hand ownership to the clipboard manager. + // Keep this bounded: even dropping a native clipboard can stall on an X11 server. + if let Some(responses) = &self.responses { + let deadline = Instant::now() + Duration::from_millis(/*millis*/ 250); + // A queued result does not mean the lease has finished handing off. Only + // channel disconnection follows that cleanup, including unpolled copies. + while responses + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .is_ok() + {} + } + } +} + +#[cfg(test)] +#[path = "worker_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/clipboard_copy/worker_tests.rs b/codex-rs/tui/src/clipboard_copy/worker_tests.rs new file mode 100644 index 0000000000..d564399e06 --- /dev/null +++ b/codex-rs/tui/src/clipboard_copy/worker_tests.rs @@ -0,0 +1,270 @@ +//! Blocked backends leave UI work and teardown independent of clipboard progress. + +use super::*; +use crate::clipboard_copy::CopyOutcome; +use crate::history_cell::PlainHistoryCell; +use crate::keymap::RuntimeKeymap; +use crate::pager_overlay::Overlay; +use crate::tui::TuiEvent; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use std::time::Duration; + +#[tokio::test] +async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion() { + let mut tui = crate::tui::test_support::make_test_tui().unwrap(); + let (draws, mut draw_rx) = tokio::sync::broadcast::channel(/*capacity*/ 1); + let frames = FrameRequester::new(draws); + 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) + }) + .unwrap(); + let mut app = Box::new(crate::app::test_support::make_test_app().await); + let (mut chat, tx, mut events, _ops) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + chat.handle_server_notification( + codex_app_server_protocol::ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + thread_id: String::new(), + turn_id: "turn-1".into(), + completed_at_ms: 0, + item: codex_app_server_protocol::ThreadItem::AgentMessage { + id: "msg-1".into(), + text: "café\nsecond line".into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }, + ), + /*replay_kind*/ None, + ); + app.chat_widget = chat; + app.app_event_tx = tx; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config) + .await + .unwrap(); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)), + ) + .await + .unwrap(); + // Copy must start without draining AppEvent, before a subsequent paste key can arrive. + assert!(tui.clipboard.is_busy()); + while events.try_recv().is_ok() {} + assert_eq!( + start_rx + .recv_timeout(Duration::from_secs(/*secs*/ 5)) + .unwrap(), + ("café\nsecond line".into(), CopyFormat::Markdown) + ); + for _ in 0..100 { + assert_eq!(tui.copy_transcript_selection("newer"), Ok(CopyStatus::Busy)); + } + assert!(tui.clipboard.poll().is_none()); + let mut overlay = Overlay::new_transcript( + vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))], + RuntimeKeymap::defaults().pager, + /*copy_on_select*/ false, + ); + overlay + .handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ) + .unwrap(); + assert!(overlay.is_done()); + drop(overlay); + assert!(tui.clipboard.is_busy()); + + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)), + ) + .await + .unwrap(); + let mut feedback = Vec::new(); + while let Ok(event) = events.try_recv() { + if let crate::app_event::AppEvent::InsertHistoryCell(cell) = event { + feedback.extend( + cell.display_lines(/*width*/ 80) + .iter() + .map(ToString::to_string), + ); + } + } + insta::assert_snapshot!("image_paste_while_copy_is_busy", feedback.join("\n")); + release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), draw_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(tui.clipboard.poll(), Some(&(1, Ok(CopyStatus::Confirmed)))); + assert!(!tui.clipboard.is_busy()); + assert!(start_rx.try_recv().is_err()); +} + +#[test] +fn dropping_worker_does_not_wait_for_blocked_backend() { + let mut worker = ClipboardWorker::default(); + let (started, start_rx) = mpsc::channel(); + 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) + }) + .unwrap(); + worker + .copy( + "text".into(), + CopyFormat::PlainText, + FrameRequester::test_dummy(), + ) + .unwrap(); + start_rx + .recv_timeout(Duration::from_secs(/*secs*/ 5)) + .unwrap(); + drop(worker); + release.send(()).unwrap(); + finish_rx + .recv_timeout(Duration::from_secs(/*secs*/ 5)) + .unwrap(); +} + +#[tokio::test] +async fn expired_setup_discards_delivery_and_keeps_worker_busy_until_it_returns() { + // Expiry must also be enforced by the worker when the UI has not polled yet. + for poll_before_release in [true, false] { + let mut worker = ClipboardWorker::default(); + let (frames, mut scheduled) = FrameRequester::test_channel(); + let (started, start_rx) = mpsc::channel(); + 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) + }) + .unwrap(); + worker + .copy("abandoned".into(), CopyFormat::PlainText, frames.clone()) + .unwrap(); + start_rx.recv_timeout(SETUP_TIMEOUT).unwrap(); + scheduled.try_recv().unwrap(); + assert!(worker.poll().is_none()); + // Every poll re-arms the setup deadline after earlier UI draws consume it. + assert!(scheduled.try_recv().is_ok()); + let setup = &worker.pending.as_ref().unwrap().1; + *setup.phase.lock().unwrap() = Setup::Pending(Instant::now()); + let failure = (1, Err(SETUP_TIMEOUT_MESSAGE.into())); + if poll_before_release { + assert_eq!(worker.poll(), Some(&failure)); + } + assert!(worker.is_busy()); + assert_eq!( + worker.copy("retry".into(), CopyFormat::PlainText, frames.clone()), + Ok(CopyStatus::Busy) + ); + release.send(()).unwrap(); + tokio::time::timeout(SETUP_TIMEOUT, async { + while worker.is_busy() { + worker.poll(); + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(worker.poll(), Some(&failure)); + assert!(write_rx.try_recv().is_err()); + assert!(start_rx.try_recv().is_err()); + worker + .copy("fresh".into(), CopyFormat::PlainText, frames) + .unwrap(); + start_rx.recv_timeout(SETUP_TIMEOUT).unwrap(); + assert_eq!(worker.poll(), Some(&failure)); + release.send(()).unwrap(); + assert_eq!(write_rx.recv_timeout(SETUP_TIMEOUT).unwrap(), "fresh"); + tokio::time::timeout(SETUP_TIMEOUT, async { + while worker.is_busy() { + worker.poll(); + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(worker.poll(), Some(&(2, Ok(CopyStatus::Confirmed)))); + } +} + +#[test] +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, + }; + worker + .copy( + "text".into(), + CopyFormat::PlainText, + FrameRequester::test_dummy(), + ) + .unwrap(); + let cleanup = std::thread::spawn(move || { + // The second rendezvous probes cleanup after receiving a result: shutdown + // must wait for disconnection, not mistake the first result for handoff. + for _ in 0..2 { + outgoing + .send(Response { + result: Ok(CopyStatus::Confirmed), + terminal_text: None, + }) + .unwrap(); + } + }); + drop(worker); + cleanup.join().unwrap(); +} + +#[test] +fn delivery_claim_cannot_be_abandoned() { + let setup = CopySetup { + phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)), + frames: FrameRequester::test_dummy(), + }; + assert_eq!(setup.begin_delivery(), Ok(())); + assert!( + !setup + .phase + .lock() + .unwrap() + .timed_out(Instant::now() + SETUP_TIMEOUT) + ); + // Backend fallback checks cannot revoke a delivery that already began. + assert_eq!(setup.begin_delivery(), Ok(())); +} diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index 8160732866..b2dbeefdf4 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -170,6 +170,20 @@ impl TranscriptOverlay { } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + if let Some(completion) = tui.clipboard.poll() + && let Some(follow) = + self.view + .finish_copy(&self.cells, completion, /*current*/ true) + { + self.notice = Some(match &completion.1 { + Ok(status) => status.message("selection"), + Err(error) => format!("Copy failed: {error}"), + }); + if follow { + self.view.jump_to_latest(); + self.is_done = self.browsing_footer.is_some(); + } + } if matches!(event, TuiEvent::Resume) { self.view.end_drag(); } @@ -432,19 +446,16 @@ impl TranscriptOverlay { ViewAction::Copy(text) | ViewAction::CopyOnSelect(text) | ViewAction::CopyAndFollow(text) => { - let result = if copy_on_select { - tui.copy_transcript_selection(&text) - } else { - self.view - .copy_selected_text_with(&self.cells, &text, |text| { - tui.copy_transcript_selection(text) - }) - }; + let result = self.view.copy_selected_text_with( + &self.cells, + &text, + !copy_on_select, + |text| tui.copy_transcript_selection(text), + ); if resume_following - && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)) + && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_))) { - self.view.jump_to_latest(); - self.is_done = self.browsing_footer.is_some(); + self.view.follow_pending_copy(); } self.notice = Some(match result { Ok(status) => status.message("selection"), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 2f90753872..cc04b6e551 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -586,6 +586,7 @@ async fn run_session_picker_with_loader( loop { tokio::select! { Some(ev) = tui_events.next() => { + alt.tui.clipboard.poll(); let screen_size = alt.tui.screen_size_for_event(&ev)?; let ev = if let TuiEvent::Key(key) = ev { let Some(key) = state.route_key_chord(key) else { diff --git a/codex-rs/tui/src/transcript_view/composer_gap.rs b/codex-rs/tui/src/transcript_view/composer_gap.rs index db5fb43680..345a59f321 100644 --- a/codex-rs/tui/src/transcript_view/composer_gap.rs +++ b/codex-rs/tui/src/transcript_view/composer_gap.rs @@ -10,9 +10,9 @@ use std::time::Duration; use std::time::Instant; pub(super) struct CopyFeedback { - result: Result, - characters: usize, - expires_at: Instant, + pub(super) result: Result, + pub(super) characters: usize, + expires_at: Option, } impl TranscriptView { @@ -25,7 +25,7 @@ impl TranscriptView { if let Some(feedback) = self .copy_feedback .as_ref() - .filter(|feedback| feedback.expires_at > now) + .filter(|feedback| feedback.expires_at.is_none_or(|expiry| expiry > now)) { return feedback.line(width.saturating_sub(/*rhs*/ 1)).width() > 0; } @@ -51,10 +51,19 @@ impl TranscriptView { result: &Result, characters: usize, ) { + if result == &Ok(CopyStatus::Busy) + && self + .copy_feedback + .as_ref() + .is_some_and(|feedback| matches!(feedback.result, Ok(CopyStatus::Pending(_)))) + { + return; + } self.copy_feedback = Some(CopyFeedback { result: result.as_ref().copied().map_err(|_| ()), characters, - expires_at: Instant::now() + Duration::from_secs(/*secs*/ 5), + expires_at: (!matches!(result, Ok(CopyStatus::Pending(_)))) + .then(|| Instant::now() + Duration::from_secs(/*secs*/ 5)), }); } @@ -69,7 +78,7 @@ impl TranscriptView { if self .copy_feedback .as_ref() - .is_some_and(|feedback| feedback.expires_at <= now) + .is_some_and(|feedback| feedback.expires_at.is_some_and(|expiry| expiry <= now)) { self.copy_feedback = None; } @@ -79,7 +88,9 @@ impl TranscriptView { }; if let Some(feedback) = &self.copy_feedback { let line = feedback.line(area.width.saturating_sub(/*rhs*/ 1)); - let delay = feedback.expires_at.saturating_duration_since(now); + let delay = feedback + .expires_at + .map(|expiry| expiry.saturating_duration_since(now)); let width = line.width().min(usize::from(area.width)) as u16; let target = Rect::new( area.right().saturating_sub(width + 1).max(area.x), @@ -89,7 +100,7 @@ impl TranscriptView { ); line.render(target, buffer); self.render_follow_control(/*area*/ None, buffer); - return Some(delay); + return delay; } self.render_follow_control(Some(area), buffer); if let Some(hint) = self.fitting_composer_hint(area.width, hint) { @@ -112,6 +123,16 @@ impl CopyFeedback { format!("Copied {characters} chars"), "Copied".into(), ], + Ok(CopyStatus::Pending(_)) => [ + format!("Copying {characters} chars…"), + "Copying…".into(), + "Copying…".into(), + ], + Ok(CopyStatus::Busy) => [ + "Copy already in progress".into(), + "Copy in progress".into(), + "Copy busy".into(), + ], Ok(CopyStatus::Unconfirmed) => [ "Copy sent to terminal · paste to verify".into(), "Copy sent · verify paste".into(), diff --git a/codex-rs/tui/src/transcript_view/composer_gap_tests.rs b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs index 5aa6414813..f8a526376a 100644 --- a/codex-rs/tui/src/transcript_view/composer_gap_tests.rs +++ b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs @@ -10,10 +10,83 @@ use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; +#[test] +fn pending_copy_feedback_survives_expiry_and_selection_replacement() { + let cells = vec![cell("selected text")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 60, /*height*/ 3); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + let selected = view.selected_text(&cells).unwrap(); + let pending = view.copy_selected_text_with( + &cells, + &selected, + /*clear_selection*/ true, + |_| Ok(CopyStatus::Pending(1)), + ); + view.show_copy_feedback(&pending, selected.chars().count()); + view.show_copy_feedback(&Ok(CopyStatus::Busy), /*characters*/ 0); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + let later = Instant::now() + Duration::from_secs(/*secs*/ 10); + assert!(view.composer_gap_has_content(area.width, /*hint*/ None, later)); + assert_eq!( + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, later), + None + ); + let pending_frame = text(&buffer); + assert_eq!( + view.finish_copy( + &cells, + &(1, Err("setup timed out".into())), + /*current*/ true + ), + Some(false) + ); + assert_eq!(view.selected_text(&cells), Some(selected)); + buffer.reset(); + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now()); + insta::assert_snapshot!(format!( + "Pending after ten seconds\n{pending_frame}\nSetup failed\n{}", + text(&buffer) + )); + + // Feedback still completes if the selection owner disappears, including composer copies. + view.show_copy_feedback(&Ok(CopyStatus::Pending(2)), /*characters*/ 3); + view.end_selection(&cells); + assert_eq!( + view.finish_copy( + &cells, + &(2, Ok(CopyStatus::Confirmed)), + /*current*/ false + ), + None + ); + assert_eq!( + view.copy_feedback.as_ref().unwrap().result, + Ok(CopyStatus::Confirmed) + ); + view.show_copy_feedback(&Ok(CopyStatus::Pending(3)), /*characters*/ 4); + view.finish_copy(&cells, &(2, Err("stale".into())), /*current*/ true); + assert_eq!( + view.copy_feedback.as_ref().unwrap().result, + Ok(CopyStatus::Pending(3)) + ); + view.finish_copy( + &cells, + &(4, Ok(CopyStatus::Confirmed)), + /*current*/ true, + ); + assert!(view.copy_feedback.is_none()); +} + #[test] fn copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery() { let mut snapshots = Vec::new(); for result in [ + Ok(CopyStatus::Pending(1)), + Ok(CopyStatus::Busy), Ok(CopyStatus::Confirmed), Ok(CopyStatus::Unconfirmed), Err("unavailable".to_owned()), @@ -24,14 +97,15 @@ fn copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery() { assert!(view.composer_gap_has_content(width, /*hint*/ None, Instant::now())); let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); let mut buffer = Buffer::empty(area); - assert!( + assert_eq!( view.render_composer_gap( Some(area), /*hint*/ None, &mut buffer, Instant::now() ) - .is_some() + .is_some(), + !matches!(result, Ok(CopyStatus::Pending(_))) ); assert_eq!(buffer[(width - 1, 0)].symbol(), " "); snapshots.push(format!("{result:?}, {width} columns\n{}", text(&buffer))); @@ -59,7 +133,7 @@ fn feedback_releases_navigation_targets_and_expiry_restores_them() { view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now()); assert!(!text(&buffer).contains("Back to bottom")); assert!(!view.is_following()); - view.copy_feedback.as_mut().unwrap().expires_at = Instant::now(); + view.copy_feedback.as_mut().unwrap().expires_at = Some(Instant::now()); buffer.reset(); assert_eq!( view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now()), @@ -159,7 +233,7 @@ fn tip_links_follow_alignment_and_release_stale_targets() { view.show_copy_feedback(&Ok(CopyStatus::Confirmed), /*characters*/ 3); view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); assert!(view.handle_mouse(click, &[]).is_none()); - view.copy_feedback.as_mut().unwrap().expires_at = Instant::now(); + view.copy_feedback.as_mut().unwrap().expires_at = Some(Instant::now()); view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now()); assert!(matches!( view.handle_mouse(click, &[]), diff --git a/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs b/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs index f3df68a0f0..d0e4da5b46 100644 --- a/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs +++ b/codex-rs/tui/src/transcript_view/copy_on_select_tests.rs @@ -1,6 +1,7 @@ //! Automatic copies retain completed mouse selections. use super::*; +use crate::clipboard_copy::CopyStatus; use crate::history_cell::AgentMarkdownCell; use crate::transcript_view::tests::cell; use crate::transcript_view::tests::render; @@ -49,7 +50,7 @@ fn copy_on_select_waits_for_release_and_retains_selection() { assert_eq!(view.selected_text(&cells).as_deref(), Some("selected")); assert!(view.handle_mouse(release, &cells).is_none()); assert!(!view.tick_selection(&cells)); - if copied.is_some() { + if let Some(copied) = copied { let buffer = render(&mut view, &cells, /*width*/ 20, /*height*/ 1); let highlight = (0..20) .map(|column| { @@ -70,6 +71,30 @@ fn copy_on_select_waits_for_release_and_retains_selection() { ^^^^^^^^············ " ); + for result in [ + Ok(CopyStatus::Confirmed), + Ok(CopyStatus::Unconfirmed), + Err("clipboard unavailable".to_owned()), + ] { + view.copy_selected_text_with( + &cells, + &copied, + /*clear_selection*/ false, + |_| Ok(CopyStatus::Pending(1)), + ) + .unwrap(); + assert_eq!( + view.finish_copy(&cells, &(1, result.clone()), /*current*/ true), + Some(false) + ); + assert_eq!(view.selected_text(&cells).as_deref(), Some(copied.as_str())); + assert_eq!( + view.copy_feedback + .as_ref() + .map(|feedback| (feedback.result, feedback.characters)), + Some((result.map_err(|_| ()), copied.chars().count())) + ); + } } } } diff --git a/codex-rs/tui/src/transcript_view/follow_control_tests.rs b/codex-rs/tui/src/transcript_view/follow_control_tests.rs index 2905c33878..b12bfbb56d 100644 --- a/codex-rs/tui/src/transcript_view/follow_control_tests.rs +++ b/codex-rs/tui/src/transcript_view/follow_control_tests.rs @@ -130,7 +130,7 @@ fn copying_selection_at_bottom_does_not_show_back_to_bottom() { view.begin_selection(&cells, /*column*/ 0, /*row*/ 2, /*clicks*/ 2); view.end_drag(); let selected = view.selected_text(&cells).expect("selected word"); - view.copy_selected_text_with(&cells, &selected, |text| { + view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |text| { assert_eq!(text, "needle"); Ok(crate::clipboard_copy::CopyStatus::Confirmed) }) @@ -221,7 +221,7 @@ fn copying_live_selection_retains_the_displayed_revision() { view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| { Some(vec![HyperlinkLine::from("changed live text")]) }); - view.copy_selected_text_with(&cells, &selected, |_| { + view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| { Ok(crate::clipboard_copy::CopyStatus::Confirmed) }) .unwrap(); diff --git a/codex-rs/tui/src/transcript_view/prompt_header_tests.rs b/codex-rs/tui/src/transcript_view/prompt_header_tests.rs index 827b569496..d2963c30c0 100644 --- a/codex-rs/tui/src/transcript_view/prompt_header_tests.rs +++ b/codex-rs/tui/src/transcript_view/prompt_header_tests.rs @@ -186,7 +186,7 @@ fn paused_header_reservation_survives_copy_until_scroll_or_resize() { ); let selected = view.selected_text(&cells).expect("selected word"); assert_eq!(selected, "done"); - view.copy_selected_text_with(&cells, &selected, |_| { + view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| { Ok(crate::clipboard_copy::CopyStatus::Confirmed) }) .expect("confirmed copy"); diff --git a/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs b/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs index a1c7373e68..a0f5d1dd4c 100644 --- a/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs +++ b/codex-rs/tui/src/transcript_view/right_click_copy_tests.rs @@ -34,10 +34,15 @@ fn right_click_retains_selection_until_confirmed_and_preserves_reading_position( panic!("right-click must request a copy without following new output"); }; assert_eq!(selected, "selected text\n"); - let copied = view.copy_selected_text_with(&cells, &selected, |copied| { - assert_eq!(copied, selected); - result.clone() - }); + let copied = view.copy_selected_text_with( + &cells, + &selected, + /*clear_selection*/ true, + |copied| { + assert_eq!(copied, selected); + result.clone() + }, + ); assert_eq!(copied, result); assert_eq!( (view.selected_text(&cells), view.position), diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index 602af42546..34c95aa712 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -8,7 +8,18 @@ use unicode_segmentation::UnicodeSegmentation; use super::*; use crate::text_selection::SelectionUnit; +struct PendingCopy { + id: u64, + start: Anchor, + end: Anchor, + position: Position, + detailed: bool, + follow: bool, + clear_selection: bool, +} + pub(super) struct Selection { + pending_copy: Option, pub(super) snapshot: ViewSnapshot, pub(super) start: Anchor, pub(super) end: Anchor, @@ -50,6 +61,7 @@ impl TranscriptView { }; let was_following = self.is_following(); self.selection = Some(Selection { + pending_copy: None, snapshot, start, end, @@ -191,21 +203,98 @@ impl TranscriptView { Some(text) } - /// Release selection after confirmed delivery. Failed or unacknowledged terminal writes - /// retain the selected revision so the user can verify pasting and retry. + /// 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( &mut self, cells: &[Arc], text: &str, + clear_selection: bool, copy: impl FnOnce(&str) -> Result, ) -> Result { let result = copy(text); - if matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)) { - self.end_selection(cells); + match result { + Ok(crate::clipboard_copy::CopyStatus::Confirmed) => { + if clear_selection { + self.end_selection(cells); + } + } + Ok(crate::clipboard_copy::CopyStatus::Pending(id)) => { + if let Some(selection) = &mut self.selection { + selection.pending_copy = Some(PendingCopy { + id, + start: selection.start, + end: selection.end, + position: self.position, + detailed: self.detailed, + follow: false, + clear_selection, + }); + } + } + Ok( + crate::clipboard_copy::CopyStatus::Unconfirmed + | crate::clipboard_copy::CopyStatus::Busy, + ) + | Err(_) => {} } result } + pub(crate) fn follow_pending_copy(&mut self) { + if let Some(pending) = self + .selection + .as_mut() + .and_then(|s| s.pending_copy.as_mut()) + { + pending.follow = true; + } + } + + /// A replaced selection has no ticket; moved endpoints cannot consume an old completion. + pub(crate) fn finish_copy( + &mut self, + cells: &[Arc], + completion: &(u64, crate::clipboard_copy::worker::CopyResult), + current: bool, + ) -> Option { + // Feedback also belongs to composer copies and survives selection changes. Complete + // its matching ticket even when the selection can no longer consume the result. + if let Some(feedback) = &self.copy_feedback + && let Ok(crate::clipboard_copy::CopyStatus::Pending(id)) = feedback.result + { + if id == completion.0 { + self.show_copy_feedback(&completion.1, feedback.characters); + } else if id < completion.0 { + // A picker can consume this result and start another copy while the view + // is hidden. A newer completion proves the older request is no longer pending. + self.copy_feedback = None; + } + } + let selection = self.selection.as_mut()?; + if selection.pending_copy.as_ref()?.id != completion.0 { + return None; + } + let pending = selection.pending_copy.take()?; + if !current + || (pending.start, pending.end) != (selection.start, selection.end) + || pending.position != self.position + || pending.detailed != self.detailed + { + return None; + } + let characters = self + .selected_text(cells) + .map_or(/*default*/ 0, |text| text.chars().count()); + if pending.clear_selection + && completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed) + { + self.end_selection(cells); + } + self.show_copy_feedback(&completion.1, characters); + Some(pending.follow && completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed)) + } + /// End the pointer gesture without discarding selected text when input ownership changes. pub(crate) fn end_drag(&mut self) { self.follow_control = Default::default(); diff --git a/codex-rs/tui/src/transcript_view/selection_tests.rs b/codex-rs/tui/src/transcript_view/selection_tests.rs index 2358f5ef6e..1713c423e8 100644 --- a/codex-rs/tui/src/transcript_view/selection_tests.rs +++ b/codex-rs/tui/src/transcript_view/selection_tests.rs @@ -94,10 +94,15 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() { panic!("selection key must request a copy"); }; assert_eq!(text, "selected\tcafé"); - let failed = view.copy_selected_text_with(&cells, &text, |text| { - assert_eq!(text, "selected\tcafé"); - Err("clipboard unavailable".to_string()) - }); + let failed = view.copy_selected_text_with( + &cells, + &text, + /*clear_selection*/ true, + |text| { + assert_eq!(text, "selected\tcafé"); + Err("clipboard unavailable".to_string()) + }, + ); assert_eq!(failed, Err("clipboard unavailable".to_string())); assert_eq!( view.selected_text(&cells).as_deref(), @@ -109,10 +114,15 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() { else { panic!("failed copy must remain retryable"); }; - let copied = view.copy_selected_text_with(&cells, &text, |text| { - assert_eq!(text, "selected\tcafé"); - Ok(status) - }); + let copied = view.copy_selected_text_with( + &cells, + &text, + /*clear_selection*/ true, + |text| { + assert_eq!(text, "selected\tcafé"); + Ok(status) + }, + ); assert_eq!(copied, Ok(status)); assert_eq!( (view.selected_text(&cells), view.is_search_active()), @@ -159,7 +169,7 @@ fn command_c_ignores_release_and_copies_only_an_active_selection() { panic!("Cmd+C must copy the transcript selection"); }; assert_eq!(text, "selected café"); - view.copy_selected_text_with(&cells, &text, |_| { + view.copy_selected_text_with(&cells, &text, /*clear_selection*/ true, |_| { Ok(crate::clipboard_copy::CopyStatus::Confirmed) }) .unwrap(); @@ -615,3 +625,52 @@ fn loading_older_history_extends_the_snapshot_without_adopting_new_commits() { render(&mut view, &cells, /*width*/ 32, /*height*/ 6); assert_eq!(view.selected_text(&cells).as_deref(), Some("selected")); } + +#[test] +fn pending_copy_only_finishes_the_original_selection() { + use crate::clipboard_copy::CopyStatus; + for change in [ + "none", + "replace", + "move", + "navigate", + "hide", + "presentation", + ] { + let cells = vec![ + Arc::new(PlainHistoryCell::new(vec!["selected text".into()])) as Arc, + ]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 32, /*height*/ 3); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + let selected = view.selected_text(&cells).unwrap(); + view.copy_selected_text_with( + &cells, + &selected, + /*clear_selection*/ true, + |_| Ok(CopyStatus::Pending(1)), + ) + .unwrap(); + view.follow_pending_copy(); + match change { + "replace" => { + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3) + } + "move" => { + view.selection_key(&cells, KeyCode::Left); + } + "navigate" => view.jump_to_latest(), + "presentation" => { + view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich) + } + _ => {} + } + let before = view.selected_text(&cells); + let result = view.finish_copy(&cells, &(1, Ok(CopyStatus::Confirmed)), change != "hide"); + assert_eq!(result, (change == "none").then_some(/*t*/ true), "{change}"); + assert_eq!( + view.selected_text(&cells), + if change == "none" { None } else { before } + ); + } +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap index 6fa5ed0e44..c780e86b6b 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap @@ -1,8 +1,26 @@ --- source: tui/src/transcript_view/composer_gap_tests.rs -assertion_line: 30 +assertion_line: 42 expression: "snapshots.join(\"\\n\\n\")" --- +Ok(Pending(1)), 80 columns + Copying 24 chars… + +Ok(Pending(1)), 32 columns + Copying 24 chars… + +Ok(Pending(1)), 18 columns +Copying 24 chars… + +Ok(Busy), 80 columns + Copy already in progress + +Ok(Busy), 32 columns + Copy already in progress + +Ok(Busy), 18 columns + Copy in progress + Ok(Confirmed), 80 columns Copied 24 chars to host clipboard diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__pending_copy_feedback_survives_expiry_and_selection_replacement.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__pending_copy_feedback_survives_expiry_and_selection_replacement.snap new file mode 100644 index 0000000000..9f8d490d1a --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__pending_copy_feedback_survives_expiry_and_selection_replacement.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/transcript_view/composer_gap_tests.rs +assertion_line: 33 +expression: "format!(\"Pending after ten seconds\\n{pending_frame}\\nSetup failed\\n{}\",\ntext(&buffer))" +--- +Pending after ten seconds + Copying 13 chars… +Setup failed + Copy failed · try again diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index a2ed6f98d0..fc5a1dc483 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -648,8 +648,8 @@ pub struct Tui { // Keep the alternate screen alive when an overlay closes. owned_screen: bool, overlay_input: OverlayInput, - // Selection copies survive closing a transcript overlay or startup session picker. - selection_clipboard_lease: Option, + // Copies and native ownership survive closing an overlay or startup picker. + pub(crate) clipboard: crate::clipboard_copy::worker::ClipboardWorker, // Keeps unmanaged process stderr writes out of the inline viewport. _stderr_guard: terminal_stderr::TerminalStderrGuard, } @@ -718,7 +718,7 @@ impl Tui { alt_screen_enabled: true, owned_screen: false, overlay_input: OverlayInput::Default, - selection_clipboard_lease: None, + clipboard: Default::default(), _stderr_guard: stderr_guard, } } diff --git a/codex-rs/tui/src/tui/selection_clipboard.rs b/codex-rs/tui/src/tui/selection_clipboard.rs index 1457bed675..9df1d2e44a 100644 --- a/codex-rs/tui/src/tui/selection_clipboard.rs +++ b/codex-rs/tui/src/tui/selection_clipboard.rs @@ -1,26 +1,12 @@ -//! Keep native clipboard ownership after either transcript overlay consumer is closed. +//! Submit selection copies without waiting for native clipboard or helper I/O. use super::Tui; use crate::clipboard_copy::CopyFormat; -use crate::clipboard_copy::CopyOutcome; use crate::clipboard_copy::CopyStatus; impl Tui { pub(crate) fn copy_transcript_selection(&mut self, text: &str) -> Result { - self.copy_transcript_selection_with(text, |text| { - crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::PlainText) - }) - } - - fn copy_transcript_selection_with( - &mut self, - text: &str, - copy: impl FnOnce(&str) -> Result, - ) -> Result { - Ok(copy(text)?.store(&mut self.selection_clipboard_lease)) + self.clipboard + .copy(text.into(), CopyFormat::PlainText, self.frame_requester()) } } - -#[cfg(test)] -#[path = "selection_clipboard_tests.rs"] -mod tests; diff --git a/codex-rs/tui/src/tui/selection_clipboard_tests.rs b/codex-rs/tui/src/tui/selection_clipboard_tests.rs deleted file mode 100644 index 0a28ea6eb8..0000000000 --- a/codex-rs/tui/src/tui/selection_clipboard_tests.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Closing nested or App transcript overlays must not release Linux native ownership. - -use super::*; -use crate::clipboard_copy::ClipboardLease; -use crate::history_cell::PlainHistoryCell; -use crate::keymap::RuntimeKeymap; -use crate::pager_overlay::Overlay; -use crate::tui::TuiEvent; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use pretty_assertions::assert_eq; -use std::sync::Arc; - -#[tokio::test] -async fn selection_clipboard_survives_overlay_close_and_later_terminal_requests() --> std::io::Result<()> { - let mut tui = crate::tui::test_support::make_test_tui()?; - for nested in [false, true] { - let mut overlay = Overlay::new_transcript( - vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))], - RuntimeKeymap::defaults().pager, - /*copy_on_select*/ false, - ); - assert_eq!( - tui.copy_transcript_selection_with("selected", |text| { - assert_eq!(text, "selected"); - Ok(CopyOutcome::Copied(Some(ClipboardLease::test()))) - }), - Ok(CopyStatus::Confirmed) - ); - overlay.handle_event( - &mut tui, - TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), - )?; - assert!(overlay.is_done()); - drop(overlay); - if !nested { - tui.leave_alt_screen()?; - } - assert!(tui.selection_clipboard_lease.is_some()); - for outcome in [CopyOutcome::Requested, CopyOutcome::Copied(None)] { - tui.copy_transcript_selection_with("retry", |_| Ok(outcome)) - .unwrap(); - assert!(tui.selection_clipboard_lease.is_some()); - } - assert_eq!( - tui.copy_transcript_selection_with("retry", |_| Err("blocked".into())), - Err("blocked".into()) - ); - assert!(tui.selection_clipboard_lease.is_some()); - } - Ok(()) -}