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