use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use presentar_terminal::{CellBuffer, Color, DiffRenderer, Modifiers};
use std::io::{self, Write};
use std::time::Duration;
use super::{AgentDashboardState, StreamEvent};
const CYAN: Color = Color { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
const GREEN: Color = Color { r: 0.2, g: 0.9, b: 0.2, a: 1.0 };
const YELLOW: Color = Color { r: 1.0, g: 0.9, b: 0.0, a: 1.0 };
const RED: Color = Color { r: 1.0, g: 0.2, b: 0.2, a: 1.0 };
pub struct AgentDashboard {
state: AgentDashboardState,
buffer: CellBuffer,
renderer: DiffRenderer,
width: u16,
height: u16,
}
impl AgentDashboard {
pub fn new(state: AgentDashboardState) -> Self {
let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
Self {
state,
buffer: CellBuffer::new(width, height),
renderer: DiffRenderer::new(),
width,
height,
}
}
pub fn run(mut self, rx: &mut tokio::sync::mpsc::Receiver<StreamEvent>) -> anyhow::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let result = self.run_loop(&mut stdout, rx);
disable_raw_mode()?;
execute!(stdout, LeaveAlternateScreen, cursor::Show)?;
result
}
fn run_loop(
&mut self,
stdout: &mut io::Stdout,
rx: &mut tokio::sync::mpsc::Receiver<StreamEvent>,
) -> anyhow::Result<()> {
loop {
self.drain_events(rx);
self.handle_resize();
self.flush_frame(stdout)?;
if Self::poll_quit_key(Duration::from_millis(100))? {
return Ok(());
}
if !self.state.running {
self.flush_frame(stdout)?;
Self::wait_for_any_key()?;
return Ok(());
}
}
}
fn drain_events(&mut self, rx: &mut tokio::sync::mpsc::Receiver<StreamEvent>) {
while let Ok(ev) = rx.try_recv() {
self.state.apply_event(&ev);
}
}
fn handle_resize(&mut self) {
let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
if w != self.width || h != self.height {
self.width = w;
self.height = h;
self.buffer.resize(w, h);
self.renderer.reset();
}
}
fn flush_frame(&mut self, stdout: &mut io::Stdout) -> anyhow::Result<()> {
self.buffer.clear();
self.render();
self.renderer.flush(&mut self.buffer, stdout)?;
stdout.flush()?;
Ok(())
}
fn poll_quit_key(timeout: Duration) -> anyhow::Result<bool> {
if !event::poll(timeout)? {
return Ok(false);
}
let Event::Key(key) = event::read()? else {
return Ok(false);
};
if key.kind != KeyEventKind::Press {
return Ok(false);
}
Ok(matches!(key.code, KeyCode::Char('q') | KeyCode::Esc))
}
fn wait_for_any_key() -> anyhow::Result<()> {
loop {
if !event::poll(Duration::from_millis(200))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Press {
return Ok(());
}
}
}
fn write_str(&mut self, x: u16, y: u16, s: &str, fg: Color) {
let mut cx = x;
for ch in s.chars() {
if cx >= self.width {
break;
}
let mut buf = [0u8; 4];
let encoded = ch.encode_utf8(&mut buf);
self.buffer.update(cx, y, encoded, fg, Color::TRANSPARENT, Modifiers::NONE);
cx = cx.saturating_add(1);
}
}
fn render(&mut self) {
self.render_title_bar(0);
self.render_phase_indicator(2);
self.render_divider(3);
self.render_progress_bars(4);
self.render_token_usage(8);
self.render_divider(10);
self.render_tool_log(11);
self.render_recent_text(self.height.saturating_sub(6));
self.render_help_bar(self.height.saturating_sub(1));
}
fn render_divider(&mut self, row: u16) {
let w = (self.width as usize).min(80);
let divider: String = "─".repeat(w);
self.write_str(0, row, ÷r, Color::WHITE);
}
fn render_title_bar(&mut self, row: u16) {
let title = format!(" Agent: {} ", self.state.agent_name);
for (i, ch) in title.chars().enumerate() {
if (i as u16) >= self.width {
break;
}
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
self.buffer.update(i as u16, row, s, Color::BLACK, CYAN, Modifiers::BOLD);
}
let status = if self.state.running { " RUNNING " } else { " DONE " };
let status_color = if self.state.running { GREEN } else { CYAN };
let x = self.width.saturating_sub(status.len() as u16 + 1);
for (i, ch) in status.chars().enumerate() {
let cx = x + i as u16;
if cx >= self.width {
break;
}
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
self.buffer.update(cx, row, s, Color::BLACK, status_color, Modifiers::BOLD);
}
}
fn render_phase_indicator(&mut self, row: u16) {
let phase_str = format!("{:?}", self.state.phase);
let label = format!(
"Phase: {} | Iteration: {}/{}",
phase_str, self.state.iteration, self.state.max_iterations,
);
self.write_str(1, row, &label, CYAN);
if let Some(ref sr) = self.state.stop_reason {
let reason = format!(" Stop: {sr:?}");
let x = label.len() as u16 + 3;
self.write_str(x, row, &reason, YELLOW);
}
}
fn render_progress_bars(&mut self, row: u16) {
let bar_width = 30usize;
let iter_pct = self.state.iteration_pct();
let iter_label = format!("Iterations: {:>3}%", iter_pct);
self.write_str(1, row, &iter_label, Color::WHITE);
self.render_bar(iter_label.len() as u16 + 2, row, bar_width, iter_pct);
let tool_pct = if self.state.max_tool_calls > 0 {
(self.state.tool_calls * 100) / self.state.max_tool_calls
} else {
0
};
let tool_label = format!("Tool calls: {:>3}%", tool_pct);
self.write_str(1, row + 1, &tool_label, Color::WHITE);
self.render_bar(tool_label.len() as u16 + 2, row + 1, bar_width, tool_pct);
if self.state.token_budget.is_some() {
let tok_pct = self.state.token_budget_pct();
let tok_label = format!("Token budget: {:>3}%", tok_pct);
self.write_str(1, row + 2, &tok_label, Color::WHITE);
self.render_bar(tok_label.len() as u16 + 2, row + 2, bar_width, tok_pct);
}
}
fn render_bar(&mut self, x: u16, y: u16, width: usize, pct: u32) {
let filled = (pct as usize * width) / 100;
let color = if pct >= 90 {
RED
} else if pct >= 70 {
YELLOW
} else {
GREEN
};
let bar: String = "█".repeat(filled) + &"░".repeat(width.saturating_sub(filled));
self.write_str(x, y, &format!("[{bar}]"), color);
}
fn render_token_usage(&mut self, row: u16) {
let total = self.state.usage.input_tokens + self.state.usage.output_tokens;
let usage_str = format!(
"Tokens: {} in / {} out = {} total",
self.state.usage.input_tokens, self.state.usage.output_tokens, total,
);
self.write_str(1, row, &usage_str, Color::WHITE);
if self.state.max_cost_usd > 0.0 {
let cost_str =
format!(" Cost: ${:.4} / ${:.4}", self.state.cost_usd, self.state.max_cost_usd,);
let x = usage_str.len() as u16 + 2;
self.write_str(x, row, &cost_str, YELLOW);
}
}
fn render_tool_log(&mut self, row: u16) {
self.write_str(1, row, "Tool Log:", CYAN);
let max_entries =
(self.height.saturating_sub(row + 8) as usize).min(self.state.tool_log.len());
let max_w = self.width.saturating_sub(2) as usize;
let lines: Vec<String> = self
.state
.tool_log
.iter()
.rev()
.take(max_entries)
.map(|entry| {
let line = format!(" {} → {}", entry.name, entry.result_summary);
if line.len() > max_w {
format!("{}...", &line[..max_w.saturating_sub(3)])
} else {
line
}
})
.collect();
for (i, line) in lines.iter().enumerate() {
self.write_str(1, row + 1 + i as u16, line, Color::WHITE);
}
}
fn render_recent_text(&mut self, row: u16) {
self.write_str(1, row, "Output:", CYAN);
let text: String = self.state.recent_text.join("");
let w = self.width.saturating_sub(4) as usize;
let max_chars = w * 4;
let display = if text.len() > max_chars { &text[text.len() - max_chars..] } else { &text };
for (i, chunk) in display.as_bytes().chunks(w.max(1)).take(4).enumerate() {
let s = String::from_utf8_lossy(chunk);
self.write_str(2, row + 1 + i as u16, &s, GREEN);
}
}
fn render_help_bar(&mut self, row: u16) {
let help = if self.state.running { " q: quit " } else { " Press any key to exit " };
for (i, ch) in help.chars().enumerate() {
if (i as u16) >= self.width {
break;
}
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
self.buffer.update(i as u16, row, s, Color::BLACK, Color::WHITE, Modifiers::NONE);
}
}
}