1use 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
29const COLOR_PRIMARY: Color = Color::Cyan;
35const COLOR_SUCCESS: Color = Color::Green;
37const COLOR_WARNING: Color = Color::Yellow;
39const COLOR_ERROR: Color = Color::Red;
41const COLOR_DIM: Color = Color::DarkGray;
43const COLOR_REVIEW: Color = Color::Magenta;
45
46pub type MonitorResult<T> = std::result::Result<T, MonitorError>;
48
49#[derive(Debug)]
51pub enum MonitorError {
52 Io(io::Error),
54 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
81fn state_color(state: MachineState) -> Color {
92 match Status::from_machine_state(state) {
93 Status::Setup => COLOR_DIM, Status::Running => COLOR_PRIMARY, Status::Reviewing => COLOR_WARNING, Status::Correcting => COLOR_REVIEW, Status::Success => COLOR_SUCCESS, Status::Warning => COLOR_WARNING, Status::Error => COLOR_ERROR, Status::Idle => COLOR_DIM, }
102}
103
104pub struct MonitorApp {
106 current_view: View,
108 projects: Vec<ProjectData>,
110 sessions: Vec<SessionData>,
113 run_history: Vec<RunHistoryEntry>,
115 has_active_runs: bool,
117 should_quit: bool,
119 selected_index: usize,
121 run_history_filter: Option<String>,
123 history_scroll_offset: usize,
125 show_run_detail: bool,
127 quadrant_page: usize,
129 quadrant_row: usize,
131 quadrant_col: usize,
133 detail_scroll_offset: usize,
135 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 pub fn new() -> Self {
148 Self {
149 current_view: View::ProjectList, 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 pub fn refresh_data(&mut self) -> Result<()> {
172 let ui_data = match load_ui_data(None) {
175 Ok(data) => data,
176 Err(e) => {
177 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 self.current_view == View::ActiveRuns && !self.has_active_runs {
189 self.current_view = View::ProjectList;
190 }
191
192 self.clamp_selection_index();
194
195 let _ = self.refresh_run_history();
197
198 Ok(())
199 }
200
201 fn clamp_selection_index(&mut self) {
203 let max_index = match self.current_view {
204 View::ProjectList => self.projects.len().saturating_sub(1),
205 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 if self.history_scroll_offset > self.selected_index {
214 self.history_scroll_offset = self.selected_index;
215 }
216 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 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 if !self.is_quadrant_valid(self.quadrant_row, self.quadrant_col) {
229 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 fn refresh_run_history(&mut self) -> Result<()> {
242 let options = RunHistoryOptions {
244 project_filter: self.run_history_filter.clone(),
245 max_entries: Some(100), };
247
248 let history_data = load_run_history(&self.projects, &options, true).unwrap_or_default();
251
252 self.run_state_cache = history_data.run_states;
254
255 self.run_history = history_data.entries;
256
257 Ok(())
258 }
259
260 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; self.quadrant_row = 0;
266 self.quadrant_col = 0;
267 }
268
269 fn total_quadrant_pages(&self) -> usize {
271 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 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 fn prev_quadrant_page(&mut self) {
290 if self.quadrant_page > 0 {
291 self.quadrant_page -= 1;
292 }
293 }
294
295 fn runs_on_current_page(&self) -> usize {
297 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 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 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 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 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 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 pub fn handle_key(&mut self, key: KeyCode) {
352 if matches!(key, KeyCode::Char('q') | KeyCode::Char('Q')) {
354 self.should_quit = true;
355 return;
356 }
357
358 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 self.run_history_filter = None;
381 self.history_scroll_offset = 0;
382 }
383 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 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 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, 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 KeyCode::Left | KeyCode::Char('h') => {
414 if self.current_view == View::ActiveRuns {
415 self.quadrant_move_left();
416 }
417 }
418 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 match self.current_view {
430 View::RunHistory => {
431 if self.run_history_filter.is_some() {
432 self.run_history_filter = None;
434 self.selected_index = 0;
435 self.history_scroll_offset = 0;
436 } else {
437 self.current_view = View::ProjectList;
439 self.selected_index = 0;
440 }
441 }
442 View::ProjectList | View::ActiveRuns => {
443 self.should_quit = true;
445 }
446 }
447 }
448 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 fn handle_enter(&mut self) {
465 match self.current_view {
466 View::ProjectList => {
467 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 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 }
485 }
486 }
487
488 pub fn is_showing_run_detail(&self) -> bool {
490 self.show_run_detail
491 }
492
493 pub fn run_history_filter(&self) -> Option<&str> {
495 self.run_history_filter.as_deref()
496 }
497
498 pub fn should_quit(&self) -> bool {
500 self.should_quit
501 }
502
503 pub fn current_view(&self) -> View {
505 self.current_view
506 }
507
508 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 pub fn render(&self, frame: &mut Frame) {
519 let chunks = Layout::default()
520 .direction(Direction::Vertical)
521 .constraints([
522 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
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 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 let page_sessions: Vec<Option<&SessionData>> =
579 (0..4).map(|i| self.sessions.get(start_idx + i)).collect();
580
581 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 let quadrant_areas = [top_cols[0], top_cols[1], bottom_cols[0], bottom_cols[1]];
599
600 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 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 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 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 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 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 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 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, };
761
762 let border_color = if is_selected {
763 COLOR_WARNING
764 } else {
765 COLOR_PRIMARY
766 };
767 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 let base_height = 6; let extra_height = if full && !session.is_main_session {
783 1
784 } else {
785 0
786 }; 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 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 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 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 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 if full && !session.is_main_session {
862 info_lines.insert(
863 2, 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 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 const LIVE_OUTPUT_STALE_SECONDS: i64 = 5;
892
893 fn get_output_snippet(&self, run: &RunState, live_output: Option<&LiveState>) -> String {
900 if run.machine_state == MachineState::RunningClaude {
902 if let Some(live) = live_output {
903 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 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 if let Some(iter) = run.iterations.last() {
918 if !iter.output_snippet.is_empty() {
919 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 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 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()) .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 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 ("●", 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 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 let inner_height = area.height.saturating_sub(2) as usize;
1054
1055 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 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 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 let story_str = format!("{}/{}", entry.completed_stories, entry.total_stories);
1082
1083 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 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 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 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 let run_state = self.run_state_cache.get(&entry.run_id);
1159
1160 let title = format!(" Run Details: {} ", entry.project_name);
1161
1162 let modal_area = centered_rect(80, 80, area);
1164
1165 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 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 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 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 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 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
1332fn 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
1341fn 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
1362pub 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
1372pub 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
1380pub fn run_monitor() -> Result<()> {
1387 let original_hook = std::panic::take_hook();
1389 std::panic::set_hook(Box::new(move |panic_info| {
1390 let _ = disable_raw_mode();
1392 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1393 original_hook(panic_info);
1394 }));
1395
1396 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 let mut app = MonitorApp::new();
1404
1405 app.refresh_data()?;
1407
1408 if app.has_active_runs {
1410 app.current_view = View::ActiveRuns;
1411 }
1412
1413 let poll_duration = Duration::from_millis(100);
1415
1416 loop {
1417 terminal.draw(|frame| app.render(frame))?;
1419
1420 if event::poll(poll_duration)? {
1422 if let Event::Key(key) = event::read()? {
1423 if key.kind == KeyEventKind::Press {
1425 app.handle_key(key.code);
1426 }
1427 }
1428 }
1431
1432 if app.should_quit() {
1434 break;
1435 }
1436
1437 app.refresh_data()?;
1439 }
1440
1441 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}