Skip to main content

cargowatch_ui/
lib.rs

1//! Ratatui dashboard for live monitoring and history browsing.
2
3use std::collections::BTreeMap;
4
5use ansi_to_tui::IntoText;
6use ratatui::Frame;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::prelude::{Alignment, Color, Line, Modifier, Span, Style, Text};
9use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
10use time::OffsetDateTime;
11
12use cargowatch_core::{
13    AppConfig, DetectedProcess, LogFilter, SessionEvent, SessionHistoryEntry, SessionInfo,
14    SessionMode, SessionSelection, SessionState, SessionStatus, SummaryCounts,
15};
16
17/// Commands requested by the UI for the application layer to execute.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum UiAction {
20    /// Exit the dashboard.
21    Quit,
22    /// Start a new managed command from the TUI prompt.
23    StartManagedCommand(String),
24    /// Cancel the currently selected managed session.
25    CancelSession(String),
26    /// Load a history session from persistence.
27    LoadSession(String),
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum FocusPane {
32    Sessions,
33    Main,
34    Diagnostics,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum CenterView {
39    Logs,
40    Diagnostics,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44enum InputOverlay {
45    Search { buffer: String },
46    Command { buffer: String },
47}
48
49/// Stateful Ratatui dashboard model.
50pub struct Dashboard {
51    config: AppConfig,
52    active_sessions: BTreeMap<String, SessionState>,
53    history_sessions: BTreeMap<String, SessionState>,
54    history: Vec<SessionHistoryEntry>,
55    selection: usize,
56    focus: FocusPane,
57    center_view: CenterView,
58    overlay: Option<InputOverlay>,
59    filter: LogFilter,
60    follow: bool,
61    show_raw: bool,
62    log_scroll: u16,
63    diagnostic_scroll: u16,
64    status_message: String,
65}
66
67impl Dashboard {
68    /// Create a new dashboard.
69    pub fn new(config: AppConfig) -> Self {
70        let follow = config.auto_follow_running_session;
71        Self {
72            config,
73            active_sessions: BTreeMap::new(),
74            history_sessions: BTreeMap::new(),
75            history: Vec::new(),
76            selection: 0,
77            focus: FocusPane::Sessions,
78            center_view: CenterView::Logs,
79            overlay: None,
80            filter: LogFilter::default(),
81            follow,
82            show_raw: false,
83            log_scroll: 0,
84            diagnostic_scroll: 0,
85            status_message: "Press `n` for a managed run with logs, diagnostics, and artifacts. Detected external builds are summary-only.".to_string()
86        }
87    }
88
89    /// Replace the recent history list.
90    pub fn set_history(&mut self, history: Vec<SessionHistoryEntry>) {
91        self.history = history;
92        if self.selection >= self.left_items().len() {
93            self.selection = self.left_items().len().saturating_sub(1);
94        }
95    }
96
97    /// Insert or replace a session loaded from history.
98    pub fn insert_history_session(&mut self, session: SessionState) {
99        self.history_sessions
100            .insert(session.info.session_id.clone(), session);
101    }
102
103    /// Apply a new runtime event to the dashboard state.
104    pub fn apply_event(&mut self, event: &SessionEvent, max_logs: usize) {
105        match event {
106            SessionEvent::SessionStarted(info) => {
107                self.active_sessions
108                    .entry(info.session_id.clone())
109                    .or_insert_with(|| SessionState::new(info.clone(), max_logs));
110            }
111            SessionEvent::ProcessDetected(process) => {
112                let info = process_to_session_info(process);
113                self.active_sessions
114                    .entry(info.session_id.clone())
115                    .or_insert_with(|| SessionState::new(info, max_logs));
116                self.status_message = format!(
117                    "Detected {} on pid {}. Logs, diagnostics, and artifacts require managed mode.",
118                    process.classification.label(),
119                    process.pid
120                );
121            }
122            SessionEvent::ProcessUpdated(process) => {
123                self.active_sessions
124                    .entry(process.session_id.clone())
125                    .or_insert_with(|| {
126                        SessionState::new(process_to_session_info(process), max_logs)
127                    })
128                    .apply(event);
129            }
130            SessionEvent::ProcessGone { session_id, .. } => {
131                if let Some(session) = self.active_sessions.get_mut(session_id) {
132                    session.apply(event);
133                }
134            }
135            SessionEvent::OutputLine { session_id, .. }
136            | SessionEvent::Diagnostic { session_id, .. }
137            | SessionEvent::ArtifactBuilt { session_id, .. }
138            | SessionEvent::SessionFinished(cargowatch_core::SessionFinished {
139                session_id, ..
140            }) => {
141                if let Some(session) = self.active_sessions.get_mut(session_id) {
142                    session.apply(event);
143                }
144            }
145        }
146    }
147
148    /// Handle a keyboard event and return any requested app-layer action.
149    pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Option<UiAction> {
150        use crossterm::event::{KeyCode, KeyModifiers};
151
152        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
153            return self
154                .selected_session()
155                .filter(|session| session.info.mode == SessionMode::Managed && session.is_running())
156                .map(|session| UiAction::CancelSession(session.info.session_id.clone()))
157                .or(Some(UiAction::Quit));
158        }
159
160        if let Some(overlay) = &mut self.overlay {
161            match key.code {
162                KeyCode::Esc => {
163                    self.overlay = None;
164                    self.status_message = "Overlay dismissed.".to_string();
165                }
166                KeyCode::Enter => {
167                    let action = match overlay {
168                        InputOverlay::Search { buffer } => {
169                            self.filter.search =
170                                (!buffer.trim().is_empty()).then(|| buffer.trim().to_string());
171                            self.status_message = format!(
172                                "Search {}",
173                                self.filter
174                                    .search
175                                    .as_deref()
176                                    .map(|query| format!("set to `{query}`"))
177                                    .unwrap_or_else(|| "cleared".to_string())
178                            );
179                            None
180                        }
181                        InputOverlay::Command { buffer } => {
182                            let command = buffer.trim().to_string();
183                            if command.is_empty() {
184                                self.status_message =
185                                    "Enter a command after `n` to start a managed session."
186                                        .to_string();
187                                None
188                            } else {
189                                self.status_message = format!("Launching `{command}`...");
190                                Some(UiAction::StartManagedCommand(command))
191                            }
192                        }
193                    };
194                    self.overlay = None;
195                    return action;
196                }
197                KeyCode::Backspace => match overlay {
198                    InputOverlay::Search { buffer } | InputOverlay::Command { buffer } => {
199                        buffer.pop();
200                    }
201                },
202                KeyCode::Char(ch) => match overlay {
203                    InputOverlay::Search { buffer } | InputOverlay::Command { buffer } => {
204                        buffer.push(ch);
205                    }
206                },
207                _ => {}
208            }
209            return None;
210        }
211
212        match key.code {
213            KeyCode::Char('q') => return Some(UiAction::Quit),
214            KeyCode::Tab => self.focus = self.focus.next(),
215            KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
216            KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
217            KeyCode::PageUp => self.scroll_active_pane(-8),
218            KeyCode::PageDown => self.scroll_active_pane(8),
219            KeyCode::Char('f') => {
220                self.follow = !self.follow;
221                self.status_message = if self.follow {
222                    "Follow mode enabled.".to_string()
223                } else {
224                    "Follow mode disabled.".to_string()
225                };
226            }
227            KeyCode::Char('r') => {
228                self.show_raw = !self.show_raw;
229                self.status_message = if self.show_raw {
230                    "Showing raw output.".to_string()
231                } else {
232                    "Showing rendered output.".to_string()
233                };
234            }
235            KeyCode::Char('v') => {
236                self.center_view = match self.center_view {
237                    CenterView::Logs => CenterView::Diagnostics,
238                    CenterView::Diagnostics => CenterView::Logs,
239                };
240            }
241            KeyCode::Char('/') => {
242                self.overlay = Some(InputOverlay::Search {
243                    buffer: self.filter.search.clone().unwrap_or_default(),
244                });
245            }
246            KeyCode::Char('n') => {
247                self.overlay = Some(InputOverlay::Command {
248                    buffer: "cargo check".to_string(),
249                });
250            }
251            KeyCode::Char('0') => self.filter = LogFilter::default(),
252            KeyCode::Char('1') => {
253                self.filter = LogFilter::only(cargowatch_core::event::Severity::Error)
254            }
255            KeyCode::Char('2') => {
256                self.filter = LogFilter::only(cargowatch_core::event::Severity::Warning)
257            }
258            KeyCode::Char('3') => {
259                self.filter = LogFilter::only(cargowatch_core::event::Severity::Note)
260            }
261            KeyCode::Char('4') => {
262                self.filter = LogFilter::only(cargowatch_core::event::Severity::Help)
263            }
264            KeyCode::Char('5') => {
265                self.filter = LogFilter::only(cargowatch_core::event::Severity::Info)
266            }
267            KeyCode::Enter => {
268                if let Some(session_id) = self.selected_history_id_to_load() {
269                    return Some(UiAction::LoadSession(session_id));
270                }
271            }
272            KeyCode::Char('c') => {
273                return self
274                    .selected_session()
275                    .filter(|session| {
276                        session.info.mode == SessionMode::Managed && session.is_running()
277                    })
278                    .map(|session| UiAction::CancelSession(session.info.session_id.clone()));
279            }
280            _ => {}
281        }
282        None
283    }
284
285    /// Render the dashboard.
286    pub fn render(&self, frame: &mut Frame) {
287        let root = frame.area();
288        let rows = Layout::default()
289            .direction(Direction::Vertical)
290            .constraints([
291                Constraint::Length(3),
292                Constraint::Min(12),
293                Constraint::Length(2),
294            ])
295            .split(root);
296        let body = Layout::default()
297            .direction(Direction::Horizontal)
298            .constraints([
299                Constraint::Percentage(24),
300                Constraint::Percentage(51),
301                Constraint::Percentage(25),
302            ])
303            .split(rows[1]);
304
305        frame.render_widget(self.render_status_bar(), rows[0]);
306        let mut session_state = ListState::default();
307        session_state.select(Some(self.selection));
308        frame.render_stateful_widget(self.render_session_list(), body[0], &mut session_state);
309        frame.render_widget(self.render_center_pane(), body[1]);
310        frame.render_widget(self.render_diagnostics_pane(), body[2]);
311        frame.render_widget(self.render_footer(), rows[2]);
312
313        if let Some(overlay) = &self.overlay {
314            let popup = centered_rect(60, 5, root);
315            frame.render_widget(Clear, popup);
316            frame.render_widget(self.render_overlay(overlay), popup);
317        }
318    }
319
320    fn selected_history_id_to_load(&self) -> Option<String> {
321        match self.left_items().get(self.selection) {
322            Some(SessionSelection::History(session_id))
323                if !self.history_sessions.contains_key(session_id) =>
324            {
325                Some(session_id.clone())
326            }
327            _ => None,
328        }
329    }
330
331    fn selected_session(&self) -> Option<&SessionState> {
332        match self.left_items().get(self.selection) {
333            Some(SessionSelection::Active(session_id)) => self.active_sessions.get(session_id),
334            Some(SessionSelection::History(session_id)) => self.history_sessions.get(session_id),
335            None => None,
336        }
337    }
338
339    fn left_items(&self) -> Vec<SessionSelection> {
340        let mut active = self
341            .active_sessions
342            .values()
343            .map(|session| session.history_entry())
344            .collect::<Vec<_>>();
345        active.sort_by(|left, right| right.info.started_at.cmp(&left.info.started_at));
346
347        let mut items = active
348            .into_iter()
349            .map(|entry| SessionSelection::Active(entry.info.session_id))
350            .collect::<Vec<_>>();
351        for entry in &self.history {
352            if !self.active_sessions.contains_key(&entry.info.session_id) {
353                items.push(SessionSelection::History(entry.info.session_id.clone()));
354            }
355        }
356        items
357    }
358
359    fn move_selection(&mut self, delta: isize) {
360        match self.focus {
361            FocusPane::Sessions => {
362                let len = self.left_items().len();
363                if len == 0 {
364                    self.selection = 0;
365                    return;
366                }
367                let current = self.selection as isize;
368                self.selection = (current + delta).clamp(0, (len - 1) as isize) as usize;
369            }
370            FocusPane::Main => {
371                self.log_scroll = self.log_scroll.saturating_add_signed(-delta as i16)
372            }
373            FocusPane::Diagnostics => {
374                self.diagnostic_scroll = self.diagnostic_scroll.saturating_add_signed(-delta as i16)
375            }
376        }
377    }
378
379    fn scroll_active_pane(&mut self, delta: i16) {
380        match self.focus {
381            FocusPane::Sessions => self.move_selection(delta.signum() as isize),
382            FocusPane::Main => self.log_scroll = self.log_scroll.saturating_add_signed(delta),
383            FocusPane::Diagnostics => {
384                self.diagnostic_scroll = self.diagnostic_scroll.saturating_add_signed(delta)
385            }
386        }
387    }
388
389    fn render_status_bar(&self) -> Paragraph<'static> {
390        let selected = self.selected_session();
391        let title = selected
392            .map(|session| session.command_line())
393            .unwrap_or_else(|| "No session selected".to_string());
394        let workspace = selected
395            .map(|session| session.workspace_label())
396            .unwrap_or_else(|| "Waiting for a managed run or detected process".to_string());
397        let status = selected
398            .map(|session| format_status(session.info.status, session.duration_ms))
399            .unwrap_or_else(|| "idle".to_string());
400        let mode = selected
401            .map(|session| match session.info.mode {
402                SessionMode::Managed => "managed",
403                SessionMode::Detected => "detected",
404            })
405            .unwrap_or("monitor");
406
407        let lines = Text::from(vec![
408            Line::from(vec![
409                Span::styled(
410                    " CargoWatch ",
411                    Style::default()
412                        .bg(self.color(&self.config.theme.accent))
413                        .fg(Color::Black)
414                        .add_modifier(Modifier::BOLD),
415                ),
416                Span::raw("  "),
417                Span::styled(
418                    mode,
419                    Style::default().fg(self.color(&self.config.theme.info)),
420                ),
421                Span::raw("  "),
422                Span::raw(title),
423            ]),
424            Line::from(vec![
425                Span::styled(
426                    "workspace ",
427                    Style::default().fg(self.color(&self.config.theme.muted)),
428                ),
429                Span::raw(workspace),
430                Span::raw("  "),
431                Span::styled(
432                    "status ",
433                    Style::default().fg(self.color(&self.config.theme.muted)),
434                ),
435                Span::raw(status),
436            ]),
437        ]);
438        Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
439    }
440
441    fn render_session_list(&self) -> List<'static> {
442        let items = self
443            .left_items()
444            .into_iter()
445            .map(|selection| {
446                let history = match selection {
447                    SessionSelection::Active(id) => self
448                        .active_sessions
449                        .get(&id)
450                        .map(SessionState::history_entry),
451                    SessionSelection::History(id) => self
452                        .history_sessions
453                        .get(&id)
454                        .map(SessionState::history_entry)
455                        .or_else(|| {
456                            self.history
457                                .iter()
458                                .find(|entry| entry.info.session_id == id)
459                                .cloned()
460                        }),
461                };
462                history.unwrap_or_else(empty_history_item)
463            })
464            .map(|entry| {
465                let status_style = style_for_status(&self.config, entry.info.status);
466                let title = entry.info.title.clone();
467                let command_line = entry.command_line();
468                ListItem::new(vec![
469                    Line::from(vec![
470                        Span::styled(
471                            format_status(entry.info.status, entry.duration_ms),
472                            status_style,
473                        ),
474                        Span::raw(" "),
475                        Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
476                    ]),
477                    Line::from(vec![Span::styled(
478                        command_line,
479                        Style::default().fg(self.color(&self.config.theme.muted)),
480                    )]),
481                    Line::from(vec![Span::raw(format!(
482                        "e:{} w:{} n:{} h:{}",
483                        entry.summary.errors,
484                        entry.summary.warnings,
485                        entry.summary.notes,
486                        entry.summary.help
487                    ))]),
488                ])
489            })
490            .collect::<Vec<_>>();
491
492        let block = titled_block(
493            self.focus == FocusPane::Sessions,
494            "Sessions / History",
495            self.color(&self.config.theme.accent),
496        );
497        List::new(items)
498            .block(block)
499            .highlight_style(Style::default().bg(Color::DarkGray))
500            .highlight_symbol(">> ")
501    }
502
503    fn render_center_pane(&self) -> Paragraph<'static> {
504        let title = match self.center_view {
505            CenterView::Logs => {
506                if self.show_raw {
507                    "Live Logs (raw)"
508                } else {
509                    "Live Logs (rendered)"
510                }
511            }
512            CenterView::Diagnostics => "Structured Diagnostics",
513        };
514        let text = match self.center_view {
515            CenterView::Logs => self.render_logs_text(),
516            CenterView::Diagnostics => self.render_diagnostic_text(true),
517        };
518
519        Paragraph::new(text)
520            .block(titled_block(
521                self.focus == FocusPane::Main,
522                title,
523                self.color(&self.config.theme.accent),
524            ))
525            .wrap(Wrap { trim: false })
526            .scroll((self.current_log_scroll(), 0))
527    }
528
529    fn render_diagnostics_pane(&self) -> Paragraph<'static> {
530        let mut lines = Vec::new();
531        if let Some(session) = self.selected_session() {
532            lines.extend(render_summary_lines(&self.config, session.summary));
533            lines.push(Line::default());
534
535            let diagnostics = session
536                .diagnostics
537                .iter()
538                .filter(|diagnostic| self.filter.matches_diagnostic(diagnostic))
539                .take(64)
540                .collect::<Vec<_>>();
541            if diagnostics.is_empty() {
542                lines.push(Line::raw("No diagnostics match the current filter."));
543            } else {
544                for diagnostic in diagnostics {
545                    let location = diagnostic
546                        .file
547                        .as_ref()
548                        .map(|file| {
549                            format!("{}:{}", file.display(), diagnostic.line.unwrap_or_default())
550                        })
551                        .unwrap_or_else(|| "unknown location".to_string());
552                    lines.push(Line::from(vec![
553                        Span::styled(
554                            format!("[{}] ", diagnostic.severity_label()),
555                            style_for_severity(&self.config, diagnostic.severity),
556                        ),
557                        Span::raw(diagnostic.message.clone()),
558                    ]));
559                    lines.push(Line::from(vec![Span::styled(
560                        location,
561                        Style::default().fg(self.color(&self.config.theme.muted)),
562                    )]));
563                    lines.push(Line::default());
564                }
565            }
566        } else {
567            lines.push(Line::raw(
568                "Waiting for a selected session. Use `n` for a managed run or leave the dashboard open for detected external build summaries.",
569            ));
570        }
571
572        Paragraph::new(Text::from(lines))
573            .block(titled_block(
574                self.focus == FocusPane::Diagnostics,
575                "Diagnostics",
576                self.color(&self.config.theme.accent),
577            ))
578            .wrap(Wrap { trim: false })
579            .scroll((self.diagnostic_scroll, 0))
580    }
581
582    fn render_footer(&self) -> Paragraph<'static> {
583        let filter = if self.filter == LogFilter::default() {
584            "all".to_string()
585        } else {
586            format!(
587                "e:{} w:{} n:{} h:{} i:{}",
588                self.filter.errors as u8,
589                self.filter.warnings as u8,
590                self.filter.notes as u8,
591                self.filter.help as u8,
592                self.filter.info as u8
593            )
594        };
595        let footer = Line::from(vec![
596            Span::raw("Tab panes  "),
597            Span::raw("j/k move  "),
598            Span::raw("PgUp/PgDn scroll  "),
599            Span::raw("n new run  "),
600            Span::raw("c cancel  "),
601            Span::raw("/ search  "),
602            Span::raw("r raw/rendered  "),
603            Span::raw("v log/diag  "),
604            Span::raw("0-5 filter  "),
605            Span::raw("q quit  "),
606            Span::styled(
607                format!(
608                    "filter:{filter} follow:{}",
609                    if self.follow { "on" } else { "off" }
610                ),
611                Style::default().fg(self.color(&self.config.theme.info)),
612            ),
613        ]);
614        Paragraph::new(Text::from(vec![
615            footer,
616            Line::from(Span::styled(
617                self.status_message.clone(),
618                Style::default().fg(self.color(&self.config.theme.muted)),
619            )),
620        ]))
621        .alignment(Alignment::Left)
622    }
623
624    fn render_overlay(&self, overlay: &InputOverlay) -> Paragraph<'static> {
625        let (title, buffer, hint) = match overlay {
626            InputOverlay::Search { buffer } => (
627                "Search Logs",
628                buffer.as_str(),
629                "Press Enter to apply, Esc to cancel.",
630            ),
631            InputOverlay::Command { buffer } => (
632                "Start Managed Run",
633                buffer.as_str(),
634                "Command is parsed shell-style. Example: cargo test -p my_crate",
635            ),
636        };
637        Paragraph::new(Text::from(vec![
638            Line::raw(buffer.to_string()),
639            Line::default(),
640            Line::styled(
641                hint,
642                Style::default().fg(self.color(&self.config.theme.muted)),
643            ),
644        ]))
645        .block(
646            Block::default()
647                .title(title)
648                .borders(Borders::ALL)
649                .border_style(Style::default().fg(self.color(&self.config.theme.accent))),
650        )
651        .wrap(Wrap { trim: false })
652    }
653
654    fn render_logs_text(&self) -> Text<'static> {
655        let Some(session) = self.selected_session() else {
656            return Text::from(vec![
657                Line::raw("No session selected."),
658                Line::raw(""),
659                Line::raw(
660                    "Managed mode captures logs, diagnostics, and artifacts because CargoWatch launches the process.",
661                ),
662                Line::raw(
663                    "Detected mode only shows best-effort summaries for already-running external builds.",
664                ),
665            ]);
666        };
667        let entries = session
668            .logs
669            .iter()
670            .filter(|entry| self.filter.matches_log(entry))
671            .collect::<Vec<_>>();
672        if entries.is_empty() {
673            return Text::from(vec![
674                Line::raw("No log lines match the current filter."),
675                Line::raw(""),
676                Line::raw("Tip: press `0` to reset filters or `/` to edit the search query."),
677            ]);
678        }
679
680        let mut lines = Vec::new();
681        for entry in entries {
682            let content = if self.show_raw {
683                entry.raw.as_deref().unwrap_or(&entry.text)
684            } else {
685                &entry.text
686            };
687            match content.to_string().into_text() {
688                Ok(text) => lines.extend(text.lines),
689                Err(_) => lines.push(Line::styled(
690                    content.to_string(),
691                    style_for_severity(
692                        &self.config,
693                        entry
694                            .severity
695                            .unwrap_or(cargowatch_core::event::Severity::Info),
696                    ),
697                )),
698            }
699            lines.push(Line::default());
700        }
701        Text::from(lines)
702    }
703
704    fn render_diagnostic_text(&self, detailed: bool) -> Text<'static> {
705        let Some(session) = self.selected_session() else {
706            return Text::from("No session selected.");
707        };
708        let diagnostics = session
709            .diagnostics
710            .iter()
711            .filter(|diagnostic| self.filter.matches_diagnostic(diagnostic))
712            .collect::<Vec<_>>();
713        if diagnostics.is_empty() {
714            return Text::from("No diagnostics match the current filter.");
715        }
716
717        let mut lines = Vec::new();
718        for diagnostic in diagnostics {
719            let body = if detailed {
720                diagnostic
721                    .rendered
722                    .as_deref()
723                    .unwrap_or(&diagnostic.message)
724            } else {
725                &diagnostic.message
726            };
727            match body.to_string().into_text() {
728                Ok(text) => lines.extend(text.lines),
729                Err(_) => {
730                    lines.push(Line::styled(
731                        body.to_string(),
732                        style_for_severity(&self.config, diagnostic.severity),
733                    ));
734                }
735            }
736            lines.push(Line::default());
737        }
738        Text::from(lines)
739    }
740
741    fn current_log_scroll(&self) -> u16 {
742        if self.follow { 65_535 } else { self.log_scroll }
743    }
744
745    fn color(&self, value: &str) -> Color {
746        parse_color(value).unwrap_or(Color::White)
747    }
748}
749
750impl FocusPane {
751    fn next(self) -> Self {
752        match self {
753            Self::Sessions => Self::Main,
754            Self::Main => Self::Diagnostics,
755            Self::Diagnostics => Self::Sessions,
756        }
757    }
758}
759
760trait DiagnosticSeverityLabel {
761    fn severity_label(&self) -> &'static str;
762}
763
764impl DiagnosticSeverityLabel for cargowatch_core::DiagnosticRecord {
765    fn severity_label(&self) -> &'static str {
766        match self.severity {
767            cargowatch_core::event::Severity::Error => "error",
768            cargowatch_core::event::Severity::Warning => "warning",
769            cargowatch_core::event::Severity::Note => "note",
770            cargowatch_core::event::Severity::Help => "help",
771            cargowatch_core::event::Severity::Info => "info",
772            cargowatch_core::event::Severity::Success => "success",
773        }
774    }
775}
776
777fn process_to_session_info(process: &DetectedProcess) -> SessionInfo {
778    SessionInfo {
779        session_id: process.session_id.clone(),
780        mode: SessionMode::Detected,
781        title: format!("{} ({})", process.classification.label(), process.pid),
782        command: process.command.clone(),
783        cwd: process.cwd.clone().unwrap_or_else(|| ".".into()),
784        workspace_root: process.workspace_root.clone(),
785        started_at: process.started_at,
786        status: SessionStatus::Running,
787        external_pid: Some(process.pid),
788        classification: Some(process.classification),
789    }
790}
791
792fn render_summary_lines(config: &AppConfig, summary: SummaryCounts) -> Vec<Line<'static>> {
793    vec![
794        Line::from(vec![
795            Span::styled(
796                format!("Errors: {}", summary.errors),
797                Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red)),
798            ),
799            Span::raw("  "),
800            Span::styled(
801                format!("Warnings: {}", summary.warnings),
802                Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow)),
803            ),
804        ]),
805        Line::from(vec![
806            Span::styled(
807                format!("Notes: {}", summary.notes),
808                Style::default().fg(parse_color(&config.theme.info).unwrap_or(Color::Blue)),
809            ),
810            Span::raw("  "),
811            Span::styled(
812                format!("Help: {}", summary.help),
813                Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan)),
814            ),
815        ]),
816    ]
817}
818
819fn style_for_status(config: &AppConfig, status: SessionStatus) -> Style {
820    match status {
821        SessionStatus::Running => {
822            Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan))
823        }
824        SessionStatus::Succeeded => {
825            Style::default().fg(parse_color(&config.theme.success).unwrap_or(Color::Green))
826        }
827        SessionStatus::Failed => {
828            Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red))
829        }
830        SessionStatus::Cancelled => {
831            Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow))
832        }
833        SessionStatus::Lost => {
834            Style::default().fg(parse_color(&config.theme.muted).unwrap_or(Color::Gray))
835        }
836    }
837}
838
839fn style_for_severity(config: &AppConfig, severity: cargowatch_core::event::Severity) -> Style {
840    match severity {
841        cargowatch_core::event::Severity::Error => {
842            Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red))
843        }
844        cargowatch_core::event::Severity::Warning => {
845            Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow))
846        }
847        cargowatch_core::event::Severity::Note => {
848            Style::default().fg(parse_color(&config.theme.info).unwrap_or(Color::Blue))
849        }
850        cargowatch_core::event::Severity::Help => {
851            Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan))
852        }
853        cargowatch_core::event::Severity::Info => Style::default().fg(Color::White),
854        cargowatch_core::event::Severity::Success => {
855            Style::default().fg(parse_color(&config.theme.success).unwrap_or(Color::Green))
856        }
857    }
858}
859
860fn parse_color(value: &str) -> Option<Color> {
861    let hex = value.strip_prefix('#').unwrap_or(value);
862    if hex.len() != 6 {
863        return None;
864    }
865    let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
866    let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
867    let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
868    Some(Color::Rgb(red, green, blue))
869}
870
871fn titled_block(active: bool, title: &str, accent: Color) -> Block<'static> {
872    Block::default()
873        .title(if active {
874            Span::styled(
875                title.to_string(),
876                Style::default().fg(accent).add_modifier(Modifier::BOLD),
877            )
878        } else {
879            Span::raw(title.to_string())
880        })
881        .borders(Borders::ALL)
882}
883
884fn empty_history_item() -> SessionHistoryEntry {
885    SessionHistoryEntry {
886        info: SessionInfo {
887            session_id: "missing".to_string(),
888            mode: SessionMode::Managed,
889            title: "missing session".to_string(),
890            command: Vec::new(),
891            cwd: ".".into(),
892            workspace_root: None,
893            started_at: OffsetDateTime::UNIX_EPOCH,
894            status: SessionStatus::Lost,
895            external_pid: None,
896            classification: None,
897        },
898        finished_at: None,
899        exit_code: None,
900        duration_ms: None,
901        summary: SummaryCounts::default(),
902    }
903}
904
905fn format_status(status: SessionStatus, duration_ms: Option<i64>) -> String {
906    let base = match status {
907        SessionStatus::Running => "running",
908        SessionStatus::Succeeded => "ok",
909        SessionStatus::Failed => "failed",
910        SessionStatus::Cancelled => "cancelled",
911        SessionStatus::Lost => "gone",
912    };
913    match duration_ms {
914        Some(duration_ms) => format!("{base} {}s", duration_ms / 1_000),
915        None => base.to_string(),
916    }
917}
918
919fn centered_rect(width_percent: u16, height: u16, area: Rect) -> Rect {
920    let vertical = Layout::default()
921        .direction(Direction::Vertical)
922        .constraints([
923            Constraint::Min(1),
924            Constraint::Length(height),
925            Constraint::Min(1),
926        ])
927        .split(area);
928    Layout::default()
929        .direction(Direction::Horizontal)
930        .constraints([
931            Constraint::Percentage((100 - width_percent) / 2),
932            Constraint::Percentage(width_percent),
933            Constraint::Percentage((100 - width_percent) / 2),
934        ])
935        .split(vertical[1])[1]
936}