opencode-stats 1.3.7

A terminal dashboard for OpenCode usage statistics inspired by the /stats command in Claude Code
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

use crate::analytics::AnalyticsSnapshot;
use crate::analytics::model_stats::{ModelUsageRow, ProviderUsageRow, chart_with_focus};
use crate::ui::theme::Theme;
use crate::ui::widgets::common::{metric_line, truncate_label};
use crate::ui::widgets::linechart::build_chart;
use crate::utils::formatting::{format_price_summary, format_tokens};
use crate::utils::time::TimeRange;

pub fn render_models(
    frame: &mut ratatui::Frame<'_>,
    area: Rect,
    snapshot: &AnalyticsSnapshot,
    _range: TimeRange,
    focused_model_index: usize,
    theme: &Theme,
) {
    let [chart_area, _, header_area, _, detail_area, _] = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Fill(1),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(3),
            Constraint::Length(1),
        ])
        .areas(area);

    let focused_row = snapshot.models.get(focused_model_index);
    let chart_data = chart_with_focus(
        &snapshot.chart,
        focused_row.map(|row| row.model_id.as_str()),
    );
    frame.render_widget(build_chart(&chart_data, theme), chart_area);

    if let Some(row) = focused_row {
        frame.render_widget(
            Paragraph::new(focus_header_line(
                row,
                focused_model_index,
                &snapshot.models,
                theme,
            )),
            header_area,
        );
        render_model_detail(frame, detail_area, row, theme);
    } else {
        frame.render_widget(
            Paragraph::new("No model activity in this time range.").style(theme.muted_style()),
            detail_area,
        );
    }
}

fn focus_header_line(
    row: &ModelUsageRow,
    focused_model_index: usize,
    models: &[ModelUsageRow],
    theme: &Theme,
) -> Line<'static> {
    let total = models.len().max(1);
    Line::from(vec![
        Span::styled(
            format!("{}", truncate_label(&row.model_id, 26)),
            Style::default().fg(theme.series_color(focused_model_index)),
        ),
        Span::styled(format!("  ({:.2}%)", row.percentage), theme.muted_style()),
        Span::styled("  |  ", theme.muted_style()),
        Span::styled(
            format!("{}/{}", focused_model_index.min(total - 1) + 1, total),
            theme.muted_style(),
        ),
        Span::styled("  |  ", theme.muted_style()),
        Span::styled("j/k ↑/↓ cycle", theme.muted_style()),
    ])
}

fn render_model_detail(
    frame: &mut ratatui::Frame<'_>,
    area: Rect,
    row: &ModelUsageRow,
    theme: &Theme,
) {
    let [row_one, row_two, row_three] = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .areas(area);
    let [top_left, top_mid, top_right] = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(33),
            Constraint::Percentage(34),
        ])
        .areas(row_one);
    let [bottom_left, bottom_mid, bottom_right] = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(33),
            Constraint::Percentage(34),
        ])
        .areas(row_two);
    let [third_left, third_mid, third_right] = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(33),
            Constraint::Percentage(34),
        ])
        .areas(row_three);

    frame.render_widget(
        Paragraph::new(metric_line(
            "Tokens ",
            format_tokens(row.total_tokens),
            theme,
        )),
        top_left,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Cost ", format_price_summary(&row.cost), theme)),
        top_mid,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Sessions ", row.sessions.to_string(), theme)),
        top_right,
    );
    frame.render_widget(
        Paragraph::new(metric_line(
            "Input ",
            format_tokens(row.input_tokens),
            theme,
        )),
        bottom_left,
    );
    frame.render_widget(
        Paragraph::new(metric_line(
            "Output ",
            format_tokens(row.output_tokens),
            theme,
        )),
        bottom_mid,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Messages ", row.messages.to_string(), theme)),
        bottom_right,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Prompts ", row.prompts.to_string(), theme)),
        third_left,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Days ", row.active_days.to_string(), theme)),
        third_mid,
    );
    frame.render_widget(
        Paragraph::new(metric_line(
            "Rate ",
            format!("{:.2} tok/s", row.p50_output_tokens_per_second),
            theme,
        )),
        third_right,
    );
}

