mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
files
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(¬ice_path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to read pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
|
||||
Ok(notice) => notice,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to parse pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
|
||||
&& migration_prompt_hidden(config, migration_config_key)
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
return None;
|
||||
}
|
||||
|
||||
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(¬ice.from_model)
|
||||
&& seen_target == ¬ice.to_model
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_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(¬ice_path);
|
||||
|
||||
config
|
||||
.notices
|
||||
.model_migrations
|
||||
.insert(notice.from_model.clone(), notice.to_model.clone());
|
||||
|
||||
Some(notice)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_schedule_model_migration_notice(
|
||||
config: &Config,
|
||||
current_model: &str,
|
||||
available_models: &[ModelPreset],
|
||||
) {
|
||||
let Some(ModelUpgrade {
|
||||
id: target_model,
|
||||
migration_config_key,
|
||||
..
|
||||
}) = available_models
|
||||
.iter()
|
||||
.find(|preset| preset.model == current_model)
|
||||
.and_then(|preset| preset.upgrade.as_ref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(¬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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ice_path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to read pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
|
||||
Ok(notice) => notice,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to parse pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
|
||||
&& migration_prompt_hidden(config, migration_config_key)
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(seen_target) = config.notices.model_migrations.get(¬ice.from_model)
|
||||
&& seen_target == ¬ice.to_model
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Best-effort: clear the one-shot file so it doesn't appear again.
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
|
||||
config
|
||||
.notices
|
||||
.model_migrations
|
||||
.insert(notice.from_model.clone(), notice.to_model.clone());
|
||||
|
||||
Some(notice)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_schedule_model_migration_notice(
|
||||
config: &Config,
|
||||
current_model: &str,
|
||||
available_models: &[ModelPreset],
|
||||
) {
|
||||
let Some(ModelUpgrade {
|
||||
id: target_model,
|
||||
migration_config_key,
|
||||
..
|
||||
}) = available_models
|
||||
.iter()
|
||||
.find(|preset| preset.model == current_model)
|
||||
.and_then(|preset| preset.upgrade.as_ref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if available_models
|
||||
.iter()
|
||||
.all(|preset| preset.model != target_model.as_str())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if !should_show_model_migration_notice(
|
||||
current_model,
|
||||
target_model.as_str(),
|
||||
available_models,
|
||||
config,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let notice_path = pending_model_migration_notice_path(config);
|
||||
if notice_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let notice = PendingModelMigrationNotice {
|
||||
from_model: current_model.to_string(),
|
||||
to_model: target_model.to_string(),
|
||||
migration_config_key: Some(migration_config_key.to_string()),
|
||||
};
|
||||
let Ok(json_line) = serde_json::to_string(¬ice).map(|json| format!("{json}\n")) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(parent) = notice_path.parent()
|
||||
&& let Err(err) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to create directory for pending model migration notice"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = std::fs::write(¬ice_path, json_line) {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to persist pending model migration notice"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¬ice_path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to read pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
|
||||
Ok(notice) => notice,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to parse pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
|
||||
&& migration_prompt_hidden(config, migration_config_key)
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
return None;
|
||||
}
|
||||
|
||||
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(¬ice.from_model)
|
||||
&& seen_target == ¬ice.to_model
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_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(¬ice_path);
|
||||
|
||||
config
|
||||
.notices
|
||||
.model_migrations
|
||||
.insert(notice.from_model.clone(), notice.to_model.clone());
|
||||
|
||||
Some(notice)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_schedule_model_migration_notice(
|
||||
config: &Config,
|
||||
current_model: &str,
|
||||
available_models: &[ModelPreset],
|
||||
) {
|
||||
let Some(ModelUpgrade {
|
||||
id: target_model,
|
||||
migration_config_key,
|
||||
..
|
||||
}) = available_models
|
||||
.iter()
|
||||
.find(|preset| preset.model == current_model)
|
||||
.and_then(|preset| preset.upgrade.as_ref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(¬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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬ice_path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to read pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
|
||||
Ok(notice) => notice,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to parse pending model migration notice"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
|
||||
&& migration_prompt_hidden(config, migration_config_key)
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(seen_target) = config.notices.model_migrations.get(¬ice.from_model)
|
||||
&& seen_target == ¬ice.to_model
|
||||
{
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Best-effort: clear the one-shot file so it doesn't appear again.
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
|
||||
config
|
||||
.notices
|
||||
.model_migrations
|
||||
.insert(notice.from_model.clone(), notice.to_model.clone());
|
||||
|
||||
Some(notice)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_schedule_model_migration_notice(
|
||||
config: &Config,
|
||||
current_model: &str,
|
||||
available_models: &[ModelPreset],
|
||||
) {
|
||||
let Some(ModelUpgrade {
|
||||
id: target_model,
|
||||
migration_config_key,
|
||||
..
|
||||
}) = available_models
|
||||
.iter()
|
||||
.find(|preset| preset.model == current_model)
|
||||
.and_then(|preset| preset.upgrade.as_ref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if available_models
|
||||
.iter()
|
||||
.all(|preset| preset.model != target_model.as_str())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if !should_show_model_migration_notice(
|
||||
current_model,
|
||||
target_model.as_str(),
|
||||
available_models,
|
||||
config,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let notice_path = pending_model_migration_notice_path(config);
|
||||
if notice_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let notice = PendingModelMigrationNotice {
|
||||
from_model: current_model.to_string(),
|
||||
to_model: target_model.to_string(),
|
||||
migration_config_key: Some(migration_config_key.to_string()),
|
||||
};
|
||||
let Ok(json_line) = serde_json::to_string(¬ice).map(|json| format!("{json}\n")) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(parent) = notice_path.parent()
|
||||
&& let Err(err) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to create directory for pending model migration notice"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = std::fs::write(¬ice_path, json_line) {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
notice_path = %notice_path.display(),
|
||||
"failed to persist pending model migration notice"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user