Suppress repeated image paste presses in legacy terminals (#51411)

## Why

Legacy terminals report key auto-repeat as new presses, which can trigger repeated image pastes when presses accumulate during a slow paste.

## What changed

Suppress repeated `Ctrl+V` and `Alt+V` image paste presses within a sliding 250 ms window, refreshed after each suppressed press and after a paste completes. Clear suppression on other ordinary key presses or key release events so a fresh paste can proceed immediately.

## Testing

Add regression coverage for repeated presses, window expiry, and resetting suppression on another key press or a key release. Update the shortcut remapping test to release the key between image pastes.

GitOrigin-RevId: 45abc056e5bda47d1c9704702e369e63dcce29db
This commit is contained in:
Eric Traut
2026-10-06 16:41:53 +00:00
committed by copyberry
parent ccde2fc8b7
commit ff9ab4aed9
5 changed files with 46 additions and 1 deletions
+2
View File
@@ -595,6 +595,8 @@ pub(crate) struct ChatWidget {
pending_stream_consolidations: usize,
/// Copy feedback is discarded with its originating conversation.
pending_clipboard: Option<clipboard::PendingCopy>,
/// Legacy terminals report auto-repeat as new presses; suppress the burst after a slow paste.
suppress_image_paste_until: Instant,
copy_last_response_binding: Vec<KeyBinding>,
running_commands: HashMap<String, RunningCommand>,
collab_agent_metadata: HashMap<ThreadId, AgentMetadata>,
+3
View File
@@ -4,6 +4,8 @@
use super::*;
use crate::clipboard_copy::worker::CopyResult;
pub(super) const IMAGE_PASTE_REPEAT_WINDOW: Duration = Duration::from_millis(/*millis*/ 250);
pub(super) enum PendingCopy {
Message(u64, String),
Selection(u64),
@@ -28,6 +30,7 @@ impl ChatWidget {
)));
}
}
self.suppress_image_paste_until = Instant::now() + IMAGE_PASTE_REPEAT_WINDOW;
}
pub(super) fn show_clipboard_flash(&mut self, result: &CopyResult) {
@@ -179,6 +179,7 @@ impl ChatWidget {
plan_stream_controller: None,
pending_stream_consolidations: 0,
pending_clipboard: None,
suppress_image_paste_until: Instant::now(),
copy_last_response_binding,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
@@ -161,13 +161,24 @@ impl ChatWidget {
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
{
if Instant::now() < self.suppress_image_paste_until {
self.suppress_image_paste_until =
Instant::now() + clipboard::IMAGE_PASTE_REPEAT_WINDOW;
return KeyEventAction::None;
}
self.suppress_image_paste_until =
Instant::now() + clipboard::IMAGE_PASTE_REPEAT_WINDOW;
return KeyEventAction::PasteImage;
}
other if other.kind == KeyEventKind::Press => {
self.suppress_image_paste_until = Instant::now();
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
other if other.kind == KeyEventKind::Release => {
self.suppress_image_paste_until = Instant::now();
}
_ => {}
}
@@ -2511,10 +2511,15 @@ async fn slash_keymap_invalid_args_show_usage() {
async fn copy_shortcut_can_be_remapped() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
for modifiers in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
let paste = KeyEvent::new(KeyCode::Char('v'), modifiers);
assert_matches!(
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), modifiers)),
chat.handle_key_event(paste),
crate::chatwidget::KeyEventAction::PasteImage
);
chat.handle_key_event(KeyEvent {
kind: KeyEventKind::Release,
..paste
});
}
let mut keymap_config = chat.config_ref().tui_keymap.clone();
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
@@ -2543,6 +2548,29 @@ async fn copy_shortcut_can_be_remapped() {
);
}
#[tokio::test]
async fn legacy_image_paste_ignores_buffered_press_until_other_input() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage);
assert_matches!(chat.handle_key_event(paste), KeyEventAction::None);
chat.suppress_image_paste_until = Instant::now() - Duration::from_secs(/*secs*/ 1);
assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage);
chat.suppress_image_paste_until = Instant::now() + Duration::from_secs(/*secs*/ 1);
chat.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage);
chat.suppress_image_paste_until = Instant::now() + Duration::from_secs(/*secs*/ 1);
chat.handle_key_event(KeyEvent {
kind: KeyEventKind::Release,
..paste
});
assert_matches!(chat.handle_key_event(paste), KeyEventAction::PasteImage);
}
#[tokio::test]
async fn copy_shortcut_submits_markdown_and_reports_completion_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;