diff --git a/announcement_tip.toml b/announcement_tip.toml new file mode 100644 index 00000000000..3ad4a765904 --- /dev/null +++ b/announcement_tip.toml @@ -0,0 +1,16 @@ +# 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" + +[[announcements]] +content = "This is a test announcement" +version_regex = "^0\\.0\\.0$" +to_date = "2026-01-10" diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b487c6aeff5..4e6f5987754 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -816,11 +816,11 @@ pub(crate) fn padded_emoji(emoji: &str) -> String { #[derive(Debug)] struct TooltipHistoryCell { - tip: &'static str, + tip: String, } impl TooltipHistoryCell { - fn new(tip: &'static str) -> Self { + fn new(tip: String) -> Self { Self { tip } } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6b784affce6..4eb487f1daf 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -97,7 +97,6 @@ pub use markdown_render::render_markdown_text; pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; use std::io::Write as _; - // (tests access modules directly within the crate) pub async fn run_main( @@ -344,6 +343,8 @@ async fn run_ratatui_app( ) -> color_eyre::Result { color_eyre::install()?; + tooltips::announcement::prewarm(); + // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. // Chain to the previous hook so users still get a rich panic report diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 8a3a1c1e969..402a18ba2b1 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -2,15 +2,10 @@ use codex_core::features::FEATURES; use lazy_static::lazy_static; use rand::Rng; +const ANNOUNCEMENT_TIP_URL: &str = + "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); -fn beta_tooltips() -> Vec<&'static str> { - FEATURES - .iter() - .filter_map(|spec| spec.stage.beta_announcement()) - .collect() -} - lazy_static! { static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS .lines() @@ -25,9 +20,20 @@ lazy_static! { }; } -pub(crate) fn random_tooltip() -> Option<&'static str> { +fn beta_tooltips() -> Vec<&'static str> { + FEATURES + .iter() + .filter_map(|spec| spec.stage.beta_announcement()) + .collect() +} + +/// Pick a random tooltip to show to the user when starting Codex. +pub(crate) fn random_tooltip() -> Option { + if let Some(announcement) = announcement::fetch_announcement_tip() { + return Some(announcement); + } let mut rng = rand::rng(); - pick_tooltip(&mut rng) + pick_tooltip(&mut rng).map(str::to_string) } fn pick_tooltip(rng: &mut R) -> Option<&'static str> { @@ -40,9 +46,149 @@ fn pick_tooltip(rng: &mut R) -> Option<&'static str> { } } +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 { + let response = reqwest::blocking::Client::new() + .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; @@ -62,4 +208,104 @@ mod tests { let mut rng = StdRng::seed_from_u64(7); assert_eq!(expected, pick_tooltip(&mut rng)); } + + #[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) + ); + } } diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 7696a387528..5381a50c928 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -699,11 +699,11 @@ pub(crate) fn padded_emoji(emoji: &str) -> String { #[derive(Debug)] struct TooltipHistoryCell { - tip: &'static str, + tip: String, } impl TooltipHistoryCell { - fn new(tip: &'static str) -> Self { + fn new(tip: String) -> Self { Self { tip } } } diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 3c5ac92f6c3..19e10ad0cea 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -359,6 +359,8 @@ async fn run_ratatui_app( ) -> color_eyre::Result { color_eyre::install()?; + tooltips::announcement::prewarm(); + // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. // Chain to the previous hook so users still get a rich panic report diff --git a/codex-rs/tui2/src/tooltips.rs b/codex-rs/tui2/src/tooltips.rs index eb419c2ea33..402a18ba2b1 100644 --- a/codex-rs/tui2/src/tooltips.rs +++ b/codex-rs/tui2/src/tooltips.rs @@ -1,6 +1,9 @@ +use codex_core::features::FEATURES; use lazy_static::lazy_static; use rand::Rng; +const ANNOUNCEMENT_TIP_URL: &str = + "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); lazy_static! { @@ -9,24 +12,183 @@ lazy_static! { .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .collect(); + static ref ALL_TOOLTIPS: Vec<&'static str> = { + let mut tips = Vec::new(); + tips.extend(TOOLTIPS.iter().copied()); + tips.extend(beta_tooltips()); + tips + }; } -pub(crate) fn random_tooltip() -> Option<&'static str> { +fn beta_tooltips() -> Vec<&'static str> { + FEATURES + .iter() + .filter_map(|spec| spec.stage.beta_announcement()) + .collect() +} + +/// Pick a random tooltip to show to the user when starting Codex. +pub(crate) fn random_tooltip() -> Option { + if let Some(announcement) = announcement::fetch_announcement_tip() { + return Some(announcement); + } let mut rng = rand::rng(); - pick_tooltip(&mut rng) + pick_tooltip(&mut rng).map(str::to_string) } fn pick_tooltip(rng: &mut R) -> Option<&'static str> { - if TOOLTIPS.is_empty() { + if ALL_TOOLTIPS.is_empty() { None } else { - TOOLTIPS.get(rng.random_range(0..TOOLTIPS.len())).copied() + 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 { + let response = reqwest::blocking::Client::new() + .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; @@ -46,4 +208,104 @@ mod tests { let mut rng = StdRng::seed_from_u64(7); assert_eq!(expected, pick_tooltip(&mut rng)); } + + #[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) + ); + } }