Skip to content

Commit a645053

Browse files
author
dolzhenko.e4
committed
tui: try text clipboard before image paste on paste shortcut
1 parent 79768dd commit a645053

6 files changed

Lines changed: 281 additions & 29 deletions

File tree

codex-rs/tui/src/chatwidget.rs

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ use crate::bottom_pane::StatusLinePreviewData;
5252
use crate::bottom_pane::StatusLineSetupView;
5353
use crate::bottom_pane::TerminalTitleItem;
5454
use crate::bottom_pane::TerminalTitleSetupView;
55+
use crate::clipboard_shortcut::ShortcutPasteAction;
56+
use crate::clipboard_shortcut::ShortcutPasteRequest;
57+
use crate::clipboard_shortcut::resolve_shortcut_paste;
5558
use crate::mention_codec::LinkedMention;
5659
use crate::mention_codec::encode_history_mentions;
5760
use crate::model_catalog::ModelCatalog;
@@ -316,7 +319,6 @@ use crate::bottom_pane::SelectionItem;
316319
use crate::bottom_pane::SelectionViewParams;
317320
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
318321
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
319-
use crate::clipboard_paste::paste_image_to_temp_png;
320322
use crate::clipboard_text;
321323
use crate::collaboration_modes;
322324
use crate::diff_render::display_path_for;
@@ -955,6 +957,8 @@ pub(crate) struct ChatWidget {
955957
realtime_conversation: RealtimeConversationUiState,
956958
last_rendered_user_message_event: Option<RenderedUserMessageEvent>,
957959
last_non_retry_error: Option<(String, String)>,
960+
#[cfg(test)]
961+
shortcut_paste_handler: Option<fn(ShortcutPasteRequest) -> ShortcutPasteAction>,
958962
}
959963

960964
/// Cached nickname and role for a collab agent thread, used to attach human-readable labels to
@@ -4716,6 +4720,8 @@ impl ChatWidget {
47164720
realtime_conversation: RealtimeConversationUiState::default(),
47174721
last_rendered_user_message_event: None,
47184722
last_non_retry_error: None,
4723+
#[cfg(test)]
4724+
shortcut_paste_handler: None,
47194725
};
47204726

