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)"),
);
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).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"));
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 std::path::PathBuf;
use super::*;
use crate::agent::AgentModel;
use crate::domain::session::{SessionSize, SessionStats, Status};
#[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);
}
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 {
Session {
base_branch: "main".to_string(),
created_at,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: session_id.to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
model,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: crate::domain::session::PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens,
output_tokens,
},
status: Status::Review,
summary: None,
title: Some(title.to_string()),
updated_at,
}
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
#[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 text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Token Stats"));
assert!(text.contains("gpt-5.4"));
assert!(text.contains("claude-opus-4-7"));
}
#[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)"));
}
}