diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index 676f1986..1ed7e95d 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -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 diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index c33c5db7..db55168a 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -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 diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 47d08660..1cadf7d1 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -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 diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 69113a24..fa92ed22 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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 { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ca1ebebb..285155e7 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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();