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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
¤t_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,
|
||||
¤t_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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user