diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7b4d2f7ec4..026953f51e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -52,6 +52,7 @@ use crate::ui_consts::LIVE_PREFIX_COLS; use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; +use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -61,6 +62,34 @@ use std::time::Instant; /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; +fn maybe_prefix_root_like(path: &Path) -> Option { + #[cfg(windows)] + { + let _ = path; + None + } + + #[cfg(not(windows))] + { + if path.has_root() { + return None; + } + + let path_str = path.to_string_lossy(); + const ROOT_PREFIXES: [&str; 5] = + ["Applications/", "Library/", "System/", "Users/", "Volumes/"]; + + if ROOT_PREFIXES + .iter() + .any(|prefix| path_str.starts_with(prefix)) + { + return Some(PathBuf::from(format!("/{path_str}"))); + } + + None + } +} + /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { @@ -275,11 +304,11 @@ impl ChatComposer { return false; }; - match image::image_dimensions(&path_buf) { - Ok((w, h)) => { + match Self::resolve_image_path_with_fallback(path_buf) { + Ok((resolved_path, w, h)) => { tracing::info!("OK: {pasted}"); - let format_label = pasted_image_format(&path_buf).label(); - self.attach_image(path_buf, w, h, format_label); + let format_label = pasted_image_format(&resolved_path).label(); + self.attach_image(resolved_path, w, h, format_label); true } Err(err) => { @@ -289,6 +318,34 @@ impl ChatComposer { } } + fn resolve_image_path_with_fallback( + path: PathBuf, + ) -> Result<(PathBuf, u32, u32), image::ImageError> { + match image::image_dimensions(&path) { + Ok((w, h)) => Ok((path, w, h)), + Err(err) => { + if let image::ImageError::IoError(io_err) = &err + && io_err.kind() == ErrorKind::NotFound + { + if let Some(fallback) = maybe_prefix_root_like(&path) { + match image::image_dimensions(&fallback) { + Ok((w, h)) => return Ok((fallback, w, h)), + Err(fallback_err) => { + tracing::debug!( + ?fallback_err, + original = %path.display(), + fallback = %fallback.display(), + "fallback_dimensions_failed", + ); + } + } + } + } + Err(err) + } + } + } + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { let was_disabled = self.disable_paste_burst; self.disable_paste_burst = disabled; @@ -3448,4 +3505,20 @@ mod tests { assert_eq!(composer.textarea.text(), "z".repeat(count)); assert!(composer.pending_pastes.is_empty()); } + + #[cfg(not(windows))] + #[test] + fn maybe_prefix_root_like_adds_leading_slash() { + let input = PathBuf::from("Users/example/image.png"); + let result = maybe_prefix_root_like(&input); + assert_eq!(result, Some(PathBuf::from("/Users/example/image.png"))); + } + + #[cfg(not(windows))] + #[test] + fn maybe_prefix_root_like_ignores_relative_dirs() { + let input = PathBuf::from("project/assets/image.png"); + let result = maybe_prefix_root_like(&input); + assert!(result.is_none()); + } } diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 4945eea350..073d7709c6 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -187,12 +187,36 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { // 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); + let mut path = parts.into_iter().next()?; + + #[cfg(not(windows))] + { + path = fixup_unix_root_relative_path(path); + } + + return Some(PathBuf::from(path)); } None } +#[cfg(not(windows))] +fn fixup_unix_root_relative_path(mut path: String) -> String { + use std::path::Path; + + if Path::new(&path).has_root() { + return path; + } + + const ROOT_PREFIXES: [&str; 5] = ["Applications/", "Library/", "System/", "Users/", "Volumes/"]; + + if ROOT_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) { + path.insert(0, '/'); + } + + path +} + /// Infer an image format for the provided path based on its extension. pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { match path @@ -255,6 +279,25 @@ mod pasted_paths_tests { assert!(result.is_none()); } + #[cfg(not(windows))] + #[test] + fn normalize_dragged_finder_users_path() { + let input = "'Users/alice/Pictures/example.png'"; + let result = normalize_pasted_path(input).expect("should add leading slash for Users/"); + assert_eq!(result, PathBuf::from("/Users/alice/Pictures/example.png")); + } + + #[cfg(not(windows))] + #[test] + fn normalize_dragged_finder_volumes_path() { + let input = "'Volumes/ExternalDrive/photos/image.jpg'"; + let result = normalize_pasted_path(input).expect("should add leading slash for Volumes/"); + assert_eq!( + result, + PathBuf::from("/Volumes/ExternalDrive/photos/image.jpg") + ); + } + #[test] fn pasted_image_format_png_jpeg_unknown() { assert_eq!(