tokidex 0.1.1

macOS terminal UI for inspecting local Codex token usage
use chrono::{Local, TimeZone};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::{Alignment, Frame, Line, Modifier, Span, Style};
use ratatui::style::Color;
use ratatui::text::Text;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};

use crate::app::{App, display_cwd, display_id, display_rollout, display_title};
use crate::model::{RateLimit, SessionRecord, TokenUsage};

pub fn draw(frame: &mut Frame<'_>, app: &App) {
    let root = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(5),
            Constraint::Min(8),
            Constraint::Length(3),
        ])
        .split(frame.area());

    draw_summary(frame, root[0], app);
    draw_body(frame, root[1], app);
    draw_footer(frame, root[2], app);
}

fn draw_summary(frame: &mut Frame<'_>, area: ratatui::layout::Rect, app: &App) {
    let totals = app.totals();
    let rate = app
        .latest_rate_limit()
        .map(format_rate_limit)
        .unwrap_or_else(|| "rate limit: unavailable".to_string());
    let text = vec![
        Line::from(vec![
            Span::styled(
                "tokidex",
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(
                "  range: {}  sessions: {}",
                app.range.label(),
                app.visible.len()
            )),
        ]),
        Line::from(format!(
            "total: {}  input: {}  cached: {}  output: {}  reasoning: {}",
            format_number(totals.total_tokens),
            format_number(totals.input_tokens),
            format_number(totals.cached_input_tokens),
            format_number(totals.output_tokens),
            format_number(totals.reasoning_output_tokens)
        )),
        Line::from(if app.privacy.enabled() {
            "rate limit: hidden in privacy mode".to_string()
        } else {
            rate
        }),
    ];

    frame.render_widget(
        Paragraph::new(text).block(
            Block::default()
                .borders(Borders::ALL)
                .title("Codex token usage"),
        ),
        area,
    );
}

