Skip to content
Open
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
76 changes: 52 additions & 24 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,48 @@ 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(
AppEvent::OpenRateLimitResetConfirmation {
picker_request_id,
confirmation_gate,
credit_id,
reset_title,
reset_detail,
reset_description,
} => {
self.chat_widget.show_rate_limit_reset_confirmation(
picker_request_id,
confirmation_gate,
Comment on lines +947 to +949

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Reset stale gates when confirmation is not shown

When two different reset rows are accepted before the first OpenRateLimitResetConfirmation is handled, the first event opens the confirmation and the second reaches this call while the active view is no longer the picker, so show_rate_limit_reset_confirmation returns false. Because that return value is ignored, the second row's AtomicBool remains false; after backing out of the first confirmation, selecting that second row no longer sends another event until the picker is rebuilt.

Useful? React with 👍 / 👎.

credit_id,
reset_title,
reset_detail,
reset_description,
);
}
AppEvent::ConsumeRateLimitResetCredit {
idempotency_key,
credit_id,
} => {
if let Some(request_id) = self
.chat_widget
.start_rate_limit_reset_consumption(&idempotency_key)
{
self.consume_rate_limit_reset_credit(
app_server,
request_id,
Vec::new(),
Err(err),
idempotency_key,
credit_id,
);
}
},
AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => {
let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup();
self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key);
}
AppEvent::RateLimitResetCreditConsumed {
request_id,
idempotency_key,
credit_id,
result,
} => {
if let Err(err) = &result {
Expand All @@ -956,6 +983,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
21 changes: 16 additions & 5 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.

use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;

use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
Expand Down Expand Up @@ -120,7 +122,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 +133,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 +328,27 @@ 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>,
/// Confirm the reset credit selected from the reset-credit picker.
OpenRateLimitResetConfirmation {
picker_request_id: u64,
confirmation_gate: Arc<AtomicBool>,
credit_id: Option<String>,
reset_title: String,
reset_detail: Option<String>,
reset_description: 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
3 changes: 3 additions & 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 Expand Up @@ -557,6 +558,8 @@ pub(crate) struct ChatWidget {
completed_token_activity_output: Option<history_cell::CompositeHistoryCell>,
next_token_activity_request_id: u64,
pending_rate_limit_reset_request_id: Option<u64>,
pending_rate_limit_reset_idempotency_key: Option<String>,
rate_limit_reset_picker_request_id: Option<u64>,
pending_rate_limit_reset_hint_request_id: Option<u64>,
pending_usage_menu_rate_limit_request_id: Option<u64>,
pending_rate_limit_reset_hint: Option<PlainHistoryCell>,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ impl ChatWidget {
completed_token_activity_output: None,
next_token_activity_request_id: 0,
pending_rate_limit_reset_request_id: None,
pending_rate_limit_reset_idempotency_key: None,
rate_limit_reset_picker_request_id: None,
pending_rate_limit_reset_hint_request_id: None,
pending_usage_menu_rate_limit_request_id: None,
pending_rate_limit_reset_hint: None,
Expand Down
77 changes: 77 additions & 0 deletions codex-rs/tui/src/chatwidget/reset_credits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use chrono::DateTime;
use chrono::Local;
use chrono::Utc;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;

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

pub(super) fn reset_credit_options(
summary: &RateLimitResetCreditsSummary,
) -> 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_title = credit
.title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty())
.unwrap_or("Full reset");
let reset_description = credit
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.unwrap_or("Reset your current usage limits.");
ResetCreditOption {
credit_id: Some(credit.id.clone()),
name: reset_title.to_string(),
detail: Some(format!("{expiration}.")),
description: reset_description.to_string(),
}
})
.collect::<Vec<_>>();

if options.is_empty() {
options.push(ResetCreditOption {
credit_id: None,
name: "Full reset".to_string(),
detail: None,
description: "Reset your current usage limits.".to_string(),
});
}

options
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/usage.rs
expression: "render_bottom_popup(&chat, 80)"
---
Use this reset?
Full reset (Weekly + 5 hr) · Expires 09:39 on 18 Jun 2026.

1. Yes, use reset Reset your weekly and 5-hour usage limits.
› 2. No, go back Choose a different reset.

Press enter to confirm or esc to go back
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
Loading
Loading