feat(tui/limits): expand usage history and align layout

This commit is contained in:
James Peter
2025-09-26 13:12:52 +10:00
parent 8214e70eef
commit 7c956be79b
2 changed files with 278 additions and 31 deletions
+265 -25
View File
@@ -33,6 +33,7 @@ use codex_core::account_usage::{
RateLimitWarningScope,
StoredRateLimitSnapshot,
StoredUsageSummary,
TokenTotals,
};
use codex_core::auth_accounts::{self, StoredAccount};
use codex_login::AuthManager;
@@ -147,6 +148,30 @@ use tokio::sync::mpsc::unbounded_channel;
use tracing::info;
// use image::GenericImageView;
const TOKENS_PER_MILLION: f64 = 1_000_000.0;
const INPUT_COST_PER_MILLION_USD: f64 = 1.25;
const CACHED_INPUT_COST_PER_MILLION_USD: f64 = 0.125;
const OUTPUT_COST_PER_MILLION_USD: f64 = 10.0;
const STATUS_LABEL_INDENT: &str = " ";
const STATUS_LABEL_TARGET_WIDTH: usize = 7;
const STATUS_LABEL_GAP: usize = 2;
const STATUS_CONTENT_PREFIX: &str = " ";
fn status_field_prefix(label: &str) -> String {
let padding = STATUS_LABEL_GAP
.saturating_add(STATUS_LABEL_TARGET_WIDTH.saturating_sub(label.len()));
format!(
"{indent}{label}:{spaces}",
indent = STATUS_LABEL_INDENT,
label = label,
spaces = " ".repeat(padding)
)
}
fn status_content_prefix() -> String {
STATUS_CONTENT_PREFIX.to_string()
}
use crate::app_event::{
AppEvent,
BackgroundPlacement,
@@ -209,7 +234,7 @@ use codex_core::protocol::RateLimitSnapshotEvent;
use codex_core::protocol::ValidationGroup;
use crate::rate_limits_view::{build_limits_view, RateLimitResetInfo, DEFAULT_GRID_CONFIG};
use codex_core::review_format::format_review_findings_block;
use chrono::{DateTime, Duration as ChronoDuration, Local, Utc};
use chrono::{DateTime, Datelike, Duration as ChronoDuration, Local, Timelike, Utc};
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
@@ -3471,7 +3496,7 @@ impl ChatWidget<'_> {
.or_else(|| active_id.clone())
.unwrap_or_else(|| "Current session".to_string());
let header = Self::account_header_lines(account_ref, snapshot_ref, summary_ref);
let extra = Self::daily_usage_lines(summary_ref);
let extra = Self::usage_history_lines(summary_ref);
let view = build_limits_view(&snapshot, current_reset, DEFAULT_GRID_CONFIG);
tabs.push(LimitsTab::view(title, header, view, extra));
if let Some(id) = active_id.as_ref() {
@@ -3533,10 +3558,10 @@ impl ChatWidget<'_> {
Some(&record),
usage_summary.as_ref(),
);
let extra = Self::daily_usage_lines(usage_summary.as_ref());
let extra = Self::usage_history_lines(usage_summary.as_ref());
tabs.push(LimitsTab::view(title, header, view, extra));
} else {
let mut lines = Self::daily_usage_lines(usage_summary.as_ref());
let mut lines = Self::usage_history_lines(usage_summary.as_ref());
lines.push(Self::dim_line(
" Rate limit snapshot not yet available.",
));
@@ -3549,7 +3574,7 @@ impl ChatWidget<'_> {
}
}
None => {
let mut lines = Self::daily_usage_lines(usage_summary.as_ref());
let mut lines = Self::usage_history_lines(usage_summary.as_ref());
lines.push(Self::dim_line(
" Rate limit snapshot not yet available.",
));
@@ -3564,7 +3589,7 @@ impl ChatWidget<'_> {
}
if tabs.is_empty() {
let mut lines = Self::daily_usage_lines(None);
let mut lines = Self::usage_history_lines(None);
lines.push(Self::dim_line(
" Rate limit snapshot not yet available.",
));
@@ -3582,6 +3607,54 @@ impl ChatWidget<'_> {
.unwrap_or_else(|| account.id.clone())
}
fn usage_cost_usd_from_totals(totals: &TokenTotals) -> f64 {
let non_cached_input = totals
.input_tokens
.saturating_sub(totals.cached_input_tokens);
let input_cost = (non_cached_input as f64 / TOKENS_PER_MILLION)
* INPUT_COST_PER_MILLION_USD;
let cached_cost = (totals.cached_input_tokens as f64 / TOKENS_PER_MILLION)
* CACHED_INPUT_COST_PER_MILLION_USD;
let output_cost = (totals.output_tokens as f64 / TOKENS_PER_MILLION)
* OUTPUT_COST_PER_MILLION_USD;
input_cost + cached_cost + output_cost
}
fn format_usd(amount: f64) -> String {
let cents = (amount * 100.0).round().max(0.0);
let cents_u128 = cents as u128;
let dollars_u128 = cents_u128 / 100;
let cents_part = (cents_u128 % 100) as u8;
let dollars = (dollars_u128.min(u128::from(u64::MAX))) as u64;
if cents_part == 0 {
format!("${} USD", format_with_separators(dollars))
} else {
format!(
"${}.{:02} USD",
format_with_separators(dollars),
cents_part
)
}
}
fn accumulate_token_totals(target: &mut TokenTotals, delta: &TokenTotals) {
target.input_tokens = target
.input_tokens
.saturating_add(delta.input_tokens);
target.cached_input_tokens = target
.cached_input_tokens
.saturating_add(delta.cached_input_tokens);
target.output_tokens = target
.output_tokens
.saturating_add(delta.output_tokens);
target.reasoning_output_tokens = target
.reasoning_output_tokens
.saturating_add(delta.reasoning_output_tokens);
target.total_tokens = target
.total_tokens
.saturating_add(delta.total_tokens);
}
fn account_header_lines(
account: Option<&StoredAccount>,
record: Option<&StoredRateLimitSnapshot>,
@@ -3601,35 +3674,152 @@ impl ChatWidget<'_> {
.or_else(|| usage.and_then(|u| u.plan.as_deref()))
.unwrap_or("Unknown");
let total_tokens = usage
.map(|u| u.totals.total_tokens)
.unwrap_or(0);
let value_style = Style::default().fg(crate::colors::text_dim());
let is_api_key = matches!(account.map(|acc| acc.mode), Some(McpAuthMode::ApiKey));
let totals = usage
.map(|u| u.totals.clone())
.unwrap_or_default();
let non_cached_input = totals
.input_tokens
.saturating_sub(totals.cached_input_tokens);
let cached_input = totals.cached_input_tokens;
let output_tokens = totals.output_tokens;
let reasoning_tokens = totals.reasoning_output_tokens;
let total_tokens = totals.total_tokens;
let cost_usd = Self::usage_cost_usd_from_totals(&totals);
let formatted_total = format_with_separators(total_tokens);
let formatted_cost = Self::format_usd(cost_usd);
let cost_suffix = if is_api_key {
format!("({formatted_cost})")
} else {
format!("(API would cost {formatted_cost})")
};
lines.push(RtLine::from(String::new()));
lines.push(RtLine::from(vec![
RtSpan::raw(" Type: "),
RtSpan::raw(status_field_prefix("Type")),
RtSpan::styled(account_type.to_string(), value_style),
]));
lines.push(RtLine::from(vec![
RtSpan::raw(" Plan: "),
RtSpan::raw(status_field_prefix("Plan")),
RtSpan::styled(plan.to_string(), value_style),
]));
let total_value = format!("{} tokens", format_with_separators(total_tokens));
let tokens_summary = format!("{formatted_total} total {cost_suffix}");
lines.push(RtLine::from(vec![
RtSpan::raw(" Total: "),
RtSpan::styled(total_value, value_style),
RtSpan::raw(status_field_prefix("Tokens")),
RtSpan::styled(tokens_summary, value_style),
]));
let indent = status_content_prefix();
let mut counts = vec![
(format_with_separators(cached_input), "cached"),
(format_with_separators(non_cached_input), "input"),
(format_with_separators(output_tokens), "output"),
(format_with_separators(reasoning_tokens), "reasoning"),
];
let max_width = counts
.iter()
.map(|(count, _)| count.len())
.max()
.unwrap_or(0);
for (count, label) in counts.drain(..) {
let line_text = format!(
"{indent}{count:>width$} {label}",
indent = indent.as_str(),
count = count,
label = label,
width = max_width
);
lines.push(RtLine::from(vec![RtSpan::styled(line_text, value_style)]));
}
lines
}
fn hourly_usage_lines(summary: Option<&StoredUsageSummary>) -> Vec<RtLine<'static>> {
const WIDTH: usize = 14;
let now = Local::now();
let anchor = now
- ChronoDuration::minutes(now.minute() as i64)
- ChronoDuration::seconds(now.second() as i64)
- ChronoDuration::nanoseconds(now.nanosecond() as i64);
let mut hours: Vec<(DateTime<Local>, TokenTotals)> = (0..12)
.map(|offset| {
(
anchor - ChronoDuration::hours(offset as i64),
TokenTotals::default(),
)
})
.collect();
let mut lookup: HashMap<i64, usize> = HashMap::new();
for (idx, (dt, _)) in hours.iter().enumerate() {
lookup.insert(dt.timestamp(), idx);
}
if let Some(summary) = summary {
for entry in &summary.hourly_entries {
let local_time = entry.timestamp.with_timezone(&Local);
let truncated = local_time
- ChronoDuration::minutes(local_time.minute() as i64)
- ChronoDuration::seconds(local_time.second() as i64)
- ChronoDuration::nanoseconds(local_time.nanosecond() as i64);
if let Some(&pos) = lookup.get(&truncated.timestamp()) {
let (_, totals) = &mut hours[pos];
Self::accumulate_token_totals(totals, &entry.tokens);
}
}
}
let max_total = hours
.iter()
.map(|(_, totals)| totals.total_tokens)
.max()
.unwrap_or(0);
let mut lines: Vec<RtLine<'static>> = Vec::new();
lines.push(RtLine::from(vec![RtSpan::styled(
"12 Hour History",
Style::default().add_modifier(Modifier::BOLD),
)]));
let prefix = status_content_prefix();
for (dt, totals) in hours.iter() {
let label = Self::format_hour_label(*dt);
let bar = Self::bar_segment(totals.total_tokens, max_total, WIDTH);
let tokens = format_with_separators(totals.total_tokens);
let cost_text = Self::format_usd(Self::usage_cost_usd_from_totals(totals));
let cost_span = RtSpan::styled(
format!(" ({cost_text})"),
Style::default().fg(crate::colors::text_dim()),
);
lines.push(RtLine::from(vec![
RtSpan::raw(prefix.clone()),
RtSpan::styled(
format!("{label} "),
Style::default().fg(crate::colors::text_dim()),
),
RtSpan::styled("│ ", Style::default().fg(crate::colors::text_dim())),
RtSpan::styled(bar, Style::default().fg(crate::colors::primary())),
RtSpan::raw(format!(" {tokens} tokens")),
cost_span,
]));
}
lines
}
fn daily_usage_lines(summary: Option<&StoredUsageSummary>) -> Vec<RtLine<'static>> {
const WIDTH: usize = 14;
let today = Local::now().date_naive();
let mut daily: Vec<(chrono::NaiveDate, u64)> = (0..7)
.map(|offset| (today - ChronoDuration::days(offset as i64), 0u64))
let mut daily: Vec<(chrono::NaiveDate, TokenTotals)> = (0..7)
.map(|offset| {
(
today - ChronoDuration::days(offset as i64),
TokenTotals::default(),
)
})
.collect();
if let Some(summary) = summary {
@@ -3638,36 +3828,86 @@ impl ChatWidget<'_> {
let diff = today.signed_duration_since(entry_date).num_days();
if (0..=6).contains(&diff) {
let idx = diff as usize;
let (_, total) = &mut daily[idx];
*total = total.saturating_add(entry.tokens.total_tokens);
let (_, totals) = &mut daily[idx];
Self::accumulate_token_totals(totals, &entry.tokens);
}
}
}
let max_total = daily.iter().map(|(_, total)| *total).max().unwrap_or(0);
let max_total = daily
.iter()
.map(|(_, totals)| totals.total_tokens)
.max()
.unwrap_or(0);
let mut lines: Vec<RtLine<'static>> = Vec::new();
lines.push(RtLine::from(vec![RtSpan::styled(
"7 Day History",
Style::default().add_modifier(Modifier::BOLD),
)]));
for (day, total) in daily.iter() {
let label = day.format("%b %d").to_string();
let bar = Self::bar_segment(*total, max_total, WIDTH);
let tokens = format_with_separators(*total);
let prefix = status_content_prefix();
for (day, totals) in daily.iter() {
let label = Self::format_daily_label(*day);
let bar = Self::bar_segment(totals.total_tokens, max_total, WIDTH);
let tokens = format_with_separators(totals.total_tokens);
let daily_cost = Self::usage_cost_usd_from_totals(totals);
let cost_text = Self::format_usd(daily_cost);
let cost_span = RtSpan::styled(
format!(" ({cost_text})"),
Style::default().fg(crate::colors::text_dim()),
);
lines.push(RtLine::from(vec![
RtSpan::raw(prefix.clone()),
RtSpan::styled(
format!(" {label} "),
format!("{label} "),
Style::default().fg(crate::colors::text_dim()),
),
RtSpan::styled("│ ", Style::default().fg(crate::colors::text_dim())),
RtSpan::styled(bar, Style::default().fg(crate::colors::primary())),
RtSpan::raw(format!(" {tokens} tokens")),
cost_span,
]));
}
lines
}
fn day_suffix(day: u32) -> &'static str {
if (11..=13).contains(&(day % 100)) {
return "th";
}
match day % 10 {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th",
}
}
fn format_daily_label(date: chrono::NaiveDate) -> String {
let suffix = Self::day_suffix(date.day());
format!("{} {:>2}{}", date.format("%b"), date.day(), suffix)
}
fn format_hour_label(dt: DateTime<Local>) -> String {
let suffix = Self::day_suffix(dt.day());
let (is_pm, hour) = dt.hour12();
let meridiem = if is_pm { "pm" } else { "am" };
format!(
"{} {:>2}{} {:>2}{}",
dt.format("%b"),
dt.day(),
suffix,
hour,
meridiem
)
}
fn usage_history_lines(summary: Option<&StoredUsageSummary>) -> Vec<RtLine<'static>> {
let mut lines = Self::hourly_usage_lines(summary);
lines.extend(Self::daily_usage_lines(summary));
lines
}
fn bar_segment(value: u64, max: u64, width: usize) -> String {
const FILL: &str = "▇";
if max == 0 {
+13 -6
View File
@@ -45,6 +45,10 @@ fn chart_indent_width() -> usize {
INDENTS.header.len() + INDENTS.chart_extra.len()
}
fn content_column_width() -> usize {
INDENTS.header.len() + INDENTS.label_target_width + 1 + INDENTS.label_gap
}
fn label_text(text: &str) -> String {
let mut result = label_indent();
result.push_str(text);
@@ -208,7 +212,6 @@ fn build_summary_lines(
lines.push("".into());
lines.push(section_header("Chart"));
lines.push("".into());
lines
}
@@ -951,22 +954,27 @@ impl GridLayout {
fn render(&self, state: GridState) -> Vec<Line<'static>> {
let counts = self.cell_counts(state);
let mut lines = Vec::new();
let mut cell_index = 0isize;
let total_cells = (self.size * self.size) as isize;
let indent = chart_indent();
for _ in 0..self.size {
let desired_width = content_column_width();
for row in 0..self.size {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(indent.clone()));
let padding = desired_width.saturating_sub(indent.len());
spans.push(Span::raw(" ".repeat(padding)));
for col in 0..self.size {
if col > 0 {
spans.push(" ".into());
}
let span = if cell_index < counts.dark_cells {
let linear_index = (self.size * row) + col;
let slot = total_cells - 1 - linear_index as isize;
let span = if slot < counts.dark_cells {
Span::styled(
WEEKLY_CELL.to_string(),
Style::default().fg(colors::text()),
)
} else if cell_index < counts.dark_cells + counts.green_cells {
} else if slot < counts.dark_cells + counts.green_cells {
Span::styled(
HOURLY_CELL.to_string(),
Style::default().fg(colors::primary()),
@@ -978,7 +986,6 @@ impl GridLayout {
)
};
spans.push(span);
cell_index += 1;
}
lines.push(Line::from(spans));
}