fix(tui): restore cancelled prompt on ctrl-c

This commit is contained in:
Nightt
2026-05-18 16:29:18 +08:00
committed by Hunter Bown
parent ba8b4b7adf
commit 4a03f83997
3 changed files with 85 additions and 3 deletions
+51
View File
@@ -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<Instant>,
/// 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<String>,
/// 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());
+11 -3
View File
@@ -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<W: Write>(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<W: Write>(writer: &mut W) {
"PopKeyboardEnhancementFlags direct write failed on Windows"
);
}
return;
}
#[cfg(not(windows))]
let _ = execute!(writer, PopKeyboardEnhancementFlags);
+23
View File
@@ -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();