Compare commits

...

1 Commits

Author SHA1 Message Date
Charles Cunningham
b8da60780d tui: make Cmd+Shift+V paste text inline without placeholder 2026-02-17 14:25:22 -08:00
4 changed files with 115 additions and 0 deletions

View File

@@ -641,6 +641,18 @@ impl ChatComposer {
true
}
/// Integrate pasted text as literal text, without creating large-paste placeholders.
///
/// This powers the Cmd+Shift+V shortcut so users can intentionally paste large blocks while
/// keeping the full text visible/editable in the textarea.
pub(crate) fn handle_verbatim_paste(&mut self, pasted: String) -> bool {
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
self.textarea.insert_str(&pasted);
self.paste_burst.clear_after_explicit_paste();
self.sync_popups();
true
}
pub fn handle_paste_image_path(&mut self, pasted: String) -> bool {
let Some(path_buf) = normalize_pasted_path(&pasted) else {
return false;
@@ -5263,6 +5275,37 @@ mod tests {
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn handle_verbatim_paste_large_inserts_text_without_placeholder() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
let needs_redraw = composer.handle_verbatim_paste(large.clone());
assert!(needs_redraw);
assert_eq!(composer.textarea.text(), large);
assert!(composer.pending_pastes.is_empty());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, large),
_ => panic!("expected Submitted"),
}
}
/// Behavior: editing that removes a paste placeholder should also clear the associated
/// `pending_pastes` entry so it cannot be submitted accidentally.
#[test]

View File

@@ -427,6 +427,23 @@ impl BottomPane {
}
}
pub fn handle_verbatim_paste(&mut self, pasted: String) {
if let Some(view) = self.view_stack.last_mut() {
let needs_redraw = view.handle_paste(pasted);
if view.is_complete() {
self.on_active_view_complete();
}
if needs_redraw {
self.request_redraw();
}
} else {
let needs_redraw = self.composer.handle_verbatim_paste(pasted);
if needs_redraw {
self.request_redraw();
}
}
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.composer.insert_str(text);
self.request_redraw();

View File

@@ -184,6 +184,7 @@ 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::paste_image_to_temp_png;
use crate::clipboard_paste::paste_text;
use crate::collaboration_modes;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -3090,6 +3091,27 @@ impl ChatWidget {
}
return;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::SUPER)
&& modifiers.contains(KeyModifiers::SHIFT)
&& !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
{
match paste_text() {
Ok(text) => self.bottom_pane.handle_verbatim_paste(text),
Err(err) => {
tracing::warn!("failed to paste text: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste text: {err}",
)));
}
}
return;
}
other if other.kind == KeyEventKind::Press => {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;

View File

@@ -22,6 +22,23 @@ impl std::fmt::Display for PasteImageError {
}
impl std::error::Error for PasteImageError {}
#[derive(Debug, Clone)]
pub enum PasteTextError {
ClipboardUnavailable(String),
NoText(String),
}
impl std::fmt::Display for PasteTextError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PasteTextError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"),
PasteTextError::NoText(msg) => write!(f, "no text on clipboard: {msg}"),
}
}
}
impl std::error::Error for PasteTextError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncodedImageFormat {
Png,
@@ -149,6 +166,22 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
}
}
#[cfg(not(target_os = "android"))]
pub fn paste_text() -> Result<String, PasteTextError> {
let mut cb = arboard::Clipboard::new()
.map_err(|e| PasteTextError::ClipboardUnavailable(e.to_string()))?;
cb.get()
.text()
.map_err(|e| PasteTextError::NoText(e.to_string()))
}
#[cfg(target_os = "android")]
pub fn paste_text() -> Result<String, PasteTextError> {
Err(PasteTextError::ClipboardUnavailable(
"clipboard text paste is unsupported on Android".into(),
))
}
/// Attempt WSL fallback for clipboard image paste.
///
/// If clipboard is unavailable (common under WSL because arboard cannot access