17 KiB
PR #2567: Copying / Dragging image files (MacOS Terminal + iTerm)
- URL: https://github.com/openai/codex/pull/2567
- Author: dedrisian-oai
- Created: 2025-08-21 21:58:50 UTC
- Updated: 2025-08-25 23:39:49 UTC
- Changes: +242/-10, Files changed: 4, Commits: 15
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
- Created: 2025-08-25 17:19:57 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2298675628
@@ -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 testsor removed altogether if they are at the top of the file?
- Created: 2025-08-25 17:22:12 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2298679533
@@ -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
tmpis of typeTempDirand when it is dropped at the end of the test, it will delete itself, so you can removelet _ = fs::remove_file(tmp_path);
codex-rs/tui/src/bottom_pane/string_utils.rs
- Created: 2025-08-21 23:59:52 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2292378860
@@ -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?
- Created: 2025-08-25 17:22:56 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2298681334
@@ -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?
- Created: 2025-08-25 17:26:43 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2298688043
@@ -0,0 +1,98 @@
+use std::path::PathBuf;
I try to avoid introducing files named
utils.rsorstring_utils.rsbecause it can easily become a dumping ground for random things. In this case, I would consider something likeimage_paths.rsorpasted_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
- Created: 2025-08-25 22:44:23 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2299298186
@@ -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!