Skip to main content

yarli_cli/dashboard/
renderer.rs

1//! Dashboard mode renderer — fullscreen TUI (Section 16.3).
2//!
3//! Manages a fullscreen terminal with panel layout: task list (left), output viewer,
4//! gates panel, audit panel, Why Not Done bar, and key hints.
5
6use std::io::{self, Stdout};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use crossterm::event::{self, Event, KeyEvent};
11use crossterm::terminal::{
12    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
13};
14use crossterm::ExecutableCommand;
15use ratatui::backend::CrosstermBackend;
16use ratatui::layout::{Constraint, Direction, Layout};
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::Paragraph;
20use ratatui::Terminal;
21
22use crate::yarli_core::fsm::run::RunState;
23use crate::yarli_core::fsm::task::TaskState;
24
25use super::copy_mode::CopyMode;
26use super::input::{map_key_event, DashboardAction};
27use super::overlay::{OverlayEntry, OverlayStack};
28use super::state::{PanelId, PanelManager, PanelState};
29use super::widgets::CollapsiblePanel;
30use crate::stream::events::StreamEvent;
31use crate::stream::spinner::{Spinner, GLYPH_BLOCKED, GLYPH_COMPLETE, GLYPH_FAILED, GLYPH_PENDING};
32use crate::stream::style::Tier;
33use crate::yarli_observability::{AuditSink, JsonlAuditSink};
34
35/// Configuration for the dashboard renderer.
36#[derive(Debug, Clone)]
37pub struct DashboardConfig {
38    /// Tick rate for UI refresh (milliseconds).
39    pub tick_rate_ms: u64,
40}
41
42impl Default for DashboardConfig {
43    fn default() -> Self {
44        Self { tick_rate_ms: 100 }
45    }
46}
47
48/// The fullscreen dashboard renderer.
49pub struct DashboardRenderer {
50    terminal: Terminal<CrosstermBackend<Stdout>>,
51    state: PanelManager,
52    spinners: std::collections::HashMap<crate::yarli_core::domain::TaskId, Spinner>,
53    config: DashboardConfig,
54    copy_mode: CopyMode,
55    overlays: OverlayStack,
56    audit_file: Option<PathBuf>,
57}
58
59impl DashboardRenderer {
60    /// Create a new dashboard renderer, entering alternate screen + raw mode.
61    pub fn new(config: DashboardConfig, audit_file: Option<PathBuf>) -> io::Result<Self> {
62        enable_raw_mode()?;
63        let mut stdout = io::stdout();
64        stdout.execute(EnterAlternateScreen)?;
65
66        let backend = CrosstermBackend::new(stdout);
67        let terminal = Terminal::new(backend)?;
68
69        Ok(Self {
70            terminal,
71            state: PanelManager::new(),
72            spinners: std::collections::HashMap::new(),
73            config,
74            copy_mode: CopyMode::new(),
75            overlays: OverlayStack::new(),
76            audit_file,
77        })
78    }
79
80    /// Restore terminal state on exit.
81    pub fn restore(&mut self) -> io::Result<()> {
82        disable_raw_mode()?;
83        self.terminal.backend_mut().execute(LeaveAlternateScreen)?;
84        self.terminal.show_cursor()?;
85        Ok(())
86    }
87
88    /// Process a stream event and update internal state.
89    pub fn handle_event(&mut self, event: StreamEvent) {
90        match event {
91            StreamEvent::TaskDiscovered {
92                task_id,
93                task_name,
94                depends_on,
95            } => {
96                self.state.update_task(
97                    task_id,
98                    &task_name,
99                    TaskState::TaskOpen,
100                    None,
101                    Some(depends_on),
102                );
103            }
104            StreamEvent::TaskTransition {
105                task_id,
106                task_name,
107                from: _,
108                to,
109                elapsed,
110                exit_code: _,
111                detail: _,
112                at: _,
113            } => {
114                self.state
115                    .update_task(task_id, &task_name, to, elapsed, None);
116                if to == TaskState::TaskExecuting {
117                    self.spinners.entry(task_id).or_default();
118                }
119                if to.is_terminal() {
120                    self.spinners.remove(&task_id);
121                }
122            }
123            StreamEvent::RunTransition {
124                run_id,
125                from: _,
126                to,
127                reason: _,
128                at: _,
129            } => {
130                self.state.run_id = Some(run_id);
131                self.state.run_state = Some(to);
132            }
133            StreamEvent::CommandOutput {
134                task_id,
135                task_name: _,
136                line,
137            } => {
138                self.state.append_output(task_id, line);
139            }
140            StreamEvent::ExplainUpdate { summary } => {
141                self.state.explain_summary = Some(summary);
142            }
143            StreamEvent::TaskWorker { task_id, worker_id } => {
144                if let Some(view) = self.state.tasks.get_mut(&task_id) {
145                    view.worker_id = Some(worker_id);
146                }
147            }
148            StreamEvent::RunStarted {
149                run_id,
150                objective,
151                at: _,
152            } => {
153                self.state.run_id = Some(run_id);
154                self.state.objective = Some(objective);
155            }
156            StreamEvent::RunExited { payload } => {
157                self.state.continuation_payload = Some(payload);
158            }
159            StreamEvent::TransientStatus { message } => {
160                self.state.transient_status = Some(message);
161            }
162            StreamEvent::Tick => {
163                for spinner in self.spinners.values_mut() {
164                    spinner.tick();
165                }
166            }
167        }
168    }
169
170    /// Process a keyboard event and return whether to quit.
171    pub fn handle_key_event(&mut self, key_event: KeyEvent) -> bool {
172        let action = map_key_event(key_event, self.state.focused);
173        match action {
174            DashboardAction::Quit => return true,
175            DashboardAction::DismissOverlay => {
176                if self.overlays.has_focus() {
177                    self.overlays.pop();
178                }
179            }
180            DashboardAction::FocusNext => self.state.focus_next(),
181            DashboardAction::FocusPrev => self.state.focus_prev(),
182            DashboardAction::FocusPanel(p) => self.state.focus_panel(p),
183            DashboardAction::Collapse => self.state.collapse_focused(),
184            DashboardAction::Expand => self.state.expand_focused(),
185            DashboardAction::RestoreAll => self.state.restore_all(),
186            DashboardAction::ScrollUp => self.state.scroll_up(1),
187            DashboardAction::ScrollDown => self.state.scroll_down(1),
188            DashboardAction::ScrollHalfPageUp => self.state.scroll_up(10),
189            DashboardAction::ScrollHalfPageDown => self.state.scroll_down(10),
190            DashboardAction::ScrollToTop => self.state.scroll_to_top(),
191            DashboardAction::ScrollToBottom => self.state.scroll_to_bottom(),
192            DashboardAction::SelectNextTask => self.state.select_next_task(),
193            DashboardAction::SelectPrevTask => self.state.select_prev_task(),
194            DashboardAction::ToggleCopyMode => {
195                self.state.copy_mode = !self.state.copy_mode;
196                let _ = self.copy_mode.toggle(&mut io::stdout());
197            }
198            DashboardAction::ToggleHelp => {
199                self.state.show_help = !self.state.show_help;
200                let help_content = build_help_text();
201                self.overlays.toggle(OverlayEntry::help(help_content));
202            }
203            DashboardAction::None => {}
204        }
205        false
206    }
207
208    /// Get the overlay stack for testing.
209    #[cfg(test)]
210    pub fn overlays(&self) -> &OverlayStack {
211        &self.overlays
212    }
213
214    /// Poll for crossterm input events with timeout. Returns true if quit was requested.
215    pub fn poll_input(&mut self) -> io::Result<bool> {
216        if event::poll(Duration::from_millis(self.config.tick_rate_ms))? {
217            if let Event::Key(key_event) = event::read()? {
218                return Ok(self.handle_key_event(key_event));
219            }
220        }
221        Ok(false)
222    }
223
224    /// Draw the full dashboard to the terminal.
225    pub fn draw(&mut self) -> io::Result<()> {
226        let state = &self.state;
227        let spinners = &self.spinners;
228        let borderless = self.copy_mode.strip_borders();
229
230        // Pre-compute all data needed for rendering.
231        let task_lines = build_task_list_lines(state, spinners);
232        let output_lines = build_output_lines(state);
233        let gate_lines = build_gate_lines(state);
234        let audit_lines = self.build_audit_lines();
235        let explain_line = build_explain_line(state);
236        let key_hints_line = build_key_hints_line(state);
237        let title_line = if borderless {
238            self.copy_mode
239                .banner_line()
240                .unwrap_or_else(|| build_title_line(state))
241        } else {
242            build_title_line(state)
243        };
244
245        let task_panel_state = state.panel_state(PanelId::TaskList);
246        let output_panel_state = state.panel_state(PanelId::Output);
247        let gates_panel_state = state.panel_state(PanelId::Gates);
248        let audit_panel_state = state.panel_state(PanelId::Audit);
249        let focused = state.focused;
250        let task_scroll = state
251            .scroll_offsets
252            .get(&PanelId::TaskList)
253            .copied()
254            .unwrap_or(0);
255        let output_scroll = state
256            .scroll_offsets
257            .get(&PanelId::Output)
258            .copied()
259            .unwrap_or(0);
260        let gate_scroll = state
261            .scroll_offsets
262            .get(&PanelId::Gates)
263            .copied()
264            .unwrap_or(0);
265        let audit_scroll = state
266            .scroll_offsets
267            .get(&PanelId::Audit)
268            .copied()
269            .unwrap_or(0);
270        let auto_scroll = state.output_auto_scroll;
271
272        // Capture overlay stack reference for use inside the closure.
273        let overlays = &self.overlays;
274
275        self.terminal.draw(|frame| {
276            let full_area = frame.area();
277
278            // Main layout: [title] [body] [explain] [key_hints]
279            let main_chunks = Layout::default()
280                .direction(Direction::Vertical)
281                .constraints([
282                    Constraint::Length(1), // Title bar
283                    Constraint::Min(4),    // Body panels
284                    Constraint::Length(1), // WHY NOT DONE bar
285                    Constraint::Length(1), // Key hints bar
286                ])
287                .split(full_area);
288
289            // Title bar.
290            frame.render_widget(Paragraph::new(title_line), main_chunks[0]);
291
292            // Body: horizontal split [TaskList | Output+Gates]
293            let body_area = main_chunks[1];
294
295            // Determine column widths based on terminal width and panel states.
296            let (left_constraint, right_constraint) = match (task_panel_state, output_panel_state) {
297                (PanelState::Hidden, _) => (Constraint::Length(0), Constraint::Min(10)),
298                (_, PanelState::Hidden) => (Constraint::Min(10), Constraint::Length(0)),
299                (PanelState::Collapsed, _) => (Constraint::Length(1), Constraint::Min(10)),
300                _ => {
301                    if body_area.width >= 120 {
302                        (Constraint::Percentage(30), Constraint::Percentage(70))
303                    } else {
304                        (Constraint::Percentage(40), Constraint::Percentage(60))
305                    }
306                }
307            };
308
309            let body_cols = Layout::default()
310                .direction(Direction::Horizontal)
311                .constraints([left_constraint, right_constraint])
312                .split(body_area);
313
314            // Left column: Task list.
315            let task_panel = CollapsiblePanel::new("Tasks", task_panel_state)
316                .content(task_lines)
317                .focused(focused == PanelId::TaskList)
318                .scroll_offset(task_scroll)
319                .shortcut(Some('1'))
320                .borderless(borderless);
321            frame.render_widget(task_panel, body_cols[0]);
322
323            // Right column: [Output | Gates | Audit].
324            let mut right_constraints = Vec::new();
325            let mut right_panel_states = Vec::new();
326            for panel_state in &[output_panel_state, gates_panel_state, audit_panel_state] {
327                if *panel_state != PanelState::Hidden {
328                    right_panel_states.push(*panel_state);
329                }
330            }
331
332            let visible_panels = right_panel_states.len();
333            for state in right_panel_states.iter() {
334                match state {
335                    PanelState::Expanded => {
336                        let constraint = match visible_panels {
337                            1 => Constraint::Min(3),
338                            2 => Constraint::Percentage(50),
339                            _ => Constraint::Percentage(34),
340                        };
341                        right_constraints.push(constraint);
342                    }
343                    PanelState::Collapsed => right_constraints.push(Constraint::Length(1)),
344                    PanelState::Hidden => {}
345                }
346            }
347            if right_constraints.is_empty() {
348                right_constraints.push(Constraint::Min(3));
349            }
350
351            let right_rows = Layout::default()
352                .direction(Direction::Vertical)
353                .constraints(right_constraints.clone())
354                .split(body_cols[1]);
355
356            let mut right_index = 0usize;
357            if output_panel_state != PanelState::Hidden {
358                let output_title = if !auto_scroll {
359                    "Output [PAUSED]"
360                } else {
361                    "Output"
362                };
363                let output_panel = CollapsiblePanel::new(output_title, output_panel_state)
364                    .content(output_lines)
365                    .focused(focused == PanelId::Output)
366                    .scroll_offset(output_scroll)
367                    .shortcut(Some('2'))
368                    .borderless(borderless);
369                frame.render_widget(output_panel, right_rows[right_index]);
370                right_index += 1;
371            }
372
373            if gates_panel_state != PanelState::Hidden {
374                let gate_panel = CollapsiblePanel::new("Gates", gates_panel_state)
375                    .content(gate_lines)
376                    .focused(focused == PanelId::Gates)
377                    .scroll_offset(gate_scroll)
378                    .shortcut(Some('3'))
379                    .borderless(borderless);
380                frame.render_widget(gate_panel, right_rows[right_index]);
381                right_index += 1;
382            }
383
384            if audit_panel_state != PanelState::Hidden {
385                let audit_panel = CollapsiblePanel::new("Audit", audit_panel_state)
386                    .content(audit_lines)
387                    .focused(focused == PanelId::Audit)
388                    .scroll_offset(audit_scroll)
389                    .shortcut(Some('4'))
390                    .borderless(borderless);
391                frame.render_widget(audit_panel, right_rows[right_index]);
392            }
393
394            // WHY NOT DONE bar.
395            frame.render_widget(Paragraph::new(explain_line), main_chunks[2]);
396
397            // Key hints bar.
398            frame.render_widget(Paragraph::new(key_hints_line), main_chunks[3]);
399
400            // Overlays (rendered on top of everything).
401            if !overlays.is_empty() {
402                overlays.render(frame, full_area);
403            }
404        })?;
405
406        Ok(())
407    }
408
409    /// Get the panel manager for testing.
410    #[cfg(test)]
411    pub fn state(&self) -> &PanelManager {
412        &self.state
413    }
414
415    /// Get mutable panel manager for testing.
416    #[cfg(test)]
417    pub fn state_mut(&mut self) -> &mut PanelManager {
418        &mut self.state
419    }
420
421    fn build_audit_lines(&self) -> Vec<Line<'static>> {
422        let Some(path) = self.audit_file.as_deref() else {
423            return vec![Line::from(Span::styled(
424                " No audit file configured",
425                Tier::Contextual.style(),
426            ))];
427        };
428
429        build_audit_lines_from_file(path).unwrap_or_else(|err| {
430            vec![Line::from(Span::styled(
431                format!(" Failed to read audit log: {err}"),
432                Tier::Urgent.style(),
433            ))]
434        })
435    }
436}
437
438impl Drop for DashboardRenderer {
439    fn drop(&mut self) {
440        let _ = self.restore();
441    }
442}
443
444// ---------------------------------------------------------------------------
445// Line builders — pure functions for testability
446// ---------------------------------------------------------------------------
447
448fn build_audit_lines_from_file(path: &Path) -> io::Result<Vec<Line<'static>>> {
449    let sink = JsonlAuditSink::new(path);
450    let entries = sink
451        .read_all()
452        .map_err(|error| io::Error::new(io::ErrorKind::Other, error))?;
453
454    if entries.is_empty() {
455        return Ok(vec![Line::from(Span::styled(
456            " No audit events yet",
457            Tier::Contextual.style(),
458        ))]);
459    }
460
461    let rows = entries
462        .into_iter()
463        .rev()
464        .take(40)
465        .map(|entry| {
466            let row = format!(
467                "{:<20} {:<20} {:<16} {}",
468                entry.timestamp.format("%m-%d %H:%M:%S"),
469                format!("{:?}", entry.category),
470                entry.actor,
471                entry.reason
472            );
473            Line::from(Span::styled(row, Tier::Contextual.style()))
474        })
475        .collect();
476
477    Ok(rows)
478}
479
480/// Build task list lines with state glyphs and timing.
481pub fn build_task_list_lines<'a>(
482    state: &PanelManager,
483    spinners: &std::collections::HashMap<crate::yarli_core::domain::TaskId, Spinner>,
484) -> Vec<Line<'a>> {
485    let mut lines = Vec::new();
486
487    for (idx, task_id) in state.task_order.iter().enumerate() {
488        let Some(task) = state.tasks.get(task_id) else {
489            continue;
490        };
491
492        let is_selected = idx == state.selected_task_idx;
493
494        let (glyph, tier) = match task.state {
495            TaskState::TaskExecuting => {
496                let sp = spinners.get(task_id).map(|s| s.frame()).unwrap_or('⠋');
497                (sp, Tier::Active)
498            }
499            TaskState::TaskWaiting => ('⠿', Tier::Active),
500            TaskState::TaskBlocked => (GLYPH_BLOCKED, Tier::Contextual),
501            TaskState::TaskReady | TaskState::TaskOpen => (GLYPH_PENDING, Tier::Contextual),
502            TaskState::TaskComplete => (GLYPH_COMPLETE, Tier::Contextual),
503            TaskState::TaskFailed => (GLYPH_FAILED, Tier::Urgent),
504            TaskState::TaskCancelled => (GLYPH_BLOCKED, Tier::Contextual),
505            TaskState::TaskVerifying => (GLYPH_PENDING, Tier::Active),
506        };
507
508        let elapsed_str = task
509            .elapsed
510            .map(format_compact_duration)
511            .unwrap_or_default();
512
513        let cursor = if is_selected { ">" } else { " " };
514        let cursor_style = if is_selected {
515            Style::default()
516                .fg(Color::Cyan)
517                .add_modifier(Modifier::BOLD)
518        } else {
519            Style::default()
520        };
521
522        let mut spans = vec![
523            Span::styled(cursor.to_string(), cursor_style),
524            Span::styled(format!("{glyph} "), tier.style()),
525            Span::styled(format!("{:<14}", task.name), tier.style()),
526            Span::styled(format!(" {:<6}", elapsed_str), Tier::Contextual.style()),
527        ];
528        if let Some(by) = task.blocked_by.as_deref() {
529            spans.push(Span::styled(
530                format!(" blocked_by={by}"),
531                Tier::Contextual.style(),
532            ));
533        }
534        lines.push(Line::from(spans));
535    }
536
537    if lines.is_empty() {
538        lines.push(Line::from(Span::styled(
539            " No tasks",
540            Tier::Contextual.style(),
541        )));
542    }
543
544    lines
545}
546
547/// Build output lines for the selected task.
548pub fn build_output_lines<'a>(state: &PanelManager) -> Vec<Line<'a>> {
549    if state.output_lines.is_empty() {
550        let task_name = state
551            .selected_task()
552            .map(|t| t.name.as_str())
553            .unwrap_or("none");
554        return vec![Line::from(Span::styled(
555            format!(" Waiting for output from {task_name}..."),
556            Tier::Contextual.style(),
557        ))];
558    }
559
560    state
561        .output_lines
562        .iter()
563        .map(|line| Line::from(Span::raw(line.clone())))
564        .collect()
565}
566
567/// Build gate status lines.
568pub fn build_gate_lines<'a>(state: &PanelManager) -> Vec<Line<'a>> {
569    if state.gate_results.is_empty() {
570        return vec![Line::from(Span::styled(
571            " No gate results yet",
572            Tier::Contextual.style(),
573        ))];
574    }
575
576    state
577        .gate_results
578        .iter()
579        .map(|(name, passed, reason)| {
580            let (glyph, tier) = if *passed {
581                (GLYPH_COMPLETE, Tier::Contextual)
582            } else {
583                (GLYPH_FAILED, Tier::Urgent)
584            };
585            let mut spans = vec![
586                Span::styled(format!(" {glyph} "), tier.style()),
587                Span::styled(name.clone(), tier.style()),
588            ];
589            if let Some(r) = reason {
590                spans.push(Span::styled(format!("  ({r})"), Tier::Contextual.style()));
591            }
592            Line::from(spans)
593        })
594        .collect()
595}
596
597/// Build the "WHY NOT DONE" explain line.
598pub fn build_explain_line<'a>(state: &PanelManager) -> Line<'a> {
599    if let Some(ref summary) = state.explain_summary {
600        Line::from(vec![
601            Span::styled(" WHY: ", Tier::Urgent.accent()),
602            Span::styled(summary.clone(), Tier::Urgent.style()),
603        ])
604    } else if let Some(ref status) = state.transient_status {
605        Line::from(vec![
606            Span::styled(" STATUS: ", Tier::Active.accent()),
607            Span::styled(status.clone(), Tier::Contextual.style()),
608        ])
609    } else {
610        let run_state_str = state
611            .run_state
612            .map(|s| format!("{s:?}"))
613            .unwrap_or_else(|| "pending".to_string());
614        let summary = state.task_summary();
615        Line::from(vec![Span::styled(
616            format!(
617                " {run_state_str} | {}/{} complete, {} active, {} failed",
618                summary.complete, summary.total, summary.active, summary.failed
619            ),
620            Tier::Contextual.style(),
621        )])
622    }
623}
624
625/// Build the key hints bar.
626pub fn build_key_hints_line<'a>(state: &PanelManager) -> Line<'a> {
627    let copy_indicator = if state.copy_mode { "[COPY] " } else { "" };
628    Line::from(vec![
629        Span::styled(
630            format!(" {copy_indicator}q:quit  Tab:focus  j/k:scroll  -/+:collapse/expand  =:restore  ?:help  c:copy"),
631            Tier::Background.style(),
632        ),
633    ])
634}
635
636/// Build the title bar line.
637pub fn build_title_line<'a>(state: &PanelManager) -> Line<'a> {
638    let run_id_str = state
639        .run_id
640        .map(|id| format!("run/{}", &id.to_string()[..8]))
641        .unwrap_or_else(|| "yarli".to_string());
642
643    let run_state_str = state
644        .run_state
645        .map(|s| format!("{s:?}"))
646        .unwrap_or_default();
647
648    let tier = state
649        .run_state
650        .map(|s| match s {
651            RunState::RunFailed | RunState::RunBlocked => Tier::Urgent,
652            RunState::RunActive | RunState::RunVerifying => Tier::Active,
653            RunState::RunCompleted => Tier::Contextual,
654            RunState::RunDrained => Tier::Contextual,
655            _ => Tier::Contextual,
656        })
657        .unwrap_or(Tier::Contextual);
658
659    Line::from(vec![
660        Span::styled(
661            " YARLI Dashboard ".to_string(),
662            Style::default()
663                .fg(Color::Cyan)
664                .add_modifier(Modifier::BOLD),
665        ),
666        Span::styled(format!("| {run_id_str} "), Tier::Contextual.style()),
667        Span::styled(run_state_str, tier.style()),
668    ])
669}
670
671/// Build the help text content as a Vec<String> for the overlay stack.
672pub fn build_help_text() -> Vec<String> {
673    vec![
674        "  YARLI Dashboard Help".into(),
675        "".into(),
676        "  Global:".into(),
677        "    q / Ctrl-C    Quit".into(),
678        "    Tab           Cycle focus to next panel".into(),
679        "    Shift+Tab     Cycle focus to previous panel".into(),
680        "    1-4           Jump to panel".into(),
681        "    c             Toggle copy mode".into(),
682        "    ?             Toggle this help".into(),
683        "    Esc           Dismiss overlay".into(),
684        "".into(),
685        "  Panels:".into(),
686        "    - / [         Collapse focused panel".into(),
687        "    + / ]         Expand focused panel".into(),
688        "    =             Restore all panels".into(),
689        "".into(),
690        "  Scrolling:".into(),
691        "    j / k         Scroll line / Select task".into(),
692        "    Ctrl+D/U      Half-page scroll".into(),
693        "    PgUp/PgDn     Page scroll".into(),
694        "    g / G         Top / Bottom".into(),
695        "".into(),
696        "  Press ? or Esc to close".into(),
697    ]
698}
699
700/// Format duration compactly for the task list.
701fn format_compact_duration(d: Duration) -> String {
702    let secs = d.as_secs();
703    if secs >= 3600 {
704        format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
705    } else if secs >= 60 {
706        format!("{}m{}s", secs / 60, secs % 60)
707    } else {
708        format!("{}s", secs)
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use uuid::Uuid;
716
717    #[test]
718    fn format_compact_duration_seconds() {
719        assert_eq!(format_compact_duration(Duration::from_secs(42)), "42s");
720    }
721
722    #[test]
723    fn format_compact_duration_minutes() {
724        assert_eq!(format_compact_duration(Duration::from_secs(125)), "2m5s");
725    }
726
727    #[test]
728    fn format_compact_duration_hours() {
729        assert_eq!(format_compact_duration(Duration::from_secs(3661)), "1h1m");
730    }
731
732    #[test]
733    fn build_task_list_empty() {
734        let state = PanelManager::new();
735        let spinners = std::collections::HashMap::new();
736        let lines = build_task_list_lines(&state, &spinners);
737        assert_eq!(lines.len(), 1);
738    }
739
740    #[test]
741    fn build_task_list_with_tasks() {
742        let mut state = PanelManager::new();
743        state.update_task(
744            Uuid::new_v4(),
745            "lint",
746            TaskState::TaskComplete,
747            Some(Duration::from_secs(3)),
748            None,
749        );
750        state.update_task(
751            Uuid::new_v4(),
752            "build",
753            TaskState::TaskExecuting,
754            Some(Duration::from_secs(34)),
755            None,
756        );
757        state.update_task(
758            Uuid::new_v4(),
759            "test",
760            TaskState::TaskFailed,
761            Some(Duration::from_secs(14)),
762            None,
763        );
764
765        let spinners = std::collections::HashMap::new();
766        let lines = build_task_list_lines(&state, &spinners);
767        assert_eq!(lines.len(), 3);
768    }
769
770    #[test]
771    fn build_output_lines_empty() {
772        let state = PanelManager::new();
773        let lines = build_output_lines(&state);
774        assert_eq!(lines.len(), 1);
775    }
776
777    #[test]
778    fn build_output_lines_with_content() {
779        let mut state = PanelManager::new();
780        let id = Uuid::new_v4();
781        state.update_task(id, "t1", TaskState::TaskExecuting, None, None);
782        state.append_output(id, "Compiling crate1".into());
783        state.append_output(id, "Compiling crate2".into());
784
785        let lines = build_output_lines(&state);
786        assert_eq!(lines.len(), 2);
787    }
788
789    #[test]
790    fn build_gate_lines_empty() {
791        let state = PanelManager::new();
792        let lines = build_gate_lines(&state);
793        assert_eq!(lines.len(), 1); // "No gate results yet"
794    }
795
796    #[test]
797    fn build_gate_lines_with_results() {
798        let mut state = PanelManager::new();
799        state.gate_results.push(("tests_passed".into(), true, None));
800        state
801            .gate_results
802            .push(("policy_clean".into(), false, Some("denied".into())));
803
804        let lines = build_gate_lines(&state);
805        assert_eq!(lines.len(), 2);
806    }
807
808    #[test]
809    fn build_explain_line_with_summary() {
810        let mut state = PanelManager::new();
811        state.explain_summary = Some("2 tasks blocked".into());
812        let line = build_explain_line(&state);
813        assert!(!line.spans.is_empty());
814    }
815
816    #[test]
817    fn build_explain_line_without_summary() {
818        let state = PanelManager::new();
819        let line = build_explain_line(&state);
820        assert!(!line.spans.is_empty());
821    }
822
823    #[test]
824    fn build_explain_line_with_transient_status() {
825        let mut state = PanelManager::new();
826        state.transient_status = Some("heartbeat pending=1 leased=0".into());
827        let line = build_explain_line(&state);
828        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
829        assert!(text.contains("STATUS"));
830        assert!(text.contains("heartbeat"));
831    }
832
833    #[test]
834    fn build_key_hints_contains_shortcuts() {
835        let state = PanelManager::new();
836        let line = build_key_hints_line(&state);
837        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
838        assert!(text.contains("quit"));
839        assert!(text.contains("Tab"));
840    }
841
842    #[test]
843    fn build_title_line_with_run() {
844        let mut state = PanelManager::new();
845        state.run_id = Some(Uuid::new_v4());
846        state.run_state = Some(RunState::RunActive);
847        let line = build_title_line(&state);
848        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
849        assert!(text.contains("YARLI"));
850        assert!(text.contains("run/"));
851    }
852
853    #[test]
854    fn build_title_line_no_run() {
855        let state = PanelManager::new();
856        let line = build_title_line(&state);
857        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
858        assert!(text.contains("yarli"));
859    }
860
861    #[test]
862    fn build_key_hints_copy_mode() {
863        let mut state = PanelManager::new();
864        state.copy_mode = true;
865        let line = build_key_hints_line(&state);
866        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
867        assert!(text.contains("[COPY]"));
868    }
869
870    #[test]
871    fn build_help_text_contains_shortcuts() {
872        let text = build_help_text();
873        let joined = text.join("\n");
874        assert!(joined.contains("Quit"));
875        assert!(joined.contains("copy mode"));
876        assert!(joined.contains("Esc"));
877    }
878
879    #[test]
880    fn build_help_text_not_empty() {
881        let text = build_help_text();
882        assert!(text.len() > 10);
883    }
884}