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