Fix TUI cancel busy animations

Clear stale busy state and retry/title animations on local cancel.\n\nLocal verification:\n- cargo test -p codewhale-tui\n- codewhale doctor\n- codewhale --provider deepseek --model deepseek-v4-pro exec "Reply with exactly: OK"
This commit is contained in:
Hunter Bown
2026-05-31 14:06:52 -07:00
committed by GitHub
parent a5b363c354
commit f09b4ae72f
5 changed files with 68 additions and 22 deletions
+1
View File
@@ -38,6 +38,7 @@ impl EngineHandle {
Ok(token) => token.cancel(),
Err(poisoned) => poisoned.into_inner().cancel(),
}
crate::retry_status::clear();
}
/// Check if a request is currently cancelled
+1 -1
View File
@@ -16,7 +16,7 @@ pub(crate) enum EscapeAction {
pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction {
if slash_menu_open {
EscapeAction::CloseSlashMenu
} else if app.is_loading {
} else if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
EscapeAction::CancelRequest
} else if app.queued_draft.is_some() && app.input.is_empty() {
EscapeAction::DiscardQueuedDraft
+10
View File
@@ -334,6 +334,16 @@ pub fn stop_title_animation() {
play_completion_sound();
}
/// Stop the title animation without playing the completion sound.
///
/// Cancellation and failed turns should return the terminal title to rest
/// without presenting them as completed work.
pub fn stop_title_animation_quietly() {
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
set_terminal_title("CodeWhale");
}
/// Clear the ✅ completion marker from the title when the user interacts.
///
/// Call this on every user input event (key press, mouse click) so the
+34 -21
View File
@@ -1587,6 +1587,11 @@ async fn run_event_loop(
) {
let _ = engine_handle.send(Op::ListSubAgents).await;
}
crate::tui::notifications::clear_taskbar_progress();
if status != crate::core::events::TurnOutcomeStatus::Completed {
crate::retry_status::clear();
crate::tui::notifications::stop_title_animation_quietly();
}
let turn_tokens = usage.input_tokens + usage.output_tokens;
app.session.total_tokens =
app.session.total_tokens.saturating_add(turn_tokens);
@@ -1654,28 +1659,31 @@ async fn run_event_loop(
app.accrue_session_cost_estimate(cost);
}
// Emit OSC 9 / BEL desktop notification for long turns.
if status == crate::core::events::TurnOutcomeStatus::Completed
&& let Some((method, threshold, include_summary)) =
// Emit OSC 9 / BEL desktop notification for long turns, and
// always stop the title animation that began on TurnStarted.
if status == crate::core::events::TurnOutcomeStatus::Completed {
if let Some((method, threshold, include_summary)) =
notifications::settings(config)
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = notifications::completed_turn_message(
app,
&current_streaming_text,
include_summary,
turn_elapsed,
turn_cost,
);
crate::tui::notifications::notify_done(
method,
in_tmux,
&msg,
threshold,
turn_elapsed,
);
crate::tui::notifications::clear_taskbar_progress();
crate::tui::notifications::stop_title_animation();
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = notifications::completed_turn_message(
app,
&current_streaming_text,
include_summary,
turn_elapsed,
turn_cost,
);
crate::tui::notifications::notify_done(
method,
in_tmux,
&msg,
threshold,
turn_elapsed,
);
crate::tui::notifications::stop_title_animation();
} else {
crate::tui::notifications::stop_title_animation_quietly();
}
}
// Generate post-turn receipt for completed turns.
@@ -6885,11 +6893,16 @@ async fn apply_approval_decision(
fn mark_active_turn_cancelled_locally(app: &mut App) {
app.is_loading = false;
app.dispatch_started_at = None;
app.turn_started_at = None;
app.streaming_state.reset();
app.runtime_turn_id = None;
app.runtime_turn_status = None;
app.suppress_stream_events_until_turn_complete = true;
app.finalize_active_cell_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
crate::retry_status::clear();
crate::tui::notifications::clear_taskbar_progress();
crate::tui::notifications::stop_title_animation_quietly();
}
fn suppress_engine_event_after_local_cancel(event: &EngineEvent) -> bool {
+22
View File
@@ -3232,13 +3232,25 @@ fn test_ctrl_c_cancels_streaming_sets_status() {
#[test]
fn local_cancel_marks_late_stream_events_for_suppression() {
let _retry_guard = crate::retry_status::test_guard();
let mut app = create_test_app();
app.is_loading = true;
app.turn_started_at = Some(Instant::now());
app.runtime_turn_id = Some("turn_cancel_me".to_string());
app.runtime_turn_status = Some("in_progress".to_string());
app.streaming_state.start_text(0, None);
crate::retry_status::start(2, Duration::from_secs(3), "network error");
mark_active_turn_cancelled_locally(&mut app);
assert!(!app.is_loading);
assert!(app.turn_started_at.is_none());
assert!(app.runtime_turn_id.is_none());
assert!(app.runtime_turn_status.is_none());
assert!(matches!(
crate::retry_status::snapshot(),
crate::retry_status::RetryState::Idle
));
assert!(app.suppress_stream_events_until_turn_complete);
assert!(suppress_engine_event_after_local_cancel(
&EngineEvent::MessageDelta {
@@ -6123,6 +6135,16 @@ fn next_escape_action_cancels_when_loading_with_empty_input() {
assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
}
#[test]
fn next_escape_action_cancels_in_progress_runtime_even_if_loading_flag_was_cleared() {
let mut app = create_test_app();
app.is_loading = false;
app.runtime_turn_status = Some("in_progress".to_string());
app.input.clear();
assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
}
#[test]
fn next_escape_action_cancels_when_loading_with_input() {
let mut app = create_test_app();