From 683579cd941aa77a257ca1ff69d91f00e403a3c2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 5 Jan 2026 19:25:17 +0000 Subject: [PATCH 1/7] feat: forced tool tips --- announcement_tip | 1 + 1 file changed, 1 insertion(+) create mode 100644 announcement_tip diff --git a/announcement_tip b/announcement_tip new file mode 100644 index 00000000000..33125252ed5 --- /dev/null +++ b/announcement_tip @@ -0,0 +1 @@ +Breaking news, this is 2026 \ No newline at end of file From 0076c062b9691b89a52f1750f2e0a206c528022d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 5 Jan 2026 19:48:16 +0000 Subject: [PATCH 2/7] Add to the code --- announcement_tip | 1 - codex-rs/tui/src/history_cell.rs | 4 ++-- codex-rs/tui/src/tooltips.rs | 40 +++++++++++++++++++++++++++++-- codex-rs/tui2/src/history_cell.rs | 4 ++-- codex-rs/tui2/src/tooltips.rs | 40 +++++++++++++++++++++++++++++-- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/announcement_tip b/announcement_tip index 33125252ed5..e69de29bb2d 100644 --- a/announcement_tip +++ b/announcement_tip @@ -1 +0,0 @@ -Breaking news, this is 2026 \ No newline at end of file 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/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 8a3a1c1e969..25de097b005 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -1,7 +1,13 @@ use codex_core::features::FEATURES; use lazy_static::lazy_static; use rand::Rng; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::runtime::Handle; +use tokio::task; +const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; +static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); fn beta_tooltips() -> Vec<&'static str> { @@ -25,9 +31,12 @@ lazy_static! { }; } -pub(crate) fn random_tooltip() -> Option<&'static str> { +pub(crate) fn random_tooltip() -> Option { + if let Some(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,6 +49,33 @@ fn pick_tooltip(rng: &mut R) -> Option<&'static str> { } } +fn fetch_announcement_tip() -> Option { + let tip_ref = ANNOUNCEMENT_TIP.get_or_init(|| { + let handle = Handle::try_current().ok()?; + let text = task::block_in_place(|| { + handle.block_on(async { + let response = reqwest::Client::new() + .get(ANNOUNCEMENT_TIP_URL) + .timeout(Duration::from_millis(500)) + .send() + .await + .ok()?; + let text = response.error_for_status().ok()?.text().await.ok()?; + Some(text) + }) + })?; + + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + tip_ref.clone() +} + #[cfg(test)] mod tests { use super::*; 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/tooltips.rs b/codex-rs/tui2/src/tooltips.rs index eb419c2ea33..a9103064342 100644 --- a/codex-rs/tui2/src/tooltips.rs +++ b/codex-rs/tui2/src/tooltips.rs @@ -1,6 +1,12 @@ use lazy_static::lazy_static; use rand::Rng; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::runtime::Handle; +use tokio::task; +const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; +static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); lazy_static! { @@ -11,9 +17,12 @@ lazy_static! { .collect(); } -pub(crate) fn random_tooltip() -> Option<&'static str> { +pub(crate) fn random_tooltip() -> Option { + if let Some(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> { @@ -24,6 +33,33 @@ fn pick_tooltip(rng: &mut R) -> Option<&'static str> { } } +fn fetch_announcement_tip() -> Option { + let tip_ref = ANNOUNCEMENT_TIP.get_or_init(|| { + let handle = Handle::try_current().ok()?; + let text = task::block_in_place(|| { + handle.block_on(async { + let response = reqwest::Client::new() + .get(ANNOUNCEMENT_TIP_URL) + .timeout(Duration::from_millis(500)) + .send() + .await + .ok()?; + let text = response.error_for_status().ok()?.text().await.ok()?; + Some(text) + }) + })?; + + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + tip_ref.clone() +} + #[cfg(test)] mod tests { use super::*; From fd988d291c040e13edad8986a77f3a6018fd79d7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 5 Jan 2026 19:49:47 +0000 Subject: [PATCH 3/7] fmt --- codex-rs/tui/src/tooltips.rs | 3 ++- codex-rs/tui2/src/tooltips.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 25de097b005..0ed5a3a6d56 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -6,7 +6,8 @@ use std::time::Duration; use tokio::runtime::Handle; use tokio::task; -const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; +const ANNOUNCEMENT_TIP_URL: &str = + "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); diff --git a/codex-rs/tui2/src/tooltips.rs b/codex-rs/tui2/src/tooltips.rs index a9103064342..6d8fac8415b 100644 --- a/codex-rs/tui2/src/tooltips.rs +++ b/codex-rs/tui2/src/tooltips.rs @@ -5,7 +5,8 @@ use std::time::Duration; use tokio::runtime::Handle; use tokio::task; -const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; +const ANNOUNCEMENT_TIP_URL: &str = + "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); From acc289be86b8460afe1b950044cb7e8e0d94dbf7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 5 Jan 2026 19:51:29 +0000 Subject: [PATCH 4/7] timeouts --- codex-rs/tui/src/tooltips.rs | 2 +- codex-rs/tui2/src/tooltips.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 0ed5a3a6d56..6463cfb5f8b 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -57,7 +57,7 @@ fn fetch_announcement_tip() -> Option { handle.block_on(async { let response = reqwest::Client::new() .get(ANNOUNCEMENT_TIP_URL) - .timeout(Duration::from_millis(500)) + .timeout(Duration::from_millis(100)) .send() .await .ok()?; diff --git a/codex-rs/tui2/src/tooltips.rs b/codex-rs/tui2/src/tooltips.rs index 6d8fac8415b..5c40849da2d 100644 --- a/codex-rs/tui2/src/tooltips.rs +++ b/codex-rs/tui2/src/tooltips.rs @@ -41,7 +41,7 @@ fn fetch_announcement_tip() -> Option { handle.block_on(async { let response = reqwest::Client::new() .get(ANNOUNCEMENT_TIP_URL) - .timeout(Duration::from_millis(500)) + .timeout(Duration::from_millis(100)) .send() .await .ok()?; From 0032a8c7a93055f21c7f517af89a5ad6315740de Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 6 Jan 2026 10:19:11 +0000 Subject: [PATCH 5/7] way better strategy --- announcement_tip | 16 +++ codex-rs/tui/src/lib.rs | 3 +- codex-rs/tui/src/tooltips.rs | 257 ++++++++++++++++++++++++++++----- codex-rs/tui2/src/lib.rs | 2 + codex-rs/tui2/src/tooltips.rs | 263 ++++++++++++++++++++++++++++++---- 5 files changed, 477 insertions(+), 64 deletions(-) diff --git a/announcement_tip b/announcement_tip index e69de29bb2d..5945737ffaa 100644 --- a/announcement_tip +++ b/announcement_tip @@ -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-08" 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 6463cfb5f8b..9c55dc5eb52 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -1,23 +1,11 @@ use codex_core::features::FEATURES; use lazy_static::lazy_static; use rand::Rng; -use std::sync::OnceLock; -use std::time::Duration; -use tokio::runtime::Handle; -use tokio::task; const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; -static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); 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() @@ -32,8 +20,16 @@ lazy_static! { }; } +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) = fetch_announcement_tip() { + if let Some(announcement) = announcement::fetch_announcement_tip() { return Some(announcement); } let mut rng = rand::rng(); @@ -50,36 +46,155 @@ fn pick_tooltip(rng: &mut R) -> Option<&'static str> { } } -fn fetch_announcement_tip() -> Option { - let tip_ref = ANNOUNCEMENT_TIP.get_or_init(|| { - let handle = Handle::try_current().ok()?; - let text = task::block_in_place(|| { - handle.block_on(async { - let response = reqwest::Client::new() - .get(ANNOUNCEMENT_TIP_URL) - .timeout(Duration::from_millis(100)) - .send() - .await - .ok()?; - let text = response.error_for_status().ok()?.text().await.ok()?; - Some(text) +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() + } + + #[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()?; + let text = response.error_for_status().ok()?.text().ok()?; + + // Normalize the tip. + parse_announcement_tip_toml(&text).or_else(|| { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + } + + 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(), }) - })?; + } - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) + fn version_matches(&self, version: &str) -> bool { + self.version_regex + .as_ref() + .is_none_or(|regex| regex.is_match(version)) } - }); - tip_ref.clone() + 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; @@ -99,4 +214,78 @@ 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)); + } } 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 5c40849da2d..9c55dc5eb52 100644 --- a/codex-rs/tui2/src/tooltips.rs +++ b/codex-rs/tui2/src/tooltips.rs @@ -1,13 +1,9 @@ +use codex_core::features::FEATURES; use lazy_static::lazy_static; use rand::Rng; -use std::sync::OnceLock; -use std::time::Duration; -use tokio::runtime::Handle; -use tokio::task; const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; -static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); lazy_static! { @@ -16,10 +12,24 @@ 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 + }; } +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) = fetch_announcement_tip() { + if let Some(announcement) = announcement::fetch_announcement_tip() { return Some(announcement); } let mut rng = rand::rng(); @@ -27,43 +37,164 @@ pub(crate) fn random_tooltip() -> Option { } 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() } } -fn fetch_announcement_tip() -> Option { - let tip_ref = ANNOUNCEMENT_TIP.get_or_init(|| { - let handle = Handle::try_current().ok()?; - let text = task::block_in_place(|| { - handle.block_on(async { - let response = reqwest::Client::new() - .get(ANNOUNCEMENT_TIP_URL) - .timeout(Duration::from_millis(100)) - .send() - .await - .ok()?; - let text = response.error_for_status().ok()?.text().await.ok()?; - Some(text) +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() + } + + #[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()?; + let text = response.error_for_status().ok()?.text().ok()?; + + // Normalize the tip. + parse_announcement_tip_toml(&text).or_else(|| { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + } + + 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(), }) - })?; + } - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) + fn version_matches(&self, version: &str) -> bool { + self.version_regex + .as_ref() + .is_none_or(|regex| regex.is_match(version)) } - }); - tip_ref.clone() + 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; @@ -83,4 +214,78 @@ 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)); + } } From 8f80d7a975737198e3c169b19cdf04a592f1ace1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 6 Jan 2026 10:29:47 +0000 Subject: [PATCH 6/7] fix --- announcement_tip | 2 +- codex-rs/tui/src/tooltips.rs | 44 +++++++++++++++++++++++++---------- codex-rs/tui2/src/tooltips.rs | 44 +++++++++++++++++++++++++---------- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/announcement_tip b/announcement_tip index 5945737ffaa..5a68bc6b06c 100644 --- a/announcement_tip +++ b/announcement_tip @@ -12,5 +12,5 @@ target_app = "cli" [[announcements]] content = "This is a test announcement" -version_regex = "^0\.0\.0$" +version_regex = "^0\\.0\\.0$" to_date = "2026-01-08" diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 9c55dc5eb52..31a5c2c7443 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -66,7 +66,11 @@ pub(crate) mod announcement { /// 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() + ANNOUNCEMENT_TIP + .get() + .cloned() + .flatten() + .and_then(|raw| parse_announcement_tip_toml(&raw)) } #[derive(Debug, Deserialize)] @@ -105,17 +109,7 @@ pub(crate) mod announcement { .timeout(Duration::from_millis(2000)) .send() .ok()?; - let text = response.error_for_status().ok()?.text().ok()?; - - // Normalize the tip. - parse_announcement_tip_toml(&text).or_else(|| { - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) + response.error_for_status().ok()?.text().ok() } pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option { @@ -288,4 +282,30 @@ 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/tooltips.rs b/codex-rs/tui2/src/tooltips.rs index 9c55dc5eb52..31a5c2c7443 100644 --- a/codex-rs/tui2/src/tooltips.rs +++ b/codex-rs/tui2/src/tooltips.rs @@ -66,7 +66,11 @@ pub(crate) mod announcement { /// 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() + ANNOUNCEMENT_TIP + .get() + .cloned() + .flatten() + .and_then(|raw| parse_announcement_tip_toml(&raw)) } #[derive(Debug, Deserialize)] @@ -105,17 +109,7 @@ pub(crate) mod announcement { .timeout(Duration::from_millis(2000)) .send() .ok()?; - let text = response.error_for_status().ok()?.text().ok()?; - - // Normalize the tip. - parse_announcement_tip_toml(&text).or_else(|| { - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) + response.error_for_status().ok()?.text().ok() } pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option { @@ -288,4 +282,30 @@ 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) + ); + } } From 6c92d49dbcd09f1a9ebedf97e5b32ccf1aec98c1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 6 Jan 2026 10:32:50 +0000 Subject: [PATCH 7/7] Rename the file --- announcement_tip => announcement_tip.toml | 2 +- codex-rs/tui/src/tooltips.rs | 2 +- codex-rs/tui2/src/tooltips.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename announcement_tip => announcement_tip.toml (96%) diff --git a/announcement_tip b/announcement_tip.toml similarity index 96% rename from announcement_tip rename to announcement_tip.toml index 5a68bc6b06c..3ad4a765904 100644 --- a/announcement_tip +++ b/announcement_tip.toml @@ -13,4 +13,4 @@ target_app = "cli" [[announcements]] content = "This is a test announcement" version_regex = "^0\\.0\\.0$" -to_date = "2026-01-08" +to_date = "2026-01-10" diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 31a5c2c7443..402a18ba2b1 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -3,7 +3,7 @@ use lazy_static::lazy_static; use rand::Rng; const ANNOUNCEMENT_TIP_URL: &str = - "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; + "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); lazy_static! { diff --git a/codex-rs/tui2/src/tooltips.rs b/codex-rs/tui2/src/tooltips.rs index 31a5c2c7443..402a18ba2b1 100644 --- a/codex-rs/tui2/src/tooltips.rs +++ b/codex-rs/tui2/src/tooltips.rs @@ -3,7 +3,7 @@ use lazy_static::lazy_static; use rand::Rng; const ANNOUNCEMENT_TIP_URL: &str = - "https://raw.githubusercontent.com/openai/codex/main/announcement_tip"; + "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); lazy_static! {