diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index eb2d1ad9655..cc99aef7cfd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1070,6 +1070,11 @@ impl ChatWidget { if !from_replay && self.queued_user_messages.is_empty() { self.maybe_prompt_plan_implementation(); } + // Keep this flag for replayed completion events so a subsequent live TurnComplete can + // still show the prompt once after thread switch replay. + if !from_replay { + self.saw_plan_item_this_turn = false; + } // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); // Emit a notification when the turn completes (suppressed if focused). diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 980d15c3395..aa5273110fb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1260,6 +1260,61 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { ); } +#[tokio::test] +async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_complete() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.on_task_started(); + chat.on_plan_delta("- Step 1\n- Step 2\n".to_string()); + chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string()); + + chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: Some("Plan details".to_string()), + })]); + let replay_popup = render_bottom_popup(&chat, 80); + assert!( + !replay_popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected no prompt for replayed turn completion, got {replay_popup:?}" + ); + + chat.handle_codex_event(Event { + id: "live-turn-complete-1".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: Some("Plan details".to_string()), + }), + }); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected prompt for first live turn completion after replay, got {popup:?}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let dismissed_popup = render_bottom_popup(&chat, 80); + assert!( + !dismissed_popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected prompt to dismiss on Esc, got {dismissed_popup:?}" + ); + + chat.handle_codex_event(Event { + id: "live-turn-complete-2".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: Some("Plan details".to_string()), + }), + }); + let duplicate_popup = render_bottom_popup(&chat, 80); + assert!( + !duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected no prompt for duplicate live completion, got {duplicate_popup:?}" + ); +} + #[tokio::test] async fn plan_implementation_popup_skips_when_messages_queued() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;