mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
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:
16
announcement_tip.toml
Normal file
16
announcement_tip.toml
Normal file
@@ -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"
|
||||||
@@ -816,11 +816,11 @@ pub(crate) fn padded_emoji(emoji: &str) -> String {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TooltipHistoryCell {
|
struct TooltipHistoryCell {
|
||||||
tip: &'static str,
|
tip: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TooltipHistoryCell {
|
impl TooltipHistoryCell {
|
||||||
fn new(tip: &'static str) -> Self {
|
fn new(tip: String) -> Self {
|
||||||
Self { tip }
|
Self { tip }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ pub use markdown_render::render_markdown_text;
|
|||||||
pub use public_widgets::composer_input::ComposerAction;
|
pub use public_widgets::composer_input::ComposerAction;
|
||||||
pub use public_widgets::composer_input::ComposerInput;
|
pub use public_widgets::composer_input::ComposerInput;
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|
||||||
// (tests access modules directly within the crate)
|
// (tests access modules directly within the crate)
|
||||||
|
|
||||||
pub async fn run_main(
|
pub async fn run_main(
|
||||||
@@ -344,6 +343,8 @@ async fn run_ratatui_app(
|
|||||||
) -> color_eyre::Result<AppExitInfo> {
|
) -> color_eyre::Result<AppExitInfo> {
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
tooltips::announcement::prewarm();
|
||||||
|
|
||||||
// Forward panic reports through tracing so they appear in the UI status
|
// Forward panic reports through tracing so they appear in the UI status
|
||||||
// line, but do not swallow the default/color-eyre panic handler.
|
// line, but do not swallow the default/color-eyre panic handler.
|
||||||
// Chain to the previous hook so users still get a rich panic report
|
// Chain to the previous hook so users still get a rich panic report
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ use codex_core::features::FEATURES;
|
|||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use rand::Rng;
|
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");
|
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! {
|
lazy_static! {
|
||||||
static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS
|
static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS
|
||||||
.lines()
|
.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();
|
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> {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::tooltips::announcement::parse_announcement_tip_toml;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
|
|
||||||
@@ -62,4 +208,104 @@ mod tests {
|
|||||||
let mut rng = StdRng::seed_from_u64(7);
|
let mut rng = StdRng::seed_from_u64(7);
|
||||||
assert_eq!(expected, pick_tooltip(&mut rng));
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -817,11 +817,11 @@ pub(crate) fn padded_emoji(emoji: &str) -> String {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TooltipHistoryCell {
|
struct TooltipHistoryCell {
|
||||||
tip: &'static str,
|
tip: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TooltipHistoryCell {
|
impl TooltipHistoryCell {
|
||||||
fn new(tip: &'static str) -> Self {
|
fn new(tip: String) -> Self {
|
||||||
Self { tip }
|
Self { tip }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,8 @@ async fn run_ratatui_app(
|
|||||||
) -> color_eyre::Result<AppExitInfo> {
|
) -> color_eyre::Result<AppExitInfo> {
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
tooltips::announcement::prewarm();
|
||||||
|
|
||||||
// Forward panic reports through tracing so they appear in the UI status
|
// Forward panic reports through tracing so they appear in the UI status
|
||||||
// line, but do not swallow the default/color-eyre panic handler.
|
// line, but do not swallow the default/color-eyre panic handler.
|
||||||
// Chain to the previous hook so users still get a rich panic report
|
// Chain to the previous hook so users still get a rich panic report
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
use codex_core::features::FEATURES;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use rand::Rng;
|
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");
|
const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt");
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
@@ -9,24 +12,183 @@ lazy_static! {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
.collect();
|
.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<String> {
|
||||||
|
if let Some(announcement) = announcement::fetch_announcement_tip() {
|
||||||
|
return Some(announcement);
|
||||||
|
}
|
||||||
let mut rng = rand::rng();
|
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> {
|
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
|
||||||
if TOOLTIPS.is_empty() {
|
if ALL_TOOLTIPS.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} 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<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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::tooltips::announcement::parse_announcement_tip_toml;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
|
|
||||||
@@ -46,4 +208,104 @@ mod tests {
|
|||||||
let mut rng = StdRng::seed_from_u64(7);
|
let mut rng = StdRng::seed_from_u64(7);
|
||||||
assert_eq!(expected, pick_tooltip(&mut rng));
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user