add referral invites to /usage

This commit is contained in:
jayp-oai
2026-06-27 20:52:37 -07:00
parent bdd282f3bb
commit f2eb0492e2
24 changed files with 1203 additions and 26 deletions
+2
View File
@@ -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",
+1
View File
@@ -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 }
+35
View File
@@ -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<IoResult<()>>,
},
ReferralClient {
response_tx: oneshot::Sender<Arc<ReferralClient>>,
},
Shutdown {
response_tx: oneshot::Sender<IoResult<()>>,
},
@@ -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::<ClientCommand>(channel_capacity);
let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(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<Arc<ReferralClient>> {
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<Option<Arc<ReferralClient>>> {
match self {
Self::InProcess(handle) => handle.referral_client().await.map(Some),
Self::Remote(_) => Ok(None),
}
}
}
impl AppServerClient {
+14 -1
View File
@@ -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<InProcessServerEvent>,
runtime_handle: tokio::task::JoinHandle<()>,
referral_client: Arc<ReferralClient>,
#[cfg(test)]
_test_codex_home: Option<tempfile::TempDir>,
}
@@ -342,6 +345,10 @@ impl InProcessClientHandle {
pub fn sender(&self) -> InProcessClientSender {
self.client.clone()
}
pub fn referral_client(&self) -> Arc<ReferralClient> {
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<InProcessClie
let installation_id = resolve_installation_id(&args.config.codex_home).await?;
let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(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::<OutgoingEnvelope>(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<InProcessClie
let _ = done_tx.send(());
}
});
Ok(InProcessClientHandle {
client: InProcessClientSender { client_tx },
event_rx,
runtime_handle,
referral_client,
#[cfg(test)]
_test_codex_home: None,
})
+1 -1
View File
@@ -18,12 +18,12 @@ codex-model-provider = { workspace = true }
codex-plugin = { workspace = true }
codex-utils-cli = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
[dev-dependencies]
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
[lib]
+1
View File
@@ -2,4 +2,5 @@ pub mod apply_command;
mod chatgpt_client;
pub mod connectors;
pub mod get_task;
pub mod referrals;
pub mod workspace_settings;
+345
View File
@@ -0,0 +1,345 @@
//! Client-owned referral requests used by short-lived Codex experiments.
use anyhow::Context;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::error::Error;
use std::fmt;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
const REFERRAL_KEY: &str = "codex_referral_persistent_invite";
const ELIGIBILITY_TIMEOUT: Duration = Duration::from_secs(15);
const INVITE_TIMEOUT: Duration = Duration::from_secs(45);
/// A rewarded referral offer and the account that received it.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReferralOffer {
pub description: String,
pub rules: Vec<String>,
pub grant_action: Option<Value>,
pub grant_amount: Option<Value>,
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::<DefiniteReferralInviteRejection>()
.is_some()
}
pub fn is_referral_invite_preflight_failure(error: &anyhow::Error) -> bool {
error
.downcast_ref::<ReferralInvitePreflightFailure>()
.is_some()
}
#[derive(Deserialize)]
struct EligibilityResponse {
should_show: bool,
#[serde(default)]
has_rewards: bool,
#[serde(default)]
description: Option<String>,
#[serde(default)]
grant_action: Option<Value>,
#[serde(default)]
grant_amount: Option<Value>,
}
#[derive(Deserialize)]
struct RulesResponse {
#[serde(default)]
rules: Vec<String>,
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<bool>,
}
/// HTTP client for the temporary client-owned referral flow.
pub struct ReferralClient {
auth_manager: Arc<OnceLock<Arc<AuthManager>>>,
base_url: String,
}
impl ReferralClient {
pub fn new(auth_manager: Arc<OnceLock<Arc<AuthManager>>>, base_url: String) -> Self {
Self {
auth_manager,
base_url,
}
}
pub async fn load_offer(&self) -> anyhow::Result<Option<ReferralOffer>> {
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<ReferralRewardStatus> {
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 &current_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<Arc<AuthManager>> {
self.auth_manager
.get()
.cloned()
.context("ChatGPT authentication is not ready")
}
async fn load_auth(
&self,
auth_manager: &Arc<AuthManager>,
) -> 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<AuthManager>,
) -> anyhow::Result<CodexAuth> {
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<ReferralIdentity> {
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<String>) -> 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())
}
+41
View File
@@ -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<reqwest::Client, BuildCustomCaTransp
build_reqwest_client_with_custom_ca(default_reqwest_client_builder())
}
/// Tries to build the default reqwest client with retries and redirects disabled.
pub fn try_build_single_attempt_reqwest_client()
-> Result<reqwest::Client, BuildCustomCaTransportError> {
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() {
+1
View File
@@ -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 }
@@ -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,
+24
View File
@@ -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);
+32
View File
@@ -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<Box<ReferralOffer>>),
/// 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<ReferralOffer>,
},
/// 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 {
@@ -33,6 +33,7 @@ pub(crate) struct CustomPromptView {
title: String,
placeholder: String,
context_label: Option<String>,
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;
+2
View File
@@ -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<PlainHistoryCell>,
available_rate_limit_reset_credits: Option<i64>,
next_rate_limit_reset_request_id: u64,
referral_state: referrals::ReferralState,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
rate_limit_warnings: RateLimitWarningState,
@@ -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(),
+286
View File
@@ -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<ReferralOffer>,
pub(super) offer_loaded_at: Option<Instant>,
pending_request_id: Option<Uuid>,
pending_email: Option<String>,
}
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<ReferralOffer>,
) {
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<SelectionItem> {
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()
}
}
+1
View File
@@ -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;
@@ -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
+1
View File
@@ -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;
@@ -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<AppEvent>) -> 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
}
@@ -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
@@ -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<AppEvent>) -> 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<String>) {
states.push(render_bottom_popup(chat, /*width*/ 80));
}
+34 -24
View File
@@ -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)
+59
View File
@@ -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!({