Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions announcement_tip.toml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions codex-rs/tui/src/history_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -344,6 +343,8 @@ async fn run_ratatui_app(
) -> color_eyre::Result<AppExitInfo> {
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
Expand Down
264 changes: 255 additions & 9 deletions codex-rs/tui/src/tooltips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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> {
Expand All @@ -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;

Expand All @@ -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)
);
}
}
4 changes: 2 additions & 2 deletions codex-rs/tui2/src/history_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ async fn run_ratatui_app(
) -> color_eyre::Result<AppExitInfo> {
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
Expand Down
Loading
Loading