Skip to content

Commit 3c711f3

Browse files
2mawi2joshka-oai
andauthored
Fix spinner/Esc interrupt when MCP startup completes mid-turn (#8661)
## **Problem** Codex’s TUI uses a single “task running” indicator (spinner + Esc interrupt hint) to communicate “the UI is busy”. In practice, “busy” can mean two different things: an agent turn is running, or MCP servers are still starting up. Without a clear contract, those lifecycles can interfere: startup completion can clear the spinner while a turn is still in progress, or the UI can appear idle while MCP is still booting. This is user-visible confusion during the most important moments (startup and the first turn), so it was worth making the contract explicit and guarding it. ## **Mental model** `ChatWidget` is the UI-side adapter for the `codex_core::protocol` event stream. It receives `EventMsg` events and updates two major UI surfaces: the transcript (history/streaming cells) and the bottom pane (composer + status indicator). The key concept after this change is that the bottom pane’s “task running” indicator is treated as **derived UI-busy state**, not “agent is running”. It is considered active while either: - an agent turn is in progress (`TurnStarted` → completion/abort), or - MCP startup is in progress (`McpStartupUpdate` → `McpStartupComplete`). Those lifecycles are tracked independently, and the bottom-pane indicator is defined as their union. ## **Non-goals** - This does not introduce separate UI indicators for “turn busy” vs “MCP busy”. - This does not change MCP startup behavior, ordering guarantees, or core protocol semantics. - This does not rework unrelated status/header rendering or transcript layout. ## **Tradeoffs** - The “one flag represents multiple lifecycles” approach remains lossy: it preserves correct “busy vs idle” semantics but cannot express *which* kind of busy is happening without further UI changes. - The design keeps complexity low by keeping a single derived boolean, rather than adding a more expressive bottom-pane state machine. That’s chosen because it matches existing UX and minimizes churn while fixing the confusion. ## **Architecture** - `codex-core` owns the actual lifecycles and emits `codex_core::protocol` events. - `ChatWidget` owns the UI interpretation of those lifecycles. It is responsible for keeping the bottom pane’s derived “busy” state consistent with the event stream, and for updating the status header when MCP progress updates arrive. - The bottom pane remains a dumb renderer of the single “task running” flag; it does not learn about MCP or agent turns directly. ## **Observability** - When working: the spinner/Esc hint stays visible during MCP startup and does not disappear mid-turn when `McpStartupComplete` arrives; startup status headers can update without clearing “busy” for an active turn. - When broken: you’ll see the spinner/hint flicker off while output is still streaming, or the UI appears idle while MCP startup status is still changing. ## **Tests** - Adds/strengthens a regression test that asserts MCP startup completion does not clear the “task running” indicator for an active turn (in both `tui` and `tui2` variants). - These tests prove the **contract** (“busy is the union of turn + startup”) at the UI boundary; they do not attempt to validate MCP startup ordering, real-world startup timing, or backend integration behavior. Fixes #7017 Signed-off-by: 2mawi2 <2mawi2@users.noreply.github.com> Co-authored-by: 2mawi2 <2mawi2@users.noreply.github.com> Co-authored-by: Josh McKinney <joshka@openai.com>
1 parent 141d2b5 commit 3c711f3

File tree

4 files changed

+158
-14
lines changed

4 files changed

+158
-14
lines changed

codex-rs/tui/src/chatwidget.rs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
//! cache key is designed to change when the active cell mutates in place or when its transcript
1515
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
1616
//! every draw.
17-
17+
//!
18+
//! The bottom pane exposes a single "task running" indicator that drives the spinner and interrupt
19+
//! hints. This module treats that indicator as derived UI-busy state: it is set while an agent turn
20+
//! is in progress and while MCP server startup is in progress. Those lifecycles are tracked
21+
//! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via
22+
//! `update_task_running_state`.
1823
use std::collections::HashMap;
1924
use std::collections::HashSet;
2025
use std::collections::VecDeque;
@@ -330,6 +335,12 @@ pub(crate) enum ExternalEditorState {
330335
Active,
331336
}
332337

338+
/// Maintains the per-session UI state for the chat screen.
339+
///
340+
/// This type owns the state derived from a `codex_core::protocol` event stream (history cells,
341+
/// active streaming buffers, bottom-pane overlays, and transient status text). It is not
342+
/// responsible for running the agent itself; it only reflects progress by updating UI state and by
343+
/// sending `Op` requests back to codex-core.
333344
pub(crate) struct ChatWidget {
334345
app_event_tx: AppEventSender,
335346
codex_op_tx: UnboundedSender<Op>,
@@ -364,6 +375,16 @@ pub(crate) struct ChatWidget {
364375
last_unified_wait: Option<UnifiedExecWaitState>,
365376
task_complete_pending: bool,
366377
unified_exec_processes: Vec<UnifiedExecProcessSummary>,
378+
/// Tracks whether codex-core currently considers an agent turn to be in progress.
379+
///
380+
/// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion)
381+
/// can update the status header without accidentally clearing the spinner for an active turn.
382+
agent_turn_running: bool,
383+
/// Tracks per-server MCP startup state while startup is in progress.
384+
///
385+
/// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the
386+
/// bottom pane is treated as "running" while this is populated, even if no agent turn is
387+
/// currently executing.
367388
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
368389
// Queue of interruptive UI events deferred during an active write cycle
369390
interrupts: InterruptManager,
@@ -457,6 +478,14 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
457478
}
458479

459480
impl ChatWidget {
481+
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
482+
///
483+
/// The bottom pane only has one running flag, but this module treats it as a derived state of
484+
/// both the agent turn lifecycle and MCP startup lifecycle.
485+
fn update_task_running_state(&mut self) {
486+
self.bottom_pane
487+
.set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some());
488+
}
460489
fn flush_answer_stream_with_separator(&mut self) {
461490
if let Some(mut controller) = self.stream_controller.take()
462491
&& let Some(cell) = controller.finalize()
@@ -613,8 +642,9 @@ impl ChatWidget {
613642
// Raw reasoning uses the same flow as summarized reasoning
614643

615644
fn on_task_started(&mut self) {
645+
self.agent_turn_running = true;
616646
self.bottom_pane.clear_ctrl_c_quit_hint();
617-
self.bottom_pane.set_task_running(true);
647+
self.update_task_running_state();
618648
self.retry_status_header = None;
619649
self.bottom_pane.set_interrupt_hint_visible(true);
620650
self.set_status_header(String::from("Working"));
@@ -628,7 +658,8 @@ impl ChatWidget {
628658
self.flush_answer_stream_with_separator();
629659
self.flush_wait_cell();
630660
// Mark task stopped and request redraw now that all content is in history.
631-
self.bottom_pane.set_task_running(false);
661+
self.agent_turn_running = false;
662+
self.update_task_running_state();
632663
self.running_commands.clear();
633664
self.suppressed_exec_calls.clear();
634665
self.last_unified_wait = None;
@@ -755,12 +786,16 @@ impl ChatWidget {
755786
self.rate_limit_snapshot = None;
756787
}
757788
}
758-
/// Finalize any active exec as failed and stop/clear running UI state.
789+
/// Finalize any active exec as failed and stop/clear agent-turn UI state.
790+
///
791+
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
792+
/// and should continue to drive the bottom-pane running indicator while it is in progress.
759793
fn finalize_turn(&mut self) {
760794
// Ensure any spinner is replaced by a red ✗ and flushed into history.
761795
self.finalize_active_cell_as_failed();
762796
// Reset running state and clear streaming buffers.
763-
self.bottom_pane.set_task_running(false);
797+
self.agent_turn_running = false;
798+
self.update_task_running_state();
764799
self.running_commands.clear();
765800
self.suppressed_exec_calls.clear();
766801
self.last_unified_wait = None;
@@ -789,7 +824,7 @@ impl ChatWidget {
789824
}
790825
status.insert(ev.server, ev.status);
791826
self.mcp_startup_status = Some(status);
792-
self.bottom_pane.set_task_running(true);
827+
self.update_task_running_state();
793828
if let Some(current) = &self.mcp_startup_status {
794829
let total = current.len();
795830
let mut starting: Vec<_> = current
@@ -845,7 +880,7 @@ impl ChatWidget {
845880
}
846881

847882
self.mcp_startup_status = None;
848-
self.bottom_pane.set_task_running(false);
883+
self.update_task_running_state();
849884
self.maybe_send_next_queued_input();
850885
self.request_redraw();
851886
}
@@ -1522,6 +1557,7 @@ impl ChatWidget {
15221557
last_unified_wait: None,
15231558
task_complete_pending: false,
15241559
unified_exec_processes: Vec::new(),
1560+
agent_turn_running: false,
15251561
mcp_startup_status: None,
15261562
interrupts: InterruptManager::new(),
15271563
reasoning_buffer: String::new(),
@@ -1612,6 +1648,7 @@ impl ChatWidget {
16121648
last_unified_wait: None,
16131649
task_complete_pending: false,
16141650
unified_exec_processes: Vec::new(),
1651+
agent_turn_running: false,
16151652
mcp_startup_status: None,
16161653
interrupts: InterruptManager::new(),
16171654
reasoning_buffer: String::new(),

codex-rs/tui/src/chatwidget/tests.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
//! Exercises `ChatWidget` event handling and rendering invariants.
2+
//!
3+
//! These tests treat the widget as the adapter between `codex_core::protocol::EventMsg` inputs and
4+
//! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header
5+
//! changes show up as stable, reviewable diffs.
6+
17
use super::*;
28
use crate::app_event::AppEvent;
39
use crate::app_event_sender::AppEventSender;
@@ -30,6 +36,7 @@ use codex_core::protocol::ExecCommandSource;
3036
use codex_core::protocol::ExecPolicyAmendment;
3137
use codex_core::protocol::ExitedReviewModeEvent;
3238
use codex_core::protocol::FileChange;
39+
use codex_core::protocol::McpStartupCompleteEvent;
3340
use codex_core::protocol::McpStartupStatus;
3441
use codex_core::protocol::McpStartupUpdateEvent;
3542
use codex_core::protocol::Op;
@@ -409,6 +416,7 @@ async fn make_chatwidget_manual(
409416
last_unified_wait: None,
410417
task_complete_pending: false,
411418
unified_exec_processes: Vec::new(),
419+
agent_turn_running: false,
412420
mcp_startup_status: None,
413421
interrupts: InterruptManager::new(),
414422
reasoning_buffer: String::new(),
@@ -2953,6 +2961,32 @@ async fn mcp_startup_header_booting_snapshot() {
29532961
assert_snapshot!("mcp_startup_header_booting", terminal.backend());
29542962
}
29552963

2964+
#[tokio::test]
2965+
async fn mcp_startup_complete_does_not_clear_running_task() {
2966+
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
2967+
2968+
chat.handle_codex_event(Event {
2969+
id: "task-1".into(),
2970+
msg: EventMsg::TurnStarted(TurnStartedEvent {
2971+
model_context_window: None,
2972+
}),
2973+
});
2974+
2975+
assert!(chat.bottom_pane.is_task_running());
2976+
assert!(chat.bottom_pane.status_indicator_visible());
2977+
2978+
chat.handle_codex_event(Event {
2979+
id: "mcp-1".into(),
2980+
msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent {
2981+
ready: vec!["schaltwerk".into()],
2982+
..Default::default()
2983+
}),
2984+
});
2985+
2986+
assert!(chat.bottom_pane.is_task_running());
2987+
assert!(chat.bottom_pane.status_indicator_visible());
2988+
}
2989+
29562990
#[tokio::test]
29572991
async fn background_event_updates_status_header() {
29582992
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;

codex-rs/tui2/src/chatwidget.rs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
//! cache key is designed to change when the active cell mutates in place or when its transcript
1515
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
1616
//! every draw.
17-
17+
//!
18+
//! The bottom pane exposes a single "task running" indicator that drives the spinner and interrupt
19+
//! hints. This module treats that indicator as derived UI-busy state: it is set while an agent turn
20+
//! is in progress and while MCP server startup is in progress. Those lifecycles are tracked
21+
//! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via
22+
//! `update_task_running_state`.
1823
use std::collections::HashMap;
1924
use std::collections::HashSet;
2025
use std::collections::VecDeque;
@@ -298,6 +303,12 @@ enum RateLimitSwitchPromptState {
298303
Shown,
299304
}
300305

306+
/// Maintains the per-session UI state for the chat screen.
307+
///
308+
/// This type owns the state derived from a `codex_core::protocol` event stream (history cells,
309+
/// active streaming buffers, bottom-pane overlays, and transient status text). It is not
310+
/// responsible for running the agent itself; it only reflects progress by updating UI state and by
311+
/// sending `Op` requests back to codex-core.
301312
pub(crate) struct ChatWidget {
302313
app_event_tx: AppEventSender,
303314
codex_op_tx: UnboundedSender<Op>,
@@ -331,6 +342,16 @@ pub(crate) struct ChatWidget {
331342
suppressed_exec_calls: HashSet<String>,
332343
last_unified_wait: Option<UnifiedExecWaitState>,
333344
task_complete_pending: bool,
345+
/// Tracks whether codex-core currently considers an agent turn to be in progress.
346+
///
347+
/// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion)
348+
/// can update the status header without accidentally clearing the spinner for an active turn.
349+
agent_turn_running: bool,
350+
/// Tracks per-server MCP startup state while startup is in progress.
351+
///
352+
/// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the
353+
/// bottom pane is treated as "running" while this is populated, even if no agent turn is
354+
/// currently executing.
334355
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
335356
// Queue of interruptive UI events deferred during an active write cycle
336357
interrupts: InterruptManager,
@@ -423,6 +444,14 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
423444
}
424445

425446
impl ChatWidget {
447+
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
448+
///
449+
/// The bottom pane only has one running flag, but this module treats it as a derived state of
450+
/// both the agent turn lifecycle and MCP startup lifecycle.
451+
fn update_task_running_state(&mut self) {
452+
self.bottom_pane
453+
.set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some());
454+
}
426455
fn flush_answer_stream_with_separator(&mut self) {
427456
if let Some(mut controller) = self.stream_controller.take()
428457
&& let Some(cell) = controller.finalize()
@@ -579,8 +608,9 @@ impl ChatWidget {
579608
// Raw reasoning uses the same flow as summarized reasoning
580609

581610
fn on_task_started(&mut self) {
611+
self.agent_turn_running = true;
582612
self.bottom_pane.clear_ctrl_c_quit_hint();
583-
self.bottom_pane.set_task_running(true);
613+
self.update_task_running_state();
584614
self.retry_status_header = None;
585615
self.bottom_pane.set_interrupt_hint_visible(true);
586616
self.set_status_header(String::from("Working"));
@@ -593,7 +623,8 @@ impl ChatWidget {
593623
// If a stream is currently active, finalize it.
594624
self.flush_answer_stream_with_separator();
595625
// Mark task stopped and request redraw now that all content is in history.
596-
self.bottom_pane.set_task_running(false);
626+
self.agent_turn_running = false;
627+
self.update_task_running_state();
597628
self.running_commands.clear();
598629
self.suppressed_exec_calls.clear();
599630
self.last_unified_wait = None;
@@ -720,12 +751,16 @@ impl ChatWidget {
720751
self.rate_limit_snapshot = None;
721752
}
722753
}
723-
/// Finalize any active exec as failed and stop/clear running UI state.
754+
/// Finalize any active exec as failed and stop/clear agent-turn UI state.
755+
///
756+
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
757+
/// and should continue to drive the bottom-pane running indicator while it is in progress.
724758
fn finalize_turn(&mut self) {
725759
// Ensure any spinner is replaced by a red ✗ and flushed into history.
726760
self.finalize_active_cell_as_failed();
727761
// Reset running state and clear streaming buffers.
728-
self.bottom_pane.set_task_running(false);
762+
self.agent_turn_running = false;
763+
self.update_task_running_state();
729764
self.running_commands.clear();
730765
self.suppressed_exec_calls.clear();
731766
self.last_unified_wait = None;
@@ -754,7 +789,7 @@ impl ChatWidget {
754789
}
755790
status.insert(ev.server, ev.status);
756791
self.mcp_startup_status = Some(status);
757-
self.bottom_pane.set_task_running(true);
792+
self.update_task_running_state();
758793
if let Some(current) = &self.mcp_startup_status {
759794
let total = current.len();
760795
let mut starting: Vec<_> = current
@@ -810,7 +845,7 @@ impl ChatWidget {
810845
}
811846

812847
self.mcp_startup_status = None;
813-
self.bottom_pane.set_task_running(false);
848+
self.update_task_running_state();
814849
self.maybe_send_next_queued_input();
815850
self.request_redraw();
816851
}
@@ -1381,6 +1416,7 @@ impl ChatWidget {
13811416
suppressed_exec_calls: HashSet::new(),
13821417
last_unified_wait: None,
13831418
task_complete_pending: false,
1419+
agent_turn_running: false,
13841420
mcp_startup_status: None,
13851421
interrupts: InterruptManager::new(),
13861422
reasoning_buffer: String::new(),
@@ -1469,6 +1505,7 @@ impl ChatWidget {
14691505
suppressed_exec_calls: HashSet::new(),
14701506
last_unified_wait: None,
14711507
task_complete_pending: false,
1508+
agent_turn_running: false,
14721509
mcp_startup_status: None,
14731510
interrupts: InterruptManager::new(),
14741511
reasoning_buffer: String::new(),

codex-rs/tui2/src/chatwidget/tests.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
//! Exercises `ChatWidget` event handling and rendering invariants.
2+
//!
3+
//! These tests treat the widget as the adapter between `codex_core::protocol::EventMsg` inputs and
4+
//! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header
5+
//! changes show up as stable, reviewable diffs.
6+
17
use super::*;
28
use crate::app_event::AppEvent;
39
use crate::app_event_sender::AppEventSender;
@@ -29,6 +35,7 @@ use codex_core::protocol::ExecCommandSource;
2935
use codex_core::protocol::ExecPolicyAmendment;
3036
use codex_core::protocol::ExitedReviewModeEvent;
3137
use codex_core::protocol::FileChange;
38+
use codex_core::protocol::McpStartupCompleteEvent;
3239
use codex_core::protocol::McpStartupStatus;
3340
use codex_core::protocol::McpStartupUpdateEvent;
3441
use codex_core::protocol::Op;
@@ -397,6 +404,7 @@ async fn make_chatwidget_manual(
397404
suppressed_exec_calls: HashSet::new(),
398405
last_unified_wait: None,
399406
task_complete_pending: false,
407+
agent_turn_running: false,
400408
mcp_startup_status: None,
401409
interrupts: InterruptManager::new(),
402410
reasoning_buffer: String::new(),
@@ -2520,6 +2528,34 @@ async fn mcp_startup_header_booting_snapshot() {
25202528
assert_snapshot!("mcp_startup_header_booting", terminal.backend());
25212529
}
25222530

2531+
#[tokio::test]
2532+
async fn mcp_startup_complete_does_not_clear_running_task() {
2533+
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
2534+
2535+
chat.handle_codex_event(Event {
2536+
id: "task-1".into(),
2537+
msg: EventMsg::TurnStarted(TurnStartedEvent {
2538+
model_context_window: None,
2539+
}),
2540+
});
2541+
2542+
// The bottom pane has a single "task running" indicator even though MCP startup and an agent
2543+
// turn are tracked independently, so a startup completion event must not clear an active turn.
2544+
assert!(chat.bottom_pane.is_task_running());
2545+
assert!(chat.bottom_pane.status_indicator_visible());
2546+
2547+
chat.handle_codex_event(Event {
2548+
id: "mcp-1".into(),
2549+
msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent {
2550+
ready: vec!["schaltwerk".into()],
2551+
..Default::default()
2552+
}),
2553+
});
2554+
2555+
assert!(chat.bottom_pane.is_task_running());
2556+
assert!(chat.bottom_pane.status_indicator_visible());
2557+
}
2558+
25232559
#[tokio::test]
25242560
async fn background_event_updates_status_header() {
25252561
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;

0 commit comments

Comments
 (0)