diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e8eb55f692..db54193148 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -36,67 +36,6 @@ use std::time::Instant; /// Time window for debouncing redraw requests. const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1); -/// Naive percent-decoding for file:// URL paths; returns None on invalid UTF-8. -fn percent_decode_to_string(input: &str) -> Option { - let bytes = input.as_bytes(); - let mut out: Vec = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - let h1 = bytes[i + 1]; - let h2 = bytes[i + 2]; - let hex = |c: u8| -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } - }; - if let (Some(x), Some(y)) = (hex(h1), hex(h2)) { - out.push(x * 16 + y); - i += 3; - continue; - } - } - out.push(bytes[i]); - i += 1; - } - String::from_utf8(out).ok() -} - -/// Convert a file:// URL into a local path (macOS/Unix only, UTF-8). -fn file_url_to_path(s: &str) -> Option { - if let Some(rest) = s.strip_prefix("file://") { - // Strip optional host like file://localhost/... - let rest = rest.strip_prefix("localhost").unwrap_or(rest); - // Ensure leading slash remains for absolute paths - let decoded = percent_decode_to_string(rest)?; - let p = PathBuf::from(decoded); - return Some(p); - } - None -} - -/// Unescape simple bash-style backslash escapes (e.g., spaces, parens). -fn unescape_backslashes(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(n) = chars.next() { - out.push(n); - } else { - // Trailing backslash; keep it. - out.push('\\'); - } - } else { - out.push(c); - } - } - out -} - // Testable helper: generic over paste function so we can inject stubs in unit tests. fn try_handle_ctrl_v_with( app_event_tx: &AppEventSender, @@ -452,125 +391,8 @@ impl App<'_> { }; } AppEvent::Paste(text) => { - // Prefer attaching a pasted image file path, if the text looks - // like an existing image file. This avoids grabbing the Finder - // icon bitmap from the clipboard when a user copied a file. - let mut handled = false; - let mut s = text.trim().to_string(); - if !s.is_empty() { - // Strip surrounding quotes (common for paths with spaces) - if (s.starts_with('"') && s.ends_with('"')) - || (s.starts_with('\'') && s.ends_with('\'')) - { - s = s[1..s.len() - 1].to_string(); - } - // Expand leading ~/ to HOME - 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(); - } - } - let path = std::path::PathBuf::from(&s); - if path.is_file() { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - let ext_l = ext.to_ascii_lowercase(); - if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") { - let (mut w, mut h) = (0u32, 0u32); - if let Ok((dw, dh)) = image::image_dimensions(&path) { - w = dw; - h = dh; - } - let fmt = if ext_l == "png" { "PNG" } else { "JPEG" }; - if let AppState::Chat { widget } = &mut self.app_state { - widget.attach_image(path, w, h, fmt); - } - handled = true; - } - } - } - } - - if !handled { - // Try to parse shell-escaped or URL-style file paths from the paste. - let candidates: Vec = if let Some(tokens) = shlex::split(&text) { - tokens - } else { - vec![text.clone()] - }; - - 'outer: for raw in candidates { - let mut s = raw.trim().to_string(); - // Strip surrounding quotes if present (redundant with shlex, but safe) - if (s.starts_with('"') && s.ends_with('"')) - || (s.starts_with('\'') && s.ends_with('\'')) - { - s = s[1..s.len() - 1].to_string(); - } - // Expand leading ~/ to HOME - 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(); - } - } - - let mut try_paths: Vec = Vec::new(); - if let Some(p) = file_url_to_path(&s) { - try_paths.push(p); - } - // As-is path - try_paths.push(PathBuf::from(&s)); - // Unescaped variant (e.g., My\ Photo.png) - let unescaped = unescape_backslashes(&s); - if unescaped != s { - try_paths.push(PathBuf::from(unescaped)); - } - - for path in try_paths { - if path.is_file() { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - let ext_l = ext.to_ascii_lowercase(); - if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") { - let (mut w, mut h) = (0u32, 0u32); - if let Ok((dw, dh)) = image::image_dimensions(&path) { - w = dw; - h = dh; - } - let fmt = if ext_l == "png" { "PNG" } else { "JPEG" }; - if let AppState::Chat { widget } = &mut self.app_state { - widget.attach_image(path, w, h, fmt); - } - handled = true; - break 'outer; - } - } - } - } - } - - if !handled { - // If no usable path was pasted, try to read an image bitmap - // from the clipboard; otherwise, fall back to text paste. - match crate::clipboard_paste::paste_image_to_temp_png() { - Ok((path, info)) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.attach_image( - path, - info.width, - info.height, - info.encoded_format_label, - ); - } - } - Err(_) => { - self.dispatch_paste_event(text); - } - } - } - } + // Route paste handling to the active widget. + self.dispatch_paste_event(text); } AppEvent::CodexEvent(event) => { self.dispatch_codex_event(event); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1ce424afae..dde690878a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -215,7 +215,6 @@ impl ChatComposer { pub fn take_recent_submission_images_with_placeholders( &mut self, ) -> Vec<(String, std::path::PathBuf)> { - std::mem::take(&mut self.attached_images) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 99e4a22d38..7a66a14aa9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -97,6 +97,8 @@ struct UserMessage { } use crate::streaming::StreamKind; +use crate::string_utils::file_url_to_path; +use crate::string_utils::unescape_backslashes; impl From for UserMessage { fn from(text: String) -> Self { @@ -578,7 +580,103 @@ impl ChatWidget<'_> { } pub(crate) fn handle_paste(&mut self, text: String) { - self.bottom_pane.handle_paste(text); + // 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. + 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()) { + let ext_l = ext.to_ascii_lowercase(); + if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") { + let (mut w, mut h) = (0u32, 0u32); + if let Ok((dw, dh)) = image::image_dimensions(&path) { + w = dw; + h = dh; + } + let fmt = if ext_l == "png" { "PNG" } else { "JPEG" }; + widget.attach_image(path, w, h, fmt); + return true; + } + } + } + false + } + + // Trim and strip quotes for the most direct case. + let mut s = text.trim().to_string(); + if !s.is_empty() { + if (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(); + } + } + handled = try_attach_image(self, std::path::PathBuf::from(&s)); + } + + // 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()] + }; + + 'outer: for raw in candidates { + let mut s = raw.trim().to_string(); + if (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(); + } + } + + 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 still not handled, try to read an image bitmap from the clipboard. + if !handled { + match crate::clipboard_paste::paste_image_to_temp_png() { + Ok((path, info)) => { + self.attach_image(path, info.width, info.height, info.encoded_format_label); + } + Err(_) => { + // Fall back to textual paste into the composer. + self.bottom_pane.handle_paste(text); + } + } + } } fn flush_active_exec_cell(&mut self) { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2352a1580e..5be1c9450e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,6 +49,7 @@ mod shimmer; mod slash_command; mod status_indicator_widget; mod streaming; +mod string_utils; mod text_formatting; mod tui; mod user_approval_widget; diff --git a/codex-rs/tui/src/string_utils.rs b/codex-rs/tui/src/string_utils.rs new file mode 100644 index 0000000000..c2891eca2d --- /dev/null +++ b/codex-rs/tui/src/string_utils.rs @@ -0,0 +1,61 @@ +// String and path parsing helpers used across the TUI. + +// Naive percent-decoding for file:// URL paths; returns None on invalid UTF-8. +pub(crate) fn percent_decode_to_string(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let h1 = bytes[i + 1]; + let h2 = bytes[i + 2]; + let hex = |c: u8| -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } + }; + if let (Some(x), Some(y)) = (hex(h1), hex(h2)) { + out.push(x * 16 + y); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).ok() +} + +// Convert a file:// URL into a local path (macOS/Unix only, UTF-8). +pub(crate) fn file_url_to_path(s: &str) -> Option { + if let Some(rest) = s.strip_prefix("file://") { + // Strip optional host like file://localhost/... + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + let decoded = percent_decode_to_string(rest)?; + let p = std::path::PathBuf::from(decoded); + return Some(p); + } + None +} + +// Unescape simple bash-style backslash escapes (e.g., spaces, parens). +pub(crate) fn unescape_backslashes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(n) = chars.next() { + out.push(n); + } else { + // Trailing backslash; keep it. + out.push('\\'); + } + } else { + out.push(c); + } + } + out +}