mirror of
https://github.com/openai/codex.git
synced 2026-04-28 18:32:04 +03:00
528 lines
17 KiB
Markdown
528 lines
17 KiB
Markdown
# 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:
|
|
|
|
- [x] Add support for dragging / copying image files into chat.
|
|
- [x] Don't remove image placeholders when submitting.
|
|
- [x] 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
|
|
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
|
|
|
|
```diff
|
|
@@ -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?
|
|
|
|
- Created: 2025-08-25 17:22:12 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2298679533
|
|
|
|
```diff
|
|
@@ -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:
|
|
>
|
|
> ```rust
|
|
> 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
|
|
|
|
- Created: 2025-08-21 23:59:52 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2292378860
|
|
|
|
```diff
|
|
@@ -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
|
|
|
|
```diff
|
|
@@ -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
|
|
|
|
```diff
|
|
@@ -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
|
|
|
|
- Created: 2025-08-25 22:44:23 UTC | Link: https://github.com/openai/codex/pull/2567#discussion_r2299298186
|
|
|
|
```diff
|
|
@@ -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! |