use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
use crate::domain::session::{DailyActivity, Session};
use crate::ui::page::session_list::{model_column_width, project_column_width};
use crate::ui::state::help_action;
use crate::ui::util::{
build_activity_heatmap_grid, build_visible_heatmap_month_row, current_day_key_local,
format_token_count, heatmap_intensity_level, heatmap_max_count, inline_text,
visible_heatmap_week_count,
};
use crate::ui::{Page, style};
const DAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const HEATMAP_CELL_WIDTH: usize = 2;
const HEATMAP_DAY_LABEL_WIDTH: usize = 4;
const HEATMAP_SECTION_HEIGHT: u16 = 11;
const TABLE_COLUMN_SPACING: u16 = 2;
pub struct StatsPage<'a> {
sessions: &'a [Session],
stats_activity: &'a [DailyActivity],
}
impl<'a> StatsPage<'a> {
pub fn new(sessions: &'a [Session], stats_activity: &'a [DailyActivity]) -> Self {
Self {
sessions,
stats_activity,
}
}
}
impl Page for StatsPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
let chunks = Layout::default()
.constraints([Constraint::Min(0), Constraint::Length(1)])
.margin(1)
.split(area);
let main_area = chunks[0];
let footer_area = chunks[1];
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(HEATMAP_SECTION_HEIGHT),
Constraint::Min(0),
])
.split(main_area);
self.render_heatmap(f, main_chunks[0]);
self.render_table(f, main_chunks[1]);
self.render_footer(f, footer_area);
}
}
impl StatsPage<'_> {
fn render_heatmap(&self, f: &mut Frame, area: Rect) {
let heatmap = Paragraph::new(self.build_heatmap_lines(area.width)).block(
Block::default()
.borders(Borders::ALL)
.title("Activity Heatmap (Last 53 Weeks)")
.border_style(style::border_style()),
);
f.render_widget(heatmap, area);
}
fn render_table(&self, f: &mut Frame, area: Rect) {
let header_style = Style::default()
.bg(style::palette::surface())
.fg(style::palette::text_muted())
.add_modifier(Modifier::BOLD);
let header_cells = ["Session", "Project", "Model", "Input", "Output"]
.iter()
.map(|header| Cell::from(*header));
let header = Row::new(header_cells)
.style(header_style)
.height(1)
.bottom_margin(1);
let rows = self.sessions.iter().map(|session| {
let cells = vec![
Cell::from(inline_text(session.display_title())),
Cell::from(session.project_name.clone()),
Cell::from(session.model.as_str()),
Cell::from(format_token_count(session.stats.input_tokens)),
Cell::from(format_token_count(session.stats.output_tokens)),
];
Row::new(cells)
.style(Style::default().fg(style::palette::text()))
.height(1)
});
let table = Table::new(
rows,
[
Constraint::Min(0),
project_column_width(self.sessions),
model_column_width(self.sessions),
Constraint::Length(10),
Constraint::Length(10),
],
)
.column_spacing(TABLE_COLUMN_SPACING)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title("Token Stats")
.border_style(style::border_style()),
);
f.render_widget(table, area);
}
fn render_footer(&self, f: &mut Frame, area: Rect) {
let footer_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Min(0)])
.split(area);
let help = Paragraph::new(help_action::footer_line(
&help_action::stats_footer_actions(),
));
f.render_widget(help, footer_chunks[0]);
let total_input: u64 = self
.sessions
.iter()
.map(|session| session.stats.input_tokens)
.sum();
let total_output: u64 = self
.sessions
.iter()
.map(|session| session.stats.output_tokens)
.sum();
let input_display = format_token_count(total_input);
let output_display = format_token_count(total_output);
let summary = format!(
"Sessions: {} | Input: {} | Output: {}",
self.sessions.len(),
input_display,
output_display
);
let stats = Paragraph::new(summary)
.style(Style::default().fg(style::palette::text_muted()))
.alignment(Alignment::Right);
f.render_widget(stats, footer_chunks[1]);
}
fn build_heatmap_lines(&self, available_width: u16) -> Vec<Line<'static>> {
let content_width = usize::from(available_width.saturating_sub(2));
let end_day_key = current_day_key_local();
let activity = self.build_local_activity();
let grid = build_activity_heatmap_grid(&activity, end_day_key);
let max_count = heatmap_max_count(&grid);
let visible_week_count =
visible_heatmap_week_count(content_width, HEATMAP_DAY_LABEL_WIDTH, HEATMAP_CELL_WIDTH);
let mut lines: Vec<Line<'static>> = Vec::new();
let month_row = build_visible_heatmap_month_row(
end_day_key,
HEATMAP_DAY_LABEL_WIDTH,
HEATMAP_CELL_WIDTH,
visible_week_count,
);
lines.push(Line::from(Span::styled(
month_row,
Style::default().fg(style::palette::text_muted()),
)));
for (day_index, day_label) in DAY_LABELS.iter().enumerate() {
let mut spans = vec![Span::styled(
format!("{day_label} "),
Style::default().fg(style::palette::text_muted()),
)];
let first_visible_week = grid[day_index].len().saturating_sub(visible_week_count);
for cell_count in &grid[day_index][first_visible_week..] {
let intensity = heatmap_intensity_level(*cell_count, max_count);
spans.push(Span::styled(
" ",
Style::default().bg(Self::heatmap_color(intensity)),
));
}
lines.push(Line::from(spans));
}
if content_width < 24 {
lines.push(Line::from(Span::styled(
format!("Max/day: {max_count}"),
Style::default().fg(style::palette::text_muted()),
)));
return lines;
}
let mut legend = vec![Span::styled(
"Less ",
Style::default().fg(style::palette::text_muted()),
)];
for intensity in 0_u8..=4 {
legend.push(Span::styled(
" ",
Style::default().bg(Self::heatmap_color(intensity)),
));
legend.push(Span::raw(" "));
}
legend.push(Span::styled(
"More",
Style::default().fg(style::palette::text_muted()),
));
if content_width >= 36 {
legend.push(Span::raw(format!(" | Max/day: {max_count}")));
}
lines.push(Line::from(legend));
lines
}
fn build_local_activity(&self) -> Vec<DailyActivity> {
self.stats_activity.to_vec()
}
fn heatmap_color(intensity: u8) -> Color {
match intensity {
1 => Color::Rgb(14, 68, 41),
2 => Color::Rgb(0, 109, 50),
3 => Color::Rgb(38, 166, 65),
4 => Color::Rgb(57, 211, 83),
_ => Color::Rgb(33, 38, 45),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::AgentModel;
use crate::domain::session::SessionStats;
use crate::domain::session::tests::SessionFixtureBuilder;
use crate::domain::theme::ColorTheme;
#[test]
fn test_token_stats_table_column_spacing_is_wider_for_readability() {
let expected_spacing = 2;
let spacing = TABLE_COLUMN_SPACING;
assert_eq!(spacing, expected_spacing);
}
#[test]
fn test_render_uses_palette_border_for_stats_panels() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let sessions = vec![session_fixture()];
let activity: Vec<DailyActivity> = Vec::new();
let mut page = StatsPage::new(&sessions, &activity);
let backend = ratatui::backend::TestBackend::new(160, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw stats page");
let border_cell_count = foreground_symbol_cell_count(terminal.backend().buffer(), "┌");
assert!(
border_cell_count >= 2,
"expected heatmap and token table panels to use palette border color"
);
}
fn session_fixture() -> Session {
session_fixture_with(
"session-id",
"Stats Session",
AgentModel::Gemini3FlashPreview,
1_500,
700,
0,
1_800,
)
}
fn session_fixture_with(
session_id: &str,
title: &str,
model: AgentModel,
input_tokens: u64,
output_tokens: u64,
created_at: i64,
updated_at: i64,
) -> Session {
SessionFixtureBuilder::new()
.id(session_id)
.title(Some(title.to_string()))
.model(model)
.stats(SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens,
output_tokens,
})
.created_at(created_at)
.updated_at(updated_at)
.build()
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
fn find_text_start_cell<'a>(
buffer: &'a ratatui::buffer::Buffer,
needle: &str,
) -> Option<&'a ratatui::buffer::Cell> {
let width = usize::from(buffer.area.width.max(1));
let needle_symbols = needle
.chars()
.map(|character| character.to_string())
.collect::<Vec<_>>();
let content = buffer.content();
for row_start in (0..content.len()).step_by(width) {
let row_end = row_start + width.min(content.len().saturating_sub(row_start));
let row = &content[row_start..row_end];
for (index, window) in row.windows(needle_symbols.len()).enumerate() {
let window_matches = window
.iter()
.zip(&needle_symbols)
.all(|(cell, symbol)| cell.symbol() == symbol);
if window_matches {
return Some(&row[index]);
}
}
}
None
}
fn foreground_symbol_cell_count(buffer: &ratatui::buffer::Buffer, symbol: &str) -> usize {
buffer
.content()
.iter()
.filter(|cell| cell.symbol() == symbol && cell.fg == style::palette::border())
.count()
}
#[test]
fn test_render_shows_activity_heatmap_legend() {
let sessions = vec![session_fixture()];
let activity = vec![DailyActivity {
day_key: current_day_key_local(),
session_count: 3,
}];
let mut page = StatsPage::new(&sessions, &activity);
let backend = ratatui::backend::TestBackend::new(160, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw stats page");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Activity Heatmap"));
assert!(text.contains("Less"));
assert!(text.contains("More"));
}
#[test]
fn test_build_heatmap_lines_uses_persisted_activity_for_max_count() {
let sessions = vec![session_fixture_with(
"session-1",
"Active Session",
AgentModel::Gpt54,
10,
10,
0,
0,
)];
let activity = vec![DailyActivity {
day_key: current_day_key_local(),
session_count: 50,
}];
let page = StatsPage::new(&sessions, &activity);
let heatmap_lines = page.build_heatmap_lines(160);
let rendered_text = heatmap_lines
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered_text.contains("Max/day: 50"));
}
#[test]
fn test_build_heatmap_lines_trims_visible_weeks_on_narrow_width() {
let sessions = vec![session_fixture()];
let activity = vec![DailyActivity {
day_key: current_day_key_local(),
session_count: 1,
}];
let page = StatsPage::new(&sessions, &activity);
let heatmap_lines = page.build_heatmap_lines(28);
let monday_row = &heatmap_lines[1];
assert_eq!(monday_row.spans.len(), 12);
}
#[test]
fn test_render_shows_session_token_table() {
let sessions = vec![
session_fixture_with(
"session-1",
"Longest Session",
AgentModel::Gpt54,
1_000,
500,
0,
7_200,
),
session_fixture_with(
"session-2",
"Second Session",
AgentModel::Gpt54,
2_000,
700,
10,
20,
),
session_fixture_with(
"session-3",
"Claude Session",
AgentModel::ClaudeOpus47,
300,
200,
30,
90,
),
];
let activity: Vec<DailyActivity> = Vec::new();
let mut page = StatsPage::new(&sessions, &activity);
let backend = ratatui::backend::TestBackend::new(220, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw stats page");
let buffer = terminal.backend().buffer();
let fallback_cell = &buffer.content()[0];
let title_cell = find_text_start_cell(buffer, "Longest Session").unwrap_or(fallback_cell);
let text = buffer_text(buffer);
assert!(text.contains("Token Stats"));
assert!(text.contains("gpt-5.4"));
assert!(text.contains("claude-opus-4-7"));
assert_eq!(title_cell.fg, style::palette::text());
}
#[test]
fn test_render_does_not_show_session_summary_panel() {
let sessions = vec![session_fixture()];
let activity: Vec<DailyActivity> = Vec::new();
let mut page = StatsPage::new(&sessions, &activity);
let backend = ratatui::backend::TestBackend::new(220, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
Page::render(&mut page, frame, area);
})
.expect("failed to draw stats page");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Activity Heatmap"));
assert!(!text.contains("Session Stats"));
assert!(!text.contains("Favorite model:"));
assert!(!text.contains("Longest Agentty session:"));
assert!(!text.contains("Model stats (All time)"));
}
}