diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4af61b2c62..4fdd87e4ff 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -595,6 +595,8 @@ pub(crate) struct ChatWidget { pending_stream_consolidations: usize, /// Copy feedback is discarded with its originating conversation. pending_clipboard: Option, + /// 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, running_commands: HashMap, collab_agent_metadata: HashMap, diff --git a/codex-rs/tui/src/chatwidget/clipboard.rs b/codex-rs/tui/src/chatwidget/clipboard.rs index 03c3cfac11..ec512cdb44 100644 --- a/codex-rs/tui/src/chatwidget/clipboard.rs +++ b/codex-rs/tui/src/chatwidget/clipboard.rs @@ -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) { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index a7e19f0474..dc5adf7b73 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -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(), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 571ae8d32b..ddabe0e3e2 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -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(); + } _ => {} } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 3589c3df26..fbf476c9ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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;