From 7a087c6cea80a9ac3b03c77332cff11f57ca6a87 Mon Sep 17 00:00:00 2001 From: pap Date: Sun, 27 Jul 2025 18:53:18 +0100 Subject: [PATCH] ctrl+v image + /image --- codex-rs/Cargo.lock | 188 ++++++++++++++++++ codex-rs/tui/Cargo.toml | 3 +- codex-rs/tui/src/app.rs | 109 +++++++++- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 69 +++++++ codex-rs/tui/src/bottom_pane/mod.rs | 11 + codex-rs/tui/src/chatwidget.rs | 20 +- codex-rs/tui/src/clipboard_paste.rs | 66 ++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 2 + 10 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/clipboard_paste.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ba71596ecd..d1943d5b3f 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" @@ -823,6 +843,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "arboard", "base64 0.22.1", "clap", "codex-ansi-escape", @@ -1262,6 +1283,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" @@ -1725,6 +1756,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" @@ -2864,6 +2905,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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]] name = "object" version = "0.36.7" @@ -5445,6 +5559,21 @@ dependencies = [ "windows-targets 0.53.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" @@ -5477,6 +5606,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[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" @@ -5489,6 +5624,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[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" @@ -5501,6 +5642,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[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" @@ -5525,6 +5672,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[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" @@ -5537,6 +5690,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[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" @@ -5549,6 +5708,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[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" @@ -5561,6 +5726,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[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" @@ -5621,6 +5792,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 9d73e3b386..219a2249ff 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -30,7 +30,7 @@ codex-linux-sandbox = { path = "../linux-sandbox" } codex-login = { path = "../login" } color-eyre = "0.6.3" crossterm = { version = "0.28.1", features = ["bracketed-paste"] } -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" } path-clean = "1.0.1" @@ -60,6 +60,7 @@ tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" unicode-width = "0.1" uuid = "1" +arboard = "3" [dev-dependencies] insta = "1.43.1" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e7097e6af0..e7432bd2cd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -28,6 +28,57 @@ 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. +fn try_handle_ctrl_v_with( + 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.code == KeyCode::Char('v') + && key_event + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + tracing::debug!( + "Ctrl+V detected – attempting clipboard image import (shortcut for /image)" + ); + 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 +} + +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> { @@ -99,6 +150,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(_, _) => { @@ -117,7 +171,7 @@ impl App<'_> { scroll_event_helper.scroll_down(); } crossterm::event::Event::Paste(pasted) => { - // Many terminals convert newlines to \r when + // Many terminals convert newlines to \r when // pasting, e.g. [iTerm2][]. But [tui-textarea // expects \n][tui-textarea]. This seems like a bug // in tui-textarea IMO, but work around it for now. @@ -329,6 +383,19 @@ impl App<'_> { widget.add_diff_output(text); } } + SlashCommand::Image => { + match crate::clipboard_paste::paste_image_to_temp_png() { + Ok((path, info)) => { + tracing::info!("slash_command_image imported path={:?} width={} height={} format={}", path, info.width, info.height, info.encoded_format_label); + self.app_event_tx.send(AppEvent::AttachImage { path, width: info.width, height: info.height, format_label: info.encoded_format_label }); + } + Err(err) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_background_event(format!("image import failed: {err}")); + } + } + } + } }, AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); @@ -338,6 +405,11 @@ 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()?; @@ -427,3 +499,38 @@ impl App<'_> { } } } + +#[cfg(test)] +mod 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().expect("event") { + 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 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"); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a1f304fe42..bb136565eb 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -52,4 +52,7 @@ pub(crate) enum AppEvent { }, InsertHistory(Vec>), + + /// Image pasted via Cmd+V (clipboard image attachment). + AttachImage { path: std::path::PathBuf, width: u32, height: u32, format_label: &'static str }, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bdfb6a23e2..eff4604e2c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -42,6 +42,8 @@ pub(crate) struct ChatComposer<'a> { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, + attached_images: Vec<(String, std::path::PathBuf)>, + recent_submission_images: Vec, } /// Popup state – at most one can be visible at any time. @@ -66,6 +68,8 @@ impl ChatComposer<'_> { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), + attached_images: Vec::new(), + recent_submission_images: Vec::new(), }; this.update_border(has_input_focus); this @@ -145,6 +149,17 @@ 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(&mut self) -> Vec { + std::mem::take(&mut self.recent_submission_images) + } + /// Integrate results from an asynchronous file search. pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { // Only apply if user is still editing a token starting with `query`. @@ -445,10 +460,33 @@ impl ChatComposer<'_> { } self.pending_pastes.clear(); + // If removing all image placeholders leaves only whitespace, treat as empty (no submission). + let mut content_without_images = text.clone(); + for (placeholder, _) in &self.attached_images { + content_without_images = content_without_images.replace(placeholder, ""); + } + if content_without_images.trim().is_empty() { + return (InputResult::None, true); + } + + // Consume image placeholders and stage their paths (text now guaranteed non-empty after removal). + let mut attached_paths = Vec::new(); + for (placeholder, path) in &self.attached_images { + if text.contains(placeholder) { + text = text.replace(placeholder, ""); + attached_paths.push(path.clone()); + } + } + if !attached_paths.is_empty() { + self.recent_submission_images = attached_paths; + text = text.trim().to_string(); + } + if text.is_empty() { (InputResult::None, true) } else { self.history.record_local_submission(&text); + self.attached_images.clear(); (InputResult::Submitted(text), true) } } @@ -1170,4 +1208,35 @@ mod tests { ] ); } + + // --- Image attachment tests --- + #[test] + fn attach_image_and_submit_includes_image_paths() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let (tx, _rx) = std::sync::mpsc::channel(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new(true, sender); + 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(); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0], path); + } + + #[test] + fn attach_image_without_text_not_submitted() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let (tx, _rx) = std::sync::mpsc::channel(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new(true, sender); + 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)); + assert!(matches!(result, InputResult::None)); + assert!(composer.take_recent_submission_images().is_empty()); + assert_eq!(composer.attached_images.len(), 1); // still pending + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ebec534f21..17e1413c9d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -233,6 +233,17 @@ 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(&mut self) -> Vec { + 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 6744707319..6e54f305f1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -174,13 +174,25 @@ impl ChatWidget<'_> { } InputFocus::BottomPane => 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) + self.bottom_pane.attach_image(path.clone(), width, height, format_label); + // Surface a quick background event so user sees confirmation. + self.conversation_history.add_background_event(format!("[image copied] {}x{} {}", width, height, format_label)); + self.emit_last_history_entry(); + self.request_redraw(); + } + pub(crate) fn handle_paste(&mut self, text: String) { if matches!(self.input_focus, InputFocus::BottomPane) { self.bottom_pane.handle_paste(text); @@ -516,6 +528,12 @@ impl ChatWidget<'_> { pub(crate) fn token_usage(&self) -> &TokenUsage { &self.token_usage } + + pub(crate) fn add_background_event(&mut self, msg: String) { + self.conversation_history.add_background_event(msg); + self.emit_last_history_entry(); + self.request_redraw(); + } } impl WidgetRef for &ChatWidget<'_> { diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs new file mode 100644 index 0000000000..af7950fde1 --- /dev/null +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -0,0 +1,66 @@ +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, 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 = 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)) +} \ No newline at end of file diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 905f0aaf0b..f680450ca9 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -44,6 +44,7 @@ mod text_block; mod text_formatting; mod tui; mod user_approval_widget; +mod clipboard_paste; pub use cli::Cli; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 603eb721cd..ca414fa081 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. New, Diff, + Image, // import image from clipboard Quit, } @@ -26,6 +27,7 @@ impl SlashCommand { SlashCommand::Diff => { "Show git diff of the working directory (including untracked files)" } + SlashCommand::Image => "Import an image from the system clipboard (can be used with ctrl+v).", } }