Simplify startup promotions to platform-specific desktop app tips (#49093)

## What changed

- Replace plan-specific and Fast-mode promotions with the desktop app tip for the current platform, while preserving announcement priority and random-tip fallback.
- Share desktop app tips between the preferred slot and random tip pool, including Windows. Keep Linux app tips limited to graphical sessions outside WSL.
- Clarify installation and launch instructions for macOS, Windows, and Linux, and format launcher commands as code.
- Remove the Fast-mode status plumbing used by tooltip selection.

## Testing

Add snapshots for all three platform tips at a narrow terminal width. Update tests to cover plan-independent tip selection and platform availability.

GitOrigin-RevId: 34bbc95c47c83618f9ac1691a1b69ca4690b8665
This commit is contained in:
Eric Traut
2026-09-28 22:40:35 +00:00
committed by copyberry
parent 222e24b737
commit 19892aee0c
13 changed files with 83 additions and 187 deletions
-2
View File
@@ -5819,7 +5819,6 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
is_first,
/*tooltip_override*/ None,
/*auth_plan*/ None,
/*show_fast_status*/ false,
)) as Arc<dyn HistoryCell>
};
@@ -7521,7 +7520,6 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() {
is_first,
/*tooltip_override*/ None,
/*auth_plan*/ None,
/*show_fast_status*/ false,
)) as Arc<dyn HistoryCell>
};
@@ -2661,7 +2661,6 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
/*is_first_event*/ false,
Some("This is a test announcement".to_string()),
/*auth_plan*/ None,
/*show_fast_status*/ false,
)),
);
app.enqueue_primary_thread_session(started.session, started.turns)
@@ -37,7 +37,6 @@ async fn owned_startup_hides_tip_in_transcript() -> Result<()> {
/*is_first_event*/ false,
Some("Use /mcp to list configured MCP tools.".into()),
/*auth_plan*/ None,
/*show_fast_status*/ false,
);
history_cell::set_session_greeting(&mut session_info, &greeting);
app.transcript_cells = vec![
@@ -28,7 +28,6 @@ fn insert_splash(app: &mut App, tui: &mut tui::Tui) {
/*is_first_event*/ false,
/*tooltip_override*/ None,
/*auth_plan*/ None,
/*show_fast_status*/ false,
)),
);
}
@@ -7,7 +7,6 @@ use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::service_tier_resolution;
use codex_features::Feature;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::SPEED_TIER_FAST;
impl ChatWidget {
@@ -34,13 +33,6 @@ impl ChatWidget {
)
}
pub(crate) fn should_show_fast_status(&self, model: &str, service_tier: Option<&str>) -> bool {
service_tier.is_some_and(|service_tier| {
service_tier == ServiceTier::Fast.request_value()
&& self.model_supports_service_tier(model, service_tier)
}) && self.has_chatgpt_account
}
pub(super) fn fast_mode_enabled(&self) -> bool {
self.config.features.enabled(Feature::FastMode)
}
@@ -124,24 +116,6 @@ impl ChatWidget {
.send(AppEvent::PersistServiceTierSelection { service_tier });
}
fn model_supports_service_tier(&self, model: &str, service_tier: &str) -> bool {
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| {
preset
.service_tiers
.iter()
.any(|tier| tier.id == service_tier)
})
})
.unwrap_or(false)
}
fn current_model_fast_service_tier(&self) -> Option<ServiceTierCommand> {
self.current_model_service_tier_commands()
.into_iter()
@@ -201,8 +201,6 @@ impl ChatWidget {
let model_for_header = self.current_model().to_string();
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status = self
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref());
let mut session_info_cell = history_cell::new_session_info(
&self.config,
&self.local_settings,
@@ -212,7 +210,6 @@ impl ChatWidget {
self.show_welcome_banner,
startup_tooltip_override,
self.plan_type,
show_fast_status,
);
history_cell::set_session_greeting(
&mut session_info_cell,
@@ -2377,47 +2377,6 @@ async fn commentary_completion_restores_status_indicator_before_exec_begin() {
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
}
#[tokio::test]
async fn fast_status_indicator_requires_chatgpt_auth() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
#[tokio::test]
async fn fast_status_indicator_is_hidden_for_models_without_fast_support() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.2").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.2").supports_fast_mode());
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
#[tokio::test]
async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
// Snapshot test: ChatWidget at very small heights (idle)
// Ensures overall layout behaves when terminal height is extremely constrained.
#[tokio::test]
+1 -4
View File
@@ -193,7 +193,6 @@ pub(crate) fn new_session_info(
is_first_event: bool,
tooltip_override: Option<String>,
auth_plan: Option<PlanType>,
show_fast_status: bool,
) -> SessionInfoCell {
// Header rendered as history (so it appears at the very top).
let header = SessionHeaderHistoryCell::new(
@@ -246,9 +245,7 @@ pub(crate) fn new_session_info(
} else {
if local_settings.tui.show_tooltips
&& let Some(tooltips) = tooltip_override
.or_else(|| {
tooltips::get_tooltip(auth_plan, show_fast_status, &local_settings.tui.keymap)
})
.or_else(|| tooltips::get_tooltip(auth_plan, &local_settings.tui.keymap))
.map(|tip| TooltipHistoryCell::new(tip, &config.cwd))
{
parts.push(Box::new(tooltips));
-5
View File
@@ -747,7 +747,6 @@ async fn session_info_uses_availability_nux_tooltip_override() {
/*is_first_event*/ false,
Some("Model just became available".to_string()),
Some(PlanType::Free),
/*show_fast_status*/ false,
);
let rendered = render_transcript(&cell).join("\n");
@@ -771,7 +770,6 @@ async fn session_info_availability_nux_tooltip_snapshot() {
/*is_first_event*/ false,
Some("Model just became available".to_string()),
Some(PlanType::Free),
/*show_fast_status*/ false,
);
let rendered = render_transcript(&cell).join("\n");
@@ -793,7 +791,6 @@ async fn session_info_preserves_styled_tooltip_links() {
.to_string(),
),
Some(PlanType::Free),
/*show_fast_status*/ false,
);
let lines = cell.transcript_hyperlink_lines(/*width*/ 30);
@@ -843,7 +840,6 @@ async fn session_info_first_event_suppresses_tooltips_and_nux() {
/*is_first_event*/ true,
Some("Model just became available".to_string()),
Some(PlanType::Free),
/*show_fast_status*/ false,
);
let rendered = render_transcript(&cell).join("\n");
@@ -864,7 +860,6 @@ async fn session_info_hides_tooltips_when_disabled() {
/*is_first_event*/ false,
Some("Model just became available".to_string()),
Some(PlanType::Free),
/*show_fast_status*/ false,
);
let rendered = render_transcript(&cell).join("\n");
@@ -0,0 +1,8 @@
---
source: tui/src/tooltips.rs
expression: rendered
---
Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use the "), Span::from("desktop app").bold(), Span::from(". Install it")]) []
Line::from("from") []
Line::from(Span::from("https://learn.chatgpt.com/docs/linux/linux-app").fg(Color::Reset).underlined()) [TerminalHyperlink { columns: 0..46, destination: "https://learn.chatgpt.com/docs/linux/linux-app", destination_kind: Web }]
Line::from_iter([Span::from("and run "), Span::from("chatgpt").fg(Color::Rgb(166, 227, 161)), Span::from(".")]) []
@@ -0,0 +1,7 @@
---
source: tui/src/tooltips.rs
expression: rendered
---
Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use the "), Span::from("desktop app").bold(), Span::from(". Run "), Span::from("codex app").fg(Color::Rgb(166, 227, 161))]) []
Line::from("to open it. It installs automatically if") []
Line::from("needed.") []
@@ -0,0 +1,8 @@
---
source: tui/src/tooltips.rs
expression: rendered
---
Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use the "), Span::from("desktop app").bold(), Span::from(". Install it")]) []
Line::from("from") []
Line::from(Span::from("https://chatgpt.com/codex?app-landing-page=true").fg(Color::Reset).underlined()) [TerminalHyperlink { columns: 0..47, destination: "https://chatgpt.com/codex?app-landing-page=true", destination_kind: Web }]
Line::from_iter([Span::from("and run "), Span::from("codex app").fg(Color::Rgb(166, 227, 161)), Span::from(".")]) []
+59 -103
View File
@@ -21,16 +21,10 @@ const ANNOUNCEMENT_TIP_URL: &str =
const IS_MACOS: bool = cfg!(target_os = "macos");
const IS_WINDOWS: bool = cfg!(target_os = "windows");
const APP_TOOLTIP: &str = "Try the **Desktop app**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const WINDOWS_APP_TOOLTIP: &str = "Use the **desktop app**. Install it from https://chatgpt.com/codex?app-landing-page=true and run `codex app`.";
const MACOS_APP_TOOLTIP: &str =
"Run `codex app` to open the Desktop app (it installs on macOS if needed).";
const LINUX_APP_TOOLTIP: &str = "Try the **Desktop app** on Linux: install it from https://learn.chatgpt.com/docs/linux/linux-app and run 'chatgpt'.";
const FAST_TOOLTIP: &str =
"*New* Use **/fast** to enable our fastest inference with increased plan usage.";
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Desktop 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* For a limited time, Codex is included in your plan for free – let’s build together.";
"Use the **desktop app**. Run `codex app` to open it. It installs automatically if needed.";
const LINUX_APP_TOOLTIP: &str = "Use the **desktop app**. Install it from https://learn.chatgpt.com/docs/linux/linux-app and run `chatgpt`.";
const RAW_TOOLTIPS: &str = include_str!("../assets/tooltips.txt");
@@ -39,11 +33,7 @@ lazy_static! {
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.chain(if IS_MACOS {
Some(MACOS_APP_TOOLTIP)
} else {
linux_app_tooltip(LinuxDesktopSession::current())
})
.chain(app_tooltip())
.collect();
static ref ALL_TOOLTIPS: Vec<&'static str> = {
let mut tips = Vec::new();
@@ -68,13 +58,9 @@ fn experimental_tooltips(
}
/// Pick a random tooltip to show to the user when starting Codex.
pub(crate) fn get_tooltip(
plan: Option<PlanType>,
fast_mode_enabled: bool,
keymap: &TuiKeymap,
) -> Option<String> {
pub(crate) fn get_tooltip(plan: Option<PlanType>, keymap: &TuiKeymap) -> Option<String> {
let mut rng = rand::rng();
preferred_tooltip(&mut rng, plan, fast_mode_enabled).or_else(|| pick_tooltip(&mut rng, keymap))
preferred_tooltip(&mut rng, plan).or_else(|| pick_tooltip(&mut rng, keymap))
}
/// Apply the shared announcement and promotion policy before falling back to local tips.
@@ -82,7 +68,6 @@ pub(crate) fn get_tooltip(
pub(crate) fn preferred_tooltip<R: Rng + ?Sized>(
rng: &mut R,
plan: Option<PlanType>,
fast_mode_enabled: bool,
) -> Option<String> {
if let Some(announcement) = announcement::fetch_announcement_tip(plan) {
return Some(announcement);
@@ -90,34 +75,7 @@ pub(crate) fn preferred_tooltip<R: Rng + ?Sized>(
// Leave small chance for a random tooltip to be shown.
if rng.random_ratio(/*numerator*/ 8, /*denominator*/ 10) {
match plan {
Some(plan_type)
if matches!(
plan_type,
PlanType::Plus
| PlanType::Enterprise
| PlanType::Pro
| PlanType::ProLite
| PlanType::ProMax
) || plan_type.is_team_like()
|| plan_type.is_business_like() =>
{
if let Some(tooltip) = pick_paid_tooltip(rng, fast_mode_enabled) {
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());
}
}
return app_tooltip().map(str::to_string);
}
None
@@ -153,29 +111,16 @@ fn linux_app_tooltip(session: LinuxDesktopSession) -> Option<&'static str> {
(session.has_display && !session.is_wsl).then_some(LINUX_APP_TOOLTIP)
}
fn paid_app_tooltip() -> Option<&'static str> {
if IS_MACOS || IS_WINDOWS {
Some(APP_TOOLTIP)
fn app_tooltip() -> Option<&'static str> {
if IS_MACOS {
Some(MACOS_APP_TOOLTIP)
} else if IS_WINDOWS {
Some(WINDOWS_APP_TOOLTIP)
} else {
linux_app_tooltip(LinuxDesktopSession::current())
}
}
/// Paid users spend most startup sessions in a dedicated promo slot rather than the
/// generic random tip pool. Keep this business logic explicit: we currently split
/// that slot between the app promo and Fast mode, but suppress the Fast promo once
/// the user already has Fast mode enabled.
fn pick_paid_tooltip<R: Rng + ?Sized>(
rng: &mut R,
fast_mode_enabled: bool,
) -> Option<&'static str> {
if fast_mode_enabled || rng.random_bool(0.5) {
paid_app_tooltip()
} else {
Some(FAST_TOOLTIP)
}
}
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R, keymap: &TuiKeymap) -> Option<String> {
// Resolve current settings for each new tip; never replace an invalid or unbound keymap
// with defaults, or cache shortcut text across /keymap edits.
@@ -495,37 +440,54 @@ mod tests {
assert_eq!(expected, pick_tooltip(&mut rng, &TuiKeymap::default()));
}
#[test]
fn desktop_app_tips_render_at_narrow_width() {
let cwd = std::env::current_dir().unwrap();
for (platform, tip) in [
("macos", MACOS_APP_TOOLTIP),
("windows", WINDOWS_APP_TOOLTIP),
("linux", LINUX_APP_TOOLTIP),
] {
let rendered = render_tooltip_lines(tip, /*width*/ 40, &cwd)
.iter()
.map(|line| format!("{:?} {:?}", line.line, line.hyperlinks))
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(format!("desktop_app_tip_{platform}"), rendered);
}
}
#[test]
fn desktop_app_tooltip_uses_supported_platform_launcher() {
let tooltip = TOOLTIPS
.iter()
.copied()
.find(|tip| tip.contains("Desktop app"));
.find(|tip| tip.contains("desktop app"));
if linux_app_tooltip(LinuxDesktopSession::current()).is_some() {
let tooltip = tooltip.expect("Linux should advertise the desktop app");
assert_eq!(paid_app_tooltip(), Some(tooltip));
assert_eq!(app_tooltip(), Some(tooltip));
} else if IS_MACOS {
let tooltip = tooltip.expect("macOS should advertise the desktop app");
insta::assert_snapshot!(tooltip, @"Run `codex app` to open the Desktop app (it installs on macOS if needed).");
assert_eq!(paid_app_tooltip(), Some(APP_TOOLTIP));
assert_eq!(app_tooltip(), Some(tooltip));
} else if IS_WINDOWS {
assert_eq!(tooltip, None);
assert_eq!(paid_app_tooltip(), Some(APP_TOOLTIP));
let tooltip = tooltip.expect("Windows should advertise the desktop app");
assert_eq!(app_tooltip(), Some(tooltip));
} else {
assert_eq!(tooltip, None);
assert_eq!(paid_app_tooltip(), None);
assert_eq!(app_tooltip(), None);
}
}
#[test]
fn linux_desktop_app_tooltip_requires_graphical_native_session() {
let tooltip = linux_app_tooltip(LinuxDesktopSession {
has_display: true,
is_wsl: false,
})
.expect("graphical native Linux should advertise the desktop app");
insta::assert_snapshot!(tooltip, @"Try the **Desktop app** on Linux: install it from https://learn.chatgpt.com/docs/linux/linux-app and run 'chatgpt'.");
assert_eq!(
linux_app_tooltip(LinuxDesktopSession {
has_display: true,
is_wsl: false,
}),
Some(LINUX_APP_TOOLTIP)
);
assert_eq!(
linux_app_tooltip(LinuxDesktopSession {
@@ -544,30 +506,24 @@ mod tests {
}
#[test]
fn paid_tooltip_pool_rotates_between_promos() {
let mut seen = std::collections::BTreeSet::new();
for seed in 0..32 {
let mut rng = StdRng::seed_from_u64(seed);
seen.insert(pick_paid_tooltip(
&mut rng, /*fast_mode_enabled*/ false,
));
fn plans_use_platform_app_tip() {
for plan in [
Some(PlanType::Free),
Some(PlanType::Go),
Some(PlanType::Plus),
Some(PlanType::Pro),
None,
] {
let mut seen = std::collections::BTreeSet::new();
for seed in 0..32 {
let mut rng = StdRng::seed_from_u64(seed);
seen.insert(preferred_tooltip(&mut rng, plan));
}
assert_eq!(
seen,
std::collections::BTreeSet::from([None, app_tooltip().map(str::to_string)])
);
}
let expected = std::collections::BTreeSet::from([paid_app_tooltip(), Some(FAST_TOOLTIP)]);
assert_eq!(seen, expected);
}
#[test]
fn paid_tooltip_pool_skips_fast_when_fast_mode_is_enabled() {
let mut seen = std::collections::BTreeSet::new();
for seed in 0..8 {
let mut rng = StdRng::seed_from_u64(seed);
seen.insert(pick_paid_tooltip(&mut rng, /*fast_mode_enabled*/ true));
}
let expected = std::collections::BTreeSet::from([paid_app_tooltip()]);
assert_eq!(seen, expected);
assert!(!seen.contains(&Some(FAST_TOOLTIP)));
}
#[test]