diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 912adba5c7..56a852fd99 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -39,8 +39,8 @@ pub enum ConfigEdit { SetNoticeHideModelMigrationPrompt(String, bool), /// Record that a migration prompt was shown for an old->new model mapping. RecordModelMigrationSeen { from: String, to: String }, - /// Record that an availability NUX message was displayed. - RecordAvailabilityNuxDisplay { message: String }, + /// Record that an availability NUX was displayed for a model. + RecordAvailabilityNuxDisplay { model: String }, /// Replace the entire `[mcp_servers]` table. ReplaceMcpServers(BTreeMap), /// Set or clear a skill config entry under `[[skills.config]]`. @@ -333,7 +333,7 @@ impl ConfigDocument { &[Notice::TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), - ConfigEdit::RecordAvailabilityNuxDisplay { message } => { + ConfigEdit::RecordAvailabilityNuxDisplay { model } => { let resolved = self.scoped_segments( Scope::Global, &[Notice::TABLE_KEY, "availability_nux_display_counts"], @@ -342,10 +342,10 @@ impl ConfigDocument { return Ok(false); }; let current = table - .get(message.as_str()) + .get(model.as_str()) .and_then(toml_edit::Item::as_integer) .unwrap_or(0); - table[message.as_str()] = value(current + 1); + table[model.as_str()] = value(current + 1); Ok(true) } ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( @@ -805,9 +805,9 @@ impl ConfigEditsBuilder { self } - pub fn record_availability_nux_display(mut self, message: &str) -> Self { + pub fn record_availability_nux_display(mut self, model: &str) -> Self { self.edits.push(ConfigEdit::RecordAvailabilityNuxDisplay { - message: message.to_string(), + model: model.to_string(), }); self } @@ -1468,7 +1468,7 @@ gpt-5 = "gpt-5.1" existing = "value" [notice.availability_nux_display_counts] -"*New* Spark is now available to you." = 1 +"spark" = 1 "#, ) .expect("seed"); @@ -1476,7 +1476,7 @@ existing = "value" codex_home, None, &[ConfigEdit::RecordAvailabilityNuxDisplay { - message: "*New* Spark is now available to you.".to_string(), + model: "spark".to_string(), }], ) .expect("persist"); @@ -1487,7 +1487,7 @@ existing = "value" existing = "value" [notice.availability_nux_display_counts] -"*New* Spark is now available to you." = 2 +"spark" = 2 "#; assert_eq!(contents, expected); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1dee7f78e1..cf77f6aa80 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2743,16 +2743,16 @@ impl App { )); } } - AppEvent::PersistAvailabilityNuxDisplayed { message } => { + AppEvent::PersistAvailabilityNuxDisplayed { model } => { let count = self .config .notices .availability_nux_display_counts - .entry(message.clone()) + .entry(model.clone()) .or_default(); *count += 1; if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .record_availability_nux_display(message.as_str()) + .record_availability_nux_display(model.as_str()) .apply() .await { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 262299fc37..271c6acc75 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -337,7 +337,7 @@ pub(crate) enum AppEvent { /// Persist that an availability NUX was displayed. PersistAvailabilityNuxDisplayed { - message: String, + model: String, }, /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f420d03ac9..cb10cf91ff 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1179,31 +1179,31 @@ impl ChatWidget { displayed_startup_tips, ); self.apply_session_info_cell(session_info_cell); - let displayed_availability_nux_messages: Vec = if self.show_welcome_banner { + let displayed_availability_nux_models: Vec = if self.show_welcome_banner { startup_tips .first_session_tips .iter() - .filter_map(StartupTip::availability_nux_message) + .filter_map(StartupTip::availability_nux_model) .map(str::to_string) .collect() } else { startup_tips .selected_tip .iter() - .filter_map(StartupTip::availability_nux_message) + .filter_map(StartupTip::availability_nux_model) .map(str::to_string) .collect() }; - for message in displayed_availability_nux_messages { + for model in displayed_availability_nux_models { let count = self .config .notices .availability_nux_display_counts - .entry(message.clone()) + .entry(model.clone()) .or_default(); *count += 1; self.app_event_tx - .send(AppEvent::PersistAvailabilityNuxDisplayed { message }); + .send(AppEvent::PersistAvailabilityNuxDisplayed { model }); } if let Some(messages) = initial_messages { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 180c7b346a..e299c24e19 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1892,9 +1892,7 @@ async fn session_configured_first_session_shows_model_nux_and_increments_display chat.config .notices .availability_nux_display_counts - .get( - "*New* You're using **GPT Test**. Fast, high-reliability coding model. Use /model to compare or switch anytime.", - ) + .get("gpt-5.1") ); } @@ -1938,7 +1936,7 @@ async fn session_configured_ineligible_model_does_not_increment_display_count() chat.config .notices .availability_nux_display_counts - .get("*New* Spark is now available to you.") + .get("gpt-5.1") ); } diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 471b04698c..50b59a06d1 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -5,7 +5,6 @@ use codex_protocol::openai_models::ModelPreset; use lazy_static::lazy_static; use rand::Rng; use std::collections::BTreeMap; -use std::collections::HashSet; const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; @@ -53,20 +52,20 @@ fn experimental_tooltips() -> Vec<&'static str> { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum StartupTip { Generic(String), - AvailabilityNux { message: String }, + AvailabilityNux { model: String, message: String }, } impl StartupTip { pub(crate) fn message(&self) -> String { match self { - Self::Generic(message) | Self::AvailabilityNux { message } => message.clone(), + Self::Generic(message) | Self::AvailabilityNux { message, .. } => message.clone(), } } - pub(crate) fn availability_nux_message(&self) -> Option<&str> { + pub(crate) fn availability_nux_model(&self) -> Option<&str> { match self { Self::Generic(_) => None, - Self::AvailabilityNux { message } => Some(message.as_str()), + Self::AvailabilityNux { model, .. } => Some(model.as_str()), } } } @@ -188,25 +187,31 @@ fn availability_nux_tips( models: &[ModelPreset], availability_nux_display_counts: &BTreeMap, ) -> Vec { - let mut seen_messages = HashSet::new(); - models .iter() - .filter_map(|preset| preset.availability_nux.as_ref()) - .filter(|availability_nux| { + .filter_map(|preset| { + preset + .availability_nux + .as_ref() + .map(|availability_nux| (preset.model.as_str(), availability_nux)) + }) + .filter(|(model, _)| { availability_nux_display_counts - .get(&availability_nux.message) + .get(*model) .copied() .unwrap_or(0) < 4 }) - .filter(|availability_nux| seen_messages.insert(availability_nux.message.clone())) - .map(startup_tip_from_availability_nux) + .map(|(model, availability_nux)| startup_tip_from_availability_nux(model, availability_nux)) .collect() } -fn startup_tip_from_availability_nux(availability_nux: &ModelAvailabilityNux) -> StartupTip { +fn startup_tip_from_availability_nux( + model: &str, + availability_nux: &ModelAvailabilityNux, +) -> StartupTip { StartupTip::AvailabilityNux { + model: model.to_string(), message: availability_nux.message.clone(), } } @@ -372,11 +377,16 @@ mod tests { use rand::SeedableRng; use rand::rngs::StdRng; - fn model_preset(availability_nux: Option<&str>, description: &str) -> ModelPreset { + fn model_preset( + model: &str, + display_name: &str, + availability_nux: Option<&str>, + description: &str, + ) -> ModelPreset { ModelPreset { - id: "gpt-test".to_string(), - model: "gpt-test".to_string(), - display_name: "GPT Test".to_string(), + id: model.to_string(), + model: model.to_string(), + display_name: display_name.to_string(), description: description.to_string(), default_reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium, supported_reasoning_efforts: vec![], @@ -512,6 +522,8 @@ content = "This is a test announcement" #[test] fn first_session_eligible_model_returns_all_availability_nux_tips() { let preset = model_preset( + "gpt-test", + "GPT Test", Some("*New* Spark is now available to you."), "Fast, high-reliability coding model.", ); @@ -529,6 +541,7 @@ content = "This is a test announcement" assert_eq!( StartupTips { first_session_tips: vec![StartupTip::AvailabilityNux { + model: "gpt-test".to_string(), message: "*New* Spark is now available to you.".to_string(), }], selected_tip: None, @@ -539,7 +552,7 @@ content = "This is a test announcement" #[test] fn first_session_ineligible_model_skips_tip() { - let preset = model_preset(None, ""); + let preset = model_preset("gpt-test", "GPT Test", None, ""); let mut rng = StdRng::seed_from_u64(1); let tips = get_startup_tips_with_rng( @@ -555,18 +568,21 @@ content = "This is a test announcement" } #[test] - fn first_session_dedupes_multiple_availability_nuxes_by_message() { + fn first_session_returns_multiple_availability_nuxes() { let mut rng = StdRng::seed_from_u64(1); let models = vec![ - model_preset(Some("*New* Spark is now available to you."), ""), - model_preset(Some("*New* Spark is now available to you."), ""), - ModelPreset { - model: "gpt-other".to_string(), - availability_nux: Some(ModelAvailabilityNux { - message: "*New* Canvas is now available to you.".to_string(), - }), - ..model_preset(None, "") - }, + model_preset( + "spark", + "Spark", + Some("*New* Spark is now available to you."), + "", + ), + model_preset( + "canvas", + "Canvas", + Some("*New* Canvas is now available to you."), + "", + ), ]; let tips = get_startup_tips_with_rng( @@ -582,9 +598,11 @@ content = "This is a test announcement" StartupTips { first_session_tips: vec![ StartupTip::AvailabilityNux { + model: "spark".to_string(), message: "*New* Spark is now available to you.".to_string(), }, StartupTip::AvailabilityNux { + model: "canvas".to_string(), message: "*New* Canvas is now available to you.".to_string(), }, ], @@ -596,7 +614,12 @@ content = "This is a test announcement" #[test] fn later_session_can_select_availability_nux_from_weighted_pool() { - let preset = model_preset(Some("*New* Spark is now available to you."), ""); + let preset = model_preset( + "gpt-test", + "GPT Test", + Some("*New* Spark is now available to you."), + "", + ); let mut rng = StdRng::seed_from_u64(5); let tips = get_startup_tips_with_rng( @@ -612,6 +635,7 @@ content = "This is a test announcement" StartupTips { first_session_tips: Vec::new(), selected_tip: Some(StartupTip::AvailabilityNux { + model: "gpt-test".to_string(), message: "*New* Spark is now available to you.".to_string(), }), }, @@ -621,8 +645,13 @@ content = "This is a test announcement" #[test] fn later_session_count_limit_disables_availability_nux() { - let preset = model_preset(Some("*New* Spark is now available to you."), ""); - let counts = BTreeMap::from([("*New* Spark is now available to you.".to_string(), 4)]); + let preset = model_preset( + "gpt-test", + "GPT Test", + Some("*New* Spark is now available to you."), + "", + ); + let counts = BTreeMap::from([("gpt-test".to_string(), 4)]); let mut rng = StdRng::seed_from_u64(5); let tips = get_startup_tips_with_rng( @@ -645,7 +674,12 @@ content = "This is a test announcement" #[test] fn later_session_eligible_model_includes_announcement_and_generic_candidates() { - let preset = model_preset(Some("*New* Spark is now available to you."), ""); + let preset = model_preset( + "gpt-test", + "GPT Test", + Some("*New* Spark is now available to you."), + "", + ); let mut saw_announcement = false; let mut saw_plan_tip = false; let mut saw_random_tip = false;