agent_trace/adapters/tui/
panels.rs1use 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
7pub 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
77const 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
113pub 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#[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}