@@ -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 \n line2\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 > ( ) ;
0 commit comments