mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
3 Commits
response-a
...
daniel/wip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
317882c143 | ||
|
|
c6d3203cf5 | ||
|
|
edf2402b95 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -994,6 +994,7 @@ dependencies = [
|
||||
"tui-markdown",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
"url",
|
||||
"uuid",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
98
codex-rs/tui/src/bottom_pane/string_utils.rs
Normal file
98
codex-rs/tui/src/bottom_pane/string_utils.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user