Skip to main content

agent_trace/adapters/tui/
panels.rs

1use crate::manifest::{DocumentEntry, Manifest};
2use crate::types::LogEntry;
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
6
7// ── Tree Panel State ──────────────────────────────────────────────────────────
8
9pub struct TreeState {
10    pub documents: Vec<DocumentEntry>,
11    pub list_state: ListState,
12}
13
14impl TreeState {
15    pub fn new(manifest: &Manifest) -> Self {
16        Self {
17            documents: manifest.documents().to_vec(),
18            list_state: ListState::default(),
19        }
20    }
21
22    pub fn update(&mut self, manifest: &Manifest) {
23        self.documents = manifest.documents().to_vec();
24    }
25
26    pub fn scroll_up(&mut self) {
27        let i = match self.list_state.selected() {
28            Some(i) => {
29                if i == 0 {
30                    0
31                } else {
32                    i - 1
33                }
34            }
35            None => 0,
36        };
37        self.list_state.select(Some(i));
38    }
39
40    pub fn scroll_down(&mut self) {
41        let len = self.documents.len();
42        let i = match self.list_state.selected() {
43            Some(i) => {
44                if i >= len.saturating_sub(1) {
45                    i
46                } else {
47                    i + 1
48                }
49            }
50            None => 0,
51        };
52        self.list_state.select(Some(i));
53    }
54
55    pub fn render_widget(&mut self) -> (List<'_>, &mut ListState) {
56        let items: Vec<ListItem> = self
57            .documents
58            .iter()
59            .map(|doc| {
60                let indicator = doc.doc_type.indicator();
61                let line = Line::from(vec![
62                    Span::styled(format!("[{indicator}] "), Style::default().fg(Color::Cyan)),
63                    Span::raw(doc.path.display().to_string()),
64                ]);
65                ListItem::new(line)
66            })
67            .collect();
68
69        let list = List::new(items)
70            .block(Block::default().title("Documents").borders(Borders::ALL))
71            .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
72
73        (list, &mut self.list_state)
74    }
75}
76
77// ── Changelog Panel State ─────────────────────────────────────────────────────
78
79const MAX_CHANGELOG_ENTRIES: usize = 200;
80
81pub struct ChangelogState {
82    pub entries: Vec<LogEntry>,
83    pub scroll: usize,
84}
85
86impl ChangelogState {
87    pub fn new(initial: Vec<LogEntry>) -> Self {
88        Self {
89            entries: initial,
90            scroll: 0,
91        }
92    }
93
94    pub fn push(&mut self, entry: LogEntry) {
95        self.entries.insert(0, entry);
96        if self.entries.len() > MAX_CHANGELOG_ENTRIES {
97            self.entries.truncate(MAX_CHANGELOG_ENTRIES);
98            self.scroll = self.scroll.min(MAX_CHANGELOG_ENTRIES.saturating_sub(1));
99        }
100    }
101
102    pub fn scroll_up(&mut self) {
103        self.scroll = self.scroll.saturating_sub(1);
104    }
105
106    pub fn scroll_down(&mut self) {
107        if self.scroll + 1 < self.entries.len() {
108            self.scroll += 1;
109        }
110    }
111}
112
113// ── Chat Input State ──────────────────────────────────────────────────────────
114
115pub struct ChatState {
116    pub input: String,
117    pub cursor: usize,
118    pub history: Vec<String>,
119    pub history_idx: Option<usize>,
120    pub output: Option<String>,
121}
122
123impl ChatState {
124    pub fn new(history: Vec<String>) -> Self {
125        Self {
126            input: String::new(),
127            cursor: 0,
128            history,
129            history_idx: None,
130            output: None,
131        }
132    }
133
134    pub fn push_char(&mut self, c: char) {
135        self.input.insert(self.cursor, c);
136        self.cursor += c.len_utf8();
137    }
138
139    pub fn backspace(&mut self) {
140        if self.cursor > 0 {
141            let prev = self.input[..self.cursor]
142                .char_indices()
143                .last()
144                .map(|(i, _)| i)
145                .unwrap_or(0);
146            self.input.remove(prev);
147            self.cursor = prev;
148        }
149    }
150
151    pub fn take_input(&mut self) -> String {
152        let cmd = self.input.clone();
153        if !cmd.trim().is_empty() {
154            self.history.push(cmd.clone());
155        }
156        self.input.clear();
157        self.cursor = 0;
158        self.history_idx = None;
159        cmd
160    }
161
162    pub fn history_up(&mut self) {
163        if self.history.is_empty() {
164            return;
165        }
166        let idx = match self.history_idx {
167            None => self.history.len() - 1,
168            Some(i) => i.saturating_sub(1),
169        };
170        self.history_idx = Some(idx);
171        self.input = self.history[idx].clone();
172        self.cursor = self.input.len();
173    }
174
175    pub fn history_down(&mut self) {
176        match self.history_idx {
177            None => {}
178            Some(i) => {
179                if i + 1 < self.history.len() {
180                    self.history_idx = Some(i + 1);
181                    self.input = self.history[i + 1].clone();
182                    self.cursor = self.input.len();
183                } else {
184                    self.history_idx = None;
185                    self.input.clear();
186                    self.cursor = 0;
187                }
188            }
189        }
190    }
191}
192
193// ── Panel Focus ───────────────────────────────────────────────────────────────
194
195#[derive(Debug, Clone, PartialEq)]
196pub enum Focus {
197    Tree,
198    Changelog,
199    Chat,
200}
201
202impl Focus {
203    pub fn next(&self) -> Self {
204        match self {
205            Focus::Tree => Focus::Changelog,
206            Focus::Changelog => Focus::Chat,
207            Focus::Chat => Focus::Tree,
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_chat_push_and_take() {
218        let mut chat = ChatState::new(vec![]);
219        chat.push_char('l');
220        chat.push_char('s');
221        assert_eq!(chat.input, "ls");
222        let cmd = chat.take_input();
223        assert_eq!(cmd, "ls");
224        assert!(chat.input.is_empty());
225    }
226
227    #[test]
228    fn test_chat_backspace() {
229        let mut chat = ChatState::new(vec![]);
230        chat.push_char('a');
231        chat.push_char('b');
232        chat.backspace();
233        assert_eq!(chat.input, "a");
234    }
235
236    #[test]
237    fn test_chat_history() {
238        let mut chat = ChatState::new(vec!["ls".into(), "info prd.md".into()]);
239        chat.history_up();
240        assert_eq!(chat.input, "info prd.md");
241        chat.history_up();
242        assert_eq!(chat.input, "ls");
243        chat.history_down();
244        assert_eq!(chat.input, "info prd.md");
245    }
246
247    #[test]
248    fn test_focus_cycles() {
249        let f = Focus::Tree;
250        assert_eq!(f.next(), Focus::Changelog);
251        assert_eq!(f.next().next(), Focus::Chat);
252        assert_eq!(f.next().next().next(), Focus::Tree);
253    }
254
255    #[test]
256    fn test_changelog_state_push() {
257        let mut log = ChangelogState::new(vec![]);
258        use chrono::Utc;
259        let entry = LogEntry {
260            commit_id: "abc".into(),
261            timestamp: Utc::now(),
262            action: crate::types::Action::Create,
263            actor: crate::types::Actor::User,
264            agent_name: None,
265            files: vec![],
266            summary: "test".into(),
267        };
268        log.push(entry);
269        assert_eq!(log.entries.len(), 1);
270    }
271}