bctx 0.1.13

bctx CLI — intercept CLI commands and compress output for LLM coding agents
use anyhow::Result;
use conductor::beacon::Beacon;
use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{BarChart, Block, Borders, Gauge, List, ListItem, Paragraph, Wrap},
    Frame, Terminal,
};
use std::cmp::Reverse;
use std::{collections::HashMap, io, time::Duration};

pub struct DashboardData {
    pub total_tokens_before: u64,
    pub total_tokens_after: u64,
    pub beacon_count: usize,
    pub skill_savings: Vec<(String, u64)>, // (skill_name, tokens_saved)
    pub session_savings_pct: f64,
    pub vault_crystallized: usize,
    pub vault_resonant: usize,
    pub recent_commands: Vec<String>,
}

impl DashboardData {
    pub fn load() -> Self {
        let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
        let beacons_dir = std::path::PathBuf::from(&home)
            .join(".bctx")
            .join("beacons");
        let executions_db = std::path::PathBuf::from(&home)
            .join(".bctx")
            .join("executions.db");
        let vault_crystallized_path = std::path::PathBuf::from(&home)
            .join(".bctx")
            .join("vault")
            .join("crystallized.json");
        let vault_resonant_path = std::path::PathBuf::from(&home)
            .join(".bctx")
            .join("vault")
            .join("resonant.json");

        // Load beacons
        let mut total_before = 0u64;
        let mut total_after = 0u64;
        let mut skill_map: HashMap<String, u64> = HashMap::new();
        let mut beacon_count = 0usize;

        if let Ok(entries) = std::fs::read_dir(&beacons_dir) {
            for entry in entries.flatten() {
                if let Ok(data) = std::fs::read_to_string(entry.path()) {
                    if let Ok(beacon) = serde_json::from_str::<Beacon>(&data) {
                        total_before += beacon.tokens_used as u64 + beacon.tokens_saved as u64;
                        total_after += beacon.tokens_used as u64;
                        *skill_map.entry(beacon.skill_id.clone()).or_insert(0) +=
                            beacon.tokens_saved as u64;
                        beacon_count += 1;
                    }
                }
            }
        }

        let mut skill_savings: Vec<(String, u64)> = skill_map.into_iter().collect();
        skill_savings.sort_by_key(|a| Reverse(a.1));

        let session_savings_pct = if total_before > 0 {
            (1.0 - total_after as f64 / total_before as f64) * 100.0
        } else {
            0.0
        };

        // Load recent commands from execution tracker
        let mut recent_commands = Vec::new();
        if let Ok(store) = forge::tracker::store::ExecutionStore::open(&executions_db) {
            if let Ok(records) = store.recent(8) {
                for rec in records {
                    recent_commands.push(format!(
                        "{} {} [exit={}]",
                        rec.program,
                        if rec.args.len() > 30 {
                            format!("{}", &rec.args[..30])
                        } else {
                            rec.args.clone()
                        },
                        rec.exit_code
                    ));
                }
            }
        }

        // Count vault facts
        let vault_crystallized = count_json_array(&vault_crystallized_path);
        let vault_resonant = count_json_array(&vault_resonant_path);

        DashboardData {
            total_tokens_before: total_before,
            total_tokens_after: total_after,
            beacon_count,
            skill_savings,
            session_savings_pct,
            vault_crystallized,
            vault_resonant,
            recent_commands,
        }
    }

    pub fn tokens_saved(&self) -> u64 {
        self.total_tokens_before
            .saturating_sub(self.total_tokens_after)
    }
}

fn count_json_array(path: &std::path::Path) -> usize {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| v.as_array().map(|a| a.len()))
        .unwrap_or(0)
}

pub fn run_tui() -> 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 = tui_loop(&mut terminal);

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    result
}

fn tui_loop<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>) -> Result<()> {
    let mut data = DashboardData::load();
    let mut tick = 0u32;

    loop {
        terminal.draw(|f| render(f, &data, tick))?;

        if event::poll(Duration::from_millis(500))? {
            if let Event::Key(key) = event::read()? {
                match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => break,
                    KeyCode::Char('r') => data = DashboardData::load(),
                    _ => {}
                }
            }
        }

        tick = tick.wrapping_add(1);
        // Refresh data every 10 ticks (~5s)
        if tick.is_multiple_of(10) {
            data = DashboardData::load();
        }
    }
    Ok(())
}

