//! 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`. //! //! For preamble-capable models, assistant output may include commentary before //! the final answer. During streaming we hide the status row to avoid duplicate //! progress indicators; once commentary completes and stream queues drain, we //! re-show it so users still see turn-in-progress state between output bursts. //! //! Slash-command parsing lives in the bottom-pane composer, but slash-command acceptance lives //! here. That split lets the composer stage a recall entry before clearing input while this module //! records the attempted slash command after dispatch just like ordinary submitted text. use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; use std::ops::Deref; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::app_command::AppCommand; use crate::app_event::HistoryLookupResponse; use crate::app_event::RealtimeAudioDeviceKind; use crate::app_server_approval_conversions::file_update_changes_to_display; use crate::approval_events::ApplyPatchApprovalRequestEvent; use crate::approval_events::ExecApprovalRequestEvent; #[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLineSetupView; use crate::bottom_pane::StatusSurfacePreviewData; use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; use crate::diff_model::FileChange; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; use crate::legacy_core::config::Constrained; use crate::legacy_core::config::ConstraintResult; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::model_catalog::ModelCatalog; use crate::multi_agents; use crate::multi_agents::AgentMetadata; use crate::session_state::SessionNetworkProxyRuntime; use crate::session_state::ThreadSessionState; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::CreditsSnapshot; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::GuardianApprovalReviewAction; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::ModelVerification as AppServerModelVerification; use codex_app_server_protocol::RateLimitReachedType; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillMetadata as ProtocolSkillMetadata; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::TextElement as AppServerTextElement; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnPlanStepStatus; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_chatgpt::connectors; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; use codex_config::types::WindowsSandboxModeToml; use codex_core_skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; #[cfg(test)] use codex_git_utils::CommitLogEntry; use codex_git_utils::current_branch_name; use codex_git_utils::get_git_repo_root; use codex_git_utils::local_git_branches; use codex_git_utils::recent_commits; use codex_otel::RuntimeMetricsSummary; use codex_otel::SessionTelemetry; use codex_plugin::PluginCapabilitySummary; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::GuardianAssessmentDecisionSource; use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::approvals::GuardianAssessmentStatus; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_sleep_inhibitor::SleepInhibitor; 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 tracing::debug; use tracing::warn; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; const MULTI_AGENT_ENABLE_NO: &str = "Not now"; const MULTI_AGENT_ENABLE_NOTICE: &str = "Subagents will be enabled in the next session."; const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING: &str = "Your conversations have multiple flags for possible cybersecurity risk. Responses may take longer because extra safety checks are on. To get authorized for security work, join the Trusted Access for Cyber program: https://chatgpt.com/cyber"; const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories"; const MEMORIES_ENABLE_TITLE: &str = "Enable memories?"; const MEMORIES_ENABLE_YES: &str = "Yes, enable"; const MEMORIES_ENABLE_NO: &str = "Not now"; const MEMORIES_ENABLE_NOTICE: &str = "Memories will be enabled in the next session."; const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently /// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall /// back to Shift+Left for those environments while keeping the more discoverable /// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { if matches!( terminal_info.multiplexer.as_ref(), Some(Multiplexer::Tmux { .. }) ) { return key_hint::shift(KeyCode::Left); } match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } TerminalName::Ghostty | TerminalName::Iterm2 | TerminalName::WezTerm | TerminalName::Kitty | TerminalName::Alacritty | TerminalName::Konsole | TerminalName::GnomeTerminal | TerminalName::Vte | TerminalName::WindowsTerminal | TerminalName::Dumb | TerminalName::Unknown => key_hint::alt(KeyCode::Up), } } fn queued_message_edit_hint_binding( bindings: &[KeyBinding], terminal_info: TerminalInfo, ) -> Option { let terminal_binding = queued_message_edit_binding_for_terminal(terminal_info); bindings .contains(&terminal_binding) .then_some(terminal_binding) .or_else(|| bindings.first().copied()) } use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; use crate::app_event::RateLimitRefreshOrigin; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::auto_review_denials; use crate::auto_review_denials::RecentAutoReviewDenials; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CollaborationModeIndicator; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::GoalStatusIndicator; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::McpServerElicitationFormRequest; use crate::bottom_pane::MemoriesSettingsView; use crate::bottom_pane::MentionBinding; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::QueuedInputAction; 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::collaboration_modes; 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::split_command_string; 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::HistoryCell; use crate::history_cell::HistoryRenderMode; use crate::history_cell::HookCell; use crate::history_cell::McpInvocation; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; use crate::keymap::ChatKeymap; use crate::keymap::RuntimeKeymap; 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::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod goal_status; use self::goal_status::GoalStatusState; #[cfg(test)] use self::goal_status::goal_status_indicator_from_app_goal; mod goal_menu; mod goal_validation; mod ide_context; use self::ide_context::IdeContextState; mod interrupts; use self::interrupts::InterruptManager; mod keymap_picker; mod mcp_startup; use self::mcp_startup::McpStartupStatus; mod session_header; use self::session_header::SessionHeader; mod hooks; mod skills; mod slash_dispatch; use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; mod plugins; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod realtime; use self::realtime::RealtimeConversationUiState; mod reasoning_shortcuts; mod side; mod status_surfaces; use self::status_surfaces::CachedProjectRootName; use self::status_surfaces::TerminalTitleStatusKind; mod user_messages; use self::user_messages::PendingSteerCompareKey; use self::user_messages::UserMessageDisplay; mod warnings; use self::warnings::WarningDisplayState; pub(crate) use crate::branch_summary::StatusLineGitSummary; use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::streaming::commit_tick::CommitTickScope; use crate::streaming::commit_tick::run_commit_tick; use crate::streaming::controller::PlanStreamController; use crate::streaming::controller::StreamController; use crate::workspace_command::WorkspaceCommandRunner; use chrono::Local; use codex_app_server_protocol::AskForApproval; use codex_file_search::FileMatch; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_utils_approval_presets::ApprovalPreset; use codex_utils_approval_presets::builtin_approval_presets; use strum::IntoEnumIterator; use unicode_segmentation::UnicodeSegmentation; 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"; const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"]; // Track information about an in-flight exec command. struct RunningCommand { command: Vec, parsed_cmd: Vec, source: ExecCommandSource, } struct UnifiedExecProcessSummary { key: String, call_id: String, command_display: String, recent_chunks: Vec, } 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 { .. })) } fn command_execution_command_and_parsed( command: &str, command_actions: &[codex_app_server_protocol::CommandAction], ) -> (Vec, Vec) { ( split_command_string(command), command_actions .iter() .cloned() .map(codex_app_server_protocol::CommandAction::into_core) .collect(), ) } const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; const MAX_AGENT_COPY_HISTORY: usize = 32; #[derive(Debug)] struct AgentTurnMarkdown { user_turn_count: usize, markdown: String, } #[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, /// App-server-backed runner used by status surfaces for workspace metadata probes. /// /// Tests that do not exercise git status-line refreshes may leave this unset. Production TUI /// construction provides a runner for the active app-server session. pub(crate) workspace_command_runner: Option, pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) has_chatgpt_account: bool, pub(crate) model_catalog: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) status_account_display: Option, pub(crate) runtime_model_provider_base_url: Option, pub(crate) initial_plan_type: Option, pub(crate) model: Option, pub(crate) startup_tooltip_override: Option, // Shared latch so we only warn once about invalid status-line item IDs. pub(crate) status_line_invalid_items_warned: Arc, // Shared latch so we only warn once about invalid terminal-title item IDs. pub(crate) terminal_title_invalid_items_warned: Arc, pub(crate) session_telemetry: SessionTelemetry, } #[derive(Default)] enum RateLimitSwitchPromptState { #[default] Idle, Pending, Shown, } #[derive(Debug, Clone, Default)] enum ConnectorsCacheState { #[default] Uninitialized, Loading, Ready(ConnectorsSnapshot), Failed(String), } #[derive(Debug, Clone, Default)] struct PluginListFetchState { cache_cwd: Option, in_flight_cwd: Option, } #[derive(Debug, Clone)] struct PluginInstallAuthFlowState { plugin_display_name: String, next_app_index: usize, } #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, UsageLimit, Generic, } fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option { match info { AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: Some(429), } => Some(RateLimitErrorKind::Generic), _ => None, } } fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { matches!(info, AppServerCodexErrorInfo::CyberPolicy) } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] Closed, Requested, Active, } #[derive(Clone, Debug, PartialEq, Eq)] struct StatusIndicatorState { header: String, details: Option, details_max_lines: usize, } impl StatusIndicatorState { fn working() -> Self { Self { header: String::from("Working"), details: None, details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, } } fn is_guardian_review(&self) -> bool { self.header == "Reviewing approval request" || self.header.starts_with("Reviewing ") } } #[derive(Clone, Debug, Default, PartialEq, Eq)] struct PendingGuardianReviewStatus { entries: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] struct PendingGuardianReviewStatusEntry { id: String, detail: String, } impl PendingGuardianReviewStatus { fn start_or_update(&mut self, id: String, detail: String) { if let Some(existing) = self.entries.iter_mut().find(|entry| entry.id == id) { existing.detail = detail; } else { self.entries .push(PendingGuardianReviewStatusEntry { id, detail }); } } fn finish(&mut self, id: &str) -> bool { let original_len = self.entries.len(); self.entries.retain(|entry| entry.id != id); self.entries.len() != original_len } fn is_empty(&self) -> bool { self.entries.is_empty() } // Guardian review status is derived from the full set of currently pending // review entries. The generic status cache on `ChatWidget` stores whichever // footer is currently rendered; this helper computes the guardian-specific // footer snapshot that should replace it while reviews remain in flight. fn status_indicator_state(&self) -> Option { let details = if self.entries.len() == 1 { self.entries.first().map(|entry| entry.detail.clone()) } else if self.entries.is_empty() { None } else { let mut lines = self .entries .iter() .take(3) .map(|entry| format!("• {}", entry.detail)) .collect::>(); let remaining = self.entries.len().saturating_sub(3); if remaining > 0 { lines.push(format!("+{remaining} more")); } Some(lines.join("\n")) }; let details = details?; let header = if self.entries.len() == 1 { String::from("Reviewing approval request") } else { format!("Reviewing {} approval requests", self.entries.len()) }; let details_max_lines = if self.entries.len() == 1 { 1 } else { 4 }; Some(StatusIndicatorState { header, details: Some(details), details_max_lines, }) } } /// 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_target: CodexOpTarget, 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, raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. effective_service_tier: Option, /// The unmasked collaboration mode settings (always Default mode). /// /// Masks are applied on top of this base mode to derive the effective mode. current_collaboration_mode: CollaborationMode, /// The currently active collaboration mask, if any. active_collaboration_mask: Option, has_chatgpt_account: bool, model_catalog: Arc, session_telemetry: SessionTelemetry, session_header: SessionHeader, initial_user_message: Option, status_account_display: Option, runtime_model_provider_base_url: Option, token_info: Option, rate_limit_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, warning_display_state: WarningDisplayState, rate_limit_switch_prompt: RateLimitSwitchPromptState, add_credits_nudge_email_in_flight: Option, adaptive_chunking: AdaptiveChunkingPolicy, // Stream lifecycle controller stream_controller: Option, // Stream lifecycle controller for proposed plan output. plan_stream_controller: Option, /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, copy_last_response_binding: Vec, /// Raw markdown of the most recently completed agent response that /// survived any local thread rollback. last_agent_markdown: Option, /// Copyable agent responses keyed by the number of visible user turns at /// the time the response completed. agent_turn_markdowns: Vec, /// Number of user turns currently reflected in the visible transcript. visible_user_turn_count: usize, /// True when rollback discarded the requested copy source because it was /// older than the retained copy history. copy_history_evicted_by_rollback: bool, /// Raw markdown of the most recently completed proposed plan. /// /// This is cached only for the approval popup. It is reset at the start of each new task so the /// fresh-context action cannot accidentally submit an older plan after a later turn begins. latest_proposed_plan_markdown: Option, /// Whether this turn already produced a copyable response. /// /// `TurnComplete.last_agent_message` is a fallback source: use it only when no earlier /// agent/plan/review item recorded copyable markdown for the turn. This gives item-level /// sources precedence and avoids duplicating the same final answer when both event shapes are /// emitted. saw_copy_source_this_turn: bool, running_commands: HashMap, collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, skills_initial_state: Option>, last_unified_wait: Option, unified_exec_wait_streak: Option, turn_sleep_inhibitor: SleepInhibitor, 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 startup status update until the /// app-server-backed startup round settles, and the bottom pane is treated /// as "running" while this is populated, even if no agent turn is currently /// executing. mcp_startup_status: Option>, /// Expected MCP servers for the current startup round, seeded from enabled local config. mcp_startup_expected_servers: Option>, /// After startup settles, ignore stale updates until enough notifications confirm a new round. mcp_startup_ignore_updates_until_next_start: bool, /// A lag signal for the next round means terminal-only updates are enough to settle it. mcp_startup_allow_terminal_only_next_round: bool, /// Buffers post-settle MCP startup updates until they cover a full fresh round. mcp_startup_pending_next_round: HashMap, /// Tracks whether the buffered next round has seen any `Starting` update yet. mcp_startup_pending_next_round_saw_starting: bool, connectors_cache: ConnectorsCacheState, connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, connectors_force_refetch_pending: bool, ide_context: IdeContextState, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, plugin_install_apps_needing_auth: Vec, plugin_install_auth_flow: Option, plugins_active_tab_id: Option, newly_installed_marketplace_tab_id: 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, // The currently rendered footer state. We keep the already-formatted // details here so transient stream interruptions can restore the footer // exactly as it was shown. current_status: StatusIndicatorState, // Guardian review keeps its own pending set so it can derive a single // footer summary from one or more in-flight review events. pending_guardian_review_status: PendingGuardianReviewStatus, recent_auto_review_denials: RecentAutoReviewDenials, // Active hook runs render in a dedicated live cell so they can run alongside tools. active_hook_cell: Option, // Semantic status used for terminal-title status rendering. terminal_title_status_kind: TerminalTitleStatusKind, // Previous status header to restore after a transient stream retry. retry_status_header: Option, // Set when commentary output completes; once stream queues go idle we restore the status row. pending_status_indicator_restore: bool, suppress_queue_autosend: bool, thread_id: Option, /// Nudge dismissals that should survive draft edits within the current thread scope. /// /// The nudge is only a discovery aid, so once a user dismisses it or enters Plan mode we keep it /// hidden for that thread instead of resurfacing it on every matching draft. dismissed_plan_mode_nudge_scopes: HashSet, last_turn_id: Option, budget_limited_turn_ids: HashSet, thread_name: Option, thread_rename_block_message: Option, active_side_conversation: bool, normal_placeholder_text: String, side_placeholder_text: String, forked_from: Option, interrupted_turn_notice_mode: InterruptedTurnNoticeMode, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, // One-shot tooltip override for the primary startup session. startup_tooltip_override: Option, // 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, // During snapshot restore, defer startup prompt submission until replayed // history has been rendered so resumed/forked prompts keep chronological // order. suppress_initial_user_message_submit: bool, // User inputs queued while a turn is in progress. queued_user_messages: VecDeque, // History records for queued user messages. Slash commands such as `/goal` // can render history that differs from the text submitted to core, so this // stays in lockstep with `queued_user_messages`, with missing entries // treated as user-message text. queued_user_message_history_records: VecDeque, // A user turn has been submitted to core, but `TurnStarted` has not arrived yet. user_turn_pending_start: bool, // User messages that tried to steer a non-regular turn and must be retried first. rejected_steers_queue: VecDeque, // History records for rejected steers. Slash commands such as `/goal` can // render history that differs from the text submitted to core, so this stays // in lockstep with `rejected_steers_queue`, with missing entries treated as // user-message text. rejected_steer_history_records: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the // corresponding user message item. pending_steers: VecDeque, // When set, the next interrupt should resubmit all pending steers as one // fresh user turn instead of restoring them into the composer. submit_pending_steers_after_interrupt: bool, /// Main chat-surface bindings resolved from `tui.keymap.chat`. chat_keymap: ChatKeymap, /// Keybinding to show for popping the most-recently queued message back /// into the composer. This may differ from the first configured binding /// when the default set includes a terminal-specific fallback. queued_message_edit_hint_binding: Option, // 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 the next streamed assistant content should be preceded by a final message separator. // // This is set whenever we insert a visible history cell that conceptually belongs to a turn. // The separator itself is only rendered if the turn recorded "work" activity. needs_final_message_separator: bool, // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). // // This gates rendering of the "Worked for …" separator so purely conversational turns don't // show an empty divider. had_work_activity: bool, // Whether the current turn emitted a plan update. saw_plan_update_this_turn: bool, // Whether the current turn emitted a proposed plan item that has not been superseded by a // later steer. This is cleared when the user submits a steer so the plan popup only appears // if a newer proposed plan arrives afterward. saw_plan_item_this_turn: bool, // Latest `update_plan` checklist task counts for terminal-title rendering. last_plan_progress: Option<(usize, usize)>, // Incremental buffer for streamed plan content. plan_delta_buffer: String, // True while a plan item is streaming. plan_item_active: bool, // Runtime metrics accumulated across delta snapshots for the active turn. turn_runtime_metrics: RuntimeMetricsSummary, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback feedback: codex_feedback::CodexFeedback, // Current session rollout path (if known) current_rollout_path: Option, // Current working directory (if known) current_cwd: Option, // App-server-backed command runner for status-line workspace metadata lookups. workspace_command_runner: Option, // Instruction source files loaded for the current session, supplied by app-server. instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. session_network_proxy: Option, // Shared latch so we only warn once about invalid status-line item IDs. status_line_invalid_items_warned: Arc, // Shared latch so we only warn once about invalid terminal-title item IDs. terminal_title_invalid_items_warned: Arc, // Last terminal title emitted, to avoid writing duplicate OSC updates. pub(crate) last_terminal_title: Option, // Last visible "action required" state observed by the terminal-title renderer. last_terminal_title_requires_action: bool, // Original terminal-title config captured when the setup UI opens. // // The outer `Option` tracks whether a setup session is active (`Some`) // or not (`None`). The inner `Option>` mirrors the shape // of `config.tui_terminal_title` (which is `None` when using defaults). // On cancel or persist-failure the inner value is restored to config; // on confirm the outer is set to `None` to end the session. terminal_title_setup_original_items: Option>>, // Baseline instant used to animate spinner-prefixed title statuses. terminal_title_animation_origin: Instant, // Cached project-root display name keyed by cwd for status/title rendering. status_line_project_root_name_cache: Option, // Cached git branch name for the status line (None if unknown). status_line_branch: Option, // CWD used to resolve the cached branch; change resets branch state. status_line_branch_cwd: Option, // True while an async branch lookup is in flight. status_line_branch_pending: bool, // True once we've attempted a branch lookup for the current CWD. status_line_branch_lookup_complete: bool, // Cached PR and branch-change summary for the active status-line cwd. status_line_git_summary: Option, // CWD used to resolve the cached Git summary; change resets summary state. status_line_git_summary_cwd: Option, // True while an async Git summary lookup is in flight. status_line_git_summary_pending: bool, // True once we've attempted a Git summary lookup for the current CWD. status_line_git_summary_lookup_complete: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, goal_status_active_turn_started_at: Option, external_editor_state: ExternalEditorState, realtime_conversation: RealtimeConversationUiState, last_rendered_user_message_display: Option, last_non_retry_error: Option<(String, String)>, } #[cfg_attr(not(test), allow(dead_code))] enum CodexOpTarget { Direct(UnboundedSender), AppEvent, } /// 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, } #[derive(Debug, Clone, PartialEq)] pub(crate) struct UserMessage { text: String, local_images: Vec, /// Remote image attachments represented as URLs (for example data URLs) /// provided by app-server clients. /// /// Unlike `local_images`, these are not created by TUI image attach/paste /// flows. The TUI can restore and remove them while editing/backtracking. remote_image_urls: Vec, text_elements: Vec, mention_bindings: Vec, } #[derive(Clone, Debug, PartialEq)] enum UserMessageHistoryRecord { UserMessageText, Override(UserMessageHistoryOverride), } #[derive(Clone, Debug, PartialEq)] struct UserMessageHistoryOverride { text: String, text_elements: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShellEscapePolicy { Allow, Disallow, } #[derive(Debug, Clone, PartialEq)] struct QueuedUserMessage { user_message: UserMessage, action: QueuedInputAction, } impl QueuedUserMessage { fn new(user_message: UserMessage, action: QueuedInputAction) -> Self { Self { user_message, action, } } fn into_user_message(self) -> UserMessage { self.user_message } } impl From for QueuedUserMessage { fn from(user_message: UserMessage) -> Self { Self::new(user_message, QueuedInputAction::Plain) } } impl Deref for QueuedUserMessage { type Target = UserMessage; fn deref(&self) -> &Self::Target { &self.user_message } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum QueueDrain { Continue, Stop, } #[derive(Debug, Clone, PartialEq, Default)] struct ThreadComposerState { text: String, local_images: Vec, remote_image_urls: Vec, text_elements: Vec, mention_bindings: Vec, pending_pastes: Vec<(String, String)>, } impl ThreadComposerState { fn has_content(&self) -> bool { !self.text.is_empty() || !self.local_images.is_empty() || !self.remote_image_urls.is_empty() || !self.text_elements.is_empty() || !self.mention_bindings.is_empty() || !self.pending_pastes.is_empty() } } #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, pending_steer_history_records: VecDeque, pending_steer_compare_keys: VecDeque, rejected_steers_queue: VecDeque, rejected_steer_history_records: VecDeque, queued_user_messages: VecDeque, queued_user_message_history_records: VecDeque, user_turn_pending_start: bool, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, task_running: bool, agent_turn_running: bool, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, local_images: Vec::new(), remote_image_urls: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), mention_bindings: Vec::new(), } } } impl From<&str> for UserMessage { fn from(text: &str) -> Self { Self { text: text.to_string(), local_images: Vec::new(), remote_image_urls: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), mention_bindings: Vec::new(), } } } struct PendingSteer { user_message: UserMessage, history_record: UserMessageHistoryRecord, compare_key: PendingSteerCompareKey, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum InterruptedTurnNoticeMode { #[default] Default, Suppress, } pub(crate) fn create_initial_user_message( text: Option, local_image_paths: Vec, text_elements: Vec, ) -> Option { let text = text.unwrap_or_default(); if text.is_empty() && local_image_paths.is_empty() { None } else { let local_images = local_image_paths .into_iter() .enumerate() .map(|(idx, path)| LocalImageAttachment { placeholder: local_image_label_text(idx + 1), path, }) .collect(); Some(UserMessage { text, local_images, remote_image_urls: Vec::new(), text_elements, mention_bindings: Vec::new(), }) } } fn append_text_with_rebased_elements( target_text: &mut String, target_text_elements: &mut Vec, text: &str, text_elements: impl IntoIterator, ) { let offset = target_text.len(); target_text.push_str(text); target_text_elements.extend(text_elements.into_iter().map(|mut element| { element.byte_range.start += offset; element.byte_range.end += offset; element })); } fn app_server_text_elements(elements: &[TextElement]) -> Vec { elements.iter().cloned().map(Into::into).collect() } fn build_placeholder_mapping( local_images: Vec, next_label: &mut usize, ) -> (HashMap, Vec) { let mut mapping: HashMap = HashMap::new(); let mut remapped_images = Vec::new(); for attachment in local_images { let new_placeholder = local_image_label_text(*next_label); *next_label += 1; mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); remapped_images.push(LocalImageAttachment { placeholder: new_placeholder, path: attachment.path, }); } (mapping, remapped_images) } fn remap_placeholders_in_text( text: String, text_elements: Vec, mapping: &HashMap, ) -> (String, Vec) { if mapping.is_empty() { return (text, text_elements); } let mut elements = text_elements; elements.sort_by_key(|elem| elem.byte_range.start); let mut cursor = 0usize; let mut rebuilt = String::new(); let mut rebuilt_elements = Vec::new(); for mut elem in elements { let start = elem.byte_range.start.min(text.len()); let end = elem.byte_range.end.min(text.len()); if let Some(segment) = text.get(cursor..start) { rebuilt.push_str(segment); } let original = text.get(start..end).unwrap_or(""); let placeholder = elem.placeholder(&text); let replacement = placeholder .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); let elem_start = rebuilt.len(); rebuilt.push_str(replacement); let elem_end = rebuilt.len(); if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); cursor = end; } if let Some(segment) = text.get(cursor..) { rebuilt.push_str(segment); } (rebuilt, rebuilt_elements) } // When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering // its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so // the combined local_image_paths order matches the labels, even if placeholders were moved // in the text (e.g., [Image #2] appearing before [Image #1]). Apply the same remapping to // history overrides so restored drafts and rendered transcript entries agree. fn remap_placeholders_for_message_and_history_record( message: UserMessage, history_record: UserMessageHistoryRecord, next_label: &mut usize, ) -> (UserMessage, UserMessageHistoryRecord) { let UserMessage { text, text_elements, local_images, remote_image_urls, mention_bindings, } = message; let (mapping, remapped_images) = build_placeholder_mapping(local_images, next_label); let (text, text_elements) = remap_placeholders_in_text(text, text_elements, &mapping); let history_record = match history_record { UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { let (text, text_elements) = remap_placeholders_in_text(history.text, history.text_elements, &mapping); UserMessageHistoryRecord::Override(UserMessageHistoryOverride { text, text_elements, }) } record => record, }; ( UserMessage { text, local_images: remapped_images, remote_image_urls, text_elements, mention_bindings, }, history_record, ) } #[cfg(test)] fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { remap_placeholders_for_message_and_history_record( message, UserMessageHistoryRecord::UserMessageText, next_label, ) .0 } fn remap_user_messages_with_history_records( messages: Vec<(UserMessage, UserMessageHistoryRecord)>, ) -> Vec<(UserMessage, UserMessageHistoryRecord)> { let total_remote_images = messages .iter() .map(|(message, _)| message.remote_image_urls.len()) .sum::(); let mut next_image_label = total_remote_images + 1; messages .into_iter() .map(|(message, history_record)| { remap_placeholders_for_message_and_history_record( message, history_record, &mut next_image_label, ) }) .collect() } fn merge_user_messages(messages: Vec) -> UserMessage { let messages = remap_user_messages_with_history_records( messages .into_iter() .map(|message| (message, UserMessageHistoryRecord::UserMessageText)) .collect(), ); merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)) } fn merge_remapped_user_messages(messages: impl IntoIterator) -> UserMessage { let mut combined = UserMessage { text: String::new(), text_elements: Vec::new(), local_images: Vec::new(), remote_image_urls: Vec::new(), mention_bindings: Vec::new(), }; for (idx, message) in messages.into_iter().enumerate() { if idx > 0 { combined.text.push('\n'); } let UserMessage { text, text_elements, local_images, remote_image_urls, mention_bindings, } = message; append_text_with_rebased_elements( &mut combined.text, &mut combined.text_elements, &text, text_elements, ); combined.local_images.extend(local_images); combined.remote_image_urls.extend(remote_image_urls); combined.mention_bindings.extend(mention_bindings); } combined } fn user_message_for_restore( message: UserMessage, history_record: &UserMessageHistoryRecord, ) -> UserMessage { match history_record { UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => UserMessage { text: history.text.clone(), text_elements: history.text_elements.clone(), ..message }, UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => { message } } } fn user_message_preview_text( message: &UserMessage, history_record: Option<&UserMessageHistoryRecord>, ) -> String { match history_record { Some(UserMessageHistoryRecord::Override(history)) if !history.text.is_empty() => { history.text.clone() } Some(UserMessageHistoryRecord::Override(_)) | Some(UserMessageHistoryRecord::UserMessageText) | None => message.text.clone(), } } fn user_message_display_for_history( message: UserMessage, history_record: &UserMessageHistoryRecord, ) -> UserMessageDisplay { let message = user_message_for_restore(message, history_record); ChatWidget::user_message_display_from_parts( message.text, message.text_elements, message .local_images .into_iter() .map(|image| image.path) .collect(), message.remote_image_urls, ) } fn merge_user_messages_with_history_record( messages: Vec<(UserMessage, UserMessageHistoryRecord)>, ) -> (UserMessage, UserMessageHistoryRecord) { let messages = remap_user_messages_with_history_records(messages); let history_record = if messages .iter() .all(|(_, record)| *record == UserMessageHistoryRecord::UserMessageText) { UserMessageHistoryRecord::UserMessageText } else { let mut history_text = String::new(); let mut history_text_elements = Vec::new(); let mut history_segment_count = 0usize; let mut append_history_segment = |text: &str, text_elements: Vec| { if history_segment_count > 0 { history_text.push('\n'); } append_text_with_rebased_elements( &mut history_text, &mut history_text_elements, text, text_elements, ); history_segment_count += 1; }; for (message, record) in &messages { match record { UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { append_history_segment(&history.text, history.text_elements.clone()); } UserMessageHistoryRecord::Override(_) if message.text.is_empty() => {} UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => { append_history_segment(&message.text, message.text_elements.clone()); } } } UserMessageHistoryRecord::Override(UserMessageHistoryOverride { text: history_text, text_elements: history_text_elements, }) }; ( merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)), history_record, ) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ReplayKind { ResumeInitialMessages, ThreadSnapshot, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SessionConfiguredDisplay { Normal, /// Apply session state without emitting the session info cell. Quiet, SideConversation, } /// Scope used to keep Plan-mode nudge dismissal local to one conversation context. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum PlanModeNudgeScope { /// Drafts entered before the server has assigned a thread id. NewThread, /// Drafts associated with one configured thread. Thread(ThreadId), } #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) enum TurnAbortReason { Interrupted, BudgetLimited, } /// Returns whether `text` contains the standalone word `plan`. /// /// This intentionally mirrors the App suggestion heuristic instead of trying to infer broader /// planning intent from substrings such as `planning`. Slash and shell drafts still match here so /// callers can keep lexical matching separate from presentation policy. fn contains_plan_keyword(text: &str) -> bool { text.split(|ch: char| !ch.is_alphanumeric() && ch != '_') .any(|word| word.eq_ignore_ascii_case("plan")) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ThreadItemRenderSource { Live, Replay(ReplayKind), } impl ThreadItemRenderSource { fn is_replay(self) -> bool { matches!(self, Self::Replay(_)) } fn replay_kind(self) -> Option { match self { Self::Live => None, Self::Replay(replay_kind) => Some(replay_kind), } } } fn exec_approval_request_from_params( params: CommandExecutionRequestApprovalParams, fallback_cwd: &AbsolutePathBuf, ) -> ExecApprovalRequestEvent { ExecApprovalRequestEvent { call_id: params.item_id, command: params .command .as_deref() .map(split_command_string) .unwrap_or_default(), cwd: params.cwd.unwrap_or_else(|| fallback_cwd.clone()), reason: params.reason, network_approval_context: params.network_approval_context, additional_permissions: params.additional_permissions, turn_id: params.turn_id, approval_id: params.approval_id, proposed_execpolicy_amendment: params.proposed_execpolicy_amendment, proposed_network_policy_amendments: params.proposed_network_policy_amendments, available_decisions: params.available_decisions, } } fn patch_approval_request_from_params( params: FileChangeRequestApprovalParams, ) -> ApplyPatchApprovalRequestEvent { ApplyPatchApprovalRequestEvent { call_id: params.item_id, turn_id: params.turn_id, changes: HashMap::new(), reason: params.reason, grant_root: params.grant_root, } } fn request_permissions_from_params( params: codex_app_server_protocol::PermissionsRequestApprovalParams, ) -> RequestPermissionsEvent { RequestPermissionsEvent { turn_id: params.turn_id, call_id: params.item_id, reason: params.reason, permissions: params.permissions.into(), cwd: Some(params.cwd), } } fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsageInfo { TokenUsageInfo { total_token_usage: TokenUsage { total_tokens: token_usage.total.total_tokens, input_tokens: token_usage.total.input_tokens, cached_input_tokens: token_usage.total.cached_input_tokens, output_tokens: token_usage.total.output_tokens, reasoning_output_tokens: token_usage.total.reasoning_output_tokens, }, last_token_usage: TokenUsage { total_tokens: token_usage.last.total_tokens, input_tokens: token_usage.last.input_tokens, cached_input_tokens: token_usage.last.cached_input_tokens, output_tokens: token_usage.last.output_tokens, reasoning_output_tokens: token_usage.last.reasoning_output_tokens, }, model_context_window: token_usage.model_context_window, } } impl ChatWidget { /// Stores or overwrites the cached nickname and role for a collab agent thread. /// /// Called by `App::upsert_agent_picker_thread` and `App::replace_chat_widget` to keep the /// rendering metadata in sync with the navigation cache. Must be called before any /// notification referencing this thread is processed, otherwise the rendered item will fall /// back to showing the raw thread id. pub(crate) fn set_collab_agent_metadata( &mut self, thread_id: ThreadId, agent_nickname: Option, agent_role: Option, ) { self.collab_agent_metadata.insert( thread_id, AgentMetadata { agent_nickname, agent_role, }, ); } /// Returns the cached metadata for a thread, defaulting to empty if none has been registered. fn collab_agent_metadata(&self, thread_id: ThreadId) -> AgentMetadata { self.collab_agent_metadata .get(&thread_id) .cloned() .unwrap_or_default() } fn realtime_conversation_enabled(&self) -> bool { self.config.features.enabled(Feature::RealtimeConversation) && cfg!(not(target_os = "linux")) } fn realtime_audio_device_selection_enabled(&self) -> bool { self.realtime_conversation_enabled() } /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. /// /// The bottom pane only has one running flag, but this module treats it as a derived state of /// both the agent turn lifecycle and MCP startup lifecycle. fn update_task_running_state(&mut self) { self.bottom_pane .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); self.refresh_plan_mode_nudge(); self.refresh_status_surfaces(); } fn restore_reasoning_status_header(&mut self) { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else if self.bottom_pane.is_task_running() { self.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); } } fn flush_unified_exec_wait_streak(&mut self) { let Some(wait) = self.unified_exec_wait_streak.take() else { return; }; self.needs_final_message_separator = true; let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(cell))); self.restore_reasoning_status_header(); } fn flush_answer_stream_with_separator(&mut self) { let had_stream_controller = self.stream_controller.is_some(); if let Some(mut controller) = self.stream_controller.take() { let (cell, source) = controller.finalize(); if let Some(cell) = cell { self.add_boxed_history(cell); } // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell // that can re-render from source on resize. if let Some(source) = source { self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, cwd: self.config.cwd.to_path_buf(), }); } } self.adaptive_chunking.reset(); if had_stream_controller && self.stream_controllers_idle() { self.app_event_tx.send(AppEvent::StopCommitAnimation); } } fn stream_controllers_idle(&self) -> bool { self.stream_controller .as_ref() .map(|controller| controller.queued_lines() == 0) .unwrap_or(true) && self .plan_stream_controller .as_ref() .map(|controller| controller.queued_lines() == 0) .unwrap_or(true) } /// Restore the status indicator only after commentary completion is pending, /// the turn is still running, and all stream queues have drained. /// /// This gate prevents flicker while normal output is still actively /// streaming, but still restores a visible "working" affordance when a /// commentary block ends before the turn itself has completed. fn maybe_restore_status_indicator_after_stream_idle(&mut self) { if !self.pending_status_indicator_restore || !self.bottom_pane.is_task_running() || !self.stream_controllers_idle() { return; } self.bottom_pane.ensure_status_indicator(); self.set_status( self.current_status.header.clone(), self.current_status.details.clone(), StatusDetailsCapitalization::Preserve, self.current_status.details_max_lines, ); self.pending_status_indicator_restore = false; } /// Update the status indicator header and details. /// /// Passing `None` clears any existing details. fn set_status( &mut self, header: String, details: Option, details_capitalization: StatusDetailsCapitalization, details_max_lines: usize, ) { let details = details .filter(|details| !details.is_empty()) .map(|details| { let trimmed = details.trim_start(); match details_capitalization { StatusDetailsCapitalization::CapitalizeFirst => { crate::text_formatting::capitalize_first(trimmed) } StatusDetailsCapitalization::Preserve => trimmed.to_string(), } }); self.current_status = StatusIndicatorState { header: header.clone(), details: details.clone(), details_max_lines, }; self.bottom_pane.update_status( header, details, StatusDetailsCapitalization::Preserve, details_max_lines, ); let title_uses_status = self .config .tui_terminal_title .as_ref() .is_some_and(|items| { items .iter() .any(|item| item == "run-state" || item == "status") }); if title_uses_status { self.refresh_status_surfaces(); } } /// 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, /*details*/ None, StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); } /// Sets the currently rendered footer status-line value. pub(crate) fn set_status_line(&mut self, status_line: Option>) { self.bottom_pane.set_status_line(status_line); } /// Sets the terminal hyperlink target for the currently rendered footer status line. pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { self.bottom_pane.set_status_line_hyperlink(url); } /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. /// /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the /// user actually looking at?" and the footer stack remains a pure renderer of that decision. pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { self.bottom_pane.set_active_agent_label(active_agent_label); } /// Recomputes footer status-line content from config and current runtime state. /// /// This method is the status-line orchestrator: it parses configured item identifiers, /// warns once per session about invalid items, updates whether status-line mode is enabled, /// schedules async git-branch lookup when needed, and renders only values that are currently /// available. /// /// The omission behavior is intentional. If selected items are unavailable (for example before /// a session id exists or before branch lookup completes), those items are skipped without /// placeholders so the line remains compact and stable. pub(crate) fn refresh_status_line(&mut self) { self.refresh_status_surfaces(); } /// Records that status-line setup was canceled. /// /// Cancellation is intentionally side-effect free for config state; the existing configuration /// remains active and no persistence is attempted. pub(crate) fn cancel_status_line_setup(&self) { tracing::info!("Status line setup canceled by user"); } /// Applies status-line item selection from the setup view to in-memory config. /// /// An empty selection persists as an explicit empty list. pub(crate) fn setup_status_line(&mut self, items: Vec, use_theme_colors: bool) { tracing::info!( "status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}" ); let ids = items.iter().map(ToString::to_string).collect::>(); self.config.tui_status_line = Some(ids); self.config.tui_status_line_use_colors = use_theme_colors; self.refresh_status_line(); } /// Applies a temporary terminal-title selection while the setup UI is open. pub(crate) fn preview_terminal_title(&mut self, items: Vec) { if self.terminal_title_setup_original_items.is_none() { self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); } let ids = items.iter().map(ToString::to_string).collect::>(); self.config.tui_terminal_title = Some(ids); self.refresh_terminal_title(); } /// Restores the terminal-title config that was active before the setup UI /// opened, undoing any preview changes. No-op if no setup session is active. pub(crate) fn revert_terminal_title_setup_preview(&mut self) { let Some(original_items) = self.terminal_title_setup_original_items.take() else { return; }; self.config.tui_terminal_title = original_items; self.refresh_terminal_title(); } /// Dismisses the terminal-title setup UI and reverts to the pre-setup config. pub(crate) fn cancel_terminal_title_setup(&mut self) { tracing::info!("Terminal title setup canceled by user"); self.revert_terminal_title_setup_preview(); } /// Commits a confirmed terminal-title selection, ending the setup session. /// /// After this call, `revert_terminal_title_setup_preview` becomes a no-op /// because the original config snapshot is discarded. pub(crate) fn setup_terminal_title(&mut self, items: Vec) { tracing::info!("terminal title setup confirmed with items: {items:#?}"); let ids = items.iter().map(ToString::to_string).collect::>(); self.terminal_title_setup_original_items = None; self.config.tui_terminal_title = Some(ids); self.refresh_terminal_title(); } /// Stores async git-branch lookup results for the current status-line cwd. /// /// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch /// names after directory changes. pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option) { if self.status_line_branch_cwd.as_ref() != Some(&cwd) { self.status_line_branch_pending = false; return; } self.status_line_branch = branch; self.status_line_branch_pending = false; self.status_line_branch_lookup_complete = true; self.refresh_status_surfaces(); } /// Stores async Git summary lookup results for the current status-line cwd. pub(crate) fn set_status_line_git_summary( &mut self, cwd: PathBuf, summary: StatusLineGitSummary, ) { if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) { self.status_line_git_summary_pending = false; return; } self.status_line_git_summary = Some(summary); self.status_line_git_summary_pending = false; self.status_line_git_summary_lookup_complete = true; self.refresh_status_surfaces(); } fn collect_runtime_metrics_delta(&mut self) { if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { self.apply_runtime_metrics_delta(delta); } } fn apply_runtime_metrics_delta(&mut self, delta: RuntimeMetricsSummary) { let should_log_timing = has_websocket_timing_metrics(delta); self.turn_runtime_metrics.merge(delta); if should_log_timing { self.log_websocket_timing_totals(delta); } } fn log_websocket_timing_totals(&mut self, delta: RuntimeMetricsSummary) { if let Some(label) = history_cell::runtime_metrics_label(delta.responses_api_summary()) { self.add_plain_history_lines(vec![ vec!["• ".dim(), format!("WebSocket timing: {label}").dark_gray()].into(), ]); } } fn refresh_runtime_metrics(&mut self) { self.collect_runtime_metrics_delta(); } fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.retry_status_header.take() { self.set_status_header(header); } } /// Record or update the raw markdown for the current agent turn. fn record_agent_markdown(&mut self, message: &str) { if message.is_empty() { return; } let markdown = message.to_string(); match self.agent_turn_markdowns.last_mut() { Some(entry) if entry.user_turn_count == self.visible_user_turn_count => { entry.markdown = markdown.clone(); } _ => { self.agent_turn_markdowns.push(AgentTurnMarkdown { user_turn_count: self.visible_user_turn_count, markdown: markdown.clone(), }); if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { self.agent_turn_markdowns.remove(0); } } } self.last_agent_markdown = Some(markdown); self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = true; } fn record_visible_user_turn_for_copy(&mut self) { self.visible_user_turn_count = self.visible_user_turn_count.saturating_add(1); } // --- Small event handlers --- fn on_session_configured_with_display_and_fork_parent_title( &mut self, session: ThreadSessionState, display: SessionConfiguredDisplay, fork_parent_title: Option, ) { self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.visible_user_turn_count = 0; self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = false; let history_metadata = session.message_history.unwrap_or_default(); self.bottom_pane.set_history_metadata( session.thread_id, history_metadata.log_id, history_metadata.entry_count, ); self.set_skills(/*skills*/ None); self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; self.thread_id = Some(session.thread_id); if previous_thread_id != self.thread_id { self.recent_auto_review_denials = RecentAutoReviewDenials::default(); } self.refresh_plan_mode_nudge(); self.last_turn_id = None; self.thread_name = session.thread_name.clone(); self.current_goal_status_indicator = None; self.current_goal_status = None; self.goal_status_active_turn_started_at = None; self.budget_limited_turn_ids.clear(); self.update_collaboration_mode_indicator(); self.forked_from = session.forked_from_id; self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); self.effective_service_tier = session .service_tier .as_deref() .and_then(ServiceTier::from_request_value); if let Err(err) = self .config .permissions .approval_policy .set(session.approval_policy.to_core()) { tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured"); self.config.permissions.approval_policy = Constrained::allow_only(session.approval_policy.to_core()); } let permission_sync = self .config .permissions .set_permission_profile_with_active_profile( session.permission_profile.clone(), session.active_permission_profile.clone(), ); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); self.config.permissions.permission_profile = Constrained::allow_only(session.permission_profile.clone()); self.config.permissions.active_permission_profile = session.active_permission_profile.clone(); } self.config.approvals_reviewer = session.approvals_reviewer; self.status_line_project_root_name_cache = None; let forked_from_id = session.forked_from_id; let model_for_header = session.model.clone(); self.session_header.set_model(&model_for_header); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(model_for_header.clone()), Some(session.reasoning_effort), /*developer_instructions*/ None, ); if let Some(mask) = self.active_collaboration_mask.as_mut() { mask.model = Some(model_for_header.clone()); mask.reasoning_effort = Some(session.reasoning_effort); } self.refresh_model_display(); self.refresh_status_surfaces(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); self.sync_goal_command_enabled(); self.refresh_plugin_mentions(); if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self.should_show_fast_status(&model_for_header, self.effective_service_tier); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, &session, self.show_welcome_banner, startup_tooltip_override, self.plan_type, show_fast_status, ); self.apply_session_info_cell(session_info_cell); } else if self .active_cell .as_ref() .is_some_and(|cell| cell.as_any().is::()) { self.active_cell = None; self.bump_active_cell_revision(); } self.saw_copy_source_this_turn = false; self.refresh_skills_for_current_cwd(/*force_reload*/ true); if self.connectors_enabled() { self.prefetch_connectors(); } if let Some(user_message) = self.initial_user_message.take() { if self.suppress_initial_user_message_submit { self.initial_user_message = Some(user_message); } else { self.submit_user_message(user_message); } } if display == SessionConfiguredDisplay::Normal && let Some(forked_from_id) = forked_from_id { self.emit_forked_thread_event(forked_from_id, fork_parent_title); } if !self.suppress_session_configured_redraw { self.request_redraw(); } } pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { self.suppress_initial_user_message_submit = suppressed; } pub(crate) fn submit_initial_user_message_if_pending(&mut self) { if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } } pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) { self.instruction_source_paths = session.instruction_source_paths.clone(); let fork_parent_title = session.fork_parent_title.clone(); self.on_session_configured_with_display_and_fork_parent_title( session, SessionConfiguredDisplay::Normal, fork_parent_title, ); } pub(crate) fn handle_thread_session_quiet(&mut self, session: ThreadSessionState) { self.instruction_source_paths = session.instruction_source_paths.clone(); self.on_session_configured_with_display_and_fork_parent_title( session, SessionConfiguredDisplay::Quiet, /*fork_parent_title*/ None, ); } pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) { self.instruction_source_paths = session.instruction_source_paths.clone(); let fork_parent_title = session.fork_parent_title.clone(); self.on_session_configured_with_display_and_fork_parent_title( session, SessionConfiguredDisplay::SideConversation, fork_parent_title, ); } fn emit_forked_thread_event( &mut self, forked_from_id: ThreadId, fork_parent_title: Option, ) { let forked_from_id_text = forked_from_id.to_string(); let line: Line<'static> = if let Some(name) = fork_parent_title && !name.trim().is_empty() { vec![ "• ".dim(), "Thread forked from ".into(), name.cyan(), " (".into(), forked_from_id_text.cyan(), ")".into(), ] .into() } else { vec![ "• ".dim(), "Thread forked from ".into(), forked_from_id_text.cyan(), ] .into() }; self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( PlainHistoryCell::new(vec![line]), ))); } fn on_thread_name_updated(&mut self, thread_id: ThreadId, thread_name: Option) { if self.thread_id == Some(thread_id) { if let Some(name) = thread_name.as_deref() { let cell = Self::rename_confirmation_cell(name, self.thread_id); self.add_boxed_history(Box::new(cell)); } self.thread_name = thread_name; self.refresh_status_surfaces(); self.request_redraw(); self.maybe_send_next_queued_input(); } } fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, include_logs: bool, ) { self.show_feedback_note(category, include_logs); } fn show_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, include_logs: bool, ) { let view = crate::bottom_pane::FeedbackNoteView::new( category, self.last_turn_id.clone(), self.app_event_tx.clone(), include_logs, ); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); } pub(crate) fn open_app_link_view(&mut self, params: crate::bottom_pane::AppLinkViewParams) { let view = crate::bottom_pane::AppLinkView::new(params, self.app_event_tx.clone()); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); } pub(crate) fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) { // A remotely resolved request must not remain user-actionable. It may be // materialized in the bottom pane or still deferred behind active streaming. let removed_deferred = self.interrupts.remove_resolved_prompt(request); let removed_visible = self.bottom_pane.dismiss_app_server_request(request); if removed_deferred || removed_visible { self.request_redraw(); } } pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let snapshot = self.feedback.snapshot(self.thread_id); let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), category, self.current_rollout_path.clone(), self.thread_id .map(|thread_id| format!("auto-review-rollout-{thread_id}.jsonl")), snapshot.feedback_diagnostics(), ); self.bottom_pane.show_selection_view(params); self.request_redraw(); } fn finalize_completed_assistant_message(&mut self, message: Option<&str>) { // If we have a stream_controller, the finalized message payload is redundant because the // visible content has already been accumulated through deltas. if self.stream_controller.is_none() && let Some(message) = message && !message.is_empty() { self.handle_streaming_delta(message.to_string()); } self.flush_answer_stream_with_separator(); self.handle_stream_finished(); self.request_redraw(); } fn on_agent_message_delta(&mut self, delta: String) { self.handle_streaming_delta(delta); } fn on_plan_delta(&mut self, delta: String) { if self.active_mode_kind() != ModeKind::Plan { return; } if !self.plan_item_active { self.plan_item_active = true; self.plan_delta_buffer.clear(); } self.plan_delta_buffer.push_str(&delta); // Before streaming plan content, flush any active exec cell group. self.flush_unified_exec_wait_streak(); self.flush_active_cell(); if self.plan_stream_controller.is_none() { self.plan_stream_controller = Some(PlanStreamController::new( self.current_stream_width(/*reserved_cols*/ 4), &self.config.cwd, self.history_render_mode(), )); } if let Some(controller) = self.plan_stream_controller.as_mut() && controller.push(&delta) { self.app_event_tx.send(AppEvent::StartCommitAnimation); self.run_catch_up_commit_tick(); } self.request_redraw(); } fn on_plan_item_completed(&mut self, text: String) { let streamed_plan = self.plan_delta_buffer.trim().to_string(); let plan_text = if text.trim().is_empty() { streamed_plan } else { text }; if !plan_text.trim().is_empty() { self.record_agent_markdown(&plan_text); self.latest_proposed_plan_markdown = Some(plan_text.clone()); } // Plan commit ticks can hide the status row; remember whether we streamed plan output so // completion can restore it once stream queues are idle. let should_restore_after_stream = self.plan_stream_controller.is_some(); self.plan_delta_buffer.clear(); self.plan_item_active = false; self.saw_plan_item_this_turn = true; let (finalized_streamed_cell, consolidated_plan_source) = if let Some(mut controller) = self.plan_stream_controller.take() { controller.finalize() } else { (None, None) }; if let Some(cell) = finalized_streamed_cell { self.add_boxed_history(cell); // TODO: Replace streamed output with the final plan item text if plan streaming is // removed or if we need to reconcile mismatches between streamed and final content. if let Some(source) = consolidated_plan_source { self.app_event_tx .send(AppEvent::ConsolidateProposedPlan(source)); } } else if !plan_text.is_empty() { self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd)); } else if let Some(source) = consolidated_plan_source { self.app_event_tx .send(AppEvent::ConsolidateProposedPlan(source)); } if should_restore_after_stream { self.pending_status_indicator_restore = true; self.maybe_restore_status_indicator_after_stream_idle(); } } fn on_agent_reasoning_delta(&mut self, delta: String) { // For reasoning deltas, do not stream to history. Accumulate the // current reasoning block and extract the first bold element // (between **/**) as the chunk header. Show this header as status. self.reasoning_buffer.push_str(&delta); if self.unified_exec_wait_streak.is_some() { // Unified exec waiting should take precedence over reasoning-derived status headers. self.request_redraw(); return; } if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. } self.request_redraw(); } fn on_agent_reasoning_final(&mut self) { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), &self.config.cwd, ); self.add_boxed_history(cell); } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); self.request_redraw(); } fn on_reasoning_section_break(&mut self) { // Start a new reasoning block for header extraction and accumulate transcript. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); self.full_reasoning_buffer.push_str("\n\n"); self.reasoning_buffer.clear(); } // Raw reasoning uses the same flow as summarized reasoning fn on_task_started(&mut self) { self.user_turn_pending_start = false; self.agent_turn_running = true; self.goal_status_active_turn_started_at = Some(Instant::now()); self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); self.saw_copy_source_this_turn = false; self.saw_plan_update_this_turn = false; self.saw_plan_item_this_turn = false; self.had_work_activity = false; self.latest_proposed_plan_markdown = None; self.plan_delta_buffer.clear(); self.plan_item_active = false; self.adaptive_chunking.reset(); self.plan_stream_controller = None; self.turn_runtime_metrics = RuntimeMetricsSummary::default(); self.session_telemetry.reset_runtime_metrics(); self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.update_task_running_state(); self.retry_status_header = None; if self.active_hook_cell.take().is_some() { self.bump_active_cell_revision(); } self.pending_status_indicator_restore = false; self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); self.terminal_title_status_kind = TerminalTitleStatusKind::Working; 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, duration_ms: Option, from_replay: bool, ) { self.submit_pending_steers_after_interrupt = false; // Use `last_agent_message` from the turn-complete notification as the copy // source only when no earlier item-level event (AgentMessageItem, plan // commit, review output) already recorded markdown for this turn. This // prevents the final summary from overwriting a more specific source. if let Some(message) = last_agent_message .as_ref() .filter(|message| !message.is_empty()) && !self.saw_copy_source_this_turn { self.record_agent_markdown(message); } // For desktop notifications: prefer the notification payload, fall back to // the item-level copy source if present, otherwise send an empty string. let notification_response = last_agent_message .as_ref() .filter(|message| !message.is_empty()) .cloned() .or_else(|| { if self.saw_copy_source_this_turn { self.last_agent_markdown.clone() } else { None } }) .unwrap_or_default(); self.saw_copy_source_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); if let Some(mut controller) = self.plan_stream_controller.take() { let (cell, source) = controller.finalize(); if let Some(cell) = cell { self.add_boxed_history(cell); } if let Some(source) = source { self.app_event_tx .send(AppEvent::ConsolidateProposedPlan(source)); } } self.flush_unified_exec_wait_streak(); if !from_replay { self.collect_runtime_metrics_delta(); let runtime_metrics = (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); let show_work_separator = self.had_work_activity && (self.needs_final_message_separator || runtime_metrics.is_some()); if show_work_separator || runtime_metrics.is_some() { let elapsed_seconds = if show_work_separator { duration_ms .and_then(|duration_ms| u64::try_from(duration_ms).ok()) .map(|duration_ms| duration_ms / 1_000) .or_else(|| { self.bottom_pane .status_widget() .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) }) } else { None }; self.add_to_history(history_cell::FinalMessageSeparator::new( elapsed_seconds, runtime_metrics, )); } self.turn_runtime_metrics = RuntimeMetricsSummary::default(); self.needs_final_message_separator = false; self.had_work_activity = false; self.request_status_line_branch_refresh(); self.request_status_line_git_summary_refresh(); } // Mark task stopped and request redraw now that all content is in history. self.pending_status_indicator_restore = false; self.user_turn_pending_start = false; self.agent_turn_running = false; self.goal_status_active_turn_started_at = None; self.turn_sleep_inhibitor .set_turn_running(/*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.request_redraw(); let had_pending_steers = !self.pending_steers.is_empty(); self.refresh_pending_input_preview(); if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can // still show the prompt once after thread switch replay. if !from_replay { self.saw_plan_item_this_turn = false; } // If there is a queued user message, send exactly one now to begin the next turn. let follow_up_started = self.maybe_send_next_queued_input(); let active_goal_continuing = self .current_goal_status .as_ref() .is_some_and(GoalStatusState::is_active); // Emit a notification when the agent is truly waiting for the user. // Queued follow-up input and active goal continuation both start the // next turn immediately, so notifying at that boundary would feel like // a false "needs attention". if !follow_up_started && !active_goal_continuing { self.notify(Notification::AgentTurnComplete { response: notification_response, }); } self.maybe_show_pending_rate_limit_prompt(); } fn maybe_prompt_plan_implementation(&mut self) { if !self.collaboration_modes_enabled() { return; } if self.has_queued_follow_up_messages() { return; } if self.active_mode_kind() != ModeKind::Plan { return; } if !self.saw_plan_item_this_turn { return; } if !self.bottom_pane.no_modal_or_popup_active() { return; } if matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Pending ) { return; } self.open_plan_implementation_prompt(); } fn open_plan_implementation_prompt(&mut self) { let default_mask = collaboration_modes::default_mode_mask(self.model_catalog.as_ref()); let context_usage_label = self.plan_implementation_context_usage_label(); self.bottom_pane .show_selection_view(plan_implementation::selection_view_params( default_mask, self.latest_proposed_plan_markdown.as_deref(), context_usage_label.as_deref(), )); self.notify(Notification::PlanModePrompt { title: PLAN_IMPLEMENTATION_TITLE.to_string(), }); } /// Returns a context-used label for the plan implementation prompt. /// /// The footer reports context remaining because it is ambient status, but /// this prompt is asking whether to discard prior conversation state before /// implementing a plan. Reporting used context makes the cleanup tradeoff /// explicit. A fully fresh or unknown context window returns no label so /// the clear-context option does not imply urgency without evidence. fn plan_implementation_context_usage_label(&self) -> Option { let info = self.token_info.as_ref()?; let percent = self.context_remaining_percent(info); let used_tokens = self.context_used_tokens(info, percent.is_some()); if let Some(percent) = percent { let used_percent = 100 - percent.clamp(0, 100); if used_percent <= 0 { return None; } return Some(format!("{used_percent}% used")); } if let Some(tokens) = used_tokens && tokens > 0 { return Some(format!("{} used", format_tokens_compact(tokens))); } None } fn has_queued_follow_up_messages(&self) -> bool { !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() } fn pop_next_queued_user_message( &mut self, ) -> Option<(QueuedUserMessage, UserMessageHistoryRecord)> { if self.rejected_steers_queue.is_empty() { self.queued_user_messages.pop_front().map(|user_message| { let history_record = self .queued_user_message_history_records .pop_front() .unwrap_or(UserMessageHistoryRecord::UserMessageText); (user_message, history_record) }) } else { let rejected_messages = self.rejected_steers_queue.drain(..).collect::>(); let mut history_records = self .rejected_steer_history_records .drain(..) .collect::>(); history_records.resize( rejected_messages.len(), UserMessageHistoryRecord::UserMessageText, ); let (message, history_record) = merge_user_messages_with_history_record( rejected_messages .into_iter() .zip(history_records) .collect::>(), ); Some((QueuedUserMessage::from(message), history_record)) } } fn pop_latest_queued_user_message(&mut self) -> Option { if let Some(user_message) = self.queued_user_messages.pop_back() { let history_record = self .queued_user_message_history_records .pop_back() .unwrap_or(UserMessageHistoryRecord::UserMessageText); Some(user_message_for_restore( user_message.into_user_message(), &history_record, )) } else { let user_message = self.rejected_steers_queue.pop_back()?; let history_record = self .rejected_steer_history_records .pop_back() .unwrap_or(UserMessageHistoryRecord::UserMessageText); Some(user_message_for_restore(user_message, &history_record)) } } pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { let Some(pending_steer) = self.pending_steers.pop_front() else { tracing::warn!( "received active-turn-not-steerable error without a matching pending steer" ); return false; }; self.rejected_steers_queue .push_back(pending_steer.user_message); self.rejected_steer_history_records .push_back(pending_steer.history_record); self.refresh_pending_input_preview(); true } fn handle_app_server_steer_rejected_error( &mut self, codex_error_info: &AppServerCodexErrorInfo, ) -> bool { matches!( codex_error_info, AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } ) && self.enqueue_rejected_steer() } pub(crate) fn open_multi_agent_enable_prompt(&mut self) { let items = vec![ SelectionItem { name: MULTI_AGENT_ENABLE_YES.to_string(), description: Some( "Save the setting now. You will need a new session to use it.".to_string(), ), actions: vec![Box::new(|tx| { tx.send(AppEvent::UpdateFeatureFlags { updates: vec![(Feature::Collab, true)], }); tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_warning_event(MULTI_AGENT_ENABLE_NOTICE.to_string()), ))); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: MULTI_AGENT_ENABLE_NO.to_string(), description: Some("Keep subagents disabled.".to_string()), dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: Some(MULTI_AGENT_ENABLE_TITLE.to_string()), subtitle: Some("Subagents are currently disabled in your config.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } pub(crate) fn open_memories_popup(&mut self) { if !self.config.features.enabled(Feature::MemoryTool) { self.open_memories_enable_prompt(); return; } let view = MemoriesSettingsView::new( self.config.memories.use_memories, self.config.memories.generate_memories, self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); } pub(crate) fn open_memories_enable_prompt(&mut self) { let items = vec![ SelectionItem { name: MEMORIES_ENABLE_YES.to_string(), description: Some( "Save the setting now. You will need a new session to use it.".to_string(), ), actions: vec![Box::new(|tx| { tx.send(AppEvent::UpdateFeatureFlags { updates: vec![(Feature::MemoryTool, true)], }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: MEMORIES_ENABLE_NO.to_string(), description: Some("Keep memories disabled.".to_string()), dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: Some(MEMORIES_ENABLE_TITLE.to_string()), subtitle: Some("Memories are currently disabled in your config.".to_string()), footer_note: Some(Line::from(vec![ "Learn more: ".dim(), MEMORIES_DOC_URL.cyan().underlined(), ])), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } pub(crate) fn set_memory_settings(&mut self, use_memories: bool, generate_memories: bool) { self.config.memories.use_memories = use_memories; self.config.memories.generate_memories = generate_memories; } 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(/*percent*/ None, /*used_tokens*/ 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(/*percent*/ None, /*used_tokens*/ None); self.token_info = None; } } } } pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { if let Some(mut snapshot) = snapshot { let limit_id = snapshot .limit_id .clone() .unwrap_or_else(|| "codex".to_string()); let limit_label = snapshot .limit_name .clone() .unwrap_or_else(|| limit_id.clone()); if snapshot.credits.is_none() { snapshot.credits = self .rate_limit_snapshots_by_limit_id .get(&limit_id) .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 is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); if is_codex_limit && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type { self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); } let warnings = if is_codex_limit { self.rate_limit_warnings.take_warnings( snapshot .secondary .as_ref() .map(|window| f64::from(window.used_percent)), snapshot .secondary .as_ref() .and_then(|window| window.window_duration_mins), snapshot .primary .as_ref() .map(|window| f64::from(window.used_percent)), snapshot .primary .as_ref() .and_then(|window| window.window_duration_mins), ) } else { vec![] }; let high_usage = is_codex_limit && (snapshot .secondary .as_ref() .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) .unwrap_or(false) || snapshot .primary .as_ref() .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) .unwrap_or(false)); let has_workspace_credits = snapshot .credits .as_ref() .map(|credits| credits.has_credits) .unwrap_or(false); if high_usage && !has_workspace_credits && !self.rate_limit_switch_prompt_hidden() && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown ) { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; } let display = rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); self.rate_limit_snapshots_by_limit_id .insert(limit_id, 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_snapshots_by_limit_id.clear(); self.codex_rate_limit_reached_type = None; } self.refresh_status_line(); } /// Finalize any active exec as failed and stop/clear agent-turn UI state. /// /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup /// and should continue to drive the bottom-pane running indicator while it is in progress. fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_cell_as_failed(); // Turn-scoped hook rows are transient live state; once the turn is over, // do not leave an orphaned running row behind if no matching completion // event arrived before cancellation. if self.active_hook_cell.take().is_some() { self.bump_active_cell_revision(); } // Reset running state and clear streaming buffers. self.user_turn_pending_start = false; self.agent_turn_running = false; self.goal_status_active_turn_started_at = None; self.turn_sleep_inhibitor .set_turn_running(/*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.adaptive_chunking.reset(); self.stream_controller = None; self.plan_stream_controller = None; self.pending_status_indicator_restore = false; self.request_status_line_branch_refresh(); self.request_status_line_git_summary_refresh(); self.maybe_show_pending_rate_limit_prompt(); } fn on_server_overloaded_error(&mut self, message: String) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); let message = if message.trim().is_empty() { "Codex is currently experiencing high load.".to_string() } else { message }; self.add_to_history(history_cell::new_warning_event(message)); self.request_redraw(); self.maybe_send_next_queued_input(); } fn on_error(&mut self, message: String) { self.submit_pending_steers_after_interrupt = false; 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_cyber_policy_error(&mut self) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_cyber_policy_error_event()); self.request_redraw(); // After an error ends the turn, try sending the next queued input. self.maybe_send_next_queued_input(); } fn workspace_owner_usage_nudge_enabled(&self) -> bool { self.config .features .enabled(Feature::WorkspaceOwnerUsageNudge) } fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) { if !self.workspace_owner_usage_nudge_enabled() { self.on_error(message); return; } let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| { if matches!(error_kind, RateLimitErrorKind::UsageLimit) { match kind { RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { RateLimitReachedType::WorkspaceOwnerUsageLimitReached } RateLimitReachedType::WorkspaceMemberCreditsDepleted => { RateLimitReachedType::WorkspaceMemberUsageLimitReached } other => other, } } else { kind } }); self.codex_rate_limit_reached_type = rate_limit_reached_type; match rate_limit_reached_type { Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => { self.on_error( "You're out of credits. Your workspace is out of credits. Add credits to continue using Codex." .to_string(), ); } Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => { self.on_error( "Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex." .to_string(), ); } Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => { self.on_error(message); self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits); } Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => { self.on_error(message); self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit); } Some(RateLimitReachedType::RateLimitReached) | None => { self.on_error(message); } } } fn handle_non_retry_error( &mut self, message: String, codex_error_info: Option, ) { if codex_error_info .as_ref() .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) { } else if codex_error_info .as_ref() .is_some_and(is_app_server_cyber_policy_error) { self.on_cyber_policy_error(); } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) { match info { RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { self.on_rate_limit_error(info, message) } } } else { self.on_error(message); } } fn on_warning(&mut self, message: impl Into) { let message = message.into(); if !self.warning_display_state.should_display(&message) { return; } self.add_to_history(history_cell::new_warning_event(message)); self.request_redraw(); } fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) { if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) { self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); } } /// Handle a turn aborted due to user interrupt (Esc), budget exhaustion, /// or review completion. /// 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(); let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt; self.submit_pending_steers_after_interrupt = false; if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { if send_pending_steers_immediately { self.add_to_history(history_cell::new_info_event( "Model interrupted to submit steer instructions.".to_owned(), /*hint*/ None, )); } else { self.add_to_history(history_cell::new_error_event( self.interrupted_turn_message(reason), )); } } // The server has already discarded pending input by the time the // interrupted turn reaches the UI, so any unacknowledged steers still // tracked here must be restored locally instead of waiting for a later commit. if send_pending_steers_immediately { let pending_steers = self .pending_steers .drain(..) .map(|pending| (pending.user_message, pending.history_record)) .collect::>(); if !pending_steers.is_empty() { let (user_message, history_record) = merge_user_messages_with_history_record(pending_steers); self.submit_user_message_with_history_record(user_message, history_record); } else if let Some(combined) = self.drain_pending_messages_for_restore() { self.restore_user_message_to_composer(combined); } } else if let Some(combined) = self.drain_pending_messages_for_restore() { self.restore_user_message_to_composer(combined); } self.refresh_pending_input_preview(); self.request_redraw(); } /// Merge pending steers, queued drafts, and the current composer state into a single message. /// /// Each pending message numbers attachments from `[Image #1]` relative to its own remote /// images. When we concatenate multiple messages after interrupt, we must renumber local-image /// placeholders in a stable order and rebase text element byte ranges so the restored composer /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } let existing_message = UserMessage { text: self.bottom_pane.composer_text(), text_elements: self.bottom_pane.composer_text_elements(), local_images: self.bottom_pane.composer_local_images(), remote_image_urls: self.bottom_pane.remote_image_urls(), mention_bindings: self.bottom_pane.composer_mention_bindings(), }; let rejected_messages = self.rejected_steers_queue.drain(..).collect::>(); let mut rejected_history_records = self .rejected_steer_history_records .drain(..) .collect::>(); rejected_history_records.resize( rejected_messages.len(), UserMessageHistoryRecord::UserMessageText, ); let mut to_merge: Vec = rejected_messages .into_iter() .zip(rejected_history_records.iter()) .map(|(message, history_record)| user_message_for_restore(message, history_record)) .collect(); to_merge.extend( self.pending_steers .drain(..) .map(|steer| user_message_for_restore(steer.user_message, &steer.history_record)), ); let queued_messages = self.queued_user_messages.drain(..).collect::>(); let mut queued_history_records = self .queued_user_message_history_records .drain(..) .collect::>(); queued_history_records.resize( queued_messages.len(), UserMessageHistoryRecord::UserMessageText, ); to_merge.extend( queued_messages .into_iter() .zip(queued_history_records.iter()) .map(|(message, history_record)| { user_message_for_restore(message.into_user_message(), history_record) }), ); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() || !existing_message.remote_image_urls.is_empty() { to_merge.push(existing_message); } Some(merge_user_messages(to_merge)) } pub(crate) fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { let UserMessage { text, local_images, remote_image_urls, text_elements, mention_bindings, } = user_message; let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); self.set_remote_image_urls(remote_image_urls); self.bottom_pane.set_composer_text_with_mention_bindings( text, text_elements, local_image_paths, mention_bindings, ); } pub(crate) fn capture_thread_input_state(&self) -> Option { let composer = ThreadComposerState { text: self.bottom_pane.composer_text(), text_elements: self.bottom_pane.composer_text_elements(), local_images: self.bottom_pane.composer_local_images(), remote_image_urls: self.bottom_pane.remote_image_urls(), mention_bindings: self.bottom_pane.composer_mention_bindings(), pending_pastes: self.bottom_pane.composer_pending_pastes(), }; Some(ThreadInputState { composer: composer.has_content().then_some(composer), pending_steers: self .pending_steers .iter() .map(|pending| pending.user_message.clone()) .collect(), pending_steer_history_records: self .pending_steers .iter() .map(|pending| pending.history_record.clone()) .collect(), pending_steer_compare_keys: self .pending_steers .iter() .map(|pending| pending.compare_key.clone()) .collect(), rejected_steers_queue: self.rejected_steers_queue.clone(), rejected_steer_history_records: self.rejected_steer_history_records.clone(), queued_user_messages: self.queued_user_messages.clone(), queued_user_message_history_records: self.queued_user_message_history_records.clone(), user_turn_pending_start: self.user_turn_pending_start, current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, }) } pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; self.agent_turn_running = input_state.agent_turn_running; self.goal_status_active_turn_started_at = self.agent_turn_running.then_some(Instant::now()); self.user_turn_pending_start = input_state.user_turn_pending_start; self.update_collaboration_mode_indicator(); self.refresh_model_dependent_surfaces(); if let Some(composer) = input_state.composer { let local_image_paths = composer .local_images .into_iter() .map(|img| img.path) .collect(); self.set_remote_image_urls(composer.remote_image_urls); self.bottom_pane.set_composer_text_with_mention_bindings( composer.text, composer.text_elements, local_image_paths, composer.mention_bindings, ); self.bottom_pane .set_composer_pending_pastes(composer.pending_pastes); } else { self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), Vec::new(), Vec::new(), Vec::new(), ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } let mut pending_steer_history_records = input_state.pending_steer_history_records; pending_steer_history_records.resize( input_state.pending_steers.len(), UserMessageHistoryRecord::UserMessageText, ); let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys; self.pending_steers = input_state .pending_steers .into_iter() .zip(pending_steer_history_records) .map(|(user_message, history_record)| PendingSteer { compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| { PendingSteerCompareKey { message: user_message.text.clone(), image_count: user_message.local_images.len() + user_message.remote_image_urls.len(), } }), history_record, user_message, }) .collect(); self.rejected_steers_queue = input_state.rejected_steers_queue; self.rejected_steer_history_records = input_state.rejected_steer_history_records; self.rejected_steer_history_records.resize( self.rejected_steers_queue.len(), UserMessageHistoryRecord::UserMessageText, ); self.queued_user_messages = input_state.queued_user_messages; self.queued_user_message_history_records = input_state.queued_user_message_history_records; self.queued_user_message_history_records.resize( self.queued_user_messages.len(), UserMessageHistoryRecord::UserMessageText, ); } else { self.agent_turn_running = false; self.goal_status_active_turn_started_at = None; self.user_turn_pending_start = false; self.pending_steers.clear(); self.rejected_steers_queue.clear(); self.rejected_steer_history_records.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), Vec::new(), Vec::new(), Vec::new(), ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); self.queued_user_messages.clear(); self.queued_user_message_history_records.clear(); } self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); if restored_task_running && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); self.refresh_status_surfaces(); } self.refresh_pending_input_preview(); self.request_redraw(); } pub(crate) fn set_queue_autosend_suppressed(&mut self, suppressed: bool) { self.suppress_queue_autosend = suppressed; } fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.saw_plan_update_this_turn = true; let total = update.plan.len(); let completed = update .plan .iter() .filter(|item| match &item.status { StepStatus::Completed => true, StepStatus::Pending | StepStatus::InProgress => false, }) .count(); self.last_plan_progress = (total > 0).then_some((completed, total)); self.refresh_status_surfaces(); self.add_to_history(history_cell::new_plan_update(update)); } fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) { let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_exec_approval(ev), |s| s.handle_exec_approval_now(ev2), ); } fn on_apply_patch_approval_request(&mut self, _id: String, ev: ApplyPatchApprovalRequestEvent) { let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_apply_patch_approval(ev), |s| s.handle_apply_patch_approval_now(ev2), ); } /// Handle guardian review lifecycle events for the current thread. /// /// In-progress assessments temporarily own the live status footer so the /// user can see what is being reviewed, including parallel review /// aggregation. Terminal assessments clear or update that footer state and /// render the final approved/denied history cell when guardian returns a /// decision. fn on_guardian_assessment(&mut self, ev: GuardianAssessmentEvent) { let permission_request_summary = |subject: &str, reason: &Option| { reason .as_deref() .map(str::trim) .filter(|reason| !reason.is_empty()) .map(|reason| format!("{subject}: {reason}")) .unwrap_or_else(|| subject.to_string()) }; let guardian_action_summary = |action: &GuardianAssessmentAction| match action { GuardianAssessmentAction::Command { command, .. } => Some(command.clone()), GuardianAssessmentAction::Execve { program, argv, .. } => { let command = if argv.is_empty() { vec![program.clone()] } else { argv.clone() }; shlex::try_join(command.iter().map(String::as_str)) .ok() .or_else(|| Some(command.join(" "))) } GuardianAssessmentAction::ApplyPatch { files, .. } => Some(if files.len() == 1 { format!("apply_patch touching {}", files[0].display()) } else { format!("apply_patch touching {} files", files.len()) }), GuardianAssessmentAction::NetworkAccess { target, .. } => { Some(format!("network access to {target}")) } GuardianAssessmentAction::McpToolCall { server, tool_name, connector_name, .. } => { let label = connector_name.as_deref().unwrap_or(server.as_str()); Some(format!("MCP {tool_name} on {label}")) } GuardianAssessmentAction::RequestPermissions { reason, .. } => { Some(permission_request_summary("permission request", reason)) } }; let guardian_command = |action: &GuardianAssessmentAction| match action { GuardianAssessmentAction::Command { command, .. } => shlex::split(command) .filter(|command| !command.is_empty()) .or_else(|| Some(vec![command.clone()])), GuardianAssessmentAction::Execve { program, argv, .. } => Some(if argv.is_empty() { vec![program.clone()] } else { argv.clone() }) .filter(|command| !command.is_empty()), GuardianAssessmentAction::ApplyPatch { .. } | GuardianAssessmentAction::NetworkAccess { .. } | GuardianAssessmentAction::McpToolCall { .. } | GuardianAssessmentAction::RequestPermissions { .. } => None, }; if ev.status == GuardianAssessmentStatus::InProgress && let Some(detail) = guardian_action_summary(&ev.action) { // In-progress assessments own the live footer state while the // review is pending. Parallel reviews are aggregated into one // footer summary by `PendingGuardianReviewStatus`. self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); self.pending_guardian_review_status .start_or_update(ev.id.clone(), detail); if let Some(status) = self.pending_guardian_review_status.status_indicator_state() { self.set_status( status.header, status.details, StatusDetailsCapitalization::Preserve, status.details_max_lines, ); } self.request_redraw(); return; } // Terminal assessments remove the matching pending footer entry first, // then render the final approved/denied history cell below. if self.pending_guardian_review_status.finish(&ev.id) { if let Some(status) = self.pending_guardian_review_status.status_indicator_state() { self.set_status( status.header, status.details, StatusDetailsCapitalization::Preserve, status.details_max_lines, ); } else if self.current_status.is_guardian_review() { self.set_status_header(String::from("Working")); } } else if self.pending_guardian_review_status.is_empty() && self.current_status.is_guardian_review() { self.set_status_header(String::from("Working")); } if ev.status == GuardianAssessmentStatus::Approved { let cell = if let Some(command) = guardian_command(&ev.action) { history_cell::new_approval_decision_cell( command, crate::history_cell::ReviewDecision::Approved, history_cell::ApprovalDecisionActor::Guardian, ) } else if let Some(summary) = guardian_action_summary(&ev.action) { history_cell::new_guardian_approved_action_request(summary) } else { let summary = serde_json::to_string(&ev.action) .unwrap_or_else(|_| "".to_string()); history_cell::new_guardian_approved_action_request(summary) }; self.add_boxed_history(cell); self.request_redraw(); return; } if ev.status == GuardianAssessmentStatus::TimedOut { let cell = if let Some(command) = guardian_command(&ev.action) { history_cell::new_approval_decision_cell( command, crate::history_cell::ReviewDecision::TimedOut, history_cell::ApprovalDecisionActor::Guardian, ) } else { match &ev.action { GuardianAssessmentAction::ApplyPatch { files, .. } => { let files = files .iter() .map(|path| path.display().to_string()) .collect::>(); history_cell::new_guardian_timed_out_patch_request(files) } GuardianAssessmentAction::McpToolCall { server, tool_name, .. } => history_cell::new_guardian_timed_out_action_request(format!( "codex could call MCP tool {server}.{tool_name}" )), GuardianAssessmentAction::NetworkAccess { target, .. } => { history_cell::new_guardian_timed_out_action_request(format!( "codex could access {target}" )) } GuardianAssessmentAction::RequestPermissions { reason, .. } => { history_cell::new_guardian_timed_out_action_request( permission_request_summary("codex could request permissions", reason), ) } GuardianAssessmentAction::Command { .. } => unreachable!(), GuardianAssessmentAction::Execve { .. } => unreachable!(), } }; self.add_boxed_history(cell); self.request_redraw(); return; } if ev.status != GuardianAssessmentStatus::Denied { return; } self.recent_auto_review_denials.push(ev.clone()); let cell = if let Some(command) = guardian_command(&ev.action) { history_cell::new_approval_decision_cell( command, crate::history_cell::ReviewDecision::Denied, history_cell::ApprovalDecisionActor::Guardian, ) } else { match &ev.action { GuardianAssessmentAction::ApplyPatch { files, .. } => { let files = files .iter() .map(|path| path.display().to_string()) .collect::>(); history_cell::new_guardian_denied_patch_request(files) } GuardianAssessmentAction::McpToolCall { server, tool_name, .. } => history_cell::new_guardian_denied_action_request(format!( "codex to call MCP tool {server}.{tool_name}" )), GuardianAssessmentAction::NetworkAccess { target, .. } => { history_cell::new_guardian_denied_action_request(format!( "codex to access {target}" )) } GuardianAssessmentAction::RequestPermissions { reason, .. } => { history_cell::new_guardian_denied_action_request(permission_request_summary( "codex to request permissions", reason, )) } GuardianAssessmentAction::Command { .. } => unreachable!(), GuardianAssessmentAction::Execve { .. } => unreachable!(), } }; self.add_boxed_history(cell); self.request_redraw(); } fn on_elicitation_request( &mut self, request_id: AppServerRequestId, params: McpServerElicitationRequestParams, ) { let request_id2 = request_id.clone(); let params2 = params.clone(); self.defer_or_handle( |q| q.push_elicitation(request_id, params), |s| s.handle_elicitation_request_now(request_id2, params2), ); } fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) { let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_user_input(ev), |s| s.handle_request_user_input_now(ev2), ); } fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_request_permissions(ev), |s| s.handle_request_permissions_now(ev2), ); } fn on_command_execution_started(&mut self, item: ThreadItem) { let ThreadItem::CommandExecution { id, command, process_id, source, command_actions, .. } = &item else { return; }; let (_command, parsed_cmd) = command_execution_command_and_parsed(command, command_actions); self.flush_answer_stream_with_separator(); if is_unified_exec_source(*source) { if *source == ExecCommandSource::UnifiedExecStartup { self.track_unified_exec_process_begin(id, process_id.as_deref(), command); } if !self.bottom_pane.is_task_running() { return; } // Unified exec may be parsed as Unknown; keep the working indicator visible regardless. self.bottom_pane.ensure_status_indicator(); if !is_standard_tool_call(&parsed_cmd) { return; } } let item2 = item.clone(); self.defer_or_handle( |q| q.push_item_started(item), |s| s.handle_command_execution_started_now(item2), ); } fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) { self.track_unified_exec_output_chunk(call_id, delta.as_bytes()); if !self.bottom_pane.is_task_running() { return; } let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) else { return; }; if cell.append_output(call_id, delta) { self.bump_active_cell_revision(); self.request_redraw(); } } fn on_terminal_interaction(&mut self, process_id: String, stdin: String) { if !self.bottom_pane.is_task_running() { return; } self.flush_answer_stream_with_separator(); let command_display = self .unified_exec_processes .iter() .find(|process| process.key == process_id) .map(|process| process.command_display.clone()); if stdin.is_empty() { // Empty stdin means we are polling for background output. // Surface this in the status indicator (single "waiting" surface) instead of // the transcript. Keep the header short so the interrupt hint remains visible. self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal; self.set_status( "Waiting for background terminal".to_string(), command_display.clone(), StatusDetailsCapitalization::Preserve, /*details_max_lines*/ 1, ); match &mut self.unified_exec_wait_streak { Some(wait) if wait.process_id == process_id => { wait.update_command_display(command_display); } Some(_) => { self.flush_unified_exec_wait_streak(); self.unified_exec_wait_streak = Some(UnifiedExecWaitStreak::new(process_id, command_display)); } None => { self.unified_exec_wait_streak = Some(UnifiedExecWaitStreak::new(process_id, command_display)); } } self.request_redraw(); } else { if self .unified_exec_wait_streak .as_ref() .is_some_and(|wait| wait.process_id == process_id) { self.flush_unified_exec_wait_streak(); } self.add_to_history(history_cell::new_unified_exec_interaction( command_display, stdin, )); } } fn on_patch_apply_begin(&mut self, changes: HashMap) { self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); } fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_view_image_tool_call( path, &self.config.cwd, )); self.request_redraw(); } fn on_image_generation_begin(&mut self) { self.flush_answer_stream_with_separator(); } fn on_image_generation_end( &mut self, call_id: String, revised_prompt: Option, saved_path: Option, ) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_image_generation_call( call_id, revised_prompt, saved_path, )); self.request_redraw(); } fn on_file_change_completed(&mut self, item: ThreadItem) { let item2 = item.clone(); self.defer_or_handle( |q| q.push_item_completed(item), |s| s.handle_file_change_completed_now(item2), ); } fn on_command_execution_completed(&mut self, item: ThreadItem) { let ThreadItem::CommandExecution { id, process_id, source, .. } = &item else { return; }; if is_unified_exec_source(*source) { if let Some(process_id) = process_id.as_deref() && self .unified_exec_wait_streak .as_ref() .is_some_and(|wait| wait.process_id == process_id) { self.flush_unified_exec_wait_streak(); } self.track_unified_exec_process_end(id, process_id.as_deref()); if !self.bottom_pane.is_task_running() { return; } } let item2 = item.clone(); self.defer_or_handle( |q| q.push_item_completed(item), |s| s.handle_command_execution_completed_now(item2), ); } fn track_unified_exec_process_begin( &mut self, call_id: &str, process_id: Option<&str>, command: &str, ) { let key = process_id.unwrap_or(call_id).to_string(); let command = split_command_string(command); let command_display = strip_bash_lc_and_escape(&command); if let Some(existing) = self .unified_exec_processes .iter_mut() .find(|process| process.key == key) { existing.call_id = call_id.to_string(); existing.command_display = command_display; existing.recent_chunks.clear(); } else { self.unified_exec_processes.push(UnifiedExecProcessSummary { key, call_id: call_id.to_string(), command_display, recent_chunks: Vec::new(), }); } self.sync_unified_exec_footer(); } fn track_unified_exec_process_end(&mut self, call_id: &str, process_id: Option<&str>) { let key = process_id.unwrap_or(call_id); let before = self.unified_exec_processes.len(); self.unified_exec_processes .retain(|process| process.key != key); if self.unified_exec_processes.len() != before { self.sync_unified_exec_footer(); } } fn sync_unified_exec_footer(&mut self) { let processes = self .unified_exec_processes .iter() .map(|process| process.command_display.clone()) .collect(); self.bottom_pane.set_unified_exec_processes(processes); } /// Record recent stdout/stderr lines for the unified exec footer. fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) { let Some(process) = self .unified_exec_processes .iter_mut() .find(|process| process.call_id == call_id) else { return; }; let text = String::from_utf8_lossy(chunk); for line in text .lines() .map(str::trim_end) .filter(|line| !line.is_empty()) { process.recent_chunks.push(line.to_string()); } const MAX_RECENT_CHUNKS: usize = 3; if process.recent_chunks.len() > MAX_RECENT_CHUNKS { let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS; process.recent_chunks.drain(0..drop_count); } } fn on_mcp_tool_call_started(&mut self, item: ThreadItem) { let item2 = item.clone(); self.defer_or_handle( |q| q.push_item_started(item), |s| s.handle_mcp_tool_call_started_now(item2), ); } fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) { let item2 = item.clone(); self.defer_or_handle( |q| q.push_item_completed(item), |s| s.handle_mcp_tool_call_completed_now(item2), ); } fn on_web_search_begin(&mut self, call_id: String) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_web_search_call( call_id, String::new(), self.config.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); } fn on_web_search_end( &mut self, call_id: String, query: String, action: codex_app_server_protocol::WebSearchAction, ) { self.flush_answer_stream_with_separator(); let mut handled = false; if let Some(cell) = self .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) && cell.call_id() == call_id { cell.update(action.clone(), query.clone()); cell.complete(); self.bump_active_cell_revision(); self.flush_active_cell(); handled = true; } if !handled { self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); } self.had_work_activity = true; } fn on_collab_event(&mut self, cell: PlainHistoryCell) { self.flush_answer_stream_with_separator(); self.add_to_history(cell); self.request_redraw(); } fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { let ThreadItem::CollabAgentToolCall { id, tool, status, .. } = &item else { return; }; if matches!(tool, CollabAgentTool::SpawnAgent) && let Some(spawn_request) = multi_agents::spawn_request_summary(&item) { self.pending_collab_spawn_requests .insert(id.clone(), spawn_request); } let cached_spawn_request = if matches!(tool, CollabAgentTool::SpawnAgent) && !matches!(status, CollabAgentToolCallStatus::InProgress) { self.pending_collab_spawn_requests.remove(id) } else { None }; if let Some(cell) = multi_agents::tool_call_history_cell( &item, cached_spawn_request.as_ref(), |thread_id| self.collab_agent_metadata(thread_id), ) { self.on_collab_event(cell); } } pub(crate) fn handle_history_entry_response(&mut self, event: HistoryLookupResponse) { let HistoryLookupResponse { offset, log_id, entry, } = event; self.bottom_pane .on_history_entry_response(log_id, offset, entry); } fn on_shutdown_complete(&mut self) { self.request_immediate_exit(); } fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); self.refresh_status_line(); } fn interrupted_turn_message(&self, reason: TurnAbortReason) -> String { if reason == TurnAbortReason::BudgetLimited { return "Goal budget reached - the turn was stopped.".to_string(); } "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_string() } fn on_deprecation_notice(&mut self, summary: String, details: Option) { self.add_to_history(history_cell::new_deprecation_notice(summary, details)); self.request_redraw(); } fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { self.flush_answer_stream_with_separator(); self.flush_completed_hook_output(); match self.active_hook_cell.as_mut() { Some(cell) => { cell.start_run(run); self.bump_active_cell_revision(); } None => { self.active_hook_cell = Some(history_cell::new_active_hook_cell( run, self.config.animations, )); self.bump_active_cell_revision(); } } self.request_redraw(); } fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) { let completed_existing_run = self .active_hook_cell .as_mut() .map(|cell| cell.complete_run(completed.clone())) .unwrap_or(false); if completed_existing_run { self.bump_active_cell_revision(); } else { match self.active_hook_cell.as_mut() { Some(cell) => { cell.add_completed_run(completed); self.bump_active_cell_revision(); } None => { let cell = history_cell::new_completed_hook_cell(completed, self.config.animations); if !cell.is_empty() { self.active_hook_cell = Some(cell); self.bump_active_cell_revision(); } } } } self.flush_completed_hook_output(); self.finish_active_hook_cell_if_idle(); self.request_redraw(); } fn flush_completed_hook_output(&mut self) { let Some(completed_cell) = self .active_hook_cell .as_mut() .and_then(HookCell::take_completed_persistent_runs) else { return; }; let active_cell_is_empty = self .active_hook_cell .as_ref() .is_some_and(HookCell::is_empty); if active_cell_is_empty { self.active_hook_cell = None; } self.bump_active_cell_revision(); self.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); } fn finish_active_hook_cell_if_idle(&mut self) { let Some(cell) = self.active_hook_cell.as_ref() else { return; }; if cell.is_empty() { self.active_hook_cell = None; self.bump_active_cell_revision(); return; } if cell.should_flush() && let Some(cell) = self.active_hook_cell.take() { self.bump_active_cell_revision(); self.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(cell))); } } fn update_due_hook_visibility(&mut self) { let Some(cell) = self.active_hook_cell.as_mut() else { return; }; let now = Instant::now(); if cell.advance_time(now) { self.bump_active_cell_revision(); } self.finish_active_hook_cell_if_idle(); } fn schedule_hook_timer_if_needed(&self) { if self.config.animations && self .active_hook_cell .as_ref() .is_some_and(HookCell::has_visible_running_run) { self.frame_requester .schedule_frame_in(Duration::from_millis(50)); } let Some(deadline) = self .active_hook_cell .as_ref() .and_then(HookCell::next_timer_deadline) else { return; }; let delay = deadline.saturating_duration_since(Instant::now()); self.frame_requester.schedule_frame_in(delay); } fn on_stream_error(&mut self, message: String, additional_details: Option) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status.header.clone()); } self.bottom_pane.ensure_status_indicator(); self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status( message, additional_details, StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); } pub(crate) fn pre_draw_tick(&mut self) { self.update_due_hook_visibility(); self.schedule_hook_timer_if_needed(); self.bottom_pane.pre_draw_tick(); self.refresh_plan_mode_nudge(); self.refresh_goal_status_indicator_for_time_tick(); if self.terminal_title_shows_action_required() != self.last_terminal_title_requires_action { self.refresh_terminal_title(); } if self.should_animate_terminal_title_spinner() || self.should_animate_terminal_title_action_required() { self.refresh_terminal_title(); } } /// Handle completion of an `AgentMessage` turn item. /// /// Commentary completion sets a deferred restore flag so the status row /// returns once stream queues are idle. Final-answer completion (or absent /// phase for legacy models) clears the flag to preserve historical behavior. fn on_agent_message_item_completed(&mut self, item: AgentMessageItem) { let mut message = String::new(); for content in &item.content { match content { AgentMessageContent::Text { text } => message.push_str(text), } } self.finalize_completed_assistant_message( (!message.is_empty()).then_some(message.as_str()), ); if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) && !message.is_empty() { self.record_agent_markdown(&message); } self.pending_status_indicator_restore = match item.phase { // Models that don't support preambles only output AgentMessageItems on turn completion. Some(MessagePhase::FinalAnswer) | None => !self.pending_steers.is_empty(), Some(MessagePhase::Commentary) => true, }; self.maybe_restore_status_indicator_after_stream_idle(); } /// Periodic tick for stream commits. In smooth mode this preserves one-line pacing, while /// catch-up mode drains larger batches to reduce queue lag. pub(crate) fn on_commit_tick(&mut self) { self.run_commit_tick(); } /// Runs a regular periodic commit tick. fn run_commit_tick(&mut self) { self.run_commit_tick_with_scope(CommitTickScope::AnyMode); } /// Runs an opportunistic commit tick only if catch-up mode is active. fn run_catch_up_commit_tick(&mut self) { self.run_commit_tick_with_scope(CommitTickScope::CatchUpOnly); } /// Runs a commit tick for the current stream queue snapshot. /// /// `scope` controls whether this call may commit in smooth mode or only when catch-up /// is currently active. While lines are actively streaming we hide the status row to avoid /// duplicate "in progress" affordances. Restoration is gated separately so we only re-show /// the row after commentary completion once stream queues are idle. fn run_commit_tick_with_scope(&mut self, scope: CommitTickScope) { let now = Instant::now(); let outcome = run_commit_tick( &mut self.adaptive_chunking, self.stream_controller.as_mut(), self.plan_stream_controller.as_mut(), scope, now, ); for cell in outcome.cells { self.bottom_pane.hide_status_indicator(); self.add_boxed_history(cell); } if outcome.has_controller && outcome.all_idle { self.maybe_restore_status_indicator_after_stream_idle(); self.app_event_tx.send(AppEvent::StopCommitAnimation); } if self.agent_turn_running { self.refresh_runtime_metrics(); } } fn flush_interrupt_queue(&mut self) { let mut mgr = std::mem::take(&mut self.interrupts); mgr.flush_all(self); self.interrupts = mgr; } #[inline] fn defer_or_handle( &mut self, push: impl FnOnce(&mut InterruptManager), handle: impl FnOnce(&mut Self), ) { // Preserve deterministic FIFO across queued interrupts: once anything // is queued due to an active write cycle, continue queueing until the // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). if self.stream_controller.is_some() || !self.interrupts.is_empty() { push(&mut self.interrupts); } else { handle(self); } } fn handle_stream_finished(&mut self) { if self.task_complete_pending { self.bottom_pane.hide_status_indicator(); self.task_complete_pending = false; } // A completed stream indicates non-exec content was just inserted. self.flush_interrupt_queue(); } #[inline] fn handle_streaming_delta(&mut self, delta: String) { // Before streaming agent content, flush any active exec cell group. self.flush_unified_exec_wait_streak(); self.flush_active_cell(); if self.stream_controller.is_none() { // If the previous turn inserted non-stream history (exec output, patch status, MCP // calls), render a separator before starting the next streamed assistant message. if self.needs_final_message_separator && self.had_work_activity { self.add_to_history(history_cell::FinalMessageSeparator::new( /*elapsed_seconds*/ None, /*runtime_metrics*/ None, )); self.needs_final_message_separator = false; } else if self.needs_final_message_separator { // Reset the flag even if we don't show separator (no work was done) self.needs_final_message_separator = false; } self.stream_controller = Some(StreamController::new( self.current_stream_width(/*reserved_cols*/ 2), &self.config.cwd, self.history_render_mode(), )); } if let Some(controller) = self.stream_controller.as_mut() && controller.push(&delta) { self.app_event_tx.send(AppEvent::StartCommitAnimation); self.run_catch_up_commit_tick(); } self.request_redraw(); } /// Finalizes an exec call while preserving the active exec cell grouping contract. /// /// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an /// end event for a call that was never materialized as the current active `ExecCell` (for /// example, when another exploring group is still active). In that case we render the end as a /// standalone history entry instead of replacing or flushing the unrelated active exploring /// cell. If this method treated every unknown end as "complete the active cell", the UI could /// merge unrelated commands and hide still-running exploring work. pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) { enum ExecEndTarget { // Normal case: the active exec cell already tracks this call id. ActiveTracked, // We have an active exec group, but it does not contain this call id. Render the end // as a standalone finalized history cell so the active group remains intact. OrphanHistoryWhileActiveExec, // No active exec cell can safely own this end; build a new cell from the end payload. NewCell, } let ThreadItem::CommandExecution { id, command, process_id: _, source, command_actions, aggregated_output, exit_code, duration_ms, .. } = item else { return; }; let event_command = split_command_string(&command); let event_parsed = command_actions .into_iter() .map(codex_app_server_protocol::CommandAction::into_core) .collect(); let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); let exit_code = exit_code.unwrap_or_default(); let aggregated_output = aggregated_output.unwrap_or_default(); let running = self.running_commands.remove(&id); if self.suppressed_exec_calls.remove(&id) { return; } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), None => (event_command, event_parsed, source), }; let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); let is_user_shell = source == ExecCommandSource::UserShell; let end_target = match self.active_cell.as_ref() { Some(cell) => match cell.as_any().downcast_ref::() { Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => { ExecEndTarget::ActiveTracked } Some(exec_cell) if exec_cell.is_active() => { ExecEndTarget::OrphanHistoryWhileActiveExec } Some(_) | None => ExecEndTarget::NewCell, }, None => ExecEndTarget::NewCell, }; // Unified exec interaction rows intentionally hide command output text in the exec cell and // instead render the interaction-specific content elsewhere in the UI. let output = if is_unified_exec_interaction { CommandOutput { exit_code, formatted_output: String::new(), aggregated_output: String::new(), } } else { CommandOutput { exit_code, formatted_output: aggregated_output.clone(), aggregated_output, } }; match end_target { ExecEndTarget::ActiveTracked => { if let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) { let completed = cell.complete_call(&id, output, duration); debug_assert!(completed, "active exec cell should contain {id}"); if cell.should_flush() { self.flush_active_cell(); } else { self.bump_active_cell_revision(); self.request_redraw(); } } } ExecEndTarget::OrphanHistoryWhileActiveExec => { let mut orphan = new_active_exec_command( id.clone(), command, parsed, source, /*interaction_input*/ None, self.config.animations, ); let completed = orphan.complete_call(&id, output, duration); debug_assert!(completed, "new orphan exec cell should contain {id}"); self.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(orphan))); self.request_redraw(); } ExecEndTarget::NewCell => { self.flush_active_cell(); let mut cell = new_active_exec_command( id.clone(), command, parsed, source, /*interaction_input*/ None, self.config.animations, ); let completed = cell.complete_call(&id, output, duration); debug_assert!(completed, "new exec cell should contain {id}"); if cell.should_flush() { self.add_to_history(cell); } else { self.active_cell = Some(Box::new(cell)); self.bump_active_cell_revision(); self.request_redraw(); } } } // Mark that actual work was done (command executed) self.had_work_activity = true; if is_user_shell { self.maybe_send_next_queued_input(); } } pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { let ThreadItem::FileChange { status, .. } = item else { return; }; // If the patch was successful, just let the "Edited" block stand. // Otherwise, add a failure block. if matches!(status, codex_app_server_protocol::PatchApplyStatus::Failed) { self.add_to_history(history_cell::new_patch_apply_failure(String::new())); } // Mark that actual work was done (patch applied) self.had_work_activity = true; } pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) { self.flush_answer_stream_with_separator(); let command = shlex::try_join(ev.command.iter().map(String::as_str)) .unwrap_or_else(|_| ev.command.join(" ")); self.notify(Notification::ExecApprovalRequested { command }); let available_decisions = ev.effective_available_decisions(); let request = ApprovalRequest::Exec { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, id: ev.effective_approval_id(), command: ev.command, reason: ev.reason, available_decisions, network_approval_context: ev.network_approval_context, additional_permissions: ev.additional_permissions, }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); } pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) { self.flush_answer_stream_with_separator(); let request = ApprovalRequest::ApplyPatch { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, id: ev.call_id, reason: ev.reason, changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.to_path_buf(), changes: ev.changes.keys().cloned().collect(), }); } pub(crate) fn handle_elicitation_request_now( &mut self, request_id: AppServerRequestId, params: McpServerElicitationRequestParams, ) { self.flush_answer_stream_with_separator(); self.notify(Notification::ElicitationRequested { server_name: params.server_name.clone(), }); let thread_id = self.thread_id.unwrap_or_default(); if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( thread_id, ¶ms.server_name, request_id.clone(), ¶ms.request, ) { self.open_app_link_view(params); } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id.clone(), params.clone(), ) { self.bottom_pane .push_mcp_server_elicitation_request(request); } else { match params.request { McpServerElicitationRequest::Form { message, .. } => { let request = ApprovalRequest::McpElicitation { thread_id, thread_label: None, server_name: params.server_name, request_id, message, }; self.bottom_pane .push_approval_request(request, &self.config.features); } McpServerElicitationRequest::Url { .. } => { self.app_event_tx.resolve_elicitation( thread_id, params.server_name, request_id, codex_app_server_protocol::McpServerElicitationAction::Decline, /*content*/ None, /*meta*/ None, ); } } } self.request_redraw(); } pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); } pub(crate) fn push_mcp_server_elicitation_request( &mut self, request: McpServerElicitationFormRequest, ) { self.bottom_pane .push_mcp_server_elicitation_request(request); self.request_redraw(); } pub(crate) fn handle_request_user_input_now(&mut self, ev: ToolRequestUserInputParams) { self.flush_answer_stream_with_separator(); let question_count = ev.questions.len(); let summary = Notification::user_input_request_summary(&ev.questions); let title = match (question_count, summary.as_deref()) { (1, Some(summary)) => summary.to_string(), (1, None) => "Question requested".to_string(), (count, _) => format!("{count} questions requested"), }; self.notify(Notification::PlanModePrompt { title }); self.bottom_pane.push_user_input_request(ev); self.request_redraw(); } pub(crate) fn handle_request_permissions_now(&mut self, ev: RequestPermissionsEvent) { self.flush_answer_stream_with_separator(); let request = ApprovalRequest::Permissions { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, call_id: ev.call_id, reason: ev.reason, permissions: ev.permissions, }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); } pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { let ThreadItem::CommandExecution { id, command, source, command_actions, .. } = item else { return; }; let (command, parsed_cmd) = command_execution_command_and_parsed(&command, &command_actions); // Ensure the status indicator is visible while the command runs. self.bottom_pane.ensure_status_indicator(); let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(parsed_cmd); self.running_commands.insert( id.clone(), RunningCommand { command: command.clone(), parsed_cmd: parsed_cmd.clone(), source, }, ); let is_wait_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); let command_display = command.join(" "); let should_suppress_unified_wait = is_wait_interaction && self .last_unified_wait .as_ref() .is_some_and(|wait| wait.is_duplicate(&command_display)); if is_wait_interaction { self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); } else { self.last_unified_wait = None; } if should_suppress_unified_wait { self.suppressed_exec_calls.insert(id); return; } if let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) && let Some(new_exec) = cell.with_added_call( id.clone(), command.clone(), parsed_cmd.clone(), source, /*interaction_input*/ None, ) { *cell = new_exec; self.bump_active_cell_revision(); } else { self.flush_active_cell(); self.active_cell = Some(Box::new(new_active_exec_command( id, command, parsed_cmd, source, /*interaction_input*/ None, self.config.animations, ))); self.bump_active_cell_revision(); } self.request_redraw(); } pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { let ThreadItem::McpToolCall { id, server, tool, arguments, .. } = item else { return; }; self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( id, McpInvocation { server, tool, arguments: Some(arguments), }, self.config.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); } pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) { self.flush_answer_stream_with_separator(); let ThreadItem::McpToolCall { id, server, tool, arguments, result, error, duration_ms, .. } = item else { return; }; let invocation = McpInvocation { server, tool, arguments: Some(arguments), }; let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); let result = match (result, error) { (_, Some(error)) => Err(error.message), (Some(result), None) => { let result = *result; Ok(codex_protocol::mcp::CallToolResult { content: result.content, structured_content: result.structured_content, is_error: Some(false), meta: None, }) } (None, None) => Err("MCP tool call completed without a result".to_string()), }; let extra_cell = match self .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) { Some(cell) if cell.call_id() == id => cell.complete(duration, result), _ => { self.flush_active_cell(); let mut cell = history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); let extra_cell = cell.complete(duration, result); self.active_cell = Some(Box::new(cell)); extra_cell } }; self.flush_active_cell(); if let Some(extra) = extra_cell { self.add_boxed_history(extra); } // Mark that actual work was done (MCP tool call) self.had_work_activity = true; } pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { match item { item @ ThreadItem::CommandExecution { .. } => { self.handle_command_execution_started_now(item); } item @ ThreadItem::McpToolCall { .. } => { self.handle_mcp_tool_call_started_now(item); } _ => {} } } pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItem) { match item { item @ ThreadItem::CommandExecution { .. } => { self.handle_command_execution_completed_now(item); } item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item), item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item), _ => {} } } pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { Self::new_with_op_target(common, CodexOpTarget::AppEvent) } fn new_with_op_target(common: ChatWidgetInit, codex_op_target: CodexOpTarget) -> Self { let ChatWidgetInit { config, frame_requester, app_event_tx, workspace_command_runner, initial_user_message, enhanced_keys_supported, has_chatgpt_account, model_catalog, feedback, is_first_run, status_account_display, runtime_model_provider_base_url, initial_plan_type, model, startup_tooltip_override, status_line_invalid_items_warned, terminal_title_invalid_items_warned, session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; config.model = model.clone(); let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let side_placeholder = SIDE_PLACEHOLDERS[rng.random_range(0..SIDE_PLACEHOLDERS.len())].to_string(); let model_override = model.as_deref(); let model_for_header = model .clone() .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); let active_collaboration_mask = Self::initial_collaboration_mask(&config, model_catalog.as_ref(), model_override); let header_model = active_collaboration_mask .as_ref() .and_then(|mask| mask.model.clone()) .unwrap_or_else(|| model_for_header.clone()); let fallback_default = Settings { model: header_model.clone(), reasoning_effort: None, developer_instructions: None, }; // Collaboration modes start in Default mode. let current_collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: fallback_default, }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.to_path_buf()); let effective_service_tier = config .service_tier .as_deref() .and_then(ServiceTier::from_request_value); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); let copy_last_response_binding = runtime_keymap .as_ref() .map(|keymap| keymap.app.copy.clone()) .unwrap_or_else(|| default_keymap.app.copy.clone()); let chat_keymap = runtime_keymap .as_ref() .map(|keymap| keymap.chat.clone()) .unwrap_or_else(|| default_keymap.chat.clone()); let queued_message_edit_hint_binding = queued_message_edit_hint_binding( &chat_keymap.edit_queued_message, current_terminal_info, ); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_target, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder.clone(), disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, skills: None, }), active_cell, active_cell_revision: 0, raw_output_mode: config.tui_raw_output_mode, config, effective_service_tier, skills_all: Vec::new(), skills_initial_state: None, current_collaboration_mode, active_collaboration_mask, has_chatgpt_account, model_catalog, session_telemetry, session_header: SessionHeader::new(header_model), initial_user_message, status_account_display, runtime_model_provider_base_url, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), warning_display_state: WarningDisplayState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), add_credits_nudge_email_in_flight: None, adaptive_chunking: AdaptiveChunkingPolicy::default(), stream_controller: None, plan_stream_controller: None, clipboard_lease: None, copy_last_response_binding, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, unified_exec_wait_streak: None, turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, last_agent_markdown: None, agent_turn_markdowns: Vec::new(), visible_user_turn_count: 0, copy_history_evicted_by_rollback: false, latest_proposed_plan_markdown: None, saw_copy_source_this_turn: false, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, mcp_startup_allow_terminal_only_next_round: false, mcp_startup_pending_next_round: HashMap::new(), mcp_startup_pending_next_round_saw_starting: false, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, ide_context: IdeContextState::default(), plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, plugins_active_tab_id: None, newly_installed_marketplace_tab_id: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), recent_auto_review_denials: RecentAutoReviewDenials::default(), active_hook_cell: None, terminal_title_status_kind: TerminalTitleStatusKind::Working, retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, dismissed_plan_mode_nudge_scopes: HashSet::new(), last_turn_id: None, budget_limited_turn_ids: HashSet::new(), thread_name: None, thread_rename_block_message: None, active_side_conversation: false, normal_placeholder_text: placeholder, side_placeholder_text: side_placeholder, forked_from: None, interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, queued_user_messages: VecDeque::new(), queued_user_message_history_records: VecDeque::new(), user_turn_pending_start: false, rejected_steers_queue: VecDeque::new(), rejected_steer_history_records: VecDeque::new(), pending_steers: VecDeque::new(), submit_pending_steers_after_interrupt: false, chat_keymap, queued_message_edit_hint_binding, show_welcome_banner: is_first_run, startup_tooltip_override, suppress_session_configured_redraw: false, suppress_initial_user_message_submit: 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, had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, turn_runtime_metrics: RuntimeMetricsSummary::default(), last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, current_cwd, workspace_command_runner, instruction_source_paths: Vec::new(), session_network_proxy: None, status_line_invalid_items_warned, terminal_title_invalid_items_warned, last_terminal_title: None, last_terminal_title_requires_action: false, terminal_title_setup_original_items: None, terminal_title_animation_origin: Instant::now(), status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, status_line_branch_pending: false, status_line_branch_lookup_complete: false, status_line_git_summary: None, status_line_git_summary_cwd: None, status_line_git_summary_pending: false, status_line_git_summary_lookup_complete: false, current_goal_status_indicator: None, current_goal_status: None, goal_status_active_turn_started_at: None, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_display: None, last_non_retry_error: None, }; widget.prefetch_rate_limits(); if let Some(keymap) = runtime_keymap { widget.bottom_pane.set_keymap_bindings(&keymap); } widget .bottom_pane .set_vim_enabled(widget.config.tui_vim_mode_default); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); widget .bottom_pane .set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled()); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); widget .bottom_pane .set_collaboration_modes_enabled(/*enabled*/ true); widget.sync_fast_command_enabled(); widget.sync_personality_command_enabled(); widget.sync_plugins_command_enabled(); widget.sync_goal_command_enabled(); widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_hint_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && matches!( WindowsSandboxLevel::from_config(&widget.config), WindowsSandboxLevel::RestrictedToken ), ); widget.update_collaboration_mode_indicator(); widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); widget.refresh_status_surfaces(); widget } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { if self.bottom_pane.has_active_view() && !matches!( key_event, KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') ) && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) { self.bottom_pane.handle_key_event(key_event); if self.bottom_pane.no_modal_or_popup_active() { self.maybe_send_next_queued_input(); } return; } if self.handle_reasoning_shortcut(key_event) { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; return; } if key_event.kind == KeyEventKind::Press && self.copy_last_response_binding.is_pressed(key_event) { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.copy_last_agent_markdown(); return; } 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 modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) && c.eq_ignore_ascii_case(&'v') => { 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}", ))); } } 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; } _ => {} } if key_event.kind == KeyEventKind::Press && self.chat_keymap.edit_queued_message.is_pressed(key_event) && self.has_queued_follow_up_messages() && self.bottom_pane.no_modal_or_popup_active() { if let Some(user_message) = self.pop_latest_queued_user_message() { self.restore_user_message_to_composer(user_message); self.refresh_pending_input_preview(); self.request_redraw(); } return; } if matches!(key_event.code, KeyCode::Esc) && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && !self.pending_steers.is_empty() && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() && !self.should_handle_vim_insert_escape(key_event) { self.submit_pending_steers_after_interrupt = true; if !self.submit_op(AppCommand::interrupt()) { self.submit_pending_steers_after_interrupt = false; } return; } if matches!(key_event.code, KeyCode::Esc) && key_event.kind == KeyEventKind::Press && self.should_show_plan_mode_nudge() { self.dismiss_plan_mode_nudge(); return; } if self.handle_plugins_popup_key_event(key_event) { return; } match key_event { KeyEvent { code: KeyCode::BackTab, kind: KeyEventKind::Press, .. } if self.collaboration_modes_enabled() && !self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() => { self.cycle_collaboration_mode(); self.refresh_plan_mode_nudge(); } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted { text, text_elements, } => { let local_images = self .bottom_pane .take_recent_submission_images_with_placeholders(); let remote_image_urls = self.take_remote_image_urls(); let user_message = UserMessage { text, local_images, remote_image_urls, text_elements, mention_bindings: self .bottom_pane .take_recent_submission_mention_bindings(), }; if user_message.text.is_empty() && user_message.local_images.is_empty() && user_message.remote_image_urls.is_empty() { return; } let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui(); if should_submit_now { if self.only_user_shell_commands_running() && !user_message.text.starts_with('!') { self.queue_user_message(user_message); return; } // Submitted is emitted when user submits. // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); self.submit_user_message(user_message); } else { self.queue_user_message(user_message); } } InputResult::Queued { text, text_elements, action, } => { let local_images = self .bottom_pane .take_recent_submission_images_with_placeholders(); let remote_image_urls = self.take_remote_image_urls(); let user_message = UserMessage { text, local_images, remote_image_urls, text_elements, mention_bindings: self .bottom_pane .take_recent_submission_mention_bindings(), }; self.queue_user_message_with_options(user_message, action); } InputResult::Command(cmd) => { self.handle_slash_command_dispatch(cmd); } InputResult::CommandWithArgs(cmd, args, text_elements) => { self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); } InputResult::None => {} } if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { self.maybe_send_next_queued_input(); } self.refresh_plan_mode_nudge(); } } } /// Attach a local image to the composer when the active model supports image inputs. /// /// When the model does not advertise image support, we keep the draft unchanged and surface a /// warning event so users can switch models or remove attachments. pub(crate) fn attach_image(&mut self, path: PathBuf) { if !self.current_model_supports_images() { self.add_to_history(history_cell::new_warning_event( self.image_inputs_not_supported_message(), )); self.request_redraw(); return; } tracing::info!("attach_image path={path:?}"); self.bottom_pane.attach_image(path); self.request_redraw(); } 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.refresh_plan_mode_nudge(); 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 show_selection_view(&mut self, params: SelectionViewParams) { self.bottom_pane.show_selection_view(params); self.refresh_plan_mode_nudge(); self.request_redraw(); } pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.bottom_pane.no_modal_or_popup_active() } pub(crate) fn can_launch_external_editor(&self) -> bool { self.bottom_pane.can_launch_external_editor() } pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool { // Ctrl+L is not a slash command, but it follows /clear's current rule: // block while a task is running. if !self.bottom_pane.is_task_running() { return true; } let message = "Ctrl+L is disabled while a task is in progress.".to_string(); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); false } /// Copy the last agent response (raw markdown) to the system clipboard. pub(crate) fn copy_last_agent_markdown(&mut self) { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } pub(crate) fn truncate_agent_copy_history_to_user_turn_count( &mut self, user_turn_count: usize, ) { self.visible_user_turn_count = user_turn_count; let had_copy_history = !self.agent_turn_markdowns.is_empty(); self.agent_turn_markdowns .retain(|entry| entry.user_turn_count <= user_turn_count); self.last_agent_markdown = self .agent_turn_markdowns .last() .map(|entry| entry.markdown.clone()); self.copy_history_evicted_by_rollback = had_copy_history && self.last_agent_markdown.is_none(); self.saw_copy_source_this_turn = false; } /// Inner implementation with an injectable clipboard backend for testing. fn copy_last_agent_markdown_with( &mut self, copy_fn: impl FnOnce(&str) -> Result, String>, ) { match self.last_agent_markdown.clone() { Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { Ok(lease) => { self.clipboard_lease = lease; self.add_to_history(history_cell::new_info_event( "Copied last message to clipboard".into(), /*hint*/ None, )); } Err(error) => self.add_to_history(history_cell::new_error_event(format!( "Copy failed: {error}" ))), }, _ if self.copy_history_evicted_by_rollback => { self.add_to_history(history_cell::new_error_event(format!( "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." ))); } _ => self.add_to_history(history_cell::new_error_event( "No agent response to copy".into(), )), } self.request_redraw(); } #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.last_agent_markdown.as_deref() } fn show_rename_prompt(&mut self) { if !self.ensure_thread_rename_allowed() { return; } let tx = self.app_event_tx.clone(); let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); let title = if existing_name.is_some() { "Rename thread" } else { "Name thread" }; let view = CustomPromptView::new( title.to_string(), "Type a name and press Enter".to_string(), /*initial_text*/ existing_name.unwrap_or_default().to_string(), /*context_label*/ None, Box::new(move |name: String| { let Some(name) = crate::legacy_core::util::normalize_thread_name(&name) else { tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_error_event("Thread name cannot be empty.".to_string()), ))); return; }; tx.set_thread_name(name); }), ); self.bottom_pane.show_view(Box::new(view)); } fn ensure_thread_rename_allowed(&mut self) -> bool { match self.thread_rename_block_message.clone() { Some(message) => { self.add_error_message(message); false } None => true, } } pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); self.refresh_plan_mode_nudge(); } // 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() { self.refresh_plan_mode_nudge(); // 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) { self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); } fn queue_user_message_with_options( &mut self, user_message: UserMessage, action: QueuedInputAction, ) { if !self.is_session_configured() || self.is_user_turn_pending_or_running() { self.queued_user_messages .push_back(QueuedUserMessage::new(user_message, action)); self.queued_user_message_history_records .push_back(UserMessageHistoryRecord::UserMessageText); self.refresh_pending_input_preview(); } else { self.submit_user_message(user_message); } } fn submit_shell_command(&mut self, command: &str) -> QueueDrain { let cmd = command.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()), ), ))); QueueDrain::Continue } else { self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); QueueDrain::Stop } } fn submit_shell_command_with_history( &mut self, command: &str, history_text: &str, ) -> QueueDrain { let drain = self.submit_shell_command(command); if drain == QueueDrain::Stop { self.append_message_history_entry(history_text.to_string()); } drain } fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain { match user_message.text.strip_prefix('!') { Some(command) => { let history_text = user_message.text.clone(); self.submit_shell_command_with_history(command, &history_text) } None => { self.submit_user_message(user_message); QueueDrain::Stop } } } fn submit_user_message(&mut self, user_message: UserMessage) { let _accepted = self.submit_user_message_with_history_record( user_message, UserMessageHistoryRecord::UserMessageText, ); } fn submit_user_message_with_history_record( &mut self, user_message: UserMessage, history_record: UserMessageHistoryRecord, ) -> bool { self.submit_user_message_with_history_and_shell_escape_policy( user_message, history_record, ShellEscapePolicy::Allow, ) .0 } fn submit_user_message_with_shell_escape_policy( &mut self, user_message: UserMessage, shell_escape_policy: ShellEscapePolicy, ) -> Option { self.submit_user_message_with_history_and_shell_escape_policy( user_message, UserMessageHistoryRecord::UserMessageText, shell_escape_policy, ) .1 } fn submit_user_message_with_history_and_shell_escape_policy( &mut self, user_message: UserMessage, history_record: UserMessageHistoryRecord, shell_escape_policy: ShellEscapePolicy, ) -> (bool, Option) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages .push_front(QueuedUserMessage::from(user_message)); self.queued_user_message_history_records .push_front(history_record); self.refresh_pending_input_preview(); return (true, None); } if user_message.text.is_empty() && user_message.local_images.is_empty() && user_message.remote_image_urls.is_empty() { return (false, None); } if (!user_message.local_images.is_empty() || !user_message.remote_image_urls.is_empty()) && !self.current_model_supports_images() { let UserMessage { text, text_elements, local_images, mention_bindings, remote_image_urls, } = user_message_for_restore(user_message, &history_record); self.restore_blocked_image_submission( text, text_elements, local_images, mention_bindings, remote_image_urls, ); return (false, None); } let UserMessage { text, local_images, remote_image_urls, text_elements, mention_bindings, } = user_message; let render_in_history = !self.agent_turn_running; let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. if shell_escape_policy == ShellEscapePolicy::Allow && let Some(stripped) = text.strip_prefix('!') { let app_command = match self.submit_shell_command_with_history(stripped, &text) { QueueDrain::Continue => None, QueueDrain::Stop => Some(AppCommand::run_user_shell_command( stripped.trim().to_string(), )), }; return (app_command.is_some(), app_command); } for image_url in &remote_image_urls { items.push(UserInput::Image { url: image_url.clone(), }); } for image in &local_images { items.push(UserInput::LocalImage { path: image.path.clone(), }); } if !text.is_empty() { items.push(UserInput::Text { text: text.clone(), text_elements: app_server_text_elements(&text_elements), }); } let mentions = collect_tool_mentions(&text, &HashMap::new()); let bound_names: HashSet = mention_bindings .iter() .map(|binding| binding.mention.clone()) .collect(); let mut skill_names_lower: HashSet = HashSet::new(); let mut selected_skill_paths: HashSet = HashSet::new(); let mut selected_plugin_ids: HashSet = HashSet::new(); if let Some(skills) = self.bottom_pane.skills() { skill_names_lower = skills .iter() .map(|skill| skill.name.to_ascii_lowercase()) .collect(); for binding in &mention_bindings { let path = binding .path .strip_prefix("skill://") .unwrap_or(binding.path.as_str()); let path = Path::new(path); if let Some(skill) = skills .iter() .find(|skill| skill.path_to_skills_md.as_path() == path) && selected_skill_paths.insert(skill.path_to_skills_md.clone()) { items.push(UserInput::Skill { name: skill.name.clone(), path: skill.path_to_skills_md.to_path_buf(), }); } } let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills); for skill in skill_mentions { if bound_names.contains(skill.name.as_str()) || !selected_skill_paths.insert(skill.path_to_skills_md.clone()) { continue; } items.push(UserInput::Skill { name: skill.name.clone(), path: skill.path_to_skills_md.to_path_buf(), }); } } if let Some(plugins) = self.plugins_for_mentions() { for binding in &mention_bindings { let Some(plugin_config_name) = binding .path .strip_prefix("plugin://") .filter(|id| !id.is_empty()) else { continue; }; if !selected_plugin_ids.insert(plugin_config_name.to_string()) { continue; } if let Some(plugin) = plugins .iter() .find(|plugin| plugin.config_name == plugin_config_name) { items.push(UserInput::Mention { name: plugin.display_name.clone(), path: binding.path.clone(), }); } } } let mut selected_app_ids: HashSet = HashSet::new(); if let Some(apps) = self.connectors_for_mentions() { for binding in &mention_bindings { let Some(app_id) = binding .path .strip_prefix("app://") .filter(|id| !id.is_empty()) else { continue; }; if !selected_app_ids.insert(app_id.to_string()) { continue; } if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) { items.push(UserInput::Mention { name: app.name.clone(), path: binding.path.clone(), }); } } let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower); for app in app_mentions { let slug = codex_connectors::metadata::connector_mention_slug(&app); if bound_names.contains(&slug) || !selected_app_ids.insert(app.id.clone()) { continue; } let app_id = app.id.as_str(); items.push(UserInput::Mention { name: app.name.clone(), path: format!("app://{app_id}"), }); } } let effective_mode = self.effective_collaboration_mode(); if effective_mode.model().trim().is_empty() { self.add_error_message( "Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(), ); self.restore_user_message_to_composer(user_message_for_restore( UserMessage { text, local_images, remote_image_urls, text_elements, mention_bindings, }, &history_record, )); return (false, None); } self.maybe_apply_ide_context(&mut items); let collaboration_mode = if self.collaboration_modes_enabled() { self.active_collaboration_mask .as_ref() .map(|_| effective_mode.clone()) } else { None }; let pending_steer = (!render_in_history).then(|| PendingSteer { user_message: UserMessage { text: text.clone(), local_images: local_images.clone(), remote_image_urls: remote_image_urls.clone(), text_elements: text_elements.clone(), mention_bindings: mention_bindings.clone(), }, history_record: history_record.clone(), compare_key: Self::pending_steer_compare_key_from_items(&items), }); let personality = self .config .personality .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); let service_tier = match self.config.service_tier.clone() { Some(service_tier) => Some(Some(service_tier)), None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, }; let permission_profile = self.config.permissions.permission_profile(); let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), AskForApproval::from(self.config.permissions.approval_policy.value()), permission_profile, effective_mode.model().to_string(), effective_mode.reasoning_effort(), /*summary*/ None, service_tier, /*final_output_json_schema*/ None, collaboration_mode, personality, ); if !self.submit_op(op.clone()) { return (false, None); } if render_in_history { self.user_turn_pending_start = true; } // Persist the submitted text to cross-session message history. Mentions are encoded into // placeholder syntax so recall can reconstruct the mention bindings in a future session. let encoded_mentions = mention_bindings .iter() .map(|binding| LinkedMention { mention: binding.mention.clone(), path: binding.path.clone(), }) .collect::>(); let history_text = match &history_record { UserMessageHistoryRecord::UserMessageText if !text.is_empty() => { Some(encode_history_mentions(&text, &encoded_mentions)) } UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { Some(encode_history_mentions(&history.text, &encoded_mentions)) } UserMessageHistoryRecord::UserMessageText | UserMessageHistoryRecord::Override(_) => { None } }; if let Some(history_text) = history_text { self.append_message_history_entry(history_text); } if let Some(pending_steer) = pending_steer { self.pending_steers.push_back(pending_steer); self.saw_plan_item_this_turn = false; self.refresh_pending_input_preview(); } // Show replayable user content in conversation history. let display_user_message = render_in_history.then(|| { user_message_display_for_history( UserMessage { text, local_images, remote_image_urls, text_elements, mention_bindings, }, &history_record, ) }); if let Some(display) = display_user_message { self.on_user_message_display(display); } self.needs_final_message_separator = false; (true, Some(op)) } /// Restore the blocked submission draft without losing mention resolution state. /// /// The blocked-image path intentionally keeps the draft in the composer so /// users can remove attachments and retry. We must restore /// mention bindings alongside visible text; restoring only `$name` tokens /// makes the draft look correct while degrading mention resolution to /// name-only heuristics on retry. fn restore_blocked_image_submission( &mut self, text: String, text_elements: Vec, local_images: Vec, mention_bindings: Vec, remote_image_urls: Vec, ) { // Preserve the user's composed payload so they can retry after changing models. let local_image_paths = local_images.iter().map(|img| img.path.clone()).collect(); self.set_remote_image_urls(remote_image_urls); self.bottom_pane.set_composer_text_with_mention_bindings( text, text_elements, local_image_paths, mention_bindings, ); self.add_to_history(history_cell::new_warning_event( self.image_inputs_not_supported_message(), )); self.request_redraw(); } /// Replay a subset of initial events into the UI to seed the transcript when /// resuming an existing session. This approximates the live event flow and /// is intentionally conservative: only safe-to-replay items are rendered to /// avoid triggering side effects. Event ids are passed as `None` to /// distinguish replayed events from live ones. pub(crate) fn replay_thread_turns(&mut self, turns: Vec, replay_kind: ReplayKind) { for turn in turns { let Turn { id: turn_id, items_view: _, items, status, error, started_at, completed_at, duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; self.on_task_started(); } for item in items { self.replay_thread_item(item, turn_id.clone(), replay_kind); } if matches!( status, TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed ) { self.handle_turn_completed_notification( TurnCompletedNotification { thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: Turn { id: turn_id, items_view: codex_app_server_protocol::TurnItemsView::NotLoaded, items: Vec::new(), status, error, started_at, completed_at, duration_ms, }, }, Some(replay_kind), ); } } } pub(crate) fn replay_thread_item( &mut self, item: ThreadItem, turn_id: String, replay_kind: ReplayKind, ) { self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind)); } fn handle_thread_item( &mut self, item: ThreadItem, turn_id: String, render_source: ThreadItemRenderSource, ) { let from_replay = render_source.is_replay(); let replay_kind = render_source.replay_kind(); match item { ThreadItem::UserMessage { content, .. } => { self.on_committed_user_message(&content, from_replay); } ThreadItem::AgentMessage { id, text, phase, memory_citation, } => { self.on_agent_message_item_completed(AgentMessageItem { id, content: vec![AgentMessageContent::Text { text }], phase, memory_citation: memory_citation.map(|citation| { codex_protocol::memory_citation::MemoryCitation { entries: citation .entries .into_iter() .map( |entry| codex_protocol::memory_citation::MemoryCitationEntry { path: entry.path, line_start: entry.line_start, line_end: entry.line_end, note: entry.note, }, ) .collect(), rollout_ids: citation.thread_ids, } }), }); } ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), ThreadItem::Reasoning { summary, content, .. } => { if from_replay { for delta in summary { self.on_agent_reasoning_delta(delta); } if self.config.show_raw_agent_reasoning { for delta in content { self.on_agent_reasoning_delta(delta); } } } self.on_agent_reasoning_final(); } item @ ThreadItem::CommandExecution { status: codex_app_server_protocol::CommandExecutionStatus::InProgress, .. } => self.on_command_execution_started(item), item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), ThreadItem::FileChange { status: codex_app_server_protocol::PatchApplyStatus::InProgress, .. } => {} item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), ThreadItem::WebSearch { id, query, action } => { self.on_web_search_begin(id.clone()); self.on_web_search_end( id, query, action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), ); } ThreadItem::ImageView { id: _, path } => { self.on_view_image_tool_call(path); } ThreadItem::ImageGeneration { id, revised_prompt, saved_path, .. } => { self.on_image_generation_end(id, revised_prompt, saved_path); } ThreadItem::EnteredReviewMode { review, .. } => { if from_replay { self.enter_review_mode_with_hint(review, /*from_replay*/ true); } } ThreadItem::ExitedReviewMode { .. } => { self.exit_review_mode_after_item(); } ThreadItem::ContextCompaction { .. } => { self.add_info_message("Context compacted".to_string(), /*hint*/ None); } ThreadItem::HookPrompt { .. } => {} ThreadItem::CollabAgentToolCall { id, tool, status, sender_thread_id, receiver_thread_ids, prompt, model, reasoning_effort, agents_states, } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { id, tool, status, sender_thread_id, receiver_thread_ids, prompt, model, reasoning_effort, agents_states, }), ThreadItem::DynamicToolCall { .. } => {} } if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() { self.request_redraw(); } } pub(crate) fn handle_server_request( &mut self, request: ServerRequest, replay_kind: Option, ) { let id = request.id().to_string(); match request { ServerRequest::CommandExecutionRequestApproval { params, .. } => { let fallback_cwd = self.config.cwd.clone(); self.on_exec_approval_request( id, exec_approval_request_from_params(params, &fallback_cwd), ); } ServerRequest::FileChangeRequestApproval { params, .. } => { self.on_apply_patch_approval_request( id, patch_approval_request_from_params(params), ); } ServerRequest::McpServerElicitationRequest { request_id, params } => { self.on_elicitation_request(request_id, params); } ServerRequest::PermissionsRequestApproval { params, .. } => { self.on_request_permissions(request_permissions_from_params(params)); } ServerRequest::ToolRequestUserInput { params, .. } => { self.on_request_user_input(params); } ServerRequest::DynamicToolCall { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => { if replay_kind.is_none() { self.add_error_message(TUI_STUB_MESSAGE.to_string()); } } } } pub(crate) fn handle_server_notification( &mut self, notification: ServerNotification, replay_kind: Option, ) { if self.active_side_conversation && replay_kind.is_none() && matches!(notification, ServerNotification::McpServerStatusUpdated(_)) { return; } let from_replay = replay_kind.is_some(); let is_resume_initial_replay = matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)); let is_retry_error = matches!( ¬ification, ServerNotification::Error(ErrorNotification { will_retry: true, .. }) ); if !is_resume_initial_replay && !is_retry_error { self.restore_retry_status_header_if_present(); } match notification { ServerNotification::ThreadTokenUsageUpdated(notification) => { self.set_token_info(Some(token_usage_info_from_app_server( notification.token_usage, ))); } ServerNotification::ThreadNameUpdated(notification) => { match ThreadId::from_string(¬ification.thread_id) { Ok(thread_id) => { self.on_thread_name_updated(thread_id, notification.thread_name) } Err(err) => { tracing::warn!( thread_id = notification.thread_id, error = %err, "ignoring app-server ThreadNameUpdated with invalid thread_id" ); } } } ServerNotification::ThreadGoalUpdated(notification) => { self.on_thread_goal_updated(notification.goal, notification.turn_id); } ServerNotification::ThreadGoalCleared(notification) => { self.on_thread_goal_cleared(notification.thread_id.as_str()); } ServerNotification::TurnStarted(notification) => { self.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { self.on_task_started(); } } ServerNotification::TurnCompleted(notification) => { self.handle_turn_completed_notification(notification, replay_kind); } ServerNotification::ItemStarted(notification) => { self.handle_item_started_notification(notification, replay_kind.is_some()); } ServerNotification::ItemCompleted(notification) => { self.handle_item_completed_notification(notification, replay_kind); } ServerNotification::AgentMessageDelta(notification) => { self.on_agent_message_delta(notification.delta); } ServerNotification::PlanDelta(notification) => self.on_plan_delta(notification.delta), ServerNotification::ReasoningSummaryTextDelta(notification) => { self.on_agent_reasoning_delta(notification.delta); } ServerNotification::ReasoningTextDelta(notification) => { if self.config.show_raw_agent_reasoning { self.on_agent_reasoning_delta(notification.delta); } } ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(), ServerNotification::TerminalInteraction(notification) => { self.on_terminal_interaction(notification.process_id, notification.stdin) } ServerNotification::CommandExecutionOutputDelta(notification) => { self.on_exec_command_output_delta(¬ification.item_id, ¬ification.delta); } ServerNotification::FileChangeOutputDelta(notification) => { self.on_patch_apply_output_delta(notification.item_id, notification.delta); } ServerNotification::TurnDiffUpdated(notification) => { self.on_turn_diff(notification.diff) } ServerNotification::TurnPlanUpdated(notification) => { self.on_plan_update(UpdatePlanArgs { explanation: notification.explanation, plan: notification .plan .into_iter() .map(|step| UpdatePlanItemArg { step: step.step, status: match step.status { TurnPlanStepStatus::Pending => UpdatePlanItemStatus::Pending, TurnPlanStepStatus::InProgress => UpdatePlanItemStatus::InProgress, TurnPlanStepStatus::Completed => UpdatePlanItemStatus::Completed, }, }) .collect(), }) } ServerNotification::HookStarted(notification) => { self.on_hook_started(notification.run); } ServerNotification::HookCompleted(notification) => { self.on_hook_completed(notification.run); } ServerNotification::Error(notification) => { if notification.will_retry { if !from_replay { self.on_stream_error( notification.error.message, notification.error.additional_details, ); } } else { self.last_non_retry_error = Some(( notification.turn_id.clone(), notification.error.message.clone(), )); self.handle_non_retry_error( notification.error.message, notification.error.codex_error_info, ); } } ServerNotification::SkillsChanged(_) => { self.refresh_skills_for_current_cwd(/*force_reload*/ true); } ServerNotification::ModelRerouted(_) => {} ServerNotification::ModelVerification(notification) => { self.on_app_server_model_verification(¬ification.verifications) } ServerNotification::Warning(notification) => self.on_warning(notification.message), ServerNotification::GuardianWarning(notification) => { self.on_warning(notification.message) } ServerNotification::DeprecationNotice(notification) => { self.on_deprecation_notice(notification.summary, notification.details) } ServerNotification::ConfigWarning(notification) => self.on_warning( notification .details .map(|details| format!("{}: {details}", notification.summary)) .unwrap_or(notification.summary), ), ServerNotification::McpServerStatusUpdated(notification) => { self.on_mcp_server_status_updated(notification) } ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { self.on_guardian_review_notification( notification.review_id, notification.turn_id, notification.review, /*decision_source*/ None, notification.action, ); } ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { self.on_guardian_review_notification( notification.review_id, notification.turn_id, notification.review, Some(notification.decision_source), notification.action, ); } ServerNotification::ThreadClosed(_) => { if !from_replay { self.on_shutdown_complete(); } } ServerNotification::ThreadRealtimeStarted(notification) => { if !from_replay { self.on_realtime_conversation_started(notification); } } ServerNotification::ThreadRealtimeItemAdded(notification) => { if !from_replay { self.on_realtime_item_added(notification); } } ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { if !from_replay { self.on_realtime_output_audio_delta(notification); } } ServerNotification::ThreadRealtimeError(notification) => { if !from_replay { self.on_realtime_error(notification); } } ServerNotification::ThreadRealtimeClosed(notification) => { if !from_replay { self.on_realtime_conversation_closed(notification); } } ServerNotification::ThreadRealtimeSdp(notification) => { if !from_replay { self.on_realtime_conversation_sdp(notification.sdp); } } ServerNotification::ServerRequestResolved(_) | ServerNotification::AccountUpdated(_) | ServerNotification::AccountRateLimitsUpdated(_) | ServerNotification::ThreadStarted(_) | ServerNotification::ThreadStatusChanged(_) | ServerNotification::ThreadArchived(_) | ServerNotification::ThreadUnarchived(_) | ServerNotification::RawResponseItemCompleted(_) | ServerNotification::CommandExecOutputDelta(_) | ServerNotification::ProcessOutputDelta(_) | ServerNotification::ProcessExited(_) | ServerNotification::FileChangePatchUpdated(_) | ServerNotification::McpToolCallProgress(_) | ServerNotification::McpServerOauthLoginCompleted(_) | ServerNotification::AppListUpdated(_) | ServerNotification::RemoteControlStatusChanged(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::ThreadRealtimeTranscriptDelta(_) | ServerNotification::ThreadRealtimeTranscriptDone(_) | ServerNotification::WindowsWorldWritableWarning(_) | ServerNotification::WindowsSandboxSetupCompleted(_) | ServerNotification::AccountLoginCompleted(_) => {} ServerNotification::ContextCompacted(_) => {} } } pub(crate) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { self.on_list_skills(response); } fn handle_turn_completed_notification( &mut self, notification: TurnCompletedNotification, replay_kind: Option, ) { match notification.turn.status { TurnStatus::Completed => { self.last_non_retry_error = None; self.on_task_complete( /*last_agent_message*/ None, notification.turn.duration_ms, replay_kind.is_some(), ) } TurnStatus::Interrupted => { self.last_non_retry_error = None; let reason = if self .budget_limited_turn_ids .remove(notification.turn.id.as_str()) { TurnAbortReason::BudgetLimited } else { TurnAbortReason::Interrupted }; self.on_interrupted_turn(reason); } TurnStatus::Failed => { if let Some(error) = notification.turn.error { if self.last_non_retry_error.as_ref() == Some(&(notification.turn.id.clone(), error.message.clone())) { self.last_non_retry_error = None; } else { self.handle_non_retry_error(error.message, error.codex_error_info); } } else { self.last_non_retry_error = None; self.finalize_turn(); self.request_redraw(); self.maybe_send_next_queued_input(); } } TurnStatus::InProgress => {} } } fn handle_item_started_notification( &mut self, notification: ItemStartedNotification, from_replay: bool, ) { match notification.item { item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item), ThreadItem::FileChange { id: _, changes, .. } => { self.on_patch_apply_begin(file_update_changes_to_display(changes)); } item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item), ThreadItem::WebSearch { id, .. } => { self.on_web_search_begin(id); } ThreadItem::ImageGeneration { .. } => { self.on_image_generation_begin(); } ThreadItem::CollabAgentToolCall { id, tool, status, sender_thread_id, receiver_thread_ids, prompt, model, reasoning_effort, agents_states, } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { id, tool, status, sender_thread_id, receiver_thread_ids, prompt, model, reasoning_effort, agents_states, }), ThreadItem::EnteredReviewMode { review, .. } => { if !from_replay { self.enter_review_mode_with_hint(review, /*from_replay*/ false); } } _ => {} } } fn handle_item_completed_notification( &mut self, notification: ItemCompletedNotification, replay_kind: Option, ) { self.handle_thread_item( notification.item, notification.turn_id, replay_kind.map_or(ThreadItemRenderSource::Live, ThreadItemRenderSource::Replay), ); } fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} fn on_guardian_review_notification( &mut self, id: String, turn_id: String, review: codex_app_server_protocol::GuardianApprovalReview, decision_source: Option, action: GuardianApprovalReviewAction, ) { self.on_guardian_assessment(GuardianAssessmentEvent { id, target_item_id: None, turn_id, status: match review.status { codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { GuardianAssessmentStatus::InProgress } codex_app_server_protocol::GuardianApprovalReviewStatus::Approved => { GuardianAssessmentStatus::Approved } codex_app_server_protocol::GuardianApprovalReviewStatus::Denied => { GuardianAssessmentStatus::Denied } codex_app_server_protocol::GuardianApprovalReviewStatus::TimedOut => { GuardianAssessmentStatus::TimedOut } codex_app_server_protocol::GuardianApprovalReviewStatus::Aborted => { GuardianAssessmentStatus::Aborted } }, risk_level: review.risk_level.map(|risk_level| match risk_level { codex_app_server_protocol::GuardianRiskLevel::Low => { codex_protocol::approvals::GuardianRiskLevel::Low } codex_app_server_protocol::GuardianRiskLevel::Medium => { codex_protocol::approvals::GuardianRiskLevel::Medium } codex_app_server_protocol::GuardianRiskLevel::High => { codex_protocol::approvals::GuardianRiskLevel::High } codex_app_server_protocol::GuardianRiskLevel::Critical => { codex_protocol::approvals::GuardianRiskLevel::Critical } }), user_authorization: review.user_authorization.map(|user_authorization| { match user_authorization { codex_app_server_protocol::GuardianUserAuthorization::Unknown => { codex_protocol::approvals::GuardianUserAuthorization::Unknown } codex_app_server_protocol::GuardianUserAuthorization::Low => { codex_protocol::approvals::GuardianUserAuthorization::Low } codex_app_server_protocol::GuardianUserAuthorization::Medium => { codex_protocol::approvals::GuardianUserAuthorization::Medium } codex_app_server_protocol::GuardianUserAuthorization::High => { codex_protocol::approvals::GuardianUserAuthorization::High } } }), rationale: review.rationale, decision_source: decision_source.map(|source| match source { codex_app_server_protocol::AutoReviewDecisionSource::Agent => { GuardianAssessmentDecisionSource::Agent } }), action: action.into(), }); } fn enter_review_mode_with_hint(&mut self, hint: String, from_replay: bool) { if self.pre_review_token_info.is_none() { self.pre_review_token_info = Some(self.token_info.clone()); } if !from_replay && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); } self.is_review_mode = true; let banner = format!(">> Code review started: {hint} <<"); self.add_to_history(history_cell::new_review_status_line(banner)); self.request_redraw(); } fn exit_review_mode_after_item(&mut self) { self.flush_answer_stream_with_separator(); self.flush_interrupt_queue(); self.flush_active_cell(); self.is_review_mode = false; self.restore_pre_review_token_info(); self.add_to_history(history_cell::new_review_status_line( "<< Code review finished >>".to_string(), )); self.request_redraw(); } fn on_committed_user_message(&mut self, items: &[UserInput], from_replay: bool) { let display = Self::user_message_display_from_inputs(items); if from_replay { if !self.is_review_mode { self.on_user_message_display(display); } return; } let compare_key = Self::pending_steer_compare_key_from_items(items); if self .pending_steers .front() .is_some_and(|pending| pending.compare_key == compare_key) { if let Some(pending) = self.pending_steers.pop_front() { self.refresh_pending_input_preview(); let pending_display = user_message_display_for_history(pending.user_message, &pending.history_record); self.on_user_message_display(pending_display); } else if self.last_rendered_user_message_display.as_ref() != Some(&display) { tracing::warn!( "pending steer matched compare key but queue was empty when rendering committed user message" ); self.on_user_message_display(display); } } else if !self.is_review_mode && self.last_rendered_user_message_display.as_ref() != Some(&display) { self.on_user_message_display(display); } } fn on_user_message_display(&mut self, display: UserMessageDisplay) { self.last_rendered_user_message_display = Some(display.clone()); if !display.message.trim().is_empty() || !display.text_elements.is_empty() || !display.local_images.is_empty() || !display.remote_image_urls.is_empty() { self.record_visible_user_turn_for_copy(); self.add_to_history(history_cell::new_user_prompt( display.message, display.text_elements, display.local_images, display.remote_image_urls, )); } // User messages reset separator state so the next agent response doesn't add a stray break. self.needs_final_message_separator = false; } /// 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.notifications) { return; } if let Some(existing) = self.pending_notification.as_ref() && existing.priority() > notification.priority() { 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. pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { if self.suppress_queue_autosend { return false; } if self.is_user_turn_pending_or_running() { return false; } let mut submitted_follow_up = false; while !self.is_user_turn_pending_or_running() { let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else { break; }; match queued_message.action { QueuedInputAction::Plain => { submitted_follow_up = self.submit_user_message_with_history_record( queued_message.into_user_message(), history_record, ); break; } QueuedInputAction::ParseSlash => { let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); if drain == QueueDrain::Stop { submitted_follow_up = self.is_user_turn_pending_or_running(); break; } } QueuedInputAction::RunShell => { let drain = self.submit_queued_shell_prompt(queued_message.into_user_message()); if drain == QueueDrain::Stop { submitted_follow_up = self.is_user_turn_pending_or_running(); break; } } } } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); submitted_follow_up } pub(super) fn is_user_turn_pending_or_running(&self) -> bool { self.user_turn_pending_start || self.bottom_pane.is_task_running() } fn only_user_shell_commands_running(&self) -> bool { self.agent_turn_running && !self.running_commands.is_empty() && self .running_commands .values() .all(|command| command.source == ExecCommandSource::UserShell) } /// Rebuild and update the bottom-pane pending-input preview. fn refresh_pending_input_preview(&mut self) { let queued_messages: Vec = self .queued_user_messages .iter() .enumerate() .map(|(idx, message)| { user_message_preview_text( message, self.queued_user_message_history_records.get(idx), ) }) .collect(); let pending_steers: Vec = self .pending_steers .iter() .map(|steer| { user_message_preview_text(&steer.user_message, Some(&steer.history_record)) }) .collect(); let rejected_steers: Vec = self .rejected_steers_queue .iter() .enumerate() .map(|(idx, message)| { user_message_preview_text(message, self.rejected_steer_history_records.get(idx)) }) .collect(); self.bottom_pane.set_pending_input_preview( queued_messages, pending_steers, rejected_steers, ); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { self.bottom_pane.set_pending_thread_approvals(threads); } pub(crate) fn clear_thread_rename_block(&mut self) { self.thread_rename_block_message = None; } pub(crate) fn set_thread_rename_block_message(&mut self, message: impl Into) { self.thread_rename_block_message = Some(message.into()); } pub(crate) fn set_interrupted_turn_notice_mode(&mut self, mode: InterruptedTurnNoticeMode) { self.interrupted_turn_notice_mode = mode; } 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, refreshing_rate_limits: bool, request_id: Option, ) { 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); let collaboration_mode = self.collaboration_mode_label(); let model = self.current_model().to_string(); let model_default_reasoning_effort = self.model_catalog .try_list_models() .ok() .and_then(|models| { models .into_iter() .find(|preset| preset.model == model) .map(|preset| preset.default_reasoning_effort) }); let reasoning_effort_override = Some( self.effective_reasoning_effort() .or(self.config.model_reasoning_effort) .or(model_default_reasoning_effort), ); let rate_limit_snapshots: Vec = self .rate_limit_snapshots_by_limit_id .values() .cloned() .collect(); let agents_summary = crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( &self.config, self.runtime_model_provider_base_url.as_deref(), self.status_account_display.as_ref(), token_info, total_usage, &self.thread_id, self.thread_name.clone(), self.forked_from, rate_limit_snapshots.as_slice(), self.plan_type, Local::now(), self.model_display_name(), collaboration_mode, reasoning_effort_override, agents_summary, refreshing_rate_limits, ); if let Some(request_id) = request_id { self.refreshing_status_outputs.push((request_id, handle)); } self.add_to_history(cell); } pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { if self.refreshing_status_outputs.is_empty() { return; } let rate_limit_snapshots: Vec = self .rate_limit_snapshots_by_limit_id .values() .cloned() .collect(); let now = Local::now(); let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len()); let mut updated_any = false; for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) { if pending_request_id == request_id { updated_any = true; handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now); } else { remaining.push((pending_request_id, handle)); } } self.refreshing_status_outputs = remaining; if updated_any { self.request_redraw(); } } pub(crate) fn add_debug_config_output(&mut self) { self.add_to_history(crate::debug_config::new_debug_config_output( &self.config, self.session_network_proxy.as_ref(), )); } fn open_status_line_setup(&mut self) { let configured_status_line_items = self.configured_status_line_items(); let view = StatusLineSetupView::new( Some(configured_status_line_items.as_slice()), self.config.tui_status_line_use_colors, self.status_surface_preview_data(), self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); } fn open_terminal_title_setup(&mut self) { let configured_terminal_title_items = self.configured_terminal_title_items(); self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); let view = TerminalTitleSetupView::new( Some(configured_terminal_title_items.as_slice()), self.terminal_title_preview_data(), self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); } fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { self.status_surface_preview_value_for_item(item) .map(|value| (item, value)) })) } fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { let mut preview_data = self.status_surface_preview_data(); let now = Instant::now(); for item in TerminalTitleItem::iter() { let Some(preview_item) = item.preview_item() else { continue; }; let Some(value) = self.terminal_title_value_for_item(item, now) else { continue; }; preview_data.set_live(preview_item, value); } preview_data } fn open_theme_picker(&mut self) { let codex_home = crate::legacy_core::config::find_codex_home().ok(); let terminal_width = self .last_rendered_width .get() .and_then(|width| u16::try_from(width).ok()); let params = crate::theme_picker::build_theme_picker_params( self.config.tui_theme.as_deref(), codex_home.as_deref(), terminal_width, ); self.bottom_pane.show_selection_view(params); } fn status_line_context_window_size(&self) -> Option { self.token_info .as_ref() .and_then(|info| info.model_context_window) .or(self.config.model_context_window) } fn status_line_context_remaining_percent(&self) -> Option { let Some(context_window) = self.status_line_context_window_size() else { return Some(100); }; let default_usage = TokenUsage::default(); let usage = self .token_info .as_ref() .map(|info| &info.last_token_usage) .unwrap_or(&default_usage); Some( usage .percent_of_context_window_remaining(context_window) .clamp(0, 100), ) } fn status_line_context_used_percent(&self) -> Option { let remaining = self.status_line_context_remaining_percent().unwrap_or(100); Some((100 - remaining).clamp(0, 100)) } fn status_line_total_usage(&self) -> TokenUsage { self.token_info .as_ref() .map(|info| info.total_token_usage.clone()) .unwrap_or_default() } fn status_line_limit_display( &self, window: Option<&RateLimitWindowDisplay>, label: &str, ) -> Option { let window = window?; let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64); Some(format!("{label} {remaining:.0}%")) } fn status_line_reasoning_effort_label(effort: Option) -> &'static str { match effort { Some(ReasoningEffortConfig::Minimal) => "minimal", Some(ReasoningEffortConfig::Low) => "low", Some(ReasoningEffortConfig::Medium) => "medium", Some(ReasoningEffortConfig::High) => "high", Some(ReasoningEffortConfig::XHigh) => "xhigh", None | Some(ReasoningEffortConfig::None) => "default", } } pub(crate) fn add_ps_output(&mut self) { let processes = self .unified_exec_processes .iter() .map(|process| history_cell::UnifiedExecProcessDetails { command_display: process.command_display.clone(), recent_chunks: process.recent_chunks.clone(), }) .collect(); self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); } fn clean_background_terminals(&mut self) { self.submit_op(AppCommand::clean_background_terminals()); self.unified_exec_processes.clear(); self.sync_unified_exec_footer(); self.add_info_message( "Stopping all background terminals.".to_string(), /*hint*/ None, ); } fn stop_rate_limit_poller(&mut self) {} pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { self.prefetch_connectors_with_options(force_refetch); } fn prefetch_connectors(&mut self) { self.prefetch_connectors_with_options(/*force_refetch*/ false); } fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { if !self.connectors_enabled() { return; } if self.connectors_prefetch_in_flight { if force_refetch { self.connectors_force_refetch_pending = true; } return; } self.connectors_prefetch_in_flight = true; if !matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) { self.connectors_cache = ConnectorsCacheState::Loading; } let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let accessible_result = match connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status( &config, force_refetch, ) .await { Ok(connectors) => connectors, Err(err) => { app_event_tx.send(AppEvent::ConnectorsLoaded { result: Err(format!("Failed to load apps: {err}")), is_final: true, }); return; } }; let should_schedule_force_refetch = !force_refetch && !accessible_result.codex_apps_ready; let accessible_connectors = accessible_result.connectors; app_event_tx.send(AppEvent::ConnectorsLoaded { result: Ok(ConnectorsSnapshot { connectors: accessible_connectors.clone(), }), is_final: false, }); let result: Result = async { let all_connectors = connectors::list_all_connectors_with_options(&config, force_refetch).await?; let connectors = connectors::merge_connectors_with_accessible( all_connectors, accessible_connectors, /*all_connectors_loaded*/ true, ); Ok(ConnectorsSnapshot { connectors }) } .await .map_err(|err: anyhow::Error| format!("Failed to load apps: {err}")); app_event_tx.send(AppEvent::ConnectorsLoaded { result, is_final: true, }); if should_schedule_force_refetch { app_event_tx.send(AppEvent::RefreshConnectors { force_refetch: true, }); } }); } #[cfg_attr(not(test), allow(dead_code))] fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); } #[cfg_attr(not(test), allow(dead_code))] fn should_prefetch_rate_limits(&self) -> bool { self.config.model_provider.requires_openai_auth && self.has_chatgpt_account } fn lower_cost_preset(&self) -> Option { let models = self.model_catalog.try_list_models().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; let switch_model_for_events = switch_model.clone(); let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let switch_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, /*permission_profile*/ None, /*windows_sandbox_level*/ None, Some(switch_model_for_events.clone()), Some(Some(default_effort)), /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, ))); tx.send(AppEvent::UpdateModel(switch_model_for_events.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 {switch_model}"), 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 {switch_model} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { if !self.workspace_owner_usage_nudge_enabled() || self.add_credits_nudge_email_in_flight.is_some() { return; } let (title, prompt) = match credit_type { AddCreditsNudgeCreditType::Credits => ( "You've reached your workspace credit limit", "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?", ), AddCreditsNudgeCreditType::UsageLimit => ( "Usage limit reached", "Request a limit increase from your owner to continue using codex. Request increase?", ), }; let send_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); })]; let items = vec![ SelectionItem { name: "Yes".to_string(), display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), actions: send_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "No".to_string(), display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), is_default: true, dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { title: Some(title.to_string()), subtitle: Some(prompt.to_string()), footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx: Some(1), ..Default::default() }); } pub(crate) fn start_add_credits_nudge_email_request( &mut self, credit_type: AddCreditsNudgeCreditType, ) -> bool { if !self.workspace_owner_usage_nudge_enabled() { return false; } self.add_credits_nudge_email_in_flight = Some(credit_type); true } pub(crate) fn finish_add_credits_nudge_email_request( &mut self, result: Result, ) { let credit_type = self .add_credits_nudge_email_in_flight .take() .unwrap_or(AddCreditsNudgeCreditType::Credits); if !self.workspace_owner_usage_nudge_enabled() { return; } let message = match (credit_type, result) { (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { "Workspace owner notified." } ( AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::CooldownActive), ) => "Workspace owner was already notified recently.", (AddCreditsNudgeCreditType::Credits, Err(_)) => { "Could not notify your workspace owner. Please try again." } (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => { "Limit increase requested." } ( AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::CooldownActive), ) => "A limit increase was already requested recently.", (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => { "Could not request a limit increase. Please try again." } }; self.add_to_history(history_cell::new_info_event( message.to_string(), /*hint*/ None, )); self.request_redraw(); } /// 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(), /*hint*/ None, ); return; } let presets: Vec = match self.model_catalog.try_list_models() { Ok(models) => models, Err(_) => { self.add_info_message( "Models are being updated; please try /model again in a moment.".to_string(), /*hint*/ None, ); return; } }; self.open_model_popup_with_presets(presets); } pub(crate) fn open_personality_popup(&mut self) { if !self.is_session_configured() { self.add_info_message( "Personality selection is disabled until startup completes.".to_string(), /*hint*/ None, ); return; } if !self.current_model_supports_personality() { let current_model = self.current_model(); self.add_error_message(format!( "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." )); return; } self.open_personality_popup_for_current_model(); } fn open_personality_popup_for_current_model(&mut self) { let current_personality = self.config.personality.unwrap_or(Personality::Friendly); let personalities = [Personality::Friendly, Personality::Pragmatic]; let supports_personality = self.current_model_supports_personality(); let items: Vec = personalities .into_iter() .map(|personality| { let name = Self::personality_label(personality).to_string(); let description = Some(Self::personality_description(personality).to_string()); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, /*permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, Some(personality), ))); tx.send(AppEvent::UpdatePersonality(personality)); tx.send(AppEvent::PersistPersonalitySelection { personality }); })]; SelectionItem { name, description, is_current: current_personality == personality, is_disabled: !supports_personality, actions, dismiss_on_select: true, ..Default::default() } }) .collect(); let mut header = ColumnRenderable::new(); header.push(Line::from("Select Personality".bold())); header.push(Line::from("Choose a communication style for Codex.".dim())); self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } pub(crate) fn open_realtime_audio_popup(&mut self) { let items = [ RealtimeAudioDeviceKind::Microphone, RealtimeAudioDeviceKind::Speaker, ] .into_iter() .map(|kind| { let description = Some(format!( "Current: {}", self.current_realtime_audio_selection_label(kind) )); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); })]; SelectionItem { name: kind.title().to_string(), description, actions, dismiss_on_select: true, ..Default::default() } }) .collect(); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Settings".to_string()), subtitle: Some("Configure settings for Codex.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } #[cfg(not(target_os = "linux"))] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { match list_realtime_audio_device_names(kind) { Ok(device_names) => { self.open_realtime_audio_device_selection_with_names(kind, device_names); } Err(err) => { self.add_error_message(format!( "Failed to load realtime {} devices: {err}", kind.noun() )); } } } #[cfg(target_os = "linux")] pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { let _ = kind; } #[cfg(not(target_os = "linux"))] fn open_realtime_audio_device_selection_with_names( &mut self, kind: RealtimeAudioDeviceKind, device_names: Vec, ) { let current_selection = self.current_realtime_audio_device_name(kind); let current_available = current_selection .as_deref() .is_some_and(|name| device_names.iter().any(|device_name| device_name == name)); let mut items = vec![SelectionItem { name: "System default".to_string(), description: Some("Use your operating system default device.".to_string()), is_current: current_selection.is_none(), actions: vec![Box::new(move |tx| { tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None }); })], dismiss_on_select: true, ..Default::default() }]; if let Some(selection) = current_selection.as_deref() && !current_available { items.push(SelectionItem { name: format!("Unavailable: {selection}"), description: Some("Configured device is not currently available.".to_string()), is_current: true, is_disabled: true, disabled_reason: Some("Reconnect the device or choose another one.".to_string()), ..Default::default() }); } items.extend(device_names.into_iter().map(|device_name| { let persisted_name = device_name.clone(); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: Some(persisted_name.clone()), }); })]; SelectionItem { is_current: current_selection.as_deref() == Some(device_name.as_str()), name: device_name, actions, dismiss_on_select: true, ..Default::default() } })); let mut header = ColumnRenderable::new(); header.push(Line::from(format!("Select {}", kind.title()).bold())); header.push(Line::from( "Saved devices apply to realtime voice only.".dim(), )); self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) { let restart_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::RestartRealtimeAudioDevice { kind }); })]; let items = vec![ SelectionItem { name: "Restart now".to_string(), description: Some(format!("Restart local {} audio now.", kind.noun())), actions: restart_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Apply later".to_string(), description: Some(format!( "Keep the current {} until local audio starts again.", kind.noun() )), dismiss_on_select: true, ..Default::default() }, ]; let mut header = ColumnRenderable::new(); header.push(Line::from(format!("Restart {} now?", kind.title()).bold())); header.push(Line::from( "Configuration is saved. Restart local audio to use it immediately.".dim(), )); self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } 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 overridden 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| preset.model.as_str() == current_model) .map(|preset| preset.model.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 should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( model.as_str(), Some(preset.default_reasoning_effort), ); let actions = Self::model_selection_actions( model.clone(), Some(preset.default_reasoning_effort), should_prompt_plan_mode_scope, ); SelectionItem { name: model.clone(), description, is_current: 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(), /*hint*/ 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 = 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.model.clone(), description, is_current, is_default: preset.is_default, actions, dismiss_on_select: single_supported_effort, dismiss_parent_on_child_accept: !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(self.bottom_pane.standard_popup_hint_line()), items, header, ..Default::default() }); } pub(crate) fn open_collaboration_modes_popup(&mut self) { let presets = collaboration_modes::presets_for_tui(self.model_catalog.as_ref()); if presets.is_empty() { self.add_info_message( "No collaboration modes are available right now.".to_string(), /*hint*/ None, ); return; } let current_kind = self .active_collaboration_mask .as_ref() .and_then(|mask| mask.mode) .or_else(|| { collaboration_modes::default_mask(self.model_catalog.as_ref()) .and_then(|mask| mask.mode) }); let items: Vec = presets .into_iter() .map(|mask| { let name = mask.name.clone(); let is_current = current_kind == mask.mode; let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); })]; SelectionItem { name, is_current, actions, dismiss_on_select: true, ..Default::default() } }) .collect(); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select Collaboration Mode".to_string()), subtitle: Some("Pick a collaboration preset.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } fn model_selection_actions( model_for_action: String, effort_for_action: Option, should_prompt_plan_mode_scope: bool, ) -> Vec { vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), effort: effort_for_action, }); return; } 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, }); })] } fn should_prompt_plan_mode_reasoning_scope( &self, selected_model: &str, selected_effort: Option, ) -> bool { if !self.collaboration_modes_enabled() || self.active_mode_kind() != ModeKind::Plan || selected_model != self.current_model() { return false; } // Prompt whenever the selection is not a true no-op for both: // 1) the active Plan-mode effective reasoning, and // 2) the stored global defaults that would be updated by the fallback path. selected_effort != self.effective_reasoning_effort() || selected_model != self.current_collaboration_mode.model() || selected_effort != self.current_collaboration_mode.reasoning_effort() } pub(crate) fn open_plan_reasoning_scope_prompt( &mut self, model: String, effort: Option, ) { let reasoning_phrase = match effort { Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), Some(selected_effort) => { format!( "{} reasoning", Self::reasoning_effort_label(selected_effort).to_lowercase() ) } None => "the selected reasoning".to_string(), }; let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); let plan_reasoning_source = if let Some(plan_override) = self.config.plan_mode_reasoning_effort { format!( "user-chosen Plan override ({})", Self::reasoning_effort_label(plan_override).to_lowercase() ) } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { match plan_mask.reasoning_effort.flatten() { Some(plan_effort) => format!( "built-in Plan default ({})", Self::reasoning_effort_label(plan_effort).to_lowercase() ), None => "built-in Plan default (no reasoning)".to_string(), } } else { "built-in Plan default".to_string() }; let all_modes_description = format!( "Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}." ); let subtitle = format!("Choose where to apply {reasoning_phrase}."); let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort)); tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); tx.send(AppEvent::PersistModelSelection { model: model.clone(), effort, }); })]; self.bottom_pane.show_selection_view(SelectionViewParams { title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), subtitle: Some(subtitle), footer_hint: Some(standard_popup_hint_line()), items: vec![ SelectionItem { name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(), description: Some(plan_only_description), actions: plan_only_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: PLAN_MODE_REASONING_SCOPE_ALL_MODES.to_string(), description: Some(all_modes_description), actions: all_modes_actions, dismiss_on_select: true, ..Default::default() }, ], ..Default::default() }); self.notify(Notification::PlanModePrompt { title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(), }); } /// 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 in_plan_mode = self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; 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 { let selected_effort = choices.first().and_then(|c| c.stored); let selected_model = preset.model; if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { self.app_event_tx .send(AppEvent::OpenPlanReasoningScopePrompt { model: selected_model, effort: selected_effort, }); } else { self.apply_model_and_effort(selected_model, selected_effort); } 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() == preset.model.as_str(); let highlight_choice = if is_current_model { if in_plan_mode { self.config .plan_mode_reasoning_effort .or(self.effective_reasoning_effort()) } else { self.effective_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 choice_effort = choice.stored; let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); let actions: Vec = vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), effort: choice_effort, }); } else { tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), effort: choice_effort, }); } })]; 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_without_persist( &self, model: String, effort: Option, ) { self.app_event_tx.send(AppEvent::UpdateModel(model)); self.app_event_tx .send(AppEvent::UpdateReasoningEffort(effort)); } fn apply_model_and_effort(&self, model: String, effort: Option) { self.apply_model_and_effort_without_persist(model.clone(), effort); self.app_event_tx .send(AppEvent::PersistModelSelection { model, effort }); } /// Open the permissions popup. pub(crate) fn open_approvals_popup(&mut self) { self.open_permissions_popup(); } /// Open a popup to choose the permissions mode. pub(crate) fn open_permissions_popup(&mut self) { let include_read_only = cfg!(target_os = "windows"); let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); let current_permission_profile = self.config.permissions.permission_profile(); let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); let current_review_policy = self.config.approvals_reviewer; let mut items: Vec = Vec::new(); let presets: Vec = builtin_approval_presets(); #[cfg(target_os = "windows")] let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); #[cfg(target_os = "windows")] let windows_degraded_sandbox_enabled = matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; let show_elevate_sandbox_hint = crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && windows_degraded_sandbox_enabled && presets.iter().any(|preset| preset.id == "auto"); let guardian_disabled_reason = |enabled: bool| { let mut next_features = self.config.features.get().clone(); next_features.set_enabled(Feature::GuardianApproval, enabled); self.config .features .can_set(&next_features) .err() .map(|err| err.to_string()) }; for preset in presets.into_iter() { if !include_read_only && preset.id == "read-only" { continue; } let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled { "Default (non-admin sandbox)".to_string() } else { preset.label.to_string() }; let preset_approval = AskForApproval::from(preset.approval); let base_description = Some(preset.description.replace(" (Identical to Agent mode)", "")); let approval_disabled_reason = match self .config .permissions .approval_policy .can_set(&preset.approval) { Ok(()) => None, Err(err) => Some(err.to_string()), }; let default_disabled_reason = approval_disabled_reason .clone() .or_else(|| guardian_disabled_reason(false)); let requires_confirmation = preset.id == "full-access" && !self .config .notices .hide_full_access_warning .unwrap_or(false); let default_actions: Vec = if requires_confirmation { let preset_clone = preset.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenFullAccessConfirmation { preset: preset_clone.clone(), return_to_permissions: !include_read_only, }); })] } else if preset.id == "auto" { #[cfg(target_os = "windows")] { if WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled { let preset_clone = preset.clone(); if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && crate::legacy_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.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) } } #[cfg(not(target_os = "windows"))] { Self::approval_preset_actions( preset_approval, preset.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) } } else { Self::approval_preset_actions( preset_approval, preset.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) }; if preset.id == "auto" { items.push(SelectionItem { name: base_name.clone(), description: base_description.clone(), is_current: current_review_policy == ApprovalsReviewer::User && Self::preset_matches_current( current_approval, ¤t_permission_profile, self.config.cwd.as_path(), &preset, ), actions: default_actions, dismiss_on_select: true, disabled_reason: default_disabled_reason, ..Default::default() }); if guardian_approval_enabled { items.push(SelectionItem { name: "Auto-review".to_string(), description: Some( "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent." .to_string(), ), is_current: current_review_policy == ApprovalsReviewer::AutoReview && Self::preset_matches_current( current_approval, ¤t_permission_profile, self.config.cwd.as_path(), &preset, ), actions: Self::approval_preset_actions( preset_approval, preset.permission_profile.clone(), "Auto-review".to_string(), ApprovalsReviewer::AutoReview, ), dismiss_on_select: true, disabled_reason: approval_disabled_reason .or_else(|| guardian_disabled_reason(true)), ..Default::default() }); } } else { items.push(SelectionItem { name: base_name, description: base_description, is_current: Self::preset_matches_current( current_approval, ¤t_permission_profile, self.config.cwd.as_path(), &preset, ), actions: default_actions, dismiss_on_select: true, disabled_reason: default_disabled_reason, ..Default::default() }); } } let footer_note = show_elevate_sandbox_hint.then(|| { vec![ "The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(), "/setup-default-sandbox".cyan(), ".".dim(), ] .into() }); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Update Model Permissions".to_string()), footer_note, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(()), ..Default::default() }); } pub(crate) fn open_auto_review_denials_popup(&mut self) { if self.recent_auto_review_denials.is_empty() { self.add_info_message( "No recent auto-review denials in this thread.".to_string(), Some("Denials are recorded after auto-review rejects an action.".to_string()), ); return; } let Some(thread_id) = self.thread_id() else { self.add_error_message("That thread is no longer available.".to_string()); return; }; let mut items = vec![SelectionItem { name: "Command".to_string(), description: Some("Rationale".to_string()), is_disabled: true, search_value: Some(String::new()), ..Default::default() }]; items.extend(self.recent_auto_review_denials.entries().map(|event| { let id = event.id.clone(); let summary = auto_review_denials::action_summary(&event.action); let rationale = event .rationale .as_deref() .unwrap_or("Auto-review did not include a rationale."); SelectionItem { name: summary.clone(), description: Some(rationale.to_string()), selected_description: Some(rationale.to_string()), search_value: Some(format!("{summary} {rationale}")), actions: vec![Box::new(move |tx| { tx.send(AppEvent::ApproveRecentAutoReviewDenial { thread_id, id: id.clone(), }); })], dismiss_on_select: true, ..Default::default() } })); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Auto-review Denials".to_string()), subtitle: Some("Select a denied action to approve.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, col_width_mode: ColumnWidthMode::AutoAllRows, ..Default::default() }); self.request_redraw(); } pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) { let Some(event) = self.recent_auto_review_denials.take(&id) else { self.add_error_message("That auto-review denial is no longer available.".to_string()); return; }; self.app_event_tx.send(AppEvent::SubmitThreadOp { thread_id, op: AppCommand::approve_guardian_denied_action(event), }); self.add_info_message( "Approval recorded for one retry of the selected auto-review denial.".to_string(), Some( "The model will see the approval context; the retry still goes through auto-review." .to_string(), ), ); } pub(crate) fn open_experimental_popup(&mut self) { let features: Vec = FEATURES .iter() .filter_map(|spec| { let name = spec.stage.experimental_menu_name()?; let description = spec.stage.experimental_menu_description()?; Some(ExperimentalFeatureItem { 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, permission_profile: PermissionProfile, label: String, approvals_reviewer: ApprovalsReviewer, ) -> Vec { vec![Box::new(move |tx| { let permission_profile_clone = permission_profile.clone(); tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, Some(approval), Some(approvals_reviewer), Some(permission_profile_clone.clone()), /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, ))); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event( format!("Permissions updated to {label}"), /*hint*/ None, ), ))); })] } fn preset_matches_current( current_approval: AskForApproval, current_permission_profile: &PermissionProfile, cwd: &std::path::Path, preset: &ApprovalPreset, ) -> bool { let preset_approval = AskForApproval::from(preset.approval); if current_approval != preset_approval { return false; } match preset.id { "full-access" => matches!(current_permission_profile, PermissionProfile::Disabled), "read-only" => { let file_system_policy = current_permission_profile.file_system_sandbox_policy(); matches!( current_permission_profile, PermissionProfile::Managed { .. } ) && !file_system_policy.has_full_disk_write_access() && file_system_policy .get_writable_roots_with_cwd(cwd) .is_empty() && current_permission_profile.network_sandbox_policy() == preset.permission_profile.network_sandbox_policy() } "auto" => { let file_system_policy = current_permission_profile.file_system_sandbox_policy(); matches!( current_permission_profile, PermissionProfile::Managed { .. } ) && file_system_policy.can_write_path_with_cwd(cwd, cwd) && !file_system_policy.has_full_disk_write_access() && current_permission_profile.network_sandbox_policy() == preset.permission_profile.network_sandbox_policy() } _ => current_permission_profile == &preset.permission_profile, } } #[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(); let Ok(policy) = self .config .permissions .permission_profile() .to_legacy_sandbox_policy(self.config.cwd.as_path()) else { return Some((Vec::new(), 0, true)); }; match codex_windows_sandbox::apply_world_writable_scan_and_denies( self.config.codex_home.as_path(), cwd.as_path(), &env_map, &policy, 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, return_to_permissions: bool, ) { let selected_name = preset.label.to_string(); let approval = AskForApproval::from(preset.approval); let permission_profile = preset.permission_profile; 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, permission_profile.clone(), selected_name.clone(), ApprovalsReviewer::User, ); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); let mut accept_and_remember_actions = Self::approval_preset_actions( approval, permission_profile, selected_name, ApprovalsReviewer::User, ); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); tx.send(AppEvent::PersistFullAccessWarningAcknowledged); })); let deny_actions: Vec = vec![Box::new(move |tx| { if return_to_permissions { tx.send(AppEvent::OpenPermissionsPopup); } else { 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, permission_profile) = match &preset { Some(p) => ( Some(AskForApproval::from(p.approval)), Some(p.permission_profile.clone()), ), None => (None, None), }; let mut header_children: Vec> = Vec::new(); let describe_profile = |profile: &PermissionProfile| { if matches!(profile, PermissionProfile::Disabled) { "Full Access mode" } else if profile .file_system_sandbox_policy() .can_write_path_with_cwd(self.config.cwd.as_path(), self.config.cwd.as_path()) { "Agent mode" } else { "Read-Only mode" } }; let mode_label = preset .as_ref() .map(|p| describe_profile(&p.permission_profile)) .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); 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 permission profile, 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 via // /permissions, 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(permission_profile)) = (approval, permission_profile.clone()) { accept_actions.extend(Self::approval_preset_actions( approval, permission_profile, mode_label.to_string(), ApprovalsReviewer::User, )); } 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(permission_profile)) = (approval, permission_profile) { accept_and_remember_actions.extend(Self::approval_preset_actions( approval, permission_profile, mode_label.to_string(), ApprovalsReviewer::User, )); } 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 !crate::legacy_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; } self.session_telemetry.counter( "codex.windows_sandbox.elevated_prompt_shown", /*inc*/ 1, &[], ); let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], ]) .wrap(Wrap { trim: false }), )); let accept_otel = self.session_telemetry.clone(); let legacy_otel = self.session_telemetry.clone(); let legacy_preset = preset.clone(); let quit_otel = self.session_telemetry.clone(); let items = vec![ SelectionItem { name: "Set up default sandbox (requires Administrator permissions)".to_string(), description: None, actions: vec![Box::new(move |tx| { accept_otel.counter( "codex.windows_sandbox.elevated_prompt_accept", /*inc*/ 1, &[], ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(), description: None, actions: vec![Box::new(move |tx| { legacy_otel.counter( "codex.windows_sandbox.elevated_prompt_use_legacy", /*inc*/ 1, &[], ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: legacy_preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Quit".to_string(), description: None, actions: vec![Box::new(move |tx| { quit_otel.counter( "codex.windows_sandbox.elevated_prompt_quit", /*inc*/ 1, &[], ); tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); })], 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) { use ratatui_macros::line; let mut lines = Vec::new(); lines.push(line![ "Couldn't set up your sandbox with Administrator permissions".bold() ]); lines.push(line![""]); lines.push(line![ "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." ]); lines.push(line![ "Learn more " ]); 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 quit_otel = self.session_telemetry.clone(); let items = vec![ SelectionItem { name: "Try setting up admin sandbox again".to_string(), description: None, actions: vec![Box::new({ let otel = self.session_telemetry.clone(); let preset = elevated_preset; move |tx| { otel.counter( "codex.windows_sandbox.fallback_retry_elevated", /*inc*/ 1, &[], ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); } })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Use Codex with non-admin sandbox".to_string(), description: None, actions: vec![Box::new({ let otel = self.session_telemetry.clone(); let preset = legacy_preset; move |tx| { otel.counter( "codex.windows_sandbox.fallback_use_legacy", /*inc*/ 1, &[], ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: preset.clone(), }); } })], dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Quit".to_string(), description: None, actions: vec![Box::new(move |tx| { quit_otel.counter( "codex.windows_sandbox.fallback_prompt_quit", /*inc*/ 1, &[], ); tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); })], 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) {} #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { if show_now && WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled && 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, _show_now: bool) {} #[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( /*enabled*/ false, Some("Input disabled until setup completes.".to_string()), ); self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ false); self.set_status( "Setting up sandbox...".to_string(), Some("Hang tight, this may take a few minutes".to_string()), StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); 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(/*enabled*/ true, /*placeholder*/ 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) {} /// 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 .permissions .approval_policy .set(policy.to_core()) { tracing::warn!(%err, "failed to set approval_policy on chat config"); } } /// Set the permission profile in the widget's config copy. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_permission_profile( &mut self, profile: PermissionProfile, ) -> ConstraintResult<()> { self.config.permissions.set_permission_profile(profile) } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { self.config.permissions.windows_sandbox_mode = mode; #[cfg(target_os = "windows")] self.bottom_pane.set_windows_degraded_sandbox_active( crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && matches!( WindowsSandboxLevel::from_config(&self.config), WindowsSandboxLevel::RestrictedToken ), ); } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) -> bool { if let Err(err) = self.config.features.set_enabled(feature, enabled) { tracing::warn!( error = %err, feature = feature.key(), "failed to update constrained chat widget feature state" ); } let enabled = self.config.features.enabled(feature); if feature == Feature::RealtimeConversation { let realtime_conversation_enabled = self.realtime_conversation_enabled(); self.bottom_pane .set_realtime_conversation_enabled(realtime_conversation_enabled); self.bottom_pane .set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled()); if !realtime_conversation_enabled && self.realtime_conversation.is_live() { self.request_realtime_conversation_close(Some( "Realtime voice mode was closed because the feature was disabled.".to_string(), )); } } if feature == Feature::FastMode { self.sync_fast_command_enabled(); } if feature == Feature::Personality { self.sync_personality_command_enabled(); } if feature == Feature::Plugins { self.sync_plugins_command_enabled(); self.refresh_plugin_mentions(); } if feature == Feature::Goals { self.sync_goal_command_enabled(); if !enabled { self.current_goal_status_indicator = None; self.current_goal_status = None; self.goal_status_active_turn_started_at = None; self.budget_limited_turn_ids.clear(); self.update_collaboration_mode_indicator(); } } if feature == Feature::PreventIdleSleep { self.turn_sleep_inhibitor = SleepInhibitor::new(enabled); self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); } #[cfg(target_os = "windows")] if matches!( feature, Feature::WindowsSandbox | Feature::WindowsSandboxElevated ) { self.bottom_pane.set_windows_degraded_sandbox_active( crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && matches!( WindowsSandboxLevel::from_config(&self.config), WindowsSandboxLevel::RestrictedToken ), ); } enabled } pub(crate) fn set_approvals_reviewer(&mut self, policy: ApprovalsReviewer) { self.config.approvals_reviewer = policy; } 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) } /// Override the reasoning effort used when Plan mode is active. /// /// When the active mask is already Plan, the override is applied immediately /// so the footer reflects it without waiting for the next mode switch. /// Passing `None` resets to the Plan-mode preset default. pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { self.config.plan_mode_reasoning_effort = effort; if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() && mask.mode == Some(ModeKind::Plan) { if let Some(effort) = effort { mask.reasoning_effort = Some(Some(effort)); } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { mask.reasoning_effort = plan_mask.reasoning_effort; } } self.refresh_model_dependent_surfaces(); } /// Set the reasoning effort for the non-Plan collaboration mode. /// /// Does not touch the active Plan mask — Plan reasoning is controlled /// exclusively by the Plan preset and `set_plan_mode_reasoning_effort`. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.current_collaboration_mode = self.current_collaboration_mode.with_updates( /*model*/ None, Some(effort), /*developer_instructions*/ None, ); if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() && mask.mode != Some(ModeKind::Plan) { // Generic "global default" updates should not mutate the active Plan mask. // Plan reasoning is controlled by the Plan preset and Plan-only override updates. mask.reasoning_effort = Some(effort); } self.refresh_model_dependent_surfaces(); } /// Set the personality in the widget's config copy. pub(crate) fn set_personality(&mut self, personality: Personality) { self.config.personality = Some(personality); } /// Set Fast mode in the widget's config copy. pub(crate) fn set_service_tier(&mut self, service_tier: Option) { self.config.service_tier = service_tier.map(|service_tier| service_tier.request_value().to_string()); self.effective_service_tier = service_tier; } pub(crate) fn current_service_tier(&self) -> Option { self.effective_service_tier } pub(crate) fn configured_service_tier(&self) -> Option { self.config .service_tier .as_deref() .and_then(ServiceTier::from_request_value) } pub(crate) fn fast_default_opt_out(&self) -> Option { self.config.notices.fast_default_opt_out } pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> { self.status_account_display.as_ref() } pub(crate) fn runtime_model_provider_base_url(&self) -> Option<&str> { self.runtime_model_provider_base_url.as_deref() } #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn model_catalog(&self) -> Arc { self.model_catalog.clone() } pub(crate) fn current_plan_type(&self) -> Option { self.plan_type } pub(crate) fn has_chatgpt_account(&self) -> bool { self.has_chatgpt_account } pub(crate) fn update_account_state( &mut self, status_account_display: Option, plan_type: Option, has_chatgpt_account: bool, ) { self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; self.bottom_pane .set_connectors_enabled(self.connectors_enabled()); } pub(crate) fn should_show_fast_status( &self, model: &str, service_tier: Option, ) -> bool { self.model_supports_fast_mode(model) && matches!(service_tier, Some(ServiceTier::Fast)) && self.has_chatgpt_account } fn fast_mode_enabled(&self) -> bool { self.config.features.enabled(Feature::FastMode) } pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool { self.fast_mode_enabled() && !self.is_user_turn_pending_or_running() && self.bottom_pane.no_modal_or_popup_active() } pub(crate) fn set_realtime_audio_device( &mut self, kind: RealtimeAudioDeviceKind, name: Option, ) { match kind { RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name, RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name, } } /// Set the syntax theme override in the widget's config copy. pub(crate) fn set_tui_theme(&mut self, theme: Option) { self.config.tui_theme = theme; } /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(model.to_string()), /*effort*/ None, /*developer_instructions*/ None, ); if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() { mask.model = Some(model.to_string()); } self.refresh_model_dependent_surfaces(); } fn set_service_tier_selection(&mut self, service_tier: Option) { if service_tier.is_none() { self.config.notices.fast_default_opt_out = Some(true); } self.set_service_tier(service_tier); self.app_event_tx .send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, /*permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, /*summary*/ None, Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), /*collaboration_mode*/ None, /*personality*/ None, ))); self.app_event_tx .send(AppEvent::PersistServiceTierSelection { service_tier }); } pub(crate) fn toggle_fast_mode_from_ui(&mut self) { let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { None } else { Some(ServiceTier::Fast) }; self.set_service_tier_selection(next_tier); } pub(crate) fn current_model(&self) -> &str { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.model(); } self.active_collaboration_mask .as_ref() .and_then(|mask| mask.model.as_deref()) .unwrap_or_else(|| self.current_collaboration_mode.model()) } pub(crate) fn realtime_conversation_is_live(&self) -> bool { self.realtime_conversation.is_live() } fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option { match kind { RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(), RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(), } } fn current_realtime_audio_selection_label(&self, kind: RealtimeAudioDeviceKind) -> String { self.current_realtime_audio_device_name(kind) .unwrap_or_else(|| "System default".to_string()) } fn sync_fast_command_enabled(&mut self) { self.bottom_pane .set_fast_command_enabled(self.fast_mode_enabled()); } fn sync_personality_command_enabled(&mut self) { self.bottom_pane .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); } fn sync_plugins_command_enabled(&mut self) { self.bottom_pane .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); } fn sync_goal_command_enabled(&mut self) { self.bottom_pane .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); } fn current_model_supports_personality(&self) -> bool { let model = self.current_model(); self.model_catalog .try_list_models() .ok() .and_then(|models| { models .into_iter() .find(|preset| preset.model == model) .map(|preset| preset.supports_personality) }) .unwrap_or(false) } fn model_supports_fast_mode(&self, model: &str) -> bool { self.model_catalog .try_list_models() .ok() .and_then(|models| { models .into_iter() .find(|preset| preset.model == model) .map(|preset| preset.supports_fast_mode()) }) .unwrap_or(false) } /// Return whether the effective model currently advertises image-input support. /// /// We intentionally default to `true` when model metadata cannot be read so transient catalog /// failures do not hard-block user input in the UI. fn current_model_supports_images(&self) -> bool { let model = self.current_model(); self.model_catalog .try_list_models() .ok() .and_then(|models| { models .into_iter() .find(|preset| preset.model == model) .map(|preset| preset.input_modalities.contains(&InputModality::Image)) }) .unwrap_or(true) } fn sync_image_paste_enabled(&mut self) { let enabled = self.current_model_supports_images(); self.bottom_pane.set_image_paste_enabled(enabled); } fn image_inputs_not_supported_message(&self) -> String { format!( "Model {} does not support image inputs. Remove images or switch models.", self.current_model() ) } #[allow(dead_code)] // Used in tests pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { &self.current_collaboration_mode } pub(crate) fn current_reasoning_effort(&self) -> Option { self.effective_reasoning_effort() } #[cfg(test)] pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { self.active_mode_kind() } fn is_session_configured(&self) -> bool { self.thread_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { true } /// Returns the dismissal scope that applies to the currently visible draft. fn plan_mode_nudge_scope(&self) -> PlanModeNudgeScope { self.thread_id .map_or(PlanModeNudgeScope::NewThread, PlanModeNudgeScope::Thread) } /// Returns whether the current draft should replace the normal footer with the Plan-mode nudge. /// /// `ChatWidget` owns this policy because it can combine lexical draft matching with mode /// availability, interaction state, and thread-scoped dismissal. `ChatComposer` only renders /// the resulting visibility bit. Keeping slash and shell drafts out here avoids advertising a /// mode switch while the user is intentionally composing another local command. fn should_show_plan_mode_nudge(&self) -> bool { let text = self.bottom_pane.composer_text(); let trimmed = text.trim_start(); self.collaboration_modes_enabled() && collaboration_modes::plan_mask(self.model_catalog.as_ref()).is_some() && self.active_mode_kind() != ModeKind::Plan && self.bottom_pane.composer_input_enabled() && !self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() && !trimmed.starts_with('/') && !trimmed.starts_with('!') && contains_plan_keyword(&text) && !self .dismissed_plan_mode_nudge_scopes .contains(&self.plan_mode_nudge_scope()) } /// Synchronizes the footer presentation with the current Plan-mode nudge policy. fn refresh_plan_mode_nudge(&mut self) { self.bottom_pane .set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge()); } /// Hides the nudge for the current thread scope until the user changes conversation context. fn dismiss_plan_mode_nudge(&mut self) { self.dismissed_plan_mode_nudge_scopes .insert(self.plan_mode_nudge_scope()); self.refresh_plan_mode_nudge(); } fn initial_collaboration_mask( _config: &Config, model_catalog: &ModelCatalog, model_override: Option<&str>, ) -> Option { let mut mask = collaboration_modes::default_mask(model_catalog)?; if let Some(model_override) = model_override { mask.model = Some(model_override.to_string()); } Some(mask) } fn active_mode_kind(&self) -> ModeKind { self.active_collaboration_mask .as_ref() .and_then(|mask| mask.mode) .unwrap_or(ModeKind::Default) } fn effective_reasoning_effort(&self) -> Option { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.reasoning_effort(); } let current_effort = self.current_collaboration_mode.reasoning_effort(); self.active_collaboration_mask .as_ref() .and_then(|mask| mask.reasoning_effort) .unwrap_or(current_effort) } fn effective_collaboration_mode(&self) -> CollaborationMode { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.clone(); } self.active_collaboration_mask.as_ref().map_or_else( || self.current_collaboration_mode.clone(), |mask| self.current_collaboration_mode.apply_mask(mask), ) } fn refresh_model_display(&mut self) { let effective = self.effective_collaboration_mode(); self.session_header.set_model(effective.model()); // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); self.refresh_terminal_title(); } /// Refresh every UI surface that depends on the effective model, reasoning /// effort, or collaboration mode. /// /// Call this at the end of any setter that mutates `current_collaboration_mode`, /// `active_collaboration_mask`, or per-mode reasoning-effort overrides. /// Consolidating both refreshes here prevents the bug where callers update the /// header/title (`refresh_model_display`) but forget the footer status line /// (`refresh_status_line`). fn refresh_model_dependent_surfaces(&mut self) { self.refresh_model_display(); self.refresh_status_line(); } fn model_display_name(&self) -> &str { let model = self.current_model(); if model.is_empty() { DEFAULT_MODEL_DISPLAY_NAME } else { model } } /// Get the label for the current collaboration mode. fn collaboration_mode_label(&self) -> Option<&'static str> { if !self.collaboration_modes_enabled() { return None; } let active_mode = self.active_mode_kind(); active_mode .is_tui_visible() .then_some(active_mode.display_name()) } fn collaboration_mode_indicator(&self) -> Option { if !self.collaboration_modes_enabled() { return None; } match self.active_mode_kind() { ModeKind::Plan => Some(CollaborationModeIndicator::Plan), ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => None, } } fn update_collaboration_mode_indicator(&mut self) { let indicator = self.collaboration_mode_indicator(); let goal_indicator = if indicator.is_none() { self.goal_status_indicator(Instant::now()) } else { None }; self.current_goal_status_indicator = goal_indicator.clone(); self.bottom_pane.set_collaboration_mode_indicator(indicator); self.bottom_pane.set_goal_status_indicator(goal_indicator); } fn refresh_goal_status_indicator_for_time_tick(&mut self) { if self.collaboration_mode_indicator().is_some() { return; } let goal_indicator = self.goal_status_indicator(Instant::now()); if goal_indicator != self.current_goal_status_indicator { self.current_goal_status_indicator = goal_indicator.clone(); self.bottom_pane.set_goal_status_indicator(goal_indicator); } } fn goal_status_indicator(&self, now: Instant) -> Option { if !self.config.features.enabled(Feature::Goals) { return None; } self.current_goal_status .as_ref() .and_then(|state| state.indicator(now, self.goal_status_active_turn_started_at)) } fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option) { if let Some(active_thread_id) = self.thread_id && active_thread_id.to_string() != goal.thread_id { return; } if !self.config.features.enabled(Feature::Goals) { self.current_goal_status_indicator = None; self.current_goal_status = None; self.update_collaboration_mode_indicator(); return; } if goal.status == AppThreadGoalStatus::BudgetLimited && let Some(turn_id) = turn_id { self.budget_limited_turn_ids.insert(turn_id); } self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); self.update_collaboration_mode_indicator(); } fn personality_label(personality: Personality) -> &'static str { match personality { Personality::None => "None", Personality::Friendly => "Friendly", Personality::Pragmatic => "Pragmatic", } } fn personality_description(personality: Personality) -> &'static str { match personality { Personality::None => "No personality instructions.", Personality::Friendly => "Warm, collaborative, and helpful.", Personality::Pragmatic => "Concise, task-focused, and direct.", } } /// Cycle to the next collaboration mode variant (Plan -> Default -> Plan). fn cycle_collaboration_mode(&mut self) { if !self.collaboration_modes_enabled() { return; } if let Some(next_mask) = collaboration_modes::next_mask( self.model_catalog.as_ref(), self.active_collaboration_mask.as_ref(), ) { self.set_collaboration_mask(next_mask); } } /// Update the active collaboration mask. /// /// When collaboration modes are enabled and a preset is selected, /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. pub(crate) fn set_collaboration_mask(&mut self, mut mask: CollaborationModeMask) { if !self.collaboration_modes_enabled() { return; } let previous_mode = self.active_mode_kind(); let previous_model = self.current_model().to_string(); let previous_effort = self.effective_reasoning_effort(); if mask.mode == Some(ModeKind::Plan) && let Some(effort) = self.config.plan_mode_reasoning_effort { mask.reasoning_effort = Some(Some(effort)); } if mask.mode == Some(ModeKind::Plan) { self.dismissed_plan_mode_nudge_scopes .insert(self.plan_mode_nudge_scope()); } self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); self.refresh_plan_mode_nudge(); self.refresh_model_dependent_surfaces(); let next_mode = self.active_mode_kind(); let next_model = self.current_model(); let next_effort = self.effective_reasoning_effort(); if previous_mode != next_mode && (previous_model != next_model || previous_effort != next_effort) { let mut message = format!("Model changed to {next_model}"); if !next_model.starts_with("codex-auto-") { let reasoning_label = match next_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", Some(ReasoningEffortConfig::Low) => "low", Some(ReasoningEffortConfig::Medium) => "medium", Some(ReasoningEffortConfig::High) => "high", Some(ReasoningEffortConfig::XHigh) => "xhigh", None | Some(ReasoningEffortConfig::None) => "default", }; message.push(' '); message.push_str(reasoning_label); } message.push_str(" for "); message.push_str(next_mode.display_name()); message.push_str(" mode."); self.add_info_message(message, /*hint*/ None); } self.request_redraw(); } fn connectors_enabled(&self) -> bool { self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account } fn connectors_for_mentions(&self) -> Option<&[AppInfo]> { if !self.connectors_enabled() { return None; } if let Some(snapshot) = &self.connectors_partial_snapshot { return Some(snapshot.connectors.as_slice()); } match &self.connectors_cache { ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), _ => None, } } fn plugins_for_mentions(&self) -> Option<&[PluginCapabilitySummary]> { if !self.config.features.enabled(Feature::Plugins) { return None; } self.bottom_pane.plugins().map(Vec::as_slice) } /// 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, /*reasoning_effort*/ None, /*show_fast_status*/ false, config.cwd.to_path_buf(), CODEX_CLI_VERSION, ) .with_yolo_mode(history_cell::is_yolo_mode(config)), ) } /// 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_memories_enable_notice(&mut self) { self.add_to_history(history_cell::new_warning_event( MEMORIES_ENABLE_NOTICE.to_string(), )); 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(); } fn add_app_server_stub_message(&mut self, feature: &str) { warn!(feature, "stubbed unsupported TUI feature"); self.add_error_message(format!("{feature}: {TUI_STUB_MESSAGE}")); } fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { let resume_cmd = crate::legacy_core::util::resume_command(Some(name), thread_id) .unwrap_or_else(|| format!("codex resume {name}")); let name = name.to_string(); let line = vec![ "• ".into(), "Thread renamed to ".into(), name.cyan(), ", to resume this thread run ".into(), resume_cmd.cyan(), ]; PlainHistoryCell::new(vec![line.into()]) } /// Begin the asynchronous MCP inventory flow: show a loading spinner and /// request the app-server fetch via `AppEvent::FetchMcpInventory`. /// /// The spinner lives in `active_cell` and is cleared by /// [`clear_mcp_inventory_loading`] once the result arrives. pub(crate) fn add_mcp_output(&mut self, detail: McpServerStatusDetail) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( self.config.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); self.app_event_tx .send(AppEvent::FetchMcpInventory { detail }); } /// Remove the MCP loading spinner if it is still the active cell. /// /// Uses `Any`-based type checking so that a late-arriving inventory result /// does not accidentally clear an unrelated cell that was set in the meantime. pub(crate) fn clear_mcp_inventory_loading(&mut self) { let Some(active) = self.active_cell.as_ref() else { return; }; if !active .as_any() .is::() { return; } self.active_cell = None; self.bump_active_cell_revision(); self.request_redraw(); } pub(crate) fn add_connectors_output(&mut self) { if !self.connectors_enabled() { self.add_info_message( "Apps are disabled.".to_string(), Some("Enable the apps feature to use $ or /apps.".to_string()), ); return; } let connectors_cache = self.connectors_cache.clone(); let should_force_refetch = !self.connectors_prefetch_in_flight || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); self.prefetch_connectors_with_options(should_force_refetch); match connectors_cache { ConnectorsCacheState::Ready(snapshot) => { if snapshot.connectors.is_empty() { self.add_info_message("No apps available.".to_string(), /*hint*/ None); } else { self.open_connectors_popup(&snapshot.connectors); } } ConnectorsCacheState::Failed(err) => { self.add_to_history(history_cell::new_error_event(err)); } ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { self.open_connectors_loading_popup(); } } self.request_redraw(); } fn open_connectors_loading_popup(&mut self) { if !self.bottom_pane.replace_selection_view_if_active( CONNECTORS_SELECTION_VIEW_ID, self.connectors_loading_popup_params(), ) { self.bottom_pane .show_selection_view(self.connectors_loading_popup_params()); } } fn open_connectors_popup(&mut self, connectors: &[AppInfo]) { self.bottom_pane.show_selection_view( self.connectors_popup_params(connectors, /*selected_connector_id*/ None), ); } fn connectors_loading_popup_params(&self) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Apps".bold())); header.push(Line::from("Loading installed and available apps...".dim())); SelectionViewParams { view_id: Some(CONNECTORS_SELECTION_VIEW_ID), header: Box::new(header), items: vec![SelectionItem { name: "Loading apps...".to_string(), description: Some("This updates when the full list is ready.".to_string()), is_disabled: true, ..Default::default() }], ..Default::default() } } fn connectors_popup_params( &self, connectors: &[AppInfo], selected_connector_id: Option<&str>, ) -> SelectionViewParams { let total = connectors.len(); let installed = connectors .iter() .filter(|connector| connector.is_accessible) .count(); let mut header = ColumnRenderable::new(); header.push(Line::from("Apps".bold())); header.push(Line::from( "Use $ to insert an installed app into your prompt.".dim(), )); header.push(Line::from( format!("Installed {installed} of {total} available apps.").dim(), )); let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| { connectors .iter() .position(|connector| connector.id == selected_connector_id) }); let mut items: Vec = Vec::with_capacity(connectors.len()); for connector in connectors { let connector_label = codex_connectors::metadata::connector_display_label(connector); let connector_title = connector_label.clone(); let link_description = Self::connector_description(connector); let description = Self::connector_brief_description(connector); let status_label = Self::connector_status_label(connector); let search_value = format!("{connector_label} {}", connector.id); let mut item = SelectionItem { name: connector_label, description: Some(description), search_value: Some(search_value), ..Default::default() }; let is_installed = connector.is_accessible; let selected_label = if is_installed { format!( "{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app." ) } else { format!("{status_label}. Press Enter to open the app page to install this app.") }; let missing_label = format!("{status_label}. App link unavailable."); let instructions = if connector.is_accessible { "Manage this app in your browser." } else { "Install this app in your browser, then reload Codex." }; if let Some(install_url) = connector.install_url.clone() { let app_id = connector.id.clone(); let is_enabled = connector.is_enabled; let title = connector_title.clone(); let instructions = instructions.to_string(); let description = link_description.clone(); item.actions = vec![Box::new(move |tx| { tx.send(AppEvent::OpenAppLink { app_id: app_id.clone(), title: title.clone(), description: description.clone(), instructions: instructions.clone(), url: install_url.clone(), is_installed, is_enabled, }); })]; item.dismiss_on_select = true; item.selected_description = Some(selected_label); } else { let missing_label_for_action = missing_label.clone(); item.actions = vec![Box::new(move |tx| { tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event( missing_label_for_action.clone(), /*hint*/ None, ), ))); })]; item.dismiss_on_select = true; item.selected_description = Some(missing_label); } items.push(item); } SelectionViewParams { view_id: Some(CONNECTORS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search apps".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, initial_selected_idx, ..Default::default() } } fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) { let selected_connector_id = if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = ( self.bottom_pane .selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID), &self.connectors_cache, ) { snapshot .connectors .get(selected_index) .map(|connector| connector.id.as_str()) } else { None }; let _ = self.bottom_pane.replace_selection_view_if_active( CONNECTORS_SELECTION_VIEW_ID, self.connectors_popup_params(connectors, selected_connector_id), ); } fn connector_brief_description(connector: &AppInfo) -> String { let status_label = Self::connector_status_label(connector); match Self::connector_description(connector) { Some(description) => format!("{status_label} · {description}"), None => status_label.to_string(), } } fn connector_status_label(connector: &AppInfo) -> &'static str { if connector.is_accessible { if connector.is_enabled { "Installed" } else { "Installed · Disabled" } } else { "Can be installed" } } fn connector_description(connector: &AppInfo) -> Option { connector .description .as_deref() .map(str::trim) .filter(|description| !description.is_empty()) .map(str::to_string) } /// 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. /// /// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the /// first press always stops live voice, even when the composer contains the recording meter. /// /// When the double-press quit shortcut is enabled, pressing the same shortcut again before /// expiry requests a shutdown-first quit. fn on_ctrl_c(&mut self) { let key = key_hint::ctrl(KeyCode::Char('c')); if self.realtime_conversation.is_live() { self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.stop_realtime_conversation_from_ui(); return; } 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.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); self.submit_op(AppCommand::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(AppCommand::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 } /// Return the markdown body width available to an active stream. /// /// Streaming controllers render only the message body, while history cells add bullets, /// gutters, or plan padding around that body. Callers pass the reserved columns for that /// wrapper so live output uses the same width that finalized cells will use during reflow. fn current_stream_width(&self, reserved_cols: usize) -> Option { self.last_rendered_width.get().and_then(|width| { if width == 0 { None } else { Some(crate::width::usable_content_width(width, reserved_cols).unwrap_or(1)) } }) } pub(crate) fn raw_output_mode(&self) -> bool { self.raw_output_mode } pub(crate) fn history_render_mode(&self) -> HistoryRenderMode { if self.raw_output_mode { HistoryRenderMode::Raw } else { HistoryRenderMode::Rich } } pub(crate) fn set_raw_output_mode(&mut self, enabled: bool) { self.raw_output_mode = enabled; self.config.tui_raw_output_mode = enabled; let render_mode = self.history_render_mode(); if let Some(controller) = self.stream_controller.as_mut() { controller.set_render_mode(render_mode); } if let Some(controller) = self.plan_stream_controller.as_mut() { controller.set_render_mode(render_mode); } self.refresh_status_surfaces(); } pub(crate) fn raw_output_mode_notice(enabled: bool) -> &'static str { if enabled { "Raw output mode on: transcript text is shown for clean terminal selection." } else { "Raw output mode off: rich transcript rendering restored." } } pub(crate) fn set_raw_output_mode_and_notify(&mut self, enabled: bool) { self.set_raw_output_mode(enabled); self.add_info_message( Self::raw_output_mode_notice(enabled).to_string(), /*hint*/ None, ); } pub(crate) fn toggle_raw_output_mode_and_notify(&mut self) -> bool { let enabled = !self.raw_output_mode; self.set_raw_output_mode_and_notify(enabled); enabled } /// Update resize-sensitive chat widget state after the terminal width changes. /// /// The app calls this even when terminal resize reflow is disabled so live stream wrapping /// remains consistent with the current viewport. Finalized transcript rebuilding stays gated at /// the app layer. pub(crate) fn on_terminal_resize(&mut self, width: u16) { let had_rendered_width = self.last_rendered_width.get().is_some(); self.last_rendered_width.set(Some(width as usize)); let stream_width = self.current_stream_width(/*reserved_cols*/ 2); let plan_stream_width = self.current_stream_width(/*reserved_cols*/ 4); if let Some(controller) = self.stream_controller.as_mut() { controller.set_width(stream_width); } if let Some(controller) = self.plan_stream_controller.as_mut() { controller.set_width(plan_stream_width); } if !had_rendered_width { self.request_redraw(); } } /// Whether an agent message stream is active (not a plan stream). pub(crate) fn has_active_agent_stream(&self) -> bool { self.stream_controller.is_some() } /// Whether a proposed-plan stream is active. pub(crate) fn has_active_plan_stream(&self) -> bool { self.plan_stream_controller.is_some() } fn is_plan_streaming_in_tui(&self) -> bool { self.plan_stream_controller.is_some() } pub(crate) fn composer_is_empty(&self) -> bool { self.bottom_pane.composer_is_empty() } #[cfg(test)] pub(crate) fn is_task_running_for_test(&self) -> bool { self.bottom_pane.is_task_running() } pub(crate) fn toggle_vim_mode_and_notify(&mut self) { let enabled = self.bottom_pane.toggle_vim_enabled(); let message = if enabled { "Vim mode enabled." } else { "Vim mode disabled." }; self.add_info_message(message.to_string(), /*hint*/ None); } pub(crate) fn submit_user_message_with_mode( &mut self, text: String, mut collaboration_mode: CollaborationModeMask, ) { if collaboration_mode.mode == Some(ModeKind::Plan) && let Some(effort) = self.config.plan_mode_reasoning_effort { collaboration_mode.reasoning_effort = Some(Some(effort)); } if self.agent_turn_running && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) { self.add_error_message( "Cannot switch collaboration mode while a turn is running.".to_string(), ); return; } self.set_collaboration_mask(collaboration_mode); let should_queue = self.is_plan_streaming_in_tui(); let user_message = UserMessage { text, local_images: Vec::new(), remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: Vec::new(), }; if should_queue { self.queue_user_message(user_message); } else { self.submit_user_message(user_message); } } /// 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 should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { self.bottom_pane .composer_should_handle_vim_insert_escape(key_event) } 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, text_elements: Vec, local_image_paths: Vec, ) { self.bottom_pane .set_composer_text(text, text_elements, local_image_paths); self.refresh_plan_mode_nudge(); } pub(crate) fn set_remote_image_urls(&mut self, remote_image_urls: Vec) { self.bottom_pane.set_remote_image_urls(remote_image_urls); } fn take_remote_image_urls(&mut self) -> Vec { self.bottom_pane.take_remote_image_urls() } #[cfg(test)] pub(crate) fn remote_image_urls(&self) -> Vec { self.bottom_pane.remote_image_urls() } #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { self.rejected_steers_queue .iter() .map(|message| message.text.clone()) .chain( self.queued_user_messages .iter() .map(|message| message.text.clone()), ) .collect() } #[cfg(test)] pub(crate) fn pending_thread_approvals(&self) -> &[String] { self.bottom_pane.pending_thread_approvals() } #[cfg(test)] pub(crate) fn has_active_view(&self) -> bool { self.bottom_pane.has_active_view() } 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(); } fn refresh_skills_for_current_cwd(&mut self, force_reload: bool) { self.submit_op(AppCommand::list_skills( vec![self.config.cwd.to_path_buf()], force_reload, )); } /// Forward a command directly to codex. pub(crate) fn submit_op(&mut self, op: T) -> bool where T: Into, { let op: AppCommand = op.into(); self.prepare_local_op_submission(&op); if op.is_review() && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); } match &self.codex_op_target { CodexOpTarget::Direct(codex_op_tx) => { crate::session_log::log_outbound_op(&op); if let Err(e) = codex_op_tx.send(op) { tracing::error!("failed to submit op: {e}"); return false; } } CodexOpTarget::AppEvent => { self.app_event_tx.send(AppEvent::CodexOp(op)); } } true } fn append_message_history_entry(&self, text: String) { let Some(thread_id) = self.thread_id else { tracing::warn!("failed to append to message history: no active thread id"); return; }; self.app_event_tx .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); } pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { if matches!(op, AppCommand::Interrupt) && self.agent_turn_running { if let Some(controller) = self.stream_controller.as_mut() { controller.clear_queue(); } if let Some(controller) = self.plan_stream_controller.as_mut() { controller.clear_queue(); } self.request_redraw(); } } fn on_list_skills(&mut self, ev: SkillsListResponse) { self.set_skills_from_response(&ev); self.refresh_plugin_mentions(); } pub(crate) fn on_connectors_loaded( &mut self, result: Result, is_final: bool, ) { let mut trigger_pending_force_refetch = false; if is_final { self.connectors_prefetch_in_flight = false; if self.connectors_force_refetch_pending { self.connectors_force_refetch_pending = false; trigger_pending_force_refetch = true; } } match result { Ok(mut snapshot) => { if !is_final { snapshot.connectors = connectors::merge_connectors_with_accessible( Vec::new(), snapshot.connectors, /*all_connectors_loaded*/ false, ); } snapshot.connectors = connectors::with_app_enabled_state(snapshot.connectors, &self.config); if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors_cache { let enabled_by_id: HashMap<&str, bool> = existing_snapshot .connectors .iter() .map(|connector| (connector.id.as_str(), connector.is_enabled)) .collect(); for connector in &mut snapshot.connectors { if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) { connector.is_enabled = *is_enabled; } } } if is_final { self.connectors_partial_snapshot = None; self.refresh_connectors_popup_if_open(&snapshot.connectors); self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); } else { self.connectors_partial_snapshot = Some(snapshot.clone()); } self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } Err(err) => { let partial_snapshot = self.connectors_partial_snapshot.take(); if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache { warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); self.bottom_pane .set_connectors_snapshot(Some(snapshot.clone())); } else if let Some(snapshot) = partial_snapshot { warn!( "failed to load full apps list; falling back to installed apps snapshot: {err}" ); self.refresh_connectors_popup_if_open(&snapshot.connectors); self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } else { self.connectors_cache = ConnectorsCacheState::Failed(err); self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); } } } if trigger_pending_force_refetch { self.prefetch_connectors_with_options(/*force_refetch*/ true); } } pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { let ConnectorsCacheState::Ready(mut snapshot) = self.connectors_cache.clone() else { return; }; let mut changed = false; for connector in &mut snapshot.connectors { if connector.id == connector_id { changed = connector.is_enabled != enabled; connector.is_enabled = enabled; break; } } if !changed { return; } self.refresh_connectors_popup_if_open(&snapshot.connectors); self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } pub(crate) fn refresh_plugin_mentions(&mut self) { if !self.config.features.enabled(Feature::Plugins) { self.bottom_pane.set_plugin_mentions(/*plugins*/ None); return; } self.app_event_tx.send(AppEvent::RefreshPluginMentions); } pub(crate) fn on_plugin_mentions_loaded( &mut self, plugins: Option>, ) { if self.bottom_pane.plugins() == plugins.as_ref() { return; } self.bottom_pane.set_plugin_mentions(plugins); } pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) { self.config.features = config.features.clone(); self.config.config_layer_stack = config.config_layer_stack.clone(); self.config.realtime = config.realtime.clone(); self.config.memories = config.memories.clone(); self.config.terminal_resize_reflow = config.terminal_resize_reflow; } 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.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); } })], dismiss_on_select: false, dismiss_parent_on_child_accept: true, ..Default::default() }); items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { tx.review(ReviewTarget::UncommittedChanges); })], 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.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); } })], dismiss_on_select: false, dismiss_parent_on_child_accept: true, ..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, dismiss_parent_on_child_accept: true, ..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.review(ReviewTarget::BaseBranch { branch: branch.clone(), }); })], 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 = recent_commits(cwd, /*limit*/ 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.review(ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }); })], 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(), /*initial_text*/ String::new(), /*context_label*/ None, Box::new(move |prompt: String| { let trimmed = prompt.trim().to_string(); if trimmed.is_empty() { return; } tx.review(ReviewTarget::Custom { instructions: trimmed, }); }), ); 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 } pub(crate) fn thread_name(&self) -> Option { self.thread_name.clone() } /// Returns the current thread's precomputed rollout path. /// /// For fresh non-ephemeral threads this path may exist before the file is /// materialized; rollout persistence is deferred until the first user /// message is recorded. 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(); let hook_cell = self.active_hook_cell.as_ref(); if cell.is_none() && hook_cell.is_none() { return None; } Some(ActiveCellTranscriptKey { revision: self.active_cell_revision, is_stream_continuation: cell .map(|cell| cell.is_stream_continuation()) .unwrap_or(false), animation_tick: cell .and_then(|cell| cell.transcript_animation_tick()) .or_else(|| { hook_cell.and_then(super::history_cell::HistoryCell::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 mut lines = Vec::new(); if let Some(cell) = self.active_cell.as_ref() { lines.extend(cell.transcript_lines(width)); } if let Some(hook_cell) = self.active_hook_cell.as_ref() { // Compute hook lines first so hidden hooks do not add a separator. let hook_lines = hook_cell.transcript_lines(width); if !hook_lines.is_empty() && !lines.is_empty() { lines.push("".into()); } lines.extend(hook_lines); } (!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 } #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { self.bottom_pane.status_line_text() } 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( /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, )), None => RenderableItem::Owned(Box::new(())), }; let active_hook_cell_renderable = match &self.active_hook_cell { Some(cell) if cell.should_render() => { RenderableItem::Borrowed(cell).inset(Insets::tlbr( /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, )) } _ => RenderableItem::Owned(Box::new(())), }; let mut flex = FlexRenderable::new(); flex.push(/*flex*/ 1, active_cell_renderable); flex.push(/*flex*/ 0, active_hook_cell_renderable); flex.push( /*flex*/ 0, RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr( /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, )), ); RenderableItem::Owned(Box::new(flex)) } } #[cfg(not(target_os = "linux"))] impl ChatWidget { pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { let updated = self.bottom_pane.update_recording_meter_in_place(id, text); if updated { self.request_redraw(); } updated } pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) { self.bottom_pane.remove_recording_meter_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); } } fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool { summary.responses_api_overhead_ms > 0 || summary.responses_api_inference_time_ms > 0 || summary.responses_api_engine_iapi_ttft_ms > 0 || summary.responses_api_engine_service_ttft_ms > 0 || summary.responses_api_engine_iapi_tbt_ms > 0 || summary.responses_api_engine_service_tbt_ms > 0 } impl Drop for ChatWidget { fn drop(&mut self) { self.reset_realtime_conversation_state(); 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) } fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { self.as_renderable().cursor_style(area) } } #[derive(Debug)] enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, ElicitationRequested { server_name: String }, PlanModePrompt { title: 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, /*max_graphemes*/ 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}") } Notification::PlanModePrompt { title } => { format!("Plan mode prompt: {title}") } } } fn type_name(&self) -> &str { match self { Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } | Notification::ElicitationRequested { .. } => "approval-requested", Notification::PlanModePrompt { .. } => "plan-mode-prompt", } } fn priority(&self) -> u8 { match self { Notification::AgentTurnComplete { .. } => 0, Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } | Notification::ElicitationRequested { .. } | Notification::PlanModePrompt { .. } => 1, } } 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)) } } fn user_input_request_summary( questions: &[codex_app_server_protocol::ToolRequestUserInputQuestion], ) -> Option { let first_question = questions.first()?; let summary = if first_question.header.trim().is_empty() { first_question.question.trim() } else { first_question.header.trim() }; if summary.is_empty() { None } else { Some(truncate_text(summary, /*max_graphemes*/ 30)) } } } 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", ]; const SIDE_PLACEHOLDERS: [&str; 3] = [ "Check recently modified functions for compatibility", "How many files have been modified?", "Will this algorithm scale well?", ]; // 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 } #[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.review(ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }); })], 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() }); } #[cfg(test)] pub(crate) mod tests;