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, 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(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 { + 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 = 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 + ); + } +}