use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::super::app::{App, TokenFilter, TokenPeriod, TokenView, token_period_models};
use super::super::theme;
use super::format::{fixed, spinner_frame};
use super::panes::{
draw_selector_list, picker_row, section_box, section_box_loading, section_box_verbatim,
selector_width,
};
use crate::pricing::PriceTable;
use crate::tokens::{
ModelTokens, PeriodModel, TokenStats, bucket_activity, bucket_tokens, current_bucket_bounds,
effective_cache_basis, is_anthropic, model_display_name, today_date,
};
const KEY_W: usize = 10;
const DASH_MAX_W: u16 = 120;
const WIDE_KEY_W: usize = 12;
const SPARK: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
const HOUR_BOX_W: u16 = 28;
pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
match app.token_view {
TokenView::Dashboard => draw_dashboard(frame, area, app),
TokenView::Models => draw_models(frame, area, app),
}
}
fn fmt_count(n: u64) -> String {
let f = n as f64;
let (v, suffix) = if f >= 1e12 {
(f / 1e12, "T")
} else if f >= 1e9 {
(f / 1e9, "B")
} else if f >= 1e6 {
(f / 1e6, "M")
} else if f >= 1e3 {
(f / 1e3, "K")
} else {
return n.to_string();
};
if v >= 100.0 {
format!("{v:.0}{suffix}")
} else if v >= 10.0 {
format!("{v:.1}{suffix}")
} else {
format!("{v:.2}{suffix}")
}
}
fn group_thousands(n: f64, decimals: usize) -> String {
let s = format!("{n:.decimals$}");
let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(i, f)| (i, f));
let digits = int.len();
let mut out = String::with_capacity(digits + digits / 3 + 1 + frac.len());
for (i, ch) in int.chars().enumerate() {
if i > 0 && (digits - i) % 3 == 0 {
out.push(',');
}
out.push(ch);
}
if !frac.is_empty() {
out.push('.');
out.push_str(frac);
}
out
}
fn fmt_money(usd: f64) -> String {
if usd <= 0.0 {
return "$0".to_string();
}
if usd >= 1.0 {
format!("${}", group_thousands(usd, 2))
} else {
let s = format!("${usd:.3}");
if s == "$0.000" {
"<$0.001".to_string()
} else {
s
}
}
}
fn money_style() -> Style {
Style::default().fg(theme::accent_2_color())
}
fn cost_line(
label: &str,
prices: Option<&PriceTable>,
models: &[ModelTokens],
floor: bool,
) -> Line<'static> {
let value = match prices {
Some(p) => {
let (total, unpriced) = p.total_cost(models);
let mut s = fmt_money(total);
if unpriced > 0 || floor {
s.push('+');
}
s
}
None => "—".to_string(),
};
Line::from(vec![key(label), Span::styled(value, money_style())])
}
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
fn short_date(ymd: &str) -> String {
if ymd.len() < 10 {
return ymd.to_string();
}
let month: usize = ymd[5..7].parse().unwrap_or(1);
let day: u32 = ymd[8..10].parse().unwrap_or(0);
let mon = MONTHS.get(month.saturating_sub(1)).copied().unwrap_or("?");
format!("{mon} {day}")
}
fn month_label(ymd: &str) -> String {
if ymd.len() < 7 {
return ymd.to_string();
}
let month: usize = ymd[5..7].parse().unwrap_or(1);
MONTHS
.get(month.saturating_sub(1))
.copied()
.unwrap_or("?")
.to_string()
}
const INDET_BLOCK: usize = 4;
fn bar_chart(
vals: &[u64],
width: usize,
height: usize,
fill: Style,
cell_w: usize,
gap: usize,
) -> Vec<Line<'static>> {
bar_chart_scaled(vals, width, height, fill, false, cell_w, gap)
}
fn bar_chart_sqrt(
vals: &[u64],
width: usize,
height: usize,
fill: Style,
cell_w: usize,
gap: usize,
) -> Vec<Line<'static>> {
bar_chart_scaled(vals, width, height, fill, true, cell_w, gap)
}
fn bar_chart_scaled(
vals: &[u64],
width: usize,
height: usize,
fill: Style,
sqrt: bool,
cell_w: usize,
gap: usize,
) -> Vec<Line<'static>> {
if height == 0 || vals.is_empty() {
return Vec::new();
}
let max = vals.iter().copied().max().unwrap_or(0);
let row_cap = (height * 8) as f64;
let eighths: Vec<usize> = if max == 0 {
vec![1; vals.len()]
} else {
vals.iter()
.map(|&v| {
let ratio = v as f64 / max as f64;
let scaled = if sqrt { ratio.sqrt() } else { ratio };
let e = (scaled * row_cap).round() as usize;
if v > 0 { e.max(1) } else { e }
})
.collect()
};
let chart_w = vals.len() * cell_w + vals.len().saturating_sub(1) * gap;
let pad = width.saturating_sub(chart_w) / 2;
(0..height)
.map(|row| {
let from_bottom = height - row; let mut s = String::with_capacity(chart_w * 3);
for (i, &e) in eighths.iter().enumerate() {
if i > 0 {
s.extend(std::iter::repeat_n(' ', gap));
}
let full = e / 8;
let rem = e % 8;
let ch = if from_bottom <= full {
'█'
} else if from_bottom == full + 1 && rem > 0 {
SPARK[rem - 1]
} else {
' '
};
s.extend(std::iter::repeat_n(ch, cell_w));
}
Line::from(vec![Span::raw(" ".repeat(pad)), Span::styled(s, fill)])
})
.collect()
}
fn bucket_layout(n: usize, width: usize) -> (usize, usize) {
let cell = (width + 1)
.checked_div(n)
.map_or(0, |c| c.saturating_sub(1))
.min(8);
if cell >= 2 { (cell, 1) } else { (1, 0) }
}
fn month_ticks(dates: &[&str], width: usize, cell_w: usize, gap: usize) -> Line<'static> {
let chart_w = dates.len() * cell_w + dates.len().saturating_sub(1) * gap;
let pad = width.saturating_sub(chart_w) / 2;
let mut out = String::new();
let mut prev_month = "";
for (i, date) in dates.iter().enumerate() {
if date.len() < 7 || date[5..7] == *prev_month {
continue;
}
prev_month = &date[5..7];
let col = i * (cell_w + gap);
if !out.is_empty() && col < out.chars().count() + 1 {
continue;
}
out.push_str(&" ".repeat(col - out.chars().count()));
out.push_str(&month_label(date));
}
Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled(out, theme::faint()),
])
}
fn indeterminate_bar(tick: u64, track: usize, label: &str) -> Line<'static> {
let max = track.saturating_sub(INDET_BLOCK);
let pos = if max == 0 {
0
} else {
let period = 2 * max;
let phase = (tick as usize) % period;
if phase <= max { phase } else { period - phase }
};
let after = track.saturating_sub(pos + INDET_BLOCK);
Line::from(vec![
Span::styled("[", theme::line()),
Span::styled("░".repeat(pos), theme::line()),
Span::styled("█".repeat(INDET_BLOCK.min(track)), theme::accent()),
Span::styled("░".repeat(after), theme::line()),
Span::styled("]", theme::line()),
Span::styled(format!(" {label}"), theme::dim()),
])
}
fn determinate_bar(done: usize, total: usize, track: usize, label: &str) -> Line<'static> {
let filled = (done * track).checked_div(total).unwrap_or(0).min(track);
Line::from(vec![
Span::styled("█".repeat(filled), theme::accent()),
Span::styled("░".repeat(track.saturating_sub(filled)), theme::line()),
Span::styled(format!(" {label}"), theme::dim()),
])
}
fn hbar(value: u64, max: u64, width: usize, fill: Style) -> Vec<Span<'static>> {
let filled = if max == 0 {
0
} else {
(((value as f64 / max as f64) * width as f64).round() as usize).min(width)
};
vec![
Span::styled("█".repeat(filled), fill),
Span::styled(
"░".repeat(width.saturating_sub(filled)),
theme::line_strong(),
),
]
}
fn key(label: &str) -> Span<'static> {
Span::styled(format!("{label:<KEY_W$}"), theme::label())
}
fn inner_w(area: Rect) -> usize {
(area.width as usize).saturating_sub(4)
}
fn inner_h(area: Rect) -> usize {
(area.height as usize).saturating_sub(2)
}
fn trail<T>(items: &[T], width: usize) -> &[T] {
let n = items.len().min(width.max(1));
&items[items.len().saturating_sub(n)..]
}
fn span_w(spans: &[Span<'static>]) -> usize {
spans.iter().map(|s| s.content.chars().count()).sum()
}
fn lr(left: Vec<Span<'static>>, right: Vec<Span<'static>>, width: usize) -> Line<'static> {
let gap = width.saturating_sub(span_w(&left) + span_w(&right)).max(1);
let mut spans = left;
spans.push(Span::raw(" ".repeat(gap)));
spans.extend(right);
Line::from(spans)
}
fn center(spans: Vec<Span<'static>>, width: usize) -> Line<'static> {
let pad = width.saturating_sub(span_w(&spans)) / 2;
let mut out = vec![Span::raw(" ".repeat(pad))];
out.extend(spans);
Line::from(out)
}
fn busiest_hour(hours: &[u64; 24]) -> Option<usize> {
let (hour, &count) = hours.iter().enumerate().max_by_key(|&(_, c)| *c)?;
(count > 0).then_some(hour)
}
fn clamp_width(area: Rect, max_w: u16) -> Rect {
if area.width <= max_w {
return area;
}
Rect {
x: area.x + (area.width - max_w) / 2,
width: max_w,
..area
}
}
struct DashRects {
first: Rect,
total: Rect,
trend: Rect,
models: Rect,
comp: Rect,
hour: Rect,
activity: Rect,
}
const TWO_COL_MIN_W: u16 = 140;
const TWO_COL_MIN_H: u16 = 30;
const CARD_COL_W: u16 = 56;
fn dash_rects(area: Rect) -> DashRects {
if area.width >= TWO_COL_MIN_W && area.height >= TWO_COL_MIN_H {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(CARD_COL_W), Constraint::Min(0)])
.split(area);
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), Constraint::Length(6), Constraint::Length(7), Constraint::Length(6), Constraint::Min(5), ])
.split(cols[0]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(cols[1]);
return DashRects {
first: left[0],
total: left[1],
models: left[2],
comp: left[3],
hour: left[4],
trend: right[0],
activity: right[1],
};
}
let area = clamp_width(area, DASH_MAX_W);
let trend_h = area.height.saturating_sub(6 + 7 + 4).clamp(4, 10);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), Constraint::Length(trend_h), Constraint::Length(7), Constraint::Min(4), ])
.split(area);
let top = halves(rows[0], 42);
let mid = halves(rows[2], 55);
let bot = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(HOUR_BOX_W), Constraint::Min(0)])
.split(rows[3]);
DashRects {
first: top[0],
total: top[1],
trend: rows[1],
models: mid[0],
comp: mid[1],
hour: bot[0],
activity: bot[1],
}
}
fn draw_dashboard(frame: &mut Frame<'_>, area: Rect, app: &App) {
let Some(stats) = app.token_stats.as_ref() else {
let area = clamp_width(area, DASH_MAX_W);
let block = section_box("tokens", false, true);
let inner = block.inner(area);
frame.render_widget(block, area);
let line = if app.tokens_failed {
Line::from(Span::styled(
"~/.claude/stats-cache.json unreadable",
theme::danger(),
))
} else {
const LABEL: &str = "parsing stats-cache.json…";
let track = inner_w(area)
.saturating_sub(2 + 2 + LABEL.chars().count())
.clamp(INDET_BLOCK, 40);
indeterminate_bar(app.tick_count, track, LABEL)
};
frame.render_widget(Paragraph::new(line).style(theme::base()), inner);
return;
};
let count_cache = app.config().state.count_cache;
let prices = app.price_table.as_ref();
let period = app.token_period;
let card_spin = app.tokens_topping_up.then(|| spinner_frame(app.tick_count));
let r = dash_rects(area);
if let Some(bucket) = period.bucket() {
let (from, to) = current_bucket_bounds(&today_date(), bucket);
let meta = format!("{} →", short_date(&from));
card(
frame,
r.first,
period.badge().unwrap_or("period"),
Some(&meta),
true,
card_spin,
period_lines(stats, inner_w(r.first), count_cache, prices, &from, &to),
);
} else {
let today_meta = stats.today.as_ref().map(|t| short_date(&t.date));
card(
frame,
r.first,
"today",
today_meta.as_deref(),
true,
card_spin,
today_lines(stats, inner_w(r.first), count_cache, prices),
);
}
let (total_body, total_meta) = total_lines(stats, inner_w(r.total), count_cache, prices);
card(
frame,
r.total,
"total",
total_meta.as_deref(),
false,
None,
total_body,
);
let trend_meta = if let Some(d) = stats.topped_up_through.as_deref() {
Some(format!("live thru {}", short_date(d)))
} else if app.tokens_topping_up {
let count = app
.tokens_progress
.map(|(d, t)| format!(" {d}/{t}"))
.unwrap_or_default();
Some(format!("{} scanning{count}", spinner_frame(app.tick_count)))
} else {
None
};
let trend_title = match period {
TokenPeriod::Weekly => "by week",
TokenPeriod::Monthly => "by month",
TokenPeriod::Lifetime | TokenPeriod::Daily => "daily",
};
let trend_body = if stats.daily.is_empty() && app.tokens_topping_up {
let label = match app.tokens_progress {
Some((d, t)) => format!("scanning session logs {d}/{t}"),
None => "scanning session logs".to_string(),
};
let track = inner_w(r.trend)
.saturating_sub(2 + 2 + label.chars().count())
.clamp(INDET_BLOCK, 40);
vec![match app.tokens_progress {
Some((d, t)) => determinate_bar(d, t, track, &label),
None => indeterminate_bar(app.tick_count, track, &label),
}]
} else {
trend_lines(stats, inner_w(r.trend), inner_h(r.trend), period)
};
card(
frame,
r.trend,
trend_title,
trend_meta.as_deref(),
false,
None,
trend_body,
);
let model_rows = token_period_models(app);
let models_meta = join_badges(app.token_filter.badge(), Some(period.lens_badge()));
card(
frame,
r.models,
"top models",
models_meta.as_deref(),
false,
None,
model_lines(
&model_rows,
inner_w(r.models),
5,
effective_cache_basis(&model_rows, count_cache),
prices,
empty_models_msg(app.token_filter),
),
);
let (comp_meta, comp) = match period {
TokenPeriod::Daily => (Some("today"), today_comp_lines(stats, inner_w(r.comp))),
TokenPeriod::Weekly | TokenPeriod::Monthly => {
(Some("lifetime"), comp_lines(stats, inner_w(r.comp)))
}
TokenPeriod::Lifetime => (Some("lifetime"), comp_lines(stats, inner_w(r.comp))),
};
card(frame, r.comp, "composition", comp_meta, false, None, comp);
let (hour_meta, hours) = match period {
TokenPeriod::Daily => (
Some("today"),
stats.today.as_ref().map(|t| t.hours).unwrap_or([0; 24]),
),
TokenPeriod::Weekly | TokenPeriod::Monthly | TokenPeriod::Lifetime => {
(Some("lifetime"), stats.hour_counts)
}
};
card(
frame,
r.hour,
"hour of day",
hour_meta,
false,
None,
hour_lines(&hours, inner_w(r.hour), inner_h(r.hour)),
);
let act_meta = match period {
TokenPeriod::Weekly => Some("by week"),
TokenPeriod::Monthly => Some("by month"),
TokenPeriod::Lifetime | TokenPeriod::Daily => Some("by day"),
};
card(
frame,
r.activity,
"activity",
act_meta,
false,
None,
activity_lines(stats, inner_w(r.activity), inner_h(r.activity), period),
);
}
fn join_badges(filter: Option<&'static str>, period: Option<&'static str>) -> Option<String> {
match (filter, period) {
(Some(f), Some(p)) => Some(format!("{f} {p}")),
(Some(f), None) => Some(f.to_string()),
(None, Some(p)) => Some(p.to_string()),
(None, None) => None,
}
}
fn empty_models_msg(filter: TokenFilter) -> &'static str {
if filter == TokenFilter::All {
"no model usage yet"
} else {
"no models match the filter"
}
}
fn halves(area: Rect, left_pct: u16) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(left_pct),
Constraint::Percentage(100 - left_pct),
])
.split(area)
}
fn card(
frame: &mut Frame<'_>,
area: Rect,
title: &str,
meta: Option<&str>,
first: bool,
spinner: Option<&str>,
lines: Vec<Line<'static>>,
) {
let mut block = match spinner {
Some(f) => section_box_loading(title, false, first, f),
None => section_box(title, false, first),
};
if let Some(m) = meta {
block = block.title(
Line::from(Span::styled(format!(" {m} "), theme::dim())).alignment(Alignment::Right),
);
}
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines).style(theme::base()), inner);
}
fn today_lines(
stats: &TokenStats,
w: usize,
count_cache: bool,
prices: Option<&PriceTable>,
) -> Vec<Line<'static>> {
let Some(t) = stats.today.as_ref() else {
return vec![Line::from(Span::styled(
"idle so far today",
theme::faint(),
))];
};
let tokens = if count_cache { t.total() } else { t.in_out() };
vec![
kv_accent("tokens", fmt_count(tokens)),
cost_line("cost", prices, &t.models, false),
Line::from(vec![
key("msgs"),
Span::styled(group_thousands(t.messages as f64, 0), theme::body()),
]),
lr(
vec![Span::styled(
format!("{} in", fmt_count(t.input)),
theme::dim(),
)],
vec![Span::styled(
format!("{} out", fmt_count(t.output)),
theme::dim(),
)],
w,
),
]
}
fn total_lines(
stats: &TokenStats,
w: usize,
count_cache: bool,
prices: Option<&PriceTable>,
) -> (Vec<Line<'static>>, Option<String>) {
let last = stats
.topped_up_through
.as_deref()
.or(stats.daily.last().map(|d| d.date.as_str()))
.or(stats.last_computed_date.as_deref());
let total = if count_cache {
stats.total_tokens()
} else {
stats.total_in_out()
};
let lines = vec![
lr(
vec![
key("tokens"),
Span::styled(
fmt_count(total),
theme::accent().add_modifier(Modifier::BOLD),
),
],
vec![Span::styled(
format!("{:.0}% cache hit", stats.cache_hit_ratio() * 100.0),
Style::default().fg(theme::info_color()),
)],
w,
),
cost_line("cost", prices, &stats.models, false),
Line::from(vec![
key("sessions"),
Span::styled(
group_thousands(stats.total_sessions as f64, 0),
theme::body(),
),
]),
Line::from(vec![
key("msgs"),
Span::styled(fmt_count(stats.total_messages), theme::body()),
]),
];
let meta = match (stats.first_session_date.as_deref(), last) {
(Some(first), Some(latest)) => {
Some(format!("{} → {}", short_date(first), short_date(latest)))
}
_ => None,
};
(lines, meta)
}
fn period_lines(
stats: &TokenStats,
w: usize,
count_cache: bool,
prices: Option<&PriceTable>,
from: &str,
to: &str,
) -> Vec<Line<'static>> {
let in_range = |date: &str| date >= from && date <= to;
let rows = crate::tokens::period_models(&stats.daily_models, from, to);
let complete = rows.iter().all(|m| m.split_complete);
let splits: Vec<ModelTokens> = rows.iter().map(|m| m.split.clone()).collect();
let in_out: u64 = stats
.daily
.iter()
.filter(|d| in_range(&d.date))
.map(|d| d.tokens)
.sum();
if in_out == 0 && rows.is_empty() {
return vec![Line::from(Span::styled("idle so far", theme::faint()))];
}
let tokens = if count_cache && complete {
splits.iter().map(ModelTokens::total).sum()
} else {
in_out
};
let msgs: u64 = stats
.activity
.iter()
.filter(|a| in_range(&a.date))
.map(|a| a.messages)
.sum();
vec![
kv_accent("tokens", fmt_count(tokens)),
cost_line("cost", prices, &splits, !complete),
Line::from(vec![
key("msgs"),
Span::styled(group_thousands(msgs as f64, 0), theme::body()),
]),
lr(
vec![Span::styled(short_date(from), theme::dim())],
vec![Span::styled(short_date(to), theme::dim())],
w,
),
]
}
fn trend_lines(stats: &TokenStats, w: usize, h: usize, period: TokenPeriod) -> Vec<Line<'static>> {
let series = match period.bucket() {
Some(b) => bucket_tokens(&stats.daily, b),
None => stats.daily.clone(),
};
let tail = trail(&series, w);
let vals: Vec<u64> = tail.iter().map(|d| d.tokens).collect();
if vals.is_empty() {
return vec![Line::from(Span::styled("no daily data", theme::faint()))];
}
let dates: Vec<&str> = tail.iter().map(|d| d.date.as_str()).collect();
let (peak_v, peak_d) = series
.iter()
.max_by_key(|d| d.tokens)
.map(|d| (d.tokens, d.date.clone()))
.unwrap_or((0, String::new()));
let peak = match period {
TokenPeriod::Weekly => {
format!("peak {} wk of {}", fmt_count(peak_v), short_date(&peak_d))
}
TokenPeriod::Monthly => format!("peak {} {}", fmt_count(peak_v), month_label(&peak_d)),
TokenPeriod::Lifetime | TokenPeriod::Daily => {
format!("peak {} {}", fmt_count(peak_v), short_date(&peak_d))
}
};
let (cell, gap) = bucket_layout(vals.len(), w);
let ticks = h >= 4;
let mut lines = bar_chart_sqrt(
&vals,
w,
h.saturating_sub(1 + usize::from(ticks)),
theme::accent(),
cell,
gap,
);
if ticks {
lines.push(month_ticks(&dates, w, cell, gap));
}
lines.push(center(vec![Span::styled(peak, theme::faint())], w));
lines
}
fn model_lines(
rows: &[PeriodModel],
w: usize,
n: usize,
basis: bool,
prices: Option<&PriceTable>,
empty_msg: &'static str,
) -> Vec<Line<'static>> {
if rows.is_empty() {
return vec![Line::from(Span::styled(empty_msg, theme::faint()))];
}
let max = rows.first().map(|m| m.metric(basis)).unwrap_or(0);
let names: Vec<String> = rows
.iter()
.take(n)
.map(|m| model_display_name(&m.model))
.collect();
let counts: Vec<String> = rows
.iter()
.take(n)
.map(|m| fmt_count(m.metric(basis)))
.collect();
let count_w = counts.iter().map(|s| s.chars().count()).max().unwrap_or(3);
let costs: Vec<String> = rows
.iter()
.take(n)
.map(|m| match prices {
None => String::new(),
Some(p) => p
.cost(&m.split)
.map(|c| {
let mut s = fmt_money(c);
if !m.split_complete {
s.push('+');
}
s
})
.unwrap_or_else(|| "—".to_string()),
})
.collect();
let cost_w = costs.iter().map(|s| s.chars().count()).max().unwrap_or(0);
let cost_col = if cost_w > 0 { cost_w + 1 } else { 0 };
let show_cost = cost_col > 0 && w >= 6 + 2 + 8 + count_w + cost_col;
let cost_col = if show_cost { cost_col } else { 0 };
let longest = names.iter().map(|s| s.chars().count()).max().unwrap_or(6);
let max_label = w.saturating_sub(count_w + 2 + 8 + cost_col);
let label_w = longest.clamp(6, max_label.max(6));
let bar_w = w
.saturating_sub(label_w)
.saturating_sub(count_w + 2 + cost_col)
.clamp(4, 30);
rows.iter()
.take(n)
.zip(names.iter())
.zip(counts.iter())
.zip(costs.iter())
.map(|(((m, name), count), cost)| {
let fill = if is_anthropic(&m.model) {
theme::accent()
} else {
theme::dim()
};
let val = m.metric(basis);
let mut spans = vec![
Span::styled(fixed(name, label_w), theme::body()),
Span::raw(" "),
];
spans.extend(hbar(val, max, bar_w, fill));
spans.push(Span::styled(format!(" {count:>count_w$}"), theme::dim()));
if show_cost {
let style = if cost == "—" {
theme::faint()
} else {
money_style()
};
let gap = w
.saturating_sub(label_w + 1 + bar_w + 1 + count_w + cost_w)
.max(1);
spans.push(Span::raw(" ".repeat(gap)));
spans.push(Span::styled(format!("{cost:>cost_w$}"), style));
}
Line::from(spans)
})
.collect()
}
fn comp_lines(stats: &TokenStats, w: usize) -> Vec<Line<'static>> {
comp_rows(
stats.total_input,
stats.total_output,
stats.total_cache_create,
stats.total_cache_read,
w,
)
}
fn today_comp_lines(stats: &TokenStats, w: usize) -> Vec<Line<'static>> {
match stats.today.as_ref() {
Some(t) => comp_rows(t.input, t.output, t.cache_create, t.cache_read, w),
None => vec![Line::from(Span::styled(
"idle so far today",
theme::faint(),
))],
}
}
fn comp_rows(
input: u64,
output: u64,
cache_write: u64,
cache_read: u64,
w: usize,
) -> Vec<Line<'static>> {
let grand = input
.saturating_add(output)
.saturating_add(cache_write)
.saturating_add(cache_read);
let bar_w = w.saturating_sub(WIDE_KEY_W).saturating_sub(6).clamp(4, 28);
[
("input", input, theme::accent()),
("output", output, theme::success()),
("cache write", cache_write, theme::warning()),
("cache read", cache_read, theme::info()),
]
.into_iter()
.map(|(label, value, fill)| {
let pct = if grand == 0 {
0.0
} else {
value as f64 / grand as f64 * 100.0
};
let mut left = vec![Span::styled(
format!("{label:<WIDE_KEY_W$}"),
theme::label(),
)];
left.extend(hbar(value, grand, bar_w, fill));
lr(
left,
vec![Span::styled(format!("{pct:>3.0}%"), theme::dim())],
w,
)
})
.collect()
}
fn hour_lines(hours: &[u64; 24], w: usize, h: usize) -> Vec<Line<'static>> {
let peak = busiest_hour(hours)
.map(|h| format!("peak {h:02}:00"))
.unwrap_or_default();
let cell = (w / 24).clamp(1, 3);
let ticks = h >= 4;
let chart_h = h.saturating_sub(1 + usize::from(ticks));
let mut lines = bar_chart(hours, w, chart_h, theme::accent(), cell, 0);
if ticks {
let pad = w.saturating_sub(24 * cell) / 2;
let mut row = String::new();
for hour in [0usize, 6, 12, 18] {
row.push_str(&" ".repeat(hour * cell - row.chars().count()));
row.push_str(&hour.to_string());
}
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled(row, theme::faint()),
]));
}
lines.push(center(vec![Span::styled(peak, theme::faint())], w));
lines
}
fn activity_lines(
stats: &TokenStats,
w: usize,
h: usize,
period: TokenPeriod,
) -> Vec<Line<'static>> {
let series = match period.bucket() {
Some(b) => bucket_activity(&stats.activity, b),
None => stats.activity.clone(),
};
let tail = trail(&series, w);
let msgs: Vec<u64> = tail.iter().map(|a| a.messages).collect();
if msgs.is_empty() {
return vec![Line::from(Span::styled("no activity data", theme::faint()))];
}
let dates: Vec<&str> = tail.iter().map(|a| a.date.as_str()).collect();
let gran = match period {
TokenPeriod::Weekly => "wk",
TokenPeriod::Monthly => "mo",
TokenPeriod::Lifetime | TokenPeriod::Daily => "day",
};
let caption = series
.iter()
.max_by_key(|a| a.messages)
.map(|a| {
format!(
"peak {gran}: {} msgs {} sess {} tools",
fmt_count(a.messages),
a.sessions,
fmt_count(a.tool_calls),
)
})
.unwrap_or_default();
let (cell, gap) = bucket_layout(msgs.len(), w);
let ticks = h >= 4;
let mut lines = bar_chart_sqrt(
&msgs,
w,
h.saturating_sub(1 + usize::from(ticks)),
theme::accent(),
cell,
gap,
);
if ticks {
lines.push(month_ticks(&dates, w, cell, gap));
}
lines.push(center(vec![Span::styled(caption, theme::faint())], w));
lines
}
fn kv_accent(label: &str, value: String) -> Line<'static> {
Line::from(vec![
key(label),
Span::styled(value, theme::accent().add_modifier(Modifier::BOLD)),
])
}
fn draw_models(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);
let grouped = token_period_models(app);
let sel = app.token_model_cursor.min(grouped.len().saturating_sub(1));
let title = match join_badges(
app.token_filter.badge(),
Some(app.token_period.lens_badge()),
) {
Some(badge) => format!("models {badge}"),
None => "models".to_string(),
};
if grouped.is_empty() {
let block = section_box(&title, true, true);
let inner = block.inner(cols[0]);
frame.render_widget(block, cols[0]);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
empty_models_msg(app.token_filter),
theme::faint(),
)))
.style(theme::base()),
inner,
);
draw_model_detail(
frame,
cols[1],
None,
0,
app.price_table.as_ref(),
app.token_period,
);
return;
}
draw_selector_list(frame, cols[0], &title, true, sel, |w| {
grouped
.iter()
.enumerate()
.map(|(i, m)| {
let style = if is_anthropic(&m.model) {
Style::default().fg(theme::text_color())
} else {
theme::dim()
};
picker_row(i == sel, true, model_display_name(&m.model), style, w)
})
.collect()
});
draw_model_detail(
frame,
cols[1],
grouped.get(sel),
period_grand(app),
app.price_table.as_ref(),
app.token_period,
);
}
fn period_grand(app: &App) -> u64 {
let Some(stats) = app.token_stats.as_ref() else {
return 0;
};
if let Some(bucket) = app.token_period.bucket() {
let (from, to) = current_bucket_bounds(&today_date(), bucket);
stats
.daily
.iter()
.filter(|d| d.date >= from && d.date <= to)
.map(|d| d.tokens)
.sum()
} else if app.token_period == TokenPeriod::Daily {
stats.today.as_ref().map(|t| t.total()).unwrap_or(0)
} else {
stats.total_tokens()
}
}
fn draw_model_detail(
frame: &mut Frame<'_>,
area: Rect,
model: Option<&PeriodModel>,
grand: u64,
prices: Option<&PriceTable>,
period: TokenPeriod,
) {
let title = model
.map(|m| model_display_name(&m.model))
.unwrap_or_else(|| "model".to_string());
let block = section_box_verbatim(&title, false, false);
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(m) = model else {
frame.render_widget(
Paragraph::new(Line::from(Span::styled("no model data", theme::faint())))
.style(theme::base()),
inner,
);
return;
};
let kv = |label: &str, value: String| {
Line::from(vec![
Span::styled(format!("{label:<WIDE_KEY_W$}"), theme::label()),
Span::styled(value, theme::body()),
])
};
let s = &m.split;
let mut lines = if m.split_complete {
vec![
kv("input", fmt_count(s.input)),
kv("output", fmt_count(s.output)),
kv("cache read", fmt_count(s.cache_read)),
kv("cache write", fmt_count(s.cache_create)),
Line::from(""),
Line::from(vec![
Span::styled(format!("{:<WIDE_KEY_W$}", "total"), theme::label()),
Span::styled(
fmt_count(s.total()),
theme::accent().add_modifier(Modifier::BOLD),
),
]),
kv("in+out", fmt_count(s.in_out())),
]
} else {
vec![
Line::from(vec![
Span::styled(format!("{:<WIDE_KEY_W$}", "tokens"), theme::label()),
Span::styled(
fmt_count(m.in_out),
theme::accent().add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::styled(
"in+out only (older days carry no split)",
theme::faint(),
)),
]
};
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"COST · API-EQUIVALENT",
theme::label(),
)));
let cost_kv = |label: &str, value: String| {
Line::from(vec![key(label), Span::styled(value, money_style())])
};
match prices {
None => lines.push(Line::from(Span::styled("rates loading", theme::faint()))),
Some(p) => match p.rate(&m.model) {
None => lines.push(Line::from(Span::styled(
"no rate for this model",
theme::faint(),
))),
Some(r) if m.split_complete => {
let c_in = s.input as f64 * r.input;
let c_out = s.output as f64 * r.output;
let c_cache =
s.cache_read as f64 * r.cache_read + s.cache_create as f64 * r.cache_write;
lines.push(cost_kv("input", fmt_money(c_in)));
lines.push(cost_kv("output", fmt_money(c_out)));
lines.push(cost_kv("cache", fmt_money(c_cache)));
lines.push(Line::from(vec![
key("total"),
Span::styled(
fmt_money(c_in + c_out + c_cache),
money_style().add_modifier(Modifier::BOLD),
),
]));
}
Some(_) => {
let floor = p.cost(s).unwrap_or(0.0);
lines.push(Line::from(vec![
key("total"),
Span::styled(
format!("{}+", fmt_money(floor)),
money_style().add_modifier(Modifier::BOLD),
),
]));
}
},
}
let bar_w = (inner.width as usize).saturating_sub(14).clamp(6, 36);
lines.push(Line::from(""));
let share_title = match period.badge() {
Some(b) => format!("SHARE OF {}", b.to_uppercase()),
None => "SHARE OF ALL TOKENS".to_string(),
};
lines.push(Line::from(Span::styled(share_title, theme::label())));
let share_val = if period.bucket().is_some() {
m.in_out
} else {
s.total()
};
let share = if grand == 0 {
0.0
} else {
share_val as f64 / grand as f64 * 100.0
};
let mut share_line = hbar(share_val, grand, bar_w, theme::accent());
share_line.push(Span::styled(format!(" {share:>4.1}%"), theme::dim()));
lines.push(Line::from(share_line));
if m.split_complete {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("CACHE HIT", theme::label())));
let denom = s.cache_read + s.cache_create + s.input;
let hit = if denom == 0 {
0.0
} else {
s.cache_read as f64 / denom as f64
};
let mut hit_line = hbar(
(hit * 100.0) as u64,
100,
bar_w,
Style::default().fg(theme::info_color()),
);
hit_line.push(Span::styled(
format!(" {:>3.0}%", hit * 100.0),
theme::dim(),
));
lines.push(Line::from(hit_line));
}
frame.render_widget(Paragraph::new(lines).style(theme::base()), inner);
}
#[cfg(test)]
#[path = "../../../tests/inline/tui_render_tokens.rs"]
mod tests;