This commit is contained in:
Daniel Edrisian
2025-08-20 14:18:36 -07:00
parent 7627449f21
commit b15e2c45ed
4 changed files with 182 additions and 32 deletions

View File

@@ -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;
@@ -40,7 +42,7 @@ const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
fn try_handle_ctrl_v_with<F>(
app_event_tx: &AppEventSender,
key_event: &KeyEvent,
paste_fn: F,
_paste_fn: F,
) -> bool
where
F: Fn() -> Result<
@@ -65,25 +67,50 @@ where
mods.contains(crossterm::event::KeyModifiers::CONTROL) || has_cmd_on_macos;
if key_event.kind == KeyEventKind::Press && is_v && has_paste_modifier {
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
// 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
}
}
}
Err(err) => {
tracing::debug!("Ctrl+V image import failed: {err}");
}
// 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.
}
}
}
}
@@ -96,6 +123,64 @@ fn try_handle_ctrl_v(app_event_tx: &AppEventSender, key_event: &KeyEvent) -> boo
})
}
// 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> {

View File

@@ -207,6 +207,11 @@ impl ChatComposer {
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,

View File

@@ -194,6 +194,11 @@ impl BottomPane<'_> {
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
}

View File

@@ -572,15 +572,29 @@ impl ChatWidget<'_> {
}
InputResult::None => {
// Inline detection: if the current input exactly matches a single
// quoted path or file:// URL to a local PNG/JPEG, convert it
// immediately into an image attachment.
// 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(&current)
{
// Clear the typed path and insert the image placeholder.
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(&current)
{
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(&current[..start]);
new_text.push_str(&current[end..]);
self.bottom_pane.replace_input_text(&new_text);
self.bottom_pane.move_cursor_to_end();
self.attach_image(path, width, height, &fmt);
}
}
}
}
@@ -666,6 +680,55 @@ impl ChatWidget<'_> {
None
}
// Find a trailing quoted path or URL token at the end of the current input.
// 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()));
}
}
}
None
}
pub(crate) fn handle_paste(&mut self, text: String) {
// First, attempt to interpret the pasted text as a file path to an image
// and attach it. This mirrors the logic previously handled at the app level.
@@ -754,17 +817,9 @@ impl ChatWidget<'_> {
}
}
// If still not handled, try to read an image bitmap from the clipboard.
// If still not handled, treat it as a normal textual paste.
if !handled {
match crate::clipboard_paste::paste_image_to_temp_png() {
Ok((path, info)) => {
self.attach_image(path, info.width, info.height, info.encoded_format_label);
}
Err(_) => {
// Fall back to textual paste into the composer.
self.bottom_pane.handle_paste(text);
}
}
self.bottom_pane.handle_paste(text);
}
}