From a9b0815167c27ab13719dd12d13b63e02b70d221 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 11 Oct 2026 23:35:17 +0000 Subject: [PATCH] Support Windows clipboard text paste shortcuts in the TUI (#53224) ## Why `Ctrl+V` previously attempted only image paste, so a text-only clipboard produced an image-paste error. `Shift+Insert` did not use the clipboard paste path. ## What changed - On Windows, let `Ctrl+V` and `Shift+Insert` paste images first, then read clipboard text asynchronously when no image is available. Keep clipboard access errors distinct from missing images. - Allow keyboard paste into composer selections and active history or Vim search queries, including inline mode with right-click paste disabled. - Flush buffered typing before explicit paste to preserve input order. Discard pending clipboard text when the thread or editable target changes, while allowing shortcut releases and repeated paste shortcuts to leave the read pending. ## Testing Extend shortcut and clipboard-worker tests to cover text fallback, multiline Unicode text, stale-read cancellation, shortcut releases and repeats, and read-only drafts. Add assertions for history-search preview stability and selection changes. GitOrigin-RevId: 080c1c05a4587e325cea938a6e28c646b9ac5837 --- codex-rs/tui/src/app/clipboard.rs | 35 ++++-- codex-rs/tui/src/app/right_click_paste.rs | 96 +++++++++------ .../tui/src/app/right_click_paste_tests.rs | 10 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 7 +- .../chat_composer/history_search.rs | 2 +- .../history_search_paste_tests.rs | 14 +++ .../src/bottom_pane/chat_composer/mouse.rs | 35 +++++- .../bottom_pane/chat_composer/mouse_tests.rs | 5 + .../bottom_pane/chat_composer/paste_input.rs | 11 +- codex-rs/tui/src/bottom_pane/mod.rs | 17 ++- .../tui/src/bottom_pane/textarea/editing.rs | 2 +- codex-rs/tui/src/chatwidget/clipboard.rs | 29 ++--- codex-rs/tui/src/chatwidget/interaction.rs | 42 ++++--- .../src/chatwidget/tests/slash_commands.rs | 56 +++++++-- ...tests__image_paste_while_copy_is_busy.snap | 4 +- ...rker__tests__shortcut_clipboard_paste.snap | 9 ++ .../tui/src/clipboard_copy/worker_tests.rs | 109 +++++++++++++++--- codex-rs/tui/src/clipboard_paste.rs | 10 +- 18 files changed, 376 insertions(+), 117 deletions(-) create mode 100644 codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__shortcut_clipboard_paste.snap diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs index 1c742bfd5f..a4359cd3e4 100644 --- a/codex-rs/tui/src/app/clipboard.rs +++ b/codex-rs/tui/src/app/clipboard.rs @@ -3,6 +3,8 @@ use super::*; use crate::chatwidget::KeyEventAction; use crate::clipboard_copy::CopyFormat; +use crate::clipboard_copy::worker::PasteSource; +use crate::clipboard_paste::PasteImageError; impl App { pub(super) fn handle_clipboard_key_action( @@ -18,15 +20,34 @@ impl App { .copy(text, CopyFormat::Markdown, tui.frame_requester()); self.chat_widget.show_copy_result("last message", result); } - KeyEventAction::PasteImage => { + KeyEventAction::PasteImage { text_fallback } => { + if text_fallback.is_some() + && self + .right_click_paste_target(tui, PasteSource::Clipboard, text_fallback) + .is_none() + { + return; + } // 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( - "Clipboard busy; try image paste after it finishes".into(), - /*hint*/ None, - ); + if tui.clipboard.is_busy() || self.pending_right_click_paste.is_some() { + self.chat_widget + .add_info_message("Clipboard busy; try again".into(), /*hint*/ None); } else { - self.chat_widget.paste_image(); + match self.chat_widget.paste_image() { + Ok(()) => {} + Err(PasteImageError::NoImage(_)) if text_fallback.is_some() => { + self.start_clipboard_text_paste( + tui, + PasteSource::Clipboard, + text_fallback, + ); + } + Err(err) => { + tracing::warn!("failed to paste image: {err}"); + self.chat_widget + .add_error_message(format!("Failed to paste image: {err}")); + } + } } } } diff --git a/codex-rs/tui/src/app/right_click_paste.rs b/codex-rs/tui/src/app/right_click_paste.rs index 7d32990a2d..684390614f 100644 --- a/codex-rs/tui/src/app/right_click_paste.rs +++ b/codex-rs/tui/src/app/right_click_paste.rs @@ -1,7 +1,8 @@ -//! Right-click CLIPBOARD and local X11 middle-click PRIMARY fallback for an editable composer. +//! Clipboard text paste from Windows shortcuts, right-click, or X11 middle-click PRIMARY. //! The policy also reports why a configured mode is unavailable in this terminal. //! 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. +//! Releasing or pressing another paste shortcut does not cancel its pending paste. use super::*; use crate::clipboard_copy::worker::PasteSource; @@ -13,9 +14,9 @@ use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; pub(super) struct PendingPaste { - thread: Option, - draft: (String, usize), + target: (Option, crate::bottom_pane::ClipboardPasteTarget), source: PasteSource, + shortcut: Option, _request: Arc<()>, } @@ -76,28 +77,30 @@ impl PasteEnvironment { } impl App { - fn right_click_paste_target( + pub(super) fn right_click_paste_target( &self, tui: &tui::Tui, source: PasteSource, - ) -> Option<(Option, (String, usize))> { - let allowed = match source { - PasteSource::Clipboard => { - !self.transcript_view.has_selection_range() - && !self - .chat_widget - .rendered_selection - .borrow() - .view - .has_selection_range() - && self.right_click_paste_environment.allows( - self.local_settings.tui.right_click_paste, - self.local_settings.transcript_mode, - ) - } - PasteSource::Primary => self.right_click_paste_environment.primary, - }; - if !tui.is_owned_screen() + shortcut: Option, + ) -> Option<(Option, crate::bottom_pane::ClipboardPasteTarget)> { + let allowed = shortcut.is_some() + || match source { + PasteSource::Clipboard => { + !self.transcript_view.has_selection_range() + && !self + .chat_widget + .rendered_selection + .borrow() + .view + .has_selection_range() + && self.right_click_paste_environment.allows( + self.local_settings.tui.right_click_paste, + self.local_settings.transcript_mode, + ) + } + PasteSource::Primary => self.right_click_paste_environment.primary, + }; + if (shortcut.is_none() && !tui.is_owned_screen()) || self.overlay.is_some() || self.transcript_view.is_search_editing() || !allowed @@ -106,7 +109,7 @@ impl App { } Some(( self.current_displayed_thread_id(), - self.chat_widget.right_click_paste_target()?, + self.chat_widget.clipboard_paste_target(shortcut)?, )) } @@ -119,15 +122,24 @@ impl App { if !mouse.modifiers.is_empty() || self.pending_right_click_paste.is_some() { return; } - let Some((thread, draft)) = self.right_click_paste_target(tui, source) else { + self.start_clipboard_text_paste(tui, source, /*shortcut*/ None); + } + + pub(crate) fn start_clipboard_text_paste( + &mut self, + tui: &mut tui::Tui, + source: PasteSource, + shortcut: Option, + ) { + let Some(target) = self.right_click_paste_target(tui, source, shortcut) else { return; }; match tui.clipboard.read_text(source, tui.frame_requester()) { Ok(Some(request)) => { self.pending_right_click_paste = Some(PendingPaste { - thread, - draft, + target, source, + shortcut, _request: request, }); tui.frame_requester().schedule_frame(); @@ -150,10 +162,26 @@ impl App { TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained => true, TuiEvent::Mouse(mouse) => { mouse.kind == MouseEventKind::Moved - || mouse.kind == MouseEventKind::Up(button) - || (mouse.kind == MouseEventKind::Down(button) && mouse.modifiers.is_empty()) + || (pending.shortcut.is_none() + && (mouse.kind == MouseEventKind::Up(button) + || (mouse.kind == MouseEventKind::Down(button) + && mouse.modifiers.is_empty()))) } - TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false, + TuiEvent::Key(key) => { + pending.shortcut.is_some() + && match key.code { + KeyCode::Insert => { + key.kind == KeyEventKind::Release + || key.modifiers == KeyModifiers::SHIFT + } + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'v') => { + key.kind == KeyEventKind::Release + || key.modifiers == KeyModifiers::CONTROL + } + _ => false, + } + } + TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false, }; if !keep { self.pending_right_click_paste = None; @@ -172,11 +200,11 @@ impl App { tui.clipboard.take_text_result(); return event; }; - let Some((thread, draft)) = self.right_click_paste_target(tui, pending.source) else { - self.pending_right_click_paste = None; - return event; - }; - if pending.thread != thread || pending.draft != draft { + if self + .right_click_paste_target(tui, pending.source, pending.shortcut) + .as_ref() + != Some(&pending.target) + { self.pending_right_click_paste = None; return event; } diff --git a/codex-rs/tui/src/app/right_click_paste_tests.rs b/codex-rs/tui/src/app/right_click_paste_tests.rs index ff56e84801..ca36014ac8 100644 --- a/codex-rs/tui/src/app/right_click_paste_tests.rs +++ b/codex-rs/tui/src/app/right_click_paste_tests.rs @@ -84,9 +84,15 @@ async fn pending_primary_paste_survives_release_but_not_other_input() { (MouseEventKind::ScrollDown, false), ] { app.pending_right_click_paste = Some(PendingPaste { - thread: None, - draft: (String::new(), 0), + target: ( + None, + crate::bottom_pane::ClipboardPasteTarget::Draft( + String::new(), + crate::bottom_pane::TextArea::new().edit_target(), + ), + ), source: PasteSource::Primary, + shortcut: None, _request: Arc::new(()), }); app.invalidate_right_click_paste(&TuiEvent::Mouse(MouseEvent { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a1db8c6ed8..2ad3f1cd95 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -266,8 +266,10 @@ //! Expired bursts are flushed first so manual Tab keeps its normal shortcut behavior. //! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called //! from UI ticks to integrate a pending burst via [`ChatComposer::apply_paste`] or insert a -//! held single character. Explicit terminal paste events use [`ChatComposer::handle_paste`] to -//! classify input first; buffered keys have already been classified. +//! held single character. Explicit paste events (terminal or clipboard) use +//! [`ChatComposer::handle_paste`], which first commits buffered typing before classifying the +//! pasted text. This preserves the order when a paste arrives before the next burst-flush tick; +//! buffered keys have already been classified and go directly through `apply_paste`. //! - Startup recovery previews held keys with the live Vim Replace policy without consuming them. //! Buffered pastes remain literal in recovery so large-paste and image classification stays with //! the live paste path. @@ -382,6 +384,7 @@ mod footer_state; mod history_search; mod inline_input; mod mouse; +pub(crate) use mouse::ClipboardPasteTarget; mod paste_input; mod popup_state; mod reconnect; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index b5bd3e696e..312798f842 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -68,7 +68,7 @@ pub(super) struct HistorySearchSession { /// Active and completed Vim commands suspended during temporary draft replacement. original_vim_state: VimPersistentState, /// Footer-owned query text typed or pasted while Ctrl+R search is active. - query: String, + pub(super) query: String, /// User-visible search status used to choose footer hints and composer preview behavior. status: HistorySearchStatus, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs index 9039124265..2c9392fb91 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs @@ -1,6 +1,7 @@ //! Pasted text edits the active history query while preserving the original draft. use super::super::super::chat_composer_history::HistoryEntry; +use super::super::super::chat_composer_history::HistorySearchResult; use super::super::ChatComposer; use super::super::ComposerRenderOptions; use super::super::InputResult; @@ -49,7 +50,20 @@ fn history_search_paste_appends_query_and_accepts_match() { assert_eq!(composer.draft.textarea.text(), "git log"); composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let paste_target = composer.clipboard_paste_target(Some(KeyCode::Insert)); + // A background history lookup changes the preview, not the query being edited. + composer.apply_history_search_result(HistorySearchResult::Found(HistoryEntry::new( + "git diff".into(), + ))); + assert_eq!( + composer.clipboard_paste_target(Some(KeyCode::Insert)), + paste_target + ); assert!(composer.handle_paste("status".to_string())); + assert_ne!( + composer.clipboard_paste_target(Some(KeyCode::Insert)), + paste_target + ); assert_eq!( composer.history_search.as_ref().unwrap().query, "git status" 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 b20b533b5c..65a5926135 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -11,13 +11,38 @@ use crossterm::event::MouseButton; use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; +/// The input that will receive clipboard text, independent of a search preview. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ClipboardPasteTarget { + Draft(String, super::super::textarea::EditTarget), + HistorySearch(String), + VimSearch(String, super::super::textarea::EditTarget), +} + impl ChatComposer { - pub(crate) fn can_paste_on_right_click(&self) -> bool { - self.draft.input_enabled + pub(crate) fn clipboard_paste_target( + &self, + shortcut: Option, + ) -> Option { + let allowed = 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() + && (shortcut.is_some() + || (self.history_search.is_none() + && self.draft.textarea.vim_query().is_none() + && self.draft.textarea.mouse_selection_range().is_none())); + if !allowed { + return None; + } + Some(if let Some(search) = &self.history_search { + ClipboardPasteTarget::HistorySearch(search.query.clone()) + } else if let Some(search) = self.draft.textarea.vim_query() { + ClipboardPasteTarget::VimSearch( + search.editor.text().to_string(), + search.editor.edit_target(), + ) + } else { + ClipboardPasteTarget::Draft(self.current_text(), self.draft.textarea.edit_target()) + }) } pub(in crate::bottom_pane) fn finish_copy( 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 d001e2f6e1..920395ab17 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 @@ -203,9 +203,14 @@ fn right_click_copy_uses_the_refreshed_editor_bounds() { 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))); + let paste_target = composer.clipboard_paste_target(Some(KeyCode::Insert)); assert_eq!( composer.finish_copy(&(2, Ok(CopyStatus::Confirmed)), /*current*/ true), Some(5) ); assert_eq!(composer.draft.textarea.mouse_selection_range(), None); + assert_ne!( + composer.clipboard_paste_target(Some(KeyCode::Insert)), + paste_target + ); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index f84cf19184..78db041eb2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -68,10 +68,17 @@ impl ChatComposer { /// Classify an explicit paste before integrating text shared with the buffered key path. pub fn handle_paste(&mut self, pasted: String) -> bool { + self.flush_pending_paste_burst(); self.note_sparkle_paste(&pasted); self.apply_paste(pasted) } + pub(crate) fn flush_pending_paste_burst(&mut self) { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.apply_paste(pasted); + } + } + /// Enable or disable paste-burst handling. /// /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic @@ -94,9 +101,7 @@ impl ChatComposer { let was_disabled = self.draft.disable_paste_burst; self.draft.disable_paste_burst = disabled; if disabled && !was_disabled { - if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { - self.apply_paste(pasted); - } + self.flush_pending_paste_burst(); self.draft.paste_burst.clear_after_explicit_paste(); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5f99fdabe6..996ff53548 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -257,6 +257,7 @@ pub(crate) enum CancellationEvent { use crate::bottom_pane::prompt_args::parse_slash_name; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; +pub(crate) use chat_composer::ClipboardPasteTarget; pub(crate) use chat_composer::ComposerDraftSnapshot; pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::QueuedInputAction; @@ -1197,6 +1198,7 @@ impl BottomPane { self.composer.current_text() } + #[cfg(test)] pub(crate) fn composer_cursor(&self) -> usize { self.composer.current_cursor() } @@ -1875,8 +1877,14 @@ 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 clipboard_paste_target( + &self, + shortcut: Option, + ) -> Option { + if self.has_active_view() || (shortcut.is_none() && !self.no_modal_or_popup_active()) { + return None; + } + self.composer.clipboard_paste_target(shortcut) } pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { @@ -2192,6 +2200,11 @@ impl BottomPane { flushed } + /// Commit accepted typing before snapshotting the target of an asynchronous paste. + pub(crate) fn prepare_composer_clipboard_paste(&mut self) { + self.composer.flush_pending_paste_burst(); + } + pub(crate) fn is_in_paste_burst(&self) -> bool { self.view_stack .last() diff --git a/codex-rs/tui/src/bottom_pane/textarea/editing.rs b/codex-rs/tui/src/bottom_pane/textarea/editing.rs index 9cb7f82602..6418c3f289 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/editing.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/editing.rs @@ -7,7 +7,7 @@ use super::*; /// The selection to replace, or an empty range at the insertion cursor. /// Vim Replace without a selection computes its overwrite span as it inserts each grapheme. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub(crate) struct EditTarget { range: Range, } diff --git a/codex-rs/tui/src/chatwidget/clipboard.rs b/codex-rs/tui/src/chatwidget/clipboard.rs index ec512cdb44..3d8bfa69d7 100644 --- a/codex-rs/tui/src/chatwidget/clipboard.rs +++ b/codex-rs/tui/src/chatwidget/clipboard.rs @@ -12,25 +12,18 @@ pub(super) enum PendingCopy { } 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(crate) fn paste_image(&mut self) -> Result<(), crate::clipboard_paste::PasteImageError> { + let result = paste_image_to_temp_png(); self.suppress_image_paste_until = Instant::now() + IMAGE_PASTE_REPEAT_WINDOW; + let (path, info) = result?; + tracing::debug!( + "pasted image size={}x{} format={}", + info.width, + info.height, + info.encoded_format.label() + ); + self.attach_image(path); + Ok(()) } pub(super) fn show_clipboard_flash(&mut self, result: &CopyResult) { diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 0825bee657..8657de2e83 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -11,7 +11,7 @@ use crate::clipboard_copy::worker::CopyResult; pub(crate) enum KeyEventAction { None, CopyLastResponse(Arc), - PasteImage, + PasteImage { text_fallback: Option }, } impl ChatWidget { @@ -35,14 +35,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(), - ) - }) + /// Snapshot the paste target without changing pending submissions. Unlike mouse paste, + /// a keyboard paste may replace a composer selection or complete an active popup. + pub(crate) fn clipboard_paste_target( + &self, + shortcut: Option, + ) -> Option { + if self.blocks_direct_input { + return None; + } + self.bottom_pane.clipboard_paste_target(shortcut) } pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { @@ -177,13 +179,12 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - && c.eq_ignore_ascii_case(&'v') => + key if key.kind == KeyEventKind::Press + && ((matches!(key.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'v')) + && key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)) + || (cfg!(windows) && key_hint::shift(KeyCode::Insert).is_press(key))) => { if Instant::now() < self.suppress_image_paste_until { self.suppress_image_paste_until = @@ -192,7 +193,14 @@ impl ChatWidget { } self.suppress_image_paste_until = Instant::now() + clipboard::IMAGE_PASTE_REPEAT_WINDOW; - return KeyEventAction::PasteImage; + let text_fallback = (cfg!(windows) + && (key.modifiers == KeyModifiers::CONTROL + || key_hint::shift(KeyCode::Insert).is_press(key))) + .then_some(key.code); + if text_fallback.is_some() { + self.bottom_pane.prepare_composer_clipboard_paste(); + } + return KeyEventAction::PasteImage { text_fallback }; } other if other.kind == KeyEventKind::Press => { self.suppress_image_paste_until = Instant::now(); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 558b044323..bd776702d6 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2527,17 +2527,47 @@ 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] { - let paste = KeyEvent::new(KeyCode::Char('v'), modifiers); + for (code, modifiers) in [ + (KeyCode::Char('v'), KeyModifiers::CONTROL), + (KeyCode::Char('V'), KeyModifiers::CONTROL), + ( + KeyCode::Char('V'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + (KeyCode::Char('v'), KeyModifiers::ALT), + ] { + let paste = KeyEvent::new(code, modifiers); assert_matches!( chat.handle_key_event(paste), - crate::chatwidget::KeyEventAction::PasteImage + crate::chatwidget::KeyEventAction::PasteImage { text_fallback } + if text_fallback == (cfg!(windows) && modifiers == KeyModifiers::CONTROL) + .then_some(code) ); chat.handle_key_event(KeyEvent { kind: KeyEventKind::Release, ..paste }); } + // Crossterm maps VK_INSERT + SHIFT_PRESSED directly to Insert + Shift on Windows. + let shift_insert = KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT); + if cfg!(windows) { + chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('q'))); + assert_matches!( + chat.handle_key_event(shift_insert), + KeyEventAction::PasteImage { + text_fallback: Some(KeyCode::Insert) + } + ); + assert_eq!(chat.bottom_pane.composer_text(), "q"); + assert_matches!(chat.handle_key_event(shift_insert), KeyEventAction::None); + chat.handle_key_event(KeyEvent { + kind: KeyEventKind::Release, + ..shift_insert + }); + chat.bottom_pane + .set_composer_text(String::new(), Vec::new(), Vec::new()); + } let mut keymap_config = chat.config_ref().tui_keymap.clone(); keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One( codex_config::types::KeybindingSpec("alt-v".to_string()), @@ -2570,22 +2600,34 @@ async fn legacy_image_paste_ignores_buffered_press_until_other_input() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL); - assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage); + assert_matches!( + chat.handle_key_event(paste), + KeyEventAction::PasteImage { .. } + ); assert_matches!(chat.handle_key_event(paste), KeyEventAction::None); chat.suppress_image_paste_until = Instant::now() - Duration::from_secs(/*secs*/ 1); - assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage); + assert_matches!( + chat.handle_key_event(paste), + KeyEventAction::PasteImage { .. } + ); chat.suppress_image_paste_until = Instant::now() + Duration::from_secs(/*secs*/ 1); chat.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); - assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage); + assert_matches!( + chat.handle_key_event(paste), + KeyEventAction::PasteImage { .. } + ); chat.suppress_image_paste_until = Instant::now() + Duration::from_secs(/*secs*/ 1); chat.handle_key_event(KeyEvent { kind: KeyEventKind::Release, ..paste }); - assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage); + assert_matches!( + chat.handle_key_event(paste), + KeyEventAction::PasteImage { .. } + ); } #[tokio::test] 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 index 1ca0443f01..4479b74009 100644 --- 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 @@ -1,6 +1,6 @@ --- source: tui/src/clipboard_copy/worker_tests.rs -assertion_line: 112 +assertion_line: 115 expression: "feedback.join(\"\\n\")" --- -• Clipboard busy; try image paste after it finishes +• Clipboard busy; try again diff --git a/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__shortcut_clipboard_paste.snap b/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__shortcut_clipboard_paste.snap new file mode 100644 index 0000000000..9854e1a847 --- /dev/null +++ b/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__shortcut_clipboard_paste.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/clipboard_copy/worker_tests.rs +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)" +--- +› draft café + second line + + + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/clipboard_copy/worker_tests.rs b/codex-rs/tui/src/clipboard_copy/worker_tests.rs index be4f11b5bc..ed48e126d0 100644 --- a/codex-rs/tui/src/clipboard_copy/worker_tests.rs +++ b/codex-rs/tui/src/clipboard_copy/worker_tests.rs @@ -8,6 +8,7 @@ use crate::pager_overlay::Overlay; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use std::time::Duration; @@ -317,20 +318,75 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() { .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 "), + let ctrl_v = KeyCode::Char('v'); + let shift_insert = KeyCode::Insert; + for (shortcut, change, expected) in [ + (None, "none", "draft café\nsecond line\n"), + (None, "native", "draft native"), + (None, "edit", "replacement"), + (None, "disable", "draft "), + (Some(ctrl_v), "none", "draft café\nsecond line\n"), + (Some(shift_insert), "none", "draft café\nsecond line\n"), + (Some(ctrl_v), "native", "draft native"), + (Some(shift_insert), "edit", "replacement"), + (Some(ctrl_v), "click", "draft "), + // Last, since parent-owned threads no longer allow editing the draft. + (Some(shift_insert), "readonly", "draft "), ] { - app.local_settings.tui.right_click_paste = RightClickPaste::On; + // Shortcuts remain eligible inline, with mouse paste disabled. + tui.set_owned_screen(shortcut.is_none()).unwrap(); + app.local_settings.tui.right_click_paste = if shortcut.is_some() { + RightClickPaste::Off + } else { + RightClickPaste::On + }; app.chat_widget.apply_external_edit("draft ".into()); - app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click)) + // Cover a read still running, and a completed read awaiting its Draw. + let read_completed = shortcut == Some(ctrl_v) && change == "none"; + if shortcut.is_some() { + // Enter at the NoImage fallback, independently of the host clipboard. + app.start_clipboard_text_paste(&mut tui, PasteSource::Clipboard, shortcut); + } else { + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click)) + .await + .unwrap(); + } + start_rx.recv_timeout(SETUP_TIMEOUT).unwrap(); + if let Some(key) = shortcut { + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyEvent { + kind: KeyEventKind::Release, + ..KeyEvent::from(key) + }), + ) .await .unwrap(); - start_rx.recv_timeout(SETUP_TIMEOUT).unwrap(); - if change == "none" { + if read_completed { + // The read may complete on a key event, before Draw delivers the paste. + release.send(()).unwrap(); + tokio::time::timeout(SETUP_TIMEOUT, async { + while tui.clipboard.is_busy() { + tui.clipboard.poll(); + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + if change == "none" { + // Shift+Insert is a native paste shortcut only on Windows. + let other = if cfg!(windows) && key == ctrl_v { + KeyEvent::new(shift_insert, KeyModifiers::SHIFT) + } else { + KeyEvent::new(ctrl_v, KeyModifiers::CONTROL) + }; + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(other)) + .await + .unwrap(); + } + } else if change == "none" { app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click)) .await .unwrap(); @@ -350,7 +406,8 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() { ) .await .unwrap(); - } else if change == "native" { + } + if change == "native" { app.handle_tui_event(&mut tui, &mut server, TuiEvent::Paste("native".into())) .await .unwrap(); @@ -358,8 +415,16 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() { app.chat_widget.apply_external_edit("replacement".into()); } else if change == "disable" { app.local_settings.tui.right_click_paste = RightClickPaste::Off; + } else if change == "click" { + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click)) + .await + .unwrap(); + } else if change == "readonly" { + app.chat_widget.set_parent_owned_thread(); + } + if !read_completed { + release.send(()).unwrap(); } - release.send(()).unwrap(); tokio::time::timeout(SETUP_TIMEOUT, async { while tui.clipboard.is_busy() { app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) @@ -370,7 +435,25 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() { }) .await .unwrap(); - assert_eq!(app.chat_widget.composer_text_with_pending(), expected); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await + .unwrap(); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + expected, + "{shortcut:?} {change}" + ); + if shortcut.is_some() && change == "none" { + insta::assert_snapshot!( + "shortcut_clipboard_paste", + crate::chatwidget::tests::helpers::normalize_snapshot_paths( + crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 80 + ) + ) + ); + } } } diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index cc5489d6e0..ff291801ea 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -77,9 +77,13 @@ pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageErro img } else { let _span = tracing::debug_span!("get_image").entered(); - let img = cb - .get_image() - .map_err(|e| PasteImageError::NoImage(e.to_string()))?; + let img = cb.get_image().map_err(|e| { + if cfg!(windows) && !matches!(e, arboard::Error::ContentNotAvailable) { + PasteImageError::ClipboardUnavailable(e.to_string()) + } else { + PasteImageError::NoImage(e.to_string()) + } + })?; let w = img.width as u32; let h = img.height as u32; tracing::debug!("clipboard image opened from image: {}x{}", w, h);