From 9cd4d895ba77d165cce60b4627754c5f6d8e694c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 3 Jan 2026 22:12:55 -0800 Subject: [PATCH] async --- codex-rs/core/src/models_manager/manager.rs | 26 +- codex-rs/tui/src/app.rs | 287 +------------------- codex-rs/tui/src/chatwidget.rs | 240 +++++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 3 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/model_migration.rs | 57 ---- codex-rs/tui2/src/app.rs | 249 +---------------- codex-rs/tui2/src/chatwidget.rs | 239 +++++++++++++++- codex-rs/tui2/src/chatwidget/tests.rs | 3 + codex-rs/tui2/src/lib.rs | 1 + codex-rs/tui2/src/model_migration.rs | 58 ---- 11 files changed, 515 insertions(+), 649 deletions(-) diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 3f6c2bdfff..e3bbc8517e 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -10,6 +10,7 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use tokio::sync::Mutex; use tokio::sync::RwLock; use tokio::sync::TryLockError; use tracing::error; @@ -39,6 +40,7 @@ pub struct ModelsManager { // todo(aibrahim) merge available_models and model family creation into one struct local_models: Vec, remote_models: RwLock>, + refresh_lock: Mutex<()>, auth_manager: Arc, etag: RwLock>, codex_home: PathBuf, @@ -53,6 +55,7 @@ impl ModelsManager { Self { local_models: builtin_model_presets(auth_manager.get_auth_mode()), remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()), + refresh_lock: Mutex::new(()), auth_manager, etag: RwLock::new(None), codex_home, @@ -68,6 +71,7 @@ impl ModelsManager { Self { local_models: builtin_model_presets(auth_manager.get_auth_mode()), remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()), + refresh_lock: Mutex::new(()), auth_manager, etag: RwLock::new(None), codex_home, @@ -93,6 +97,7 @@ impl ModelsManager { if self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) { return Ok(()); } + let _refresh_guard = self.refresh_lock.lock().await; let auth = self.auth_manager.auth(); let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?; @@ -100,10 +105,23 @@ impl ModelsManager { let client = ModelsClient::new(transport, api_provider, api_auth); let client_version = format_client_version_to_whole(); - let (models, etag) = client - .list_models(&client_version, HeaderMap::new()) - .await - .map_err(map_api_error)?; + let remote_models = tokio::time::timeout( + Duration::from_secs(5), + client.list_models(&client_version, HeaderMap::new()), + ) + .await; + + let (models, etag) = match remote_models { + Ok(Ok((models, etag))) => (models, etag), + Ok(Err(err)) => { + error!("failed to refresh remote models: {}", map_api_error(err)); + return Ok(()); + } + Err(_) => { + error!("timed out refreshing remote models after 5s"); + return Ok(()); + } + }; self.apply_remote_models(models.clone()).await; *self.etag.write().await = etag.clone(); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e0af51e7eb..0ecad59308 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -10,9 +10,6 @@ use crate::external_editor; use crate::file_search::FileSearchManager; use crate::history_cell; use crate::history_cell::HistoryCell; -use crate::model_migration::ModelMigrationOutcome; -use crate::model_migration::migration_copy_for_models; -use crate::model_migration::run_model_migration_prompt; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -28,9 +25,6 @@ use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; -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::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; @@ -39,8 +33,6 @@ use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -51,7 +43,6 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; -use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -127,164 +118,6 @@ struct SessionSummary { resume_command: Option, } -fn should_show_model_migration_prompt( - 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 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 target_preset_for_upgrade<'a>( - available_models: &'a [ModelPreset], - target_model: &str, -) -> Option<&'a ModelPreset> { - available_models - .iter() - .find(|preset| preset.model == target_model) -} - -async fn handle_model_migration_prompt_if_needed( - tui: &mut tui::Tui, - config: &mut Config, - model: &str, - app_event_tx: &AppEventSender, - models_manager: Arc, -) -> Option { - let available_models = models_manager.list_models(config).await; - let upgrade = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()); - - if let Some(ModelUpgrade { - id: target_model, - reasoning_effort_mapping, - migration_config_key, - model_link, - upgrade_copy, - }) = 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(), - heading_label, - target_description, - can_opt_out, - ); - match run_model_migration_prompt(tui, prompt_copy).await { - ModelMigrationOutcome::Accepted => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - config.model = Some(target_model.clone()); - - let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping - && let Some(reasoning_effort) = config.model_reasoning_effort - { - reasoning_effort_mapping - .get(&reasoning_effort) - .cloned() - .or(config.model_reasoning_effort) - } else { - config.model_reasoning_effort - }; - - config.model_reasoning_effort = mapped_effort; - - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); - app_event_tx.send(AppEvent::PersistModelSelection { - model: target_model.clone(), - effort: mapped_effort, - }); - } - 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(), - conversation_id: None, - update_action: None, - }); - } - } - } - - None -} - pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, @@ -336,7 +169,7 @@ impl App { pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, - mut config: Config, + config: Config, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -352,24 +185,6 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); - let mut model = conversation_manager - .get_models_manager() - .get_model(&config.model, &config) - .await; - let exit_info = handle_model_migration_prompt_if_needed( - tui, - &mut config, - model.as_str(), - &app_event_tx, - conversation_manager.get_models_manager(), - ) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - if let Some(updated_model) = config.model.clone() { - model = updated_model; - } let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match resume_selection { @@ -385,7 +200,8 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: model.clone(), + // The only truthful model is the one we get back on SessionConfigured. + model: String::new(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -411,7 +227,8 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: model.clone(), + // The only truthful model is the one we get back on SessionConfigured. + model: String::new(), }; ChatWidget::new_from_existing( init, @@ -433,7 +250,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), + current_model: String::new(), active_profile, file_search, enhanced_keys_supported, @@ -555,11 +372,6 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_family = self - .server - .get_models_manager() - .construct_model_family(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { let summary = session_summary( @@ -581,7 +393,6 @@ impl App { model: self.current_model.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -635,7 +446,6 @@ impl App { resumed.conversation, resumed.session_configured, ); - self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -1423,91 +1233,6 @@ mod tests { ) } - fn all_model_presets() -> Vec { - codex_core::models_manager::model_presets::all_model_presets().clone() - } - - #[tokio::test] - async fn model_migration_prompt_only_shows_for_deprecated_models() { - let seen = BTreeMap::new(); - assert!(should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex-mini", - "gpt-5.1-codex-mini", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex-max", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - } - - #[tokio::test] - async fn model_migration_prompt_respects_hide_flag_and_self_target() { - let mut seen = BTreeMap::new(); - seen.insert("gpt-5".to_string(), "gpt-5.1".to_string()); - assert!(!should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1", - "gpt-5.1", - &seen, - &all_model_presets() - )); - } - - #[tokio::test] - async fn model_migration_prompt_skips_when_target_missing() { - let mut available = all_model_presets(); - let mut current = available - .iter() - .find(|preset| preset.model == "gpt-5-codex") - .cloned() - .expect("preset present"); - current.upgrade = Some(ModelUpgrade { - id: "missing-target".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), - model_link: None, - upgrade_copy: None, - }); - available.retain(|preset| preset.model != "gpt-5-codex"); - available.push(current.clone()); - - assert!(should_show_model_migration_prompt( - ¤t.model, - "missing-target", - &BTreeMap::new(), - &available, - )); - - assert!(target_preset_for_upgrade(&available, "missing-target").is_none()); - } - #[tokio::test] async fn update_reasoning_effort_updates_config() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f4418ceadb..1642bc502a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,6 +1,8 @@ +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,6 +17,8 @@ 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; @@ -78,6 +82,9 @@ 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; use tokio::sync::mpsc::UnboundedSender; @@ -141,12 +148,23 @@ 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"; + +#[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, @@ -402,6 +420,199 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } impl ChatWidget { + fn is_session_configured(&self) -> bool { + 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); + 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!( + "Model upgrade available: {} -> {}. Use /model to switch.", + notice.from_model, notice.to_model + ), + 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, + to_model: notice.to_model, + }); + } + + fn maybe_schedule_model_migration_notice(&self, model: &str) { + let available_models = match self.models_manager.try_list_models(&self.config) { + Ok(models) => models, + 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, + 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) { if let Some(mut controller) = self.stream_controller.take() && let Some(cell) = controller.finalize() @@ -439,13 +650,14 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.session_header.set_model(&model_for_header); + self.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, )); + self.maybe_show_pending_model_migration_notice(); if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } @@ -457,7 +669,12 @@ impl ChatWidget { }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); + } else { + // If the user queued messages while startup was still in progress, kick off the first + // turn now that we know the session is configured. + self.maybe_send_next_queued_input(); } + self.maybe_schedule_model_migration_notice(&model_for_header); if !self.suppress_session_configured_redraw { self.request_redraw(); } @@ -1719,7 +1936,7 @@ impl ChatWidget { return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); + self.queue_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { self.clear_token_usage(); @@ -1900,7 +2117,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if self.bottom_pane.is_task_running() { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_queued_user_messages(); } else { @@ -1909,6 +2126,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + if !self.is_session_configured() { + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + return; + } + let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { return; @@ -2211,6 +2434,9 @@ impl ChatWidget { // If idle and there are queued inputs, submit exactly one to start the next turn. fn maybe_send_next_queued_input(&mut self) { + if !self.is_session_configured() { + return; + } if self.bottom_pane.is_task_running() { return; } @@ -2407,6 +2633,14 @@ 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(), + None, + ); + return; + } + let presets: Vec = // todo(aibrahim): make this async function match self.models_manager.try_list_models(&self.config) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a0ff8d42e9..9c81035f1b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1021,6 +1021,7 @@ async fn alt_up_edits_most_recent_queued_message() { #[tokio::test] async fn enqueueing_history_prompt_multiple_times_is_stable() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ConversationId::new()); // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); @@ -1955,6 +1956,7 @@ async fn experimental_features_toggle_saves_on_exit() { #[tokio::test] async fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; + chat.conversation_id = Some(ConversationId::new()); chat.open_model_popup(); let popup = render_bottom_popup(&chat, 80); @@ -2169,6 +2171,7 @@ async fn feedback_upload_consent_popup_snapshot() { #[tokio::test] async fn reasoning_popup_escape_returns_to_model_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.conversation_id = Some(ConversationId::new()); chat.open_model_popup(); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index bce9a350f4..8a7f9e1200 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -58,6 +58,7 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; +#[cfg(test)] mod model_migration; mod notifications; pub mod onboarding; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index cf2208109a..0a8fc753b5 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -5,8 +5,6 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; -use crate::tui::Tui; -use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -19,7 +17,6 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use tokio_stream::StreamExt; /// Outcome of the migration prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -116,40 +113,6 @@ pub(crate) fn migration_copy_for_models( } } -pub(crate) async fn run_model_migration_prompt( - tui: &mut Tui, - copy: ModelMigrationCopy, -) -> ModelMigrationOutcome { - let alt = AltScreenGuard::enter(tui); - let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy); - - let _ = alt.tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&screen, frame.area()); - }); - - let events = alt.tui.event_stream(); - tokio::pin!(events); - - while !screen.is_done() { - if let Some(event) = events.next().await { - match event { - TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} - TuiEvent::Draw => { - let _ = alt.tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&screen, frame.area()); - }); - } - } - } else { - screen.accept(); - break; - } - } - - screen.outcome() -} - struct ModelMigrationScreen { request_frame: FrameRequester, copy: ModelMigrationCopy, @@ -323,26 +286,6 @@ impl ModelMigrationScreen { } } -// Render the prompt on the terminal's alternate screen so exiting or cancelling -// does not leave a large blank region in the normal scrollback. This does not -// change the prompt's appearance – only where it is drawn. -struct AltScreenGuard<'a> { - tui: &'a mut Tui, -} - -impl<'a> AltScreenGuard<'a> { - fn enter(tui: &'a mut Tui) -> Self { - let _ = tui.enter_alt_screen(); - Self { tui } - } -} - -impl Drop for AltScreenGuard<'_> { - fn drop(&mut self) { - let _ = self.tui.leave_alt_screen(); - } -} - fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool { key_event.modifiers.contains(KeyModifiers::CONTROL) && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 71ba06542a..b94dfe3f4c 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -10,9 +10,6 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; -use crate::model_migration::ModelMigrationOutcome; -use crate::model_migration::migration_copy_for_models; -use crate::model_migration::run_model_migration_prompt; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -39,9 +36,6 @@ use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; -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::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; @@ -51,8 +45,6 @@ use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::terminal::terminal_info; use codex_protocol::ConversationId; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -68,7 +60,6 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -158,161 +149,6 @@ struct SessionSummary { resume_command: Option, } -fn should_show_model_migration_prompt( - 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 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, - } -} - -async fn handle_model_migration_prompt_if_needed( - tui: &mut tui::Tui, - config: &mut Config, - model: &str, - app_event_tx: &AppEventSender, - models_manager: Arc, -) -> Option { - let available_models = models_manager.list_models(config).await; - let upgrade = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()); - - if let Some(ModelUpgrade { - id: target_model, - reasoning_effort_mapping, - migration_config_key, - .. - }) = 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 = available_models - .iter() - .find(|preset| preset.model == target_model); - let target_display_name = target_preset - .map(|preset| preset.display_name.clone()) - .unwrap_or_else(|| target_model.clone()); - let heading_label = if target_display_name == model { - target_model.clone() - } else { - target_display_name.clone() - }; - let target_description = target_preset.and_then(|preset| { - if preset.description.is_empty() { - None - } else { - Some(preset.description.clone()) - } - }); - let can_opt_out = current_preset.is_some(); - let prompt_copy = migration_copy_for_models( - model, - &target_model, - heading_label, - target_description, - can_opt_out, - ); - match run_model_migration_prompt(tui, prompt_copy).await { - ModelMigrationOutcome::Accepted => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - config.model = Some(target_model.clone()); - - let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping - && let Some(reasoning_effort) = config.model_reasoning_effort - { - reasoning_effort_mapping - .get(&reasoning_effort) - .cloned() - .or(config.model_reasoning_effort) - } else { - config.model_reasoning_effort - }; - - config.model_reasoning_effort = mapped_effort; - - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); - app_event_tx.send(AppEvent::PersistModelSelection { - model: target_model.clone(), - effort: mapped_effort, - }); - } - 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(), - conversation_id: None, - update_action: None, - session_lines: Vec::new(), - }); - } - } - } - - None -} - pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, @@ -374,7 +210,7 @@ impl App { pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, - mut config: Config, + config: Config, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -390,24 +226,6 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); - let mut model = conversation_manager - .get_models_manager() - .get_model(&config.model, &config) - .await; - let exit_info = handle_model_migration_prompt_if_needed( - tui, - &mut config, - model.as_str(), - &app_event_tx, - conversation_manager.get_models_manager(), - ) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - if let Some(updated_model) = config.model.clone() { - model = updated_model; - } let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match resume_selection { @@ -423,7 +241,8 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: model.clone(), + // The only truthful model is the one we get back on SessionConfigured. + model: String::new(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -449,7 +268,8 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: model.clone(), + // The only truthful model is the one we get back on SessionConfigured. + model: String::new(), }; ChatWidget::new_from_existing( init, @@ -487,7 +307,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), + current_model: String::new(), active_profile, file_search, enhanced_keys_supported, @@ -2166,45 +1986,6 @@ mod tests { ) } - fn all_model_presets() -> Vec { - codex_core::models_manager::model_presets::all_model_presets().clone() - } - - #[tokio::test] - async fn model_migration_prompt_only_shows_for_deprecated_models() { - let seen = BTreeMap::new(); - assert!(should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex-mini", - "gpt-5.1-codex-mini", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex-max", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - } - #[tokio::test] async fn transcript_selection_copy_includes_offscreen_lines() { let mut app = make_test_app().await; @@ -2237,24 +2018,6 @@ mod tests { assert_eq!(text, "one\ntwo\nthree\nfour"); } - #[tokio::test] - async fn model_migration_prompt_respects_hide_flag_and_self_target() { - let mut seen = BTreeMap::new(); - seen.insert("gpt-5".to_string(), "gpt-5.1".to_string()); - assert!(!should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1", - "gpt-5.1", - &seen, - &all_model_presets() - )); - } - #[tokio::test] async fn update_reasoning_effort_updates_config() { let mut app = make_test_app().await; diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 681deb9cdb..731e1f4785 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1,6 +1,8 @@ +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; @@ -13,6 +15,8 @@ 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; @@ -78,6 +82,8 @@ 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; @@ -136,12 +142,23 @@ 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"; + +#[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, @@ -368,6 +385,199 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } impl ChatWidget { + fn is_session_configured(&self) -> bool { + 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); + 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!( + "Model upgrade available: {} -> {}. Use /model to switch.", + notice.from_model, notice.to_model + ), + 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, + to_model: notice.to_model, + }); + } + + fn maybe_schedule_model_migration_notice(&self, model: &str) { + let available_models = match self.models_manager.try_list_models(&self.config) { + Ok(models) => models, + 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, + 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) { if let Some(mut controller) = self.stream_controller.take() && let Some(cell) = controller.finalize() @@ -405,13 +615,14 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.session_header.set_model(&model_for_header); + self.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, )); + self.maybe_show_pending_model_migration_notice(); if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } @@ -423,7 +634,12 @@ impl ChatWidget { }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); + } else { + // If the user queued messages while startup was still in progress, kick off the first + // turn now that we know the session is configured. + self.maybe_send_next_queued_input(); } + self.maybe_schedule_model_migration_notice(&model_for_header); if !self.suppress_session_configured_redraw { self.request_redraw(); } @@ -1555,7 +1771,7 @@ impl ChatWidget { return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); + self.queue_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { self.clear_token_usage(); @@ -1708,7 +1924,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if self.bottom_pane.is_task_running() { + if !self.is_session_configured() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_queued_user_messages(); } else { @@ -1717,6 +1933,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + if !self.is_session_configured() { + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + return; + } + let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { return; @@ -2019,6 +2241,9 @@ impl ChatWidget { // If idle and there are queued inputs, submit exactly one to start the next turn. fn maybe_send_next_queued_input(&mut self) { + if !self.is_session_configured() { + return; + } if self.bottom_pane.is_task_running() { return; } @@ -2205,6 +2430,14 @@ 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(), + None, + ); + return; + } + let presets: Vec = // todo(aibrahim): make this async function match self.models_manager.try_list_models(&self.config) { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 8b216812df..6fe7cf8599 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -981,6 +981,7 @@ async fn alt_up_edits_most_recent_queued_message() { #[tokio::test] async fn enqueueing_history_prompt_multiple_times_is_stable() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.conversation_id = Some(ConversationId::new()); // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); @@ -1725,6 +1726,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[tokio::test] async fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; + chat.conversation_id = Some(ConversationId::new()); chat.open_model_popup(); let popup = render_bottom_popup(&chat, 80); @@ -1939,6 +1941,7 @@ async fn feedback_upload_consent_popup_snapshot() { #[tokio::test] async fn reasoning_popup_escape_returns_to_model_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + chat.conversation_id = Some(ConversationId::new()); chat.open_model_popup(); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 4583a5d84a..78536860dd 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -58,6 +58,7 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; +#[cfg(test)] mod model_migration; mod notifications; pub mod onboarding; diff --git a/codex-rs/tui2/src/model_migration.rs b/codex-rs/tui2/src/model_migration.rs index 0b74ad40a5..4f95504200 100644 --- a/codex-rs/tui2/src/model_migration.rs +++ b/codex-rs/tui2/src/model_migration.rs @@ -5,8 +5,6 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; -use crate::tui::Tui; -use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -19,7 +17,6 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use tokio_stream::StreamExt; /// Outcome of the migration prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -96,41 +93,6 @@ pub(crate) fn migration_copy_for_models( } } -pub(crate) async fn run_model_migration_prompt( - tui: &mut Tui, - copy: ModelMigrationCopy, -) -> ModelMigrationOutcome { - let alt = AltScreenGuard::enter(tui); - let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy); - - let _ = alt.tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&screen, frame.area()); - }); - - let events = alt.tui.event_stream(); - tokio::pin!(events); - - while !screen.is_done() { - if let Some(event) = events.next().await { - match event { - TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Mouse(_) => {} - TuiEvent::Paste(_) => {} - TuiEvent::Draw => { - let _ = alt.tui.draw(u16::MAX, |frame| { - frame.render_widget_ref(&screen, frame.area()); - }); - } - } - } else { - screen.accept(); - break; - } - } - - screen.outcome() -} - struct ModelMigrationScreen { request_frame: FrameRequester, copy: ModelMigrationCopy, @@ -304,26 +266,6 @@ impl ModelMigrationScreen { } } -// Render the prompt on the terminal's alternate screen so exiting or cancelling -// does not leave a large blank region in the normal scrollback. This does not -// change the prompt's appearance – only where it is drawn. -struct AltScreenGuard<'a> { - tui: &'a mut Tui, -} - -impl<'a> AltScreenGuard<'a> { - fn enter(tui: &'a mut Tui) -> Self { - let _ = tui.enter_alt_screen(); - Self { tui } - } -} - -impl Drop for AltScreenGuard<'_> { - fn drop(&mut self) { - let _ = self.tui.leave_alt_screen(); - } -} - fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool { key_event.modifiers.contains(KeyModifiers::CONTROL) && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))