use crate::state::UsageState;
use crate::ui::render_tab_bar;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
Frame,
};
use std::collections::HashMap;
pub fn render_usage_view(
frame: &mut Frame,
area: Rect,
state: &UsageState,
api_error: Option<&str>,
) {
let content_area = if let Some(err) = api_error {
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
let err_line = Line::from(Span::styled(
format!(" Error: {} ", err),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(err_line), splits[0]);
splits[1]
} else {
area
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(4), Constraint::Min(6), Constraint::Length(1), ])
.split(content_area);
render_tab_bar(frame, chunks[0], "Usage");
render_summary_cards(frame, chunks[1], state);
render_tables(frame, chunks[2], state);
render_status_bar(frame, chunks[3], state);
}
fn render_summary_cards(frame: &mut Frame, area: Rect, state: &UsageState) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
])
.split(area);
if let Some(ref usage) = state.token_usage {
let summary = &usage.summary;
render_card(
frame,
cols[0],
"Requests",
&summary.total_requests.to_string(),
);
render_card(
frame,
cols[1],
"Input Tokens",
&fmt_tokens(summary.total_input_tokens),
);
render_card(
frame,
cols[2],
"Output Tokens",
&fmt_tokens(summary.total_output_tokens),
);
let cache_rate = if summary.total_cache_read_tokens > 0 {
let denom = summary.total_cache_read_tokens + summary.total_input_tokens;
if denom > 0 {
format!(
"{:.0}% cached",
summary.total_cache_read_tokens as f64 / denom as f64 * 100.0
)
} else {
"—".to_string()
}
} else {
"no cache".to_string()
};
render_card(frame, cols[3], "Cache Read", &cache_rate);
} else if state.is_loading {
for col in cols.iter() {
render_card(frame, *col, "…", "loading");
}
} else {
render_card(frame, cols[0], "Requests", "—");
render_card(frame, cols[1], "Input Tokens", "—");
render_card(frame, cols[2], "Output Tokens", "—");
render_card(frame, cols[3], "Cache Read", "—");
}
}
fn render_card(frame: &mut Frame, area: Rect, label: &str, value: &str) {
let block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(label, Style::default().fg(Color::Cyan)));
let inner = block.inner(area);
frame.render_widget(block, area);
let text = Paragraph::new(Span::styled(
value,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(text, inner);
}
fn render_tables(frame: &mut Frame, area: Rect, state: &UsageState) {
let show_conv = state
.conversation_depth
.as_ref()
.map(|d| d.total_conversations > 0)
.unwrap_or(false);
let show_trunc = state.truncation_rate.iter().any(|r| r.truncated > 0);
let show_cache = state
.cache_hit_rate
.iter()
.any(|r| r.total_cache_read_tokens > 0);
let show_tools = !state.tool_usage.is_empty();
let show_errors = !state.error_types.is_empty();
let show_drift = state.model_drift.iter().any(|p| p.differs);
let mut constraints = vec![Constraint::Min(5)]; if show_tools {
constraints.push(Constraint::Length(6));
}
if show_trunc || show_cache {
constraints.push(Constraint::Length(6));
}
if show_errors || show_drift {
constraints.push(Constraint::Length(6));
}
if show_conv {
constraints.push(Constraint::Length(5));
}
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
render_latency_table(frame, sections[0], state);
let mut idx = 1usize;
if show_tools {
render_tool_usage_table(frame, sections[idx], state);
idx += 1;
}
if show_trunc || show_cache {
render_trunc_cache_row(frame, sections[idx], state, show_trunc, show_cache);
idx += 1;
}
if show_errors || show_drift {
render_errors_drift_row(frame, sections[idx], state, show_errors, show_drift);
idx += 1;
}
if show_conv {
render_conv_depth(frame, sections[idx], state);
}
}
fn render_latency_table(frame: &mut Frame, area: Rect, state: &UsageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Latency by Model (tok/s = derived: span duration ≠ gen time) ");
if state.latency_stats.is_empty() {
let msg = if state.is_loading {
"Loading…"
} else {
"No latency data"
};
frame.render_widget(Paragraph::new(msg).block(block), area);
return;
}
let header = Row::new(vec![
Cell::from("Model").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("N").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("p50ms").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("p95ms").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("tok/s*p50").style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Cell::from("tok/s*p95").style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Cell::from("ctx p50").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("ctx p95").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("out/in p50").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("TTFT p50").style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Cell::from("TTFT p95").style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
]);
let rows: Vec<Row> = state
.latency_stats
.iter()
.map(|s| {
let model = s.model.as_deref().unwrap_or("(unknown)");
let ttft_p50 = if s.ttft_count > 0 {
s.ttft_p50_ms.map_or("—".to_string(), |v| v.to_string())
} else {
"—".to_string()
};
let ttft_p95 = if s.ttft_count > 0 {
s.ttft_p95_ms.map_or("—".to_string(), |v| v.to_string())
} else {
"—".to_string()
};
Row::new(vec![
Cell::from(truncate(model, 28)),
Cell::from(s.count.to_string()),
Cell::from(s.p50_ms.to_string()),
Cell::from(s.p95_ms.to_string()),
Cell::from(
s.derived_tokens_per_sec_p50
.map_or("—".to_string(), |v| format!("{:.0}", v)),
),
Cell::from(
s.derived_tokens_per_sec_p95
.map_or("—".to_string(), |v| format!("{:.0}", v)),
),
Cell::from(
s.input_tokens_p50
.map_or("—".to_string(), |v| fmt_tokens(v as u64)),
),
Cell::from(
s.input_tokens_p95
.map_or("—".to_string(), |v| fmt_tokens(v as u64)),
),
Cell::from(
s.output_input_ratio_p50
.map_or("—".to_string(), |v| format!("{:.2}×", v)),
),
Cell::from(ttft_p50),
Cell::from(ttft_p95),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Min(20),
Constraint::Length(5),
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(9),
Constraint::Length(9),
Constraint::Length(7),
Constraint::Length(7),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(8),
],
)
.header(header)
.block(block);
frame.render_widget(table, area);
}
fn render_trunc_cache_row(
frame: &mut Frame,
area: Rect,
state: &UsageState,
show_trunc: bool,
show_cache: bool,
) {
let (left, right) = if show_trunc && show_cache {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(area);
(Some(cols[0]), Some(cols[1]))
} else if show_trunc {
(Some(area), None)
} else {
(None, Some(area))
};
if let Some(a) = left {
render_truncation_table(frame, a, state);
}
if let Some(a) = right {
render_cache_table(frame, a, state);
}
}
fn render_truncation_table(frame: &mut Frame, area: Rect, state: &UsageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Truncation Rate ");
let rows: Vec<Row> = state
.truncation_rate
.iter()
.filter(|r| r.truncated > 0)
.map(|r| {
let rate_pct = r.rate * 100.0;
let color = if rate_pct > 5.0 {
Color::Red
} else if rate_pct > 1.0 {
Color::Yellow
} else {
Color::Green
};
Row::new(vec![
Cell::from(truncate(r.model.as_deref().unwrap_or("(unknown)"), 22)),
Cell::from(r.total.to_string()),
Cell::from(format!("{:.1}%", rate_pct)).style(Style::default().fg(color)),
])
})
.collect();
if rows.is_empty() {
frame.render_widget(Paragraph::new("No truncations").block(block), area);
return;
}
let header = Row::new(vec![
Cell::from("Model").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Total").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Rate").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]);
let table = Table::new(
rows,
[
Constraint::Min(10),
Constraint::Length(6),
Constraint::Length(6),
],
)
.header(header)
.block(block);
frame.render_widget(table, area);
}
fn render_cache_table(frame: &mut Frame, area: Rect, state: &UsageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Cache Hit Rate ");
let rows: Vec<Row> = state
.cache_hit_rate
.iter()
.filter(|r| r.total_cache_read_tokens > 0)
.map(|r| {
let hit_pct = r.hit_rate.unwrap_or(0.0) * 100.0;
let color = if hit_pct >= 20.0 {
Color::Green
} else if hit_pct >= 5.0 {
Color::Yellow
} else {
Color::White
};
Row::new(vec![
Cell::from(truncate(r.model.as_deref().unwrap_or("(unknown)"), 22)),
Cell::from(fmt_tokens(r.total_input_tokens)),
Cell::from(fmt_tokens(r.total_cache_read_tokens)),
Cell::from(format!("{:.1}%", hit_pct)).style(Style::default().fg(color)),
])
})
.collect();
if rows.is_empty() {
frame.render_widget(Paragraph::new("No cache reads").block(block), area);
return;
}
let header = Row::new(vec![
Cell::from("Model").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Input").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("CacheRead").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("HitRate").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]);
let table = Table::new(
rows,
[
Constraint::Min(10),
Constraint::Length(8),
Constraint::Length(9),
Constraint::Length(8),
],
)
.header(header)
.block(block);
frame.render_widget(table, area);
}
fn render_conv_depth(frame: &mut Frame, area: Rect, state: &UsageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Conversation Depth ");
if let Some(ref d) = state.conversation_depth {
if d.total_conversations > 0 {
let text = format!(
" {} conversations | avg {:.1} turns | p50 {} | p95 {} | p99 {}",
d.total_conversations, d.avg_turns, d.p50_turns, d.p95_turns, d.p99_turns
);
frame.render_widget(
Paragraph::new(Span::styled(text, Style::default().fg(Color::White))).block(block),
area,
);
return;
}
}
frame.render_widget(
Paragraph::new("No conversations with conversation_id observed").block(block),
area,
);
}
fn render_tool_usage_table(frame: &mut Frame, area: Rect, state: &UsageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Tool Usage (success rate) ");
if state.tool_usage.is_empty() {
frame.render_widget(Paragraph::new("No tool calls").block(block), area);
return;
}
let header = Row::new(vec![
Cell::from("Tool").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Calls").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Success%").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Errors").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Avg ms").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]);
let rows: Vec<Row> = state
.tool_usage
.iter()
.map(|r| {
let success_pct = if r.count > 0 {
r.success_count as f64 / r.count as f64 * 100.0
} else {
0.0
};
let color = if success_pct < 90.0 {
Color::Red
} else if success_pct < 99.0 {
Color::Yellow
} else {
Color::Green
};
Row::new(vec![
Cell::from(truncate(&r.tool_name, 24)),
Cell::from(r.count.to_string()),
Cell::from(format!("{:.1}%", success_pct)).style(Style::default().fg(color)),
Cell::from(r.error_count.to_string()),
Cell::from(format!("{:.0}", r.avg_duration_ms)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Min(16),
Constraint::Length(6),
Constraint::Length(9),
Constraint::Length(7),
Constraint::Length(8),
],
)
.header(header)
.block(block);
frame.render_widget(table, area);
}
fn render_errors_drift_row(
frame: &mut Frame,
area: Rect,
state: &UsageState,
show_errors: bool,
show_drift: bool,
) {
let (left, right) = if show_errors && show_drift {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(area);
(Some(cols[0]), Some(cols[1]))
} else if show_errors {
(Some(area), None)
} else {
(None, Some(area))
};
if let Some(a) = left {
render_error_types_table(frame, a, state);
}
if let Some(a) = right {
render_model_drift_table(frame, a, state);
}
}
fn render_error_types_table(frame: &mut Frame, area: Rect, state: &UsageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Error Types ");
if state.error_types.is_empty() {
frame.render_widget(Paragraph::new("No errors").block(block), area);
return;
}
let mut bucket_counts: HashMap<&str, usize> = HashMap::new();
for r in &state.error_types {
*bucket_counts.entry(r.bucket.as_str()).or_insert(0) += r.count;
}
let header = Row::new(vec![
Cell::from("Bucket").style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Cell::from("Type").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Cnt").style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
]);
let rows: Vec<Row> = state
.error_types
.iter()
.map(|r| {
Row::new(vec![
Cell::from(r.bucket.as_str()).style(Style::default().fg(Color::Red)),
Cell::from(truncate(&r.error_type, 20)),
Cell::from(r.count.to_string()).style(Style::default().fg(Color::Yellow)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(14),
Constraint::Min(10),
Constraint::Length(5),
],
)
.header(header)
.block(block);
frame.render_widget(table, area);
}
fn render_model_drift_table(frame: &mut Frame, area: Rect, state: &UsageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Model Drift ");
let drifted: Vec<_> = state.model_drift.iter().filter(|p| p.differs).collect();
if drifted.is_empty() {
frame.render_widget(
Paragraph::new("No drift — request and response models match").block(block),
area,
);
return;
}
let header = Row::new(vec![
Cell::from("Requested").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Cell::from("Served").style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Cell::from("N").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]);
let rows: Vec<Row> = drifted
.iter()
.map(|r| {
let req = r.request_model.as_deref().unwrap_or("(unknown)");
let resp = r.response_model.as_deref().unwrap_or("(unknown)");
Row::new(vec![
Cell::from(truncate(req, 22)),
Cell::from(truncate(resp, 22)).style(Style::default().fg(Color::Yellow)),
Cell::from(r.count.to_string()),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Min(14),
Constraint::Min(14),
Constraint::Length(5),
],
)
.header(header)
.block(block);
frame.render_widget(table, area);
}
fn render_status_bar(frame: &mut Frame, area: Rect, state: &UsageState) {
let text = if let Some(ref e) = state.error {
Line::from(Span::styled(
format!(" Error: {} | u:Usage r:Refresh q:Quit", e),
Style::default().fg(Color::Red),
))
} else {
Line::from(vec![
Span::styled(" u", Style::default().fg(Color::Yellow)),
Span::raw(":Usage "),
Span::styled("r", Style::default().fg(Color::Yellow)),
Span::raw(":Refresh "),
Span::styled("q", Style::default().fg(Color::Red)),
Span::raw(":Quit"),
])
};
frame.render_widget(Paragraph::new(text), area);
}
fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}…", &s[..max.saturating_sub(1)])
}
}