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
65/// Input mode for the TUI.
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub enum InputMode {
68    /// Normal keybinding mode.
69    Normal,
70    /// Typing a filter pattern.
71    FilterInput,
72}
73
74pub struct App {
75    pub processes: Vec<ProcessInfo>,
76    pub selected: usize,
77    pub buffers: HashMap<String, OutputBuffer>,
78    pub stream_mode: StreamMode,
79    pub paused: bool,
80    pub scroll_offsets: HashMap<String, usize>,
81    pub running: bool,
82    pub stop_all_on_quit: bool,
83    pub input_mode: InputMode,
84    /// In-progress filter text while the user is typing.
85    pub filter_buf: String,
86    /// Active filter applied to output lines. `None` means no filter.
87    pub filter: Option<String>,
88    /// Cached visible height of the output pane (set during render).
89    pub visible_height: usize,
90}
91
92impl Default for App {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl App {
99    pub fn new() -> Self {
100        Self {
101            processes: Vec::new(),
102            selected: 0,
103            buffers: HashMap::new(),
104            stream_mode: StreamMode::Stdout,
105            paused: false,
106            scroll_offsets: HashMap::new(),
107            running: true,
108            stop_all_on_quit: false,
109            input_mode: InputMode::Normal,
110            filter_buf: String::new(),
111            filter: None,
112            visible_height: 20,
113        }
114    }
115
116    pub fn update_processes(&mut self, processes: Vec<ProcessInfo>) {
117        self.processes = processes;
118        if self.selected >= self.processes.len() && !self.processes.is_empty() {
119            self.selected = self.processes.len() - 1;
120        }
121    }
122
123    pub fn selected_name(&self) -> Option<&str> {
124        self.processes.get(self.selected).map(|p| p.name.as_str())
125    }
126
127    pub fn select_next(&mut self) {
128        if !self.processes.is_empty() {
129            self.selected = (self.selected + 1) % self.processes.len();
130        }
131    }
132
133    pub fn select_prev(&mut self) {
134        if !self.processes.is_empty() {
135            self.selected = if self.selected == 0 {
136                self.processes.len() - 1
137            } else {
138                self.selected - 1
139            };
140        }
141    }
142
143    pub fn cycle_stream_mode(&mut self) {
144        self.stream_mode = match self.stream_mode {
145            StreamMode::Stdout => StreamMode::Stderr,
146            StreamMode::Stderr => StreamMode::Both,
147            StreamMode::Both => StreamMode::Stdout,
148        };
149    }
150
151    pub fn toggle_pause(&mut self) {
152        self.paused = !self.paused;
153        if !self.paused {
154            // Reset scroll offset to bottom on unpause
155            if let Some(name) = self.processes.get(self.selected).map(|p| p.name.clone()) {
156                self.scroll_offsets.remove(&name);
157            }
158        }
159    }
160
161    /// Scroll up by half a page. Auto-pauses if not already paused.
162    pub fn scroll_up(&mut self) {
163        if !self.paused {
164            self.paused = true;
165        }
166        if let Some(name) = self.selected_name().map(str::to_string) {
167            let half_page = (self.visible_height / 2).max(1);
168            let offset = self.scroll_offsets.entry(name).or_insert(0);
169            *offset = offset.saturating_add(half_page);
170        }
171    }
172
173    /// Scroll down by half a page. If we reach the bottom, unpause.
174    pub fn scroll_down(&mut self) {
175        if let Some(name) = self.selected_name().map(str::to_string) {
176            let half_page = (self.visible_height / 2).max(1);
177            let offset = self.scroll_offsets.entry(name).or_insert(0);
178            *offset = offset.saturating_sub(half_page);
179            if *offset == 0 {
180                self.paused = false;
181            }
182        }
183    }
184
185    pub fn scroll_to_top(&mut self) {
186        if !self.paused {
187            self.paused = true;
188        }
189        if let Some(name) = self.selected_name().map(str::to_string) {
190            let total = self.line_count_for(&name);
191            self.scroll_offsets.insert(name, total);
192        }
193    }
194
195    pub fn scroll_to_bottom(&mut self) {
196        if let Some(name) = self.selected_name().map(str::to_string) {
197            self.scroll_offsets.remove(&name);
198            self.paused = false;
199        }
200    }
201
202    /// Count visible lines for the selected process (respecting stream mode and filter).
203    fn line_count_for(&self, name: &str) -> usize {
204        let Some(buf) = self.buffers.get(name) else {
205            return 0;
206        };
207        let lines = match self.stream_mode {
208            StreamMode::Stdout => buf.stdout_lines(),
209            StreamMode::Stderr => buf.stderr_lines(),
210            StreamMode::Both => buf.all_lines().into_iter().map(|(_, l)| l).collect(),
211        };
212        match &self.filter {
213            Some(pat) => lines.iter().filter(|l| l.contains(pat.as_str())).count(),
214            None => lines.len(),
215        }
216    }
217
218    pub fn start_filter(&mut self) {
219        self.input_mode = InputMode::FilterInput;
220        self.filter_buf = self.filter.clone().unwrap_or_default();
221    }
222
223    pub fn confirm_filter(&mut self) {
224        self.input_mode = InputMode::Normal;
225        if self.filter_buf.is_empty() {
226            self.filter = None;
227        } else {
228            self.filter = Some(self.filter_buf.clone());
229        }
230    }
231
232    pub fn cancel_filter(&mut self) {
233        self.input_mode = InputMode::Normal;
234        self.filter_buf.clear();
235    }
236
237    pub fn clear_filter(&mut self) {
238        self.filter = None;
239        self.filter_buf.clear();
240    }
241
242    pub fn push_output(&mut self, process: &str, stream: Stream, line: &str) {
243        let buf = self
244            .buffers
245            .entry(process.to_string())
246            .or_insert_with(|| OutputBuffer::new(MAX_BUFFER_LINES));
247        let source = match stream {
248            Stream::Stdout => LineSource::Stdout,
249            Stream::Stderr => LineSource::Stderr,
250        };
251        buf.push(source, line.to_string());
252    }
253
254    pub fn quit(&mut self) {
255        self.running = false;
256    }
257
258    pub fn quit_and_stop(&mut self) {
259        self.stop_all_on_quit = true;
260        self.running = false;
261    }
262
263    pub fn running_count(&self) -> usize {
264        self.processes
265            .iter()
266            .filter(|p| p.state == crate::protocol::ProcessState::Running)
267            .count()
268    }
269
270    pub fn exited_count(&self) -> usize {
271        self.processes
272            .iter()
273            .filter(|p| p.state == crate::protocol::ProcessState::Exited)
274            .count()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::protocol::{ProcessInfo, ProcessState, Stream};
282
283    fn make_process(name: &str, state: ProcessState) -> ProcessInfo {
284        let exit_code = if state == ProcessState::Exited {
285            Some(0)
286        } else {
287            None
288        };
289        let uptime_secs = if state == ProcessState::Running {
290            Some(10)
291        } else {
292            None
293        };
294        ProcessInfo {
295            name: name.into(),
296            id: format!("p-{}", name),
297            pid: 100,
298            state,
299            exit_code,
300            uptime_secs,
301            command: "true".into(),
302            port: None,
303            url: None,
304        }
305    }
306
307    #[test]
308    fn test_select_next_wraps() {
309        let mut app = App::new();
310        app.update_processes(vec![
311            make_process("a", ProcessState::Running),
312            make_process("b", ProcessState::Running),
313            make_process("c", ProcessState::Running),
314        ]);
315        app.selected = 2; // last item
316        app.select_next();
317        assert_eq!(app.selected, 0);
318    }
319
320    #[test]
321    fn test_select_prev_wraps() {
322        let mut app = App::new();
323        app.update_processes(vec![
324            make_process("a", ProcessState::Running),
325            make_process("b", ProcessState::Running),
326            make_process("c", ProcessState::Running),
327        ]);
328        app.selected = 0;
329        app.select_prev();
330        assert_eq!(app.selected, 2);
331    }
332
333    #[test]
334    fn test_cycle_stream_mode() {
335        let mut app = App::new();
336        assert_eq!(app.stream_mode, StreamMode::Stdout);
337        app.cycle_stream_mode();
338        assert_eq!(app.stream_mode, StreamMode::Stderr);
339        app.cycle_stream_mode();
340        assert_eq!(app.stream_mode, StreamMode::Both);
341        app.cycle_stream_mode();
342        assert_eq!(app.stream_mode, StreamMode::Stdout);
343    }
344
345    #[test]
346    fn test_toggle_pause() {
347        let mut app = App::new();
348        assert!(!app.paused);
349        app.toggle_pause();
350        assert!(app.paused);
351        app.toggle_pause();
352        assert!(!app.paused);
353    }
354
355    #[test]
356    fn test_push_output() {
357        let mut app = App::new();
358        app.push_output("web", Stream::Stdout, "hello world");
359        let buf = app.buffers.get("web").unwrap();
360        let lines = buf.stdout_lines();
361        assert_eq!(lines.len(), 1);
362        assert_eq!(lines[0], "hello world");
363    }
364
365    #[test]
366    fn test_running_count() {
367        let mut app = App::new();
368        app.update_processes(vec![
369            make_process("a", ProcessState::Running),
370            make_process("b", ProcessState::Exited),
371            make_process("c", ProcessState::Running),
372        ]);
373        assert_eq!(app.running_count(), 2);
374    }
375
376    #[test]
377    fn test_exited_count() {
378        let mut app = App::new();
379        app.update_processes(vec![
380            make_process("a", ProcessState::Running),
381            make_process("b", ProcessState::Exited),
382            make_process("c", ProcessState::Exited),
383        ]);
384        assert_eq!(app.exited_count(), 2);
385    }
386}