1use 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#[derive(Debug, Clone)]
37pub struct DashboardConfig {
38 pub tick_rate_ms: u64,
40}
41
42impl Default for DashboardConfig {
43 fn default() -> Self {
44 Self { tick_rate_ms: 100 }
45 }
46}
47
48pub 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 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 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 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 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 #[cfg(test)]
210 pub fn overlays(&self) -> &OverlayStack {
211 &self.overlays
212 }
213
214 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 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 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 let overlays = &self.overlays;
274
275 self.terminal.draw(|frame| {
276 let full_area = frame.area();
277
278 let main_chunks = Layout::default()
280 .direction(Direction::Vertical)
281 .constraints([
282 Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), ])
287 .split(full_area);
288
289 frame.render_widget(Paragraph::new(title_line), main_chunks[0]);
291
292 let body_area = main_chunks[1];
294
295 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 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 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 frame.render_widget(Paragraph::new(explain_line), main_chunks[2]);
396
397 frame.render_widget(Paragraph::new(key_hints_line), main_chunks[3]);
399
400 if !overlays.is_empty() {
402 overlays.render(frame, full_area);
403 }
404 })?;
405
406 Ok(())
407 }
408
409 #[cfg(test)]
411 pub fn state(&self) -> &PanelManager {
412 &self.state
413 }
414
415 #[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
444fn 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
480pub 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
547pub 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
567pub 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
597pub 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
625pub 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
636pub 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
671pub 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
700fn 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); }
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}