Skip to main content

ferridriver_test/
tui.rs

1//! Watch mode TUI: fullscreen ratatui dashboard with real-time test progress.
2//!
3//! Uses alternate screen for fullscreen, `Layout` for header/body/footer,
4//! `Paragraph::scroll()` for test list scrolling, `Scrollbar` for position.
5//!
6//! Styling follows Vitest/Jest conventions:
7//! - Passed: green checkmark, test name in default white (not green)
8//! - Failed: red cross, test name in red, error in dim red
9//! - Running: yellow spinner dot, test name in white
10//! - Skipped: dim gray dash, test name in dim gray
11//! - Steps: indented, icon matches status, title in dim white
12//! - Duration: always dim gray
13
14use std::io::{self, Stdout};
15use std::time::{Duration, Instant};
16
17use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers};
18use crossterm::execute;
19use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
20use futures::StreamExt;
21use ratatui::Terminal;
22use ratatui::backend::CrosstermBackend;
23use ratatui::layout::{Constraint, Layout};
24use ratatui::style::{Color, Modifier, Style};
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};
27use tokio::sync::mpsc;
28
29use crate::interactive::WatchCommand;
30
31// ── Messages ───────────────────────────────────────────────────────────
32
33/// Messages from the TUI reporter to the TUI dashboard.
34pub enum TuiMessage {
35  /// A test run is starting.
36  RunStarted {
37    total: usize,
38    workers: u32,
39    names: Vec<TestEntry>,
40  },
41  /// A test began executing.
42  TestStarted { name: String },
43  /// A step within a running test started or finished.
44  StepUpdate {
45    test_name: String,
46    step_title: String,
47    status: EntryStatus,
48    duration_ms: Option<u64>,
49  },
50  /// A test finished with a result.
51  TestFinished {
52    name: String,
53    status: EntryStatus,
54    duration: Duration,
55    error: Option<String>,
56  },
57  /// The run is complete.
58  RunFinished {
59    passed: usize,
60    failed: usize,
61    skipped: usize,
62    flaky: usize,
63    duration: Duration,
64  },
65}
66
67/// A test entry in the dashboard.
68#[derive(Clone)]
69pub struct TestEntry {
70  pub name: String,
71  pub status: EntryStatus,
72  pub duration: Option<Duration>,
73  pub steps: Vec<StepEntry>,
74  pub error: Option<String>,
75}
76
77/// A step within a test entry.
78#[derive(Clone)]
79pub struct StepEntry {
80  pub title: String,
81  pub status: EntryStatus,
82  pub duration_ms: Option<u64>,
83}
84
85/// Status of a test/step entry.
86#[derive(Clone, Copy, PartialEq, Eq)]
87pub enum EntryStatus {
88  Pending,
89  Running,
90  Passed,
91  Failed,
92  Skipped,
93  Flaky,
94}
95
96/// Status bar state.
97#[derive(Clone)]
98pub enum WatchStatus {
99  Idle,
100  Running {
101    completed: usize,
102    total: usize,
103    start: Instant,
104  },
105  Done {
106    passed: usize,
107    failed: usize,
108    skipped: usize,
109    flaky: usize,
110    duration: Duration,
111  },
112}
113
114/// Result of `drain_while_running()`.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum DrainResult {
117  Completed,
118  Cancelled,
119  ChannelClosed,
120}
121
122// ── Style constants ───────────────────────────────────────────────────
123
124const CLR_PASS: Color = Color::Green;
125const CLR_FAIL: Color = Color::Red;
126const CLR_RUN: Color = Color::Yellow;
127const CLR_FLAKY: Color = Color::Yellow;
128const CLR_DIM: Color = Color::DarkGray;
129const CLR_CYAN: Color = Color::Cyan;
130
131const ICON_PASS: &str = "\u{2713}"; // checkmark
132const ICON_FAIL: &str = "\u{2717}"; // cross
133const ICON_RUN: &str = "\u{25cf}"; // filled circle
134const ICON_SKIP: &str = "\u{2212}"; // minus
135const ICON_PEND: &str = "\u{25cb}"; // empty circle
136const ICON_FLAKY: &str = "\u{25ce}"; // bullseye
137
138// ── WatchTui ───────────────────────────────────────────────────────────
139
140pub struct WatchTui {
141  terminal: Terminal<CrosstermBackend<Stdout>>,
142  event_stream: EventStream,
143  msg_rx: mpsc::UnboundedReceiver<TuiMessage>,
144  status: WatchStatus,
145  entries: Vec<TestEntry>,
146  total_tests: usize,
147  num_workers: u32,
148  completed: usize,
149  run_start: Instant,
150  scroll_offset: usize,
151  total_content_lines: usize,
152  /// Whether a run is in progress (changes key hint text).
153  is_running: bool,
154  /// Active filter input (Some = filter mode active, None = normal mode).
155  filter_input: Option<String>,
156  /// The current active grep filter (shown in header).
157  pub active_filter: Option<String>,
158}
159
160impl WatchTui {
161  pub fn new() -> Result<(Self, mpsc::UnboundedSender<TuiMessage>), String> {
162    crossterm::terminal::enable_raw_mode().map_err(|e| format!("enable raw mode: {e}"))?;
163    execute!(io::stdout(), EnterAlternateScreen).map_err(|e| format!("enter alternate screen: {e}"))?;
164
165    let backend = CrosstermBackend::new(io::stdout());
166    let terminal = Terminal::new(backend).map_err(|e| format!("create terminal: {e}"))?;
167
168    let (msg_tx, msg_rx) = mpsc::unbounded_channel();
169
170    let tui = Self {
171      terminal,
172      event_stream: EventStream::new(),
173      msg_rx,
174      status: WatchStatus::Idle,
175      entries: Vec::new(),
176      total_tests: 0,
177      num_workers: 0,
178      completed: 0,
179      run_start: Instant::now(),
180      scroll_offset: 0,
181      total_content_lines: 0,
182      is_running: false,
183      filter_input: None,
184      active_filter: None,
185    };
186
187    Ok((tui, msg_tx))
188  }
189
190  // ── Message handling ──
191
192  fn handle_message(&mut self, msg: TuiMessage) {
193    match msg {
194      TuiMessage::RunStarted { total, workers, names } => {
195        self.total_tests = total;
196        self.num_workers = workers;
197        self.completed = 0;
198        self.run_start = Instant::now();
199        self.scroll_offset = 0;
200        self.entries = names;
201        self.is_running = true;
202        self.status = WatchStatus::Running {
203          completed: 0,
204          total,
205          start: self.run_start,
206        };
207        self.render();
208      },
209      TuiMessage::TestStarted { name } => {
210        if let Some(entry) = self.entries.iter_mut().find(|e| e.name == name) {
211          entry.status = EntryStatus::Running;
212          entry.steps.clear();
213          entry.error = None;
214        } else {
215          self.entries.push(TestEntry {
216            name,
217            status: EntryStatus::Running,
218            duration: None,
219            steps: Vec::new(),
220            error: None,
221          });
222        }
223        self.auto_scroll_to_running();
224        self.render();
225      },
226      TuiMessage::StepUpdate {
227        test_name,
228        step_title,
229        status,
230        duration_ms,
231      } => {
232        if let Some(entry) = self.entries.iter_mut().find(|e| e.name == test_name) {
233          if let Some(step) = entry.steps.iter_mut().find(|s| s.title == step_title) {
234            step.status = status;
235            step.duration_ms = duration_ms;
236          } else {
237            entry.steps.push(StepEntry {
238              title: step_title,
239              status,
240              duration_ms,
241            });
242          }
243        }
244        self.render();
245      },
246      TuiMessage::TestFinished {
247        name,
248        status,
249        duration,
250        error,
251      } => {
252        self.completed += 1;
253        if let Some(entry) = self.entries.iter_mut().find(|e| e.name == name) {
254          entry.status = status;
255          entry.duration = Some(duration);
256          entry.error = error;
257        } else {
258          self.entries.push(TestEntry {
259            name,
260            status,
261            duration: Some(duration),
262            steps: Vec::new(),
263            error,
264          });
265        }
266        self.status = WatchStatus::Running {
267          completed: self.completed,
268          total: self.total_tests,
269          start: self.run_start,
270        };
271        self.render();
272      },
273      TuiMessage::RunFinished {
274        passed,
275        failed,
276        skipped,
277        flaky,
278        duration,
279      } => {
280        self.is_running = false;
281        self.status = WatchStatus::Done {
282          passed,
283          failed,
284          skipped,
285          flaky,
286          duration,
287        };
288        self.render();
289      },
290    }
291  }
292
293  fn body_height(&mut self) -> usize {
294    // header(2) + footer(3) = 5 reserved
295    (self.terminal.get_frame().area().height as usize).saturating_sub(5)
296  }
297
298  // ── Scrolling ──
299
300  fn auto_scroll_to_running(&mut self) {
301    let visible = self.body_height();
302    if visible == 0 {
303      return;
304    }
305
306    let mut target_line = 0usize;
307    let mut found = false;
308    for entry in &self.entries {
309      if entry.status == EntryStatus::Running {
310        found = true;
311        break;
312      }
313      target_line += 1 + entry.steps.len();
314      if entry.status == EntryStatus::Failed && entry.error.is_some() {
315        target_line += 1;
316      }
317    }
318    if !found {
319      return;
320    }
321
322    let viewport_end = self.scroll_offset + visible;
323    if target_line < self.scroll_offset {
324      self.scroll_offset = target_line;
325    } else if target_line >= viewport_end {
326      let context = visible / 3;
327      self.scroll_offset = target_line.saturating_sub(visible.saturating_sub(context));
328    }
329  }
330
331  fn scroll_by(&mut self, delta: isize, visible_height: usize) {
332    let max = self.total_content_lines.saturating_sub(visible_height);
333    if delta < 0 {
334      self.scroll_offset = self.scroll_offset.saturating_sub(delta.unsigned_abs());
335    } else {
336      self.scroll_offset = (self.scroll_offset + delta.unsigned_abs()).min(max);
337    }
338  }
339
340  // ── Content building ──
341
342  fn build_content_lines(entries: &[TestEntry], width: usize) -> Vec<Line<'static>> {
343    let mut lines: Vec<Line<'static>> = Vec::new();
344
345    for entry in entries {
346      // ── Test name line ──
347      let (icon, icon_color) = status_icon(entry.status);
348      let name_style = match entry.status {
349        EntryStatus::Failed => Style::default().fg(CLR_FAIL),
350        EntryStatus::Skipped | EntryStatus::Pending => Style::default().fg(CLR_DIM),
351        EntryStatus::Running => Style::default().fg(Color::White),
352        _ => Style::default(), // default terminal color for passed
353      };
354
355      let mut spans = vec![
356        Span::raw(" "),
357        Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
358        Span::styled(entry.name.clone(), name_style),
359      ];
360
361      if let Some(dur) = entry.duration {
362        spans.push(Span::styled(
363          format!(" ({:.0}ms)", dur.as_millis()),
364          Style::default().fg(CLR_DIM),
365        ));
366      }
367
368      lines.push(Line::from(spans));
369
370      // ── Steps ──
371      for step in &entry.steps {
372        let (sicon, sicon_color) = status_icon(step.status);
373        let step_name_style = match step.status {
374          EntryStatus::Failed => Style::default().fg(CLR_FAIL),
375          EntryStatus::Running => Style::default().fg(CLR_RUN),
376          _ => Style::default().fg(CLR_DIM),
377        };
378
379        let mut step_spans = vec![
380          Span::raw("      "),
381          Span::styled(format!("{sicon} "), Style::default().fg(sicon_color)),
382          Span::styled(step.title.clone(), step_name_style),
383        ];
384
385        if let Some(ms) = step.duration_ms {
386          step_spans.push(Span::styled(format!(" ({ms}ms)"), Style::default().fg(CLR_DIM)));
387        }
388
389        lines.push(Line::from(step_spans));
390      }
391
392      // ── Error ──
393      if entry.status == EntryStatus::Failed {
394        if let Some(ref err) = entry.error {
395          for err_line in err.lines().take(3) {
396            if !err_line.is_empty() {
397              lines.push(Line::from(vec![
398                Span::raw("      "),
399                Span::styled(
400                  truncate_str(err_line, width.saturating_sub(8)),
401                  Style::default().fg(CLR_FAIL).add_modifier(Modifier::DIM),
402                ),
403              ]));
404            }
405          }
406        }
407      }
408    }
409
410    lines
411  }
412
413  // ── Rendering ──
414
415  fn render(&mut self) {
416    let entries = self.entries.clone();
417    let status = self.status.clone();
418    let total_tests = self.total_tests;
419    let num_workers = self.num_workers;
420    let scroll_offset = self.scroll_offset;
421    let is_running = self.is_running;
422    let filter_input = self.filter_input.clone();
423    let active_filter = self.active_filter.clone();
424
425    let _ = self.terminal.draw(|frame| {
426      let area = frame.area();
427      let width = area.width as usize;
428
429      // Layout: header(2) | body(fill) | footer(3)
430      let [header_area, body_area, footer_area] =
431        Layout::vertical([Constraint::Length(2), Constraint::Min(1), Constraint::Length(3)]).areas(area);
432
433      // ── Header ──
434      let mut header_lines = render_header(&status, total_tests, num_workers);
435      // Show active filter in header.
436      if let Some(ref pattern) = active_filter {
437        header_lines[1] = Line::from(vec![
438          Span::raw(" "),
439          Span::styled("Filter: ", Style::default().fg(CLR_DIM)),
440          Span::styled(
441            pattern.clone(),
442            Style::default().fg(CLR_CYAN).add_modifier(Modifier::BOLD),
443          ),
444        ]);
445      }
446      frame.render_widget(Paragraph::new(header_lines), header_area);
447
448      // ── Scrollable test list ──
449      let content_lines = Self::build_content_lines(&entries, width);
450      let total_lines = content_lines.len();
451
452      let paragraph = Paragraph::new(content_lines).scroll((scroll_offset as u16, 0));
453      frame.render_widget(paragraph, body_area);
454
455      // ── Scrollbar ──
456      if total_lines > body_area.height as usize {
457        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
458        let max_scroll = total_lines.saturating_sub(body_area.height as usize);
459        let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll_offset);
460        frame.render_stateful_widget(scrollbar, body_area, &mut scrollbar_state);
461      }
462
463      // ── Footer ──
464      let [sep_area, status_area, hints_area] =
465        Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)]).areas(footer_area);
466
467      frame.render_widget(
468        Paragraph::new(Line::styled("\u{2500}".repeat(width), Style::default().fg(CLR_DIM))),
469        sep_area,
470      );
471      frame.render_widget(Paragraph::new(render_status_line(&status, width)), status_area);
472
473      // Hints line: filter input mode or normal hints.
474      let hints_line = if let Some(ref input) = filter_input {
475        Line::from(vec![
476          Span::raw(" "),
477          Span::styled(
478            "Filter pattern: ",
479            Style::default().fg(CLR_CYAN).add_modifier(Modifier::BOLD),
480          ),
481          Span::styled(input.clone(), Style::default().fg(Color::White)),
482          Span::styled("\u{2588}", Style::default().fg(Color::White)), // cursor block
483          Span::styled("  (Enter to apply, Esc to cancel)", Style::default().fg(CLR_DIM)),
484        ])
485      } else {
486        render_hints(is_running, active_filter.is_some())
487      };
488      frame.render_widget(Paragraph::new(hints_line), hints_area);
489    });
490
491    // Update total content lines outside draw closure.
492    let width = self.terminal.get_frame().area().width as usize;
493    self.total_content_lines = Self::build_content_lines(&self.entries, width).len();
494  }
495
496  pub fn set_status(&mut self, status: WatchStatus) {
497    self.status = status;
498    self.render();
499  }
500
501  pub fn flush(&mut self) {
502    while let Ok(msg) = self.msg_rx.try_recv() {
503      self.handle_message(msg);
504    }
505  }
506
507  pub async fn drain_while_running(&mut self) -> DrainResult {
508    loop {
509      tokio::select! {
510        msg = self.msg_rx.recv() => {
511          match msg {
512            Some(msg) => {
513              let is_done = matches!(&msg, TuiMessage::RunFinished { .. });
514              self.handle_message(msg);
515              if is_done { return DrainResult::Completed; }
516            }
517            None => return DrainResult::ChannelClosed,
518          }
519        }
520        event = self.event_stream.next() => {
521          let Some(Ok(event)) = event else { continue };
522          if let Event::Key(key) = event {
523            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
524              return DrainResult::Cancelled;
525            }
526            if key.code == KeyCode::Char('q') {
527              return DrainResult::Cancelled;
528            }
529            let body_height = self.terminal.get_frame().area().height.saturating_sub(5) as usize;
530            match key.code {
531              KeyCode::Up | KeyCode::Char('k') => { self.scroll_by(-1, body_height); self.render(); }
532              KeyCode::Down | KeyCode::Char('j') => { self.scroll_by(1, body_height); self.render(); }
533              KeyCode::PageUp => { self.scroll_by(-isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); }
534              KeyCode::PageDown => { self.scroll_by(isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); }
535              _ => {}
536            }
537          } else if let Event::Resize(_, _) = event {
538            self.render();
539          }
540        }
541      }
542    }
543  }
544
545  pub async fn next_command(&mut self) -> Option<WatchCommand> {
546    loop {
547      tokio::select! {
548        msg = self.msg_rx.recv() => {
549          self.handle_message(msg?);
550        }
551        event = self.event_stream.next() => {
552          let Some(Ok(event)) = event else { return None };
553          if let Event::Key(key) = event {
554            // ── Filter input mode ──
555            if self.filter_input.is_some() {
556              match key.code {
557                KeyCode::Enter => {
558                  let pattern = self.filter_input.take().unwrap_or_default();
559                  if !pattern.is_empty() {
560                    self.active_filter = Some(pattern.clone());
561                    self.render();
562                    return Some(WatchCommand::FilterByName(pattern));
563                  }
564                  self.render();
565                  continue;
566                }
567                KeyCode::Esc => {
568                  self.filter_input = None;
569                  self.render();
570                  continue;
571                }
572                KeyCode::Backspace => {
573                  if let Some(ref mut input) = self.filter_input {
574                    input.pop();
575                  }
576                  self.render();
577                  continue;
578                }
579                KeyCode::Char(c) => {
580                  if let Some(ref mut input) = self.filter_input {
581                    input.push(c);
582                  }
583                  self.render();
584                  continue;
585                }
586                _ => continue,
587              }
588            }
589
590            // ── Normal mode ──
591            // Ctrl+C always quits.
592            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
593              return Some(WatchCommand::Quit);
594            }
595
596            // Scroll keys.
597            let body_height = self.body_height();
598            match key.code {
599              KeyCode::Up | KeyCode::Char('k') => { self.scroll_by(-1, body_height); self.render(); continue; }
600              KeyCode::Down | KeyCode::Char('j') => { self.scroll_by(1, body_height); self.render(); continue; }
601              KeyCode::PageUp => { self.scroll_by(-isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); continue; }
602              KeyCode::PageDown => { self.scroll_by(isize::try_from(body_height).unwrap_or(isize::MAX), body_height); self.render(); continue; }
603              _ => {}
604            }
605
606            // Command keys.
607            match key.code {
608              KeyCode::Char('p') => {
609                // Enter filter input mode.
610                self.filter_input = Some(String::new());
611                self.render();
612                continue;
613              }
614              KeyCode::Char('c') if self.active_filter.is_some() => {
615                // Clear active filter.
616                self.active_filter = None;
617                self.render();
618                return Some(WatchCommand::RunAll);
619              }
620              _ => {}
621            }
622            if let Some(cmd) = map_key_event(key) { return Some(cmd); }
623          } else if let Event::Resize(_, _) = event {
624            self.render();
625          }
626        }
627      }
628    }
629  }
630
631  pub fn shutdown(&mut self) {
632    let _ = self.terminal.clear();
633    let _ = crossterm::terminal::disable_raw_mode();
634    let _ = execute!(io::stdout(), LeaveAlternateScreen);
635  }
636}
637
638impl Drop for WatchTui {
639  fn drop(&mut self) {
640    self.shutdown();
641  }
642}
643
644// ── Helpers ────────────────────────────────────────────────────────────
645
646/// Icon + color for a status.
647fn status_icon(status: EntryStatus) -> (&'static str, Color) {
648  match status {
649    EntryStatus::Pending => (ICON_PEND, CLR_DIM),
650    EntryStatus::Running => (ICON_RUN, CLR_RUN),
651    EntryStatus::Passed => (ICON_PASS, CLR_PASS),
652    EntryStatus::Failed => (ICON_FAIL, CLR_FAIL),
653    EntryStatus::Skipped => (ICON_SKIP, CLR_DIM),
654    EntryStatus::Flaky => (ICON_FLAKY, CLR_FLAKY),
655  }
656}
657
658/// Header: title line + blank or status summary.
659fn render_header(status: &WatchStatus, total: usize, workers: u32) -> Vec<Line<'static>> {
660  match status {
661    WatchStatus::Idle => vec![
662      Line::from(vec![
663        Span::styled(
664          " WATCH ",
665          Style::default()
666            .fg(Color::Black)
667            .bg(CLR_CYAN)
668            .add_modifier(Modifier::BOLD),
669        ),
670        Span::styled("  Watching for changes...", Style::default().fg(CLR_DIM)),
671      ]),
672      Line::raw(""),
673    ],
674    WatchStatus::Running { .. } => vec![
675      Line::from(vec![
676        Span::styled(
677          " RUNS ",
678          Style::default()
679            .fg(Color::Black)
680            .bg(CLR_RUN)
681            .add_modifier(Modifier::BOLD),
682        ),
683        Span::raw(format!("  {total} test(s) with {workers} worker(s)")),
684      ]),
685      Line::raw(""),
686    ],
687    WatchStatus::Done {
688      passed,
689      failed,
690      skipped,
691      flaky,
692      ..
693    } => {
694      let badge = if *failed > 0 {
695        Span::styled(
696          " FAIL ",
697          Style::default()
698            .fg(Color::White)
699            .bg(CLR_FAIL)
700            .add_modifier(Modifier::BOLD),
701        )
702      } else {
703        Span::styled(
704          " PASS ",
705          Style::default()
706            .fg(Color::Black)
707            .bg(CLR_PASS)
708            .add_modifier(Modifier::BOLD),
709        )
710      };
711      let mut summary = vec![badge, Span::raw("  ")];
712      if *passed > 0 {
713        summary.push(Span::styled(
714          format!("{passed} passed"),
715          Style::default().fg(CLR_PASS).add_modifier(Modifier::BOLD),
716        ));
717      }
718      if *failed > 0 {
719        if *passed > 0 {
720          summary.push(Span::styled(", ", Style::default().fg(CLR_DIM)));
721        }
722        summary.push(Span::styled(
723          format!("{failed} failed"),
724          Style::default().fg(CLR_FAIL).add_modifier(Modifier::BOLD),
725        ));
726      }
727      if *flaky > 0 {
728        summary.push(Span::styled(", ", Style::default().fg(CLR_DIM)));
729        summary.push(Span::styled(format!("{flaky} flaky"), Style::default().fg(CLR_FLAKY)));
730      }
731      if *skipped > 0 {
732        summary.push(Span::styled(", ", Style::default().fg(CLR_DIM)));
733        summary.push(Span::styled(format!("{skipped} skipped"), Style::default().fg(CLR_DIM)));
734      }
735      let total = passed + failed + skipped;
736      summary.push(Span::styled(format!("  ({total} total)"), Style::default().fg(CLR_DIM)));
737      vec![Line::from(summary), Line::raw("")]
738    },
739  }
740}
741
742/// Status/progress line.
743fn render_status_line(status: &WatchStatus, width: usize) -> Line<'static> {
744  match status {
745    WatchStatus::Idle => Line::from(vec![
746      Span::raw(" "),
747      Span::styled("Press ", Style::default().fg(CLR_DIM)),
748      Span::styled("a", Style::default().add_modifier(Modifier::BOLD)),
749      Span::styled(" to run all, ", Style::default().fg(CLR_DIM)),
750      Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
751      Span::styled(" to quit", Style::default().fg(CLR_DIM)),
752    ]),
753    WatchStatus::Running {
754      completed,
755      total,
756      start,
757    } => {
758      let elapsed = start.elapsed();
759      let pct = if *total > 0 {
760        (*completed as f64 / *total as f64) * 100.0
761      } else {
762        0.0
763      };
764      let bar_w = (width / 3).clamp(10, 40);
765      let filled = (*completed * bar_w) / (*total).max(1);
766      let empty = bar_w - filled;
767      Line::from(vec![
768        Span::raw(" "),
769        Span::styled("Tests ", Style::default().add_modifier(Modifier::BOLD)),
770        Span::styled(
771          format!("{completed}"),
772          Style::default().fg(CLR_PASS).add_modifier(Modifier::BOLD),
773        ),
774        Span::styled(format!("/{total}  "), Style::default().fg(CLR_DIM)),
775        Span::styled("\u{2588}".repeat(filled), Style::default().fg(CLR_PASS)),
776        Span::styled("\u{2591}".repeat(empty), Style::default().fg(CLR_DIM)),
777        Span::styled(format!("  {:.0}%", pct), Style::default().fg(CLR_DIM)),
778        Span::styled(format!("  {:.1}s", elapsed.as_secs_f64()), Style::default().fg(CLR_DIM)),
779      ])
780    },
781    WatchStatus::Done { duration, .. } => Line::from(vec![
782      Span::raw(" "),
783      Span::styled("Time  ", Style::default().add_modifier(Modifier::BOLD)),
784      Span::styled(format!("{:.2}s", duration.as_secs_f64()), Style::default().fg(CLR_DIM)),
785    ]),
786  }
787}
788
789/// Key hints — context-aware (different during run vs idle, with/without filter).
790fn render_hints(is_running: bool, has_filter: bool) -> Line<'static> {
791  if is_running {
792    Line::from(vec![
793      Span::raw(" "),
794      Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
795      Span::styled(" cancel", Style::default().fg(CLR_DIM)),
796      Span::styled("  ", Style::default().fg(CLR_DIM)),
797      Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD)),
798      Span::styled(" scroll", Style::default().fg(CLR_DIM)),
799    ])
800  } else {
801    let mut spans = vec![
802      Span::raw(" "),
803      Span::styled("a", Style::default().add_modifier(Modifier::BOLD)),
804      Span::styled(" run all", Style::default().fg(CLR_DIM)),
805      Span::styled("  ", Style::default().fg(CLR_DIM)),
806      Span::styled("f", Style::default().add_modifier(Modifier::BOLD)),
807      Span::styled(" failed", Style::default().fg(CLR_DIM)),
808      Span::styled("  ", Style::default().fg(CLR_DIM)),
809      Span::styled("p", Style::default().add_modifier(Modifier::BOLD)),
810      Span::styled(" filter", Style::default().fg(CLR_DIM)),
811    ];
812    if has_filter {
813      spans.extend([
814        Span::styled("  ", Style::default().fg(CLR_DIM)),
815        Span::styled("c", Style::default().add_modifier(Modifier::BOLD)),
816        Span::styled(" clear filter", Style::default().fg(CLR_DIM)),
817      ]);
818    }
819    spans.extend([
820      Span::styled("  ", Style::default().fg(CLR_DIM)),
821      Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD)),
822      Span::styled(" scroll", Style::default().fg(CLR_DIM)),
823      Span::styled("  ", Style::default().fg(CLR_DIM)),
824      Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
825      Span::styled(" quit", Style::default().fg(CLR_DIM)),
826    ]);
827    Line::from(spans)
828  }
829}
830
831fn truncate_str(s: &str, max_len: usize) -> String {
832  if s.len() <= max_len {
833    s.to_string()
834  } else {
835    format!("{}...", &s[..max_len.saturating_sub(3)])
836  }
837}
838
839fn map_key_event(key: crossterm::event::KeyEvent) -> Option<WatchCommand> {
840  if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
841    return Some(WatchCommand::Quit);
842  }
843  match key.code {
844    KeyCode::Char('a') => Some(WatchCommand::RunAll),
845    KeyCode::Char('f') => Some(WatchCommand::RunFailed),
846    KeyCode::Char('q') => Some(WatchCommand::Quit),
847    KeyCode::Enter => Some(WatchCommand::Rerun),
848    // 'p' and 'c' handled in next_command() directly (filter mode).
849    _ => None,
850  }
851}