fix(tui): restore cancelled prompt on ctrl-c
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user