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