Skip to main content

batuta/agent/
tui_render.rs

1//! Agent TUI rendering (presentar-terminal backend).
2//!
3//! Extracted from `tui.rs` for QA-002 compliance (≤500 lines).
4//! Feature-gated behind `presentar-terminal`.
5
6use 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
25/// Interactive agent TUI dashboard.
26pub struct AgentDashboard {
27    state: AgentDashboardState,
28    buffer: CellBuffer,
29    renderer: DiffRenderer,
30    width: u16,
31    height: u16,
32}
33
34impl AgentDashboard {
35    /// Create a new dashboard from agent state.
36    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    /// Run the dashboard loop, receiving events from a channel.
48    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    /// Returns `true` if quit key (q/Esc) was pressed.
108    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    /// Block until any key is pressed.
122    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    /// Write a string character-by-character into the cell buffer.
137    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, &divider, 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}