Compare commits

...

37 Commits

Author SHA1 Message Date
dedrisian-oai
359f228287 Merge branch 'copy_images' into daniel/copy_images 2025-08-19 18:35:58 -07:00
Daniel Edrisian
a10146c26d Clippy fix 2025-08-19 18:26:59 -07:00
Daniel Edrisian
a5df629f69 tui: preserve image placeholders in history on submit
- Add parsing of paste to attach real files already merged
- Keep submitted image placeholders visible in history via new_user_prompt_with_images
- Plumb placeholder strings through BottomPane -> ChatWidget on submit
- Keep agent input free of placeholders (send images as LocalImage)
2025-08-19 18:26:59 -07:00
Daniel Edrisian
2273d18302 tui: attach real image file on drag/drop
- Parse shell-escaped paths via shlex and unescape backslashes
- Recognize and percent-decode file:// URLs
- Prefer existing .png/.jpg/.jpeg file paths over clipboard bitmap
- Keep ~/ expansion and quoting handling
- Fix composer tests to pass placeholder_text to ChatComposer::new
2025-08-19 18:26:59 -07:00
Daniel Edrisian
afe7c087f6 Fix merge issues 2025-08-19 18:26:58 -07:00
Daniel Edrisian
248abec937 Fix merge issues 2025-08-19 18:24:55 -07:00
Daniel Edrisian
6439e0f944 Merge branch 'main' into copy_images 2025-08-19 16:58:22 -07:00
Daniel Edrisian
169e7951b8 a 2025-08-19 16:45:32 -07:00
pap
194dc82c93 remove unecessary changes 2025-08-09 16:42:34 +01:00
pap
e93ca9fa11 remove code 2025-08-09 16:36:57 +01:00
pap
b45b547f8a fix img insertion cursor 2025-08-09 16:10:56 +01:00
pap
a392e7027c use single source of truth attached_images 2025-08-09 16:07:03 +01:00
pap
d2623c1af8 leverage existing pasting erasure from cmd+v large content 2025-08-09 15:54:27 +01:00
pap
b778ec91d8 less code changes 2025-08-09 15:44:36 +01:00
pap
98e0bafbed fix tests and reduce boilerplate code 2025-08-09 15:33:56 +01:00
pap
eb45697bcc fix clippy, old commands use and remove event printing 2025-08-09 15:08:16 +01:00
pap
669387a034 Merge branch 'main' into copy_images 2025-08-09 14:51:14 +01:00
pap
c03e8fc860 code cleaning 2025-08-01 19:56:32 +01:00
pap
c9b80cd456 re-use command name instead of slashcommand as we removed at_command 2025-08-01 19:45:40 +01:00
pap-openai
5ebbecb968 Merge branch 'main' into copy_images 2025-08-01 19:42:43 +01:00
pap
cd078bb9d1 fix behaviour and tests 2025-08-01 19:42:16 +01:00
pap
ac5705c0ed removing at_command 2025-08-01 19:33:58 +01:00
pap
62d85e069e Merge branch 'main' into copy_images 2025-08-01 18:24:49 +01:00
pap
33d03c97a4 fmt 2025-07-29 23:19:40 +01:00
pap
44eb4db165 Merge branch 'main' into copy_images 2025-07-28 00:08:03 +01:00
pap
04b15ab5a2 fix a few comments/imports 2025-07-28 00:05:08 +01:00
pap
5bf8829465 creating helper function for cursor / removing duplicate code 2025-07-27 23:41:46 +01:00
pap
038484753e if @file is an image selected, then we attach the image (png, jpg) 2025-07-27 23:36:24 +01:00
pap
d58b5f9e91 rename at_image to at_clipboard_image 2025-07-27 23:26:32 +01:00
pap
68f1ceabdc fixing at_command that trigger file search at the same time. add default files for @file command to prompt user what they can search 2025-07-27 23:23:36 +01:00
pap
71d37310fd remove /image and rename command slashcommand 2025-07-27 23:09:45 +01:00
pap
92f97f0641 remove dead code 2025-07-27 22:42:45 +01:00
pap
778857ac14 FileSearchPopup becomes an at_command 2025-07-27 22:35:11 +01:00
pap
a56b327428 add "@ command" 2025-07-27 22:24:12 +01:00
pap
d6d1df4b1f delete images in prompt with backspace 2025-07-27 21:37:09 +01:00
easong-openai
af90cbc0b8 Remove tab focus switching (#1694)
Previously pressing tab would switch TUI focus to the history scrollbox - no longer necessary.
2025-07-27 20:50:38 +01:00
pap
7a087c6cea ctrl+v image + /image 2025-07-27 18:53:18 +01:00
9 changed files with 1020 additions and 16 deletions

163
codex-rs/Cargo.lock generated
View File

@@ -186,6 +186,26 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
[[package]]
name = "arboard"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"x11rb",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@@ -926,6 +946,7 @@ name = "codex-tui"
version = "0.0.0"
dependencies = [
"anyhow",
"arboard",
"base64 0.22.1",
"chrono",
"clap",
@@ -1405,6 +1426,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.9.1",
"objc2",
]
[[package]]
name = "display_container"
version = "0.9.0"
@@ -1868,6 +1899,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
dependencies = [
"libc",
"windows-targets 0.48.5",
]
[[package]]
name = "getopts"
version = "0.2.23"
@@ -3064,6 +3105,42 @@ dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
dependencies = [
"bitflags 2.9.1",
"objc2",
"objc2-core-graphics",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.9.1",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
dependencies = [
"bitflags 2.9.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3078,6 +3155,18 @@ checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
dependencies = [
"bitflags 2.9.1",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
dependencies = [
"bitflags 2.9.1",
"objc2",
"objc2-core-foundation",
]
[[package]]
@@ -5839,6 +5928,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5877,6 +5981,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5895,6 +6005,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5913,6 +6029,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5943,6 +6065,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5961,6 +6089,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5979,6 +6113,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -5997,6 +6137,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -6057,6 +6203,23 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "x11rb"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
dependencies = [
"gethostname",
"rustix 0.38.44",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]]
name = "yaml-rust"
version = "0.4.5"

View File

@@ -22,6 +22,7 @@ workspace = true
[dependencies]
anyhow = "1"
arboard = "3"
base64 = "0.22.1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
@@ -40,7 +41,10 @@ codex-ollama = { path = "../ollama" }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
image = { version = "^0.25.6", default-features = false, features = [
"jpeg",
"png",
] }
lazy_static = "1"
once_cell = "1"
mcp-types = { path = "../mcp-types" }

View File

@@ -36,6 +36,243 @@ use std::time::Instant;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
/// Naive percent-decoding for file:// URL paths; returns None on invalid UTF-8.
fn percent_decode_to_string(input: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let h1 = bytes[i + 1];
let h2 = bytes[i + 2];
let hex = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
if let (Some(x), Some(y)) = (hex(h1), hex(h2)) {
out.push(x * 16 + y);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).ok()
}
/// Convert a file:// URL into a local path (macOS/Unix only, UTF-8).
fn file_url_to_path(s: &str) -> Option<PathBuf> {
if let Some(rest) = s.strip_prefix("file://") {
// Strip optional host like file://localhost/...
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
// Ensure leading slash remains for absolute paths
let decoded = percent_decode_to_string(rest)?;
let p = PathBuf::from(decoded);
return Some(p);
}
None
}
/// Unescape simple bash-style backslash escapes (e.g., spaces, parens).
fn unescape_backslashes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(n) = chars.next() {
out.push(n);
} else {
// Trailing backslash; keep it.
out.push('\\');
}
} else {
out.push(c);
}
}
out
}
// Testable helper: generic over paste function so we can inject stubs in unit tests.
fn try_handle_ctrl_v_with<F>(
app_event_tx: &AppEventSender,
key_event: &KeyEvent,
paste_fn: F,
) -> bool
where
F: Fn() -> Result<
(std::path::PathBuf, crate::clipboard_paste::PastedImageInfo),
crate::clipboard_paste::PasteImageError,
>,
{
// 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!(
"ctrl_v_image imported path={:?} width={} height={} format={}",
path,
info.width,
info.height,
info.encoded_format_label
);
app_event_tx.send(AppEvent::AttachImage {
path,
width: info.width,
height: info.height,
format_label: info.encoded_format_label,
});
return true; // consumed
}
Err(err) => {
tracing::debug!("Ctrl+V image import failed: {err}");
}
}
}
false
}
#[cfg(test)]
mod paste_tests {
use super::*;
use crossterm::event::KeyModifiers;
#[test]
fn ctrl_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::CONTROL);
let dummy_info = crate::clipboard_paste::PastedImageInfo {
width: 10,
height: 5,
encoded_format_label: "PNG",
};
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
Ok((
std::path::PathBuf::from("/tmp/test.png"),
dummy_info.clone(),
))
});
assert!(handled, "expected ctrl+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/test.png"));
assert_eq!(width, 10);
assert_eq!(height, 5);
assert_eq!(format_label, "PNG");
}
_ => panic!("unexpected event (not AttachImage)"),
}
}
#[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();
let sender = AppEventSender::new(tx);
let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
Err(crate::clipboard_paste::PasteImageError::NoImage(
"none".into(),
))
});
assert!(
!handled,
"on failure ctrl+v should not be considered consumed"
);
assert!(
rx.try_recv().is_err(),
"no events should be sent on failure"
);
}
}
fn try_handle_ctrl_v(app_event_tx: &AppEventSender, key_event: &KeyEvent) -> bool {
try_handle_ctrl_v_with(app_event_tx, key_event, || {
crate::clipboard_paste::paste_image_to_temp_png()
})
}
/// Top-level application state: which full-screen view is currently active.
#[allow(clippy::large_enum_variant)]
enum AppState<'a> {
@@ -113,6 +350,9 @@ impl App<'_> {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
if try_handle_ctrl_v(&app_event_tx, &key_event) {
continue;
}
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
crossterm::event::Event::Resize(_, _) => {
@@ -338,7 +578,125 @@ 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 {
// Try to parse shell-escaped or URL-style file paths from the paste.
let candidates: Vec<String> = if let Some(tokens) = shlex::split(&text) {
tokens
} else {
vec![text.clone()]
};
'outer: for raw in candidates {
let mut s = raw.trim().to_string();
// Strip surrounding quotes if present (redundant with shlex, but safe)
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 mut try_paths: Vec<PathBuf> = Vec::new();
if let Some(p) = file_url_to_path(&s) {
try_paths.push(p);
}
// As-is path
try_paths.push(PathBuf::from(&s));
// Unescaped variant (e.g., My\ Photo.png)
let unescaped = unescape_backslashes(&s);
if unescaped != s {
try_paths.push(PathBuf::from(unescaped));
}
for path in try_paths {
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;
break 'outer;
}
}
}
}
}
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);
@@ -499,6 +857,16 @@ impl App<'_> {
widget.apply_file_search_result(query, matches);
}
}
AppEvent::AttachImage {
path,
width,
height,
format_label,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.attach_image(path, width, height, format_label);
}
}
}
}
terminal.clear()?;
@@ -624,6 +992,7 @@ impl App<'_> {
}
}
// merged tests
fn should_show_onboarding(
login_status: LoginStatus,
config: &Config,
@@ -644,7 +1013,7 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
}
#[cfg(test)]
mod tests {
mod onboarding_tests {
use super::*;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;

View File

@@ -63,4 +63,12 @@ pub(crate) enum AppEvent {
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),
/// Image pasted via Cmd+V (clipboard image attachment).
AttachImage {
path: std::path::PathBuf,
width: u32,
height: u32,
format_label: &'static str,
},
}

View File

@@ -30,6 +30,7 @@ use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::path::Path;
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
@@ -69,7 +70,9 @@ pub(crate) struct ChatComposer {
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
attached_images: Vec<(String, std::path::PathBuf)>,
placeholder_text: String,
last_submitted_display: Option<String>,
}
/// Popup state at most one can be visible at any time.
@@ -101,7 +104,9 @@ impl ChatComposer {
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
last_submitted_display: None,
}
}
@@ -194,6 +199,29 @@ impl ChatComposer {
true
}
pub fn attach_image(
&mut self,
path: std::path::PathBuf,
width: u32,
height: u32,
format_label: &str,
) -> bool {
let placeholder = format!("[image {width}x{height} {format_label}]");
self.textarea.insert_str(&placeholder);
self.attached_images.push((placeholder, path));
true
}
pub fn take_recent_submission_images_with_placeholders(
&mut self,
) -> Vec<(String, std::path::PathBuf)> {
std::mem::take(&mut self.attached_images)
}
pub fn take_last_submitted_display(&mut self) -> Option<String> {
self.last_submitted_display.take()
}
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`.
@@ -341,12 +369,69 @@ impl ChatComposer {
} => {
if let Some(sel) = popup.selected_match() {
let sel_path = sel.to_string();
// Drop popup borrow before using self mutably again.
self.insert_selected_path(&sel_path);
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
let is_image = {
let lower = sel_path.to_ascii_lowercase();
lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
};
if is_image {
// Determine dimensions; if that fails fall back to normal path insertion.
let path_buf = std::path::PathBuf::from(&sel_path);
match image::image_dimensions(&path_buf) {
Ok((w, h)) => {
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let before_cursor = &text[..cursor_offset];
let after_cursor = &text[cursor_offset..];
// Determine token boundaries in the full text.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_offset + end_rel_idx;
self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);
let format_label = match Path::new(&sel_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",
};
let _ = self.attach_image(path_buf.clone(), w, h, format_label);
// Add a trailing space to keep typing fluid.
self.textarea.insert_str(" ");
}
Err(_) => {
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
}
}
} else {
// Non-image: original behavior.
self.insert_selected_path(&sel_path);
}
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
(InputResult::None, false)
// No selection: treat Enter as closing the popup/session.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
@@ -527,23 +612,36 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
// Build display string that preserves inline image placeholders
let mut display = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
if display.contains(placeholder) {
display = display.replace(placeholder, actual);
}
}
self.last_submitted_display = Some(display);
// Replace all pending pastes in the text
// Build the agent text: remove image placeholders, trim
let mut text = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.pending_pastes.clear();
if text.is_empty() {
(InputResult::None, true)
} else {
self.history.record_local_submission(&text);
(InputResult::Submitted(text), true)
for (placeholder, _) in &self.attached_images {
if text.contains(placeholder) {
text = text.replace(placeholder, "");
}
}
text = text.trim().to_string();
if !text.is_empty() {
self.history.record_local_submission(&text);
}
// Clear textarea after capturing content
self.textarea.set_text("");
(InputResult::Submitted(text), true)
}
input => self.handle_input_basic(input),
}
@@ -551,6 +649,16 @@ impl ChatComposer {
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// Special handling for backspace on placeholders
if let KeyEvent {
code: KeyCode::Backspace,
..
} = input
{
if self.try_remove_any_placeholder_at_cursor() {
return (InputResult::None, true);
}
}
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.text();
@@ -559,9 +667,60 @@ impl ChatComposer {
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
// Keep only attached images whose placeholders still exist in text
self.attached_images
.retain(|(placeholder, _)| text_after.contains(placeholder));
(InputResult::None, true)
}
/// Attempts to remove an image or paste placeholder if the cursor is at the end of one.
/// Returns true if a placeholder was removed.
fn try_remove_any_placeholder_at_cursor(&mut self) -> bool {
let p = self.textarea.cursor();
let text = self.textarea.text();
// Try image placeholders first
if let Some((idx, placeholder)) =
self.attached_images
.iter()
.enumerate()
.find_map(|(i, (ph, _))| {
if p < ph.len() {
return None;
}
let start = p - ph.len();
if text[start..p] == *ph {
Some((i, ph.clone()))
} else {
None
}
})
{
self.textarea.replace_range(p - placeholder.len()..p, "");
self.attached_images.remove(idx);
return true;
}
// Then try pasted-content placeholders
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
if p < ph.len() {
return None;
}
let start = p - ph.len();
if text[start..p] == *ph {
Some(ph.clone())
} else {
None
}
}) {
self.textarea.replace_range(p - placeholder.len()..p, "");
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
return true;
}
false
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
@@ -1321,4 +1480,86 @@ mod tests {
]
);
}
// --- Image attachment tests ---
#[test]
fn attach_image_and_submit_includes_image_paths() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
let path = std::path::PathBuf::from("/tmp/image1.png");
assert!(composer.attach_image(path.clone(), 32, 16, "PNG"));
composer.handle_paste(" hi".into());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "hi"),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images_with_placeholders();
assert_eq!(imgs.len(), 1);
assert_eq!(imgs[0].1, path);
}
#[test]
fn attach_image_without_text_submits_empty_text_and_images() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
let path = std::path::PathBuf::from("/tmp/image2.png");
assert!(composer.attach_image(path.clone(), 10, 5, "PNG"));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert!(text.is_empty()),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images_with_placeholders();
assert_eq!(imgs.len(), 1);
assert_eq!(imgs[0].1, path);
assert!(composer.attached_images.is_empty());
}
#[test]
fn image_placeholder_backspace_behaves_like_text_placeholder() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
let path = std::path::PathBuf::from("/tmp/image3.png");
assert!(composer.attach_image(path.clone(), 20, 10, "PNG"));
let placeholder = composer.attached_images[0].0.clone();
// Case 1: backspace at end
composer.textarea.move_cursor_to_end_of_line(false);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(!composer.textarea.text().contains(&placeholder));
assert!(composer.attached_images.is_empty());
// Re-add and test backspace in middle: should break the placeholder string
// and drop the image mapping (same as text placeholder behavior).
assert!(composer.attach_image(path.clone(), 20, 10, "PNG"));
let placeholder2 = composer.attached_images[0].0.clone();
// Move cursor to roughly middle of placeholder
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
let mid_pos = start_pos + (placeholder2.len() / 2);
composer.textarea.set_cursor(mid_pos);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(!composer.textarea.text().contains(&placeholder2));
assert!(composer.attached_images.is_empty());
} else {
panic!("Placeholder not found in textarea");
}
}
}

View File

@@ -297,6 +297,33 @@ impl BottomPane<'_> {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}
pub(crate) fn attach_image(
&mut self,
path: std::path::PathBuf,
width: u32,
height: u32,
format_label: &str,
) {
if self.active_view.is_none() {
let needs_redraw = self
.composer
.attach_image(path, width, height, format_label);
if needs_redraw {
self.request_redraw();
}
}
}
pub(crate) fn take_recent_submission_images_with_placeholders(
&mut self,
) -> Vec<(String, std::path::PathBuf)> {
self.composer
.take_recent_submission_images_with_placeholders()
}
pub(crate) fn take_last_submitted_display(&mut self) -> Option<String> {
self.composer.take_last_submitted_display()
}
}
impl WidgetRef for &BottomPane<'_> {

View File

@@ -543,12 +543,40 @@ impl ChatWidget<'_> {
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
self.submit_user_message(text.into());
let images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let display = self
.bottom_pane
.take_last_submitted_display()
.unwrap_or_else(|| text.clone());
self.submit_user_message_with_display(text, images, display);
}
InputResult::None => {}
}
}
pub(crate) fn attach_image(
&mut self,
path: std::path::PathBuf,
width: u32,
height: u32,
format_label: &str,
) {
tracing::info!(
"attach_image path={:?} width={} height={} format={}",
path,
width,
height,
format_label
);
// Forward to bottom pane; width/height/format currently only affect placeholder text.
let _ = (width, height, format_label); // reserved for future use (e.g., status flash)
self.bottom_pane
.attach_image(path.clone(), width, height, format_label);
self.request_redraw();
}
pub(crate) fn handle_paste(&mut self, text: String) {
self.bottom_pane.handle_paste(text);
}
@@ -603,6 +631,38 @@ impl ChatWidget<'_> {
}
}
fn submit_user_message_with_display(
&mut self,
text: String,
images: Vec<(String, std::path::PathBuf)>,
display_text: String,
) {
let mut items: Vec<InputItem> = Vec::new();
if !text.is_empty() {
items.push(InputItem::Text { text: text.clone() });
}
for (_, path) in &images {
items.push(InputItem::LocalImage { path: path.clone() });
}
if items.is_empty() {
return;
}
self.codex_op_tx
.send(Op::UserInput { items })
.unwrap_or_else(|e| {
tracing::error!("failed to send message: {e}");
});
if !text.is_empty() {
self.codex_op_tx
.send(Op::AddToHistory { text: text.clone() })
.unwrap_or_else(|e| {
tracing::error!("failed to send AddHistory op: {e}");
});
}
// Show the original display text (with inline image placeholders) in history.
self.add_to_history(&history_cell::new_user_prompt(display_text));
}
pub(crate) fn handle_codex_event(&mut self, event: Event) {
// Reset redraw flag for this dispatch
self.needs_redraw = false;

View File

@@ -0,0 +1,131 @@
use std::path::PathBuf;
#[derive(Debug)]
pub enum PasteImageError {
ClipboardUnavailable(String),
NoImage(String),
EncodeFailed(String),
IoError(String),
}
impl std::fmt::Display for PasteImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"),
PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"),
PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"),
PasteImageError::IoError(msg) => write!(f, "io error: {msg}"),
}
}
}
impl std::error::Error for PasteImageError {}
#[derive(Debug, Clone)]
pub struct PastedImageInfo {
pub width: u32,
pub height: u32,
pub encoded_format_label: &'static str, // Always PNG for now.
}
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
tracing::debug!("attempting clipboard image read");
let mut cb = arboard::Clipboard::new()
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;
let img = cb
.get_image()
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
let w = img.width as u32;
let h = img.height as u32;
let mut png: Vec<u8> = Vec::new();
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
};
let dyn_img = image::DynamicImage::ImageRgba8(rgba_img);
tracing::debug!("clipboard image decoded RGBA {}x{}", w, h);
{
let mut cursor = std::io::Cursor::new(&mut png);
dyn_img
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
}
tracing::debug!("clipboard image encoded to PNG ({} bytes)", png.len());
Ok((
png,
PastedImageInfo {
width: w,
height: h,
encoded_format_label: "PNG",
},
))
}
/// Convenience: write to a temp file and return its path + info.
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
let (png, info) = paste_image_as_png()?;
let mut path = std::env::temp_dir();
let fname = format!("clipboard-{}x{}.png", info.width, info.height);
path.push(fname);
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<PathBuf> {
fn run_osascript(lines: &[&str]) -> Option<String> {
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
}

View File

@@ -30,6 +30,7 @@ mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
mod clipboard_paste;
mod common;
pub mod custom_terminal;
mod diff_render;