mod filters;
#[cfg(test)]
mod keymap_tests;
mod model_picker;
mod timeline;
use bcode_plugin_sdk::tui::{
BoxedPluginTuiSurface, PluginTuiAction, PluginTuiHost, PluginTuiRegistry, PluginTuiSurface,
PluginTuiSurfaceFactory, PluginTuiSurfaceFuture, PluginTuiSurfaceOpenRequest, PluginTuiTheme,
};
use bcode_session_models::{SessionCostRange, SessionId, SessionUsageQuery};
use bcode_usage_models::{USAGE_VERSION, UsageQuery, UsageReport};
use bmux_tui::component::{Component, Constraints, LayoutCx};
use bmux_tui::geometry::Size;
use bmux_tui::{
event::Event,
geometry::Rect,
paint::{LocalRect, PaintCx},
prelude::Line,
style::Style,
};
use bmux_tui_components::bar_chart::{BarChartComponent, BarChartItem};
use bmux_tui_components::table::{Table, TableColumn, TableRow, TableState};
use std::{
collections::BTreeSet,
sync::{Arc, Mutex},
};
#[must_use]
pub fn tui_registry() -> PluginTuiRegistry {
let mut registry = PluginTuiRegistry::default();
for action in keymap_actions() {
registry.register_keymap_action(action);
}
registry.register_factory(Box::new(Factory));
registry
}
fn keymap_actions() -> Vec<bcode_plugin_sdk::tui::keymap::KeymapAction> {
[
("close", "Close", &["escape", "q"] as &[&str]),
("models", "Choose models", &["m"] as &[&str]),
("filters", "Edit filters", &["f"] as &[&str]),
("summary", "Next summary page", &["s"] as &[&str]),
("refresh", "Refresh / cancel summary", &["r"] as &[&str]),
("next", "Next report page", &["n"] as &[&str]),
("collect", "Collect active session", &["c"] as &[&str]),
("details", "Toggle details", &["d"] as &[&str]),
("export_json", "Copy JSON", &["j"] as &[&str]),
("export_csv", "Copy CSV", &["x"] as &[&str]),
(
"filter_selected",
"Filter selected row",
&["enter"] as &[&str],
),
(
"filter_provider",
"Filter selected provider",
&["p"] as &[&str],
),
("range_day", "Last day", &["1"] as &[&str]),
("range_week", "Last week", &["7"] as &[&str]),
("range_month", "Last month", &["3"] as &[&str]),
("clear_filters", "Clear filters", &["a"] as &[&str]),
]
.into_iter()
.map(
|(id, description, defaults)| bcode_plugin_sdk::tui::keymap::KeymapAction {
id: format!("bcode.usage.{id}"),
scope: "usage".into(),
description: description.into(),
defaults: defaults.iter().map(|key| (*key).into()).collect(),
},
)
.chain(
[
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Up,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Down,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Home,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::End,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Left,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Right,
]
.into_iter()
.map(|action| action.definition("usage")),
)
.chain(
["usage-model-picker", "usage-filters"]
.into_iter()
.flat_map(|scope| {
use bcode_plugin_sdk::tui::keymap::controls::ControlAction;
ControlAction::TEXT
.iter()
.copied()
.chain([
ControlAction::Submit,
ControlAction::Cancel,
ControlAction::FocusNext,
ControlAction::FocusPrevious,
ControlAction::PageUp,
ControlAction::PageDown,
])
.map(move |action| action.definition(scope))
}),
)
.chain(std::iter::once(
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Toggle
.definition("usage-model-picker"),
))
.collect()
}
fn scope_hints(host: &dyn PluginTuiHost, scope: &str) -> String {
keymap_actions()
.into_iter()
.filter(|action| action.scope == scope)
.filter_map(|action| {
let keys = host.keymap_keys(scope, &action.id);
(!keys.is_empty()).then(|| format!("{} {}", keys.join("/"), action.description))
})
.collect::<Vec<_>>()
.join(" | ")
}
struct Factory;
impl PluginTuiSurfaceFactory for Factory {
fn surface_kind(&self) -> &'static str {
"usage-dashboard"
}
fn open(&self, request: PluginTuiSurfaceOpenRequest) -> PluginTuiSurfaceFuture {
Box::pin(async move {
Ok(Box::new(Dashboard::new(request.session_id)) as BoxedPluginTuiSurface)
})
}
}
enum Update {
Report(Result<UsageReport, String>),
Collection(Result<bcode_session_models::SessionUsagePage, String>),
}
struct Dashboard {
summary: Option<bcode_usage::summary::UsageSummaryAccumulator>,
shortcuts: String,
report_scope: &'static str,
model_picker: Option<model_picker::ModelPicker>,
filters: Option<filters::Filters>,
session: Option<SessionId>,
query: UsageQuery,
report: Option<UsageReport>,
updates: Arc<Mutex<Option<Update>>>,
busy: bool,
status: String,
collection: SessionUsageQuery,
table: TableState,
area: Rect,
details: bool,
}
impl Dashboard {
fn handle_table_key(&mut self, stroke: bmux_keyboard::KeyStroke, host: &dyn PluginTuiHost) {
if let Some(stroke) = bcode_plugin_sdk::tui::keymap::controls::ControlInput::from_host(
host,
"usage",
stroke,
&[
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Up,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Down,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Home,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::End,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Left,
bcode_plugin_sdk::tui::keymap::controls::ControlAction::Right,
],
false,
)
.native_stroke()
{
Table::new(&self.columns(), &self.rows()).handle_event(
self.area,
&mut self.table,
&Event::Key(stroke),
);
}
}
fn new(session: Option<SessionId>) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(1, |duration| {
u64::try_from(duration.as_millis()).unwrap_or(1)
});
Self {
summary: None,
shortcuts: String::new(),
report_scope: "PAGE SUBTOTALS",
model_picker: None,
filters: None,
session,
query: UsageQuery {
version: USAGE_VERSION,
range: SessionCostRange {
from_timestamp_ms: now.saturating_sub(30 * 86_400_000),
to_timestamp_ms: now,
},
models: BTreeSet::new(),
providers: BTreeSet::new(),
session_id: None,
bucket_ms: 86_400_000,
after: None,
revision: None,
limit: 256,
},
report: None,
updates: Arc::new(Mutex::new(None)),
busy: false,
status: "Query snapshots or collect active session (one page per action)".into(),
collection: SessionUsageQuery {
range: SessionCostRange {
from_timestamp_ms: 0,
to_timestamp_ms: i64::MAX.cast_unsigned(),
},
after: None,
limit: 256,
generation: None,
},
table: TableState::default(),
area: Rect::new(0, 0, 0, 0),
details: false,
}
}
fn receive(&mut self) {
let update = self.updates.lock().ok().and_then(|mut slot| slot.take());
if let Some(update) = update {
self.busy = false;
match update {
Update::Report(Ok(report)) => self.receive_report(report),
Update::Collection(Ok(page)) => {
self.collection.after = page.next_after;
self.collection.generation = Some(page.generation);
self.status = if self.collection.after.is_some() {
"Collection incomplete: collect next page"
} else {
"Session snapshot published: query or recollect"
}
.into();
}
Update::Report(Err(error)) | Update::Collection(Err(error)) => {
self.summary = None;
self.status = error;
self.collection.after = None;
self.collection.generation = None;
}
}
}
}
fn receive_report(&mut self, report: UsageReport) {
if let Some(summary) = &mut self.summary {
if let Err(error) = summary.accept(self.query.after, &report) {
self.status = error;
self.summary = None;
return;
}
if report.next_after.is_some() {
self.query.after = report.next_after;
self.query.revision = Some(report.revision);
self.status = "Summary incomplete: next bounded page or cancel".into();
return;
}
let Some(summary) = self.summary.take() else {
return;
};
match summary.finish() {
Ok(report) => {
self.status.clone_from(&report.coverage);
self.report = Some(report);
self.report_scope = "WHOLE RANGE: INDEXED SNAPSHOTS";
}
Err(error) => self.status = error,
}
} else {
self.report_scope = "PAGE SUBTOTALS";
self.status.clone_from(&report.coverage);
self.report = Some(report);
}
}
fn start_summary_page(&mut self, host: &dyn PluginTuiHost) {
if self.summary.is_none() {
self.summary = Some(bcode_usage::summary::UsageSummaryAccumulator::default());
self.query.after = None;
self.query.revision = None;
self.details = false;
}
self.refresh(host);
}
fn refresh(&mut self, host: &dyn PluginTuiHost) {
if self.busy {
return;
}
self.busy = true;
let future = host.usage_report(self.query.clone());
let updates = Arc::clone(&self.updates);
host.spawn(Box::pin(async move {
let result = future
.await
.map_err(|_| "Query failed; restart query".to_owned());
if let Ok(mut slot) = updates.lock() {
*slot = Some(Update::Report(result));
}
}));
}
fn rows(&self) -> Vec<TableRow> {
let Some(report) = &self.report else {
return Vec::new();
};
if self.details {
report
.requests
.iter()
.map(|row| {
TableRow::rich(vec![
Line::from(row.session_id.to_string()),
Line::from(row.entry.key.clone()),
Line::from(format!("{:?}", row.entry.usage.cost)),
])
})
.collect()
} else {
report
.models
.iter()
.map(|row| {
TableRow::rich(vec![
Line::from(
row.model
.provider
.clone()
.unwrap_or_else(|| "Unknown".into()),
),
Line::from(row.model.model.clone().unwrap_or_else(|| "Unknown".into())),
Line::from(format!(
"{:?} micros; {}/{} priced",
row.totals.cost_micros, row.totals.priced_requests, row.totals.requests
)),
])
})
.collect()
}
}
fn picker_event(&mut self, event: &Event, host: &dyn PluginTuiHost) -> bool {
let Some(picker) = &mut self.model_picker else {
return false;
};
if let Event::Key(stroke) = event {
match host.keymap_action("usage-model-picker", *stroke).as_deref() {
Some("tui.control.cancel") => {
self.model_picker = None;
return true;
}
Some("tui.control.submit") => {
self.query.models = picker.selection();
self.query.after = None;
self.query.revision = None;
self.model_picker = None;
self.refresh(host);
return true;
}
_ => {}
}
}
picker.event(event, host);
true
}
fn filter_event(&mut self, event: &Event, host: &dyn PluginTuiHost) -> bool {
let Some(filters) = &mut self.filters else {
return false;
};
if let Event::Key(stroke) = event {
match host.keymap_action("usage-filters", *stroke).as_deref() {
Some("tui.control.cancel") => {
self.filters = None;
return true;
}
Some("tui.control.submit") => {
match filters.apply(&self.query) {
Ok(query) => {
self.query = query;
self.filters = None;
self.refresh(host);
}
Err(error) => filters.error = error,
}
return true;
}
_ => {}
}
}
filters.event(event, host);
true
}
fn filter_key(&mut self, action: &str, host: &dyn PluginTuiHost) -> bool {
if self.busy {
return false;
}
let selected = self.table.selected().unwrap_or(0);
match action {
"bcode.usage.filter_selected" if self.details => {
let Some(row) = self
.report
.as_ref()
.and_then(|report| report.requests.get(selected))
else {
return false;
};
self.query.session_id = Some(row.session_id);
}
"bcode.usage.filter_selected" => {
let Some(row) = self
.report
.as_ref()
.and_then(|report| report.models.get(selected))
else {
return false;
};
self.query.models = BTreeSet::from([row.model.clone()]);
}
"bcode.usage.filter_provider" if !self.details => {
let Some(provider) = self
.report
.as_ref()
.and_then(|report| report.models.get(selected))
.and_then(|row| row.model.provider.clone())
else {
return false;
};
self.query.providers = BTreeSet::from([provider]);
}
"bcode.usage.range_day" | "bcode.usage.range_week" | "bcode.usage.range_month" => {
let days = match action {
"bcode.usage.range_day" => 1,
"bcode.usage.range_week" => 7,
_ => 30,
};
self.query.range.from_timestamp_ms = self
.query
.range
.to_timestamp_ms
.saturating_sub(days * 86_400_000);
}
"bcode.usage.clear_filters" => {
self.query.models.clear();
self.query.providers.clear();
self.query.session_id = None;
}
_ => return false,
}
self.summary = None;
self.query.after = None;
self.query.revision = None;
self.table = TableState::default();
self.refresh(host);
true
}
const fn columns(&self) -> [TableColumn<'static>; 3] {
if self.details {
[
TableColumn::new("Session"),
TableColumn::new("Request"),
TableColumn::new("Estimate / provenance"),
]
} else {
[
TableColumn::new("Provider"),
TableColumn::new("Model"),
TableColumn::new("Estimated costs / coverage"),
]
}
}
}
impl PluginTuiSurface for Dashboard {
fn id(&self) -> &'static str {
"usage-dashboard"
}
fn title(&self) -> &'static str {
"Usage — estimated snapshot costs"
}
fn render(&mut self, area: Rect, frame: &mut PaintCx<'_, '_>) {
self.receive();
if let Some(picker) = &mut self.model_picker {
picker.paint(area, frame);
return;
}
if let Some(filters) = &self.filters {
filters.paint(area, frame);
return;
}
self.area = Rect::new(
area.x,
area.y.saturating_add(area.height.min(3)),
area.width,
area.height.saturating_sub(3),
);
let heading = format!(
"Usage | UTC {}..{} | {}",
self.query.range.from_timestamp_ms, self.query.range.to_timestamp_ms, self.report_scope
);
for (index, text) in [
heading.as_str(),
self.status.as_str(),
self.shortcuts.as_str(),
]
.into_iter()
.enumerate()
{
let row = u16::try_from(index).unwrap_or_default();
if row < area.height {
frame.write_line_with_fallback_style(
LocalRect::terminal(Rect::new(area.x, area.y + row, area.width, 1)),
&Line::from(text),
Style::new(),
);
}
}
if !self.details
&& self.area.height >= 12
&& let Some(report) = &self.report
{
if report.totals.cost_micros.len() == 1
&& let Some(currency) = report.totals.cost_micros.keys().next()
{
let labels: Vec<_> = report
.models
.iter()
.take(5)
.map(|row| {
format!(
"{} {}",
row.model.model.as_deref().unwrap_or("Unknown"),
currency
)
})
.collect();
let bars: Vec<_> = labels
.iter()
.zip(report.models.iter())
.map(|(label, row)| {
BarChartItem::new(
label,
row.totals
.cost_micros
.get(currency)
.copied()
.unwrap_or_default(),
)
})
.collect();
let chart_area = Rect::new(self.area.x, self.area.y, self.area.width, 5);
let chart = BarChartComponent::new("usage-model-costs", &bars);
let layout = chart.layout(
Constraints::tight(Size::new(chart_area.width, chart_area.height)),
&mut LayoutCx::new(),
);
frame.with_child(
i32::from(chart_area.x),
i64::from(chart_area.y),
LocalRect::new(0, 0, chart_area.width, chart_area.height),
|cx| chart.paint(&layout, cx),
);
self.area.y = self.area.y.saturating_add(5);
self.area.height = self.area.height.saturating_sub(5);
}
}
if !self.details
&& self.area.height >= 12
&& let Some(report) = &self.report
{
let chart_area = Rect::new(self.area.x, self.area.y, self.area.width, 6);
timeline::paint(report, chart_area, frame);
self.area.y = self.area.y.saturating_add(6);
self.area.height = self.area.height.saturating_sub(6);
}
Table::new(&self.columns(), &self.rows()).paint(self.area, &self.table, frame);
}
fn render_with_theme(
&mut self,
area: Rect,
frame: &mut PaintCx<'_, '_>,
theme: Option<&PluginTuiTheme>,
) {
if let Some(theme) = theme {
frame.fill(LocalRect::terminal(area), " ", theme.canvas);
}
self.render(area, frame);
}
fn prepare_keymap(&mut self, host: &dyn PluginTuiHost) {
self.shortcuts = scope_hints(host, "usage");
if let Some(picker) = &mut self.model_picker {
picker.update_hints(host);
}
if let Some(filters) = &mut self.filters {
filters.update_hints(host);
}
}
fn render_with_host(
&mut self,
area: Rect,
frame: &mut PaintCx<'_, '_>,
theme: Option<&PluginTuiTheme>,
host: &dyn PluginTuiHost,
) {
self.prepare_keymap(host);
self.render_with_theme(area, frame, theme);
}
fn handle_event(&mut self, event: &Event, host: &dyn PluginTuiHost) -> PluginTuiAction {
self.receive();
if self.summary.is_some()
&& matches!(event, Event::Key(stroke) if !matches!(host.keymap_action("usage", *stroke).as_deref(), Some("bcode.usage.summary" | "bcode.usage.refresh" | "bcode.usage.close")))
{
self.status = "Summary in progress: next summary page or refresh to cancel".into();
return PluginTuiAction::Redraw;
}
if self.picker_event(event, host) || self.filter_event(event, host) {
return PluginTuiAction::Redraw;
}
if let Event::Key(stroke) = event {
let action = host.keymap_action("usage", *stroke);
let action = action.as_deref().unwrap_or("");
if self.filter_key(action, host) {
return PluginTuiAction::Redraw;
}
match action {
"bcode.usage.close" => {
return PluginTuiAction::Close { outcome: None };
}
"bcode.usage.models" if !self.busy => {
self.model_picker = Some(model_picker::ModelPicker::new(
self.report
.iter()
.flat_map(|report| report.models.iter().map(|row| row.model.clone())),
self.query.models.clone(),
));
}
"bcode.usage.filters" if !self.busy => {
self.filters = Some(filters::Filters::new(&self.query));
}
"bcode.usage.summary" if !self.busy => self.start_summary_page(host),
"bcode.usage.refresh" if !self.busy => {
self.summary = None;
self.query.after = None;
self.query.revision = None;
self.refresh(host);
}
"bcode.usage.next" if !self.busy => {
if let Some(report) = &self.report
&& report.next_after.is_some()
{
self.query.after = report.next_after;
self.query.revision = Some(report.revision);
self.refresh(host);
}
}
"bcode.usage.collect" if !self.busy => {
if let Some(session) = self.session {
let future = host.collect_usage(session, self.collection.clone());
let updates = Arc::clone(&self.updates);
self.busy = true;
host.spawn(Box::pin(async move {
let result = future
.await
.map_err(|_| "Collection failed; restart collection".to_owned());
if let Ok(mut slot) = updates.lock() {
*slot = Some(Update::Collection(result));
}
}));
} else {
self.status = "Open /usage from a session to collect it".into();
}
}
"bcode.usage.details" => {
self.details = !self.details;
self.table = TableState::default();
}
"bcode.usage.export_json" | "bcode.usage.export_csv" => {
if let Some(report) = &self.report {
let output = if action == "bcode.usage.export_csv" {
Ok(bcode_usage::export_csv(report))
} else {
bcode_usage::export_json(report).map_err(|_| "serialization failed")
};
self.status = match output {
Ok(text) => match host.copy_text(text) {
Ok(()) => "Copied this report page, including coverage".into(),
Err(_) => "Clipboard unavailable".into(),
},
Err(error) => error.into(),
};
}
}
_ => {
self.handle_table_key(*stroke, host);
}
}
} else {
Table::new(&self.columns(), &self.rows()).handle_event(
self.area,
&mut self.table,
event,
);
}
PluginTuiAction::Redraw
}
}