use std::collections::HashMap;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::super::app::App;
use super::super::theme;
use super::format::{activity_verb, format_reset, spinner_frame, spinner_style};
use super::panes::{
active_pill, draw_profile_selector, section_box, section_box_verbatim, selector_width,
};
use crate::format::plan_label;
use crate::profile::Profile;
use crate::providers::StatRowKind;
use crate::usage::{
ExtraPeriod, FetchStatus, ProfileActivity, UsageWindow, WindowDollars, ideal_pace_pct,
now_epoch_secs, now_ms,
};
const KEY_W: usize = 8;
struct HeaderState {
is_active: bool,
activity: ProfileActivity,
next_refresh_ms: Option<u64>,
tick: u64,
}
pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(selector_width(area.width)),
Constraint::Min(20),
])
.split(area);
draw_profile_selector(frame, cols[0], app, app.profile_cursor, true);
draw_usage_detail(frame, cols[1], app);
}
fn draw_usage_detail(frame: &mut Frame<'_>, area: Rect, app: &App) {
let cfg = app.config();
let profile = cfg
.profiles
.get(app.profile_cursor.min(cfg.profiles.len().saturating_sub(1)));
let title = profile.map(|p| p.name.as_str()).unwrap_or("usage");
let block = if profile.is_some() {
section_box_verbatim(title, false, false)
} else {
section_box(title, false, false)
};
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(profile) = profile else {
let hint = Paragraph::new(Line::from(Span::styled(
"no accounts yet — press n to create one",
theme::dim(),
)))
.style(theme::base());
frame.render_widget(hint, inner);
return;
};
let header = HeaderState {
is_active: cfg.is_active(&profile.name),
activity: app
.activity
.lock()
.ok()
.and_then(|g| g.get(profile.name.as_str()).copied())
.unwrap_or(ProfileActivity::Idle),
next_refresh_ms: app
.next_refresh_per_profile
.lock()
.ok()
.and_then(|m| m.get(profile.name.as_str()).copied()),
tick: app.tick_count,
};
let show_estimates = cfg.state.show_estimates;
let show_pace = cfg.state.show_pace;
let lines = build_usage_lines(
profile,
inner.width,
&header,
app,
show_estimates,
show_pace,
);
frame.render_widget(Paragraph::new(lines).style(theme::base()), inner);
}
fn build_usage_lines(
profile: &Profile,
inner_w: u16,
header: &HeaderState,
app: &App,
show_estimates: bool,
show_pace: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.extend(header_lines(profile, inner_w, header));
lines.push(Line::from(""));
if profile.api_key.is_some() || profile.is_third_party() {
lines.extend(build_tp_rows(profile, inner_w, show_estimates, show_pace));
return lines;
}
if profile.usage.is_none() {
lines.push(Line::from(Span::styled(
format!(" {}", oauth_empty_msg(profile)),
theme::faint(),
)));
return lines;
}
let mut stats = collect_stats(profile);
if stats.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", oauth_empty_msg(profile)),
theme::faint(),
)));
return lines;
}
let history = app
.history_cache
.get(profile.name.as_str())
.map(|v| v.as_slice())
.unwrap_or(&[]);
let mut window_rates: HashMap<String, Option<f64>> = HashMap::new();
if let Some(u) = profile.usage.as_ref() {
let five_h: Vec<_> = u
.windows()
.into_iter()
.filter(|(l, _)| !l.starts_with("7d"))
.collect();
if !five_h.is_empty() {
window_rates.extend(crate::usage::compute_burn_rates_from_history(
history,
&five_h,
60 * 60 * 1000, 3, 10 * 60 * 1000, ));
}
}
for stat in &mut stats {
if !stat.label.starts_with("7d") {
stat.burn_rate = window_rates.get(&stat.label).and_then(|r| *r);
}
}
if !show_estimates {
for stat in &mut stats {
stat.burn_rate = None;
}
}
if !show_pace {
for stat in &mut stats {
stat.pace_pct = None;
}
}
lines.extend(render_stat_block(&stats, inner_w));
lines
}
fn render_stat_block(stats: &[Stat], inner_w: u16) -> Vec<Line<'static>> {
let max_trailing = stats
.iter()
.map(|s| s.trailing.chars().count())
.max()
.unwrap_or(0);
let bar_width = bar_width_for(inner_w, max_trailing);
let pct_col = (bar_width + max_trailing).min(inner_w as usize);
let max_rate_w = stats
.iter()
.map(|s| s.rate_section_width())
.max()
.unwrap_or(0);
let max_amount_w = stats
.iter()
.map(|s| s.amount.chars().count())
.max()
.unwrap_or(0);
let mut lines = Vec::new();
for (i, stat) in stats.iter().enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
lines.extend(stat.render(bar_width, pct_col, max_rate_w, max_amount_w));
}
lines
}
struct Stat {
label: String,
pct: f64,
color: Style,
trailing: String,
amount: String,
burn_rate: Option<f64>,
rate_unit: &'static str,
pace_pct: Option<f64>,
reset_secs: Option<i64>,
}
fn eta_left_secs(rate: f64, pct: f64, rate_unit: &str) -> Option<i64> {
if rate <= 0.0 || pct >= 100.0 {
return None;
}
let rate_per_h = if rate_unit == "d" { rate / 24.0 } else { rate };
let hours = (100.0 - pct) / rate_per_h;
let secs = (hours * 3600.0) as i64;
(secs > 0).then_some(secs)
}
fn eta_left(rate: f64, pct: f64, rate_unit: &str) -> Option<String> {
eta_left_secs(rate, pct, rate_unit).map(crate::usage::humanize_duration)
}
impl Stat {
fn rate_section_width(&self) -> usize {
let Some(rate) = self.burn_rate.filter(|r| *r > 0.0) else {
return 0;
};
let mut w =
" · ".chars().count() + format!("{:.1} %/{}", rate, self.rate_unit).chars().count();
if let Some(dur) = eta_left(rate, self.pct, self.rate_unit) {
w += " · ".chars().count() + dur.chars().count() + " left".chars().count();
}
w
}
fn render(
&self,
bar_width: usize,
pct_col: usize,
max_rate_w: usize,
max_amount_w: usize,
) -> Vec<Line<'static>> {
let pct_str = format!("{:.0}%", self.pct);
let amount_section_w = if max_amount_w > 0 {
max_amount_w + 2
} else {
0
};
let header_pad = pct_col
.saturating_sub(self.label.chars().count())
.saturating_sub(max_rate_w)
.saturating_sub(amount_section_w)
.saturating_sub(pct_str.chars().count());
let filled = (((self.pct / 100.0) * bar_width as f64).round() as usize).min(bar_width);
let marker = self.pace_pct.filter(|_| bar_width > 0).map(|p| {
(((p.clamp(0.0, 100.0) / 100.0) * bar_width as f64).round() as usize).min(bar_width - 1)
});
let mut bar_line = bar_spans(filled, bar_width, self.color, marker);
if !self.trailing.is_empty() {
let pad = pct_col
.saturating_sub(bar_width)
.saturating_sub(self.trailing.chars().count());
if pad > 0 {
bar_line.push(Span::raw(" ".repeat(pad)));
}
bar_line.push(Span::styled(self.trailing.clone(), theme::faint()));
}
let mut label_spans = vec![Span::styled(self.label.clone(), theme::label())];
if let Some(rate) = self.burn_rate
&& rate > 0.0
{
let rate_color = Style::default().fg(theme::util_color(rate.clamp(0.0, 100.0)));
label_spans.push(Span::styled(" · ", theme::dim()));
let rate_str = format!("{:.1} %/{}", rate, self.rate_unit);
label_spans.push(Span::styled(rate_str.clone(), rate_color));
if let Some(eta_secs) = eta_left_secs(rate, self.pct, self.rate_unit) {
let dur = crate::usage::humanize_duration(eta_secs);
let runs_dry_first = self.reset_secs.is_some_and(|r| eta_secs < r);
let style = if runs_dry_first {
theme::warning()
} else {
theme::faint()
};
label_spans.push(Span::styled(" · ", theme::faint()));
label_spans.push(Span::styled(format!("{dur} left"), style));
}
let my_rate_w = self.rate_section_width();
let extra = max_rate_w.saturating_sub(my_rate_w);
if extra > 0 {
label_spans.push(Span::raw(" ".repeat(extra)));
}
} else {
label_spans.push(Span::raw(" ".repeat(max_rate_w)));
}
vec![
Line::from({
let mut spans = label_spans;
spans.push(Span::raw(" ".repeat(header_pad)));
if max_amount_w > 0 {
let pad = max_amount_w.saturating_sub(self.amount.chars().count());
spans.push(Span::raw(" ".repeat(pad)));
if !self.amount.is_empty() {
spans.push(Span::styled(self.amount.clone(), theme::faint()));
}
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
pct_str,
self.color.add_modifier(Modifier::BOLD),
));
spans
}),
Line::from(bar_line),
]
}
}
fn window_rate_unit(label: &str) -> &'static str {
if label.starts_with("7d") {
return "d";
}
if label.ends_with('d') && label.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) {
return "d";
}
"h"
}
#[derive(Clone, Copy)]
struct WindowGates {
show_estimates: bool,
show_pace: bool,
}
fn make_window_stat(
label: &str,
pct: f64,
resets_at: Option<&str>,
now: i64,
amount: String,
trailing: String,
gates: WindowGates,
) -> Stat {
let pct = pct.clamp(0.0, 100.0);
let rate_unit = window_rate_unit(label);
let window = UsageWindow {
utilization: pct,
resets_at: resets_at.map(str::to_string),
};
let burn_rate = gates
.show_estimates
.then(|| {
crate::usage::window_avg_pace_per_day(label, &window, now, 3600).map(|per_day| {
if rate_unit == "d" {
per_day
} else {
per_day / 24.0
}
})
})
.flatten();
let pace_pct = gates
.show_pace
.then(|| ideal_pace_pct(label, &window, now))
.flatten();
let reset_secs = resets_at
.and_then(crate::usage::iso_to_epoch_secs)
.map(|r| r - now);
Stat {
label: label.to_string(),
pct,
color: Style::default().fg(theme::util_color(pct)),
trailing,
amount,
burn_rate,
rate_unit,
pace_pct,
reset_secs,
}
}
fn fmt_window_dollars(d: &WindowDollars) -> String {
match (d.used, d.limit) {
(Some(u), Some(l)) => format!("${u:.2} / ${l:.2}"),
(Some(u), None) => format!("${u:.2}"),
(None, Some(l)) => format!("/ ${l:.2}"),
(None, None) => String::new(),
}
}
fn collect_stats(profile: &Profile) -> Vec<Stat> {
let Some(usage) = profile.usage.as_ref() else {
return Vec::new();
};
let now_secs = now_epoch_secs();
let mut stats: Vec<Stat> = Vec::new();
for (label, w) in usage.windows() {
let trailing = format_reset(w)
.map(|r| format!(" resets in {r}"))
.unwrap_or_default();
let amount = usage
.window_dollars
.iter()
.find(|d| d.label == label)
.map(fmt_window_dollars)
.unwrap_or_default();
stats.push(make_window_stat(
label,
w.utilization,
w.resets_at.as_deref(),
now_secs,
amount,
trailing,
WindowGates {
show_estimates: true,
show_pace: true,
},
));
}
let spend_shown = usage.spend.as_ref().is_some_and(|s| s.is_visible());
if let Some(extra) = &usage.extra_usage
&& extra.is_enabled
&& !spend_shown
{
let pct = extra.utilization.unwrap_or(0.0).clamp(0.0, 100.0);
let sym = match extra.currency.as_deref() {
Some("USD") | None => "$",
Some(other) => other,
};
let used = extra.used_credits.unwrap_or(0.0) / 100.0;
let limit = extra.monthly_limit.unwrap_or(0.0) / 100.0;
stats.push(Stat {
label: "extra".to_string(),
pct,
color: Style::default().fg(theme::util_color(pct)),
trailing: format!("{sym}{used:.2} / {sym}{limit:.2}"),
amount: String::new(),
burn_rate: None,
rate_unit: "h",
pace_pct: None,
reset_secs: None,
});
}
if let Some(extra) = &usage.extra_usage {
for (label, raw) in [("extra (24h)", &extra.daily), ("extra (7d)", &extra.weekly)] {
let Some(period) = raw.as_ref().and_then(ExtraPeriod::from_value) else {
continue;
};
let pct = period.utilization.unwrap_or(0.0).clamp(0.0, 100.0);
let sym = match period.currency.as_deref().or(extra.currency.as_deref()) {
Some("USD") | None => "$",
Some(other) => other,
};
let cost = match (period.used_credits, period.monthly_limit) {
(Some(u), Some(l)) => format!("{sym}{u:.2} / {sym}{l:.2}"),
(Some(u), None) => format!("{sym}{u:.2}"),
_ => String::new(),
};
stats.push(Stat {
label: label.to_string(),
pct,
color: Style::default().fg(theme::util_color(pct)),
trailing: cost,
amount: String::new(),
burn_rate: None,
rate_unit: "h",
pace_pct: None,
reset_secs: None,
});
}
}
if let Some(spend) = &usage.spend
&& spend.is_visible()
{
let pct = spend.percent.unwrap_or(0.0).clamp(0.0, 100.0);
let sym = match spend.currency.as_deref() {
Some("USD") | None => "$",
Some(other) => other,
};
let used = spend.used.unwrap_or(0.0);
let cost = match spend.limit {
Some(limit) => format!("{sym}{used:.2} / {sym}{limit:.2}"),
None => format!("{sym}{used:.2}"),
};
stats.push(Stat {
label: "spend".to_string(),
pct,
color: Style::default().fg(theme::util_color(pct)),
trailing: cost,
amount: String::new(),
burn_rate: None,
rate_unit: "h",
pace_pct: None,
reset_secs: None,
});
}
stats
}
fn bar_width_for(inner_w: u16, max_trailing: usize) -> usize {
let avail = (inner_w as usize).saturating_sub(max_trailing);
if avail >= 10 {
avail
} else {
avail.max(1)
}
}
fn bar_spans(
filled: usize,
bar_width: usize,
fill: Style,
marker_col: Option<usize>,
) -> Vec<Span<'static>> {
let empty = bar_width - filled;
let Some(m) = marker_col.filter(|&m| m < bar_width) else {
return vec![
Span::styled("█".repeat(filled), fill),
Span::styled("░".repeat(empty), theme::line_strong()),
];
};
let run =
|glyph: &str, n: usize, style: Style| (n > 0).then(|| Span::styled(glyph.repeat(n), style));
let mut spans = Vec::with_capacity(4);
if m < filled {
spans.extend(run("█", m, fill));
spans.push(Span::styled("│".to_string(), theme::warning()));
spans.extend(run("█", filled - m - 1, fill));
spans.extend(run("░", empty, theme::line_strong()));
} else {
spans.extend(run("█", filled, fill));
spans.extend(run("░", m - filled, theme::line_strong()));
spans.push(Span::styled("│".to_string(), theme::dim()));
spans.extend(run("░", bar_width - m - 1, theme::line_strong()));
}
spans
}
fn header_lines(profile: &Profile, inner_w: u16, header: &HeaderState) -> Vec<Line<'static>> {
let plan = profile
.third_party_usage
.as_ref()
.and_then(|s| s.plan.clone())
.or_else(|| {
profile
.usage
.as_ref()
.and_then(|u| u.plan.as_ref())
.map(plan_label)
})
.unwrap_or_else(|| {
if profile.is_oauth() {
"oauth".to_string()
} else {
"api".to_string()
}
});
let mut plan_spans = vec![key_span("plan"), Span::styled(plan.clone(), theme::body())];
if header.is_active {
let left_w = KEY_W + plan.chars().count();
let indicator_w = "[ active ]".chars().count(); let pad = (inner_w as usize)
.saturating_sub(left_w)
.saturating_sub(indicator_w);
plan_spans.push(Span::raw(" ".repeat(pad)));
plan_spans.extend(active_pill());
}
let mut lines = vec![Line::from(plan_spans)];
lines.push(status_line(profile, header));
lines
}
fn status_line(profile: &Profile, header: &HeaderState) -> Line<'static> {
let key = key_span("status");
if !matches!(header.activity, ProfileActivity::Idle) {
let frame = spinner_frame(header.tick);
let verb = activity_verb(header.activity);
return Line::from(vec![
key,
Span::styled(format!("{frame} {verb}"), spinner_style(header.activity)),
]);
}
let countdown = header.next_refresh_ms.map(|next| {
let secs = ((next as i64 - now_ms() as i64) / 1000).max(0);
format!("{secs}s")
});
let mut spans = vec![key];
match profile.fetch_status {
Some(FetchStatus::Failed) => {
spans.extend([
Span::styled("[ ", theme::dim()),
Span::styled("failed", theme::danger().add_modifier(Modifier::BOLD)),
Span::styled(" ]", theme::dim()),
]);
if let Some(c) = countdown {
spans.push(Span::styled(format!(" · retry in {c}"), theme::faint()));
}
}
Some(FetchStatus::Cached) => {
spans.extend([
Span::styled("[ ", theme::dim()),
Span::styled("cached", theme::warning().add_modifier(Modifier::BOLD)),
Span::styled(" ]", theme::dim()),
]);
if let Some(c) = countdown {
spans.push(Span::styled(format!(" · refresh in {c}"), theme::faint()));
}
}
Some(FetchStatus::RateLimited) => {
spans.extend([
Span::styled("[ ", theme::dim()),
Span::styled(
"rate limited",
theme::warning().add_modifier(Modifier::BOLD),
),
Span::styled(" ]", theme::dim()),
]);
if let Some(c) = countdown {
spans.push(Span::styled(format!(" · retry in {c}"), theme::faint()));
}
}
_ => match countdown {
Some(c) => spans.push(Span::styled(format!("↻ refresh in {c}"), theme::faint())),
None => spans.push(Span::styled("↻ up to date", theme::faint())),
},
}
Line::from(spans)
}
fn oauth_empty_msg(profile: &Profile) -> &'static str {
let has_oauth = profile
.credentials
.as_ref()
.is_some_and(|c| c.claude_ai_oauth.is_some());
if !has_oauth {
"no credentials — capture or sign in"
} else if profile.fetch_status == Some(FetchStatus::Failed) {
"no usage available"
} else {
"loading"
}
}
fn build_tp_rows(
profile: &Profile,
inner_w: u16,
show_estimates: bool,
show_pace: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let Some(stats) = profile.third_party_usage.as_ref() else {
let msg = match profile.fetch_status {
Some(FetchStatus::Failed) => "no usage available",
Some(FetchStatus::RateLimited) => "rate limited — retrying",
_ => "loading",
};
lines.push(Line::from(Span::styled(msg, theme::faint())));
return lines;
};
let has_bars = !stats.bars.is_empty();
if has_bars {
let bar_stats = stats_from_bars(&stats.bars, show_estimates, show_pace);
lines.extend(render_stat_block(&bar_stats, inner_w));
}
if !stats.rows.is_empty() {
if has_bars {
lines.push(Line::from(""));
}
for row in &stats.rows {
if row.label.is_empty() {
let style = match row.kind {
StatRowKind::Danger => theme::danger(),
_ => theme::faint(),
};
lines.push(Line::from(Span::styled(row.value.to_string(), style)));
} else if row.kind == StatRowKind::Heading {
lines.push(Line::from(Span::styled(
row.label.to_string(),
theme::label(),
)));
} else {
let style = match row.kind {
StatRowKind::Danger => theme::danger(),
StatRowKind::Faint => theme::faint(),
_ => theme::body(),
};
lines.push(Line::from(key_value_span(&row.label, &row.value, style)));
}
}
} else if !has_bars {
let (msg, style) = if stats.is_available {
("no stats reported", theme::faint())
} else {
("usage unavailable", theme::danger())
};
lines.push(Line::from(Span::styled(msg, style)));
}
if stats.best_effort && (has_bars || !stats.rows.is_empty()) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"looks wrong? report at github.com/uwuclxdy/clauth/issues",
theme::faint(),
)));
}
lines
}
fn stats_from_bars(
bars: &[crate::providers::UsageBar],
show_estimates: bool,
show_pace: bool,
) -> Vec<Stat> {
let now = now_epoch_secs();
let gates = WindowGates {
show_estimates,
show_pace,
};
bars.iter()
.map(|bar| {
let rem = window_remaining(bar, now);
make_window_stat(
&bar.label,
bar.pct,
bar.resets_at.as_deref(),
now,
bar_amount(bar),
bar_reset_trailing(rem),
gates,
)
})
.collect()
}
fn window_remaining(bar: &crate::providers::UsageBar, now: i64) -> Option<i64> {
let reset = crate::usage::iso_to_epoch_secs(bar.resets_at.as_deref()?)?;
Some(reset - now)
}
fn bar_reset_trailing(rem: Option<i64>) -> String {
match rem.filter(|&s| s > 0) {
Some(secs) => format!(" resets in {}", crate::usage::humanize_duration(secs)),
None => String::new(),
}
}
fn bar_amount(bar: &crate::providers::UsageBar) -> String {
match (bar.used, bar.total) {
(Some(used), Some(total)) => format!("{} / {}", fmt_amount(used), fmt_amount(total)),
_ => String::new(),
}
}
fn fmt_amount(n: f64) -> String {
if n.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
}
}
const TP_KEY_W: usize = 10;
fn key_value_span(key: &str, value: &str, value_style: Style) -> Vec<Span<'static>> {
let mut spans = vec![Span::styled(format!(" {key}"), theme::faint())];
let pad = TP_KEY_W.saturating_sub(key.chars().count()).max(1);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(value.to_string(), value_style));
spans
}
fn key_span(key: &str) -> Span<'static> {
let pad = KEY_W.saturating_sub(key.chars().count()).max(1);
Span::styled(format!("{key}{}", " ".repeat(pad)), theme::label())
}
#[cfg(test)]
#[path = "../../../tests/inline/tui_render_usage.rs"]
mod tests;