ctrl+v image + /image

This commit is contained in:
pap
2025-07-27 18:53:18 +01:00
parent 5a0079fea2
commit 7a087c6cea
10 changed files with 469 additions and 3 deletions

188
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"
@@ -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"

View File

@@ -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"

View File

@@ -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<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.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");
}
}

View File

@@ -52,4 +52,7 @@ pub(crate) enum AppEvent {
},
InsertHistory(Vec<Line<'static>>),
/// Image pasted via Cmd+V (clipboard image attachment).
AttachImage { path: std::path::PathBuf, width: u32, height: u32, format_label: &'static str },
}

View File

@@ -42,6 +42,8 @@ pub(crate) struct ChatComposer<'a> {
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
attached_images: Vec<(String, std::path::PathBuf)>,
recent_submission_images: Vec<std::path::PathBuf>,
}
/// 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::path::PathBuf> {
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<FileMatch>) {
// 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
}
}

View File

@@ -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<std::path::PathBuf> {
self.composer.take_recent_submission_images()
}
}
impl WidgetRef for &BottomPane<'_> {

View File

@@ -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<'_> {

View File

@@ -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<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))
}

View File

@@ -44,6 +44,7 @@ mod text_block;
mod text_formatting;
mod tui;
mod user_approval_widget;
mod clipboard_paste;
pub use cli::Cli;

View File

@@ -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).",
}
}