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