diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9e5ac2d95e4..b98e6ad186f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -517,7 +517,7 @@ impl App { // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 let pasted = pasted.replace("\r", "\n"); - self.chat_widget.handle_paste(pasted); + self.chat_widget.handle_paste_event(pasted); } TuiEvent::Draw => { self.chat_widget.maybe_post_pending_notification(tui); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index e25baa11121..534291bb726 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 2151 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 6156a5b96ab..826e44f708f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 455 expression: terminal.backend() --- " / for commands ! for shell commands " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3fd7a891e4..6e3e1e63e05 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1588,26 +1588,10 @@ impl ChatWidget { modifiers, kind: KeyEventKind::Press, .. - } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - && c.eq_ignore_ascii_case(&'v') => + } if c.eq_ignore_ascii_case(&'v') + && modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { - 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}", - ))); - } - } + self.paste_image_from_clipboard(); return; } other if other.kind == KeyEventKind::Press => { @@ -1658,6 +1642,32 @@ impl ChatWidget { self.request_redraw(); } + /// Attempt to attach an image from the system clipboard. + /// + /// This is a best-effort path used when we receive an empty paste event, + /// which some terminals emit when the clipboard contains non-text data + /// (like images). When the clipboard can't be read or no image exists, + /// surface a helpful follow-up so the user can retry with a file path. + fn paste_image_from_clipboard(&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}. Try saving the image to a file and pasting the file path instead.", + ))); + } + } + } + pub(crate) fn composer_text_with_pending(&self) -> String { self.bottom_pane.composer_text_with_pending() } @@ -1909,6 +1919,20 @@ impl ChatWidget { self.bottom_pane.handle_paste(text); } + /// Route paste events through image detection. + /// + /// Terminals vary in how they represent paste: some emit an empty paste + /// payload when the clipboard isn't text (common for image-only clipboard + /// contents). Treat the empty payload as a hint to attempt a clipboard + /// image read; otherwise, fall back to text handling. + pub(crate) fn handle_paste_event(&mut self, text: String) { + if text.is_empty() { + self.paste_image_from_clipboard(); + } else { + self.handle_paste(text); + } + } + // Returns true if caller should skip rendering this frame (a future frame is scheduled). pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() {