47214727
widget
@@ -4778,33 +4784,6 @@ impl ChatWidget {
47784784
self.quit_shortcut_expires_at = None;
47794785
self.quit_shortcut_key = None;
47804786
}
4781-
KeyEvent {
4782-
code: KeyCode::Char(c),
4783-
modifiers,
4784-
kind: KeyEventKind::Press,
4785-
..
4786-
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
4787-
&& c.eq_ignore_ascii_case(&'v') =>
4788-
{
4789-
match paste_image_to_temp_png() {
4790-
Ok((path, info)) => {
4791-
tracing::debug!(
4792-
"pasted image size={}x{} format={}",
4793-
info.width,
4794-
info.height,
4795-
info.encoded_format.label()
4796-
);
4797-
self.attach_image(path);
4798-
}
4799-
Err(err) => {
4800-
tracing::warn!("failed to paste image: {err}");
4801-
self.add_to_history(history_cell::new_error_event(format!(
4802-
"Failed to paste image: {err}",
4803-
)));
4804-
}
4805-
}
4806-
return;
4807-
}
48084787
other if other.kind == KeyEventKind::Press => {
48094788
self.bottom_pane.clear_quit_shortcut_hint();
48104789
self.quit_shortcut_expires_at = None;
@@ -4813,6 +4792,10 @@ impl ChatWidget {
48134792
_ => {}
48144793
}
48154794

4795+
if self.handle_shortcut_paste_key(key_event) {
4796+
return;
4797+
}
4798+
48164799
if key_event.kind == KeyEventKind::Press
48174800
&& self.queued_message_edit_binding.is_press(key_event)
48184801
&& self.has_queued_follow_up_messages()
@@ -4926,6 +4909,63 @@ impl ChatWidget {
49264909
}
49274910
}
49284911

4912+
fn handle_shortcut_paste_key(&mut self, key_event: KeyEvent) -> bool {
4913+
let request = match key_event {
4914+
KeyEvent {
4915+
code: KeyCode::Char(c),
4916+
modifiers,
4917+
kind: KeyEventKind::Press,
4918+
..
4919+
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
4920+
&& c.eq_ignore_ascii_case(&'v') =>
4921+
{
4922+
Some(if modifiers == KeyModifiers::ALT {
4923+
ShortcutPasteRequest::AltV
4924+
} else {
4925+
ShortcutPasteRequest::CtrlV
4926+
})
4927+
}
4928+
_ => None,
4929+
};
4930+
4931+
let Some(request) = request else {
4932+
return false;
4933+
};
4934+
4935+
let action = {
4936+
#[cfg(test)]
4937+
if let Some(handler) = self.shortcut_paste_handler {
4938+
handler(request)
4939+
} else {
4940+
resolve_shortcut_paste(request)
4941+
}
4942+
4943+
#[cfg(not(test))]
4944+
{
4945+
resolve_shortcut_paste(request)
4946+
}
4947+
};
4948+
4949+
match action {
4950+
ShortcutPasteAction::Text(text) => self.handle_paste(text),
4951+
ShortcutPasteAction::Image { path, info } => {
4952+
tracing::debug!(
4953+
"pasted image size={}x{} format={}",
4954+
info.width,
4955+
info.height,
4956+
info.encoded_format.label()
4957+
);
4958+
self.attach_image(path);
4959+
}
4960+
ShortcutPasteAction::Error(message) => {
4961+
warn!("{message}");
4962+
self.add_to_history(history_cell::new_error_event(message));
4963+
}
4964+
}
4965+
4966+
true
4967+
}
4968+
49294969
/// Attach a local image to the composer when the active model supports image inputs.
49304970
///
49314971
/// When the model does not advertise image support, we keep the draft unchanged and surface a
@@ -10337,6 +10377,14 @@ impl ChatWidget {
1033710377
self.bottom_pane.is_task_running()
1033810378
}
1033910379

10380+
#[cfg(test)]
10381+
pub(crate) fn set_shortcut_paste_handler_for_test(
10382+
&mut self,
10383+
handler: fn(ShortcutPasteRequest) -> ShortcutPasteAction,
10384+
) {
10385+
self.shortcut_paste_handler = Some(handler);
10386+
}
10387+
1034010388
pub(crate) fn submit_user_message_with_mode(
1034110389
&mut self,
1034210390
text: String,

codex-rs/tui/src/chatwidget/tests/composer_submission.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,86 @@
11
use super::*;
22
use pretty_assertions::assert_eq;
33

4+
fn shortcut_paste_text(
5+
_request: crate::clipboard_shortcut::ShortcutPasteRequest,
6+
) -> crate::clipboard_shortcut::ShortcutPasteAction {
7+
crate::clipboard_shortcut::ShortcutPasteAction::Text("hello from clipboard".to_string())
8+
}
9+
10+
fn shortcut_paste_image(
11+
_request: crate::clipboard_shortcut::ShortcutPasteRequest,
12+
) -> crate::clipboard_shortcut::ShortcutPasteAction {
13+
crate::clipboard_shortcut::ShortcutPasteAction::Image {
14+
path: PathBuf::from("/tmp/from-shortcut.png"),
15+
info: crate::clipboard_paste::PastedImageInfo {
16+
width: 120,
17+
height: 80,
18+
encoded_format: crate::clipboard_paste::EncodedImageFormat::Png,
19+
},
20+
}
21+
}
22+
23+
fn shortcut_paste_error(
24+
_request: crate::clipboard_shortcut::ShortcutPasteRequest,
25+
) -> crate::clipboard_shortcut::ShortcutPasteAction {
26+
crate::clipboard_shortcut::ShortcutPasteAction::Error(
27+
"Failed to paste clipboard contents: no text or image available on the clipboard."
28+
.to_string(),
29+
)
30+
}
31+
32+
#[tokio::test]
33+
async fn ctrl_v_pastes_text_into_the_composer() {
34+
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
35+
chat.set_shortcut_paste_handler_for_test(shortcut_paste_text);
36+
37+
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
38+
39+
assert_eq!(chat.bottom_pane.composer_text(), "hello from clipboard");
40+
assert!(drain_insert_history(&mut rx).is_empty());
41+
}
42+
43+
#[tokio::test]
44+
async fn ctrl_v_can_attach_an_image_when_the_shortcut_policy_falls_back_to_image() {
45+
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
46+
chat.set_shortcut_paste_handler_for_test(shortcut_paste_image);
47+
48+
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
49+
50+
assert_eq!(
51+
chat.bottom_pane.composer_local_image_paths(),
52+
vec![PathBuf::from("/tmp/from-shortcut.png")]
53+
);
54+
assert_eq!(chat.bottom_pane.composer_text(), "[Image #1]");
55+
assert!(drain_insert_history(&mut rx).is_empty());
56+
}
57+
58+
#[tokio::test]
59+
async fn alt_v_uses_the_same_shortcut_paste_resolution_path() {
60+
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
61+
chat.set_shortcut_paste_handler_for_test(shortcut_paste_text);
62+
63+
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
64+
65+
assert_eq!(chat.bottom_pane.composer_text(), "hello from clipboard");
66+
assert!(drain_insert_history(&mut rx).is_empty());
67+
}
68+
69+
#[tokio::test]
70+
async fn ctrl_v_reports_a_user_visible_error_when_clipboard_paste_fails() {
71+
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
72+
chat.set_shortcut_paste_handler_for_test(shortcut_paste_error);
73+
74+
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
75+
76+
let rendered = drain_insert_history(&mut rx)
77+
.into_iter()
78+
.map(|lines| lines_to_single_string(&lines))
79+
.collect::<Vec<_>>()
80+
.join("\n");
81+
assert!(rendered.contains("Failed to paste clipboard contents"));
82+
}
83+
484
#[tokio::test]
585
async fn submission_preserves_text_elements_and_local_images() {
686
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

codex-rs/tui/src/chatwidget/tests/helpers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ pub(super) async fn make_chatwidget_manual(
287287
realtime_conversation: RealtimeConversationUiState::default(),
288288
last_rendered_user_message_event: None,
289289
last_non_retry_error: None,
290+
shortcut_paste_handler: None,
290291
};
291292
widget.set_model(&resolved_model);
292293
(widget, rx, op_rx)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use std::path::PathBuf;
2+
3+
use crate::clipboard_paste::PasteImageError;
4+
use crate::clipboard_paste::PastedImageInfo;
5+
use crate::clipboard_paste::paste_image_to_temp_png;
6+
use crate::clipboard_text::ReadClipboardTextError;
7+
use crate::clipboard_text::read_text_from_clipboard;
8+
9+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10+
pub(crate) enum ShortcutPasteRequest {
11+
CtrlV,
12+
AltV,
13+
}
14+
15+
#[derive(Debug, Clone)]
16+
pub(crate) enum ShortcutPasteAction {
17+
Text(String),
18+
Image {
19+
path: PathBuf,
20+
info: PastedImageInfo,
21+
},
22+
Error(String),
23+
}
24+
25+
pub(crate) fn resolve_shortcut_paste(request: ShortcutPasteRequest) -> ShortcutPasteAction {
26+
let _ = request;
27+
28+
match read_text_from_clipboard() {
29+
Ok(Some(text)) => ShortcutPasteAction::Text(text),
30+
Ok(None) => match paste_image_to_temp_png() {
31+
Ok((path, info)) => ShortcutPasteAction::Image { path, info },
32+
Err(PasteImageError::NoImage(_)) => ShortcutPasteAction::Error(
33+
"Failed to paste clipboard contents: no text or image available on the clipboard."
34+
.to_string(),
35+
),
36+
Err(err) => {
37+
ShortcutPasteAction::Error(format!("Failed to paste clipboard image: {err}"))
38+
}
39+
},
40+
Err(ReadClipboardTextError::ClipboardUnavailable(text_err)) => {
41+
match paste_image_to_temp_png() {
42+
Ok((path, info)) => ShortcutPasteAction::Image { path, info },
43+
Err(PasteImageError::NoImage(_)) => ShortcutPasteAction::Error(format!(
44+
"Failed to paste clipboard text or image: clipboard text unavailable: {text_err}; no image on clipboard."
45+
)),
46+
Err(err) => ShortcutPasteAction::Error(format!(
47+
"Failed to paste clipboard text or image: clipboard text unavailable: {text_err}; image paste failed: {err}"
48+
)),
49+
}
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)