From d261d89bdc5c13f0826f20c373dc26bb7ae11b4b Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Tue, 19 Aug 2025 19:13:35 -0700 Subject: [PATCH] Fix cmd+C not working on MacOS --- codex-rs/tui/src/app.rs | 137 ++++++++++++++++++++++++++-- codex-rs/tui/src/clipboard_paste.rs | 58 ++++++++++++ 2 files changed, 188 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a2704b4a06..46f52dff1c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -48,12 +48,41 @@ where crate::clipboard_paste::PasteImageError, >, { - if key_event.kind == KeyEventKind::Press - && key_event.code == KeyCode::Char('v') - && key_event - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { + // Treat both Ctrl+V and Cmd+V (SUPER on macOS) as the "paste image" hotkey. + let is_v = matches!(key_event.code, KeyCode::Char('v')); + let mods = key_event.modifiers; + let has_paste_modifier = mods.contains(crossterm::event::KeyModifiers::CONTROL) + || mods.contains(crossterm::event::KeyModifiers::SUPER); + + if key_event.kind == KeyEventKind::Press && is_v && has_paste_modifier { + // On macOS, prefer attaching a file URL from the pasteboard if present. + #[cfg(target_os = "macos")] + { + if let Some(path) = crate::clipboard_paste::image_file_from_clipboard_macos() { + let (mut w, mut h) = (0u32, 0u32); + if let Ok((dw, dh)) = image::image_dimensions(&path) { + w = dw; + h = dh; + } + let fmt = match path + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + .as_deref() + { + Some("png") => "PNG", + Some("jpg") | Some("jpeg") => "JPEG", + _ => "IMG", + }; + app_event_tx.send(AppEvent::AttachImage { + path, + width: w, + height: h, + format_label: fmt, + }); + return true; + } + } match paste_fn() { Ok((path, info)) => { tracing::info!( @@ -390,7 +419,65 @@ impl App<'_> { }; } AppEvent::Paste(text) => { - self.dispatch_paste_event(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 { + // 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); + } + } + } } AppEvent::CodexEvent(event) => { self.dispatch_codex_event(event); @@ -796,6 +883,42 @@ mod tests { } } + #[test] + fn cmd_v_success_attaches_image() { + let (tx, rx) = std::sync::mpsc::channel(); + let sender = AppEventSender::new(tx); + let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER); + let dummy_info = crate::clipboard_paste::PastedImageInfo { + width: 12, + height: 8, + encoded_format_label: "PNG", + }; + let handled = try_handle_ctrl_v_with(&sender, &key_event, || { + Ok(( + std::path::PathBuf::from("/tmp/test2.png"), + dummy_info.clone(), + )) + }); + assert!(handled, "expected cmd+v to be handled on success"); + match rx + .recv() + .unwrap_or_else(|e| panic!("failed to receive event: {e}")) + { + AppEvent::AttachImage { + path, + width, + height, + format_label, + } => { + assert_eq!(path, std::path::PathBuf::from("/tmp/test2.png")); + assert_eq!(width, 12); + assert_eq!(height, 8); + assert_eq!(format_label, "PNG"); + } + _ => panic!("unexpected event (not AttachImage)"), + } + } + #[test] fn ctrl_v_failure_not_consumed() { let (tx, rx) = std::sync::mpsc::channel(); diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 365ad390c0..558d2227a3 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -71,3 +71,61 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag std::fs::write(&path, &png).map_err(|e| PasteImageError::IoError(e.to_string()))?; Ok((path, info)) } + +/// macOS-specific: Try extracting image file paths from the system pasteboard +/// when the user copied a file in Finder. Prefer attaching the actual file +/// instead of the small icon bitmap that may also be present on the clipboard. +#[cfg(target_os = "macos")] +pub fn image_file_from_clipboard_macos() -> Option { + fn run_osascript(lines: &[&str]) -> Option { + use std::process::Command; + let output = Command::new("osascript") + .args(lines.iter().flat_map(|l| ["-e", *l])) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).to_string()) + } + + // 1) Try to read a list of aliases (multiple files) + if let Some(out) = run_osascript(&[ + "try", + "set theFiles to the clipboard as alias list", + "set out to \"\"", + "repeat with f in theFiles", + "set out to out & POSIX path of f & \"\n\"", + "end repeat", + "out", + "end try", + ]) { + for line in out.lines() { + let p = std::path::PathBuf::from(line.trim()); + if p.is_file() { + if let Some(ext) = p.extension().and_then(|e| e.to_str()) { + let ext = ext.to_ascii_lowercase(); + if matches!(ext.as_str(), "png" | "jpg" | "jpeg") { + return Some(p); + } + } + } + } + } + + // 2) Fallback: single alias + if let Some(out) = run_osascript(&["try", "POSIX path of (the clipboard as alias)", "end try"]) + { + let p = std::path::PathBuf::from(out.trim()); + if p.is_file() { + if let Some(ext) = p.extension().and_then(|e| e.to_str()) { + let ext = ext.to_ascii_lowercase(); + if matches!(ext.as_str(), "png" | "jpg" | "jpeg") { + return Some(p); + } + } + } + } + + None +}