use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app_view::AppView;
use crate::theme::Theme;
use crate::ui::{fmt_cost, humanize};
use super::chrome;
#[allow(clippy::too_many_lines)] pub fn render(f: &mut Frame, app: &AppView) {
let area = crate::ui::centered(f.area(), chrome::WIDE.0, chrome::WIDE.1);
let Some(data) = &app.usage_data else {
return;
};
let theme = &app.theme;
let dim = Style::default().fg(theme.fg_dim);
let title = chrome::popup_title(app, "π", format!("usage Β· {}", app.usage_range.label()));
let hint = format!(
"{}ββ/t {} Β· ββ recent Β· Ctrl+R refresh Β· Esc close",
chrome::count_hint(data.totals.requests as usize, "request"),
app.usage_range.title()
);
let inner = chrome::render_hinted(f, area, title, &hint, app, true, chrome::Tone::Normal);
if data.totals.requests == 0 {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
app.usage_range.empty_message(),
dim,
))),
inner,
);
return;
}
let rows = Layout::vertical([
Constraint::Length(4), Constraint::Length(1), Constraint::Length(1 + data.by_backend.len() as u16), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1 + data.by_model.len() as u16), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
render_summary(f, app, rows[0]);
let width = rows[0].width;
f.render_widget(
Paragraph::new(section_header(theme, "by backend", width)),
rows[1],
);
let mut backend_lines: Vec<Line> = vec![Line::from(vec![
Span::styled(" ", dim),
Span::styled(format!("{:<14}", "backend"), dim),
Span::styled(format!("{:>6} {:>9} {:>8} ", "req", "prompt", "out"), dim),
Span::styled(format!("{:<6} ", "cached"), dim),
Span::styled(format!("{:>9}", "cost"), dim),
])];
for b in &data.by_backend {
let cached = rate(b.cache_read_tokens, b.prompt_tokens);
let name = chrome::truncate(&b.backend, 14);
let mut row = vec![
Span::styled("β ", Style::default().fg(backend_color(theme, &b.backend))),
Span::styled(
format!("{name:<14}"),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
];
row.extend(numeric_cells(
theme,
b.requests,
b.prompt_tokens,
b.completion_tokens,
cached,
b.cost,
));
backend_lines.push(Line::from(row));
}
f.render_widget(Paragraph::new(backend_lines), rows[2]);
f.render_widget(
Paragraph::new(section_header(theme, "most used models", width)),
rows[4],
);
let mut model_lines: Vec<Line> = vec![Line::from(vec![
Span::styled(" ", dim),
Span::styled(format!("{:<30}", "model"), dim),
Span::styled(format!("{:>6} {:>9} {:>8} ", "req", "prompt", "out"), dim),
Span::styled(format!("{:<6} ", "cached"), dim),
Span::styled(format!("{:>9}", "cost"), dim),
])];
for (i, m) in data.by_model.iter().enumerate() {
let cached = rate(m.cache_read_tokens, m.prompt_tokens);
let rank_style = if i == 0 {
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD)
} else {
dim
};
let name = chrome::truncate(&m.model, 30);
let mut row = vec![
Span::styled(format!("{:>2} ", i + 1), rank_style),
Span::styled(format!("{name:<30}"), Style::default().fg(theme.fg)),
];
row.extend(numeric_cells(
theme,
m.requests,
m.prompt_tokens,
m.completion_tokens,
cached,
m.cost,
));
model_lines.push(Line::from(row));
}
f.render_widget(Paragraph::new(model_lines), rows[5]);
f.render_widget(
Paragraph::new(section_header(theme, "recent requests", width)),
rows[7],
);
let recent_area = rows[8];
let visible = recent_area.height as usize;
let top = app
.usage_scroll
.min(data.recent.len().saturating_sub(visible));
let mut recent_lines: Vec<Line> = Vec::with_capacity(visible);
for r in data.recent.iter().skip(top).take(visible) {
let cached = rate(r.cache_read_tokens, r.prompt_tokens);
let time = r
.created_at
.parse::<chrono::DateTime<chrono::Utc>>()
.map_or_else(|_| "--:--".to_string(), |t| t.format("%H:%M").to_string());
let model = chrome::truncate(&r.model, 24);
let tokens = format!(
"{}β{}",
humanize(r.prompt_tokens),
humanize(r.completion_tokens)
);
recent_lines.push(Line::from(vec![
Span::styled(format!("{time} "), dim),
Span::styled("β ", Style::default().fg(backend_color(theme, &r.backend))),
Span::styled(format!("{model:<24}"), Style::default().fg(theme.fg)),
Span::styled(format!("{tokens:>13} "), dim),
Span::styled(
"β".repeat((cached * 8.0).round() as usize),
Style::default().fg(cache_color(theme, cached)),
),
Span::styled(
"β".repeat(8 - (cached * 8.0).round() as usize),
Style::default().fg(theme.border_dim),
),
Span::styled(
format!(" {:>4}%", (cached * 100.0).round()),
Style::default()
.fg(cache_color(theme, cached))
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {:>9}", fmt_cost(r.cost)),
cost_style(theme, r.cost.unwrap_or(0.0)),
),
]));
}
f.render_widget(Paragraph::new(recent_lines), recent_area);
}
fn render_summary(f: &mut Frame, app: &AppView, area: Rect) {
let theme = &app.theme;
let Some(data) = &app.usage_data else { return };
let t = &data.totals;
let dim = Style::default().fg(theme.fg_dim);
let accent_bold = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD);
let headline = Line::from(vec![
Span::styled(format!("{} requests", t.requests), accent_bold),
Span::styled(
format!(
" Β· {} prompt Β· {} output Β· {} cache writes",
humanize(t.prompt_tokens),
humanize(t.completion_tokens),
humanize(t.cache_creation_tokens),
),
dim,
),
Span::styled(
format!(" Β· {}", fmt_cost_agg(t.cost)),
cost_style(theme, t.cost),
),
]);
let cached = rate(t.cache_read_tokens, t.prompt_tokens);
let bar_line = if t.prompt_tokens > 0 {
let mut spans: Vec<Span> = vec![Span::raw(" ")];
spans.extend(cache_bar(cached, 24, theme));
spans.push(Span::styled(
format!(" {:.0}% of prompt served from cache", cached * 100.0),
Style::default()
.fg(cache_color(theme, cached))
.add_modifier(Modifier::BOLD),
));
Line::from(spans)
} else {
Line::from(Span::styled(" no cache data reported", dim))
};
f.render_widget(
Paragraph::new(vec![headline, Line::from(""), bar_line]),
area,
);
}
fn numeric_cells(
theme: &Theme,
requests: u64,
prompt: u64,
completion: u64,
cached: f64,
cost: f64,
) -> Vec<Span<'static>> {
let dim = Style::default().fg(theme.fg_dim);
vec![
Span::styled(
format!(
"{:>6} {:>9} {:>8} ",
requests,
humanize(prompt),
humanize(completion)
),
dim,
),
Span::styled(
format!("{:>6}% ", (cached * 100.0).round()),
Style::default().fg(cache_color(theme, cached)),
),
Span::styled(
format!("{:>9}", fmt_cost_agg(cost)),
cost_style(theme, cost),
),
]
}
fn section_header(theme: &Theme, title: &str, width: u16) -> Line<'static> {
let rule = "β".repeat(width.saturating_sub(3 + title.len() as u16) as usize);
Line::from(vec![
Span::styled("β", Style::default().fg(theme.accent)),
Span::styled(
title.to_string(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {rule}"), Style::default().fg(theme.border_dim)),
])
}
fn rate(cache_read: u64, prompt: u64) -> f64 {
if prompt == 0 {
0.0
} else {
(cache_read as f64 / prompt as f64).clamp(0.0, 1.0)
}
}
fn cache_color(theme: &Theme, rate: f64) -> Color {
if rate >= 0.7 {
theme.success
} else if rate >= 0.4 {
theme.warning
} else {
theme.error
}
}
fn backend_color(theme: &Theme, backend: &str) -> Color {
match backend {
"OpenRouter" => theme.accent,
"OpenAI" => theme.success,
"Codex" => theme.accent2,
_ => theme.warning,
}
}
fn cache_bar(rate: f64, width: usize, theme: &Theme) -> Vec<Span<'static>> {
let filled = (rate * width as f64).round() as usize;
vec![
Span::styled(
"β".repeat(filled),
Style::default().fg(cache_color(theme, rate)),
),
Span::styled(
"β".repeat(width - filled),
Style::default().fg(theme.border_dim),
),
]
}
fn cost_style(theme: &Theme, cost: f64) -> Style {
if cost > 0.0 {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg_dim)
}
}
fn fmt_cost_agg(cost: f64) -> String {
if cost > 0.0 {
fmt_cost(Some(cost))
} else {
"β".to_string()
}
}
pub fn handle_key(app: &mut AppView, key: KeyEvent) {
use nexus_core::app::Popup;
match key.code {
KeyCode::Esc => app.popup = Popup::None,
KeyCode::Up | KeyCode::Char('k') => app.scroll_usage(-1),
KeyCode::Down | KeyCode::Char('j') => app.scroll_usage(1),
KeyCode::PageUp => app.scroll_usage(-10),
KeyCode::PageDown => app.scroll_usage(10),
KeyCode::Left | KeyCode::Char('h') => app.cycle_usage_range(-1),
KeyCode::Right | KeyCode::Char('l' | 't') => app.cycle_usage_range(1),
KeyCode::Char('r')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.refresh_usage();
}
_ => {}
}
}