Compare commits

...

3 Commits

Author SHA1 Message Date
Daniel Edrisian
317882c143 with tracing 2025-08-25 09:59:24 -07:00
Daniel Edrisian
c6d3203cf5 Fixes + clippy 2025-08-25 09:46:32 -07:00
Daniel Edrisian
edf2402b95 Drag / Copy image files into chat 2025-08-25 09:41:38 -07:00
6 changed files with 160 additions and 11 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -994,6 +994,7 @@ dependencies = [
"tui-markdown",
"unicode-segmentation",
"unicode-width 0.1.14",
"url",
"uuid",
"vt100",
]

View File

@@ -40,7 +40,10 @@ codex-login = { path = "../login" }
codex-ollama = { path = "../ollama" }
codex-protocol = { path = "../protocol" }
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"
image = { version = "^0.25.6", default-features = false, features = [
"jpeg",
@@ -82,6 +85,7 @@ tui-input = "0.14.0"
tui-markdown = "0.3.3"
unicode-segmentation = "1.12.0"
unicode-width = "0.1"
url = "2"
uuid = "1"
[target.'cfg(unix)'.dependencies]

View File

@@ -124,6 +124,7 @@ impl App {
} else {
match event {
TuiEvent::Key(key_event) => {
tracing::info!("event: {}", key_event.code);
self.handle_key_event(tui, key_event).await;
}
TuiEvent::Paste(pasted) => {
@@ -132,7 +133,8 @@ impl App {
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
self.chat_widget.handle_paste(pasted);
self.chat_widget.handle_paste(pasted.clone());
tracing::info!("paste event: {}", pasted);
}
TuiEvent::Draw => {
tui.draw(

View File

@@ -27,6 +27,8 @@ use crate::slash_command::SlashCommand;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::string_utils::get_img_format_label;
use crate::bottom_pane::string_utils::normalize_pasted_path;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_file_search::FileMatch;
@@ -199,6 +201,8 @@ impl ChatComposer {
let placeholder = format!("[Pasted Content {char_count} chars]");
self.textarea.insert_element(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else if self.handle_paste_image_path(pasted.clone()) {
self.textarea.insert_str(" ");
} else {
self.textarea.insert_str(&pasted);
}
@@ -207,6 +211,25 @@ impl ChatComposer {
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 = get_img_format_label(path_buf.clone());
self.attach_image(path_buf, w, h, &format_label);
true
}
Err(err) => {
tracing::info!("ERR: {err}");
false
}
}
}
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
let placeholder = format!("[image {width}x{height} {format_label}]");
// Insert as an element to match large paste placeholder behavior:
@@ -625,13 +648,6 @@ impl ChatComposer {
}
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();
if !text.is_empty() {
self.history.record_local_submission(&text);
@@ -1583,7 +1599,7 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "hi"),
InputResult::Submitted(text) => assert_eq!(text, "[image 32x16 PNG] hi"),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
@@ -1601,7 +1617,7 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert!(text.is_empty()),
InputResult::Submitted(text) => assert_eq!(text, "[image 10x5 PNG]"),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
@@ -1677,4 +1693,31 @@ mod tests {
"one image mapping remains"
);
}
#[test]
fn pasting_filepath_attaches_image() {
use image::ImageBuffer;
use image::Rgba;
use std::fs;
use std::path::PathBuf;
let tmp_path: PathBuf = std::env::temp_dir().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()]);
let _ = fs::remove_file(tmp_path);
}
}

View File

@@ -23,6 +23,7 @@ mod popup_consts;
mod scroll_state;
mod selection_popup_common;
mod status_indicator_view;
mod string_utils;
mod textarea;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -0,0 +1,98 @@
use std::path::PathBuf;
pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
// file:// URL → filesystem path
if let Ok(url) = url::Url::parse(pasted)
&& url.scheme() == "file" {
return url.to_file_path().ok();
}
// 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
}
pub fn get_img_format_label(path: PathBuf) -> String {
match path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
{
Some(ext) if ext == "png" => "PNG",
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
_ => "IMG",
}
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[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_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_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_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 img_format_label_png_jpeg_unknown() {
assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.PNG")), "PNG");
assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.jpg")), "JPEG");
assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.JPEG")), "JPEG");
assert_eq!(get_img_format_label(PathBuf::from("/a/b/c")), "IMG");
assert_eq!(get_img_format_label(PathBuf::from("/a/b/c.webp")), "IMG");
}
#[test]
fn img_format_label_with_windows_style_paths() {
assert_eq!(get_img_format_label(PathBuf::from(r"C:\a\b\c.PNG")), "PNG");
assert_eq!(
get_img_format_label(PathBuf::from(r"C:\a\b\c.jpeg")),
"JPEG"
);
assert_eq!(get_img_format_label(PathBuf::from(r"C:\a\b\noext")), "IMG");
}
}