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);