Files
codex/prs/bolinfest/PR-2567.md
2025-09-02 15:17:45 -07:00

17 KiB

PR #2567: Copying / Dragging image files (MacOS Terminal + iTerm)

Description

In this PR:

  • Add support for dragging / copying image files into chat.
  • Don't remove image placeholders when submitting.
  • Add tests.

Works for:

  • Image Files
  • Dragging MacOS Screenshots (Terminal, iTerm)

Todos:

  • In some terminals (VSCode, WIndows Powershell, and remote SSH-ing), copy-pasting a file streams the escaped filepath as individual key events rather than a single Paste event. We'll need to have a function (in a separate PR) for detecting these paste events.

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index b5b65011e7..c4678213cb 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -1001,6 +1001,7 @@ dependencies = [
  "tui-markdown",
  "unicode-segmentation",
  "unicode-width 0.1.14",
+ "url",
  "uuid",
  "vt100",
 ]
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 6d69e97e73..8e5fdf1ae4 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -40,7 +40,10 @@ codex-login = { path = "../login" }
 codex-ollama = { path = "../ollama" }
 codex-protocol = { path = "../protocol" }
 color-eyre = "0.6.3"
-crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] }
+crossterm = { version = "0.28.1", features = [
+    "bracketed-paste",
+    "event-stream",
+] }
 diffy = "0.4.2"
 image = { version = "^0.25.6", default-features = false, features = [
     "jpeg",
@@ -82,6 +85,7 @@ tui-input = "0.14.0"
 tui-markdown = "0.3.3"
 unicode-segmentation = "1.12.0"
 unicode-width = "0.1"
+url = "2"
 uuid = "1"
 
 [target.'cfg(unix)'.dependencies]
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 049f872635..8b10d224dd 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -29,6 +29,8 @@ use crate::app_event::AppEvent;
 use crate::app_event_sender::AppEventSender;
 use crate::bottom_pane::textarea::TextArea;
 use crate::bottom_pane::textarea::TextAreaState;
+use crate::clipboard_paste::normalize_pasted_path;
+use crate::clipboard_paste::pasted_image_format;
 use codex_file_search::FileMatch;
 use std::cell::RefCell;
 use std::collections::HashMap;
@@ -220,6 +222,8 @@ impl ChatComposer {
             let placeholder = format!("[Pasted Content {char_count} chars]");
             self.textarea.insert_element(&placeholder);
             self.pending_pastes.push((placeholder, pasted));
+        } else if self.handle_paste_image_path(pasted.clone()) {
+            self.textarea.insert_str(" ");
         } else {
             self.textarea.insert_str(&pasted);
         }
@@ -232,6 +236,25 @@ impl ChatComposer {
         true
     }
 
+    pub fn handle_paste_image_path(&mut self, pasted: String) -> bool {
+        let Some(path_buf) = normalize_pasted_path(&pasted) else {
+            return false;
+        };
+
+        match image::image_dimensions(&path_buf) {
+            Ok((w, h)) => {
+                tracing::info!("OK: {pasted}");
+                let format_label = pasted_image_format(&path_buf).label();
+                self.attach_image(path_buf, w, h, format_label);
+                true
+            }
+            Err(err) => {
+                tracing::info!("ERR: {err}");
+                false
+            }
+        }
+    }
+
     /// Replace the entire composer content with `text` and reset cursor.
     pub(crate) fn set_text_content(&mut self, text: String) {
         self.textarea.set_text(&text);
@@ -730,13 +753,6 @@ impl ChatComposer {
                 }
                 self.pending_pastes.clear();
 
-                // Strip image placeholders from the submitted text; images are retrieved via take_recent_submission_images()
-                for img in &self.attached_images {
-                    if text.contains(&img.placeholder) {
-                        text = text.replace(&img.placeholder, "");
-                    }
-                }
-
                 text = text.trim().to_string();
                 if !text.is_empty() {
                     self.history.record_local_submission(&text);
@@ -1236,7 +1252,10 @@ impl WidgetRef for ChatComposer {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use image::ImageBuffer;
+    use image::Rgba;
     use std::path::PathBuf;
+    use tempfile::tempdir;
 
     use crate::app_event::AppEvent;
     use crate::bottom_pane::AppEventSender;
@@ -1819,7 +1838,7 @@ mod tests {
         let (result, _) =
             composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
         match result {
-            InputResult::Submitted(text) => assert_eq!(text, "hi"),
+            InputResult::Submitted(text) => assert_eq!(text, "[image 32x16 PNG] hi"),
             _ => panic!("expected Submitted"),
         }
         let imgs = composer.take_recent_submission_images();
@@ -1837,7 +1856,7 @@ mod tests {
         let (result, _) =
             composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
         match result {
-            InputResult::Submitted(text) => assert!(text.is_empty()),
+            InputResult::Submitted(text) => assert_eq!(text, "[image 10x5 PNG]"),
             _ => panic!("expected Submitted"),
         }
         let imgs = composer.take_recent_submission_images();
@@ -1913,4 +1932,25 @@ mod tests {
             "one image mapping remains"
         );
     }
+
+    #[test]
+    fn pasting_filepath_attaches_image() {
+        let tmp = tempdir().expect("create TempDir");
+        let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png");
+        let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
+            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::<AppEvent>();
+        let sender = AppEventSender::new(tx);
+        let mut composer =
+            ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
+
+        let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
+        assert!(needs_redraw);
+        assert!(composer.textarea.text().starts_with("[image 3x2 PNG] "));
+
+        let imgs = composer.take_recent_submission_images();
+        assert_eq!(imgs, vec![tmp_path.clone()]);
+    }
 }
diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs
index 3888ac343f..5a6a8b2f2e 100644
--- a/codex-rs/tui/src/clipboard_paste.rs
+++ b/codex-rs/tui/src/clipboard_paste.rs
@@ -1,3 +1,4 @@
+use std::path::Path;
 use std::path::PathBuf;
 use tempfile::Builder;
 
@@ -24,12 +25,16 @@ impl std::error::Error for PasteImageError {}
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum EncodedImageFormat {
     Png,
+    Jpeg,
+    Other,
 }
 
 impl EncodedImageFormat {
     pub fn label(self) -> &'static str {
         match self {
             EncodedImageFormat::Png => "PNG",
+            EncodedImageFormat::Jpeg => "JPEG",
+            EncodedImageFormat::Other => "IMG",
         }
     }
 }
@@ -95,3 +100,185 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
         .map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
     Ok((path, info))
 }
+
+/// Normalize pasted text that may represent a filesystem path.
+///
+/// Supports:
+/// - `file://` URLs (converted to local paths)
+/// - Windows/UNC paths
+/// - shell-escaped single paths (via `shlex`)
+pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
+    let pasted = pasted.trim();
+
+    // file:// URL → filesystem path
+    if let Ok(url) = url::Url::parse(pasted)
+        && url.scheme() == "file"
+    {
+        return url.to_file_path().ok();
+    }
+
+    // TODO: We'll improve the implementation/unit tests over time, as appropriate.
+    // Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e
+    //
+    // Detect unquoted Windows paths and bypass POSIX shlex which
+    // treats backslashes as escapes (e.g., C:\Users\Alice\file.png).
+    // Also handles UNC paths (\\server\share\path).
+    let looks_like_windows_path = {
+        // Drive letter path: C:\ or C:/
+        let drive = pasted
+            .chars()
+            .next()
+            .map(|c| c.is_ascii_alphabetic())
+            .unwrap_or(false)
+            && pasted.get(1..2) == Some(":")
+            && pasted
+                .get(2..3)
+                .map(|s| s == "\\" || s == "/")
+                .unwrap_or(false);
+        // UNC path: \\server\share
+        let unc = pasted.starts_with("\\\\");
+        drive || unc
+    };
+    if looks_like_windows_path {
+        return Some(PathBuf::from(pasted));
+    }
+
+    // shell-escaped single path → unescaped
+    let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
+    if parts.len() == 1 {
+        return parts.into_iter().next().map(PathBuf::from);
+    }
+
+    None
+}
+
+/// Infer an image format for the provided path based on its extension.
+pub fn pasted_image_format(path: &Path) -> EncodedImageFormat {
+    match path
+        .extension()
+        .and_then(|e| e.to_str())
+        .map(|s| s.to_ascii_lowercase())
+        .as_deref()
+    {
+        Some("png") => EncodedImageFormat::Png,
+        Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg,
+        _ => EncodedImageFormat::Other,
+    }
+}
+
+#[cfg(test)]
+mod pasted_paths_tests {
+    use super::*;
+
+    #[cfg(not(windows))]
+    #[test]
+    fn normalize_file_url() {
+        let input = "file:///tmp/example.png";
+        let result = normalize_pasted_path(input).expect("should parse file URL");
+        assert_eq!(result, PathBuf::from("/tmp/example.png"));
+    }
+
+    #[test]
+    fn normalize_file_url_windows() {
+        let input = r"C:\Temp\example.png";
+        let result = normalize_pasted_path(input).expect("should parse file URL");
+        assert_eq!(result, PathBuf::from(r"C:\Temp\example.png"));
+    }
+
+    #[test]
+    fn normalize_shell_escaped_single_path() {
+        let input = "/home/user/My\\ File.png";
+        let result = normalize_pasted_path(input).expect("should unescape shell-escaped path");
+        assert_eq!(result, PathBuf::from("/home/user/My File.png"));
+    }
+
+    #[test]
+    fn normalize_simple_quoted_path_fallback() {
+        let input = "\"/home/user/My File.png\"";
+        let result = normalize_pasted_path(input).expect("should trim simple quotes");
+        assert_eq!(result, PathBuf::from("/home/user/My File.png"));
+    }
+
+    #[test]
+    fn normalize_single_quoted_unix_path() {
+        let input = "'/home/user/My File.png'";
+        let result = normalize_pasted_path(input).expect("should trim single quotes via shlex");
+        assert_eq!(result, PathBuf::from("/home/user/My File.png"));
+    }
+
+    #[test]
+    fn normalize_multiple_tokens_returns_none() {
+        // Two tokens after shell splitting → not a single path
+        let input = "/home/user/a\\ b.png /home/user/c.png";
+        let result = normalize_pasted_path(input);
+        assert!(result.is_none());
+    }
+
+    #[test]
+    fn pasted_image_format_png_jpeg_unknown() {
+        assert_eq!(
+            pasted_image_format(Path::new("/a/b/c.PNG")),
+            EncodedImageFormat::Png
+        );
+        assert_eq!(
+            pasted_image_format(Path::new("/a/b/c.jpg")),
+            EncodedImageFormat::Jpeg
+        );
+        assert_eq!(
+            pasted_image_format(Path::new("/a/b/c.JPEG")),
+            EncodedImageFormat::Jpeg
+        );
+        assert_eq!(
+            pasted_image_format(Path::new("/a/b/c")),
+            EncodedImageFormat::Other
+        );
+        assert_eq!(
+            pasted_image_format(Path::new("/a/b/c.webp")),
+            EncodedImageFormat::Other
+        );
+    }
+
+    #[test]
+    fn normalize_single_quoted_windows_path() {
+        let input = r"'C:\\Users\\Alice\\My File.jpeg'";
+        let result =
+            normalize_pasted_path(input).expect("should trim single quotes on windows path");
+        assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg"));
+    }
+
+    #[test]
+    fn normalize_unquoted_windows_path_with_spaces() {
+        let input = r"C:\\Users\\Alice\\My Pictures\\example image.png";
+        let result = normalize_pasted_path(input).expect("should accept unquoted windows path");
+        assert_eq!(
+            result,
+            PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")
+        );
+    }
+
+    #[test]
+    fn normalize_unc_windows_path() {
+        let input = r"\\\\server\\share\\folder\\file.jpg";
+        let result = normalize_pasted_path(input).expect("should accept UNC windows path");
+        assert_eq!(
+            result,
+            PathBuf::from(r"\\\\server\\share\\folder\\file.jpg")
+        );
+    }
+
+    #[test]
+    fn pasted_image_format_with_windows_style_paths() {
+        assert_eq!(
+            pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")),
+            EncodedImageFormat::Png
+        );
+        assert_eq!(
+            pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")),
+            EncodedImageFormat::Jpeg
+        );
+        assert_eq!(
+            pasted_image_format(Path::new(r"C:\\a\\b\\noext")),
+            EncodedImageFormat::Other
+        );
+    }
+}

Review Comments

codex-rs/tui/src/bottom_pane/chat_composer.rs

@@ -1677,4 +1693,31 @@ mod tests {
             "one image mapping remains"
         );
     }
+
+    #[test]
+    fn pasting_filepath_attaches_image() {
+        use image::ImageBuffer;
+        use image::Rgba;
+        use std::fs;
+        use std::path::PathBuf;

These imports can probably be moved up to the top of mod tests or removed altogether if they are at the top of the file?

@@ -1677,4 +1693,31 @@ mod tests {
             "one image mapping remains"
         );
     }
+
+    #[test]
+    fn pasting_filepath_attaches_image() {
+        use image::ImageBuffer;
+        use image::Rgba;
+        use std::fs;
+        use std::path::PathBuf;
+
+        let tmp_path: PathBuf = std::env::temp_dir().join("codex_tui_test_paste_image.png");

Prefer:

let tmp = tempdir().expect("create TempDir");
let tmp_path = tmp.path();

Then tmp is of type TempDir and when it is dropped at the end of the test, it will delete itself, so you can remove let _ = fs::remove_file(tmp_path);

codex-rs/tui/src/bottom_pane/string_utils.rs

@@ -0,0 +1,99 @@
+use std::path::PathBuf;
+
+pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
+    // file:// URL → filesystem path
+    if let Ok(url) = url::Url::parse(pasted) {
+        if url.scheme() == "file" {
+            return url.to_file_path().ok();
+        }
+    }
+
+    // shell-escaped single path → unescaped
+    let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
+    if parts.len() == 1 {
+        return parts.into_iter().next().map(PathBuf::from);
+    }
+
+    None
+}
+
+pub fn get_img_format_label(path: PathBuf) -> String {

Image type enum instead of string?

@@ -0,0 +1,98 @@
+use std::path::PathBuf;
+
+pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
+    // file:// URL → filesystem path
+    if let Ok(url) = url::Url::parse(pasted)
+        && url.scheme() == "file" {
+            return url.to_file_path().ok();
+        }

just fmt?

@@ -0,0 +1,98 @@
+use std::path::PathBuf;

I try to avoid introducing files named utils.rs or string_utils.rs because it can easily become a dumping ground for random things. In this case, I would consider something like image_paths.rs or pasted_paths.rs. Basically something that reflects that we should not put, I don't know, an phone number parsing function in here.

codex-rs/tui/src/clipboard_paste.rs

@@ -95,3 +100,181 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
         .map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
     Ok((path, info))
 }
+
+/// Normalize pasted text that may represent a filesystem path.
+///
+/// Supports:
+/// - `file://` URLs (converted to local paths)
+/// - shell-escaped single paths (via `shlex`)
+pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
+    let pasted = pasted.trim();
+
+    // file:// URL → filesystem path
+    if let Ok(url) = url::Url::parse(pasted)
+        && url.scheme() == "file"
+    {
+        return url.to_file_path().ok();
+    }
+
+    // Detect unquoted Windows paths and bypass POSIX shlex which

OK, let's go with this and we'll improve the implementation/unit tests over time, as appropriate!