Skip to main content

autom8/ui/tui/
app.rs

1//! Monitor TUI Application
2//!
3//! The main application struct and event loop for the monitor command.
4
5use super::views::View;
6use crate::error::Result;
7use crate::state::{LiveState, MachineState, RunState};
8use crate::ui::shared::{
9    format_duration, format_relative_time, format_state_label, load_run_history, load_ui_data,
10    ProjectData, RunHistoryEntry, RunHistoryOptions, SessionData, Status,
11};
12use chrono::Utc;
13use crossterm::{
14    event::{self, Event, KeyCode, KeyEventKind},
15    execute,
16    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
17};
18use ratatui::{
19    backend::CrosstermBackend,
20    layout::{Constraint, Direction, Layout, Rect},
21    style::{Color, Modifier, Style},
22    text::{Line, Span},
23    widgets::{Block, Borders, List, ListItem, Paragraph, Tabs, Wrap},
24    Frame, Terminal,
25};
26use std::io::{self, Stdout};
27use std::time::Duration;
28
29// ============================================================================
30// Color Constants (consistent with output.rs autom8 branding)
31// ============================================================================
32
33/// Cyan - primary branding color, used for headers and highlights
34const COLOR_PRIMARY: Color = Color::Cyan;
35/// Green - success states
36const COLOR_SUCCESS: Color = Color::Green;
37/// Yellow - warning/in-progress states (reviewing, general warnings)
38const COLOR_WARNING: Color = Color::Yellow;
39/// Red - error/failure states
40const COLOR_ERROR: Color = Color::Red;
41/// Gray - dimmed/secondary text (idle, setup phases)
42const COLOR_DIM: Color = Color::DarkGray;
43/// Magenta - review/correction states (attention needed)
44const COLOR_REVIEW: Color = Color::Magenta;
45
46/// Result type for monitor operations.
47pub type MonitorResult<T> = std::result::Result<T, MonitorError>;
48
49/// Error types for the monitor TUI.
50#[derive(Debug)]
51pub enum MonitorError {
52    /// IO error from terminal operations
53    Io(io::Error),
54    /// Error from autom8 operations
55    Autom8(crate::error::Autom8Error),
56}
57
58impl std::fmt::Display for MonitorError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            MonitorError::Io(e) => write!(f, "IO error: {}", e),
62            MonitorError::Autom8(e) => write!(f, "Autom8 error: {}", e),
63        }
64    }
65}
66
67impl std::error::Error for MonitorError {}
68
69impl From<io::Error> for MonitorError {
70    fn from(err: io::Error) -> Self {
71        MonitorError::Io(err)
72    }
73}
74
75impl From<crate::error::Autom8Error> for MonitorError {
76    fn from(err: crate::error::Autom8Error) -> Self {
77        MonitorError::Autom8(err)
78    }
79}
80
81// Data types (RunProgress, ProjectData, SessionData, RunHistoryEntry) are imported
82// from crate::ui::shared.
83// Time formatting (format_duration, format_relative_time) and state labels
84// (format_state_label) are also imported from crate::ui::shared for consistency.
85
86/// Get a color for a machine state using the shared Status enum.
87///
88/// This uses the shared Status enum to ensure consistent color mapping
89/// between GUI and TUI. The Status enum groups MachineState values into
90/// semantic categories, and we map those to terminal colors here.
91fn state_color(state: MachineState) -> Color {
92    match Status::from_machine_state(state) {
93        Status::Setup => COLOR_DIM,         // Gray - setup/initialization
94        Status::Running => COLOR_PRIMARY,   // Cyan - active implementation
95        Status::Reviewing => COLOR_WARNING, // Yellow/Amber - evaluation
96        Status::Correcting => COLOR_REVIEW, // Magenta - attention needed
97        Status::Success => COLOR_SUCCESS,   // Green - success path
98        Status::Warning => COLOR_WARNING,   // Yellow - general warnings
99        Status::Error => COLOR_ERROR,       // Red - failure
100        Status::Idle => COLOR_DIM,          // Gray - inactive
101    }
102}
103
104/// The main monitor application state.
105pub struct MonitorApp {
106    /// Current view being displayed
107    current_view: View,
108    /// Cached project data (used for Project List view)
109    projects: Vec<ProjectData>,
110    /// Cached session data for Active Runs view.
111    /// Contains only running sessions (is_running=true and not stale).
112    sessions: Vec<SessionData>,
113    /// Cached run history entries (sorted by date, most recent first)
114    run_history: Vec<RunHistoryEntry>,
115    /// Whether there are any active runs
116    has_active_runs: bool,
117    /// Whether the app should quit
118    should_quit: bool,
119    /// Selected index for list navigation
120    selected_index: usize,
121    /// Project name to filter Run History view (set when pressing Enter on Project List)
122    run_history_filter: Option<String>,
123    /// Scroll offset for run history view
124    history_scroll_offset: usize,
125    /// Whether to show the detail view for a selected run
126    show_run_detail: bool,
127    /// Current page in Active Runs view (0-indexed) for pagination when > 4 runs
128    quadrant_page: usize,
129    /// Selected quadrant row (0 or 1) for Active Runs 2D navigation
130    quadrant_row: usize,
131    /// Selected quadrant column (0 or 1) for Active Runs 2D navigation
132    quadrant_col: usize,
133    /// Scroll offset for detail view
134    detail_scroll_offset: usize,
135    /// Cache of full RunState objects for detail view (keyed by run_id)
136    run_state_cache: std::collections::HashMap<String, RunState>,
137}
138
139impl Default for MonitorApp {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl MonitorApp {
146    /// Create a new MonitorApp.
147    pub fn new() -> Self {
148        Self {
149            current_view: View::ProjectList, // Will be updated on first refresh
150            projects: Vec::new(),
151            sessions: Vec::new(),
152            run_history: Vec::new(),
153            has_active_runs: false,
154            should_quit: false,
155            selected_index: 0,
156            run_history_filter: None,
157            history_scroll_offset: 0,
158            show_run_detail: false,
159            quadrant_page: 0,
160            quadrant_row: 0,
161            quadrant_col: 0,
162            detail_scroll_offset: 0,
163            run_state_cache: std::collections::HashMap::new(),
164        }
165    }
166
167    /// Refresh project data from disk.
168    ///
169    /// This method handles corrupted or invalid state files gracefully,
170    /// showing error indicators in the UI instead of crashing.
171    pub fn refresh_data(&mut self) -> Result<()> {
172        // Use shared data loading function
173        // TUI handles errors gracefully - log and continue with defaults
174        let ui_data = match load_ui_data(None) {
175            Ok(data) => data,
176            Err(e) => {
177                // Log error but continue with empty data
178                eprintln!("Warning: Failed to load UI data: {}", e);
179                Default::default()
180            }
181        };
182
183        self.projects = ui_data.projects;
184        self.sessions = ui_data.sessions;
185        self.has_active_runs = ui_data.has_active_runs;
186
187        // If current view is ActiveRuns but no active runs, switch to ProjectList
188        if self.current_view == View::ActiveRuns && !self.has_active_runs {
189            self.current_view = View::ProjectList;
190        }
191
192        // Clamp selected_index to valid range when projects are removed
193        self.clamp_selection_index();
194
195        // Load run history (errors are handled internally)
196        let _ = self.refresh_run_history();
197
198        Ok(())
199    }
200
201    /// Ensure selected_index and quadrant_page stay within bounds when projects/history change.
202    fn clamp_selection_index(&mut self) {
203        let max_index = match self.current_view {
204            View::ProjectList => self.projects.len().saturating_sub(1),
205            // Active Runs now uses sessions, not projects
206            View::ActiveRuns => self.sessions.len().saturating_sub(1),
207            View::RunHistory => self.run_history.len().saturating_sub(1),
208        };
209        if self.selected_index > max_index {
210            self.selected_index = max_index;
211        }
212        // Also clamp scroll offset
213        if self.history_scroll_offset > self.selected_index {
214            self.history_scroll_offset = self.selected_index;
215        }
216        // Clamp quadrant_page for Active Runs view
217        let max_page = self.total_quadrant_pages().saturating_sub(1);
218        if self.quadrant_page > max_page {
219            self.quadrant_page = max_page;
220        }
221        // Clamp quadrant row/col to valid positions
222        let runs_on_page = self.runs_on_current_page();
223        if runs_on_page == 0 {
224            self.quadrant_row = 0;
225            self.quadrant_col = 0;
226        } else {
227            // Ensure current position is valid
228            if !self.is_quadrant_valid(self.quadrant_row, self.quadrant_col) {
229                // Move to the last valid quadrant
230                let last_idx = runs_on_page.saturating_sub(1);
231                self.quadrant_row = last_idx / 2;
232                self.quadrant_col = last_idx % 2;
233            }
234        }
235    }
236
237    /// Refresh run history from all projects.
238    ///
239    /// This method handles corrupted run files gracefully by skipping them
240    /// rather than failing the entire refresh.
241    fn refresh_run_history(&mut self) -> Result<()> {
242        // Use run_history_filter if set (from selecting a project in Project List)
243        let options = RunHistoryOptions {
244            project_filter: self.run_history_filter.clone(),
245            max_entries: Some(100), // Limit to last 100 runs for performance
246        };
247
248        // Use shared function to load run history
249        // TUI needs full state for detail view, so request it
250        let history_data = load_run_history(&self.projects, &options, true).unwrap_or_default();
251
252        // Update the run state cache with the full states
253        self.run_state_cache = history_data.run_states;
254
255        self.run_history = history_data.entries;
256
257        Ok(())
258    }
259
260    /// Switch to the next view.
261    pub fn next_view(&mut self) {
262        self.current_view = self.current_view.next(!self.has_active_runs);
263        self.selected_index = 0;
264        self.quadrant_page = 0; // Reset pagination when switching views
265        self.quadrant_row = 0;
266        self.quadrant_col = 0;
267    }
268
269    /// Get the total number of pages for Active Runs view.
270    fn total_quadrant_pages(&self) -> usize {
271        // Active Runs view now uses sessions (not projects)
272        let active_count = self.sessions.len();
273        if active_count == 0 {
274            1
275        } else {
276            active_count.div_ceil(4)
277        }
278    }
279
280    /// Move to the next page in Active Runs view.
281    fn next_quadrant_page(&mut self) {
282        let total_pages = self.total_quadrant_pages();
283        if total_pages > 1 && self.quadrant_page < total_pages - 1 {
284            self.quadrant_page += 1;
285        }
286    }
287
288    /// Move to the previous page in Active Runs view.
289    fn prev_quadrant_page(&mut self) {
290        if self.quadrant_page > 0 {
291            self.quadrant_page -= 1;
292        }
293    }
294
295    /// Get the number of active runs on the current page (0-4).
296    fn runs_on_current_page(&self) -> usize {
297        // Active Runs view now uses sessions (not projects)
298        let active_count = self.sessions.len();
299        let start_idx = self.quadrant_page * 4;
300        let remaining = active_count.saturating_sub(start_idx);
301        remaining.min(4)
302    }
303
304    /// Check if a quadrant position is valid (has a run) on the current page.
305    fn is_quadrant_valid(&self, row: usize, col: usize) -> bool {
306        let quadrant_idx = row * 2 + col;
307        quadrant_idx < self.runs_on_current_page()
308    }
309
310    /// Navigate up in the quadrant grid (Active Runs view).
311    fn quadrant_move_up(&mut self) {
312        if self.quadrant_row > 0 {
313            let new_row = self.quadrant_row - 1;
314            if self.is_quadrant_valid(new_row, self.quadrant_col) {
315                self.quadrant_row = new_row;
316            }
317        }
318    }
319
320    /// Navigate down in the quadrant grid (Active Runs view).
321    fn quadrant_move_down(&mut self) {
322        if self.quadrant_row < 1 {
323            let new_row = self.quadrant_row + 1;
324            if self.is_quadrant_valid(new_row, self.quadrant_col) {
325                self.quadrant_row = new_row;
326            }
327        }
328    }
329
330    /// Navigate left in the quadrant grid (Active Runs view).
331    fn quadrant_move_left(&mut self) {
332        if self.quadrant_col > 0 {
333            let new_col = self.quadrant_col - 1;
334            if self.is_quadrant_valid(self.quadrant_row, new_col) {
335                self.quadrant_col = new_col;
336            }
337        }
338    }
339
340    /// Navigate right in the quadrant grid (Active Runs view).
341    fn quadrant_move_right(&mut self) {
342        if self.quadrant_col < 1 {
343            let new_col = self.quadrant_col + 1;
344            if self.is_quadrant_valid(self.quadrant_row, new_col) {
345                self.quadrant_col = new_col;
346            }
347        }
348    }
349
350    /// Handle keyboard input.
351    pub fn handle_key(&mut self, key: KeyCode) {
352        // q/Q always quits immediately from any screen
353        if matches!(key, KeyCode::Char('q') | KeyCode::Char('Q')) {
354            self.should_quit = true;
355            return;
356        }
357
358        // Handle keys in detail view
359        if self.show_run_detail {
360            match key {
361                KeyCode::Esc | KeyCode::Enter => {
362                    self.show_run_detail = false;
363                    self.detail_scroll_offset = 0;
364                }
365                KeyCode::Up | KeyCode::Char('k') => {
366                    self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(1);
367                }
368                KeyCode::Down | KeyCode::Char('j') => {
369                    self.detail_scroll_offset = self.detail_scroll_offset.saturating_add(1);
370                }
371                _ => {}
372            }
373            return;
374        }
375
376        match key {
377            KeyCode::Tab => {
378                self.next_view();
379                // Clear run history filter when switching views with Tab
380                self.run_history_filter = None;
381                self.history_scroll_offset = 0;
382            }
383            // Up navigation (arrow key or k)
384            KeyCode::Up | KeyCode::Char('k') => {
385                if self.current_view == View::ActiveRuns {
386                    self.quadrant_move_up();
387                } else if self.selected_index > 0 {
388                    self.selected_index -= 1;
389                    // Adjust scroll offset if needed
390                    if self.current_view == View::RunHistory
391                        && self.selected_index < self.history_scroll_offset
392                    {
393                        self.history_scroll_offset = self.selected_index;
394                    }
395                }
396            }
397            // Down navigation (arrow key or j)
398            KeyCode::Down | KeyCode::Char('j') => {
399                if self.current_view == View::ActiveRuns {
400                    self.quadrant_move_down();
401                } else {
402                    let max_index = match self.current_view {
403                        View::ProjectList => self.projects.len().saturating_sub(1),
404                        View::ActiveRuns => 0, // Not used, handled above
405                        View::RunHistory => self.run_history.len().saturating_sub(1),
406                    };
407                    if self.selected_index < max_index {
408                        self.selected_index += 1;
409                    }
410                }
411            }
412            // Left navigation (h) - only meaningful in Active Runs quadrant view
413            KeyCode::Left | KeyCode::Char('h') => {
414                if self.current_view == View::ActiveRuns {
415                    self.quadrant_move_left();
416                }
417            }
418            // Right navigation (l) - only meaningful in Active Runs quadrant view
419            KeyCode::Right | KeyCode::Char('l') => {
420                if self.current_view == View::ActiveRuns {
421                    self.quadrant_move_right();
422                }
423            }
424            KeyCode::Enter => {
425                self.handle_enter();
426            }
427            KeyCode::Esc => {
428                // Hierarchical escape behavior - go back one level
429                match self.current_view {
430                    View::RunHistory => {
431                        if self.run_history_filter.is_some() {
432                            // Clear filter first
433                            self.run_history_filter = None;
434                            self.selected_index = 0;
435                            self.history_scroll_offset = 0;
436                        } else {
437                            // Go back to ProjectList
438                            self.current_view = View::ProjectList;
439                            self.selected_index = 0;
440                        }
441                    }
442                    View::ProjectList | View::ActiveRuns => {
443                        // Root views - quit
444                        self.should_quit = true;
445                    }
446                }
447            }
448            // Pagination for Active Runs view (n/] = next, p/[ = previous)
449            KeyCode::Char('n') | KeyCode::Char(']') => {
450                if self.current_view == View::ActiveRuns {
451                    self.next_quadrant_page();
452                }
453            }
454            KeyCode::Char('p') | KeyCode::Char('[') => {
455                if self.current_view == View::ActiveRuns {
456                    self.prev_quadrant_page();
457                }
458            }
459            _ => {}
460        }
461    }
462
463    /// Handle Enter key press based on current view.
464    fn handle_enter(&mut self) {
465        match self.current_view {
466            View::ProjectList => {
467                // Switch to Run History filtered by selected project
468                if let Some(project) = self.projects.get(self.selected_index) {
469                    self.run_history_filter = Some(project.info.name.clone());
470                    self.current_view = View::RunHistory;
471                    self.selected_index = 0;
472                    self.history_scroll_offset = 0;
473                }
474            }
475            View::RunHistory => {
476                // Show detail view for selected run
477                if self.selected_index < self.run_history.len() {
478                    self.show_run_detail = true;
479                    self.detail_scroll_offset = 0;
480                }
481            }
482            View::ActiveRuns => {
483                // No action for now
484            }
485        }
486    }
487
488    /// Check if run detail view is shown.
489    pub fn is_showing_run_detail(&self) -> bool {
490        self.show_run_detail
491    }
492
493    /// Get the current run history filter (project name).
494    pub fn run_history_filter(&self) -> Option<&str> {
495        self.run_history_filter.as_deref()
496    }
497
498    /// Check if the app should quit.
499    pub fn should_quit(&self) -> bool {
500        self.should_quit
501    }
502
503    /// Get the current view.
504    pub fn current_view(&self) -> View {
505        self.current_view
506    }
507
508    /// Get available views based on current state.
509    fn available_views(&self) -> Vec<View> {
510        if self.has_active_runs {
511            View::all().to_vec()
512        } else {
513            vec![View::ProjectList, View::RunHistory]
514        }
515    }
516
517    /// Render the UI to the terminal.
518    pub fn render(&self, frame: &mut Frame) {
519        let chunks = Layout::default()
520            .direction(Direction::Vertical)
521            .constraints([
522                Constraint::Length(3), // Header with tabs
523                Constraint::Min(0),    // Main content
524                Constraint::Length(1), // Footer
525            ])
526            .split(frame.area());
527
528        self.render_header(frame, chunks[0]);
529        self.render_content(frame, chunks[1]);
530        self.render_footer(frame, chunks[2]);
531    }
532
533    fn render_header(&self, frame: &mut Frame, area: Rect) {
534        let available_views = self.available_views();
535        let titles: Vec<Line> = available_views
536            .iter()
537            .map(|v| Line::from(v.name()))
538            .collect();
539
540        let selected_idx = available_views
541            .iter()
542            .position(|v| *v == self.current_view)
543            .unwrap_or(0);
544
545        let tabs = Tabs::new(titles)
546            .block(
547                Block::default()
548                    .borders(Borders::ALL)
549                    .title(" autom8 monitor ")
550                    .border_style(Style::default().fg(COLOR_PRIMARY)),
551            )
552            .select(selected_idx)
553            .style(Style::default().fg(Color::White))
554            .highlight_style(
555                Style::default()
556                    .fg(COLOR_PRIMARY)
557                    .add_modifier(Modifier::BOLD),
558            );
559
560        frame.render_widget(tabs, area);
561    }
562
563    fn render_content(&self, frame: &mut Frame, area: Rect) {
564        match self.current_view {
565            View::ActiveRuns => self.render_active_runs(frame, area),
566            View::ProjectList => self.render_project_list(frame, area),
567            View::RunHistory => self.render_run_history(frame, area),
568        }
569    }
570
571    fn render_active_runs(&self, frame: &mut Frame, area: Rect) {
572        // Calculate pagination info - now using sessions instead of projects
573        let total_runs = self.sessions.len();
574        let total_pages = total_runs.div_ceil(4).max(1);
575        let start_idx = self.quadrant_page * 4;
576
577        // Get the 4 sessions (or fewer) for the current page
578        let page_sessions: Vec<Option<&SessionData>> =
579            (0..4).map(|i| self.sessions.get(start_idx + i)).collect();
580
581        // Fixed 2x2 grid layout - always split into 2 rows, each with 2 columns
582        let rows = Layout::default()
583            .direction(Direction::Vertical)
584            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
585            .split(area);
586
587        let top_cols = Layout::default()
588            .direction(Direction::Horizontal)
589            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
590            .split(rows[0]);
591
592        let bottom_cols = Layout::default()
593            .direction(Direction::Horizontal)
594            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
595            .split(rows[1]);
596
597        // Map quadrant indices to areas: [0]=top-left, [1]=top-right, [2]=bottom-left, [3]=bottom-right
598        let quadrant_areas = [top_cols[0], top_cols[1], bottom_cols[0], bottom_cols[1]];
599
600        // Render each quadrant
601        for (i, opt_session) in page_sessions.iter().enumerate() {
602            let row = i / 2;
603            let col = i % 2;
604            let is_selected = row == self.quadrant_row && col == self.quadrant_col;
605
606            match opt_session {
607                Some(session) => {
608                    self.render_session_or_error(
609                        frame,
610                        quadrant_areas[i],
611                        session,
612                        false,
613                        is_selected,
614                    );
615                }
616                None => {
617                    // Empty bordered box for unused quadrants
618                    let empty_block = Block::default()
619                        .borders(Borders::ALL)
620                        .border_style(Style::default().fg(COLOR_DIM));
621                    frame.render_widget(empty_block, quadrant_areas[i]);
622                }
623            }
624        }
625
626        // Render page indicator if more than 4 runs (overlay at top-right of area)
627        if total_pages > 1 {
628            let indicator = format!(" Page {}/{} ", self.quadrant_page + 1, total_pages);
629            let indicator_width = indicator.len() as u16;
630            let indicator_area = Rect::new(
631                area.x + area.width.saturating_sub(indicator_width + 1),
632                area.y,
633                indicator_width,
634                1,
635            );
636            let indicator_widget = Paragraph::new(indicator)
637                .style(Style::default().fg(COLOR_PRIMARY).bg(Color::Black));
638            frame.render_widget(indicator_widget, indicator_area);
639        }
640    }
641
642    /// Render either a run detail or an error panel for a session
643    fn render_session_or_error(
644        &self,
645        frame: &mut Frame,
646        area: Rect,
647        session: &SessionData,
648        full: bool,
649        is_selected: bool,
650    ) {
651        if let Some(ref error) = session.load_error {
652            self.render_session_error_panel(frame, area, session, error, is_selected);
653        } else {
654            self.render_session_detail(frame, area, session, full, is_selected);
655        }
656    }
657
658    /// Render an error panel for a session with a corrupted state file or stale worktree
659    fn render_session_error_panel(
660        &self,
661        frame: &mut Frame,
662        area: Rect,
663        session: &SessionData,
664        error: &str,
665        is_selected: bool,
666    ) {
667        let border_color = if is_selected {
668            COLOR_WARNING
669        } else {
670            COLOR_ERROR
671        };
672        // Use display_title() to show "project (session-id)"
673        let block = Block::default()
674            .borders(Borders::ALL)
675            .title(format!(" {} ", session.display_title()))
676            .border_style(Style::default().fg(border_color));
677
678        let inner = block.inner(area);
679        frame.render_widget(block, area);
680
681        // Build error lines based on whether this is a stale session or corrupted state
682        let error_lines = if session.is_stale {
683            vec![
684                Line::from(vec![
685                    Span::styled("⚠ ", Style::default().fg(COLOR_ERROR)),
686                    Span::styled(
687                        "Stale Session",
688                        Style::default()
689                            .fg(COLOR_ERROR)
690                            .add_modifier(Modifier::BOLD),
691                    ),
692                ]),
693                Line::from(""),
694                Line::from(vec![
695                    Span::styled("Session ", Style::default().fg(COLOR_DIM)),
696                    Span::styled(
697                        &session.metadata.session_id,
698                        Style::default().fg(COLOR_PRIMARY),
699                    ),
700                    Span::styled(" failed to load.", Style::default().fg(COLOR_DIM)),
701                ]),
702                Line::from(""),
703                Line::from(Span::styled(error, Style::default().fg(COLOR_DIM))),
704                Line::from(""),
705                Line::from(Span::styled(
706                    "The worktree directory no longer exists.",
707                    Style::default().fg(COLOR_DIM),
708                )),
709                Line::from(Span::styled(
710                    "Run `autom8 clean --orphaned` to remove stale sessions.",
711                    Style::default().fg(COLOR_DIM),
712                )),
713            ]
714        } else {
715            vec![
716                Line::from(vec![
717                    Span::styled("⚠ ", Style::default().fg(COLOR_ERROR)),
718                    Span::styled(
719                        "State File Error",
720                        Style::default()
721                            .fg(COLOR_ERROR)
722                            .add_modifier(Modifier::BOLD),
723                    ),
724                ]),
725                Line::from(""),
726                Line::from(vec![
727                    Span::styled("Session ", Style::default().fg(COLOR_DIM)),
728                    Span::styled(
729                        &session.metadata.session_id,
730                        Style::default().fg(COLOR_PRIMARY),
731                    ),
732                    Span::styled(" failed to load.", Style::default().fg(COLOR_DIM)),
733                ]),
734                Line::from(""),
735                Line::from(Span::styled(error, Style::default().fg(COLOR_DIM))),
736                Line::from(""),
737                Line::from(Span::styled(
738                    "The state file may be corrupted or unreadable.",
739                    Style::default().fg(COLOR_DIM),
740                )),
741            ]
742        };
743
744        let paragraph = Paragraph::new(error_lines).wrap(Wrap { trim: true });
745        frame.render_widget(paragraph, inner);
746    }
747
748    /// Render detailed view for a single session
749    fn render_session_detail(
750        &self,
751        frame: &mut Frame,
752        area: Rect,
753        session: &SessionData,
754        full: bool,
755        is_selected: bool,
756    ) {
757        let run = match session.run.as_ref() {
758            Some(r) => r,
759            None => return, // No run to render
760        };
761
762        let border_color = if is_selected {
763            COLOR_WARNING
764        } else {
765            COLOR_PRIMARY
766        };
767        // Use display_title() to show "project (session-id)"
768        let block = Block::default()
769            .borders(Borders::ALL)
770            .title(format!(" {} ", session.display_title()))
771            .border_style(Style::default().fg(border_color));
772
773        let inner = block.inner(area);
774        frame.render_widget(block, area);
775
776        // Calculate header height based on session type and display mode
777        // Base: 4 lines (State, Story, Progress, Duration)
778        // + 1 for Session type (always shown)
779        // + 1 for Branch (always shown)
780        // + 1 for Worktree path (only for worktree sessions in full mode)
781        let base_height = 6; // State, Story, Progress, Duration, Session, Branch
782        let extra_height = if full && !session.is_main_session {
783            1
784        } else {
785            0
786        }; // Worktree path
787
788        // Split into header info and output snippet
789        let chunks = Layout::default()
790            .direction(Direction::Vertical)
791            .constraints([
792                Constraint::Length(base_height + extra_height),
793                Constraint::Min(0),
794            ])
795            .split(inner);
796
797        // Header info
798        // Check if session appears stuck (heartbeat is stale)
799        let appears_stuck = session.appears_stuck();
800
801        let state_str = if appears_stuck {
802            format!("{} (Not responding)", format_state_label(run.machine_state))
803        } else {
804            format_state_label(run.machine_state).to_string()
805        };
806        let state_color_value = if appears_stuck {
807            COLOR_WARNING
808        } else {
809            state_color(run.machine_state)
810        };
811
812        let duration = format_duration(run.started_at);
813        let story = run.current_story.as_deref().unwrap_or("N/A");
814
815        let progress_str = session
816            .progress
817            .as_ref()
818            .map(|p| p.as_fraction())
819            .unwrap_or_else(|| "N/A".to_string());
820
821        // Session type indicator with visual distinction
822        let (session_type_indicator, session_type_color) = if session.is_main_session {
823            ("● main", COLOR_PRIMARY)
824        } else {
825            ("◆ worktree", COLOR_REVIEW)
826        };
827
828        let mut info_lines = vec![
829            // Session type line (first for visibility)
830            Line::from(vec![
831                Span::styled("Session: ", Style::default().fg(COLOR_DIM)),
832                Span::styled(
833                    session_type_indicator,
834                    Style::default().fg(session_type_color),
835                ),
836            ]),
837            // Branch line (always visible)
838            Line::from(vec![
839                Span::styled("Branch: ", Style::default().fg(COLOR_DIM)),
840                Span::styled(&run.branch, Style::default().fg(Color::White)),
841            ]),
842            Line::from(vec![
843                Span::styled("State: ", Style::default().fg(COLOR_DIM)),
844                Span::styled(&state_str, Style::default().fg(state_color_value)),
845            ]),
846            Line::from(vec![
847                Span::styled("Story: ", Style::default().fg(COLOR_DIM)),
848                Span::styled(story, Style::default().fg(Color::White)),
849            ]),
850            Line::from(vec![
851                Span::styled("Progress: ", Style::default().fg(COLOR_DIM)),
852                Span::styled(&progress_str, Style::default().fg(COLOR_PRIMARY)),
853            ]),
854            Line::from(vec![
855                Span::styled("Duration: ", Style::default().fg(COLOR_DIM)),
856                Span::styled(&duration, Style::default().fg(COLOR_WARNING)),
857            ]),
858        ];
859
860        // Add worktree path for worktree sessions in full mode
861        if full && !session.is_main_session {
862            info_lines.insert(
863                2, // After Session and Branch
864                Line::from(vec![
865                    Span::styled("Path: ", Style::default().fg(COLOR_DIM)),
866                    Span::styled(
867                        session.truncated_worktree_path(),
868                        Style::default().fg(COLOR_DIM),
869                    ),
870                ]),
871            );
872        }
873
874        let info = Paragraph::new(info_lines);
875        frame.render_widget(info, chunks[0]);
876
877        // Output snippet section (prefer live output when available)
878        let output_snippet = self.get_output_snippet(run, session.live_output.as_ref());
879        let output = Paragraph::new(output_snippet)
880            .style(Style::default().fg(COLOR_DIM))
881            .wrap(Wrap { trim: true })
882            .block(
883                Block::default()
884                    .borders(Borders::TOP)
885                    .title(" Latest Output "),
886            );
887        frame.render_widget(output, chunks[1]);
888    }
889
890    /// Staleness threshold for live output (5 seconds)
891    const LIVE_OUTPUT_STALE_SECONDS: i64 = 5;
892
893    /// Get the latest output snippet from a run, preferring live output when available and fresh.
894    ///
895    /// Priority:
896    /// 1. If state is RunningClaude and live_output exists and is fresh (<5 seconds old), use live output
897    /// 2. Otherwise, if iteration has output_snippet, use that (last 5 lines)
898    /// 3. Fallback to status message based on machine state
899    fn get_output_snippet(&self, run: &RunState, live_output: Option<&LiveState>) -> String {
900        // Check for fresh live output when Claude is running
901        if run.machine_state == MachineState::RunningClaude {
902            if let Some(live) = live_output {
903                // Check if live output is fresh (within 5 seconds)
904                let age = Utc::now().signed_duration_since(live.updated_at);
905                if age.num_seconds() < Self::LIVE_OUTPUT_STALE_SECONDS
906                    && !live.output_lines.is_empty()
907                {
908                    // Take last 5 lines from live output (consistent with iteration output)
909                    let take_count = 5.min(live.output_lines.len());
910                    let start = live.output_lines.len().saturating_sub(take_count);
911                    return live.output_lines[start..].join("\n");
912                }
913            }
914        }
915
916        // Get output from the current or last iteration
917        if let Some(iter) = run.iterations.last() {
918            if !iter.output_snippet.is_empty() {
919                // Take last few lines of output
920                let lines: Vec<&str> = iter.output_snippet.lines().collect();
921                let take_count = 5.min(lines.len());
922                let start = lines.len().saturating_sub(take_count);
923                return lines[start..].join("\n");
924            }
925        }
926
927        // Fallback to status message based on state
928        match run.machine_state {
929            MachineState::Idle => "Waiting to start...".to_string(),
930            MachineState::LoadingSpec => "Loading spec file...".to_string(),
931            MachineState::GeneratingSpec => "Generating spec from markdown...".to_string(),
932            MachineState::Initializing => "Initializing run...".to_string(),
933            MachineState::PickingStory => "Selecting next story...".to_string(),
934            MachineState::RunningClaude => "Claude is working...".to_string(),
935            MachineState::Reviewing => {
936                format!("Reviewing changes (cycle {})...", run.review_iteration)
937            }
938            MachineState::Correcting => "Applying corrections...".to_string(),
939            MachineState::Committing => "Committing changes...".to_string(),
940            MachineState::CreatingPR => "Creating pull request...".to_string(),
941            MachineState::Completed => "Run completed successfully!".to_string(),
942            MachineState::Failed => "Run failed.".to_string(),
943        }
944    }
945
946    fn render_project_list(&self, frame: &mut Frame, area: Rect) {
947        if self.projects.is_empty() {
948            let message = "No projects found. Run 'autom8' in a project directory to create one.";
949            let paragraph = Paragraph::new(message)
950                .style(Style::default().fg(COLOR_DIM))
951                .block(Block::default().borders(Borders::ALL).title(" Projects "));
952            frame.render_widget(paragraph, area);
953            return;
954        }
955
956        // Count running sessions per project for multi-session awareness
957        let session_counts: std::collections::HashMap<String, usize> = self
958            .sessions
959            .iter()
960            .filter(|s| s.run.is_some() || s.load_error.is_some()) // Count sessions that are running or have errors
961            .fold(std::collections::HashMap::new(), |mut acc, s| {
962                *acc.entry(s.project_name.clone()).or_insert(0) += 1;
963                acc
964            });
965
966        let items: Vec<ListItem> = self
967            .projects
968            .iter()
969            .enumerate()
970            .map(|(i, p)| {
971                let is_selected = i == self.selected_index;
972                let session_count = session_counts.get(&p.info.name).copied().unwrap_or(0);
973
974                // Status indicator and text - check for errors first, then aggregate session state
975                let (status_indicator, status_text, status_clr) = if p.load_error.is_some() {
976                    ("⚠", "Error".to_string(), COLOR_ERROR)
977                } else if session_count > 1 {
978                    // Multiple sessions running - show count
979                    ("●", format!("[{} sessions]", session_count), COLOR_SUCCESS)
980                } else if p.active_run.is_some() || session_count == 1 {
981                    ("●", "Running".to_string(), COLOR_SUCCESS)
982                } else if let Some(last_run) = p.info.last_run_date {
983                    (
984                        "○",
985                        format!("Last run: {}", format_relative_time(last_run)),
986                        COLOR_DIM,
987                    )
988                } else {
989                    ("○", "Idle".to_string(), COLOR_DIM)
990                };
991
992                let name_style = if is_selected {
993                    Style::default()
994                        .fg(COLOR_WARNING)
995                        .add_modifier(Modifier::BOLD)
996                } else {
997                    Style::default().fg(Color::White)
998                };
999
1000                let line = Line::from(vec![
1001                    Span::styled(
1002                        if is_selected { "▶ " } else { "  " },
1003                        Style::default().fg(COLOR_PRIMARY),
1004                    ),
1005                    Span::styled(
1006                        format!("{} ", status_indicator),
1007                        Style::default().fg(status_clr),
1008                    ),
1009                    Span::styled(&p.info.name, name_style),
1010                    Span::styled(
1011                        format!("  {}", status_text),
1012                        Style::default().fg(status_clr),
1013                    ),
1014                ]);
1015
1016                ListItem::new(line)
1017            })
1018            .collect();
1019
1020        let title = format!(" Projects ({}) ", self.projects.len());
1021        let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
1022
1023        frame.render_widget(list, area);
1024    }
1025
1026    fn render_run_history(&self, frame: &mut Frame, area: Rect) {
1027        // Check if we should show the detail view
1028        if self.show_run_detail {
1029            self.render_run_detail_modal(frame, area);
1030            return;
1031        }
1032
1033        let title = if let Some(ref project) = self.run_history_filter {
1034            format!(" Run History: {} ({}) ", project, self.run_history.len())
1035        } else {
1036            format!(" Run History ({}) ", self.run_history.len())
1037        };
1038
1039        if self.run_history.is_empty() {
1040            let message = if self.run_history_filter.is_some() {
1041                "No runs found for this project"
1042            } else {
1043                "No run history found"
1044            };
1045            let paragraph = Paragraph::new(message)
1046                .style(Style::default().fg(COLOR_DIM))
1047                .block(Block::default().borders(Borders::ALL).title(title));
1048            frame.render_widget(paragraph, area);
1049            return;
1050        }
1051
1052        // Calculate visible area (accounting for borders)
1053        let inner_height = area.height.saturating_sub(2) as usize;
1054
1055        // Build list items
1056        let items: Vec<ListItem> = self
1057            .run_history
1058            .iter()
1059            .enumerate()
1060            .skip(self.history_scroll_offset)
1061            .take(inner_height)
1062            .map(|(i, entry)| {
1063                let is_selected = i == self.selected_index;
1064
1065                // Status indicator and color
1066                let (status_indicator, status_clr) = match entry.status {
1067                    crate::state::RunStatus::Completed => ("✓", COLOR_SUCCESS),
1068                    crate::state::RunStatus::Failed => ("✗", COLOR_ERROR),
1069                    crate::state::RunStatus::Running => ("●", COLOR_WARNING),
1070                    crate::state::RunStatus::Interrupted => ("⚠", COLOR_WARNING),
1071                };
1072
1073                // Format date/time
1074                let date_str = entry
1075                    .started_at
1076                    .with_timezone(&chrono::Local)
1077                    .format("%Y-%m-%d %I:%M %p")
1078                    .to_string();
1079
1080                // Story count
1081                let story_str = format!("{}/{}", entry.completed_stories, entry.total_stories);
1082
1083                // Duration if completed
1084                let duration_str = if let Some(finished) = entry.finished_at {
1085                    let duration = finished.signed_duration_since(entry.started_at);
1086                    let secs = duration.num_seconds().max(0) as u64;
1087                    let mins = secs / 60;
1088                    let hours = secs / 3600;
1089                    if hours > 0 {
1090                        format!("{}h {}m", hours, (secs % 3600) / 60)
1091                    } else if mins > 0 {
1092                        format!("{}m {}s", mins, secs % 60)
1093                    } else {
1094                        format!("{}s", secs)
1095                    }
1096                } else {
1097                    "—".to_string()
1098                };
1099
1100                let name_style = if is_selected {
1101                    Style::default()
1102                        .fg(COLOR_WARNING)
1103                        .add_modifier(Modifier::BOLD)
1104                } else {
1105                    Style::default().fg(Color::White)
1106                };
1107
1108                // Build line with project name (if unfiltered), date, status, stories, duration
1109                let mut spans = vec![
1110                    Span::styled(
1111                        if is_selected { "▶ " } else { "  " },
1112                        Style::default().fg(COLOR_PRIMARY),
1113                    ),
1114                    Span::styled(
1115                        format!("{} ", status_indicator),
1116                        Style::default().fg(status_clr),
1117                    ),
1118                ];
1119
1120                // Show project name only if not filtered
1121                if self.run_history_filter.is_none() {
1122                    spans.push(Span::styled(
1123                        format!("{:<16} ", truncate_string(&entry.project_name, 15)),
1124                        name_style,
1125                    ));
1126                }
1127
1128                spans.extend([
1129                    Span::styled(date_str, Style::default().fg(COLOR_PRIMARY)),
1130                    Span::styled("  ", Style::default()),
1131                    Span::styled(
1132                        format!("Stories: {:<7}", story_str),
1133                        Style::default().fg(COLOR_DIM),
1134                    ),
1135                    Span::styled(
1136                        format!("  Duration: {}", duration_str),
1137                        Style::default().fg(COLOR_DIM),
1138                    ),
1139                ]);
1140
1141                ListItem::new(Line::from(spans))
1142            })
1143            .collect();
1144
1145        let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
1146
1147        frame.render_widget(list, area);
1148    }
1149
1150    /// Render detailed view for a selected run history entry
1151    fn render_run_detail_modal(&self, frame: &mut Frame, area: Rect) {
1152        let entry = match self.run_history.get(self.selected_index) {
1153            Some(e) => e,
1154            None => return,
1155        };
1156
1157        // Get the full run state from cache (needed for iterations)
1158        let run_state = self.run_state_cache.get(&entry.run_id);
1159
1160        let title = format!(" Run Details: {} ", entry.project_name);
1161
1162        // Create a centered modal area
1163        let modal_area = centered_rect(80, 80, area);
1164
1165        // Clear background
1166        frame.render_widget(
1167            Block::default().style(Style::default().bg(Color::Black)),
1168            modal_area,
1169        );
1170
1171        let block = Block::default()
1172            .borders(Borders::ALL)
1173            .title(title)
1174            .border_style(Style::default().fg(COLOR_PRIMARY))
1175            .style(Style::default().bg(Color::Black));
1176
1177        let inner = block.inner(modal_area);
1178        frame.render_widget(block, modal_area);
1179
1180        // Build detail content using entry fields
1181        // Status with color
1182        let (status_str, status_clr) = match entry.status {
1183            crate::state::RunStatus::Completed => ("Completed", COLOR_SUCCESS),
1184            crate::state::RunStatus::Failed => ("Failed", COLOR_ERROR),
1185            crate::state::RunStatus::Running => ("Running", COLOR_WARNING),
1186            crate::state::RunStatus::Interrupted => ("Interrupted", COLOR_WARNING),
1187        };
1188
1189        // Duration
1190        let duration_str = if let Some(finished) = entry.finished_at {
1191            let duration = finished.signed_duration_since(entry.started_at);
1192            let secs = duration.num_seconds().max(0) as u64;
1193            let mins = secs / 60;
1194            let hours = secs / 3600;
1195            if hours > 0 {
1196                format!("{}h {}m {}s", hours, (secs % 3600) / 60, secs % 60)
1197            } else if mins > 0 {
1198                format!("{}m {}s", mins, secs % 60)
1199            } else {
1200                format!("{}s", secs)
1201            }
1202        } else {
1203            "In progress".to_string()
1204        };
1205
1206        let mut lines = vec![
1207            Line::from(vec![
1208                Span::styled("Status:     ", Style::default().fg(COLOR_DIM)),
1209                Span::styled(status_str, Style::default().fg(status_clr)),
1210            ]),
1211            Line::from(vec![
1212                Span::styled("Started:    ", Style::default().fg(COLOR_DIM)),
1213                Span::styled(
1214                    entry
1215                        .started_at
1216                        .with_timezone(&chrono::Local)
1217                        .format("%Y-%m-%d %I:%M:%S %p")
1218                        .to_string(),
1219                    Style::default().fg(Color::White),
1220                ),
1221            ]),
1222            Line::from(vec![
1223                Span::styled("Duration:   ", Style::default().fg(COLOR_DIM)),
1224                Span::styled(&duration_str, Style::default().fg(COLOR_WARNING)),
1225            ]),
1226            Line::from(vec![
1227                Span::styled("Branch:     ", Style::default().fg(COLOR_DIM)),
1228                Span::styled(&entry.branch, Style::default().fg(COLOR_PRIMARY)),
1229            ]),
1230            Line::from(vec![
1231                Span::styled("Stories:    ", Style::default().fg(COLOR_DIM)),
1232                Span::styled(
1233                    format!(
1234                        "{}/{} completed",
1235                        entry.completed_stories, entry.total_stories
1236                    ),
1237                    Style::default().fg(Color::White),
1238                ),
1239            ]),
1240            Line::from(""),
1241            Line::from(Span::styled(
1242                "Iterations:",
1243                Style::default()
1244                    .fg(Color::White)
1245                    .add_modifier(Modifier::BOLD),
1246            )),
1247        ];
1248
1249        // Add iteration details from cached run state (if available)
1250        if let Some(run) = run_state {
1251            for iter in &run.iterations {
1252                let iter_status_clr = match iter.status {
1253                    crate::state::IterationStatus::Success => COLOR_SUCCESS,
1254                    crate::state::IterationStatus::Failed => COLOR_ERROR,
1255                    crate::state::IterationStatus::Running => COLOR_WARNING,
1256                };
1257                let iter_status_str = match iter.status {
1258                    crate::state::IterationStatus::Success => "✓",
1259                    crate::state::IterationStatus::Failed => "✗",
1260                    crate::state::IterationStatus::Running => "●",
1261                };
1262
1263                lines.push(Line::from(vec![
1264                    Span::styled("  ", Style::default()),
1265                    Span::styled(
1266                        format!("{} ", iter_status_str),
1267                        Style::default().fg(iter_status_clr),
1268                    ),
1269                    Span::styled(&iter.story_id, Style::default().fg(Color::White)),
1270                ]));
1271
1272                // Show work summary if available
1273                if let Some(ref summary) = iter.work_summary {
1274                    let truncated = truncate_string(summary, 60);
1275                    lines.push(Line::from(vec![
1276                        Span::styled("    ", Style::default()),
1277                        Span::styled(truncated, Style::default().fg(COLOR_DIM)),
1278                    ]));
1279                }
1280            }
1281        } else {
1282            lines.push(Line::from(Span::styled(
1283                "  (iteration details not available)",
1284                Style::default().fg(COLOR_DIM),
1285            )));
1286        }
1287
1288        lines.push(Line::from(""));
1289        lines.push(Line::from(Span::styled(
1290            "j/k or ↑↓: scroll | Enter/Esc: close",
1291            Style::default().fg(COLOR_DIM),
1292        )));
1293
1294        let paragraph = Paragraph::new(lines)
1295            .wrap(Wrap { trim: true })
1296            .scroll((self.detail_scroll_offset as u16, 0));
1297        frame.render_widget(paragraph, inner);
1298    }
1299
1300    fn render_footer(&self, frame: &mut Frame, area: Rect) {
1301        let help_text = if self.show_run_detail {
1302            " jk/↑↓: scroll | Enter/Esc: close detail view ".to_string()
1303        } else {
1304            match self.current_view {
1305                View::ProjectList => {
1306                    " Tab: switch view | jk/↑↓: navigate | Enter: view history | Q: quit "
1307                        .to_string()
1308                }
1309                View::RunHistory => {
1310                    if self.run_history_filter.is_some() {
1311                        " Tab: switch view | jk/↑↓: navigate | Enter: details | Esc: clear filter | Q: quit ".to_string()
1312                    } else {
1313                        " Tab: switch view | jk/↑↓: navigate | Enter: details | Q: quit "
1314                            .to_string()
1315                    }
1316                }
1317                View::ActiveRuns => {
1318                    // Show pagination keys only when there are more than 4 runs
1319                    if self.total_quadrant_pages() > 1 {
1320                        " Tab: switch view | hjkl/arrows: navigate | n/]: next page | p/[: prev page | Q: quit ".to_string()
1321                    } else {
1322                        " Tab: switch view | hjkl/arrows: navigate | Q: quit ".to_string()
1323                    }
1324                }
1325            }
1326        };
1327        let footer = Paragraph::new(help_text).style(Style::default().fg(COLOR_DIM));
1328        frame.render_widget(footer, area);
1329    }
1330}
1331
1332/// Truncate a string to a maximum length, adding "..." if truncated
1333fn truncate_string(s: &str, max_len: usize) -> String {
1334    if s.len() <= max_len {
1335        s.to_string()
1336    } else {
1337        format!("{}...", &s[..max_len.saturating_sub(3)])
1338    }
1339}
1340
1341/// Create a centered rectangle of given percentage width/height
1342fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
1343    let popup_layout = Layout::default()
1344        .direction(Direction::Vertical)
1345        .constraints([
1346            Constraint::Percentage((100 - percent_y) / 2),
1347            Constraint::Percentage(percent_y),
1348            Constraint::Percentage((100 - percent_y) / 2),
1349        ])
1350        .split(area);
1351
1352    Layout::default()
1353        .direction(Direction::Horizontal)
1354        .constraints([
1355            Constraint::Percentage((100 - percent_x) / 2),
1356            Constraint::Percentage(percent_x),
1357            Constraint::Percentage((100 - percent_x) / 2),
1358        ])
1359        .split(popup_layout[1])[1]
1360}
1361
1362/// Initialize the terminal for TUI mode.
1363pub fn init_terminal() -> MonitorResult<Terminal<CrosstermBackend<Stdout>>> {
1364    enable_raw_mode()?;
1365    let mut stdout = io::stdout();
1366    execute!(stdout, EnterAlternateScreen)?;
1367    let backend = CrosstermBackend::new(stdout);
1368    let terminal = Terminal::new(backend)?;
1369    Ok(terminal)
1370}
1371
1372/// Restore the terminal to normal mode.
1373pub fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> MonitorResult<()> {
1374    disable_raw_mode()?;
1375    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1376    terminal.show_cursor()?;
1377    Ok(())
1378}
1379
1380/// Run the monitor TUI application.
1381///
1382/// This is the main entry point for the monitor command. It initializes the terminal,
1383/// runs the event loop, and restores the terminal on exit.
1384///
1385/// The refresh interval is hardcoded to 100ms for responsive UI updates.
1386pub fn run_monitor() -> Result<()> {
1387    // Set up panic hook to restore terminal on panic
1388    let original_hook = std::panic::take_hook();
1389    std::panic::set_hook(Box::new(move |panic_info| {
1390        // Attempt to restore terminal state
1391        let _ = disable_raw_mode();
1392        let _ = execute!(io::stdout(), LeaveAlternateScreen);
1393        original_hook(panic_info);
1394    }));
1395
1396    // Initialize terminal
1397    let mut terminal = init_terminal().map_err(|e| match e {
1398        MonitorError::Io(io_err) => crate::error::Autom8Error::Io(io_err),
1399        MonitorError::Autom8(err) => err,
1400    })?;
1401
1402    // Create app state
1403    let mut app = MonitorApp::new();
1404
1405    // Initial data load
1406    app.refresh_data()?;
1407
1408    // Set default view based on active runs
1409    if app.has_active_runs {
1410        app.current_view = View::ActiveRuns;
1411    }
1412
1413    // Main event loop - hardcoded to 100ms for responsive UI
1414    let poll_duration = Duration::from_millis(100);
1415
1416    loop {
1417        // Render
1418        terminal.draw(|frame| app.render(frame))?;
1419
1420        // Poll for events with timeout
1421        if event::poll(poll_duration)? {
1422            if let Event::Key(key) = event::read()? {
1423                // Only handle key press events (not release or repeat)
1424                if key.kind == KeyEventKind::Press {
1425                    app.handle_key(key.code);
1426                }
1427            }
1428            // Handle resize events gracefully - ratatui handles this automatically
1429            // on the next draw call
1430        }
1431
1432        // Check if we should quit
1433        if app.should_quit() {
1434            break;
1435        }
1436
1437        // Refresh data each cycle
1438        app.refresh_data()?;
1439    }
1440
1441    // Restore terminal
1442    restore_terminal(&mut terminal).map_err(|e| match e {
1443        MonitorError::Io(io_err) => crate::error::Autom8Error::Io(io_err),
1444        MonitorError::Autom8(err) => err,
1445    })?;
1446
1447    Ok(())
1448}