mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
## Why Codex users can earn personal rate-limit reset credits, but the CLI does not currently provide a way to view or redeem them. The `/usage` command restored in #27925 is intended to be the entry point for usage-related actions, so reset redemption belongs there rather than in a separate dashed slash command. Depends on #28143 for the app-server and backend-client reset-credit APIs. ## What changed - Turn bare `/usage` into a menu with entries for token activity and earned rate-limit resets while preserving `/usage daily`, `/usage weekly`, and `/usage cumulative`. - Add loading, empty, confirmation, success, retry, and error states with a caller-generated UUID idempotency key reused across retries of the same logical reset. - Show an availability hint only for backend-classified rate-limit errors with credits available. - Hide the reset entry for workspace accounts. ## Validation - `just test -p codex-tui chatwidget::tests::usage` — 19 passed. - `just fix -p codex-tui` — passed. - `just fmt` — passed. - `cargo insta pending-snapshots` from `codex-rs/tui` — no pending snapshots. ## Examples <img width="1168" height="304" alt="image" src="https://github.com/user-attachments/assets/caa4c1e3-e996-494d-ae17-50b521f5dce8" /> <img width="908" height="260" alt="image" src="https://github.com/user-attachments/assets/e38a726b-77cc-4bd0-9ea8-9f3ad21c5768" /> ### Reset flow <img width="1509" height="312" alt="image" src="https://github.com/user-attachments/assets/d987013c-78a5-48a2-ad8d-c61ad267a327" /> <img width="585" height="190" alt="image" src="https://github.com/user-attachments/assets/de32be19-79b9-4a3e-8574-6f1c208c98ae" /> <img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/88a165cf-796d-4fdc-a7bc-ea89917573da" /> <img width="512" height="193" alt="image" src="https://github.com/user-attachments/assets/d2353998-5aa8-442e-a5f8-3a8a5b832753" />
293 lines
12 KiB
Rust
293 lines
12 KiB
Rust
//! Coordinates asynchronous `/usage` cards in the chat widget.
|
|
//!
|
|
//! The slash command builds a composite history cell immediately, but the widget
|
|
//! keeps that cell transient while the account request runs. The transient card is
|
|
//! rendered above the composer through [`ChatWidget::pending_token_activity_output`]
|
|
//! so loading never requires clearing or rewriting transcript history. When the
|
|
//! matching response arrives, [`TokenActivityHandle`] updates the shared card state
|
|
//! and [`ChatWidget::finish_token_activity_refresh`] moves the cell into a completed
|
|
//! slot. Event dispatch commits that completed cell into history only after active
|
|
//! output and stream consolidation no longer block insertion.
|
|
//!
|
|
//! Pure chart rendering and date bucketing live in [`chart`]. This module owns
|
|
//! request correlation, transient/completed card state, and integration with
|
|
//! `ChatWidget` history insertion.
|
|
|
|
mod chart;
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::RwLock;
|
|
|
|
use chrono::NaiveDate;
|
|
use chrono::Utc;
|
|
use codex_app_server_protocol::GetAccountTokenUsageResponse;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
|
|
use super::ChatWidget;
|
|
use crate::app_event::AppEvent;
|
|
use crate::history_cell::CompositeHistoryCell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::PlainHistoryCell;
|
|
use crate::history_cell::plain_lines;
|
|
|
|
pub(crate) use chart::TokenActivityView;
|
|
|
|
/// Tracks the renderable lifecycle of one token activity history cell.
|
|
#[derive(Debug)]
|
|
enum TokenActivityState {
|
|
Loading,
|
|
Loaded {
|
|
response: GetAccountTokenUsageResponse,
|
|
today: NaiveDate,
|
|
},
|
|
Error,
|
|
}
|
|
|
|
/// Completes an asynchronously rendered token activity history cell.
|
|
///
|
|
/// Clones share the same card state, allowing the background request path to
|
|
/// update a cell still owned by the widget's transient-output state. The widget
|
|
/// remains responsible for request-ID matching, redraws, and history insertion.
|
|
#[derive(Clone, Debug)]
|
|
pub(super) struct TokenActivityHandle {
|
|
state: Arc<RwLock<TokenActivityState>>,
|
|
}
|
|
|
|
/// Holds the one transient token activity card waiting on its background response.
|
|
///
|
|
/// The request ID prevents late results from mutating a newer `/usage` card. The
|
|
/// cell stays out of transcript history until the matching response completes and
|
|
/// the widget confirms that active output no longer blocks insertion.
|
|
pub(super) struct PendingTokenActivityOutput {
|
|
request_id: u64,
|
|
cell: CompositeHistoryCell,
|
|
handle: TokenActivityHandle,
|
|
}
|
|
|
|
impl TokenActivityHandle {
|
|
/// Replaces the loading state with either fetched activity or an unavailable state.
|
|
///
|
|
/// This method intentionally discards the error string because the TUI exposes
|
|
/// one stable unavailable message. Calling it more than once replaces the prior
|
|
/// terminal state, so request-ID matching should happen before completion.
|
|
pub(super) fn finish(&self, result: Result<GetAccountTokenUsageResponse, String>) {
|
|
self.finish_with_today(result, Utc::now().date_naive());
|
|
}
|
|
|
|
fn finish_with_today(
|
|
&self,
|
|
result: Result<GetAccountTokenUsageResponse, String>,
|
|
today: NaiveDate,
|
|
) {
|
|
let state = match result {
|
|
Ok(response) => TokenActivityState::Loaded { response, today },
|
|
Err(_) => TokenActivityState::Error,
|
|
};
|
|
#[expect(clippy::expect_used)]
|
|
let mut current = self.state.write().expect("token activity state poisoned");
|
|
*current = state;
|
|
}
|
|
}
|
|
|
|
/// Renders one `/usage` card from shared asynchronous state.
|
|
#[derive(Debug)]
|
|
struct TokenActivityHistoryCell {
|
|
view: TokenActivityView,
|
|
state: Arc<RwLock<TokenActivityState>>,
|
|
}
|
|
|
|
/// Creates the card contents and completion handle for one `/usage` invocation.
|
|
///
|
|
/// The composite cell includes the echoed slash command and a loading card from
|
|
/// the start. Callers must retain the returned handle and complete it when the
|
|
/// matching background response arrives; otherwise the transient card stays loading.
|
|
pub(super) fn new_token_activity_output(
|
|
view: TokenActivityView,
|
|
) -> (CompositeHistoryCell, TokenActivityHandle) {
|
|
let command = PlainHistoryCell::new(vec![
|
|
format!("/usage {}", view.label().to_lowercase())
|
|
.magenta()
|
|
.into(),
|
|
]);
|
|
let state = Arc::new(RwLock::new(TokenActivityState::Loading));
|
|
let handle = TokenActivityHandle {
|
|
state: Arc::clone(&state),
|
|
};
|
|
let card = TokenActivityHistoryCell { view, state };
|
|
(
|
|
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]),
|
|
handle,
|
|
)
|
|
}
|
|
|
|
impl HistoryCell for TokenActivityHistoryCell {
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
#[expect(clippy::expect_used)]
|
|
let state = self.state.read().expect("token activity state poisoned");
|
|
match &*state {
|
|
TokenActivityState::Loading => {
|
|
vec![
|
|
" Token activity".bold().into(),
|
|
" Loading...".dim().into(),
|
|
]
|
|
}
|
|
TokenActivityState::Error => vec![
|
|
" Token activity".bold().into(),
|
|
" Token activity unavailable".dim().into(),
|
|
],
|
|
TokenActivityState::Loaded { response, today } => {
|
|
chart::loaded_lines(self.view, response, *today, width)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn raw_lines(&self) -> Vec<Line<'static>> {
|
|
plain_lines(self.display_lines(u16::MAX))
|
|
}
|
|
}
|
|
|
|
impl ChatWidget {
|
|
/// Starts a token activity refresh and replaces the current transient card.
|
|
///
|
|
/// Each invocation receives a request ID so background responses update only
|
|
/// their own card. The card remains outside transcript history until completion,
|
|
/// which keeps loading visible without disturbing existing transcript content.
|
|
pub(crate) fn add_token_activity_output(&mut self, view: TokenActivityView) {
|
|
let request_id = self.next_token_activity_request_id;
|
|
self.next_token_activity_request_id =
|
|
self.next_token_activity_request_id.wrapping_add(/*rhs*/ 1);
|
|
let (cell, handle) = new_token_activity_output(view);
|
|
self.completed_token_activity_output = None;
|
|
self.refreshing_token_activity_output = Some(PendingTokenActivityOutput {
|
|
request_id,
|
|
cell,
|
|
handle,
|
|
});
|
|
self.bump_active_cell_revision();
|
|
self.request_redraw();
|
|
self.app_event_tx
|
|
.send(AppEvent::RefreshTokenActivity { request_id });
|
|
}
|
|
|
|
/// Returns the transient token activity card that should render above the composer.
|
|
///
|
|
/// A loading card takes precedence over a completed card waiting for history
|
|
/// insertion. Callers should render the returned cell but leave ownership with
|
|
/// the widget so completion and insertion can update it safely.
|
|
pub(super) fn pending_token_activity_output(&self) -> Option<&dyn HistoryCell> {
|
|
self.refreshing_token_activity_output
|
|
.as_ref()
|
|
.map(|output| &output.cell as &dyn HistoryCell)
|
|
.or_else(|| {
|
|
self.completed_token_activity_output
|
|
.as_ref()
|
|
.map(|cell| cell as &dyn HistoryCell)
|
|
})
|
|
}
|
|
|
|
/// Applies a background token activity result to its matching transient card.
|
|
///
|
|
/// Returns `true` when the pending request matched and moved into the completed
|
|
/// slot. Late responses return `false`, including responses for cards replaced
|
|
/// by a newer `/usage` invocation or cleared during transcript changes.
|
|
pub(crate) fn finish_token_activity_refresh(
|
|
&mut self,
|
|
request_id: u64,
|
|
result: Result<GetAccountTokenUsageResponse, String>,
|
|
) -> bool {
|
|
let Some(output) = self.refreshing_token_activity_output.take() else {
|
|
return false;
|
|
};
|
|
if output.request_id != request_id {
|
|
self.refreshing_token_activity_output = Some(output);
|
|
return false;
|
|
}
|
|
output.handle.finish(result);
|
|
self.completed_token_activity_output = Some(output.cell);
|
|
self.bump_active_cell_revision();
|
|
self.request_redraw();
|
|
true
|
|
}
|
|
|
|
/// Reports whether completed asynchronous usage output must wait before insertion.
|
|
///
|
|
/// Inserting while a stream, queued consolidation, or active transcript cell is
|
|
/// present can reorder output relative to visible work, so callers retry once
|
|
/// these barriers clear.
|
|
pub(crate) fn usage_history_insertion_blocked(&self) -> bool {
|
|
self.stream_controller.is_some()
|
|
|| self.plan_stream_controller.is_some()
|
|
|| self.pending_stream_consolidations > 0
|
|
|| self.transcript.active_cell.is_some()
|
|
|| self.active_hook_cell.is_some()
|
|
}
|
|
|
|
/// Records a stream consolidation barrier that delays token card insertion.
|
|
///
|
|
/// Each queued consolidation should eventually call
|
|
/// [`ChatWidget::note_stream_consolidation_completed`].
|
|
pub(crate) fn note_stream_consolidation_queued(&mut self) {
|
|
self.pending_stream_consolidations =
|
|
self.pending_stream_consolidations.saturating_add(/*rhs*/ 1);
|
|
}
|
|
|
|
/// Releases one queued stream consolidation barrier.
|
|
///
|
|
/// The counter saturates at zero so an unmatched completion does not underflow,
|
|
/// but paired queue/completion calls are still the intended contract.
|
|
pub(crate) fn note_stream_consolidation_completed(&mut self) {
|
|
self.pending_stream_consolidations =
|
|
self.pending_stream_consolidations.saturating_sub(/*rhs*/ 1);
|
|
}
|
|
|
|
/// Transfers the completed token activity card into the history insertion path.
|
|
///
|
|
/// Callers should use this only after
|
|
/// [`ChatWidget::usage_history_insertion_blocked`] returns `false`;
|
|
/// taking the card removes it from the transient render area.
|
|
pub(crate) fn take_completed_token_activity_output(&mut self) -> Option<CompositeHistoryCell> {
|
|
let output = self.completed_token_activity_output.take()?;
|
|
self.bump_active_cell_revision();
|
|
Some(output)
|
|
}
|
|
|
|
/// Requests another insertion attempt when completed usage output is waiting.
|
|
///
|
|
/// This is used after stream or history lifecycle events that may have cleared
|
|
/// the insertion barriers without directly owning the completed output.
|
|
pub(crate) fn request_pending_usage_output_insertion(&self) {
|
|
if self.completed_token_activity_output.is_some()
|
|
|| self.pending_rate_limit_reset_hint().is_some()
|
|
{
|
|
self.app_event_tx.send(AppEvent::CommitPendingUsageOutput);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn request_pending_usage_output_insertion_after_stream_shutdown(&self) {
|
|
if self.completed_token_activity_output.is_some()
|
|
|| self.pending_rate_limit_reset_hint().is_some()
|
|
{
|
|
self.app_event_tx
|
|
.send(AppEvent::CommitPendingUsageOutputAfterStreamShutdown);
|
|
}
|
|
}
|
|
|
|
/// Drops transient and completed token cards that must no longer update.
|
|
///
|
|
/// Late background responses cannot mutate cards after a transcript reset,
|
|
/// backtrack, or replacement flow clears this widget-owned state.
|
|
pub(crate) fn clear_pending_token_activity_refreshes(&mut self) {
|
|
let cleared_refresh = self.refreshing_token_activity_output.take().is_some();
|
|
let cleared_completed = self.completed_token_activity_output.take().is_some();
|
|
if cleared_refresh || cleared_completed {
|
|
self.bump_active_cell_revision();
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tokens_tests.rs"]
|
|
mod tests;
|