mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
wip 2
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¤t)
|
||||
{
|
||||
// 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(¤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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user