1use crossterm::{
7 cursor,
8 event::{self, Event, KeyCode, KeyEventKind},
9 execute,
10 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11};
12
13use presentar_terminal::{CellBuffer, Color, DiffRenderer, Modifiers};
14
15use std::io::{self, Write};
16use std::time::Duration;
17
18use super::{AgentDashboardState, StreamEvent};
19
20const CYAN: Color = Color { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
21const GREEN: Color = Color { r: 0.2, g: 0.9, b: 0.2, a: 1.0 };
22const YELLOW: Color = Color { r: 1.0, g: 0.9, b: 0.0, a: 1.0 };
23const RED: Color = Color { r: 1.0, g: 0.2, b: 0.2, a: 1.0 };
24
25pub struct AgentDashboard {
27 state: AgentDashboardState,
28 buffer: CellBuffer,
29 renderer: DiffRenderer,
30 width: u16,
31 height: u16,
32}
33
34impl AgentDashboard {
35 pub fn new(state: AgentDashboardState) -> Self {
37 let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
38 Self {
39 state,
40 buffer: CellBuffer::new(width, height),
41 renderer: DiffRenderer::new(),
42 width,
43 height,
44 }
45 }
46
47 pub fn run(mut self, rx: &mut tokio::sync::mpsc::Receiver<StreamEvent>) -> anyhow::Result<()> {
49 enable_raw_mode()?;
50 let mut stdout = io::stdout();
51 execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
52
53 let result = self.run_loop(&mut stdout, rx);
54
55 disable_raw_mode()?;
56 execute!(stdout, LeaveAlternateScreen, cursor::Show)?;
57
58 result
59 }
60
61 fn run_loop(
62 &mut self,
63 stdout: &mut io::Stdout,
64 rx: &mut tokio::sync::mpsc::Receiver<StreamEvent>,
65 ) -> anyhow::Result<()> {
66 loop {
67 self.drain_events(rx);
68 self.handle_resize();
69 self.flush_frame(stdout)?;
70
71 if Self::poll_quit_key(Duration::from_millis(100))? {
72 return Ok(());
73 }
74
75 if !self.state.running {
76 self.flush_frame(stdout)?;
77 Self::wait_for_any_key()?;
78 return Ok(());
79 }
80 }
81 }
82
83 fn drain_events(&mut self, rx: &mut tokio::sync::mpsc::Receiver<StreamEvent>) {
84 while let Ok(ev) = rx.try_recv() {
85 self.state.apply_event(&ev);
86 }
87 }
88
89 fn handle_resize(&mut self) {
90 let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
91 if w != self.width || h != self.height {
92 self.width = w;
93 self.height = h;
94 self.buffer.resize(w, h);
95 self.renderer.reset();
96 }
97 }
98
99 fn flush_frame(&mut self, stdout: &mut io::Stdout) -> anyhow::Result<()> {
100 self.buffer.clear();
101 self.render();
102 self.renderer.flush(&mut self.buffer, stdout)?;
103 stdout.flush()?;
104 Ok(())
105 }
106
107 fn poll_quit_key(timeout: Duration) -> anyhow::Result<bool> {
109 if !event::poll(timeout)? {
110 return Ok(false);
111 }
112 let Event::Key(key) = event::read()? else {
113 return Ok(false);
114 };
115 if key.kind != KeyEventKind::Press {
116 return Ok(false);
117 }
118 Ok(matches!(key.code, KeyCode::Char('q') | KeyCode::Esc))
119 }
120
121 fn wait_for_any_key() -> anyhow::Result<()> {
123 loop {
124 if !event::poll(Duration::from_millis(200))? {
125 continue;
126 }
127 let Event::Key(key) = event::read()? else {
128 continue;
129 };
130 if key.kind == KeyEventKind::Press {
131 return Ok(());
132 }
133 }
134 }
135
136 fn write_str(&mut self, x: u16, y: u16, s: &str, fg: Color) {
138 let mut cx = x;
139 for ch in s.chars() {
140 if cx >= self.width {
141 break;
142 }
143 let mut buf = [0u8; 4];
144 let encoded = ch.encode_utf8(&mut buf);
145 self.buffer.update(cx, y, encoded, fg, Color::TRANSPARENT, Modifiers::NONE);
146 cx = cx.saturating_add(1);
147 }
148 }
149
150 fn render(&mut self) {
151 self.render_title_bar(0);
152 self.render_phase_indicator(2);
153 self.render_divider(3);
154 self.render_progress_bars(4);
155 self.render_token_usage(8);
156 self.render_divider(10);
157 self.render_tool_log(11);
158 self.render_recent_text(self.height.saturating_sub(6));
159 self.render_help_bar(self.height.saturating_sub(1));
160 }
161
162 fn render_divider(&mut self, row: u16) {
163 let w = (self.width as usize).min(80);
164 let divider: String = "─".repeat(w);
165 self.write_str(0, row, ÷r, Color::WHITE);
166 }
167
168 fn render_title_bar(&mut self, row: u16) {
169 let title = format!(" Agent: {} ", self.state.agent_name);
170 for (i, ch) in title.chars().enumerate() {
171 if (i as u16) >= self.width {
172 break;
173 }
174 let mut buf = [0u8; 4];
175 let s = ch.encode_utf8(&mut buf);
176 self.buffer.update(i as u16, row, s, Color::BLACK, CYAN, Modifiers::BOLD);
177 }
178
179 let status = if self.state.running { " RUNNING " } else { " DONE " };
180 let status_color = if self.state.running { GREEN } else { CYAN };
181 let x = self.width.saturating_sub(status.len() as u16 + 1);
182 for (i, ch) in status.chars().enumerate() {
183 let cx = x + i as u16;
184 if cx >= self.width {
185 break;
186 }
187 let mut buf = [0u8; 4];
188 let s = ch.encode_utf8(&mut buf);
189 self.buffer.update(cx, row, s, Color::BLACK, status_color, Modifiers::BOLD);
190 }
191 }
192
193 fn render_phase_indicator(&mut self, row: u16) {
194 let phase_str = format!("{:?}", self.state.phase);
195 let label = format!(
196 "Phase: {} | Iteration: {}/{}",
197 phase_str, self.state.iteration, self.state.max_iterations,
198 );
199 self.write_str(1, row, &label, CYAN);
200
201 if let Some(ref sr) = self.state.stop_reason {
202 let reason = format!(" Stop: {sr:?}");
203 let x = label.len() as u16 + 3;
204 self.write_str(x, row, &reason, YELLOW);
205 }
206 }
207
208 fn render_progress_bars(&mut self, row: u16) {
209 let bar_width = 30usize;
210
211 let iter_pct = self.state.iteration_pct();
212 let iter_label = format!("Iterations: {:>3}%", iter_pct);
213 self.write_str(1, row, &iter_label, Color::WHITE);
214 self.render_bar(iter_label.len() as u16 + 2, row, bar_width, iter_pct);
215
216 let tool_pct = if self.state.max_tool_calls > 0 {
217 (self.state.tool_calls * 100) / self.state.max_tool_calls
218 } else {
219 0
220 };
221 let tool_label = format!("Tool calls: {:>3}%", tool_pct);
222 self.write_str(1, row + 1, &tool_label, Color::WHITE);
223 self.render_bar(tool_label.len() as u16 + 2, row + 1, bar_width, tool_pct);
224
225 if self.state.token_budget.is_some() {
226 let tok_pct = self.state.token_budget_pct();
227 let tok_label = format!("Token budget: {:>3}%", tok_pct);
228 self.write_str(1, row + 2, &tok_label, Color::WHITE);
229 self.render_bar(tok_label.len() as u16 + 2, row + 2, bar_width, tok_pct);
230 }
231 }
232
233 fn render_bar(&mut self, x: u16, y: u16, width: usize, pct: u32) {
234 let filled = (pct as usize * width) / 100;
235 let color = if pct >= 90 {
236 RED
237 } else if pct >= 70 {
238 YELLOW
239 } else {
240 GREEN
241 };
242 let bar: String = "█".repeat(filled) + &"░".repeat(width.saturating_sub(filled));
243 self.write_str(x, y, &format!("[{bar}]"), color);
244 }
245
246 fn render_token_usage(&mut self, row: u16) {
247 let total = self.state.usage.input_tokens + self.state.usage.output_tokens;
248 let usage_str = format!(
249 "Tokens: {} in / {} out = {} total",
250 self.state.usage.input_tokens, self.state.usage.output_tokens, total,
251 );
252 self.write_str(1, row, &usage_str, Color::WHITE);
253
254 if self.state.max_cost_usd > 0.0 {
255 let cost_str =
256 format!(" Cost: ${:.4} / ${:.4}", self.state.cost_usd, self.state.max_cost_usd,);
257 let x = usage_str.len() as u16 + 2;
258 self.write_str(x, row, &cost_str, YELLOW);
259 }
260 }
261
262 fn render_tool_log(&mut self, row: u16) {
263 self.write_str(1, row, "Tool Log:", CYAN);
264
265 let max_entries =
266 (self.height.saturating_sub(row + 8) as usize).min(self.state.tool_log.len());
267 let max_w = self.width.saturating_sub(2) as usize;
268
269 let lines: Vec<String> = self
270 .state
271 .tool_log
272 .iter()
273 .rev()
274 .take(max_entries)
275 .map(|entry| {
276 let line = format!(" {} → {}", entry.name, entry.result_summary);
277 if line.len() > max_w {
278 format!("{}...", &line[..max_w.saturating_sub(3)])
279 } else {
280 line
281 }
282 })
283 .collect();
284
285 for (i, line) in lines.iter().enumerate() {
286 self.write_str(1, row + 1 + i as u16, line, Color::WHITE);
287 }
288 }
289
290 fn render_recent_text(&mut self, row: u16) {
291 self.write_str(1, row, "Output:", CYAN);
292
293 let text: String = self.state.recent_text.join("");
294 let w = self.width.saturating_sub(4) as usize;
295 let max_chars = w * 4;
296 let display = if text.len() > max_chars { &text[text.len() - max_chars..] } else { &text };
297
298 for (i, chunk) in display.as_bytes().chunks(w.max(1)).take(4).enumerate() {
299 let s = String::from_utf8_lossy(chunk);
300 self.write_str(2, row + 1 + i as u16, &s, GREEN);
301 }
302 }
303
304 fn render_help_bar(&mut self, row: u16) {
305 let help = if self.state.running { " q: quit " } else { " Press any key to exit " };
306 for (i, ch) in help.chars().enumerate() {
307 if (i as u16) >= self.width {
308 break;
309 }
310 let mut buf = [0u8; 4];
311 let s = ch.encode_utf8(&mut buf);
312 self.buffer.update(i as u16, row, s, Color::BLACK, Color::WHITE, Modifiers::NONE);
313 }
314 }
315}