This commit is contained in:
Ahmed Ibrahim
2026-01-03 22:12:55 -08:00
parent 82fcc087b5
commit 9cd4d895ba
11 changed files with 515 additions and 649 deletions
+22 -4
View File
@@ -10,6 +10,7 @@ use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::sync::RwLock;
use tokio::sync::TryLockError;
use tracing::error;
@@ -39,6 +40,7 @@ pub struct ModelsManager {
// todo(aibrahim) merge available_models and model family creation into one struct
local_models: Vec<ModelPreset>,
remote_models: RwLock<Vec<ModelInfo>>,
refresh_lock: Mutex<()>,
auth_manager: Arc<AuthManager>,
etag: RwLock<Option<String>>,
codex_home: PathBuf,
@@ -53,6 +55,7 @@ impl ModelsManager {
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
refresh_lock: Mutex::new(()),
auth_manager,
etag: RwLock::new(None),
codex_home,
@@ -68,6 +71,7 @@ impl ModelsManager {
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
refresh_lock: Mutex::new(()),
auth_manager,
etag: RwLock::new(None),
codex_home,
@@ -93,6 +97,7 @@ impl ModelsManager {
if self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) {
return Ok(());
}
let _refresh_guard = self.refresh_lock.lock().await;
let auth = self.auth_manager.auth();
let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?;
@@ -100,10 +105,23 @@ impl ModelsManager {
let client = ModelsClient::new(transport, api_provider, api_auth);
let client_version = format_client_version_to_whole();
let (models, etag) = client
.list_models(&client_version, HeaderMap::new())
.await
.map_err(map_api_error)?;
let remote_models = tokio::time::timeout(
Duration::from_secs(5),
client.list_models(&client_version, HeaderMap::new()),
)
.await;
let (models, etag) = match remote_models {
Ok(Ok((models, etag))) => (models, etag),
Ok(Err(err)) => {
error!("failed to refresh remote models: {}", map_api_error(err));
return Ok(());
}
Err(_) => {
error!("timed out refreshing remote models after 5s");
return Ok(());
}
};
self.apply_remote_models(models.clone()).await;
*self.etag.write().await = etag.clone();
+6 -281
View File
@@ -10,9 +10,6 @@ use crate::external_editor;
use crate::file_search::FileSearchManager;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::model_migration::ModelMigrationOutcome;
use crate::model_migration::migration_copy_for_models;
use crate::model_migration::run_model_migration_prompt;
use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
@@ -28,9 +25,6 @@ use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::protocol::EventMsg;
use codex_core::protocol::FinalOutput;
use codex_core::protocol::ListSkillsResponseEvent;
@@ -39,8 +33,6 @@ use codex_core::protocol::SessionSource;
use codex_core::protocol::SkillErrorInfo;
use codex_core::protocol::TokenUsage;
use codex_protocol::ConversationId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
@@ -51,7 +43,6 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -127,164 +118,6 @@ struct SessionSummary {
resume_command: Option<String>,
}
fn should_show_model_migration_prompt(
current_model: &str,
target_model: &str,
seen_migrations: &BTreeMap<String, String>,
available_models: &[ModelPreset],
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = seen_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
if available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
{
return true;
}
false
}
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
fn target_preset_for_upgrade<'a>(
available_models: &'a [ModelPreset],
target_model: &str,
) -> Option<&'a ModelPreset> {
available_models
.iter()
.find(|preset| preset.model == target_model)
}
async fn handle_model_migration_prompt_if_needed(
tui: &mut tui::Tui,
config: &mut Config,
model: &str,
app_event_tx: &AppEventSender,
models_manager: Arc<ModelsManager>,
) -> Option<AppExitInfo> {
let available_models = models_manager.list_models(config).await;
let upgrade = available_models
.iter()
.find(|preset| preset.model == model)
.and_then(|preset| preset.upgrade.as_ref());
if let Some(ModelUpgrade {
id: target_model,
reasoning_effort_mapping,
migration_config_key,
model_link,
upgrade_copy,
}) = upgrade
{
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return None;
}
let target_model = target_model.to_string();
if !should_show_model_migration_prompt(
model,
&target_model,
&config.notices.model_migrations,
&available_models,
) {
return None;
}
let current_preset = available_models.iter().find(|preset| preset.model == model);
let target_preset = target_preset_for_upgrade(&available_models, &target_model);
let target_preset = target_preset?;
let target_display_name = target_preset.display_name.clone();
let heading_label = if target_display_name == model {
target_model.clone()
} else {
target_display_name.clone()
};
let target_description =
(!target_preset.description.is_empty()).then(|| target_preset.description.clone());
let can_opt_out = current_preset.is_some();
let prompt_copy = migration_copy_for_models(
model,
&target_model,
model_link.clone(),
upgrade_copy.clone(),
heading_label,
target_description,
can_opt_out,
);
match run_model_migration_prompt(tui, prompt_copy).await {
ModelMigrationOutcome::Accepted => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
config.model = Some(target_model.clone());
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
&& let Some(reasoning_effort) = config.model_reasoning_effort
{
reasoning_effort_mapping
.get(&reasoning_effort)
.cloned()
.or(config.model_reasoning_effort)
} else {
config.model_reasoning_effort
};
config.model_reasoning_effort = mapped_effort;
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
app_event_tx.send(AppEvent::PersistModelSelection {
model: target_model.clone(),
effort: mapped_effort,
});
}
ModelMigrationOutcome::Rejected => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
}
ModelMigrationOutcome::Exit => {
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
update_action: None,
});
}
}
}
None
}
pub(crate) struct App {
pub(crate) server: Arc<ConversationManager>,
pub(crate) app_event_tx: AppEventSender,
@@ -336,7 +169,7 @@ impl App {
pub async fn run(
tui: &mut tui::Tui,
auth_manager: Arc<AuthManager>,
mut config: Config,
config: Config,
active_profile: Option<String>,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -352,24 +185,6 @@ impl App {
auth_manager.clone(),
SessionSource::Cli,
));
let mut model = conversation_manager
.get_models_manager()
.get_model(&config.model, &config)
.await;
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
model.as_str(),
&app_event_tx,
conversation_manager.get_models_manager(),
)
.await;
if let Some(exit_info) = exit_info {
return Ok(exit_info);
}
if let Some(updated_model) = config.model.clone() {
model = updated_model;
}
let enhanced_keys_supported = tui.enhanced_keys_supported();
let mut chat_widget = match resume_selection {
@@ -385,7 +200,8 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
// The only truthful model is the one we get back on SessionConfigured.
model: String::new(),
};
ChatWidget::new(init, conversation_manager.clone())
}
@@ -411,7 +227,8 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
// The only truthful model is the one we get back on SessionConfigured.
model: String::new(),
};
ChatWidget::new_from_existing(
init,
@@ -433,7 +250,7 @@ impl App {
chat_widget,
auth_manager: auth_manager.clone(),
config,
current_model: model.clone(),
current_model: String::new(),
active_profile,
file_search,
enhanced_keys_supported,
@@ -555,11 +372,6 @@ impl App {
}
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
let model_family = self
.server
.get_models_manager()
.construct_model_family(self.current_model.as_str(), &self.config)
.await;
match event {
AppEvent::NewSession => {
let summary = session_summary(
@@ -581,7 +393,6 @@ impl App {
model: self.current_model.clone(),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.current_model = model_family.get_model_slug().to_string();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
@@ -635,7 +446,6 @@ impl App {
resumed.conversation,
resumed.session_configured,
);
self.current_model = model_family.get_model_slug().to_string();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
@@ -1423,91 +1233,6 @@ mod tests {
)
}
fn all_model_presets() -> Vec<ModelPreset> {
codex_core::models_manager::model_presets::all_model_presets().clone()
}
#[tokio::test]
async fn model_migration_prompt_only_shows_for_deprecated_models() {
let seen = BTreeMap::new();
assert!(should_show_model_migration_prompt(
"gpt-5",
"gpt-5.1",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5-codex",
"gpt-5.1-codex",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5-codex-mini",
"gpt-5.1-codex-mini",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5.1-codex",
"gpt-5.1-codex-max",
&seen,
&all_model_presets()
));
assert!(!should_show_model_migration_prompt(
"gpt-5.1-codex",
"gpt-5.1-codex",
&seen,
&all_model_presets()
));
}
#[tokio::test]
async fn model_migration_prompt_respects_hide_flag_and_self_target() {
let mut seen = BTreeMap::new();
seen.insert("gpt-5".to_string(), "gpt-5.1".to_string());
assert!(!should_show_model_migration_prompt(
"gpt-5",
"gpt-5.1",
&seen,
&all_model_presets()
));
assert!(!should_show_model_migration_prompt(
"gpt-5.1",
"gpt-5.1",
&seen,
&all_model_presets()
));
}
#[tokio::test]
async fn model_migration_prompt_skips_when_target_missing() {
let mut available = all_model_presets();
let mut current = available
.iter()
.find(|preset| preset.model == "gpt-5-codex")
.cloned()
.expect("preset present");
current.upgrade = Some(ModelUpgrade {
id: "missing-target".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
model_link: None,
upgrade_copy: None,
});
available.retain(|preset| preset.model != "gpt-5-codex");
available.push(current.clone());
assert!(should_show_model_migration_prompt(
&current.model,
"missing-target",
&BTreeMap::new(),
&available,
));
assert!(target_preset_for_upgrade(&available, "missing-target").is_none());
}
#[tokio::test]
async fn update_reasoning_effort_updates_config() {
let mut app = make_test_app().await;
+237 -3
View File
@@ -1,6 +1,8 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -15,6 +17,8 @@ use codex_core::features::Feature;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -78,6 +82,9 @@ use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Line;
use serde::Deserialize;
use serde::Serialize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use tokio::sync::mpsc::UnboundedSender;
@@ -141,12 +148,23 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_file_search::FileMatch;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::UpdatePlanArgs;
use strum::IntoEnumIterator;
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingModelMigrationNotice {
from_model: String,
to_model: String,
// Used to respect hide flags even if config changes between scheduling and display.
#[serde(default)]
migration_config_key: Option<String>,
}
// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec<String>,
@@ -402,6 +420,199 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget {
fn is_session_configured(&self) -> bool {
self.conversation_id.is_some()
}
fn pending_model_migration_notice_path(&self) -> PathBuf {
self.config
.codex_home
.join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME)
}
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
fn should_show_model_migration_notice(
current_model: &str,
target_model: &str,
seen_migrations: &BTreeMap<String, String>,
available_models: &[ModelPreset],
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = seen_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
if available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
{
return true;
}
false
}
fn maybe_show_pending_model_migration_notice(&mut self) {
let notice_path = self.pending_model_migration_notice_path();
let contents = match std::fs::read_to_string(&notice_path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to read pending model migration notice"
);
return;
}
};
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
Ok(notice) => notice,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to parse pending model migration notice"
);
return;
}
};
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
&& Self::migration_prompt_hidden(&self.config, migration_config_key)
{
let _ = std::fs::remove_file(&notice_path);
return;
}
if let Some(seen_target) = self.config.notices.model_migrations.get(&notice.from_model)
&& seen_target == &notice.to_model
{
let _ = std::fs::remove_file(&notice_path);
return;
}
self.add_to_history(history_cell::new_info_event(
format!(
"Model upgrade available: {} -> {}. Use /model to switch.",
notice.from_model, notice.to_model
),
None,
));
// Best-effort: clear the one-shot file so it doesn't appear again.
let _ = std::fs::remove_file(&notice_path);
// Update in-memory state to prevent re-scheduling within this run, and persist so future
// runs don't re-show the notice.
self.config
.notices
.model_migrations
.insert(notice.from_model.clone(), notice.to_model.clone());
self.app_event_tx
.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: notice.from_model,
to_model: notice.to_model,
});
}
fn maybe_schedule_model_migration_notice(&self, model: &str) {
let available_models = match self.models_manager.try_list_models(&self.config) {
Ok(models) => models,
Err(_) => return,
};
let Some(ModelUpgrade {
id: target_model,
migration_config_key,
..
}) = available_models
.iter()
.find(|preset| preset.model == model)
.and_then(|preset| preset.upgrade.as_ref())
else {
return;
};
if Self::migration_prompt_hidden(&self.config, migration_config_key.as_str()) {
return;
}
if available_models
.iter()
.all(|preset| preset.model != target_model.as_str())
{
return;
}
if !Self::should_show_model_migration_notice(
model,
target_model.as_str(),
&self.config.notices.model_migrations,
&available_models,
) {
return;
}
let notice_path = self.pending_model_migration_notice_path();
if notice_path.exists() {
return;
}
let notice = PendingModelMigrationNotice {
from_model: model.to_string(),
to_model: target_model.to_string(),
migration_config_key: Some(migration_config_key.to_string()),
};
let Ok(json_line) = serde_json::to_string(&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"
);
}
}
fn flush_answer_stream_with_separator(&mut self) {
if let Some(mut controller) = self.stream_controller.take()
&& let Some(cell) = controller.finalize()
@@ -439,13 +650,14 @@ impl ChatWidget {
self.current_rollout_path = Some(event.rollout_path.clone());
let initial_messages = event.initial_messages.clone();
let model_for_header = event.model.clone();
self.session_header.set_model(&model_for_header);
self.set_model(&model_for_header);
self.add_to_history(history_cell::new_session_info(
&self.config,
&model_for_header,
event,
self.show_welcome_banner,
));
self.maybe_show_pending_model_migration_notice();
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
}
@@ -457,7 +669,12 @@ impl ChatWidget {
});
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
} else {
// If the user queued messages while startup was still in progress, kick off the first
// turn now that we know the session is configured.
self.maybe_send_next_queued_input();
}
self.maybe_schedule_model_migration_notice(&model_for_header);
if !self.suppress_session_configured_redraw {
self.request_redraw();
}
@@ -1719,7 +1936,7 @@ impl ChatWidget {
return;
}
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
self.queue_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
self.clear_token_usage();
@@ -1900,7 +2117,7 @@ impl ChatWidget {
}
fn queue_user_message(&mut self, user_message: UserMessage) {
if self.bottom_pane.is_task_running() {
if !self.is_session_configured() || self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
@@ -1909,6 +2126,12 @@ impl ChatWidget {
}
fn submit_user_message(&mut self, user_message: UserMessage) {
if !self.is_session_configured() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
return;
}
let UserMessage { text, image_paths } = user_message;
if text.is_empty() && image_paths.is_empty() {
return;
@@ -2211,6 +2434,9 @@ impl ChatWidget {
// If idle and there are queued inputs, submit exactly one to start the next turn.
fn maybe_send_next_queued_input(&mut self) {
if !self.is_session_configured() {
return;
}
if self.bottom_pane.is_task_running() {
return;
}
@@ -2407,6 +2633,14 @@ impl ChatWidget {
/// Open a popup to choose a quick auto model. Selecting "All models"
/// opens the full picker with every available preset.
pub(crate) fn open_model_popup(&mut self) {
if !self.is_session_configured() {
self.add_info_message(
"Model selection is disabled until startup completes.".to_string(),
None,
);
return;
}
let presets: Vec<ModelPreset> =
// todo(aibrahim): make this async function
match self.models_manager.try_list_models(&self.config) {
+3
View File
@@ -1021,6 +1021,7 @@ async fn alt_up_edits_most_recent_queued_message() {
#[tokio::test]
async fn enqueueing_history_prompt_multiple_times_is_stable() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.conversation_id = Some(ConversationId::new());
// Submit an initial prompt to seed history.
chat.bottom_pane.set_composer_text("repeat me".to_string());
@@ -1955,6 +1956,7 @@ async fn experimental_features_toggle_saves_on_exit() {
#[tokio::test]
async fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
chat.conversation_id = Some(ConversationId::new());
chat.open_model_popup();
let popup = render_bottom_popup(&chat, 80);
@@ -2169,6 +2171,7 @@ async fn feedback_upload_consent_popup_snapshot() {
#[tokio::test]
async fn reasoning_popup_escape_returns_to_model_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.conversation_id = Some(ConversationId::new());
chat.open_model_popup();
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
+1
View File
@@ -58,6 +58,7 @@ pub mod live_wrap;
mod markdown;
mod markdown_render;
mod markdown_stream;
#[cfg(test)]
mod model_migration;
mod notifications;
pub mod onboarding;
-57
View File
@@ -5,8 +5,6 @@ use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -19,7 +17,6 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
/// Outcome of the migration prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -116,40 +113,6 @@ pub(crate) fn migration_copy_for_models(
}
}
pub(crate) async fn run_model_migration_prompt(
tui: &mut Tui,
copy: ModelMigrationCopy,
) -> ModelMigrationOutcome {
let alt = AltScreenGuard::enter(tui);
let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy);
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
let events = alt.tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}
}
} else {
screen.accept();
break;
}
}
screen.outcome()
}
struct ModelMigrationScreen {
request_frame: FrameRequester,
copy: ModelMigrationCopy,
@@ -323,26 +286,6 @@ impl ModelMigrationScreen {
}
}
// Render the prompt on the terminal's alternate screen so exiting or cancelling
// does not leave a large blank region in the normal scrollback. This does not
// change the prompt's appearance – only where it is drawn.
struct AltScreenGuard<'a> {
tui: &'a mut Tui,
}
impl<'a> AltScreenGuard<'a> {
fn enter(tui: &'a mut Tui) -> Self {
let _ = tui.enter_alt_screen();
Self { tui }
}
}
impl Drop for AltScreenGuard<'_> {
fn drop(&mut self) {
let _ = self.tui.leave_alt_screen();
}
}
fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
+6 -243
View File
@@ -10,9 +10,6 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::file_search::FileSearchManager;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::model_migration::ModelMigrationOutcome;
use crate::model_migration::migration_copy_for_models;
use crate::model_migration::run_model_migration_prompt;
use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
@@ -39,9 +36,6 @@ use codex_core::config::Config;
use codex_core::config::edit::ConfigEditsBuilder;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::protocol::EventMsg;
use codex_core::protocol::FinalOutput;
use codex_core::protocol::ListSkillsResponseEvent;
@@ -51,8 +45,6 @@ use codex_core::protocol::SkillErrorInfo;
use codex_core::protocol::TokenUsage;
use codex_core::terminal::terminal_info;
use codex_protocol::ConversationId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
@@ -68,7 +60,6 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -158,161 +149,6 @@ struct SessionSummary {
resume_command: Option<String>,
}
fn should_show_model_migration_prompt(
current_model: &str,
target_model: &str,
seen_migrations: &BTreeMap<String, String>,
available_models: &[ModelPreset],
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = seen_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
if available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
{
return true;
}
false
}
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
async fn handle_model_migration_prompt_if_needed(
tui: &mut tui::Tui,
config: &mut Config,
model: &str,
app_event_tx: &AppEventSender,
models_manager: Arc<ModelsManager>,
) -> Option<AppExitInfo> {
let available_models = models_manager.list_models(config).await;
let upgrade = available_models
.iter()
.find(|preset| preset.model == model)
.and_then(|preset| preset.upgrade.as_ref());
if let Some(ModelUpgrade {
id: target_model,
reasoning_effort_mapping,
migration_config_key,
..
}) = upgrade
{
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return None;
}
let target_model = target_model.to_string();
if !should_show_model_migration_prompt(
model,
&target_model,
&config.notices.model_migrations,
&available_models,
) {
return None;
}
let current_preset = available_models.iter().find(|preset| preset.model == model);
let target_preset = available_models
.iter()
.find(|preset| preset.model == target_model);
let target_display_name = target_preset
.map(|preset| preset.display_name.clone())
.unwrap_or_else(|| target_model.clone());
let heading_label = if target_display_name == model {
target_model.clone()
} else {
target_display_name.clone()
};
let target_description = target_preset.and_then(|preset| {
if preset.description.is_empty() {
None
} else {
Some(preset.description.clone())
}
});
let can_opt_out = current_preset.is_some();
let prompt_copy = migration_copy_for_models(
model,
&target_model,
heading_label,
target_description,
can_opt_out,
);
match run_model_migration_prompt(tui, prompt_copy).await {
ModelMigrationOutcome::Accepted => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
config.model = Some(target_model.clone());
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
&& let Some(reasoning_effort) = config.model_reasoning_effort
{
reasoning_effort_mapping
.get(&reasoning_effort)
.cloned()
.or(config.model_reasoning_effort)
} else {
config.model_reasoning_effort
};
config.model_reasoning_effort = mapped_effort;
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
app_event_tx.send(AppEvent::PersistModelSelection {
model: target_model.clone(),
effort: mapped_effort,
});
}
ModelMigrationOutcome::Rejected => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
}
ModelMigrationOutcome::Exit => {
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
update_action: None,
session_lines: Vec::new(),
});
}
}
}
None
}
pub(crate) struct App {
pub(crate) server: Arc<ConversationManager>,
pub(crate) app_event_tx: AppEventSender,
@@ -374,7 +210,7 @@ impl App {
pub async fn run(
tui: &mut tui::Tui,
auth_manager: Arc<AuthManager>,
mut config: Config,
config: Config,
active_profile: Option<String>,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -390,24 +226,6 @@ impl App {
auth_manager.clone(),
SessionSource::Cli,
));
let mut model = conversation_manager
.get_models_manager()
.get_model(&config.model, &config)
.await;
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
model.as_str(),
&app_event_tx,
conversation_manager.get_models_manager(),
)
.await;
if let Some(exit_info) = exit_info {
return Ok(exit_info);
}
if let Some(updated_model) = config.model.clone() {
model = updated_model;
}
let enhanced_keys_supported = tui.enhanced_keys_supported();
let mut chat_widget = match resume_selection {
@@ -423,7 +241,8 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
// The only truthful model is the one we get back on SessionConfigured.
model: String::new(),
};
ChatWidget::new(init, conversation_manager.clone())
}
@@ -449,7 +268,8 @@ impl App {
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
// The only truthful model is the one we get back on SessionConfigured.
model: String::new(),
};
ChatWidget::new_from_existing(
init,
@@ -487,7 +307,7 @@ impl App {
chat_widget,
auth_manager: auth_manager.clone(),
config,
current_model: model.clone(),
current_model: String::new(),
active_profile,
file_search,
enhanced_keys_supported,
@@ -2166,45 +1986,6 @@ mod tests {
)
}
fn all_model_presets() -> Vec<ModelPreset> {
codex_core::models_manager::model_presets::all_model_presets().clone()
}
#[tokio::test]
async fn model_migration_prompt_only_shows_for_deprecated_models() {
let seen = BTreeMap::new();
assert!(should_show_model_migration_prompt(
"gpt-5",
"gpt-5.1",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5-codex",
"gpt-5.1-codex",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5-codex-mini",
"gpt-5.1-codex-mini",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5.1-codex",
"gpt-5.1-codex-max",
&seen,
&all_model_presets()
));
assert!(!should_show_model_migration_prompt(
"gpt-5.1-codex",
"gpt-5.1-codex",
&seen,
&all_model_presets()
));
}
#[tokio::test]
async fn transcript_selection_copy_includes_offscreen_lines() {
let mut app = make_test_app().await;
@@ -2237,24 +2018,6 @@ mod tests {
assert_eq!(text, "one\ntwo\nthree\nfour");
}
#[tokio::test]
async fn model_migration_prompt_respects_hide_flag_and_self_target() {
let mut seen = BTreeMap::new();
seen.insert("gpt-5".to_string(), "gpt-5.1".to_string());
assert!(!should_show_model_migration_prompt(
"gpt-5",
"gpt-5.1",
&seen,
&all_model_presets()
));
assert!(!should_show_model_migration_prompt(
"gpt-5.1",
"gpt-5.1",
&seen,
&all_model_presets()
));
}
#[tokio::test]
async fn update_reasoning_effort_updates_config() {
let mut app = make_test_app().await;
+236 -3
View File
@@ -1,6 +1,8 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -13,6 +15,8 @@ use codex_core::config::types::Notifications;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -78,6 +82,8 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use serde::Deserialize;
use serde::Serialize;
use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use tracing::debug;
@@ -136,12 +142,23 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_file_search::FileMatch;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::UpdatePlanArgs;
use strum::IntoEnumIterator;
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
const PENDING_MODEL_MIGRATION_NOTICE_FILENAME: &str = "pending_model_migration_notice.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingModelMigrationNotice {
from_model: String,
to_model: String,
// Used to respect hide flags even if config changes between scheduling and display.
#[serde(default)]
migration_config_key: Option<String>,
}
// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec<String>,
@@ -368,6 +385,199 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget {
fn is_session_configured(&self) -> bool {
self.conversation_id.is_some()
}
fn pending_model_migration_notice_path(&self) -> PathBuf {
self.config
.codex_home
.join(PENDING_MODEL_MIGRATION_NOTICE_FILENAME)
}
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
fn should_show_model_migration_notice(
current_model: &str,
target_model: &str,
seen_migrations: &BTreeMap<String, String>,
available_models: &[ModelPreset],
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = seen_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
if available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
{
return true;
}
false
}
fn maybe_show_pending_model_migration_notice(&mut self) {
let notice_path = self.pending_model_migration_notice_path();
let contents = match std::fs::read_to_string(&notice_path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to read pending model migration notice"
);
return;
}
};
let notice: PendingModelMigrationNotice = match serde_json::from_str(&contents) {
Ok(notice) => notice,
Err(err) => {
tracing::error!(
error = %err,
notice_path = %notice_path.display(),
"failed to parse pending model migration notice"
);
return;
}
};
if let Some(migration_config_key) = notice.migration_config_key.as_deref()
&& Self::migration_prompt_hidden(&self.config, migration_config_key)
{
let _ = std::fs::remove_file(&notice_path);
return;
}
if let Some(seen_target) = self.config.notices.model_migrations.get(&notice.from_model)
&& seen_target == &notice.to_model
{
let _ = std::fs::remove_file(&notice_path);
return;
}
self.add_to_history(history_cell::new_info_event(
format!(
"Model upgrade available: {} -> {}. Use /model to switch.",
notice.from_model, notice.to_model
),
None,
));
// Best-effort: clear the one-shot file so it doesn't appear again.
let _ = std::fs::remove_file(&notice_path);
// Update in-memory state to prevent re-scheduling within this run, and persist so future
// runs don't re-show the notice.
self.config
.notices
.model_migrations
.insert(notice.from_model.clone(), notice.to_model.clone());
self.app_event_tx
.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: notice.from_model,
to_model: notice.to_model,
});
}
fn maybe_schedule_model_migration_notice(&self, model: &str) {
let available_models = match self.models_manager.try_list_models(&self.config) {
Ok(models) => models,
Err(_) => return,
};
let Some(ModelUpgrade {
id: target_model,
migration_config_key,
..
}) = available_models
.iter()
.find(|preset| preset.model == model)
.and_then(|preset| preset.upgrade.as_ref())
else {
return;
};
if Self::migration_prompt_hidden(&self.config, migration_config_key.as_str()) {
return;
}
if available_models
.iter()
.all(|preset| preset.model != target_model.as_str())
{
return;
}
if !Self::should_show_model_migration_notice(
model,
target_model.as_str(),
&self.config.notices.model_migrations,
&available_models,
) {
return;
}
let notice_path = self.pending_model_migration_notice_path();
if notice_path.exists() {
return;
}
let notice = PendingModelMigrationNotice {
from_model: model.to_string(),
to_model: target_model.to_string(),
migration_config_key: Some(migration_config_key.to_string()),
};
let Ok(json_line) = serde_json::to_string(&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"
);
}
}
fn flush_answer_stream_with_separator(&mut self) {
if let Some(mut controller) = self.stream_controller.take()
&& let Some(cell) = controller.finalize()
@@ -405,13 +615,14 @@ impl ChatWidget {
self.current_rollout_path = Some(event.rollout_path.clone());
let initial_messages = event.initial_messages.clone();
let model_for_header = event.model.clone();
self.session_header.set_model(&model_for_header);
self.set_model(&model_for_header);
self.add_to_history(history_cell::new_session_info(
&self.config,
&model_for_header,
event,
self.show_welcome_banner,
));
self.maybe_show_pending_model_migration_notice();
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
}
@@ -423,7 +634,12 @@ impl ChatWidget {
});
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
} else {
// If the user queued messages while startup was still in progress, kick off the first
// turn now that we know the session is configured.
self.maybe_send_next_queued_input();
}
self.maybe_schedule_model_migration_notice(&model_for_header);
if !self.suppress_session_configured_redraw {
self.request_redraw();
}
@@ -1555,7 +1771,7 @@ impl ChatWidget {
return;
}
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
self.queue_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
self.clear_token_usage();
@@ -1708,7 +1924,7 @@ impl ChatWidget {
}
fn queue_user_message(&mut self, user_message: UserMessage) {
if self.bottom_pane.is_task_running() {
if !self.is_session_configured() || self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
@@ -1717,6 +1933,12 @@ impl ChatWidget {
}
fn submit_user_message(&mut self, user_message: UserMessage) {
if !self.is_session_configured() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
return;
}
let UserMessage { text, image_paths } = user_message;
if text.is_empty() && image_paths.is_empty() {
return;
@@ -2019,6 +2241,9 @@ impl ChatWidget {
// If idle and there are queued inputs, submit exactly one to start the next turn.
fn maybe_send_next_queued_input(&mut self) {
if !self.is_session_configured() {
return;
}
if self.bottom_pane.is_task_running() {
return;
}
@@ -2205,6 +2430,14 @@ impl ChatWidget {
/// Open a popup to choose a quick auto model. Selecting "All models"
/// opens the full picker with every available preset.
pub(crate) fn open_model_popup(&mut self) {
if !self.is_session_configured() {
self.add_info_message(
"Model selection is disabled until startup completes.".to_string(),
None,
);
return;
}
let presets: Vec<ModelPreset> =
// todo(aibrahim): make this async function
match self.models_manager.try_list_models(&self.config) {
+3
View File
@@ -981,6 +981,7 @@ async fn alt_up_edits_most_recent_queued_message() {
#[tokio::test]
async fn enqueueing_history_prompt_multiple_times_is_stable() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.conversation_id = Some(ConversationId::new());
// Submit an initial prompt to seed history.
chat.bottom_pane.set_composer_text("repeat me".to_string());
@@ -1725,6 +1726,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
#[tokio::test]
async fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
chat.conversation_id = Some(ConversationId::new());
chat.open_model_popup();
let popup = render_bottom_popup(&chat, 80);
@@ -1939,6 +1941,7 @@ async fn feedback_upload_consent_popup_snapshot() {
#[tokio::test]
async fn reasoning_popup_escape_returns_to_model_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.conversation_id = Some(ConversationId::new());
chat.open_model_popup();
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
+1
View File
@@ -58,6 +58,7 @@ pub mod live_wrap;
mod markdown;
mod markdown_render;
mod markdown_stream;
#[cfg(test)]
mod model_migration;
mod notifications;
pub mod onboarding;
-58
View File
@@ -5,8 +5,6 @@ use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -19,7 +17,6 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
/// Outcome of the migration prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -96,41 +93,6 @@ pub(crate) fn migration_copy_for_models(
}
}
pub(crate) async fn run_model_migration_prompt(
tui: &mut Tui,
copy: ModelMigrationCopy,
) -> ModelMigrationOutcome {
let alt = AltScreenGuard::enter(tui);
let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy);
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
let events = alt.tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}
}
} else {
screen.accept();
break;
}
}
screen.outcome()
}
struct ModelMigrationScreen {
request_frame: FrameRequester,
copy: ModelMigrationCopy,
@@ -304,26 +266,6 @@ impl ModelMigrationScreen {
}
}
// Render the prompt on the terminal's alternate screen so exiting or cancelling
// does not leave a large blank region in the normal scrollback. This does not
// change the prompt's appearance – only where it is drawn.
struct AltScreenGuard<'a> {
tui: &'a mut Tui,
}
impl<'a> AltScreenGuard<'a> {
fn enter(tui: &'a mut Tui) -> Self {
let _ = tui.enter_alt_screen();
Self { tui }
}
}
impl Drop for AltScreenGuard<'_> {
fn drop(&mut self) {
let _ = self.tui.leave_alt_screen();
}
}
fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))