diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 672357775..f8bb6cd77 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -252,7 +252,6 @@ fn queued_message_edit_hint_binding( } use crate::app_event::AppEvent; -use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; use crate::app_event::RateLimitRefreshOrigin; #[cfg(target_os = "windows")] @@ -318,7 +317,6 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod command_lifecycle; mod connectors; -use self::connectors::ConnectorsCacheState; use self::connectors::ConnectorsState; mod exec_state; use self::exec_state::RunningCommand; @@ -362,11 +360,11 @@ use self::plugins::PluginListFetchState; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; +mod model_popups; +mod permission_popups; mod protocol; mod protocol_requests; mod rate_limits; -use self::rate_limits::NUDGE_MODEL_SLUG; -use self::rate_limits::RATE_LIMIT_SWITCH_PROMPT_THRESHOLD; use self::rate_limits::RateLimitErrorKind; use self::rate_limits::RateLimitSwitchPromptState; use self::rate_limits::RateLimitWarningState; @@ -380,11 +378,15 @@ mod reasoning_shortcuts; mod review; use self::review::ReviewState; mod service_tiers; +mod settings; +mod settings_popups; mod side; mod status_state; +mod windows_sandbox_prompts; use self::status_state::StatusIndicatorState; use self::status_state::StatusState; use self::status_state::TerminalTitleStatusKind; +mod status_controls; mod status_surfaces; mod streaming; use self::status_surfaces::CachedProjectRootName; @@ -896,187 +898,6 @@ impl ChatWidget { self.realtime_conversation_enabled() } - /// 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.status_state.set_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 restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.status_state.take_retry_status_header() { self.set_status_header(header); @@ -1487,107 +1308,6 @@ impl ChatWidget { } } - 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(); - } - pub(crate) fn handle_history_entry_response(&mut self, event: HistoryLookupResponse) { let HistoryLookupResponse { offset, @@ -2399,91 +2119,6 @@ impl ChatWidget { 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, @@ -2491,122 +2126,6 @@ impl ChatWidget { )); } - 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.list_keymap(), - ); - 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.list_keymap(), - ); - 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 @@ -2629,2659 +2148,6 @@ impl ChatWidget { ); } - 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 environment_manager = Arc::clone(&self.environment_manager); - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let accessible_result = - match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - force_refetch, - &environment_manager, - ) - .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 = - chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) - .await?; - let connectors = chatgpt_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.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 { - 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); - 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.review.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.review - .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.review.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.list_keymap(), - ); - 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"); - } else { - self.refresh_status_surfaces(); - } - } - - /// 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)?; - self.refresh_status_surfaces(); - Ok(()) - } - - #[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_service_tier_commands(); - } - 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.turn_lifecycle.goal_status_active_turn_started_at = None; - self.turn_lifecycle.budget_limited_turn_ids.clear(); - self.update_collaboration_mode_indicator(); - } - } - if feature == Feature::MentionsV2 { - self.sync_mentions_v2_enabled(); - } - if feature == Feature::PreventIdleSleep { - self.turn_lifecycle.set_prevent_idle_sleep(enabled); - } - #[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; - self.refresh_status_surfaces(); - } - - 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); - } - - 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 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(); - } - - 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_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 sync_mentions_v2_enabled(&mut self) { - self.bottom_pane - .set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2)); - } - - 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) - } - - /// 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.sync_service_tier_commands(); - 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.turn_lifecycle.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.turn_lifecycle.mark_budget_limited(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; @@ -5416,220 +2282,6 @@ impl ChatWidget { 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); @@ -5989,100 +2641,6 @@ impl ChatWidget { 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 = chatgpt_connectors::merge_connectors_with_accessible( - Vec::new(), - snapshot.connectors, - /*all_connectors_loaded*/ false, - ); - } - snapshot.connectors = - chatgpt_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); diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs index 0cd5429ba..942f2e9da 100644 --- a/codex-rs/tui/src/chatwidget/connectors.rs +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -1,5 +1,6 @@ -//! Connector list cache state for `ChatWidget`. +//! Connector cache, popup, and refresh handling for `ChatWidget`. +use super::*; use crate::app_event::ConnectorsSnapshot; #[derive(Debug, Clone, Default)] @@ -18,3 +19,415 @@ pub(super) struct ConnectorsState { pub(super) prefetch_in_flight: bool, pub(super) force_refetch_pending: bool, } + +impl ChatWidget { + pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { + self.prefetch_connectors_with_options(force_refetch); + } + + pub(super) 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 environment_manager = Arc::clone(&self.environment_manager); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let accessible_result = + match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, + force_refetch, + &environment_manager, + ) + .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 = + chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) + .await?; + let connectors = chatgpt_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, + }); + } + }); + } + + pub(super) fn connectors_enabled(&self) -> bool { + self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account + } + + pub(super) 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, + } + } + + 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) + } + + 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 = chatgpt_connectors::merge_connectors_with_accessible( + Vec::new(), + snapshot.connectors, + /*all_connectors_loaded*/ false, + ); + } + snapshot.connectors = + chatgpt_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)); + } +} diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs new file mode 100644 index 000000000..0ee91c11f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -0,0 +1,583 @@ +//! Model, collaboration, and reasoning popups for `ChatWidget`. +//! +//! These surfaces are tightly related because changing one often redirects +//! into another, especially while Plan mode is active. + +use super::*; + +impl ChatWidget { + /// 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); + } + + 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() + }); + } + + pub(super) 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", + } + } + + pub(super) 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 }); + } +} diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs new file mode 100644 index 000000000..dc428b409 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -0,0 +1,463 @@ +//! Permission and approval popup flows for `ChatWidget`. +//! +//! This module owns the generic permission pickers and confirmation surfaces; +//! Windows-specific sandbox prompting lives beside it in +//! `windows_sandbox_prompts`. + +use super::*; + +impl ChatWidget { + /// 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.review.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.review + .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.review.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(super) 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, + ), + ))); + })] + } + + pub(super) 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, + } + } + + 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() + }); + } +} diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 8d5bd89ae..6b5414536 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -1,5 +1,6 @@ -//! Rate-limit warning and prompt state for `ChatWidget`. +//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`. +use super::*; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; @@ -125,3 +126,327 @@ pub(super) fn app_server_rate_limit_error_kind( pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { matches!(info, AppServerCodexErrorInfo::CyberPolicy) } + +impl ChatWidget { + 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(); + } + + pub(super) fn stop_rate_limit_poller(&mut self) {} + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn prefetch_rate_limits(&mut self) { + self.stop_rate_limit_poller(); + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) 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) + } + + pub(super) 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() + }); + } + + pub(super) fn open_workspace_owner_nudge_prompt( + &mut self, + credit_type: AddCreditsNudgeCreditType, + ) { + if 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 { + 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); + 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(); + } + + 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; + } + } +} diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs new file mode 100644 index 000000000..419070ef9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -0,0 +1,611 @@ +//! Runtime settings state and model/collaboration coordination for `ChatWidget`. + +use super::*; + +impl ChatWidget { + /// 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"); + } else { + self.refresh_status_surfaces(); + } + } + + /// 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)?; + self.refresh_status_surfaces(); + Ok(()) + } + + #[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_service_tier_commands(); + } + 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.turn_lifecycle.goal_status_active_turn_started_at = None; + self.turn_lifecycle.budget_limited_turn_ids.clear(); + self.update_collaboration_mode_indicator(); + } + } + if feature == Feature::MentionsV2 { + self.sync_mentions_v2_enabled(); + } + if feature == Feature::PreventIdleSleep { + self.turn_lifecycle.set_prevent_idle_sleep(enabled); + } + #[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; + self.refresh_status_surfaces(); + } + + 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); + } + + #[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); + } + + 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 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(); + } + + 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() + } + + pub(super) 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(), + } + } + + pub(super) fn current_realtime_audio_selection_label( + &self, + kind: RealtimeAudioDeviceKind, + ) -> String { + self.current_realtime_audio_device_name(kind) + .unwrap_or_else(|| "System default".to_string()) + } + + pub(super) fn sync_personality_command_enabled(&mut self) { + self.bottom_pane + .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); + } + + pub(super) fn sync_plugins_command_enabled(&mut self) { + self.bottom_pane + .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); + } + + pub(super) fn sync_goal_command_enabled(&mut self) { + self.bottom_pane + .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); + } + + pub(super) fn sync_mentions_v2_enabled(&mut self) { + self.bottom_pane + .set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2)); + } + + pub(super) 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) + } + + /// 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. + pub(super) 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) + } + + pub(super) fn sync_image_paste_enabled(&mut self) { + let enabled = self.current_model_supports_images(); + self.bottom_pane.set_image_paste_enabled(enabled); + } + + pub(super) 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() + } + + pub(super) fn is_session_configured(&self) -> bool { + self.thread_id.is_some() + } + + pub(super) 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. + pub(super) 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. + pub(super) 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. + pub(super) fn dismiss_plan_mode_nudge(&mut self) { + self.dismissed_plan_mode_nudge_scopes + .insert(self.plan_mode_nudge_scope()); + self.refresh_plan_mode_nudge(); + } + + pub(super) 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) + } + + pub(super) fn active_mode_kind(&self) -> ModeKind { + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .unwrap_or(ModeKind::Default) + } + + pub(super) 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) + } + + pub(super) 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), + ) + } + + pub(super) 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.sync_service_tier_commands(); + 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`). + pub(super) fn refresh_model_dependent_surfaces(&mut self) { + self.refresh_model_display(); + self.refresh_status_line(); + } + + pub(super) 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. + pub(super) 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, + } + } + + pub(super) 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); + } + + pub(super) 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.turn_lifecycle.goal_status_active_turn_started_at) + }) + } + + pub(super) 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.turn_lifecycle.mark_budget_limited(turn_id); + } + self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); + self.update_collaboration_mode_indicator(); + } + + /// Cycle to the next collaboration mode variant (Plan -> Default -> Plan). + pub(super) 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(); + } +} diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs new file mode 100644 index 000000000..cdd38ad3a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -0,0 +1,285 @@ +//! Settings-adjacent popup surfaces for `ChatWidget`. +//! +//! This keeps theme, personality, audio-device, and experimental-feature UI +//! out of the main orchestration module without changing their event wiring. + +use super::*; + +impl ChatWidget { + pub(super) 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); + } + + 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"))] + pub(super) 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() + }); + } + + 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.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + 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.", + } + } +} diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs new file mode 100644 index 000000000..70f213bee --- /dev/null +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -0,0 +1,380 @@ +//! Status output and setup controls for `ChatWidget`. +//! +//! Rendering details live in `status_surfaces`; this module owns the mutable +//! widget entrypoints that apply status state, open setup views, and update the +//! history-facing `/status` surface. + +use super::*; + +impl ChatWidget { + /// Update the status indicator header and details. + /// + /// Passing `None` clears any existing details. + pub(super) 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.status_state.set_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. + pub(super) 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(); + } + + 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(super) 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.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(super) 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.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(super) 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)) + })) + } + + pub(super) 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 + } + + pub(super) 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) + } + + pub(super) 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), + ) + } + + pub(super) 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)) + } + + pub(super) fn status_line_total_usage(&self) -> TokenUsage { + self.token_info + .as_ref() + .map(|info| info.total_token_usage.clone()) + .unwrap_or_default() + } + + pub(super) 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}%")) + } + + pub(super) 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", + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 4f4398d34..379ff7c6d 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,4 +1,6 @@ use super::*; +use crate::app_event::ConnectorsSnapshot; +use crate::chatwidget::connectors::ConnectorsCacheState; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 4cc01d759..53b60e0d8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,5 +1,6 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; +use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; use pretty_assertions::assert_eq; use ratatui::backend::TestBackend; use serial_test::serial; diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs new file mode 100644 index 000000000..3f6de0879 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -0,0 +1,446 @@ +//! Windows sandbox prompts and warning surfaces for `ChatWidget`. + +use super::*; + +impl ChatWidget { + #[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 + } + + #[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) {} +}