mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Fix usage-limit reset copy and state (#28793)
## Why The reset flow introduced in #28154 still describes earned reset credits as "rate-limit resets" and uses generic reset-scope copy. It can also retain a stale available-credit count after redemption or an account change, leaving the reset action enabled after the last credit is used. This follow-up updates terminology only within that reset feature. Existing rate-limit wording elsewhere in the CLI and TUI is unchanged. ## What changed - Rename reset-specific `/usage` menu items, startup hints, and reset dialogs to "usage limit reset." - Describe monthly resets for Free, Go, and accounts that report a monthly usage window; otherwise describe the current 5-hour and weekly limits. - Recheck a cached zero balance when `/usage` is reopened, and refresh the balance after redemption so the final reset immediately disables the action. - Correlate async refresh results before updating snapshots and clear account-derived reset state, warnings, prompts, and status surfaces when the account changes. ## Validation - `just test -p codex-tui chatwidget::tests::usage` — 29 passed. - `just test -p codex-tui chatwidget::tests::status_command_tests` — 7 passed. - Account-boundary prompt and plan-mode prompt regression tests passed. - `cargo insta pending-snapshots` from `codex-rs/tui` — no pending snapshots.\ <img width="814" height="318" alt="image" src="https://github.com/user-attachments/assets/2a460e96-458b-4805-8d9f-c759382d21a4" /> view for monthly <img width="905" height="243" alt="image" src="https://github.com/user-attachments/assets/179f88e3-08fb-4af5-8dc6-ce6a944ed681" />
This commit is contained in:
@@ -19,7 +19,6 @@ use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::MarketplaceUpgradeParams;
|
||||
use codex_app_server_protocol::MarketplaceUpgradeResponse;
|
||||
|
||||
use codex_app_server_protocol::RateLimitResetCreditsSummary;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
|
||||
use crate::hooks_rpc::fetch_hooks_list;
|
||||
@@ -90,7 +89,8 @@ impl App {
|
||||
.and_then(|result| result.map_err(|err| err.to_string()))
|
||||
}
|
||||
RateLimitRefreshOrigin::StartupPrefetch { .. }
|
||||
| RateLimitRefreshOrigin::StatusCommand { .. } => {
|
||||
| RateLimitRefreshOrigin::StatusCommand { .. }
|
||||
| RateLimitRefreshOrigin::UsageMenu { .. } => {
|
||||
request.await.map_err(|err| err.to_string())
|
||||
}
|
||||
};
|
||||
@@ -127,7 +127,7 @@ impl App {
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
RATE_LIMIT_RESET_REQUEST_TIMEOUT,
|
||||
fetch_rate_limit_reset_credits(request_handle),
|
||||
fetch_account_rate_limits(request_handle),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "account/rateLimits/read timed out in TUI".to_string())
|
||||
@@ -789,24 +789,6 @@ pub(super) async fn fetch_account_token_activity(
|
||||
.wrap_err("account/usage/read failed in TUI")
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_rate_limit_reset_credits(
|
||||
request_handle: AppServerRequestHandle,
|
||||
) -> Result<RateLimitResetCreditsSummary> {
|
||||
let request_id = RequestId::String(format!("account-rate-limit-resets-{}", Uuid::new_v4()));
|
||||
let response: GetAccountRateLimitsResponse = request_handle
|
||||
.request_typed(ClientRequest::GetAccountRateLimits {
|
||||
request_id,
|
||||
params: None,
|
||||
})
|
||||
.await
|
||||
.wrap_err("account/rateLimits/read failed in TUI")?;
|
||||
response.rate_limit_reset_credits.ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"account/rateLimits/read response did not include rateLimitResetCredits"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn consume_rate_limit_reset_credit_request(
|
||||
request_handle: AppServerRequestHandle,
|
||||
idempotency_key: String,
|
||||
|
||||
@@ -758,15 +758,14 @@ impl App {
|
||||
AppEvent::RateLimitsLoaded { origin, result } => match result {
|
||||
Ok(response) => {
|
||||
let rate_limit_reset_credits = response.rate_limit_reset_credits.clone();
|
||||
for snapshot in app_server_rate_limit_snapshots(response) {
|
||||
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
let snapshots = app_server_rate_limit_snapshots(response);
|
||||
match origin {
|
||||
RateLimitRefreshOrigin::StartupPrefetch {
|
||||
reset_hint_request_id,
|
||||
} => {
|
||||
if self.chat_widget.finish_rate_limit_reset_hint_refresh(
|
||||
reset_hint_request_id,
|
||||
snapshots,
|
||||
rate_limit_reset_credits.ok_or_else(|| {
|
||||
"account/rateLimits/read response did not include rateLimitResetCredits"
|
||||
.to_string()
|
||||
@@ -779,6 +778,7 @@ impl App {
|
||||
RateLimitRefreshOrigin::ResetConsume { request_id } => {
|
||||
self.chat_widget.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
snapshots,
|
||||
rate_limit_reset_credits.ok_or_else(|| {
|
||||
"account/rateLimits/read response did not include rateLimitResetCredits"
|
||||
.to_string()
|
||||
@@ -788,7 +788,17 @@ impl App {
|
||||
}
|
||||
RateLimitRefreshOrigin::StatusCommand { request_id } => {
|
||||
self.chat_widget
|
||||
.finish_status_rate_limit_refresh(request_id);
|
||||
.finish_status_rate_limit_refresh(request_id, snapshots);
|
||||
}
|
||||
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()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,16 +810,27 @@ impl App {
|
||||
} => {
|
||||
self.chat_widget.finish_rate_limit_reset_hint_refresh(
|
||||
reset_hint_request_id,
|
||||
Vec::new(),
|
||||
Err(err),
|
||||
);
|
||||
}
|
||||
RateLimitRefreshOrigin::ResetConsume { request_id } => {
|
||||
self.chat_widget
|
||||
.finish_post_consume_reset_credits_refresh(request_id, Err(err));
|
||||
self.chat_widget.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Err(err),
|
||||
);
|
||||
}
|
||||
RateLimitRefreshOrigin::StatusCommand { request_id } => {
|
||||
self.chat_widget
|
||||
.finish_status_rate_limit_refresh(request_id);
|
||||
.finish_status_rate_limit_refresh(request_id, Vec::new());
|
||||
}
|
||||
RateLimitRefreshOrigin::UsageMenu { request_id } => {
|
||||
self.chat_widget.finish_usage_menu_rate_limit_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Err(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -822,15 +843,29 @@ impl App {
|
||||
let request_id = self.chat_widget.show_rate_limit_reset_loading_popup();
|
||||
self.refresh_rate_limit_reset_credits(app_server, request_id);
|
||||
}
|
||||
AppEvent::RateLimitResetCreditsLoaded { request_id, result } => {
|
||||
if let Err(err) = &result {
|
||||
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),
|
||||
);
|
||||
}
|
||||
self.chat_widget
|
||||
.finish_rate_limit_reset_credits_refresh(request_id, result);
|
||||
}
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -27,7 +27,6 @@ use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::RateLimitResetCreditsSummary;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -119,7 +118,9 @@ pub(crate) struct PluginRemoteSectionError {
|
||||
/// updates the cached snapshots and any available reset-credit notice (no
|
||||
/// status card to finalize). A `StatusCommand` is tied to a specific `/status`
|
||||
/// invocation and must call `finish_status_rate_limit_refresh` when done so the
|
||||
/// card stops showing a "refreshing" state.
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RateLimitRefreshOrigin {
|
||||
/// Eagerly fetched after bootstrap for `/status` data and reset availability.
|
||||
@@ -127,6 +128,8 @@ pub(crate) enum RateLimitRefreshOrigin {
|
||||
/// User-initiated via `/status`; the `request_id` correlates with the
|
||||
/// status card that should be updated when the fetch completes.
|
||||
StatusCommand { request_id: u64 },
|
||||
/// User reopened `/usage` while the cached reset-credit count was zero.
|
||||
UsageMenu { request_id: u64 },
|
||||
/// Refresh requested after a reset credit was successfully consumed.
|
||||
ResetConsume { request_id: u64 },
|
||||
}
|
||||
@@ -315,7 +318,7 @@ pub(crate) enum AppEvent {
|
||||
/// Result of reading the current reset-credit balance.
|
||||
RateLimitResetCreditsLoaded {
|
||||
request_id: u64,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
result: Result<GetAccountRateLimitsResponse, String>,
|
||||
},
|
||||
|
||||
/// Consume one reset credit using a stable idempotency key.
|
||||
|
||||
@@ -557,6 +557,7 @@ pub(crate) struct ChatWidget {
|
||||
next_token_activity_request_id: u64,
|
||||
pending_rate_limit_reset_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>,
|
||||
available_rate_limit_reset_credits: Option<i64>,
|
||||
next_rate_limit_reset_request_id: u64,
|
||||
|
||||
@@ -131,6 +131,7 @@ impl ChatWidget {
|
||||
next_token_activity_request_id: 0,
|
||||
pending_rate_limit_reset_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,
|
||||
available_rate_limit_reset_credits: None,
|
||||
next_rate_limit_reset_request_id: 0,
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
|
||||
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
|
||||
pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||||
pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt";
|
||||
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage";
|
||||
@@ -399,6 +400,7 @@ impl ChatWidget {
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID),
|
||||
title: Some("Approaching rate limits".to_string()),
|
||||
subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::chatwidget::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID;
|
||||
|
||||
impl ChatWidget {
|
||||
/// Set the approval policy in the widget's config copy.
|
||||
@@ -217,12 +218,22 @@ impl ChatWidget {
|
||||
has_chatgpt_account: bool,
|
||||
has_codex_backend_auth: bool,
|
||||
) {
|
||||
let account_state_changed = self.status_account_display != status_account_display
|
||||
|| self.has_chatgpt_account != has_chatgpt_account
|
||||
|| self.has_codex_backend_auth != has_codex_backend_auth;
|
||||
if account_state_changed {
|
||||
self.clear_pending_token_activity_refreshes();
|
||||
self.clear_pending_rate_limit_reset_requests();
|
||||
// Account-update notifications are the identity boundary. The visible account fields can
|
||||
// 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.codex_rate_limit_reached_type = None;
|
||||
self.rate_limit_warnings = RateLimitWarningState::default();
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID);
|
||||
let had_refreshing_status_outputs = !self.refreshing_status_outputs.is_empty();
|
||||
let now = Local::now();
|
||||
for (_, handle) in self.refreshing_status_outputs.drain(..) {
|
||||
handle.finish_rate_limit_refresh(&[], now);
|
||||
}
|
||||
if had_refreshing_status_outputs {
|
||||
self.request_redraw();
|
||||
}
|
||||
self.status_line_workspace_headline = None;
|
||||
self.status_line_workspace_headline_pending_request_id = None;
|
||||
@@ -236,7 +247,7 @@ impl ChatWidget {
|
||||
.set_connectors_enabled(self.connectors_enabled());
|
||||
self.bottom_pane
|
||||
.set_token_activity_command_enabled(has_codex_backend_auth);
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
/// Set the syntax theme override in the widget's config copy.
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: rendered
|
||||
---
|
||||
• You have 2 rate-limit resets available. Run /usage to use one.
|
||||
• You have 2 usage limit resets available. Run /usage to use one.
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@ expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expec
|
||||
---
|
||||
active tool
|
||||
|
||||
• You have 2 rate-limit resets available. Run /usage to use one.
|
||||
• You have 2 usage limit resets available. Run /usage to use one.
|
||||
|
||||
+16
-16
@@ -2,41 +2,41 @@
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: "states.join(\"\\n---\\n\")"
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage limit resets
|
||||
Checking your available resets...
|
||||
|
||||
› Loading...
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
You have 2 rate-limit resets available.
|
||||
Usage limit resets
|
||||
You have 2 usage limit resets available.
|
||||
|
||||
1. Use a reset Reset your current Codex usage windows.
|
||||
1. Use a reset Reset your current 5-hour and weekly usage limits.
|
||||
› 2. Cancel
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
You don't have any rate-limit resets available.
|
||||
Usage limit resets
|
||||
You don't have any usage limit resets available.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Couldn't load rate-limit resets. Please try again.
|
||||
Usage limit resets
|
||||
Couldn't load usage limit resets. Please try again.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage limit resets
|
||||
Resetting your usage...
|
||||
|
||||
› Using a reset...
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage limit resets
|
||||
Couldn't reset usage. Please try again.
|
||||
|
||||
› 1. Try again
|
||||
@@ -44,27 +44,27 @@ expression: "states.join(\"\\n---\\n\")"
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage limit resets
|
||||
Your usage does not need a reset right now.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
No rate-limit resets are available.
|
||||
Usage limit resets
|
||||
No usage limit resets are available.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage limit resets
|
||||
Usage reset. Checking your remaining resets...
|
||||
|
||||
› Refreshing...
|
||||
---
|
||||
Rate-limit resets
|
||||
Usage reset. You have 1 rate-limit reset left.
|
||||
Usage limit resets
|
||||
Usage reset. You have 1 usage limit reset left.
|
||||
|
||||
› 1. Close
|
||||
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ 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 rate limit reset You have 2 rate-limit resets available.
|
||||
› 1. Show usage View recent account token usage.
|
||||
2. Redeem usage limit reset You have 2 usage limit resets available.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ 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 rate limit reset Check reset availability.
|
||||
› 1. Show usage View recent account token usage.
|
||||
2. Redeem usage limit reset Check reset availability.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ 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 rate limit reset Check reset availability.
|
||||
› 1. Show usage View recent account token usage.
|
||||
Redeem usage limit reset No usage limit resets available.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/usage.rs
|
||||
expression: "states.join(\"\\n---\\n\")"
|
||||
---
|
||||
Free:
|
||||
Usage limit resets
|
||||
You have 1 usage limit reset available.
|
||||
|
||||
1. Use a reset Reset your current monthly usage limit.
|
||||
› 2. Cancel
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Go:
|
||||
Usage limit resets
|
||||
You have 1 usage limit reset available.
|
||||
|
||||
1. Use a reset Reset your current monthly usage limit.
|
||||
› 2. Cancel
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
---
|
||||
Business with monthly window:
|
||||
Usage limit resets
|
||||
You have 1 usage limit reset available.
|
||||
|
||||
1. Use a reset Reset your current monthly usage limit.
|
||||
› 2. Cancel
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -247,11 +247,23 @@ impl ChatWidget {
|
||||
self.add_to_history(cell);
|
||||
}
|
||||
|
||||
pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) {
|
||||
if self.refreshing_status_outputs.is_empty() {
|
||||
pub(crate) fn finish_status_rate_limit_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
snapshots: Vec<RateLimitSnapshot>,
|
||||
) {
|
||||
if !self
|
||||
.refreshing_status_outputs
|
||||
.iter()
|
||||
.any(|(pending_request_id, _)| *pending_request_id == request_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for snapshot in snapshots {
|
||||
self.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.values()
|
||||
|
||||
@@ -1032,6 +1032,39 @@ async fn rate_limit_switch_prompt_shows_once_per_session() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_update_clears_derived_usage_limit_state_and_prompt() {
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 95.0);
|
||||
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
chat.maybe_show_pending_rate_limit_prompt();
|
||||
|
||||
assert!(chat.rate_limit_warnings.primary_index > 0);
|
||||
assert!(chat.codex_rate_limit_reached_type.is_some());
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
));
|
||||
assert!(!chat.bottom_pane.no_modal_or_popup_active());
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(chat.rate_limit_warnings.primary_index, 0);
|
||||
assert_eq!(chat.rate_limit_warnings.secondary_index, 0);
|
||||
assert_eq!(chat.codex_rate_limit_reached_type, None);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Idle
|
||||
));
|
||||
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_respects_hidden_notice() {
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
|
||||
@@ -46,8 +46,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs(
|
||||
other => panic!("expected rate-limit refresh request, got {other:?}"),
|
||||
};
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0)));
|
||||
chat.finish_status_rate_limit_refresh(first_request_id);
|
||||
chat.finish_status_rate_limit_refresh(first_request_id, vec![snapshot(/*percent*/ 92.0)]);
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
@@ -170,10 +169,31 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() {
|
||||
"expected /status to avoid transient refresh text in terminal history, got: {second_rendered}"
|
||||
);
|
||||
|
||||
chat.finish_status_rate_limit_refresh(first_request_id);
|
||||
chat.finish_status_rate_limit_refresh(first_request_id, Vec::new());
|
||||
pretty_assertions::assert_eq!(chat.refreshing_status_outputs.len(), 1);
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0)));
|
||||
chat.finish_status_rate_limit_refresh(second_request_id);
|
||||
chat.finish_status_rate_limit_refresh(second_request_id, vec![snapshot(/*percent*/ 92.0)]);
|
||||
assert!(chat.refreshing_status_outputs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_update_rejects_stale_status_rate_limit_snapshots() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
||||
}) => request_id,
|
||||
other => panic!("expected status refresh request, got {other:?}"),
|
||||
};
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
chat.finish_status_rate_limit_refresh(request_id, vec![snapshot(/*percent*/ 92.0)]);
|
||||
|
||||
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() {
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
|
||||
@@ -27,12 +28,13 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot() {
|
||||
async fn usage_command_disables_reset_after_cached_zero_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
|
||||
@@ -42,11 +44,109 @@ async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot
|
||||
"usage_command_menu_without_resets",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 }
|
||||
})
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_menu_refresh_enables_newly_available_reset() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 }
|
||||
})
|
||||
);
|
||||
chat.finish_usage_menu_rate_limit_refresh(
|
||||
/*request_id*/ 1,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_menu_refresh_failure_preserves_disabled_known_zero() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 }
|
||||
})
|
||||
);
|
||||
chat.finish_usage_menu_rate_limit_refresh(
|
||||
/*request_id*/ 1,
|
||||
Vec::new(),
|
||||
Err("backend unavailable".to_string()),
|
||||
);
|
||||
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("No usage limit resets available."));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unchanged() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let startup_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
startup_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 }
|
||||
})
|
||||
);
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
chat.finish_usage_menu_rate_limit_refresh(
|
||||
/*request_id*/ 1,
|
||||
vec![snapshot(/*percent*/ 92.0)],
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
);
|
||||
|
||||
assert_eq!(chat.available_rate_limit_reset_credits, None);
|
||||
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_command_can_check_reset_availability_before_startup_refresh_finishes_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -65,7 +165,7 @@ async fn usage_command_can_check_reset_availability_before_startup_refresh_finis
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_command_disables_reset_for_workspace_accounts() {
|
||||
async fn usage_command_can_check_reset_availability_for_workspace_accounts() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
@@ -74,7 +174,7 @@ async fn usage_command_disables_reset_for_workspace_accounts() {
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -84,6 +184,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() {
|
||||
let request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
@@ -104,6 +205,7 @@ async fn rate_limit_reset_popup_states_snapshot() {
|
||||
record_popup(&chat, &mut states);
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
loading_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
@@ -112,6 +214,7 @@ async fn rate_limit_reset_popup_states_snapshot() {
|
||||
let empty_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
empty_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
@@ -120,6 +223,7 @@ async fn rate_limit_reset_popup_states_snapshot() {
|
||||
let load_error_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
load_error_request_id,
|
||||
Vec::new(),
|
||||
Err("backend unavailable".to_string()),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
@@ -165,6 +269,7 @@ async fn rate_limit_reset_popup_states_snapshot() {
|
||||
record_popup(&chat, &mut states);
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
success_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
record_popup(&chat, &mut states);
|
||||
@@ -172,12 +277,66 @@ async fn rate_limit_reset_popup_states_snapshot() {
|
||||
assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let mut states = Vec::new();
|
||||
|
||||
chat.plan_type = Some(PlanType::Free);
|
||||
let free_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
free_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
states.push(format!(
|
||||
"Free:\n{}",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
));
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
chat.plan_type = Some(PlanType::Go);
|
||||
let go_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
go_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80)));
|
||||
|
||||
dismiss_popup(&mut chat);
|
||||
let mut monthly_business_snapshot = snapshot(/*percent*/ 50.0);
|
||||
monthly_business_snapshot.plan_type = Some(PlanType::Business);
|
||||
monthly_business_snapshot
|
||||
.primary
|
||||
.as_mut()
|
||||
.expect("monthly business snapshot primary window")
|
||||
.window_duration_mins = Some(30 * 24 * 60);
|
||||
let business_request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
business_request_id,
|
||||
vec![monthly_business_snapshot],
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
states.push(format!(
|
||||
"Business with monthly window:\n{}",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_limit_reset_confirmation_monthly",
|
||||
states.join("\n---\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_confirmation_selects_cancel_by_default() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
|
||||
@@ -193,6 +352,7 @@ async fn rate_limit_reset_confirmation_can_use_reset() {
|
||||
let request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
|
||||
@@ -226,12 +386,13 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_credit_outcome_allows_reset_availability_recheck() {
|
||||
async fn no_credit_outcome_disables_reset_entry_in_usage_menu() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let startup_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
startup_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
@@ -244,10 +405,16 @@ async fn no_credit_outcome_allows_reset_availability_recheck() {
|
||||
dismiss_popup(&mut chat);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::UsageMenu { request_id: 2 }
|
||||
})
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -270,6 +437,7 @@ async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() {
|
||||
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
dismiss_popup(&mut chat);
|
||||
@@ -300,11 +468,12 @@ async fn already_redeemed_is_an_idempotent_success() {
|
||||
));
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
.contains("Usage reset. You have 0 rate-limit resets left.")
|
||||
.contains("Usage reset. You have 0 usage limit resets left.")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,6 +484,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() {
|
||||
let startup_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
startup_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
|
||||
@@ -327,6 +497,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() {
|
||||
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
consume_request_id,
|
||||
Vec::new(),
|
||||
Err("backend unavailable".to_string()),
|
||||
));
|
||||
dismiss_popup(&mut chat);
|
||||
@@ -334,7 +505,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() {
|
||||
|
||||
let rendered = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(rendered.contains("Check reset availability."));
|
||||
assert!(!rendered.contains("You have 2 rate-limit resets available."));
|
||||
assert!(!rendered.contains("You have 2 usage limit resets available."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -350,6 +521,7 @@ async fn account_change_invalidates_pending_reset_requests() {
|
||||
|
||||
assert!(!chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
@@ -363,6 +535,7 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() {
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
|
||||
@@ -385,6 +558,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() {
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -395,7 +569,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
.contains("You have 2 rate-limit resets available.")
|
||||
.contains("You have 2 usage limit resets available.")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -413,6 +587,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() {
|
||||
));
|
||||
assert!(chat.finish_post_consume_reset_credits_refresh(
|
||||
request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -423,7 +598,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
.contains("Usage reset. You have 1 rate-limit reset left.")
|
||||
.contains("Usage reset. You have 1 usage limit reset left.")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -455,6 +630,7 @@ async fn startup_check_shows_available_reset_hint_snapshot() {
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
let rendered = lines_to_single_string(
|
||||
@@ -477,6 +653,7 @@ async fn startup_reset_hint_waits_for_active_output_snapshot() {
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
|
||||
@@ -507,6 +684,7 @@ async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() {
|
||||
|
||||
assert!(!chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
@@ -519,6 +697,7 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() {
|
||||
let hint_request_id = chat.start_rate_limit_reset_startup_check();
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_some());
|
||||
@@ -536,13 +715,14 @@ async fn startup_check_omits_reset_hint_when_none_are_available() {
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_check_omits_reset_hint_for_workspace_accounts() {
|
||||
async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
@@ -550,10 +730,11 @@ async fn startup_check_omits_reset_hint_for_workspace_accounts() {
|
||||
|
||||
assert!(chat.finish_rate_limit_reset_hint_refresh(
|
||||
hint_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_none());
|
||||
assert_eq!(chat.available_rate_limit_reset_credits, None);
|
||||
assert!(chat.pending_rate_limit_reset_hint().is_some());
|
||||
assert_eq!(chat.available_rate_limit_reset_credits, Some(2));
|
||||
}
|
||||
|
||||
fn consume_response(
|
||||
|
||||
@@ -3,6 +3,7 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
|
||||
use codex_app_server_protocol::RateLimitResetCreditsSummary;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::rate_limits::get_limits_duration;
|
||||
use super::*;
|
||||
|
||||
const USAGE_MENU_VIEW_ID: &str = "usage-menu";
|
||||
@@ -11,8 +12,21 @@ 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();
|
||||
let reset_eligible =
|
||||
self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_account);
|
||||
let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0);
|
||||
self.bottom_pane
|
||||
.show_selection_view(self.usage_menu_params());
|
||||
if should_refresh_reset_availability {
|
||||
let request_id = self.take_next_rate_limit_reset_request_id();
|
||||
self.pending_usage_menu_rate_limit_request_id = Some(request_id);
|
||||
self.app_event_tx.send(AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::UsageMenu { request_id },
|
||||
});
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
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) {
|
||||
(true, Some(available_count)) if available_count > 0 => (
|
||||
@@ -22,10 +36,12 @@ impl ChatWidget {
|
||||
reset_label(available_count)
|
||||
),
|
||||
),
|
||||
(true, _) => (true, "Check reset availability.".to_string()),
|
||||
(false, _) => (false, "No rate-limit resets available.".to_string()),
|
||||
(true, None) => (true, "Check reset availability.".to_string()),
|
||||
(true, Some(_)) | (false, _) => {
|
||||
(false, "No usage limit resets available.".to_string())
|
||||
}
|
||||
};
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
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()),
|
||||
@@ -41,7 +57,7 @@ impl ChatWidget {
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Redeem rate limit reset".to_string(),
|
||||
name: "Redeem usage limit reset".to_string(),
|
||||
description: Some(reset_description),
|
||||
is_disabled: !reset_action_enabled,
|
||||
actions: vec![Box::new(|tx| {
|
||||
@@ -52,8 +68,32 @@ impl ChatWidget {
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish_usage_menu_rate_limit_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
snapshots: Vec<RateLimitSnapshot>,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
) {
|
||||
if self.pending_usage_menu_rate_limit_request_id != Some(request_id) {
|
||||
return;
|
||||
}
|
||||
self.pending_usage_menu_rate_limit_request_id = None;
|
||||
for snapshot in snapshots {
|
||||
self.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
if let Ok(response) = result {
|
||||
self.available_rate_limit_reset_credits = Some(response.available_count);
|
||||
}
|
||||
let params = self.usage_menu_params();
|
||||
if self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params)
|
||||
{
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 {
|
||||
@@ -62,7 +102,7 @@ impl ChatWidget {
|
||||
self.pending_rate_limit_reset_request_id = Some(request_id);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
title: Some("Usage limit resets".to_string()),
|
||||
subtitle: Some("Checking your available resets...".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Loading...".to_string(),
|
||||
@@ -78,26 +118,30 @@ impl ChatWidget {
|
||||
pub(crate) fn finish_rate_limit_reset_credits_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
snapshots: Vec<RateLimitSnapshot>,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
) -> bool {
|
||||
if self.pending_rate_limit_reset_request_id != Some(request_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
for snapshot in snapshots {
|
||||
self.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
let params = match result {
|
||||
Ok(response) => {
|
||||
self.available_rate_limit_reset_credits = Some(response.available_count);
|
||||
if response.available_count > 0 {
|
||||
Self::rate_limit_reset_confirmation_params(response.available_count)
|
||||
self.rate_limit_reset_confirmation_params(response.available_count)
|
||||
} else {
|
||||
Self::rate_limit_reset_message_params(
|
||||
"You don't have any rate-limit resets available.",
|
||||
"You don't have any usage limit resets available.",
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(_) => Self::rate_limit_reset_message_params(
|
||||
"Couldn't load rate-limit resets. Please try again.",
|
||||
"Couldn't load usage limit resets. Please try again.",
|
||||
),
|
||||
};
|
||||
let replaced = self
|
||||
@@ -109,11 +153,32 @@ impl ChatWidget {
|
||||
replaced
|
||||
}
|
||||
|
||||
fn rate_limit_reset_confirmation_params(available_count: i64) -> SelectionViewParams {
|
||||
fn rate_limit_reset_confirmation_params(&self, available_count: i64) -> SelectionViewParams {
|
||||
let idempotency_key = Uuid::new_v4().to_string();
|
||||
let has_monthly_window = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.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()
|
||||
.any(|window| {
|
||||
window
|
||||
.window_minutes
|
||||
.and_then(get_limits_duration)
|
||||
.as_deref()
|
||||
== Some("monthly")
|
||||
});
|
||||
let reset_description = if has_monthly_window
|
||||
|| matches!(self.plan_type, Some(PlanType::Free | PlanType::Go))
|
||||
{
|
||||
"Reset your current monthly usage limit."
|
||||
} else {
|
||||
"Reset your current 5-hour and weekly usage limits."
|
||||
};
|
||||
SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
title: Some("Usage limit resets".to_string()),
|
||||
subtitle: Some(format!(
|
||||
"You have {available_count} {} available.",
|
||||
reset_label(available_count)
|
||||
@@ -122,7 +187,7 @@ impl ChatWidget {
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Use a reset".to_string(),
|
||||
description: Some("Reset your current Codex usage windows.".to_string()),
|
||||
description: Some(reset_description.to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::ConsumeRateLimitResetCredit {
|
||||
idempotency_key: idempotency_key.clone(),
|
||||
@@ -145,7 +210,7 @@ impl ChatWidget {
|
||||
fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams {
|
||||
SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
title: Some("Usage limit resets".to_string()),
|
||||
subtitle: Some(message.to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Close".to_string(),
|
||||
@@ -162,7 +227,7 @@ impl ChatWidget {
|
||||
self.pending_rate_limit_reset_request_id = Some(request_id);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
title: Some("Usage limit resets".to_string()),
|
||||
subtitle: Some("Resetting your usage...".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Using a reset...".to_string(),
|
||||
@@ -206,7 +271,7 @@ impl ChatWidget {
|
||||
}
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NoCredit => {
|
||||
self.available_rate_limit_reset_credits = Some(0);
|
||||
"No rate-limit resets are available."
|
||||
"No usage limit resets are available."
|
||||
}
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset
|
||||
| ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(),
|
||||
@@ -218,7 +283,7 @@ impl ChatWidget {
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
self.replace_rate_limit_reset_popup(SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
title: Some("Usage limit resets".to_string()),
|
||||
subtitle: Some("Couldn't reset usage. Please try again.".to_string()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
@@ -247,12 +312,16 @@ impl ChatWidget {
|
||||
pub(crate) fn finish_post_consume_reset_credits_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
snapshots: Vec<RateLimitSnapshot>,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
) -> bool {
|
||||
if self.pending_rate_limit_reset_request_id != Some(request_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
for snapshot in snapshots {
|
||||
self.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
let message = match result {
|
||||
Ok(response) => {
|
||||
@@ -272,7 +341,7 @@ impl ChatWidget {
|
||||
fn rate_limit_reset_success_loading_params() -> SelectionViewParams {
|
||||
SelectionViewParams {
|
||||
view_id: Some(RATE_LIMIT_RESET_VIEW_ID),
|
||||
title: Some("Rate-limit resets".to_string()),
|
||||
title: Some("Usage limit resets".to_string()),
|
||||
subtitle: Some("Usage reset. Checking your remaining resets...".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Refreshing...".to_string(),
|
||||
@@ -303,18 +372,19 @@ impl ChatWidget {
|
||||
pub(crate) fn finish_rate_limit_reset_hint_refresh(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
snapshots: Vec<RateLimitSnapshot>,
|
||||
result: Result<RateLimitResetCreditsSummary, String>,
|
||||
) -> bool {
|
||||
if self.pending_rate_limit_reset_hint_request_id != Some(request_id) {
|
||||
return false;
|
||||
}
|
||||
self.pending_rate_limit_reset_hint_request_id = None;
|
||||
for snapshot in snapshots {
|
||||
self.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
if !self.has_codex_backend_auth {
|
||||
return false;
|
||||
}
|
||||
if self.plan_type.is_some_and(PlanType::is_workspace_account) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(response) = result {
|
||||
self.available_rate_limit_reset_credits = Some(response.available_count);
|
||||
self.set_rate_limit_reset_available_hint(response.available_count);
|
||||
@@ -324,8 +394,11 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) {
|
||||
self.pending_rate_limit_reset_request_id = None;
|
||||
self.pending_usage_menu_rate_limit_request_id = None;
|
||||
self.available_rate_limit_reset_credits = None;
|
||||
self.rate_limit_snapshots_by_limit_id.clear();
|
||||
self.clear_pending_rate_limit_reset_hint();
|
||||
self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID);
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID);
|
||||
}
|
||||
@@ -375,8 +448,8 @@ impl ChatWidget {
|
||||
|
||||
fn reset_label(count: i64) -> &'static str {
|
||||
if count == 1 {
|
||||
"rate-limit reset"
|
||||
"usage limit reset"
|
||||
} else {
|
||||
"rate-limit resets"
|
||||
"usage limit resets"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ impl SlashCommand {
|
||||
SlashCommand::Import => "import setup, this project, and recent chats from Claude Code",
|
||||
SlashCommand::Hooks => "view and manage lifecycle hooks",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Usage => "view account usage or use a rate-limit reset",
|
||||
SlashCommand::Usage => "view account usage or use a usage limit reset",
|
||||
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
|
||||
SlashCommand::Title => "configure which items appear in the terminal title",
|
||||
SlashCommand::Statusline => "configure which items appear in the status line",
|
||||
|
||||
Reference in New Issue
Block a user