diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a7793205..51329eb1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -242,8 +242,7 @@ impl ChatComposer { let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { return false; }; - self.textarea.set_text(&text); - self.textarea.set_cursor(0); + self.set_text_content(text); true } @@ -316,9 +315,15 @@ impl ChatComposer { self.sync_file_search_popup(); } - pub(crate) fn clear_for_ctrl_c(&mut self) { + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { + if self.is_empty() { + return None; + } + let previous = self.current_text(); self.set_text_content(String::new()); self.history.reset_navigation(); + self.history.record_local_submission(&previous); + Some(previous) } /// Get the current composer text. @@ -896,8 +901,7 @@ impl ChatComposer { _ => unreachable!(), }; if let Some(text) = replace_text { - self.textarea.set_text(&text); - self.textarea.set_cursor(0); + self.set_text_content(text); return (InputResult::None, true); } } @@ -1828,6 +1832,28 @@ mod tests { assert!(!composer.esc_backtrack_hint); } + #[test] + fn clear_for_ctrl_c_records_cleared_draft() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_text_content("draft text".to_string()); + assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); + assert!(composer.is_empty()); + + assert_eq!( + composer.history.navigate_up(&composer.app_event_tx), + Some("draft text".to_string()) + ); + } + #[test] fn question_mark_only_toggles_on_first_char() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 9af5588b..d2956a34 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -692,6 +692,40 @@ fn ctrl_c_shutdown_ignores_caps_lock() { } } +#[test] +fn ctrl_c_cleared_prompt_is_recoverable_via_history() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + + chat.bottom_pane.insert_str("draft message "); + chat.bottom_pane + .attach_image(PathBuf::from("/tmp/preview.png"), 24, 42, "png"); + let placeholder = "[preview.png 24x42]"; + assert!( + chat.bottom_pane.composer_text().ends_with(placeholder), + "expected placeholder {placeholder:?} in composer text" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + let restored_text = chat.bottom_pane.composer_text(); + assert!( + restored_text.ends_with(placeholder), + "expected placeholder {placeholder:?} after history recall" + ); + assert!(restored_text.starts_with("draft message ")); + assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible()); + + let images = chat.bottom_pane.take_recent_submission_images(); + assert!( + images.is_empty(), + "attachments are not preserved in history recall" + ); +} + #[test] fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();