use std::collections::HashMap;
use std::time::{Duration, Instant};
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyCode, KeyModifiers};
use futures::StreamExt;
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Gauge, Paragraph},
Frame,
};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::error::RslphError;
use crate::eval::{TestResults, TrialEvent, TrialEventKind};
use crate::prompts::PromptMode;
use crate::tui::terminal::{init_terminal, restore_terminal};
#[derive(Debug, Clone)]
pub struct DashboardState {
pub trials: HashMap<(PromptMode, u32), TrialProgress>,
pub start_time: Instant,
pub all_complete: bool,
}
#[derive(Debug, Clone)]
pub struct TrialProgress {
pub mode: PromptMode,
pub trial_num: u32,
pub status: TrialStatus,
pub current_iteration: u32,
pub max_iterations: u32,
pub elapsed_secs: f64,
pub pass_rate: Option<f64>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TrialStatus {
Pending,
Planning,
Building,
Testing,
Complete,
Failed,
}
impl DashboardState {
pub fn new(modes: &[PromptMode], trials_per_mode: u32) -> Self {
let mut trials = HashMap::new();
for mode in modes {
for trial_num in 1..=trials_per_mode {
trials.insert(
(*mode, trial_num),
TrialProgress {
mode: *mode,
trial_num,
status: TrialStatus::Pending,
current_iteration: 0,
max_iterations: 0,
elapsed_secs: 0.0,
pass_rate: None,
error: None,
},
);
}
}
Self {
trials,
start_time: Instant::now(),
all_complete: false,
}
}
pub fn update(&mut self, event: &TrialEvent) {
if let Some(trial) = self.trials.get_mut(&(event.mode, event.trial_num)) {
match &event.event {
TrialEventKind::Started => trial.status = TrialStatus::Pending,
TrialEventKind::Planning => trial.status = TrialStatus::Planning,
TrialEventKind::Building {
iteration,
max_iterations,
} => {
trial.status = TrialStatus::Building;
trial.current_iteration = *iteration;
trial.max_iterations = *max_iterations;
}
TrialEventKind::Testing => trial.status = TrialStatus::Testing,
TrialEventKind::Complete { result } => {
trial.status = TrialStatus::Complete;
trial.pass_rate = result
.eval_result
.test_results
.as_ref()
.map(|tr: &TestResults| tr.pass_rate() / 100.0);
trial.elapsed_secs = result.eval_result.elapsed_secs;
}
TrialEventKind::Failed { error } => {
trial.status = TrialStatus::Failed;
trial.error = Some(error.clone());
}
}
}
self.all_complete = self
.trials
.values()
.all(|t| matches!(t.status, TrialStatus::Complete | TrialStatus::Failed));
}
pub fn total_elapsed_secs(&self) -> f64 {
self.start_time.elapsed().as_secs_f64()
}
}
pub async fn run_dashboard_tui(
modes: Vec<PromptMode>,
trials_per_mode: u32,
mut event_rx: mpsc::UnboundedReceiver<TrialEvent>,
cancel_token: CancellationToken,
) -> Result<(), RslphError> {
let mut terminal = init_terminal()
.map_err(|e| RslphError::Subprocess(format!("Terminal init failed: {}", e)))?;
let mut state = DashboardState::new(&modes, trials_per_mode);
let mut event_stream = EventStream::new();
let mut render_interval = tokio::time::interval(Duration::from_millis(33));
loop {
terminal
.draw(|frame| {
render_dashboard(frame, frame.area(), &state);
})
.map_err(|e| RslphError::Subprocess(format!("Render failed: {}", e)))?;
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
break;
}
maybe_key = event_stream.next() => {
match maybe_key {
Some(Ok(CrosstermEvent::Key(key))) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
if let KeyCode::Char('c') = key.code {
cancel_token.cancel();
break;
}
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
cancel_token.cancel();
break;
}
_ => {}
}
}
Some(Ok(CrosstermEvent::Resize(_, _))) => {
}
Some(Err(_)) | None => {
}
_ => {}
}
}
event = event_rx.recv() => {
match event {
Some(trial_event) => {
state.update(&trial_event);
if state.all_complete {
let _ = terminal.draw(|frame| {
render_dashboard(frame, frame.area(), &state);
});
tokio::time::sleep(Duration::from_secs(2)).await;
break;
}
}
None => {
break;
}
}
}
_ = render_interval.tick() => {
}
}
}
restore_terminal()
.map_err(|e| RslphError::Subprocess(format!("Terminal restore failed: {}", e)))?;
Ok(())
}
pub fn render_dashboard(frame: &mut Frame, area: Rect, state: &DashboardState) {
let mut modes: Vec<PromptMode> = state
.trials
.keys()
.map(|(m, _)| *m)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
modes.sort_by_key(|m| format!("{:?}", m));
let mode_count = modes.len();
if mode_count == 0 {
let msg =
Paragraph::new("No trials configured").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
let [header_area, content_area] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(area);
render_header(frame, header_area, state);
let col_constraints: Vec<Constraint> = (0..mode_count)
.map(|_| Constraint::Percentage(100 / mode_count as u16))
.collect();
let columns = Layout::horizontal(col_constraints).split(content_area);
for (col_idx, mode) in modes.iter().enumerate() {
render_mode_column(frame, columns[col_idx], *mode, state);
}
}
fn render_header(frame: &mut Frame, area: Rect, state: &DashboardState) {
let elapsed = state.total_elapsed_secs();
let completed = state
.trials
.values()
.filter(|t| matches!(t.status, TrialStatus::Complete | TrialStatus::Failed))
.count();
let total = state.trials.len();
let status_char = if state.all_complete {
"Done"
} else {
"Running"
};
let header_text = format!(
"Parallel Eval [{status_char}] | {completed}/{total} trials | Elapsed: {elapsed:.1}s"
);
let header = Paragraph::new(header_text)
.style(Style::default().add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::BOTTOM));
frame.render_widget(header, area);
}
fn render_mode_column(frame: &mut Frame, area: Rect, mode: PromptMode, state: &DashboardState) {
let mut trials: Vec<&TrialProgress> =
state.trials.values().filter(|t| t.mode == mode).collect();
trials.sort_by_key(|t| t.trial_num);
let row_constraints: Vec<Constraint> = std::iter::once(Constraint::Length(2))
.chain(trials.iter().map(|_| Constraint::Length(4)))
.chain(std::iter::once(Constraint::Min(0))) .collect();
let rows = Layout::vertical(row_constraints).split(area);
let header = Paragraph::new(format!("{:?}", mode))
.style(Style::default().add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::BOTTOM));
frame.render_widget(header, rows[0]);
for (idx, trial) in trials.iter().enumerate() {
render_trial_cell(frame, rows[idx + 1], trial);
}
}
fn render_trial_cell(frame: &mut Frame, area: Rect, trial: &TrialProgress) {
let status_color = match trial.status {
TrialStatus::Pending => Color::DarkGray,
TrialStatus::Planning => Color::Yellow,
TrialStatus::Building => Color::Blue,
TrialStatus::Testing => Color::Cyan,
TrialStatus::Complete => Color::Green,
TrialStatus::Failed => Color::Red,
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(status_color))
.title(format!("Trial {}", trial.trial_num));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 2 {
return;
}
let [status_area, progress_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
let status_text = match &trial.status {
TrialStatus::Pending => "Pending...".to_string(),
TrialStatus::Planning => "Planning...".to_string(),
TrialStatus::Building => {
format!("Build {}/{}", trial.current_iteration, trial.max_iterations)
}
TrialStatus::Testing => "Testing...".to_string(),
TrialStatus::Complete => format!("Done: {:.0}%", trial.pass_rate.unwrap_or(0.0) * 100.0),
TrialStatus::Failed => "FAILED".to_string(),
};
let status = Paragraph::new(status_text).style(Style::default().fg(status_color));
frame.render_widget(status, status_area);
if trial.status == TrialStatus::Building && trial.max_iterations > 0 {
let progress = trial.current_iteration as f64 / trial.max_iterations as f64;
let gauge = Gauge::default()
.ratio(progress.min(1.0))
.gauge_style(Style::default().fg(Color::Blue));
frame.render_widget(gauge, progress_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dashboard_state_new() {
let modes = vec![PromptMode::Basic, PromptMode::Gsd];
let state = DashboardState::new(&modes, 2);
assert_eq!(state.trials.len(), 4); assert!(!state.all_complete);
for trial in state.trials.values() {
assert_eq!(trial.status, TrialStatus::Pending);
}
}
#[test]
fn test_dashboard_state_update_started() {
let modes = vec![PromptMode::Basic];
let mut state = DashboardState::new(&modes, 1);
let event = TrialEvent {
mode: PromptMode::Basic,
trial_num: 1,
event: TrialEventKind::Started,
};
state.update(&event);
let trial = state.trials.get(&(PromptMode::Basic, 1)).unwrap();
assert_eq!(trial.status, TrialStatus::Pending);
}
#[test]
fn test_dashboard_state_update_planning() {
let modes = vec![PromptMode::Basic];
let mut state = DashboardState::new(&modes, 1);
let event = TrialEvent {
mode: PromptMode::Basic,
trial_num: 1,
event: TrialEventKind::Planning,
};
state.update(&event);
let trial = state.trials.get(&(PromptMode::Basic, 1)).unwrap();
assert_eq!(trial.status, TrialStatus::Planning);
}
#[test]
fn test_dashboard_state_update_building() {
let modes = vec![PromptMode::Basic];
let mut state = DashboardState::new(&modes, 1);
let event = TrialEvent {
mode: PromptMode::Basic,
trial_num: 1,
event: TrialEventKind::Building {
iteration: 3,
max_iterations: 10,
},
};
state.update(&event);
let trial = state.trials.get(&(PromptMode::Basic, 1)).unwrap();
assert_eq!(trial.status, TrialStatus::Building);
assert_eq!(trial.current_iteration, 3);
assert_eq!(trial.max_iterations, 10);
}
#[test]
fn test_dashboard_state_update_failed() {
let modes = vec![PromptMode::Basic];
let mut state = DashboardState::new(&modes, 1);
let event = TrialEvent {
mode: PromptMode::Basic,
trial_num: 1,
event: TrialEventKind::Failed {
error: "Test error".to_string(),
},
};
state.update(&event);
let trial = state.trials.get(&(PromptMode::Basic, 1)).unwrap();
assert_eq!(trial.status, TrialStatus::Failed);
assert_eq!(trial.error, Some("Test error".to_string()));
assert!(state.all_complete); }
#[test]
fn test_dashboard_state_all_complete() {
let modes = vec![PromptMode::Basic, PromptMode::Gsd];
let mut state = DashboardState::new(&modes, 1);
assert!(!state.all_complete);
let event1 = TrialEvent {
mode: PromptMode::Basic,
trial_num: 1,
event: TrialEventKind::Failed {
error: "Error".to_string(),
},
};
state.update(&event1);
assert!(!state.all_complete);
let event2 = TrialEvent {
mode: PromptMode::Gsd,
trial_num: 1,
event: TrialEventKind::Failed {
error: "Error".to_string(),
},
};
state.update(&event2);
assert!(state.all_complete); }
#[test]
fn test_trial_status_variants() {
let _ = TrialStatus::Pending;
let _ = TrialStatus::Planning;
let _ = TrialStatus::Building;
let _ = TrialStatus::Testing;
let _ = TrialStatus::Complete;
let _ = TrialStatus::Failed;
}
}