//! Startup warnings, model migration prompts, and bootstrap prompt helpers.//!//! These helpers run before or during `App::run` bootstrap. They translate configuration and model//! catalog state into one-time TUI prompts or warning cells without owning the main event loop.use super::*;use std::collections::HashSet;use std::path::PathBuf;#[derive(Debug, PartialEq, Eq, Hash)]struct SkillLoadWarningKey { path: PathBuf, message: String,}#[derive(Debug, Default)]pub(super) struct SkillLoadWarningState { active: HashSet<SkillLoadWarningKey>,}impl SkillLoadWarningState { pub(super) fn clear(&mut self) { self.active.clear(); } pub(super) fn newly_active_errors(&mut self, errors: &[SkillErrorInfo]) -> Vec<SkillErrorInfo> { let previous = std::mem::take(&mut self.active); let mut current = HashSet::new(); let mut newly_active = Vec::new(); for error in errors { let key = SkillLoadWarningKey { path: error.path.clone(), message: error.message.clone(), }; let was_active = previous.contains(&key); if current.insert(key) && !was_active { newly_active.push(error.clone()); } } self.active = current; newly_active }}pub(super) fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { if errors.is_empty() { return; } let error_count = errors.len(); app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( crate::history_cell::new_warning_event(format!( "Skipped loading {error_count} skill(s) due to invalid SKILL.md files." )), ))); for error in errors { let path = error.path.display(); let message = error.message.as_str(); app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( crate::history_cell::new_warning_event(format!("{path}: {message}")), ))); }}pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) { let mut disabled_folders = Vec::new(); for layer in config.config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ true, ) { let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else { continue; }; let Some(disabled_reason) = &layer.disabled_reason else { continue; }; disabled_folders.push(( dot_codex_folder.as_path().display().to_string(), disabled_reason.clone(), )); } if disabled_folders.is_empty() { return; } let mut message = concat!( "Project-local config, hooks, and exec policies are disabled in the following folders ", "until the project is trusted, but skills still load.\n", ) .to_string(); for (index, (folder, reason)) in disabled_folders.iter().enumerate() { let display_index = index + 1; message.push_str(&format!(" {display_index}. {folder}\n")); message.push_str(&format!(" {reason}\n")); } app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_warning_event(message), )));}pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) { let Some(message) = codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile()) else { return; }; app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_warning_event(message), )));}pub(super) fn should_show_model_migration_prompt( current_model: &str, target_model: &str, seen_migrations: &BTreeMap<String, String>, available_models: &[ModelPreset],) -> bool { if target_model == current_model { return false; } if let Some(seen_target) = seen_migrations.get(current_model) && seen_target == target_model { return false; } if !available_models .iter() .any(|preset| preset.model == target_model && preset.show_in_picker) { return false; } if available_models .iter() .any(|preset| preset.model == current_model && preset.upgrade.is_some()) { return true; } if available_models .iter() .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model)) { return true; } false}pub(super) fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool { match migration_config_key { HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config .notices .hide_gpt_5_1_codex_max_migration_prompt .unwrap_or(false), HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => { config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false) } _ => false, }}pub(super) fn target_preset_for_upgrade<'a>( available_models: &'a [ModelPreset], target_model: &str,) -> Option<&'a ModelPreset> { available_models .iter() .find(|preset| preset.model == target_model && preset.show_in_picker)}pub(super) fn apply_accepted_model_migration( config: &mut Config, app_event_tx: &AppEventSender, from_model: String, target_model: String, target_default_effort: ReasoningEffortConfig,) { app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { from_model, to_model: target_model.clone(), }); config.model = Some(target_model.clone()); config.model_reasoning_effort = Some(target_default_effort.clone()); app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(Some( target_default_effort.clone(), ))); app_event_tx.send(AppEvent::PersistModelSelection { model: target_model, effort: Some(target_default_effort), });}pub(super) const MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT: u32 = 4;#[derive(Debug, Clone, PartialEq, Eq)]pub(super) struct StartupTooltipOverride { pub(super) model_slug: String, pub(super) message: String,}pub(super) fn select_model_availability_nux( available_models: &[ModelPreset], nux_config: &ModelAvailabilityNuxConfig,) -> Option<StartupTooltipOverride> { available_models.iter().find_map(|preset| { let ModelAvailabilityNux { message } = preset.availability_nux.as_ref()?; let shown_count = nux_config .shown_count .get(&preset.model) .copied() .unwrap_or_default(); (shown_count < MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT).then(|| StartupTooltipOverride { model_slug: preset.model.clone(), message: message.clone(), }) })}pub(super) async fn prepare_startup_tooltip_override( config: &mut Config, available_models: &[ModelPreset], is_first_run: bool,) -> Option<String> { if is_first_run || !config.show_tooltips { return None; } let tooltip_override = select_model_availability_nux(available_models, &config.model_availability_nux)?; let shown_count = config .model_availability_nux .shown_count .get(&tooltip_override.model_slug) .copied() .unwrap_or_default(); let next_count = shown_count.saturating_add(1); let mut updated_shown_count = config.model_availability_nux.shown_count.clone(); updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count); if let Err(err) = ConfigEditsBuilder::for_config(config) .set_model_availability_nux_count(&updated_shown_count) .apply() .await { tracing::error!( error = %err, model = %tooltip_override.model_slug, "failed to persist model availability nux count" ); return Some(tooltip_override.message); } config.model_availability_nux.shown_count = updated_shown_count; Some(tooltip_override.message)}pub(super) async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, model: &str, app_event_tx: &AppEventSender, available_models: &[ModelPreset],) -> Option<AppExitInfo> { let upgrade = available_models .iter() .find(|preset| preset.model == model) .and_then(|preset| preset.upgrade.as_ref()); if let Some(ModelUpgrade { id: target_model, migration_config_key, model_link, upgrade_copy, migration_markdown, }) = upgrade { if migration_prompt_hidden(config, migration_config_key.as_str()) { return None; } let target_model = target_model.to_string(); if !should_show_model_migration_prompt( model, &target_model, &config.notices.model_migrations, available_models, ) { return None; } let current_preset = available_models.iter().find(|preset| preset.model == model); let target_preset = target_preset_for_upgrade(available_models, &target_model); let target_preset = target_preset?; let target_display_name = target_preset.display_name.clone(); let heading_label = if target_display_name == model { target_model.clone() } else { target_display_name.clone() }; let target_description = (!target_preset.description.is_empty()).then(|| target_preset.description.clone()); let can_opt_out = current_preset.is_some(); let prompt_copy = migration_copy_for_models( model, &target_model, model_link.clone(), upgrade_copy.clone(), migration_markdown.clone(), heading_label, target_description, can_opt_out, ); match run_model_migration_prompt(tui, prompt_copy).await { ModelMigrationOutcome::Accepted => { apply_accepted_model_migration( config, app_event_tx, model.to_string(), target_model.clone(), target_preset.default_reasoning_effort.clone(), ); } ModelMigrationOutcome::Rejected => { app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { from_model: model.to_string(), to_model: target_model.clone(), }); } ModelMigrationOutcome::Exit => { return Some(AppExitInfo { token_usage: TokenUsage::default(), thread_id: None, resume_hint: None, update_action: None, exit_reason: ExitReason::UserRequested, }); } } } None}pub(super) fn normalize_harness_overrides_for_cwd( mut overrides: ConfigOverrides, base_cwd: &AbsolutePathBuf,) -> Result<ConfigOverrides> { if overrides.additional_writable_roots.is_empty() { return Ok(overrides); } let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len()); for root in overrides.additional_writable_roots.drain(..) { let absolute = base_cwd.join(root); normalized.push(absolute.into_path_buf()); } overrides.additional_writable_roots = normalized; Ok(overrides)}#[cfg(test)]mod tests { use super::*; use crate::test_support::PathBufExt; use pretty_assertions::assert_eq; use ratatui::text::Line; use std::path::PathBuf; use tempfile::tempdir; use tokio::sync::mpsc::unbounded_channel; #[test] fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { let temp_dir = tempdir()?; let base_cwd = temp_dir.path().join("base").abs(); std::fs::create_dir_all(base_cwd.as_path())?; let overrides = ConfigOverrides { additional_writable_roots: vec![PathBuf::from("rel")], ..Default::default() }; let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?; assert_eq!( normalized.additional_writable_roots, vec![base_cwd.join("rel").into_path_buf()] ); Ok(()) } fn skill_error(path: &str, message: &str) -> SkillErrorInfo { SkillErrorInfo { path: PathBuf::from(path), message: message.to_string(), } } fn render_line_text(line: &Line<'static>) -> String { line.spans .iter() .map(|span| span.content.as_ref()) .collect() } fn render_skill_load_warning_cells(errors: &[SkillErrorInfo]) -> String { let (tx, mut rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(tx); emit_skill_load_warnings(&app_event_tx, errors); let mut rendered = Vec::new(); while let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() { rendered.extend( cell.display_lines(/*width*/ 120) .iter() .map(render_line_text), ); } rendered.join("\n") } #[test] fn skill_load_warning_state_suppresses_repeated_active_errors() { let mut state = SkillLoadWarningState::default(); let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); assert_eq!( state.newly_active_errors(std::slice::from_ref(&error)), vec![error.clone()] ); assert_eq!( state.newly_active_errors(std::slice::from_ref(&error)), Vec::<SkillErrorInfo>::new() ); } #[test] fn skill_load_warning_state_reemits_after_error_clears() { let mut state = SkillLoadWarningState::default(); let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); assert_eq!( state.newly_active_errors(std::slice::from_ref(&error)), vec![error.clone()] ); assert_eq!(state.newly_active_errors(&[]), Vec::<SkillErrorInfo>::new()); assert_eq!( state.newly_active_errors(std::slice::from_ref(&error)), vec![error] ); } #[test] fn skill_load_warning_state_displays_new_message_for_active_path() { let mut state = SkillLoadWarningState::default(); let initial = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); let changed = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid frontmatter"); assert_eq!( state.newly_active_errors(std::slice::from_ref(&initial)), vec![initial] ); assert_eq!( state.newly_active_errors(std::slice::from_ref(&changed)), vec![changed] ); } #[test] fn skill_load_warning_state_clear_allows_active_error_again() { let mut state = SkillLoadWarningState::default(); let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); assert_eq!( state.newly_active_errors(std::slice::from_ref(&error)), vec![error.clone()] ); assert_eq!( state.newly_active_errors(std::slice::from_ref(&error)), Vec::<SkillErrorInfo>::new() ); state.clear(); assert_eq!( state.newly_active_errors(std::slice::from_ref(&error)), vec![error] ); } #[test] fn repeated_active_skill_load_warning_renders_once() { let mut state = SkillLoadWarningState::default(); let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); let first_errors = state.newly_active_errors(std::slice::from_ref(&error)); let repeated_errors = state.newly_active_errors(std::slice::from_ref(&error)); let rendered = [ render_skill_load_warning_cells(&first_errors), render_skill_load_warning_cells(&repeated_errors), ] .into_iter() .filter(|output| !output.is_empty()) .collect::<Vec<_>>() .join("\n"); insta::assert_snapshot!(rendered, @r"⚠ Skipped loading 1 skill(s) due to invalid SKILL.md files.⚠ /repo/.codex/skills/abc/SKILL.md: invalid description"); }}