Skip to main content

agent_procs/tui/
event_loop.rs

1use super::app::App;
2use super::disk_log_reader::DiskLogReader;
3use super::input;
4use crate::cli;
5use crate::cli::logs::tail_file;
6use crate::paths;
7use crate::protocol::{Request, Response, Stream as ProtoStream};
8use crossterm::event::{Event, EventStream, MouseButton, MouseEventKind};
9use futures::StreamExt;
10use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
11use tokio::sync::mpsc;
12use tokio::time::{Duration, interval};
13
14pub enum AppEvent {
15    Key(crossterm::event::KeyEvent),
16    Mouse(crossterm::event::MouseEvent),
17    OutputLine {
18        process: String,
19        stream: ProtoStream,
20        line: String,
21    },
22    StatusUpdate(Vec<crate::protocol::ProcessInfo>),
23    OutputStreamClosed,
24}
25
26enum ReconnectState {
27    Connected,
28    Reconnecting { attempt: u32 },
29    Failed,
30}
31
32const MAX_RECONNECT_ATTEMPTS: u32 = 10;
33
34pub struct TuiEventLoop {
35    rx: mpsc::Receiver<AppEvent>,
36    tx: mpsc::Sender<AppEvent>,
37    session: String,
38    reconnect_state: ReconnectState,
39}
40
41impl TuiEventLoop {
42    pub fn new(session: &str) -> Self {
43        let (tx, rx) = mpsc::channel::<AppEvent>(256);
44        Self {
45            rx,
46            tx,
47            session: session.to_string(),
48            reconnect_state: ReconnectState::Connected,
49        }
50    }
51
52    /// Spawn all background tasks (output stream, status poller, key reader, SIGTERM).
53    pub fn spawn_background_tasks(&self) {
54        let session_str = self.session.clone();
55        let output_tx = self.tx.clone();
56        tokio::spawn(async move {
57            output_stream_reader(&session_str, output_tx).await;
58        });
59
60        let session_str = self.session.clone();
61        let status_tx = self.tx.clone();
62        tokio::spawn(async move {
63            status_poller(&session_str, status_tx).await;
64        });
65
66        let key_tx = self.tx.clone();
67        tokio::spawn(async move {
68            key_reader(key_tx).await;
69        });
70
71        let sigterm_tx = self.tx.clone();
72        tokio::spawn(async move {
73            if let Ok(mut sig) =
74                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
75            {
76                sig.recv().await;
77                let _ = sigterm_tx
78                    .send(AppEvent::Key(crossterm::event::KeyEvent::new(
79                        crossterm::event::KeyCode::Char('q'),
80                        crossterm::event::KeyModifiers::empty(),
81                    )))
82                    .await;
83            }
84        });
85    }
86
87    /// Run the main event loop, dispatching events to the app and drawing the terminal.
88    pub async fn run(
89        &mut self,
90        terminal: &mut ratatui::Terminal<ratatui::prelude::CrosstermBackend<std::io::Stdout>>,
91        app: &mut App,
92    ) {
93        while app.running {
94            if let Err(e) = terminal.draw(|f| super::ui::draw(f, app)) {
95                eprintln!("error: terminal draw failed: {}", e);
96                break;
97            }
98
99            if let Some(event) = self.rx.recv().await {
100                match event {
101                    AppEvent::Key(key) => {
102                        self.handle_key(app, key).await;
103                    }
104                    AppEvent::Mouse(mouse) => {
105                        Self::handle_mouse(app, mouse);
106                    }
107                    AppEvent::OutputLine {
108                        process,
109                        stream,
110                        line,
111                    } => {
112                        self.reconnect_state = ReconnectState::Connected;
113                        app.push_output(&process, stream, &line);
114                    }
115                    AppEvent::StatusUpdate(processes) => {
116                        // Create disk readers for new processes
117                        let log_dir = paths::log_dir(&self.session);
118                        for p in &processes {
119                            if !app.disk_readers.contains_key(&p.name) {
120                                app.disk_readers.insert(
121                                    p.name.clone(),
122                                    DiskLogReader::new(log_dir.clone(), p.name.clone()),
123                                );
124                            }
125                        }
126                        app.update_processes(processes);
127                    }
128                    AppEvent::OutputStreamClosed => {
129                        self.handle_reconnect();
130                    }
131                }
132            }
133        }
134
135        if app.stop_all_on_quit {
136            let _ = cli::request(&self.session, &Request::Shutdown, false).await;
137        }
138    }
139
140    async fn handle_key(&self, app: &mut App, key: crossterm::event::KeyEvent) {
141        if app.input_mode == super::app::InputMode::FilterInput {
142            match input::handle_filter_key(key) {
143                input::FilterAction::Char(c) => app.filter_buf.push(c),
144                input::FilterAction::Backspace => {
145                    app.filter_buf.pop();
146                }
147                input::FilterAction::Confirm => app.confirm_filter(),
148                input::FilterAction::Cancel => app.cancel_filter(),
149            }
150        } else {
151            let action = input::handle_key(key);
152            match action {
153                input::Action::SelectNext => app.select_next(),
154                input::Action::SelectPrev => app.select_prev(),
155                input::Action::CycleStream => app.cycle_stream_mode(),
156                input::Action::TogglePause => app.toggle_pause(),
157                input::Action::ScrollUp => app.scroll_up(),
158                input::Action::ScrollDown => app.scroll_down(),
159                input::Action::ScrollToTop => app.scroll_to_top(),
160                input::Action::ScrollToBottom => app.scroll_to_bottom(),
161                input::Action::StartFilter => app.start_filter(),
162                input::Action::ClearFilter => app.clear_filter(),
163                input::Action::Quit => app.quit(),
164                input::Action::QuitAndStop => app.quit_and_stop(),
165                input::Action::Stop => {
166                    if let Some(name) = app.selected_name() {
167                        let _ = cli::request(
168                            &self.session,
169                            &Request::Stop {
170                                target: name.to_string(),
171                            },
172                            false,
173                        )
174                        .await;
175                    }
176                }
177                input::Action::StopAll => {
178                    let _ = cli::request(&self.session, &Request::StopAll, false).await;
179                }
180                input::Action::Restart => {
181                    if let Some(name) = app.selected_name() {
182                        let _ = cli::request(
183                            &self.session,
184                            &Request::Restart {
185                                target: name.to_string(),
186                            },
187                            false,
188                        )
189                        .await;
190                    }
191                }
192                input::Action::None => {}
193            }
194        }
195    }
196
197    fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) {
198        const MOUSE_SCROLL_LINES: usize = 3;
199        match mouse.kind {
200            MouseEventKind::ScrollUp => app.scroll_up_by(MOUSE_SCROLL_LINES),
201            MouseEventKind::ScrollDown => app.scroll_down_by(MOUSE_SCROLL_LINES),
202            MouseEventKind::Down(MouseButton::Left) if mouse.column < 22 => {
203                let row = mouse.row.saturating_sub(1) as usize;
204                if row < app.processes.len() {
205                    app.selected = row;
206                }
207            }
208            _ => {}
209        }
210    }
211
212    fn handle_reconnect(&mut self) {
213        let attempt = match self.reconnect_state {
214            ReconnectState::Connected => 0,
215            ReconnectState::Reconnecting { attempt } => attempt,
216            ReconnectState::Failed => return,
217        };
218
219        if attempt >= MAX_RECONNECT_ATTEMPTS {
220            self.reconnect_state = ReconnectState::Failed;
221            return;
222        }
223
224        self.reconnect_state = ReconnectState::Reconnecting {
225            attempt: attempt + 1,
226        };
227
228        let session_str = self.session.clone();
229        let reconnect_tx = self.tx.clone();
230        tokio::spawn(async move {
231            let delay = Duration::from_secs(2u64.saturating_pow(attempt).min(30));
232            tokio::time::sleep(delay).await;
233            output_stream_reader(&session_str, reconnect_tx).await;
234        });
235    }
236}
237
238pub async fn output_stream_reader(session: &str, tx: mpsc::Sender<AppEvent>) {
239    let stream = match cli::connect(session, false).await {
240        Ok(s) => s,
241        Err(_) => {
242            let _ = tx.send(AppEvent::OutputStreamClosed).await;
243            return;
244        }
245    };
246
247    let (reader, mut writer) = stream.into_split();
248
249    let req = Request::Logs {
250        target: None,
251        tail: 0,
252        follow: true,
253        stderr: false,
254        all: true,
255        timeout_secs: None,
256        lines: None,
257    };
258    let mut json = match serde_json::to_string(&req) {
259        Ok(j) => j,
260        Err(_) => return,
261    };
262    json.push('\n');
263    if writer.write_all(json.as_bytes()).await.is_err() {
264        return;
265    }
266    if writer.flush().await.is_err() {
267        return;
268    }
269
270    let mut lines = BufReader::new(reader);
271    loop {
272        let mut line = String::new();
273        match lines.read_line(&mut line).await {
274            Ok(0) | Err(_) => break,
275            Ok(_) => {
276                if let Ok(resp) = serde_json::from_str::<Response>(&line) {
277                    match resp {
278                        Response::LogLine {
279                            process,
280                            stream,
281                            line,
282                        } => {
283                            let _ = tx
284                                .send(AppEvent::OutputLine {
285                                    process,
286                                    stream,
287                                    line,
288                                })
289                                .await;
290                        }
291                        Response::LogEnd => break,
292                        _ => {}
293                    }
294                }
295            }
296        }
297    }
298
299    let _ = tx.send(AppEvent::OutputStreamClosed).await;
300}
301
302pub async fn status_poller(session: &str, tx: mpsc::Sender<AppEvent>) {
303    let mut ticker = interval(Duration::from_secs(2));
304    loop {
305        ticker.tick().await;
306        if let Ok(Response::Status { processes }) =
307            cli::request(session, &Request::Status, false).await
308            && tx.send(AppEvent::StatusUpdate(processes)).await.is_err()
309        {
310            break;
311        }
312    }
313}
314
315pub async fn key_reader(tx: mpsc::Sender<AppEvent>) {
316    let mut reader = EventStream::new();
317    while let Some(Ok(event)) = reader.next().await {
318        let app_event = match event {
319            Event::Key(key) => AppEvent::Key(key),
320            Event::Mouse(mouse) => AppEvent::Mouse(mouse),
321            _ => continue,
322        };
323        if tx.send(app_event).await.is_err() {
324            break;
325        }
326    }
327}
328
329/// Initialize disk readers and pre-populate the hot buffer with recent lines.
330///
331/// Scans the log directory to discover process names, creates a `DiskLogReader`
332/// for each, and loads the last 1000 lines per stream into the hot buffer so
333/// the initial view has content before live lines arrive.
334pub fn init_disk_readers(session: &str, app: &mut App) {
335    let log_dir = paths::log_dir(session);
336
337    let entries = match std::fs::read_dir(&log_dir) {
338        Ok(e) => e,
339        Err(_) => return,
340    };
341
342    // Discover process names from existing log files
343    let mut seen_processes: std::collections::HashSet<String> = std::collections::HashSet::new();
344    let mut file_entries: Vec<(String, ProtoStream, std::path::PathBuf)> = Vec::new();
345
346    for entry in entries.flatten() {
347        let name = entry.file_name().to_string_lossy().to_string();
348        // Match current log files (e.g., "web.stdout", "web.stderr")
349        // Skip rotated (.1, .2, ...) and index (.idx) files for hot buffer loading
350        let (proc_name, stream) = if let Some(p) = name.strip_suffix(".stdout") {
351            (p.to_string(), ProtoStream::Stdout)
352        } else if let Some(p) = name.strip_suffix(".stderr") {
353            (p.to_string(), ProtoStream::Stderr)
354        } else {
355            continue;
356        };
357
358        seen_processes.insert(proc_name.clone());
359        file_entries.push((proc_name, stream, entry.path()));
360    }
361
362    // Create disk readers for all discovered processes
363    for proc_name in &seen_processes {
364        app.disk_readers.insert(
365            proc_name.clone(),
366            DiskLogReader::new(log_dir.clone(), proc_name.clone()),
367        );
368    }
369
370    // Pre-populate hot buffer with last 1000 lines from current log files
371    for (proc_name, stream, path) in &file_entries {
372        if let Ok(lines) = tail_file(path, 1000) {
373            for line in lines {
374                app.push_output(proc_name, *stream, &line);
375            }
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::protocol::{ProcessInfo, ProcessState};
384    use crossterm::event::{KeyModifiers, MouseEvent};
385
386    fn make_process(name: &str) -> ProcessInfo {
387        ProcessInfo {
388            name: name.into(),
389            id: format!("p-{name}"),
390            pid: 100,
391            state: ProcessState::Running,
392            exit_code: None,
393            uptime_secs: Some(10),
394            command: "true".into(),
395            port: None,
396            url: None,
397            restart_count: None,
398            max_restarts: None,
399            restart_policy: None,
400            watched: None,
401        }
402    }
403
404    fn left_click(column: u16, row: u16) -> MouseEvent {
405        MouseEvent {
406            kind: MouseEventKind::Down(MouseButton::Left),
407            column,
408            row,
409            modifiers: KeyModifiers::NONE,
410        }
411    }
412
413    #[test]
414    fn left_click_selects_only_process_rows_inside_left_pane() {
415        let mut app = App::new();
416        app.update_processes(vec![
417            make_process("a"),
418            make_process("b"),
419            make_process("c"),
420        ]);
421
422        TuiEventLoop::handle_mouse(&mut app, left_click(21, 3));
423        assert_eq!(app.selected, 2);
424
425        TuiEventLoop::handle_mouse(&mut app, left_click(22, 1));
426        assert_eq!(app.selected, 2, "right pane click must not select");
427
428        TuiEventLoop::handle_mouse(&mut app, left_click(21, 4));
429        assert_eq!(app.selected, 2, "row beyond process list must not select");
430    }
431}