From c982cbd6e10b29f3e2ea14d6d43cb58c8e51fadd Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 4 Jan 2026 14:29:05 -0800 Subject: [PATCH] files --- codex-rs/tui/src/chatwidget.rs | 193 ++------------------ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/model_migration_logic.rs | 196 +++++++++++++++++++++ codex-rs/tui2/src/chatwidget.rs | 193 ++------------------ codex-rs/tui2/src/lib.rs | 1 + codex-rs/tui2/src/model_migration_logic.rs | 196 +++++++++++++++++++++ 6 files changed, 422 insertions(+), 358 deletions(-) create mode 100644 codex-rs/tui/src/model_migration_logic.rs create mode 100644 codex-rs/tui2/src/model_migration_logic.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1642bc502a..4d7942e462 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,8 +1,6 @@ -use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; -use std::io; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -17,8 +15,6 @@ use codex_core::features::Feature; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; -use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -82,8 +78,6 @@ use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; -use serde::Deserialize; -use serde::Serialize; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; @@ -148,23 +142,14 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; -const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json"; +use crate::model_migration_logic; -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PendingModelMigrationNotice { - from_model: String, - to_model: String, - // Used to respect hide flags even if config changes between scheduling and display. - #[serde(default)] - migration_config_key: Option, -} // Track information about an in-flight exec command. struct RunningCommand { command: Vec, @@ -424,98 +409,12 @@ impl ChatWidget { self.conversation_id.is_some() } - fn pending_model_migration_notice_path(&self) -> PathBuf { - self.config - .codex_home - .join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME) - } - - 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, - } - } - - fn should_show_model_migration_notice( - current_model: &str, - target_model: &str, - seen_migrations: &BTreeMap, - 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 == 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 - } - fn maybe_show_pending_model_migration_notice(&mut self) { - let notice_path = self.pending_model_migration_notice_path(); - let contents = match std::fs::read_to_string(¬ice_path) { - Ok(contents) => contents, - Err(err) if err.kind() == io::ErrorKind::NotFound => return, - Err(err) => { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to read pending model migration notice" - ); - return; - } - }; - - let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) { - Ok(notice) => notice, - Err(err) => { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to parse pending model migration notice" - ); - return; - } - }; - - if let Some(migration_config_key) = notice.migration_config_key.as_deref() - && Self::migration_prompt_hidden(&self.config, migration_config_key) - { - let _ = std::fs::remove_file(¬ice_path); + let Some(notice) = + model_migration_logic::take_pending_model_migration_notice(&mut self.config) + else { return; - } - - if let Some(seen_target) = self.config.notices.model_migrations.get(¬ice.from_model) - && seen_target == ¬ice.to_model - { - let _ = std::fs::remove_file(¬ice_path); - return; - } + }; self.add_to_history(history_cell::new_info_event( format!( @@ -524,16 +423,6 @@ impl ChatWidget { ), None, )); - - // Best-effort: clear the one-shot file so it doesn't appear again. - let _ = std::fs::remove_file(¬ice_path); - - // Update in-memory state to prevent re-scheduling within this run, and persist so future - // runs don't re-show the notice. - self.config - .notices - .model_migrations - .insert(notice.from_model.clone(), notice.to_model.clone()); self.app_event_tx .send(AppEvent::PersistModelMigrationPromptAcknowledged { from_model: notice.from_model, @@ -547,70 +436,11 @@ impl ChatWidget { Err(_) => return, }; - let Some(ModelUpgrade { - id: target_model, - migration_config_key, - .. - }) = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()) - else { - return; - }; - - if Self::migration_prompt_hidden(&self.config, migration_config_key.as_str()) { - return; - } - - if available_models - .iter() - .all(|preset| preset.model != target_model.as_str()) - { - return; - } - - if !Self::should_show_model_migration_notice( + model_migration_logic::maybe_schedule_model_migration_notice( + &self.config, model, - target_model.as_str(), - &self.config.notices.model_migrations, &available_models, - ) { - return; - } - - let notice_path = self.pending_model_migration_notice_path(); - if notice_path.exists() { - return; - } - - let notice = PendingModelMigrationNotice { - from_model: model.to_string(), - to_model: target_model.to_string(), - migration_config_key: Some(migration_config_key.to_string()), - }; - let Ok(json_line) = serde_json::to_string(¬ice).map(|json| format!("{json}\n")) else { - return; - }; - - if let Some(parent) = notice_path.parent() - && let Err(err) = std::fs::create_dir_all(parent) - { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to create directory for pending model migration notice" - ); - return; - } - - if let Err(err) = std::fs::write(¬ice_path, json_line) { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to persist pending model migration notice" - ); - } + ); } fn flush_answer_stream_with_separator(&mut self) { @@ -1634,7 +1464,12 @@ impl ChatWidget { model, } = common; let mut config = config; - config.model = Some(model.clone()); + // `model` is an optional override provided by the app. Avoid clobbering the configured + // model with an empty string during startup; that would propagate to core and render as a + // blank model in the session header (/model current label, etc). + if !model.is_empty() { + config.model = Some(model.clone()); + } let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8a7f9e1200..c8df513cc4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -60,6 +60,7 @@ mod markdown_render; mod markdown_stream; #[cfg(test)] mod model_migration; +mod model_migration_logic; mod notifications; pub mod onboarding; mod oss_selection; diff --git a/codex-rs/tui/src/model_migration_logic.rs b/codex-rs/tui/src/model_migration_logic.rs new file mode 100644 index 0000000000..3e7b97aa0e --- /dev/null +++ b/codex-rs/tui/src/model_migration_logic.rs @@ -0,0 +1,196 @@ +use codex_core::config::Config; +use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::path::PathBuf; + +const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PendingModelMigrationNotice { + pub(crate) from_model: String, + pub(crate) to_model: String, + // Used to respect hide flags even if config changes between scheduling and display. + #[serde(default)] + pub(crate) migration_config_key: Option, +} + +fn pending_model_migration_notice_path(config: &Config) -> PathBuf { + config + .codex_home + .join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME) +} + +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, + } +} + +fn should_show_model_migration_notice( + current_model: &str, + target_model: &str, + available_models: &[ModelPreset], + config: &Config, +) -> bool { + if target_model == current_model { + return false; + } + + if let Some(seen_target) = config.notices.model_migrations.get(current_model) + && seen_target == target_model + { + return false; + } + + if available_models + .iter() + .any(|preset| preset.model == current_model && preset.upgrade.is_some()) + { + return true; + } + + available_models + .iter() + .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model)) +} + +/// Read and clear the one-shot migration notice file, returning the notice if it should be shown. +/// +/// If the notice is returned, this also updates `config.notices.model_migrations` to prevent +/// re-scheduling within the current process. +pub(crate) fn take_pending_model_migration_notice( + config: &mut Config, +) -> Option { + let notice_path = pending_model_migration_notice_path(config); + let contents = match std::fs::read_to_string(¬ice_path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return None, + Err(err) => { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to read pending model migration notice" + ); + return None; + } + }; + + let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) { + Ok(notice) => notice, + Err(err) => { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to parse pending model migration notice" + ); + return None; + } + }; + + if let Some(migration_config_key) = notice.migration_config_key.as_deref() + && migration_prompt_hidden(config, migration_config_key) + { + let _ = std::fs::remove_file(¬ice_path); + return None; + } + + if let Some(seen_target) = config.notices.model_migrations.get(¬ice.from_model) + && seen_target == ¬ice.to_model + { + let _ = std::fs::remove_file(¬ice_path); + return None; + } + + // Best-effort: clear the one-shot file so it doesn't appear again. + let _ = std::fs::remove_file(¬ice_path); + + config + .notices + .model_migrations + .insert(notice.from_model.clone(), notice.to_model.clone()); + + Some(notice) +} + +pub(crate) fn maybe_schedule_model_migration_notice( + config: &Config, + current_model: &str, + available_models: &[ModelPreset], +) { + let Some(ModelUpgrade { + id: target_model, + migration_config_key, + .. + }) = available_models + .iter() + .find(|preset| preset.model == current_model) + .and_then(|preset| preset.upgrade.as_ref()) + else { + return; + }; + + if migration_prompt_hidden(config, migration_config_key.as_str()) { + return; + } + + if available_models + .iter() + .all(|preset| preset.model != target_model.as_str()) + { + return; + } + + if !should_show_model_migration_notice( + current_model, + target_model.as_str(), + available_models, + config, + ) { + return; + } + + let notice_path = pending_model_migration_notice_path(config); + if notice_path.exists() { + return; + } + + let notice = PendingModelMigrationNotice { + from_model: current_model.to_string(), + to_model: target_model.to_string(), + migration_config_key: Some(migration_config_key.to_string()), + }; + let Ok(json_line) = serde_json::to_string(¬ice).map(|json| format!("{json}\n")) else { + return; + }; + + if let Some(parent) = notice_path.parent() + && let Err(err) = std::fs::create_dir_all(parent) + { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to create directory for pending model migration notice" + ); + return; + } + + if let Err(err) = std::fs::write(¬ice_path, json_line) { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to persist pending model migration notice" + ); + } +} diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 731e1f4785..a6bf575ca3 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1,8 +1,6 @@ -use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; -use std::io; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -15,8 +13,6 @@ use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; -use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -82,8 +78,6 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; -use serde::Deserialize; -use serde::Serialize; use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use tracing::debug; @@ -142,23 +136,14 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; -const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json"; +use crate::model_migration_logic; -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PendingModelMigrationNotice { - from_model: String, - to_model: String, - // Used to respect hide flags even if config changes between scheduling and display. - #[serde(default)] - migration_config_key: Option, -} // Track information about an in-flight exec command. struct RunningCommand { command: Vec, @@ -389,98 +374,12 @@ impl ChatWidget { self.conversation_id.is_some() } - fn pending_model_migration_notice_path(&self) -> PathBuf { - self.config - .codex_home - .join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME) - } - - 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, - } - } - - fn should_show_model_migration_notice( - current_model: &str, - target_model: &str, - seen_migrations: &BTreeMap, - 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 == 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 - } - fn maybe_show_pending_model_migration_notice(&mut self) { - let notice_path = self.pending_model_migration_notice_path(); - let contents = match std::fs::read_to_string(¬ice_path) { - Ok(contents) => contents, - Err(err) if err.kind() == io::ErrorKind::NotFound => return, - Err(err) => { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to read pending model migration notice" - ); - return; - } - }; - - let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) { - Ok(notice) => notice, - Err(err) => { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to parse pending model migration notice" - ); - return; - } - }; - - if let Some(migration_config_key) = notice.migration_config_key.as_deref() - && Self::migration_prompt_hidden(&self.config, migration_config_key) - { - let _ = std::fs::remove_file(¬ice_path); + let Some(notice) = + model_migration_logic::take_pending_model_migration_notice(&mut self.config) + else { return; - } - - if let Some(seen_target) = self.config.notices.model_migrations.get(¬ice.from_model) - && seen_target == ¬ice.to_model - { - let _ = std::fs::remove_file(¬ice_path); - return; - } + }; self.add_to_history(history_cell::new_info_event( format!( @@ -489,16 +388,6 @@ impl ChatWidget { ), None, )); - - // Best-effort: clear the one-shot file so it doesn't appear again. - let _ = std::fs::remove_file(¬ice_path); - - // Update in-memory state to prevent re-scheduling within this run, and persist so future - // runs don't re-show the notice. - self.config - .notices - .model_migrations - .insert(notice.from_model.clone(), notice.to_model.clone()); self.app_event_tx .send(AppEvent::PersistModelMigrationPromptAcknowledged { from_model: notice.from_model, @@ -512,70 +401,11 @@ impl ChatWidget { Err(_) => return, }; - let Some(ModelUpgrade { - id: target_model, - migration_config_key, - .. - }) = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()) - else { - return; - }; - - if Self::migration_prompt_hidden(&self.config, migration_config_key.as_str()) { - return; - } - - if available_models - .iter() - .all(|preset| preset.model != target_model.as_str()) - { - return; - } - - if !Self::should_show_model_migration_notice( + model_migration_logic::maybe_schedule_model_migration_notice( + &self.config, model, - target_model.as_str(), - &self.config.notices.model_migrations, &available_models, - ) { - return; - } - - let notice_path = self.pending_model_migration_notice_path(); - if notice_path.exists() { - return; - } - - let notice = PendingModelMigrationNotice { - from_model: model.to_string(), - to_model: target_model.to_string(), - migration_config_key: Some(migration_config_key.to_string()), - }; - let Ok(json_line) = serde_json::to_string(¬ice).map(|json| format!("{json}\n")) else { - return; - }; - - if let Some(parent) = notice_path.parent() - && let Err(err) = std::fs::create_dir_all(parent) - { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to create directory for pending model migration notice" - ); - return; - } - - if let Err(err) = std::fs::write(¬ice_path, json_line) { - tracing::error!( - error = %err, - notice_path = %notice_path.display(), - "failed to persist pending model migration notice" - ); - } + ); } fn flush_answer_stream_with_separator(&mut self) { @@ -1498,7 +1328,12 @@ impl ChatWidget { model, } = common; let mut config = config; - config.model = Some(model.clone()); + // `model` is an optional override provided by the app. Avoid clobbering the configured + // model with an empty string during startup; that would propagate to core and render as a + // blank model in the session header (/model current label, etc). + if !model.is_empty() { + config.model = Some(model.clone()); + } let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 78536860dd..068130f99b 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -60,6 +60,7 @@ mod markdown_render; mod markdown_stream; #[cfg(test)] mod model_migration; +mod model_migration_logic; mod notifications; pub mod onboarding; mod oss_selection; diff --git a/codex-rs/tui2/src/model_migration_logic.rs b/codex-rs/tui2/src/model_migration_logic.rs new file mode 100644 index 0000000000..3e7b97aa0e --- /dev/null +++ b/codex-rs/tui2/src/model_migration_logic.rs @@ -0,0 +1,196 @@ +use codex_core::config::Config; +use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::path::PathBuf; + +const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PendingModelMigrationNotice { + pub(crate) from_model: String, + pub(crate) to_model: String, + // Used to respect hide flags even if config changes between scheduling and display. + #[serde(default)] + pub(crate) migration_config_key: Option, +} + +fn pending_model_migration_notice_path(config: &Config) -> PathBuf { + config + .codex_home + .join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME) +} + +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, + } +} + +fn should_show_model_migration_notice( + current_model: &str, + target_model: &str, + available_models: &[ModelPreset], + config: &Config, +) -> bool { + if target_model == current_model { + return false; + } + + if let Some(seen_target) = config.notices.model_migrations.get(current_model) + && seen_target == target_model + { + return false; + } + + if available_models + .iter() + .any(|preset| preset.model == current_model && preset.upgrade.is_some()) + { + return true; + } + + available_models + .iter() + .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model)) +} + +/// Read and clear the one-shot migration notice file, returning the notice if it should be shown. +/// +/// If the notice is returned, this also updates `config.notices.model_migrations` to prevent +/// re-scheduling within the current process. +pub(crate) fn take_pending_model_migration_notice( + config: &mut Config, +) -> Option { + let notice_path = pending_model_migration_notice_path(config); + let contents = match std::fs::read_to_string(¬ice_path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return None, + Err(err) => { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to read pending model migration notice" + ); + return None; + } + }; + + let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) { + Ok(notice) => notice, + Err(err) => { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to parse pending model migration notice" + ); + return None; + } + }; + + if let Some(migration_config_key) = notice.migration_config_key.as_deref() + && migration_prompt_hidden(config, migration_config_key) + { + let _ = std::fs::remove_file(¬ice_path); + return None; + } + + if let Some(seen_target) = config.notices.model_migrations.get(¬ice.from_model) + && seen_target == ¬ice.to_model + { + let _ = std::fs::remove_file(¬ice_path); + return None; + } + + // Best-effort: clear the one-shot file so it doesn't appear again. + let _ = std::fs::remove_file(¬ice_path); + + config + .notices + .model_migrations + .insert(notice.from_model.clone(), notice.to_model.clone()); + + Some(notice) +} + +pub(crate) fn maybe_schedule_model_migration_notice( + config: &Config, + current_model: &str, + available_models: &[ModelPreset], +) { + let Some(ModelUpgrade { + id: target_model, + migration_config_key, + .. + }) = available_models + .iter() + .find(|preset| preset.model == current_model) + .and_then(|preset| preset.upgrade.as_ref()) + else { + return; + }; + + if migration_prompt_hidden(config, migration_config_key.as_str()) { + return; + } + + if available_models + .iter() + .all(|preset| preset.model != target_model.as_str()) + { + return; + } + + if !should_show_model_migration_notice( + current_model, + target_model.as_str(), + available_models, + config, + ) { + return; + } + + let notice_path = pending_model_migration_notice_path(config); + if notice_path.exists() { + return; + } + + let notice = PendingModelMigrationNotice { + from_model: current_model.to_string(), + to_model: target_model.to_string(), + migration_config_key: Some(migration_config_key.to_string()), + }; + let Ok(json_line) = serde_json::to_string(¬ice).map(|json| format!("{json}\n")) else { + return; + }; + + if let Some(parent) = notice_path.parent() + && let Err(err) = std::fs::create_dir_all(parent) + { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to create directory for pending model migration notice" + ); + return; + } + + if let Err(err) = std::fs::write(¬ice_path, json_line) { + tracing::error!( + error = %err, + notice_path = %notice_path.display(), + "failed to persist pending model migration notice" + ); + } +}