mi6_cli/
lib.rs

1//! mi6 - A top-like CLI for monitoring agentic coding sessions.
2
3#![allow(clippy::print_stdout, clippy::print_stderr)]
4
5mod format;
6mod ui;
7
8use std::io;
9use std::sync::mpsc;
10use std::thread;
11use std::time::{Duration, Instant};
12
13use anyhow::Result;
14use crossterm::{
15    event::{
16        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
17        MouseEventKind,
18    },
19    execute,
20    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
21};
22use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect, widgets::TableState};
23
24use mi6_core::{GlobalStats, SessionInfo, SessionMonitor};
25
26/// Available refresh intervals in milliseconds.
27const REFRESH_INTERVALS_MS: &[u64] = &[100, 200, 300, 400, 500, 1000, 2000, 3000, 4000, 5000, 10000];
28
29/// Default refresh interval index (300ms).
30const DEFAULT_REFRESH_IDX: usize = 2;
31
32/// Data received from background refresh thread.
33struct RefreshData {
34    sessions: Vec<SessionInfo>,
35    global_stats: GlobalStats,
36    error: Option<String>,
37}
38
39/// Application state.
40pub struct App {
41    pub sessions: Vec<SessionInfo>,
42    pub global_stats: GlobalStats,
43    pub table_state: TableState,
44    pub error_message: Option<String>,
45    refresh_interval_idx: usize,
46    pub show_dead: bool,
47    data_rx: mpsc::Receiver<RefreshData>,
48    interval_tx: mpsc::Sender<Duration>,
49    /// Area where the sessions table is rendered (for mouse click handling).
50    pub(crate) table_area: Rect,
51}
52
53impl App {
54    fn new() -> Self {
55        let (data_tx, data_rx) = mpsc::channel();
56        let (interval_tx, interval_rx) = mpsc::channel();
57
58        // Spawn background refresh thread - does ALL slow work
59        let initial_interval = Duration::from_millis(REFRESH_INTERVALS_MS[DEFAULT_REFRESH_IDX]);
60        thread::spawn(move || {
61            refresh_thread(data_tx, interval_rx, initial_interval);
62        });
63
64        // Return immediately with empty state - UI shows instantly
65        Self {
66            sessions: Vec::new(),
67            global_stats: GlobalStats::default(),
68            table_state: TableState::default(),
69            error_message: None,
70            refresh_interval_idx: DEFAULT_REFRESH_IDX,
71            show_dead: false,
72            data_rx,
73            interval_tx,
74            table_area: Rect::default(),
75        }
76    }
77
78    /// Check for new data from background thread (non-blocking).
79    /// Returns true if new data was received.
80    fn poll_data(&mut self) -> bool {
81        let mut got_data = false;
82        while let Ok(data) = self.data_rx.try_recv() {
83            self.sessions = data.sessions;
84            self.global_stats = data.global_stats;
85            self.error_message = data.error;
86            got_data = true;
87        }
88        got_data
89    }
90
91    pub fn visible_sessions(&self) -> Vec<&SessionInfo> {
92        if self.show_dead {
93            self.sessions.iter().collect()
94        } else {
95            // Show sessions that are either:
96            // 1. Confirmed alive (have PID and PID is running), or
97            // 2. Have no PID but had recent activity (within last 5 minutes)
98            //    These are likely active but PID wasn't captured by fyi hooks
99            let five_min_ago = chrono::Utc::now() - chrono::Duration::minutes(5);
100            self.sessions
101                .iter()
102                .filter(|s| s.is_alive || (s.pid.is_none() && s.last_activity > five_min_ago))
103                .collect()
104        }
105    }
106
107    fn toggle_show_dead(&mut self) {
108        self.show_dead = !self.show_dead;
109        // Reset selection when toggling
110        self.table_state.select(None);
111    }
112
113    fn refresh_interval(&self) -> Duration {
114        Duration::from_millis(REFRESH_INTERVALS_MS[self.refresh_interval_idx])
115    }
116
117    pub fn refresh_interval_display(&self) -> String {
118        let ms = REFRESH_INTERVALS_MS[self.refresh_interval_idx];
119        if ms >= 1000 {
120            format!("{}s", ms / 1000)
121        } else {
122            format!("{ms}ms")
123        }
124    }
125
126    fn increase_refresh_interval(&mut self) {
127        if self.refresh_interval_idx < REFRESH_INTERVALS_MS.len() - 1 {
128            self.refresh_interval_idx += 1;
129            let _ = self.interval_tx.send(self.refresh_interval());
130        }
131    }
132
133    fn decrease_refresh_interval(&mut self) {
134        if self.refresh_interval_idx > 0 {
135            self.refresh_interval_idx -= 1;
136            let _ = self.interval_tx.send(self.refresh_interval());
137        }
138    }
139
140    fn next_row(&mut self) {
141        let count = self.visible_sessions().len();
142        if count == 0 {
143            return;
144        }
145        let i = match self.table_state.selected() {
146            Some(i) => (i + 1).min(count - 1),
147            None => 0,
148        };
149        self.table_state.select(Some(i));
150    }
151
152    fn previous_row(&mut self) {
153        let count = self.visible_sessions().len();
154        if count == 0 {
155            return;
156        }
157        let i = match self.table_state.selected() {
158            Some(i) => i.saturating_sub(1),
159            None => 0,
160        };
161        self.table_state.select(Some(i));
162    }
163
164    /// Handle a mouse click at the given position, selecting the row if applicable.
165    fn handle_click(&mut self, x: u16, y: u16) {
166        // Check if click is within the table area
167        if x < self.table_area.x
168            || x >= self.table_area.x + self.table_area.width
169            || y < self.table_area.y
170            || y >= self.table_area.y + self.table_area.height
171        {
172            return;
173        }
174
175        // Calculate row index: subtract top border (1) and header row (1)
176        let y_in_table = y.saturating_sub(self.table_area.y);
177        if y_in_table < 2 {
178            // Click was on border or header
179            return;
180        }
181
182        // Account for scroll offset (first visible row index)
183        let visual_row = (y_in_table - 2) as usize;
184        let row_index = self.table_state.offset() + visual_row;
185        let count = self.visible_sessions().len();
186        if row_index < count {
187            self.table_state.select(Some(row_index));
188        }
189    }
190}
191
192/// Background thread that periodically refreshes session data.
193fn refresh_thread(
194    data_tx: mpsc::Sender<RefreshData>,
195    interval_rx: mpsc::Receiver<Duration>,
196    initial_interval: Duration,
197) {
198    // Phase 1: Fast initial load - just DB, no system info
199    let mut monitor = match SessionMonitor::new_fast() {
200        Ok(m) => m,
201        Err(e) => {
202            let _ = data_tx.send(RefreshData {
203                sessions: Vec::new(),
204                global_stats: GlobalStats::default(),
205                error: Some(format!("Failed to open fyi database: {e}")),
206            });
207            return;
208        }
209    };
210
211    // Send initial data immediately - fast mode skips git/per-session queries
212    if let Ok(sessions) = monitor.get_sessions_fast() {
213        let global_stats = monitor.get_global_stats().unwrap_or_default();
214        let _ = data_tx.send(RefreshData {
215            sessions,
216            global_stats,
217            error: None,
218        });
219    }
220
221    // Phase 2: Now do the slow system refresh
222    monitor.refresh();
223
224    let mut interval = initial_interval;
225
226    loop {
227        let (sessions, error) = match monitor.get_sessions() {
228            Ok(s) => (s, None),
229            Err(e) => (Vec::new(), Some(format!("Error: {e}"))),
230        };
231
232        let global_stats = monitor.get_global_stats().unwrap_or_default();
233
234        // Send data to main thread
235        if data_tx
236            .send(RefreshData {
237                sessions,
238                global_stats,
239                error,
240            })
241            .is_err()
242        {
243            return;
244        }
245
246        // Sleep for the interval, but check for interval updates
247        let sleep_start = Instant::now();
248        while sleep_start.elapsed() < interval {
249            if let Ok(new_interval) = interval_rx.try_recv() {
250                interval = new_interval;
251            }
252            thread::sleep(Duration::from_millis(50));
253        }
254
255        // Refresh for next iteration
256        monitor.refresh();
257    }
258}
259
260fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
261    let mut needs_redraw = true;
262
263    loop {
264        // Check for new data from background thread (non-blocking)
265        if app.poll_data() {
266            needs_redraw = true;
267        }
268
269        // Poll for input with short timeout
270        if event::poll(Duration::from_millis(16))? {
271            match event::read()? {
272                Event::Key(key) => {
273                    if key.kind == KeyEventKind::Press {
274                        needs_redraw = true;
275                        match key.code {
276                            KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
277                            KeyCode::Down | KeyCode::Char('j') => app.next_row(),
278                            KeyCode::Up | KeyCode::Char('k') => app.previous_row(),
279                            KeyCode::Char('a') => app.toggle_show_dead(),
280                            KeyCode::Char('+') | KeyCode::Char('=') => app.increase_refresh_interval(),
281                            KeyCode::Char('-') => app.decrease_refresh_interval(),
282                            _ => {}
283                        }
284                    }
285                }
286                Event::Mouse(mouse) => {
287                    match mouse.kind {
288                        MouseEventKind::Down(event::MouseButton::Left) => {
289                            app.handle_click(mouse.column, mouse.row);
290                            needs_redraw = true;
291                        }
292                        MouseEventKind::ScrollDown => {
293                            app.next_row();
294                            needs_redraw = true;
295                        }
296                        MouseEventKind::ScrollUp => {
297                            app.previous_row();
298                            needs_redraw = true;
299                        }
300                        _ => {}
301                    }
302                }
303                Event::Resize(_, _) => {
304                    needs_redraw = true;
305                }
306                _ => {}
307            }
308        }
309
310        // Only redraw when something changed
311        if needs_redraw {
312            terminal.draw(|f| ui::render(f, &mut app))?;
313            needs_redraw = false;
314        }
315    }
316}
317
318/// Run the mi6 TUI application.
319pub fn run() -> Result<()> {
320    // Setup terminal
321    enable_raw_mode()?;
322    let mut stdout = io::stdout();
323    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
324    let backend = CrosstermBackend::new(stdout);
325    let mut terminal = Terminal::new(backend)?;
326
327    // Create app and run
328    let app = App::new();
329    let result = run_app(&mut terminal, app);
330
331    // Restore terminal
332    disable_raw_mode()?;
333    execute!(
334        terminal.backend_mut(),
335        LeaveAlternateScreen,
336        DisableMouseCapture
337    )?;
338    terminal.show_cursor()?;
339
340    if let Err(e) = result {
341        eprintln!("Error: {e}");
342    }
343
344    Ok(())
345}