//! The main Codex TUI chat surface. //! //! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering //! for both the main viewport and overlay UIs. //! //! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active //! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a //! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a //! cached, render-only live tail derived from the current active cell so in-flight tool calls are //! visible immediately. //! //! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail //! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The //! cache key is designed to change when the active cell mutates in place or when its transcript //! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on //! every draw. //! //! The bottom pane exposes a single "task running" indicator that drives the spinner and interrupt //! hints. This module treats that indicator as derived UI-busy state: it is set while an agent turn //! is in progress and while MCP server startup is in progress. Those lifecycles are tracked //! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via //! `update_task_running_state`. use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; use codex_core::config::Config; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; use codex_core::protocol::AgentReasoningEvent; use codex_core::protocol::AgentReasoningRawContentDeltaEvent; use codex_core::protocol::AgentReasoningRawContentEvent; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::BackgroundEventEvent; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandOutputDeltaEvent; use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::ListCustomPromptsResponseEvent; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpStartupCompleteEvent; use codex_core::protocol::McpStartupStatus; use codex_core::protocol::McpStartupUpdateEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::SkillsListEntry; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnCompleteEvent; use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use rand::Rng; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use tracing::debug; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; use crate::app_event::AppEvent; use crate::app_event::ExitMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BetaFeatureItem; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collab; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; use crate::exec_command::strip_bash_lc_and_escape; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::markdown::append_markdown; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; use self::interrupts::InterruptManager; mod agent; use self::agent::spawn_agent; use self::agent::spawn_agent_from_existing; mod session_header; use self::session_header::SessionHeader; use crate::streaming::controller::StreamController; use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; // Track information about an in-flight exec command. struct RunningCommand { command: Vec, parsed_cmd: Vec, source: ExecCommandSource, } struct UnifiedExecProcessSummary { key: String, command_display: String, } struct UnifiedExecWaitState { command_display: String, } impl UnifiedExecWaitState { fn new(command_display: String) -> Self { Self { command_display } } fn is_duplicate(&self, command_display: &str) -> bool { self.command_display == command_display } } #[derive(Clone, Debug)] struct UnifiedExecWaitStreak { process_id: String, command_display: Option, } impl UnifiedExecWaitStreak { fn new(process_id: String, command_display: Option) -> Self { Self { process_id, command_display: command_display.filter(|display| !display.is_empty()), } } fn update_command_display(&mut self, command_display: Option) { if self.command_display.is_some() { return; } self.command_display = command_display.filter(|display| !display.is_empty()); } } fn is_unified_exec_source(source: ExecCommandSource) -> bool { matches!( source, ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction ) } fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { !parsed_cmd.is_empty() && parsed_cmd .iter() .all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. })) } const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; #[derive(Default)] struct RateLimitWarningState { secondary_index: usize, primary_index: usize, } impl RateLimitWarningState { fn take_warnings( &mut self, secondary_used_percent: Option, secondary_window_minutes: Option, primary_used_percent: Option, primary_window_minutes: Option, ) -> Vec { let reached_secondary_cap = matches!(secondary_used_percent, Some(percent) if percent == 100.0); let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); if reached_secondary_cap || reached_primary_cap { return Vec::new(); } let mut warnings = Vec::new(); if let Some(secondary_used_percent) = secondary_used_percent { let mut highest_secondary: Option = None; while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] { highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); self.secondary_index += 1; } if let Some(threshold) = highest_secondary { let limit_label = secondary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "weekly".to_string()); let remaining_percent = 100.0 - threshold; warnings.push(format!( "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } if let Some(primary_used_percent) = primary_used_percent { let mut highest_primary: Option = None; while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] { highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); self.primary_index += 1; } if let Some(threshold) = highest_primary { let limit_label = primary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "5h".to_string()); let remaining_percent = 100.0 - threshold; warnings.push(format!( "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } warnings } } pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { const MINUTES_PER_HOUR: i64 = 60; const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; const ROUNDING_BIAS_MINUTES: i64 = 3; let windows_minutes = windows_minutes.max(0); if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); format!("{hours}h") } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { "weekly".to_string() } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { "monthly".to_string() } else { "annual".to_string() } } /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, pub(crate) initial_prompt: Option, pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) model: Option, } #[derive(Default)] enum RateLimitSwitchPromptState { #[default] Idle, Pending, Shown, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] Closed, Requested, Active, } /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming /// buffers, bottom-pane overlays, and transient status text) and turns key presses into user /// intent (`Op` submissions and `AppEvent` requests). /// /// It is not responsible for running the agent itself; it reflects progress by updating UI state /// and by sending requests back to codex-core. /// /// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing /// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting /// active work, arming the double-press quit shortcut, and requesting shutdown-first exit. pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, bottom_pane: BottomPane, active_cell: Option>, /// Monotonic-ish counter used to invalidate transcript overlay caching. /// /// The transcript overlay appends a cached "live tail" for the current active cell. Most /// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer /// identity alone is not a good cache key. /// /// Callers bump this whenever the active cell's transcript output could change without /// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, model: Option, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, initial_user_message: Option, token_info: Option, rate_limit_snapshot: Option, plan_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, rate_limit_poller: Option>, // Stream lifecycle controller stream_controller: Option, running_commands: HashMap, suppressed_exec_calls: HashSet, last_unified_wait: Option, unified_exec_wait_streak: Option, task_complete_pending: bool, unified_exec_processes: Vec, /// Tracks whether codex-core currently considers an agent turn to be in progress. /// /// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion) /// can update the status header without accidentally clearing the spinner for an active turn. agent_turn_running: bool, /// Tracks per-server MCP startup state while startup is in progress. /// /// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the /// bottom pane is treated as "running" while this is populated, even if no agent turn is /// currently executing. mcp_startup_status: Option>, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, // Current status header shown in the status indicator. current_status_header: String, // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, // When resuming an existing session (selected via resume picker), avoid an // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, // Pending notification to show when unfocused on next Draw pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press /// must occur before `quit_shortcut_expires_at`. quit_shortcut_expires_at: Option, /// Tracks which quit shortcut key was pressed first. /// /// We require the second press to match this key so `Ctrl+C` followed by /// `Ctrl+D` (or vice versa) doesn't quit accidentally. quit_shortcut_key: Option, // Simple review mode flag; used to adjust layout and banners. is_review_mode: bool, // Snapshot of token usage to restore after review mode exits. pre_review_token_info: Option>, // Whether to add a final message separator after the last message needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback feedback: codex_feedback::CodexFeedback, // Current session rollout path (if known) current_rollout_path: Option, external_editor_state: ExternalEditorState, } /// Snapshot of active-cell state that affects transcript overlay rendering. /// /// The overlay keeps a cached "live tail" for the in-flight cell; this key lets /// it cheaply decide when to recompute that tail as the active cell evolves. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ActiveCellTranscriptKey { /// Cache-busting revision for in-place updates. /// /// Many active cells are updated incrementally while streaming (for example when exec groups /// add output or change status), and the transcript overlay caches its live tail, so this /// revision gives a cheap way to say "same active cell, but its transcript output is different /// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`. pub(crate) revision: u64, /// Whether the active cell continues the prior stream, which affects /// spacing between transcript blocks. pub(crate) is_stream_continuation: bool, /// Optional animation tick for time-dependent transcript output. /// /// When this changes, the overlay recomputes the cached tail even if the revision and width /// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any /// underlying data change. pub(crate) animation_tick: Option, } struct UserMessage { text: String, image_paths: Vec, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, image_paths: Vec::new(), } } } impl From<&str> for UserMessage { fn from(text: &str) -> Self { Self { text: text.to_string(), image_paths: Vec::new(), } } } fn create_initial_user_message(text: String, image_paths: Vec) -> Option { if text.is_empty() && image_paths.is_empty() { None } else { Some(UserMessage { text, image_paths }) } } 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. fn update_task_running_state(&mut self) { self.bottom_pane .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); } fn restore_reasoning_status_header(&mut self) { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { self.set_status_header(header); } else if self.bottom_pane.is_task_running() { 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.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) { if let Some(mut controller) = self.stream_controller.take() && let Some(cell) = controller.finalize() { self.add_boxed_history(cell); } } /// Update the status indicator header and details. /// /// Passing `None` clears any existing details. fn set_status(&mut self, header: String, details: Option) { self.current_status_header = header.clone(); self.bottom_pane.update_status(header, details); } /// Convenience wrapper around [`Self::set_status`]; /// updates the status indicator header and clears any existing details. fn set_status_header(&mut self, header: String) { self.set_status(header, None); } fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.retry_status_header.take() { self.set_status_header(header); } } // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); self.thread_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, ); self.apply_session_info_cell(session_info_cell); if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } // Ask codex-core to enumerate custom prompts for this session. self.submit_op(Op::ListCustomPrompts); self.submit_op(Op::ListSkills { cwds: Vec::new(), force_reload: false, }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } if !self.suppress_session_configured_redraw { self.request_redraw(); } } fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { let skills = skills_for_cwd(&self.config.cwd, &response.skills); self.set_skills(Some(skills)); } pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, include_logs: bool, ) { // Build a fresh snapshot at the time of opening the note overlay. let snapshot = self.feedback.snapshot(self.thread_id); let rollout = if include_logs { self.current_rollout_path.clone() } else { None }; let view = crate::bottom_pane::FeedbackNoteView::new( category, snapshot, rollout, self.app_event_tx.clone(), include_logs, ); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); } pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), category, self.current_rollout_path.clone(), ); self.bottom_pane.show_selection_view(params); self.request_redraw(); } fn on_agent_message(&mut self, message: String) { // If we have a stream_controller, then the final agent message is redundant and will be a // duplicate of what has already been streamed. if self.stream_controller.is_none() { self.handle_streaming_delta(message); } 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_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.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.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.agent_turn_running = true; self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.update_task_running_state(); self.retry_status_header = None; self.bottom_pane.set_interrupt_hint_visible(true); self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.request_redraw(); } fn on_task_complete(&mut self, last_agent_message: Option) { // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); self.flush_unified_exec_wait_streak(); // Mark task stopped and request redraw now that all content is in history. self.agent_turn_running = false; 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.clear_unified_exec_processes(); self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), }); self.maybe_show_pending_rate_limit_prompt(); } pub(crate) fn set_token_info(&mut self, info: Option) { match info { Some(info) => self.apply_token_info(info), None => { self.bottom_pane.set_context_window(None, None); self.token_info = None; } } } fn apply_token_info(&mut self, info: TokenUsageInfo) { let percent = self.context_remaining_percent(&info); let used_tokens = self.context_used_tokens(&info, percent.is_some()); self.bottom_pane.set_context_window(percent, used_tokens); self.token_info = Some(info); } fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { info.model_context_window.map(|window| { info.last_token_usage .percent_of_context_window_remaining(window) }) } fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option { if percent_known { return None; } Some(info.total_token_usage.tokens_in_context_window()) } fn restore_pre_review_token_info(&mut self) { if let Some(saved) = self.pre_review_token_info.take() { match saved { Some(info) => self.apply_token_info(info), None => { self.bottom_pane.set_context_window(None, None); self.token_info = None; } } } } pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { if let Some(mut snapshot) = snapshot { if snapshot.credits.is_none() { snapshot.credits = self .rate_limit_snapshot .as_ref() .and_then(|display| display.credits.as_ref()) .map(|credits| CreditsSnapshot { has_credits: credits.has_credits, unlimited: credits.unlimited, balance: credits.balance.clone(), }); } self.plan_type = snapshot.plan_type.or(self.plan_type); let warnings = self.rate_limit_warnings.take_warnings( snapshot .secondary .as_ref() .map(|window| window.used_percent), snapshot .secondary .as_ref() .and_then(|window| window.window_minutes), snapshot.primary.as_ref().map(|window| window.used_percent), snapshot .primary .as_ref() .and_then(|window| window.window_minutes), ); let high_usage = snapshot .secondary .as_ref() .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) .unwrap_or(false) || snapshot .primary .as_ref() .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) .unwrap_or(false); if high_usage && !self.rate_limit_switch_prompt_hidden() && self.current_model() != Some(NUDGE_MODEL_SLUG) && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown ) { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; } let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); self.rate_limit_snapshot = Some(display); if !warnings.is_empty() { for warning in warnings { self.add_to_history(history_cell::new_warning_event(warning)); } self.request_redraw(); } } else { self.rate_limit_snapshot = None; } } /// 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) { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_cell_as_failed(); // Reset running state and clear streaming buffers. self.agent_turn_running = false; self.update_task_running_state(); self.running_commands.clear(); self.suppressed_exec_calls.clear(); self.last_unified_wait = None; self.clear_unified_exec_processes(); self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); } fn on_error(&mut self, message: String) { self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); // After an error ends the turn, try sending the next queued input. self.maybe_send_next_queued_input(); } fn on_warning(&mut self, message: impl Into) { self.add_to_history(history_cell::new_warning_event(message.into())); self.request_redraw(); } fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { let mut status = self.mcp_startup_status.take().unwrap_or_default(); if let McpStartupStatus::Failed { error } = &ev.status { self.on_warning(error); } status.insert(ev.server, ev.status); self.mcp_startup_status = Some(status); self.update_task_running_state(); if let Some(current) = &self.mcp_startup_status { let total = current.len(); let mut starting: Vec<_> = current .iter() .filter_map(|(name, state)| { if matches!(state, McpStartupStatus::Starting) { Some(name) } else { None } }) .collect(); starting.sort(); if let Some(first) = starting.first() { let completed = total.saturating_sub(starting.len()); let max_to_show = 3; let mut to_show: Vec = starting .iter() .take(max_to_show) .map(ToString::to_string) .collect(); if starting.len() > max_to_show { to_show.push("…".to_string()); } let header = if total > 1 { format!( "Starting MCP servers ({completed}/{total}): {}", to_show.join(", ") ) } else { format!("Booting MCP server: {first}") }; self.set_status_header(header); } } self.request_redraw(); } fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { let mut parts = Vec::new(); if !ev.failed.is_empty() { let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); parts.push(format!("failed: {}", failed_servers.join(", "))); } if !ev.cancelled.is_empty() { self.on_warning(format!( "MCP startup interrupted. The following servers were not initialized: {}", ev.cancelled.join(", ") )); } if !parts.is_empty() { self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); } self.mcp_startup_status = None; self.update_task_running_state(); self.maybe_send_next_queued_input(); self.request_redraw(); } /// Handle a turn aborted due to user interrupt (Esc). /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { // Finalize, log a gentle prompt, and clear running state. self.finalize_turn(); if reason != TurnAbortReason::ReviewEnded { self.add_to_history(history_cell::new_error_event( "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), )); } // If any messages were queued during the task, restore them into the composer. if !self.queued_user_messages.is_empty() { let queued_text = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect::>() .join("\n"); let existing_text = self.bottom_pane.composer_text(); let combined = if existing_text.is_empty() { queued_text } else if queued_text.is_empty() { existing_text } else { format!("{queued_text}\n{existing_text}") }; self.bottom_pane.set_composer_text(combined); // Clear the queue and update the status indicator list. self.queued_user_messages.clear(); self.refresh_queued_user_messages(); } self.request_redraw(); } fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { let id2 = id.clone(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_exec_approval(id, ev), |s| s.handle_exec_approval_now(id2, ev2), ); } fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) { let id2 = id.clone(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_apply_patch_approval(id, ev), |s| s.handle_apply_patch_approval_now(id2, ev2), ); } fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_elicitation(ev), |s| s.handle_elicitation_request_now(ev2), ); } fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { self.track_unified_exec_process_begin(&ev); if !is_standard_tool_call(&ev.parsed_cmd) { return; } } let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); } fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) else { return; }; if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) { self.bump_active_cell_revision(); self.request_redraw(); } } fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) { self.flush_answer_stream_with_separator(); let command_display = self .unified_exec_processes .iter() .find(|process| process.key == ev.process_id) .map(|process| process.command_display.clone()); if ev.stdin.is_empty() { // Empty stdin means we are polling for background output. // Surface this in the status header (single "waiting" surface) instead of the transcript. self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(true); let header = if let Some(command) = &command_display { format!("Waiting for background terminal · {command}") } else { "Waiting for background terminal".to_string() }; self.set_status_header(header); match &mut self.unified_exec_wait_streak { Some(wait) if wait.process_id == ev.process_id => { wait.update_command_display(command_display); } Some(_) => { self.flush_unified_exec_wait_streak(); self.unified_exec_wait_streak = Some(UnifiedExecWaitStreak::new(ev.process_id, command_display)); } None => { self.unified_exec_wait_streak = Some(UnifiedExecWaitStreak::new(ev.process_id, command_display)); } } self.request_redraw(); } else { if self .unified_exec_wait_streak .as_ref() .is_some_and(|wait| wait.process_id == ev.process_id) { self.flush_unified_exec_wait_streak(); } self.add_to_history(history_cell::new_unified_exec_interaction( command_display, ev.stdin, )); } } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, )); } fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_view_image_tool_call( event.path, &self.config.cwd, )); self.request_redraw(); } fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { let ev2 = event.clone(); self.defer_or_handle( |q| q.push_patch_end(event), |s| s.handle_patch_apply_end_now(ev2), ); } fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { if is_unified_exec_source(ev.source) { if let Some(process_id) = ev.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(&ev); if !self.bottom_pane.is_task_running() { return; } } let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); } fn track_unified_exec_process_begin(&mut self, ev: &ExecCommandBeginEvent) { if ev.source != ExecCommandSource::UnifiedExecStartup { return; } let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string()); let command_display = strip_bash_lc_and_escape(&ev.command); if let Some(existing) = self .unified_exec_processes .iter_mut() .find(|process| process.key == key) { existing.command_display = command_display; } else { self.unified_exec_processes.push(UnifiedExecProcessSummary { key, command_display, }); } self.sync_unified_exec_footer(); } fn track_unified_exec_process_end(&mut self, ev: &ExecCommandEndEvent) { let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string()); 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); } fn clear_unified_exec_processes(&mut self) { if self.unified_exec_processes.is_empty() { return; } self.unified_exec_processes.clear(); self.sync_unified_exec_footer(); } fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); } fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); } fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { self.flush_answer_stream_with_separator(); } fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_web_search_call(ev.query)); } fn on_collab_event(&mut self, cell: PlainHistoryCell) { self.flush_answer_stream_with_separator(); self.add_to_history(cell); self.request_redraw(); } fn on_get_history_entry_response( &mut self, event: codex_core::protocol::GetHistoryEntryResponseEvent, ) { let codex_core::protocol::GetHistoryEntryResponseEvent { offset, log_id, entry, } = event; self.bottom_pane .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); } fn on_shutdown_complete(&mut self) { self.request_immediate_exit(); } fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); } fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { let DeprecationNoticeEvent { summary, details } = event; self.add_to_history(history_cell::new_deprecation_notice(summary, details)); self.request_redraw(); } fn on_background_event(&mut self, message: String) { debug!("BackgroundEvent: {message}"); self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(true); self.set_status_header(message); } fn on_undo_started(&mut self, event: UndoStartedEvent) { self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); let message = event .message .unwrap_or_else(|| "Undo in progress...".to_string()); self.set_status_header(message); } fn on_undo_completed(&mut self, event: UndoCompletedEvent) { let UndoCompletedEvent { success, message } = event; self.bottom_pane.hide_status_indicator(); let message = message.unwrap_or_else(|| { if success { "Undo completed successfully.".to_string() } else { "Undo failed.".to_string() } }); if success { self.add_info_message(message, None); } else { self.add_error_message(message); } } fn on_stream_error(&mut self, message: String, additional_details: Option) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status_header.clone()); } self.set_status(message, additional_details); } /// Periodic tick to commit at most one queued line to history with a small delay, /// animating the output. pub(crate) fn on_commit_tick(&mut self) { if let Some(controller) = self.stream_controller.as_mut() { let (cell, is_idle) = controller.on_commit_tick(); if let Some(cell) = cell { self.bottom_pane.hide_status_indicator(); self.add_boxed_history(cell); } if is_idle { self.app_event_tx.send(AppEvent::StopCommitAnimation); } } } 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) { // Before streaming agent content, flush any active exec cell group. self.flush_active_cell(); if self.stream_controller.is_none() { if self.needs_final_message_separator { let elapsed_seconds = self .bottom_pane .status_widget() .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; } self.stream_controller = Some(StreamController::new( self.last_rendered_width.get().map(|w| w.saturating_sub(2)), )); } if let Some(controller) = self.stream_controller.as_mut() && controller.push(&delta) { self.app_event_tx.send(AppEvent::StartCommitAnimation); } self.request_redraw(); } pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { let running = self.running_commands.remove(&ev.call_id); if self.suppressed_exec_calls.remove(&ev.call_id) { return; } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), }; let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); let needs_new = self .active_cell .as_ref() .map(|cell| cell.as_any().downcast_ref::().is_none()) .unwrap_or(true); if needs_new { self.flush_active_cell(); self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), command, parsed, source, ev.interaction_input.clone(), self.config.animations, ))); } if let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) { let output = if is_unified_exec_interaction { CommandOutput { exit_code: ev.exit_code, formatted_output: String::new(), aggregated_output: String::new(), } } else { CommandOutput { exit_code: ev.exit_code, formatted_output: ev.formatted_output.clone(), aggregated_output: ev.aggregated_output.clone(), } }; cell.complete_call(&ev.call_id, output, ev.duration); if cell.should_flush() { self.flush_active_cell(); } else { self.bump_active_cell_revision(); self.request_redraw(); } } } pub(crate) fn handle_patch_apply_end_now( &mut self, event: codex_core::protocol::PatchApplyEndEvent, ) { // If the patch was successful, just let the "Edited" block stand. // Otherwise, add a failure block. if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } } pub(crate) fn handle_exec_approval_now(&mut self, id: String, 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 request = ApprovalRequest::Exec { id, command: ev.command, reason: ev.reason, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); } pub(crate) fn handle_apply_patch_approval_now( &mut self, id: String, ev: ApplyPatchApprovalRequestEvent, ) { self.flush_answer_stream_with_separator(); let request = ApprovalRequest::ApplyPatch { id, reason: ev.reason, changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.clone(), changes: ev.changes.keys().cloned().collect(), }); } pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { self.flush_answer_stream_with_separator(); self.notify(Notification::ElicitationRequested { server_name: ev.server_name.clone(), }); let request = ApprovalRequest::McpElicitation { server_name: ev.server_name, request_id: ev.id, message: ev.message, }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( ev.call_id.clone(), RunningCommand { command: ev.command.clone(), parsed_cmd: ev.parsed_cmd.clone(), source: ev.source, }, ); let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction) && ev .interaction_input .as_deref() .map(str::is_empty) .unwrap_or(true); let command_display = ev.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(ev.call_id); return; } let interaction_input = ev.interaction_input.clone(); if let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) && let Some(new_exec) = cell.with_added_call( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd.clone(), ev.source, interaction_input.clone(), ) { *cell = new_exec; self.bump_active_cell_revision(); } else { self.flush_active_cell(); self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd, ev.source, interaction_input, self.config.animations, ))); self.bump_active_cell_revision(); } self.request_redraw(); } pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( ev.call_id, ev.invocation, self.config.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { self.flush_answer_stream_with_separator(); let McpToolCallEndEvent { call_id, invocation, duration, result, } = ev; let extra_cell = match self .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) { Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), _ => { self.flush_active_cell(); let mut cell = history_cell::new_active_mcp_tool_call( call_id, invocation, self.config.animations, ); let extra_cell = cell.complete(duration, result); self.active_cell = Some(Box::new(cell)); extra_cell } }; self.flush_active_cell(); if let Some(extra) = extra_cell { self.add_boxed_history(extra); } } pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { let ChatWidgetInit { config, frame_requester, app_event_tx, initial_prompt, initial_images, enhanced_keys_supported, auth_manager, models_manager, feedback, is_first_run, model, } = common; let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); let model_for_header = config .model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); let active_cell = if model.is_none() { Some(Self::placeholder_session_header_cell(&config)) } else { None }; let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, skills: None, }), active_cell, active_cell_revision: 0, config, model, auth_manager, models_manager, session_header: SessionHeader::new(model_for_header), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, ), token_info: None, rate_limit_snapshot: None, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, stream_controller: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, unified_exec_wait_streak: None, task_complete_pending: false, unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, quit_shortcut_expires_at: None, quit_shortcut_key: None, is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; widget.prefetch_rate_limits(); widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } /// Create a ChatWidget attached to an existing conversation (e.g., a fork). pub(crate) fn new_from_existing( common: ChatWidgetInit, conversation: std::sync::Arc, session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { let ChatWidgetInit { config, frame_requester, app_event_tx, initial_prompt, initial_images, enhanced_keys_supported, auth_manager, models_manager, feedback, model, .. } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let header_model = model.unwrap_or_else(|| session_configured.model.clone()); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, skills: None, }), active_cell: None, active_cell_revision: 0, config, model: Some(header_model.clone()), auth_manager, models_manager, session_header: SessionHeader::new(header_model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, ), token_info: None, rate_limit_snapshot: None, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), rate_limit_poller: None, stream_controller: None, running_commands: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, unified_exec_wait_streak: None, task_complete_pending: false, unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), retry_status_header: None, thread_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, quit_shortcut_expires_at: None, quit_shortcut_key: None, is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; widget.prefetch_rate_limits(); widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { self.on_ctrl_c(); return; } KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => { if self.on_ctrl_d() { return; } self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; } KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if c.eq_ignore_ascii_case(&'v') && modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { self.paste_image_from_clipboard(); return; } other if other.kind == KeyEventKind::Press => { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; } _ => {} } match key_event { KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, kind: KeyEventKind::Press, .. } if !self.queued_user_messages.is_empty() => { // Prefer the most recently queued item. if let Some(user_message) = self.queued_user_messages.pop_back() { self.bottom_pane.set_composer_text(user_message.text); self.refresh_queued_user_messages(); self.request_redraw(); } } _ => { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { // Enter always sends messages immediately (bypasses queue check) // Clear any reasoning status header when submitting a new message self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), }; if !self.is_session_configured() { self.queue_user_message(user_message); } else { self.submit_user_message(user_message); } } InputResult::Queued(text) => { // Tab queues the message if a task is running, otherwise submits immediately let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), }; self.queue_user_message(user_message); } InputResult::Command(cmd) => { self.dispatch_command(cmd); } InputResult::CommandWithArgs(cmd, args) => { self.dispatch_command_with_args(cmd, args); } InputResult::None => {} } } } } pub(crate) fn attach_image(&mut self, path: PathBuf) { tracing::info!("attach_image path={path:?}"); self.bottom_pane.attach_image(path); self.request_redraw(); } /// Attempt to attach an image from the system clipboard. /// /// This is a best-effort path used when we receive an empty paste event, /// which some terminals emit when the clipboard contains non-text data /// (like images). When the clipboard can't be read or no image exists, /// surface a helpful follow-up so the user can retry with a file path. fn paste_image_from_clipboard(&mut self) { match paste_image_to_temp_png() { Ok((path, info)) => { tracing::debug!( "pasted image size={}x{} format={}", info.width, info.height, info.encoded_format.label() ); self.attach_image(path); } Err(err) => { tracing::warn!("failed to paste image: {err}"); self.add_to_history(history_cell::new_error_event(format!( "Failed to paste image: {err}. Try saving the image to a file and pasting the file path instead.", ))); } } } pub(crate) fn composer_text_with_pending(&self) -> String { self.bottom_pane.composer_text_with_pending() } pub(crate) fn apply_external_edit(&mut self, text: String) { self.bottom_pane.apply_external_edit(text); self.request_redraw(); } pub(crate) fn external_editor_state(&self) -> ExternalEditorState { self.external_editor_state } pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) { self.external_editor_state = state; } pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { self.bottom_pane.set_footer_hint_override(items); } pub(crate) fn can_launch_external_editor(&self) -> bool { self.bottom_pane.can_launch_external_editor() } fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() ); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); return; } match cmd { SlashCommand::Feedback => { if !self.config.feedback_enabled { let params = crate::bottom_pane::feedback_disabled_params(); self.bottom_pane.show_selection_view(params); self.request_redraw(); return; } // Step 1: pick a category (UI built in feedback_view) let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); self.bottom_pane.show_selection_view(params); self.request_redraw(); } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); } SlashCommand::Fork => { self.app_event_tx.send(AppEvent::OpenForkPicker); } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { let message = format!( "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." ); self.add_info_message(message, None); return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { self.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { self.open_review_popup(); } SlashCommand::Model => { self.open_model_popup(); } SlashCommand::Approvals => { self.open_approvals_popup(); } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] { let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox() .is_some() && !codex_core::is_windows_elevated_sandbox_enabled(); if !windows_degraded_sandbox_enabled || !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. return; } let Some(preset) = builtin_approval_presets() .into_iter() .find(|preset| preset.id == "auto") else { // Avoid panicking in interactive UI; treat this as a recoverable // internal error. self.add_error_message( "Internal error: missing the 'auto' approval preset.".to_string(), ); return; }; if let Err(err) = self.config.approval_policy.can_set(&preset.approval) { self.add_error_message(err.to_string()); return; } self.app_event_tx .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); } #[cfg(not(target_os = "windows"))] { // Not supported; on non-Windows this command should never be reachable. }; } SlashCommand::Experimental => { self.open_experimental_popup(); } SlashCommand::Quit | SlashCommand::Exit => { self.request_quit_without_confirmation(); } SlashCommand::Logout => { if let Err(e) = codex_core::auth::logout( &self.config.codex_home, self.config.cli_auth_credentials_store_mode, ) { tracing::error!("failed to logout: {e}"); } self.request_quit_without_confirmation(); } // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); // } SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { let text = match get_git_diff().await { Ok((is_git_repo, diff_text)) => { if is_git_repo { diff_text } else { "`/diff` — _not inside a git repository_".to_string() } } Err(e) => format!("Failed to compute diff: {e}"), }; tx.send(AppEvent::DiffResult(text)); }); } SlashCommand::Mention => { self.insert_str("@"); } SlashCommand::Skills => { self.insert_str("$"); } SlashCommand::Status => { self.add_status_output(); } SlashCommand::Ps => { self.add_ps_output(); } SlashCommand::Mcp => { self.add_mcp_output(); } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( format!("Current rollout path: {}", path.display()), None, ); } else { self.add_info_message("Rollout path is not available yet.".to_string(), None); } } SlashCommand::TestApproval => { use codex_core::protocol::EventMsg; use std::collections::HashMap; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::FileChange; self.app_event_tx.send(AppEvent::CodexEvent(Event { id: "1".to_string(), // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { // call_id: "1".to_string(), // command: vec!["git".into(), "apply".into()], // cwd: self.config.cwd.clone(), // reason: Some("test".to_string()), // }), msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "1".to_string(), turn_id: "turn-1".to_string(), changes: HashMap::from([ ( PathBuf::from("/tmp/test.txt"), FileChange::Add { content: "test".to_string(), }, ), ( PathBuf::from("/tmp/test2.txt"), FileChange::Update { unified_diff: "+test\n-test2".to_string(), move_path: None, }, ), ]), reason: None, grant_root: Some(PathBuf::from("/tmp")), }), })); } } } fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() ); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); return; } let trimmed = args.trim(); match cmd { SlashCommand::Review if !trimmed.is_empty() => { self.submit_op(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: trimmed.to_string(), }, user_facing_hint: None, }, }); } _ => self.dispatch_command(cmd), } } pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } /// Route paste events through image detection. /// /// Terminals vary in how they represent paste: some emit an empty paste /// payload when the clipboard isn't text (common for image-only clipboard /// contents). Treat the empty payload as a hint to attempt a clipboard /// image read; otherwise, fall back to text handling. pub(crate) fn handle_paste_event(&mut self, text: String) { if text.is_empty() { self.paste_image_from_clipboard(); } else { self.handle_paste(text); } } // Returns true if caller should skip rendering this frame (a future frame is scheduled). pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() { // A paste just flushed; request an immediate redraw and skip this frame. self.request_redraw(); true } else if self.bottom_pane.is_in_paste_burst() { // While capturing a burst, schedule a follow-up tick and skip this frame // to avoid redundant renders between ticks. frame_requester.schedule_frame_in( crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), ); true } else { false } } fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { self.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); } } pub(crate) fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { self.add_boxed_history(Box::new(cell)); } fn add_boxed_history(&mut self, cell: Box) { // Keep the placeholder session header as the active cell until real session info arrives, // so we can merge headers instead of committing a duplicate box to history. let keep_placeholder_header_active = !self.is_session_configured() && self .active_cell .as_ref() .is_some_and(|c| c.as_any().is::()); if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() { // Only break exec grouping if the cell renders visible lines. self.flush_active_cell(); self.needs_final_message_separator = true; } self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } fn queue_user_message(&mut self, user_message: UserMessage) { if !self.is_session_configured() || self.bottom_pane.is_task_running() || self.is_review_mode { self.queued_user_messages.push_back(user_message); self.refresh_queued_user_messages(); } else { self.submit_user_message(user_message); } } fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { return; } let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. if let Some(stripped) = text.strip_prefix('!') { let cmd = stripped.trim(); if cmd.is_empty() { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event( USER_SHELL_COMMAND_HELP_TITLE.to_string(), Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), ), ))); return; } self.submit_op(Op::RunUserShellCommand { command: cmd.to_string(), }); return; } for path in image_paths { items.push(UserInput::LocalImage { path }); } if !text.is_empty() { // TODO: Thread text element ranges from the composer input. Empty keeps old behavior. items.push(UserInput::Text { text: text.clone(), text_elements: Vec::new(), }); } if let Some(skills) = self.bottom_pane.skills() { let skill_mentions = find_skill_mentions(&text, skills); for skill in skill_mentions { items.push(UserInput::Skill { name: skill.name.clone(), path: skill.path.clone(), }); } } self.codex_op_tx .send(Op::UserInput { items, final_output_json_schema: None, }) .unwrap_or_else(|e| { tracing::error!("failed to send message: {e}"); }); // Persist the text to cross-session message history. if !text.is_empty() { self.codex_op_tx .send(Op::AddToHistory { text: text.clone() }) .unwrap_or_else(|e| { tracing::error!("failed to send AddHistory op: {e}"); }); } // Only show the text portion in conversation history. if !text.is_empty() { self.add_to_history(history_cell::new_user_prompt(text)); } self.needs_final_message_separator = false; } /// 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. fn replay_initial_messages(&mut self, events: Vec) { for msg in events { if matches!(msg, EventMsg::SessionConfigured(_)) { continue; } // `id: None` indicates a synthetic/fake id coming from replay. self.dispatch_event_msg(None, msg, true); } } pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; self.dispatch_event_msg(Some(id), msg, false); } /// Dispatch a protocol `EventMsg` to the appropriate handler. /// /// `id` is `Some` for live events and `None` for replayed events from /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id /// that must not be used to correlate follow-up actions. fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); if !is_stream_error { self.restore_retry_status_header_if_present(); } match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) => {} _ => { tracing::trace!("handle_codex_event: {:?}", msg); } } match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, }) => self.on_agent_reasoning_delta(delta), EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { self.on_agent_reasoning_delta(text); self.on_agent_reasoning_final(); } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TurnStarted(_) => self.on_task_started(), EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => { self.on_task_complete(last_agent_message) } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { self.on_interrupted_turn(ev.reason); } TurnAbortReason::Replaced => { self.on_error("Turn aborted: replaced by a new task".to_owned()) } TurnAbortReason::ReviewEnded => { self.on_interrupted_turn(ev.reason); } }, EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::ExecApprovalRequest(ev) => { // For replayed events, synthesize an empty id (these should not occur). self.on_exec_approval_request(id.unwrap_or_default(), ev) } EventMsg::ApplyPatchApprovalRequest(ev) => { self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) } EventMsg::ElicitationRequest(ev) => { self.on_elicitation_request(ev); } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::SkillsUpdateAvailable => { self.submit_op(Op::ListSkills { cwds: Vec::new(), force_reload: true, }); } EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { self.on_background_event(message) } EventMsg::UndoStarted(ev) => self.on_undo_started(ev), EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), EventMsg::StreamError(StreamErrorEvent { message, additional_details, .. }) => self.on_stream_error(message, additional_details), EventMsg::UserMessage(ev) => { if from_replay { self.on_user_message_event(ev); } } EventMsg::EnteredReviewMode(review_request) => { self.on_entered_review_mode(review_request, from_replay) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), EventMsg::CollabAgentSpawnBegin(_) => {} EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)), EventMsg::CollabAgentInteractionBegin(_) => {} EventMsg::CollabAgentInteractionEnd(ev) => { self.on_collab_event(collab::interaction_end(ev)) } EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)), EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)), EventMsg::CollabCloseBegin(_) => {} EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)), EventMsg::ThreadRolledBack(_) => {} EventMsg::RawResponseItem(_) | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) => {} } } fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) { // Enter review mode and emit a concise banner if self.pre_review_token_info.is_none() { self.pre_review_token_info = Some(self.token_info.clone()); } // Avoid toggling running state for replayed history events on resume. if !from_replay && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(true); } self.is_review_mode = true; let hint = review .user_facing_hint .unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target)); let banner = format!(">> Code review started: {hint} <<"); self.add_to_history(history_cell::new_review_status_line(banner)); self.request_redraw(); } fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { // Leave review mode; if output is present, flush pending stream + show results. if let Some(output) = review.review_output { self.flush_answer_stream_with_separator(); self.flush_interrupt_queue(); self.flush_active_cell(); if output.findings.is_empty() { let explanation = output.overall_explanation.trim().to_string(); if explanation.is_empty() { tracing::error!("Reviewer failed to output a response."); self.add_to_history(history_cell::new_error_event( "Reviewer failed to output a response.".to_owned(), )); } else { // Show explanation when there are no structured findings. let mut rendered: Vec> = vec!["".into()]; append_markdown(&explanation, None, &mut rendered); let body_cell = AgentMessageCell::new(rendered, false); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); } } // Final message is rendered as part of the AgentMessage. } self.is_review_mode = false; self.restore_pre_review_token_info(); // Append a finishing banner at the end of this turn. self.add_to_history(history_cell::new_review_status_line( "<< Code review finished >>".to_string(), )); self.request_redraw(); } fn on_user_message_event(&mut self, event: UserMessageEvent) { let message = event.message.trim(); if !message.is_empty() { self.add_to_history(history_cell::new_user_prompt(message.to_string())); } } /// Exit the UI immediately without waiting for shutdown. /// /// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits; /// this is mainly a fallback for shutdown completion or emergency exits. fn request_immediate_exit(&self) { self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); } /// Request a shutdown-first quit. /// /// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for /// the double-press Ctrl+C/Ctrl+D quit shortcut. fn request_quit_without_confirmation(&self) { self.app_event_tx .send(AppEvent::Exit(ExitMode::ShutdownFirst)); } fn request_redraw(&mut self) { self.frame_requester.schedule_frame(); } fn bump_active_cell_revision(&mut self) { // Wrapping avoids overflow; wraparound would require 2^64 bumps and at // worst causes a one-time cache-key collision. self.active_cell_revision = self.active_cell_revision.wrapping_add(1); } fn notify(&mut self, notification: Notification) { if !notification.allowed_for(&self.config.tui_notifications) { return; } self.pending_notification = Some(notification); self.request_redraw(); } pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { if let Some(notif) = self.pending_notification.take() { tui.notify(notif.display()); } } /// Mark the active cell as failed (✗) and flush it into history. fn finalize_active_cell_as_failed(&mut self) { if let Some(mut cell) = self.active_cell.take() { // Insert finalized cell into history and keep grouping consistent. if let Some(exec) = cell.as_any_mut().downcast_mut::() { exec.mark_failed(); } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { tool.mark_failed(); } self.add_boxed_history(cell); } } // If idle and there are queued inputs, submit exactly one to start the next turn. fn maybe_send_next_queued_input(&mut self) { if self.bottom_pane.is_task_running() { return; } if let Some(user_message) = self.queued_user_messages.pop_front() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). self.refresh_queued_user_messages(); } /// Rebuild and update the queued user messages from the current queue. fn refresh_queued_user_messages(&mut self) { let messages: Vec = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect(); self.bottom_pane.set_queued_user_messages(messages); } pub(crate) fn add_diff_in_progress(&mut self) { self.request_redraw(); } pub(crate) fn on_diff_complete(&mut self) { self.request_redraw(); } pub(crate) fn add_status_output(&mut self) { let default_usage = TokenUsage::default(); let token_info = self.token_info.as_ref(); let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), token_info, total_usage, &self.thread_id, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), self.model_display_name(), )); } pub(crate) fn add_ps_output(&mut self) { let processes = self .unified_exec_processes .iter() .map(|process| process.command_display.clone()) .collect(); self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); } fn stop_rate_limit_poller(&mut self) { if let Some(handle) = self.rate_limit_poller.take() { handle.abort(); } } fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); if self.auth_manager.auth_cached().map(|auth| auth.mode) != Some(AuthMode::ChatGPT) { return; } let base_url = self.config.chatgpt_base_url.clone(); let app_event_tx = self.app_event_tx.clone(); let auth_manager = Arc::clone(&self.auth_manager); let handle = tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60)); loop { if let Some(auth) = auth_manager.auth().await && auth.mode == AuthMode::ChatGPT && let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await { app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot)); } interval.tick().await; } }); self.rate_limit_poller = Some(handle); } fn lower_cost_preset(&self) -> Option { let models = self.models_manager.try_list_models(&self.config).ok()?; models .iter() .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) .cloned() } fn rate_limit_switch_prompt_hidden(&self) -> bool { self.config .notices .hide_rate_limit_model_nudge .unwrap_or(false) } fn maybe_show_pending_rate_limit_prompt(&mut self) { if self.rate_limit_switch_prompt_hidden() { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; return; } if !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Pending ) { return; } if let Some(preset) = self.lower_cost_preset() { self.open_rate_limit_switch_prompt(preset); self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; } else { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; } } fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { let switch_model = preset.model.to_string(); let display_name = preset.display_name.to_string(); let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let switch_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: None, sandbox_policy: None, model: Some(switch_model.clone()), effort: Some(Some(default_effort)), summary: None, })); tx.send(AppEvent::UpdateModel(switch_model.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); })]; let keep_actions: Vec = Vec::new(); let never_actions: Vec = vec![Box::new(|tx| { tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); })]; let description = if preset.description.is_empty() { Some("Uses fewer credits for upcoming turns.".to_string()) } else { Some(preset.description) }; let items = vec![ SelectionItem { name: format!("Switch to {display_name}"), description, selected_description: None, is_current: false, actions: switch_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Keep current model".to_string(), description: None, selected_description: None, is_current: false, actions: keep_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Keep current model (never show again)".to_string(), description: Some( "Hide future rate limit reminders about switching models.".to_string(), ), selected_description: None, is_current: false, actions: never_actions, dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {display_name} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { if !self.is_session_configured() { self.add_info_message( "Model selection is disabled until startup completes.".to_string(), None, ); return; } let presets: Vec = match self.models_manager.try_list_models(&self.config) { Ok(models) => models, Err(_) => { self.add_info_message( "Models are being updated; please try /model again in a moment.".to_string(), None, ); return; } }; self.open_model_popup_with_presets(presets); } fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { let title = title.to_string(); let subtitle = subtitle.to_string(); let mut header = ColumnRenderable::new(); header.push(Line::from(title.bold())); header.push(Line::from(subtitle.dim())); if let Some(warning) = self.model_menu_warning_line() { header.push(warning); } Box::new(header) } fn model_menu_warning_line(&self) -> Option> { let base_url = self.custom_openai_base_url()?; let warning = format!( "Warning: OPENAI_BASE_URL is set to {base_url}. Selecting models may not be supported or work properly." ); Some(Line::from(warning.red())) } fn custom_openai_base_url(&self) -> Option { if !self.config.model_provider.is_openai() { return None; } let base_url = self.config.model_provider.base_url.as_ref()?; let trimmed = base_url.trim(); if trimmed.is_empty() { return None; } let normalized = trimmed.trim_end_matches('/'); if normalized == DEFAULT_OPENAI_BASE_URL { return None; } Some(trimmed.to_string()) } pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { let presets: Vec = presets .into_iter() .filter(|preset| preset.show_in_picker) .collect(); let current_model = self.current_model(); let current_label = presets .iter() .find(|preset| Some(preset.model.as_str()) == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); let (mut auto_presets, other_presets): (Vec, Vec) = presets .into_iter() .partition(|preset| Self::is_auto_model(&preset.model)); if auto_presets.is_empty() { self.open_all_models_popup(other_presets); return; } auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); let mut items: Vec = auto_presets .into_iter() .map(|preset| { let description = (!preset.description.is_empty()).then_some(preset.description.clone()); let model = preset.model.clone(); let actions = Self::model_selection_actions( model.clone(), Some(preset.default_reasoning_effort), ); SelectionItem { name: preset.display_name.clone(), description, is_current: Some(model.as_str()) == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, ..Default::default() } }) .collect(); if !other_presets.is_empty() { let all_models = other_presets; let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::OpenAllModelsPopup { models: all_models.clone(), }); })]; let is_current = !items.iter().any(|item| item.is_current); let description = Some(format!( "Choose a specific model and reasoning level (current: {current_label})" )); items.push(SelectionItem { name: "All models".to_string(), description, is_current, actions, dismiss_on_select: true, ..Default::default() }); } let header = self.model_menu_header( "Select Model", "Pick a quick auto mode or browse all models.", ); self.bottom_pane.show_selection_view(SelectionViewParams { footer_hint: Some(standard_popup_hint_line()), items, header, ..Default::default() }); } fn is_auto_model(model: &str) -> bool { model.starts_with("codex-auto-") } fn auto_model_order(model: &str) -> usize { match model { "codex-auto-fast" => 0, "codex-auto-balanced" => 1, "codex-auto-thorough" => 2, _ => 3, } } pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { if presets.is_empty() { self.add_info_message( "No additional models are available right now.".to_string(), None, ); return; } let mut items: Vec = Vec::new(); for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); let is_current = Some(preset.model.as_str()) == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { let preset_for_event = preset_for_action.clone(); tx.send(AppEvent::OpenReasoningPopup { model: preset_for_event, }); })]; items.push(SelectionItem { name: preset.display_name.clone(), description, is_current, is_default: preset.is_default, actions, dismiss_on_select: single_supported_effort, ..Default::default() }); } let header = self.model_menu_header( "Select Model and Effort", "Access legacy models by running codex -m or in your config.toml", ); self.bottom_pane.show_selection_view(SelectionViewParams { footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()), items, header, ..Default::default() }); } fn model_selection_actions( model_for_action: String, effort_for_action: Option, ) -> Vec { vec![Box::new(move |tx| { let effort_label = effort_for_action .map(|effort| effort.to_string()) .unwrap_or_else(|| "default".to_string()); tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: None, sandbox_policy: None, model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), effort: effort_for_action, }); tracing::info!( "Selected model: {}, Selected effort: {}", model_for_action, effort_label ); })] } /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let supported = preset.supported_reasoning_efforts; let warn_effort = if supported .iter() .any(|option| option.effort == ReasoningEffortConfig::XHigh) { Some(ReasoningEffortConfig::XHigh) } else if supported .iter() .any(|option| option.effort == ReasoningEffortConfig::High) { Some(ReasoningEffortConfig::High) } else { None }; let warning_text = warn_effort.map(|effort| { let effort_label = Self::reasoning_effort_label(effort); format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") }); let warn_for_model = preset.model.starts_with("gpt-5.1-codex") || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); struct EffortChoice { stored: Option, display: ReasoningEffortConfig, } let mut choices: Vec = Vec::new(); for effort in ReasoningEffortConfig::iter() { if supported.iter().any(|option| option.effort == effort) { choices.push(EffortChoice { stored: Some(effort), display: effort, }); } } if choices.is_empty() { choices.push(EffortChoice { stored: Some(default_effort), display: default_effort, }); } if choices.len() == 1 { if let Some(effort) = choices.first().and_then(|c| c.stored) { self.apply_model_and_effort(preset.model, Some(effort)); } else { self.apply_model_and_effort(preset.model, None); } return; } let default_choice: Option = choices .iter() .any(|choice| choice.stored == Some(default_effort)) .then_some(Some(default_effort)) .flatten() .or_else(|| choices.iter().find_map(|choice| choice.stored)) .or(Some(default_effort)); let model_slug = preset.model.to_string(); let is_current_model = self.current_model() == Some(preset.model.as_str()); let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { default_choice }; let selection_choice = highlight_choice.or(default_choice); let initial_selected_idx = choices .iter() .position(|choice| choice.stored == selection_choice) .or_else(|| { selection_choice .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) }); let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.display; let mut effort_label = Self::reasoning_effort_label(effort).to_string(); if choice.stored == default_choice { effort_label.push_str(" (default)"); } let description = choice .stored .and_then(|effort| { supported .iter() .find(|option| option.effort == effort) .map(|option| option.description.to_string()) }) .filter(|text| !text.is_empty()); let show_warning = warn_for_model && warn_effort == Some(effort); let selected_description = if show_warning { warning_text.as_ref().map(|warning_message| { description.as_ref().map_or_else( || warning_message.clone(), |d| format!("{d}\n{warning_message}"), ) }) } else { None }; let model_for_action = model_slug.clone(); let actions = Self::model_selection_actions(model_for_action, choice.stored); items.push(SelectionItem { name: effort_label, description, selected_description, is_current: is_current_model && choice.stored == highlight_choice, actions, dismiss_on_select: true, ..Default::default() }); } let mut header = ColumnRenderable::new(); header.push(Line::from( format!("Select Reasoning Level for {model_slug}").bold(), )); self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx, ..Default::default() }); } fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { match effort { ReasoningEffortConfig::None => "None", ReasoningEffortConfig::Minimal => "Minimal", ReasoningEffortConfig::Low => "Low", ReasoningEffortConfig::Medium => "Medium", ReasoningEffortConfig::High => "High", ReasoningEffortConfig::XHigh => "Extra high", } } fn apply_model_and_effort(&self, model: String, effort: Option) { self.app_event_tx .send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: None, sandbox_policy: None, model: Some(model.clone()), effort: Some(effort), summary: None, })); self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); self.app_event_tx .send(AppEvent::UpdateReasoningEffort(effort)); self.app_event_tx.send(AppEvent::PersistModelSelection { model: model.clone(), effort, }); tracing::info!( "Selected model: {}, Selected effort: {}", model, effort .map(|e| e.to_string()) .unwrap_or_else(|| "default".to_string()) ); } /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). pub(crate) fn open_approvals_popup(&mut self) { let current_approval = self.config.approval_policy.value(); let current_sandbox = self.config.sandbox_policy.get(); let mut items: Vec = Vec::new(); let presets: Vec = builtin_approval_presets(); #[cfg(target_os = "windows")] let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some() && !codex_core::is_windows_elevated_sandbox_enabled(); #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && windows_degraded_sandbox_enabled && presets.iter().any(|preset| preset.id == "auto"); for preset in presets.into_iter() { let is_current = Self::preset_matches_current(current_approval, current_sandbox, &preset); let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { "Agent (non-elevated sandbox)".to_string() } else { preset.label.to_string() }; let description = Some(preset.description.to_string()); let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) { Ok(()) => None, Err(err) => Some(err.to_string()), }; let requires_confirmation = preset.id == "full-access" && !self .config .notices .hide_full_access_warning .unwrap_or(false); let actions: Vec = if requires_confirmation { let preset_clone = preset.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenFullAccessConfirmation { preset: preset_clone.clone(), }); })] } else if preset.id == "auto" { #[cfg(target_os = "windows")] { if codex_core::get_platform_sandbox().is_none() { let preset_clone = preset.clone(); if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && codex_core::windows_sandbox::sandbox_setup_is_complete( self.config.codex_home.as_path(), ) { vec![Box::new(move |tx| { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset: preset_clone.clone(), mode: WindowsSandboxEnableMode::Elevated, }); })] } else { vec![Box::new(move |tx| { tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { preset: preset_clone.clone(), }); })] } } else if let Some((sample_paths, extra_count, failed_scan)) = self.world_writable_warning_details() { let preset_clone = preset.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset_clone.clone()), sample_paths: sample_paths.clone(), extra_count, failed_scan, }); })] } else { Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) } } #[cfg(not(target_os = "windows"))] { Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) } } else { Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) }; items.push(SelectionItem { name, description, is_current, actions, dismiss_on_select: true, disabled_reason, ..Default::default() }); } let footer_note = show_elevate_sandbox_hint.then(|| { vec![ "The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(), "/setup-elevated-sandbox".cyan(), ".".dim(), ] .into() }); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select Approval Mode".to_string()), footer_note, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(()), ..Default::default() }); } pub(crate) fn open_experimental_popup(&mut self) { let features: Vec = FEATURES .iter() .filter_map(|spec| { let name = spec.stage.beta_menu_name()?; let description = spec.stage.beta_menu_description()?; Some(BetaFeatureItem { feature: spec.id, name: name.to_string(), description: description.to_string(), enabled: self.config.features.enabled(spec.id), }) }) .collect(); let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone()); self.bottom_pane.show_view(Box::new(view)); } fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, ) -> Vec { vec![Box::new(move |tx| { let sandbox_clone = sandbox.clone(); tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: Some(approval), sandbox_policy: Some(sandbox_clone.clone()), model: None, effort: None, summary: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); })] } fn preset_matches_current( current_approval: AskForApproval, current_sandbox: &SandboxPolicy, preset: &ApprovalPreset, ) -> bool { if current_approval != preset.approval { return false; } matches!( (&preset.sandbox, current_sandbox), (SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly) | ( SandboxPolicy::DangerFullAccess, SandboxPolicy::DangerFullAccess ) | ( SandboxPolicy::WorkspaceWrite { .. }, SandboxPolicy::WorkspaceWrite { .. } ) ) } #[cfg(target_os = "windows")] pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { if self .config .notices .hide_world_writable_warning .unwrap_or(false) { return None; } let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); match codex_windows_sandbox::apply_world_writable_scan_and_denies( self.config.codex_home.as_path(), cwd.as_path(), &env_map, self.config.sandbox_policy.get(), Some(self.config.codex_home.as_path()), ) { Ok(_) => None, Err(_) => Some((Vec::new(), 0, true)), } } #[cfg(not(target_os = "windows"))] #[allow(dead_code)] pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { None } pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { let approval = preset.approval; let sandbox = preset.sandbox; let mut header_children: Vec> = Vec::new(); let title_line = Line::from("Enable full access?").bold(); let info_line = Line::from(vec![ "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " .into(), "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." .fg(Color::Red), ]); header_children.push(Box::new(title_line)); header_children.push(Box::new( Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), )); let header = ColumnRenderable::with(header_children); let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); tx.send(AppEvent::PersistFullAccessWarningAcknowledged); })); let deny_actions: Vec = vec![Box::new(|tx| { tx.send(AppEvent::OpenApprovalsPopup); })]; let items = vec![ SelectionItem { name: "Yes, continue anyway".to_string(), description: Some("Apply full access for this session".to_string()), actions: accept_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Yes, and don't ask again".to_string(), description: Some("Enable full access and remember this choice".to_string()), actions: accept_and_remember_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Cancel".to_string(), description: Some("Go back without enabling full access".to_string()), actions: deny_actions, dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), ..Default::default() }); } #[cfg(target_os = "windows")] pub(crate) fn open_world_writable_warning_confirmation( &mut self, preset: Option, sample_paths: Vec, extra_count: usize, failed_scan: bool, ) { let (approval, sandbox) = match &preset { Some(p) => (Some(p.approval), Some(p.sandbox.clone())), None => (None, None), }; let mut header_children: Vec> = Vec::new(); let describe_policy = |policy: &SandboxPolicy| match policy { SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", SandboxPolicy::ReadOnly => "Read-Only mode", _ => "Agent mode", }; let mode_label = preset .as_ref() .map(|p| describe_policy(&p.sandbox)) .unwrap_or_else(|| describe_policy(self.config.sandbox_policy.get())); let info_line = if failed_scan { Line::from(vec![ "We couldn't complete the world-writable scan, so protections cannot be verified. " .into(), format!("The Windows sandbox cannot guarantee protection in {mode_label}.") .fg(Color::Red), ]) } else { Line::from(vec![ "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), " Consider removing write access for Everyone from the following folders:".into(), ]) }; header_children.push(Box::new( Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), )); if !sample_paths.is_empty() { // Show up to three examples and optionally an "and X more" line. let mut lines: Vec = Vec::new(); lines.push(Line::from("")); for p in &sample_paths { lines.push(Line::from(format!(" - {p}"))); } if extra_count > 0 { lines.push(Line::from(format!("and {extra_count} more"))); } header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); } let header = ColumnRenderable::with(header_children); // Build actions ensuring acknowledgement happens before applying the new sandbox policy, // so downstream policy-change hooks don't re-trigger the warning. let mut accept_actions: Vec = Vec::new(); // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals), // to avoid duplicate warnings from the ensuing policy change. if preset.is_some() { accept_actions.push(Box::new(|tx| { tx.send(AppEvent::SkipNextWorldWritableScan); })); } if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); } let mut accept_and_remember_actions: Vec = Vec::new(); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); if let (Some(approval), Some(sandbox)) = (approval, sandbox) { accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); } let items = vec![ SelectionItem { name: "Continue".to_string(), description: Some(format!("Apply {mode_label} for this session")), actions: accept_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Continue and don't warn again".to_string(), description: Some(format!("Enable {mode_label} and remember this choice")), actions: accept_and_remember_actions, dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), ..Default::default() }); } #[cfg(not(target_os = "windows"))] pub(crate) fn open_world_writable_warning_confirmation( &mut self, _preset: Option, _sample_paths: Vec, _extra_count: usize, _failed_scan: bool, ) { } #[cfg(target_os = "windows")] pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { use ratatui_macros::line; if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { // Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it // directly (no elevation prompts). let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], line!["Learn more: https://developers.openai.com/codex/windows"], ]) .wrap(Wrap { trim: false }), )); let preset_clone = preset; let items = vec![ SelectionItem { name: "Enable experimental sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset: preset_clone.clone(), mode: WindowsSandboxEnableMode::Legacy, }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Go back".to_string(), description: None, actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenApprovalsPopup); })], dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: None, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), ..Default::default() }); return; } let current_approval = self.config.approval_policy.value(); let current_sandbox = self.config.sandbox_policy.get(); let presets = builtin_approval_presets(); let stay_full_access = presets .iter() .find(|preset| preset.id == "full-access") .is_some_and(|preset| { Self::preset_matches_current(current_approval, current_sandbox, preset) }); let stay_actions = if stay_full_access { Vec::new() } else { presets .iter() .find(|preset| preset.id == "read-only") .map(|preset| { Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) }) .unwrap_or_default() }; let stay_label = if stay_full_access { "Stay in Agent Full Access".to_string() } else { "Stay in Read-Only".to_string() }; let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ line!["Set Up Agent Sandbox".bold()], line![""], line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."], line!["Learn more: https://developers.openai.com/codex/windows"], ]) .wrap(Wrap { trim: false }), )); let items = vec![ SelectionItem { name: "Set up agent sandbox (requires elevation)".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: stay_label, description: None, actions: stay_actions, dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: None, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), ..Default::default() }); } #[cfg(not(target_os = "windows"))] pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} #[cfg(target_os = "windows")] pub(crate) fn open_windows_sandbox_fallback_prompt( &mut self, preset: ApprovalPreset, reason: WindowsSandboxFallbackReason, ) { use ratatui_macros::line; let _ = reason; let current_approval = self.config.approval_policy.value(); let current_sandbox = self.config.sandbox_policy.get(); let presets = builtin_approval_presets(); let stay_full_access = presets .iter() .find(|preset| preset.id == "full-access") .is_some_and(|preset| { Self::preset_matches_current(current_approval, current_sandbox, preset) }); let stay_actions = if stay_full_access { Vec::new() } else { presets .iter() .find(|preset| preset.id == "read-only") .map(|preset| { Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) }) .unwrap_or_default() }; let stay_label = if stay_full_access { "Stay in Agent Full Access".to_string() } else { "Stay in Read-Only".to_string() }; let mut lines = Vec::new(); lines.push(line!["Use Non-Elevated Sandbox?".bold()]); lines.push(line![""]); lines.push(line![ "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." ]); lines.push(line![ "Learn more: https://developers.openai.com/codex/windows" ]); let mut header = ColumnRenderable::new(); header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); let elevated_preset = preset.clone(); let legacy_preset = preset; let items = vec![ SelectionItem { name: "Try elevated agent sandbox setup again".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: elevated_preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Use non-elevated agent sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset: legacy_preset.clone(), mode: WindowsSandboxEnableMode::Legacy, }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: stay_label, description: None, actions: stay_actions, dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: None, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), ..Default::default() }); } #[cfg(not(target_os = "windows"))] pub(crate) fn open_windows_sandbox_fallback_prompt( &mut self, _preset: ApprovalPreset, _reason: WindowsSandboxFallbackReason, ) { } #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { if self.config.forced_auto_mode_downgraded_on_windows && codex_core::get_platform_sandbox().is_none() && let Some(preset) = builtin_approval_presets() .into_iter() .find(|preset| preset.id == "auto") { self.open_windows_sandbox_enable_prompt(preset); } } #[cfg(not(target_os = "windows"))] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} #[cfg(target_os = "windows")] pub(crate) fn show_windows_sandbox_setup_status(&mut self) { // While elevated sandbox setup runs, prevent typing so the user doesn't // accidentally queue messages that will run under an unexpected mode. self.bottom_pane.set_composer_input_enabled( false, Some("Input disabled until setup completes.".to_string()), ); self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string()); self.request_redraw(); } #[cfg(not(target_os = "windows"))] #[allow(dead_code)] pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} #[cfg(target_os = "windows")] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { self.bottom_pane.set_composer_input_enabled(true, None); self.bottom_pane.hide_status_indicator(); self.request_redraw(); } #[cfg(not(target_os = "windows"))] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} #[cfg(target_os = "windows")] pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { self.config.forced_auto_mode_downgraded_on_windows = false; } #[cfg(not(target_os = "windows"))] #[allow(dead_code)] pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {} /// Set the approval policy in the widget's config copy. pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { if let Err(err) = self.config.approval_policy.set(policy) { tracing::warn!(%err, "failed to set approval_policy on chat config"); } } /// Set the sandbox policy in the widget's config copy. pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> { #[cfg(target_os = "windows")] let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly) || codex_core::get_platform_sandbox().is_some(); self.config.sandbox_policy.set(policy)?; #[cfg(target_os = "windows")] if should_clear_downgrade { self.config.forced_auto_mode_downgraded_on_windows = false; } Ok(()) } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { if enabled { self.config.features.enable(feature); } else { self.config.features.disable(feature); } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { self.config.notices.hide_full_access_warning = Some(acknowledged); } pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { self.config.notices.hide_world_writable_warning = Some(acknowledged); } pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { self.config.notices.hide_rate_limit_model_nudge = Some(hidden); if hidden { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; } } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn world_writable_warning_hidden(&self) -> bool { self.config .notices .hide_world_writable_warning .unwrap_or(false) } /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; } /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); self.model = Some(model.to_string()); } fn current_model(&self) -> Option<&str> { self.model.as_deref() } fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } /// Build a placeholder header cell while the session is configuring. fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), placeholder_style, None, config.cwd.clone(), CODEX_CLI_VERSION, )) } /// Merge the real session info cell with any placeholder header to avoid double boxes. fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) { let mut session_info_cell = Some(Box::new(cell) as Box); let merged_header = if let Some(active) = self.active_cell.take() { if active .as_any() .is::() { // Reuse the existing placeholder header to avoid rendering two boxes. if let Some(cell) = session_info_cell.take() { self.active_cell = Some(cell); } true } else { self.active_cell = Some(active); false } } else { false }; self.flush_active_cell(); if !merged_header && let Some(cell) = session_info_cell { self.add_boxed_history(cell); } } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { self.add_to_history(history_cell::new_info_event(message, hint)); self.request_redraw(); } pub(crate) fn add_plain_history_lines(&mut self, lines: Vec>) { self.add_boxed_history(Box::new(PlainHistoryCell::new(lines))); self.request_redraw(); } pub(crate) fn add_error_message(&mut self, message: String) { self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); } pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); } else { self.submit_op(Op::ListMcpTools); } } /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); } /// Handles a Ctrl+C press at the chat-widget layer. /// /// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut /// is armed. /// /// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first /// quit. fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if modal_or_popup_active { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); } else { self.arm_quit_shortcut(key); } } return; } if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if self.is_cancellable_work_active() { self.submit_op(Op::Interrupt); } else { self.request_quit_without_confirmation(); } return; } if self.quit_shortcut_active_for(key) { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.request_quit_without_confirmation(); return; } self.arm_quit_shortcut(key); if self.is_cancellable_work_active() { self.submit_op(Op::Interrupt); } } /// Handles a Ctrl+D press at the chat-widget layer. /// /// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active. /// Otherwise it should be routed to the active view and not attempt to quit. fn on_ctrl_d(&mut self) -> bool { let key = key_hint::ctrl(KeyCode::Char('d')); if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() { return false; } self.request_quit_without_confirmation(); return true; } if self.quit_shortcut_active_for(key) { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.request_quit_without_confirmation(); return true; } if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() { return false; } self.arm_quit_shortcut(key); true } /// True if `key` matches the armed quit shortcut and the window has not expired. fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool { self.quit_shortcut_key == Some(key) && self .quit_shortcut_expires_at .is_some_and(|expires_at| Instant::now() < expires_at) } /// Arm the double-press quit shortcut and show the footer hint. /// /// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since /// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether /// quitting is currently allowed, while delegating rendering to `BottomPane`. fn arm_quit_shortcut(&mut self, key: KeyBinding) { self.quit_shortcut_expires_at = Instant::now() .checked_add(QUIT_SHORTCUT_TIMEOUT) .or_else(|| Some(Instant::now())); self.quit_shortcut_key = Some(key); self.bottom_pane.show_quit_shortcut_hint(key); } // Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting. fn is_cancellable_work_active(&self) -> bool { self.bottom_pane.is_task_running() || self.is_review_mode } pub(crate) fn composer_is_empty(&self) -> bool { self.bottom_pane.composer_is_empty() } /// True when the UI is in the regular composer state with no running task, /// no modal overlay (e.g. approvals or status indicator), and no composer popups. /// In this state Esc-Esc backtracking is enabled. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { self.bottom_pane.is_normal_backtrack_mode() } pub(crate) fn insert_str(&mut self, text: &str) { self.bottom_pane.insert_str(text); } /// Replace the composer content with the provided text and reset cursor. pub(crate) fn set_composer_text(&mut self, text: String) { self.bottom_pane.set_composer_text(text); } pub(crate) fn show_esc_backtrack_hint(&mut self) { self.bottom_pane.show_esc_backtrack_hint(); } pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&mut self, op: Op) { // Record outbound operation for session replay fidelity. crate::session_log::log_outbound_op(&op); if matches!(&op, Op::Review { .. }) && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(true); } if let Err(e) = self.codex_op_tx.send(op) { tracing::error!("failed to submit op: {e}"); } } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, ev.tools, ev.resources, ev.resource_templates, &ev.auth_statuses, )); } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { let len = ev.custom_prompts.len(); debug!("received {len} custom prompts"); // Forward to bottom pane so the slash popup can show them now. self.bottom_pane.set_custom_prompts(ev.custom_prompts); } fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { self.set_skills_from_response(&ev); } pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); items.push(SelectionItem { name: "Review against a base branch".to_string(), description: Some("(PR Style)".into()), actions: vec![Box::new({ let cwd = self.config.cwd.clone(); move |tx| { tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); } })], dismiss_on_select: false, ..Default::default() }); items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { tx.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { target: ReviewTarget::UncommittedChanges, user_facing_hint: None, }, })); })], dismiss_on_select: true, ..Default::default() }); // New: Review a specific commit (opens commit picker) items.push(SelectionItem { name: "Review a commit".to_string(), actions: vec![Box::new({ let cwd = self.config.cwd.clone(); move |tx| { tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); } })], dismiss_on_select: false, ..Default::default() }); items.push(SelectionItem { name: "Custom review instructions".to_string(), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenReviewCustomPrompt); })], dismiss_on_select: false, ..Default::default() }); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a review preset".into()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { let branches = local_git_branches(cwd).await; let current_branch = current_branch_name(cwd) .await .unwrap_or_else(|| "(detached HEAD)".to_string()); let mut items: Vec = Vec::with_capacity(branches.len()); for option in branches { let branch = option.clone(); items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { tx3.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { target: ReviewTarget::BaseBranch { branch: branch.clone(), }, user_facing_hint: None, }, })); })], dismiss_on_select: true, search_value: Some(option), ..Default::default() }); } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a base branch".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search branches".to_string()), ..Default::default() }); } pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { let commits = codex_core::git_info::recent_commits(cwd, 100).await; let mut items: Vec = Vec::with_capacity(commits.len()); for entry in commits { let subject = entry.subject.clone(); let sha = entry.sha.clone(); let search_val = format!("{subject} {sha}"); items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { tx3.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }, user_facing_hint: None, }, })); })], dismiss_on_select: true, search_value: Some(search_val), ..Default::default() }); } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a commit to review".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), ..Default::default() }); } pub(crate) fn show_review_custom_prompt(&mut self) { let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( "Custom review instructions".to_string(), "Type instructions and press Enter".to_string(), None, Box::new(move |prompt: String| { let trimmed = prompt.trim().to_string(); if trimmed.is_empty() { return; } tx.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: trimmed, }, user_facing_hint: None, }, })); }), ); self.bottom_pane.show_view(Box::new(view)); } pub(crate) fn token_usage(&self) -> TokenUsage { self.token_info .as_ref() .map(|ti| ti.total_token_usage.clone()) .unwrap_or_default() } pub(crate) fn thread_id(&self) -> Option { self.thread_id } fn is_session_configured(&self) -> bool { self.thread_id.is_some() } pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } /// Returns a cache key describing the current in-flight active cell for the transcript overlay. /// /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the /// current active cell, and the overlay caches that tail; this key is what it uses to decide /// whether it must recompute. When there is no active cell, this returns `None` so the overlay /// can drop the tail entirely. /// /// If callers mutate the active cell's transcript output without bumping the revision (or /// providing an appropriate animation tick), the overlay will keep showing a stale tail while /// the main viewport updates. pub(crate) fn active_cell_transcript_key(&self) -> Option { let cell = self.active_cell.as_ref()?; Some(ActiveCellTranscriptKey { revision: self.active_cell_revision, is_stream_continuation: cell.is_stream_continuation(), animation_tick: cell.transcript_animation_tick(), }) } /// Returns the active cell's transcript lines for a given terminal width. /// /// This is a convenience for the transcript overlay live-tail path, and it intentionally /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers /// should pass the same width the overlay uses; using a different width will cause wrapping /// mismatches between the main viewport and the transcript overlay. pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { let cell = self.active_cell.as_ref()?; let lines = cell.transcript_lines(width); (!lines.is_empty()).then_some(lines) } /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { &self.config } pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; } fn as_renderable(&self) -> RenderableItem<'_> { let active_cell_renderable = match &self.active_cell { Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), None => RenderableItem::Owned(Box::new(())), }; let mut flex = FlexRenderable::new(); flex.push(1, active_cell_renderable); flex.push( 0, RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), ); RenderableItem::Owned(Box::new(flex)) } } impl Drop for ChatWidget { fn drop(&mut self) { self.stop_rate_limit_poller(); } } impl Renderable for ChatWidget { fn render(&self, area: Rect, buf: &mut Buffer) { self.as_renderable().render(area, buf); self.last_rendered_width.set(Some(area.width as usize)); } fn desired_height(&self, width: u16) -> u16 { self.as_renderable().desired_height(width) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } } enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, ElicitationRequested { server_name: String }, } impl Notification { fn display(&self) -> String { match self { Notification::AgentTurnComplete { response } => { Notification::agent_turn_preview(response) .unwrap_or_else(|| "Agent turn complete".to_string()) } Notification::ExecApprovalRequested { command } => { format!("Approval requested: {}", truncate_text(command, 30)) } Notification::EditApprovalRequested { cwd, changes } => { format!( "Codex wants to edit {}", if changes.len() == 1 { #[allow(clippy::unwrap_used)] display_path_for(changes.first().unwrap(), cwd) } else { format!("{} files", changes.len()) } ) } Notification::ElicitationRequested { server_name } => { format!("Approval requested by {server_name}") } } } fn type_name(&self) -> &str { match self { Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } | Notification::ElicitationRequested { .. } => "approval-requested", } } fn allowed_for(&self, settings: &Notifications) -> bool { match settings { Notifications::Enabled(enabled) => *enabled, Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), } } fn agent_turn_preview(response: &str) -> Option { let mut normalized = String::new(); for part in response.split_whitespace() { if !normalized.is_empty() { normalized.push(' '); } normalized.push_str(part); } let trimmed = normalized.trim(); if trimmed.is_empty() { None } else { Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) } } } const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; const PLACEHOLDERS: [&str; 8] = [ "Explain this codebase", "Summarize recent commits", "Implement {feature}", "Find and fix a bug in @filename", "Write tests for @filename", "Improve documentation in @filename", "Run /review on my current changes", "Use /skills to list available skills", ]; // Extract the first bold (Markdown) element in the form **...** from `s`. // Returns the inner text if found; otherwise `None`. fn extract_first_bold(s: &str) -> Option { let bytes = s.as_bytes(); let mut i = 0usize; while i + 1 < bytes.len() { if bytes[i] == b'*' && bytes[i + 1] == b'*' { let start = i + 2; let mut j = start; while j + 1 < bytes.len() { if bytes[j] == b'*' && bytes[j + 1] == b'*' { // Found closing ** let inner = &s[start..j]; let trimmed = inner.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } else { return None; } } j += 1; } // No closing; stop searching (wait for more deltas) return None; } i += 1; } None } async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { match BackendClient::from_auth(base_url, &auth) { Ok(client) => match client.get_rate_limits().await { Ok(snapshot) => Some(snapshot), Err(err) => { debug!(error = ?err, "failed to fetch rate limits from /usage"); None } }, Err(err) => { debug!(error = ?err, "failed to construct backend client for rate limits"); None } } } #[cfg(test)] pub(crate) fn show_review_commit_picker_with_entries( chat: &mut ChatWidget, entries: Vec, ) { let mut items: Vec = Vec::with_capacity(entries.len()); for entry in entries { let subject = entry.subject.clone(); let sha = entry.sha.clone(); let search_val = format!("{subject} {sha}"); items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { tx3.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }, user_facing_hint: None, }, })); })], dismiss_on_select: true, search_value: Some(search_val), ..Default::default() }); } chat.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a commit to review".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), ..Default::default() }); } fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec { skills_entries .iter() .find(|entry| entry.cwd.as_path() == cwd) .map(|entry| { entry .skills .iter() .map(|skill| SkillMetadata { name: skill.name.clone(), description: skill.description.clone(), short_description: skill.short_description.clone(), interface: skill.interface.clone().map(|interface| SkillInterface { display_name: interface.display_name, short_description: interface.short_description, icon_small: interface.icon_small, icon_large: interface.icon_large, brand_color: interface.brand_color, default_prompt: interface.default_prompt, }), path: skill.path.clone(), scope: skill.scope, }) .collect() }) .unwrap_or_default() } fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { let mut seen: HashSet = HashSet::new(); let mut matches: Vec = Vec::new(); for skill in skills { if seen.contains(&skill.name) { continue; } let needle = format!("${}", skill.name); if text.contains(&needle) { seen.insert(skill.name.clone()); matches.push(skill.clone()); } } matches } #[cfg(test)] pub(crate) mod tests;