From bbb707cf917f730b93bfc5d3f91fbb4459cb1235 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 26 May 2026 22:49:08 -0700 Subject: [PATCH] tui: keep image paths literal in shell mode Fixes #24711. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 35 +++++++++++- codex-rs/tui/src/chatwidget.rs | 3 ++ codex-rs/tui/src/chatwidget/interaction.rs | 53 +++++++++++++------ .../chatwidget/tests/composer_submission.rs | 18 +++++++ codex-rs/tui/src/clipboard_paste.rs | 22 ++++++++ 5 files changed, 112 insertions(+), 19 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8fcda378fc..d97b5cf079 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -869,8 +869,8 @@ impl ChatComposer { /// /// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder /// element (expanded on submit) and stores the full text in `pending_pastes`. - /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a - /// trailing space so the user can keep typing naturally. + /// - Otherwise, if the paste looks like an image path outside shell mode, attaches the image + /// and inserts a trailing space so the user can keep typing naturally. /// - Otherwise, inserts the pasted text directly into the textarea. /// /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect @@ -884,6 +884,7 @@ impl ChatComposer { self.draft.pending_pastes.push((placeholder, pasted)); } else if char_count > 1 && self.image_paste_enabled() + && !self.draft.is_bash_mode && self.handle_paste_image_path(pasted.clone()) { self.draft.textarea.insert_str(" "); @@ -9719,6 +9720,36 @@ mod tests { assert_eq!(imgs, vec![tmp_path]); } + #[test] + fn pasting_filepath_in_shell_mode_keeps_literal_text() { + let tmp = tempdir().expect("create TempDir"); + let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png"); + let img: ImageBuffer, Vec> = + ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); + img.save(&tmp_path).expect("failed to write temp png"); + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['!']); + assert!(composer.draft.is_bash_mode); + + let path = tmp_path.to_string_lossy().to_string(); + let needs_redraw = composer.handle_paste(path.clone()); + + assert!(needs_redraw); + assert_eq!(composer.draft.textarea.text(), path); + assert_eq!(composer.current_text(), format!("!{path}")); + assert!(composer.local_image_paths().is_empty()); + } + #[test] fn slash_path_input_submits_without_command_error() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 16ac980f35..665a4109a8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -284,7 +284,10 @@ use crate::bottom_pane::SelectionItem; 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::PasteImageError; +use crate::clipboard_paste::PastedImageInfo; use crate::clipboard_paste::paste_image_to_temp_png; +use crate::clipboard_paste::paste_text_or_file_path; use crate::collaboration_modes; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 0819bd6c03..d854144091 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -72,23 +72,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}", - ))); - } - } + self.handle_paste_image_shortcut(paste_text_or_file_path, paste_image_to_temp_png); return; } other if other.kind == KeyEventKind::Press => { @@ -158,6 +142,41 @@ impl ChatWidget { } } + pub(crate) fn handle_paste_image_shortcut( + &mut self, + paste_text_or_file_path: impl FnOnce() -> Result, String>, + paste_image_to_temp_png: impl FnOnce() -> Result<(PathBuf, PastedImageInfo), PasteImageError>, + ) { + if self.bottom_pane.composer_text().starts_with('!') { + match paste_text_or_file_path() { + Ok(Some(text)) => self.handle_paste(text), + Ok(None) => tracing::debug!( + "clipboard did not contain text or a file path to paste in shell mode" + ), + Err(err) => tracing::warn!("failed to paste text in shell mode: {err}"), + } + return; + } + + 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}", + ))); + } + } + } + /// Attach a local image to the composer when the active model supports image inputs. /// /// When the model does not advertise image support, we keep the draft unchanged and surface a diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index bfe563b94c..e3cab2fc09 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -985,6 +985,24 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn paste_image_shortcut_in_shell_mode_pastes_clipboard_text() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane + .set_composer_text("!ls ".to_string(), Vec::new(), Vec::new()); + + chat.handle_paste_image_shortcut( + || Ok(Some("/tmp/from-clipboard.png".to_string())), + || panic!("shell mode should not read image clipboard"), + ); + + assert_eq!( + chat.bottom_pane.composer_text(), + "!ls /tmp/from-clipboard.png" + ); + assert!(chat.bottom_pane.take_recent_submission_images().is_empty()); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 4d28b365fe..2b80772239 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -46,6 +46,28 @@ pub struct PastedImageInfo { pub encoded_format: EncodedImageFormat, // Always PNG for now. } +#[cfg(not(target_os = "android"))] +pub fn paste_text_or_file_path() -> Result, String> { + let mut cb = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; + + if let Ok(text) = cb.get_text() + && !text.is_empty() + { + return Ok(Some(text)); + } + + let files = cb.get().file_list().unwrap_or_default(); + Ok(files + .into_iter() + .next() + .map(|path| path.to_string_lossy().into_owned())) +} + +#[cfg(target_os = "android")] +pub fn paste_text_or_file_path() -> Result, String> { + Ok(None) +} + /// Capture image from system clipboard, encode to PNG, and return bytes + info. #[cfg(not(target_os = "android"))] pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> {