mirror of
https://github.com/openai/codex.git
synced 2026-02-08 18:03:37 +00:00
Compare commits
45 Commits
patch-squa
...
daniel/cop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5654f35aa | ||
|
|
4c48f3992b | ||
|
|
1b0ef70b53 | ||
|
|
817ac79367 | ||
|
|
b15e2c45ed | ||
|
|
7627449f21 | ||
|
|
3f1ccb487d | ||
|
|
ee67dcaa99 | ||
|
|
3f539c4d07 | ||
|
|
ae27220198 | ||
|
|
195f588f0e | ||
|
|
28f2898ebf | ||
|
|
ce559a0482 | ||
|
|
d261d89bdc | ||
|
|
7e54c4885f | ||
|
|
38e96f6317 | ||
|
|
194dc82c93 | ||
|
|
e93ca9fa11 | ||
|
|
b45b547f8a | ||
|
|
a392e7027c | ||
|
|
d2623c1af8 | ||
|
|
b778ec91d8 | ||
|
|
98e0bafbed | ||
|
|
eb45697bcc | ||
|
|
669387a034 | ||
|
|
c03e8fc860 | ||
|
|
c9b80cd456 | ||
|
|
5ebbecb968 | ||
|
|
cd078bb9d1 | ||
|
|
ac5705c0ed | ||
|
|
62d85e069e | ||
|
|
33d03c97a4 | ||
|
|
44eb4db165 | ||
|
|
04b15ab5a2 | ||
|
|
5bf8829465 | ||
|
|
038484753e | ||
|
|
d58b5f9e91 | ||
|
|
68f1ceabdc | ||
|
|
71d37310fd | ||
|
|
92f97f0641 | ||
|
|
778857ac14 | ||
|
|
a56b327428 | ||
|
|
d6d1df4b1f | ||
|
|
af90cbc0b8 | ||
|
|
7a087c6cea |
163
codex-rs/Cargo.lock
generated
163
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"
|
||||
@@ -926,6 +946,7 @@ name = "codex-tui"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -1405,6 +1426,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "display_container"
|
||||
version = "0.9.0"
|
||||
@@ -1868,6 +1899,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.23"
|
||||
@@ -3064,6 +3105,42 @@ dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3078,6 +3155,18 @@ checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5839,6 +5928,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -5877,6 +5981,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5895,6 +6005,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5913,6 +6029,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5943,6 +6065,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5961,6 +6089,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5979,6 +6113,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5997,6 +6137,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6057,6 +6203,23 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix 0.38.44",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
|
||||
@@ -22,6 +22,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
arboard = "3"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -40,7 +41,10 @@ codex-ollama = { path = "../ollama" }
|
||||
color-eyre = "0.6.3"
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
|
||||
diffy = "0.4.2"
|
||||
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
|
||||
image = { version = "^0.25.6", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
] }
|
||||
lazy_static = "1"
|
||||
once_cell = "1"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreen;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::string_utils::file_url_to_path;
|
||||
use crate::string_utils::unescape_backslashes;
|
||||
use crate::tui;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::config::Config;
|
||||
@@ -36,6 +38,149 @@ 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,
|
||||
>,
|
||||
{
|
||||
// Treat Ctrl+V as paste everywhere; treat Cmd+V (SUPER) as paste only on macOS.
|
||||
let is_v = matches!(key_event.code, KeyCode::Char('v'));
|
||||
let mods = key_event.modifiers;
|
||||
let has_cmd_on_macos = {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
mods.contains(crossterm::event::KeyModifiers::SUPER)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
let has_paste_modifier =
|
||||
mods.contains(crossterm::event::KeyModifiers::CONTROL) || has_cmd_on_macos;
|
||||
|
||||
if key_event.kind == KeyEventKind::Press && is_v && has_paste_modifier {
|
||||
// Prefer attaching an image by path if the clipboard contains a file path/URL.
|
||||
// This avoids grabbing the file icon bitmap that some apps (e.g. VS Code) place
|
||||
// on the clipboard when copying a file.
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||||
if let Ok(txt) = cb.get_text() {
|
||||
if let Some((path, w, h, fmt)) = try_parse_image_path_from_text(&txt) {
|
||||
tracing::info!(
|
||||
"ctrl_v_path attaching image via path={:?} size={}x{} format={}",
|
||||
path,
|
||||
w,
|
||||
h,
|
||||
fmt
|
||||
);
|
||||
app_event_tx.send(AppEvent::AttachImage {
|
||||
path,
|
||||
width: w,
|
||||
height: h,
|
||||
format_label: fmt,
|
||||
});
|
||||
return true; // consumed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In production, do not read bitmaps from the clipboard for Ctrl/Cmd+V.
|
||||
// Allow the normal bracketed paste event to deliver any text content instead.
|
||||
#[cfg(test)]
|
||||
{
|
||||
match _paste_fn() {
|
||||
Ok((path, info)) => {
|
||||
app_event_tx.send(AppEvent::AttachImage {
|
||||
path,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
format_label: info.encoded_format_label,
|
||||
});
|
||||
return true; // consumed
|
||||
}
|
||||
Err(_err) => {
|
||||
// Not handled in tests either.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
// Best-effort parse of clipboard text to locate a single local image path or file:// URL.
|
||||
// Returns path + dimensions + format if it resolves to an existing PNG/JPEG file.
|
||||
fn try_parse_image_path_from_text(
|
||||
txt: &str,
|
||||
) -> Option<(std::path::PathBuf, u32, u32, &'static str)> {
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
if let Some(tokens) = shlex::split(txt) {
|
||||
candidates.extend(tokens);
|
||||
} else {
|
||||
candidates.push(txt.to_string());
|
||||
}
|
||||
|
||||
for raw in candidates {
|
||||
let mut s = raw.trim().to_string();
|
||||
if s.len() >= 2
|
||||
&& ((s.starts_with('"') && s.ends_with('"'))
|
||||
|| (s.starts_with('\'') && s.ends_with('\'')))
|
||||
{
|
||||
s = s[1..s.len() - 1].to_string();
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("~/") {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let mut p = std::path::PathBuf::from(home);
|
||||
p.push(rest);
|
||||
s = p.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
|
||||
let mut try_paths: Vec<std::path::PathBuf> = Vec::new();
|
||||
if let Some(p) = file_url_to_path(&s) {
|
||||
try_paths.push(p);
|
||||
}
|
||||
try_paths.push(std::path::PathBuf::from(&s));
|
||||
let unescaped = unescape_backslashes(&s);
|
||||
if unescaped != s {
|
||||
try_paths.push(std::path::PathBuf::from(unescaped));
|
||||
}
|
||||
|
||||
for path in try_paths {
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let ext_l = ext.to_ascii_lowercase();
|
||||
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
|
||||
let (mut w, mut h) = (0u32, 0u32);
|
||||
if let Ok((dw, dh)) = image::image_dimensions(&path) {
|
||||
w = dw;
|
||||
h = dh;
|
||||
}
|
||||
let fmt: &'static str = if ext_l == "png" { "PNG" } else { "JPEG" };
|
||||
return Some((path, w, h, fmt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Top-level application state: which full-screen view is currently active.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum AppState<'a> {
|
||||
@@ -113,6 +258,9 @@ impl App<'_> {
|
||||
if let Ok(event) = crossterm::event::read() {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
if try_handle_ctrl_v(&app_event_tx, &key_event) {
|
||||
continue;
|
||||
}
|
||||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
}
|
||||
crossterm::event::Event::Resize(_, _) => {
|
||||
@@ -338,6 +486,7 @@ impl App<'_> {
|
||||
};
|
||||
}
|
||||
AppEvent::Paste(text) => {
|
||||
// Route paste handling to the active widget.
|
||||
self.dispatch_paste_event(text);
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
@@ -499,6 +648,16 @@ impl App<'_> {
|
||||
widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
}
|
||||
AppEvent::AttachImage {
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
format_label,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.attach_image(path, width, height, format_label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
@@ -649,6 +808,7 @@ mod tests {
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_login::AuthMode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
fn make_config(preferred: AuthMode) -> Config {
|
||||
let mut cfg = Config::load_from_base_config_with_overrides(
|
||||
@@ -696,4 +856,114 @@ mod tests {
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_v_success_attaches_image() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
|
||||
let dummy_info = crate::clipboard_paste::PastedImageInfo {
|
||||
width: 10,
|
||||
height: 5,
|
||||
encoded_format_label: "PNG",
|
||||
};
|
||||
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
|
||||
Ok((
|
||||
std::path::PathBuf::from("/tmp/test.png"),
|
||||
dummy_info.clone(),
|
||||
))
|
||||
});
|
||||
assert!(handled, "expected ctrl+v to be handled on success");
|
||||
match rx
|
||||
.recv()
|
||||
.unwrap_or_else(|e| panic!("failed to receive event: {e}"))
|
||||
{
|
||||
AppEvent::AttachImage {
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
format_label,
|
||||
} => {
|
||||
assert_eq!(path, std::path::PathBuf::from("/tmp/test.png"));
|
||||
assert_eq!(width, 10);
|
||||
assert_eq!(height, 5);
|
||||
assert_eq!(format_label, "PNG");
|
||||
}
|
||||
_ => panic!("unexpected event (not AttachImage)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cmd_v_success_attaches_image() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER);
|
||||
let dummy_info = crate::clipboard_paste::PastedImageInfo {
|
||||
width: 12,
|
||||
height: 8,
|
||||
encoded_format_label: "PNG",
|
||||
};
|
||||
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
|
||||
Ok((
|
||||
std::path::PathBuf::from("/tmp/test2.png"),
|
||||
dummy_info.clone(),
|
||||
))
|
||||
});
|
||||
assert!(handled, "expected cmd+v to be handled on success");
|
||||
match rx
|
||||
.recv()
|
||||
.unwrap_or_else(|e| panic!("failed to receive event: {e}"))
|
||||
{
|
||||
AppEvent::AttachImage {
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
format_label,
|
||||
} => {
|
||||
assert_eq!(path, std::path::PathBuf::from("/tmp/test2.png"));
|
||||
assert_eq!(width, 12);
|
||||
assert_eq!(height, 8);
|
||||
assert_eq!(format_label, "PNG");
|
||||
}
|
||||
_ => panic!("unexpected event (not AttachImage)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn super_v_not_handled_on_non_macos() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let key_event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER);
|
||||
let handled = try_handle_ctrl_v_with(&sender, &key_event, || {
|
||||
Err(crate::clipboard_paste::PasteImageError::NoImage(
|
||||
"none".into(),
|
||||
))
|
||||
});
|
||||
assert!(
|
||||
!handled,
|
||||
"SUPER should not be treated as paste on non-macOS"
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use std::time::Duration;
|
||||
use crate::app::ChatWidgetArgs;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
|
||||
@@ -63,4 +63,12 @@ pub(crate) enum AppEvent {
|
||||
/// Onboarding: result of login_with_chatgpt.
|
||||
OnboardingAuthComplete(Result<(), String>),
|
||||
OnboardingComplete(ChatWidgetArgs),
|
||||
|
||||
/// Image pasted via Cmd+V (clipboard image attachment).
|
||||
AttachImage {
|
||||
path: std::path::PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
@@ -69,7 +70,9 @@ pub(crate) struct ChatComposer {
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
token_usage_info: Option<TokenUsageInfo>,
|
||||
has_focus: bool,
|
||||
attached_images: Vec<(String, std::path::PathBuf)>,
|
||||
placeholder_text: String,
|
||||
last_submitted_display: Option<String>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -101,7 +104,9 @@ impl ChatComposer {
|
||||
pending_pastes: Vec::new(),
|
||||
token_usage_info: None,
|
||||
has_focus: has_input_focus,
|
||||
attached_images: Vec::new(),
|
||||
placeholder_text,
|
||||
last_submitted_display: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +199,42 @@ impl ChatComposer {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn current_text(&self) -> String {
|
||||
self.textarea.text().to_string()
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, text: &str) {
|
||||
self.textarea.set_text(text);
|
||||
}
|
||||
|
||||
pub fn set_cursor_to_end(&mut self) {
|
||||
let len = self.textarea.text().chars().count();
|
||||
self.textarea.set_cursor(len);
|
||||
}
|
||||
|
||||
pub fn attach_image(
|
||||
&mut self,
|
||||
path: std::path::PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) -> bool {
|
||||
let placeholder = format!("[image {width}x{height} {format_label}]");
|
||||
self.textarea.insert_str(&placeholder);
|
||||
self.attached_images.push((placeholder, path));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn take_recent_submission_images_with_placeholders(
|
||||
&mut self,
|
||||
) -> Vec<(String, std::path::PathBuf)> {
|
||||
std::mem::take(&mut self.attached_images)
|
||||
}
|
||||
|
||||
pub fn take_last_submitted_display(&mut self) -> Option<String> {
|
||||
self.last_submitted_display.take()
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
@@ -341,12 +382,69 @@ impl ChatComposer {
|
||||
} => {
|
||||
if let Some(sel) = popup.selected_match() {
|
||||
let sel_path = sel.to_string();
|
||||
// Drop popup borrow before using self mutably again.
|
||||
self.insert_selected_path(&sel_path);
|
||||
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
|
||||
let is_image = {
|
||||
let lower = sel_path.to_ascii_lowercase();
|
||||
lower.ends_with(".png")
|
||||
|| lower.ends_with(".jpg")
|
||||
|| lower.ends_with(".jpeg")
|
||||
};
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = std::path::PathBuf::from(&sel_path);
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((w, h)) => {
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
let before_cursor = &text[..cursor_offset];
|
||||
let after_cursor = &text[cursor_offset..];
|
||||
|
||||
// Determine token boundaries in the full text.
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = cursor_offset + end_rel_idx;
|
||||
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
let format_label = match Path::new(&sel_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
{
|
||||
Some(ext) if ext == "png" => "PNG",
|
||||
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
|
||||
_ => "IMG",
|
||||
};
|
||||
let _ = self.attach_image(path_buf.clone(), w, h, format_label);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-image: original behavior.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
self.active_popup = ActivePopup::None;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
(InputResult::None, false)
|
||||
// No selection: treat Enter as closing the popup/session.
|
||||
self.active_popup = ActivePopup::None;
|
||||
(InputResult::None, true)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
@@ -527,23 +625,36 @@ impl ChatComposer {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
self.textarea.set_text("");
|
||||
// Build display string that preserves inline image placeholders
|
||||
let mut display = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if display.contains(placeholder) {
|
||||
display = display.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.last_submitted_display = Some(display);
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
// Build the agent text: remove image placeholders, trim
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
if text.is_empty() {
|
||||
(InputResult::None, true)
|
||||
} else {
|
||||
self.history.record_local_submission(&text);
|
||||
(InputResult::Submitted(text), true)
|
||||
for (placeholder, _) in &self.attached_images {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, "");
|
||||
}
|
||||
}
|
||||
|
||||
text = text.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
// Clear textarea after capturing content
|
||||
self.textarea.set_text("");
|
||||
(InputResult::Submitted(text), true)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
@@ -551,6 +662,17 @@ impl ChatComposer {
|
||||
|
||||
/// Handle generic Input events that modify the textarea content.
|
||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
// Special handling for backspace on placeholders
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} = input
|
||||
{
|
||||
if self.try_remove_any_placeholder_at_cursor() {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
@@ -559,9 +681,61 @@ impl ChatComposer {
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
|
||||
// Keep only attached images whose placeholders still exist in text
|
||||
self.attached_images
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
/// Attempts to remove an image or paste placeholder if the cursor is at the end of one.
|
||||
/// Returns true if a placeholder was removed.
|
||||
fn try_remove_any_placeholder_at_cursor(&mut self) -> bool {
|
||||
let p = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
|
||||
// Try image placeholders first
|
||||
if let Some((idx, placeholder)) =
|
||||
self.attached_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, (ph, _))| {
|
||||
if p < ph.len() {
|
||||
return None;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text[start..p] == *ph {
|
||||
Some((i, ph.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.attached_images.remove(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then try pasted-content placeholders
|
||||
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if p < ph.len() {
|
||||
return None;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text[start..p] == *ph {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
@@ -1321,4 +1495,86 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Image attachment tests ---
|
||||
#[test]
|
||||
fn attach_image_and_submit_includes_image_paths() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let path = std::path::PathBuf::from("/tmp/image1.png");
|
||||
assert!(composer.attach_image(path.clone(), 32, 16, "PNG"));
|
||||
composer.handle_paste(" hi".into());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "hi"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images_with_placeholders();
|
||||
assert_eq!(imgs.len(), 1);
|
||||
assert_eq!(imgs[0].1, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_image_without_text_submits_empty_text_and_images() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let path = std::path::PathBuf::from("/tmp/image2.png");
|
||||
assert!(composer.attach_image(path.clone(), 10, 5, "PNG"));
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert!(text.is_empty()),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images_with_placeholders();
|
||||
assert_eq!(imgs.len(), 1);
|
||||
assert_eq!(imgs[0].1, path);
|
||||
assert!(composer.attached_images.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_placeholder_backspace_behaves_like_text_placeholder() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let path = std::path::PathBuf::from("/tmp/image3.png");
|
||||
assert!(composer.attach_image(path.clone(), 20, 10, "PNG"));
|
||||
let placeholder = composer.attached_images[0].0.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
composer.textarea.move_cursor_to_end_of_line(false);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert!(!composer.textarea.text().contains(&placeholder));
|
||||
assert!(composer.attached_images.is_empty());
|
||||
|
||||
// Re-add and test backspace in middle: should break the placeholder string
|
||||
// and drop the image mapping (same as text placeholder behavior).
|
||||
assert!(composer.attach_image(path.clone(), 20, 10, "PNG"));
|
||||
let placeholder2 = composer.attached_images[0].0.clone();
|
||||
// Move cursor to roughly middle of placeholder
|
||||
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
||||
let mid_pos = start_pos + (placeholder2.len() / 2);
|
||||
composer.textarea.set_cursor(mid_pos);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert!(!composer.textarea.text().contains(&placeholder2));
|
||||
assert!(composer.attached_images.is_empty());
|
||||
} else {
|
||||
panic!("Placeholder not found in textarea");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,20 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_input_text(&self) -> String {
|
||||
self.composer.current_text()
|
||||
}
|
||||
|
||||
pub(crate) fn replace_input_text(&mut self, text: &str) {
|
||||
self.composer.set_text(text);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn move_cursor_to_end(&mut self) {
|
||||
self.composer.set_cursor_to_end();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
||||
self.ctrl_c_quit_hint
|
||||
}
|
||||
@@ -297,6 +311,36 @@ impl BottomPane<'_> {
|
||||
self.composer.on_file_search_result(query, matches);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: std::path::PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
if self.active_view.is_none() {
|
||||
let needs_redraw = self
|
||||
.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_recent_submission_images_with_placeholders(
|
||||
&mut self,
|
||||
) -> Vec<(String, std::path::PathBuf)> {
|
||||
self.composer
|
||||
.take_recent_submission_images_with_placeholders()
|
||||
}
|
||||
|
||||
pub(crate) fn take_last_submitted_display(&mut self) -> Option<String> {
|
||||
self.composer.take_last_submitted_display()
|
||||
}
|
||||
|
||||
// Removed restart_live_status_with_text – no longer used by the current streaming UI.
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
|
||||
@@ -97,6 +97,8 @@ struct UserMessage {
|
||||
}
|
||||
|
||||
use crate::streaming::StreamKind;
|
||||
use crate::string_utils::file_url_to_path;
|
||||
use crate::string_utils::unescape_backslashes;
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
@@ -543,14 +545,325 @@ impl ChatWidget<'_> {
|
||||
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
self.submit_user_message(text.into());
|
||||
let mut images = self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders();
|
||||
let display = self
|
||||
.bottom_pane
|
||||
.take_last_submitted_display()
|
||||
.unwrap_or_else(|| text.clone());
|
||||
// If there are no images yet, and the submitted text looks like
|
||||
// a single quoted path or file:// URL to a local image, treat
|
||||
// it as an image attachment instead of plain text.
|
||||
if images.is_empty() {
|
||||
if let Some((path, width, height, fmt)) =
|
||||
Self::try_interpret_as_single_image_path(&text)
|
||||
{
|
||||
// Attach the detected image and clear the agent text.
|
||||
self.attach_image(path.clone(), width, height, &fmt);
|
||||
images.push((String::new(), path));
|
||||
// Keep display unchanged so the user sees what they typed.
|
||||
self.submit_user_message_with_display(String::new(), images, display);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.submit_user_message_with_display(text, images, display);
|
||||
}
|
||||
InputResult::None => {
|
||||
// Inline detection: if the current input exactly matches a single
|
||||
// quoted path or file:// URL to a local PNG/JPEG, or if the
|
||||
// last token at the end looks like one, convert it immediately
|
||||
// into an image attachment.
|
||||
let current = self.bottom_pane.current_input_text();
|
||||
if let Some((path, width, height, fmt)) =
|
||||
Self::try_interpret_as_single_image_path(¤t)
|
||||
{
|
||||
self.bottom_pane.replace_input_text("");
|
||||
self.bottom_pane.move_cursor_to_end();
|
||||
self.attach_image(path, width, height, &fmt);
|
||||
} else if let Some((start, end, candidate)) =
|
||||
Self::extract_trailing_path_candidate(¤t)
|
||||
{
|
||||
if let Some((path, width, height, fmt)) =
|
||||
Self::try_interpret_as_single_image_path(&candidate)
|
||||
{
|
||||
let mut new_text = String::new();
|
||||
new_text.push_str(¤t[..start]);
|
||||
new_text.push_str(¤t[end..]);
|
||||
self.bottom_pane.replace_input_text(&new_text);
|
||||
self.bottom_pane.move_cursor_to_end();
|
||||
self.attach_image(path, width, height, &fmt);
|
||||
}
|
||||
}
|
||||
}
|
||||
InputResult::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: std::path::PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
tracing::info!(
|
||||
"attach_image path={:?} width={} height={} format={}",
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
format_label
|
||||
);
|
||||
// Forward to bottom pane; width/height/format currently only affect placeholder text.
|
||||
let _ = (width, height, format_label); // reserved for future use (e.g., status flash)
|
||||
self.bottom_pane
|
||||
.attach_image(path.clone(), width, height, format_label);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
// Heuristic: interpret the entire submitted text as a single image path/URL
|
||||
// when it is quoted or looks like a standalone path. Returns (path, w, h, fmt)
|
||||
// if it resolves to a local PNG/JPEG image.
|
||||
fn try_interpret_as_single_image_path(
|
||||
text: &str,
|
||||
) -> Option<(std::path::PathBuf, u32, u32, String)> {
|
||||
let s = text.trim();
|
||||
if s.is_empty() || s.contains('\n') {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Strip surrounding quotes if present.
|
||||
let mut candidate = s.to_string();
|
||||
if candidate.len() >= 2
|
||||
&& ((candidate.starts_with('"') && candidate.ends_with('"'))
|
||||
|| (candidate.starts_with('\'') && candidate.ends_with('\'')))
|
||||
{
|
||||
candidate = candidate[1..candidate.len() - 1].to_string();
|
||||
}
|
||||
|
||||
// Expand ~/ to $HOME
|
||||
if let Some(rest) = candidate.strip_prefix("~/") {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let mut p = std::path::PathBuf::from(home);
|
||||
p.push(rest);
|
||||
candidate = p.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
|
||||
// Consider multiple representations
|
||||
let mut try_paths: Vec<std::path::PathBuf> = Vec::new();
|
||||
if let Some(p) = file_url_to_path(&candidate) {
|
||||
try_paths.push(p);
|
||||
}
|
||||
try_paths.push(std::path::PathBuf::from(&candidate));
|
||||
let unescaped = unescape_backslashes(&candidate);
|
||||
if unescaped != candidate {
|
||||
try_paths.push(std::path::PathBuf::from(unescaped));
|
||||
}
|
||||
|
||||
for path in try_paths {
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let ext_l = ext.to_ascii_lowercase();
|
||||
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
|
||||
let (mut w, mut h) = (0u32, 0u32);
|
||||
if let Ok((dw, dh)) = image::image_dimensions(&path) {
|
||||
w = dw;
|
||||
h = dh;
|
||||
}
|
||||
let fmt = if ext_l == "png" { "PNG" } else { "JPEG" }.to_string();
|
||||
return Some((path, w, h, fmt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Find a trailing quoted path or URL token at the end of the current input.
|
||||
// Also handles Windows-style paths like C:\Users\... without quotes.
|
||||
// Returns (start_idx, end_idx, candidate_str) in byte indices if found.
|
||||
fn extract_trailing_path_candidate(s: &str) -> Option<(usize, usize, String)> {
|
||||
let trimmed_end = s.trim_end();
|
||||
if trimmed_end.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = trimmed_end.len();
|
||||
|
||||
// Case 1: ends with a quoted segment '...'/"..."
|
||||
if trimmed_end.ends_with('"') {
|
||||
if let Some(start_q) = trimmed_end[..end - 1].rfind('"') {
|
||||
if end - start_q >= 2 {
|
||||
let cand = &trimmed_end[start_q..end];
|
||||
let start_idx = start_q;
|
||||
let end_idx = end;
|
||||
return Some((start_idx, end_idx, cand.to_string()));
|
||||
}
|
||||
}
|
||||
} else if trimmed_end.ends_with('\'') {
|
||||
if let Some(start_q) = trimmed_end[..end - 1].rfind('\'') {
|
||||
if end - start_q >= 2 {
|
||||
let cand = &trimmed_end[start_q..end];
|
||||
let start_idx = start_q;
|
||||
let end_idx = end;
|
||||
return Some((start_idx, end_idx, cand.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Case 2: last whitespace-delimited token
|
||||
let token = trimmed_end.split_whitespace().last().unwrap_or("");
|
||||
if !token.is_empty() {
|
||||
let start_idx = trimmed_end.rfind(token).unwrap_or(end - token.len());
|
||||
let end_idx = start_idx + token.len();
|
||||
// Only consider likely file paths/URLs to reduce false positives.
|
||||
if token.starts_with('/')
|
||||
|| token.starts_with("./")
|
||||
|| token.starts_with("../")
|
||||
|| token.starts_with("~/")
|
||||
|| token.starts_with("file://")
|
||||
{
|
||||
return Some((start_idx, end_idx, token.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Heuristic for Windows drive-letter paths without quotes, possibly containing spaces.
|
||||
// Look for a trailing pattern like <Letter>:\ or <Letter>:/ and treat the remainder
|
||||
// of the line as the candidate path.
|
||||
// Example: "See C:\\Users\\John Doe\\image 1.png" -> candidate starts at 'C'.
|
||||
if let Some(colon_pos) = trimmed_end.rfind(':') {
|
||||
if colon_pos > 0 && colon_pos + 1 < end {
|
||||
let bytes = trimmed_end.as_bytes();
|
||||
let prev = bytes[colon_pos - 1];
|
||||
let next = bytes[colon_pos + 1];
|
||||
let is_letter = (prev as char).is_ascii_alphabetic();
|
||||
let is_sep = next == b'\\' || next == b'/';
|
||||
if is_letter && is_sep {
|
||||
let start_idx = colon_pos - 1; // include the drive letter
|
||||
let end_idx = end; // take until end of line (may include spaces)
|
||||
let cand = &trimmed_end[start_idx..end_idx];
|
||||
return Some((start_idx, end_idx, cand.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 4: UNC paths. Look for the last occurrence of \\\\ and consider the remainder
|
||||
// of the line as the path candidate.
|
||||
if let Some(pos) = trimmed_end.rfind("\\\\") {
|
||||
if pos + 1 < end && &trimmed_end[pos..pos + 2] == "\\\\" {
|
||||
let cand = &trimmed_end[pos..end];
|
||||
return Some((pos, end, cand.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
// Helper: attempt to attach image and return true if attached.
|
||||
fn try_attach_image(widget: &mut ChatWidget<'_>, path: std::path::PathBuf) -> bool {
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let ext_l = ext.to_ascii_lowercase();
|
||||
if matches!(ext_l.as_str(), "png" | "jpg" | "jpeg") {
|
||||
let (mut w, mut h) = (0u32, 0u32);
|
||||
if let Ok((dw, dh)) = image::image_dimensions(&path) {
|
||||
w = dw;
|
||||
h = dh;
|
||||
}
|
||||
let fmt = if ext_l == "png" { "PNG" } else { "JPEG" };
|
||||
widget.attach_image(path, w, h, fmt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Normalize a candidate string into possible paths to try.
|
||||
fn candidate_paths(candidate: &str) -> Vec<std::path::PathBuf> {
|
||||
let mut s = candidate.trim().to_string();
|
||||
if s.len() >= 2
|
||||
&& ((s.starts_with('"') && s.ends_with('"'))
|
||||
|| (s.starts_with('\'') && s.ends_with('\'')))
|
||||
{
|
||||
s = s[1..s.len() - 1].to_string();
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("~/") {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let mut p = std::path::PathBuf::from(home);
|
||||
p.push(rest);
|
||||
s = p.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
let mut try_paths: Vec<std::path::PathBuf> = Vec::new();
|
||||
if let Some(p) = file_url_to_path(&s) {
|
||||
try_paths.push(p);
|
||||
}
|
||||
try_paths.push(std::path::PathBuf::from(&s));
|
||||
let unescaped = unescape_backslashes(&s);
|
||||
if unescaped != s {
|
||||
try_paths.push(std::path::PathBuf::from(unescaped));
|
||||
}
|
||||
try_paths
|
||||
}
|
||||
|
||||
let mut any_attached = false;
|
||||
|
||||
// Strategy:
|
||||
// - If multiple lines were pasted (drag-and-drop often does this),
|
||||
// treat each line as a single candidate path. Attach images for lines
|
||||
// that resolve to valid image files; keep other lines as leftover text.
|
||||
// - Otherwise, try tokenizing the single line using shlex and attach
|
||||
// images for any token that resolves to a valid image file; paste
|
||||
// any remaining tokens as text.
|
||||
|
||||
if text.contains('\n') {
|
||||
let mut leftover_lines: Vec<&str> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut attached_this_line = false;
|
||||
for p in candidate_paths(line) {
|
||||
if try_attach_image(self, p) {
|
||||
any_attached = true;
|
||||
attached_this_line = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !attached_this_line {
|
||||
leftover_lines.push(line);
|
||||
}
|
||||
}
|
||||
let leftover = leftover_lines.join("\n");
|
||||
if !leftover.trim().is_empty() {
|
||||
self.bottom_pane.handle_paste(leftover);
|
||||
} else if !any_attached {
|
||||
// Nothing attached and no leftover: forward original text.
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
} else {
|
||||
// Single line: consider shlex tokens to support quoted paths.
|
||||
let tokens: Vec<String> = shlex::split(&text).unwrap_or_else(|| vec![text.clone()]);
|
||||
let mut leftover_tokens: Vec<String> = Vec::new();
|
||||
for tok in tokens {
|
||||
let mut attached_this_token = false;
|
||||
for p in candidate_paths(&tok) {
|
||||
if try_attach_image(self, p) {
|
||||
any_attached = true;
|
||||
attached_this_token = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !attached_this_token {
|
||||
leftover_tokens.push(tok);
|
||||
}
|
||||
}
|
||||
let leftover = leftover_tokens.join(" ");
|
||||
if !leftover.trim().is_empty() {
|
||||
self.bottom_pane.handle_paste(leftover);
|
||||
} else if !any_attached {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_active_exec_cell(&mut self) {
|
||||
@@ -603,6 +916,38 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_user_message_with_display(
|
||||
&mut self,
|
||||
text: String,
|
||||
images: Vec<(String, std::path::PathBuf)>,
|
||||
display_text: String,
|
||||
) {
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
if !text.is_empty() {
|
||||
items.push(InputItem::Text { text: text.clone() });
|
||||
}
|
||||
for (_, path) in &images {
|
||||
items.push(InputItem::LocalImage { path: path.clone() });
|
||||
}
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.codex_op_tx
|
||||
.send(Op::UserInput { items })
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send message: {e}");
|
||||
});
|
||||
if !text.is_empty() {
|
||||
self.codex_op_tx
|
||||
.send(Op::AddToHistory { text: text.clone() })
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send AddHistory op: {e}");
|
||||
});
|
||||
}
|
||||
// Show the original display text (with inline image placeholders) in history.
|
||||
self.add_to_history(&history_cell::new_user_prompt_with_images(display_text));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
// Reset redraw flag for this dispatch
|
||||
self.needs_redraw = false;
|
||||
|
||||
@@ -170,6 +170,110 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_trailing_windows_drive_path_token_end() {
|
||||
let input = r"Check this C:\\Users\\me\\pic.png".to_string();
|
||||
let res = ChatWidget::extract_trailing_path_candidate(&input).expect("candidate");
|
||||
let (_, _, cand) = res;
|
||||
assert!(cand.ends_with("pic.png"));
|
||||
assert!(cand.starts_with("C:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_trailing_windows_drive_path_with_spaces() {
|
||||
let input = r"Please use C:\\Users\\John Doe\\Pictures\\image 1.png".to_string();
|
||||
let res = ChatWidget::extract_trailing_path_candidate(&input).expect("candidate");
|
||||
let (_, _, cand) = res;
|
||||
assert!(cand.starts_with("C:"));
|
||||
assert!(cand.contains("John Doe"));
|
||||
assert!(cand.ends_with("image 1.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_trailing_unc_path() {
|
||||
let input = r"See \\\\server\\share\\img.jpg".to_string();
|
||||
let res = ChatWidget::extract_trailing_path_candidate(&input).expect("candidate");
|
||||
let (_, _, cand) = res;
|
||||
assert!(cand.starts_with("\\\\"));
|
||||
assert!(cand.ends_with("img.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_multiple_lines_attaches_multiple_images_and_no_text() {
|
||||
use image::ImageBuffer;
|
||||
use image::Rgba;
|
||||
// Create two temp images
|
||||
let dir = std::env::temp_dir();
|
||||
let p1 = dir.join(format!("codex_multi_a_{}.png", uuid::Uuid::new_v4()));
|
||||
let p2 = dir.join(format!("codex_multi_b_{}.png", uuid::Uuid::new_v4()));
|
||||
let img1: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(2, 3, |_x, _y| Rgba([255, 0, 0, 255]));
|
||||
image::DynamicImage::ImageRgba8(img1)
|
||||
.save_with_format(&p1, image::ImageFormat::Png)
|
||||
.expect("write png");
|
||||
let img2: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(4, 1, |_x, _y| Rgba([0, 255, 0, 255]));
|
||||
image::DynamicImage::ImageRgba8(img2)
|
||||
.save_with_format(&p2, image::ImageFormat::Png)
|
||||
.expect("write png");
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
let paste = format!("{}\n{}\n", p1.display(), p2.display());
|
||||
chat.handle_paste(paste);
|
||||
|
||||
// Expect composer text to contain two image placeholders and no literal paths.
|
||||
let composer_text = chat.bottom_pane.current_input_text();
|
||||
assert!(
|
||||
composer_text.contains("[image 2x3 PNG]"),
|
||||
"missing png placeholder: {composer_text}"
|
||||
);
|
||||
assert!(
|
||||
composer_text.contains("[image 4x1 PNG]"),
|
||||
"missing png placeholder (second): {composer_text}"
|
||||
);
|
||||
assert!(!composer_text.contains("a.png"));
|
||||
assert!(!composer_text.contains("b.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_single_line_two_quoted_paths_attaches_both_and_keeps_other_text() {
|
||||
use image::ImageBuffer;
|
||||
use image::Rgba;
|
||||
let dir = std::env::temp_dir();
|
||||
let p1 = dir.join(format!("codex_multi_c_{}.png", uuid::Uuid::new_v4()));
|
||||
let p2 = dir.join(format!("codex_multi_d_{}.png", uuid::Uuid::new_v4()));
|
||||
let img1: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(1, 1, |_x, _y| Rgba([1, 2, 3, 255]));
|
||||
image::DynamicImage::ImageRgba8(img1)
|
||||
.save_with_format(&p1, image::ImageFormat::Png)
|
||||
.expect("write png");
|
||||
let img2: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([4, 5, 6, 255]));
|
||||
image::DynamicImage::ImageRgba8(img2)
|
||||
.save_with_format(&p2, image::ImageFormat::Png)
|
||||
.expect("write png");
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
let paste = format!(
|
||||
"Please see \"{}\" and \"{}\" now",
|
||||
p1.display(),
|
||||
p2.display()
|
||||
);
|
||||
chat.handle_paste(paste);
|
||||
|
||||
let composer_text = chat.bottom_pane.current_input_text();
|
||||
assert!(
|
||||
composer_text.contains("Please see"),
|
||||
"leftover text missing: {composer_text}"
|
||||
);
|
||||
assert!(
|
||||
composer_text.contains("now"),
|
||||
"leftover text missing: {composer_text}"
|
||||
);
|
||||
assert!(composer_text.contains("[image 1x1 PNG]"));
|
||||
assert!(composer_text.contains("[image 3x2 PNG]"));
|
||||
}
|
||||
|
||||
fn open_fixture(name: &str) -> std::fs::File {
|
||||
// 1) Prefer fixtures within this crate
|
||||
{
|
||||
|
||||
74
codex-rs/tui/src/clipboard_paste.rs
Normal file
74
codex-rs/tui/src/clipboard_paste.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
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,
|
||||
#[allow(dead_code)]
|
||||
pub encoded_format_label: &'static str, // Used in tests; always "PNG" currently.
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
@@ -194,6 +194,11 @@ pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_user_prompt_with_images(message: String) -> PlainHistoryCell {
|
||||
// Currently identical to new_user_prompt; kept separate for clarity when images are present.
|
||||
new_user_prompt(message)
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_exec_command(
|
||||
command: Vec<String>,
|
||||
parsed: Vec<ParsedCommand>,
|
||||
|
||||
@@ -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;
|
||||
@@ -48,6 +49,7 @@ mod shimmer;
|
||||
mod slash_command;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
mod string_utils;
|
||||
mod text_formatting;
|
||||
mod tui;
|
||||
mod user_approval_widget;
|
||||
|
||||
192
codex-rs/tui/src/string_utils.rs
Normal file
192
codex-rs/tui/src/string_utils.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
// String and path parsing helpers used across the TUI.
|
||||
|
||||
// Naive percent-decoding for file:// URL paths; returns None on invalid UTF-8.
|
||||
pub(crate) fn percent_decode_to_string(input: &str) -> Option<String> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let h1 = bytes[i + 1];
|
||||
let h2 = bytes[i + 2];
|
||||
let hex = |c: u8| -> Option<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Some(c - b'0'),
|
||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let (Some(x), Some(y)) = (hex(h1), hex(h2)) {
|
||||
out.push(x * 16 + y);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
// Convert a file:// URL into a local path.
|
||||
// Rules:
|
||||
// - Accept only empty host or "localhost"; reject remote hosts.
|
||||
// - On Unix, require absolute paths (leading '/').
|
||||
// - On Windows, support forms like file:///C:/path and file://localhost/C:/path.
|
||||
pub(crate) fn file_url_to_path(s: &str) -> Option<std::path::PathBuf> {
|
||||
let mut rest = s.strip_prefix("file://")?;
|
||||
|
||||
// Handle optional host (e.g., file://localhost/...). Only allow empty or localhost.
|
||||
if let Some(after_host) = rest.strip_prefix("localhost") {
|
||||
rest = after_host;
|
||||
} else if rest.starts_with('/') {
|
||||
// empty host, keep as-is
|
||||
} else {
|
||||
// Non-local host is not supported – reject.
|
||||
return None;
|
||||
}
|
||||
|
||||
// Percent-decode the path portion
|
||||
let decoded = percent_decode_to_string(rest)?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// On Windows, URLs often look like file:///C:/path or file://localhost/C:/path
|
||||
// If the decoded path starts with '/<drive>:/', strip the leading slash.
|
||||
let path = if decoded.len() >= 4
|
||||
&& decoded.as_bytes()[0] == b'/'
|
||||
&& decoded.as_bytes()[1].is_ascii_alphabetic()
|
||||
&& decoded.as_bytes()[2] == b':'
|
||||
&& decoded.as_bytes()[3] == b'/'
|
||||
{
|
||||
decoded[1..].to_string()
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
return Some(std::path::PathBuf::from(path));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// On Unix, require absolute path.
|
||||
if !decoded.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::PathBuf::from(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
// Unescape simple bash-style backslash escapes (e.g., spaces, parens).
|
||||
pub(crate) fn unescape_backslashes(s: &str) -> String {
|
||||
// On Windows, do not unescape backslashes; they are core to paths like C:\Users.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
// On Unix, unescape common shell-escaped characters (e.g., spaces, parens, quotes).
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
if let Some(n) = chars.next() {
|
||||
// Only unescape a known set of characters typically escaped in shells.
|
||||
let should_unescape = matches!(
|
||||
n,
|
||||
' ' | '('
|
||||
| ')'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| '\\'
|
||||
| '$'
|
||||
| '&'
|
||||
| '!'
|
||||
| '#'
|
||||
| ';'
|
||||
| ':'
|
||||
| '@'
|
||||
| '='
|
||||
| '+'
|
||||
| ','
|
||||
| '~'
|
||||
| '|'
|
||||
| '<'
|
||||
| '>'
|
||||
| '?'
|
||||
| '*'
|
||||
);
|
||||
if should_unescape {
|
||||
out.push(n);
|
||||
} else {
|
||||
out.push('\\');
|
||||
out.push(n);
|
||||
}
|
||||
} else {
|
||||
// Trailing backslash; keep it.
|
||||
out.push('\\');
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percent_decode_basic() {
|
||||
assert_eq!(percent_decode_to_string("/a%20b").as_deref(), Some("/a b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(windows))]
|
||||
fn file_url_localhost_unix() {
|
||||
assert_eq!(
|
||||
file_url_to_path("file://localhost//tmp/foo").unwrap(),
|
||||
std::path::PathBuf::from("/tmp/foo")
|
||||
);
|
||||
assert_eq!(
|
||||
file_url_to_path("file:////tmp/foo").unwrap(),
|
||||
std::path::PathBuf::from("/tmp/foo")
|
||||
);
|
||||
assert!(file_url_to_path("file://host/tmp/foo").is_none());
|
||||
assert!(file_url_to_path("file://localhosttmp/foo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn file_url_windows_drive() {
|
||||
assert_eq!(
|
||||
file_url_to_path("file:///C:/Users/test").unwrap(),
|
||||
std::path::PathBuf::from("C:/Users/test")
|
||||
);
|
||||
assert_eq!(
|
||||
file_url_to_path("file://localhost/C:/Users/test").unwrap(),
|
||||
std::path::PathBuf::from("C:/Users/test")
|
||||
);
|
||||
assert!(file_url_to_path("file://host/C:/Users/test").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(windows))]
|
||||
fn unescape_backslashes_unix() {
|
||||
assert_eq!(unescape_backslashes("My\\ File(1).png"), "My File(1).png");
|
||||
// Leave unknown escapes intact
|
||||
assert_eq!(unescape_backslashes("abc\\z"), "abc\\z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn unescape_backslashes_windows_noop() {
|
||||
assert_eq!(unescape_backslashes("C:\\Users\\test"), "C:\\Users\\test");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user