Copying / Dragging image files (MacOS Terminal + iTerm) (#2567)
In this PR: - [x] Add support for dragging / copying image files into chat. - [x] Don't remove image placeholders when submitting. - [x] Add tests. Works for: - Image Files - Dragging MacOS Screenshots (Terminal, iTerm) Todos: - [ ] In some terminals (VSCode, WIndows Powershell, and remote SSH-ing), copy-pasting a file streams the escaped filepath as individual key events rather than a single Paste event. We'll need to have a function (in a separate PR) for detecting these paste events.
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1001,6 +1001,7 @@ dependencies = [
|
|||||||
"tui-markdown",
|
"tui-markdown",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.1.14",
|
"unicode-width 0.1.14",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"vt100",
|
"vt100",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ codex-login = { path = "../login" }
|
|||||||
codex-ollama = { path = "../ollama" }
|
codex-ollama = { path = "../ollama" }
|
||||||
codex-protocol = { path = "../protocol" }
|
codex-protocol = { path = "../protocol" }
|
||||||
color-eyre = "0.6.3"
|
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"
|
diffy = "0.4.2"
|
||||||
image = { version = "^0.25.6", default-features = false, features = [
|
image = { version = "^0.25.6", default-features = false, features = [
|
||||||
"jpeg",
|
"jpeg",
|
||||||
@@ -82,6 +85,7 @@ tui-input = "0.14.0"
|
|||||||
tui-markdown = "0.3.3"
|
tui-markdown = "0.3.3"
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
|
url = "2"
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ use crate::app_event::AppEvent;
|
|||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::bottom_pane::textarea::TextArea;
|
use crate::bottom_pane::textarea::TextArea;
|
||||||
use crate::bottom_pane::textarea::TextAreaState;
|
use crate::bottom_pane::textarea::TextAreaState;
|
||||||
|
use crate::clipboard_paste::normalize_pasted_path;
|
||||||
|
use crate::clipboard_paste::pasted_image_format;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -220,6 +222,8 @@ impl ChatComposer {
|
|||||||
let placeholder = format!("[Pasted Content {char_count} chars]");
|
let placeholder = format!("[Pasted Content {char_count} chars]");
|
||||||
self.textarea.insert_element(&placeholder);
|
self.textarea.insert_element(&placeholder);
|
||||||
self.pending_pastes.push((placeholder, pasted));
|
self.pending_pastes.push((placeholder, pasted));
|
||||||
|
} else if self.handle_paste_image_path(pasted.clone()) {
|
||||||
|
self.textarea.insert_str(" ");
|
||||||
} else {
|
} else {
|
||||||
self.textarea.insert_str(&pasted);
|
self.textarea.insert_str(&pasted);
|
||||||
}
|
}
|
||||||
@@ -232,6 +236,25 @@ impl ChatComposer {
|
|||||||
true
|
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 = pasted_image_format(&path_buf).label();
|
||||||
|
self.attach_image(path_buf, w, h, format_label);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::info!("ERR: {err}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the entire composer content with `text` and reset cursor.
|
/// Replace the entire composer content with `text` and reset cursor.
|
||||||
pub(crate) fn set_text_content(&mut self, text: String) {
|
pub(crate) fn set_text_content(&mut self, text: String) {
|
||||||
self.textarea.set_text(&text);
|
self.textarea.set_text(&text);
|
||||||
@@ -730,13 +753,6 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
self.pending_pastes.clear();
|
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();
|
text = text.trim().to_string();
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
self.history.record_local_submission(&text);
|
self.history.record_local_submission(&text);
|
||||||
@@ -1236,7 +1252,10 @@ impl WidgetRef for ChatComposer {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use image::ImageBuffer;
|
||||||
|
use image::Rgba;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::bottom_pane::AppEventSender;
|
use crate::bottom_pane::AppEventSender;
|
||||||
@@ -1819,7 +1838,7 @@ mod tests {
|
|||||||
let (result, _) =
|
let (result, _) =
|
||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
match result {
|
match result {
|
||||||
InputResult::Submitted(text) => assert_eq!(text, "hi"),
|
InputResult::Submitted(text) => assert_eq!(text, "[image 32x16 PNG] hi"),
|
||||||
_ => panic!("expected Submitted"),
|
_ => panic!("expected Submitted"),
|
||||||
}
|
}
|
||||||
let imgs = composer.take_recent_submission_images();
|
let imgs = composer.take_recent_submission_images();
|
||||||
@@ -1837,7 +1856,7 @@ mod tests {
|
|||||||
let (result, _) =
|
let (result, _) =
|
||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
match result {
|
match result {
|
||||||
InputResult::Submitted(text) => assert!(text.is_empty()),
|
InputResult::Submitted(text) => assert_eq!(text, "[image 10x5 PNG]"),
|
||||||
_ => panic!("expected Submitted"),
|
_ => panic!("expected Submitted"),
|
||||||
}
|
}
|
||||||
let imgs = composer.take_recent_submission_images();
|
let imgs = composer.take_recent_submission_images();
|
||||||
@@ -1913,4 +1932,25 @@ mod tests {
|
|||||||
"one image mapping remains"
|
"one image mapping remains"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasting_filepath_attaches_image() {
|
||||||
|
let tmp = tempdir().expect("create TempDir");
|
||||||
|
let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png");
|
||||||
|
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||||
|
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) = unbounded_channel::<AppEvent>();
|
||||||
|
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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tempfile::Builder;
|
use tempfile::Builder;
|
||||||
|
|
||||||
@@ -24,12 +25,16 @@ impl std::error::Error for PasteImageError {}
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum EncodedImageFormat {
|
pub enum EncodedImageFormat {
|
||||||
Png,
|
Png,
|
||||||
|
Jpeg,
|
||||||
|
Other,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EncodedImageFormat {
|
impl EncodedImageFormat {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
EncodedImageFormat::Png => "PNG",
|
EncodedImageFormat::Png => "PNG",
|
||||||
|
EncodedImageFormat::Jpeg => "JPEG",
|
||||||
|
EncodedImageFormat::Other => "IMG",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,3 +100,185 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
|
|||||||
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
|
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
|
||||||
Ok((path, info))
|
Ok((path, info))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize pasted text that may represent a filesystem path.
|
||||||
|
///
|
||||||
|
/// Supports:
|
||||||
|
/// - `file://` URLs (converted to local paths)
|
||||||
|
/// - Windows/UNC paths
|
||||||
|
/// - shell-escaped single paths (via `shlex`)
|
||||||
|
pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
|
||||||
|
let pasted = pasted.trim();
|
||||||
|
|
||||||
|
// file:// URL → filesystem path
|
||||||
|
if let Ok(url) = url::Url::parse(pasted)
|
||||||
|
&& url.scheme() == "file"
|
||||||
|
{
|
||||||
|
return url.to_file_path().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: We'll improve the implementation/unit tests over time, as appropriate.
|
||||||
|
// Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e
|
||||||
|
//
|
||||||
|
// Detect unquoted Windows paths and bypass POSIX shlex which
|
||||||
|
// treats backslashes as escapes (e.g., C:\Users\Alice\file.png).
|
||||||
|
// Also handles UNC paths (\\server\share\path).
|
||||||
|
let looks_like_windows_path = {
|
||||||
|
// Drive letter path: C:\ or C:/
|
||||||
|
let drive = pasted
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.is_ascii_alphabetic())
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& pasted.get(1..2) == Some(":")
|
||||||
|
&& pasted
|
||||||
|
.get(2..3)
|
||||||
|
.map(|s| s == "\\" || s == "/")
|
||||||
|
.unwrap_or(false);
|
||||||
|
// UNC path: \\server\share
|
||||||
|
let unc = pasted.starts_with("\\\\");
|
||||||
|
drive || unc
|
||||||
|
};
|
||||||
|
if looks_like_windows_path {
|
||||||
|
return Some(PathBuf::from(pasted));
|
||||||
|
}
|
||||||
|
|
||||||
|
// shell-escaped single path → unescaped
|
||||||
|
let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
|
||||||
|
if parts.len() == 1 {
|
||||||
|
return parts.into_iter().next().map(PathBuf::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infer an image format for the provided path based on its extension.
|
||||||
|
pub fn pasted_image_format(path: &Path) -> EncodedImageFormat {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("png") => EncodedImageFormat::Png,
|
||||||
|
Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg,
|
||||||
|
_ => EncodedImageFormat::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod pasted_paths_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[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_file_url_windows() {
|
||||||
|
let input = r"C:\Temp\example.png";
|
||||||
|
let result = normalize_pasted_path(input).expect("should parse file URL");
|
||||||
|
assert_eq!(result, PathBuf::from(r"C:\Temp\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_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 pasted_image_format_png_jpeg_unknown() {
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new("/a/b/c.PNG")),
|
||||||
|
EncodedImageFormat::Png
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new("/a/b/c.jpg")),
|
||||||
|
EncodedImageFormat::Jpeg
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new("/a/b/c.JPEG")),
|
||||||
|
EncodedImageFormat::Jpeg
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new("/a/b/c")),
|
||||||
|
EncodedImageFormat::Other
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new("/a/b/c.webp")),
|
||||||
|
EncodedImageFormat::Other
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_unquoted_windows_path_with_spaces() {
|
||||||
|
let input = r"C:\\Users\\Alice\\My Pictures\\example image.png";
|
||||||
|
let result = normalize_pasted_path(input).expect("should accept unquoted windows path");
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_unc_windows_path() {
|
||||||
|
let input = r"\\\\server\\share\\folder\\file.jpg";
|
||||||
|
let result = normalize_pasted_path(input).expect("should accept UNC windows path");
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
PathBuf::from(r"\\\\server\\share\\folder\\file.jpg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasted_image_format_with_windows_style_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")),
|
||||||
|
EncodedImageFormat::Png
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")),
|
||||||
|
EncodedImageFormat::Jpeg
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pasted_image_format(Path::new(r"C:\\a\\b\\noext")),
|
||||||
|
EncodedImageFormat::Other
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user