Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 11 additions & 22 deletions codex-rs/tui/src/app/background_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ impl App {
tokio::spawn(async move {
let request = fetch_account_rate_limits(request_handle);
let result = match origin {
RateLimitRefreshOrigin::ResetConsume { .. } => {
RateLimitRefreshOrigin::ResetConsume { .. }
| RateLimitRefreshOrigin::ResetPicker { .. } => {
tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request)
.await
.map_err(|_| "account/rateLimits/read timed out in TUI".to_string())
Expand Down Expand Up @@ -118,44 +119,31 @@ impl App {
});
}

pub(super) fn refresh_rate_limit_reset_credits(
&mut self,
app_server: &AppServerSession,
request_id: u64,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
RATE_LIMIT_RESET_REQUEST_TIMEOUT,
fetch_account_rate_limits(request_handle),
)
.await
.map_err(|_| "account/rateLimits/read timed out in TUI".to_string())
.and_then(|result| result.map_err(|err| err.to_string()));
app_event_tx.send(AppEvent::RateLimitResetCreditsLoaded { request_id, result });
});
}

pub(super) fn consume_rate_limit_reset_credit(
&mut self,
app_server: &AppServerSession,
request_id: u64,
idempotency_key: String,
credit_id: Option<String>,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
RATE_LIMIT_RESET_REQUEST_TIMEOUT,
consume_rate_limit_reset_credit_request(request_handle, idempotency_key.clone()),
consume_rate_limit_reset_credit_request(
request_handle,
idempotency_key.clone(),
credit_id.clone(),
),
)
.await
.map_err(|_| "account/rateLimitResetCredit/consume timed out in TUI".to_string())
.and_then(|result| result.map_err(|err| err.to_string()));
app_event_tx.send(AppEvent::RateLimitResetCreditConsumed {
request_id,
idempotency_key,
credit_id,
result,
});
});
Expand Down Expand Up @@ -793,14 +781,15 @@ pub(super) async fn fetch_account_token_activity(
pub(super) async fn consume_rate_limit_reset_credit_request(
request_handle: AppServerRequestHandle,
idempotency_key: String,
credit_id: Option<String>,
) -> Result<ConsumeAccountRateLimitResetCreditResponse> {
let request_id = RequestId::String(format!("consume-rate-limit-reset-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::ConsumeAccountRateLimitResetCredit {
request_id,
params: ConsumeAccountRateLimitResetCreditParams {
idempotency_key,
credit_id: None,
credit_id,
},
})
.await
Expand Down
59 changes: 33 additions & 26 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,16 @@ impl App {
}
RateLimitRefreshOrigin::UsageMenu { request_id } => {
self.chat_widget.finish_usage_menu_rate_limit_refresh(
request_id,
snapshots,
rate_limit_reset_credits.ok_or_else(|| {
"account/rateLimits/read response did not include rateLimitResetCredits"
.to_string()
}),
);
}
RateLimitRefreshOrigin::ResetPicker { request_id } => {
self.chat_widget.finish_rate_limit_reset_credits_refresh(
request_id,
snapshots,
rate_limit_reset_credits.ok_or_else(|| {
Expand Down Expand Up @@ -905,6 +915,13 @@ impl App {
Err(err),
);
}
RateLimitRefreshOrigin::ResetPicker { request_id } => {
self.chat_widget.finish_rate_limit_reset_credits_refresh(
request_id,
Vec::new(),
Err(err),
);
}
}
}
},
Expand All @@ -914,38 +931,27 @@ impl App {
}
AppEvent::OpenRateLimitResetCredits => {
let request_id = self.chat_widget.show_rate_limit_reset_loading_popup();
self.refresh_rate_limit_reset_credits(app_server, request_id);
self.refresh_rate_limits(
app_server,
RateLimitRefreshOrigin::ResetPicker { request_id },
);
}
AppEvent::RateLimitResetCreditsLoaded { request_id, result } => match result {
Ok(response) => {
let rate_limit_reset_credits = response.rate_limit_reset_credits.clone();
self.chat_widget.finish_rate_limit_reset_credits_refresh(
request_id,
app_server_rate_limit_snapshots(response),
rate_limit_reset_credits.ok_or_else(|| {
"account/rateLimits/read response did not include rateLimitResetCredits"
.to_string()
}),
);
}
Err(err) => {
tracing::warn!(
"account/rateLimits/read failed during reset-credit refresh: {err}"
);
self.chat_widget.finish_rate_limit_reset_credits_refresh(
request_id,
Vec::new(),
Err(err),
);
}
},
AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => {
AppEvent::ConsumeRateLimitResetCredit {
idempotency_key,
credit_id,
} => {
let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup();
self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key);
self.consume_rate_limit_reset_credit(
app_server,
request_id,
idempotency_key,
credit_id,
);
}
AppEvent::RateLimitResetCreditConsumed {
request_id,
idempotency_key,
credit_id,
result,
} => {
if let Err(err) = &result {
Expand All @@ -956,6 +962,7 @@ impl App {
if self.chat_widget.finish_rate_limit_reset_consume(
request_id,
idempotency_key,
credit_id,
result,
) {
self.refresh_rate_limits(
Expand Down
13 changes: 6 additions & 7 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ pub(crate) struct PluginRemoteSectionError {
/// invocation and must call `finish_status_rate_limit_refresh` when done so the
/// card stops showing a "refreshing" state. A `UsageMenu` refreshes a cached
/// zero reset count so the disabled menu entry can become available without a
/// restart.
/// restart. A `ResetPicker` refreshes the rate limits and detailed reset-credit
/// rows before showing redemption choices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RateLimitRefreshOrigin {
/// Eagerly fetched after bootstrap for `/status` data and reset availability.
Expand All @@ -130,6 +131,8 @@ pub(crate) enum RateLimitRefreshOrigin {
StatusCommand { request_id: u64 },
/// User reopened `/usage` while the cached reset-credit count was zero.
UsageMenu { request_id: u64 },
/// User opened the reset-credit picker.
ResetPicker { request_id: u64 },
/// Refresh requested after a reset credit was successfully consumed.
ResetConsume { request_id: u64 },
}
Expand Down Expand Up @@ -323,21 +326,17 @@ pub(crate) enum AppEvent {
/// Open the reset-credit flow selected from the `/usage` menu.
OpenRateLimitResetCredits,

/// Result of reading the current reset-credit balance.
RateLimitResetCreditsLoaded {
request_id: u64,
result: Result<GetAccountRateLimitsResponse, String>,
},

/// Consume one reset credit using a stable idempotency key.
ConsumeRateLimitResetCredit {
idempotency_key: String,
credit_id: Option<String>,
},

/// Result of consuming one reset credit.
RateLimitResetCreditConsumed {
request_id: u64,
idempotency_key: String,
credit_id: Option<String>,
result: Result<ConsumeAccountRateLimitResetCreditResponse, String>,
},

Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ use self::rate_limits::RateLimitWarningState;
use self::rate_limits::app_server_rate_limit_error_kind;
pub(crate) use self::rate_limits::fallback_limit_label;
use self::rate_limits::is_app_server_cyber_policy_error;
mod reset_credits;
pub(crate) use self::rate_limits::limit_label_for_window;
mod reasoning_shortcuts;
mod rendering;
Expand Down
129 changes: 129 additions & 0 deletions codex-rs/tui/src/chatwidget/reset_credits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use crate::status::RateLimitSnapshotDisplay;
use chrono::DateTime;
use chrono::Local;
use chrono::Utc;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_protocol::account::PlanType;
use std::collections::BTreeMap;

use super::rate_limits::get_limits_duration;

pub(super) enum RateLimitResetScope {
Monthly,
WeeklyAndFiveHour,
Unknown,
}

impl RateLimitResetScope {
pub(super) fn picker_label(&self) -> &'static str {
match self {
Self::Monthly => "Full reset (Monthly)",
Self::WeeklyAndFiveHour => "Full reset (Weekly + 5h)",
Self::Unknown => "Full reset",
}
}

pub(super) fn usage_description(&self) -> &'static str {
match self {
Self::Monthly => "Reset your current monthly usage limit.",
Self::WeeklyAndFiveHour => "Reset your current 5-hour and weekly usage limits.",
Self::Unknown => "Reset your current usage limits.",
}
}
}

#[derive(Debug, Eq, PartialEq)]
pub(super) struct ResetCreditOption {
pub(super) credit_id: Option<String>,
pub(super) name: String,
pub(super) description: String,
}

pub(super) fn rate_limit_reset_scope(
rate_limits: &BTreeMap<String, RateLimitSnapshotDisplay>,
plan_type: Option<PlanType>,
) -> RateLimitResetScope {
let window_labels = rate_limits
.iter()
.find(|(limit_id, _)| limit_id.eq_ignore_ascii_case("codex"))
.into_iter()
.flat_map(|(_, snapshot)| [snapshot.primary.as_ref(), snapshot.secondary.as_ref()])
.flatten()
.filter_map(|window| window.window_minutes.and_then(get_limits_duration))
.collect::<Vec<_>>();

if window_labels.iter().any(|label| label == "monthly")
|| matches!(plan_type, Some(PlanType::Free | PlanType::Go))
{
RateLimitResetScope::Monthly
} else if window_labels
.iter()
.any(|label| label == "5h" || label == "weekly")
{
RateLimitResetScope::WeeklyAndFiveHour
} else {
RateLimitResetScope::Unknown
}
}

pub(super) fn reset_credit_options(
summary: &RateLimitResetCreditsSummary,
scope: RateLimitResetScope,
) -> Vec<ResetCreditOption> {
let available_count = summary.available_count.max(0);
let detail_limit = usize::try_from(available_count).unwrap_or(usize::MAX);
let mut available_credits = summary
.credits
.as_deref()
.unwrap_or_default()
.iter()
.filter(|credit| credit.status == RateLimitResetCreditStatus::Available)
.collect::<Vec<_>>();
available_credits.sort_by_key(|credit| credit.expires_at.unwrap_or(i64::MAX));

let mut options = available_credits
.into_iter()
.take(detail_limit)
.map(|credit| {
let expiration = match credit.expires_at {
Some(expires_at) => DateTime::<Utc>::from_timestamp(expires_at, 0)
.map(|expires_at| {
format!(
"Expires {}",
expires_at
.with_timezone(&Local)
.format("%H:%M on %-d %b %Y")
)
})
.unwrap_or_else(|| "Expiration unavailable".to_string()),
None => "Does not expire".to_string(),
};
let reset_label = credit
.title
.as_deref()
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| match credit.reset_type {
RateLimitResetType::CodexRateLimits | RateLimitResetType::Unknown => {
scope.picker_label()
}
});
ResetCreditOption {
credit_id: Some(credit.id.clone()),
name: reset_label.to_string(),
description: format!("{expiration}."),
}
})
.collect::<Vec<_>>();

if options.is_empty() {
options.push(ResetCreditOption {
credit_id: None,
name: "Use a reset".to_string(),
description: scope.usage_description().to_string(),
});
}

options
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: "render_bottom_popup(&chat, 44)"
---
Usage limit resets
2 usage limit resets available.

› 1. Cancel
2. Full reset Expires 09:39 on 18 Jun
2026.
3. Full reset Does not expire.

Press enter to confirm or esc to go back
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ expression: "states.join(\"\\n---\\n\")"
Press enter to confirm or esc to go back
---
Usage limit resets
You have 2 usage limit resets available.
2 usage limit resets available.

1. Use a reset Reset your current 5-hour and weekly usage limits.
› 2. Cancel
› 1. Cancel
2. Full reset (Weekly + 5 hr) Expires 09:39 on 18 Jun 2026.
3. Full reset (Weekly + 5 hr) Expires 08:59 on 27 Jun 2026.

Press enter to confirm or esc to go back
---
Expand All @@ -27,7 +28,8 @@ expression: "states.join(\"\\n---\\n\")"
Usage limit resets
Couldn't load usage limit resets. Please try again.

› 1. Close
› 1. Try again
2. Close

Press enter to confirm or esc to go back
---
Expand Down
Loading
Loading