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)>, 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 = crate::commands::home_dir();
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");
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
};
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
));
}
}
}
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);
if tick.is_multiple_of(10) {
data = DashboardData::load();
}
}
Ok(())
}
fn render(f: &mut Frame, data: &DashboardData, _tick: u32) {
let area = f.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
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]);
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]);
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);
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]);
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]);
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);
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()
}
}