diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index d15a2e1500..0a3caeeaa2 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -386,6 +386,39 @@ impl McpConnectionManager { } } + /// Returns an owned future that waits only for the requested MCP servers. + /// + /// Constructing this future clones the selected clients, allowing callers to + /// release any lock guarding the manager before awaiting startup. + pub fn wait_for_servers_ready( + &self, + server_names: &[String], + ) -> impl std::future::Future> + Send + 'static { + let clients = server_names + .iter() + .map(|server_name| (server_name.clone(), self.clients.get(server_name).cloned())) + .collect::>(); + async move { + let mut failures = Vec::new(); + for (server_name, client) in clients { + let Some(client) = client else { + failures.push(McpStartupFailure { + server: server_name.clone(), + error: format!("MCP server `{server_name}` was not initialized"), + }); + continue; + }; + if let Err(error) = client.client().await { + failures.push(McpStartupFailure { + server: server_name, + error: startup_outcome_error_message(error), + }); + } + } + failures + } + } + pub async fn required_startup_failures( &self, required_servers: &[String], @@ -477,6 +510,37 @@ impl McpConnectionManager { server_infos } + /// Returns model-visible tools already available from startup cache or a ready client. + /// + /// Pending MCP servers without a cached snapshot are intentionally omitted so + /// ordinary model work does not wait on optional background initialization. + #[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))] + pub async fn list_ready_or_cached_tools(&self) -> Vec { + let mut tools = Vec::new(); + for (server_name, managed_client) in &self.clients { + let has_cached_tool_info_snapshot = + managed_client.cached_tool_info_snapshot.is_some(); + let startup_complete = managed_client + .startup_complete + .load(std::sync::atomic::Ordering::Acquire); + trace!( + server_name = %server_name, + has_cached_tool_info_snapshot, + startup_complete, + "checking ready MCP server tools while building nonblocking tool list" + ); + let Some(server_tools) = managed_client.listed_tools_if_ready_or_cached().await else { + continue; + }; + tools.extend( + server_tools + .into_iter() + .map(|tool| self.with_server_metadata(tool)), + ); + } + normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names) + } + /// Force-refresh codex apps tools by bypassing the in-process cache. /// /// On success, the refreshed tools replace the cache contents and the diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 60a0026eeb..9db3d11ef3 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -810,7 +810,7 @@ async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() client: pending_client, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, - startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(/*v*/ false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), }, @@ -980,6 +980,39 @@ async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_ assert!(timeout_result.is_err()); } +#[tokio::test] +async fn list_ready_or_cached_tools_skips_pending_client_without_cached_tool_info_snapshot() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + manager.clients.insert( + "optional".to_string(), + AsyncManagedClient { + client: pending_client, + cached_tool_info_snapshot: None, + cached_server_info: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(/*v*/ false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let tools = tokio::time::timeout( + Duration::from_millis(10), + manager.list_ready_or_cached_tools(), + ) + .await + .expect("pending optional client should not block ready tool listing"); + assert!(tools.is_empty()); +} + #[tokio::test] async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() { let pending_client = futures::future::pending::>() diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 78078720e7..461c1a3918 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -255,58 +255,57 @@ impl AsyncManagedClient { None } - pub(crate) async fn listed_tools(&self) -> Option> { - let annotate_tools = |tools: Vec| { - let mut tools = tools; - for tool in &mut tools { - if tool.server_name == CODEX_APPS_MCP_SERVER_NAME { - tool.tool = tool_with_model_visible_input_schema(&tool.tool); - } - - let plugin_names = match tool.connector_id.as_deref() { - Some(connector_id) => self - .tool_plugin_provenance - .plugin_display_names_for_connector_id(connector_id), - None => self - .tool_plugin_provenance - .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), - }; - tool.plugin_display_names = plugin_names.to_vec(); - - if plugin_names.is_empty() { - continue; - } - - let plugin_source_note = if plugin_names.len() == 1 { - format!("This tool is part of plugin `{}`.", plugin_names[0]) - } else { - format!( - "This tool is part of plugins {}.", - plugin_names - .iter() - .map(|plugin_name| format!("`{plugin_name}`")) - .collect::>() - .join(", ") - ) - }; - let description = tool - .tool - .description - .as_deref() - .map(str::trim) - .unwrap_or(""); - let annotated_description = if description.is_empty() { - plugin_source_note - } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { - format!("{description} {plugin_source_note}") - } else { - format!("{description}. {plugin_source_note}") - }; - tool.tool.description = Some(Cow::Owned(annotated_description)); + fn annotate_tools(&self, mut tools: Vec) -> Vec { + for tool in &mut tools { + if tool.server_name == CODEX_APPS_MCP_SERVER_NAME { + tool.tool = tool_with_model_visible_input_schema(&tool.tool); } - tools - }; + let plugin_names = match tool.connector_id.as_deref() { + Some(connector_id) => self + .tool_plugin_provenance + .plugin_display_names_for_connector_id(connector_id), + None => self + .tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), + }; + tool.plugin_display_names = plugin_names.to_vec(); + + if plugin_names.is_empty() { + continue; + } + + let plugin_source_note = if plugin_names.len() == 1 { + format!("This tool is part of plugin `{}`.", plugin_names[0]) + } else { + format!( + "This tool is part of plugins {}.", + plugin_names + .iter() + .map(|plugin_name| format!("`{plugin_name}`")) + .collect::>() + .join(", ") + ) + }; + let description = tool + .tool + .description + .as_deref() + .map(str::trim) + .unwrap_or(""); + let annotated_description = if description.is_empty() { + plugin_source_note + } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { + format!("{description} {plugin_source_note}") + } else { + format!("{description}. {plugin_source_note}") + }; + tool.tool.description = Some(Cow::Owned(annotated_description)); + } + tools + } + + pub(crate) async fn listed_tools(&self) -> Option> { // Keep cache payloads raw; plugin provenance is resolved per-session at read time. let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() { @@ -317,7 +316,17 @@ impl AsyncManagedClient { Err(_) => self.cached_tool_info_snapshot.clone(), } }; - tools.map(annotate_tools) + tools.map(|tools| self.annotate_tools(tools)) + } + + pub(crate) async fn listed_tools_if_ready_or_cached(&self) -> Option> { + if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() { + return Some(self.annotate_tools(startup_tools)); + } + if !self.startup_complete.load(Ordering::Acquire) { + return None; + } + self.listed_tools().await } } diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 30d297030a..00bd23ef3d 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -662,6 +662,11 @@ pub struct Tui { #[serde(default = "default_true")] pub show_tooltips: bool, + /// Show background MCP server startup status and optional failure diagnostics in the TUI. + /// Defaults to `false`. + #[serde(default)] + pub show_mcp_startup_status: bool, + /// Start the composer in Vim mode (`Normal`) by default. /// Defaults to `false`. #[serde(default)] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2d7fd818c9..79672e1f8f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2955,6 +2955,11 @@ "default": null, "description": "Preferred layout for resume/fork session picker results." }, + "show_mcp_startup_status": { + "default": false, + "description": "Show background MCP server startup status and optional failure diagnostics in the TUI. Defaults to `false`.", + "type": "boolean" + }, "show_tooltips": { "default": true, "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index cb72404ed7..0b348fe9fb 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -561,6 +561,7 @@ fn config_toml_deserializes_model_availability_nux() { notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, + show_mcp_startup_status: false, vim_mode_default: false, raw_output_mode: false, alternate_screen: AltScreenMode::default(), @@ -3209,6 +3210,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, + show_mcp_startup_status: false, vim_mode_default: false, raw_output_mode: false, alternate_screen: AltScreenMode::Auto, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 482159dfb3..934462628b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -672,6 +672,9 @@ pub struct Config { /// Show startup tooltips in the TUI welcome screen. pub show_tooltips: bool, + /// Show background MCP server startup status and optional failure diagnostics in the TUI. + pub tui_show_mcp_startup_status: bool, + /// Persisted startup availability NUX state for model tooltips. pub model_availability_nux: ModelAvailabilityNuxConfig, @@ -3498,6 +3501,11 @@ impl Config { .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), + tui_show_mcp_startup_status: cfg + .tui + .as_ref() + .map(|t| t.show_mcp_startup_status) + .unwrap_or(false), model_availability_nux: cfg .tui .as_ref() diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 55a941e8c4..a0b5d87ccf 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -101,7 +101,9 @@ pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( config: &Config, ) -> Vec { with_app_enabled_state( - accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await), + accessible_connectors_from_mcp_tools( + &mcp_connection_manager.list_ready_or_cached_tools().await, + ), config, ) .into_iter() diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f808134f99..2a044ec5e7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -72,6 +72,7 @@ use codex_async_utils::OrCancelExt; use codex_features::Feature; use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ServiceTier; @@ -446,7 +447,7 @@ async fn run_hooks_and_record_inputs( #[expect( clippy::await_holding_invalid_type, - reason = "MCP tool listing borrows the read guard across cancellation-aware await" + reason = "ready-or-cached tool inspection never awaits unresolved MCP startup" )] async fn build_skills_and_plugins( sess: &Arc, @@ -477,6 +478,40 @@ async fn build_skills_and_plugins( // enabled plugins, then converted into turn-scoped guidance below. let mentioned_plugins = collect_explicit_plugin_mentions(&user_input, loaded_plugins.capability_summaries()); + let explicitly_mentioned_apps = collect_explicit_app_ids(&user_input); + let mut explicitly_requested_mcp_servers = mentioned_plugins + .iter() + .flat_map(|plugin| plugin.mcp_server_names.iter().cloned()) + .collect::>(); + if turn_context.apps_enabled() && !explicitly_mentioned_apps.is_empty() { + explicitly_requested_mcp_servers.insert(CODEX_APPS_MCP_SERVER_NAME.to_string()); + } + if !explicitly_requested_mcp_servers.is_empty() { + let mut explicitly_requested_mcp_servers = explicitly_requested_mcp_servers + .into_iter() + .collect::>(); + explicitly_requested_mcp_servers.sort(); + let readiness = { + let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; + mcp_connection_manager.wait_for_servers_ready(&explicitly_requested_mcp_servers) + }; + let failures = match readiness.or_cancel(cancellation_token).await { + Ok(failures) => failures, + Err(_) => return None, + }; + for failure in failures { + sess.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: format!( + "MCP dependency `{}` requested for this turn is unavailable: {}", + failure.server, failure.error + ), + }), + ) + .await; + } + } let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { // Plugin mentions need raw MCP/app inventory even when app tools // are normally hidden so we can describe the plugin's currently @@ -486,7 +521,7 @@ async fn build_skills_and_plugins( .mcp_connection_manager .read() .await - .list_all_tools() + .list_ready_or_cached_tools() .or_cancel(cancellation_token) .await { @@ -556,7 +591,7 @@ async fn build_skills_and_plugins( ); let plugin_items = build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); - let mut explicitly_enabled_connectors = collect_explicit_app_ids(&user_input); + let mut explicitly_enabled_connectors = explicitly_mentioned_apps; explicitly_enabled_connectors.extend(skill_connector_ids); let connector_names_by_id = available_connectors .iter() @@ -1015,7 +1050,7 @@ async fn run_sampling_request( #[expect( clippy::await_holding_invalid_type, - reason = "tool router construction reads through the session-owned manager guard" + reason = "ready-or-cached tool inspection never awaits unresolved MCP startup" )] #[instrument(level = "trace", skip_all, @@ -1038,7 +1073,7 @@ pub(crate) async fn built_tools( .await; let has_mcp_servers = mcp_connection_manager.has_servers(); let all_mcp_tools = mcp_connection_manager - .list_all_tools() + .list_ready_or_cached_tools() .or_cancel(cancellation_token) .await?; drop(mcp_connection_manager); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c24d5dfcec..275b8ae7d2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1836,6 +1836,7 @@ impl ChatWidget { } pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) { + self.sync_mcp_startup_status_config(config.tui_show_mcp_startup_status); self.config.features = config.features.clone(); self.config.config_layer_stack = config.config_layer_stack.clone(); self.config.realtime = config.realtime.clone(); diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index 05d3def329..498d57a8b5 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -1,8 +1,8 @@ //! MCP startup state and status handling for the chat widget. //! //! The app server reports MCP server startup as per-server status updates. This -//! module keeps the TUI's buffered startup round state coherent and translates -//! those updates into status headers, warnings, and queued-input release points. +//! module keeps optional diagnostic presentation state coherent when background +//! startup status display is explicitly enabled. use std::collections::BTreeSet; @@ -176,6 +176,9 @@ impl ChatWidget { } pub(super) fn finish_mcp_startup(&mut self, failed: Vec, cancelled: Vec) { + if !self.config.tui_show_mcp_startup_status { + return; + } if !cancelled.is_empty() { self.on_warning(format!( "MCP startup interrupted. The following servers were not initialized: {}", @@ -205,6 +208,9 @@ impl ChatWidget { } pub(crate) fn finish_mcp_startup_after_lag(&mut self) { + if !self.config.tui_show_mcp_startup_status { + return; + } if self.mcp_startup_ignore_updates_until_next_start { if self.mcp_startup_pending_next_round.is_empty() { self.mcp_startup_pending_next_round_saw_starting = false; @@ -257,6 +263,9 @@ impl ChatWidget { &mut self, notification: McpServerStatusUpdatedNotification, ) { + if !self.config.tui_show_mcp_startup_status { + return; + } let status = match notification.status { McpServerStartupState::Starting => McpStartupStatus::Starting, McpServerStartupState::Ready => McpStartupStatus::Ready, @@ -273,4 +282,24 @@ impl ChatWidget { /*complete_when_settled*/ true, ); } + + pub(crate) fn sync_mcp_startup_status_config(&mut self, show_status: bool) { + let was_visible = self.config.tui_show_mcp_startup_status; + self.config.tui_show_mcp_startup_status = show_status; + if was_visible && !show_status { + let mcp_startup_owned_status = self.status_header_is_mcp_startup_owned(); + self.mcp_startup_status = None; + self.mcp_startup_expected_servers = None; + self.mcp_startup_ignore_updates_until_next_start = false; + self.mcp_startup_allow_terminal_only_next_round = false; + self.mcp_startup_pending_next_round.clear(); + self.mcp_startup_pending_next_round_saw_starting = false; + self.update_task_running_state(); + if mcp_startup_owned_status { + self.restore_reasoning_status_header(); + } + self.maybe_send_next_queued_input(); + self.request_redraw(); + } + } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_status_is_hidden_by_default.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_status_is_hidden_by_default.snap new file mode 100644 index 0000000000..2e00323c56 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_status_is_hidden_by_default.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/mcp_startup.rs +expression: normalized_backend_snapshot(terminal.backend()) +--- +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.5 default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 6a3bbbc07b..798d28f120 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -781,8 +781,8 @@ impl ChatWidget { /// Computes the compact runtime status label used by word-based status items. /// - /// Startup takes precedence over normal task states, and idle state renders - /// as `Ready` regardless of the last active status bucket. + /// Visible diagnostic startup takes precedence over normal task states, and + /// idle state renders as `Ready` regardless of the last active status bucket. pub(super) fn run_state_status_text(&self) -> String { if self.mcp_startup_status.is_some() { return "Starting".to_string(); diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index ba862a5293..6bdbfdc608 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -2,6 +2,7 @@ use super::*; use pretty_assertions::assert_eq; fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartupState) { + chat.config.tui_show_mcp_startup_status = true; chat.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { name: name.to_string(), @@ -13,6 +14,7 @@ fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartup } fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) { + chat.config.tui_show_mcp_startup_status = true; chat.handle_server_notification( ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { name: name.to_string(), @@ -23,6 +25,47 @@ fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) { ); } +#[tokio::test] +async fn mcp_startup_status_is_hidden_by_default_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string()]); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Failed, + error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), + }), + /*replay_kind*/ None, + ); + + assert!(chat.mcp_startup_status.is_none()); + assert!(!chat.bottom_pane.is_task_running()); + assert!(drain_insert_history(&mut rx).is_empty()); + + let height = chat.desired_height(/*width*/ 80); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 80, height, + )) + .expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw chat widget"); + assert_chatwidget_snapshot!( + "mcp_startup_status_is_hidden_by_default", + normalized_backend_snapshot(terminal.backend()) + ); +} + #[tokio::test] async fn mcp_startup_header_booting_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 4868d6a25c..b4830affc1 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -8,8 +8,8 @@ use super::*; impl ChatWidget { /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. /// - /// The bottom pane only has one running flag, but this module treats it as a derived state of - /// both the agent turn lifecycle and MCP startup lifecycle. + /// The bottom pane only has one running flag, but MCP startup enters this + /// visible lifecycle only when its diagnostic presentation is enabled. pub(super) fn update_task_running_state(&mut self) { self.bottom_pane.set_task_running( self.turn_lifecycle.agent_turn_running || self.mcp_startup_status.is_some(),