use codex_features::FEATURES; use codex_protocol::account::PlanType; use lazy_static::lazy_static; use rand::Rng; const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; const IS_MACOS: bool = cfg!(target_os = "macos"); const IS_WINDOWS: bool = cfg!(target_os = "windows"); const PAID_TOOLTIP: &str = "*New* Try the **Codex App** with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true"; const PAID_TOOLTIP_WINDOWS: &str = "*New* Try the **Codex App**, now available on **Windows**, with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true"; const PAID_TOOLTIP_NON_MAC: &str = "*New* 2x rate limits until *April 2nd*."; const FAST_TOOLTIP: &str = "*New* Use **/fast** to enable our fastest inference at 2X plan usage."; const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true"; const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex."; const FREE_GO_TOOLTIP: &str = "*New* For a limited time, Codex is included in your plan for free – let’s build together."; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); lazy_static! { static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS .lines() .map(str::trim) .filter(|line| { if line.is_empty() || line.starts_with('#') { return false; } if !IS_MACOS && !IS_WINDOWS && line.contains("codex app") { return false; } true }) .collect(); static ref ALL_TOOLTIPS: Vec<&'static str> = { let mut tips = Vec::new(); tips.extend(TOOLTIPS.iter().copied()); tips.extend(experimental_tooltips()); tips }; } fn experimental_tooltips() -> Vec<&'static str> { FEATURES .iter() .filter_map(|spec| spec.stage.experimental_announcement()) .collect() } /// Pick a random tooltip to show to the user when starting Codex. pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Option { let mut rng = rand::rng(); if let Some(announcement) = announcement::fetch_announcement_tip() { return Some(announcement); } // Leave small chance for a random tooltip to be shown. if rng.random_ratio(8, 10) { match plan { Some(PlanType::Plus) | Some(PlanType::Business) | Some(PlanType::Team) | Some(PlanType::Enterprise) | Some(PlanType::Pro) => { return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string()); } Some(PlanType::Go) | Some(PlanType::Free) => { return Some(FREE_GO_TOOLTIP.to_string()); } _ => { let tooltip = if IS_MACOS { OTHER_TOOLTIP } else { OTHER_TOOLTIP_NON_MAC }; return Some(tooltip.to_string()); } } } pick_tooltip(&mut rng).map(str::to_string) } fn paid_app_tooltip() -> &'static str { if IS_MACOS { PAID_TOOLTIP } else if IS_WINDOWS { PAID_TOOLTIP_WINDOWS } else { PAID_TOOLTIP_NON_MAC } } /// Paid users spend most startup sessions in a dedicated promo slot rather than the /// generic random tip pool. Keep this business logic explicit: we currently split /// that slot between the app promo and Fast mode, but suppress the Fast promo once /// the user already has Fast mode enabled. fn pick_paid_tooltip(rng: &mut R, fast_mode_enabled: bool) -> &'static str { if fast_mode_enabled || rng.random_bool(0.5) { paid_app_tooltip() } else { FAST_TOOLTIP } } fn pick_tooltip(rng: &mut R) -> Option<&'static str> { if ALL_TOOLTIPS.is_empty() { None } else { ALL_TOOLTIPS .get(rng.random_range(0..ALL_TOOLTIPS.len())) .copied() } } pub(crate) mod announcement { use crate::tooltips::ANNOUNCEMENT_TIP_URL; use crate::version::CODEX_CLI_VERSION; use chrono::NaiveDate; use chrono::Utc; use regex_lite::Regex; use serde::Deserialize; use std::sync::OnceLock; use std::thread; use std::time::Duration; static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); /// Prewarm the cache of the announcement tip. pub(crate) fn prewarm() { let _ = thread::spawn(|| ANNOUNCEMENT_TIP.get_or_init(init_announcement_tip_in_thread)); } /// Fetch the announcement tip, return None if the prewarm is not done yet. pub(crate) fn fetch_announcement_tip() -> Option { ANNOUNCEMENT_TIP .get() .cloned() .flatten() .and_then(|raw| parse_announcement_tip_toml(&raw)) } #[derive(Debug, Deserialize)] struct AnnouncementTipRaw { content: String, from_date: Option, to_date: Option, version_regex: Option, target_app: Option, } #[derive(Debug, Deserialize)] struct AnnouncementTipDocument { announcements: Vec, } #[derive(Debug)] struct AnnouncementTip { content: String, from_date: Option, to_date: Option, version_regex: Option, target_app: String, } fn init_announcement_tip_in_thread() -> Option { thread::spawn(blocking_init_announcement_tip) .join() .ok() .flatten() } fn blocking_init_announcement_tip() -> Option { // Avoid system proxy detection to prevent macOS system-configuration panics (#8912). let client = reqwest::blocking::Client::builder() .no_proxy() .build() .ok()?; let response = client .get(ANNOUNCEMENT_TIP_URL) .timeout(Duration::from_millis(2000)) .send() .ok()?; response.error_for_status().ok()?.text().ok() } pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option { let announcements = toml::from_str::(text) .map(|doc| doc.announcements) .or_else(|_| toml::from_str::>(text)) .ok()?; let mut latest_match = None; let today = Utc::now().date_naive(); for raw in announcements { let Some(tip) = AnnouncementTip::from_raw(raw) else { continue; }; if tip.version_matches(CODEX_CLI_VERSION) && tip.date_matches(today) && tip.target_app == "cli" { latest_match = Some(tip.content); } } latest_match } impl AnnouncementTip { fn from_raw(raw: AnnouncementTipRaw) -> Option { let content = raw.content.trim(); if content.is_empty() { return None; } let from_date = match raw.from_date { Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?), None => None, }; let to_date = match raw.to_date { Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?), None => None, }; let version_regex = match raw.version_regex { Some(pattern) => Some(Regex::new(&pattern).ok()?), None => None, }; Some(Self { content: content.to_string(), from_date, to_date, version_regex, target_app: raw.target_app.unwrap_or("cli".to_string()).to_lowercase(), }) } fn version_matches(&self, version: &str) -> bool { self.version_regex .as_ref() .is_none_or(|regex| regex.is_match(version)) } fn date_matches(&self, today: NaiveDate) -> bool { if let Some(from) = self.from_date && today < from { return false; } if let Some(to) = self.to_date && today >= to { return false; } true } } } #[cfg(test)] mod tests { use super::*; use crate::tooltips::announcement::parse_announcement_tip_toml; use rand::SeedableRng; use rand::rngs::StdRng; #[test] fn random_tooltip_returns_some_tip_when_available() { let mut rng = StdRng::seed_from_u64(42); assert!(pick_tooltip(&mut rng).is_some()); } #[test] fn random_tooltip_is_reproducible_with_seed() { let expected = { let mut rng = StdRng::seed_from_u64(7); pick_tooltip(&mut rng) }; let mut rng = StdRng::seed_from_u64(7); assert_eq!(expected, pick_tooltip(&mut rng)); } #[test] fn paid_tooltip_pool_rotates_between_promos() { let mut seen = std::collections::BTreeSet::new(); for seed in 0..32 { let mut rng = StdRng::seed_from_u64(seed); seen.insert(pick_paid_tooltip(&mut rng, false)); } let expected = std::collections::BTreeSet::from([paid_app_tooltip(), FAST_TOOLTIP]); assert_eq!(seen, expected); } #[test] fn paid_tooltip_pool_skips_fast_when_fast_mode_is_enabled() { let mut seen = std::collections::BTreeSet::new(); for seed in 0..8 { let mut rng = StdRng::seed_from_u64(seed); seen.insert(pick_paid_tooltip(&mut rng, true)); } let expected = std::collections::BTreeSet::from([paid_app_tooltip()]); assert_eq!(seen, expected); assert!(!seen.contains(&FAST_TOOLTIP)); } #[test] fn announcement_tip_toml_picks_last_matching() { let toml = r#" [[announcements]] content = "first" from_date = "2000-01-01" [[announcements]] content = "latest match" version_regex = ".*" target_app = "cli" [[announcements]] content = "should not match" to_date = "2000-01-01" "#; assert_eq!( Some("latest match".to_string()), parse_announcement_tip_toml(toml) ); let toml = r#" [[announcements]] content = "first" from_date = "2000-01-01" target_app = "cli" [[announcements]] content = "latest match" version_regex = ".*" [[announcements]] content = "should not match" to_date = "2000-01-01" "#; assert_eq!( Some("latest match".to_string()), parse_announcement_tip_toml(toml) ); } #[test] fn announcement_tip_toml_picks_no_match() { let toml = r#" [[announcements]] content = "first" from_date = "2000-01-01" to_date = "2000-01-05" [[announcements]] content = "latest match" version_regex = "invalid_version_name" [[announcements]] content = "should not match either " target_app = "vsce" "#; assert_eq!(None, parse_announcement_tip_toml(toml)); } #[test] fn announcement_tip_toml_bad_deserialization() { let toml = r#" [[announcements]] content = 123 from_date = "2000-01-01" "#; assert_eq!(None, parse_announcement_tip_toml(toml)); } #[test] fn announcement_tip_toml_parse_comments() { let toml = r#" # Example announcement tips for Codex TUI. # Each [[announcements]] entry is evaluated in order; the last matching one is shown. # Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive. # version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions. # target_app specify which app should display the announcement (cli, vsce, ...). [[announcements]] content = "Welcome to Codex! Check out the new onboarding flow." from_date = "2024-10-01" to_date = "2024-10-15" target_app = "cli" version_regex = "^0\\.0\\.0$" [[announcements]] content = "This is a test announcement" "#; assert_eq!( Some("This is a test announcement".to_string()), parse_announcement_tip_toml(toml) ); } }