mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
ctrl+v image + /image
This commit is contained in:
188
codex-rs/Cargo.lock
generated
188
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
66
codex-rs/tui/src/clipboard_paste.rs
Normal file
66
codex-rs/tui/src/clipboard_paste.rs
Normal 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))
|
||||
}
|
||||
@@ -44,6 +44,7 @@ mod text_block;
|
||||
mod text_formatting;
|
||||
mod tui;
|
||||
mod user_approval_widget;
|
||||
mod clipboard_paste;
|
||||
|
||||
pub use cli::Cli;
|
||||
|
||||
|
||||
@@ -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).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user