51 KiB
PR #1695: ctrl+v image + @file accepts images
- URL: https://github.com/openai/codex/pull/1695
- Author: pap-openai
- Created: 2025-07-27 17:54:23 UTC
- Updated: 2025-08-23 03:17:44 UTC
- Changes: +727/-13, Files changed: 9, Commits: 44
Description
allow ctrl+v in TUI for images + @file that are images are appended as raw files (and read by the model) rather than pasted as a path that cannot be read by the model.
Re-used components and same interface we're using for copying pasted content in 72504f1d9c. @aibrahim-oai as you've implemented this, mind having a look at this one?
https://github.com/user-attachments/assets/c6c1153b-6b32-4558-b9a2-f8c57d2be710
Full Diff
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 34e7932053..cb4cd68d81 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -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"
@@ -928,6 +948,7 @@ name = "codex-tui"
version = "0.0.0"
dependencies = [
"anyhow",
+ "arboard",
"async-stream",
"base64 0.22.1",
"chrono",
@@ -962,6 +983,7 @@ dependencies = [
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
+ "tempfile",
"textwrap 0.16.2",
"tokio",
"tokio-stream",
@@ -1410,6 +1432,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"
@@ -1863,6 +1895,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"
@@ -3059,6 +3101,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"
@@ -3073,6 +3151,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]]
@@ -5846,6 +5936,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"
@@ -5884,6 +5989,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"
@@ -5902,6 +6013,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"
@@ -5920,6 +6037,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"
@@ -5950,6 +6073,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"
@@ -5968,6 +6097,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"
@@ -5986,6 +6121,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"
@@ -6004,6 +6145,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"
@@ -6070,6 +6217,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"
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 20ceb0b79c..6d69e97e73 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -22,6 +22,7 @@ workspace = true
[dependencies]
anyhow = "1"
+arboard = "3"
async-stream = "0.3.6"
base64 = "0.22.1"
chrono = { version = "0.4", features = ["serde"] }
@@ -41,7 +42,10 @@ codex-protocol = { path = "../protocol" }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] }
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"
mcp-types = { path = "../mcp-types" }
once_cell = "1"
@@ -61,6 +65,7 @@ shlex = "1.3.0"
strum = "0.27.2"
strum_macros = "0.27.2"
supports-color = "3.0.2"
+tempfile = "3"
textwrap = "0.16.2"
tokio = { version = "1", features = [
"io-std",
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 057d77ff94..a532ba71c8 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -145,6 +145,15 @@ impl App {
},
)?;
}
+ TuiEvent::AttachImage {
+ path,
+ width,
+ height,
+ format_label,
+ } => {
+ self.chat_widget
+ .attach_image(path, width, height, format_label);
+ }
}
}
Ok(true)
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 0cc34542c5..909ecbc0d9 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -31,6 +31,9 @@ use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_file_search::FileMatch;
use std::cell::RefCell;
+use std::collections::HashMap;
+use std::path::Path;
+use std::path::PathBuf;
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
@@ -43,6 +46,12 @@ pub enum InputResult {
None,
}
+#[derive(Clone, Debug, PartialEq)]
+struct AttachedImage {
+ placeholder: String,
+ path: PathBuf,
+}
+
struct TokenUsageInfo {
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
@@ -71,6 +80,7 @@ pub(crate) struct ChatComposer {
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
+ attached_images: Vec<AttachedImage>,
placeholder_text: String,
}
@@ -103,6 +113,7 @@ impl ChatComposer {
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
+ attached_images: Vec::new(),
placeholder_text,
}
}
@@ -196,6 +207,20 @@ impl ChatComposer {
true
}
+ 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:
+ // styled distinctly and treated atomically for cursor/mutations.
+ self.textarea.insert_element(&placeholder);
+ self.attached_images
+ .push(AttachedImage { placeholder, path });
+ }
+
+ pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
+ let images = std::mem::take(&mut self.attached_images);
+ images.into_iter().map(|img| img.path).collect()
+ }
+
/// 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`.
@@ -346,19 +371,74 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
- 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);
+ let Some(sel) = popup.selected_match() else {
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
+ };
+
+ let sel_path = sel.to_string();
+ // If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
+ let is_image = Self::is_image_path(&sel_path);
+ if is_image {
+ // Determine dimensions; if that fails fall back to normal path insertion.
+ let path_buf = PathBuf::from(&sel_path);
+ if let Ok((w, h)) = image::image_dimensions(&path_buf) {
+ // 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",
+ };
+ self.attach_image(path_buf.clone(), w, h, format_label);
+ // Add a trailing space to keep typing fluid.
+ self.textarea.insert_str(" ");
+ } else {
+ // Fallback to plain path insertion if metadata read fails.
+ self.insert_selected_path(&sel_path);
+ }
+ } else {
+ // Non-image: inserting file path.
+ self.insert_selected_path(&sel_path);
}
- (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),
}
}
+ fn is_image_path(path: &str) -> bool {
+ let lower = path.to_ascii_lowercase();
+ lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
+ }
+
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
@@ -545,12 +625,19 @@ impl ChatComposer {
}
self.pending_pastes.clear();
- if text.is_empty() {
- (InputResult::None, true)
- } else {
+ // 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);
- (InputResult::Submitted(text), true)
}
+ // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
+ (InputResult::Submitted(text), true)
}
input => self.handle_input_basic(input),
}
@@ -558,6 +645,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
+ && self.try_remove_any_placeholder_at_cursor()
+ {
+ return (InputResult::None, true);
+ }
+
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.text();
@@ -566,9 +663,165 @@ impl ChatComposer {
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
+ // Keep attached images in proportion to how many matching placeholders exist in the text.
+ // This handles duplicate placeholders that share the same visible label.
+ if !self.attached_images.is_empty() {
+ let mut needed: HashMap<String, usize> = HashMap::new();
+ for img in &self.attached_images {
+ needed
+ .entry(img.placeholder.clone())
+ .or_insert_with(|| text_after.matches(&img.placeholder).count());
+ }
+
+ let mut used: HashMap<String, usize> = HashMap::new();
+ let mut kept: Vec<AttachedImage> = Vec::with_capacity(self.attached_images.len());
+ for img in self.attached_images.drain(..) {
+ let total_needed = *needed.get(&img.placeholder).unwrap_or(&0);
+ let used_count = used.entry(img.placeholder.clone()).or_insert(0);
+ if *used_count < total_needed {
+ kept.push(img);
+ *used_count += 1;
+ }
+ }
+ self.attached_images = kept;
+ }
+
(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
+ let mut out: Option<(usize, String)> = None;
+ // Detect if the cursor is at the end of any image placeholder.
+ // If duplicates exist, remove the specific occurrence's mapping.
+ for (i, img) in self.attached_images.iter().enumerate() {
+ let ph = &img.placeholder;
+ if p < ph.len() {
+ continue;
+ }
+ let start = p - ph.len();
+ if text[start..p] != *ph {
+ continue;
+ }
+
+ // Count the number of occurrences of `ph` before `start`.
+ let mut occ_before = 0usize;
+ let mut search_pos = 0usize;
+ while search_pos < start {
+ if let Some(found) = text[search_pos..start].find(ph) {
+ occ_before += 1;
+ search_pos += found + ph.len();
+ } else {
+ break;
+ }
+ }
+
+ // Remove the occ_before-th attached image that shares this placeholder label.
+ out = if let Some((remove_idx, _)) = self
+ .attached_images
+ .iter()
+ .enumerate()
+ .filter(|(_, img2)| img2.placeholder == *ph)
+ .nth(occ_before)
+ {
+ Some((remove_idx, ph.clone()))
+ } else {
+ Some((i, ph.clone()))
+ };
+ break;
+ }
+ if let Some((idx, placeholder)) = out {
+ self.textarea.replace_range(p - placeholder.len()..p, "");
+ self.attached_images.remove(idx);
+ return true;
+ }
+
+ // Also handle when the cursor is at the START of an image placeholder.
+ // let result = 'out: {
+ let out: Option<(usize, String)> = 'out: {
+ for (i, img) in self.attached_images.iter().enumerate() {
+ let ph = &img.placeholder;
+ if p + ph.len() > text.len() {
+ continue;
+ }
+ if &text[p..p + ph.len()] != ph {
+ continue;
+ }
+
+ // Count occurrences of `ph` before `p`.
+ let mut occ_before = 0usize;
+ let mut search_pos = 0usize;
+ while search_pos < p {
+ if let Some(found) = text[search_pos..p].find(ph) {
+ occ_before += 1;
+ search_pos += found + ph.len();
+ } else {
+ break 'out None;
+ }
+ }
+
+ if let Some((remove_idx, _)) = self
+ .attached_images
+ .iter()
+ .enumerate()
+ .filter(|(_, img2)| img2.placeholder == *ph)
+ .nth(occ_before)
+ {
+ break 'out Some((remove_idx, ph.clone()));
+ } else {
+ break 'out Some((i, ph.clone()));
+ }
+ }
+ None
+ };
+
+ if let Some((idx, placeholder)) = out {
+ self.textarea.replace_range(p..p + placeholder.len(), "");
+ 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;
+ }
+
+ // Also handle when the cursor is at the START of a pasted-content placeholder.
+ if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
+ if p + ph.len() > text.len() {
+ return None;
+ }
+ if &text[p..p + ph.len()] == ph {
+ Some(ph.clone())
+ } else {
+ None
+ }
+ }) {
+ self.textarea.replace_range(p..p + placeholder.len(), "");
+ 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.
@@ -746,10 +999,14 @@ impl WidgetRef for &ChatComposer {
#[cfg(test)]
mod tests {
+ use super::*;
+ use std::path::PathBuf;
+
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
+ use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
@@ -1312,4 +1569,112 @@ mod tests {
]
);
}
+
+ // --- Image attachment tests ---
+ #[test]
+ fn attach_image_and_submit_includes_image_paths() {
+ 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 path = PathBuf::from("/tmp/image1.png");
+ 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();
+ assert_eq!(vec![path], imgs);
+ }
+
+ #[test]
+ fn attach_image_without_text_submits_empty_text_and_images() {
+ 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 path = PathBuf::from("/tmp/image2.png");
+ 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();
+ assert_eq!(imgs.len(), 1);
+ assert_eq!(imgs[0], path);
+ assert!(composer.attached_images.is_empty());
+ }
+
+ #[test]
+ fn image_placeholder_backspace_behaves_like_text_placeholder() {
+ 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 path = PathBuf::from("/tmp/image3.png");
+ composer.attach_image(path.clone(), 20, 10, "PNG");
+ let placeholder = composer.attached_images[0].placeholder.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).
+ composer.attach_image(path.clone(), 20, 10, "PNG");
+ let placeholder2 = composer.attached_images[0].placeholder.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");
+ }
+ }
+
+ #[test]
+ fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
+ 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 path1 = PathBuf::from("/tmp/image_dup1.png");
+ let path2 = PathBuf::from("/tmp/image_dup2.png");
+
+ composer.attach_image(path1.clone(), 10, 5, "PNG");
+ // separate placeholders with a space for clarity
+ composer.handle_paste(" ".into());
+ composer.attach_image(path2.clone(), 10, 5, "PNG");
+
+ let ph = composer.attached_images[0].placeholder.clone();
+ let text = composer.textarea.text().to_string();
+ let start1 = text.find(&ph).expect("first placeholder present");
+ let end1 = start1 + ph.len();
+ composer.textarea.set_cursor(end1);
+
+ // Backspace should delete the first placeholder and its mapping.
+ composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
+
+ let new_text = composer.textarea.text().to_string();
+ assert_eq!(1, new_text.matches(&ph).count(), "one placeholder remains");
+ assert_eq!(
+ vec![AttachedImage {
+ path: path2,
+ placeholder: "[image 10x5 PNG]".to_string()
+ }],
+ composer.attached_images,
+ "one image mapping remains"
+ );
+ }
}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 48ad3f02b5..04f5d4b9bf 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -1,4 +1,5 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
+use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
@@ -342,6 +343,24 @@ impl BottomPane {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}
+
+ pub(crate) fn attach_image(
+ &mut self,
+ path: PathBuf,
+ width: u32,
+ height: u32,
+ format_label: &str,
+ ) {
+ if self.active_view.is_none() {
+ self.composer
+ .attach_image(path, width, height, format_label);
+ self.request_redraw();
+ }
+ }
+
+ pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
+ self.composer.take_recent_submission_images()
+ }
}
impl WidgetRef for &BottomPane {
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 6e6f8de834..0e4bd85664 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -583,7 +583,11 @@ 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();
+ self.submit_user_message(UserMessage {
+ text,
+ image_paths: images,
+ });
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
@@ -592,6 +596,21 @@ impl ChatWidget {
}
}
+ pub(crate) fn attach_image(
+ &mut self,
+ path: PathBuf,
+ width: u32,
+ height: u32,
+ format_label: &str,
+ ) {
+ tracing::info!(
+ "attach_image path={path:?} width={width} height={height} format={format_label}",
+ );
+ self.bottom_pane
+ .attach_image(path.clone(), width, height, format_label);
+ self.request_redraw();
+ }
+
fn dispatch_command(&mut self, cmd: SlashCommand) {
match cmd {
SlashCommand::New => {
diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs
new file mode 100644
index 0000000000..3888ac343f
--- /dev/null
+++ b/codex-rs/tui/src/clipboard_paste.rs
@@ -0,0 +1,97 @@
+use std::path::PathBuf;
+use tempfile::Builder;
+
+#[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, Copy, PartialEq, Eq)]
+pub enum EncodedImageFormat {
+ Png,
+}
+
+impl EncodedImageFormat {
+ pub fn label(self) -> &'static str {
+ match self {
+ EncodedImageFormat::Png => "PNG",
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct PastedImageInfo {
+ pub width: u32,
+ pub height: u32,
+ pub encoded_format: EncodedImageFormat, // 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 {w}x{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 ({len} bytes)",
+ len = png.len()
+ );
+ Ok((
+ png,
+ PastedImageInfo {
+ width: w,
+ height: h,
+ encoded_format: EncodedImageFormat::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()?;
+ // Create a unique temporary file with a .png suffix to avoid collisions.
+ let tmp = Builder::new()
+ .prefix("codex-clipboard-")
+ .suffix(".png")
+ .tempfile()
+ .map_err(|e| PasteImageError::IoError(e.to_string()))?;
+ std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?;
+ // Persist the file (so it remains after the handle is dropped) and return its PathBuf.
+ let (_file, path) = tmp
+ .keep()
+ .map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
+ Ok((path, info))
+}
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 454a7f3ead..bce0d89933 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -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;
diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs
index 3f4df39b7e..4498f46e17 100644
--- a/codex-rs/tui/src/tui.rs
+++ b/codex-rs/tui/src/tui.rs
@@ -1,6 +1,7 @@
use std::io::Result;
use std::io::Stdout;
use std::io::stdout;
+use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -15,7 +16,11 @@ use crossterm::cursor;
use crossterm::cursor::MoveTo;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::EnableBracketedPaste;
+use crossterm::event::Event;
+use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
+use crossterm::event::KeyEventKind;
+use crossterm::event::KeyModifiers;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
@@ -30,6 +35,7 @@ use ratatui::crossterm::terminal::enable_raw_mode;
use ratatui::layout::Offset;
use ratatui::text::Line;
+use crate::clipboard_paste::paste_image_to_temp_png;
use crate::custom_terminal;
use crate::custom_terminal::Terminal as CustomTerminal;
use tokio::select;
@@ -103,6 +109,12 @@ pub enum TuiEvent {
Key(KeyEvent),
Paste(String),
Draw,
+ AttachImage {
+ path: PathBuf,
+ width: u32,
+ height: u32,
+ format_label: &'static str,
+ },
}
pub struct Tui {
@@ -236,6 +248,29 @@ impl Tui {
select! {
Some(Ok(event)) = crossterm_events.next() => {
match event {
+ // Detect Ctrl+V to attach an image from the clipboard.
+ Event::Key(key_event @ KeyEvent {
+ code: KeyCode::Char('v'),
+ modifiers: KeyModifiers::CONTROL,
+ kind: KeyEventKind::Press,
+ ..
+ }) => {
+ match paste_image_to_temp_png() {
+ Ok((path, info)) => {
+ yield TuiEvent::AttachImage {
+ path,
+ width: info.width,
+ height: info.height,
+ format_label: info.encoded_format.label(),
+ };
+ }
+ Err(_) => {
+ // Fall back to normal key handling if no image is available.
+ yield TuiEvent::Key(key_event);
+ }
+ }
+ }
+
crossterm::event::Event::Key(key_event) => {
#[cfg(unix)]
if matches!(
@@ -261,10 +296,10 @@ impl Tui {
}
yield TuiEvent::Key(key_event);
}
- crossterm::event::Event::Resize(_, _) => {
+ Event::Resize(_, _) => {
yield TuiEvent::Draw;
}
- crossterm::event::Event::Paste(pasted) => {
+ Event::Paste(pasted) => {
yield TuiEvent::Paste(pasted);
}
_ => {}
Review Comments
codex-rs/tui/src/app.rs
- Created: 2025-08-22 00:59:15 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292440926
@@ -34,6 +34,55 @@ use std::time::Duration;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
+// Testable helper: generic over paste function so we can inject stubs in unit tests.
For reference:
750ca9e21d/.github/codex/labels/codex-rust-review.md (L21)
- Created: 2025-08-22 01:00:11 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292441812
@@ -34,6 +34,55 @@ use std::time::Duration;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
+// Testable helper: generic over paste function so we can inject stubs in unit tests.
Though also,
app.rsshould be focused on driving the event loop. The specifics for how each event is handled should live in their own files.
- Created: 2025-08-22 01:00:34 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292442118
@@ -36,6 +36,55 @@ use std::time::Instant;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
+// 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,
+ >,
+{
+ if key_event.kind == KeyEventKind::Press
+ && key_event.code == KeyCode::Char('v')
+ && key_event
+ .modifiers
+ .contains(crossterm::event::KeyModifiers::CONTROL)
So does
cmdget mapped toCONTROLin this case on macOS?
- Created: 2025-08-22 01:01:17 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292442703
@@ -36,6 +36,55 @@ use std::time::Instant;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
+// 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,
+ >,
+{
+ if key_event.kind == KeyEventKind::Press
+ && key_event.code == KeyCode::Char('v')
+ && key_event
+ .modifiers
+ .contains(crossterm::event::KeyModifiers::CONTROL)
+ {
+ 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
+ );
Consider:
tracing::info!( "ctrl_v_image imported path={path:?} info={info:?}" );
codex-rs/tui/src/bottom_pane/chat_composer.rs
- Created: 2025-08-21 23:53:37 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292373230
@@ -1321,4 +1469,86 @@ mod tests {
]
);
}
+
+ // --- Image attachment tests ---
+ #[test]
+ fn attach_image_and_submit_includes_image_paths() {
+ use crossterm::event::KeyCode;
You can probably move all these imports up?
- Created: 2025-08-22 01:03:06 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292444165
@@ -69,6 +70,7 @@ pub(crate) struct ChatComposer {
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
+ attached_images: Vec<(String, std::path::PathBuf)>,
Maybe we should introduce a small struct to make this more self-documenting?
- Created: 2025-08-22 01:03:39 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292444601
@@ -194,6 +197,24 @@ 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
Do we need a return value?
codex-rs/tui/src/chatwidget.rs
- Created: 2025-08-21 23:44:54 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292365502
@@ -543,12 +543,37 @@ 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();
+ self.submit_user_message(UserMessage {
+ text,
+ image_paths: images,
+ });
}
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
+ );
tracing::info!( "attach_image path={path:?} width={width} height={height} format={format_label}", );
- Created: 2025-08-21 23:45:52 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292366377
@@ -543,12 +543,37 @@ 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();
+ self.submit_user_message(UserMessage {
+ text,
+ image_paths: images,
+ });
}
InputResult::None => {}
}
}
+ pub(crate) fn attach_image(
+ &mut self,
+ path: std::path::PathBuf,
use std::path::PathBuf;is already in this file, so prefer shorter name (there's a lot of places where the fully-qualified version is used, but could be omitted in this PR: I assume Codex did it?)path: PathBuf,
- Created: 2025-08-21 23:46:14 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292366675
@@ -543,12 +543,37 @@ 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();
+ self.submit_user_message(UserMessage {
+ text,
+ image_paths: images,
+ });
}
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)
Delete?
codex-rs/tui/src/clipboard_paste.rs
- Created: 2025-08-21 23:46:31 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292366919
@@ -0,0 +1,73 @@
+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.
Prefer enum?
- Created: 2025-08-21 23:52:33 UTC | Link: https://github.com/openai/codex/pull/1695#discussion_r2292372397
@@ -0,0 +1,73 @@
+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);
I feel like we should be using something like https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.new to avoid collisions.