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