fn render(f: &mut Frame, data: &DashboardData, _tick: u32) {
    let area = f.area();

    // Top-level layout: title + body
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(0),
            Constraint::Length(1),
        ])
        .split(area);

    // Title bar
    let title = Paragraph::new(Line::from(vec![
        Span::styled(
            " bctx ",
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw("token dashboard  "),
        Span::styled(
            "[q] quit  [r] refresh",
            Style::default().fg(Color::DarkGray),
        ),
    ]))
    .block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Cyan)),
    );
    f.render_widget(title, rows[0]);

    // Main body: left + right columns
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
        .split(rows[1]);

    render_left(f, data, cols[0]);
    render_right(f, data, cols[1]);

    // Status bar
    let status = Paragraph::new(Line::from(vec![Span::styled(
        format!(" {} beacon events loaded", data.beacon_count),
        Style::default().fg(Color::DarkGray),
    )]));
    f.render_widget(status, rows[2]);
}

fn render_left(f: &mut Frame, data: &DashboardData, area: Rect) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(5),
            Constraint::Length(5),
            Constraint::Min(0),
        ])
        .split(area);

    // Savings gauge
    let pct = data.session_savings_pct.clamp(0.0, 100.0);
    let gauge = Gauge::default()
        .block(
            Block::default()
                .title(" Token Savings ")
                .borders(Borders::ALL),
        )
        .gauge_style(Style::default().fg(Color::Green).bg(Color::DarkGray))
        .ratio(pct / 100.0)
        .label(format!(
            "{pct:.1}% saved  ({} tokens → {})",
            fmt_num(data.total_tokens_before),
            fmt_num(data.total_tokens_after)
        ));
    f.render_widget(gauge, chunks[0]);

    // Vault health
    let vault_text = vec![
        Line::from(vec![
            Span::styled("  Crystallized: ", Style::default().fg(Color::Yellow)),
            Span::raw(format!("{} facts", data.vault_crystallized)),
        ]),
        Line::from(vec![
            Span::styled("  Resonant:     ", Style::default().fg(Color::Cyan)),
            Span::raw(format!("{} facts", data.vault_resonant)),
        ]),
    ];
    let vault_block =
        Paragraph::new(vault_text).block(Block::default().title(" Vault ").borders(Borders::ALL));
    f.render_widget(vault_block, chunks[1]);

    // Recent commands
    let items: Vec<ListItem> = data
        .recent_commands
        .iter()
        .map(|cmd| {
            ListItem::new(Line::from(vec![
                Span::styled("  ", Style::default()),
                Span::raw(cmd.clone()),
            ]))
        })
        .collect();
    let list = List::new(items).block(
        Block::default()
            .title(" Recent Commands ")
            .borders(Borders::ALL),
    );
    f.render_widget(list, chunks[2]);
}

fn render_right(f: &mut Frame, data: &DashboardData, area: Rect) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(0)])
        .split(area);

    // Skill savings bar chart
    if data.skill_savings.is_empty() {
        let p = Paragraph::new("\n  No skill data yet.\n  Run bctx mcp to start collecting.")
            .block(
                Block::default()
                    .title(" Top Skills by Savings ")
                    .borders(Borders::ALL),
            )
            .wrap(Wrap { trim: true });
        f.render_widget(p, chunks[0]);
    } else {
        let bar_data: Vec<(&str, u64)> = data
            .skill_savings
            .iter()
            .take(8)
            .map(|(name, val)| (name.as_str(), *val))
            .collect();
        let chart = BarChart::default()
            .block(
                Block::default()
                    .title(" Top Skills by Savings ")
                    .borders(Borders::ALL),
            )
            .bar_width(9)
            .bar_gap(1)
            .bar_style(Style::default().fg(Color::Cyan))
            .value_style(
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD),
            )
            .data(&bar_data);
        f.render_widget(chart, chunks[0]);
    }
}

fn fmt_num(n: u64) -> String {
    if n >= 1_000_000 {
        format!("{:.1}M", n as f64 / 1_000_000.0)
    } else if n >= 1_000 {
        format!("{:.1}K", n as f64 / 1_000.0)
    } else {
        n.to_string()
    }
}