From f886cc7303da49f080af36623930dc19162cd05e Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Wed, 20 Aug 2025 15:10:38 -0700 Subject: [PATCH] multi image drop --- codex-rs/tui/src/chatwidget.rs | 115 +++++++++++++++------------ codex-rs/tui/src/chatwidget/tests.rs | 76 ++++++++++++++++++ 2 files changed, 140 insertions(+), 51 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 819f70fc0a..4481017663 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -760,11 +760,7 @@ impl ChatWidget<'_> { } pub(crate) fn handle_paste(&mut self, text: String) { - // First, attempt to interpret the pasted text as a file path to an image - // and attach it. This mirrors the logic previously handled at the app level. - let mut handled = false; - - // Helper to attach an image if the path looks valid, returning true if handled. + // Helper: attempt to attach image and return true if attached. fn try_attach_image(widget: &mut ChatWidget<'_>, path: std::path::PathBuf) -> bool { if path.is_file() { if let Some(ext) = path.extension().and_then(|e| e.to_str()) { @@ -784,9 +780,9 @@ impl ChatWidget<'_> { false } - // Trim and strip quotes for the most direct case. - let mut s = text.trim().to_string(); - if !s.is_empty() { + // Normalize a candidate string into possible paths to try. + fn candidate_paths(candidate: &str) -> Vec { + let mut s = candidate.trim().to_string(); if s.len() >= 2 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\''))) @@ -800,56 +796,73 @@ impl ChatWidget<'_> { s = p.to_string_lossy().into_owned(); } } - handled = try_attach_image(self, std::path::PathBuf::from(&s)); + let mut try_paths: Vec = Vec::new(); + if let Some(p) = file_url_to_path(&s) { + try_paths.push(p); + } + try_paths.push(std::path::PathBuf::from(&s)); + let unescaped = unescape_backslashes(&s); + if unescaped != s { + try_paths.push(std::path::PathBuf::from(unescaped)); + } + try_paths } - // If not handled yet, try multiple candidate interpretations: shlex tokens, - // URL-style paths, and unescaped variants. - if !handled { - let candidates: Vec = if let Some(tokens) = shlex::split(&text) { - tokens - } else { - vec![text.clone()] - }; + let mut any_attached = false; - 'outer: for raw in candidates { - let mut s = raw.trim().to_string(); - if s.len() >= 2 - && ((s.starts_with('"') && s.ends_with('"')) - || (s.starts_with('\'') && s.ends_with('\''))) - { - s = s[1..s.len() - 1].to_string(); - } - if let Some(rest) = s.strip_prefix("~/") { - if let Ok(home) = std::env::var("HOME") { - let mut p = std::path::PathBuf::from(home); - p.push(rest); - s = p.to_string_lossy().into_owned(); + // Strategy: + // - If multiple lines were pasted (drag-and-drop often does this), + // treat each line as a single candidate path. Attach images for lines + // that resolve to valid image files; keep other lines as leftover text. + // - Otherwise, try tokenizing the single line using shlex and attach + // images for any token that resolves to a valid image file; paste + // any remaining tokens as text. + + if text.contains('\n') { + let mut leftover_lines: Vec<&str> = Vec::new(); + for line in text.lines() { + let mut attached_this_line = false; + for p in candidate_paths(line) { + if try_attach_image(self, p) { + any_attached = true; + attached_this_line = true; + break; } } - - let mut try_paths: Vec = Vec::new(); - if let Some(p) = file_url_to_path(&s) { - try_paths.push(p); - } - try_paths.push(std::path::PathBuf::from(&s)); - let unescaped = unescape_backslashes(&s); - if unescaped != s { - try_paths.push(std::path::PathBuf::from(unescaped)); - } - - for path in try_paths { - if try_attach_image(self, path) { - handled = true; - break 'outer; - } + if !attached_this_line { + leftover_lines.push(line); } } - } - - // If still not handled, treat it as a normal textual paste. - if !handled { - self.bottom_pane.handle_paste(text); + let leftover = leftover_lines.join("\n"); + if !leftover.trim().is_empty() { + self.bottom_pane.handle_paste(leftover); + } else if !any_attached { + // Nothing attached and no leftover: forward original text. + self.bottom_pane.handle_paste(text); + } + } else { + // Single line: consider shlex tokens to support quoted paths. + let tokens: Vec = shlex::split(&text).unwrap_or_else(|| vec![text.clone()]); + let mut leftover_tokens: Vec = Vec::new(); + for tok in tokens { + let mut attached_this_token = false; + for p in candidate_paths(&tok) { + if try_attach_image(self, p) { + any_attached = true; + attached_this_token = true; + break; + } + } + if !attached_this_token { + leftover_tokens.push(tok); + } + } + let leftover = leftover_tokens.join(" "); + if !leftover.trim().is_empty() { + self.bottom_pane.handle_paste(leftover); + } else if !any_attached { + self.bottom_pane.handle_paste(text); + } } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0ab1f54287..2faf0f91ef 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -198,6 +198,82 @@ fn extract_trailing_unc_path() { assert!(cand.ends_with("img.jpg")); } +#[test] +fn paste_multiple_lines_attaches_multiple_images_and_no_text() { + use image::ImageBuffer; + use image::Rgba; + // Create two temp images + let dir = std::env::temp_dir(); + let p1 = dir.join(format!("codex_multi_a_{}.png", uuid::Uuid::new_v4())); + let p2 = dir.join(format!("codex_multi_b_{}.png", uuid::Uuid::new_v4())); + let img1: ImageBuffer, Vec> = + ImageBuffer::from_fn(2, 3, |_x, _y| Rgba([255, 0, 0, 255])); + image::DynamicImage::ImageRgba8(img1) + .save_with_format(&p1, image::ImageFormat::Png) + .expect("write png"); + let img2: ImageBuffer, Vec> = + ImageBuffer::from_fn(4, 1, |_x, _y| Rgba([0, 255, 0, 255])); + image::DynamicImage::ImageRgba8(img2) + .save_with_format(&p2, image::ImageFormat::Png) + .expect("write png"); + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let paste = format!("{}\n{}\n", p1.display(), p2.display()); + chat.handle_paste(paste); + + // Expect composer text to contain two image placeholders and no literal paths. + let composer_text = chat.bottom_pane.current_input_text(); + assert!( + composer_text.contains("[image 2x3 PNG]"), + "missing png placeholder: {composer_text}" + ); + assert!( + composer_text.contains("[image 4x1 PNG]"), + "missing png placeholder (second): {composer_text}" + ); + assert!(!composer_text.contains("a.png")); + assert!(!composer_text.contains("b.jpg")); +} + +#[test] +fn paste_single_line_two_quoted_paths_attaches_both_and_keeps_other_text() { + use image::ImageBuffer; + use image::Rgba; + let dir = std::env::temp_dir(); + let p1 = dir.join(format!("codex_multi_c_{}.png", uuid::Uuid::new_v4())); + let p2 = dir.join(format!("codex_multi_d_{}.png", uuid::Uuid::new_v4())); + let img1: ImageBuffer, Vec> = + ImageBuffer::from_fn(1, 1, |_x, _y| Rgba([1, 2, 3, 255])); + image::DynamicImage::ImageRgba8(img1) + .save_with_format(&p1, image::ImageFormat::Png) + .expect("write png"); + let img2: ImageBuffer, Vec> = + ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([4, 5, 6, 255])); + image::DynamicImage::ImageRgba8(img2) + .save_with_format(&p2, image::ImageFormat::Png) + .expect("write png"); + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let paste = format!( + "Please see \"{}\" and \"{}\" now", + p1.display(), + p2.display() + ); + chat.handle_paste(paste); + + let composer_text = chat.bottom_pane.current_input_text(); + assert!( + composer_text.contains("Please see"), + "leftover text missing: {composer_text}" + ); + assert!( + composer_text.contains("now"), + "leftover text missing: {composer_text}" + ); + assert!(composer_text.contains("[image 1x1 PNG]")); + assert!(composer_text.contains("[image 3x2 PNG]")); +} + fn open_fixture(name: &str) -> std::fs::File { // 1) Prefer fixtures within this crate {