pub fn render_providers(
    frame: &mut ratatui::Frame<'_>,
    area: Rect,
    snapshot: &AnalyticsSnapshot,
    _range: TimeRange,
    focused_provider_index: usize,
    theme: &Theme,
) {
    let [chart_area, _, header_area, _, detail_area, _] = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Fill(1),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(3),
            Constraint::Length(1),
        ])
        .areas(area);

    let focused_row = snapshot.providers.get(focused_provider_index);
    let chart_data = chart_with_focus(
        &snapshot.provider_chart,
        focused_row.map(|row| row.provider_id.as_str()),
    );
    frame.render_widget(build_chart(&chart_data, theme), chart_area);

    if let Some(row) = focused_row {
        frame.render_widget(
            Paragraph::new(focus_provider_line(
                row,
                focused_provider_index,
                &snapshot.providers,
                theme,
            )),
            header_area,
        );
        render_provider_detail(frame, detail_area, row, theme);
    } else {
        frame.render_widget(
            Paragraph::new("No provider activity in this time range.").style(theme.muted_style()),
            detail_area,
        );
    }
}

fn focus_provider_line(
    row: &ProviderUsageRow,
    focused_provider_index: usize,
    providers: &[ProviderUsageRow],
    theme: &Theme,
) -> Line<'static> {
    let total = providers.len().max(1);
    Line::from(vec![
        Span::styled(
            format!("{}", truncate_label(&row.provider_id, 26)),
            Style::default().fg(theme.series_color(focused_provider_index)),
        ),
        Span::styled(format!("  ({:.2}%)", row.percentage), theme.muted_style()),
        Span::styled("  |  ", theme.muted_style()),
        Span::styled(
            format!("{}/{}", focused_provider_index.min(total - 1) + 1, total),
            theme.muted_style(),
        ),
        Span::styled("  |  ", theme.muted_style()),
        Span::styled("j/k ↑/↓ cycle", theme.muted_style()),
    ])
}

fn render_provider_detail(
    frame: &mut ratatui::Frame<'_>,
    area: Rect,
    row: &ProviderUsageRow,
    theme: &Theme,
) {
    let [row_one, row_two, row_three] = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .areas(area);
    let [top_left, top_mid, top_right] = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(33),
            Constraint::Percentage(34),
        ])
        .areas(row_one);
    let [bottom_left, bottom_mid, bottom_right] = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(33),
            Constraint::Percentage(34),
        ])
        .areas(row_two);
    let [third_left, third_mid, third_right] = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(33),
            Constraint::Percentage(34),
        ])
        .areas(row_three);

    frame.render_widget(
        Paragraph::new(metric_line(
            "Tokens ",
            format_tokens(row.total_tokens),
            theme,
        )),
        top_left,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Cost ", format_price_summary(&row.cost), theme)),
        top_mid,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Sessions ", row.sessions.to_string(), theme)),
        top_right,
    );
    frame.render_widget(
        Paragraph::new(metric_line(
            "Input ",
            format_tokens(row.input_tokens),
            theme,
        )),
        bottom_left,
    );
    frame.render_widget(
        Paragraph::new(metric_line(
            "Output ",
            format_tokens(row.output_tokens),
            theme,
        )),
        bottom_mid,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Messages ", row.messages.to_string(), theme)),
        bottom_right,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Prompts ", row.prompts.to_string(), theme)),
        third_left,
    );
    frame.render_widget(
        Paragraph::new(metric_line("Days ", row.active_days.to_string(), theme)),
        third_mid,
    );
    frame.render_widget(
        Paragraph::new(metric_line(
            "Rate ",
            format!("{:.2} tok/s", row.p50_output_tokens_per_second),
            theme,
        )),
        third_right,
    );
}