use ratatui::{ prelude::*, widgets::{Block, Borders, List, ListItem, Paragraph, Tabs}, }; use crate::config::Config; use crate::session::store::StateStore; use crate::session::{Session, SessionState}; pub struct Dashboard { db: StateStore, cfg: Config, sessions: Vec, selected_pane: Pane, selected_session: usize, show_help: bool, scroll_offset: usize, } #[derive(Debug, Clone, Copy, PartialEq)] enum Pane { Sessions, Output, Metrics, } impl Dashboard { pub fn new(db: StateStore, cfg: Config) -> Self { let sessions = db.list_sessions().unwrap_or_default(); Self { db, cfg, sessions, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, scroll_offset: 0, } } pub fn render(&self, frame: &mut Frame) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Header Constraint::Min(10), // Main content Constraint::Length(3), // Status bar ]) .split(frame.area()); self.render_header(frame, chunks[0]); if self.show_help { self.render_help(frame, chunks[1]); } else { let main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(35), // Session list Constraint::Percentage(65), // Output/details ]) .split(chunks[1]); self.render_sessions(frame, main_chunks[0]); let right_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage(70), // Output Constraint::Percentage(30), // Metrics ]) .split(main_chunks[1]); self.render_output(frame, right_chunks[0]); self.render_metrics(frame, right_chunks[1]); } self.render_status_bar(frame, chunks[2]); } fn render_header(&self, frame: &mut Frame, area: Rect) { let running = self .sessions .iter() .filter(|s| s.state == SessionState::Running) .count(); let total = self.sessions.len(); let title = format!(" ECC 2.0 | {running} running / {total} total "); let tabs = Tabs::new(vec!["Sessions", "Output", "Metrics"]) .block(Block::default().borders(Borders::ALL).title(title)) .select(match self.selected_pane { Pane::Sessions => 0, Pane::Output => 1, Pane::Metrics => 2, }) .highlight_style( Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ); frame.render_widget(tabs, area); } fn render_sessions(&self, frame: &mut Frame, area: Rect) { let items: Vec = self .sessions .iter() .enumerate() .map(|(i, s)| { let state_icon = match s.state { SessionState::Running => "●", SessionState::Idle => "○", SessionState::Completed => "✓", SessionState::Failed => "✗", SessionState::Stopped => "■", SessionState::Pending => "◌", }; let style = if i == self.selected_session { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default() }; let text = format!( "{state_icon} {} [{}] {}", &s.id[..8.min(s.id.len())], s.agent_type, s.task ); ListItem::new(text).style(style) }) .collect(); let border_style = if self.selected_pane == Pane::Sessions { Style::default().fg(Color::Cyan) } else { Style::default() }; let list = List::new(items).block( Block::default() .borders(Borders::ALL) .title(" Sessions ") .border_style(border_style), ); frame.render_widget(list, area); } fn render_output(&self, frame: &mut Frame, area: Rect) { let content = if let Some(session) = self.sessions.get(self.selected_session) { format!( "Agent output for session {}...\n\n(Live streaming coming soon)", session.id ) } else { "No sessions. Press 'n' to start one.".to_string() }; let border_style = if self.selected_pane == Pane::Output { Style::default().fg(Color::Cyan) } else { Style::default() }; let paragraph = Paragraph::new(content).block( Block::default() .borders(Borders::ALL) .title(" Output ") .border_style(border_style), ); frame.render_widget(paragraph, area); } fn render_metrics(&self, frame: &mut Frame, area: Rect) { let content = if let Some(session) = self.sessions.get(self.selected_session) { let m = &session.metrics; format!( "Tokens: {} | Tools: {} | Files: {} | Cost: ${:.4} | Duration: {}s", m.tokens_used, m.tool_calls, m.files_changed, m.cost_usd, m.duration_secs ) } else { "No metrics available".to_string() }; let border_style = if self.selected_pane == Pane::Metrics { Style::default().fg(Color::Cyan) } else { Style::default() }; let paragraph = Paragraph::new(content).block( Block::default() .borders(Borders::ALL) .title(" Metrics ") .border_style(border_style), ); frame.render_widget(paragraph, area); } fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let text = " [n]ew session [s]top [Tab] switch pane [j/k] scroll [?] help [q]uit "; let paragraph = Paragraph::new(text) .style(Style::default().fg(Color::DarkGray)) .block(Block::default().borders(Borders::ALL)); frame.render_widget(paragraph, area); } fn render_help(&self, frame: &mut Frame, area: Rect) { let help = vec![ "Keyboard Shortcuts:", "", " n New session", " s Stop selected session", " Tab Next pane", " S-Tab Previous pane", " j/↓ Scroll down", " k/↑ Scroll up", " r Refresh", " ? Toggle help", " q/C-c Quit", ]; let paragraph = Paragraph::new(help.join("\n")).block( Block::default() .borders(Borders::ALL) .title(" Help ") .border_style(Style::default().fg(Color::Yellow)), ); frame.render_widget(paragraph, area); } pub fn next_pane(&mut self) { self.selected_pane = match self.selected_pane { Pane::Sessions => Pane::Output, Pane::Output => Pane::Metrics, Pane::Metrics => Pane::Sessions, }; } pub fn prev_pane(&mut self) { self.selected_pane = match self.selected_pane { Pane::Sessions => Pane::Metrics, Pane::Output => Pane::Sessions, Pane::Metrics => Pane::Output, }; } pub fn scroll_down(&mut self) { if self.selected_pane == Pane::Sessions && !self.sessions.is_empty() { self.selected_session = (self.selected_session + 1).min(self.sessions.len() - 1); } else { self.scroll_offset = self.scroll_offset.saturating_add(1); } } pub fn scroll_up(&mut self) { if self.selected_pane == Pane::Sessions { self.selected_session = self.selected_session.saturating_sub(1); } else { self.scroll_offset = self.scroll_offset.saturating_sub(1); } } pub fn new_session(&mut self) { // TODO: Open a dialog to create a new session tracing::info!("New session dialog requested"); } pub fn stop_selected(&mut self) { if let Some(session) = self.sessions.get(self.selected_session) { let _ = self.db.update_state(&session.id, &SessionState::Stopped); self.refresh(); } } pub fn refresh(&mut self) { self.sessions = self.db.list_sessions().unwrap_or_default(); } pub fn toggle_help(&mut self) { self.show_help = !self.show_help; } pub async fn tick(&mut self) { // Periodic refresh every few ticks self.sessions = self.db.list_sessions().unwrap_or_default(); } }