Support Windows clipboard text paste shortcuts in the TUI (#53224)

## Why

`Ctrl+V` previously attempted only image paste, so a text-only clipboard produced an image-paste error. `Shift+Insert` did not use the clipboard paste path.

## What changed

- On Windows, let `Ctrl+V` and `Shift+Insert` paste images first, then read clipboard text asynchronously when no image is available. Keep clipboard access errors distinct from missing images.
- Allow keyboard paste into composer selections and active history or Vim search queries, including inline mode with right-click paste disabled.
- Flush buffered typing before explicit paste to preserve input order. Discard pending clipboard text when the thread or editable target changes, while allowing shortcut releases and repeated paste shortcuts to leave the read pending.

## Testing

Extend shortcut and clipboard-worker tests to cover text fallback, multiline Unicode text, stale-read cancellation, shortcut releases and repeats, and read-only drafts. Add assertions for history-search preview stability and selection changes.

GitOrigin-RevId: 080c1c05a4587e325cea938a6e28c646b9ac5837
This commit is contained in:
Eric Traut
2026-10-11 23:44:43 +00:00
committed by copyberry
parent 9f3d25588b
commit a9b0815167
18 changed files with 376 additions and 117 deletions
+28 -7
View File
@@ -3,6 +3,8 @@
use super::*;
use crate::chatwidget::KeyEventAction;
use crate::clipboard_copy::CopyFormat;
use crate::clipboard_copy::worker::PasteSource;
use crate::clipboard_paste::PasteImageError;
impl App {
pub(super) fn handle_clipboard_key_action(
@@ -18,15 +20,34 @@ impl App {
.copy(text, CopyFormat::Markdown, tui.frame_requester());
self.chat_widget.show_copy_result("last message", result);
}
KeyEventAction::PasteImage => {
KeyEventAction::PasteImage { text_fallback } => {
if text_fallback.is_some()
&& self
.right_click_paste_target(tui, PasteSource::Clipboard, text_fallback)
.is_none()
{
return;
}
// Finish paste before the next key, but never wait on the copy worker's lock.
if tui.clipboard.is_busy() {
self.chat_widget.add_info_message(
"Clipboard busy; try image paste after it finishes".into(),
/*hint*/ None,
);
if tui.clipboard.is_busy() || self.pending_right_click_paste.is_some() {
self.chat_widget
.add_info_message("Clipboard busy; try again".into(), /*hint*/ None);
} else {
self.chat_widget.paste_image();
match self.chat_widget.paste_image() {
Ok(()) => {}
Err(PasteImageError::NoImage(_)) if text_fallback.is_some() => {
self.start_clipboard_text_paste(
tui,
PasteSource::Clipboard,
text_fallback,
);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.chat_widget
.add_error_message(format!("Failed to paste image: {err}"));
}
}
}
}
}
+62 -34
View File
@@ -1,7 +1,8 @@
//! Right-click CLIPBOARD and local X11 middle-click PRIMARY fallback for an editable composer.
//! Clipboard text paste from Windows shortcuts, right-click, or X11 middle-click PRIMARY.
//! The policy also reports why a configured mode is unavailable in this terminal.
//! Existing selection handlers run first. Only a draw may deliver a read, so real input
//! invalidates pending paste before completion and is never replaced by clipboard text.
//! Releasing or pressing another paste shortcut does not cancel its pending paste.
use super::*;
use crate::clipboard_copy::worker::PasteSource;
@@ -13,9 +14,9 @@ use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
pub(super) struct PendingPaste {
thread: Option<ThreadId>,
draft: (String, usize),
target: (Option<ThreadId>, crate::bottom_pane::ClipboardPasteTarget),
source: PasteSource,
shortcut: Option<KeyCode>,
_request: Arc<()>,
}
@@ -76,28 +77,30 @@ impl PasteEnvironment {
}
impl App {
fn right_click_paste_target(
pub(super) fn right_click_paste_target(
&self,
tui: &tui::Tui,
source: PasteSource,
) -> Option<(Option<ThreadId>, (String, usize))> {
let allowed = match source {
PasteSource::Clipboard => {
!self.transcript_view.has_selection_range()
&& !self
.chat_widget
.rendered_selection
.borrow()
.view
.has_selection_range()
&& self.right_click_paste_environment.allows(
self.local_settings.tui.right_click_paste,
self.local_settings.transcript_mode,
)
}
PasteSource::Primary => self.right_click_paste_environment.primary,
};
if !tui.is_owned_screen()
shortcut: Option<KeyCode>,
) -> Option<(Option<ThreadId>, crate::bottom_pane::ClipboardPasteTarget)> {
let allowed = shortcut.is_some()
|| match source {
PasteSource::Clipboard => {
!self.transcript_view.has_selection_range()
&& !self
.chat_widget
.rendered_selection
.borrow()
.view
.has_selection_range()
&& self.right_click_paste_environment.allows(
self.local_settings.tui.right_click_paste,
self.local_settings.transcript_mode,
)
}
PasteSource::Primary => self.right_click_paste_environment.primary,
};
if (shortcut.is_none() && !tui.is_owned_screen())
|| self.overlay.is_some()
|| self.transcript_view.is_search_editing()
|| !allowed
@@ -106,7 +109,7 @@ impl App {
}
Some((
self.current_displayed_thread_id(),
self.chat_widget.right_click_paste_target()?,
self.chat_widget.clipboard_paste_target(shortcut)?,
))
}
@@ -119,15 +122,24 @@ impl App {
if !mouse.modifiers.is_empty() || self.pending_right_click_paste.is_some() {
return;
}
let Some((thread, draft)) = self.right_click_paste_target(tui, source) else {
self.start_clipboard_text_paste(tui, source, /*shortcut*/ None);
}
pub(crate) fn start_clipboard_text_paste(
&mut self,
tui: &mut tui::Tui,
source: PasteSource,
shortcut: Option<KeyCode>,
) {
let Some(target) = self.right_click_paste_target(tui, source, shortcut) else {
return;
};
match tui.clipboard.read_text(source, tui.frame_requester()) {
Ok(Some(request)) => {
self.pending_right_click_paste = Some(PendingPaste {
thread,
draft,
target,
source,
shortcut,
_request: request,
});
tui.frame_requester().schedule_frame();
@@ -150,10 +162,26 @@ impl App {
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained => true,
TuiEvent::Mouse(mouse) => {
mouse.kind == MouseEventKind::Moved
|| mouse.kind == MouseEventKind::Up(button)
|| (mouse.kind == MouseEventKind::Down(button) && mouse.modifiers.is_empty())
|| (pending.shortcut.is_none()
&& (mouse.kind == MouseEventKind::Up(button)
|| (mouse.kind == MouseEventKind::Down(button)
&& mouse.modifiers.is_empty())))
}
TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false,
TuiEvent::Key(key) => {
pending.shortcut.is_some()
&& match key.code {
KeyCode::Insert => {
key.kind == KeyEventKind::Release
|| key.modifiers == KeyModifiers::SHIFT
}
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'v') => {
key.kind == KeyEventKind::Release
|| key.modifiers == KeyModifiers::CONTROL
}
_ => false,
}
}
TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false,
};
if !keep {
self.pending_right_click_paste = None;
@@ -172,11 +200,11 @@ impl App {
tui.clipboard.take_text_result();
return event;
};
let Some((thread, draft)) = self.right_click_paste_target(tui, pending.source) else {
self.pending_right_click_paste = None;
return event;
};
if pending.thread != thread || pending.draft != draft {
if self
.right_click_paste_target(tui, pending.source, pending.shortcut)
.as_ref()
!= Some(&pending.target)
{
self.pending_right_click_paste = None;
return event;
}
@@ -84,9 +84,15 @@ async fn pending_primary_paste_survives_release_but_not_other_input() {
(MouseEventKind::ScrollDown, false),
] {
app.pending_right_click_paste = Some(PendingPaste {
thread: None,
draft: (String::new(), 0),
target: (
None,
crate::bottom_pane::ClipboardPasteTarget::Draft(
String::new(),
crate::bottom_pane::TextArea::new().edit_target(),
),
),
source: PasteSource::Primary,
shortcut: None,
_request: Arc::new(()),
});
app.invalidate_right_click_paste(&TuiEvent::Mouse(MouseEvent {
@@ -266,8 +266,10 @@
//! Expired bursts are flushed first so manual Tab keeps its normal shortcut behavior.
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
//! from UI ticks to integrate a pending burst via [`ChatComposer::apply_paste`] or insert a
//! held single character. Explicit terminal paste events use [`ChatComposer::handle_paste`] to
//! classify input first; buffered keys have already been classified.
//! held single character. Explicit paste events (terminal or clipboard) use
//! [`ChatComposer::handle_paste`], which first commits buffered typing before classifying the
//! pasted text. This preserves the order when a paste arrives before the next burst-flush tick;
//! buffered keys have already been classified and go directly through `apply_paste`.
//! - Startup recovery previews held keys with the live Vim Replace policy without consuming them.
//! Buffered pastes remain literal in recovery so large-paste and image classification stays with
//! the live paste path.
@@ -382,6 +384,7 @@ mod footer_state;
mod history_search;
mod inline_input;
mod mouse;
pub(crate) use mouse::ClipboardPasteTarget;
mod paste_input;
mod popup_state;
mod reconnect;
@@ -68,7 +68,7 @@ pub(super) struct HistorySearchSession {
/// Active and completed Vim commands suspended during temporary draft replacement.
original_vim_state: VimPersistentState,
/// Footer-owned query text typed or pasted while Ctrl+R search is active.
query: String,
pub(super) query: String,
/// User-visible search status used to choose footer hints and composer preview behavior.
status: HistorySearchStatus,
}
@@ -1,6 +1,7 @@
//! Pasted text edits the active history query while preserving the original draft.
use super::super::super::chat_composer_history::HistoryEntry;
use super::super::super::chat_composer_history::HistorySearchResult;
use super::super::ChatComposer;
use super::super::ComposerRenderOptions;
use super::super::InputResult;
@@ -49,7 +50,20 @@ fn history_search_paste_appends_query_and_accepts_match() {
assert_eq!(composer.draft.textarea.text(), "git log");
composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let paste_target = composer.clipboard_paste_target(Some(KeyCode::Insert));
// A background history lookup changes the preview, not the query being edited.
composer.apply_history_search_result(HistorySearchResult::Found(HistoryEntry::new(
"git diff".into(),
)));
assert_eq!(
composer.clipboard_paste_target(Some(KeyCode::Insert)),
paste_target
);
assert!(composer.handle_paste("status".to_string()));
assert_ne!(
composer.clipboard_paste_target(Some(KeyCode::Insert)),
paste_target
);
assert_eq!(
composer.history_search.as_ref().unwrap().query,
"git status"
@@ -11,13 +11,38 @@ use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
/// The input that will receive clipboard text, independent of a search preview.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ClipboardPasteTarget {
Draft(String, super::super::textarea::EditTarget),
HistorySearch(String),
VimSearch(String, super::super::textarea::EditTarget),
}
impl ChatComposer {
pub(crate) fn can_paste_on_right_click(&self) -> bool {
self.draft.input_enabled
pub(crate) fn clipboard_paste_target(
&self,
shortcut: Option<KeyCode>,
) -> Option<ClipboardPasteTarget> {
let allowed = self.draft.input_enabled
&& !self.blocks_direct_input
&& self.history_search.is_none()
&& self.draft.textarea.vim_query().is_none()
&& self.draft.textarea.mouse_selection_range().is_none()
&& (shortcut.is_some()
|| (self.history_search.is_none()
&& self.draft.textarea.vim_query().is_none()
&& self.draft.textarea.mouse_selection_range().is_none()));
if !allowed {
return None;
}
Some(if let Some(search) = &self.history_search {
ClipboardPasteTarget::HistorySearch(search.query.clone())
} else if let Some(search) = self.draft.textarea.vim_query() {
ClipboardPasteTarget::VimSearch(
search.editor.text().to_string(),
search.editor.edit_target(),
)
} else {
ClipboardPasteTarget::Draft(self.current_text(), self.draft.textarea.edit_target())
})
}
pub(in crate::bottom_pane) fn finish_copy(
@@ -203,9 +203,14 @@ fn right_click_copy_uses_the_refreshed_editor_bounds() {
assert_eq!(composer.draft.textarea.mouse_selection_range(), selection);
let key = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
composer.copy_selection(&key, |_| Ok(CopyStatus::Pending(2)));
let paste_target = composer.clipboard_paste_target(Some(KeyCode::Insert));
assert_eq!(
composer.finish_copy(&(2, Ok(CopyStatus::Confirmed)), /*current*/ true),
Some(5)
);
assert_eq!(composer.draft.textarea.mouse_selection_range(), None);
assert_ne!(
composer.clipboard_paste_target(Some(KeyCode::Insert)),
paste_target
);
}
@@ -68,10 +68,17 @@ impl ChatComposer {
/// Classify an explicit paste before integrating text shared with the buffered key path.
pub fn handle_paste(&mut self, pasted: String) -> bool {
self.flush_pending_paste_burst();
self.note_sparkle_paste(&pasted);
self.apply_paste(pasted)
}
pub(crate) fn flush_pending_paste_burst(&mut self) {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.apply_paste(pasted);
}
}
/// Enable or disable paste-burst handling.
///
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
@@ -94,9 +101,7 @@ impl ChatComposer {
let was_disabled = self.draft.disable_paste_burst;
self.draft.disable_paste_burst = disabled;
if disabled && !was_disabled {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.apply_paste(pasted);
}
self.flush_pending_paste_burst();
self.draft.paste_burst.clear_after_explicit_paste();
}
}
+15 -2
View File
@@ -257,6 +257,7 @@ pub(crate) enum CancellationEvent {
use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::ClipboardPasteTarget;
pub(crate) use chat_composer::ComposerDraftSnapshot;
pub(crate) use chat_composer::InputResult;
pub(crate) use chat_composer::QueuedInputAction;
@@ -1197,6 +1198,7 @@ impl BottomPane {
self.composer.current_text()
}
#[cfg(test)]
pub(crate) fn composer_cursor(&self) -> usize {
self.composer.current_cursor()
}
@@ -1875,8 +1877,14 @@ impl BottomPane {
self.composer.copy_selection(event, copy)
}
pub(crate) fn can_paste_on_right_click(&self) -> bool {
self.no_modal_or_popup_active() && self.composer.can_paste_on_right_click()
pub(crate) fn clipboard_paste_target(
&self,
shortcut: Option<KeyCode>,
) -> Option<ClipboardPasteTarget> {
if self.has_active_view() || (shortcut.is_none() && !self.no_modal_or_popup_active()) {
return None;
}
self.composer.clipboard_paste_target(shortcut)
}
pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
@@ -2192,6 +2200,11 @@ impl BottomPane {
flushed
}
/// Commit accepted typing before snapshotting the target of an asynchronous paste.
pub(crate) fn prepare_composer_clipboard_paste(&mut self) {
self.composer.flush_pending_paste_burst();
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.view_stack
.last()
@@ -7,7 +7,7 @@ use super::*;
/// The selection to replace, or an empty range at the insertion cursor.
/// Vim Replace without a selection computes its overwrite span as it inserts each grapheme.
#[derive(Debug)]
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct EditTarget {
range: Range<usize>,
}
+11 -18
View File
@@ -12,25 +12,18 @@ pub(super) enum PendingCopy {
}
impl ChatWidget {
pub(crate) fn paste_image(&mut self) {
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}",
)));
}
}
pub(crate) fn paste_image(&mut self) -> Result<(), crate::clipboard_paste::PasteImageError> {
let result = paste_image_to_temp_png();
self.suppress_image_paste_until = Instant::now() + IMAGE_PASTE_REPEAT_WINDOW;
let (path, info) = result?;
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
Ok(())
}
pub(super) fn show_clipboard_flash(&mut self, result: &CopyResult) {
+25 -17
View File
@@ -11,7 +11,7 @@ use crate::clipboard_copy::worker::CopyResult;
pub(crate) enum KeyEventAction {
None,
CopyLastResponse(Arc<str>),
PasteImage,
PasteImage { text_fallback: Option<KeyCode> },
}
impl ChatWidget {
@@ -35,14 +35,16 @@ impl ChatWidget {
self.bottom_pane.handle_composer_mouse(event)
}
/// Snapshot only the editable paste target, without changing pending submissions.
pub(crate) fn right_click_paste_target(&self) -> Option<(String, usize)> {
self.bottom_pane.can_paste_on_right_click().then(|| {
(
self.bottom_pane.composer_text(),
self.bottom_pane.composer_cursor(),
)
})
/// Snapshot the paste target without changing pending submissions. Unlike mouse paste,
/// a keyboard paste may replace a composer selection or complete an active popup.
pub(crate) fn clipboard_paste_target(
&self,
shortcut: Option<KeyCode>,
) -> Option<crate::bottom_pane::ClipboardPasteTarget> {
if self.blocks_direct_input {
return None;
}
self.bottom_pane.clipboard_paste_target(shortcut)
}
pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool {
@@ -177,13 +179,12 @@ impl ChatWidget {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
key if key.kind == KeyEventKind::Press
&& ((matches!(key.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'v'))
&& key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT))
|| (cfg!(windows) && key_hint::shift(KeyCode::Insert).is_press(key))) =>
{
if Instant::now() < self.suppress_image_paste_until {
self.suppress_image_paste_until =
@@ -192,7 +193,14 @@ impl ChatWidget {
}
self.suppress_image_paste_until =
Instant::now() + clipboard::IMAGE_PASTE_REPEAT_WINDOW;
return KeyEventAction::PasteImage;
let text_fallback = (cfg!(windows)
&& (key.modifiers == KeyModifiers::CONTROL
|| key_hint::shift(KeyCode::Insert).is_press(key)))
.then_some(key.code);
if text_fallback.is_some() {
self.bottom_pane.prepare_composer_clipboard_paste();
}
return KeyEventAction::PasteImage { text_fallback };
}
other if other.kind == KeyEventKind::Press => {
self.suppress_image_paste_until = Instant::now();
@@ -2527,17 +2527,47 @@ async fn slash_keymap_invalid_args_show_usage() {
#[tokio::test]
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);
for (code, modifiers) in [
(KeyCode::Char('v'), KeyModifiers::CONTROL),
(KeyCode::Char('V'), KeyModifiers::CONTROL),
(
KeyCode::Char('V'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
(KeyCode::Char('v'), KeyModifiers::ALT),
] {
let paste = KeyEvent::new(code, modifiers);
assert_matches!(
chat.handle_key_event(paste),
crate::chatwidget::KeyEventAction::PasteImage
crate::chatwidget::KeyEventAction::PasteImage { text_fallback }
if text_fallback == (cfg!(windows) && modifiers == KeyModifiers::CONTROL)
.then_some(code)
);
chat.handle_key_event(KeyEvent {
kind: KeyEventKind::Release,
..paste
});
}
// Crossterm maps VK_INSERT + SHIFT_PRESSED directly to Insert + Shift on Windows.
let shift_insert = KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT);
if cfg!(windows) {
chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false);
chat.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
assert_matches!(
chat.handle_key_event(shift_insert),
KeyEventAction::PasteImage {
text_fallback: Some(KeyCode::Insert)
}
);
assert_eq!(chat.bottom_pane.composer_text(), "q");
assert_matches!(chat.handle_key_event(shift_insert), KeyEventAction::None);
chat.handle_key_event(KeyEvent {
kind: KeyEventKind::Release,
..shift_insert
});
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
}
let mut keymap_config = chat.config_ref().tui_keymap.clone();
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("alt-v".to_string()),
@@ -2570,22 +2600,34 @@ 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::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);
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);
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);
assert_matches!(
chat.handle_key_event(paste),
KeyEventAction::PasteImage { .. }
);
}
#[tokio::test]
@@ -1,6 +1,6 @@
---
source: tui/src/clipboard_copy/worker_tests.rs
assertion_line: 112
assertion_line: 115
expression: "feedback.join(\"\\n\")"
---
• Clipboard busy; try image paste after it finishes
• Clipboard busy; try again
@@ -0,0 +1,9 @@
---
source: tui/src/clipboard_copy/worker_tests.rs
expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)"
---
› draft café
second line
GPT-5.6-Sol default · /tmp/project
+96 -13
View File
@@ -8,6 +8,7 @@ use crate::pager_overlay::Overlay;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use std::time::Duration;
@@ -317,20 +318,75 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() {
.await
.unwrap();
assert!(!tui.clipboard.is_busy());
tui.set_owned_screen(/*owned*/ true).unwrap();
for (change, expected) in [
("none", "draft café\nsecond line\n"),
("native", "draft native"),
("edit", "replacement"),
("disable", "draft "),
let ctrl_v = KeyCode::Char('v');
let shift_insert = KeyCode::Insert;
for (shortcut, change, expected) in [
(None, "none", "draft café\nsecond line\n"),
(None, "native", "draft native"),
(None, "edit", "replacement"),
(None, "disable", "draft "),
(Some(ctrl_v), "none", "draft café\nsecond line\n"),
(Some(shift_insert), "none", "draft café\nsecond line\n"),
(Some(ctrl_v), "native", "draft native"),
(Some(shift_insert), "edit", "replacement"),
(Some(ctrl_v), "click", "draft "),
// Last, since parent-owned threads no longer allow editing the draft.
(Some(shift_insert), "readonly", "draft "),
] {
app.local_settings.tui.right_click_paste = RightClickPaste::On;
// Shortcuts remain eligible inline, with mouse paste disabled.
tui.set_owned_screen(shortcut.is_none()).unwrap();
app.local_settings.tui.right_click_paste = if shortcut.is_some() {
RightClickPaste::Off
} else {
RightClickPaste::On
};
app.chat_widget.apply_external_edit("draft ".into());
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click))
// Cover a read still running, and a completed read awaiting its Draw.
let read_completed = shortcut == Some(ctrl_v) && change == "none";
if shortcut.is_some() {
// Enter at the NoImage fallback, independently of the host clipboard.
app.start_clipboard_text_paste(&mut tui, PasteSource::Clipboard, shortcut);
} else {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click))
.await
.unwrap();
}
start_rx.recv_timeout(SETUP_TIMEOUT).unwrap();
if let Some(key) = shortcut {
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Key(KeyEvent {
kind: KeyEventKind::Release,
..KeyEvent::from(key)
}),
)
.await
.unwrap();
start_rx.recv_timeout(SETUP_TIMEOUT).unwrap();
if change == "none" {
if read_completed {
// The read may complete on a key event, before Draw delivers the paste.
release.send(()).unwrap();
tokio::time::timeout(SETUP_TIMEOUT, async {
while tui.clipboard.is_busy() {
tui.clipboard.poll();
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
if change == "none" {
// Shift+Insert is a native paste shortcut only on Windows.
let other = if cfg!(windows) && key == ctrl_v {
KeyEvent::new(shift_insert, KeyModifiers::SHIFT)
} else {
KeyEvent::new(ctrl_v, KeyModifiers::CONTROL)
};
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(other))
.await
.unwrap();
}
} else if change == "none" {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click))
.await
.unwrap();
@@ -350,7 +406,8 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() {
)
.await
.unwrap();
} else if change == "native" {
}
if change == "native" {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Paste("native".into()))
.await
.unwrap();
@@ -358,8 +415,16 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() {
app.chat_widget.apply_external_edit("replacement".into());
} else if change == "disable" {
app.local_settings.tui.right_click_paste = RightClickPaste::Off;
} else if change == "click" {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click))
.await
.unwrap();
} else if change == "readonly" {
app.chat_widget.set_parent_owned_thread();
}
if !read_completed {
release.send(()).unwrap();
}
release.send(()).unwrap();
tokio::time::timeout(SETUP_TIMEOUT, async {
while tui.clipboard.is_busy() {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
@@ -370,7 +435,25 @@ async fn right_click_paste_uses_normal_input_and_discards_stale_reads() {
})
.await
.unwrap();
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
.await
.unwrap();
assert_eq!(
app.chat_widget.composer_text_with_pending(),
expected,
"{shortcut:?} {change}"
);
if shortcut.is_some() && change == "none" {
insta::assert_snapshot!(
"shortcut_clipboard_paste",
crate::chatwidget::tests::helpers::normalize_snapshot_paths(
crate::chatwidget::tests::helpers::render_bottom_popup(
&app.chat_widget,
/*width*/ 80
)
)
);
}
}
}
+7 -3
View File
@@ -77,9 +77,13 @@ pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageErro
img
} else {
let _span = tracing::debug_span!("get_image").entered();
let img = cb
.get_image()
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
let img = cb.get_image().map_err(|e| {
if cfg!(windows) && !matches!(e, arboard::Error::ContentNotAvailable) {
PasteImageError::ClipboardUnavailable(e.to_string())
} else {
PasteImageError::NoImage(e.to_string())
}
})?;
let w = img.width as u32;
let h = img.height as u32;
tracing::debug!("clipboard image opened from image: {}x{}", w, h);