From 8fe0ecb045462528eabf0c94b25a41aa15e84fa7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 13 May 2026 08:52:56 -0700 Subject: [PATCH] Refactor chatwidget protocol flows into modules (phase 3) (#22433) ## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. #22269 started a five-phase cleanup to move coherent behavior domains into focused modules while keeping `chatwidget.rs` as the composition layer. #22407 completed phase 2 by extracting input and submission flow. This PR is phase 3. It keeps moving high-churn event handling out of the central widget by extracting protocol, replay, streaming, and tool lifecycle handling without changing the visible behavior those flows already provide. This is once again just a mechanical movement of existing functions. No functional changes. ## What Changed - Added focused modules for protocol request dispatch, replay rendering, assistant/plan/reasoning streaming, turn runtime bookkeeping, hook lifecycle handling, command lifecycle handling, tool lifecycle rendering, and interactive tool request prompts. - Kept active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior in the same flows, just moved into smaller files. - Added module header comments to the new files so the ownership boundaries are explicit. - Left `codex-rs/tui/src/chatwidget.rs` as the registration and orchestration surface for these extracted behaviors. ## Cleanup Phases The five-phase cleanup plan from #22269 is: 1. Phase 1: mechanical helper and state moves. Completed in #22269. 2. Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. Completed in #22407. 3. Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. This PR. 4. Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. 5. Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer. --- codex-rs/tui/src/chatwidget.rs | 2504 +---------------- .../tui/src/chatwidget/command_lifecycle.rs | 454 +++ codex-rs/tui/src/chatwidget/hook_lifecycle.rs | 132 + .../tui/src/chatwidget/protocol_requests.rs | 148 + codex-rs/tui/src/chatwidget/replay.rs | 196 ++ codex-rs/tui/src/chatwidget/streaming.rs | 459 +++ codex-rs/tui/src/chatwidget/tool_lifecycle.rs | 264 ++ codex-rs/tui/src/chatwidget/tool_requests.rs | 449 +++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 477 ++++ 9 files changed, 2587 insertions(+), 2496 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/command_lifecycle.rs create mode 100644 codex-rs/tui/src/chatwidget/hook_lifecycle.rs create mode 100644 codex-rs/tui/src/chatwidget/protocol_requests.rs create mode 100644 codex-rs/tui/src/chatwidget/replay.rs create mode 100644 codex-rs/tui/src/chatwidget/streaming.rs create mode 100644 codex-rs/tui/src/chatwidget/tool_lifecycle.rs create mode 100644 codex-rs/tui/src/chatwidget/tool_requests.rs create mode 100644 codex-rs/tui/src/chatwidget/turn_runtime.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2894e79c8..672357775 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -316,6 +316,7 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; +mod command_lifecycle; mod connectors; use self::connectors::ConnectorsCacheState; use self::connectors::ConnectorsState; @@ -348,6 +349,7 @@ use self::mcp_startup::McpStartupStatus; mod pets; mod session_header; use self::session_header::SessionHeader; +mod hook_lifecycle; mod hooks; mod skills; mod slash_dispatch; @@ -361,6 +363,7 @@ use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod protocol; +mod protocol_requests; mod rate_limits; use self::rate_limits::NUDGE_MODEL_SLUG; use self::rate_limits::RATE_LIMIT_SWITCH_PROMPT_THRESHOLD; @@ -371,6 +374,7 @@ use self::rate_limits::app_server_rate_limit_error_kind; pub(crate) use self::rate_limits::get_limits_duration; use self::rate_limits::is_app_server_cyber_policy_error; mod realtime; +mod replay; use self::realtime::RealtimeConversationUiState; mod reasoning_shortcuts; mod review; @@ -382,10 +386,14 @@ use self::status_state::StatusIndicatorState; use self::status_state::StatusState; use self::status_state::TerminalTitleStatusKind; mod status_surfaces; +mod streaming; use self::status_surfaces::CachedProjectRootName; +mod tool_lifecycle; +mod tool_requests; mod transcript; use self::transcript::TranscriptState; mod turn_lifecycle; +mod turn_runtime; use self::turn_lifecycle::TurnLifecycleState; mod user_messages; use self::user_messages::PendingSteer; @@ -888,112 +896,6 @@ impl ChatWidget { self.realtime_conversation_enabled() } - /// 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. - 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(), - ); - self.refresh_plan_mode_nudge(); - self.refresh_status_surfaces(); - } - - fn restore_reasoning_status_header(&mut self) { - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status_header(header); - } else if self.bottom_pane.is_task_running() { - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.set_status_header(String::from("Working")); - } - } - - fn flush_unified_exec_wait_streak(&mut self) { - let Some(wait) = self.unified_exec_wait_streak.take() else { - return; - }; - self.transcript.needs_final_message_separator = true; - let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); - self.restore_reasoning_status_header(); - } - - fn flush_answer_stream_with_separator(&mut self) { - let had_stream_controller = self.stream_controller.is_some(); - if let Some(mut controller) = self.stream_controller.take() { - let scrollback_reflow = if controller.has_live_tail() { - crate::app_event::ConsolidationScrollbackReflow::Required - } else { - crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan - }; - self.clear_active_stream_tail(); - let (cell, source) = controller.finalize(); - let deferred_history_cell = - if scrollback_reflow == crate::app_event::ConsolidationScrollbackReflow::Required { - cell - } else { - if let Some(cell) = cell { - self.add_boxed_history(cell); - } - None - }; - // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell - // that can re-render from source on resize. - if let Some(source) = source { - let source = parse_assistant_markdown(&source).visible_markdown; - self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { - source, - cwd: self.config.cwd.to_path_buf(), - scrollback_reflow, - deferred_history_cell, - }); - } - } - self.adaptive_chunking.reset(); - if had_stream_controller && self.stream_controllers_idle() { - self.app_event_tx.send(AppEvent::StopCommitAnimation); - } - } - - fn stream_controllers_idle(&self) -> bool { - self.stream_controller - .as_ref() - .map(|controller| controller.queued_lines() == 0) - .unwrap_or(true) - && self - .plan_stream_controller - .as_ref() - .map(|controller| controller.queued_lines() == 0) - .unwrap_or(true) - } - - /// Restore the status indicator only after commentary completion is pending, - /// the turn is still running, and all stream queues have drained. - /// - /// This gate prevents flicker while normal output is still actively - /// streaming, but still restores a visible "working" affordance when a - /// commentary block ends before the turn itself has completed. - fn maybe_restore_status_indicator_after_stream_idle(&mut self) { - if !self.status_state.pending_status_indicator_restore - || !self.bottom_pane.is_task_running() - || !self.stream_controllers_idle() - { - return; - } - - self.bottom_pane.ensure_status_indicator(); - self.set_status( - self.status_state.current_status.header.clone(), - self.status_state.current_status.details.clone(), - StatusDetailsCapitalization::Preserve, - self.status_state.current_status.details_max_lines, - ); - self.status_state.pending_status_indicator_restore = false; - } - /// Update the status indicator header and details. /// /// Passing `None` clears any existing details. @@ -1175,32 +1077,6 @@ impl ChatWidget { self.refresh_status_surfaces(); } - fn collect_runtime_metrics_delta(&mut self) { - if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { - self.apply_runtime_metrics_delta(delta); - } - } - - fn apply_runtime_metrics_delta(&mut self, delta: RuntimeMetricsSummary) { - let should_log_timing = has_websocket_timing_metrics(delta); - self.turn_runtime_metrics.merge(delta); - if should_log_timing { - self.log_websocket_timing_totals(delta); - } - } - - fn log_websocket_timing_totals(&mut self, delta: RuntimeMetricsSummary) { - if let Some(label) = history_cell::runtime_metrics_label(delta.responses_api_summary()) { - self.add_plain_history_lines(vec![ - vec!["• ".dim(), format!("WebSocket timing: {label}").dark_gray()].into(), - ]); - } - } - - fn refresh_runtime_metrics(&mut self) { - self.collect_runtime_metrics_delta(); - } - fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.status_state.take_retry_status_header() { self.set_status_header(header); @@ -1474,390 +1350,6 @@ impl ChatWidget { self.request_redraw(); } - fn finalize_completed_assistant_message(&mut self, message: Option<&str>) { - // If we have a stream_controller, the finalized message payload is redundant because the - // visible content has already been accumulated through deltas. - if self.stream_controller.is_none() - && let Some(message) = message - && !message.is_empty() - { - self.handle_streaming_delta(message.to_string()); - } - self.flush_answer_stream_with_separator(); - self.handle_stream_finished(); - self.request_redraw(); - } - - fn on_agent_message_delta(&mut self, delta: String) { - self.handle_streaming_delta(delta); - } - - fn on_plan_delta(&mut self, delta: String) { - if self.active_mode_kind() != ModeKind::Plan { - return; - } - if !self.transcript.plan_item_active { - self.transcript.plan_item_active = true; - self.transcript.plan_delta_buffer.clear(); - } - self.transcript.plan_delta_buffer.push_str(&delta); - if self.plan_stream_controller.is_none() { - // Before starting a plan stream, flush any active exec cell group. - self.flush_unified_exec_wait_streak(); - self.flush_active_cell(); - self.plan_stream_controller = Some(PlanStreamController::new( - self.current_stream_width(/*reserved_cols*/ 4), - &self.config.cwd, - self.history_render_mode(), - )); - } - if let Some(controller) = self.plan_stream_controller.as_mut() - && controller.push(&delta) - { - self.app_event_tx.send(AppEvent::StartCommitAnimation); - self.run_catch_up_commit_tick(); - } - self.sync_active_stream_tail(); - self.request_redraw(); - } - - fn on_plan_item_completed(&mut self, text: String) { - let streamed_plan = self.transcript.plan_delta_buffer.trim().to_string(); - let plan_text = if text.trim().is_empty() { - streamed_plan - } else { - text - }; - if !plan_text.trim().is_empty() { - self.record_agent_markdown(&plan_text); - self.transcript.latest_proposed_plan_markdown = Some(plan_text.clone()); - } - // Plan commit ticks can hide the status row; remember whether we streamed plan output so - // completion can restore it once stream queues are idle. - let should_restore_after_stream = self.plan_stream_controller.is_some(); - self.transcript.plan_delta_buffer.clear(); - self.transcript.plan_item_active = false; - self.transcript.saw_plan_item_this_turn = true; - let (finalized_streamed_cell, consolidated_plan_source) = - if let Some(mut controller) = self.plan_stream_controller.take() { - let had_live_tail = controller.has_live_tail(); - self.clear_active_stream_tail(); - let (cell, source) = controller.finalize(); - if had_live_tail { - (None, source) - } else { - (cell, source) - } - } else { - (None, None) - }; - if let Some(cell) = finalized_streamed_cell { - self.add_boxed_history(cell); - // TODO: Replace streamed output with the final plan item text if plan streaming is - // removed or if we need to reconcile mismatches between streamed and final content. - if let Some(source) = consolidated_plan_source { - self.app_event_tx - .send(AppEvent::ConsolidateProposedPlan(source)); - } - } else if !plan_text.is_empty() { - self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd)); - } else if let Some(source) = consolidated_plan_source { - self.app_event_tx - .send(AppEvent::ConsolidateProposedPlan(source)); - } - if should_restore_after_stream { - self.status_state.pending_status_indicator_restore = true; - self.maybe_restore_status_indicator_after_stream_idle(); - } - } - - fn on_agent_reasoning_delta(&mut self, delta: String) { - // For reasoning deltas, do not stream to history. Accumulate the - // current reasoning block and extract the first bold element - // (between **/**) as the chunk header. Show this header as status. - self.reasoning_buffer.push_str(&delta); - - if self.unified_exec_wait_streak.is_some() { - // Unified exec waiting should take precedence over reasoning-derived status headers. - self.request_redraw(); - return; - } - - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - // Update the shimmer header to the extracted reasoning chunk header. - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. - } - self.request_redraw(); - } - - fn on_agent_reasoning_final(&mut self) { - // At the end of a reasoning block, record transcript-only content. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - if !self.full_reasoning_buffer.is_empty() { - let cell = history_cell::new_reasoning_summary_block( - self.full_reasoning_buffer.clone(), - &self.config.cwd, - ); - self.add_boxed_history(cell); - } - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.request_redraw(); - } - - fn on_reasoning_section_break(&mut self) { - // Start a new reasoning block for header extraction and accumulate transcript. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - self.full_reasoning_buffer.push_str("\n\n"); - self.reasoning_buffer.clear(); - } - - // Raw reasoning uses the same flow as summarized reasoning - - fn on_task_started(&mut self) { - self.input_queue.user_turn_pending_start = false; - self.turn_lifecycle.start(Instant::now()); - self.transcript.reset_turn_flags(); - self.adaptive_chunking.reset(); - self.plan_stream_controller = None; - self.turn_runtime_metrics = RuntimeMetricsSummary::default(); - self.session_telemetry.reset_runtime_metrics(); - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.update_task_running_state(); - self.status_state.retry_status_header = None; - if self.active_hook_cell.take().is_some() { - self.bump_active_cell_revision(); - } - self.status_state.pending_status_indicator_restore = false; - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ true); - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.set_status_header(String::from("Working")); - self.full_reasoning_buffer.clear(); - self.reasoning_buffer.clear(); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Running, - /*body*/ None, - ); - self.request_redraw(); - } - - fn on_task_complete( - &mut self, - last_agent_message: Option, - duration_ms: Option, - from_replay: bool, - ) { - self.input_queue.submit_pending_steers_after_interrupt = false; - // Use `last_agent_message` from the turn-complete notification as the copy - // source only when no earlier item-level event (AgentMessageItem, plan - // commit, review output) already recorded markdown for this turn. This - // prevents the final summary from overwriting a more specific source. - let sanitized_last_agent_message = last_agent_message - .as_deref() - .map(|message| parse_assistant_markdown(message).visible_markdown); - if let Some(message) = sanitized_last_agent_message - .as_ref() - .filter(|message| !message.is_empty()) - && !self.transcript.saw_copy_source_this_turn - { - self.record_agent_markdown(message); - } - // For desktop notifications: prefer the notification payload, fall back to - // the item-level copy source if present, otherwise send an empty string. - let notification_response = sanitized_last_agent_message - .as_ref() - .filter(|message| !message.is_empty()) - .cloned() - .or_else(|| { - if self.transcript.saw_copy_source_this_turn { - self.transcript.last_agent_markdown.clone() - } else { - None - } - }) - .unwrap_or_default(); - self.transcript.saw_copy_source_this_turn = false; - // If a stream is currently active, finalize it. - self.flush_answer_stream_with_separator(); - if let Some(mut controller) = self.plan_stream_controller.take() { - let had_live_tail = controller.has_live_tail(); - self.clear_active_stream_tail(); - let (cell, source) = controller.finalize(); - if !had_live_tail && let Some(cell) = cell { - self.add_boxed_history(cell); - } - if let Some(source) = source { - self.app_event_tx - .send(AppEvent::ConsolidateProposedPlan(source)); - } - } - self.flush_unified_exec_wait_streak(); - if !from_replay { - self.collect_runtime_metrics_delta(); - let runtime_metrics = - (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); - let show_work_separator = self.transcript.had_work_activity - && (self.transcript.needs_final_message_separator || runtime_metrics.is_some()); - if show_work_separator || runtime_metrics.is_some() { - let elapsed_seconds = if show_work_separator { - duration_ms - .and_then(|duration_ms| u64::try_from(duration_ms).ok()) - .map(|duration_ms| duration_ms / 1_000) - .or_else(|| { - self.bottom_pane - .status_widget() - .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) - }) - } else { - None - }; - self.add_to_history(history_cell::FinalMessageSeparator::new( - elapsed_seconds, - runtime_metrics, - )); - } - self.turn_runtime_metrics = RuntimeMetricsSummary::default(); - self.transcript.needs_final_message_separator = false; - self.transcript.had_work_activity = false; - self.request_status_line_branch_refresh(); - self.request_status_line_git_summary_refresh(); - } - // Mark task stopped and request redraw now that all content is in history. - self.status_state.pending_status_indicator_restore = false; - self.input_queue.user_turn_pending_start = false; - self.turn_lifecycle.finish(); - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.unified_exec_wait_streak = None; - if !from_replay { - let body = Notification::agent_turn_preview(¬ification_response); - self.set_ambient_pet_notification(crate::pets::PetNotificationKind::Review, body); - } - self.request_redraw(); - - let had_pending_steers = !self.input_queue.pending_steers.is_empty(); - self.refresh_pending_input_preview(); - - if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { - 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.transcript.saw_plan_item_this_turn = false; - } - // If there is a queued user message, send exactly one now to begin the next turn. - let follow_up_started = self.maybe_send_next_queued_input(); - let active_goal_continuing = self - .current_goal_status - .as_ref() - .is_some_and(GoalStatusState::is_active); - // Emit a notification when the agent is truly waiting for the user. - // Queued follow-up input and active goal continuation both start the - // next turn immediately, so notifying at that boundary would feel like - // a false "needs attention". - if !follow_up_started && !active_goal_continuing { - self.notify(Notification::AgentTurnComplete { - response: notification_response, - }); - } - - self.maybe_show_pending_rate_limit_prompt(); - } - - fn maybe_prompt_plan_implementation(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - if self.has_queued_follow_up_messages() { - return; - } - if self.active_mode_kind() != ModeKind::Plan { - return; - } - if !self.transcript.saw_plan_item_this_turn { - return; - } - if !self.bottom_pane.no_modal_or_popup_active() { - return; - } - - if matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - ) { - return; - } - - self.open_plan_implementation_prompt(); - } - - fn open_plan_implementation_prompt(&mut self) { - let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); - let context_usage_label = self.plan_implementation_context_usage_label(); - - self.bottom_pane - .show_selection_view(plan_implementation::selection_view_params( - default_mask, - self.transcript.latest_proposed_plan_markdown.as_deref(), - context_usage_label.as_deref(), - )); - self.notify(Notification::PlanModePrompt { - title: PLAN_IMPLEMENTATION_TITLE.to_string(), - }); - } - - /// Returns a context-used label for the plan implementation prompt. - /// - /// The footer reports context remaining because it is ambient status, but - /// this prompt is asking whether to discard prior conversation state before - /// implementing a plan. Reporting used context makes the cleanup tradeoff - /// explicit. A fully fresh or unknown context window returns no label so - /// the clear-context option does not imply urgency without evidence. - fn plan_implementation_context_usage_label(&self) -> Option { - let info = self.token_info.as_ref()?; - let percent = self.context_remaining_percent(info); - - let used_tokens = self.context_used_tokens(info, percent.is_some()); - if let Some(percent) = percent { - let used_percent = 100 - percent.clamp(0, 100); - if used_percent <= 0 { - return None; - } - return Some(format!("{used_percent}% used")); - } - - if let Some(tokens) = used_tokens - && tokens > 0 - { - return Some(format!("{} used", format_tokens_compact(tokens))); - } - - None - } - - fn has_queued_follow_up_messages(&self) -> bool { - self.input_queue.has_queued_follow_up_messages() - } - - fn handle_app_server_steer_rejected_error( - &mut self, - codex_error_info: &AppServerCodexErrorInfo, - ) -> bool { - matches!( - codex_error_info, - AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } - ) && self.enqueue_rejected_steer() - } - pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { @@ -2095,799 +1587,6 @@ impl ChatWidget { } self.refresh_status_line(); } - /// Finalize any active exec as failed and stop/clear agent-turn UI state. - /// - /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup - /// and should continue to drive the bottom-pane running indicator while it is in progress. - fn finalize_turn(&mut self) { - // Drop preview-only stream tail content on any termination path before - // failed-cell finalization, so transient tail cells are never persisted. - self.clear_active_stream_tail(); - // 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.input_queue.user_turn_pending_start = false; - self.turn_lifecycle.finish(); - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.unified_exec_wait_streak = None; - self.adaptive_chunking.reset(); - self.stream_controller = None; - self.plan_stream_controller = None; - self.status_state.pending_status_indicator_restore = false; - self.request_status_line_branch_refresh(); - self.request_status_line_git_summary_refresh(); - self.maybe_show_pending_rate_limit_prompt(); - } - - fn on_server_overloaded_error(&mut self, message: String) { - self.input_queue.submit_pending_steers_after_interrupt = false; - self.finalize_turn(); - - let message = if message.trim().is_empty() { - "Codex is currently experiencing high load.".to_string() - } else { - message - }; - - self.add_to_history(history_cell::new_warning_event(message)); - self.request_redraw(); - self.maybe_send_next_queued_input(); - } - - fn on_error(&mut self, message: String) { - self.input_queue.submit_pending_steers_after_interrupt = false; - self.finalize_turn(); - self.add_to_history(history_cell::new_error_event(message)); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Failed, - /*body*/ None, - ); - self.request_redraw(); - - // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); - } - - fn on_cyber_policy_error(&mut self) { - self.input_queue.submit_pending_steers_after_interrupt = false; - self.finalize_turn(); - self.add_to_history(history_cell::new_cyber_policy_error_event()); - self.request_redraw(); - - // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); - } - - fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { - let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { - if matches!(error_kind, RateLimitErrorKind::UsageLimit) { - match kind { - RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - RateLimitReachedType::WorkspaceOwnerUsageLimitReached - } - RateLimitReachedType::WorkspaceMemberCreditsDepleted => { - RateLimitReachedType::WorkspaceMemberUsageLimitReached - } - other => other, - } - } else { - kind - } - }); - self.codex_rate_limit_reached_type = rate_limit_reached_type; - - match rate_limit_reached_type { - Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { - self.on_error( - "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." - .to_string(), - ); - } - Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => { - self.on_error( - "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex." - .to_string(), - ); - } - Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { - self.on_error(message); - self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); - } - Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { - self.on_error(message); - self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); - } - Some(RateLimitReachedType::RateLimitReached) | None => { - self.on_error(message); - } - } - } - - fn handle_non_retry_error( - &mut self, - message: String, - codex_error_info: Option, - ) { - if codex_error_info - .as_ref() - .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) - { - } else if codex_error_info - .as_ref() - .is_some_and(is_app_server_cyber_policy_error) - { - self.on_cyber_policy_error(); - } else if let Some(info) = codex_error_info - .as_ref() - .and_then(app_server_rate_limit_error_kind) - { - match info { - RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), - RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_rate_limit_error(info, message) - } - } - } else { - self.on_error(message); - } - } - - fn on_warning(&mut self, message: impl Into) { - let message = message.into(); - if !self.warning_display_state.should_display(&message) { - return; - } - self.add_to_history(history_cell::new_warning_event(message)); - self.request_redraw(); - } - - fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) { - if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) { - self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); - } - } - - fn on_plan_update(&mut self, update: UpdatePlanArgs) { - self.transcript.saw_plan_update_this_turn = true; - let total = update.plan.len(); - let completed = update - .plan - .iter() - .filter(|item| match &item.status { - StepStatus::Completed => true, - StepStatus::Pending | StepStatus::InProgress => false, - }) - .count(); - self.transcript.last_plan_progress = (total > 0).then_some((completed, total)); - self.refresh_status_surfaces(); - self.add_to_history(history_cell::new_plan_update(update)); - } - - fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_exec_approval(ev), - |s| s.handle_exec_approval_now(ev2), - ); - } - - fn on_apply_patch_approval_request(&mut self, _id: String, ev: ApplyPatchApprovalRequestEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_apply_patch_approval(ev), - |s| s.handle_apply_patch_approval_now(ev2), - ); - } - - /// Handle guardian review lifecycle events for the current thread. - /// - /// In-progress assessments temporarily own the live status footer so the - /// user can see what is being reviewed, including parallel review - /// aggregation. Terminal assessments clear or update that footer state and - /// render the final approved/denied history cell when guardian returns a - /// decision. - fn on_guardian_assessment(&mut self, ev: GuardianAssessmentEvent) { - let permission_request_summary = |subject: &str, reason: &Option| { - reason - .as_deref() - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .map(|reason| format!("{subject}: {reason}")) - .unwrap_or_else(|| subject.to_string()) - }; - let guardian_action_summary = |action: &GuardianAssessmentAction| match action { - GuardianAssessmentAction::Command { command, .. } => Some(command.clone()), - GuardianAssessmentAction::Execve { program, argv, .. } => { - let command = if argv.is_empty() { - vec![program.clone()] - } else { - argv.clone() - }; - shlex::try_join(command.iter().map(String::as_str)) - .ok() - .or_else(|| Some(command.join(" "))) - } - GuardianAssessmentAction::ApplyPatch { files, .. } => Some(if files.len() == 1 { - format!("apply_patch touching {}", files[0].display()) - } else { - format!("apply_patch touching {} files", files.len()) - }), - GuardianAssessmentAction::NetworkAccess { target, .. } => { - Some(format!("network access to {target}")) - } - GuardianAssessmentAction::McpToolCall { - server, - tool_name, - connector_name, - .. - } => { - let label = connector_name.as_deref().unwrap_or(server.as_str()); - Some(format!("MCP {tool_name} on {label}")) - } - GuardianAssessmentAction::RequestPermissions { reason, .. } => { - Some(permission_request_summary("permission request", reason)) - } - }; - let guardian_command = |action: &GuardianAssessmentAction| match action { - GuardianAssessmentAction::Command { command, .. } => shlex::split(command) - .filter(|command| !command.is_empty()) - .or_else(|| Some(vec![command.clone()])), - GuardianAssessmentAction::Execve { program, argv, .. } => Some(if argv.is_empty() { - vec![program.clone()] - } else { - argv.clone() - }) - .filter(|command| !command.is_empty()), - GuardianAssessmentAction::ApplyPatch { .. } - | GuardianAssessmentAction::NetworkAccess { .. } - | GuardianAssessmentAction::McpToolCall { .. } - | GuardianAssessmentAction::RequestPermissions { .. } => None, - }; - - if ev.status == GuardianAssessmentStatus::InProgress - && let Some(detail) = guardian_action_summary(&ev.action) - { - // In-progress assessments own the live footer state while the - // review is pending. Parallel reviews are aggregated into one - // footer summary by `PendingGuardianReviewStatus`. - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ true); - self.status_state - .pending_guardian_review_status - .start_or_update(ev.id.clone(), detail); - if let Some(status) = self - .status_state - .pending_guardian_review_status - .status_indicator_state() - { - self.set_status( - status.header, - status.details, - StatusDetailsCapitalization::Preserve, - status.details_max_lines, - ); - } - self.request_redraw(); - return; - } - - // Terminal assessments remove the matching pending footer entry first, - // then render the final approved/denied history cell below. - if self - .status_state - .pending_guardian_review_status - .finish(&ev.id) - { - if let Some(status) = self - .status_state - .pending_guardian_review_status - .status_indicator_state() - { - self.set_status( - status.header, - status.details, - StatusDetailsCapitalization::Preserve, - status.details_max_lines, - ); - } else if self.status_state.current_status.is_guardian_review() { - self.set_status_header(String::from("Working")); - } - } else if self.status_state.pending_guardian_review_status.is_empty() - && self.status_state.current_status.is_guardian_review() - { - self.set_status_header(String::from("Working")); - } - - if ev.status == GuardianAssessmentStatus::Approved { - let cell = if let Some(command) = guardian_command(&ev.action) { - history_cell::new_approval_decision_cell( - command, - crate::history_cell::ReviewDecision::Approved, - history_cell::ApprovalDecisionActor::Guardian, - ) - } else if let Some(summary) = guardian_action_summary(&ev.action) { - history_cell::new_guardian_approved_action_request(summary) - } else { - let summary = serde_json::to_string(&ev.action) - .unwrap_or_else(|_| "".to_string()); - history_cell::new_guardian_approved_action_request(summary) - }; - - self.add_boxed_history(cell); - self.request_redraw(); - return; - } - - if ev.status == GuardianAssessmentStatus::TimedOut { - let cell = if let Some(command) = guardian_command(&ev.action) { - history_cell::new_approval_decision_cell( - command, - crate::history_cell::ReviewDecision::TimedOut, - history_cell::ApprovalDecisionActor::Guardian, - ) - } else { - match &ev.action { - GuardianAssessmentAction::ApplyPatch { files, .. } => { - let files = files - .iter() - .map(|path| path.display().to_string()) - .collect::>(); - history_cell::new_guardian_timed_out_patch_request(files) - } - GuardianAssessmentAction::McpToolCall { - server, tool_name, .. - } => history_cell::new_guardian_timed_out_action_request(format!( - "codex could call MCP tool {server}.{tool_name}" - )), - GuardianAssessmentAction::NetworkAccess { target, .. } => { - history_cell::new_guardian_timed_out_action_request(format!( - "codex could access {target}" - )) - } - GuardianAssessmentAction::RequestPermissions { reason, .. } => { - history_cell::new_guardian_timed_out_action_request( - permission_request_summary("codex could request permissions", reason), - ) - } - GuardianAssessmentAction::Command { .. } => unreachable!(), - GuardianAssessmentAction::Execve { .. } => unreachable!(), - } - }; - - self.add_boxed_history(cell); - self.request_redraw(); - return; - } - - if ev.status != GuardianAssessmentStatus::Denied { - return; - } - self.review.recent_auto_review_denials.push(ev.clone()); - let cell = if let Some(command) = guardian_command(&ev.action) { - history_cell::new_approval_decision_cell( - command, - crate::history_cell::ReviewDecision::Denied, - history_cell::ApprovalDecisionActor::Guardian, - ) - } else { - match &ev.action { - GuardianAssessmentAction::ApplyPatch { files, .. } => { - let files = files - .iter() - .map(|path| path.display().to_string()) - .collect::>(); - history_cell::new_guardian_denied_patch_request(files) - } - GuardianAssessmentAction::McpToolCall { - server, tool_name, .. - } => history_cell::new_guardian_denied_action_request(format!( - "codex to call MCP tool {server}.{tool_name}" - )), - GuardianAssessmentAction::NetworkAccess { target, .. } => { - history_cell::new_guardian_denied_action_request(format!( - "codex to access {target}" - )) - } - GuardianAssessmentAction::RequestPermissions { reason, .. } => { - history_cell::new_guardian_denied_action_request(permission_request_summary( - "codex to request permissions", - reason, - )) - } - GuardianAssessmentAction::Command { .. } => unreachable!(), - GuardianAssessmentAction::Execve { .. } => unreachable!(), - } - }; - - self.add_boxed_history(cell); - self.request_redraw(); - } - - fn on_elicitation_request( - &mut self, - request_id: AppServerRequestId, - params: McpServerElicitationRequestParams, - ) { - let request_id2 = request_id.clone(); - let params2 = params.clone(); - self.defer_or_handle( - |q| q.push_elicitation(request_id, params), - |s| s.handle_elicitation_request_now(request_id2, params2), - ); - } - - fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_user_input(ev), - |s| s.handle_request_user_input_now(ev2), - ); - } - - fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_request_permissions(ev), - |s| s.handle_request_permissions_now(ev2), - ); - } - - fn on_command_execution_started(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { - id, - command, - process_id, - source, - command_actions, - .. - } = &item - else { - return; - }; - let (_command, parsed_cmd) = command_execution_command_and_parsed(command, command_actions); - self.flush_answer_stream_with_separator(); - if is_unified_exec_source(*source) { - if *source == ExecCommandSource::UnifiedExecStartup { - self.track_unified_exec_process_begin(id, process_id.as_deref(), command); - } - if !self.bottom_pane.is_task_running() { - return; - } - // Unified exec may be parsed as Unknown; keep the working indicator visible regardless. - self.bottom_pane.ensure_status_indicator(); - if !is_standard_tool_call(&parsed_cmd) { - return; - } - } - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_started(item), - |s| s.handle_command_execution_started_now(item2), - ); - } - - fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) { - self.track_unified_exec_output_chunk(call_id, delta.as_bytes()); - if !self.bottom_pane.is_task_running() { - return; - } - - let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - else { - return; - }; - - if cell.append_output(call_id, delta) { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - - fn on_terminal_interaction(&mut self, process_id: String, stdin: String) { - if !self.bottom_pane.is_task_running() { - return; - } - self.flush_answer_stream_with_separator(); - let command_display = self - .unified_exec_processes - .iter() - .find(|process| process.key == process_id) - .map(|process| process.command_display.clone()); - if stdin.is_empty() { - // Empty stdin means we are polling for background output. - // Surface this in the status indicator (single "waiting" surface) instead of - // the transcript. Keep the header short so the interrupt hint remains visible. - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ true); - self.status_state.terminal_title_status_kind = - TerminalTitleStatusKind::WaitingForBackgroundTerminal; - self.set_status( - "Waiting for background terminal".to_string(), - command_display.clone(), - StatusDetailsCapitalization::Preserve, - /*details_max_lines*/ 1, - ); - match &mut self.unified_exec_wait_streak { - Some(wait) if wait.process_id == process_id => { - wait.update_command_display(command_display); - } - Some(_) => { - self.flush_unified_exec_wait_streak(); - self.unified_exec_wait_streak = - Some(UnifiedExecWaitStreak::new(process_id, command_display)); - } - None => { - self.unified_exec_wait_streak = - Some(UnifiedExecWaitStreak::new(process_id, command_display)); - } - } - self.request_redraw(); - } else { - if self - .unified_exec_wait_streak - .as_ref() - .is_some_and(|wait| wait.process_id == process_id) - { - self.flush_unified_exec_wait_streak(); - } - self.add_to_history(history_cell::new_unified_exec_interaction( - command_display, - stdin, - )); - } - } - - fn on_patch_apply_begin(&mut self, changes: HashMap) { - self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); - } - - fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_view_image_tool_call( - path, - &self.config.cwd, - )); - self.request_redraw(); - } - - fn on_image_generation_begin(&mut self) { - self.flush_answer_stream_with_separator(); - } - - fn on_image_generation_end( - &mut self, - call_id: String, - revised_prompt: Option, - saved_path: Option, - ) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_image_generation_call( - call_id, - revised_prompt, - saved_path, - )); - self.request_redraw(); - } - - fn on_file_change_completed(&mut self, item: ThreadItem) { - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_completed(item), - |s| s.handle_file_change_completed_now(item2), - ); - } - - fn on_command_execution_completed(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { - id, - process_id, - source, - .. - } = &item - else { - return; - }; - if is_unified_exec_source(*source) { - if let Some(process_id) = process_id.as_deref() - && self - .unified_exec_wait_streak - .as_ref() - .is_some_and(|wait| wait.process_id == process_id) - { - self.flush_unified_exec_wait_streak(); - } - self.track_unified_exec_process_end(id, process_id.as_deref()); - if !self.bottom_pane.is_task_running() { - return; - } - } - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_completed(item), - |s| s.handle_command_execution_completed_now(item2), - ); - } - - fn track_unified_exec_process_begin( - &mut self, - call_id: &str, - process_id: Option<&str>, - command: &str, - ) { - let key = process_id.unwrap_or(call_id).to_string(); - let command = split_command_string(command); - let command_display = strip_bash_lc_and_escape(&command); - if let Some(existing) = self - .unified_exec_processes - .iter_mut() - .find(|process| process.key == key) - { - existing.call_id = call_id.to_string(); - existing.command_display = command_display; - existing.recent_chunks.clear(); - } else { - self.unified_exec_processes.push(UnifiedExecProcessSummary { - key, - call_id: call_id.to_string(), - command_display, - recent_chunks: Vec::new(), - }); - } - self.sync_unified_exec_footer(); - } - - fn track_unified_exec_process_end(&mut self, call_id: &str, process_id: Option<&str>) { - let key = process_id.unwrap_or(call_id); - let before = self.unified_exec_processes.len(); - self.unified_exec_processes - .retain(|process| process.key != key); - if self.unified_exec_processes.len() != before { - self.sync_unified_exec_footer(); - } - } - - fn sync_unified_exec_footer(&mut self) { - let processes = self - .unified_exec_processes - .iter() - .map(|process| process.command_display.clone()) - .collect(); - self.bottom_pane.set_unified_exec_processes(processes); - } - - /// Record recent stdout/stderr lines for the unified exec footer. - fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) { - let Some(process) = self - .unified_exec_processes - .iter_mut() - .find(|process| process.call_id == call_id) - else { - return; - }; - - let text = String::from_utf8_lossy(chunk); - for line in text - .lines() - .map(str::trim_end) - .filter(|line| !line.is_empty()) - { - process.recent_chunks.push(line.to_string()); - } - - const MAX_RECENT_CHUNKS: usize = 3; - if process.recent_chunks.len() > MAX_RECENT_CHUNKS { - let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS; - process.recent_chunks.drain(0..drop_count); - } - } - - fn on_mcp_tool_call_started(&mut self, item: ThreadItem) { - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_started(item), - |s| s.handle_mcp_tool_call_started_now(item2), - ); - } - - fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) { - let item2 = item.clone(); - self.defer_or_handle( - |q| q.push_item_completed(item), - |s| s.handle_mcp_tool_call_completed_now(item2), - ); - } - - fn on_web_search_begin(&mut self, call_id: String) { - self.flush_answer_stream_with_separator(); - self.flush_active_cell(); - self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( - call_id, - String::new(), - self.config.animations, - ))); - self.bump_active_cell_revision(); - self.request_redraw(); - } - - fn on_web_search_end( - &mut self, - call_id: String, - query: String, - action: codex_app_server_protocol::WebSearchAction, - ) { - self.flush_answer_stream_with_separator(); - let mut handled = false; - if let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - && cell.call_id() == call_id - { - cell.update(action.clone(), query.clone()); - cell.complete(); - self.bump_active_cell_revision(); - self.flush_active_cell(); - handled = true; - } - - if !handled { - self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); - } - self.transcript.had_work_activity = true; - } - - fn on_collab_event(&mut self, cell: PlainHistoryCell) { - self.flush_answer_stream_with_separator(); - self.add_to_history(cell); - self.request_redraw(); - } - - fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { - let ThreadItem::CollabAgentToolCall { - id, tool, status, .. - } = &item - else { - return; - }; - if matches!(tool, CollabAgentTool::SpawnAgent) - && let Some(spawn_request) = multi_agents::spawn_request_summary(&item) - { - self.pending_collab_spawn_requests - .insert(id.clone(), spawn_request); - } - - let cached_spawn_request = if matches!(tool, CollabAgentTool::SpawnAgent) - && !matches!(status, CollabAgentToolCallStatus::InProgress) - { - self.pending_collab_spawn_requests.remove(id) - } else { - None - }; - - if let Some(cell) = multi_agents::tool_call_history_cell( - &item, - cached_spawn_request.as_ref(), - |thread_id| self.collab_agent_metadata(thread_id), - ) { - self.on_collab_event(cell); - } - } pub(crate) fn handle_history_entry_response(&mut self, event: HistoryLookupResponse) { let HistoryLookupResponse { @@ -2899,161 +1598,6 @@ impl ChatWidget { .on_history_entry_response(log_id, offset, entry); } - fn on_shutdown_complete(&mut self) { - self.request_immediate_exit(); - } - - fn on_turn_diff(&mut self, unified_diff: String) { - debug!("TurnDiffEvent: {unified_diff}"); - self.refresh_status_line(); - } - - fn interrupted_turn_message(&self, reason: TurnAbortReason) -> String { - if reason == TurnAbortReason::BudgetLimited { - return "Goal budget reached - the turn was stopped.".to_string(); - } - - "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_string() - } - - fn on_deprecation_notice(&mut self, summary: String, details: Option) { - self.add_to_history(history_cell::new_deprecation_notice(summary, details)); - self.request_redraw(); - } - - fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { - self.flush_answer_stream_with_separator(); - self.flush_completed_hook_output(); - match self.active_hook_cell.as_mut() { - Some(cell) => { - cell.start_run(run); - self.bump_active_cell_revision(); - } - None => { - self.active_hook_cell = Some(history_cell::new_active_hook_cell( - run, - self.config.animations, - )); - self.bump_active_cell_revision(); - } - } - self.request_redraw(); - } - - fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) { - let completed_existing_run = self - .active_hook_cell - .as_mut() - .map(|cell| cell.complete_run(completed.clone())) - .unwrap_or(false); - if completed_existing_run { - self.bump_active_cell_revision(); - } else { - match self.active_hook_cell.as_mut() { - Some(cell) => { - cell.add_completed_run(completed); - self.bump_active_cell_revision(); - } - None => { - let cell = - history_cell::new_completed_hook_cell(completed, self.config.animations); - if !cell.is_empty() { - self.active_hook_cell = Some(cell); - self.bump_active_cell_revision(); - } - } - } - } - self.flush_completed_hook_output(); - self.finish_active_hook_cell_if_idle(); - self.request_redraw(); - } - - fn flush_completed_hook_output(&mut self) { - let Some(completed_cell) = self - .active_hook_cell - .as_mut() - .and_then(HookCell::take_completed_persistent_runs) - else { - return; - }; - let active_cell_is_empty = self - .active_hook_cell - .as_ref() - .is_some_and(HookCell::is_empty); - if active_cell_is_empty { - self.active_hook_cell = None; - } - self.bump_active_cell_revision(); - self.transcript.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); - } - - fn finish_active_hook_cell_if_idle(&mut self) { - let Some(cell) = self.active_hook_cell.as_ref() else { - return; - }; - if cell.is_empty() { - self.active_hook_cell = None; - self.bump_active_cell_revision(); - return; - } - if cell.should_flush() - && let Some(cell) = self.active_hook_cell.take() - { - self.bump_active_cell_revision(); - self.transcript.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); - } - } - - fn update_due_hook_visibility(&mut self) { - let Some(cell) = self.active_hook_cell.as_mut() else { - return; - }; - let now = Instant::now(); - if cell.advance_time(now) { - self.bump_active_cell_revision(); - } - self.finish_active_hook_cell_if_idle(); - } - - fn schedule_hook_timer_if_needed(&self) { - if self.config.animations - && self - .active_hook_cell - .as_ref() - .is_some_and(HookCell::has_visible_running_run) - { - self.frame_requester - .schedule_frame_in(Duration::from_millis(50)); - } - - let Some(deadline) = self - .active_hook_cell - .as_ref() - .and_then(HookCell::next_timer_deadline) - else { - return; - }; - let delay = deadline.saturating_duration_since(Instant::now()); - self.frame_requester.schedule_frame_in(delay); - } - - fn on_stream_error(&mut self, message: String, additional_details: Option) { - self.status_state.remember_retry_status_header(); - self.bottom_pane.ensure_status_indicator(); - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status( - message, - additional_details, - StatusDetailsCapitalization::CapitalizeFirst, - STATUS_DETAILS_DEFAULT_MAX_LINES, - ); - } - pub(crate) fn pre_draw_tick(&mut self) { self.update_due_hook_visibility(); self.schedule_hook_timer_if_needed(); @@ -3073,668 +1617,6 @@ impl ChatWidget { } } - /// Handle completion of an `AgentMessage` turn item. - /// - /// Commentary completion sets a deferred restore flag so the status row - /// returns once stream queues are idle. Final-answer completion (or absent - /// phase for legacy models) clears the flag to preserve historical behavior. - fn on_agent_message_item_completed(&mut self, item: AgentMessageItem, from_replay: bool) { - let mut message = String::new(); - for content in &item.content { - match content { - AgentMessageContent::Text { text } => message.push_str(text), - } - } - let parsed = parse_assistant_markdown(&message); - self.finalize_completed_assistant_message( - (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), - ); - if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) - && !parsed.visible_markdown.is_empty() - { - self.record_agent_markdown(&parsed.visible_markdown); - } - if !from_replay - && let Some(cwd) = parsed.last_created_branch_cwd() - && let Some(thread_id) = self.thread_id - && let Some(runner) = self.workspace_command_runner.clone() - { - let cwd = PathBuf::from(cwd); - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - if let Some(branch) = - crate::branch_summary::current_branch_name(runner.as_ref(), &cwd).await - { - tx.send(AppEvent::SyncThreadGitBranch { thread_id, branch }); - } - }); - } - self.status_state.pending_status_indicator_restore = match item.phase { - // Models that don't support preambles only output AgentMessageItems on turn completion. - Some(MessagePhase::FinalAnswer) | None => !self.input_queue.pending_steers.is_empty(), - Some(MessagePhase::Commentary) => true, - }; - self.maybe_restore_status_indicator_after_stream_idle(); - } - - /// Periodic tick for stream commits. In smooth mode this preserves one-line pacing, while - /// catch-up mode drains larger batches to reduce queue lag. - pub(crate) fn on_commit_tick(&mut self) { - self.run_commit_tick(); - } - - /// Runs a regular periodic commit tick. - fn run_commit_tick(&mut self) { - self.run_commit_tick_with_scope(CommitTickScope::AnyMode); - } - - /// Runs an opportunistic commit tick only if catch-up mode is active. - fn run_catch_up_commit_tick(&mut self) { - self.run_commit_tick_with_scope(CommitTickScope::CatchUpOnly); - } - - /// Runs a commit tick for the current stream queue snapshot. - /// - /// `scope` controls whether this call may commit in smooth mode or only when catch-up - /// is currently active. While lines are actively streaming we hide the status row to avoid - /// duplicate "in progress" affordances. Restoration is gated separately so we only re-show - /// the row after commentary completion once stream queues are idle. - fn run_commit_tick_with_scope(&mut self, scope: CommitTickScope) { - let now = Instant::now(); - let outcome = run_commit_tick( - &mut self.adaptive_chunking, - self.stream_controller.as_mut(), - self.plan_stream_controller.as_mut(), - scope, - now, - ); - for cell in outcome.cells { - self.bottom_pane.hide_status_indicator(); - self.add_boxed_history(cell); - } - self.sync_active_stream_tail(); - - if outcome.has_controller && outcome.all_idle { - self.maybe_restore_status_indicator_after_stream_idle(); - self.app_event_tx.send(AppEvent::StopCommitAnimation); - } - - if self.turn_lifecycle.agent_turn_running { - self.refresh_runtime_metrics(); - } - } - - fn flush_interrupt_queue(&mut self) { - let mut mgr = std::mem::take(&mut self.interrupts); - mgr.flush_all(self); - self.interrupts = mgr; - } - - #[inline] - fn defer_or_handle( - &mut self, - push: impl FnOnce(&mut InterruptManager), - handle: impl FnOnce(&mut Self), - ) { - // Preserve deterministic FIFO across queued interrupts: once anything - // is queued due to an active write cycle, continue queueing until the - // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). - if self.stream_controller.is_some() || !self.interrupts.is_empty() { - push(&mut self.interrupts); - } else { - handle(self); - } - } - - fn handle_stream_finished(&mut self) { - if self.task_complete_pending { - self.bottom_pane.hide_status_indicator(); - self.task_complete_pending = false; - } - // A completed stream indicates non-exec content was just inserted. - self.flush_interrupt_queue(); - } - - #[inline] - fn handle_streaming_delta(&mut self, delta: String) { - if self.stream_controller.is_none() { - // Before starting an agent stream, flush any active exec cell group. - self.flush_unified_exec_wait_streak(); - self.flush_active_cell(); - // If the previous turn inserted non-stream history (exec output, patch status, MCP - // calls), render a separator before starting the next streamed assistant message. - if self.transcript.needs_final_message_separator && self.transcript.had_work_activity { - self.add_to_history(history_cell::FinalMessageSeparator::new( - /*elapsed_seconds*/ None, /*runtime_metrics*/ None, - )); - self.transcript.needs_final_message_separator = false; - } else if self.transcript.needs_final_message_separator { - // Reset the flag even if we don't show separator (no work was done) - self.transcript.needs_final_message_separator = false; - } - self.stream_controller = Some(StreamController::new( - self.current_stream_width(/*reserved_cols*/ 2), - &self.config.cwd, - self.history_render_mode(), - )); - } - if let Some(controller) = self.stream_controller.as_mut() - && controller.push(&delta) - { - self.app_event_tx.send(AppEvent::StartCommitAnimation); - self.run_catch_up_commit_tick(); - } - self.sync_active_stream_tail(); - self.request_redraw(); - } - - /// Finalizes an exec call while preserving the active exec cell grouping contract. - /// - /// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an - /// end event for a call that was never materialized as the current active `ExecCell` (for - /// example, when another exploring group is still active). In that case we render the end as a - /// standalone history entry instead of replacing or flushing the unrelated active exploring - /// cell. If this method treated every unknown end as "complete the active cell", the UI could - /// merge unrelated commands and hide still-running exploring work. - pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) { - enum ExecEndTarget { - // Normal case: the active exec cell already tracks this call id. - ActiveTracked, - // We have an active exec group, but it does not contain this call id. Render the end - // as a standalone finalized history cell so the active group remains intact. - OrphanHistoryWhileActiveExec, - // No active exec cell can safely own this end; build a new cell from the end payload. - NewCell, - } - - let ThreadItem::CommandExecution { - id, - command, - process_id: _, - source, - command_actions, - aggregated_output, - exit_code, - duration_ms, - .. - } = item - else { - return; - }; - let event_command = split_command_string(&command); - let event_parsed = command_actions - .into_iter() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(); - let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); - let exit_code = exit_code.unwrap_or_default(); - let aggregated_output = aggregated_output.unwrap_or_default(); - - let running = self.running_commands.remove(&id); - if self.suppressed_exec_calls.remove(&id) { - return; - } - let (command, parsed, source) = match running { - Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => (event_command, event_parsed, source), - }; - let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); - let is_unified_exec_interaction = - matches!(source, ExecCommandSource::UnifiedExecInteraction); - let is_user_shell = source == ExecCommandSource::UserShell; - let end_target = match self.transcript.active_cell.as_ref() { - Some(cell) => match cell.as_any().downcast_ref::() { - Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => { - ExecEndTarget::ActiveTracked - } - Some(exec_cell) if exec_cell.is_active() => { - ExecEndTarget::OrphanHistoryWhileActiveExec - } - Some(_) | None => ExecEndTarget::NewCell, - }, - None => ExecEndTarget::NewCell, - }; - - // Unified exec interaction rows intentionally hide command output text in the exec cell and - // instead render the interaction-specific content elsewhere in the UI. - let output = if is_unified_exec_interaction { - CommandOutput { - exit_code, - formatted_output: String::new(), - aggregated_output: String::new(), - } - } else { - CommandOutput { - exit_code, - formatted_output: aggregated_output.clone(), - aggregated_output, - } - }; - - match end_target { - ExecEndTarget::ActiveTracked => { - if let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - { - let completed = cell.complete_call(&id, output, duration); - debug_assert!(completed, "active exec cell should contain {id}"); - if cell.should_flush() { - self.flush_active_cell(); - } else { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - } - ExecEndTarget::OrphanHistoryWhileActiveExec => { - let mut orphan = new_active_exec_command( - id.clone(), - command, - parsed, - source, - /*interaction_input*/ None, - self.config.animations, - ); - let completed = orphan.complete_call(&id, output, duration); - debug_assert!(completed, "new orphan exec cell should contain {id}"); - self.transcript.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(orphan))); - self.request_redraw(); - } - ExecEndTarget::NewCell => { - self.flush_active_cell(); - let mut cell = new_active_exec_command( - id.clone(), - command, - parsed, - source, - /*interaction_input*/ None, - self.config.animations, - ); - let completed = cell.complete_call(&id, output, duration); - debug_assert!(completed, "new exec cell should contain {id}"); - if cell.should_flush() { - self.add_to_history(cell); - } else { - self.transcript.active_cell = Some(Box::new(cell)); - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - } - // Mark that actual work was done (command executed) - self.transcript.had_work_activity = true; - if is_user_shell { - self.maybe_send_next_queued_input(); - } - } - - pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { - let ThreadItem::FileChange { status, .. } = item else { - return; - }; - // If the patch was successful, just let the "Edited" block stand. - // Otherwise, add a failure block. - if matches!(status, codex_app_server_protocol::PatchApplyStatus::Failed) { - self.add_to_history(history_cell::new_patch_apply_failure(String::new())); - } - // Mark that actual work was done (patch applied) - self.transcript.had_work_activity = true; - } - - pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) { - self.flush_answer_stream_with_separator(); - let command = shlex::try_join(ev.command.iter().map(String::as_str)) - .unwrap_or_else(|_| ev.command.join(" ")); - self.notify(Notification::ExecApprovalRequested { command }); - - let available_decisions = ev.effective_available_decisions(); - let request = ApprovalRequest::Exec { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - id: ev.effective_approval_id(), - command: ev.command, - reason: ev.reason, - available_decisions, - network_approval_context: ev.network_approval_context, - additional_permissions: ev.additional_permissions, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) { - self.flush_answer_stream_with_separator(); - - let request = ApprovalRequest::ApplyPatch { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - id: ev.call_id, - reason: ev.reason, - changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.to_path_buf(), - changes: ev.changes.keys().cloned().collect(), - }); - } - - pub(crate) fn handle_elicitation_request_now( - &mut self, - request_id: AppServerRequestId, - params: McpServerElicitationRequestParams, - ) { - self.flush_answer_stream_with_separator(); - - self.notify(Notification::ElicitationRequested { - server_name: params.server_name.clone(), - }); - - let thread_id = self.thread_id.unwrap_or_default(); - if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( - thread_id, - ¶ms.server_name, - request_id.clone(), - ¶ms.request, - ) { - self.open_app_link_view(params); - } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( - thread_id, - request_id.clone(), - params.clone(), - ) { - self.bottom_pane - .push_mcp_server_elicitation_request(request); - } else { - match params.request { - McpServerElicitationRequest::Form { message, .. } => { - let request = ApprovalRequest::McpElicitation { - thread_id, - thread_label: None, - server_name: params.server_name, - request_id, - message, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - } - McpServerElicitationRequest::Url { .. } => { - self.app_event_tx.resolve_elicitation( - thread_id, - params.server_name, - request_id, - codex_app_server_protocol::McpServerElicitationAction::Decline, - /*content*/ None, - /*meta*/ None, - ); - } - } - } - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn push_mcp_server_elicitation_request( - &mut self, - request: McpServerElicitationFormRequest, - ) { - self.bottom_pane - .push_mcp_server_elicitation_request(request); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_request_user_input_now(&mut self, ev: ToolRequestUserInputParams) { - self.flush_answer_stream_with_separator(); - let question_count = ev.questions.len(); - let summary = Notification::user_input_request_summary(&ev.questions); - let title = match (question_count, summary.as_deref()) { - (1, Some(summary)) => summary.to_string(), - (1, None) => "Question requested".to_string(), - (count, _) => format!("{count} questions requested"), - }; - self.notify(Notification::PlanModePrompt { title }); - self.bottom_pane.push_user_input_request(ev); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) { - self.flush_answer_stream_with_separator(); - let request = ApprovalRequest::Permissions { - thread_id: self.thread_id.unwrap_or_default(), - thread_label: None, - call_id: ev.call_id, - reason: ev.reason, - permissions: ev.permissions, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.set_ambient_pet_notification( - crate::pets::PetNotificationKind::Waiting, - /*body*/ None, - ); - self.request_redraw(); - } - - pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { - id, - command, - source, - command_actions, - .. - } = item - else { - return; - }; - let (command, parsed_cmd) = - command_execution_command_and_parsed(&command, &command_actions); - // Ensure the status indicator is visible while the command runs. - self.bottom_pane.ensure_status_indicator(); - let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(parsed_cmd); - self.running_commands.insert( - id.clone(), - RunningCommand { - command: command.clone(), - parsed_cmd: parsed_cmd.clone(), - source, - }, - ); - let is_wait_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); - let command_display = command.join(" "); - let should_suppress_unified_wait = is_wait_interaction - && self - .last_unified_wait - .as_ref() - .is_some_and(|wait| wait.is_duplicate(&command_display)); - if is_wait_interaction { - self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); - } else { - self.last_unified_wait = None; - } - if should_suppress_unified_wait { - self.suppressed_exec_calls.insert(id); - return; - } - if let Some(cell) = self - .transcript - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - && let Some(new_exec) = cell.with_added_call( - id.clone(), - command.clone(), - parsed_cmd.clone(), - source, - /*interaction_input*/ None, - ) - { - *cell = new_exec; - self.bump_active_cell_revision(); - } else { - self.flush_active_cell(); - - self.transcript.active_cell = Some(Box::new(new_active_exec_command( - id, - command, - parsed_cmd, - source, - /*interaction_input*/ None, - self.config.animations, - ))); - self.bump_active_cell_revision(); - } - - self.request_redraw(); - } - - pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { - let ThreadItem::McpToolCall { - id, - server, - tool, - arguments, - .. - } = item - else { - return; - }; - self.flush_answer_stream_with_separator(); - self.flush_active_cell(); - self.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( - id, - McpInvocation { - server, - tool, - arguments: Some(arguments), - }, - self.config.animations, - ))); - self.bump_active_cell_revision(); - self.request_redraw(); - } - - pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) { - self.flush_answer_stream_with_separator(); - - let ThreadItem::McpToolCall { - id, - server, - tool, - arguments, - result, - error, - duration_ms, - .. - } = item - else { - return; - }; - let invocation = McpInvocation { - server, - tool, - arguments: Some(arguments), - }; - let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); - let result = match (result, error) { - (_, Some(error)) => Err(error.message), - (Some(result), None) => { - let result = *result; - Ok(codex_protocol::mcp::CallToolResult { - content: result.content, - structured_content: result.structured_content, - is_error: Some(false), - meta: None, - }) - } - (None, None) => Err("MCP tool call completed without a result".to_string()), - }; - - let extra_cell = match self - .transcript - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - { - Some(cell) if cell.call_id() == id => cell.complete(duration, result), - _ => { - self.flush_active_cell(); - let mut cell = - history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); - let extra_cell = cell.complete(duration, result); - self.transcript.active_cell = Some(Box::new(cell)); - extra_cell - } - }; - - self.flush_active_cell(); - if let Some(extra) = extra_cell { - self.add_boxed_history(extra); - } - // Mark that actual work was done (MCP tool call) - self.transcript.had_work_activity = true; - } - - pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { - match item { - item @ ThreadItem::CommandExecution { .. } => { - self.handle_command_execution_started_now(item); - } - item @ ThreadItem::McpToolCall { .. } => { - self.handle_mcp_tool_call_started_now(item); - } - _ => {} - } - } - - pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItem) { - match item { - item @ ThreadItem::CommandExecution { .. } => { - self.handle_command_execution_completed_now(item); - } - item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item), - item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item), - _ => {} - } - } - pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { Self::new_with_op_target(common, CodexOpTarget::AppEvent) } @@ -4357,376 +2239,6 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } - fn active_cell_is_stream_tail(&self) -> bool { - self.transcript.active_cell.as_ref().is_some_and(|cell| { - cell.as_any().is::() - || cell.as_any().is::() - }) - } - - fn has_active_stream_tail(&self) -> bool { - (self.stream_controller.is_some() || self.plan_stream_controller.is_some()) - && self.active_cell_is_stream_tail() - } - - fn sync_active_stream_tail(&mut self) { - if let Some(controller) = self.stream_controller.as_ref() { - let tail_lines = controller.current_tail_lines(); - if tail_lines.is_empty() { - self.clear_active_stream_tail(); - return; - } - - self.bottom_pane.hide_status_indicator(); - self.transcript.active_cell = - Some(Box::new(history_cell::StreamingAgentTailCell::new( - tail_lines, - controller.tail_starts_stream(), - ))); - self.bump_active_cell_revision(); - return; - } - - if let Some(controller) = self.plan_stream_controller.as_ref() { - let tail_lines = controller.current_tail_display_lines(); - if tail_lines.is_empty() { - self.clear_active_stream_tail(); - return; - } - - self.bottom_pane.hide_status_indicator(); - self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new( - tail_lines, - !controller.tail_starts_stream(), - ))); - self.bump_active_cell_revision(); - return; - } - - self.clear_active_stream_tail(); - } - - fn clear_active_stream_tail(&mut self) { - if self.active_cell_is_stream_tail() { - self.transcript.active_cell = None; - self.bump_active_cell_revision(); - } - } - - /// Replay a subset of initial events into the UI to seed the transcript when - /// resuming an existing session. This approximates the live event flow and - /// is intentionally conservative: only safe-to-replay items are rendered to - /// avoid triggering side effects. Event ids are passed as `None` to - /// distinguish replayed events from live ones. - pub(crate) fn replay_thread_turns(&mut self, turns: Vec, replay_kind: ReplayKind) { - for turn in turns { - let Turn { - id: turn_id, - items_view: _, - items, - status, - error, - started_at, - completed_at, - duration_ms, - } = turn; - if matches!(status, TurnStatus::InProgress) { - self.last_non_retry_error = None; - self.on_task_started(); - } - for item in items { - self.replay_thread_item(item, turn_id.clone(), replay_kind); - } - if matches!( - status, - TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed - ) { - self.handle_turn_completed_notification( - TurnCompletedNotification { - thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn: Turn { - id: turn_id, - items_view: codex_app_server_protocol::TurnItemsView::NotLoaded, - items: Vec::new(), - status, - error, - started_at, - completed_at, - duration_ms, - }, - }, - Some(replay_kind), - ); - } - } - } - - pub(crate) fn replay_thread_item( - &mut self, - item: ThreadItem, - turn_id: String, - replay_kind: ReplayKind, - ) { - self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind)); - } - - fn handle_thread_item( - &mut self, - item: ThreadItem, - turn_id: String, - render_source: ThreadItemRenderSource, - ) { - let from_replay = render_source.is_replay(); - let replay_kind = render_source.replay_kind(); - match item { - ThreadItem::UserMessage { content, .. } => { - self.on_committed_user_message(&content, from_replay); - } - ThreadItem::AgentMessage { - id, - text, - phase, - memory_citation, - } => { - self.on_agent_message_item_completed( - AgentMessageItem { - id, - content: vec![AgentMessageContent::Text { text }], - phase, - memory_citation: memory_citation.map(|citation| { - codex_protocol::memory_citation::MemoryCitation { - entries: citation - .entries - .into_iter() - .map(|entry| { - codex_protocol::memory_citation::MemoryCitationEntry { - path: entry.path, - line_start: entry.line_start, - line_end: entry.line_end, - note: entry.note, - } - }) - .collect(), - rollout_ids: citation.thread_ids, - } - }), - }, - from_replay, - ); - } - ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), - ThreadItem::Reasoning { - summary, content, .. - } => { - if from_replay { - for delta in summary { - self.on_agent_reasoning_delta(delta); - } - if self.config.show_raw_agent_reasoning { - for delta in content { - self.on_agent_reasoning_delta(delta); - } - } - } - self.on_agent_reasoning_final(); - } - item @ ThreadItem::CommandExecution { - status: codex_app_server_protocol::CommandExecutionStatus::InProgress, - .. - } => self.on_command_execution_started(item), - item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), - ThreadItem::FileChange { - status: codex_app_server_protocol::PatchApplyStatus::InProgress, - .. - } => {} - item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), - item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), - ThreadItem::WebSearch { id, query, action } => { - self.on_web_search_begin(id.clone()); - self.on_web_search_end( - id, - query, - action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), - ); - } - ThreadItem::ImageView { id: _, path } => { - self.on_view_image_tool_call(path); - } - ThreadItem::ImageGeneration { - id, - revised_prompt, - saved_path, - .. - } => { - self.on_image_generation_end(id, revised_prompt, saved_path); - } - ThreadItem::EnteredReviewMode { review, .. } => { - if from_replay { - self.enter_review_mode_with_hint(review, /*from_replay*/ true); - } - } - ThreadItem::ExitedReviewMode { .. } => { - self.exit_review_mode_after_item(); - } - ThreadItem::ContextCompaction { .. } => { - self.add_info_message("Context compacted".to_string(), /*hint*/ None); - } - ThreadItem::HookPrompt { .. } => {} - ThreadItem::CollabAgentToolCall { - id, - tool, - status, - sender_thread_id, - receiver_thread_ids, - prompt, - model, - reasoning_effort, - agents_states, - } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { - id, - tool, - status, - sender_thread_id, - receiver_thread_ids, - prompt, - model, - reasoning_effort, - agents_states, - }), - ThreadItem::DynamicToolCall { .. } => {} - } - - if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() { - self.request_redraw(); - } - } - - pub(crate) fn handle_server_request( - &mut self, - request: ServerRequest, - replay_kind: Option, - ) { - let id = request.id().to_string(); - match request { - ServerRequest::CommandExecutionRequestApproval { params, .. } => { - let fallback_cwd = self.config.cwd.clone(); - self.on_exec_approval_request( - id, - exec_approval_request_from_params(params, &fallback_cwd), - ); - } - ServerRequest::FileChangeRequestApproval { params, .. } => { - self.on_apply_patch_approval_request( - id, - patch_approval_request_from_params(params), - ); - } - ServerRequest::McpServerElicitationRequest { request_id, params } => { - self.on_elicitation_request(request_id, params); - } - ServerRequest::PermissionsRequestApproval { params, .. } => { - self.on_request_permissions(request_permissions_from_params(params)); - } - ServerRequest::ToolRequestUserInput { params, .. } => { - self.on_request_user_input(params); - } - ServerRequest::DynamicToolCall { .. } - | ServerRequest::AttestationGenerate { .. } - | ServerRequest::ChatgptAuthTokensRefresh { .. } - | ServerRequest::ApplyPatchApproval { .. } - | ServerRequest::ExecCommandApproval { .. } => { - if replay_kind.is_none() { - self.add_error_message(TUI_STUB_MESSAGE.to_string()); - } - } - } - } - - pub(crate) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { - self.on_list_skills(response); - } - - fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} - - fn on_guardian_review_notification( - &mut self, - id: String, - turn_id: String, - started_at_ms: i64, - review: codex_app_server_protocol::GuardianApprovalReview, - completion: Option<(i64, codex_app_server_protocol::AutoReviewDecisionSource)>, - action: GuardianApprovalReviewAction, - ) { - let (completed_at_ms, decision_source) = match completion { - Some((completed_at_ms, decision_source)) => { - (Some(completed_at_ms), Some(decision_source)) - } - None => (None, None), - }; - - self.on_guardian_assessment(GuardianAssessmentEvent { - id, - target_item_id: None, - turn_id, - started_at_ms, - completed_at_ms, - status: match review.status { - codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { - GuardianAssessmentStatus::InProgress - } - codex_app_server_protocol::GuardianApprovalReviewStatus::Approved => { - GuardianAssessmentStatus::Approved - } - codex_app_server_protocol::GuardianApprovalReviewStatus::Denied => { - GuardianAssessmentStatus::Denied - } - codex_app_server_protocol::GuardianApprovalReviewStatus::TimedOut => { - GuardianAssessmentStatus::TimedOut - } - codex_app_server_protocol::GuardianApprovalReviewStatus::Aborted => { - GuardianAssessmentStatus::Aborted - } - }, - risk_level: review.risk_level.map(|risk_level| match risk_level { - codex_app_server_protocol::GuardianRiskLevel::Low => { - codex_protocol::approvals::GuardianRiskLevel::Low - } - codex_app_server_protocol::GuardianRiskLevel::Medium => { - codex_protocol::approvals::GuardianRiskLevel::Medium - } - codex_app_server_protocol::GuardianRiskLevel::High => { - codex_protocol::approvals::GuardianRiskLevel::High - } - codex_app_server_protocol::GuardianRiskLevel::Critical => { - codex_protocol::approvals::GuardianRiskLevel::Critical - } - }), - user_authorization: review.user_authorization.map(|user_authorization| { - match user_authorization { - codex_app_server_protocol::GuardianUserAuthorization::Unknown => { - codex_protocol::approvals::GuardianUserAuthorization::Unknown - } - codex_app_server_protocol::GuardianUserAuthorization::Low => { - codex_protocol::approvals::GuardianUserAuthorization::Low - } - codex_app_server_protocol::GuardianUserAuthorization::Medium => { - codex_protocol::approvals::GuardianUserAuthorization::Medium - } - codex_app_server_protocol::GuardianUserAuthorization::High => { - codex_protocol::approvals::GuardianUserAuthorization::High - } - } - }), - rationale: review.rationale, - decision_source: decision_source.map(|source| match source { - codex_app_server_protocol::AutoReviewDecisionSource::Agent => { - GuardianAssessmentDecisionSource::Agent - } - }), - action: action.into(), - }); - } - fn enter_review_mode_with_hint(&mut self, hint: String, from_replay: bool) { if self.review.pre_review_token_info.is_none() { self.review.pre_review_token_info = Some(self.token_info.clone()); diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs new file mode 100644 index 000000000..efe752296 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -0,0 +1,454 @@ +//! Command execution lifecycle handlers for `ChatWidget`. +//! +//! This module owns command start/output/completion rendering, including active +//! exec-cell grouping and unified exec wait state. + +use super::*; + +impl ChatWidget { + pub(super) fn flush_unified_exec_wait_streak(&mut self) { + let Some(wait) = self.unified_exec_wait_streak.take() else { + return; + }; + self.transcript.needs_final_message_separator = true; + let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(cell))); + self.restore_reasoning_status_header(); + } + + pub(super) fn on_command_execution_started(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + command, + process_id, + source, + command_actions, + .. + } = &item + else { + return; + }; + let (_command, parsed_cmd) = command_execution_command_and_parsed(command, command_actions); + self.flush_answer_stream_with_separator(); + if is_unified_exec_source(*source) { + if *source == ExecCommandSource::UnifiedExecStartup { + self.track_unified_exec_process_begin(id, process_id.as_deref(), command); + } + if !self.bottom_pane.is_task_running() { + return; + } + // Unified exec may be parsed as Unknown; keep the working indicator visible regardless. + self.bottom_pane.ensure_status_indicator(); + if !is_standard_tool_call(&parsed_cmd) { + return; + } + } + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_started(item), + |s| s.handle_command_execution_started_now(item2), + ); + } + + pub(super) fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) { + self.track_unified_exec_output_chunk(call_id, delta.as_bytes()); + if !self.bottom_pane.is_task_running() { + return; + } + + let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + else { + return; + }; + + if cell.append_output(call_id, delta) { + self.bump_active_cell_revision(); + self.request_redraw(); + } + } + + pub(super) fn on_terminal_interaction(&mut self, process_id: String, stdin: String) { + if !self.bottom_pane.is_task_running() { + return; + } + self.flush_answer_stream_with_separator(); + let command_display = self + .unified_exec_processes + .iter() + .find(|process| process.key == process_id) + .map(|process| process.command_display.clone()); + if stdin.is_empty() { + // Empty stdin means we are polling for background output. + // Surface this in the status indicator (single "waiting" surface) instead of + // the transcript. Keep the header short so the interrupt hint remains visible. + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ true); + self.status_state.terminal_title_status_kind = + TerminalTitleStatusKind::WaitingForBackgroundTerminal; + self.set_status( + "Waiting for background terminal".to_string(), + command_display.clone(), + StatusDetailsCapitalization::Preserve, + /*details_max_lines*/ 1, + ); + match &mut self.unified_exec_wait_streak { + Some(wait) if wait.process_id == process_id => { + wait.update_command_display(command_display); + } + Some(_) => { + self.flush_unified_exec_wait_streak(); + self.unified_exec_wait_streak = + Some(UnifiedExecWaitStreak::new(process_id, command_display)); + } + None => { + self.unified_exec_wait_streak = + Some(UnifiedExecWaitStreak::new(process_id, command_display)); + } + } + self.request_redraw(); + } else { + if self + .unified_exec_wait_streak + .as_ref() + .is_some_and(|wait| wait.process_id == process_id) + { + self.flush_unified_exec_wait_streak(); + } + self.add_to_history(history_cell::new_unified_exec_interaction( + command_display, + stdin, + )); + } + } + + pub(super) fn on_command_execution_completed(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + process_id, + source, + .. + } = &item + else { + return; + }; + if is_unified_exec_source(*source) { + if let Some(process_id) = process_id.as_deref() + && self + .unified_exec_wait_streak + .as_ref() + .is_some_and(|wait| wait.process_id == process_id) + { + self.flush_unified_exec_wait_streak(); + } + self.track_unified_exec_process_end(id, process_id.as_deref()); + if !self.bottom_pane.is_task_running() { + return; + } + } + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_command_execution_completed_now(item2), + ); + } + + pub(super) fn track_unified_exec_process_begin( + &mut self, + call_id: &str, + process_id: Option<&str>, + command: &str, + ) { + let key = process_id.unwrap_or(call_id).to_string(); + let command = split_command_string(command); + let command_display = strip_bash_lc_and_escape(&command); + if let Some(existing) = self + .unified_exec_processes + .iter_mut() + .find(|process| process.key == key) + { + existing.call_id = call_id.to_string(); + existing.command_display = command_display; + existing.recent_chunks.clear(); + } else { + self.unified_exec_processes.push(UnifiedExecProcessSummary { + key, + call_id: call_id.to_string(), + command_display, + recent_chunks: Vec::new(), + }); + } + self.sync_unified_exec_footer(); + } + + pub(super) fn track_unified_exec_process_end( + &mut self, + call_id: &str, + process_id: Option<&str>, + ) { + let key = process_id.unwrap_or(call_id); + let before = self.unified_exec_processes.len(); + self.unified_exec_processes + .retain(|process| process.key != key); + if self.unified_exec_processes.len() != before { + self.sync_unified_exec_footer(); + } + } + + pub(super) fn sync_unified_exec_footer(&mut self) { + let processes = self + .unified_exec_processes + .iter() + .map(|process| process.command_display.clone()) + .collect(); + self.bottom_pane.set_unified_exec_processes(processes); + } + + /// Record recent stdout/stderr lines for the unified exec footer. + pub(super) fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) { + let Some(process) = self + .unified_exec_processes + .iter_mut() + .find(|process| process.call_id == call_id) + else { + return; + }; + + let text = String::from_utf8_lossy(chunk); + for line in text + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + { + process.recent_chunks.push(line.to_string()); + } + + const MAX_RECENT_CHUNKS: usize = 3; + if process.recent_chunks.len() > MAX_RECENT_CHUNKS { + let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS; + process.recent_chunks.drain(0..drop_count); + } + } + + pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + command, + source, + command_actions, + .. + } = item + else { + return; + }; + let (command, parsed_cmd) = + command_execution_command_and_parsed(&command, &command_actions); + // Ensure the status indicator is visible while the command runs. + self.bottom_pane.ensure_status_indicator(); + let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(parsed_cmd); + self.running_commands.insert( + id.clone(), + RunningCommand { + command: command.clone(), + parsed_cmd: parsed_cmd.clone(), + source, + }, + ); + let is_wait_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); + let command_display = command.join(" "); + let should_suppress_unified_wait = is_wait_interaction + && self + .last_unified_wait + .as_ref() + .is_some_and(|wait| wait.is_duplicate(&command_display)); + if is_wait_interaction { + self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); + } else { + self.last_unified_wait = None; + } + if should_suppress_unified_wait { + self.suppressed_exec_calls.insert(id); + return; + } + if let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + && let Some(new_exec) = cell.with_added_call( + id.clone(), + command.clone(), + parsed_cmd.clone(), + source, + /*interaction_input*/ None, + ) + { + *cell = new_exec; + self.bump_active_cell_revision(); + } else { + self.flush_active_cell(); + + self.transcript.active_cell = Some(Box::new(new_active_exec_command( + id, + command, + parsed_cmd, + source, + /*interaction_input*/ None, + self.config.animations, + ))); + self.bump_active_cell_revision(); + } + + self.request_redraw(); + } + + /// Finalizes an exec call while preserving the active exec cell grouping contract. + /// + /// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an + /// end event for a call that was never materialized as the current active `ExecCell` (for + /// example, when another exploring group is still active). In that case we render the end as a + /// standalone history entry instead of replacing or flushing the unrelated active exploring + /// cell. If this method treated every unknown end as "complete the active cell", the UI could + /// merge unrelated commands and hide still-running exploring work. + pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) { + enum ExecEndTarget { + // Normal case: the active exec cell already tracks this call id. + ActiveTracked, + // We have an active exec group, but it does not contain this call id. Render the end + // as a standalone finalized history cell so the active group remains intact. + OrphanHistoryWhileActiveExec, + // No active exec cell can safely own this end; build a new cell from the end payload. + NewCell, + } + + let ThreadItem::CommandExecution { + id, + command, + process_id: _, + source, + command_actions, + aggregated_output, + exit_code, + duration_ms, + .. + } = item + else { + return; + }; + let event_command = split_command_string(&command); + let event_parsed = command_actions + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(); + let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); + let exit_code = exit_code.unwrap_or_default(); + let aggregated_output = aggregated_output.unwrap_or_default(); + + let running = self.running_commands.remove(&id); + if self.suppressed_exec_calls.remove(&id) { + return; + } + let (command, parsed, source) = match running { + Some(rc) => (rc.command, rc.parsed_cmd, rc.source), + None => (event_command, event_parsed, source), + }; + let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); + let is_unified_exec_interaction = + matches!(source, ExecCommandSource::UnifiedExecInteraction); + let is_user_shell = source == ExecCommandSource::UserShell; + let end_target = match self.transcript.active_cell.as_ref() { + Some(cell) => match cell.as_any().downcast_ref::() { + Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => { + ExecEndTarget::ActiveTracked + } + Some(exec_cell) if exec_cell.is_active() => { + ExecEndTarget::OrphanHistoryWhileActiveExec + } + Some(_) | None => ExecEndTarget::NewCell, + }, + None => ExecEndTarget::NewCell, + }; + + // Unified exec interaction rows intentionally hide command output text in the exec cell and + // instead render the interaction-specific content elsewhere in the UI. + let output = if is_unified_exec_interaction { + CommandOutput { + exit_code, + formatted_output: String::new(), + aggregated_output: String::new(), + } + } else { + CommandOutput { + exit_code, + formatted_output: aggregated_output.clone(), + aggregated_output, + } + }; + + match end_target { + ExecEndTarget::ActiveTracked => { + if let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|c| c.as_any_mut().downcast_mut::()) + { + let completed = cell.complete_call(&id, output, duration); + debug_assert!(completed, "active exec cell should contain {id}"); + if cell.should_flush() { + self.flush_active_cell(); + } else { + self.bump_active_cell_revision(); + self.request_redraw(); + } + } + } + ExecEndTarget::OrphanHistoryWhileActiveExec => { + let mut orphan = new_active_exec_command( + id.clone(), + command, + parsed, + source, + /*interaction_input*/ None, + self.config.animations, + ); + let completed = orphan.complete_call(&id, output, duration); + debug_assert!(completed, "new orphan exec cell should contain {id}"); + self.transcript.needs_final_message_separator = true; + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(orphan))); + self.request_redraw(); + } + ExecEndTarget::NewCell => { + self.flush_active_cell(); + let mut cell = new_active_exec_command( + id.clone(), + command, + parsed, + source, + /*interaction_input*/ None, + self.config.animations, + ); + let completed = cell.complete_call(&id, output, duration); + debug_assert!(completed, "new exec cell should contain {id}"); + if cell.should_flush() { + self.add_to_history(cell); + } else { + self.transcript.active_cell = Some(Box::new(cell)); + self.bump_active_cell_revision(); + self.request_redraw(); + } + } + } + // Mark that actual work was done (command executed) + self.transcript.had_work_activity = true; + if is_user_shell { + self.maybe_send_next_queued_input(); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs new file mode 100644 index 000000000..db3ac314d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -0,0 +1,132 @@ +//! Hook run lifecycle handling for `ChatWidget`. +//! +//! This module keeps active hook cells, hook timers, and hook completion output +//! together. + +use super::*; + +impl ChatWidget { + pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { + self.flush_answer_stream_with_separator(); + self.flush_completed_hook_output(); + match self.active_hook_cell.as_mut() { + Some(cell) => { + cell.start_run(run); + self.bump_active_cell_revision(); + } + None => { + self.active_hook_cell = Some(history_cell::new_active_hook_cell( + run, + self.config.animations, + )); + self.bump_active_cell_revision(); + } + } + self.request_redraw(); + } + + pub(super) fn on_hook_completed( + &mut self, + completed: codex_app_server_protocol::HookRunSummary, + ) { + let completed_existing_run = self + .active_hook_cell + .as_mut() + .map(|cell| cell.complete_run(completed.clone())) + .unwrap_or(false); + if completed_existing_run { + self.bump_active_cell_revision(); + } else { + match self.active_hook_cell.as_mut() { + Some(cell) => { + cell.add_completed_run(completed); + self.bump_active_cell_revision(); + } + None => { + let cell = + history_cell::new_completed_hook_cell(completed, self.config.animations); + if !cell.is_empty() { + self.active_hook_cell = Some(cell); + self.bump_active_cell_revision(); + } + } + } + } + self.flush_completed_hook_output(); + self.finish_active_hook_cell_if_idle(); + self.request_redraw(); + } + + pub(super) fn flush_completed_hook_output(&mut self) { + let Some(completed_cell) = self + .active_hook_cell + .as_mut() + .and_then(HookCell::take_completed_persistent_runs) + else { + return; + }; + let active_cell_is_empty = self + .active_hook_cell + .as_ref() + .is_some_and(HookCell::is_empty); + if active_cell_is_empty { + self.active_hook_cell = None; + } + self.bump_active_cell_revision(); + self.transcript.needs_final_message_separator = true; + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); + } + + pub(super) fn finish_active_hook_cell_if_idle(&mut self) { + let Some(cell) = self.active_hook_cell.as_ref() else { + return; + }; + if cell.is_empty() { + self.active_hook_cell = None; + self.bump_active_cell_revision(); + return; + } + if cell.should_flush() + && let Some(cell) = self.active_hook_cell.take() + { + self.bump_active_cell_revision(); + self.transcript.needs_final_message_separator = true; + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(cell))); + } + } + + pub(super) fn update_due_hook_visibility(&mut self) { + let Some(cell) = self.active_hook_cell.as_mut() else { + return; + }; + let now = Instant::now(); + if cell.advance_time(now) { + self.bump_active_cell_revision(); + } + self.finish_active_hook_cell_if_idle(); + } + + pub(super) fn schedule_hook_timer_if_needed(&self) { + if self.config.animations + && self + .active_hook_cell + .as_ref() + .is_some_and(HookCell::has_visible_running_run) + { + self.frame_requester + .schedule_frame_in(Duration::from_millis(50)); + } + + let Some(deadline) = self + .active_hook_cell + .as_ref() + .and_then(HookCell::next_timer_deadline) + else { + return; + }; + let delay = deadline.saturating_duration_since(Instant::now()); + self.frame_requester.schedule_frame_in(delay); + } +} diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs new file mode 100644 index 000000000..5b53f44fd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -0,0 +1,148 @@ +//! App-server request and notification dispatch for `ChatWidget`. +//! +//! This module translates protocol requests into the focused chat-widget flows +//! that render approvals, permissions, tool input, and guardian reviews. + +use super::*; + +impl ChatWidget { + pub(crate) fn handle_server_request( + &mut self, + request: ServerRequest, + replay_kind: Option, + ) { + let id = request.id().to_string(); + match request { + ServerRequest::CommandExecutionRequestApproval { params, .. } => { + let fallback_cwd = self.config.cwd.clone(); + self.on_exec_approval_request( + id, + exec_approval_request_from_params(params, &fallback_cwd), + ); + } + ServerRequest::FileChangeRequestApproval { params, .. } => { + self.on_apply_patch_approval_request( + id, + patch_approval_request_from_params(params), + ); + } + ServerRequest::McpServerElicitationRequest { request_id, params } => { + self.on_elicitation_request(request_id, params); + } + ServerRequest::PermissionsRequestApproval { params, .. } => { + self.on_request_permissions(request_permissions_from_params(params)); + } + ServerRequest::ToolRequestUserInput { params, .. } => { + self.on_request_user_input(params); + } + ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } + | ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::ApplyPatchApproval { .. } + | ServerRequest::ExecCommandApproval { .. } => { + if replay_kind.is_none() { + self.add_error_message(TUI_STUB_MESSAGE.to_string()); + } + } + } + } + + pub(crate) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { + self.on_list_skills(response); + } + + pub(super) fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} + + pub(super) fn on_guardian_review_notification( + &mut self, + id: String, + turn_id: String, + started_at_ms: i64, + review: codex_app_server_protocol::GuardianApprovalReview, + completion: Option<(i64, codex_app_server_protocol::AutoReviewDecisionSource)>, + action: GuardianApprovalReviewAction, + ) { + let (completed_at_ms, decision_source) = match completion { + Some((completed_at_ms, decision_source)) => { + (Some(completed_at_ms), Some(decision_source)) + } + None => (None, None), + }; + + self.on_guardian_assessment(GuardianAssessmentEvent { + id, + target_item_id: None, + turn_id, + started_at_ms, + completed_at_ms, + status: match review.status { + codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { + GuardianAssessmentStatus::InProgress + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Approved => { + GuardianAssessmentStatus::Approved + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Denied => { + GuardianAssessmentStatus::Denied + } + codex_app_server_protocol::GuardianApprovalReviewStatus::TimedOut => { + GuardianAssessmentStatus::TimedOut + } + codex_app_server_protocol::GuardianApprovalReviewStatus::Aborted => { + GuardianAssessmentStatus::Aborted + } + }, + risk_level: review.risk_level.map(|risk_level| match risk_level { + codex_app_server_protocol::GuardianRiskLevel::Low => { + codex_protocol::approvals::GuardianRiskLevel::Low + } + codex_app_server_protocol::GuardianRiskLevel::Medium => { + codex_protocol::approvals::GuardianRiskLevel::Medium + } + codex_app_server_protocol::GuardianRiskLevel::High => { + codex_protocol::approvals::GuardianRiskLevel::High + } + codex_app_server_protocol::GuardianRiskLevel::Critical => { + codex_protocol::approvals::GuardianRiskLevel::Critical + } + }), + user_authorization: review.user_authorization.map(|user_authorization| { + match user_authorization { + codex_app_server_protocol::GuardianUserAuthorization::Unknown => { + codex_protocol::approvals::GuardianUserAuthorization::Unknown + } + codex_app_server_protocol::GuardianUserAuthorization::Low => { + codex_protocol::approvals::GuardianUserAuthorization::Low + } + codex_app_server_protocol::GuardianUserAuthorization::Medium => { + codex_protocol::approvals::GuardianUserAuthorization::Medium + } + codex_app_server_protocol::GuardianUserAuthorization::High => { + codex_protocol::approvals::GuardianUserAuthorization::High + } + } + }), + rationale: review.rationale, + decision_source: decision_source.map(|source| match source { + codex_app_server_protocol::AutoReviewDecisionSource::Agent => { + GuardianAssessmentDecisionSource::Agent + } + }), + action: action.into(), + }); + } + + pub(super) fn on_shutdown_complete(&mut self) { + self.request_immediate_exit(); + } + + pub(super) fn on_turn_diff(&mut self, unified_diff: String) { + debug!("TurnDiffEvent: {unified_diff}"); + self.refresh_status_line(); + } + + pub(super) fn on_deprecation_notice(&mut self, summary: String, details: Option) { + self.add_to_history(history_cell::new_deprecation_notice(summary, details)); + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs new file mode 100644 index 000000000..294e07e33 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -0,0 +1,196 @@ +//! Thread replay rendering for `ChatWidget`. +//! +//! This module rehydrates turns and items into transcript state while avoiding +//! live-only side effects. + +use super::*; + +impl ChatWidget { + /// Replay a subset of initial events into the UI to seed the transcript when + /// resuming an existing session. This approximates the live event flow and + /// is intentionally conservative: only safe-to-replay items are rendered to + /// avoid triggering side effects. Event ids are passed as `None` to + /// distinguish replayed events from live ones. + pub(crate) fn replay_thread_turns(&mut self, turns: Vec, replay_kind: ReplayKind) { + for turn in turns { + let Turn { + id: turn_id, + items_view: _, + items, + status, + error, + started_at, + completed_at, + duration_ms, + } = turn; + if matches!(status, TurnStatus::InProgress) { + self.last_non_retry_error = None; + self.on_task_started(); + } + for item in items { + self.replay_thread_item(item, turn_id.clone(), replay_kind); + } + if matches!( + status, + TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed + ) { + self.handle_turn_completed_notification( + TurnCompletedNotification { + thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn: Turn { + id: turn_id, + items_view: codex_app_server_protocol::TurnItemsView::NotLoaded, + items: Vec::new(), + status, + error, + started_at, + completed_at, + duration_ms, + }, + }, + Some(replay_kind), + ); + } + } + } + + pub(crate) fn replay_thread_item( + &mut self, + item: ThreadItem, + turn_id: String, + replay_kind: ReplayKind, + ) { + self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind)); + } + + pub(super) fn handle_thread_item( + &mut self, + item: ThreadItem, + turn_id: String, + render_source: ThreadItemRenderSource, + ) { + let from_replay = render_source.is_replay(); + let replay_kind = render_source.replay_kind(); + match item { + ThreadItem::UserMessage { content, .. } => { + self.on_committed_user_message(&content, from_replay); + } + ThreadItem::AgentMessage { + id, + text, + phase, + memory_citation, + } => { + self.on_agent_message_item_completed( + AgentMessageItem { + id, + content: vec![AgentMessageContent::Text { text }], + phase, + memory_citation: memory_citation.map(|citation| { + codex_protocol::memory_citation::MemoryCitation { + entries: citation + .entries + .into_iter() + .map(|entry| { + codex_protocol::memory_citation::MemoryCitationEntry { + path: entry.path, + line_start: entry.line_start, + line_end: entry.line_end, + note: entry.note, + } + }) + .collect(), + rollout_ids: citation.thread_ids, + } + }), + }, + from_replay, + ); + } + ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), + ThreadItem::Reasoning { + summary, content, .. + } => { + if from_replay { + for delta in summary { + self.on_agent_reasoning_delta(delta); + } + if self.config.show_raw_agent_reasoning { + for delta in content { + self.on_agent_reasoning_delta(delta); + } + } + } + self.on_agent_reasoning_final(); + } + item @ ThreadItem::CommandExecution { + status: codex_app_server_protocol::CommandExecutionStatus::InProgress, + .. + } => self.on_command_execution_started(item), + item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), + ThreadItem::FileChange { + status: codex_app_server_protocol::PatchApplyStatus::InProgress, + .. + } => {} + item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), + item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), + ThreadItem::WebSearch { id, query, action } => { + self.on_web_search_begin(id.clone()); + self.on_web_search_end( + id, + query, + action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), + ); + } + ThreadItem::ImageView { id: _, path } => { + self.on_view_image_tool_call(path); + } + ThreadItem::ImageGeneration { + id, + revised_prompt, + saved_path, + .. + } => { + self.on_image_generation_end(id, revised_prompt, saved_path); + } + ThreadItem::EnteredReviewMode { review, .. } => { + if from_replay { + self.enter_review_mode_with_hint(review, /*from_replay*/ true); + } + } + ThreadItem::ExitedReviewMode { .. } => { + self.exit_review_mode_after_item(); + } + ThreadItem::ContextCompaction { .. } => { + self.add_info_message("Context compacted".to_string(), /*hint*/ None); + } + ThreadItem::HookPrompt { .. } => {} + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + }), + ThreadItem::DynamicToolCall { .. } => {} + } + + if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() { + self.request_redraw(); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs new file mode 100644 index 000000000..e6f5701d1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -0,0 +1,459 @@ +//! Streaming transcript updates for `ChatWidget`. +//! +//! This module owns assistant, plan, and reasoning deltas, including stream-tail +//! cells, commit ticks, and interrupt deferral. + +use super::*; + +impl ChatWidget { + pub(super) fn restore_reasoning_status_header(&mut self) { + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.set_status_header(header); + } else if self.bottom_pane.is_task_running() { + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.set_status_header(String::from("Working")); + } + } + + pub(super) fn flush_answer_stream_with_separator(&mut self) { + let had_stream_controller = self.stream_controller.is_some(); + if let Some(mut controller) = self.stream_controller.take() { + let scrollback_reflow = if controller.has_live_tail() { + crate::app_event::ConsolidationScrollbackReflow::Required + } else { + crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan + }; + self.clear_active_stream_tail(); + let (cell, source) = controller.finalize(); + let deferred_history_cell = + if scrollback_reflow == crate::app_event::ConsolidationScrollbackReflow::Required { + cell + } else { + if let Some(cell) = cell { + self.add_boxed_history(cell); + } + None + }; + // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell + // that can re-render from source on resize. + if let Some(source) = source { + let source = parse_assistant_markdown(&source).visible_markdown; + self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { + source, + cwd: self.config.cwd.to_path_buf(), + scrollback_reflow, + deferred_history_cell, + }); + } + } + self.adaptive_chunking.reset(); + if had_stream_controller && self.stream_controllers_idle() { + self.app_event_tx.send(AppEvent::StopCommitAnimation); + } + } + + pub(super) fn stream_controllers_idle(&self) -> bool { + self.stream_controller + .as_ref() + .map(|controller| controller.queued_lines() == 0) + .unwrap_or(true) + && self + .plan_stream_controller + .as_ref() + .map(|controller| controller.queued_lines() == 0) + .unwrap_or(true) + } + + /// Restore the status indicator only after commentary completion is pending, + /// the turn is still running, and all stream queues have drained. + /// + /// This gate prevents flicker while normal output is still actively + /// streaming, but still restores a visible "working" affordance when a + /// commentary block ends before the turn itself has completed. + pub(super) fn maybe_restore_status_indicator_after_stream_idle(&mut self) { + if !self.status_state.pending_status_indicator_restore + || !self.bottom_pane.is_task_running() + || !self.stream_controllers_idle() + { + return; + } + + self.bottom_pane.ensure_status_indicator(); + self.set_status( + self.status_state.current_status.header.clone(), + self.status_state.current_status.details.clone(), + StatusDetailsCapitalization::Preserve, + self.status_state.current_status.details_max_lines, + ); + self.status_state.pending_status_indicator_restore = false; + } + + pub(super) fn finalize_completed_assistant_message(&mut self, message: Option<&str>) { + // If we have a stream_controller, the finalized message payload is redundant because the + // visible content has already been accumulated through deltas. + if self.stream_controller.is_none() + && let Some(message) = message + && !message.is_empty() + { + self.handle_streaming_delta(message.to_string()); + } + self.flush_answer_stream_with_separator(); + self.handle_stream_finished(); + self.request_redraw(); + } + + pub(super) fn on_agent_message_delta(&mut self, delta: String) { + self.handle_streaming_delta(delta); + } + + pub(super) fn on_plan_delta(&mut self, delta: String) { + if self.active_mode_kind() != ModeKind::Plan { + return; + } + if !self.transcript.plan_item_active { + self.transcript.plan_item_active = true; + self.transcript.plan_delta_buffer.clear(); + } + self.transcript.plan_delta_buffer.push_str(&delta); + if self.plan_stream_controller.is_none() { + // Before starting a plan stream, flush any active exec cell group. + self.flush_unified_exec_wait_streak(); + self.flush_active_cell(); + self.plan_stream_controller = Some(PlanStreamController::new( + self.current_stream_width(/*reserved_cols*/ 4), + &self.config.cwd, + self.history_render_mode(), + )); + } + if let Some(controller) = self.plan_stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + self.run_catch_up_commit_tick(); + } + self.sync_active_stream_tail(); + self.request_redraw(); + } + + pub(super) fn on_plan_item_completed(&mut self, text: String) { + let streamed_plan = self.transcript.plan_delta_buffer.trim().to_string(); + let plan_text = if text.trim().is_empty() { + streamed_plan + } else { + text + }; + if !plan_text.trim().is_empty() { + self.record_agent_markdown(&plan_text); + self.transcript.latest_proposed_plan_markdown = Some(plan_text.clone()); + } + // Plan commit ticks can hide the status row; remember whether we streamed plan output so + // completion can restore it once stream queues are idle. + let should_restore_after_stream = self.plan_stream_controller.is_some(); + self.transcript.plan_delta_buffer.clear(); + self.transcript.plan_item_active = false; + self.transcript.saw_plan_item_this_turn = true; + let (finalized_streamed_cell, consolidated_plan_source) = + if let Some(mut controller) = self.plan_stream_controller.take() { + let had_live_tail = controller.has_live_tail(); + self.clear_active_stream_tail(); + let (cell, source) = controller.finalize(); + if had_live_tail { + (None, source) + } else { + (cell, source) + } + } else { + (None, None) + }; + if let Some(cell) = finalized_streamed_cell { + self.add_boxed_history(cell); + // TODO: Replace streamed output with the final plan item text if plan streaming is + // removed or if we need to reconcile mismatches between streamed and final content. + if let Some(source) = consolidated_plan_source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } + } else if !plan_text.is_empty() { + self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd)); + } else if let Some(source) = consolidated_plan_source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } + if should_restore_after_stream { + self.status_state.pending_status_indicator_restore = true; + self.maybe_restore_status_indicator_after_stream_idle(); + } + } + + pub(super) fn on_agent_reasoning_delta(&mut self, delta: String) { + // For reasoning deltas, do not stream to history. Accumulate the + // current reasoning block and extract the first bold element + // (between **/**) as the chunk header. Show this header as status. + self.reasoning_buffer.push_str(&delta); + + if self.unified_exec_wait_streak.is_some() { + // Unified exec waiting should take precedence over reasoning-derived status headers. + self.request_redraw(); + return; + } + + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + // Update the shimmer header to the extracted reasoning chunk header. + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.set_status_header(header); + } else { + // Fallback while we don't yet have a bold header: leave existing header as-is. + } + self.request_redraw(); + } + + pub(super) fn on_agent_reasoning_final(&mut self) { + // At the end of a reasoning block, record transcript-only content. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + if !self.full_reasoning_buffer.is_empty() { + let cell = history_cell::new_reasoning_summary_block( + self.full_reasoning_buffer.clone(), + &self.config.cwd, + ); + self.add_boxed_history(cell); + } + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.request_redraw(); + } + + pub(super) fn on_reasoning_section_break(&mut self) { + // Start a new reasoning block for header extraction and accumulate transcript. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_reasoning_buffer.push_str("\n\n"); + self.reasoning_buffer.clear(); + } + + pub(super) fn on_stream_error(&mut self, message: String, additional_details: Option) { + self.status_state.remember_retry_status_header(); + self.bottom_pane.ensure_status_indicator(); + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.set_status( + message, + additional_details, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + } + + /// Handle completion of an `AgentMessage` turn item. + /// + /// Commentary completion sets a deferred restore flag so the status row + /// returns once stream queues are idle. Final-answer completion (or absent + /// phase for legacy models) clears the flag to preserve historical behavior. + pub(super) fn on_agent_message_item_completed( + &mut self, + item: AgentMessageItem, + from_replay: bool, + ) { + let mut message = String::new(); + for content in &item.content { + match content { + AgentMessageContent::Text { text } => message.push_str(text), + } + } + let parsed = parse_assistant_markdown(&message); + self.finalize_completed_assistant_message( + (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), + ); + if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) + && !parsed.visible_markdown.is_empty() + { + self.record_agent_markdown(&parsed.visible_markdown); + } + if !from_replay + && let Some(cwd) = parsed.last_created_branch_cwd() + && let Some(thread_id) = self.thread_id + && let Some(runner) = self.workspace_command_runner.clone() + { + let cwd = PathBuf::from(cwd); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + if let Some(branch) = + crate::branch_summary::current_branch_name(runner.as_ref(), &cwd).await + { + tx.send(AppEvent::SyncThreadGitBranch { thread_id, branch }); + } + }); + } + self.status_state.pending_status_indicator_restore = match item.phase { + // Models that don't support preambles only output AgentMessageItems on turn completion. + Some(MessagePhase::FinalAnswer) | None => !self.input_queue.pending_steers.is_empty(), + Some(MessagePhase::Commentary) => true, + }; + self.maybe_restore_status_indicator_after_stream_idle(); + } + + /// Periodic tick for stream commits. In smooth mode this preserves one-line pacing, while + /// catch-up mode drains larger batches to reduce queue lag. + pub(crate) fn on_commit_tick(&mut self) { + self.run_commit_tick(); + } + + /// Runs a regular periodic commit tick. + pub(super) fn run_commit_tick(&mut self) { + self.run_commit_tick_with_scope(CommitTickScope::AnyMode); + } + + /// Runs an opportunistic commit tick only if catch-up mode is active. + pub(super) fn run_catch_up_commit_tick(&mut self) { + self.run_commit_tick_with_scope(CommitTickScope::CatchUpOnly); + } + + /// Runs a commit tick for the current stream queue snapshot. + /// + /// `scope` controls whether this call may commit in smooth mode or only when catch-up + /// is currently active. While lines are actively streaming we hide the status row to avoid + /// duplicate "in progress" affordances. Restoration is gated separately so we only re-show + /// the row after commentary completion once stream queues are idle. + pub(super) fn run_commit_tick_with_scope(&mut self, scope: CommitTickScope) { + let now = Instant::now(); + let outcome = run_commit_tick( + &mut self.adaptive_chunking, + self.stream_controller.as_mut(), + self.plan_stream_controller.as_mut(), + scope, + now, + ); + for cell in outcome.cells { + self.bottom_pane.hide_status_indicator(); + self.add_boxed_history(cell); + } + self.sync_active_stream_tail(); + + if outcome.has_controller && outcome.all_idle { + self.maybe_restore_status_indicator_after_stream_idle(); + self.app_event_tx.send(AppEvent::StopCommitAnimation); + } + + if self.turn_lifecycle.agent_turn_running { + self.refresh_runtime_metrics(); + } + } + + pub(super) fn flush_interrupt_queue(&mut self) { + let mut mgr = std::mem::take(&mut self.interrupts); + mgr.flush_all(self); + self.interrupts = mgr; + } + + #[inline] + pub(super) fn defer_or_handle( + &mut self, + push: impl FnOnce(&mut InterruptManager), + handle: impl FnOnce(&mut Self), + ) { + // Preserve deterministic FIFO across queued interrupts: once anything + // is queued due to an active write cycle, continue queueing until the + // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). + if self.stream_controller.is_some() || !self.interrupts.is_empty() { + push(&mut self.interrupts); + } else { + handle(self); + } + } + + pub(super) fn handle_stream_finished(&mut self) { + if self.task_complete_pending { + self.bottom_pane.hide_status_indicator(); + self.task_complete_pending = false; + } + // A completed stream indicates non-exec content was just inserted. + self.flush_interrupt_queue(); + } + + #[inline] + pub(super) fn handle_streaming_delta(&mut self, delta: String) { + if self.stream_controller.is_none() { + // Before starting an agent stream, flush any active exec cell group. + self.flush_unified_exec_wait_streak(); + self.flush_active_cell(); + // If the previous turn inserted non-stream history (exec output, patch status, MCP + // calls), render a separator before starting the next streamed assistant message. + if self.transcript.needs_final_message_separator && self.transcript.had_work_activity { + self.add_to_history(history_cell::FinalMessageSeparator::new( + /*elapsed_seconds*/ None, /*runtime_metrics*/ None, + )); + self.transcript.needs_final_message_separator = false; + } else if self.transcript.needs_final_message_separator { + // Reset the flag even if we don't show separator (no work was done) + self.transcript.needs_final_message_separator = false; + } + self.stream_controller = Some(StreamController::new( + self.current_stream_width(/*reserved_cols*/ 2), + &self.config.cwd, + self.history_render_mode(), + )); + } + if let Some(controller) = self.stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + self.run_catch_up_commit_tick(); + } + self.sync_active_stream_tail(); + self.request_redraw(); + } + + pub(super) fn active_cell_is_stream_tail(&self) -> bool { + self.transcript.active_cell.as_ref().is_some_and(|cell| { + cell.as_any().is::() + || cell.as_any().is::() + }) + } + + pub(super) fn has_active_stream_tail(&self) -> bool { + (self.stream_controller.is_some() || self.plan_stream_controller.is_some()) + && self.active_cell_is_stream_tail() + } + + pub(super) fn sync_active_stream_tail(&mut self) { + if let Some(controller) = self.stream_controller.as_ref() { + let tail_lines = controller.current_tail_lines(); + if tail_lines.is_empty() { + self.clear_active_stream_tail(); + return; + } + + self.bottom_pane.hide_status_indicator(); + self.transcript.active_cell = + Some(Box::new(history_cell::StreamingAgentTailCell::new( + tail_lines, + controller.tail_starts_stream(), + ))); + self.bump_active_cell_revision(); + return; + } + + if let Some(controller) = self.plan_stream_controller.as_ref() { + let tail_lines = controller.current_tail_display_lines(); + if tail_lines.is_empty() { + self.clear_active_stream_tail(); + return; + } + + self.bottom_pane.hide_status_indicator(); + self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new( + tail_lines, + !controller.tail_starts_stream(), + ))); + self.bump_active_cell_revision(); + return; + } + + self.clear_active_stream_tail(); + } + + pub(super) fn clear_active_stream_tail(&mut self) { + if self.active_cell_is_stream_tail() { + self.transcript.active_cell = None; + self.bump_active_cell_revision(); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs new file mode 100644 index 000000000..456e064ca --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -0,0 +1,264 @@ +//! Non-command tool lifecycle rendering for `ChatWidget`. +//! +//! This module handles patch, MCP, web search, image, and collaborator tool +//! events as transcript cells. + +use super::*; + +impl ChatWidget { + pub(super) fn on_patch_apply_begin(&mut self, changes: HashMap) { + self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); + } + + pub(super) fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_view_image_tool_call( + path, + &self.config.cwd, + )); + self.request_redraw(); + } + + pub(super) fn on_image_generation_begin(&mut self) { + self.flush_answer_stream_with_separator(); + } + + pub(super) fn on_image_generation_end( + &mut self, + call_id: String, + revised_prompt: Option, + saved_path: Option, + ) { + self.flush_answer_stream_with_separator(); + self.add_to_history(history_cell::new_image_generation_call( + call_id, + revised_prompt, + saved_path, + )); + self.request_redraw(); + } + + pub(super) fn on_file_change_completed(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_file_change_completed_now(item2), + ); + } + + pub(super) fn on_mcp_tool_call_started(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_started(item), + |s| s.handle_mcp_tool_call_started_now(item2), + ); + } + + pub(super) fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_mcp_tool_call_completed_now(item2), + ); + } + + pub(super) fn on_web_search_begin(&mut self, call_id: String) { + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( + call_id, + String::new(), + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + pub(super) fn on_web_search_end( + &mut self, + call_id: String, + query: String, + action: codex_app_server_protocol::WebSearchAction, + ) { + self.flush_answer_stream_with_separator(); + let mut handled = false; + if let Some(cell) = self + .transcript + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.call_id() == call_id + { + cell.update(action.clone(), query.clone()); + cell.complete(); + self.bump_active_cell_revision(); + self.flush_active_cell(); + handled = true; + } + + if !handled { + self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); + } + self.transcript.had_work_activity = true; + } + + pub(super) fn on_collab_event(&mut self, cell: PlainHistoryCell) { + self.flush_answer_stream_with_separator(); + self.add_to_history(cell); + self.request_redraw(); + } + + pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { + let ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } = &item + else { + return; + }; + if matches!(tool, CollabAgentTool::SpawnAgent) + && let Some(spawn_request) = multi_agents::spawn_request_summary(&item) + { + self.pending_collab_spawn_requests + .insert(id.clone(), spawn_request); + } + + let cached_spawn_request = if matches!(tool, CollabAgentTool::SpawnAgent) + && !matches!(status, CollabAgentToolCallStatus::InProgress) + { + self.pending_collab_spawn_requests.remove(id) + } else { + None + }; + + if let Some(cell) = multi_agents::tool_call_history_cell( + &item, + cached_spawn_request.as_ref(), + |thread_id| self.collab_agent_metadata(thread_id), + ) { + self.on_collab_event(cell); + } + } + + pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { + let ThreadItem::FileChange { status, .. } = item else { + return; + }; + // If the patch was successful, just let the "Edited" block stand. + // Otherwise, add a failure block. + if matches!(status, codex_app_server_protocol::PatchApplyStatus::Failed) { + self.add_to_history(history_cell::new_patch_apply_failure(String::new())); + } + // Mark that actual work was done (patch applied) + self.transcript.had_work_activity = true; + } + + pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { + let ThreadItem::McpToolCall { + id, + server, + tool, + arguments, + .. + } = item + else { + return; + }; + self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( + id, + McpInvocation { + server, + tool, + arguments: Some(arguments), + }, + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) { + self.flush_answer_stream_with_separator(); + + let ThreadItem::McpToolCall { + id, + server, + tool, + arguments, + result, + error, + duration_ms, + .. + } = item + else { + return; + }; + let invocation = McpInvocation { + server, + tool, + arguments: Some(arguments), + }; + let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); + let result = match (result, error) { + (_, Some(error)) => Err(error.message), + (Some(result), None) => { + let result = *result; + Ok(codex_protocol::mcp::CallToolResult { + content: result.content, + structured_content: result.structured_content, + is_error: Some(false), + meta: None, + }) + } + (None, None) => Err("MCP tool call completed without a result".to_string()), + }; + + let extra_cell = match self + .transcript + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + { + Some(cell) if cell.call_id() == id => cell.complete(duration, result), + _ => { + self.flush_active_cell(); + let mut cell = + history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); + let extra_cell = cell.complete(duration, result); + self.transcript.active_cell = Some(Box::new(cell)); + extra_cell + } + }; + + self.flush_active_cell(); + if let Some(extra) = extra_cell { + self.add_boxed_history(extra); + } + // Mark that actual work was done (MCP tool call) + self.transcript.had_work_activity = true; + } + + pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { + match item { + item @ ThreadItem::CommandExecution { .. } => { + self.handle_command_execution_started_now(item); + } + item @ ThreadItem::McpToolCall { .. } => { + self.handle_mcp_tool_call_started_now(item); + } + _ => {} + } + } + + pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItem) { + match item { + item @ ThreadItem::CommandExecution { .. } => { + self.handle_command_execution_completed_now(item); + } + item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item), + item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item), + _ => {} + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs new file mode 100644 index 000000000..7ec567325 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -0,0 +1,449 @@ +//! Interactive tool request surfaces for `ChatWidget`. +//! +//! This module owns approval, permission, elicitation, and user-input prompts +//! that block on user decisions. + +use super::*; + +impl ChatWidget { + pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_exec_approval(ev), + |s| s.handle_exec_approval_now(ev2), + ); + } + + pub(super) fn on_apply_patch_approval_request( + &mut self, + _id: String, + ev: ApplyPatchApprovalRequestEvent, + ) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_apply_patch_approval(ev), + |s| s.handle_apply_patch_approval_now(ev2), + ); + } + + /// Handle guardian review lifecycle events for the current thread. + /// + /// In-progress assessments temporarily own the live status footer so the + /// user can see what is being reviewed, including parallel review + /// aggregation. Terminal assessments clear or update that footer state and + /// render the final approved/denied history cell when guardian returns a + /// decision. + pub(super) fn on_guardian_assessment(&mut self, ev: GuardianAssessmentEvent) { + let permission_request_summary = |subject: &str, reason: &Option| { + reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .map(|reason| format!("{subject}: {reason}")) + .unwrap_or_else(|| subject.to_string()) + }; + let guardian_action_summary = |action: &GuardianAssessmentAction| match action { + GuardianAssessmentAction::Command { command, .. } => Some(command.clone()), + GuardianAssessmentAction::Execve { program, argv, .. } => { + let command = if argv.is_empty() { + vec![program.clone()] + } else { + argv.clone() + }; + shlex::try_join(command.iter().map(String::as_str)) + .ok() + .or_else(|| Some(command.join(" "))) + } + GuardianAssessmentAction::ApplyPatch { files, .. } => Some(if files.len() == 1 { + format!("apply_patch touching {}", files[0].display()) + } else { + format!("apply_patch touching {} files", files.len()) + }), + GuardianAssessmentAction::NetworkAccess { target, .. } => { + Some(format!("network access to {target}")) + } + GuardianAssessmentAction::McpToolCall { + server, + tool_name, + connector_name, + .. + } => { + let label = connector_name.as_deref().unwrap_or(server.as_str()); + Some(format!("MCP {tool_name} on {label}")) + } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + Some(permission_request_summary("permission request", reason)) + } + }; + let guardian_command = |action: &GuardianAssessmentAction| match action { + GuardianAssessmentAction::Command { command, .. } => shlex::split(command) + .filter(|command| !command.is_empty()) + .or_else(|| Some(vec![command.clone()])), + GuardianAssessmentAction::Execve { program, argv, .. } => Some(if argv.is_empty() { + vec![program.clone()] + } else { + argv.clone() + }) + .filter(|command| !command.is_empty()), + GuardianAssessmentAction::ApplyPatch { .. } + | GuardianAssessmentAction::NetworkAccess { .. } + | GuardianAssessmentAction::McpToolCall { .. } + | GuardianAssessmentAction::RequestPermissions { .. } => None, + }; + + if ev.status == GuardianAssessmentStatus::InProgress + && let Some(detail) = guardian_action_summary(&ev.action) + { + // In-progress assessments own the live footer state while the + // review is pending. Parallel reviews are aggregated into one + // footer summary by `PendingGuardianReviewStatus`. + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ true); + self.status_state + .pending_guardian_review_status + .start_or_update(ev.id.clone(), detail); + if let Some(status) = self + .status_state + .pending_guardian_review_status + .status_indicator_state() + { + self.set_status( + status.header, + status.details, + StatusDetailsCapitalization::Preserve, + status.details_max_lines, + ); + } + self.request_redraw(); + return; + } + + // Terminal assessments remove the matching pending footer entry first, + // then render the final approved/denied history cell below. + if self + .status_state + .pending_guardian_review_status + .finish(&ev.id) + { + if let Some(status) = self + .status_state + .pending_guardian_review_status + .status_indicator_state() + { + self.set_status( + status.header, + status.details, + StatusDetailsCapitalization::Preserve, + status.details_max_lines, + ); + } else if self.status_state.current_status.is_guardian_review() { + self.set_status_header(String::from("Working")); + } + } else if self.status_state.pending_guardian_review_status.is_empty() + && self.status_state.current_status.is_guardian_review() + { + self.set_status_header(String::from("Working")); + } + + if ev.status == GuardianAssessmentStatus::Approved { + let cell = if let Some(command) = guardian_command(&ev.action) { + history_cell::new_approval_decision_cell( + command, + crate::history_cell::ReviewDecision::Approved, + history_cell::ApprovalDecisionActor::Guardian, + ) + } else if let Some(summary) = guardian_action_summary(&ev.action) { + history_cell::new_guardian_approved_action_request(summary) + } else { + let summary = serde_json::to_string(&ev.action) + .unwrap_or_else(|_| "".to_string()); + history_cell::new_guardian_approved_action_request(summary) + }; + + self.add_boxed_history(cell); + self.request_redraw(); + return; + } + + if ev.status == GuardianAssessmentStatus::TimedOut { + let cell = if let Some(command) = guardian_command(&ev.action) { + history_cell::new_approval_decision_cell( + command, + crate::history_cell::ReviewDecision::TimedOut, + history_cell::ApprovalDecisionActor::Guardian, + ) + } else { + match &ev.action { + GuardianAssessmentAction::ApplyPatch { files, .. } => { + let files = files + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + history_cell::new_guardian_timed_out_patch_request(files) + } + GuardianAssessmentAction::McpToolCall { + server, tool_name, .. + } => history_cell::new_guardian_timed_out_action_request(format!( + "codex could call MCP tool {server}.{tool_name}" + )), + GuardianAssessmentAction::NetworkAccess { target, .. } => { + history_cell::new_guardian_timed_out_action_request(format!( + "codex could access {target}" + )) + } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + history_cell::new_guardian_timed_out_action_request( + permission_request_summary("codex could request permissions", reason), + ) + } + GuardianAssessmentAction::Command { .. } => unreachable!(), + GuardianAssessmentAction::Execve { .. } => unreachable!(), + } + }; + + self.add_boxed_history(cell); + self.request_redraw(); + return; + } + + if ev.status != GuardianAssessmentStatus::Denied { + return; + } + self.review.recent_auto_review_denials.push(ev.clone()); + let cell = if let Some(command) = guardian_command(&ev.action) { + history_cell::new_approval_decision_cell( + command, + crate::history_cell::ReviewDecision::Denied, + history_cell::ApprovalDecisionActor::Guardian, + ) + } else { + match &ev.action { + GuardianAssessmentAction::ApplyPatch { files, .. } => { + let files = files + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + history_cell::new_guardian_denied_patch_request(files) + } + GuardianAssessmentAction::McpToolCall { + server, tool_name, .. + } => history_cell::new_guardian_denied_action_request(format!( + "codex to call MCP tool {server}.{tool_name}" + )), + GuardianAssessmentAction::NetworkAccess { target, .. } => { + history_cell::new_guardian_denied_action_request(format!( + "codex to access {target}" + )) + } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + history_cell::new_guardian_denied_action_request(permission_request_summary( + "codex to request permissions", + reason, + )) + } + GuardianAssessmentAction::Command { .. } => unreachable!(), + GuardianAssessmentAction::Execve { .. } => unreachable!(), + } + }; + + self.add_boxed_history(cell); + self.request_redraw(); + } + + pub(super) fn on_elicitation_request( + &mut self, + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + ) { + let request_id2 = request_id.clone(); + let params2 = params.clone(); + self.defer_or_handle( + |q| q.push_elicitation(request_id, params), + |s| s.handle_elicitation_request_now(request_id2, params2), + ); + } + + pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_user_input(ev), + |s| s.handle_request_user_input_now(ev2), + ); + } + + pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_request_permissions(ev), + |s| s.handle_request_permissions_now(ev2), + ); + } + + pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) { + self.flush_answer_stream_with_separator(); + let command = shlex::try_join(ev.command.iter().map(String::as_str)) + .unwrap_or_else(|_| ev.command.join(" ")); + self.notify(Notification::ExecApprovalRequested { command }); + + let available_decisions = ev.effective_available_decisions(); + let request = ApprovalRequest::Exec { + thread_id: self.thread_id.unwrap_or_default(), + thread_label: None, + id: ev.effective_approval_id(), + command: ev.command, + reason: ev.reason, + available_decisions, + network_approval_context: ev.network_approval_context, + additional_permissions: ev.additional_permissions, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) { + self.flush_answer_stream_with_separator(); + + let request = ApprovalRequest::ApplyPatch { + thread_id: self.thread_id.unwrap_or_default(), + thread_label: None, + id: ev.call_id, + reason: ev.reason, + changes: ev.changes.clone(), + cwd: self.config.cwd.clone(), + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + self.notify(Notification::EditApprovalRequested { + cwd: self.config.cwd.to_path_buf(), + changes: ev.changes.keys().cloned().collect(), + }); + } + + pub(crate) fn handle_elicitation_request_now( + &mut self, + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + ) { + self.flush_answer_stream_with_separator(); + + self.notify(Notification::ElicitationRequested { + server_name: params.server_name.clone(), + }); + + let thread_id = self.thread_id.unwrap_or_default(); + if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( + thread_id, + ¶ms.server_name, + request_id.clone(), + ¶ms.request, + ) { + self.open_app_link_view(params); + } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id.clone(), + params.clone(), + ) { + self.bottom_pane + .push_mcp_server_elicitation_request(request); + } else { + match params.request { + McpServerElicitationRequest::Form { message, .. } => { + let request = ApprovalRequest::McpElicitation { + thread_id, + thread_label: None, + server_name: params.server_name, + request_id, + message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + } + McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name, + request_id, + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + } + } + } + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn push_mcp_server_elicitation_request( + &mut self, + request: McpServerElicitationFormRequest, + ) { + self.bottom_pane + .push_mcp_server_elicitation_request(request); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn handle_request_user_input_now(&mut self, ev: ToolRequestUserInputParams) { + self.flush_answer_stream_with_separator(); + let question_count = ev.questions.len(); + let summary = Notification::user_input_request_summary(&ev.questions); + let title = match (question_count, summary.as_deref()) { + (1, Some(summary)) => summary.to_string(), + (1, None) => "Question requested".to_string(), + (count, _) => format!("{count} questions requested"), + }; + self.notify(Notification::PlanModePrompt { title }); + self.bottom_pane.push_user_input_request(ev); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) { + self.flush_answer_stream_with_separator(); + let request = ApprovalRequest::Permissions { + thread_id: self.thread_id.unwrap_or_default(), + thread_label: None, + call_id: ev.call_id, + reason: ev.reason, + permissions: ev.permissions, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs new file mode 100644 index 000000000..077c33606 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -0,0 +1,477 @@ +//! Agent turn lifecycle and runtime bookkeeping for `ChatWidget`. +//! +//! This module owns task start/completion state, runtime metrics, plan updates, +//! and final-message separator handling. + +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. + 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(), + ); + self.refresh_plan_mode_nudge(); + self.refresh_status_surfaces(); + } + + pub(super) fn collect_runtime_metrics_delta(&mut self) { + if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { + self.apply_runtime_metrics_delta(delta); + } + } + + pub(super) fn apply_runtime_metrics_delta(&mut self, delta: RuntimeMetricsSummary) { + let should_log_timing = has_websocket_timing_metrics(delta); + self.turn_runtime_metrics.merge(delta); + if should_log_timing { + self.log_websocket_timing_totals(delta); + } + } + + pub(super) fn log_websocket_timing_totals(&mut self, delta: RuntimeMetricsSummary) { + if let Some(label) = history_cell::runtime_metrics_label(delta.responses_api_summary()) { + self.add_plain_history_lines(vec![ + vec!["• ".dim(), format!("WebSocket timing: {label}").dark_gray()].into(), + ]); + } + } + + pub(super) fn refresh_runtime_metrics(&mut self) { + self.collect_runtime_metrics_delta(); + } + + // Raw reasoning uses the same flow as summarized reasoning + + pub(super) fn on_task_started(&mut self) { + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.start(Instant::now()); + self.transcript.reset_turn_flags(); + self.adaptive_chunking.reset(); + self.plan_stream_controller = None; + self.turn_runtime_metrics = RuntimeMetricsSummary::default(); + self.session_telemetry.reset_runtime_metrics(); + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.update_task_running_state(); + self.status_state.retry_status_header = None; + if self.active_hook_cell.take().is_some() { + self.bump_active_cell_revision(); + } + self.status_state.pending_status_indicator_restore = false; + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ true); + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.set_status_header(String::from("Working")); + self.full_reasoning_buffer.clear(); + self.reasoning_buffer.clear(); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Running, + /*body*/ None, + ); + self.request_redraw(); + } + + pub(super) fn on_task_complete( + &mut self, + last_agent_message: Option, + duration_ms: Option, + from_replay: bool, + ) { + self.input_queue.submit_pending_steers_after_interrupt = false; + // Use `last_agent_message` from the turn-complete notification as the copy + // source only when no earlier item-level event (AgentMessageItem, plan + // commit, review output) already recorded markdown for this turn. This + // prevents the final summary from overwriting a more specific source. + let sanitized_last_agent_message = last_agent_message + .as_deref() + .map(|message| parse_assistant_markdown(message).visible_markdown); + if let Some(message) = sanitized_last_agent_message + .as_ref() + .filter(|message| !message.is_empty()) + && !self.transcript.saw_copy_source_this_turn + { + self.record_agent_markdown(message); + } + // For desktop notifications: prefer the notification payload, fall back to + // the item-level copy source if present, otherwise send an empty string. + let notification_response = sanitized_last_agent_message + .as_ref() + .filter(|message| !message.is_empty()) + .cloned() + .or_else(|| { + if self.transcript.saw_copy_source_this_turn { + self.transcript.last_agent_markdown.clone() + } else { + None + } + }) + .unwrap_or_default(); + self.transcript.saw_copy_source_this_turn = false; + // If a stream is currently active, finalize it. + self.flush_answer_stream_with_separator(); + if let Some(mut controller) = self.plan_stream_controller.take() { + let had_live_tail = controller.has_live_tail(); + self.clear_active_stream_tail(); + let (cell, source) = controller.finalize(); + if !had_live_tail && let Some(cell) = cell { + self.add_boxed_history(cell); + } + if let Some(source) = source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } + } + self.flush_unified_exec_wait_streak(); + if !from_replay { + self.collect_runtime_metrics_delta(); + let runtime_metrics = + (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); + let show_work_separator = self.transcript.had_work_activity + && (self.transcript.needs_final_message_separator || runtime_metrics.is_some()); + if show_work_separator || runtime_metrics.is_some() { + let elapsed_seconds = if show_work_separator { + duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()) + .map(|duration_ms| duration_ms / 1_000) + .or_else(|| { + self.bottom_pane + .status_widget() + .map(crate::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) + }) + } else { + None + }; + self.add_to_history(history_cell::FinalMessageSeparator::new( + elapsed_seconds, + runtime_metrics, + )); + } + self.turn_runtime_metrics = RuntimeMetricsSummary::default(); + self.transcript.needs_final_message_separator = false; + self.transcript.had_work_activity = false; + self.request_status_line_branch_refresh(); + self.request_status_line_git_summary_refresh(); + } + // Mark task stopped and request redraw now that all content is in history. + self.status_state.pending_status_indicator_restore = false; + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.finish(); + self.update_task_running_state(); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.unified_exec_wait_streak = None; + if !from_replay { + let body = Notification::agent_turn_preview(¬ification_response); + self.set_ambient_pet_notification(crate::pets::PetNotificationKind::Review, body); + } + self.request_redraw(); + + let had_pending_steers = !self.input_queue.pending_steers.is_empty(); + self.refresh_pending_input_preview(); + + if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { + 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.transcript.saw_plan_item_this_turn = false; + } + // If there is a queued user message, send exactly one now to begin the next turn. + let follow_up_started = self.maybe_send_next_queued_input(); + let active_goal_continuing = self + .current_goal_status + .as_ref() + .is_some_and(GoalStatusState::is_active); + // Emit a notification when the agent is truly waiting for the user. + // Queued follow-up input and active goal continuation both start the + // next turn immediately, so notifying at that boundary would feel like + // a false "needs attention". + if !follow_up_started && !active_goal_continuing { + self.notify(Notification::AgentTurnComplete { + response: notification_response, + }); + } + + self.maybe_show_pending_rate_limit_prompt(); + } + + pub(super) fn maybe_prompt_plan_implementation(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + if self.has_queued_follow_up_messages() { + return; + } + if self.active_mode_kind() != ModeKind::Plan { + return; + } + if !self.transcript.saw_plan_item_this_turn { + return; + } + if !self.bottom_pane.no_modal_or_popup_active() { + return; + } + + if matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ) { + return; + } + + self.open_plan_implementation_prompt(); + } + + pub(super) fn open_plan_implementation_prompt(&mut self) { + let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); + let context_usage_label = self.plan_implementation_context_usage_label(); + + self.bottom_pane + .show_selection_view(plan_implementation::selection_view_params( + default_mask, + self.transcript.latest_proposed_plan_markdown.as_deref(), + context_usage_label.as_deref(), + )); + self.notify(Notification::PlanModePrompt { + title: PLAN_IMPLEMENTATION_TITLE.to_string(), + }); + } + + /// Returns a context-used label for the plan implementation prompt. + /// + /// The footer reports context remaining because it is ambient status, but + /// this prompt is asking whether to discard prior conversation state before + /// implementing a plan. Reporting used context makes the cleanup tradeoff + /// explicit. A fully fresh or unknown context window returns no label so + /// the clear-context option does not imply urgency without evidence. + pub(super) fn plan_implementation_context_usage_label(&self) -> Option { + let info = self.token_info.as_ref()?; + let percent = self.context_remaining_percent(info); + + let used_tokens = self.context_used_tokens(info, percent.is_some()); + if let Some(percent) = percent { + let used_percent = 100 - percent.clamp(0, 100); + if used_percent <= 0 { + return None; + } + return Some(format!("{used_percent}% used")); + } + + if let Some(tokens) = used_tokens + && tokens > 0 + { + return Some(format!("{} used", format_tokens_compact(tokens))); + } + + None + } + + pub(super) fn has_queued_follow_up_messages(&self) -> bool { + self.input_queue.has_queued_follow_up_messages() + } + + pub(super) fn handle_app_server_steer_rejected_error( + &mut self, + codex_error_info: &AppServerCodexErrorInfo, + ) -> bool { + matches!( + codex_error_info, + AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + ) && self.enqueue_rejected_steer() + } + + /// Finalize any active exec as failed and stop/clear agent-turn UI state. + /// + /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup + /// and should continue to drive the bottom-pane running indicator while it is in progress. + pub(super) fn finalize_turn(&mut self) { + // Drop preview-only stream tail content on any termination path before + // failed-cell finalization, so transient tail cells are never persisted. + self.clear_active_stream_tail(); + // 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.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.finish(); + self.update_task_running_state(); + self.running_commands.clear(); + self.suppressed_exec_calls.clear(); + self.last_unified_wait = None; + self.unified_exec_wait_streak = None; + self.adaptive_chunking.reset(); + self.stream_controller = None; + self.plan_stream_controller = None; + self.status_state.pending_status_indicator_restore = false; + self.request_status_line_branch_refresh(); + self.request_status_line_git_summary_refresh(); + self.maybe_show_pending_rate_limit_prompt(); + } + + pub(super) fn on_server_overloaded_error(&mut self, message: String) { + self.input_queue.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + + let message = if message.trim().is_empty() { + "Codex is currently experiencing high load.".to_string() + } else { + message + }; + + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + self.maybe_send_next_queued_input(); + } + + pub(super) fn on_error(&mut self, message: String) { + self.input_queue.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_error_event(message)); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Failed, + /*body*/ None, + ); + self.request_redraw(); + + // After an error ends the turn, try sending the next queued input. + self.maybe_send_next_queued_input(); + } + + pub(super) fn on_cyber_policy_error(&mut self) { + self.input_queue.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_cyber_policy_error_event()); + self.request_redraw(); + + // After an error ends the turn, try sending the next queued input. + self.maybe_send_next_queued_input(); + } + + pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { + let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { + if matches!(error_kind, RateLimitErrorKind::UsageLimit) { + match kind { + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + RateLimitReachedType::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + RateLimitReachedType::WorkspaceMemberUsageLimitReached + } + other => other, + } + } else { + kind + } + }); + self.codex_rate_limit_reached_type = rate_limit_reached_type; + + match rate_limit_reached_type { + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { + self.on_error( + "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." + .to_string(), + ); + } + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => { + self.on_error( + "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex." + .to_string(), + ); + } + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { + self.on_error(message); + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); + } + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { + self.on_error(message); + self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); + } + Some(RateLimitReachedType::RateLimitReached) | None => { + self.on_error(message); + } + } + } + + pub(super) fn handle_non_retry_error( + &mut self, + message: String, + codex_error_info: Option, + ) { + if codex_error_info + .as_ref() + .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) + { + } else if codex_error_info + .as_ref() + .is_some_and(is_app_server_cyber_policy_error) + { + self.on_cyber_policy_error(); + } else if let Some(info) = codex_error_info + .as_ref() + .and_then(app_server_rate_limit_error_kind) + { + match info { + RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), + RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { + self.on_rate_limit_error(info, message) + } + } + } else { + self.on_error(message); + } + } + + pub(super) fn on_warning(&mut self, message: impl Into) { + let message = message.into(); + if !self.warning_display_state.should_display(&message) { + return; + } + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + } + + pub(super) fn on_app_server_model_verification( + &mut self, + verifications: &[AppServerModelVerification], + ) { + if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) { + self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); + } + } + + pub(super) fn on_plan_update(&mut self, update: UpdatePlanArgs) { + self.transcript.saw_plan_update_this_turn = true; + let total = update.plan.len(); + let completed = update + .plan + .iter() + .filter(|item| match &item.status { + StepStatus::Completed => true, + StepStatus::Pending | StepStatus::InProgress => false, + }) + .count(); + self.transcript.last_plan_progress = (total > 0).then_some((completed, total)); + self.refresh_status_surfaces(); + self.add_to_history(history_cell::new_plan_update(update)); + } + + pub(super) fn interrupted_turn_message(&self, reason: TurnAbortReason) -> String { + if reason == TurnAbortReason::BudgetLimited { + return "Goal budget reached - the turn was stopped.".to_string(); + } + + "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_string() + } +}