Skip to main content

agent_trace/adapters/tui/
app.rs

1use 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
18// ── Minimum terminal size ─────────────────────────────────────────────────────
19const MIN_WIDTH: u16 = 80;
20const MIN_HEIGHT: u16 = 24;
21
22// ── Application ───────────────────────────────────────────────────────────────
23
24pub 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            // Poll for keyboard events with a short timeout.
64            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            // Drain UI events from poll loop.
71            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                // Refresh tree.
127                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        // Simple command dispatch — structured commands only (no LLM in this impl).
139        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        // Check minimum terminal size.
169        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        // Layout: top = [tree | changelog], bottom = chat
177        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 chat has command output, show that instead.
212        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        // Just verify it doesn't panic.
318    }
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}