Files
codex/codex-rs/tui/src/tooltips.rs
Josh McKinney 1e75173ebd Point Codex App tooltip links to app landing page (#11515)
### Motivation
- Ensure the in-TUI Codex App call-to-action opens the app landing page
variant `https://chatgpt.com/codex?app-landing-page=true` so users reach
the intended landing experience.

### Description
- Update tooltip constants in `codex-rs/tui/src/tooltips.rs` to replace
`https://chatgpt.com/codex` with
`https://chatgpt.com/codex?app-landing-page=true` for the PAID and OTHER
tooltip variants.

### Testing
- Ran `just fmt` in `codex-rs` and `cargo test -p codex-tui`, and the
test suite completed successfully.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_698d20cf6f088329bb82b07d3ce76e61)
2026-02-12 23:35:57 -08:00

367 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use codex_core::features::FEATURES;
use codex_protocol::account::PlanType;
use lazy_static::lazy_static;
use rand::Rng;
const ANNOUNCEMENT_TIP_URL: &str =
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
const IS_MACOS: bool = cfg!(target_os = "macos");
const PAID_TOOLTIP: &str = "*New* Try the **Codex App** with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const PAID_TOOLTIP_NON_MAC: &str = "*New* 2x rate limits until *April 2nd*.";
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex.";
const FREE_GO_TOOLTIP: &str =
"*New* Codex is included in your plan for free through *March 2nd* lets build together.";
const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt");
lazy_static! {
static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS
.lines()
.map(str::trim)
.filter(|line| {
if line.is_empty() || line.starts_with('#') {
return false;
}
if !IS_MACOS && line.contains("codex app") {
return false;
}
true
})
.collect();
static ref ALL_TOOLTIPS: Vec<&'static str> = {
let mut tips = Vec::new();
tips.extend(TOOLTIPS.iter().copied());
tips.extend(experimental_tooltips());
tips
};
}
fn experimental_tooltips() -> Vec<&'static str> {
FEATURES
.iter()
.filter_map(|spec| spec.stage.experimental_announcement())
.collect()
}
/// Pick a random tooltip to show to the user when starting Codex.
pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
let mut rng = rand::rng();
if let Some(announcement) = announcement::fetch_announcement_tip() {
return Some(announcement);
}
// Leave small chance for a random tooltip to be shown.
if rng.random_ratio(8, 10) {
match plan {
Some(PlanType::Plus)
| Some(PlanType::Business)
| Some(PlanType::Team)
| Some(PlanType::Enterprise)
| Some(PlanType::Pro) => {
let tooltip = if IS_MACOS {
PAID_TOOLTIP
} else {
PAID_TOOLTIP_NON_MAC
};
return Some(tooltip.to_string());
}
Some(PlanType::Go) | Some(PlanType::Free) => {
return Some(FREE_GO_TOOLTIP.to_string());
}
_ => {
let tooltip = if IS_MACOS {
OTHER_TOOLTIP
} else {
OTHER_TOOLTIP_NON_MAC
};
return Some(tooltip.to_string());
}
}
}
pick_tooltip(&mut rng).map(str::to_string)
}
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
if ALL_TOOLTIPS.is_empty() {
None
} else {
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> {
// Avoid system proxy detection to prevent macOS system-configuration panics (#8912).
let client = reqwest::blocking::Client::builder()
.no_proxy()
.build()
.ok()?;
let response = client
.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;
#[test]
fn random_tooltip_returns_some_tip_when_available() {
let mut rng = StdRng::seed_from_u64(42);
assert!(pick_tooltip(&mut rng).is_some());
}
#[test]
fn random_tooltip_is_reproducible_with_seed() {
let expected = {
let mut rng = StdRng::seed_from_u64(7);
pick_tooltip(&mut rng)
};
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)
);
}
}