mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Sync tui2 with tui and keep dual-run glue (#7965)
- Copy latest tui sources into tui2
- Restore notifications, tests, and styles
- Keep codex-tui interop conversions and snapshots
The expected changes that are necessary to make this work are still in
place:
diff -ru codex-rs/tui codex-rs/tui2 --exclude='*.snap'
--exclude='*.snap.new'
```diff
diff -ru --ex codex-rs/tui/Cargo.toml codex-rs/tui2/Cargo.toml
--- codex-rs/tui/Cargo.toml 2025-12-12 16:39:12
+++ codex-rs/tui2/Cargo.toml 2025-12-12 17:31:01
@@ -1,15 +1,15 @@
[package]
-name = "codex-tui"
+name = "codex-tui2"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
-name = "codex-tui"
+name = "codex-tui2"
path = "src/main.rs"
[lib]
-name = "codex_tui"
+name = "codex_tui2"
path = "src/lib.rs"
[features]
@@ -42,6 +42,7 @@
codex-login = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
+codex-tui = { workspace = true }
color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
derive_more = { workspace = true, features = ["is_variant"] }
diff -ru --ex codex-rs/tui/src/app.rs codex-rs/tui2/src/app.rs
--- codex-rs/tui/src/app.rs 2025-12-12 16:39:05
+++ codex-rs/tui2/src/app.rs 2025-12-12 17:30:36
@@ -69,6 +69,16 @@
pub update_action: Option<UpdateAction>,
}
+impl From<AppExitInfo> for codex_tui::AppExitInfo {
+ fn from(info: AppExitInfo) -> Self {
+ codex_tui::AppExitInfo {
+ token_usage: info.token_usage,
+ conversation_id: info.conversation_id,
+ update_action: info.update_action.map(Into::into),
+ }
+ }
+}
+
fn session_summary(
token_usage: TokenUsage,
conversation_id: Option<ConversationId>,
Only in codex-rs/tui/src/bin: md-events.rs
Only in codex-rs/tui2/src/bin: md-events2.rs
diff -ru --ex codex-rs/tui/src/cli.rs codex-rs/tui2/src/cli.rs
--- codex-rs/tui/src/cli.rs 2025-11-19 13:40:42
+++ codex-rs/tui2/src/cli.rs 2025-12-12 17:30:43
@@ -88,3 +88,28 @@
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
+
+impl From<codex_tui::Cli> for Cli {
+ fn from(cli: codex_tui::Cli) -> Self {
+ Self {
+ prompt: cli.prompt,
+ images: cli.images,
+ resume_picker: cli.resume_picker,
+ resume_last: cli.resume_last,
+ resume_session_id: cli.resume_session_id,
+ resume_show_all: cli.resume_show_all,
+ model: cli.model,
+ oss: cli.oss,
+ oss_provider: cli.oss_provider,
+ config_profile: cli.config_profile,
+ sandbox_mode: cli.sandbox_mode,
+ approval_policy: cli.approval_policy,
+ full_auto: cli.full_auto,
+ dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox,
+ cwd: cli.cwd,
+ web_search: cli.web_search,
+ add_dir: cli.add_dir,
+ config_overrides: cli.config_overrides,
+ }
+ }
+}
diff -ru --ex codex-rs/tui/src/main.rs codex-rs/tui2/src/main.rs
--- codex-rs/tui/src/main.rs 2025-12-12 16:39:05
+++ codex-rs/tui2/src/main.rs 2025-12-12 16:39:06
@@ -1,8 +1,8 @@
use clap::Parser;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
-use codex_tui::Cli;
-use codex_tui::run_main;
+use codex_tui2::Cli;
+use codex_tui2::run_main;
#[derive(Parser, Debug)]
struct TopCli {
diff -ru --ex codex-rs/tui/src/update_action.rs codex-rs/tui2/src/update_action.rs
--- codex-rs/tui/src/update_action.rs 2025-11-19 11:11:47
+++ codex-rs/tui2/src/update_action.rs 2025-12-12 17:30:48
@@ -9,6 +9,20 @@
BrewUpgrade,
}
+impl From<UpdateAction> for codex_tui::update_action::UpdateAction {
+ fn from(action: UpdateAction) -> Self {
+ match action {
+ UpdateAction::NpmGlobalLatest => {
+ codex_tui::update_action::UpdateAction::NpmGlobalLatest
+ }
+ UpdateAction::BunGlobalLatest => {
+ codex_tui::update_action::UpdateAction::BunGlobalLatest
+ }
+ UpdateAction::BrewUpgrade => codex_tui::update_action::UpdateAction::BrewUpgrade,
+ }
+ }
+}
+
impl UpdateAction {
/// Returns the list of command-line arguments for invoking the update.
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
```
This commit is contained in:
committed by
GitHub
Unverified
parent
ad7b9d63c3
commit
6ec2831b91
+120
-132
@@ -8,7 +8,7 @@ use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::model_migration::ModelMigrationOutcome;
|
||||
use crate::model_migration::migration_copy_for_config;
|
||||
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;
|
||||
@@ -20,11 +20,11 @@ use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
||||
@@ -33,9 +33,9 @@ use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::FinalOutput;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::protocol::SkillLoadOutcomeInfo;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::skills::load_skills;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_core::skills::SkillError;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelUpgrade;
|
||||
@@ -49,6 +49,7 @@ use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -61,9 +62,6 @@ use tokio::sync::mpsc::unbounded_channel;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
|
||||
const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey];
|
||||
const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppExitInfo {
|
||||
pub token_usage: TokenUsage,
|
||||
@@ -98,6 +96,17 @@ fn session_summary(
|
||||
})
|
||||
}
|
||||
|
||||
fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillError> {
|
||||
outcome
|
||||
.errors
|
||||
.iter()
|
||||
.map(|err| SkillError {
|
||||
path: err.path.clone(),
|
||||
message: err.message.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SessionSummary {
|
||||
usage_line: String,
|
||||
@@ -107,26 +116,46 @@ struct SessionSummary {
|
||||
fn should_show_model_migration_prompt(
|
||||
current_model: &str,
|
||||
target_model: &str,
|
||||
hide_prompt_flag: Option<bool>,
|
||||
available_models: Vec<ModelPreset>,
|
||||
seen_migrations: &BTreeMap<String, String>,
|
||||
available_models: &[ModelPreset],
|
||||
) -> bool {
|
||||
if target_model == current_model || hide_prompt_flag.unwrap_or(false) {
|
||||
if target_model == current_model {
|
||||
return false;
|
||||
}
|
||||
|
||||
available_models
|
||||
if let Some(seen_target) = seen_migrations.get(current_model)
|
||||
&& seen_target == target_model
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if available_models
|
||||
.iter()
|
||||
.filter(|preset| preset.upgrade.is_some())
|
||||
.any(|preset| preset.model == current_model)
|
||||
.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) -> Option<bool> {
|
||||
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
|
||||
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)
|
||||
}
|
||||
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => config.notices.hide_gpt5_1_migration_prompt,
|
||||
_ => None,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +164,6 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
config: &mut Config,
|
||||
model: &str,
|
||||
app_event_tx: &AppEventSender,
|
||||
auth_mode: Option<AuthMode>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
) -> Option<AppExitInfo> {
|
||||
let available_models = models_manager.list_models(config).await;
|
||||
@@ -150,26 +178,52 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
migration_config_key,
|
||||
}) = upgrade
|
||||
{
|
||||
if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) {
|
||||
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target_model = target_model.to_string();
|
||||
let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str());
|
||||
if !should_show_model_migration_prompt(
|
||||
model,
|
||||
&target_model,
|
||||
hide_prompt_flag,
|
||||
available_models.clone(),
|
||||
&config.notices.model_migrations,
|
||||
&available_models,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prompt_copy = migration_copy_for_config(migration_config_key.as_str());
|
||||
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 {
|
||||
migration_config: migration_config_key.to_string(),
|
||||
from_model: model.to_string(),
|
||||
to_model: target_model.clone(),
|
||||
});
|
||||
config.model = Some(target_model.clone());
|
||||
|
||||
@@ -195,7 +249,8 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
}
|
||||
ModelMigrationOutcome::Rejected => {
|
||||
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
|
||||
migration_config: migration_config_key.to_string(),
|
||||
from_model: model.to_string(),
|
||||
to_model: target_model.clone(),
|
||||
});
|
||||
}
|
||||
ModelMigrationOutcome::Exit => {
|
||||
@@ -247,8 +302,6 @@ pub(crate) struct App {
|
||||
|
||||
// One-shot suppression of the next world-writable scan after user confirmation.
|
||||
skip_world_writable_scan_once: bool,
|
||||
|
||||
pub(crate) skills: Option<Vec<SkillMetadata>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -276,7 +329,6 @@ impl App {
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
|
||||
let auth_mode = auth_manager.auth().map(|auth| auth.mode);
|
||||
let conversation_manager = Arc::new(ConversationManager::new(
|
||||
auth_manager.clone(),
|
||||
SessionSource::Cli,
|
||||
@@ -290,7 +342,6 @@ impl App {
|
||||
&mut config,
|
||||
model.as_str(),
|
||||
&app_event_tx,
|
||||
auth_mode,
|
||||
conversation_manager.get_models_manager(),
|
||||
)
|
||||
.await;
|
||||
@@ -301,26 +352,6 @@ impl App {
|
||||
model = updated_model;
|
||||
}
|
||||
|
||||
let skills_outcome = load_skills(&config);
|
||||
if !skills_outcome.errors.is_empty() {
|
||||
match run_skill_error_prompt(tui, &skills_outcome.errors).await {
|
||||
SkillErrorPromptOutcome::Exit => {
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
conversation_id: None,
|
||||
update_action: None,
|
||||
});
|
||||
}
|
||||
SkillErrorPromptOutcome::Continue => {}
|
||||
}
|
||||
}
|
||||
|
||||
let skills = if config.features.enabled(Feature::Skills) {
|
||||
Some(skills_outcome.skills.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
||||
let model_family = conversation_manager
|
||||
.get_models_manager()
|
||||
@@ -338,7 +369,6 @@ impl App {
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: conversation_manager.get_models_manager(),
|
||||
feedback: feedback.clone(),
|
||||
skills: skills.clone(),
|
||||
is_first_run,
|
||||
model_family: model_family.clone(),
|
||||
};
|
||||
@@ -365,7 +395,6 @@ impl App {
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: conversation_manager.get_models_manager(),
|
||||
feedback: feedback.clone(),
|
||||
skills: skills.clone(),
|
||||
is_first_run,
|
||||
model_family: model_family.clone(),
|
||||
};
|
||||
@@ -403,7 +432,6 @@ impl App {
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
skip_world_writable_scan_once: false,
|
||||
skills,
|
||||
};
|
||||
|
||||
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
||||
@@ -529,7 +557,6 @@ impl App {
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
skills: self.skills.clone(),
|
||||
is_first_run: false,
|
||||
model_family: model_family.clone(),
|
||||
};
|
||||
@@ -580,7 +607,6 @@ impl App {
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
skills: self.skills.clone(),
|
||||
is_first_run: false,
|
||||
model_family: model_family.clone(),
|
||||
};
|
||||
@@ -672,6 +698,19 @@ impl App {
|
||||
self.suppress_shutdown_complete = false;
|
||||
return Ok(true);
|
||||
}
|
||||
if let EventMsg::SessionConfigured(cfg) = &event.msg
|
||||
&& let Some(outcome) = cfg.skill_load_outcome.as_ref()
|
||||
&& !outcome.errors.is_empty()
|
||||
{
|
||||
let errors = skill_errors_from_outcome(outcome);
|
||||
match run_skill_error_prompt(tui, &errors).await {
|
||||
SkillErrorPromptOutcome::Exit => {
|
||||
self.chat_widget.submit_op(Op::Shutdown);
|
||||
return Ok(false);
|
||||
}
|
||||
SkillErrorPromptOutcome::Continue => {}
|
||||
}
|
||||
}
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ConversationHistory(ev) => {
|
||||
@@ -960,13 +999,19 @@ impl App {
|
||||
));
|
||||
}
|
||||
}
|
||||
AppEvent::PersistModelMigrationPromptAcknowledged { migration_config } => {
|
||||
AppEvent::PersistModelMigrationPromptAcknowledged {
|
||||
from_model,
|
||||
to_model,
|
||||
} => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_hide_model_migration_prompt(&migration_config, true)
|
||||
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %err, "failed to persist model migration prompt acknowledgement");
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to persist model migration prompt acknowledgement"
|
||||
);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save model migration prompt preference: {err}"
|
||||
));
|
||||
@@ -1140,28 +1185,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn migration_prompt_allowed_auth_modes(migration_config_key: &str) -> Option<&'static [AuthMode]> {
|
||||
match migration_config_key {
|
||||
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_MIGRATION_AUTH_MODES),
|
||||
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_CODEX_MIGRATION_AUTH_MODES),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn migration_prompt_allows_auth_mode(
|
||||
auth_mode: Option<AuthMode>,
|
||||
migration_config_key: &str,
|
||||
) -> bool {
|
||||
if let Some(allowed_modes) = migration_prompt_allowed_auth_modes(migration_config_key) {
|
||||
match auth_mode {
|
||||
None => true,
|
||||
Some(mode) => allowed_modes.contains(&mode),
|
||||
}
|
||||
} else {
|
||||
auth_mode != Some(AuthMode::ApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1219,7 +1242,6 @@ mod tests {
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
skip_world_writable_scan_once: false,
|
||||
skills: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1260,7 +1282,6 @@ mod tests {
|
||||
pending_update_action: None,
|
||||
suppress_shutdown_complete: false,
|
||||
skip_world_writable_scan_once: false,
|
||||
skills: None,
|
||||
},
|
||||
rx,
|
||||
op_rx,
|
||||
@@ -1273,51 +1294,54 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn model_migration_prompt_only_shows_for_deprecated_models() {
|
||||
let seen = BTreeMap::new();
|
||||
assert!(should_show_model_migration_prompt(
|
||||
"gpt-5",
|
||||
"gpt-5.1",
|
||||
None,
|
||||
all_model_presets()
|
||||
&seen,
|
||||
&all_model_presets()
|
||||
));
|
||||
assert!(should_show_model_migration_prompt(
|
||||
"gpt-5-codex",
|
||||
"gpt-5.1-codex",
|
||||
None,
|
||||
all_model_presets()
|
||||
&seen,
|
||||
&all_model_presets()
|
||||
));
|
||||
assert!(should_show_model_migration_prompt(
|
||||
"gpt-5-codex-mini",
|
||||
"gpt-5.1-codex-mini",
|
||||
None,
|
||||
all_model_presets()
|
||||
&seen,
|
||||
&all_model_presets()
|
||||
));
|
||||
assert!(should_show_model_migration_prompt(
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
None,
|
||||
all_model_presets()
|
||||
&seen,
|
||||
&all_model_presets()
|
||||
));
|
||||
assert!(!should_show_model_migration_prompt(
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex",
|
||||
None,
|
||||
all_model_presets()
|
||||
&seen,
|
||||
&all_model_presets()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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",
|
||||
Some(true),
|
||||
all_model_presets()
|
||||
&seen,
|
||||
&all_model_presets()
|
||||
));
|
||||
assert!(!should_show_model_migration_prompt(
|
||||
"gpt-5.1",
|
||||
"gpt-5.1",
|
||||
None,
|
||||
all_model_presets()
|
||||
&seen,
|
||||
&all_model_presets()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1471,40 +1495,4 @@ mod tests {
|
||||
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt5_migration_allows_api_key_and_chatgpt() {
|
||||
assert!(migration_prompt_allows_auth_mode(
|
||||
Some(AuthMode::ApiKey),
|
||||
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG,
|
||||
));
|
||||
assert!(migration_prompt_allows_auth_mode(
|
||||
Some(AuthMode::ChatGPT),
|
||||
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_1_codex_max_migration_limits_to_chatgpt() {
|
||||
assert!(migration_prompt_allows_auth_mode(
|
||||
Some(AuthMode::ChatGPT),
|
||||
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
|
||||
));
|
||||
assert!(migration_prompt_allows_auth_mode(
|
||||
Some(AuthMode::ApiKey),
|
||||
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_migrations_block_api_key() {
|
||||
assert!(!migration_prompt_allows_auth_mode(
|
||||
Some(AuthMode::ApiKey),
|
||||
"unknown"
|
||||
));
|
||||
assert!(migration_prompt_allows_auth_mode(
|
||||
Some(AuthMode::ChatGPT),
|
||||
"unknown"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,6 @@ impl App {
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
skills: self.skills.clone(),
|
||||
is_first_run: false,
|
||||
};
|
||||
self.chat_widget =
|
||||
|
||||
@@ -139,7 +139,8 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Persist the acknowledgement flag for the model migration prompt.
|
||||
PersistModelMigrationPromptAcknowledged {
|
||||
migration_config: String,
|
||||
from_model: String,
|
||||
to_model: String,
|
||||
},
|
||||
|
||||
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use crate::style::user_message_style;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
|
||||
@@ -801,6 +802,10 @@ impl ChatComposer {
|
||||
self.skills.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
|
||||
self.skills.as_ref()
|
||||
}
|
||||
|
||||
/// Extract a token prefixed with `prefix` under the cursor, if any.
|
||||
///
|
||||
/// The returned string **does not** include the prefix.
|
||||
@@ -1617,7 +1622,7 @@ impl ChatComposer {
|
||||
|
||||
let builtin_match = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.any(|(cmd_name, _)| cmd_name.starts_with(name));
|
||||
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
|
||||
|
||||
if builtin_match {
|
||||
return true;
|
||||
@@ -1626,7 +1631,7 @@ impl ChatComposer {
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
self.custom_prompts
|
||||
.iter()
|
||||
.any(|p| format!("{prompt_prefix}{}", p.name).starts_with(name))
|
||||
.any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some())
|
||||
}
|
||||
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
@@ -3978,8 +3983,15 @@ mod tests {
|
||||
"'/re' should activate slash popup via prefix match"
|
||||
);
|
||||
|
||||
// Case 3: invalid prefix "/zzz" – still allowed to open popup if it
|
||||
// matches no built-in command, our current logic will *not* open popup.
|
||||
// Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback)
|
||||
composer.set_text_content("/ac".to_string());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::Command(_)),
|
||||
"'/ac' should activate slash popup via fuzzy match"
|
||||
);
|
||||
|
||||
// Case 4: invalid prefix "/zzz" – still allowed to open popup if it
|
||||
// matches no built-in command; our current logic will not open popup.
|
||||
// Verify that explicitly.
|
||||
composer.set_text_content("/zzz".to_string());
|
||||
assert!(
|
||||
|
||||
@@ -373,4 +373,23 @@ mod tests {
|
||||
let description = rows.first().and_then(|row| row.description.as_deref());
|
||||
assert_eq!(description, Some("send saved prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_filter_matches_subsequence_for_ac() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), false);
|
||||
popup.on_composer_text_change("/ac".to_string());
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
cmds.contains(&"compact") && cmds.contains(&"feedback"),
|
||||
"expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ pub(crate) struct SelectionItem {
|
||||
pub description: Option<String>,
|
||||
pub selected_description: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub is_default: bool,
|
||||
pub actions: Vec<SelectionAction>,
|
||||
pub dismiss_on_select: bool,
|
||||
pub search_value: Option<String>,
|
||||
@@ -187,11 +188,14 @@ impl ListSelectionView {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '›' } else { ' ' };
|
||||
let name = item.name.as_str();
|
||||
let name_with_marker = if item.is_current {
|
||||
format!("{name} (current)")
|
||||
let marker = if item.is_current {
|
||||
" (current)"
|
||||
} else if item.is_default {
|
||||
" (default)"
|
||||
} else {
|
||||
item.name.clone()
|
||||
""
|
||||
};
|
||||
let name_with_marker = format!("{name}{marker}");
|
||||
let n = visible_idx + 1;
|
||||
let wrap_prefix = if self.is_searchable {
|
||||
// The number keys don't work when search is enabled (since we let the
|
||||
|
||||
@@ -131,10 +131,19 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||||
self.composer.set_skill_mentions(skills);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
|
||||
self.composer.skills()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn context_window_percent(&self) -> Option<i64> {
|
||||
self.context_window_percent
|
||||
|
||||
@@ -44,6 +44,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::SkillLoadOutcomeInfo;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
@@ -263,7 +264,6 @@ pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) models_manager: Arc<ModelsManager>,
|
||||
pub(crate) feedback: codex_feedback::CodexFeedback,
|
||||
pub(crate) skills: Option<Vec<SkillMetadata>>,
|
||||
pub(crate) is_first_run: bool,
|
||||
pub(crate) model_family: ModelFamily,
|
||||
}
|
||||
@@ -392,6 +392,7 @@ impl ChatWidget {
|
||||
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.set_skills_from_outcome(event.skill_load_outcome.as_ref());
|
||||
self.conversation_id = Some(event.session_id);
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
@@ -416,6 +417,11 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) {
|
||||
let skills = outcome.map(skills_from_outcome);
|
||||
self.bottom_pane.set_skills(skills);
|
||||
}
|
||||
|
||||
pub(crate) fn open_feedback_note(
|
||||
&mut self,
|
||||
category: crate::app_event::FeedbackCategory,
|
||||
@@ -1262,7 +1268,6 @@ impl ChatWidget {
|
||||
auth_manager,
|
||||
models_manager,
|
||||
feedback,
|
||||
skills,
|
||||
is_first_run,
|
||||
model_family,
|
||||
} = common;
|
||||
@@ -1285,7 +1290,7 @@ impl ChatWidget {
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
animations_enabled: config.animations,
|
||||
skills,
|
||||
skills: None,
|
||||
}),
|
||||
active_cell: None,
|
||||
config,
|
||||
@@ -1348,7 +1353,6 @@ impl ChatWidget {
|
||||
auth_manager,
|
||||
models_manager,
|
||||
feedback,
|
||||
skills,
|
||||
model_family,
|
||||
..
|
||||
} = common;
|
||||
@@ -1371,7 +1375,7 @@ impl ChatWidget {
|
||||
placeholder_text: placeholder,
|
||||
disable_paste_burst: config.disable_paste_burst,
|
||||
animations_enabled: config.animations,
|
||||
skills,
|
||||
skills: None,
|
||||
}),
|
||||
active_cell: None,
|
||||
config,
|
||||
@@ -1738,6 +1742,16 @@ impl ChatWidget {
|
||||
items.push(UserInput::LocalImage { path });
|
||||
}
|
||||
|
||||
if let Some(skills) = self.bottom_pane.skills() {
|
||||
let skill_mentions = find_skill_mentions(&text, skills);
|
||||
for skill in skill_mentions {
|
||||
items.push(UserInput::Skill {
|
||||
name: skill.name.clone(),
|
||||
path: skill.path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.codex_op_tx
|
||||
.send(Op::UserInput { items })
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -2226,9 +2240,10 @@ impl ChatWidget {
|
||||
Some(preset.default_reasoning_effort),
|
||||
);
|
||||
SelectionItem {
|
||||
name: preset.display_name,
|
||||
name: preset.display_name.clone(),
|
||||
description,
|
||||
is_current: model == current_model,
|
||||
is_default: preset.is_default,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
@@ -2305,9 +2320,10 @@ impl ChatWidget {
|
||||
});
|
||||
})];
|
||||
items.push(SelectionItem {
|
||||
name: preset.display_name.to_string(),
|
||||
name: preset.display_name.clone(),
|
||||
description,
|
||||
is_current,
|
||||
is_default: preset.is_default,
|
||||
actions,
|
||||
dismiss_on_select: single_supported_effort,
|
||||
..Default::default()
|
||||
@@ -3460,5 +3476,33 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
});
|
||||
}
|
||||
|
||||
fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillMetadata> {
|
||||
outcome
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| SkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skill.path.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut matches: Vec<SkillMetadata> = Vec::new();
|
||||
for skill in skills {
|
||||
if seen.contains(&skill.name) {
|
||||
continue;
|
||||
}
|
||||
let needle = format!("${}", skill.name);
|
||||
if text.contains(&needle) {
|
||||
seen.insert(skill.name.clone());
|
||||
matches.push(skill.clone());
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui2/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Booting MCP server: alpha (0s • esc to interrupt) "
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" 100% context left · ? for shortcuts "
|
||||
+9
-8
@@ -5,13 +5,14 @@ expression: popup
|
||||
Select Model and Effort
|
||||
Access legacy models by running codex -m <model_name> or in your config.toml
|
||||
|
||||
› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast
|
||||
reasoning.
|
||||
2. gpt-5.1-codex Optimized for codex.
|
||||
3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
|
||||
capable.
|
||||
4. gpt-5.2 Latest frontier model with improvements across
|
||||
knowledge, reasoning and coding
|
||||
5. gpt-5.1 Broad world knowledge with strong general reasoning.
|
||||
› 1. gpt-5.1-codex-max (default) Latest Codex-optimized flagship for deep and
|
||||
fast reasoning.
|
||||
2. gpt-5.1-codex Optimized for codex.
|
||||
3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but
|
||||
less capable.
|
||||
4. gpt-5.2 Latest frontier model with improvements
|
||||
across knowledge, reasoning and coding
|
||||
5. gpt-5.1 Broad world knowledge with strong general
|
||||
reasoning.
|
||||
|
||||
Press enter to select reasoning effort, or esc to dismiss.
|
||||
|
||||
@@ -27,6 +27,8 @@ use codex_core::protocol::ExecCommandSource;
|
||||
use codex_core::protocol::ExecPolicyAmendment;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpStartupStatus;
|
||||
use codex_core::protocol::McpStartupUpdateEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
@@ -366,7 +368,6 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
auth_manager,
|
||||
models_manager: conversation_manager.get_models_manager(),
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
skills: None,
|
||||
is_first_run: true,
|
||||
model_family,
|
||||
};
|
||||
@@ -1932,7 +1933,7 @@ fn single_reasoning_option_skips_selection() {
|
||||
|
||||
let single_effort = vec![ReasoningEffortPreset {
|
||||
effort: ReasoningEffortConfig::High,
|
||||
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
|
||||
description: "Greater reasoning depth for complex or ambiguous problems".to_string(),
|
||||
}];
|
||||
let preset = ModelPreset {
|
||||
id: "model-with-single-reasoning".to_string(),
|
||||
@@ -2419,6 +2420,28 @@ fn status_widget_active_snapshot() {
|
||||
assert_snapshot!("status_widget_active", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_startup_header_booting_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||
chat.show_welcome_banner = false;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "mcp-1".into(),
|
||||
msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
|
||||
server: "alpha".into(),
|
||||
status: McpStartupStatus::Starting,
|
||||
}),
|
||||
});
|
||||
|
||||
let height = chat.desired_height(80);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
||||
.expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw chat widget");
|
||||
assert_snapshot!("mcp_startup_header_booting", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_event_updates_status_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||
|
||||
@@ -57,6 +57,7 @@ mod markdown;
|
||||
mod markdown_render;
|
||||
mod markdown_stream;
|
||||
mod model_migration;
|
||||
mod notifications;
|
||||
pub mod onboarding;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
@@ -268,6 +269,7 @@ pub async fn run_main(
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_target(false)
|
||||
.with_ansi(false)
|
||||
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
|
||||
.with_filter(env_filter());
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ use crate::selection_list::selection_option_row;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -57,11 +55,44 @@ impl MigrationMenuOption {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn migration_copy_for_config(migration_config_key: &str) -> ModelMigrationCopy {
|
||||
match migration_config_key {
|
||||
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => gpt5_migration_copy(),
|
||||
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => gpt_5_1_codex_max_migration_copy(),
|
||||
_ => gpt_5_1_codex_max_migration_copy(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,26 +100,7 @@ pub(crate) async fn run_model_migration_prompt(
|
||||
tui: &mut Tui,
|
||||
copy: ModelMigrationCopy,
|
||||
) -> ModelMigrationOutcome {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
let alt = AltScreenGuard::enter(tui);
|
||||
|
||||
let mut screen = ModelMigrationScreen::new(alt.tui.frame_requester(), copy);
|
||||
|
||||
let _ = alt.tui.draw(u16::MAX, |frame| {
|
||||
@@ -178,39 +190,15 @@ impl ModelMigrationScreen {
|
||||
return;
|
||||
}
|
||||
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||
{
|
||||
if is_ctrl_exit_combo(key_event) {
|
||||
self.exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.copy.can_opt_out {
|
||||
if matches!(key_event.code, KeyCode::Esc | KeyCode::Enter) {
|
||||
self.accept();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.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();
|
||||
}
|
||||
_ => {}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,110 +216,125 @@ impl WidgetRef for &ModelMigrationScreen {
|
||||
Clear.render(area, buf);
|
||||
|
||||
let mut column = ColumnRenderable::new();
|
||||
|
||||
column.push("");
|
||||
let mut heading = vec![Span::raw("> ")];
|
||||
heading.extend(self.copy.heading.clone());
|
||||
column.push(Line::from(heading));
|
||||
column.push(self.heading_line());
|
||||
column.push(Line::from(""));
|
||||
|
||||
for (idx, line) in self.copy.content.iter().enumerate() {
|
||||
if idx != 0 {
|
||||
column.push(Line::from(""));
|
||||
}
|
||||
|
||||
column.push(
|
||||
Paragraph::new(line.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
self.render_content(&mut column);
|
||||
if self.copy.can_opt_out {
|
||||
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)),
|
||||
);
|
||||
self.render_menu(&mut column);
|
||||
}
|
||||
|
||||
column.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn gpt_5_1_codex_max_migration_copy() -> ModelMigrationCopy {
|
||||
ModelMigrationCopy {
|
||||
heading: vec!["Codex just got an upgrade. Introducing gpt-5.1-codex-max".bold()],
|
||||
content: vec![
|
||||
Line::from(
|
||||
"Codex is now powered by gpt-5.1-codex-max, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work.",
|
||||
),
|
||||
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 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("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![
|
||||
"Learn more at ".into(),
|
||||
"https://openai.com/index/gpt-5-1-codex-max/"
|
||||
.cyan()
|
||||
.underlined(),
|
||||
".".into(),
|
||||
]),
|
||||
],
|
||||
can_opt_out: true,
|
||||
"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 gpt5_migration_copy() -> ModelMigrationCopy {
|
||||
ModelMigrationCopy {
|
||||
heading: vec!["Introducing our gpt-5.1 models".bold()],
|
||||
content: vec![
|
||||
Line::from(
|
||||
"We've upgraded our family of models supported in Codex to gpt-5.1, gpt-5.1-codex and gpt-5.1-codex-mini.",
|
||||
),
|
||||
Line::from(
|
||||
"You can continue using legacy models by specifying them directly with the -m option or in your config.toml.",
|
||||
),
|
||||
Line::from(vec![
|
||||
"Learn more at ".into(),
|
||||
"https://openai.com/index/gpt-5-1/".cyan().underlined(),
|
||||
".".into(),
|
||||
]),
|
||||
Line::from(vec!["Press enter to continue".dim()]),
|
||||
],
|
||||
can_opt_out: false,
|
||||
// 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'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ModelMigrationScreen;
|
||||
use super::gpt_5_1_codex_max_migration_copy;
|
||||
use super::migration_copy_for_config;
|
||||
use super::migration_copy_for_models;
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use insta::assert_snapshot;
|
||||
@@ -340,14 +343,20 @@ mod tests {
|
||||
#[test]
|
||||
fn prompt_snapshot() {
|
||||
let width: u16 = 60;
|
||||
let height: u16 = 20;
|
||||
let height: u16 = 28;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
|
||||
let screen = ModelMigrationScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
gpt_5_1_codex_max_migration_copy(),
|
||||
migration_copy_for_models(
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max".to_string(),
|
||||
Some("Latest Codex-optimized flagship for deep and fast reasoning.".to_string()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
{
|
||||
@@ -361,13 +370,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prompt_snapshot_gpt5_family() {
|
||||
let backend = VT100Backend::new(65, 12);
|
||||
let backend = VT100Backend::new(65, 22);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 65, 12));
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 65, 22));
|
||||
|
||||
let screen = ModelMigrationScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
migration_copy_for_config(HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG),
|
||||
migration_copy_for_models(
|
||||
"gpt-5",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1".to_string(),
|
||||
Some("Broad world knowledge with strong general reasoning.".to_string()),
|
||||
false,
|
||||
),
|
||||
);
|
||||
{
|
||||
let mut frame = terminal.get_frame();
|
||||
@@ -379,13 +394,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prompt_snapshot_gpt5_codex() {
|
||||
let backend = VT100Backend::new(60, 12);
|
||||
let backend = VT100Backend::new(60, 22);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 60, 12));
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 60, 22));
|
||||
|
||||
let screen = ModelMigrationScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
migration_copy_for_config(HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG),
|
||||
migration_copy_for_models(
|
||||
"gpt-5-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max".to_string(),
|
||||
Some("Latest Codex-optimized flagship for deep and fast reasoning.".to_string()),
|
||||
false,
|
||||
),
|
||||
);
|
||||
{
|
||||
let mut frame = terminal.get_frame();
|
||||
@@ -397,13 +418,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prompt_snapshot_gpt5_codex_mini() {
|
||||
let backend = VT100Backend::new(60, 12);
|
||||
let backend = VT100Backend::new(60, 22);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 60, 12));
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 60, 22));
|
||||
|
||||
let screen = ModelMigrationScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
migration_copy_for_config(HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG),
|
||||
migration_copy_for_models(
|
||||
"gpt-5-codex-mini",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-mini".to_string(),
|
||||
Some("Optimized for codex. Cheaper, faster, but less capable.".to_string()),
|
||||
false,
|
||||
),
|
||||
);
|
||||
{
|
||||
let mut frame = terminal.get_frame();
|
||||
@@ -417,7 +444,13 @@ mod tests {
|
||||
fn escape_key_accepts_prompt() {
|
||||
let mut screen = ModelMigrationScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
gpt_5_1_codex_max_migration_copy(),
|
||||
migration_copy_for_models(
|
||||
"gpt-old",
|
||||
"gpt-new",
|
||||
"gpt-new".to_string(),
|
||||
Some("Latest recommended model for better performance.".to_string()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
// Simulate pressing Escape
|
||||
@@ -437,7 +470,13 @@ mod tests {
|
||||
fn selecting_use_existing_model_rejects_upgrade() {
|
||||
let mut screen = ModelMigrationScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
gpt_5_1_codex_max_migration_copy(),
|
||||
migration_copy_for_models(
|
||||
"gpt-old",
|
||||
"gpt-new",
|
||||
"gpt-new".to_string(),
|
||||
Some("Latest recommended model for better performance.".to_string()),
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
screen.handle_key(KeyEvent::new(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
mod osc9;
|
||||
mod windows_toast;
|
||||
|
||||
use std::env;
|
||||
use std::io;
|
||||
|
||||
use codex_core::env::is_wsl;
|
||||
use osc9::Osc9Backend;
|
||||
use windows_toast::WindowsToastBackend;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationBackendKind {
|
||||
Osc9,
|
||||
WindowsToast,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DesktopNotificationBackend {
|
||||
Osc9(Osc9Backend),
|
||||
WindowsToast(WindowsToastBackend),
|
||||
}
|
||||
|
||||
impl DesktopNotificationBackend {
|
||||
pub fn osc9() -> Self {
|
||||
Self::Osc9(Osc9Backend)
|
||||
}
|
||||
|
||||
pub fn windows_toast() -> Self {
|
||||
Self::WindowsToast(WindowsToastBackend::default())
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> NotificationBackendKind {
|
||||
match self {
|
||||
DesktopNotificationBackend::Osc9(_) => NotificationBackendKind::Osc9,
|
||||
DesktopNotificationBackend::WindowsToast(_) => NotificationBackendKind::WindowsToast,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&mut self, message: &str) -> io::Result<()> {
|
||||
match self {
|
||||
DesktopNotificationBackend::Osc9(backend) => backend.notify(message),
|
||||
DesktopNotificationBackend::WindowsToast(backend) => backend.notify(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_backend() -> DesktopNotificationBackend {
|
||||
if should_use_windows_toasts() {
|
||||
tracing::info!(
|
||||
"Windows Terminal session detected under WSL; using Windows toast notifications"
|
||||
);
|
||||
DesktopNotificationBackend::windows_toast()
|
||||
} else {
|
||||
DesktopNotificationBackend::osc9()
|
||||
}
|
||||
}
|
||||
|
||||
fn should_use_windows_toasts() -> bool {
|
||||
is_wsl() && env::var_os("WT_SESSION").is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::NotificationBackendKind;
|
||||
use super::detect_backend;
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
|
||||
fn remove(key: &'static str) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.original {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn defaults_to_osc9_outside_wsl() {
|
||||
let _wsl_guard = EnvVarGuard::remove("WSL_DISTRO_NAME");
|
||||
let _wt_guard = EnvVarGuard::remove("WT_SESSION");
|
||||
assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn waits_for_windows_terminal() {
|
||||
let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu");
|
||||
let _wt_guard = EnvVarGuard::remove("WT_SESSION");
|
||||
assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[serial]
|
||||
fn selects_windows_toast_in_wsl_windows_terminal() {
|
||||
let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu");
|
||||
let _wt_guard = EnvVarGuard::set("WT_SESSION", "abc");
|
||||
assert_eq!(
|
||||
detect_backend().kind(),
|
||||
NotificationBackendKind::WindowsToast
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
#[serial]
|
||||
fn stays_on_osc9_outside_linux_even_with_wsl_env() {
|
||||
let _wsl_guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu");
|
||||
let _wt_guard = EnvVarGuard::set("WT_SESSION", "abc");
|
||||
assert_eq!(detect_backend().kind(), NotificationBackendKind::Osc9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::io::stdout;
|
||||
|
||||
use crossterm::Command;
|
||||
use ratatui::crossterm::execute;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Osc9Backend;
|
||||
|
||||
impl Osc9Backend {
|
||||
pub fn notify(&mut self, message: &str) -> io::Result<()> {
|
||||
execute!(stdout(), PostNotification(message.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Command that emits an OSC 9 desktop notification with a message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostNotification(pub String);
|
||||
|
||||
impl Command for PostNotification {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, "\x1b]9;{}\x07", self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute PostNotification using WinAPI; use ANSI instead",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use std::io;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
const APP_ID: &str = "Codex";
|
||||
const POWERSHELL_EXE: &str = "powershell.exe";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WindowsToastBackend {
|
||||
encoded_title: String,
|
||||
}
|
||||
|
||||
impl WindowsToastBackend {
|
||||
pub fn notify(&mut self, message: &str) -> io::Result<()> {
|
||||
let encoded_body = encode_argument(message);
|
||||
let encoded_command = build_encoded_command(&self.encoded_title, &encoded_body);
|
||||
spawn_powershell(encoded_command)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowsToastBackend {
|
||||
fn default() -> Self {
|
||||
WindowsToastBackend {
|
||||
encoded_title: encode_argument(APP_ID),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_powershell(encoded_command: String) -> io::Result<()> {
|
||||
let mut command = Command::new(POWERSHELL_EXE);
|
||||
command
|
||||
.arg("-NoProfile")
|
||||
.arg("-NoLogo")
|
||||
.arg("-EncodedCommand")
|
||||
.arg(encoded_command)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let status = command.status()?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"{POWERSHELL_EXE} exited with status {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_encoded_command(encoded_title: &str, encoded_body: &str) -> String {
|
||||
let script = build_ps_script(encoded_title, encoded_body);
|
||||
encode_script_for_powershell(&script)
|
||||
}
|
||||
|
||||
fn build_ps_script(encoded_title: &str, encoded_body: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
$encoding = [System.Text.Encoding]::UTF8
|
||||
$titleText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_title}"))
|
||||
$bodyText = $encoding.GetString([System.Convert]::FromBase64String("{encoded_body}"))
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||
$doc = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
||||
$textNodes = $doc.GetElementsByTagName("text")
|
||||
$textNodes.Item(0).AppendChild($doc.CreateTextNode($titleText)) | Out-Null
|
||||
$textNodes.Item(1).AppendChild($doc.CreateTextNode($bodyText)) | Out-Null
|
||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($doc)
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Codex').Show($toast)
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_script_for_powershell(script: &str) -> String {
|
||||
let mut wide: Vec<u8> = Vec::with_capacity((script.len() + 1) * 2);
|
||||
for unit in script.encode_utf16() {
|
||||
let bytes = unit.to_le_bytes();
|
||||
wide.extend_from_slice(&bytes);
|
||||
}
|
||||
BASE64.encode(wide)
|
||||
}
|
||||
|
||||
fn encode_argument(value: &str) -> String {
|
||||
BASE64.encode(escape_for_xml(value))
|
||||
}
|
||||
|
||||
pub fn escape_for_xml(input: &str) -> String {
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::encode_script_for_powershell;
|
||||
use super::escape_for_xml;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn escapes_xml_entities() {
|
||||
assert_eq!(escape_for_xml("5 > 3"), "5 > 3");
|
||||
assert_eq!(escape_for_xml("a & b"), "a & b");
|
||||
assert_eq!(escape_for_xml("<tag>"), "<tag>");
|
||||
assert_eq!(escape_for_xml("\"quoted\""), ""quoted"");
|
||||
assert_eq!(escape_for_xml("single 'quote'"), "single 'quote'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_safe_text_unmodified() {
|
||||
assert_eq!(escape_for_xml("codex"), "codex");
|
||||
assert_eq!(escape_for_xml("multi word text"), "multi word text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_utf16le_for_powershell() {
|
||||
assert_eq!(encode_script_for_powershell("A"), "QQA=");
|
||||
}
|
||||
}
|
||||
@@ -195,11 +195,24 @@ impl OnboardingScreen {
|
||||
pub fn should_exit(&self) -> bool {
|
||||
self.should_exit
|
||||
}
|
||||
|
||||
fn is_api_key_entry_active(&self) -> bool {
|
||||
self.steps.iter().any(|step| {
|
||||
if let Step::Auth(widget) = step {
|
||||
return widget
|
||||
.sign_in_state
|
||||
.read()
|
||||
.is_ok_and(|g| matches!(&*g, SignInState::ApiKeyEntry(_)));
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for OnboardingScreen {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
let is_api_key_entry_active = self.is_api_key_entry_active();
|
||||
let should_quit = match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
@@ -211,32 +224,33 @@ impl KeyboardHandler for OnboardingScreen {
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
} => true,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
if self.is_auth_in_progress() {
|
||||
// If the user cancels the auth menu, exit the app rather than
|
||||
// leave the user at a prompt in an unauthed state.
|
||||
self.should_exit = true;
|
||||
}
|
||||
self.is_done = true;
|
||||
}
|
||||
_ => {
|
||||
if let Some(Step::Welcome(widget)) = self
|
||||
.steps
|
||||
.iter_mut()
|
||||
.find(|step| matches!(step, Step::Welcome(_)))
|
||||
{
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_key_event(key_event);
|
||||
}
|
||||
}
|
||||
} => !is_api_key_entry_active,
|
||||
_ => false,
|
||||
};
|
||||
if should_quit {
|
||||
if self.is_auth_in_progress() {
|
||||
// If the user cancels the auth menu, exit the app rather than
|
||||
// leave the user at a prompt in an unauthed state.
|
||||
self.should_exit = true;
|
||||
}
|
||||
self.is_done = true;
|
||||
} else {
|
||||
if let Some(Step::Welcome(widget)) = self
|
||||
.steps
|
||||
.iter_mut()
|
||||
.find(|step| matches!(step, Step::Welcome(_)))
|
||||
{
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_key_event(key_event);
|
||||
}
|
||||
}
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -2,14 +2,15 @@
|
||||
source: tui2/src/model_migration.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> Codex just got an upgrade. Introducing gpt-5.1-codex-max
|
||||
> Try gpt-5.1-codex-max
|
||||
|
||||
Codex is now powered by gpt-5.1-codex-max, our latest
|
||||
frontier agentic coding model. It is smarter and faster
|
||||
than its predecessors and capable of long-running
|
||||
project-scale work.
|
||||
We recommend switching from gpt-5.1-codex-mini to
|
||||
gpt-5.1-codex-max.
|
||||
|
||||
Learn more at https://openai.com/index/gpt-5-1-codex-max/.
|
||||
Latest Codex-optimized flagship for deep and fast
|
||||
reasoning.
|
||||
|
||||
You can continue using gpt-5.1-codex-mini if you prefer.
|
||||
|
||||
Choose how you'd like Codex to proceed.
|
||||
|
||||
|
||||
+5
-7
@@ -2,14 +2,12 @@
|
||||
source: tui2/src/model_migration.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> Introducing our gpt-5.1 models
|
||||
> Try gpt-5.1-codex-max
|
||||
|
||||
We've upgraded our family of models supported in Codex to
|
||||
gpt-5.1, gpt-5.1-codex and gpt-5.1-codex-mini.
|
||||
We recommend switching from gpt-5-codex to
|
||||
gpt-5.1-codex-max.
|
||||
|
||||
You can continue using legacy models by specifying them
|
||||
directly with the -m option or in your config.toml.
|
||||
|
||||
Learn more at https://openai.com/index/gpt-5-1/.
|
||||
Latest Codex-optimized flagship for deep and fast
|
||||
reasoning.
|
||||
|
||||
Press enter to continue
|
||||
|
||||
+4
-7
@@ -2,14 +2,11 @@
|
||||
source: tui2/src/model_migration.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> Introducing our gpt-5.1 models
|
||||
> Try gpt-5.1-codex-mini
|
||||
|
||||
We've upgraded our family of models supported in Codex to
|
||||
gpt-5.1, gpt-5.1-codex and gpt-5.1-codex-mini.
|
||||
We recommend switching from gpt-5-codex-mini to
|
||||
gpt-5.1-codex-mini.
|
||||
|
||||
You can continue using legacy models by specifying them
|
||||
directly with the -m option or in your config.toml.
|
||||
|
||||
Learn more at https://openai.com/index/gpt-5-1/.
|
||||
Optimized for codex. Cheaper, faster, but less capable.
|
||||
|
||||
Press enter to continue
|
||||
|
||||
+3
-7
@@ -2,14 +2,10 @@
|
||||
source: tui2/src/model_migration.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> Introducing our gpt-5.1 models
|
||||
> Try gpt-5.1
|
||||
|
||||
We've upgraded our family of models supported in Codex to
|
||||
gpt-5.1, gpt-5.1-codex and gpt-5.1-codex-mini.
|
||||
We recommend switching from gpt-5 to gpt-5.1.
|
||||
|
||||
You can continue using legacy models by specifying them
|
||||
directly with the -m option or in your config.toml.
|
||||
|
||||
Learn more at https://openai.com/index/gpt-5-1/.
|
||||
Broad world knowledge with strong general reasoning.
|
||||
|
||||
Press enter to continue
|
||||
|
||||
+82
-52
@@ -39,6 +39,9 @@ use tokio_stream::Stream;
|
||||
pub use self::frame_requester::FrameRequester;
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::notifications::DesktopNotificationBackend;
|
||||
use crate::notifications::NotificationBackendKind;
|
||||
use crate::notifications::detect_backend;
|
||||
#[cfg(unix)]
|
||||
use crate::tui::job_control::SUSPEND_KEY;
|
||||
#[cfg(unix)]
|
||||
@@ -173,6 +176,7 @@ pub struct Tui {
|
||||
// True when terminal/tab is focused; updated internally from crossterm events
|
||||
terminal_focused: Arc<AtomicBool>,
|
||||
enhanced_keys_supported: bool,
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
@@ -198,6 +202,7 @@ impl Tui {
|
||||
alt_screen_active: Arc::new(AtomicBool::new(false)),
|
||||
terminal_focused: Arc::new(AtomicBool::new(true)),
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,11 +217,47 @@ impl Tui {
|
||||
/// Emit a desktop notification now if the terminal is unfocused.
|
||||
/// Returns true if a notification was posted.
|
||||
pub fn notify(&mut self, message: impl AsRef<str>) -> bool {
|
||||
if !self.terminal_focused.load(Ordering::Relaxed) {
|
||||
let _ = execute!(stdout(), PostNotification(message.as_ref().to_string()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
if self.terminal_focused.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(backend) = self.notification_backend.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let message = message.as_ref().to_string();
|
||||
match backend.notify(&message) {
|
||||
Ok(()) => true,
|
||||
Err(err) => match backend.kind() {
|
||||
NotificationBackendKind::WindowsToast => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"Failed to send Windows toast notification; falling back to OSC 9"
|
||||
);
|
||||
self.notification_backend = Some(DesktopNotificationBackend::osc9());
|
||||
if let Some(backend) = self.notification_backend.as_mut() {
|
||||
if let Err(osc_err) = backend.notify(&message) {
|
||||
tracing::warn!(
|
||||
error = %osc_err,
|
||||
"Failed to emit OSC 9 notification after toast fallback; \
|
||||
disabling future notifications"
|
||||
);
|
||||
self.notification_backend = None;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
NotificationBackendKind::Osc9 => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to emit OSC 9 notification; disabling future notifications"
|
||||
);
|
||||
self.notification_backend = None;
|
||||
false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,33 +277,42 @@ impl Tui {
|
||||
let event_stream = async_stream::stream! {
|
||||
loop {
|
||||
select! {
|
||||
Some(Ok(event)) = crossterm_events.next() => {
|
||||
match event {
|
||||
Event::Key(key_event) => {
|
||||
#[cfg(unix)]
|
||||
if SUSPEND_KEY.is_press(key_event) {
|
||||
let _ = suspend_context.suspend(&alt_screen_active);
|
||||
// We continue here after resume.
|
||||
yield TuiEvent::Draw;
|
||||
continue;
|
||||
event_result = crossterm_events.next() => {
|
||||
match event_result {
|
||||
Some(Ok(event)) => {
|
||||
match event {
|
||||
Event::Key(key_event) => {
|
||||
#[cfg(unix)]
|
||||
if SUSPEND_KEY.is_press(key_event) {
|
||||
let _ = suspend_context.suspend(&alt_screen_active);
|
||||
// We continue here after resume.
|
||||
yield TuiEvent::Draw;
|
||||
continue;
|
||||
}
|
||||
yield TuiEvent::Key(key_event);
|
||||
}
|
||||
Event::Resize(_, _) => {
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
Event::Paste(pasted) => {
|
||||
yield TuiEvent::Paste(pasted);
|
||||
}
|
||||
Event::FocusGained => {
|
||||
terminal_focused.store(true, Ordering::Relaxed);
|
||||
crate::terminal_palette::requery_default_colors();
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
Event::FocusLost => {
|
||||
terminal_focused.store(false, Ordering::Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
yield TuiEvent::Key(key_event);
|
||||
}
|
||||
Event::Resize(_, _) => {
|
||||
yield TuiEvent::Draw;
|
||||
Some(Err(_)) | None => {
|
||||
// Exit the loop in case of broken pipe as we will never
|
||||
// recover from it
|
||||
break;
|
||||
}
|
||||
Event::Paste(pasted) => {
|
||||
yield TuiEvent::Paste(pasted);
|
||||
}
|
||||
Event::FocusGained => {
|
||||
terminal_focused.store(true, Ordering::Relaxed);
|
||||
crate::terminal_palette::requery_default_colors();
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
Event::FocusLost => {
|
||||
terminal_focused.store(false, Ordering::Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result = draw_rx.recv() => {
|
||||
@@ -275,7 +325,9 @@ impl Tui {
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
// Sender dropped; stop emitting draws from this source.
|
||||
// Sender dropped. This stream likely outlived its owning `Tui`;
|
||||
// exit to avoid spinning on a permanently-closed receiver.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,25 +469,3 @@ impl Tui {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Command that emits an OSC 9 desktop notification with a message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostNotification(pub String);
|
||||
|
||||
impl Command for PostNotification {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, "\x1b]9;{}\x07", self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute PostNotification using WinAPI; use ANSI instead",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user