Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat trimmed bang prompts as shell mode

This only disables image-path attachment when the internal bash-mode flag is set, but the rest of the composer treats leading-whitespace bang input as a shell command: is_bang_shell_command() uses trim_start(), and submission trims before shell dispatch. In a draft like !python script.py, pasting /tmp/a.png still attaches an image placeholder instead of inserting the literal path, so the submitted shell command receives [Image #1] rather than the file path. The new image-paste shortcut has the same starts_with('!') limitation, so both paste paths should use the same trimmed shell-command predicate.

Useful? React with 👍 / 👎.

&& self.handle_paste_image_path(pasted.clone())
{
self.draft.textarea.insert_str(" ");
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 36 additions & 17 deletions codex-rs/tui/src/chatwidget/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/composer_submission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions codex-rs/tui/src/clipboard_paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add WSL fallback for shell clipboard reads

In WSL shell mode, this new text/file-path clipboard path returns immediately when arboard::Clipboard::new() fails, but the existing image paste path has a PowerShell fallback because arboard cannot commonly access the Windows clipboard from WSL. As a result, pressing the shortcut in ! mode with Windows clipboard text or a copied file path now just warns and inserts nothing, even though the feature is supposed to paste clipboard text/file paths in shell mode; this path needs an equivalent WSL fallback for text/file lists.

Useful? React with 👍 / 👎.


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()))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quote file-list paths before shell insertion

When the shell-mode paste shortcut falls back to an OS file-list entry, this returns the raw filesystem path and handle_paste_image_shortcut() inserts it directly into the ! command buffer. User shell commands are later executed under the user's shell, so common image filenames like Screen Shot 2026-05-27.png are split into multiple arguments instead of being a usable literal path; file-list paths should be shell-escaped/quoted before insertion in this shell-only path.

Useful? React with 👍 / 👎.

}

#[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> {
Expand Down
Loading