diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cb4cd68d81..4086ce753a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -994,6 +994,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 909ecbc0d9..8be594531c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -27,6 +27,8 @@ use crate::slash_command::SlashCommand; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::string_utils::get_img_format_label; +use crate::bottom_pane::string_utils::normalize_pasted_path; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use codex_file_search::FileMatch; @@ -199,6 +201,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); } @@ -207,6 +211,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 = get_img_format_label(path_buf.clone()); + self.attach_image(path_buf, w, h, &format_label); + true + } + Err(err) => { + tracing::info!("ERR: {err}"); + false + } + } + } + pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) { let placeholder = format!("[image {width}x{height} {format_label}]"); // Insert as an element to match large paste placeholder behavior: @@ -625,13 +648,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); @@ -1583,7 +1599,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(); @@ -1601,7 +1617,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(); @@ -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"); + 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) = std::sync::mpsc::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()]); + + let _ = fs::remove_file(tmp_path); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 04f5d4b9bf..b7bcbea478 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -23,6 +23,7 @@ mod popup_consts; mod scroll_state; mod selection_popup_common; mod status_indicator_view; +mod string_utils; mod textarea; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/bottom_pane/string_utils.rs b/codex-rs/tui/src/bottom_pane/string_utils.rs new file mode 100644 index 0000000000..63fb1541a4 --- /dev/null +++ b/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 { + // 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 = 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 { + match path + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + { + Some(ext) if ext == "png" => "PNG", + Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG", + _ => "IMG", + } + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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_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_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_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 img_format_label_png_jpeg_unknown() { + assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.PNG")), "PNG"); + assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.jpg")), "JPEG"); + assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.JPEG")), "JPEG"); + assert_eq!(get_img_format_label(PathBuf::from("/a/b/c")), "IMG"); + assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.webp")), "IMG"); + } + + #[test] + fn img_format_label_with_windows_style_paths() { + assert_eq!(get_img_format_label(PathBuf::from(r"C:\a\b\c.PNG")), "PNG"); + assert_eq!( + get_img_format_label(PathBuf::from(r"C:\a\b\c.jpeg")), + "JPEG" + ); + assert_eq!(get_img_format_label(PathBuf::from(r"C:\a\b\noext")), "IMG"); + } +}