This commit is contained in:
Ahmed Ibrahim
2026-01-05 08:17:34 -08:00
parent c982cbd6e1
commit a944b0ebf1
8 changed files with 861 additions and 869 deletions
+3 -4
View File
@@ -148,7 +148,7 @@ 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";
use crate::model_migration_logic;
use crate::model_migration;
// Track information about an in-flight exec command.
struct RunningCommand {
@@ -410,8 +410,7 @@ impl ChatWidget {
}
fn maybe_show_pending_model_migration_notice(&mut self) {
let Some(notice) =
model_migration_logic::take_pending_model_migration_notice(&mut self.config)
let Some(notice) = model_migration::take_pending_model_migration_notice(&mut self.config)
else {
return;
};
@@ -436,7 +435,7 @@ impl ChatWidget {
Err(_) => return,
};
model_migration_logic::maybe_schedule_model_migration_notice(
model_migration::maybe_schedule_model_migration_notice(
&self.config,
model,
&available_models,
-2
View File
@@ -58,9 +58,7 @@ pub mod live_wrap;
mod markdown;
mod markdown_render;
mod markdown_stream;
#[cfg(test)]
mod model_migration;
mod model_migration_logic;
mod notifications;
pub mod onboarding;
mod oss_selection;
+436 -241
View File
@@ -1,300 +1,501 @@
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::prelude::Stylize as _;
use ratatui::prelude::Widget;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_core::config::Config;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::path::PathBuf;
/// Outcome of the migration prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ModelMigrationOutcome {
Accepted,
Rejected,
Exit,
const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PendingModelMigrationNotice {
pub(crate) from_model: String,
pub(crate) to_model: String,
// Used to respect hide flags even if config changes between scheduling and display.
#[serde(default)]
pub(crate) migration_config_key: Option<String>,
}
#[derive(Clone)]
pub(crate) struct ModelMigrationCopy {
pub heading: Vec<Span<'static>>,
pub content: Vec<Line<'static>>,
pub can_opt_out: bool,
fn pending_model_migration_notice_path(config: &Config) -> PathBuf {
config
.codex_home
.join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MigrationMenuOption {
TryNewModel,
UseExistingModel,
}
impl MigrationMenuOption {
fn all() -> [Self; 2] {
[Self::TryNewModel, Self::UseExistingModel]
}
fn label(self) -> &'static str {
match self {
Self::TryNewModel => "Try new model",
Self::UseExistingModel => "Use existing model",
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
pub(crate) fn migration_copy_for_models(
fn should_show_model_migration_notice(
current_model: &str,
target_model: &str,
model_link: Option<String>,
migration_copy: Option<String>,
target_display_name: String,
target_description: Option<String>,
can_opt_out: bool,
) -> ModelMigrationCopy {
let heading_text = Span::from(format!(
"Codex just got an upgrade. Introducing {target_display_name}."
))
.bold();
let description_line: Line<'static>;
if let Some(migration_copy) = &migration_copy {
description_line = Line::from(migration_copy.clone());
} else {
description_line = target_description
.filter(|desc| !desc.is_empty())
.map(Line::from)
.unwrap_or_else(|| {
Line::from(format!(
"{target_display_name} is recommended for better performance and reliability."
))
});
available_models: &[ModelPreset],
config: &Config,
) -> bool {
if target_model == current_model {
return false;
}
let mut content = vec![];
if migration_copy.is_none() {
content.push(Line::from(format!(
"We recommend switching from {current_model} to {target_model}."
)));
content.push(Line::from(""));
if let Some(seen_target) = config.notices.model_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if let Some(model_link) = model_link {
content.push(Line::from(vec![
format!("{description_line} Learn more about {target_display_name} at ").into(),
model_link.cyan().underlined(),
]));
content.push(Line::from(""));
} else {
content.push(description_line);
content.push(Line::from(""));
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
if can_opt_out {
content.push(Line::from(format!(
"You can continue using {current_model} if you prefer."
)));
} else {
content.push(Line::from("Press enter to continue".dim()));
}
ModelMigrationCopy {
heading: vec![heading_text],
content,
can_opt_out,
}
available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
}
struct ModelMigrationScreen {
request_frame: FrameRequester,
copy: ModelMigrationCopy,
done: bool,
outcome: ModelMigrationOutcome,
highlighted_option: MigrationMenuOption,
}
impl ModelMigrationScreen {
fn new(request_frame: FrameRequester, copy: ModelMigrationCopy) -> Self {
Self {
request_frame,
copy,
done: false,
outcome: ModelMigrationOutcome::Accepted,
highlighted_option: MigrationMenuOption::TryNewModel,
/// Read and clear the one-shot migration notice file, returning the notice if it should be shown.
///
/// If the notice is returned, this also updates `config.notices.model_migrations` to prevent
/// re-scheduling within the current process.
pub(crate) fn take_pending_model_migration_notice(
config: &mut Config,
) -> Option<PendingModelMigrationNotice> {
let notice_path = pending_model_migration_notice_path(config);
let contents = match std::fs::read_to_string(&notice_path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to read pending model migration notice"
);
return None;
}
};
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
Ok(notice) => notice,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to parse pending model migration notice"
);
return None;
}
};
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
&& migration_prompt_hidden(config, migration_config_key)
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
fn finish_with(&mut self, outcome: ModelMigrationOutcome) {
self.outcome = outcome;
self.done = true;
self.request_frame.schedule_frame();
if let Some(seen_target) = config.notices.model_migrations.get(&notice.from_model)
&& seen_target == &notice.to_model
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
fn accept(&mut self) {
self.finish_with(ModelMigrationOutcome::Accepted);
// Best-effort: clear the one-shot file so it doesn't appear again.
let _ = std::fs::remove_file(&notice_path);
config
.notices
.model_migrations
.insert(notice.from_model.clone(), notice.to_model.clone());
Some(notice)
}
pub(crate) fn maybe_schedule_model_migration_notice(
config: &Config,
current_model: &str,
available_models: &[ModelPreset],
) {
let Some(ModelUpgrade {
id: target_model,
migration_config_key,
..
}) = available_models
.iter()
.find(|preset| preset.model == current_model)
.and_then(|preset| preset.upgrade.as_ref())
else {
return;
};
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return;
}
fn reject(&mut self) {
self.finish_with(ModelMigrationOutcome::Rejected);
if available_models
.iter()
.all(|preset| preset.model != target_model.as_str())
{
return;
}
fn exit(&mut self) {
self.finish_with(ModelMigrationOutcome::Exit);
if !should_show_model_migration_notice(
current_model,
target_model.as_str(),
available_models,
config,
) {
return;
}
fn confirm_selection(&mut self) {
if self.copy.can_opt_out {
match self.highlighted_option {
MigrationMenuOption::TryNewModel => self.accept(),
MigrationMenuOption::UseExistingModel => self.reject(),
let notice_path = pending_model_migration_notice_path(config);
if notice_path.exists() {
return;
}
let notice = PendingModelMigrationNotice {
from_model: current_model.to_string(),
to_model: target_model.to_string(),
migration_config_key: Some(migration_config_key.to_string()),
};
let Ok(json_line) = serde_json::to_string(&notice).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(&notice_path, json_line) {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to persist pending model migration notice"
);
}
}
#[cfg(test)]
mod prompt_ui {
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::prelude::Stylize as _;
use ratatui::prelude::Widget;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
/// Outcome of the migration prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ModelMigrationOutcome {
Accepted,
Rejected,
Exit,
}
#[derive(Clone)]
pub(crate) struct ModelMigrationCopy {
pub heading: Vec<Span<'static>>,
pub content: Vec<Line<'static>>,
pub can_opt_out: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MigrationMenuOption {
TryNewModel,
UseExistingModel,
}
impl MigrationMenuOption {
fn all() -> [Self; 2] {
[Self::TryNewModel, Self::UseExistingModel]
}
fn label(self) -> &'static str {
match self {
Self::TryNewModel => "Try new model",
Self::UseExistingModel => "Use existing model",
}
} else {
self.accept();
}
}
fn highlight_option(&mut self, option: MigrationMenuOption) {
if self.highlighted_option != option {
self.highlighted_option = option;
pub(crate) fn migration_copy_for_models(
current_model: &str,
target_model: &str,
model_link: Option<String>,
migration_copy: Option<String>,
target_display_name: String,
target_description: Option<String>,
can_opt_out: bool,
) -> ModelMigrationCopy {
let heading_text = Span::from(format!(
"Codex just got an upgrade. Introducing {target_display_name}."
))
.bold();
let description_line: Line<'static>;
if let Some(migration_copy) = &migration_copy {
description_line = Line::from(migration_copy.clone());
} else {
description_line = target_description
.filter(|desc| !desc.is_empty())
.map(Line::from)
.unwrap_or_else(|| {
Line::from(format!(
"{target_display_name} is recommended for better performance and reliability."
))
});
}
let mut content = vec![];
if migration_copy.is_none() {
content.push(Line::from(format!(
"We recommend switching from {current_model} to {target_model}."
)));
content.push(Line::from(""));
}
if let Some(model_link) = model_link {
content.push(Line::from(vec![
format!("{description_line} Learn more about {target_display_name} at ").into(),
model_link.cyan().underlined(),
]));
content.push(Line::from(""));
} else {
content.push(description_line);
content.push(Line::from(""));
}
if can_opt_out {
content.push(Line::from(format!(
"You can continue using {current_model} if you prefer."
)));
} else {
content.push(Line::from("Press enter to continue".dim()));
}
ModelMigrationCopy {
heading: vec![heading_text],
content,
can_opt_out,
}
}
pub(crate) struct ModelMigrationScreen {
request_frame: FrameRequester,
copy: ModelMigrationCopy,
done: bool,
outcome: ModelMigrationOutcome,
highlighted_option: MigrationMenuOption,
}
impl ModelMigrationScreen {
pub(super) fn new(request_frame: FrameRequester, copy: ModelMigrationCopy) -> Self {
Self {
request_frame,
copy,
done: false,
outcome: ModelMigrationOutcome::Accepted,
highlighted_option: MigrationMenuOption::TryNewModel,
}
}
fn finish_with(&mut self, outcome: ModelMigrationOutcome) {
self.outcome = outcome;
self.done = true;
self.request_frame.schedule_frame();
}
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
fn accept(&mut self) {
self.finish_with(ModelMigrationOutcome::Accepted);
}
if is_ctrl_exit_combo(key_event) {
self.exit();
return;
fn reject(&mut self) {
self.finish_with(ModelMigrationOutcome::Rejected);
}
if self.copy.can_opt_out {
self.handle_menu_key(key_event.code);
} else if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) {
self.accept();
}
}
fn is_done(&self) -> bool {
self.done
}
fn outcome(&self) -> ModelMigrationOutcome {
self.outcome
}
}
impl WidgetRef for &ModelMigrationScreen {
fn render_ref(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push(self.heading_line());
column.push(Line::from(""));
self.render_content(&mut column);
if self.copy.can_opt_out {
self.render_menu(&mut column);
fn exit(&mut self) {
self.finish_with(ModelMigrationOutcome::Exit);
}
column.render(area, buf);
}
}
impl ModelMigrationScreen {
fn handle_menu_key(&mut self, code: KeyCode) {
match code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
}
KeyCode::Char('1') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
fn confirm_selection(&mut self) {
if self.copy.can_opt_out {
match self.highlighted_option {
MigrationMenuOption::TryNewModel => self.accept(),
MigrationMenuOption::UseExistingModel => self.reject(),
}
} else {
self.accept();
}
KeyCode::Char('2') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
self.reject();
}
fn highlight_option(&mut self, option: MigrationMenuOption) {
if self.highlighted_option != option {
self.highlighted_option = option;
self.request_frame.schedule_frame();
}
KeyCode::Enter | KeyCode::Esc => self.confirm_selection(),
_ => {}
}
pub(super) fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if is_ctrl_exit_combo(key_event) {
self.exit();
return;
}
if self.copy.can_opt_out {
self.handle_menu_key(key_event.code);
} else if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) {
self.accept();
}
}
pub(super) fn is_done(&self) -> bool {
self.done
}
pub(super) fn outcome(&self) -> ModelMigrationOutcome {
self.outcome
}
}
fn heading_line(&self) -> Line<'static> {
let mut heading = vec![Span::raw("> ")];
heading.extend(self.copy.heading.iter().cloned());
Line::from(heading)
impl WidgetRef for &ModelMigrationScreen {
fn render_ref(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push(self.heading_line());
column.push(Line::from(""));
self.render_content(&mut column);
if self.copy.can_opt_out {
self.render_menu(&mut column);
}
column.render(area, buf);
}
}
fn render_content(&self, column: &mut ColumnRenderable) {
self.render_lines(&self.copy.content, column);
}
impl ModelMigrationScreen {
fn handle_menu_key(&mut self, code: KeyCode) {
match code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
}
KeyCode::Char('1') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
self.accept();
}
KeyCode::Char('2') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
self.reject();
}
KeyCode::Enter | KeyCode::Esc => self.confirm_selection(),
_ => {}
}
}
fn render_lines(&self, lines: &[Line<'static>], column: &mut ColumnRenderable) {
for line in lines {
fn heading_line(&self) -> Line<'static> {
let mut heading = vec![Span::raw("> ")];
heading.extend(self.copy.heading.iter().cloned());
Line::from(heading)
}
fn render_content(&self, column: &mut ColumnRenderable) {
self.render_lines(&self.copy.content, column);
}
fn render_lines(&self, lines: &[Line<'static>], column: &mut ColumnRenderable) {
for line in lines {
column.push(
Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
}
}
fn render_menu(&self, column: &mut ColumnRenderable) {
column.push(Line::from(""));
column.push(
Paragraph::new(line.clone())
Paragraph::new("Choose how you'd like Codex to proceed.")
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push(Line::from(""));
for (idx, option) in MigrationMenuOption::all().into_iter().enumerate() {
column.push(selection_option_row(
idx,
option.label().to_string(),
self.highlighted_option == option,
));
}
column.push(Line::from(""));
column.push(
Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
}
}
fn render_menu(&self, column: &mut ColumnRenderable) {
column.push(Line::from(""));
column.push(
Paragraph::new("Choose how you'd like Codex to proceed.")
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push(Line::from(""));
for (idx, option) in MigrationMenuOption::all().into_iter().enumerate() {
column.push(selection_option_row(
idx,
option.label().to_string(),
self.highlighted_option == option,
));
}
column.push(Line::from(""));
column.push(
Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
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'))
}
}
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'))
}
#[cfg(test)]
mod tests {
use super::ModelMigrationScreen;
use super::migration_copy_for_models;
use super::prompt_ui::ModelMigrationOutcome;
use super::prompt_ui::ModelMigrationScreen;
use super::prompt_ui::migration_copy_for_models;
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
@@ -436,10 +637,7 @@ mod tests {
));
assert!(screen.is_done());
// Esc should not be treated as Exit – it accepts like Enter.
assert!(matches!(
screen.outcome(),
super::ModelMigrationOutcome::Accepted
));
assert!(matches!(screen.outcome(), ModelMigrationOutcome::Accepted));
}
#[test]
@@ -467,9 +665,6 @@ mod tests {
));
assert!(screen.is_done());
assert!(matches!(
screen.outcome(),
super::ModelMigrationOutcome::Rejected
));
assert!(matches!(screen.outcome(), ModelMigrationOutcome::Rejected));
}
}
-196
View File
@@ -1,196 +0,0 @@
use codex_core::config::Config;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::path::PathBuf;
const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PendingModelMigrationNotice {
pub(crate) from_model: String,
pub(crate) to_model: String,
// Used to respect hide flags even if config changes between scheduling and display.
#[serde(default)]
pub(crate) migration_config_key: Option<String>,
}
fn pending_model_migration_notice_path(config: &Config) -> PathBuf {
config
.codex_home
.join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME)
}
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
fn should_show_model_migration_notice(
current_model: &str,
target_model: &str,
available_models: &[ModelPreset],
config: &Config,
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = config.notices.model_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
}
/// Read and clear the one-shot migration notice file, returning the notice if it should be shown.
///
/// If the notice is returned, this also updates `config.notices.model_migrations` to prevent
/// re-scheduling within the current process.
pub(crate) fn take_pending_model_migration_notice(
config: &mut Config,
) -> Option<PendingModelMigrationNotice> {
let notice_path = pending_model_migration_notice_path(config);
let contents = match std::fs::read_to_string(&notice_path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to read pending model migration notice"
);
return None;
}
};
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
Ok(notice) => notice,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to parse pending model migration notice"
);
return None;
}
};
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
&& migration_prompt_hidden(config, migration_config_key)
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
if let Some(seen_target) = config.notices.model_migrations.get(&notice.from_model)
&& seen_target == &notice.to_model
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
// Best-effort: clear the one-shot file so it doesn't appear again.
let _ = std::fs::remove_file(&notice_path);
config
.notices
.model_migrations
.insert(notice.from_model.clone(), notice.to_model.clone());
Some(notice)
}
pub(crate) fn maybe_schedule_model_migration_notice(
config: &Config,
current_model: &str,
available_models: &[ModelPreset],
) {
let Some(ModelUpgrade {
id: target_model,
migration_config_key,
..
}) = available_models
.iter()
.find(|preset| preset.model == current_model)
.and_then(|preset| preset.upgrade.as_ref())
else {
return;
};
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return;
}
if available_models
.iter()
.all(|preset| preset.model != target_model.as_str())
{
return;
}
if !should_show_model_migration_notice(
current_model,
target_model.as_str(),
available_models,
config,
) {
return;
}
let notice_path = pending_model_migration_notice_path(config);
if notice_path.exists() {
return;
}
let notice = PendingModelMigrationNotice {
from_model: current_model.to_string(),
to_model: target_model.to_string(),
migration_config_key: Some(migration_config_key.to_string()),
};
let Ok(json_line) = serde_json::to_string(&notice).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(&notice_path, json_line) {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to persist pending model migration notice"
);
}
}
+3 -4
View File
@@ -142,7 +142,7 @@ 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";
use crate::model_migration_logic;
use crate::model_migration;
// Track information about an in-flight exec command.
struct RunningCommand {
@@ -375,8 +375,7 @@ impl ChatWidget {
}
fn maybe_show_pending_model_migration_notice(&mut self) {
let Some(notice) =
model_migration_logic::take_pending_model_migration_notice(&mut self.config)
let Some(notice) = model_migration::take_pending_model_migration_notice(&mut self.config)
else {
return;
};
@@ -401,7 +400,7 @@ impl ChatWidget {
Err(_) => return,
};
model_migration_logic::maybe_schedule_model_migration_notice(
model_migration::maybe_schedule_model_migration_notice(
&self.config,
model,
&available_models,
-2
View File
@@ -58,9 +58,7 @@ pub mod live_wrap;
mod markdown;
mod markdown_render;
mod markdown_stream;
#[cfg(test)]
mod model_migration;
mod model_migration_logic;
mod notifications;
pub mod onboarding;
mod oss_selection;
+419 -224
View File
@@ -1,280 +1,481 @@
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::prelude::Stylize as _;
use ratatui::prelude::Widget;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_core::config::Config;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::path::PathBuf;
/// Outcome of the migration prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ModelMigrationOutcome {
Accepted,
Rejected,
Exit,
const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PendingModelMigrationNotice {
pub(crate) from_model: String,
pub(crate) to_model: String,
// Used to respect hide flags even if config changes between scheduling and display.
#[serde(default)]
pub(crate) migration_config_key: Option<String>,
}
#[derive(Clone)]
pub(crate) struct ModelMigrationCopy {
pub heading: Vec<Span<'static>>,
pub content: Vec<Line<'static>>,
pub can_opt_out: bool,
fn pending_model_migration_notice_path(config: &Config) -> PathBuf {
config
.codex_home
.join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MigrationMenuOption {
TryNewModel,
UseExistingModel,
}
impl MigrationMenuOption {
fn all() -> [Self; 2] {
[Self::TryNewModel, Self::UseExistingModel]
}
fn label(self) -> &'static str {
match self {
Self::TryNewModel => "Try new model",
Self::UseExistingModel => "Use existing model",
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
pub(crate) fn migration_copy_for_models(
fn should_show_model_migration_notice(
current_model: &str,
target_model: &str,
target_display_name: String,
target_description: Option<String>,
can_opt_out: bool,
) -> ModelMigrationCopy {
let heading_text = Span::from(format!("Try {target_display_name}")).bold();
let description_line = target_description
.filter(|desc| !desc.is_empty())
.map(Line::from)
.unwrap_or_else(|| {
Line::from(format!(
"{target_display_name} is recommended for better performance and reliability."
))
});
let mut content = vec![
Line::from(format!(
"We recommend switching from {current_model} to {target_model}."
)),
Line::from(""),
description_line,
Line::from(""),
];
if can_opt_out {
content.push(Line::from(format!(
"You can continue using {current_model} if you prefer."
)));
} else {
content.push(Line::from("Press enter to continue".dim()));
available_models: &[ModelPreset],
config: &Config,
) -> bool {
if target_model == current_model {
return false;
}
ModelMigrationCopy {
heading: vec![heading_text],
content,
can_opt_out,
if let Some(seen_target) = config.notices.model_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
}
struct ModelMigrationScreen {
request_frame: FrameRequester,
copy: ModelMigrationCopy,
done: bool,
outcome: ModelMigrationOutcome,
highlighted_option: MigrationMenuOption,
}
impl ModelMigrationScreen {
fn new(request_frame: FrameRequester, copy: ModelMigrationCopy) -> Self {
Self {
request_frame,
copy,
done: false,
outcome: ModelMigrationOutcome::Accepted,
highlighted_option: MigrationMenuOption::TryNewModel,
/// Read and clear the one-shot migration notice file, returning the notice if it should be shown.
///
/// If the notice is returned, this also updates `config.notices.model_migrations` to prevent
/// re-scheduling within the current process.
pub(crate) fn take_pending_model_migration_notice(
config: &mut Config,
) -> Option<PendingModelMigrationNotice> {
let notice_path = pending_model_migration_notice_path(config);
let contents = match std::fs::read_to_string(&notice_path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to read pending model migration notice"
);
return None;
}
};
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
Ok(notice) => notice,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to parse pending model migration notice"
);
return None;
}
};
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
&& migration_prompt_hidden(config, migration_config_key)
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
fn finish_with(&mut self, outcome: ModelMigrationOutcome) {
self.outcome = outcome;
self.done = true;
self.request_frame.schedule_frame();
if let Some(seen_target) = config.notices.model_migrations.get(&notice.from_model)
&& seen_target == &notice.to_model
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
fn accept(&mut self) {
self.finish_with(ModelMigrationOutcome::Accepted);
// Best-effort: clear the one-shot file so it doesn't appear again.
let _ = std::fs::remove_file(&notice_path);
config
.notices
.model_migrations
.insert(notice.from_model.clone(), notice.to_model.clone());
Some(notice)
}
pub(crate) fn maybe_schedule_model_migration_notice(
config: &Config,
current_model: &str,
available_models: &[ModelPreset],
) {
let Some(ModelUpgrade {
id: target_model,
migration_config_key,
..
}) = available_models
.iter()
.find(|preset| preset.model == current_model)
.and_then(|preset| preset.upgrade.as_ref())
else {
return;
};
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return;
}
fn reject(&mut self) {
self.finish_with(ModelMigrationOutcome::Rejected);
if available_models
.iter()
.all(|preset| preset.model != target_model.as_str())
{
return;
}
fn exit(&mut self) {
self.finish_with(ModelMigrationOutcome::Exit);
if !should_show_model_migration_notice(
current_model,
target_model.as_str(),
available_models,
config,
) {
return;
}
fn confirm_selection(&mut self) {
if self.copy.can_opt_out {
match self.highlighted_option {
MigrationMenuOption::TryNewModel => self.accept(),
MigrationMenuOption::UseExistingModel => self.reject(),
let notice_path = pending_model_migration_notice_path(config);
if notice_path.exists() {
return;
}
let notice = PendingModelMigrationNotice {
from_model: current_model.to_string(),
to_model: target_model.to_string(),
migration_config_key: Some(migration_config_key.to_string()),
};
let Ok(json_line) = serde_json::to_string(&notice).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(&notice_path, json_line) {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to persist pending model migration notice"
);
}
}
#[cfg(test)]
mod prompt_ui {
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::prelude::Stylize as _;
use ratatui::prelude::Widget;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
/// Outcome of the migration prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ModelMigrationOutcome {
Accepted,
Rejected,
Exit,
}
#[derive(Clone)]
pub(crate) struct ModelMigrationCopy {
pub heading: Vec<Span<'static>>,
pub content: Vec<Line<'static>>,
pub can_opt_out: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MigrationMenuOption {
TryNewModel,
UseExistingModel,
}
impl MigrationMenuOption {
fn all() -> [Self; 2] {
[Self::TryNewModel, Self::UseExistingModel]
}
fn label(self) -> &'static str {
match self {
Self::TryNewModel => "Try new model",
Self::UseExistingModel => "Use existing model",
}
} else {
self.accept();
}
}
fn highlight_option(&mut self, option: MigrationMenuOption) {
if self.highlighted_option != option {
self.highlighted_option = option;
pub(crate) fn migration_copy_for_models(
current_model: &str,
target_model: &str,
target_display_name: String,
target_description: Option<String>,
can_opt_out: bool,
) -> ModelMigrationCopy {
let heading_text = Span::from(format!("Try {target_display_name}")).bold();
let description_line = target_description
.filter(|desc| !desc.is_empty())
.map(Line::from)
.unwrap_or_else(|| {
Line::from(format!(
"{target_display_name} is recommended for better performance and reliability."
))
});
let mut content = vec![
Line::from(format!(
"We recommend switching from {current_model} to {target_model}."
)),
Line::from(""),
description_line,
Line::from(""),
];
if can_opt_out {
content.push(Line::from(format!(
"You can continue using {current_model} if you prefer."
)));
} else {
content.push(Line::from("Press enter to continue".dim()));
}
ModelMigrationCopy {
heading: vec![heading_text],
content,
can_opt_out,
}
}
pub(crate) struct ModelMigrationScreen {
request_frame: FrameRequester,
copy: ModelMigrationCopy,
done: bool,
outcome: ModelMigrationOutcome,
highlighted_option: MigrationMenuOption,
}
impl ModelMigrationScreen {
pub(super) fn new(request_frame: FrameRequester, copy: ModelMigrationCopy) -> Self {
Self {
request_frame,
copy,
done: false,
outcome: ModelMigrationOutcome::Accepted,
highlighted_option: MigrationMenuOption::TryNewModel,
}
}
fn finish_with(&mut self, outcome: ModelMigrationOutcome) {
self.outcome = outcome;
self.done = true;
self.request_frame.schedule_frame();
}
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
fn accept(&mut self) {
self.finish_with(ModelMigrationOutcome::Accepted);
}
if is_ctrl_exit_combo(key_event) {
self.exit();
return;
fn reject(&mut self) {
self.finish_with(ModelMigrationOutcome::Rejected);
}
if self.copy.can_opt_out {
self.handle_menu_key(key_event.code);
} else if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) {
self.accept();
}
}
fn is_done(&self) -> bool {
self.done
}
fn outcome(&self) -> ModelMigrationOutcome {
self.outcome
}
}
impl WidgetRef for &ModelMigrationScreen {
fn render_ref(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push(self.heading_line());
column.push(Line::from(""));
self.render_content(&mut column);
if self.copy.can_opt_out {
self.render_menu(&mut column);
fn exit(&mut self) {
self.finish_with(ModelMigrationOutcome::Exit);
}
column.render(area, buf);
}
}
impl ModelMigrationScreen {
fn handle_menu_key(&mut self, code: KeyCode) {
match code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
}
KeyCode::Char('1') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
fn confirm_selection(&mut self) {
if self.copy.can_opt_out {
match self.highlighted_option {
MigrationMenuOption::TryNewModel => self.accept(),
MigrationMenuOption::UseExistingModel => self.reject(),
}
} else {
self.accept();
}
KeyCode::Char('2') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
self.reject();
}
fn highlight_option(&mut self, option: MigrationMenuOption) {
if self.highlighted_option != option {
self.highlighted_option = option;
self.request_frame.schedule_frame();
}
KeyCode::Enter | KeyCode::Esc => self.confirm_selection(),
_ => {}
}
pub(super) fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if is_ctrl_exit_combo(key_event) {
self.exit();
return;
}
if self.copy.can_opt_out {
self.handle_menu_key(key_event.code);
} else if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) {
self.accept();
}
}
pub(super) fn is_done(&self) -> bool {
self.done
}
pub(super) fn outcome(&self) -> ModelMigrationOutcome {
self.outcome
}
}
fn heading_line(&self) -> Line<'static> {
let mut heading = vec![Span::raw("> ")];
heading.extend(self.copy.heading.iter().cloned());
Line::from(heading)
impl WidgetRef for &ModelMigrationScreen {
fn render_ref(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push(self.heading_line());
column.push(Line::from(""));
self.render_content(&mut column);
if self.copy.can_opt_out {
self.render_menu(&mut column);
}
column.render(area, buf);
}
}
fn render_content(&self, column: &mut ColumnRenderable) {
self.render_lines(&self.copy.content, column);
}
impl ModelMigrationScreen {
fn handle_menu_key(&mut self, code: KeyCode) {
match code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
}
KeyCode::Char('1') => {
self.highlight_option(MigrationMenuOption::TryNewModel);
self.accept();
}
KeyCode::Char('2') => {
self.highlight_option(MigrationMenuOption::UseExistingModel);
self.reject();
}
KeyCode::Enter | KeyCode::Esc => self.confirm_selection(),
_ => {}
}
}
fn render_lines(&self, lines: &[Line<'static>], column: &mut ColumnRenderable) {
for line in lines {
fn heading_line(&self) -> Line<'static> {
let mut heading = vec![Span::raw("> ")];
heading.extend(self.copy.heading.iter().cloned());
Line::from(heading)
}
fn render_content(&self, column: &mut ColumnRenderable) {
self.render_lines(&self.copy.content, column);
}
fn render_lines(&self, lines: &[Line<'static>], column: &mut ColumnRenderable) {
for line in lines {
column.push(
Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
}
}
fn render_menu(&self, column: &mut ColumnRenderable) {
column.push(Line::from(""));
column.push(
Paragraph::new(line.clone())
Paragraph::new("Choose how you'd like Codex to proceed.")
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push(Line::from(""));
for (idx, option) in MigrationMenuOption::all().into_iter().enumerate() {
column.push(selection_option_row(
idx,
option.label().to_string(),
self.highlighted_option == option,
));
}
column.push(Line::from(""));
column.push(
Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
}
}
fn render_menu(&self, column: &mut ColumnRenderable) {
column.push(Line::from(""));
column.push(
Paragraph::new("Choose how you'd like Codex to proceed.")
.wrap(Wrap { trim: false })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push(Line::from(""));
for (idx, option) in MigrationMenuOption::all().into_iter().enumerate() {
column.push(selection_option_row(
idx,
option.label().to_string(),
self.highlighted_option == option,
));
}
column.push(Line::from(""));
column.push(
Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
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'))
}
}
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'))
}
#[cfg(test)]
mod tests {
use super::ModelMigrationScreen;
use super::migration_copy_for_models;
use super::prompt_ui::ModelMigrationOutcome;
use super::prompt_ui::ModelMigrationScreen;
use super::prompt_ui::migration_copy_for_models;
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
@@ -403,10 +604,7 @@ mod tests {
));
assert!(screen.is_done());
// Esc should not be treated as Exit – it accepts like Enter.
assert!(matches!(
screen.outcome(),
super::ModelMigrationOutcome::Accepted
));
assert!(matches!(screen.outcome(), ModelMigrationOutcome::Accepted));
}
#[test]
@@ -432,9 +630,6 @@ mod tests {
));
assert!(screen.is_done());
assert!(matches!(
screen.outcome(),
super::ModelMigrationOutcome::Rejected
));
assert!(matches!(screen.outcome(), ModelMigrationOutcome::Rejected));
}
}
-196
View File
@@ -1,196 +0,0 @@
use codex_core::config::Config;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::path::PathBuf;
const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PendingModelMigrationNotice {
pub(crate) from_model: String,
pub(crate) to_model: String,
// Used to respect hide flags even if config changes between scheduling and display.
#[serde(default)]
pub(crate) migration_config_key: Option<String>,
}
fn pending_model_migration_notice_path(config: &Config) -> PathBuf {
config
.codex_home
.join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME)
}
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
fn should_show_model_migration_notice(
current_model: &str,
target_model: &str,
available_models: &[ModelPreset],
config: &Config,
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = config.notices.model_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
}
/// Read and clear the one-shot migration notice file, returning the notice if it should be shown.
///
/// If the notice is returned, this also updates `config.notices.model_migrations` to prevent
/// re-scheduling within the current process.
pub(crate) fn take_pending_model_migration_notice(
config: &mut Config,
) -> Option<PendingModelMigrationNotice> {
let notice_path = pending_model_migration_notice_path(config);
let contents = match std::fs::read_to_string(&notice_path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to read pending model migration notice"
);
return None;
}
};
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
Ok(notice) => notice,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to parse pending model migration notice"
);
return None;
}
};
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
&& migration_prompt_hidden(config, migration_config_key)
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
if let Some(seen_target) = config.notices.model_migrations.get(&notice.from_model)
&& seen_target == &notice.to_model
{
let _ = std::fs::remove_file(&notice_path);
return None;
}
// Best-effort: clear the one-shot file so it doesn't appear again.
let _ = std::fs::remove_file(&notice_path);
config
.notices
.model_migrations
.insert(notice.from_model.clone(), notice.to_model.clone());
Some(notice)
}
pub(crate) fn maybe_schedule_model_migration_notice(
config: &Config,
current_model: &str,
available_models: &[ModelPreset],
) {
let Some(ModelUpgrade {
id: target_model,
migration_config_key,
..
}) = available_models
.iter()
.find(|preset| preset.model == current_model)
.and_then(|preset| preset.upgrade.as_ref())
else {
return;
};
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return;
}
if available_models
.iter()
.all(|preset| preset.model != target_model.as_str())
{
return;
}
if !should_show_model_migration_notice(
current_model,
target_model.as_str(),
available_models,
config,
) {
return;
}
let notice_path = pending_model_migration_notice_path(config);
if notice_path.exists() {
return;
}
let notice = PendingModelMigrationNotice {
from_model: current_model.to_string(),
to_model: target_model.to_string(),
migration_config_key: Some(migration_config_key.to_string()),
};
let Ok(json_line) = serde_json::to_string(&notice).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(&notice_path, json_line) {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to persist pending model migration notice"
);
}
}