Files
codex/codex-rs/tui/src/status/rate_limits.rs
T
sayan-oai 53eb2e9f27 [tui] add optional details to TUI status header (#8293)
### What

Add optional `details` field to TUI's status indicator header. `details`
is shown under the header with text wrapping and a max height of 3
lines.

Duplicated changes to `tui2`.

### Why

Groundwork for displaying error details under `Reconnecting...` for
clarity with retryable errors.

Basic examples
<img width="1012" height="326" alt="image"
src="https://github.com/user-attachments/assets/dd751ceb-b179-4fb2-8fd1-e4784d6366fb"
/>

<img width="1526" height="358" alt="image"
src="https://github.com/user-attachments/assets/bbe466fc-faff-4a78-af7f-3073ccdd8e34"
/>

Truncation example
<img width="936" height="189" alt="image"
src="https://github.com/user-attachments/assets/f3f1b5dd-9050-438b-bb07-bd833c03e889"
/>

### Tests
Tested locally, added tests for truncation.
2025-12-23 12:40:40 -08:00

225 lines
7.0 KiB
Rust

use crate::chatwidget::get_limits_duration;
use crate::text_formatting::capitalize_first;
use super::helpers::format_reset_timestamp;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use chrono::Utc;
use codex_core::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "█";
const STATUS_LIMIT_BAR_EMPTY: &str = "░";
#[derive(Debug, Clone)]
pub(crate) struct StatusRateLimitRow {
pub label: String,
pub value: StatusRateLimitValue,
}
#[derive(Debug, Clone)]
pub(crate) enum StatusRateLimitValue {
Window {
percent_used: f64,
resets_at: Option<String>,
},
Text(String),
}
#[derive(Debug, Clone)]
pub(crate) enum StatusRateLimitData {
Available(Vec<StatusRateLimitRow>),
Stale(Vec<StatusRateLimitRow>),
Missing,
}
pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15;
#[derive(Debug, Clone)]
pub(crate) struct RateLimitWindowDisplay {
pub used_percent: f64,
pub resets_at: Option<String>,
pub window_minutes: Option<i64>,
}
impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window
.resets_at
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0))
.map(|dt| dt.with_timezone(&Local))
.map(|dt| format_reset_timestamp(dt, captured_at));
Self {
used_percent: window.used_percent,
resets_at,
window_minutes: window.window_minutes,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
pub captured_at: DateTime<Local>,
pub primary: Option<RateLimitWindowDisplay>,
pub secondary: Option<RateLimitWindowDisplay>,
pub credits: Option<CreditsSnapshotDisplay>,
}
#[derive(Debug, Clone)]
pub(crate) struct CreditsSnapshotDisplay {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
pub(crate) fn rate_limit_snapshot_display(
snapshot: &RateLimitSnapshot,
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
captured_at,
primary: snapshot
.primary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
secondary: snapshot
.secondary
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
}
}
impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
fn from(value: &CoreCreditsSnapshot) -> Self {
Self {
has_credits: value.has_credits,
unlimited: value.unlimited,
balance: value.balance.clone(),
}
}
}
pub(crate) fn compose_rate_limit_data(
snapshot: Option<&RateLimitSnapshotDisplay>,
now: DateTime<Local>,
) -> StatusRateLimitData {
match snapshot {
Some(snapshot) => {
let mut rows = Vec::with_capacity(3);
if let Some(primary) = snapshot.primary.as_ref() {
let label: String = primary
.window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
let label = capitalize_first(&label);
rows.push(StatusRateLimitRow {
label: format!("{label} limit"),
value: StatusRateLimitValue::Window {
percent_used: primary.used_percent,
resets_at: primary.resets_at.clone(),
},
});
}
if let Some(secondary) = snapshot.secondary.as_ref() {
let label: String = secondary
.window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
let label = capitalize_first(&label);
rows.push(StatusRateLimitRow {
label: format!("{label} limit"),
value: StatusRateLimitValue::Window {
percent_used: secondary.used_percent,
resets_at: secondary.resets_at.clone(),
},
});
}
if let Some(credits) = snapshot.credits.as_ref()
&& let Some(row) = credit_status_row(credits)
{
rows.push(row);
}
let is_stale = now.signed_duration_since(snapshot.captured_at)
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
if rows.is_empty() {
StatusRateLimitData::Available(vec![])
} else if is_stale {
StatusRateLimitData::Stale(rows)
} else {
StatusRateLimitData::Available(rows)
}
}
None => StatusRateLimitData::Missing,
}
}
pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String {
let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0);
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
format!(
"[{}{}]",
STATUS_LIMIT_BAR_FILLED.repeat(filled),
STATUS_LIMIT_BAR_EMPTY.repeat(empty)
)
}
pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String {
format!("{percent_remaining:.0}% left")
}
/// Builds a single `StatusRateLimitRow` for credits when the snapshot indicates
/// that the account has credit tracking enabled. When credits are unlimited we
/// show that fact explicitly; otherwise we render the rounded balance in
/// credits. Accounts with credits = 0 skip this section entirely.
fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option<StatusRateLimitRow> {
if !credits.has_credits {
return None;
}
if credits.unlimited {
return Some(StatusRateLimitRow {
label: "Credits".to_string(),
value: StatusRateLimitValue::Text("Unlimited".to_string()),
});
}
let balance = credits.balance.as_ref()?;
let display_balance = format_credit_balance(balance)?;
Some(StatusRateLimitRow {
label: "Credits".to_string(),
value: StatusRateLimitValue::Text(format!("{display_balance} credits")),
})
}
fn format_credit_balance(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(int_value) = trimmed.parse::<i64>()
&& int_value > 0
{
return Some(int_value.to_string());
}
if let Ok(value) = trimmed.parse::<f64>()
&& value > 0.0
{
let rounded = value.round() as i64;
return Some(rounded.to_string());
}
None
}