Skip to main content

magi/
tui.rs

1//! The observation deck.
2//!
3//! A competition takes minutes of agent latency per node, across several runs
4//! at once. Watching that with `magi show` in a loop is the "walking the
5//! terminal tabs" problem the whole design exists to remove, so bare `magi`
6//! opens this instead: every run in one list, status in colour, the selected
7//! run's full report beside it, refreshed from disk as the graph writes.
8//!
9//! It is **read-only on purpose**. The runs are the record of what the agents
10//! did; a keystroke that could rewrite one belongs in an explicit subcommand
11//! (`magi fold`), not one `j` away from browsing.
12//!
13//! # Structure
14//!
15//! [`App`] is pure state with pure transitions, so the interesting behaviour —
16//! selection clamping, filter cycling, keeping the cursor on the same run
17//! across a refresh — is unit-testable without a terminal. [`draw`] is the only
18//! function that knows about ratatui, and [`run`] is the only one that touches
19//! the real terminal.
20use std::io;
21use std::path::Path;
22use std::time::{Duration, Instant, SystemTime};
23
24use ansi_to_tui::IntoText;
25use anyhow::{Context as _, Result};
26use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
27use crossterm::execute;
28use crossterm::terminal::{
29    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
30};
31use ratatui::Frame;
32use ratatui::backend::{Backend, CrosstermBackend};
33use ratatui::layout::{Constraint, Direction, Layout, Rect};
34use ratatui::style::{Color, Modifier, Style};
35use ratatui::text::{Line, Span};
36use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
37use ratatui::{Terminal, TerminalOptions, Viewport};
38
39use crate::report;
40use crate::run::{self, RunState, RunStatus};
41
42/// How often the run list is re-read from disk.
43const REFRESH: Duration = Duration::from_millis(1000);
44/// How long a keypress wait blocks before the loop reconsiders refreshing.
45const TICK: Duration = Duration::from_millis(200);
46
47/// Which pane the keys move.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Focus {
50    /// The run list.
51    List,
52    /// The report pane.
53    Detail,
54}
55
56/// Which runs to show.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Filter {
59    /// Everything on disk.
60    All,
61    /// Still walking the graph.
62    Active,
63    /// Merged or gate-green.
64    Done,
65    /// Blocked or failed — the ones that want a human.
66    Attention,
67}
68
69impl Filter {
70    /// Cycle order for the `a` key.
71    pub fn next(self) -> Self {
72        match self {
73            Self::All => Self::Active,
74            Self::Active => Self::Attention,
75            Self::Attention => Self::Done,
76            Self::Done => Self::All,
77        }
78    }
79
80    /// Label for the header.
81    pub fn label(self) -> &'static str {
82        match self {
83            Self::All => "all",
84            Self::Active => "active",
85            Self::Done => "done",
86            Self::Attention => "attention",
87        }
88    }
89
90    /// Does `status` belong in this filter?
91    pub fn accepts(self, status: RunStatus) -> bool {
92        match self {
93            Self::All => true,
94            Self::Active => !status.done(),
95            Self::Done => matches!(status, RunStatus::Merged | RunStatus::Ready),
96            // A stalled run wants a human even though it is terminal, so it
97            // surfaces under "attention", not "done".
98            Self::Attention => {
99                matches!(
100                    status,
101                    RunStatus::Stalled | RunStatus::Blocked | RunStatus::Failed
102                )
103            }
104        }
105    }
106}
107
108/// One loaded run plus the mtime it was loaded at.
109#[derive(Debug, Clone)]
110struct Loaded {
111    id: String,
112    mtime: Option<SystemTime>,
113    state: RunState,
114}
115
116/// Counts for the header.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
118pub struct Counts {
119    /// Runs on disk.
120    pub total: usize,
121    /// Still walking the graph.
122    pub active: usize,
123    /// Merged or ready.
124    pub done: usize,
125    /// Blocked or failed.
126    pub attention: usize,
127    /// State files that could not be parsed.
128    pub unreadable: usize,
129}
130
131/// TUI state.
132pub struct App {
133    runs: Vec<Loaded>,
134    /// Index into [`App::visible`], not into `runs`.
135    cursor: usize,
136    /// Vertical scroll of the report pane.
137    scroll: u16,
138    focus: Focus,
139    filter: Filter,
140    /// Runs on disk whose state file could not be parsed at all.
141    unreadable: usize,
142    help: bool,
143    status: Option<String>,
144    last_refresh: Instant,
145    /// Set by `q` / `Esc` / `Ctrl-C`.
146    quit: bool,
147}
148
149impl App {
150    /// Build from already-loaded runs. Used by the tests; [`App::load`] is what
151    /// the binary calls.
152    pub fn new(states: Vec<RunState>) -> Self {
153        let runs = states
154            .into_iter()
155            .map(|state| Loaded {
156                id: state.id.clone(),
157                mtime: None,
158                state,
159            })
160            .collect();
161        Self {
162            runs,
163            cursor: 0,
164            scroll: 0,
165            focus: Focus::List,
166            filter: Filter::All,
167            unreadable: 0,
168            help: false,
169            status: None,
170            last_refresh: Instant::now(),
171            quit: false,
172        }
173    }
174
175    /// Build by reading every run on disk.
176    pub fn load() -> Self {
177        let mut app = Self::new(Vec::new());
178        app.refresh();
179        app
180    }
181
182    /// Re-read the run directory, keeping the cursor on the same run.
183    ///
184    /// Only files whose mtime moved are parsed again: with a few hundred runs
185    /// on disk, re-parsing all of them every second would be the most
186    /// expensive thing magi does while sitting idle.
187    ///
188    /// A run that fails to parse does **not** disappear. Dropping it would make
189    /// a row blink out of a live view every time a load failed — and worse, a
190    /// permanently unreadable run (a state file from a different schema) would
191    /// be invisible here while `magi list` reports it as unreadable. So the last
192    /// good snapshot is kept if there is one, and otherwise the run is counted
193    /// and surfaced in the header.
194    pub fn refresh(&mut self) {
195        let selected_id = self.selected().map(|s| s.id.clone());
196        let ids = run::list_ids();
197        let mut next: Vec<Loaded> = Vec::with_capacity(ids.len());
198        let mut unreadable = 0usize;
199        for id in ids {
200            let mtime = state_mtime(&id);
201            let previous = self.runs.iter().find(|l| l.id == id);
202            if let Some(l) = previous.filter(|l| l.mtime == mtime && mtime.is_some()) {
203                next.push(l.clone());
204                continue;
205            }
206            match RunState::load(&id) {
207                Ok(state) => next.push(Loaded { id, mtime, state }),
208                Err(_) => match previous {
209                    Some(stale) => next.push(stale.clone()),
210                    None => unreadable += 1,
211                },
212            }
213        }
214        self.runs = next;
215        self.unreadable = unreadable;
216        self.last_refresh = Instant::now();
217        // Follow the run the cursor was on; fall back to clamping.
218        if let Some(id) = selected_id
219            && let Some(pos) = self.visible().iter().position(|i| self.runs[*i].id == id)
220        {
221            self.cursor = pos;
222        }
223        self.clamp();
224    }
225
226    /// Indices into `runs` that pass the filter.
227    pub fn visible(&self) -> Vec<usize> {
228        self.runs
229            .iter()
230            .enumerate()
231            .filter(|(_, l)| self.filter.accepts(l.state.status))
232            .map(|(i, _)| i)
233            .collect()
234    }
235
236    /// The selected run, if any.
237    pub fn selected(&self) -> Option<&RunState> {
238        let visible = self.visible();
239        visible.get(self.cursor).map(|i| &self.runs[*i].state)
240    }
241
242    /// Status counts across everything on disk, filter-independent.
243    pub fn counts(&self) -> Counts {
244        let mut c = Counts {
245            total: self.runs.len(),
246            unreadable: self.unreadable,
247            ..Counts::default()
248        };
249        for l in &self.runs {
250            match l.state.status {
251                RunStatus::Merged | RunStatus::Ready => c.done += 1,
252                RunStatus::Stalled | RunStatus::Blocked | RunStatus::Failed => c.attention += 1,
253                _ => c.active += 1,
254            }
255        }
256        c
257    }
258
259    fn clamp(&mut self) {
260        let len = self.visible().len();
261        self.cursor = if len == 0 {
262            0
263        } else {
264            self.cursor.min(len - 1)
265        };
266    }
267
268    /// Move the list cursor down.
269    pub fn next_run(&mut self) {
270        let len = self.visible().len();
271        if len > 0 {
272            self.cursor = (self.cursor + 1) % len;
273            self.scroll = 0;
274        }
275    }
276
277    /// Move the list cursor up.
278    pub fn prev_run(&mut self) {
279        let len = self.visible().len();
280        if len > 0 {
281            self.cursor = (self.cursor + len - 1) % len;
282            self.scroll = 0;
283        }
284    }
285
286    /// Jump to the newest run.
287    pub fn first_run(&mut self) {
288        self.cursor = 0;
289        self.scroll = 0;
290    }
291
292    /// Jump to the oldest run.
293    pub fn last_run(&mut self) {
294        self.cursor = self.visible().len().saturating_sub(1);
295        self.scroll = 0;
296    }
297
298    /// Scroll the report pane, clamped to the range ratatui's offset accepts.
299    pub fn scroll_by(&mut self, delta: i32) {
300        let next = i32::from(self.scroll).saturating_add(delta);
301        self.scroll = next.clamp(0, i32::from(u16::MAX)) as u16;
302    }
303
304    /// Cycle the filter, keeping the cursor in range.
305    pub fn cycle_filter(&mut self) {
306        self.filter = self.filter.next();
307        self.cursor = 0;
308        self.scroll = 0;
309        self.status = Some(format!("filter: {}", self.filter.label()));
310    }
311
312    /// Swap which pane the movement keys drive.
313    pub fn toggle_focus(&mut self) {
314        self.focus = match self.focus {
315            Focus::List => Focus::Detail,
316            Focus::Detail => Focus::List,
317        };
318    }
319
320    /// Current filter.
321    pub fn filter(&self) -> Filter {
322        self.filter
323    }
324
325    /// Current focus.
326    pub fn focus(&self) -> Focus {
327        self.focus
328    }
329
330    /// Should the loop exit?
331    pub fn quitting(&self) -> bool {
332        self.quit
333    }
334
335    /// The report text for the selected run, ANSI colours included.
336    fn detail(&self) -> String {
337        match self.selected() {
338            Some(state) => report::run(state),
339            None => String::from("no runs yet\n\nrun `magi run \"<task>\"` in a repository."),
340        }
341    }
342
343    /// Apply one key press.
344    pub fn on_key(&mut self, key: KeyEvent) {
345        if key.kind == KeyEventKind::Release {
346            return;
347        }
348        self.status = None;
349        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
350
351        // Quit is checked before anything modal can intercept it. A help
352        // overlay that eats Ctrl-C is how a TUI earns a reputation for
353        // trapping people.
354        if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
355            || (ctrl && matches!(key.code, KeyCode::Char('c')))
356        {
357            self.quit = true;
358            return;
359        }
360
361        // Help is modal: any other key closes it and does nothing else, so a
362        // keystroke aimed at the overlay never leaks into the panes behind it.
363        if self.help {
364            self.help = false;
365            return;
366        }
367
368        match key.code {
369            KeyCode::Char('?') => self.help = true,
370            KeyCode::Tab | KeyCode::BackTab => self.toggle_focus(),
371            KeyCode::Char('a') => self.cycle_filter(),
372            KeyCode::Char('r') => {
373                self.refresh();
374                self.status = Some("refreshed".to_owned());
375            }
376            KeyCode::Char('o') => self.open_selected(),
377            KeyCode::Char('g') | KeyCode::Home => self.first_run(),
378            KeyCode::Char('G') | KeyCode::End => self.last_run(),
379            KeyCode::Char('J') => self.scroll_by(5),
380            KeyCode::Char('K') => self.scroll_by(-5),
381            KeyCode::PageDown => self.scroll_by(20),
382            KeyCode::PageUp => self.scroll_by(-20),
383            KeyCode::Char('j') | KeyCode::Down => match self.focus {
384                Focus::List => self.next_run(),
385                Focus::Detail => self.scroll_by(1),
386            },
387            KeyCode::Char('k') | KeyCode::Up => match self.focus {
388                Focus::List => self.prev_run(),
389                Focus::Detail => self.scroll_by(-1),
390            },
391            _ => {}
392        }
393    }
394
395    /// Hand the run's directory to the OS opener. Read-only: it reveals the
396    /// artifacts, it does not change them.
397    fn open_selected(&mut self) {
398        let Some(dir) = self.selected().map(|s| s.dir()) else {
399            return;
400        };
401        self.status = Some(match open_path(&dir) {
402            Ok(()) => format!("opened {}", dir.display()),
403            Err(e) => format!("could not open {}: {e}", dir.display()),
404        });
405    }
406
407    /// Refresh if the interval has elapsed.
408    fn tick(&mut self) {
409        if self.last_refresh.elapsed() >= REFRESH {
410            self.refresh();
411        }
412    }
413}
414
415fn state_mtime(id: &str) -> Option<SystemTime> {
416    std::fs::metadata(run::run_dir(id).join("run.json"))
417        .and_then(|m| m.modified())
418        .ok()
419}
420
421#[cfg(windows)]
422fn open_path(path: &Path) -> Result<()> {
423    std::process::Command::new("explorer")
424        .arg(path)
425        .spawn()
426        .map(|_| ())
427        .context("spawn explorer")
428}
429
430#[cfg(target_os = "macos")]
431fn open_path(path: &Path) -> Result<()> {
432    std::process::Command::new("open")
433        .arg(path)
434        .spawn()
435        .map(|_| ())
436        .context("spawn open")
437}
438
439#[cfg(all(unix, not(target_os = "macos")))]
440fn open_path(path: &Path) -> Result<()> {
441    std::process::Command::new("xdg-open")
442        .arg(path)
443        .spawn()
444        .map(|_| ())
445        .context("spawn xdg-open")
446}
447
448/// Colour for a status word in the list.
449fn status_style(status: RunStatus) -> Style {
450    match status {
451        RunStatus::Merged => Style::default()
452            .fg(Color::Green)
453            .add_modifier(Modifier::BOLD),
454        RunStatus::Ready => Style::default().fg(Color::Green),
455        RunStatus::Stalled => Style::default()
456            .fg(Color::Yellow)
457            .add_modifier(Modifier::BOLD),
458        RunStatus::Blocked => Style::default().fg(Color::Yellow),
459        RunStatus::Failed => Style::default().fg(Color::Red),
460        _ => Style::default().fg(Color::Cyan),
461    }
462}
463
464/// Render one frame.
465pub fn draw(frame: &mut Frame, app: &mut App) {
466    let chunks = Layout::default()
467        .direction(Direction::Vertical)
468        .constraints([
469            Constraint::Length(1),
470            Constraint::Min(3),
471            Constraint::Length(1),
472        ])
473        .split(frame.area());
474
475    header(frame, chunks[0], app);
476    body(frame, chunks[1], app);
477    footer(frame, chunks[2], app);
478
479    if app.help {
480        help_overlay(frame, frame.area());
481    }
482}
483
484fn header(frame: &mut Frame, area: Rect, app: &App) {
485    let c = app.counts();
486    let mut line = Line::from(vec![
487        Span::styled(
488            " magi ",
489            Style::default()
490                .fg(Color::Black)
491                .bg(Color::Cyan)
492                .add_modifier(Modifier::BOLD),
493        ),
494        Span::raw(format!("  {} runs  ", c.total)),
495        Span::styled(
496            format!("{} active", c.active),
497            status_style(RunStatus::Prep),
498        ),
499        Span::raw("  "),
500        Span::styled(format!("{} done", c.done), status_style(RunStatus::Ready)),
501        Span::raw("  "),
502        Span::styled(
503            format!("{} attention", c.attention),
504            status_style(RunStatus::Blocked),
505        ),
506        Span::raw(format!("  |  filter: {}", app.filter.label())),
507    ]);
508    if c.unreadable > 0 {
509        line.push_span(Span::styled(
510            format!("  |  {} unreadable", c.unreadable),
511            status_style(RunStatus::Failed),
512        ));
513    }
514    frame.render_widget(Paragraph::new(line), area);
515}
516
517fn body(frame: &mut Frame, area: Rect, app: &mut App) {
518    let panes = Layout::default()
519        .direction(Direction::Horizontal)
520        .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
521        .split(area);
522
523    let visible = app.visible();
524    let items: Vec<ListItem> = visible
525        .iter()
526        .map(|i| {
527            let state = &app.runs[*i].state;
528            let status = format!("{:?}", state.status).to_lowercase();
529            ListItem::new(Line::from(vec![
530                Span::styled(format!("{:<12}", status), status_style(state.status)),
531                Span::raw(format!(
532                    "{}  {}",
533                    state.short(),
534                    state.instruction.lines().next().unwrap_or_default()
535                )),
536            ]))
537        })
538        .collect();
539
540    let list_focused = app.focus == Focus::List;
541    let list = List::new(items)
542        .block(pane_block(" runs ", list_focused))
543        .highlight_style(
544            Style::default()
545                .bg(Color::DarkGray)
546                .add_modifier(Modifier::BOLD),
547        )
548        .highlight_symbol("> ");
549    let mut list_state = ListState::default();
550    if !visible.is_empty() {
551        list_state.select(Some(app.cursor));
552    }
553    frame.render_stateful_widget(list, panes[0], &mut list_state);
554
555    // `report::run` already renders every field with colour; parsing its ANSI
556    // back into spans keeps one implementation of the report instead of two.
557    let text = app
558        .detail()
559        .into_text()
560        .unwrap_or_else(|_| app.detail().into());
561    let detail = Paragraph::new(text)
562        .block(pane_block(" report ", !list_focused))
563        .wrap(Wrap { trim: false })
564        .scroll((app.scroll, 0));
565    frame.render_widget(detail, panes[1]);
566}
567
568fn pane_block(title: &str, focused: bool) -> Block<'_> {
569    let style = if focused {
570        Style::default().fg(Color::Cyan)
571    } else {
572        Style::default().fg(Color::DarkGray)
573    };
574    Block::bordered().title(title).border_style(style)
575}
576
577fn footer(frame: &mut Frame, area: Rect, app: &App) {
578    let text = match &app.status {
579        Some(msg) => msg.clone(),
580        None => "j/k move  Tab pane  J/K scroll  a filter  r refresh  o open dir  ? help  q quit"
581            .to_owned(),
582    };
583    frame.render_widget(
584        Paragraph::new(Span::styled(text, Style::default().fg(Color::DarkGray))),
585        area,
586    );
587}
588
589fn help_overlay(frame: &mut Frame, area: Rect) {
590    let lines = vec![
591        Line::from("magi — observation deck (read-only)"),
592        Line::from(""),
593        Line::from("j / k / ↓ / ↑   move in the focused pane"),
594        Line::from("Tab             switch pane (runs / report)"),
595        Line::from("J / K           scroll the report by 5"),
596        Line::from("PageDown / Up   scroll the report by 20"),
597        Line::from("g / G           newest / oldest run"),
598        Line::from("a               cycle filter: all, active, attention, done"),
599        Line::from("r               refresh now (it also refreshes every second)"),
600        Line::from("o               open the run's directory in the OS file manager"),
601        Line::from("q / Esc         quit"),
602        Line::from(""),
603        Line::from("Nothing here mutates a run. Use `magi fold` for cleanup."),
604    ];
605    let height = (lines.len() as u16 + 2).min(area.height);
606    let width = 66.min(area.width);
607    let popup = Rect {
608        x: area.x + (area.width.saturating_sub(width)) / 2,
609        y: area.y + (area.height.saturating_sub(height)) / 2,
610        width,
611        height,
612    };
613    frame.render_widget(ratatui::widgets::Clear, popup);
614    frame.render_widget(
615        Paragraph::new(lines).block(pane_block(" help ", true)),
616        popup,
617    );
618}
619
620/// RAII guard for raw mode and the alternate screen.
621///
622/// A guard rather than a cleanup block, so a panic anywhere inside the loop
623/// still gives the terminal back.
624struct TerminalGuard;
625
626impl TerminalGuard {
627    fn new() -> Result<Self> {
628        enable_raw_mode().context("enabling terminal raw mode")?;
629        execute!(io::stdout(), EnterAlternateScreen).context("entering alt screen")?;
630        Ok(Self)
631    }
632}
633
634impl Drop for TerminalGuard {
635    fn drop(&mut self) {
636        // Reverse of `new`, with `disable_raw_mode` LAST. On Windows the
637        // console-mode restore performed while leaving the alternate screen is
638        // taken from a snapshot captured after raw mode was enabled, so
639        // disabling raw mode first lets that restore put the cooked bits back
640        // to their raw values — stranding the whole console in raw mode after
641        // magi exits. Learned in yukimemi/shoka.
642        let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show);
643        let _ = disable_raw_mode();
644    }
645}
646
647/// Open the observation deck on the real terminal.
648pub fn run() -> Result<()> {
649    let _guard = TerminalGuard::new()?;
650    let backend = CrosstermBackend::new(io::stdout());
651    let mut terminal = Terminal::with_options(
652        backend,
653        TerminalOptions {
654            viewport: Viewport::Fullscreen,
655        },
656    )
657    .context("constructing ratatui terminal")?;
658    let mut app = App::load();
659    event_loop(&mut terminal, &mut app)
660}
661
662/// The loop, generic over the backend so a test can drive it.
663pub fn event_loop<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
664    while !app.quitting() {
665        terminal
666            .draw(|f| draw(f, app))
667            .map_err(|e| anyhow::anyhow!("drawing frame: {e}"))?;
668        if event::poll(TICK).context("polling for input")?
669            && let Event::Key(key) = event::read().context("reading input")?
670        {
671            app.on_key(key);
672        }
673        app.tick();
674    }
675    Ok(())
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::config::Config;
682    use crate::run::Tally;
683    use ratatui::backend::TestBackend;
684    use std::collections::BTreeMap;
685    use std::path::PathBuf;
686
687    fn state(instruction: &str, status: RunStatus) -> RunState {
688        let mut s = RunState::new(
689            PathBuf::from("/repo"),
690            "main".to_owned(),
691            "abcdef1234".to_owned(),
692            instruction.to_owned(),
693            Config::default(),
694        );
695        s.status = status;
696        s
697    }
698
699    fn app() -> App {
700        App::new(vec![
701            state("add retries", RunStatus::Reviewing),
702            state("fix the parser", RunStatus::Blocked),
703            state("document the gate", RunStatus::Merged),
704        ])
705    }
706
707    fn key(code: KeyCode) -> KeyEvent {
708        KeyEvent::new(code, KeyModifiers::NONE)
709    }
710
711    #[test]
712    fn counts_partition_every_run() {
713        let c = app().counts();
714        assert_eq!(c.total, 3);
715        assert_eq!(c.active, 1);
716        assert_eq!(c.attention, 1);
717        assert_eq!(c.done, 1);
718        assert_eq!(c.active + c.attention + c.done, c.total);
719    }
720
721    /// A corrupt state file must not make a row blink out of a live view.
722    ///
723    /// Uses a temp run home so it never touches the operator's history. The
724    /// home is process-global and set once, so this is the only lib test that
725    /// reads from disk.
726    #[test]
727    fn an_unreadable_run_keeps_its_last_snapshot_and_is_counted() {
728        let dir = tempfile::tempdir().unwrap();
729        run::set_home(dir.path().to_path_buf());
730        // If another test already pinned the home, this one has nothing to say.
731        if run::home() != dir.path() {
732            return;
733        }
734
735        let mut saved = state("watch me", RunStatus::Reviewing);
736        saved.save().expect("save run state");
737        let id = saved.id.clone();
738
739        let mut a = App::load();
740        assert_eq!(a.visible().len(), 1, "the saved run is listed");
741        assert_eq!(a.counts().unreadable, 0);
742
743        // Corrupt it and force a reload: the row stays, with the old snapshot.
744        let path = run::run_dir(&id).join("run.json");
745        std::fs::write(&path, "{ not json").unwrap();
746        a.refresh();
747        assert_eq!(a.visible().len(), 1, "row must not blink out");
748        assert_eq!(a.selected().unwrap().instruction, "watch me");
749        assert_eq!(a.counts().unreadable, 0, "a stale snapshot is not a loss");
750
751        // A fresh reader has no snapshot to fall back on, so it must say so
752        // rather than pretend the run does not exist.
753        let fresh = App::load();
754        assert!(fresh.visible().is_empty());
755        assert_eq!(fresh.counts().unreadable, 1);
756        assert_eq!(fresh.counts().total, 0);
757    }
758
759    #[test]
760    fn cursor_wraps_in_both_directions() {
761        let mut a = app();
762        assert_eq!(a.selected().unwrap().instruction, "add retries");
763        a.next_run();
764        a.next_run();
765        assert_eq!(a.selected().unwrap().instruction, "document the gate");
766        a.next_run();
767        assert_eq!(a.selected().unwrap().instruction, "add retries");
768        a.prev_run();
769        assert_eq!(a.selected().unwrap().instruction, "document the gate");
770    }
771
772    #[test]
773    fn filter_cycles_and_narrows() {
774        let mut a = app();
775        assert_eq!(a.visible().len(), 3);
776        a.cycle_filter();
777        assert_eq!(a.filter(), Filter::Active);
778        assert_eq!(a.visible().len(), 1);
779        assert_eq!(a.selected().unwrap().instruction, "add retries");
780        a.cycle_filter();
781        assert_eq!(a.filter(), Filter::Attention);
782        assert_eq!(a.selected().unwrap().instruction, "fix the parser");
783        a.cycle_filter();
784        assert_eq!(a.filter(), Filter::Done);
785        assert_eq!(a.selected().unwrap().instruction, "document the gate");
786        a.cycle_filter();
787        assert_eq!(a.filter(), Filter::All);
788    }
789
790    #[test]
791    fn a_filter_that_hides_the_cursor_does_not_panic() {
792        let mut a = app();
793        a.last_run();
794        a.filter = Filter::Active;
795        a.clamp();
796        assert!(a.selected().is_some());
797        a.filter = Filter::Done;
798        a.cursor = 99;
799        a.clamp();
800        assert_eq!(a.cursor, 0);
801    }
802
803    #[test]
804    fn empty_state_selects_nothing_and_still_renders() {
805        let mut a = App::new(Vec::new());
806        assert!(a.selected().is_none());
807        a.next_run();
808        a.prev_run();
809        a.last_run();
810        assert_eq!(a.cursor, 0);
811        assert!(a.detail().contains("no runs yet"));
812    }
813
814    #[test]
815    fn scroll_never_goes_negative() {
816        let mut a = app();
817        a.scroll_by(-10);
818        assert_eq!(a.scroll, 0);
819        a.scroll_by(7);
820        assert_eq!(a.scroll, 7);
821        a.scroll_by(-3);
822        assert_eq!(a.scroll, 4);
823    }
824
825    #[test]
826    fn focus_routes_movement_keys() {
827        let mut a = app();
828        assert_eq!(a.focus(), Focus::List);
829        a.on_key(key(KeyCode::Char('j')));
830        assert_eq!(a.selected().unwrap().instruction, "fix the parser");
831        assert_eq!(a.scroll, 0);
832
833        a.on_key(key(KeyCode::Tab));
834        assert_eq!(a.focus(), Focus::Detail);
835        a.on_key(key(KeyCode::Char('j')));
836        // Same run, scrolled instead.
837        assert_eq!(a.selected().unwrap().instruction, "fix the parser");
838        assert_eq!(a.scroll, 1);
839    }
840
841    #[test]
842    fn quit_keys() {
843        for code in [KeyCode::Char('q'), KeyCode::Esc] {
844            let mut a = app();
845            a.on_key(key(code));
846            assert!(a.quitting(), "{code:?} should quit");
847        }
848        let mut a = app();
849        a.on_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
850        assert!(a.quitting());
851        // A bare `c` is not a quit.
852        let mut a = app();
853        a.on_key(key(KeyCode::Char('c')));
854        assert!(!a.quitting());
855    }
856
857    #[test]
858    fn help_is_modal_but_never_swallows_a_quit() {
859        let mut a = app();
860        a.on_key(key(KeyCode::Char('?')));
861        assert!(a.help);
862        a.on_key(key(KeyCode::Char('j')));
863        assert!(!a.help, "any key dismisses help");
864        // Dismissal must not also move the cursor.
865        assert_eq!(a.selected().unwrap().instruction, "add retries");
866
867        // `?` closes it too, rather than toggling twice back open.
868        a.on_key(key(KeyCode::Char('?')));
869        a.on_key(key(KeyCode::Char('?')));
870        assert!(!a.help);
871
872        for code in [KeyCode::Char('q'), KeyCode::Esc] {
873            let mut a = app();
874            a.on_key(key(KeyCode::Char('?')));
875            a.on_key(key(code));
876            assert!(a.quitting(), "{code:?} must quit from the help overlay");
877        }
878        let mut a = app();
879        a.on_key(key(KeyCode::Char('?')));
880        a.on_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
881        assert!(a.quitting(), "help must not swallow Ctrl-C");
882    }
883
884    #[test]
885    fn key_releases_are_ignored() {
886        let mut a = app();
887        let mut release = key(KeyCode::Char('q'));
888        release.kind = KeyEventKind::Release;
889        a.on_key(release);
890        assert!(!a.quitting(), "a key release must not act twice");
891    }
892
893    #[test]
894    fn frame_shows_counts_list_and_report() {
895        let mut a = app();
896        a.runs[0].state.tally = Some(Tally {
897            first_choice: BTreeMap::from([('A', 3)]),
898            borda: BTreeMap::new(),
899            winner: 'A',
900            rankings: 3,
901            unanimous_initial: true,
902            deliberated: false,
903            changed_votes: 0,
904            unanimous_final: true,
905            tie_break: None,
906            judges: 3,
907            present: 3,
908            quorum: 2,
909            met_quorum: true,
910            uncontested: None,
911        });
912        let mut terminal = Terminal::new(TestBackend::new(110, 30)).unwrap();
913        terminal.draw(|f| draw(f, &mut a)).unwrap();
914
915        let rendered: String = terminal
916            .backend()
917            .buffer()
918            .content()
919            .iter()
920            .map(|c| c.symbol())
921            .collect();
922        assert!(rendered.contains("3 runs"), "{rendered}");
923        assert!(rendered.contains("1 active"));
924        assert!(rendered.contains("1 attention"));
925        assert!(rendered.contains("reviewing"), "status word in the list");
926        assert!(rendered.contains("add retries"), "instruction in the list");
927        assert!(rendered.contains("blocked"));
928        // The report pane is the real `report::run` output.
929        assert!(rendered.contains("candidates"), "report pane rendered");
930        assert!(rendered.contains("q quit"), "footer hints");
931    }
932
933    #[test]
934    fn help_overlay_renders_over_the_panes() {
935        let mut a = app();
936        a.on_key(key(KeyCode::Char('?')));
937        let mut terminal = Terminal::new(TestBackend::new(110, 30)).unwrap();
938        terminal.draw(|f| draw(f, &mut a)).unwrap();
939        let rendered: String = terminal
940            .backend()
941            .buffer()
942            .content()
943            .iter()
944            .map(|c| c.symbol())
945            .collect();
946        assert!(rendered.contains("observation deck"));
947        assert!(rendered.contains("Nothing here mutates a run"));
948    }
949
950    #[test]
951    fn a_narrow_terminal_still_renders() {
952        let mut a = app();
953        let mut terminal = Terminal::new(TestBackend::new(20, 6)).unwrap();
954        terminal.draw(|f| draw(f, &mut a)).unwrap();
955        a.on_key(key(KeyCode::Char('?')));
956        terminal.draw(|f| draw(f, &mut a)).unwrap();
957    }
958}