Skip to main content

batuta/agent/
tui.rs

1//! Agent TUI Dashboard
2//!
3//! Interactive terminal dashboard for monitoring agent loop execution.
4//! Uses presentar-terminal for rendering and crossterm for terminal control.
5//!
6//! Launched by `batuta agent status --tui` or during `batuta agent run --stream`.
7
8pub(crate) use crate::agent::driver::StreamEvent;
9use crate::agent::manifest::AgentManifest;
10use crate::agent::phase::LoopPhase;
11use crate::agent::result::{StopReason, TokenUsage};
12
13/// Truncate a string to `max_len`, appending "..." if needed.
14fn truncate_str(s: &str, max_len: usize) -> String {
15    if s.len() <= max_len {
16        s.to_owned()
17    } else {
18        format!("{}...", &s[..max_len.saturating_sub(3)])
19    }
20}
21
22/// Snapshot of agent loop state for TUI rendering.
23#[derive(Debug, Clone)]
24pub struct AgentDashboardState {
25    /// Agent name from manifest.
26    pub agent_name: String,
27    /// Current loop phase.
28    pub phase: LoopPhase,
29    /// Current iteration number.
30    pub iteration: u32,
31    /// Maximum iterations allowed.
32    pub max_iterations: u32,
33    /// Cumulative token usage.
34    pub usage: TokenUsage,
35    /// Token budget (None = unlimited).
36    pub token_budget: Option<u64>,
37    /// Tool calls executed.
38    pub tool_calls: u32,
39    /// Max tool calls allowed.
40    pub max_tool_calls: u32,
41    /// Recent text fragments.
42    pub recent_text: Vec<String>,
43    /// Recent tool call log.
44    pub tool_log: Vec<ToolLogEntry>,
45    /// Accumulated cost (USD).
46    pub cost_usd: f64,
47    /// Max cost budget.
48    pub max_cost_usd: f64,
49    /// Whether the loop is still running.
50    pub running: bool,
51    /// Final stop reason (if completed).
52    pub stop_reason: Option<StopReason>,
53}
54
55/// A log entry for a tool call.
56#[derive(Debug, Clone)]
57pub struct ToolLogEntry {
58    /// Tool name.
59    pub name: String,
60    /// Brief input summary.
61    pub input_summary: String,
62    /// Result summary.
63    pub result_summary: String,
64}
65
66impl AgentDashboardState {
67    /// Create initial state from manifest.
68    pub fn from_manifest(manifest: &AgentManifest) -> Self {
69        Self {
70            agent_name: manifest.name.clone(),
71            phase: LoopPhase::Perceive,
72            iteration: 0,
73            max_iterations: manifest.resources.max_iterations,
74            usage: TokenUsage { input_tokens: 0, output_tokens: 0 },
75            token_budget: manifest.resources.max_tokens_budget,
76            tool_calls: 0,
77            max_tool_calls: manifest.resources.max_tool_calls,
78            recent_text: Vec::new(),
79            tool_log: Vec::new(),
80            cost_usd: 0.0,
81            max_cost_usd: manifest.resources.max_cost_usd,
82            running: true,
83            stop_reason: None,
84        }
85    }
86
87    /// Apply a stream event to update state.
88    pub fn apply_event(&mut self, event: &StreamEvent) {
89        match event {
90            StreamEvent::PhaseChange { phase } => {
91                self.phase = phase.clone();
92            }
93            StreamEvent::TextDelta { text } => {
94                self.push_text(text);
95            }
96            StreamEvent::ToolUseStart { name, .. } => {
97                self.push_tool_start(name);
98            }
99            StreamEvent::ToolUseEnd { name, result, .. } => {
100                self.complete_tool(name, result);
101            }
102            StreamEvent::ContentComplete { stop_reason, usage } => {
103                self.usage = usage.clone();
104                self.stop_reason = Some(stop_reason.clone());
105                self.running = false;
106            }
107        }
108    }
109
110    fn push_text(&mut self, text: &str) {
111        self.recent_text.push(text.to_owned());
112        if self.recent_text.len() > 20 {
113            self.recent_text.remove(0);
114        }
115    }
116
117    fn push_tool_start(&mut self, name: &str) {
118        self.tool_calls += 1;
119        self.tool_log.push(ToolLogEntry {
120            name: name.to_owned(),
121            input_summary: String::new(),
122            result_summary: "running...".into(),
123        });
124        if self.tool_log.len() > 10 {
125            self.tool_log.remove(0);
126        }
127    }
128
129    fn complete_tool(&mut self, name: &str, result: &str) {
130        let Some(entry) = self.tool_log.iter_mut().rev().find(|e| e.name == name) else {
131            return;
132        };
133        entry.result_summary = truncate_str(result, 60);
134    }
135
136    /// Iteration progress as percentage (0-100).
137    pub fn iteration_pct(&self) -> u32 {
138        if self.max_iterations == 0 {
139            return 0;
140        }
141        (self.iteration * 100) / self.max_iterations
142    }
143
144    /// Token budget usage percentage (0-100), or 0 if unlimited.
145    pub fn token_budget_pct(&self) -> u32 {
146        let Some(budget) = self.token_budget else {
147            return 0;
148        };
149        if budget == 0 {
150            return 0;
151        }
152        let total = self.usage.input_tokens + self.usage.output_tokens;
153        ((total * 100) / budget) as u32
154    }
155}
156
157// ============================================================================
158// TUI rendering (feature-gated)
159// ============================================================================
160// TUI rendering (feature-gated behind presentar-terminal)
161// ============================================================================
162
163#[cfg(feature = "presentar-terminal")]
164#[path = "tui_render.rs"]
165mod tui_render;
166
167#[cfg(feature = "presentar-terminal")]
168pub use tui_render::AgentDashboard;
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_dashboard_state_from_manifest() {
176        let manifest = AgentManifest::default();
177        let state = AgentDashboardState::from_manifest(&manifest);
178        assert!(state.running);
179        assert_eq!(state.iteration, 0);
180        assert_eq!(state.max_iterations, manifest.resources.max_iterations,);
181    }
182
183    #[test]
184    fn test_apply_text_delta() {
185        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
186        state.apply_event(&StreamEvent::TextDelta { text: "hello".into() });
187        assert_eq!(state.recent_text.len(), 1);
188        assert_eq!(state.recent_text[0], "hello");
189    }
190
191    #[test]
192    fn test_apply_content_complete() {
193        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
194        state.apply_event(&StreamEvent::ContentComplete {
195            stop_reason: StopReason::EndTurn,
196            usage: TokenUsage { input_tokens: 100, output_tokens: 50 },
197        });
198        assert!(!state.running);
199        assert_eq!(state.usage.input_tokens, 100);
200        assert!(matches!(state.stop_reason, Some(StopReason::EndTurn)));
201    }
202
203    #[test]
204    fn test_apply_tool_use_events() {
205        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
206        state.apply_event(&StreamEvent::ToolUseStart { id: "1".into(), name: "rag".into() });
207        assert_eq!(state.tool_calls, 1);
208        assert_eq!(state.tool_log.len(), 1);
209        assert_eq!(state.tool_log[0].name, "rag");
210
211        state.apply_event(&StreamEvent::ToolUseEnd {
212            id: "1".into(),
213            name: "rag".into(),
214            result: "found 3 results".into(),
215        });
216        assert_eq!(state.tool_log[0].result_summary, "found 3 results",);
217    }
218
219    #[test]
220    fn test_apply_phase_change() {
221        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
222        state.apply_event(&StreamEvent::PhaseChange {
223            phase: LoopPhase::Act { tool_name: "rag".into() },
224        });
225        assert!(matches!(state.phase, LoopPhase::Act { .. }));
226    }
227
228    #[test]
229    fn test_iteration_pct() {
230        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
231        state.max_iterations = 10;
232        state.iteration = 3;
233        assert_eq!(state.iteration_pct(), 30);
234    }
235
236    #[test]
237    fn test_iteration_pct_zero_max() {
238        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
239        state.max_iterations = 0;
240        assert_eq!(state.iteration_pct(), 0);
241    }
242
243    #[test]
244    fn test_token_budget_pct() {
245        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
246        state.token_budget = Some(1000);
247        state.usage = TokenUsage { input_tokens: 400, output_tokens: 100 };
248        assert_eq!(state.token_budget_pct(), 50);
249    }
250
251    #[test]
252    fn test_token_budget_pct_unlimited() {
253        let state = AgentDashboardState::from_manifest(&AgentManifest::default());
254        assert_eq!(state.token_budget_pct(), 0);
255    }
256
257    #[test]
258    fn test_recent_text_capped_at_20() {
259        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
260        for i in 0..25 {
261            state.apply_event(&StreamEvent::TextDelta { text: format!("t{i}") });
262        }
263        assert_eq!(state.recent_text.len(), 20);
264        assert_eq!(state.recent_text[0], "t5");
265    }
266
267    #[test]
268    fn test_tool_log_capped_at_10() {
269        let mut state = AgentDashboardState::from_manifest(&AgentManifest::default());
270        for i in 0..12 {
271            state.apply_event(&StreamEvent::ToolUseStart {
272                id: format!("{i}"),
273                name: format!("tool_{i}"),
274            });
275        }
276        assert_eq!(state.tool_log.len(), 10);
277        assert_eq!(state.tool_log[0].name, "tool_2");
278    }
279}