//! Runtime settings state and model/collaboration coordination for `ChatWidget`.use super::*;use crate::app_event::AppEvent;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(); } } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_permission_profile_from_session_snapshot( &mut self, snapshot: PermissionProfileSnapshot, ) -> ConstraintResult<()> { self.config .permissions .set_permission_profile_from_session_snapshot(snapshot)?; self.refresh_status_surfaces(); Ok(()) } pub(crate) fn set_permission_profile_with_active_profile( &mut self, profile: PermissionProfile, active_permission_profile: Option<ActivePermissionProfile>, ) -> ConstraintResult<()> { self.config .permissions .set_permission_profile_from_session_snapshot( PermissionProfileSnapshot::from_session_snapshot( profile, active_permission_profile, ), )?; self.refresh_status_surfaces(); Ok(()) } pub(crate) fn set_permission_network( &mut self, network: Option<crate::legacy_core::config::NetworkProxySpec>, ) { self.config.permissions.network = network; } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option<WindowsSandboxModeToml>) { self.config.permissions.windows_sandbox_mode = mode; #[cfg(target_os = "windows")] self.bottom_pane .set_windows_degraded_sandbox_active(matches!( crate::windows_sandbox::level_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::FastMode { self.refresh_effective_service_tier(); 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(matches!( crate::windows_sandbox::level_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<ReasoningEffortConfig>) { self.config.plan_mode_reasoning_effort = effort.clone(); 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<ReasoningEffortConfig>) { self.current_collaboration_mode = self.current_collaboration_mode.with_updates( /*model*/ None, Some(effort.clone()), /*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<ModelCatalog> { self.model_catalog.clone() } pub(crate) fn current_plan_type(&self) -> Option<PlanType> { self.plan_type } pub(crate) fn has_chatgpt_account(&self) -> bool { self.has_chatgpt_account } pub(crate) fn has_codex_backend_auth(&self) -> bool { self.has_codex_backend_auth } pub(crate) fn update_account_state( &mut self, status_account_display: Option<StatusAccountDisplay>, plan_type: Option<PlanType>, has_chatgpt_account: bool, has_codex_backend_auth: bool, ) { let account_state_changed = self.status_account_display != status_account_display || self.has_chatgpt_account != has_chatgpt_account || self.has_codex_backend_auth != has_codex_backend_auth; if account_state_changed { self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); } self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; self.has_codex_backend_auth = has_codex_backend_auth; self.bottom_pane .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); } /// Set the syntax theme override in the widget's config copy. pub(crate) fn set_tui_theme(&mut self, theme: Option<String>) { 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_effective_service_tier(); 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(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<ReasoningEffortConfig> { self.effective_reasoning_effort() } pub(crate) fn on_thread_settings_updated( &mut self, notification: ThreadSettingsUpdatedNotification, ) { let Ok(thread_id) = ThreadId::from_string(¬ification.thread_id) else { tracing::warn!( thread_id = notification.thread_id, "ignoring app-server ThreadSettingsUpdated with invalid thread_id" ); return; }; if self.thread_id != Some(thread_id) { return; } self.apply_thread_settings(notification.thread_settings); } #[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<CollaborationModeMask> { 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<ReasoningEffortConfig> { 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.clone()) .unwrap_or(current_effort) } pub(crate) 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(); } fn apply_thread_settings(&mut self, mut settings: ThreadSettings) { let cwd_changed = self.config.cwd != settings.cwd; self.apply_thread_settings_cwd(settings.cwd.clone()); self.config.model_provider_id = settings.model_provider.clone(); self.set_service_tier(settings.service_tier.clone()); self.set_approval_policy(settings.approval_policy); self.set_approvals_reviewer(settings.approvals_reviewer.to_core()); self.config.personality = settings.personality; let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( &settings.sandbox_policy.to_core(), settings.cwd.as_path(), ); let permission_snapshot = PermissionProfileSnapshot::from_session_snapshot( permission_profile, settings.active_permission_profile.take().map(Into::into), ); if let Err(err) = self .config .permissions .set_permission_profile_from_session_snapshot(permission_snapshot.clone()) { tracing::warn!(%err, "failed to sync permissions from ThreadSettingsUpdated"); if let Err(replace_err) = self .config .permissions .replace_permission_profile_from_session_snapshot(permission_snapshot) { tracing::error!( %replace_err, "failed to replace permissions from ThreadSettingsUpdated after constraint fallback" ); } } settings.collaboration_mode.settings.model = settings.model; settings.collaboration_mode.settings.reasoning_effort = settings.effort; self.set_effective_collaboration_mode(settings.collaboration_mode); self.refresh_effective_service_tier(); self.refresh_status_surfaces(); self.sync_service_tier_commands(); self.sync_personality_command_enabled(); if cwd_changed { self.refresh_skills_for_current_cwd(/*force_reload*/ true); } self.refresh_plugin_mentions(); self.request_redraw(); } fn apply_thread_settings_cwd(&mut self, cwd: AbsolutePathBuf) { let previous_cwd = std::mem::replace(&mut self.config.cwd, cwd.clone()); self.current_cwd = Some(cwd.to_path_buf()); self.status_line_project_root_name_cache = None; if !self.config.workspace_roots.contains(&previous_cwd) { return; } let previous_roots = std::mem::take(&mut self.config.workspace_roots); self.config.workspace_roots.push(cwd); for root in previous_roots { if root != previous_cwd && !self.config.workspace_roots.contains(&root) { self.config.workspace_roots.push(root); } } self.config .permissions .set_workspace_roots(self.config.workspace_roots.clone()); } pub(super) fn set_effective_collaboration_mode(&mut self, mode: CollaborationMode) { let mode_kind = mode.mode; let settings = mode.settings; if mode_kind == ModeKind::Default { self.current_collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: settings.clone(), }; } self.active_collaboration_mask = Some(CollaborationModeMask { name: mode_kind.display_name().to_string(), mode: Some(mode_kind), model: Some(settings.model.clone()), reasoning_effort: Some(settings.reasoning_effort.clone()), developer_instructions: Some(settings.developer_instructions), }); self.update_collaboration_mode_indicator(); self.refresh_plan_mode_nudge(); self.refresh_model_dependent_surfaces(); } 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<CollaborationModeIndicator> { 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<GoalStatusIndicator> { 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<String>) { 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_from_user_action(next_mask); } } pub(crate) fn set_collaboration_mask_from_user_action(&mut self, mask: CollaborationModeMask) { self.set_collaboration_mask(mask); self.submit_collaboration_mode_settings_update(); } /// 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.clone() { 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.as_ref() { None | Some(ReasoningEffortConfig::None) => "default", Some(effort) => effort.as_str(), }; 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 submit_collaboration_mode_settings_update(&self) { let Some(thread_id) = self.thread_id else { return; }; self.app_event_tx.send(AppEvent::SubmitThreadOp { thread_id, op: AppCommand::override_turn_context( /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, /*permission_profile*/ None, /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, /*summary*/ None, /*service_tier*/ None, Some(self.effective_collaboration_mode()), /*personality*/ None, ), }); }}