agent_trace/adapters/tui/
app.rs1use super::panels::{ChangelogState, ChatState, Focus, TreeState};
2use crate::manifest::Manifest;
3use crate::poll::UiEvent;
4use crate::types::LogEntry;
5use anyhow::Result;
6use crossterm::event::{Event, KeyCode, KeyModifiers};
7use ratatui::{
8 backend::Backend,
9 layout::{Constraint, Direction, Layout, Rect},
10 style::{Color, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, Paragraph},
13 Frame, Terminal,
14};
15use std::path::PathBuf;
16use std::sync::{Arc, Mutex};
17
18const MIN_WIDTH: u16 = 80;
20const MIN_HEIGHT: u16 = 24;
21
22pub struct App {
25 _store_root: PathBuf,
26 pub manifest: Arc<Mutex<Manifest>>,
27 pub tree: TreeState,
28 pub changelog: ChangelogState,
29 pub chat: ChatState,
30 pub focus: Focus,
31 pub ui_rx: tokio::sync::mpsc::Receiver<UiEvent>,
32 pub should_quit: bool,
33}
34
35impl App {
36 pub fn new(
37 store_root: PathBuf,
38 manifest: Arc<Mutex<Manifest>>,
39 initial_log: Vec<LogEntry>,
40 command_history: Vec<String>,
41 ui_rx: tokio::sync::mpsc::Receiver<UiEvent>,
42 ) -> Self {
43 let tree = {
44 let m = manifest.lock().unwrap();
45 TreeState::new(&m)
46 };
47 Self {
48 _store_root: store_root,
49 manifest,
50 tree,
51 changelog: ChangelogState::new(initial_log),
52 chat: ChatState::new(command_history),
53 focus: Focus::Chat,
54 ui_rx,
55 should_quit: false,
56 }
57 }
58
59 pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
60 loop {
61 terminal.draw(|f| self.render(f))?;
62
63 if crossterm::event::poll(std::time::Duration::from_millis(33))? {
65 if let Event::Key(key) = crossterm::event::read()? {
66 self.handle_key(key);
67 }
68 }
69
70 while let Ok(event) = self.ui_rx.try_recv() {
72 self.handle_ui_event(event);
73 }
74
75 if self.should_quit {
76 break;
77 }
78 }
79 Ok(())
80 }
81
82 fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
83 match key.code {
84 KeyCode::Char('q') if self.focus != Focus::Chat => {
85 self.should_quit = true;
86 }
87 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
88 self.should_quit = true;
89 }
90 KeyCode::Tab => {
91 self.focus = self.focus.next();
92 }
93 KeyCode::Up => match self.focus {
94 Focus::Tree => self.tree.scroll_up(),
95 Focus::Changelog => self.changelog.scroll_up(),
96 Focus::Chat => self.chat.history_up(),
97 },
98 KeyCode::Down => match self.focus {
99 Focus::Tree => self.tree.scroll_down(),
100 Focus::Changelog => self.changelog.scroll_down(),
101 Focus::Chat => self.chat.history_down(),
102 },
103 KeyCode::Char(c) if self.focus == Focus::Chat => {
104 self.chat.push_char(c);
105 }
106 KeyCode::Backspace if self.focus == Focus::Chat => {
107 self.chat.backspace();
108 }
109 KeyCode::Enter if self.focus == Focus::Chat => {
110 let input = self.chat.take_input();
111 if !input.trim().is_empty() {
112 self.execute_command(&input);
113 }
114 }
115 KeyCode::Esc => {
116 self.chat.output = None;
117 }
118 _ => {}
119 }
120 }
121
122 fn handle_ui_event(&mut self, event: UiEvent) {
123 match event {
124 UiEvent::NewCommit(entry) => {
125 self.changelog.push(entry);
126 if let Ok(m) = self.manifest.lock() {
128 self.tree.update(&m);
129 }
130 }
131 UiEvent::Violation(msg) => {
132 self.chat.output = Some(msg);
133 }
134 }
135 }
136
137 fn execute_command(&mut self, input: &str) {
138 let parts: Vec<&str> = input.split_whitespace().collect();
140 match parts.as_slice() {
141 ["ls"] | ["ls", ..] => {
142 let m = self.manifest.lock().unwrap();
143 let lines: Vec<String> = m
144 .documents()
145 .iter()
146 .map(|d| format!("[{}] {}", d.doc_type.indicator(), d.path.display()))
147 .collect();
148 self.chat.output = Some(if lines.is_empty() {
149 "No documents tracked.".into()
150 } else {
151 lines.join("\n")
152 });
153 }
154 ["q"] | ["quit"] | ["exit"] => {
155 self.should_quit = true;
156 }
157 _ => {
158 self.chat.output = Some(format!(
159 "Unknown command: '{input}'. Type 'ls' to list documents, 'q' to quit."
160 ));
161 }
162 }
163 }
164
165 pub fn render(&mut self, f: &mut Frame<'_>) {
166 let size = f.area();
167
168 if size.width < MIN_WIDTH || size.height < MIN_HEIGHT {
170 let msg = Paragraph::new("Terminal too small. Please resize to at least 80x24.")
171 .style(Style::default().fg(Color::Red));
172 f.render_widget(msg, size);
173 return;
174 }
175
176 let rows = Layout::default()
178 .direction(Direction::Vertical)
179 .constraints([Constraint::Min(5), Constraint::Length(3)])
180 .split(size);
181
182 let cols = Layout::default()
183 .direction(Direction::Horizontal)
184 .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
185 .split(rows[0]);
186
187 self.render_tree(f, cols[0]);
188 self.render_changelog(f, cols[1]);
189 self.render_chat(f, rows[1]);
190 }
191
192 fn render_tree(&mut self, f: &mut Frame<'_>, area: Rect) {
193 let focused = self.focus == Focus::Tree;
194 let (list, state) = self.tree.render_widget();
195 let list = list.block(
196 Block::default()
197 .title("Documents")
198 .borders(Borders::ALL)
199 .border_style(if focused {
200 Style::default().fg(Color::Yellow)
201 } else {
202 Style::default()
203 }),
204 );
205 f.render_stateful_widget(list, area, state);
206 }
207
208 fn render_changelog(&mut self, f: &mut Frame<'_>, area: Rect) {
209 let focused = self.focus == Focus::Changelog;
210
211 if let Some(output) = &self.chat.output {
213 let para = Paragraph::new(output.clone())
214 .block(
215 Block::default()
216 .title("Output")
217 .borders(Borders::ALL)
218 .border_style(Style::default().fg(Color::Green)),
219 )
220 .wrap(ratatui::widgets::Wrap { trim: false });
221 f.render_widget(para, area);
222 return;
223 }
224
225 let visible_height = area.height.saturating_sub(2) as usize;
226 let entries = &self.changelog.entries;
227 let start = self.changelog.scroll.min(entries.len().saturating_sub(1));
228 let visible = entries.iter().skip(start).take(visible_height);
229
230 let lines: Vec<Line> = visible
231 .map(|entry| {
232 let time = entry.timestamp.format("%H:%M:%S").to_string();
233 let actor_color = if entry.actor.is_agent() {
234 Color::Magenta
235 } else {
236 Color::White
237 };
238 Line::from(vec![
239 Span::styled(time, Style::default().fg(Color::DarkGray)),
240 Span::raw(" "),
241 Span::styled(entry.actor.to_string(), Style::default().fg(actor_color)),
242 Span::raw(" "),
243 Span::raw(entry.summary.clone()),
244 ])
245 })
246 .collect();
247
248 let para = Paragraph::new(lines).block(
249 Block::default()
250 .title("Changelog")
251 .borders(Borders::ALL)
252 .border_style(if focused {
253 Style::default().fg(Color::Yellow)
254 } else {
255 Style::default()
256 }),
257 );
258 f.render_widget(para, area);
259 }
260
261 fn render_chat(&mut self, f: &mut Frame<'_>, area: Rect) {
262 let focused = self.focus == Focus::Chat;
263 let prompt = format!("> {}", self.chat.input);
264 let para = Paragraph::new(prompt).block(
265 Block::default()
266 .title("Command")
267 .borders(Borders::ALL)
268 .border_style(if focused {
269 Style::default().fg(Color::Yellow)
270 } else {
271 Style::default()
272 }),
273 );
274 f.render_widget(para, area);
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::config::StoreInfo;
282 use crate::manifest::Manifest;
283 use crate::poll::UiEvent;
284 use crate::types::{Action, Actor, CommitId, LogEntry};
285 use ratatui::backend::TestBackend;
286 use std::path::PathBuf;
287 use std::sync::{Arc, Mutex};
288 use tempfile::TempDir;
289
290 fn make_app(tmp: &TempDir) -> (App, tokio::sync::mpsc::Sender<UiEvent>) {
291 let root = tmp.path().to_path_buf();
292 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
293 let info = StoreInfo::new("test".into());
294 let manifest = Manifest::create_empty(info, &root).unwrap();
295 let manifest = Arc::new(Mutex::new(manifest));
296 let (tx, rx) = tokio::sync::mpsc::channel(10);
297 let app = App::new(root, manifest, vec![], vec![], rx);
298 (app, tx)
299 }
300
301 #[test]
302 fn test_app_renders_without_panic() {
303 let tmp = TempDir::new().unwrap();
304 let (mut app, _tx) = make_app(&tmp);
305 let backend = TestBackend::new(100, 30);
306 let mut terminal = Terminal::new(backend).unwrap();
307 terminal.draw(|f| app.render(f)).unwrap();
308 }
309
310 #[test]
311 fn test_app_renders_too_small() {
312 let tmp = TempDir::new().unwrap();
313 let (mut app, _tx) = make_app(&tmp);
314 let backend = TestBackend::new(40, 10);
315 let mut terminal = Terminal::new(backend).unwrap();
316 terminal.draw(|f| app.render(f)).unwrap();
317 }
319
320 #[test]
321 fn test_tab_cycles_focus() {
322 let tmp = TempDir::new().unwrap();
323 let (mut app, _tx) = make_app(&tmp);
324 assert_eq!(app.focus, Focus::Chat);
325 let key = crossterm::event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
326 app.handle_key(key);
327 assert_eq!(app.focus, Focus::Tree);
328 }
329
330 #[test]
331 fn test_quit_with_ctrl_c() {
332 let tmp = TempDir::new().unwrap();
333 let (mut app, _tx) = make_app(&tmp);
334 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
335 app.handle_key(key);
336 assert!(app.should_quit);
337 }
338
339 #[test]
340 fn test_new_commit_refreshes_tree() {
341 let tmp = TempDir::new().unwrap();
342 let (mut app, tx) = make_app(&tmp);
343 assert!(app.tree.documents.is_empty());
344
345 {
346 let mut m = app.manifest.lock().unwrap();
347 m.register(&PathBuf::from("added.md"), crate::types::DocType::Plan, "")
348 .unwrap();
349 }
350
351 let entry = LogEntry {
352 commit_id: CommitId("abc123".into()),
353 timestamp: chrono::Utc::now(),
354 action: Action::Create,
355 actor: Actor::Agent {
356 name: "claude".into(),
357 },
358 agent_name: Some("claude".into()),
359 files: vec![(
360 PathBuf::from("added.md"),
361 Action::Create,
362 crate::types::DocType::Plan,
363 )],
364 summary: "mcp write: added.md".into(),
365 };
366 tx.blocking_send(UiEvent::NewCommit(entry)).unwrap();
367 while let Ok(event) = app.ui_rx.try_recv() {
368 app.handle_ui_event(event);
369 }
370
371 assert_eq!(app.tree.documents.len(), 1);
372 assert_eq!(app.tree.documents[0].path, PathBuf::from("added.md"));
373 assert_eq!(app.changelog.entries.len(), 1);
374 }
375}