use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Sparkline},
Frame,
};
pub struct Dashboard {
pub current_node: Option<String>,
pub total_nodes: usize,
pub completed_nodes: usize,
pub energy: f32,
pub energy_history: Vec<u64>,
pub stable: bool,
pub status: String,
pub logs: Vec<String>,
pub energy_components: Option<perspt_core::EnergyComponents>,
pub verifier_stage: Option<String>,
pub escalation_count: usize,
pub active_branches: usize,
pub budget_steps_used: u32,
pub budget_max_steps: Option<u32>,
pub budget_cost_used: f64,
pub budget_max_cost: Option<f64>,
pub budget_revisions_used: u32,
pub budget_max_revisions: Option<u32>,
}
impl Default for Dashboard {
fn default() -> Self {
Self {
current_node: None,
total_nodes: 0,
completed_nodes: 0,
energy: 0.0,
energy_history: Vec::new(),
stable: false,
status: "Ready".to_string(),
logs: Vec::new(),
energy_components: None,
verifier_stage: None,
escalation_count: 0,
active_branches: 0,
budget_steps_used: 0,
budget_max_steps: None,
budget_cost_used: 0.0,
budget_max_cost: None,
budget_revisions_used: 0,
budget_max_revisions: None,
}
}
}
impl Dashboard {
pub fn new() -> Self {
Self::default()
}
pub fn update_energy(&mut self, energy: f32) {
self.energy = energy;
let scaled = ((energy * 100.0).clamp(0.0, 100.0)) as u64;
self.energy_history.push(scaled);
if self.energy_history.len() > 50 {
self.energy_history.remove(0);
}
self.stable = energy < 0.1; }
pub fn log(&mut self, message: String) {
self.logs.push(message);
if self.logs.len() > 1000 {
self.logs.remove(0);
}
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
.split(area);
self.render_header(frame, main_chunks[0]);
self.render_content(frame, main_chunks[1]);
self.render_footer(frame, main_chunks[2]);
}
fn render_header(&self, frame: &mut Frame, area: Rect) {
let header_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30),
Constraint::Percentage(40),
Constraint::Percentage(30),
])
.split(area);
let title = Paragraph::new("🚀 SRBN Agent Dashboard")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, header_chunks[0]);
let progress_ratio = if self.total_nodes > 0 {
self.completed_nodes as f64 / self.total_nodes as f64
} else {
0.0
};
let progress_label = format!("{}/{} nodes", self.completed_nodes, self.total_nodes);
let progress = Gauge::default()
.block(Block::default().title("Progress").borders(Borders::ALL))
.gauge_style(Style::default().fg(Color::Green))
.ratio(progress_ratio)
.label(progress_label);
frame.render_widget(progress, header_chunks[1]);
let (stability_text, stability_color) = if self.stable {
("✓ STABLE", Color::Green)
} else {
("⚡ CONVERGING", Color::Yellow)
};
let stability = Paragraph::new(stability_text)
.style(
Style::default()
.fg(stability_color)
.add_modifier(Modifier::BOLD),
)
.block(Block::default().title("Status").borders(Borders::ALL));
frame.render_widget(stability, header_chunks[2]);
}
fn render_content(&self, frame: &mut Frame, area: Rect) {
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
self.render_energy_panel(frame, content_chunks[0]);
self.render_log_panel(frame, content_chunks[1]);
}
fn render_energy_panel(&self, frame: &mut Frame, area: Rect) {
let panel_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Length(3), Constraint::Length(3), Constraint::Min(3), ])
.split(area);
let mut node_text = format!("📋 {}", self.current_node.as_deref().unwrap_or("None"));
if let Some(ref stage) = self.verifier_stage {
node_text.push_str(&format!(" [{}]", stage));
}
let node = Paragraph::new(node_text)
.block(Block::default().title("Current Task").borders(Borders::ALL));
frame.render_widget(node, panel_chunks[0]);
let energy_color = if self.energy < 0.1 {
Color::Green
} else if self.energy < 0.5 {
Color::Yellow
} else {
Color::Red
};
let energy = Paragraph::new(format!("V(x) = {:.4}", self.energy))
.style(
Style::default()
.fg(energy_color)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.title("Lyapunov Energy")
.borders(Borders::ALL),
);
frame.render_widget(energy, panel_chunks[1]);
if let Some(ref ec) = self.energy_components {
let comp_text = format!(
"syn={:.2} str={:.2} log={:.2} boot={:.2} sheaf={:.2}",
ec.v_syn, ec.v_str, ec.v_log, ec.v_boot, ec.v_sheaf
);
let mut activity_parts = Vec::new();
if self.escalation_count > 0 {
activity_parts.push(format!("{}⚠esc", self.escalation_count));
}
if self.active_branches > 0 {
activity_parts.push(format!("{}🌿br", self.active_branches));
}
let suffix = if activity_parts.is_empty() {
String::new()
} else {
format!(" {}", activity_parts.join(" "))
};
let comp = Paragraph::new(format!("{}{}", comp_text, suffix))
.style(Style::default().fg(Color::Rgb(158, 158, 158)))
.block(Block::default().title("Components").borders(Borders::ALL));
frame.render_widget(comp, panel_chunks[2]);
} else {
let comp = Paragraph::new("No component data yet")
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().title("Components").borders(Borders::ALL));
frame.render_widget(comp, panel_chunks[2]);
}
let sparkline = Sparkline::default()
.block(
Block::default()
.title("Energy History")
.borders(Borders::ALL),
)
.data(&self.energy_history)
.style(Style::default().fg(Color::Magenta));
frame.render_widget(sparkline, panel_chunks[3]);
}
fn render_log_panel(&self, frame: &mut Frame, area: Rect) {
let log_items: Vec<ListItem> = self
.logs
.iter()
.rev()
.take(20)
.map(|log| {
let style = if log.contains("ERROR") {
Style::default().fg(Color::Red)
} else if log.contains("WARN") {
Style::default().fg(Color::Yellow)
} else if log.contains("OK") || log.contains("STABLE") {
Style::default().fg(Color::Green)
} else {
Style::default()
};
ListItem::new(log.as_str()).style(style)
})
.collect();
let logs = List::new(log_items).block(
Block::default()
.title("📜 Activity Log")
.borders(Borders::ALL),
);
frame.render_widget(logs, area);
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
let mut parts = vec!["q:quit p:pause r:resume a:approve d:reject".to_string()];
let mut budget_parts = Vec::new();
let steps_str = self
.budget_max_steps
.map(|m| format!("{}/{}", self.budget_steps_used, m))
.unwrap_or_else(|| format!("{}", self.budget_steps_used));
if self.budget_steps_used > 0 || self.budget_max_steps.is_some() {
budget_parts.push(format!("steps={}", steps_str));
}
if self.budget_cost_used > 0.0 || self.budget_max_cost.is_some() {
let cost_str = self
.budget_max_cost
.map(|m| format!("${:.2}/${:.2}", self.budget_cost_used, m))
.unwrap_or_else(|| format!("${:.2}", self.budget_cost_used));
budget_parts.push(cost_str);
}
if !budget_parts.is_empty() {
parts.push(format!("💰 {}", budget_parts.join(" ")));
}
let help = Paragraph::new(parts.join(" │ "))
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL));
frame.render_widget(help, area);
}
}