Files
codex/codex-rs/tui/src/chatwidget/hook_lifecycle.rs
T
jayandGitHub f8f5a6e78f feat(tui): add rate-limit reset redemption to /usage (#28154)
## 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"
/>
2026-06-16 17:59:40 +00:00

145 lines
4.7 KiB
Rust

//! Hook run lifecycle handling for `ChatWidget`.
//!
//! This module keeps active hook cells, hook timers, and hook completion output
//! together.
use super::*;
impl ChatWidget {
/// Drop transient live hook status without flushing it into history.
pub(super) fn clear_active_hook_cell(&mut self) {
if self.active_hook_cell.take().is_some() {
self.bump_active_cell_revision();
self.request_pending_usage_output_insertion();
}
}
pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) {
self.record_visible_turn_activity();
self.flush_answer_stream_with_separator();
self.flush_completed_hook_output();
match self.active_hook_cell.as_mut() {
Some(cell) => {
cell.start_run(run);
self.bump_active_cell_revision();
}
None => {
self.active_hook_cell = Some(history_cell::new_active_hook_cell(
run,
self.config.animations,
));
self.bump_active_cell_revision();
}
}
self.request_redraw();
}
pub(super) fn on_hook_completed(
&mut self,
completed: codex_app_server_protocol::HookRunSummary,
) {
let completed_existing_run = self
.active_hook_cell
.as_mut()
.map(|cell| cell.complete_run(completed.clone()))
.unwrap_or(false);
if completed_existing_run {
self.bump_active_cell_revision();
} else {
match self.active_hook_cell.as_mut() {
Some(cell) => {
cell.add_completed_run(completed);
self.bump_active_cell_revision();
}
None => {
let cell =
history_cell::new_completed_hook_cell(completed, self.config.animations);
if !cell.is_empty() {
self.active_hook_cell = Some(cell);
self.bump_active_cell_revision();
}
}
}
}
self.flush_completed_hook_output();
self.finish_active_hook_cell_if_idle();
self.request_redraw();
}
pub(super) fn flush_completed_hook_output(&mut self) {
let Some(completed_cell) = self
.active_hook_cell
.as_mut()
.and_then(HookCell::take_completed_persistent_runs)
else {
return;
};
let active_cell_is_empty = self
.active_hook_cell
.as_ref()
.is_some_and(HookCell::is_empty);
if active_cell_is_empty {
self.active_hook_cell = None;
}
self.bump_active_cell_revision();
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(completed_cell)));
self.request_pending_usage_output_insertion();
}
pub(super) fn finish_active_hook_cell_if_idle(&mut self) {
let Some(cell) = self.active_hook_cell.as_ref() else {
return;
};
if cell.is_empty() {
self.active_hook_cell = None;
self.bump_active_cell_revision();
self.request_pending_usage_output_insertion();
return;
}
if cell.should_flush()
&& let Some(cell) = self.active_hook_cell.take()
{
self.bump_active_cell_revision();
self.transcript.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
self.request_pending_usage_output_insertion();
}
}
pub(super) fn update_due_hook_visibility(&mut self) {
let Some(cell) = self.active_hook_cell.as_mut() else {
return;
};
let now = Instant::now();
if cell.advance_time(now) {
self.bump_active_cell_revision();
}
self.finish_active_hook_cell_if_idle();
}
pub(super) fn schedule_hook_timer_if_needed(&self) {
if self.config.animations
&& self
.active_hook_cell
.as_ref()
.is_some_and(HookCell::has_visible_running_run)
{
self.frame_requester
.schedule_frame_in(Duration::from_millis(50));
}
let Some(deadline) = self
.active_hook_cell
.as_ref()
.and_then(HookCell::next_timer_deadline)
else {
return;
};
let delay = deadline.saturating_duration_since(Instant::now());
self.frame_requester.schedule_frame_in(delay);
}
}