From d55479488e125ef7a0a8584505d839a22eaf6204 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 1 May 2026 14:48:22 -0700 Subject: [PATCH] Clear live hook rows when turns finalize (#20674) # Why When a user interrupts a turn while a hook is still running, the normal turn status is cleared but the separate live hook row can remain visible as `Running` because the TUI may never receive a matching `HookCompleted` event before cancellation. Once the turn itself is finalized, that turn-scoped live state should not remain on screen. # What - clear any still-live `active_hook_cell` during turn finalization - add a regression snapshot covering an interrupted turn with a visible `PreToolUse` hook row # Testing - `cargo test -p codex-tui interrupted_turn_clears_visible_running_hook` - attempted `cargo test -p codex-tui` (currently aborts on unrelated existing stack overflow in `app::tests::discard_side_thread_removes_agent_navigation_entry`) --- codex-rs/tui/src/chatwidget.rs | 6 +++++ ...pted_turn_clears_visible_running_hook.snap | 8 ++++++ .../src/chatwidget/tests/status_and_layout.rs | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_clears_visible_running_hook.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c9621199e..e315f0d60 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2938,6 +2938,12 @@ impl ChatWidget { fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_cell_as_failed(); + // Turn-scoped hook rows are transient live state; once the turn is over, + // do not leave an orphaned running row behind if no matching completion + // event arrived before cancellation. + if self.active_hook_cell.take().is_some() { + self.bump_active_cell_revision(); + } // Reset running state and clear streaming buffers. self.user_turn_pending_start = false; self.agent_turn_running = false; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_clears_visible_running_hook.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_clears_visible_running_hook.snap new file mode 100644 index 000000000..22c70e335 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_clears_visible_running_hook.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "format!(\"before interrupt:\\n{before_interrupt}after interrupt:\\n{}\",\nactive_hook_blob(&chat))" +--- +before interrupt: +• Running PreToolUse hook: checking command policy +after interrupt: + diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index e91bb2885..73f0d3b7a 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1331,6 +1331,32 @@ async fn status_line_branch_refreshes_after_interrupt() { assert!(chat.status_line_branch_pending); } +#[tokio::test] +async fn interrupted_turn_clears_visible_running_hook() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + handle_hook_started( + &mut chat, + hook_started_run( + "pre-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PreToolUse, + Some("checking command policy"), + ), + ); + reveal_running_hooks(&mut chat); + let before_interrupt = active_hook_blob(&chat); + + handle_turn_interrupted(&mut chat, "turn-1"); + + assert_chatwidget_snapshot!( + "interrupted_turn_clears_visible_running_hook", + format!( + "before interrupt:\n{before_interrupt}after interrupt:\n{}", + active_hook_blob(&chat) + ) + ); +} + #[tokio::test] async fn status_line_fast_mode_renders_on_and_off() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;