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, TokenView};
use super::super::theme;
use super::format::fixed;
use super::panes::{
SELECTOR_WIDTH, draw_selector_list, picker_row, section_box, section_box_verbatim,
};
use crate::pricing::PriceTable;
use crate::tokens::{ModelTokens, TokenStats, group_models, is_anthropic, model_display_name};
const KEY_W: usize = 8;
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]) -> Line<'static> {
let value = match prices {
Some(p) => {
let (total, unpriced) = p.total_cost(models);
let mut s = fmt_money(total);
if unpriced > 0 {
s.push('+');
}
s
}
None => "—".to_string(),
};
Line::from(vec![key(label), Span::styled(value, money_style())])
}
fn short_date(ymd: &str) -> String {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
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 sparkline(vals: &[u64]) -> String {
let max = vals.iter().copied().max().unwrap_or(0);
if max == 0 {
return SPARK[0].to_string().repeat(vals.len());
}
vals.iter()
.map(|&v| {
let idx = ((v as f64 / max as f64) * 7.0).round() as usize;
SPARK[idx.min(7)]
})
.collect()
}
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 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 model_metric(m: &ModelTokens, count_cache: bool) -> u64 {
if count_cache { m.total() } else { m.in_out() }
}
fn ranked_models(stats: &TokenStats, count_cache: bool) -> Vec<ModelTokens> {
let mut g = group_models(&stats.models);
g.sort_unstable_by_key(|m| std::cmp::Reverse(model_metric(m, count_cache)));
g
}
fn draw_dashboard(frame: &mut Frame<'_>, area: Rect, app: &App) {
let Some(stats) = app.token_stats.as_ref() else {
let block = section_box("tokens", false, true);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"reading ~/.claude",
theme::faint(),
)))
.style(theme::base()),
inner,
);
return;
};
let count_cache = app.config().state.count_cache;
let prices = app.price_table.as_ref();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), Constraint::Length(4), Constraint::Length(7), Constraint::Min(4), ])
.split(area);
let top = halves(rows[0], 42);
let today_meta = stats.today.as_ref().map(|t| short_date(&t.date));
card(
frame,
top[0],
"today",
today_meta.as_deref(),
true,
today_lines(stats, inner_w(top[0]), count_cache, prices),
);
card(
frame,
top[1],
"total",
None,
false,
total_lines(stats, inner_w(top[1]), count_cache, prices),
);
let fresh = stats
.topped_up_through
.as_deref()
.map(|d| format!("live thru {}", short_date(d)));
card(
frame,
rows[1],
"daily",
fresh.as_deref(),
false,
daily_lines(stats, inner_w(rows[1])),
);
let mid = halves(rows[2], 55);
card(
frame,
mid[0],
"top models",
None,
false,
model_lines(stats, inner_w(mid[0]), 5, count_cache, prices),
);
card(
frame,
mid[1],
"composition",
None,
false,
comp_lines(stats, inner_w(mid[1])),
);
let bot = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(HOUR_BOX_W), Constraint::Min(0)])
.split(rows[3]);
card(
frame,
bot[0],
"hour of day",
None,
false,
hour_lines(stats, inner_w(bot[0])),
);
card(
frame,
bot[1],
"activity",
None,
false,
activity_lines(stats, inner_w(bot[1])),
);
}
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,
lines: Vec<Line<'static>>,
) {
let mut block = 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),
Line::from(vec![
key("msgs"),
Span::styled(t.messages.to_string(), 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>> {
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 mut lines = vec![
lr(
vec![
key("tokens"),
Span::styled(
fmt_count(total),
theme::accent().add_modifier(Modifier::BOLD),
),
],
vec![Span::styled(
format!("{:.0}% cached", stats.cache_hit_ratio() * 100.0),
Style::default().fg(theme::accent_2_color()),
)],
w,
),
cost_line("cost", prices, &stats.models),
lr(
vec![Span::styled(
format!("{} sess", stats.total_sessions),
theme::body(),
)],
vec![Span::styled(
format!("{} msgs", fmt_count(stats.total_messages)),
theme::body(),
)],
w,
),
];
if let (Some(first), Some(latest)) = (stats.first_session_date.as_deref(), last) {
lines.push(lr(
vec![Span::styled(short_date(first), theme::dim())],
vec![Span::styled(short_date(latest), theme::dim())],
w,
));
}
lines
}
fn daily_lines(stats: &TokenStats, w: usize) -> Vec<Line<'static>> {
let vals: Vec<u64> = trail(&stats.daily, w).iter().map(|d| d.tokens).collect();
if vals.is_empty() {
return vec![Line::from(Span::styled("no daily data", theme::faint()))];
}
let (peak_v, peak_d) = stats
.daily
.iter()
.max_by_key(|d| d.tokens)
.map(|d| (d.tokens, d.date.clone()))
.unwrap_or((0, String::new()));
vec![
center(vec![Span::styled(sparkline(&vals), theme::accent())], w),
center(
vec![Span::styled(
format!("peak {} {}", fmt_count(peak_v), short_date(&peak_d)),
theme::faint(),
)],
w,
),
]
}
fn model_lines(
stats: &TokenStats,
w: usize,
n: usize,
count_cache: bool,
prices: Option<&PriceTable>,
) -> Vec<Line<'static>> {
let grouped = ranked_models(stats, count_cache);
let max = grouped
.first()
.map(|m| model_metric(m, count_cache))
.unwrap_or(0);
let names: Vec<String> = grouped
.iter()
.take(n)
.map(|m| model_display_name(&m.model))
.collect();
let counts: Vec<String> = grouped
.iter()
.take(n)
.map(|m| fmt_count(model_metric(m, count_cache)))
.collect();
let count_w = counts.iter().map(|s| s.chars().count()).max().unwrap_or(3);
let costs: Vec<String> = grouped
.iter()
.take(n)
.map(|m| {
prices
.and_then(|p| p.cost(m))
.map(fmt_money)
.unwrap_or_default()
})
.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);
grouped
.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 = model_metric(m, count_cache);
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 {
spans.push(Span::styled(format!(" {cost:>cost_w$}"), money_style()));
}
Line::from(spans)
})
.collect()
}
fn comp_lines(stats: &TokenStats, w: usize) -> Vec<Line<'static>> {
let grand = stats.total_tokens();
let bar_w = w.saturating_sub(WIDE_KEY_W).saturating_sub(6).clamp(4, 28);
[
("input", stats.total_input, theme::accent()),
("output", stats.total_output, theme::success()),
("cache write", stats.total_cache_create, theme::warning()),
("cache read", stats.total_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 spans = vec![Span::styled(
format!("{label:<WIDE_KEY_W$}"),
theme::label(),
)];
spans.extend(hbar(value, grand, bar_w, fill));
spans.push(Span::styled(format!(" {pct:>3.0}%"), theme::dim()));
Line::from(spans)
})
.collect()
}
fn hour_lines(stats: &TokenStats, w: usize) -> Vec<Line<'static>> {
let peak = busiest_hour(&stats.hour_counts)
.map(|h| format!("peak {h:02}:00"))
.unwrap_or_default();
vec![
center(
vec![Span::styled(sparkline(&stats.hour_counts), theme::accent())],
w,
),
center(vec![Span::styled(peak, theme::faint())], w),
]
}
fn activity_lines(stats: &TokenStats, w: usize) -> Vec<Line<'static>> {
let msgs: Vec<u64> = trail(&stats.activity, w)
.iter()
.map(|a| a.messages)
.collect();
if msgs.is_empty() {
return vec![Line::from(Span::styled("no activity data", theme::faint()))];
}
let peak_msgs = stats.activity.iter().map(|a| a.messages).max().unwrap_or(0);
let peak_sess = stats.activity.iter().map(|a| a.sessions).max().unwrap_or(0);
let tools: u64 = stats.activity.iter().map(|a| a.tool_calls).sum();
vec![
center(vec![Span::styled(sparkline(&msgs), theme::accent())], w),
center(
vec![Span::styled(
format!(
"peak {} msgs {peak_sess} sess {} tools",
fmt_count(peak_msgs),
fmt_count(tools),
),
theme::faint(),
)],
w,
),
]
}
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), Constraint::Min(20)])
.split(area);
let count_cache = app.config().state.count_cache;
let grouped = app
.token_stats
.as_ref()
.map(|s| ranked_models(s, count_cache))
.unwrap_or_default();
let sel = app.token_model_cursor.min(grouped.len().saturating_sub(1));
draw_selector_list(frame, cols[0], "models", 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()
});
let grand = app
.token_stats
.as_ref()
.map(TokenStats::total_tokens)
.unwrap_or(0);
draw_model_detail(
frame,
cols[1],
grouped.get(sel),
grand,
app.price_table.as_ref(),
);
}
fn draw_model_detail(
frame: &mut Frame<'_>,
area: Rect,
model: Option<&ModelTokens>,
grand: u64,
prices: Option<&PriceTable>,
) {
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 mut lines = vec![
kv("input", fmt_count(m.input)),
kv("output", fmt_count(m.output)),
kv("cache read", fmt_count(m.cache_read)),
kv("cache write", fmt_count(m.cache_create)),
Line::from(""),
Line::from(vec![
Span::styled(format!("{:<WIDE_KEY_W$}", "total"), theme::label()),
Span::styled(
fmt_count(m.total()),
theme::accent().add_modifier(Modifier::BOLD),
),
]),
kv("io", fmt_count(m.in_out())),
];
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"COST · API-EQUIVALENT",
theme::label(),
)));
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) => {
let c_in = m.input as f64 * r.input;
let c_out = m.output as f64 * r.output;
let c_cache =
m.cache_read as f64 * r.cache_read + m.cache_create as f64 * r.cache_write;
let cost_kv = |label: &str, value: String| {
Line::from(vec![key(label), Span::styled(value, money_style())])
};
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),
),
]));
}
},
}
let bar_w = (inner.width as usize).saturating_sub(14).clamp(6, 36);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"SHARE OF ALL TOKENS",
theme::label(),
)));
let share = if grand == 0 {
0.0
} else {
m.total() as f64 / grand as f64 * 100.0
};
let mut share_line = hbar(m.total(), grand, bar_w, theme::accent());
share_line.push(Span::styled(format!(" {share:>4.1}%"), theme::dim()));
lines.push(Line::from(share_line));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("CACHE HIT", theme::label())));
let denom = m.cache_read + m.cache_create + m.input;
let hit = if denom == 0 {
0.0
} else {
m.cache_read as f64 / denom as f64
};
let mut hit_line = hbar(
(hit * 100.0) as u64,
100,
bar_w,
Style::default().fg(theme::accent_2_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);
}