//! 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<ModelPreset> = 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<dyn Renderable> { 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<Line<'static>> { 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<String> { 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<ModelPreset>) { let presets: Vec<ModelPreset> = 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<ModelPreset>, Vec<ModelPreset>) = 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<SelectionItem> = 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.clone()), ); let actions = Self::model_selection_actions( model.clone(), Some(preset.default_reasoning_effort.clone()), 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<SelectionAction> = 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<ModelPreset>) { if presets.is_empty() { self.add_info_message( "No additional models are available right now.".to_string(), /*hint*/ None, ); return; } let mut items: Vec<SelectionItem> = 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<SelectionAction> = 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 <model_name> 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() }); } fn model_selection_actions( model_for_action: String, effort_for_action: Option<ReasoningEffortConfig>, should_prompt_plan_mode_scope: bool, ) -> Vec<SelectionAction> { vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), effort: effort_for_action.clone(), }); return; } tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), effort: effort_for_action.clone(), }); })] } fn should_prompt_plan_mode_reasoning_scope( &self, selected_model: &str, selected_effort: Option<ReasoningEffortConfig>, ) -> 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<ReasoningEffortConfig>, ) { let reasoning_phrase = match effort.as_ref() { Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), Some(selected_effort) => { format!( "{} reasoning", Self::reasoning_effort_sentence_label(selected_effort) ) } 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.as_ref() { format!( "user-chosen Plan override ({})", Self::reasoning_effort_sentence_label(plan_override) ) } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { match plan_mask .reasoning_effort .as_ref() .and_then(|effort| effort.as_ref()) { Some(plan_effort) => format!( "built-in Plan default ({})", Self::reasoning_effort_sentence_label(plan_effort) ), 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<SelectionAction> = vec![Box::new({ let model = model.clone(); let effort = effort.clone(); move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); } })]; let all_modes_actions: Vec<SelectionAction> = vec![Box::new(move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort.clone())); tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model.clone(), effort: effort.clone(), }); })]; 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 = 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.as_ref().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"); let mut choices: Vec<ReasoningEffortConfig> = supported .iter() .map(|option| option.effort.clone()) .collect(); if choices.is_empty() { choices.push(default_effort.clone()); } if choices.len() == 1 { let selected_effort = choices.first().cloned(); let selected_model = preset.model; if self .should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort.clone()) { 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 = choices .contains(&default_effort) .then(|| default_effort.clone()) .or_else(|| choices.first().cloned()) .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 .clone() .or_else(|| self.effective_reasoning_effort()) } else { self.effective_reasoning_effort() } } else { default_choice.clone() }; let selection_choice = highlight_choice.clone().or_else(|| default_choice.clone()); let initial_selected_idx = choices .iter() .position(|choice| Some(choice) == selection_choice.as_ref()); let mut items: Vec<SelectionItem> = Vec::new(); for choice in choices.iter() { let effort = choice.clone(); let mut effort_label = Self::reasoning_effort_label(&effort); if Some(choice) == default_choice.as_ref() { effort_label.push_str(" (default)"); } let description = 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.as_ref() == 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 = Some(effort); let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( model_slug.as_str(), choice_effort.clone(), ); let actions: Vec<SelectionAction> = vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), effort: choice_effort.clone(), }); } else { tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(choice_effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), effort: choice_effort.clone(), }); } })]; items.push(SelectionItem { name: effort_label, description, selected_description, is_current: is_current_model && Some(choice) == highlight_choice.as_ref(), 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) -> String { match effort { ReasoningEffortConfig::None => "None".to_string(), ReasoningEffortConfig::Minimal => "Minimal".to_string(), ReasoningEffortConfig::Low => "Low".to_string(), ReasoningEffortConfig::Medium => "Medium".to_string(), ReasoningEffortConfig::High => "High".to_string(), ReasoningEffortConfig::XHigh => "Extra high".to_string(), ReasoningEffortConfig::Custom(value) => value.clone(), } } pub(super) fn reasoning_effort_sentence_label(effort: &ReasoningEffortConfig) -> String { match effort { ReasoningEffortConfig::Custom(value) => value.clone(), effort => Self::reasoning_effort_label(effort).to_lowercase(), } } pub(super) fn apply_model_and_effort_without_persist( &self, model: String, effort: Option<ReasoningEffortConfig>, ) { 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<ReasoningEffortConfig>) { self.apply_model_and_effort_without_persist(model.clone(), effort.clone()); self.app_event_tx .send(AppEvent::PersistModelSelection { model, effort }); }}