diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 88601e938407..4fee2a061eb0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2058,6 +2058,7 @@ dependencies = [ "codex-app-server", "codex-app-server-protocol", "codex-arg0", + "codex-chatgpt", "codex-config", "codex-core", "codex-exec-server", @@ -4089,6 +4090,7 @@ dependencies = [ "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", + "codex-chatgpt", "codex-cli", "codex-cloud-config", "codex-config", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index daa6ef661f00..660087a29b11 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -16,6 +16,7 @@ workspace = true codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-chatgpt = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index b7a958eff1f5..cd37deb09a17 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -45,6 +45,7 @@ use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_chatgpt::referrals::ReferralClient; use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; @@ -436,6 +437,9 @@ enum ClientCommand { error: JSONRPCErrorError, response_tx: oneshot::Sender>, }, + ReferralClient { + response_tx: oneshot::Sender>, + }, Shutdown { response_tx: oneshot::Sender>, }, @@ -484,6 +488,7 @@ impl InProcessAppServerClient { let channel_capacity = args.channel_capacity.max(1); let mut handle = codex_app_server::in_process::start(args.into_runtime_start_args()).await?; + let referral_client = handle.referral_client(); let request_sender = handle.sender(); let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -529,6 +534,9 @@ impl InProcessAppServerClient { let send_result = request_sender.fail_server_request(request_id, error); let _ = response_tx.send(send_result); } + Some(ClientCommand::ReferralClient { response_tx }) => { + let _ = response_tx.send(Arc::clone(&referral_client)); + } Some(ClientCommand::Shutdown { response_tx }) => { let shutdown_result = handle.shutdown().await; let _ = response_tx.send(shutdown_result); @@ -757,6 +765,7 @@ impl InProcessAppServerClient { command_tx, event_rx, worker_handle, + .. } = self; let mut worker_handle = worker_handle; // Drop the caller-facing receiver before asking the worker to shut @@ -829,6 +838,25 @@ impl InProcessAppServerRequestHandle { serde_json::from_value(result) .map_err(|source| TypedRequestError::Deserialize { method, source }) } + + pub async fn referral_client(&self) -> IoResult> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ClientCommand::ReferralClient { response_tx }) + .await + .map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process app-server worker channel is closed", + ) + })?; + response_rx.await.map_err(|_| { + IoError::new( + ErrorKind::BrokenPipe, + "in-process referral client channel is closed", + ) + }) + } } impl AppServerRequestHandle { @@ -848,6 +876,13 @@ impl AppServerRequestHandle { Self::Remote(handle) => handle.request_typed(request).await, } } + + pub async fn referral_client(&self) -> IoResult>> { + match self { + Self::InProcess(handle) => handle.referral_client().await.map(Some), + Self::Remote(_) => Ok(None), + } + } } impl AppServerClient { diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 938d539a9f69..e71baa1b3d90 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -45,6 +45,7 @@ use std::io::Error as IoError; use std::io::ErrorKind; use std::io::Result as IoResult; use std::sync::Arc; +use std::sync::OnceLock; use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -77,6 +78,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_chatgpt::referrals::ReferralClient; use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; @@ -261,6 +263,7 @@ pub struct InProcessClientHandle { client: InProcessClientSender, event_rx: mpsc::Receiver, runtime_handle: tokio::task::JoinHandle<()>, + referral_client: Arc, #[cfg(test)] _test_codex_home: Option, } @@ -342,6 +345,10 @@ impl InProcessClientHandle { pub fn sender(&self) -> InProcessClientSender { self.client.clone() } + + pub fn referral_client(&self) -> Arc { + Arc::clone(&self.referral_client) + } } /// Starts an in-process app-server runtime and performs initialize handshake. @@ -376,12 +383,18 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); + let referral_auth_manager = Arc::new(OnceLock::new()); + let referral_client = Arc::new(ReferralClient::new( + Arc::clone(&referral_auth_manager), + args.config.chatgpt_base_url.clone(), + )); let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); let auth_manager = AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env) .await; + let _ = referral_auth_manager.set(Arc::clone(&auth_manager)); let analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( @@ -716,11 +729,11 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult, + pub grant_action: Option, + pub grant_amount: Option, + pub requires_explicit_confirmation: bool, + pub identity: ReferralIdentity, +} + +/// The ChatGPT user and workspace that own a referral offer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReferralIdentity { + pub user_id: String, + pub account_id: String, +} + +/// Reward status returned after the backend accepted an invite. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReferralRewardStatus { + Included, + NotIncluded, + Unknown, +} + +#[derive(Debug)] +struct DefiniteReferralInviteRejection { + status: u16, +} + +#[derive(Debug)] +struct ReferralInvitePreflightFailure { + message: String, +} + +impl fmt::Display for DefiniteReferralInviteRejection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "referral invite was rejected ({})", self.status) + } +} + +impl Error for DefiniteReferralInviteRejection {} + +impl fmt::Display for ReferralInvitePreflightFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "referral invite preflight failed: {}", self.message) + } +} + +impl Error for ReferralInvitePreflightFailure {} + +pub fn is_definite_referral_invite_rejection(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some() +} + +pub fn is_referral_invite_preflight_failure(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some() +} + +#[derive(Deserialize)] +struct EligibilityResponse { + should_show: bool, + #[serde(default)] + has_rewards: bool, + #[serde(default)] + description: Option, + #[serde(default)] + grant_action: Option, + #[serde(default)] + grant_amount: Option, +} + +#[derive(Deserialize)] +struct RulesResponse { + #[serde(default)] + rules: Vec, + requires_explicit_confirmation: bool, +} + +#[derive(Serialize)] +struct InviteRequest<'a> { + referral_key: &'static str, + emails: [&'a str; 1], +} + +#[derive(Deserialize)] +struct InviteResponse { + #[serde(default)] + has_rewards: Option, +} + +/// HTTP client for the temporary client-owned referral flow. +pub struct ReferralClient { + auth_manager: Arc>>, + base_url: String, +} + +impl ReferralClient { + pub fn new(auth_manager: Arc>>, base_url: String) -> Self { + Self { + auth_manager, + base_url, + } + } + + pub async fn load_offer(&self) -> anyhow::Result> { + let manager = self.auth_manager()?; + let (auth, identity) = self.load_auth(&manager).await?; + let client = codex_login::default_client::create_client(); + let base_url = self.base_url()?; + + let response = client + .get(format!( + "{base_url}/referrals/invite/eligibility?referral_key={REFERRAL_KEY}&requested_referrals=1&supports_rewardless_invites=false" + )) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .timeout(ELIGIBILITY_TIMEOUT) + .send() + .await + .context("referral eligibility request failed")?; + if response.status().as_u16() == 403 { + return Ok(None); + } + let status = response.status(); + anyhow::ensure!( + status.is_success(), + "referral eligibility failed ({status})" + ); + let eligibility: EligibilityResponse = response + .json() + .await + .context("referral eligibility response was invalid")?; + if !eligibility.should_show || !eligibility.has_rewards { + return Ok(None); + } + + let response = client + .get(format!( + "{base_url}/wham/referrals/eligibility_rules?referral_key={REFERRAL_KEY}" + )) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .timeout(ELIGIBILITY_TIMEOUT) + .send() + .await + .context("referral rules request failed")?; + if response.status().as_u16() == 403 { + return Ok(None); + } + let status = response.status(); + anyhow::ensure!(status.is_success(), "referral rules failed ({status})"); + let rules: RulesResponse = response + .json() + .await + .context("referral rules response was invalid")?; + + let description = eligibility + .description + .clone() + .unwrap_or_else(|| fallback_description(&eligibility)); + + Ok(Some(ReferralOffer { + description, + rules: rules.rules, + grant_action: eligibility.grant_action, + grant_amount: eligibility.grant_amount, + requires_explicit_confirmation: rules.requires_explicit_confirmation, + identity, + })) + } + + pub async fn send_invite( + &self, + offer: &ReferralOffer, + email: &str, + ) -> anyhow::Result { + let current_offer = match self.load_offer().await { + Ok(Some(offer)) => offer, + Ok(None) => { + return Err(preflight_failure("referral offer is no longer available")); + } + Err(err) => { + return Err(preflight_failure(format!( + "referral offer could not be revalidated: {err}" + ))); + } + }; + if ¤t_offer != offer { + return Err(preflight_failure( + "referral offer changed before invite send", + )); + } + + let manager = self.auth_manager()?; + let (auth, identity) = self + .load_auth(&manager) + .await + .map_err(|err| preflight_failure(format!("ChatGPT authentication changed: {err}")))?; + if identity != offer.identity { + return Err(preflight_failure("referral account changed")); + } + + let client = codex_login::default_client::create_single_attempt_client(); + let base_url = self.base_url()?; + let response = client + .post(format!("{base_url}/wham/referrals/invite")) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .json(&InviteRequest { + referral_key: REFERRAL_KEY, + emails: [email], + }) + .timeout(INVITE_TIMEOUT) + .send() + .await + .context("referral invite request failed")?; + let status = response.status(); + if matches!(status.as_u16(), 400 | 403 | 409 | 422) { + return Err(DefiniteReferralInviteRejection { + status: status.as_u16(), + } + .into()); + } + anyhow::ensure!(status.is_success(), "referral invite failed ({status})"); + let response: InviteResponse = response + .json() + .await + .context("referral invite response was invalid")?; + Ok(match response.has_rewards { + Some(true) => ReferralRewardStatus::Included, + Some(false) => ReferralRewardStatus::NotIncluded, + None => ReferralRewardStatus::Unknown, + }) + } + + fn auth_manager(&self) -> anyhow::Result> { + self.auth_manager + .get() + .cloned() + .context("ChatGPT authentication is not ready") + } + + async fn load_auth( + &self, + auth_manager: &Arc, + ) -> anyhow::Result<(CodexAuth, ReferralIdentity)> { + let auth = self + .auth_from_manager(auth_manager) + .await + .context("ChatGPT authentication is unavailable")?; + let identity = Self::identity_from_auth(&auth)?; + Ok((auth, identity)) + } + + async fn auth_from_manager( + &self, + auth_manager: &Arc, + ) -> anyhow::Result { + let auth = auth_manager + .auth() + .await + .context("ChatGPT authentication is unavailable")?; + anyhow::ensure!( + auth.uses_codex_backend(), + "referrals require ChatGPT authentication" + ); + Ok(auth) + } + + fn identity_from_auth(auth: &CodexAuth) -> anyhow::Result { + let user_id = auth + .get_chatgpt_user_id() + .context("ChatGPT user ID is unavailable")?; + let account_id = auth + .get_account_id() + .context("ChatGPT account ID is unavailable")?; + Ok(ReferralIdentity { + user_id, + account_id, + }) + } + + fn base_url(&self) -> anyhow::Result<&str> { + let base_url = self.base_url.trim_end_matches('/'); + anyhow::ensure!( + base_url.contains("/backend-api"), + "referrals require a ChatGPT backend URL" + ); + Ok(base_url) + } +} + +fn preflight_failure(message: impl Into) -> anyhow::Error { + ReferralInvitePreflightFailure { + message: message.into(), + } + .into() +} + +fn fallback_description(eligibility: &EligibilityResponse) -> String { + match (&eligibility.grant_amount, &eligibility.grant_action) { + (Some(amount), Some(action)) => format!( + "Invite someone and earn {} {}.", + reward_value_to_string(amount), + reward_value_to_string(action) + ), + (Some(amount), None) => format!( + "Invite someone and earn a Codex reward worth {}.", + reward_value_to_string(amount) + ), + (None, Some(action)) => format!( + "Invite someone and earn a Codex {} reward.", + reward_value_to_string(action) + ), + (None, None) => "Invite someone and earn a Codex reward.".to_string(), + } +} + +fn reward_value_to_string(value: &Value) -> String { + value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()) +} diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index f72727bfca97..6e8962d947de 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -202,6 +202,37 @@ pub fn create_client() -> CodexHttpClient { CodexHttpClient::new(inner) } +/// Create an HTTP client for non-idempotent requests that must not be retried or redirected. +pub fn create_single_attempt_client() -> CodexHttpClient { + let inner = try_build_single_attempt_reqwest_client().unwrap_or_else(|error| { + tracing::warn!(error = %error, "failed to build single-attempt reqwest client"); + with_chatgpt_cloudflare_cookie_store( + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()), + ) + .build() + .unwrap_or_else(|fallback_error| { + tracing::warn!( + error = %fallback_error, + "failed to build fallback single-attempt reqwest client with ChatGPT Cloudflare cookie store" + ); + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .build() + .unwrap_or_else(|final_error| { + tracing::error!( + error = %final_error, + "failed to build fallback single-attempt reqwest client" + ); + std::process::abort() + }) + }) + }); + CodexHttpClient::new(inner) +} + /// Builds the default reqwest client used for ordinary Codex HTTP traffic. /// /// This starts from the standard Codex user agent, default headers, and sandbox-specific proxy @@ -235,6 +266,16 @@ pub fn try_build_reqwest_client() -> Result Result { + build_reqwest_client_with_custom_ca( + default_reqwest_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()), + ) +} + fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 6c0b83a72e98..d67a10757f18 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -30,6 +30,7 @@ codex-ansi-escape = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-chatgpt = { workspace = true } codex-install-context = { workspace = true } codex-cloud-config = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 9391f4b3e3bb..ee53ea9c9e32 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -7,6 +7,7 @@ use super::plugin_mentions::fetch_plugin_mentions; use super::*; use crate::app_event::ConnectorsSnapshot; +use crate::app_event::ReferralInviteResult; use crate::app_info::app_info_from_api; use crate::config_update::format_config_error; use codex_app_server_protocol::AppsListParams; @@ -118,6 +119,58 @@ impl App { }); } + pub(super) fn refresh_referral_offer( + &mut self, + app_server: &AppServerSession, + request_id: uuid::Uuid, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let offer = match request_handle.referral_client().await.ok().flatten() { + Some(client) => client.load_offer().await.ok().flatten(), + None => None, + }; + app_event_tx.send(AppEvent::ReferralOfferLoaded( + request_id, + offer.map(Box::new), + )); + }); + } + + pub(super) fn send_referral_invite( + &mut self, + app_server: &AppServerSession, + request_id: uuid::Uuid, + offer: codex_chatgpt::referrals::ReferralOffer, + email: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = match request_handle.referral_client().await.ok().flatten() { + Some(client) => match client.send_invite(&offer, &email).await { + Ok(status) => ReferralInviteResult::Sent(status), + Err(err) + if codex_chatgpt::referrals::is_definite_referral_invite_rejection( + &err, + ) => + { + ReferralInviteResult::Rejected + } + Err(err) + if codex_chatgpt::referrals::is_referral_invite_preflight_failure(&err) => + { + ReferralInviteResult::Unavailable + } + Err(_) => ReferralInviteResult::Unknown, + }, + None => ReferralInviteResult::Unavailable, + }; + app_event_tx.send(AppEvent::ReferralInviteSent(request_id, result)); + }); + } + pub(super) fn refresh_rate_limit_reset_credits( &mut self, app_server: &AppServerSession, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index afe269195af4..9c14d3b61650 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -912,6 +912,30 @@ impl App { self.chat_widget .add_token_activity_output(crate::chatwidget::TokenActivityView::Daily); } + AppEvent::RefreshReferralOffer(request_id) => { + self.refresh_referral_offer(app_server, request_id); + } + AppEvent::ReferralOfferLoaded(request_id, offer) => { + self.chat_widget + .finish_referral_offer_refresh(request_id, offer.map(|offer| *offer)); + } + AppEvent::OpenReferralEmailPrompt => { + self.chat_widget.show_referral_email_prompt(); + } + AppEvent::OpenReferralConfirmation(email) => { + self.chat_widget.show_referral_confirmation(email); + } + AppEvent::SendReferralInvite { email, offer } => { + if let Some((request_id, offer)) = + self.chat_widget.start_referral_send(&email, *offer) + { + self.send_referral_invite(app_server, request_id, offer, email); + } + } + AppEvent::ReferralInviteSent(request_id, reward_status) => { + self.chat_widget + .finish_referral_send(request_id, reward_status); + } AppEvent::OpenRateLimitResetCredits => { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); self.refresh_rate_limit_reset_credits(app_server, request_id); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 23e42ce8fe15..c5d7d3236151 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -28,12 +28,15 @@ use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; +use codex_chatgpt::referrals::ReferralOffer; +use codex_chatgpt::referrals::ReferralRewardStatus; use codex_connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; +use uuid::Uuid; use crate::app_command::AppCommand; use crate::app_server_session::AppServerStartedThread; @@ -320,6 +323,27 @@ pub(crate) enum AppEvent { /// Open the default token-activity view selected from the `/usage` menu. OpenTokenActivity, + /// Check whether the signed-in account has a rewarded referral offer. + RefreshReferralOffer(Uuid), + + /// Result of the client-side referral eligibility check. + ReferralOfferLoaded(Uuid, Option>), + + /// Open the referral email input. + OpenReferralEmailPrompt, + + /// Show the final referral confirmation for one email address. + OpenReferralConfirmation(String), + + /// Send one referral invite using the offer that was shown to the user. + SendReferralInvite { + email: String, + offer: Box, + }, + + /// Result of sending one referral invite. + ReferralInviteSent(Uuid, ReferralInviteResult), + /// Open the reset-credit flow selected from the `/usage` menu. OpenRateLimitResetCredits, @@ -1073,6 +1097,14 @@ pub(crate) enum AppEvent { }, } +#[derive(Debug)] +pub(crate) enum ReferralInviteResult { + Sent(ReferralRewardStatus), + Rejected, + Unavailable, + Unknown, +} + /// Named profile selection to apply after any required UI guardrails complete. #[derive(Debug, Clone)] pub(crate) struct PermissionProfileSelection { diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index bb81af7478b6..eff4ff1af97c 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -33,6 +33,7 @@ pub(crate) struct CustomPromptView { title: String, placeholder: String, context_label: Option, + view_id: Option<&'static str>, on_submit: PromptSubmitted, // UI state @@ -60,6 +61,7 @@ impl CustomPromptView { title, placeholder, context_label, + view_id: None, on_submit, textarea, textarea_state: RefCell::new(TextAreaState::default()), @@ -68,6 +70,11 @@ impl CustomPromptView { } } + pub(crate) fn with_view_id(mut self, view_id: &'static str) -> Self { + self.view_id = Some(view_id); + self + } + fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { match key_event { KeyEvent { @@ -143,6 +150,10 @@ impl BottomPaneView for CustomPromptView { self.completion } + fn view_id(&self) -> Option<&'static str> { + self.view_id + } + fn handle_paste(&mut self, pasted: String) -> bool { if pasted.is_empty() { return false; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 42903d2e7fa8..6bfe656f987d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -388,6 +388,7 @@ pub(crate) use self::rate_limits::fallback_limit_label; use self::rate_limits::is_app_server_cyber_policy_error; pub(crate) use self::rate_limits::limit_label_for_window; mod reasoning_shortcuts; +mod referrals; mod rendering; mod replay; mod review; @@ -562,6 +563,7 @@ pub(crate) struct ChatWidget { pending_rate_limit_reset_hint: Option, available_rate_limit_reset_credits: Option, next_rate_limit_reset_request_id: u64, + referral_state: referrals::ReferralState, plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 7fb6fe54e9a6..d139396ec09d 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -135,6 +135,7 @@ impl ChatWidget { pending_rate_limit_reset_hint: None, available_rate_limit_reset_credits: None, next_rate_limit_reset_request_id: 0, + referral_state: referrals::ReferralState::default(), plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), diff --git a/codex-rs/tui/src/chatwidget/referrals.rs b/codex-rs/tui/src/chatwidget/referrals.rs new file mode 100644 index 000000000000..9ad2d2c8cdf9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/referrals.rs @@ -0,0 +1,286 @@ +use codex_chatgpt::referrals::ReferralOffer; +use codex_chatgpt::referrals::ReferralRewardStatus; +use std::time::Duration; +use std::time::Instant; +use uuid::Uuid; + +use crate::app_event::ReferralInviteResult; + +use super::usage::USAGE_MENU_VIEW_ID; +use super::*; + +const REFERRAL_VIEW_ID: &str = "referral-invite"; +pub(super) const REFERRAL_OFFER_MAX_AGE: Duration = Duration::from_secs(10 * 60); + +#[derive(Default)] +pub(super) struct ReferralState { + offer: Option, + pub(super) offer_loaded_at: Option, + pending_request_id: Option, + pending_email: Option, +} + +impl ChatWidget { + pub(super) fn start_referral_offer_refresh(&mut self) { + if !self.has_chatgpt_account { + return; + } + let request_id = Uuid::new_v4(); + self.referral_state.pending_request_id = Some(request_id); + self.app_event_tx + .send(AppEvent::RefreshReferralOffer(request_id)); + } + + pub(crate) fn finish_referral_offer_refresh( + &mut self, + request_id: Uuid, + offer: Option, + ) { + if self.referral_state.pending_request_id != Some(request_id) { + return; + } + self.referral_state.pending_request_id = None; + self.referral_state.offer_loaded_at = offer.as_ref().map(|_| Instant::now()); + self.referral_state.offer = offer; + + let selected = self + .bottom_pane + .selected_index_for_active_view(USAGE_MENU_VIEW_ID); + let mut params = self.usage_menu_params(); + params.initial_selected_idx = selected; + if self + .bottom_pane + .replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params) + { + self.request_redraw(); + } + } + + pub(super) fn referral_menu_item(&self) -> Option { + let offer = self.current_referral_offer()?; + Some(SelectionItem { + name: "Invite someone to Codex".to_string(), + description: Some(offer.description.clone()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenReferralEmailPrompt); + })], + dismiss_on_select: true, + ..Default::default() + }) + } + + pub(crate) fn show_referral_email_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Invite someone to Codex".to_string(), + "name@example.com".to_string(), + String::new(), + Some("Enter one email address, then press Enter.".to_string()), + Box::new(move |email: String| { + let email = email.trim().to_string(); + if !email.is_empty() { + tx.send(AppEvent::OpenReferralConfirmation(email)); + } + }), + ) + .with_view_id(REFERRAL_VIEW_ID); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + + pub(crate) fn show_referral_confirmation(&mut self, email: String) { + let Some(offer) = self.current_referral_offer().cloned() else { + self.show_referral_message( + "This referral offer is no longer available. Reopen /usage to check again.", + ); + return; + }; + + let build_params = || { + let mut header = vec![ + Line::from("Send referral invite?").bold(), + Line::from(offer.description.clone()).dim(), + Line::from(format!("Recipient: {email}")).dim(), + ]; + header.extend( + offer + .rules + .iter() + .map(|rule| Line::from(format!("• {rule}")).dim()), + ); + if offer.requires_explicit_confirmation { + header.push( + Line::from("By sending, you confirm that you have this person's consent.") + .dim(), + ); + } + let email_for_action = email.clone(); + let offer_for_action = offer.clone(); + SelectionViewParams { + view_id: Some(REFERRAL_VIEW_ID), + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Send invite".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SendReferralInvite { + email: email_for_action.clone(), + offer: Box::new(offer_for_action.clone()), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + initial_selected_idx: Some(1), + ..Default::default() + } + }; + if !self + .bottom_pane + .replace_selection_view_if_present(REFERRAL_VIEW_ID, build_params()) + && !self + .bottom_pane + .replace_active_views_with_selection_view(&[REFERRAL_VIEW_ID], build_params()) + { + self.bottom_pane.show_selection_view(build_params()); + } + self.request_redraw(); + } + + pub(crate) fn start_referral_send( + &mut self, + email: &str, + confirmed_offer: ReferralOffer, + ) -> Option<(Uuid, ReferralOffer)> { + let Some(current_offer) = self.current_referral_offer() else { + self.show_referral_message( + "This referral offer is no longer available. Reopen /usage to check again.", + ); + return None; + }; + if current_offer != &confirmed_offer { + self.show_referral_message( + "This referral offer changed. Reopen /usage and confirm the current offer.", + ); + return None; + } + let request_id = Uuid::new_v4(); + self.referral_state.pending_request_id = Some(request_id); + self.referral_state.pending_email = Some(email.to_string()); + let params = SelectionViewParams { + view_id: Some(REFERRAL_VIEW_ID), + title: Some("Invite someone to Codex".to_string()), + subtitle: Some(format!("Sending an invite to {email}...")), + items: vec![SelectionItem { + name: "Sending invite...".to_string(), + is_disabled: true, + ..Default::default() + }], + allow_cancel: false, + ..Default::default() + }; + if !self + .bottom_pane + .replace_active_views_with_selection_view(&[REFERRAL_VIEW_ID], params) + { + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(REFERRAL_VIEW_ID), + title: Some("Invite someone to Codex".to_string()), + subtitle: Some(format!("Sending an invite to {email}...")), + items: vec![SelectionItem { + name: "Sending invite...".to_string(), + is_disabled: true, + ..Default::default() + }], + allow_cancel: false, + ..Default::default() + }); + } + self.request_redraw(); + Some((request_id, confirmed_offer)) + } + + pub(crate) fn finish_referral_send(&mut self, request_id: Uuid, result: ReferralInviteResult) { + if self.referral_state.pending_request_id != Some(request_id) { + return; + } + self.referral_state.pending_request_id = None; + let email = self.referral_state.pending_email.take().unwrap_or_default(); + let message = match result { + ReferralInviteResult::Sent(ReferralRewardStatus::Included) => { + format!("Invite sent to {email}.") + } + ReferralInviteResult::Sent(ReferralRewardStatus::NotIncluded) => { + format!("Invite sent to {email}, but it did not include a reward.") + } + ReferralInviteResult::Sent(ReferralRewardStatus::Unknown) => { + format!("Invite sent to {email}; reward status wasn't confirmed.") + } + ReferralInviteResult::Rejected => { + "We couldn't send that invite. Check the email address and try again.".to_string() + } + ReferralInviteResult::Unavailable => { + "This referral offer changed. Reopen /usage and confirm the current offer." + .to_string() + } + ReferralInviteResult::Unknown => { + "We couldn't confirm the invite. Check with the recipient before trying again." + .to_string() + } + }; + if matches!( + result, + ReferralInviteResult::Sent(_) | ReferralInviteResult::Unavailable + ) { + self.referral_state.offer = None; + self.referral_state.offer_loaded_at = None; + } + self.show_referral_message(&message); + } + + pub(crate) fn clear_referral_state(&mut self) { + self.referral_state = ReferralState::default(); + self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID); + self.bottom_pane.dismiss_view_by_id(REFERRAL_VIEW_ID); + } + + fn show_referral_message(&mut self, message: &str) { + let build_params = || SelectionViewParams { + view_id: Some(REFERRAL_VIEW_ID), + title: Some("Invite someone to Codex".to_string()), + subtitle: Some(message.to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Close".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + }; + if !self + .bottom_pane + .replace_selection_view_if_present(REFERRAL_VIEW_ID, build_params()) + && !self + .bottom_pane + .replace_active_views_with_selection_view(&[REFERRAL_VIEW_ID], build_params()) + { + self.bottom_pane.show_selection_view(build_params()); + } + self.request_redraw(); + } + + fn current_referral_offer(&self) -> Option<&ReferralOffer> { + let loaded_at = self.referral_state.offer_loaded_at?; + if loaded_at.elapsed() > REFERRAL_OFFER_MAX_AGE { + return None; + } + self.referral_state.offer.as_ref() + } +} diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 269606098f76..bd380dac05b0 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -222,6 +222,7 @@ impl ChatWidget { // be identical across two accounts, so always invalidate account-scoped requests and data. self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); + self.clear_referral_state(); self.codex_rate_limit_reached_type = None; self.rate_limit_warnings = RateLimitWarningState::default(); self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_menu_with_referral_offer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_menu_with_referral_offer.snap new file mode 100644 index 000000000000..492e2e6af1b1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_menu_with_referral_offer.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/referrals.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Usage + View account usage or redeem an earned reset. + +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset Check reset availability. + 3. Invite someone to Codex Invite a friend and earn a usage reset. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 24423d373706..ef448aa9d01c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -248,6 +248,7 @@ mod plan_mode; #[path = "tests/plugin_catalog_tests.rs"] mod plugin_catalog; mod popups_and_settings; +mod referrals; mod review_mode; mod side; mod slash_commands; diff --git a/codex-rs/tui/src/chatwidget/tests/referrals.rs b/codex-rs/tui/src/chatwidget/tests/referrals.rs new file mode 100644 index 000000000000..e6263c4fac15 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/referrals.rs @@ -0,0 +1,194 @@ +use super::*; +use codex_chatgpt::referrals::ReferralIdentity; +use codex_chatgpt::referrals::ReferralOffer; +use codex_chatgpt::referrals::ReferralRewardStatus; +use std::time::Duration; +use std::time::Instant; +use uuid::Uuid; + +use crate::app_event::ReferralInviteResult; +use crate::chatwidget::referrals::REFERRAL_OFFER_MAX_AGE; + +#[tokio::test] +async fn usage_menu_adds_eligible_referral_offer() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + + chat.dispatch_command(SlashCommand::Usage); + let request_id = referral_refresh_request(&mut rx); + chat.finish_referral_offer_refresh(request_id, Some(test_offer())); + + assert_chatwidget_snapshot!( + "usage_menu_with_referral_offer", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + +#[tokio::test] +async fn referral_popup_flow_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let offer = test_offer(); + chat.start_referral_offer_refresh(); + let request_id = referral_refresh_request(&mut rx); + chat.finish_referral_offer_refresh(request_id, Some(offer)); + + chat.show_referral_email_prompt(); + let email_prompt = render_bottom_popup(&chat, /*width*/ 80); + chat.show_referral_confirmation("friend@example.com".to_string()); + let confirmation = render_bottom_popup(&chat, /*width*/ 50); + let confirmed_offer = test_offer(); + let (send_request_id, _) = chat + .start_referral_send("friend@example.com", confirmed_offer) + .expect("eligible offer starts send"); + let sending = render_bottom_popup(&chat, /*width*/ 80); + chat.finish_referral_send( + send_request_id, + ReferralInviteResult::Sent(ReferralRewardStatus::Included), + ); + let success = render_bottom_popup(&chat, /*width*/ 80); + assert!( + chat.bottom_pane + .dismiss_active_view_if_id("referral-invite"), + "success popup should be dismissible" + ); + assert_ne!( + chat.bottom_pane.active_view_id(), + Some("referral-invite"), + "closing success should not reveal stale referral UI" + ); + + assert_snapshot!( + "referral_popup_flow", + format!( + "EMAIL\n{email_prompt}\nCONFIRMATION\n{confirmation}\nSENDING\n{sending}\nSUCCESS\n{success}" + ) + ); +} + +#[tokio::test] +async fn account_change_invalidates_referral_offer() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let offer = test_offer(); + chat.start_referral_offer_refresh(); + let stale_request_id = referral_refresh_request(&mut rx); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.start_referral_offer_refresh(); + let current_request_id = referral_refresh_request(&mut rx); + assert_ne!(stale_request_id, current_request_id); + chat.finish_referral_offer_refresh(stale_request_id, Some(offer)); + + assert_eq!( + chat.start_referral_send("friend@example.com", test_offer()), + None + ); +} + +#[tokio::test] +async fn account_change_dismisses_open_referral_prompt() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.start_referral_offer_refresh(); + let request_id = referral_refresh_request(&mut rx); + chat.finish_referral_offer_refresh(request_id, Some(test_offer())); + chat.show_referral_email_prompt(); + assert_eq!(chat.bottom_pane.active_view_id(), Some("referral-invite")); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + + assert_ne!( + chat.bottom_pane.active_view_id(), + Some("referral-invite"), + "account changes should dismiss open referral prompts" + ); +} + +#[tokio::test] +async fn referral_refresh_preserves_last_loaded_offer() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.start_referral_offer_refresh(); + let request_id = referral_refresh_request(&mut rx); + chat.finish_referral_offer_refresh(request_id, Some(test_offer())); + + chat.start_referral_offer_refresh(); + let _pending_request_id = referral_refresh_request(&mut rx); + + assert!( + chat.referral_menu_item().is_some(), + "refreshing should not discard the last loaded offer" + ); +} + +#[tokio::test] +async fn expired_referral_offer_is_not_selectable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.start_referral_offer_refresh(); + let request_id = referral_refresh_request(&mut rx); + chat.finish_referral_offer_refresh(request_id, Some(test_offer())); + + chat.referral_state.offer_loaded_at = + Some(Instant::now() - REFERRAL_OFFER_MAX_AGE - Duration::from_secs(1)); + + assert!( + chat.referral_menu_item().is_none(), + "expired referral offers should be hidden" + ); + assert_eq!( + chat.start_referral_send("friend@example.com", test_offer()), + None + ); +} + +#[tokio::test] +async fn changed_referral_offer_requires_reconfirmation_before_send() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.start_referral_offer_refresh(); + let request_id = referral_refresh_request(&mut rx); + let confirmed_offer = test_offer(); + chat.finish_referral_offer_refresh(request_id, Some(confirmed_offer.clone())); + + let mut replacement_offer = test_offer(); + replacement_offer.rules = vec!["Different terms apply.".to_string()]; + chat.start_referral_offer_refresh(); + let request_id = referral_refresh_request(&mut rx); + chat.finish_referral_offer_refresh(request_id, Some(replacement_offer)); + + assert_eq!( + chat.start_referral_send("friend@example.com", confirmed_offer), + None, + "changed referral offers should require a new confirmation" + ); +} + +fn test_offer() -> ReferralOffer { + ReferralOffer { + description: "Invite a friend and earn a usage reset.".to_string(), + rules: vec!["Your friend must be new to Codex.".to_string()], + grant_action: Some(serde_json::json!("usage_reset")), + grant_amount: Some(serde_json::json!(1)), + requires_explicit_confirmation: true, + identity: ReferralIdentity { + user_id: "user-1".to_string(), + account_id: "account-1".to_string(), + }, + } +} + +fn referral_refresh_request(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Uuid { + let event = rx.try_recv().expect("referral refresh event"); + let AppEvent::RefreshReferralOffer(request_id) = event else { + panic!("expected referral refresh, got {event:?}"); + }; + request_id +} diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__referrals__referral_popup_flow.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__referrals__referral_popup_flow.snap new file mode 100644 index 000000000000..243a8553cda9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__referrals__referral_popup_flow.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/chatwidget/tests/referrals.rs +expression: "format!(\"EMAIL\\n{email_prompt}\\nCONFIRMATION\\n{confirmation}\\nSENDING\\n{sending}\\nSUCCESS\\n{success}\")" +--- +EMAIL +▌ Invite someone to Codex +▌ Enter one email address, then press Enter. +▌ +▌ name@example.com + +Press enter to confirm or esc to go back +CONFIRMATION + Send referral invite? + Invite a friend and earn a usage reset. + Recipient: friend@example.com + • Your friend must be new to Codex. + By sending, you confirm that you have this + person's consent. + + 1. Send invite +› 2. Cancel + + Press enter to confirm or esc to go back +SENDING + Invite someone to Codex + Sending an invite to friend@example.com... + +› Sending invite... +SUCCESS + Invite someone to Codex + Invite sent to friend@example.com. + +› 1. Close + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index c05b4a62ffac..871a30eecee4 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -18,6 +18,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { )); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); assert_chatwidget_snapshot!( "usage_command_menu", @@ -39,6 +40,7 @@ async fn usage_command_disables_reset_after_cached_zero_snapshot() { )); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); assert_chatwidget_snapshot!( "usage_command_menu_without_resets", @@ -67,6 +69,7 @@ async fn usage_menu_refresh_enables_newly_available_reset() { )); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); assert_matches!( rx.try_recv(), Ok(AppEvent::RefreshRateLimits { @@ -96,6 +99,7 @@ async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { )); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); assert_matches!( rx.try_recv(), Ok(AppEvent::RefreshRateLimits { @@ -125,6 +129,7 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); assert_matches!( rx.try_recv(), Ok(AppEvent::RefreshRateLimits { @@ -154,6 +159,7 @@ async fn usage_command_can_check_reset_availability_before_startup_refresh_finis chat.start_rate_limit_reset_startup_check(); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); assert_chatwidget_snapshot!( "usage_command_menu_before_reset_refresh", @@ -171,6 +177,7 @@ async fn usage_command_can_check_reset_availability_for_workspace_accounts() { chat.plan_type = Some(PlanType::Business); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -188,6 +195,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -405,6 +413,7 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { dismiss_popup(&mut chat); chat.dispatch_command(SlashCommand::Usage); + expect_referral_refresh(&mut rx); assert_matches!( rx.try_recv(), Ok(AppEvent::RefreshRateLimits { @@ -756,6 +765,14 @@ fn finish_reset_consume_outcome( ) } +fn expect_referral_refresh(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Uuid { + let event = rx.try_recv().expect("referral eligibility refresh event"); + let AppEvent::RefreshReferralOffer(request_id) = event else { + panic!("expected referral refresh, got {event:?}"); + }; + request_id +} + fn record_popup(chat: &ChatWidget, states: &mut Vec) { states.push(render_bottom_popup(chat, /*width*/ 80)); } diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 53925e86d90a..98a9fd06d384 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -6,12 +6,13 @@ use uuid::Uuid; use super::rate_limits::get_limits_duration; use super::*; -const USAGE_MENU_VIEW_ID: &str = "usage-menu"; +pub(super) const USAGE_MENU_VIEW_ID: &str = "usage-menu"; const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset"; impl ChatWidget { pub(super) fn open_usage_menu(&mut self) { self.clear_pending_rate_limit_reset_hint(); + self.start_referral_offer_refresh(); let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0); self.bottom_pane .show_selection_view(self.usage_menu_params()); @@ -25,7 +26,7 @@ impl ChatWidget { self.request_redraw(); } - fn usage_menu_params(&self) -> SelectionViewParams { + pub(super) fn usage_menu_params(&self) -> SelectionViewParams { let reset_eligible = self.has_chatgpt_account; let (reset_action_enabled, reset_description) = match (reset_eligible, self.available_rate_limit_reset_credits) { @@ -41,32 +42,37 @@ impl ChatWidget { (false, "No usage limit resets available.".to_string()) } }; + let mut items = vec![ + SelectionItem { + name: "Show usage".to_string(), + description: Some("View recent account token usage.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenTokenActivity); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Redeem usage limit reset".to_string(), + description: Some(reset_description), + is_disabled: !reset_action_enabled, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenRateLimitResetCredits); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + if let Some(referral_item) = self.referral_menu_item() { + items.push(referral_item); + } + SelectionViewParams { view_id: Some(USAGE_MENU_VIEW_ID), title: Some("Usage".to_string()), subtitle: Some("View account usage or redeem an earned reset.".to_string()), footer_hint: Some(standard_popup_hint_line()), - items: vec![ - SelectionItem { - name: "Show usage".to_string(), - description: Some("View recent account token usage.".to_string()), - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenTokenActivity); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Redeem usage limit reset".to_string(), - description: Some(reset_description), - is_disabled: !reset_action_enabled, - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenRateLimitResetCredits); - })], - dismiss_on_select: true, - ..Default::default() - }, - ], + items, ..Default::default() } } @@ -87,7 +93,11 @@ impl ChatWidget { if let Ok(response) = result { self.available_rate_limit_reset_credits = Some(response.available_count); } - let params = self.usage_menu_params(); + let selected = self + .bottom_pane + .selected_index_for_active_view(USAGE_MENU_VIEW_ID); + let mut params = self.usage_menu_params(); + params.initial_selected_idx = selected; if self .bottom_pane .replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params) diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 369e19d7fd4d..a7ba10ae3cdc 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -197,6 +197,65 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::RefreshReferralOffer(request_id) => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "RefreshReferralOffer", + "request_id": request_id, + }); + LOGGER.write_json_line(value); + } + AppEvent::ReferralOfferLoaded(request_id, offer) => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "ReferralOfferLoaded", + "request_id": request_id, + "has_offer": offer.is_some(), + }); + LOGGER.write_json_line(value); + } + AppEvent::OpenReferralEmailPrompt => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "OpenReferralEmailPrompt", + }); + LOGGER.write_json_line(value); + } + AppEvent::OpenReferralConfirmation(_) => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "OpenReferralConfirmation", + }); + LOGGER.write_json_line(value); + } + AppEvent::SendReferralInvite { .. } => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "SendReferralInvite", + }); + LOGGER.write_json_line(value); + } + AppEvent::ReferralInviteSent(request_id, result) => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "ReferralInviteSent", + "request_id": request_id, + "result": format!("{result:?}").split('(').next().unwrap_or("Unknown"), + }); + LOGGER.write_json_line(value); + } // Noise or control flow – record variant only other => { let value = json!({