Skip to main content

agent_procs/tui/
app.rs

1use crate::protocol::{ProcessInfo, Stream};
2use std::collections::{HashMap, VecDeque};
3
4const MAX_BUFFER_LINES: usize = 10_000;
5
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum StreamMode {
8    Stdout,
9    Stderr,
10    Both,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum LineSource {
15    Stdout,
16    Stderr,
17}
18
19/// Single ring buffer storing all output with source tags.
20/// Stdout/stderr views are filtered from the same data — no duplication.
21pub struct OutputBuffer {
22    lines: VecDeque<(LineSource, String)>,
23    max_lines: usize,
24}
25
26impl OutputBuffer {
27    pub fn new(max_lines: usize) -> Self {
28        Self {
29            lines: VecDeque::with_capacity(max_lines),
30            max_lines,
31        }
32    }
33
34    pub fn push(&mut self, source: LineSource, line: String) {
35        if self.lines.len() == self.max_lines {
36            self.lines.pop_front();
37        }
38        self.lines.push_back((source, line));
39    }
40
41    pub fn stdout_lines(&self) -> Vec<&str> {
42        self.lines
43            .iter()
44            .filter(|(src, _)| *src == LineSource::Stdout)
45            .map(|(_, s)| s.as_str())
46            .collect()
47    }
48
49    pub fn stderr_lines(&self) -> Vec<&str> {
50        self.lines
51            .iter()
52            .filter(|(src, _)| *src == LineSource::Stderr)
53            .map(|(_, s)| s.as_str())
54            .collect()
55    }
56
57    pub fn all_lines(&self) -> Vec<(LineSource, &str)> {
58        self.lines
59            .iter()
60            .map(|(src, s)| (*src, s.as_str()))
61            .collect()
62    }
63}
64
65pub struct App {
66    pub processes: Vec<ProcessInfo>,
67    pub selected: usize,
68    pub buffers: HashMap<String, OutputBuffer>,
69    pub stream_mode: StreamMode,
70    pub paused: bool,
71    pub scroll_offsets: HashMap<String, usize>,
72    pub running: bool,
73    pub stop_all_on_quit: bool,
74}
75
76impl Default for App {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl App {
83    pub fn new() -> Self {
84        Self {
85            processes: Vec::new(),
86            selected: 0,
87            buffers: HashMap::new(),
88            stream_mode: StreamMode::Stdout,
89            paused: false,
90            scroll_offsets: HashMap::new(),
91            running: true,
92            stop_all_on_quit: false,
93        }
94    }
95
96    pub fn update_processes(&mut self, processes: Vec<ProcessInfo>) {
97        self.processes = processes;
98        if self.selected >= self.processes.len() && !self.processes.is_empty() {
99            self.selected = self.processes.len() - 1;
100        }
101    }
102
103    pub fn selected_name(&self) -> Option<&str> {
104        self.processes.get(self.selected).map(|p| p.name.as_str())
105    }
106
107    pub fn select_next(&mut self) {
108        if !self.processes.is_empty() {
109            self.selected = (self.selected + 1) % self.processes.len();
110        }
111    }
112
113    pub fn select_prev(&mut self) {
114        if !self.processes.is_empty() {
115            self.selected = if self.selected == 0 {
116                self.processes.len() - 1
117            } else {
118                self.selected - 1
119            };
120        }
121    }
122
123    pub fn cycle_stream_mode(&mut self) {
124        self.stream_mode = match self.stream_mode {
125            StreamMode::Stdout => StreamMode::Stderr,
126            StreamMode::Stderr => StreamMode::Both,
127            StreamMode::Both => StreamMode::Stdout,
128        };
129    }
130
131    pub fn toggle_pause(&mut self) {
132        self.paused = !self.paused;
133        if !self.paused {
134            // Reset scroll offset to bottom on unpause
135            if let Some(name) = self.processes.get(self.selected).map(|p| p.name.clone()) {
136                self.scroll_offsets.remove(&name);
137            }
138        }
139    }
140
141    pub fn push_output(&mut self, process: &str, stream: Stream, line: &str) {
142        let buf = self
143            .buffers
144            .entry(process.to_string())
145            .or_insert_with(|| OutputBuffer::new(MAX_BUFFER_LINES));
146        let source = match stream {
147            Stream::Stdout => LineSource::Stdout,
148            Stream::Stderr => LineSource::Stderr,
149        };
150        buf.push(source, line.to_string());
151    }
152
153    pub fn quit(&mut self) {
154        self.running = false;
155    }
156
157    pub fn quit_and_stop(&mut self) {
158        self.stop_all_on_quit = true;
159        self.running = false;
160    }
161
162    pub fn running_count(&self) -> usize {
163        self.processes
164            .iter()
165            .filter(|p| p.state == crate::protocol::ProcessState::Running)
166            .count()
167    }
168
169    pub fn exited_count(&self) -> usize {
170        self.processes
171            .iter()
172            .filter(|p| p.state == crate::protocol::ProcessState::Exited)
173            .count()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::protocol::{ProcessInfo, ProcessState, Stream};
181
182    fn make_process(name: &str, state: ProcessState) -> ProcessInfo {
183        let exit_code = if state == ProcessState::Exited {
184            Some(0)
185        } else {
186            None
187        };
188        let uptime_secs = if state == ProcessState::Running {
189            Some(10)
190        } else {
191            None
192        };
193        ProcessInfo {
194            name: name.into(),
195            id: format!("p-{}", name),
196            pid: 100,
197            state,
198            exit_code,
199            uptime_secs,
200            command: "true".into(),
201            port: None,
202            url: None,
203        }
204    }
205
206    #[test]
207    fn test_select_next_wraps() {
208        let mut app = App::new();
209        app.update_processes(vec![
210            make_process("a", ProcessState::Running),
211            make_process("b", ProcessState::Running),
212            make_process("c", ProcessState::Running),
213        ]);
214        app.selected = 2; // last item
215        app.select_next();
216        assert_eq!(app.selected, 0);
217    }
218
219    #[test]
220    fn test_select_prev_wraps() {
221        let mut app = App::new();
222        app.update_processes(vec![
223            make_process("a", ProcessState::Running),
224            make_process("b", ProcessState::Running),
225            make_process("c", ProcessState::Running),
226        ]);
227        app.selected = 0;
228        app.select_prev();
229        assert_eq!(app.selected, 2);
230    }
231
232    #[test]
233    fn test_cycle_stream_mode() {
234        let mut app = App::new();
235        assert_eq!(app.stream_mode, StreamMode::Stdout);
236        app.cycle_stream_mode();
237        assert_eq!(app.stream_mode, StreamMode::Stderr);
238        app.cycle_stream_mode();
239        assert_eq!(app.stream_mode, StreamMode::Both);
240        app.cycle_stream_mode();
241        assert_eq!(app.stream_mode, StreamMode::Stdout);
242    }
243
244    #[test]
245    fn test_toggle_pause() {
246        let mut app = App::new();
247        assert!(!app.paused);
248        app.toggle_pause();
249        assert!(app.paused);
250        app.toggle_pause();
251        assert!(!app.paused);
252    }
253
254    #[test]
255    fn test_push_output() {
256        let mut app = App::new();
257        app.push_output("web", Stream::Stdout, "hello world");
258        let buf = app.buffers.get("web").unwrap();
259        let lines = buf.stdout_lines();
260        assert_eq!(lines.len(), 1);
261        assert_eq!(lines[0], "hello world");
262    }
263
264    #[test]
265    fn test_running_count() {
266        let mut app = App::new();
267        app.update_processes(vec![
268            make_process("a", ProcessState::Running),
269            make_process("b", ProcessState::Exited),
270            make_process("c", ProcessState::Running),
271        ]);
272        assert_eq!(app.running_count(), 2);
273    }
274
275    #[test]
276    fn test_exited_count() {
277        let mut app = App::new();
278        app.update_processes(vec![
279            make_process("a", ProcessState::Running),
280            make_process("b", ProcessState::Exited),
281            make_process("c", ProcessState::Exited),
282        ]);
283        assert_eq!(app.exited_count(), 2);
284    }
285}