diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 91dfa00a52..ffd8799bcc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -641,6 +641,18 @@ impl ChatComposer { true } + /// Integrate pasted text as literal text, without creating large-paste placeholders. + /// + /// This powers the Cmd+Shift+V shortcut so users can intentionally paste large blocks while + /// keeping the full text visible/editable in the textarea. + pub(crate) fn handle_verbatim_paste(&mut self, pasted: String) -> bool { + let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); + self.textarea.insert_str(&pasted); + self.paste_burst.clear_after_explicit_paste(); + self.sync_popups(); + true + } + pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { let Some(path_buf) = normalize_pasted_path(&pasted) else { return false; @@ -5263,6 +5275,37 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + #[test] + fn handle_verbatim_paste_large_inserts_text_without_placeholder() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); + let needs_redraw = composer.handle_verbatim_paste(large.clone()); + assert!(needs_redraw); + assert_eq!(composer.textarea.text(), large); + assert!(composer.pending_pastes.is_empty()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, large), + _ => panic!("expected Submitted"), + } + } + /// Behavior: editing that removes a paste placeholder should also clear the associated /// `pending_pastes` entry so it cannot be submitted accidentally. #[test] diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 43aecb4940..c8fdc49de3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -427,6 +427,23 @@ impl BottomPane { } } + pub fn handle_verbatim_paste(&mut self, pasted: String) { + if let Some(view) = self.view_stack.last_mut() { + let needs_redraw = view.handle_paste(pasted); + if view.is_complete() { + self.on_active_view_complete(); + } + if needs_redraw { + self.request_redraw(); + } + } else { + let needs_redraw = self.composer.handle_verbatim_paste(pasted); + if needs_redraw { + self.request_redraw(); + } + } + } + pub(crate) fn insert_str(&mut self, text: &str) { self.composer.insert_str(text); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0a6da6a5ae..f45ea3a696 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -184,6 +184,7 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; +use crate::clipboard_paste::paste_text; use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -3090,6 +3091,27 @@ impl ChatWidget { } return; } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::SUPER) + && modifiers.contains(KeyModifiers::SHIFT) + && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && c.eq_ignore_ascii_case(&'v') => + { + match paste_text() { + Ok(text) => self.bottom_pane.handle_verbatim_paste(text), + Err(err) => { + tracing::warn!("failed to paste text: {err}"); + self.add_to_history(history_cell::new_error_event(format!( + "Failed to paste text: {err}", + ))); + } + } + return; + } other if other.kind == KeyEventKind::Press => { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 4d28b365fe..c214ca2f25 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -22,6 +22,23 @@ impl std::fmt::Display for PasteImageError { } impl std::error::Error for PasteImageError {} +#[derive(Debug, Clone)] +pub enum PasteTextError { + ClipboardUnavailable(String), + NoText(String), +} + +impl std::fmt::Display for PasteTextError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PasteTextError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"), + PasteTextError::NoText(msg) => write!(f, "no text on clipboard: {msg}"), + } + } +} + +impl std::error::Error for PasteTextError {} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EncodedImageFormat { Png, @@ -149,6 +166,22 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag } } +#[cfg(not(target_os = "android"))] +pub fn paste_text() -> Result { + let mut cb = arboard::Clipboard::new() + .map_err(|e| PasteTextError::ClipboardUnavailable(e.to_string()))?; + cb.get() + .text() + .map_err(|e| PasteTextError::NoText(e.to_string())) +} + +#[cfg(target_os = "android")] +pub fn paste_text() -> Result { + Err(PasteTextError::ClipboardUnavailable( + "clipboard text paste is unsupported on Android".into(), + )) +} + /// Attempt WSL fallback for clipboard image paste. /// /// If clipboard is unavailable (common under WSL because arboard cannot access