mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
async
This commit is contained in:
@@ -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<ModelPreset>,
|
||||
remote_models: RwLock<Vec<ModelInfo>>,
|
||||
refresh_lock: Mutex<()>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
etag: RwLock<Option<String>>,
|
||||
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();
|
||||
|
||||
+6
-281
@@ -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<String>,
|
||||
}
|
||||
|
||||
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 == 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<ModelsManager>,
|
||||
) -> Option<AppExitInfo> {
|
||||
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<ConversationManager>,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
@@ -336,7 +169,7 @@ impl App {
|
||||
pub async fn run(
|
||||
tui: &mut tui::Tui,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
mut config: Config,
|
||||
config: Config,
|
||||
active_profile: Option<String>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
@@ -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<bool> {
|
||||
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<Line<'static>> = 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<Line<'static>> =
|
||||
vec![summary.usage_line.clone().into()];
|
||||
@@ -1423,91 +1233,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn all_model_presets() -> Vec<ModelPreset> {
|
||||
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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
command: Vec<String>,
|
||||
@@ -402,6 +420,199 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> 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<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 == 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<ModelPreset> =
|
||||
// todo(aibrahim): make this async function
|
||||
match self.models_manager.try_list_models(&self.config) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
+6
-243
@@ -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<String>,
|
||||
}
|
||||
|
||||
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 == 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<ModelsManager>,
|
||||
) -> Option<AppExitInfo> {
|
||||
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<ConversationManager>,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
@@ -374,7 +210,7 @@ impl App {
|
||||
pub async fn run(
|
||||
tui: &mut tui::Tui,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
mut config: Config,
|
||||
config: Config,
|
||||
active_profile: Option<String>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
@@ -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<ModelPreset> {
|
||||
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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
command: Vec<String>,
|
||||
@@ -368,6 +385,199 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> 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<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 == 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<ModelPreset> =
|
||||
// todo(aibrahim): make this async function
|
||||
match self.models_manager.try_list_models(&self.config) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user