fn draw_body(frame: &mut Frame<'_>, area: ratatui::layout::Rect, app: &App) {
    let columns = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
        .split(area);

    if app.visible.is_empty() {
        let empty = Paragraph::new("No Codex token records found")
            .alignment(Alignment::Center)
            .block(Block::default().borders(Borders::ALL).title("Sessions"));
        frame.render_widget(empty, columns[0]);
        draw_detail(frame, columns[1], None, app);
        return;
    }

    let items = app
        .visible
        .iter()
        .enumerate()
        .map(|(index, record)| {
            let model = record.summary.model.as_deref().unwrap_or("unknown");
            let title = single_line(&display_title(record, index, app.privacy));
            ListItem::new(Line::from(vec![
                Span::styled(
                    format_time(record.summary.updated_at),
                    Style::default().fg(Color::Gray),
                ),
                Span::raw("  "),
                Span::styled(
                    format!(
                        "{:>10}",
                        format_number(record.effective_usage().total_tokens)
                    ),
                    Style::default().fg(Color::Yellow),
                ),
                Span::raw("  "),
                Span::styled(model.to_string(), Style::default().fg(Color::Green)),
                Span::raw("  "),
                Span::raw(title),
            ]))
        })
        .collect::<Vec<_>>();

    let mut state = ListState::default();
    state.select(Some(app.selected));
    frame.render_stateful_widget(
        List::new(items)
            .block(Block::default().borders(Borders::ALL).title("Sessions"))
            .highlight_style(
                Style::default()
                    .bg(Color::DarkGray)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> "),
        columns[0],
        &mut state,
    );

    draw_detail(frame, columns[1], app.selected_record(), app);
}

fn draw_detail(
    frame: &mut Frame<'_>,
    area: ratatui::layout::Rect,
    record: Option<&SessionRecord>,
    app: &App,
) {
    let Some(record) = record else {
        frame.render_widget(
            Paragraph::new("Select a session")
                .block(Block::default().borders(Borders::ALL).title("Details")),
            area,
        );
        return;
    };

    let usage = record.usage;
    let effective = record.effective_usage();
    let detail_state = if usage.is_some() {
        "JSONL token_count"
    } else {
        "SQLite total only"
    };
    let rate = record
        .rate_limit
        .map(format_rate_limit)
        .unwrap_or_else(|| "rate limit: unavailable".to_string());
    let title = display_title(record, app.selected, app.privacy);
    let lines = vec![
        Line::from(vec![Span::styled(
            single_line(&title),
            Style::default().add_modifier(Modifier::BOLD),
        )]),
        Line::from(format!("id: {}", display_id(record, app.privacy))),
        Line::from(format!(
            "model: {}",
            record.summary.model.as_deref().unwrap_or("unknown")
        )),
        Line::from(format!("cwd: {}", display_cwd(record, app.privacy))),
        Line::from(format!(
            "created: {}",
            format_time(record.summary.created_at)
        )),
        Line::from(format!(
            "updated: {}",
            format_time(record.summary.updated_at)
        )),
        Line::from(format!("rollout: {}", display_rollout(record, app.privacy))),
        Line::from(""),
        Line::from(format!("source: {detail_state}")),
        Line::from(format!("total: {}", format_number(effective.total_tokens))),
        Line::from(format!("input: {}", format_number(effective.input_tokens))),
        Line::from(format!(
            "cached input: {}",
            format_number(effective.cached_input_tokens)
        )),
        Line::from(format!(
            "output: {}",
            format_number(effective.output_tokens)
        )),
        Line::from(format!(
            "reasoning output: {}",
            format_number(effective.reasoning_output_tokens)
        )),
        Line::from(if app.privacy.enabled() {
            "rate limit: hidden in privacy mode".to_string()
        } else {
            rate
        }),
    ];

    frame.render_widget(
        Paragraph::new(Text::from(lines))
            .wrap(Wrap { trim: false })
            .block(Block::default().borders(Borders::ALL).title("Details")),
        area,
    );
}

fn draw_footer(frame: &mut Frame<'_>, area: ratatui::layout::Rect, app: &App) {
    let suffix = if app.privacy.enabled() {
        "  privacy"
    } else {
        ""
    };
    let prompt = if app.search_mode {
        format!("/{}", app.search)
    } else if app.search.is_empty() {
        format!("q quit  ↑/↓ move  / search  d today  w week  a all  r refresh{suffix}")
    } else {
        format!(
            "search: {}   q quit  ↑/↓ move  / edit  Esc clear  d/w/a range  r refresh{suffix}",
            app.search,
        )
    };
    frame.render_widget(
        Paragraph::new(prompt).block(Block::default().borders(Borders::ALL).title("Keys")),
        area,
    );
}

fn format_rate_limit(rate: RateLimit) -> String {
    let primary = rate
        .primary_used_percent
        .map(|value| format!("{value:.0}%"))
        .unwrap_or_else(|| "n/a".to_string());
    let secondary = rate
        .secondary_used_percent
        .map(|value| format!("{value:.0}%"))
        .unwrap_or_else(|| "n/a".to_string());
    format!("rate limit: primary {primary}, secondary {secondary}")
}

fn format_time(timestamp: i64) -> String {
    Local
        .timestamp_opt(timestamp, 0)
        .single()
        .map(|value| value.format("%m-%d %H:%M").to_string())
        .unwrap_or_else(|| "invalid".to_string())
}

fn single_line(value: &str) -> String {
    value.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn format_number(value: i64) -> String {
    let mut digits = value.abs().to_string();
    let mut out = String::new();
    while digits.len() > 3 {
        let chunk = digits.split_off(digits.len() - 3);
        if out.is_empty() {
            out = chunk;
        } else {
            out = format!("{chunk},{out}");
        }
    }
    if out.is_empty() {
        out = digits;
    } else if !digits.is_empty() {
        out = format!("{digits},{out}");
    }
    if value < 0 { format!("-{out}") } else { out }
}

#[allow(dead_code)]
fn _usage_for_docs(_: TokenUsage) {}