Skip to content

Commit 8d0fdd5

Browse files
author
dolzhenko.e4
committed
tui: add undo-last-paste for composer drafts
1 parent a645053 commit 8d0fdd5

5 files changed

Lines changed: 273 additions & 10 deletions

File tree

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 225 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,33 @@ struct AttachedImage {
239239
path: PathBuf,
240240
}
241241

242+
#[derive(Clone, Debug, PartialEq)]
243+
struct ComposerDraftSnapshot {
244+
text: String,
245+
text_elements: Vec<TextElement>,
246+
local_image_paths: Vec<PathBuf>,
247+
remote_image_urls: Vec<String>,
248+
mention_bindings: Vec<MentionBinding>,
249+
pending_pastes: Vec<(String, String)>,
250+
cursor: usize,
251+
}
252+
253+
#[derive(Clone, Debug, PartialEq)]
254+
struct ComposerDraftFingerprint {
255+
text: String,
256+
text_elements: Vec<TextElement>,
257+
local_image_paths: Vec<PathBuf>,
258+
remote_image_urls: Vec<String>,
259+
mention_bindings: Vec<MentionBinding>,
260+
pending_pastes: Vec<(String, String)>,
261+
}
262+
263+
#[derive(Clone, Debug, PartialEq)]
264+
struct PasteUndoSnapshot {
265+
before: ComposerDraftSnapshot,
266+
after: ComposerDraftFingerprint,
267+
}
268+
242269
/// Feature flags for reusing the chat composer in other bottom-pane surfaces.
243270
///
244271
/// The default keeps today's behavior intact. Other call sites can opt out of
@@ -290,6 +317,7 @@ pub(crate) struct ChatComposer {
290317
dismissed_file_popup_token: Option<String>,
291318
current_file_query: Option<String>,
292319
pending_pastes: Vec<(String, String)>,
320+
last_paste_undo: Option<PasteUndoSnapshot>,
293321
large_paste_counters: HashMap<usize, usize>,
294322
has_focus: bool,
295323
frame_requester: Option<FrameRequester>,
@@ -420,6 +448,7 @@ impl ChatComposer {
420448
dismissed_file_popup_token: None,
421449
current_file_query: None,
422450
pending_pastes: Vec::new(),
451+
last_paste_undo: None,
423452
large_paste_counters: HashMap::new(),
424453
has_focus: has_input_focus,
425454
frame_requester: None,
@@ -683,6 +712,7 @@ impl ChatComposer {
683712
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
684713
/// the next user Enter key, then syncs popup state.
685714
pub fn handle_paste(&mut self, pasted: String) -> bool {
715+
let before = self.capture_full_draft_snapshot();
686716
let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
687717
let char_count = pasted.chars().count();
688718
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
@@ -699,6 +729,15 @@ impl ChatComposer {
699729
}
700730
self.paste_burst.clear_after_explicit_paste();
701731
self.sync_popups();
732+
self.record_paste_undo_snapshot(before);
733+
true
734+
}
735+
736+
pub(crate) fn handle_pasted_image(&mut self, path: PathBuf) -> bool {
737+
let before = self.capture_full_draft_snapshot();
738+
self.attach_image(path);
739+
self.sync_popups();
740+
self.record_paste_undo_snapshot(before);
702741
true
703742
}
704743

@@ -849,6 +888,94 @@ impl ChatComposer {
849888
.collect();
850889
}
851890

891+
fn capture_full_draft_snapshot(&self) -> ComposerDraftSnapshot {
892+
ComposerDraftSnapshot {
893+
text: self.textarea.text().to_string(),
894+
text_elements: self.textarea.text_elements(),
895+
local_image_paths: self
896+
.attached_images
897+
.iter()
898+
.map(|img| img.path.clone())
899+
.collect(),
900+
remote_image_urls: self.remote_image_urls.clone(),
901+
mention_bindings: self.snapshot_mention_bindings(),
902+
pending_pastes: self.pending_pastes.clone(),
903+
cursor: self.textarea.cursor(),
904+
}
905+
}
906+
907+
fn capture_draft_fingerprint(&self) -> ComposerDraftFingerprint {
908+
ComposerDraftFingerprint {
909+
text: self.textarea.text().to_string(),
910+
text_elements: self.textarea.text_elements(),
911+
local_image_paths: self
912+
.attached_images
913+
.iter()
914+
.map(|img| img.path.clone())
915+
.collect(),
916+
remote_image_urls: self.remote_image_urls.clone(),
917+
mention_bindings: self.snapshot_mention_bindings(),
918+
pending_pastes: self.pending_pastes.clone(),
919+
}
920+
}
921+
922+
fn restore_full_draft_snapshot(&mut self, snapshot: ComposerDraftSnapshot) {
923+
let ComposerDraftSnapshot {
924+
text,
925+
text_elements,
926+
local_image_paths,
927+
remote_image_urls,
928+
mention_bindings,
929+
pending_pastes,
930+
cursor,
931+
} = snapshot;
932+
self.set_remote_image_urls(remote_image_urls);
933+
self.set_text_content_with_mention_bindings(
934+
text,
935+
text_elements,
936+
local_image_paths,
937+
mention_bindings,
938+
);
939+
self.set_pending_pastes(pending_pastes);
940+
self.textarea.set_cursor(cursor);
941+
self.sync_popups();
942+
}
943+
944+
fn record_paste_undo_snapshot(&mut self, before: ComposerDraftSnapshot) {
945+
self.last_paste_undo = Some(PasteUndoSnapshot {
946+
before,
947+
after: self.capture_draft_fingerprint(),
948+
});
949+
}
950+
951+
fn handle_undo_last_paste(&mut self) -> bool {
952+
let Some(snapshot) = self.last_paste_undo.take() else {
953+
return false;
954+
};
955+
if self.capture_draft_fingerprint() != snapshot.after {
956+
return false;
957+
}
958+
self.restore_full_draft_snapshot(snapshot.before);
959+
true
960+
}
961+
962+
fn is_undo_last_paste_key(key_event: KeyEvent) -> bool {
963+
matches!(
964+
key_event,
965+
KeyEvent {
966+
code: KeyCode::Char('_' | '/'),
967+
modifiers: KeyModifiers::CONTROL,
968+
kind: KeyEventKind::Press | KeyEventKind::Repeat,
969+
..
970+
} | KeyEvent {
971+
code: KeyCode::Char('\u{001f}'),
972+
modifiers: KeyModifiers::NONE,
973+
kind: KeyEventKind::Press | KeyEventKind::Repeat,
974+
..
975+
}
976+
)
977+
}
978+
852979
/// Override the footer hint items displayed beneath the composer. Passing
853980
/// `None` restores the default shortcut footer.
854981
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
@@ -1203,6 +1330,10 @@ impl ChatComposer {
12031330
return (InputResult::None, false);
12041331
}
12051332

1333+
if Self::is_undo_last_paste_key(key_event) {
1334+
return (InputResult::None, self.handle_undo_last_paste());
1335+
}
1336+
12061337
let result = match &mut self.active_popup {
12071338
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
12081339
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
@@ -3797,6 +3928,18 @@ mod tests {
37973928
use crate::bottom_pane::textarea::TextArea;
37983929
use tokio::sync::mpsc::unbounded_channel;
37993930

3931+
fn test_composer() -> ChatComposer {
3932+
let (tx, _rx) = unbounded_channel::<AppEvent>();
3933+
let sender = AppEventSender::new(tx);
3934+
ChatComposer::new(
3935+
/*has_input_focus*/ true,
3936+
sender,
3937+
/*enhanced_keys_supported*/ false,
3938+
"Ask Codex to do anything".to_string(),
3939+
/*disable_paste_burst*/ false,
3940+
)
3941+
}
3942+
38003943
#[test]
38013944
fn footer_hint_row_is_separated_from_composer() {
38023945
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -7171,15 +7314,7 @@ mod tests {
71717314

71727315
#[test]
71737316
fn pasted_crlf_normalizes_newlines_for_elements() {
7174-
let (tx, _rx) = unbounded_channel::<AppEvent>();
7175-
let sender = AppEventSender::new(tx);
7176-
let mut composer = ChatComposer::new(
7177-
/*has_input_focus*/ true,
7178-
sender,
7179-
/*enhanced_keys_supported*/ false,
7180-
"Ask Codex to do anything".to_string(),
7181-
/*disable_paste_burst*/ false,
7182-
);
7317+
let mut composer = test_composer();
71837318

71847319
let pasted = "line1\r\nline2\r\n".to_string();
71857320
composer.handle_paste(pasted);
@@ -7212,6 +7347,87 @@ mod tests {
72127347
assert_eq!(vec![path], imgs);
72137348
}
72147349

7350+
#[test]
7351+
fn undo_last_paste_restores_text_and_cursor_after_navigation() {
7352+
let mut composer = test_composer();
7353+
composer.set_text_content("before".to_string(), Vec::new(), Vec::new());
7354+
composer.textarea.set_cursor(2);
7355+
7356+
composer.handle_paste("CLIP".to_string());
7357+
assert_eq!(composer.textarea.text(), "beCLIPfore");
7358+
7359+
composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
7360+
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
7361+
7362+
assert_eq!(composer.textarea.text(), "before");
7363+
assert_eq!(composer.textarea.cursor(), 2);
7364+
}
7365+
7366+
#[test]
7367+
fn undo_last_paste_restores_large_paste_placeholder_and_payloads() {
7368+
let mut composer = test_composer();
7369+
composer.set_text_content("prefix ".to_string(), Vec::new(), Vec::new());
7370+
let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
7371+
7372+
composer.handle_paste(large_content);
7373+
assert_eq!(composer.pending_pastes.len(), 1);
7374+
7375+
composer.handle_key_event(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::CONTROL));
7376+
7377+
assert_eq!(composer.textarea.text(), "prefix ");
7378+
assert!(composer.pending_pastes.is_empty());
7379+
}
7380+
7381+
#[test]
7382+
fn undo_last_paste_restores_shortcut_image_attach() {
7383+
let mut composer = test_composer();
7384+
composer.set_text_content("describe ".to_string(), Vec::new(), Vec::new());
7385+
composer.textarea.set_cursor(composer.textarea.text().len());
7386+
7387+
composer.handle_pasted_image(PathBuf::from("/tmp/from-shortcut.png"));
7388+
assert_eq!(composer.textarea.text(), "describe [Image #1]");
7389+
assert_eq!(composer.attached_images.len(), 1);
7390+
7391+
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
7392+
7393+
assert_eq!(composer.textarea.text(), "describe ");
7394+
assert!(composer.attached_images.is_empty());
7395+
}
7396+
7397+
#[test]
7398+
fn undo_last_paste_accepts_raw_control_char_fallback() {
7399+
let mut composer = test_composer();
7400+
composer.handle_paste("clipboard".to_string());
7401+
assert_eq!(composer.textarea.text(), "clipboard");
7402+
7403+
composer.handle_key_event(KeyEvent::new(KeyCode::Char('\u{001f}'), KeyModifiers::NONE));
7404+
7405+
assert_eq!(composer.textarea.text(), "");
7406+
}
7407+
7408+
#[test]
7409+
fn undo_last_paste_is_invalidated_by_content_edits() {
7410+
let mut composer = test_composer();
7411+
composer.handle_paste("clipboard".to_string());
7412+
composer.insert_str("!");
7413+
assert_eq!(composer.textarea.text(), "clipboard!");
7414+
7415+
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
7416+
7417+
assert_eq!(composer.textarea.text(), "clipboard!");
7418+
}
7419+
7420+
#[test]
7421+
fn undo_last_paste_is_one_shot() {
7422+
let mut composer = test_composer();
7423+
composer.handle_paste("clipboard".to_string());
7424+
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
7425+
assert_eq!(composer.textarea.text(), "");
7426+
7427+
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
7428+
assert_eq!(composer.textarea.text(), "");
7429+
}
7430+
72157431
#[test]
72167432
fn suppressed_submission_restores_pending_paste_payload() {
72177433
let (tx, _rx) = unbounded_channel::<AppEvent>();

codex-rs/tui/src/bottom_pane/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,15 @@ impl BottomPane {
501501
}
502502
}
503503

504+
pub(crate) fn handle_pasted_image(&mut self, path: PathBuf) {
505+
if self.view_stack.is_empty() {
506+
let needs_redraw = self.composer.handle_pasted_image(path);
507+
if needs_redraw {
508+
self.request_redraw();
509+
}
510+
}
511+
}
512+
504513
pub(crate) fn insert_str(&mut self, text: &str) {
505514
self.composer.insert_str(text);
506515
self.composer.sync_popups();
@@ -1091,6 +1100,7 @@ impl BottomPane {
10911100
self.request_redraw();
10921101
}
10931102

1103+
#[cfg_attr(not(test), allow(dead_code))]
10941104
pub(crate) fn attach_image(&mut self, path: PathBuf) {
10951105
if self.view_stack.is_empty() {
10961106
self.composer.attach_image(path);

codex-rs/tui/src/chatwidget.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4955,7 +4955,7 @@ impl ChatWidget {
49554955
info.height,
49564956
info.encoded_format.label()
49574957
);
4958-
self.attach_image(path);
4958+
self.bottom_pane.handle_pasted_image(path);
49594959
}
49604960
ShortcutPasteAction::Error(message) => {
49614961
warn!("{message}");
@@ -4970,6 +4970,7 @@ impl ChatWidget {
49704970
///
49714971
/// When the model does not advertise image support, we keep the draft unchanged and surface a
49724972
/// warning event so users can switch models or remove attachments.
4973+
#[allow(dead_code)]
49734974
pub(crate) fn attach_image(&mut self, path: PathBuf) {
49744975
if !self.current_model_supports_images() {
49754976
self.add_to_history(history_cell::new_warning_event(

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ async fn ctrl_v_can_attach_an_image_when_the_shortcut_policy_falls_back_to_image
5555
assert!(drain_insert_history(&mut rx).is_empty());
5656
}
5757

58+
#[tokio::test]
59+
async fn undo_last_paste_removes_an_image_inserted_via_ctrl_v_shortcut() {
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_image);
62+
63+
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
64+
chat.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
65+
66+
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
67+
assert_eq!(chat.bottom_pane.composer_text(), "");
68+
assert!(drain_insert_history(&mut rx).is_empty());
69+
}
70+
5871
#[tokio::test]
5972
async fn alt_v_uses_the_same_shortcut_paste_resolution_path() {
6073
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

docs/tui-chat-composer.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,29 @@ still available for `Ctrl+Y`. This supports flows where a user kills part of a d
118118
composer action such as changing reasoning level, and then yanks that text back into the cleared
119119
draft.
120120

121+
## Undo last paste
122+
123+
The composer supports a narrow “undo last paste” shortcut rather than a general edit-history stack.
124+
125+
- Primary shortcut: `Ctrl+_`
126+
- Terminal-friendly aliases: `Ctrl+/` and raw `^_` delivery
127+
128+
This feature is composer-level, not textarea-level, because paste can mutate more than raw text:
129+
130+
- normal pasted text,
131+
- large-paste placeholders plus `pending_pastes`,
132+
- local image attachments created from pasted image paths,
133+
- shortcut image pastes that attach an image directly.
134+
135+
Behavior:
136+
137+
- On each successful paste transaction, the composer snapshots the full pre-paste draft.
138+
- It also records a fingerprint of the post-paste draft content.
139+
- Undo only succeeds if the current draft content still matches that post-paste fingerprint.
140+
- Pure cursor movement does not block undo.
141+
- Any content mutation after the paste makes undo unavailable.
142+
- The feature is single-level only; there is no redo or multi-step undo chain.
143+
121144
## Remote image rows (selection/deletion flow)
122145

123146
Remote image URLs are shown as `[Image #N]` rows above the textarea, inside the same composer box.

0 commit comments

Comments
 (0)