tui: keep image paths literal in shell mode

Fixes #24711.
This commit is contained in:
Eric Traut
2026-05-26 22:49:08 -07:00
parent 1de8c43467
commit bbb707cf91
5 changed files with 112 additions and 19 deletions

View File

@@ -869,8 +869,8 @@ impl ChatComposer {
///
/// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder
/// element (expanded on submit) and stores the full text in `pending_pastes`.
/// - Otherwise, if the paste looks like an image path, attaches the image and inserts a
/// trailing space so the user can keep typing naturally.
/// - Otherwise, if the paste looks like an image path outside shell mode, attaches the image
/// and inserts a trailing space so the user can keep typing naturally.
/// - Otherwise, inserts the pasted text directly into the textarea.
///
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
@@ -884,6 +884,7 @@ impl ChatComposer {
self.draft.pending_pastes.push((placeholder, pasted));
} else if char_count > 1
&& self.image_paste_enabled()
&& !self.draft.is_bash_mode
&& self.handle_paste_image_path(pasted.clone())
{
self.draft.textarea.insert_str(" ");
@@ -9719,6 +9720,36 @@ mod tests {
assert_eq!(imgs, vec![tmp_path]);
}
#[test]
fn pasting_filepath_in_shell_mode_keeps_literal_text() {
let tmp = tempdir().expect("create TempDir");
let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png");
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255]));
img.save(&tmp_path).expect("failed to write temp png");
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['!']);
assert!(composer.draft.is_bash_mode);
let path = tmp_path.to_string_lossy().to_string();
let needs_redraw = composer.handle_paste(path.clone());
assert!(needs_redraw);
assert_eq!(composer.draft.textarea.text(), path);
assert_eq!(composer.current_text(), format!("!{path}"));
assert!(composer.local_image_paths().is_empty());
}
#[test]
fn slash_path_input_submits_without_command_error() {
use crossterm::event::KeyCode;

View File

@@ -284,7 +284,10 @@ use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::PasteImageError;
use crate::clipboard_paste::PastedImageInfo;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::clipboard_paste::paste_text_or_file_path;
use crate::collaboration_modes;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;

View File

@@ -72,23 +72,7 @@ impl ChatWidget {
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
{
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
}
self.handle_paste_image_shortcut(paste_text_or_file_path, paste_image_to_temp_png);
return;
}
other if other.kind == KeyEventKind::Press => {
@@ -158,6 +142,41 @@ impl ChatWidget {
}
}
pub(crate) fn handle_paste_image_shortcut(
&mut self,
paste_text_or_file_path: impl FnOnce() -> Result<Option<String>, String>,
paste_image_to_temp_png: impl FnOnce() -> Result<(PathBuf, PastedImageInfo), PasteImageError>,
) {
if self.bottom_pane.composer_text().starts_with('!') {
match paste_text_or_file_path() {
Ok(Some(text)) => self.handle_paste(text),
Ok(None) => tracing::debug!(
"clipboard did not contain text or a file path to paste in shell mode"
),
Err(err) => tracing::warn!("failed to paste text in shell mode: {err}"),
}
return;
}
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
}
}
/// Attach a local image to the composer when the active model supports image inputs.
///
/// When the model does not advertise image support, we keep the draft unchanged and surface a

View File

@@ -985,6 +985,24 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn paste_image_shortcut_in_shell_mode_pastes_clipboard_text() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane
.set_composer_text("!ls ".to_string(), Vec::new(), Vec::new());
chat.handle_paste_image_shortcut(
|| Ok(Some("/tmp/from-clipboard.png".to_string())),
|| panic!("shell mode should not read image clipboard"),
);
assert_eq!(
chat.bottom_pane.composer_text(),
"!ls /tmp/from-clipboard.png"
);
assert!(chat.bottom_pane.take_recent_submission_images().is_empty());
}
#[tokio::test]
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -46,6 +46,28 @@ pub struct PastedImageInfo {
pub encoded_format: EncodedImageFormat, // Always PNG for now.
}
#[cfg(not(target_os = "android"))]
pub fn paste_text_or_file_path() -> Result<Option<String>, String> {
let mut cb = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?;
if let Ok(text) = cb.get_text()
&& !text.is_empty()
{
return Ok(Some(text));
}
let files = cb.get().file_list().unwrap_or_default();
Ok(files
.into_iter()
.next()
.map(|path| path.to_string_lossy().into_owned()))
}
#[cfg(target_os = "android")]
pub fn paste_text_or_file_path() -> Result<Option<String>, String> {
Ok(None)
}
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
#[cfg(not(target_os = "android"))]
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {