Fix stale /status rate limits in active TUI sessions (#16201)

Fix stale weekly limit in `/status` (#16194): /status reused the
session’s cached rate-limit snapshot, so the weekly remaining limit
could stay frozen within an active session.

With this change, we now dynamically update the rate limits after status
is displayed.

I needed to delete a few low-value test cases from the chatWidget tests
because the test.rs file is really large, and the new tests in this PR
pushed us over the 512K mandated limit. I'm working on a separate PR to
refactor that test file.
This commit is contained in:
Eric Traut
2026-03-31 17:03:05 -06:00
committed by GitHub
Unverified
parent 424e532a6b
commit ae057e0bb9
12 changed files with 487 additions and 42 deletions
+45 -2
View File
@@ -11,6 +11,7 @@ use crate::app_event_sender::AppEventSender;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::AppServerStartedThread;
use crate::app_server_session::ThreadSessionState;
use crate::app_server_session::app_server_rate_limit_snapshots_to_core;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::McpServerElicitationFormRequest;
@@ -58,6 +59,7 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerStatus;
@@ -111,6 +113,7 @@ use codex_protocol::protocol::ListSkillsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillErrorInfo;
@@ -1877,6 +1880,17 @@ impl App {
});
}
fn refresh_rate_limits(&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 = fetch_account_rate_limits(request_handle)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::RateLimitsLoaded { request_id, result });
});
}
fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
@@ -4364,9 +4378,23 @@ impl App {
AppEvent::FileSearchResult { query, matches } => {
self.chat_widget.apply_file_search_result(query, matches);
}
AppEvent::RateLimitSnapshotFetched(snapshot) => {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
AppEvent::RefreshRateLimits { request_id } => {
self.refresh_rate_limits(app_server, request_id);
}
AppEvent::RateLimitsLoaded { request_id, result } => match result {
Ok(snapshots) => {
for snapshot in snapshots {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
self.chat_widget
.finish_status_rate_limit_refresh(request_id);
}
Err(err) => {
tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}");
self.chat_widget
.finish_status_rate_limit_refresh(request_id);
}
},
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
@@ -5957,6 +5985,21 @@ async fn fetch_all_mcp_server_statuses(
Ok(statuses)
}
async fn fetch_account_rate_limits(
request_handle: AppServerRequestHandle,
) -> Result<Vec<RateLimitSnapshot>> {
let request_id = RequestId::String(format!("account-rate-limits-{}", 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")?;
Ok(app_server_rate_limit_snapshots_to_core(response))
}
async fn fetch_plugins_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
+10 -3
View File
@@ -137,9 +137,16 @@ pub(crate) enum AppEvent {
matches: Vec<FileMatch>,
},
/// Result of refreshing rate limits
#[allow(dead_code)]
RateLimitSnapshotFetched(RateLimitSnapshot),
/// Refresh account rate limits in the background.
RefreshRateLimits {
request_id: u64,
},
/// Result of refreshing rate limits.
RateLimitsLoaded {
request_id: u64,
result: Result<Vec<RateLimitSnapshot>, String>,
},
/// Result of prefetching connectors.
ConnectorsLoaded {
+1 -1
View File
@@ -1063,7 +1063,7 @@ async fn thread_session_state_from_thread_response(
})
}
fn app_server_rate_limit_snapshots_to_core(
pub(crate) fn app_server_rate_limit_snapshots_to_core(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
let mut snapshots = Vec::new();
+56 -4
View File
@@ -57,6 +57,7 @@ use crate::model_catalog::ModelCatalog;
use crate::multi_agents;
use crate::status::RateLimitWindowDisplay;
use crate::status::StatusAccountDisplay;
use crate::status::StatusHistoryHandle;
use crate::status::format_directory_display;
use crate::status::format_tokens_compact;
use crate::status::rate_limit_snapshot_display_for_limit;
@@ -761,6 +762,8 @@ pub(crate) struct ChatWidget {
status_account_display: Option<StatusAccountDisplay>,
token_info: Option<TokenUsageInfo>,
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
plan_type: Option<PlanType>,
rate_limit_warnings: RateLimitWarningState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
@@ -4705,6 +4708,8 @@ impl ChatWidget {
status_account_display,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: initial_plan_type,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
@@ -5328,7 +5333,18 @@ impl ChatWidget {
self.open_skills_menu();
}
SlashCommand::Status => {
self.add_status_output();
if self.should_prefetch_rate_limits() {
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id =
self.next_status_refresh_request_id.wrapping_add(1);
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
self.app_event_tx
.send(AppEvent::RefreshRateLimits { request_id });
} else {
self.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
}
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
@@ -7344,7 +7360,11 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn add_status_output(&mut self) {
pub(crate) fn add_status_output(
&mut self,
refreshing_rate_limits: bool,
request_id: Option<u64>,
) {
let default_usage = TokenUsage::default();
let token_info = self.token_info.as_ref();
let total_usage = token_info
@@ -7357,7 +7377,7 @@ impl ChatWidget {
.values()
.cloned()
.collect();
self.add_to_history(crate::status::new_status_output_with_rate_limits(
let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle(
&self.config,
self.status_account_display.as_ref(),
token_info,
@@ -7371,7 +7391,39 @@ impl ChatWidget {
self.model_display_name(),
collaboration_mode,
reasoning_effort_override,
));
refreshing_rate_limits,
);
if let Some(request_id) = request_id {
self.refreshing_status_outputs.push((request_id, handle));
}
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() {
return;
}
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
.rate_limit_snapshots_by_limit_id
.values()
.cloned()
.collect();
let now = Local::now();
let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len());
let mut updated_any = false;
for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) {
if pending_request_id == request_id {
updated_any = true;
handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now);
} else {
remaining.push((pending_request_id, handle));
}
}
self.refreshing_status_outputs = remaining;
if updated_any {
self.request_redraw();
}
}
pub(crate) fn add_debug_config_output(&mut self) {
+1
View File
@@ -235,6 +235,7 @@ mod popups_and_settings;
mod review_mode;
mod slash_commands;
mod status_and_layout;
mod status_command_tests;
pub(crate) use helpers::make_chatwidget_manual_with_sender;
pub(crate) use helpers::set_chatgpt_auth;
@@ -196,6 +196,8 @@ pub(super) async fn make_chatwidget_manual(
status_account_display: None,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
@@ -84,7 +84,9 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() {
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(100));
chat.add_status_output();
chat.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
let cells = drain_insert_history(&mut rx);
let context_line = cells
@@ -0,0 +1,126 @@
use super::*;
use assert_matches::assert_matches;
#[tokio::test]
async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Status);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 80))
}
other => panic!("expected status output before refresh request, got {other:?}"),
};
assert!(
rendered.contains("refreshing limits"),
"expected /status to explain the background refresh, got: {rendered}"
);
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshRateLimits { request_id }) => request_id,
other => panic!("expected rate-limit refresh request, got {other:?}"),
};
pretty_assertions::assert_eq!(request_id, 0);
}
#[tokio::test]
async fn status_command_updates_rendered_cell_after_rate_limit_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Status);
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected status output before refresh request, got {other:?}"),
};
let first_request_id = match rx.try_recv() {
Ok(AppEvent::RefreshRateLimits { request_id }) => request_id,
other => panic!("expected rate-limit refresh request, got {other:?}"),
};
let initial = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert!(
initial.contains("refreshing limits"),
"expected initial /status output to show refresh notice, got: {initial}"
);
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0)));
chat.finish_status_rate_limit_refresh(first_request_id);
let updated = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert_ne!(
initial, updated,
"expected refreshed /status output to change"
);
assert!(
!updated.contains("refreshing limits"),
"expected refresh notice to clear after background update, got: {updated}"
);
}
#[tokio::test]
async fn status_command_renders_immediately_without_rate_limit_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert!(
!std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })),
"non-ChatGPT sessions should not request a rate-limit refresh for /status"
);
}
#[tokio::test]
async fn status_command_overlapping_refreshes_update_matching_cells_only() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Status);
let first_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected first status output, got {other:?}"),
};
let first_request_id = match rx.try_recv() {
Ok(AppEvent::RefreshRateLimits { request_id }) => request_id,
other => panic!("expected first refresh request, got {other:?}"),
};
chat.dispatch_command(SlashCommand::Status);
let second_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected second status output, got {other:?}"),
};
let second_request_id = match rx.try_recv() {
Ok(AppEvent::RefreshRateLimits { request_id }) => request_id,
other => panic!("expected second refresh request, got {other:?}"),
};
assert_ne!(first_request_id, second_request_id);
chat.finish_status_rate_limit_refresh(first_request_id);
let first_after_failure = lines_to_single_string(&first_cell.display_lines(/*width*/ 80));
let second_still_refreshing = lines_to_single_string(&second_cell.display_lines(/*width*/ 80));
assert!(
!first_after_failure.contains("refreshing limits"),
"expected first status cell to stop refreshing after its request completed, got: {first_after_failure}"
);
assert!(
second_still_refreshing.contains("refreshing limits"),
"expected later status cell to keep refreshing until its own request completes, got: {second_still_refreshing}"
);
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0)));
chat.finish_status_rate_limit_refresh(second_request_id);
let second_after_success = lines_to_single_string(&second_cell.display_lines(/*width*/ 80));
assert!(
!second_after_success.contains("refreshing limits"),
"expected second status cell to refresh once its own request completed, got: {second_after_success}"
);
}
+151 -31
View File
@@ -42,6 +42,8 @@ use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
use std::sync::Arc;
use std::sync::RwLock;
#[derive(Debug, Clone)]
struct StatusContextWindowData {
@@ -58,6 +60,37 @@ pub(crate) struct StatusTokenUsageData {
context_window: Option<StatusContextWindowData>,
}
#[derive(Debug)]
struct StatusRateLimitState {
rate_limits: StatusRateLimitData,
refreshing_rate_limits: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct StatusHistoryHandle {
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
}
impl StatusHistoryHandle {
pub(crate) fn finish_rate_limit_refresh(
&self,
rate_limits: &[RateLimitSnapshotDisplay],
now: DateTime<Local>,
) {
let rate_limits = if rate_limits.len() <= 1 {
compose_rate_limit_data(rate_limits.first(), now)
} else {
compose_rate_limit_data_many(rate_limits, now)
};
let mut state = self
.rate_limit_state
.write()
.expect("status history rate-limit state poisoned");
state.rate_limits = rate_limits;
state.refreshing_rate_limits = false;
}
}
#[derive(Debug)]
struct StatusHistoryCell {
model_name: String,
@@ -72,7 +105,7 @@ struct StatusHistoryCell {
session_id: Option<String>,
forked_from: Option<String>,
token_usage: StatusTokenUsageData,
rate_limits: StatusRateLimitData,
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
}
#[cfg(test)]
@@ -107,9 +140,11 @@ pub(crate) fn new_status_output(
model_name,
collaboration_mode,
reasoning_effort_override,
/*refreshing_rate_limits*/ false,
)
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_status_output_with_rate_limits(
config: &Config,
@@ -125,9 +160,9 @@ pub(crate) fn new_status_output_with_rate_limits(
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
refreshing_rate_limits: bool,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let card = StatusHistoryCell::new(
new_status_output_with_rate_limits_handle(
config,
account_display,
token_info,
@@ -141,9 +176,50 @@ pub(crate) fn new_status_output_with_rate_limits(
model_name,
collaboration_mode,
reasoning_effort_override,
refreshing_rate_limits,
)
.0
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_status_output_with_rate_limits_handle(
config: &Config,
account_display: Option<&StatusAccountDisplay>,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
session_id: &Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
rate_limits: &[RateLimitSnapshotDisplay],
_plan_type: Option<PlanType>,
now: DateTime<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
refreshing_rate_limits: bool,
) -> (CompositeHistoryCell, StatusHistoryHandle) {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let (card, handle) = StatusHistoryCell::new(
config,
account_display,
token_info,
total_usage,
session_id,
thread_name,
forked_from,
rate_limits,
_plan_type,
now,
model_name,
collaboration_mode,
reasoning_effort_override,
refreshing_rate_limits,
);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
(
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]),
handle,
)
}
impl StatusHistoryCell {
@@ -162,7 +238,8 @@ impl StatusHistoryCell {
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
) -> Self {
refreshing_rate_limits: bool,
) -> (Self, StatusHistoryHandle) {
let mut config_entries = vec![
("workdir", config.cwd.display().to_string()),
("model", model_name.to_string()),
@@ -251,22 +328,29 @@ impl StatusHistoryCell {
} else {
compose_rate_limit_data_many(rate_limits, now)
};
Self {
model_name,
model_details,
directory: config.cwd.to_path_buf(),
permissions,
agents_summary,
collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider,
account,
thread_name,
session_id,
forked_from,
token_usage,
let rate_limit_state = Arc::new(RwLock::new(StatusRateLimitState {
rate_limits,
}
refreshing_rate_limits,
}));
(
Self {
model_name,
model_details,
directory: config.cwd.to_path_buf(),
permissions,
agents_summary,
collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider,
account,
thread_name,
session_id,
forked_from,
token_usage,
rate_limit_state: rate_limit_state.clone(),
},
StatusHistoryHandle { rate_limit_state },
)
}
fn token_usage_spans(&self) -> Vec<Span<'static>> {
@@ -305,30 +389,57 @@ impl StatusHistoryCell {
fn rate_limit_lines(
&self,
state: &StatusRateLimitState,
available_inner_width: usize,
formatter: &FieldFormatter,
) -> Vec<Line<'static>> {
match &self.rate_limits {
match &state.rate_limits {
StatusRateLimitData::Available(rows_data) => {
if rows_data.is_empty() {
return vec![
formatter.line("Limits", vec![Span::from("data not available yet").dim()]),
];
return vec![formatter.line(
"Limits",
vec![if state.refreshing_rate_limits {
Span::from("refreshing cached limits...").dim()
} else {
Span::from("data not available yet").dim()
}],
)];
}
self.rate_limit_row_lines(rows_data, available_inner_width, formatter)
let mut lines =
self.rate_limit_row_lines(rows_data, available_inner_width, formatter);
if state.refreshing_rate_limits {
lines.push(formatter.line(
"Notice",
vec![Span::from("refreshing limits in background...").dim()],
));
}
lines
}
StatusRateLimitData::Stale(rows_data) => {
let mut lines =
self.rate_limit_row_lines(rows_data, available_inner_width, formatter);
lines.push(formatter.line(
"Warning",
vec![Span::from("limits may be stale - start new turn to refresh.").dim()],
vec![Span::from(if state.refreshing_rate_limits {
"limits may be stale - refreshing in background..."
} else {
"limits may be stale - start new turn to refresh."
})
.dim()],
));
lines
}
StatusRateLimitData::Missing => {
vec![formatter.line("Limits", vec![Span::from("data not available yet").dim()])]
vec![formatter.line(
"Limits",
vec![Span::from(if state.refreshing_rate_limits {
"refreshing limits..."
} else {
"data not available yet"
})
.dim()],
)]
}
}
}
@@ -386,8 +497,13 @@ impl StatusHistoryCell {
lines
}
fn collect_rate_limit_labels(&self, seen: &mut BTreeSet<String>, labels: &mut Vec<String>) {
match &self.rate_limits {
fn collect_rate_limit_labels(
&self,
state: &StatusRateLimitState,
seen: &mut BTreeSet<String>,
labels: &mut Vec<String>,
) {
match &state.rate_limits {
StatusRateLimitData::Available(rows) => {
if rows.is_empty() {
push_label(labels, seen, "Limits");
@@ -442,6 +558,10 @@ impl HistoryCell for StatusHistoryCell {
.collect();
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty());
let rate_limit_state = self
.rate_limit_state
.read()
.expect("status history rate-limit state poisoned");
if self.model_provider.is_some() {
push_label(&mut labels, &mut seen, "Model provider");
@@ -466,7 +586,7 @@ impl HistoryCell for StatusHistoryCell {
push_label(&mut labels, &mut seen, "Context window");
}
self.collect_rate_limit_labels(&mut seen, &mut labels);
self.collect_rate_limit_labels(&rate_limit_state, &mut seen, &mut labels);
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
let value_width = formatter.value_width(available_inner_width);
@@ -534,7 +654,7 @@ impl HistoryCell for StatusHistoryCell {
lines.push(formatter.line("Context window", spans));
}
lines.extend(self.rate_limit_lines(available_inner_width, &formatter));
lines.extend(self.rate_limit_lines(&rate_limit_state, available_inner_width, &formatter));
let content_width = lines.iter().map(line_display_width).max().unwrap_or(0);
let inner_width = content_width.min(available_inner_width);
+3
View File
@@ -13,9 +13,12 @@ mod helpers;
mod rate_limits;
pub(crate) use account::StatusAccountDisplay;
pub(crate) use card::StatusHistoryHandle;
#[cfg(test)]
pub(crate) use card::new_status_output;
#[cfg(test)]
pub(crate) use card::new_status_output_with_rate_limits;
pub(crate) use card::new_status_output_with_rate_limits_handle;
pub(crate) use helpers::format_directory_display;
pub(crate) use helpers::format_tokens_compact;
pub(crate) use helpers::plan_type_display_name;
@@ -0,0 +1,24 @@
---
source: tui/src/status/tests.rs
assertion_line: 765
expression: sanitized
---
/status
╭───────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
│ information on rate limits and credits │
│ │
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Permissions: Custom (read-only, on-request) │
│ Agents.md: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │
│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │
│ Notice: refreshing limits in background... │
╰───────────────────────────────────────────────────────────────────────╯
+65
View File
@@ -1,4 +1,5 @@
use super::new_status_output;
use super::new_status_output_with_rate_limits;
use super::rate_limit_snapshot_display;
use crate::history_cell::HistoryCell;
use crate::status::StatusAccountDisplay;
@@ -700,6 +701,70 @@ async fn status_snapshot_shows_missing_limits_message() {
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_shows_refreshing_limits_notice() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = PathBuf::from("/workspace/tests").abs();
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 6, 7, 8, 9, 10)
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 45.0,
window_minutes: Some(300),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 900)),
}),
secondary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(10_080),
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
}),
credits: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = codex_core::test_support::get_model_offline(config.model.as_deref());
let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output_with_rate_limits(
&config,
/*account_display*/ None,
Some(&token_info),
&usage,
&None,
/*thread_name*/ None,
/*forked_from*/ None,
std::slice::from_ref(&rate_display),
None,
captured_at,
&model_slug,
/*collaboration_mode*/ None,
/*reasoning_effort_override*/ None,
/*refreshing_rate_limits*/ true,
);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_includes_credits_and_limits() {
let temp_home = TempDir::new().expect("temp home");