From 4a03f8399717e54979864d7c0a677ec7d6357b5f Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Mon, 18 May 2026 16:29:18 +0800 Subject: [PATCH] fix(tui): restore cancelled prompt on ctrl-c --- crates/tui/src/tui/app.rs | 51 ++++++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 14 ++++++++-- crates/tui/src/tui/ui/tests.rs | 23 +++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 1dfb76fa..6b0c342b 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1086,6 +1086,9 @@ pub struct App { pub coherence_state: CoherenceState, /// Timestamp of the last user message send (for brief visual feedback). pub last_send_at: Option, + /// Most recent user prompt accepted for an active engine turn. Ctrl+C can + /// restore this into an empty composer after cancelling that turn. + pub last_submitted_prompt: Option, /// Two-tap quit confirmation. When set, a prior Ctrl+C in idle state has /// armed the quit shortcut; a second Ctrl+C before this `Instant` exits /// the app, while expiry silently re-arms the prompt for next time. @@ -1621,6 +1624,7 @@ impl App { user_scrolled_during_stream: false, coherence_state: CoherenceState::default(), last_send_at: None, + last_submitted_prompt: None, quit_armed_until: None, cycle_count: 0, cycle_briefings: Vec::new(), @@ -3720,6 +3724,28 @@ impl App { Some(input) } + pub fn restore_last_submitted_prompt_if_empty(&mut self) -> bool { + if !self.input.is_empty() { + return false; + } + let Some(prompt) = self + .last_submitted_prompt + .as_ref() + .filter(|prompt| !prompt.is_empty()) + .cloned() + else { + return false; + }; + + self.input = prompt; + self.cursor_position = char_count(&self.input); + self.history_index = None; + self.history_navigation_draft = None; + self.selected_attachment_index = None; + self.needs_redraw = true; + true + } + /// Composer-Enter dispatch. Returns `Some(input)` when the press should /// fire a submit; `None` when Enter was absorbed (paste-burst Enter /// suppression — see #1073). @@ -4424,6 +4450,31 @@ mod tests { assert_eq!(app.input_history.last().map(String::as_str), Some(input)); } + #[test] + fn restore_last_submitted_prompt_rehydrates_empty_composer() { + let mut app = App::new(test_options(false), &Config::default()); + app.last_submitted_prompt = Some("fix the typo\nand retry".to_string()); + + assert!(app.restore_last_submitted_prompt_if_empty()); + + assert_eq!(app.input, "fix the typo\nand retry"); + assert_eq!(app.cursor_position, app.input.chars().count()); + assert!(app.needs_redraw); + } + + #[test] + fn restore_last_submitted_prompt_preserves_existing_draft() { + let mut app = App::new(test_options(false), &Config::default()); + app.last_submitted_prompt = Some("previous prompt".to_string()); + app.input = "new draft".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(!app.restore_last_submitted_prompt_if_empty()); + + assert_eq!(app.input, "new draft"); + assert_eq!(app.cursor_position, "new draft".chars().count()); + } + #[test] fn composer_strips_raw_sgr_mouse_report_when_mouse_capture_is_enabled() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c909e855..7d114af2 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2731,6 +2731,7 @@ async fn run_event_loop( app.is_loading = false; app.dispatch_started_at = None; app.streaming_state.reset(); + let prompt_restored = app.restore_last_submitted_prompt_if_empty(); // Optimistically clear the turn-in-progress flag // so the footer wave animation halts immediately — // without this, the strip keeps animating until @@ -2738,7 +2739,14 @@ async fn run_event_loop( // The engine's eventual TurnComplete event will // overwrite with the real outcome ("interrupted"). app.runtime_turn_status = None; - app.status_message = Some("Request cancelled".to_string()); + app.status_message = Some( + if prompt_restored { + "Request cancelled; prompt restored to composer" + } else { + "Request cancelled" + } + .to_string(), + ); app.disarm_quit(); } CtrlCDisposition::ConfirmExit => { @@ -3860,6 +3868,7 @@ async fn dispatch_user_message( app.dispatch_started_at = Some(dispatch_started_at); app.runtime_turn_status = None; app.last_send_at = Some(dispatch_started_at); + app.last_submitted_prompt = Some(message.display.clone()); let cwd = std::env::current_dir().ok(); let references = crate::tui::file_mention::context_references_from_input( @@ -5078,6 +5087,7 @@ async fn steer_user_message( let message_index = app.api_messages.len(); engine_handle.steer(content.clone()).await?; + app.last_submitted_prompt = Some(message.display.clone()); // Mirror steer input in local transcript/session state. app.add_message(HistoryCell::User { @@ -6427,7 +6437,6 @@ fn push_keyboard_enhancement_flags(writer: &mut W) { "PushKeyboardEnhancementFlags direct write failed on Windows" ); } - return; } #[cfg(not(windows))] if let Err(err) = execute!( @@ -6459,7 +6468,6 @@ pub(crate) fn pop_keyboard_enhancement_flags(writer: &mut W) { "PopKeyboardEnhancementFlags direct write failed on Windows" ); } - return; } #[cfg(not(windows))] let _ = execute!(writer, PopKeyboardEnhancementFlags); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index b94cbe34..125044fd 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2970,6 +2970,29 @@ async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() { ); } +#[tokio::test] +async fn dispatch_user_message_records_prompt_for_cancel_restore() { + let mut app = create_test_app(); + let config = Config::default(); + let mut engine = crate::core::engine::mock_engine_handle(); + let queued = crate::tui::app::QueuedMessage::new("fix this typo\nthen retry".to_string(), None); + + dispatch_user_message(&mut app, &config, &engine.handle, queued) + .await + .expect("dispatch user message"); + + assert_eq!( + app.last_submitted_prompt.as_deref(), + Some("fix this typo\nthen retry") + ); + match engine.rx_op.recv().await.expect("send message op") { + crate::core::ops::Op::SendMessage { content, .. } => { + assert_eq!(content, "fix this typo\nthen retry"); + } + other => panic!("expected SendMessage, got {other:?}"), + } +} + #[tokio::test] async fn numeric_plan_choice_still_queues_follow_up_when_busy() { let mut app = create_test_app();