feat: forced tool tips (#8752)

Force an announcement tooltip in the CLI. This query the gh repo on this
[file](https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml)
which contains announcements in TOML looking like this:
```
# 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"
version_regex = "^0\\.0\\.0$"
target_app = "cli"
``` 

To make this efficient, the announcement is queried on a best effort
basis at the launch of the CLI (no refresh made after this).
This is done in an async way and we display the announcement (with 100%
probability) iff the announcement is available, the cache is correctly
warmed and there is a matching announcement (matching is recomputed for
each new session).
This commit is contained in:
jif-oai
2026-01-06 18:02:05 +00:00
committed by GitHub
parent cab7136fb3
commit d1c6329c32
7 changed files with 545 additions and 18 deletions

View File

@@ -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<String> {
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<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
@@ -40,9 +46,149 @@ fn pick_tooltip<R: Rng + ?Sized>(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<Option<String>> = 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<String> {
ANNOUNCEMENT_TIP
.get()
.cloned()
.flatten()
.and_then(|raw| parse_announcement_tip_toml(&raw))
}
#[derive(Debug, Deserialize)]
struct AnnouncementTipRaw {
content: String,
from_date: Option<String>,
to_date: Option<String>,
version_regex: Option<String>,
target_app: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnnouncementTipDocument {
announcements: Vec<AnnouncementTipRaw>,
}
#[derive(Debug)]
struct AnnouncementTip {
content: String,
from_date: Option<NaiveDate>,
to_date: Option<NaiveDate>,
version_regex: Option<Regex>,
target_app: String,
}
fn init_announcement_tip_in_thread() -> Option<String> {
thread::spawn(blocking_init_announcement_tip)
.join()
.ok()
.flatten()
}
fn blocking_init_announcement_tip() -> Option<String> {
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<String> {
let announcements = toml::from_str::<AnnouncementTipDocument>(text)
.map(|doc| doc.announcements)
.or_else(|_| toml::from_str::<Vec<AnnouncementTipRaw>>(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<Self> {
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)
);
}
}