tokidex 0.1.2

macOS terminal UI for inspecting local Codex token usage
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use anyhow::Result;
use clap::{Parser, ValueEnum};
use crossterm::event::{self, Event, KeyCode};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokidex::app::{App, DateRange, PrivacyMode};
use tokidex::codex_store::{load_records, resolve_codex_home};

#[derive(Debug, Parser)]
#[command(version, about = "Local Codex token usage TUI")]
struct Cli {
    #[arg(long)]
    codex_home: Option<PathBuf>,
    #[arg(long, value_enum, default_value_t = RangeArg::Today)]
    range: RangeArg,
    #[arg(long, default_value_t = 30)]
    refresh: u64,
    #[arg(long)]
    privacy: bool,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
enum RangeArg {
    Today,
    Week,
    All,
}

impl From<RangeArg> for DateRange {
    fn from(value: RangeArg) -> Self {
        match value {
            RangeArg::Today => DateRange::Today,
            RangeArg::Week => DateRange::Week,
            RangeArg::All => DateRange::All,
        }
    }
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let codex_home = resolve_codex_home(cli.codex_home)?;
    let records = load_records(&codex_home).unwrap_or_else(|err| {
        eprintln!("{err:#}");
        Vec::new()
    });
    let privacy = if cli.privacy {
        PrivacyMode::On
    } else {
        PrivacyMode::Off
    };
    let app = App::new(records, cli.range.into(), privacy);
    run_terminal(app, codex_home, Duration::from_secs(cli.refresh.max(1)))
}

fn run_terminal(mut app: App, codex_home: PathBuf, refresh: Duration) -> Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let result = run_app_loop(&mut terminal, &mut app, &codex_home, refresh);
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    result
}

fn run_app_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    codex_home: &Path,
    refresh: Duration,
) -> Result<()> {
    let mut last_refresh = Instant::now();
    loop {
        terminal.draw(|frame| tokidex::ui::draw(frame, app))?;

        if event::poll(Duration::from_millis(200))?
            && let Event::Key(key) = event::read()?
        {
            match key.code {
                KeyCode::Char('q') if !app.search_mode => break,
                KeyCode::Char('/') if !app.search_mode => app.search_mode = true,
                KeyCode::Esc => {
                    app.search_mode = false;
                    app.search.clear();
                    app.recompute();
                }
                KeyCode::Enter => app.search_mode = false,
                KeyCode::Backspace if app.search_mode => {
                    app.search.pop();
                    app.recompute();
                }
                KeyCode::Char(ch) if app.search_mode => {
                    app.search.push(ch);
                    app.recompute();
                }
                KeyCode::Down | KeyCode::Char('j') => app.move_next(),
                KeyCode::Up | KeyCode::Char('k') => app.move_previous(),
                KeyCode::Char('d') => {
                    app.range = DateRange::Today;
                    app.recompute();
                }
                KeyCode::Char('w') => {
                    app.range = DateRange::Week;
                    app.recompute();
                }
                KeyCode::Char('a') => {
                    app.range = DateRange::All;
                    app.recompute();
                }
                KeyCode::Char('r') => refresh_records(app, codex_home),
                _ => {}
            }
        }

        if last_refresh.elapsed() >= refresh {
            refresh_records(app, codex_home);
            last_refresh = Instant::now();
        }
    }
    Ok(())
}

fn refresh_records(app: &mut App, codex_home: &Path) {
    match load_records(codex_home) {
        Ok(records) => {
            app.replace_records(records);
            app.status = "refreshed".to_string();
        }
        Err(err) => {
            app.status = format!("{err:#}");
        }
    }
}