Skip to content

Commit 468a8b4

Browse files
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.
1 parent cb32f9c commit 468a8b4

File tree

4 files changed

+242
-10
lines changed

4 files changed

+242
-10
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/tui/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ codex-login = { path = "../login" }
4040
codex-ollama = { path = "../ollama" }
4141
codex-protocol = { path = "../protocol" }
4242
color-eyre = "0.6.3"
43-
crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] }
43+
crossterm = { version = "0.28.1", features = [
44+
"bracketed-paste",
45+
"event-stream",
46+
] }
4447
diffy = "0.4.2"
4548
image = { version = "^0.25.6", default-features = false, features = [
4649
"jpeg",
@@ -82,6 +85,7 @@ tui-input = "0.14.0"
8285
tui-markdown = "0.3.3"
8386
unicode-segmentation = "1.12.0"
8487
unicode-width = "0.1"
88+
url = "2"
8589
uuid = "1"
8690

8791
[target.'cfg(unix)'.dependencies]

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ use crate::app_event::AppEvent;
2929
use crate::app_event_sender::AppEventSender;
3030
use crate::bottom_pane::textarea::TextArea;
3131
use crate::bottom_pane::textarea::TextAreaState;
32+
use crate::clipboard_paste::normalize_pasted_path;
33+
use crate::clipboard_paste::pasted_image_format;
3234
use codex_file_search::FileMatch;
3335
use std::cell::RefCell;
3436
use std::collections::HashMap;
@@ -220,6 +222,8 @@ impl ChatComposer {
220222
let placeholder = format!("[Pasted Content {char_count} chars]");
221223
self.textarea.insert_element(&placeholder);
222224
self.pending_pastes.push((placeholder, pasted));
225+
} else if self.handle_paste_image_path(pasted.clone()) {
226+
self.textarea.insert_str(" ");
223227
} else {
224228
self.textarea.insert_str(&pasted);
225229
}
@@ -232,6 +236,25 @@ impl ChatComposer {
232236
true
233237
}
234238

239+
pub fn handle_paste_image_path(&mut self, pasted: String) -> bool {
240+
let Some(path_buf) = normalize_pasted_path(&pasted) else {
241+
return false;
242+
};
243+
244+
match image::image_dimensions(&path_buf) {
245+
Ok((w, h)) => {
246+
tracing::info!("OK: {pasted}");
247+
let format_label = pasted_image_format(&path_buf).label();
248+
self.attach_image(path_buf, w, h, format_label);
249+
true
250+
}
251+
Err(err) => {
252+
tracing::info!("ERR: {err}");
253+
false
254+
}
255+
}
256+
}
257+
235258
/// Replace the entire composer content with `text` and reset cursor.
236259
pub(crate) fn set_text_content(&mut self, text: String) {
237260
self.textarea.set_text(&text);
@@ -730,13 +753,6 @@ impl ChatComposer {
730753
}
731754
self.pending_pastes.clear();
732755

733-
// Strip image placeholders from the submitted text; images are retrieved via take_recent_submission_images()
734-
for img in &self.attached_images {
735-
if text.contains(&img.placeholder) {
736-
text = text.replace(&img.placeholder, "");
737-
}
738-
}
739-
740756
text = text.trim().to_string();
741757
if !text.is_empty() {
742758
self.history.record_local_submission(&text);
@@ -1236,7 +1252,10 @@ impl WidgetRef for ChatComposer {
12361252
#[cfg(test)]
12371253
mod tests {
12381254
use super::*;
1255+
use image::ImageBuffer;
1256+
use image::Rgba;
12391257
use std::path::PathBuf;
1258+
use tempfile::tempdir;
12401259

12411260
use crate::app_event::AppEvent;
12421261
use crate::bottom_pane::AppEventSender;
@@ -1819,7 +1838,7 @@ mod tests {
18191838
let (result, _) =
18201839
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
18211840
match result {
1822-
InputResult::Submitted(text) => assert_eq!(text, "hi"),
1841+
InputResult::Submitted(text) => assert_eq!(text, "[image 32x16 PNG] hi"),
18231842
_ => panic!("expected Submitted"),
18241843
}
18251844
let imgs = composer.take_recent_submission_images();
@@ -1837,7 +1856,7 @@ mod tests {
18371856
let (result, _) =
18381857
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
18391858
match result {
1840-
InputResult::Submitted(text) => assert!(text.is_empty()),
1859+
InputResult::Submitted(text) => assert_eq!(text, "[image 10x5 PNG]"),
18411860
_ => panic!("expected Submitted"),
18421861
}
18431862
let imgs = composer.take_recent_submission_images();
@@ -1913,4 +1932,25 @@ mod tests {
19131932
"one image mapping remains"
19141933
);
19151934
}
1935+
1936+
#[test]
1937+
fn pasting_filepath_attaches_image() {
1938+
let tmp = tempdir().expect("create TempDir");
1939+
let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png");
1940+
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
1941+
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255]));
1942+
img.save(&tmp_path).expect("failed to write temp png");
1943+
1944+
let (tx, _rx) = unbounded_channel::<AppEvent>();
1945+
let sender = AppEventSender::new(tx);
1946+
let mut composer =
1947+
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
1948+
1949+
let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string());
1950+
assert!(needs_redraw);
1951+
assert!(composer.textarea.text().starts_with("[image 3x2 PNG] "));
1952+
1953+
let imgs = composer.take_recent_submission_images();
1954+
assert_eq!(imgs, vec![tmp_path.clone()]);
1955+
}
19161956
}

codex-rs/tui/src/clipboard_paste.rs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::path::Path;
12
use std::path::PathBuf;
23
use tempfile::Builder;
34

@@ -24,12 +25,16 @@ impl std::error::Error for PasteImageError {}
2425
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2526
pub enum EncodedImageFormat {
2627
Png,
28+
Jpeg,
29+
Other,
2730
}
2831

2932
impl EncodedImageFormat {
3033
pub fn label(self) -> &'static str {
3134
match self {
3235
EncodedImageFormat::Png => "PNG",
36+
EncodedImageFormat::Jpeg => "JPEG",
37+
EncodedImageFormat::Other => "IMG",
3338
}
3439
}
3540
}
@@ -95,3 +100,185 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
95100
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
96101
Ok((path, info))
97102
}
103+
104+
/// Normalize pasted text that may represent a filesystem path.
105+
///
106+
/// Supports:
107+
/// - `file://` URLs (converted to local paths)
108+
/// - Windows/UNC paths
109+
/// - shell-escaped single paths (via `shlex`)
110+
pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
111+
let pasted = pasted.trim();
112+
113+
// file:// URL → filesystem path
114+
if let Ok(url) = url::Url::parse(pasted)
115+
&& url.scheme() == "file"
116+
{
117+
return url.to_file_path().ok();
118+
}
119+
120+
// TODO: We'll improve the implementation/unit tests over time, as appropriate.
121+
// Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e
122+
//
123+
// Detect unquoted Windows paths and bypass POSIX shlex which
124+
// treats backslashes as escapes (e.g., C:\Users\Alice\file.png).
125+
// Also handles UNC paths (\\server\share\path).
126+
let looks_like_windows_path = {
127+
// Drive letter path: C:\ or C:/
128+
let drive = pasted
129+
.chars()
130+
.next()
131+
.map(|c| c.is_ascii_alphabetic())
132+
.unwrap_or(false)
133+
&& pasted.get(1..2) == Some(":")
134+
&& pasted
135+
.get(2..3)
136+
.map(|s| s == "\\" || s == "/")
137+
.unwrap_or(false);
138+
// UNC path: \\server\share
139+
let unc = pasted.starts_with("\\\\");
140+
drive || unc
141+
};
142+
if looks_like_windows_path {
143+
return Some(PathBuf::from(pasted));
144+
}
145+
146+
// shell-escaped single path → unescaped
147+
let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
148+
if parts.len() == 1 {
149+
return parts.into_iter().next().map(PathBuf::from);
150+
}
151+
152+
None
153+
}
154+
155+
/// Infer an image format for the provided path based on its extension.
156+
pub fn pasted_image_format(path: &Path) -> EncodedImageFormat {
157+
match path
158+
.extension()
159+
.and_then(|e| e.to_str())
160+
.map(|s| s.to_ascii_lowercase())
161+
.as_deref()
162+
{
163+
Some("png") => EncodedImageFormat::Png,
164+
Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg,
165+
_ => EncodedImageFormat::Other,
166+
}
167+
}
168+
169+
#[cfg(test)]
170+
mod pasted_paths_tests {
171+
use super::*;
172+
173+
#[cfg(not(windows))]
174+
#[test]
175+
fn normalize_file_url() {
176+
let input = "file:///tmp/example.png";
177+
let result = normalize_pasted_path(input).expect("should parse file URL");
178+
assert_eq!(result, PathBuf::from("/tmp/example.png"));
179+
}
180+
181+
#[test]
182+
fn normalize_file_url_windows() {
183+
let input = r"C:\Temp\example.png";
184+
let result = normalize_pasted_path(input).expect("should parse file URL");
185+
assert_eq!(result, PathBuf::from(r"C:\Temp\example.png"));
186+
}
187+
188+
#[test]
189+
fn normalize_shell_escaped_single_path() {
190+
let input = "/home/user/My\\ File.png";
191+
let result = normalize_pasted_path(input).expect("should unescape shell-escaped path");
192+
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
193+
}
194+
195+
#[test]
196+
fn normalize_simple_quoted_path_fallback() {
197+
let input = "\"/home/user/My File.png\"";
198+
let result = normalize_pasted_path(input).expect("should trim simple quotes");
199+
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
200+
}
201+
202+
#[test]
203+
fn normalize_single_quoted_unix_path() {
204+
let input = "'/home/user/My File.png'";
205+
let result = normalize_pasted_path(input).expect("should trim single quotes via shlex");
206+
assert_eq!(result, PathBuf::from("/home/user/My File.png"));
207+
}
208+
209+
#[test]
210+
fn normalize_multiple_tokens_returns_none() {
211+
// Two tokens after shell splitting → not a single path
212+
let input = "/home/user/a\\ b.png /home/user/c.png";
213+
let result = normalize_pasted_path(input);
214+
assert!(result.is_none());
215+
}
216+
217+
#[test]
218+
fn pasted_image_format_png_jpeg_unknown() {
219+
assert_eq!(
220+
pasted_image_format(Path::new("/a/b/c.PNG")),
221+
EncodedImageFormat::Png
222+
);
223+
assert_eq!(
224+
pasted_image_format(Path::new("/a/b/c.jpg")),
225+
EncodedImageFormat::Jpeg
226+
);
227+
assert_eq!(
228+
pasted_image_format(Path::new("/a/b/c.JPEG")),
229+
EncodedImageFormat::Jpeg
230+
);
231+
assert_eq!(
232+
pasted_image_format(Path::new("/a/b/c")),
233+
EncodedImageFormat::Other
234+
);
235+
assert_eq!(
236+
pasted_image_format(Path::new("/a/b/c.webp")),
237+
EncodedImageFormat::Other
238+
);
239+
}
240+
241+
#[test]
242+
fn normalize_single_quoted_windows_path() {
243+
let input = r"'C:\\Users\\Alice\\My File.jpeg'";
244+
let result =
245+
normalize_pasted_path(input).expect("should trim single quotes on windows path");
246+
assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg"));
247+
}
248+
249+
#[test]
250+
fn normalize_unquoted_windows_path_with_spaces() {
251+
let input = r"C:\\Users\\Alice\\My Pictures\\example image.png";
252+
let result = normalize_pasted_path(input).expect("should accept unquoted windows path");
253+
assert_eq!(
254+
result,
255+
PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")
256+
);
257+
}
258+
259+
#[test]
260+
fn normalize_unc_windows_path() {
261+
let input = r"\\\\server\\share\\folder\\file.jpg";
262+
let result = normalize_pasted_path(input).expect("should accept UNC windows path");
263+
assert_eq!(
264+
result,
265+
PathBuf::from(r"\\\\server\\share\\folder\\file.jpg")
266+
);
267+
}
268+
269+
#[test]
270+
fn pasted_image_format_with_windows_style_paths() {
271+
assert_eq!(
272+
pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")),
273+
EncodedImageFormat::Png
274+
);
275+
assert_eq!(
276+
pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")),
277+
EncodedImageFormat::Jpeg
278+
);
279+
assert_eq!(
280+
pasted_image_format(Path::new(r"C:\\a\\b\\noext")),
281+
EncodedImageFormat::Other
282+
);
283+
}
284+
}

0 commit comments

Comments
 (0)