Skip to main content

pma_core/
screen.rs

1//! A `View` as an interactive front end shows it: tables of rows to select,
2//! or a detail of fields and sections; and how a key on a selected row
3//! becomes an `Action`. The TUI and the web both draw a `Screen` and send what
4//! `action` builds, so neither decides for itself what a row is, what a key
5//! does to it, or what a state means. Each cell carries a `Tone`, so both
6//! colour a failed run or a critical task alike.
7
8use crate::api::{Action, Selection, View, WorkerChoice};
9use crate::keys::{Act, Tab};
10use crate::store::{Run, RunState};
11use crate::{accept, dates, next, publish, rank, report, report_runs, watch};
12
13/// What a row is about.
14#[derive(Debug, Clone, PartialEq)]
15pub enum Pick {
16    Nothing,
17    /// A task, by its dispatch target; `retry` once its attempts are used.
18    Task {
19        project: String,
20        target: String,
21        retry: bool,
22    },
23    /// A task no agent may take.
24    Manual {
25        project: String,
26    },
27    Run {
28        id: i64,
29        /// Ready, with no reason to read it: a batch approval may take it.
30        clean: bool,
31        /// The state the row showed. Approving or rejecting is refused once
32        /// the run has left it, so no one acts on a run they did not see.
33        state: String,
34        project: String,
35    },
36    Job {
37        id: i64,
38        awaiting: bool,
39    },
40    Instance {
41        id: i64,
42        workflow: String,
43        open: bool,
44    },
45    Note {
46        id: i64,
47        text: String,
48    },
49    Campaign {
50        name: String,
51    },
52}
53
54/// What a value means, for a front end to colour: its palette, one meaning.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Tone {
57    Plain,
58    /// Context: ages, ids, counts of nothing.
59    Muted,
60    /// Under way.
61    Accent,
62    /// Done and accepted.
63    Good,
64    /// Waits on you.
65    Warn,
66    /// Failed, or critical.
67    Bad,
68    /// A total.
69    Strong,
70}
71
72impl Tone {
73    pub fn name(self) -> &'static str {
74        match self {
75            Tone::Plain => "plain",
76            Tone::Muted => "muted",
77            Tone::Accent => "accent",
78            Tone::Good => "good",
79            Tone::Warn => "warn",
80            Tone::Bad => "bad",
81            Tone::Strong => "strong",
82        }
83    }
84}
85
86/// How a column lays out.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum Kind {
89    /// Short text, as wide as its longest value.
90    Text,
91    /// The column that takes the remaining width; truncated, never wrapped.
92    Wide,
93    /// Right-aligned.
94    Number,
95    /// A state or a priority: a short word shown as a badge.
96    Badge,
97}
98
99#[derive(Debug, Clone, PartialEq)]
100pub struct Column {
101    pub title: &'static str,
102    pub kind: Kind,
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub struct Cell {
107    pub text: String,
108    pub tone: Tone,
109}
110
111/// Whether `act` can apply to a row about `pick`. An act that needs no row
112/// fits any. A front end offers only the acts that fit the selected row.
113pub fn fits(act: Act, pick: &Pick) -> bool {
114    match act {
115        Act::Dispatch => matches!(pick, Pick::Task { .. }),
116        Act::Verify => matches!(pick, Pick::Task { .. } | Pick::Manual { .. }),
117        Act::ApproveRun
118        | Act::RejectRun
119        | Act::ReworkRun
120        | Act::AddReviewTime
121        | Act::Pr
122        | Act::Push => matches!(pick, Pick::Run { .. }),
123        Act::ApproveJob => matches!(pick, Pick::Job { awaiting: true, .. }),
124        Act::StopJob => matches!(pick, Pick::Job { .. }),
125        Act::StopWorkflow => matches!(pick, Pick::Instance { open: true, .. }),
126        Act::ResumeWorkflow => matches!(pick, Pick::Instance { open: true, .. }),
127        Act::RunCampaign => matches!(pick, Pick::Campaign { .. }),
128        Act::EditNote | Act::RemoveNote => matches!(pick, Pick::Note { .. }),
129        _ => !act.on_row(),
130    }
131}
132
133impl Pick {
134    /// The project a row is about, when it names one.
135    pub fn project(&self) -> Option<&str> {
136        match self {
137            Pick::Task { project, .. } | Pick::Manual { project } | Pick::Run { project, .. } => {
138                Some(project)
139            }
140            _ => None,
141        }
142    }
143}
144
145#[derive(Debug, Clone, PartialEq)]
146pub struct Item {
147    pub cells: Vec<Cell>,
148    pub pick: Pick,
149}
150
151impl Item {
152    /// The text of each cell, for a test or a plain listing.
153    pub fn texts(&self) -> Vec<&str> {
154        self.cells.iter().map(|c| c.text.as_str()).collect()
155    }
156}
157
158#[derive(Debug, Clone, PartialEq)]
159pub struct Pane {
160    pub title: String,
161    pub columns: Vec<Column>,
162    pub items: Vec<Item>,
163    /// What an empty pane says instead of nothing.
164    pub empty: &'static str,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Section {
169    /// Text to read, wrapped.
170    Prose,
171    /// A unified diff: lines starting `+`, `-` and `@@` mean something.
172    Diff,
173    /// Output kept as written, in a fixed-width font.
174    Log,
175}
176
177#[derive(Debug, Clone, PartialEq)]
178pub struct Detail {
179    pub title: String,
180    /// A badge beside the title, such as the run's state.
181    pub badge: Option<Cell>,
182    /// A warning above everything else.
183    pub notice: Option<String>,
184    pub fields: Vec<(String, String)>,
185    pub sections: Vec<(String, Section, String)>,
186}
187
188#[derive(Debug, Clone, PartialEq)]
189pub enum Body {
190    Panes(Vec<Pane>),
191    Detail(Detail),
192}
193
194#[derive(Debug, Clone, PartialEq)]
195pub struct Screen {
196    /// The tab it belongs to; `None` for a detail opened from one.
197    pub tab: Option<Tab>,
198    /// A line above the body, such as when the last scan ran.
199    pub header: Option<String>,
200    /// Facts above the body, label and value: a project's tier, state, CI.
201    pub summary: Vec<(String, Cell)>,
202    pub body: Body,
203}
204
205fn cell(text: impl Into<String>, tone: Tone) -> Cell {
206    Cell {
207        text: text.into(),
208        tone,
209    }
210}
211
212fn plain(text: impl Into<String>) -> Cell {
213    cell(text, Tone::Plain)
214}
215
216fn muted(text: impl Into<String>) -> Cell {
217    cell(text, Tone::Muted)
218}
219
220const fn col(title: &'static str, kind: Kind) -> Column {
221    Column { title, kind }
222}
223
224/// A run's state, by what it asks of you.
225pub fn run_tone(state: &str) -> Tone {
226    match state {
227        "queued" | "running" | "pr-open" => Tone::Accent,
228        "ready" => Tone::Warn,
229        "approved" | "pushed" | "merged" | "applied" | "done" => Tone::Good,
230        "failed" | "rejected" => Tone::Bad,
231        _ => Tone::Muted,
232    }
233}
234
235pub fn job_tone(state: &str) -> Tone {
236    match state {
237        "queued" | "running" => Tone::Accent,
238        "awaiting-approval" => Tone::Warn,
239        "done" | "approved" => Tone::Good,
240        "failed" => Tone::Bad,
241        _ => Tone::Muted,
242    }
243}
244
245pub fn priority_tone(priority: &str) -> Tone {
246    match priority {
247        "critical" => Tone::Bad,
248        "high" => Tone::Warn,
249        "low" => Tone::Muted,
250        _ => Tone::Plain,
251    }
252}
253
254/// A project's worst state, as `pma status` names it.
255fn state_tone(state: &str) -> Tone {
256    match state.split(" +").next().unwrap_or(state) {
257        "failing CI" | "broken" | "critical" => Tone::Bad,
258        "unpublished" | "stale deps" => Tone::Warn,
259        "idle" => Tone::Muted,
260        "clear" => Tone::Good,
261        _ => Tone::Plain,
262    }
263}
264
265/// How soon a task is due, by how soon.
266fn when_tone(when: &str) -> Tone {
267    match when {
268        w if w.starts_with("overdue") || w == "due today" => Tone::Bad,
269        w if w.starts_with("due") || w == "#urgent" || w == "signal" => Tone::Warn,
270        _ => Tone::Muted,
271    }
272}
273
274fn task_pick(p: &rank::Placed) -> Pick {
275    let t = &p.task;
276    match (&t.key, t.line) {
277        (Some(_), Some(line)) => Pick::Task {
278            project: t.project.clone(),
279            target: format!("{}:{line}", t.project),
280            retry: false,
281        },
282        (Some(key), None) => Pick::Task {
283            project: t.project.clone(),
284            target: format!("{}:{key}", t.project),
285            retry: false,
286        },
287        (None, _) => Pick::Manual {
288            project: t.project.clone(),
289        },
290    }
291}
292
293fn next_pick(s: &next::Subject) -> Pick {
294    match s {
295        next::Subject::Task {
296            project,
297            target,
298            retry,
299        } => Pick::Task {
300            project: project.clone(),
301            target: target.clone(),
302            retry: *retry,
303        },
304        next::Subject::Manual { project } => Pick::Manual {
305            project: project.clone(),
306        },
307        next::Subject::Run { id, state, project } => Pick::Run {
308            id: *id,
309            clean: false,
310            state: state.clone(),
311            project: project.clone(),
312        },
313    }
314}
315
316/// A task's cells as `pma next` builds them: place, tier, priority, when,
317/// an optional reason, and its text.
318fn task_cells(cells: &[String]) -> Vec<Cell> {
319    let get = |i: usize| cells.get(i).cloned().unwrap_or_default();
320    let (why, text) = match cells.len() {
321        n if n >= 6 => (get(4), get(5)),
322        _ => (String::new(), get(4)),
323    };
324    vec![
325        plain(get(0)),
326        muted(get(1)),
327        cell(get(2), priority_tone(&get(2))),
328        cell(get(3), when_tone(&get(3))),
329        cell(why, Tone::Warn),
330        plain(text),
331    ]
332}
333
334const TASK_COLUMNS: [Column; 6] = [
335    col("task", Kind::Text),
336    col("tier", Kind::Text),
337    col("priority", Kind::Badge),
338    col("when", Kind::Text),
339    col("why", Kind::Text),
340    col("title", Kind::Wide),
341];
342
343fn run_cells(r: &Run, now: i64) -> Vec<Cell> {
344    vec![
345        muted(format!("#{}", r.id)),
346        cell(r.state.name(), run_tone(r.state.name())),
347        plain(r.project.clone()),
348        plain(r.text.clone()),
349        cell(
350            r.cost_usd.map_or("-".into(), |c| format!("${c:.2}")),
351            Tone::Muted,
352        ),
353        muted(r.dispatched_at.map_or("-".into(), |t| report::ago(now - t))),
354    ]
355}
356
357const RUN_COLUMNS: [Column; 6] = [
358    col("run", Kind::Text),
359    col("state", Kind::Badge),
360    col("project", Kind::Text),
361    col("task", Kind::Wide),
362    col("cost", Kind::Number),
363    col("started", Kind::Text),
364];
365
366fn run_detail(
367    run: &Run,
368    attempts: &[crate::store::Attempt],
369    changelog: Option<String>,
370    diff: String,
371    moved: bool,
372) -> Detail {
373    let fields = report::run_fields(run)
374        .into_iter()
375        .filter(|r| r[0] != "task")
376        .map(|r| (r[0].clone(), r[1].clone()))
377        .collect();
378    let mut sections = Vec::new();
379    if let Some(s) = run.summary.as_deref().filter(|s| !s.trim().is_empty()) {
380        sections.push(("Summary".into(), Section::Prose, s.to_string()));
381    }
382    match changelog.as_deref().map(str::trim) {
383        Some("") => sections.push((
384            "Changelog".into(),
385            Section::Prose,
386            "No entry drafted.".into(),
387        )),
388        Some(entries) => sections.push(("Changelog".into(), Section::Prose, entries.into())),
389        None => {}
390    }
391    let reasons = accept::review_reasons(run);
392    sections.push((
393        "Why read it".into(),
394        Section::Prose,
395        match reasons.is_empty() {
396            true => "Every recorded gate is clean.".into(),
397            false => reasons
398                .iter()
399                .map(|r| format!("- {r}"))
400                .collect::<Vec<_>>()
401                .join("\n"),
402        },
403    ));
404    if attempts.len() > 1 {
405        sections.push((
406            "Attempts".into(),
407            Section::Log,
408            report::table(&report::attempt_rows(attempts), ""),
409        ));
410    }
411    sections.push(("Diff".into(), Section::Diff, diff));
412    Detail {
413        title: format!("#{} {}", run.id, run.text),
414        badge: Some(cell(run.state.name(), run_tone(run.state.name()))),
415        notice: moved
416            .then(|| "The run changed while it was shown. Read it again before approving.".into()),
417        fields,
418        sections,
419    }
420}
421
422/// A status row's cells, toned: `pma status`'s project, tier, state, open,
423/// idle, ci, deps, local and todo.
424fn status_cells(c: &[String]) -> Vec<Cell> {
425    let get = |i: usize| c.get(i).cloned().unwrap_or_default();
426    let ci = get(5);
427    let local = get(7);
428    let todo = get(8);
429    vec![
430        plain(get(0)),
431        muted(get(1)),
432        cell(get(2), state_tone(&get(2))),
433        plain(get(3)),
434        muted(get(4)),
435        cell(
436            ci.clone(),
437            match ci.as_str() {
438                "failing" => Tone::Bad,
439                "passing" => Tone::Good,
440                _ => Tone::Muted,
441            },
442        ),
443        cell(
444            get(6),
445            match get(6).as_str() {
446                "-" | "0" => Tone::Muted,
447                _ => Tone::Warn,
448            },
449        ),
450        cell(
451            local.clone(),
452            match local.as_str() {
453                "clean" => Tone::Muted,
454                _ => Tone::Warn,
455            },
456        ),
457        cell(
458            todo.clone(),
459            match todo.as_str() {
460                "ok" => Tone::Muted,
461                "missing" => Tone::Warn,
462                _ => Tone::Bad,
463            },
464        ),
465    ]
466}
467
468/// The screen a view makes. Every view has one, so a query a front end can
469/// send is a screen it can draw.
470pub fn screen(view: View) -> Screen {
471    let panes = |tab: Tab, header: Option<String>, panes: Vec<Pane>| Screen {
472        tab: Some(tab),
473        header,
474        summary: Vec::new(),
475        body: Body::Panes(panes),
476    };
477    let detail = |tab: Option<Tab>, header: Option<String>, d: Detail| Screen {
478        tab,
479        header,
480        summary: Vec::new(),
481        body: Body::Detail(d),
482    };
483    let now = dates::now();
484    match view {
485        View::Next { header, lists, .. } => {
486            let rows = |rows: Vec<next::Row>, cells: fn(&[String]) -> Vec<Cell>| -> Vec<Item> {
487                rows.into_iter()
488                    .map(|r| Item {
489                        pick: next_pick(&r.subject),
490                        cells: cells(&r.cells),
491                    })
492                    .collect()
493            };
494            let run_row = |c: &[String]| -> Vec<Cell> {
495                let get = |i: usize| c.get(i).cloned().unwrap_or_default();
496                vec![
497                    muted(get(0)),
498                    cell(get(1), run_tone(&get(1))),
499                    cell(get(2), Tone::Warn),
500                    plain(get(3)),
501                    plain(get(4)),
502                ]
503            };
504            panes(
505                Tab::Next,
506                Some(header),
507                vec![
508                    Pane {
509                        title: "Ready for agents".into(),
510                        columns: TASK_COLUMNS.to_vec(),
511                        items: rows(lists.agents, task_cells),
512                        empty: "Nothing an agent may take. Tag a TODO.md item #agent.",
513                    },
514                    Pane {
515                        title: "Runs waiting on you".into(),
516                        columns: vec![
517                            col("run", Kind::Text),
518                            col("state", Kind::Badge),
519                            col("next", Kind::Text),
520                            col("project", Kind::Text),
521                            col("task", Kind::Wide),
522                        ],
523                        items: rows(lists.runs, run_row),
524                        empty: "No run waits on you.",
525                    },
526                    Pane {
527                        title: "Tasks for you".into(),
528                        columns: TASK_COLUMNS.to_vec(),
529                        items: rows(lists.tasks, task_cells),
530                        empty: "No critical or urgent task needs you.",
531                    },
532                ],
533            )
534        }
535        View::Matrix {
536            header,
537            placed,
538            today,
539            ..
540        } => {
541            let mut quadrants: Vec<Pane> = rank::Quadrant::ALL
542                .iter()
543                .map(|q| Pane {
544                    title: format!("{q:?} {}", q.title()),
545                    columns: vec![
546                        col("task", Kind::Text),
547                        col("priority", Kind::Badge),
548                        col("when", Kind::Text),
549                        col("title", Kind::Wide),
550                    ],
551                    items: Vec::new(),
552                    empty: "Nothing here.",
553                })
554                .collect();
555            for p in &placed {
556                let place = match p.task.line {
557                    Some(line) => format!("{}:{line}", p.task.project),
558                    None => p.task.project.clone(),
559                };
560                let when = report::when(p, today);
561                let priority = p.task.priority.name();
562                quadrants[p.quadrant as usize].items.push(Item {
563                    cells: vec![
564                        plain(place),
565                        cell(priority, priority_tone(priority)),
566                        cell(when.clone(), when_tone(&when)),
567                        plain(p.task.text.clone()),
568                    ],
569                    pick: task_pick(p),
570                });
571            }
572            panes(Tab::Matrix, Some(header), quadrants)
573        }
574        View::Status {
575            header,
576            portfolio: p,
577            ..
578        } => {
579            let rows: Vec<report::StatusRow> = p
580                .projects
581                .iter()
582                .map(|(project, row)| report::StatusRow {
583                    project,
584                    tiered: row.tier.is_some() || p.cfg.default_tier.is_some(),
585                    has_todo: row.has_todo,
586                    lint_errors: row.lint_errors,
587                    scan_error: row.scan_error.as_deref(),
588                })
589                .collect();
590            let items = report::status_rows(&p.cfg, &rows)
591                .into_iter()
592                .map(|(_, c)| Item {
593                    pick: Pick::Manual {
594                        project: c.first().cloned().unwrap_or_default(),
595                    },
596                    cells: status_cells(&c),
597                })
598                .collect();
599            panes(
600                Tab::Status,
601                Some(header),
602                vec![Pane {
603                    title: "Projects, worst first".into(),
604                    columns: vec![
605                        col("project", Kind::Text),
606                        col("tier", Kind::Text),
607                        col("state", Kind::Badge),
608                        col("open", Kind::Number),
609                        col("idle", Kind::Number),
610                        col("ci", Kind::Badge),
611                        col("deps", Kind::Number),
612                        col("local", Kind::Text),
613                        col("todo", Kind::Text),
614                    ],
615                    items,
616                    empty: "No tiered project. `pma project tier <1-5> <project>` ranks one.",
617                }],
618            )
619        }
620        View::Stale {
621            header,
622            placed,
623            limit,
624        } => detail(
625            None,
626            Some(header),
627            Detail {
628                title: "Stale tasks".into(),
629                badge: None,
630                notice: None,
631                fields: Vec::new(),
632                sections: vec![(
633                    "Oldest first".into(),
634                    Section::Log,
635                    report::stale(&placed, limit),
636                )],
637            },
638        ),
639        View::Report { runs, attempts, by } => {
640            let by_name = report_runs::By::ALL
641                .iter()
642                .find(|(_, b)| *b == by)
643                .map_or("?", |(n, _)| n);
644            let (summary, items) = match report_runs::table_of(&runs, &attempts, by) {
645                None => ("No finished runs yet.".to_string(), Vec::new()),
646                Some((summary, rows)) => {
647                    let last = rows.len().saturating_sub(1);
648                    let items = rows
649                        .into_iter()
650                        .enumerate()
651                        .skip(1)
652                        .map(|(i, r)| Item {
653                            cells: r
654                                .into_iter()
655                                .map(|t| match i == last {
656                                    true => cell(t, Tone::Strong),
657                                    false => plain(t),
658                                })
659                                .collect(),
660                            pick: Pick::Nothing,
661                        })
662                        .collect();
663                    (summary, items)
664                }
665            };
666            let mut columns = vec![col(by_name, Kind::Wide)];
667            columns.extend(report_runs::HEADINGS.iter().map(|h| col(h, Kind::Number)));
668            panes(
669                Tab::Report,
670                Some(summary),
671                vec![Pane {
672                    title: format!("Runs by {by_name}"),
673                    columns,
674                    items,
675                    empty: "Dispatch some tasks first.",
676                }],
677            )
678        }
679        View::Runs(runs) => {
680            let items = runs
681                .iter()
682                .map(|r| Item {
683                    cells: run_cells(r, now),
684                    pick: Pick::Run {
685                        id: r.id,
686                        clean: r.state == RunState::Ready && accept::review_reasons(r).is_empty(),
687                        state: r.state.name().into(),
688                        project: r.project.clone(),
689                    },
690                })
691                .collect::<Vec<_>>();
692            panes(
693                Tab::Runs,
694                None,
695                vec![Pane {
696                    title: "Runs not yet final".into(),
697                    columns: RUN_COLUMNS.to_vec(),
698                    items,
699                    empty: "No run waits on a decision.",
700                }],
701            )
702        }
703        View::Run {
704            run,
705            attempts,
706            changelog,
707            diff,
708            moved,
709        } => detail(
710            None,
711            Some(run.project.clone()),
712            run_detail(&run, &attempts, changelog, diff, moved),
713        ),
714        View::Agents {
715            state,
716            jobs,
717            runs,
718            now,
719        } => {
720            let agents = watch::agents(&state, &runs, now)
721                .into_iter()
722                .map(|a| Item {
723                    cells: vec![
724                        muted(format!("#{}", a.run.id)),
725                        plain(a.run.project.clone()),
726                        plain(a.run.node.clone().unwrap_or_else(|| "dispatch".into())),
727                        muted(a.run.agent.clone()),
728                        cell(a.running, Tone::Accent),
729                        muted(a.quiet),
730                        plain(a.progress.tool_calls.to_string()),
731                        plain(
732                            a.progress
733                                .last_tool
734                                .or(a.progress.last_text)
735                                .unwrap_or_default(),
736                        ),
737                    ],
738                    pick: Pick::Run {
739                        id: a.run.id,
740                        clean: false,
741                        state: a.run.state.name().into(),
742                        project: a.run.project.clone(),
743                    },
744                })
745                .collect();
746            let open = watch::open_jobs(&jobs)
747                .into_iter()
748                .map(|j| Item {
749                    cells: vec![
750                        muted(format!("#{}", j.id)),
751                        cell(j.state.clone(), job_tone(&j.state)),
752                        plain(format!("pma {}", j.args.join(" "))),
753                    ],
754                    pick: Pick::Job {
755                        id: j.id,
756                        awaiting: j.state == "awaiting-approval",
757                    },
758                })
759                .collect();
760            panes(
761                Tab::Agents,
762                None,
763                vec![
764                    Pane {
765                        title: "Running agents".into(),
766                        columns: vec![
767                            col("run", Kind::Text),
768                            col("project", Kind::Text),
769                            col("node", Kind::Text),
770                            col("agent", Kind::Text),
771                            col("running", Kind::Text),
772                            col("quiet", Kind::Text),
773                            col("tools", Kind::Number),
774                            col("doing", Kind::Wide),
775                        ],
776                        items: agents,
777                        empty: "No agent is running.",
778                    },
779                    Pane {
780                        title: "Open jobs".into(),
781                        columns: vec![
782                            col("job", Kind::Text),
783                            col("state", Kind::Badge),
784                            col("command", Kind::Wide),
785                        ],
786                        items: open,
787                        empty: "No job is queued, running or waiting.",
788                    },
789                ],
790            )
791        }
792        View::Jobs(jobs) => {
793            let items = jobs
794                .iter()
795                .map(|j| Item {
796                    cells: vec![
797                        muted(format!("#{}", j.id)),
798                        cell(j.state.clone(), job_tone(&j.state)),
799                        muted(report::ago(now - j.created_at)),
800                        plain(format!("pma {}", j.args.join(" "))),
801                    ],
802                    pick: Pick::Job {
803                        id: j.id,
804                        awaiting: j.state == "awaiting-approval",
805                    },
806                })
807                .collect::<Vec<_>>();
808            panes(
809                Tab::Jobs,
810                None,
811                vec![Pane {
812                    title: "Jobs".into(),
813                    columns: vec![
814                        col("job", Kind::Text),
815                        col("state", Kind::Badge),
816                        col("queued", Kind::Text),
817                        col("command", Kind::Wide),
818                    ],
819                    items,
820                    empty: "No jobs. `--detach` on dispatch or workflow run queues one.",
821                }],
822            )
823        }
824        View::JobLog { id, text: log } => detail(
825            None,
826            None,
827            Detail {
828                title: format!("Job #{id}"),
829                badge: None,
830                notice: None,
831                fields: Vec::new(),
832                sections: vec![(
833                    "Output".into(),
834                    Section::Log,
835                    log.unwrap_or_else(|| "No output yet.".into()),
836                )],
837            },
838        ),
839        View::Notes(notes) => {
840            let items = notes
841                .into_iter()
842                .map(|n| Item {
843                    cells: vec![
844                        muted(format!("#{}", n.id)),
845                        muted(dates::format(dates::day(n.updated_at))),
846                        plain(n.text.clone()),
847                    ],
848                    pick: Pick::Note {
849                        id: n.id,
850                        text: n.text,
851                    },
852                })
853                .collect::<Vec<_>>();
854            panes(
855                Tab::Notes,
856                None,
857                vec![Pane {
858                    title: "Notes".into(),
859                    columns: vec![
860                        col("note", Kind::Text),
861                        col("updated", Kind::Text),
862                        col("text", Kind::Wide),
863                    ],
864                    items,
865                    empty: "No notes. Press n to add one.",
866                }],
867            )
868        }
869        View::Workflows {
870            revisions,
871            instances,
872        } => {
873            let open: Vec<Item> = instances
874                .iter()
875                .map(|i| {
876                    let outcome = i.outcome.clone().unwrap_or_else(|| "open".into());
877                    Item {
878                        cells: vec![
879                            muted(i.id.to_string()),
880                            plain(i.workflow.clone()),
881                            plain(i.target.clone()),
882                            cell(
883                                outcome.clone(),
884                                match outcome.as_str() {
885                                    "open" => Tone::Accent,
886                                    "finished" => Tone::Good,
887                                    "failed" => Tone::Bad,
888                                    _ => Tone::Muted,
889                                },
890                            ),
891                            muted(report::ago(now - i.started_at)),
892                        ],
893                        pick: Pick::Instance {
894                            id: i.id,
895                            workflow: i.workflow.clone(),
896                            open: i.finished_at.is_none(),
897                        },
898                    }
899                })
900                .collect();
901            let revs: Vec<Item> = revisions
902                .iter()
903                .map(|r| Item {
904                    cells: vec![
905                        muted(r.revision.to_string()),
906                        match r.activated_at {
907                            None => muted("draft"),
908                            Some(_) => cell("active", Tone::Good),
909                        },
910                        plain(
911                            r.document()
912                                .map(|d| {
913                                    d.workflows
914                                        .iter()
915                                        .map(|w| w.name.clone())
916                                        .collect::<Vec<_>>()
917                                        .join(", ")
918                                })
919                                .unwrap_or_default(),
920                        ),
921                        muted(match r.worst_case_cost {
922                            Some(c) => format!("${c:.2} per unit"),
923                            None => String::new(),
924                        }),
925                    ],
926                    pick: Pick::Nothing,
927                })
928                .collect();
929            panes(
930                Tab::Workflows,
931                None,
932                vec![
933                    Pane {
934                        title: "Instances".into(),
935                        columns: vec![
936                            col("instance", Kind::Text),
937                            col("workflow", Kind::Text),
938                            col("target", Kind::Wide),
939                            col("status", Kind::Badge),
940                            col("started", Kind::Text),
941                        ],
942                        items: open,
943                        empty: "No instance. Press f to start one.",
944                    },
945                    Pane {
946                        title: "Revisions".into(),
947                        columns: vec![
948                            col("revision", Kind::Text),
949                            col("status", Kind::Badge),
950                            col("workflows", Kind::Wide),
951                            col("worst case", Kind::Number),
952                        ],
953                        items: revs,
954                        empty: "No revision. `pma workflow propose <file>` stores one.",
955                    },
956                ],
957            )
958        }
959        View::Campaigns(all) => {
960            let items = all
961                .iter()
962                .map(|c| {
963                    let taken = c.members.iter().filter(|(_, r)| r.is_some()).count();
964                    Item {
965                        cells: vec![
966                            plain(c.name.clone()),
967                            muted(format!("class {}", c.class.name())),
968                            cell(
969                                format!("{taken}/{}", c.members.len()),
970                                match taken == c.members.len() {
971                                    true => Tone::Good,
972                                    false => Tone::Accent,
973                                },
974                            ),
975                            plain(c.text.clone()),
976                        ],
977                        pick: Pick::Campaign {
978                            name: c.name.clone(),
979                        },
980                    }
981                })
982                .collect::<Vec<_>>();
983            panes(
984                Tab::Campaigns,
985                None,
986                vec![Pane {
987                    title: "Campaigns".into(),
988                    columns: vec![
989                        col("campaign", Kind::Text),
990                        col("class", Kind::Text),
991                        col("dispatched", Kind::Number),
992                        col("task", Kind::Wide),
993                    ],
994                    items,
995                    empty: "No campaigns. `pma campaign add` defines one.",
996                }],
997            )
998        }
999        View::Project {
1000            name,
1001            path,
1002            scanned_at,
1003            tags,
1004            verify,
1005            status,
1006            placed,
1007            today,
1008            runs,
1009        } => {
1010            let mut summary: Vec<(String, Cell)> = Vec::new();
1011            match &status {
1012                None => summary.push((
1013                    "state".into(),
1014                    cell("not found by the last scan", Tone::Warn),
1015                )),
1016                Some((states, c)) => {
1017                    let toned = status_cells(c);
1018                    for (i, label) in report::STATUS_HEADINGS.iter().enumerate().skip(1) {
1019                        let mut v = toned[i].clone();
1020                        // Every state by name; each one's reason follows.
1021                        if i == 2 {
1022                            v.text = states
1023                                .iter()
1024                                .map(|(st, _)| st.name())
1025                                .collect::<Vec<_>>()
1026                                .join(", ");
1027                        }
1028                        if !v.text.is_empty() && v.text != "-" {
1029                            summary.push((label.to_string(), v));
1030                        }
1031                    }
1032                    for (st, why) in states.iter().filter(|(_, why)| !why.is_empty()) {
1033                        summary.push((
1034                            st.name().to_string(),
1035                            cell(why.clone(), state_tone(st.name())),
1036                        ));
1037                    }
1038                }
1039            }
1040            summary.push((
1041                "scanned".into(),
1042                match scanned_at {
1043                    None => cell("never", Tone::Warn),
1044                    Some(t) => cell(
1045                        report::ago(now - t),
1046                        // A day's drift is enough for CI or a branch to move.
1047                        match now - t > 86_400 {
1048                            true => Tone::Warn,
1049                            false => Tone::Muted,
1050                        },
1051                    ),
1052                },
1053            ));
1054            summary.push((
1055                "verify".into(),
1056                match verify {
1057                    Some(v) => plain(v),
1058                    None => muted("detected at dispatch"),
1059                },
1060            ));
1061            if !tags.is_empty() {
1062                summary.push(("tags".into(), plain(tags.join(", "))));
1063            }
1064            let tasks = placed
1065                .iter()
1066                .map(|p| {
1067                    let when = report::when(p, today);
1068                    let priority = p.task.priority.name();
1069                    Item {
1070                        cells: vec![
1071                            plain(match p.task.line {
1072                                Some(line) => format!("{}:{line}", p.task.project),
1073                                None => p.task.project.clone(),
1074                            }),
1075                            cell(priority, priority_tone(priority)),
1076                            cell(when.clone(), when_tone(&when)),
1077                            match p.task.eligible {
1078                                true => cell("agent", Tone::Accent),
1079                                false => muted("you"),
1080                            },
1081                            plain(p.task.text.clone()),
1082                        ],
1083                        pick: task_pick(p),
1084                    }
1085                })
1086                .collect();
1087            let runs = runs
1088                .iter()
1089                .map(|r| Item {
1090                    cells: vec![
1091                        muted(format!("#{}", r.id)),
1092                        cell(r.state.name(), run_tone(r.state.name())),
1093                        plain(r.text.clone()),
1094                        muted(r.cost_usd.map_or("-".into(), |c| format!("${c:.2}"))),
1095                        muted(r.dispatched_at.map_or("-".into(), |t| report::ago(now - t))),
1096                    ],
1097                    pick: Pick::Run {
1098                        id: r.id,
1099                        clean: r.state == RunState::Ready && accept::review_reasons(r).is_empty(),
1100                        state: r.state.name().into(),
1101                        project: r.project.clone(),
1102                    },
1103                })
1104                .collect();
1105            Screen {
1106                tab: Some(Tab::Project),
1107                header: Some(format!("{name}  {}", path.display())),
1108                summary,
1109                body: Body::Panes(vec![
1110                    Pane {
1111                        title: "Open tasks".into(),
1112                        columns: vec![
1113                            col("task", Kind::Text),
1114                            col("priority", Kind::Badge),
1115                            col("when", Kind::Text),
1116                            col("for", Kind::Badge),
1117                            col("title", Kind::Wide),
1118                        ],
1119                        items: tasks,
1120                        empty: "No open task in its TODO.md.",
1121                    },
1122                    Pane {
1123                        title: "Runs".into(),
1124                        columns: vec![
1125                            col("run", Kind::Text),
1126                            col("state", Kind::Badge),
1127                            col("task", Kind::Wide),
1128                            col("cost", Kind::Number),
1129                            col("started", Kind::Text),
1130                        ],
1131                        items: runs,
1132                        empty: "No agent has worked on it yet.",
1133                    },
1134                ]),
1135            }
1136        }
1137        View::Campaign { campaign, runs } => {
1138            let fields = campaign
1139                .members
1140                .iter()
1141                .map(|(project, run)| {
1142                    let state = match run.and_then(|id| runs.iter().find(|r| r.id == id)) {
1143                        None => "not dispatched".to_string(),
1144                        Some(r) => format!("#{} {}", r.id, r.state.name()),
1145                    };
1146                    (project.clone(), state)
1147                })
1148                .collect();
1149            detail(
1150                None,
1151                None,
1152                Detail {
1153                    title: campaign.name.clone(),
1154                    badge: None,
1155                    notice: None,
1156                    fields,
1157                    sections: vec![("Task".into(), Section::Prose, campaign.text.clone())],
1158                },
1159            )
1160        }
1161    }
1162}
1163
1164/// Text a prompt starts with: a note being edited starts as its text.
1165pub fn initial_input(act: Act, pick: &Pick) -> String {
1166    match (act, pick) {
1167        (Act::EditNote, Pick::Note { text, .. }) => text.clone(),
1168        _ => String::new(),
1169    }
1170}
1171
1172/// The question a confirmation asks.
1173pub fn question(act: Act, pick: &Pick) -> String {
1174    let what = match pick {
1175        Pick::Task { target, .. } => target.clone(),
1176        Pick::Manual { project } => project.clone(),
1177        Pick::Run { id, .. } | Pick::Job { id, .. } | Pick::Note { id, .. } => format!("#{id}"),
1178        Pick::Instance { id, .. } => format!("instance {id}"),
1179        Pick::Campaign { name } => name.clone(),
1180        Pick::Nothing => String::new(),
1181    };
1182    let verb = match act {
1183        Act::Dispatch => "dispatch",
1184        Act::RejectRun => "reject",
1185        Act::Pr => "open a pull request for",
1186        Act::Push => "push",
1187        Act::StopJob | Act::StopWorkflow => "stop",
1188        Act::RemoveNote => "remove note",
1189        Act::RunCampaign => "run campaign",
1190        Act::ApproveRuns => "approve every clean run",
1191        Act::Sync => "sync critical items to GitHub issues",
1192        _ => "do this to",
1193    };
1194    format!("{verb} {what}?").replace(" ?", "?")
1195}
1196
1197/// The action `act` takes on `pick`. `pane` is the list the pick is in, for
1198/// an act over many rows; `input` is what a prompt read.
1199pub fn action(act: Act, pick: &Pick, pane: &[Item], input: &str) -> Result<Action, String> {
1200    action_in(act, pick, pane, input, "")
1201}
1202
1203/// `action`, on a view of `project`: an act on the project itself, such as
1204/// rescanning it, needs no row.
1205pub fn action_in(
1206    act: Act,
1207    pick: &Pick,
1208    pane: &[Item],
1209    input: &str,
1210    project: &str,
1211) -> Result<Action, String> {
1212    let input = input.trim();
1213    let worker = WorkerChoice::default();
1214    let need = |what: &str| Err(format!("select {what} first"));
1215    Ok(match (act, pick) {
1216        (Act::Scan, _) => Action::Scan {
1217            of: Selection::default(),
1218            offline: false,
1219            deps: false,
1220        },
1221        (Act::ScanProject, _) => {
1222            if project.is_empty() {
1223                return Err("open a project first".into());
1224            }
1225            Action::Scan {
1226                of: Selection {
1227                    projects: vec![project.to_string()],
1228                    tags: Vec::new(),
1229                },
1230                offline: false,
1231                deps: false,
1232            }
1233        }
1234        (Act::Sync, _) => Action::Sync {
1235            of: Selection::default(),
1236            apply: true,
1237        },
1238        (Act::AddNote, _) => Action::AddNote { text: input.into() },
1239        (Act::SettlePrs, _) => Action::SettlePrs,
1240        (Act::StartWorkflow, _) => {
1241            let mut words = input.split_whitespace();
1242            let name = words
1243                .next()
1244                .ok_or("type a workflow name, then its target")?
1245                .to_string();
1246            let (mut projects, mut set) = (Vec::new(), Vec::new());
1247            let (mut local, mut unattended, mut model) = (false, false, None);
1248            for w in words {
1249                match w {
1250                    "--local" => local = true,
1251                    "--unattended" => unattended = true,
1252                    _ if w.starts_with("--model=") => model = Some(w[8..].to_string()),
1253                    _ if w.starts_with("--set=") => set.push(w[6..].to_string()),
1254                    _ if w.starts_with('-') => return Err(format!("unknown flag `{w}`")),
1255                    _ => projects.push(w.to_string()),
1256                }
1257            }
1258            Action::WorkflowRun {
1259                name,
1260                projects,
1261                tag: None,
1262                instance: None,
1263                set,
1264                approve: None,
1265                unattended,
1266                local,
1267                cap: Vec::new(),
1268                worker: WorkerChoice {
1269                    model,
1270                    ..WorkerChoice::default()
1271                },
1272            }
1273        }
1274        (Act::ApproveRuns, _) => {
1275            let ids: Vec<i64> = pane
1276                .iter()
1277                .filter_map(|i| match i.pick {
1278                    Pick::Run {
1279                        id, clean: true, ..
1280                    } => Some(id),
1281                    _ => None,
1282                })
1283                .collect();
1284            if ids.is_empty() {
1285                return Err("no clean run to approve".into());
1286            }
1287            Action::ApproveRuns { ids }
1288        }
1289        (Act::Dispatch, Pick::Task { target, retry, .. }) => Action::Dispatch {
1290            targets: vec![target.clone()],
1291            tags: Vec::new(),
1292            auto: false,
1293            count: None,
1294            retry: *retry,
1295            worker,
1296        },
1297        (Act::Dispatch, Pick::Manual { .. }) => return Err("not a task for agents".into()),
1298        (Act::Verify, Pick::Task { project, .. } | Pick::Manual { project }) => Action::Verify {
1299            of: Selection {
1300                projects: vec![project.clone()],
1301                tags: Vec::new(),
1302            },
1303        },
1304        (Act::Dispatch | Act::Verify, _) => return need("a task"),
1305        (Act::ApproveRun, Pick::Run { id, state, .. }) => Action::ApproveRun {
1306            id: *id,
1307            seen: Some(state.clone()),
1308        },
1309        (Act::RejectRun, Pick::Run { id, state, .. }) => Action::RejectRun {
1310            id: *id,
1311            keep_git_changes: false,
1312            infra: false,
1313            seen: Some(state.clone()),
1314        },
1315        (Act::ReworkRun, Pick::Run { id, .. }) => {
1316            if input.is_empty() {
1317                return Err("rework needs feedback".into());
1318            }
1319            Action::ReworkRun {
1320                id: *id,
1321                feedback: input.into(),
1322            }
1323        }
1324        (Act::AddReviewTime, Pick::Run { id, .. }) => Action::AddReviewTime {
1325            id: *id,
1326            minutes: input
1327                .parse()
1328                .map_err(|_| "type the minutes as a whole number".to_string())?,
1329        },
1330        (Act::Pr | Act::Push, Pick::Run { id, .. }) => Action::Publish {
1331            ids: vec![*id],
1332            all_approved: false,
1333            target: match act {
1334                Act::Pr => publish::Target::Pr,
1335                _ => publish::Target::Push,
1336            },
1337        },
1338        (
1339            Act::ApproveRun
1340            | Act::RejectRun
1341            | Act::ReworkRun
1342            | Act::AddReviewTime
1343            | Act::Pr
1344            | Act::Push,
1345            _,
1346        ) => return need("a run"),
1347        (Act::ApproveJob, Pick::Job { id, awaiting: true }) => Action::ApproveJob { id: *id },
1348        (Act::ApproveJob, Pick::Job { id, .. }) => {
1349            return Err(format!("job #{id} is not waiting for approval"));
1350        }
1351        (Act::StopJob, Pick::Job { id, .. }) => Action::StopJob { id: *id },
1352        (Act::ApproveJob | Act::StopJob, _) => return need("a job"),
1353        (Act::StopWorkflow, Pick::Instance { id, .. }) => Action::StopWorkflow { instance: *id },
1354        (
1355            Act::ResumeWorkflow,
1356            Pick::Instance {
1357                id,
1358                workflow,
1359                open: true,
1360            },
1361        ) => Action::WorkflowRun {
1362            name: workflow.clone(),
1363            projects: Vec::new(),
1364            tag: None,
1365            instance: Some(*id),
1366            set: Vec::new(),
1367            approve: None,
1368            unattended: false,
1369            local: false,
1370            cap: Vec::new(),
1371            worker,
1372        },
1373        (Act::ResumeWorkflow, Pick::Instance { id, .. }) => {
1374            return Err(format!("instance {id} is finished"));
1375        }
1376        (Act::StopWorkflow | Act::ResumeWorkflow, _) => return need("an instance"),
1377        (Act::RunCampaign, Pick::Campaign { name }) => Action::RunCampaign {
1378            name: name.clone(),
1379            count: None,
1380        },
1381        (Act::RunCampaign, _) => return need("a campaign"),
1382        (Act::EditNote, Pick::Note { id, .. }) => Action::EditNote {
1383            id: *id,
1384            text: input.into(),
1385        },
1386        (Act::RemoveNote, Pick::Note { id, .. }) => Action::RemoveNote { id: *id },
1387        (Act::EditNote | Act::RemoveNote, _) => return need("a note"),
1388    })
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393    use super::*;
1394
1395    fn run(id: i64, clean: bool) -> Item {
1396        Item {
1397            cells: vec![plain(format!("#{id}"))],
1398            pick: Pick::Run {
1399                id,
1400                clean,
1401                state: "ready".into(),
1402                project: "p".into(),
1403            },
1404        }
1405    }
1406
1407    #[test]
1408    fn keys_on_rows_build_actions() {
1409        let task = Pick::Task {
1410            project: "a".into(),
1411            target: "a:5".into(),
1412            retry: true,
1413        };
1414        assert_eq!(
1415            action(Act::Dispatch, &task, &[], ""),
1416            Ok(Action::Dispatch {
1417                targets: vec!["a:5".into()],
1418                tags: vec![],
1419                auto: false,
1420                count: None,
1421                retry: true,
1422                worker: WorkerChoice::default(),
1423            })
1424        );
1425        let pane = [run(1, true), run(2, false), run(3, true)];
1426        assert_eq!(
1427            action(Act::ApproveRuns, &Pick::Nothing, &pane, ""),
1428            Ok(Action::ApproveRuns { ids: vec![1, 3] })
1429        );
1430        assert!(
1431            action(Act::ApproveRun, &task, &[], "").is_err(),
1432            "not a run"
1433        );
1434        assert!(
1435            action(
1436                Act::ApproveJob,
1437                &Pick::Job {
1438                    id: 4,
1439                    awaiting: false
1440                },
1441                &[],
1442                ""
1443            )
1444            .unwrap_err()
1445            .contains("not waiting")
1446        );
1447        assert_eq!(
1448            action(
1449                Act::AddReviewTime,
1450                &Pick::Run {
1451                    id: 2,
1452                    clean: false,
1453                    state: "ready".into(),
1454                    project: "p".into()
1455                },
1456                &[],
1457                " 5 "
1458            ),
1459            Ok(Action::AddReviewTime { id: 2, minutes: 5 })
1460        );
1461        assert!(
1462            action(
1463                Act::ReworkRun,
1464                &Pick::Run {
1465                    id: 2,
1466                    clean: false,
1467                    state: "ready".into(),
1468                    project: "p".into()
1469                },
1470                &[],
1471                ""
1472            )
1473            .is_err()
1474        );
1475    }
1476
1477    /// A row offers the acts that can apply to it: a task is dispatched,
1478    /// not approved; a run is approved, not dispatched.
1479    #[test]
1480    fn a_row_offers_only_the_acts_that_fit_it() {
1481        let task = Pick::Task {
1482            project: "a".into(),
1483            target: "a:5".into(),
1484            retry: false,
1485        };
1486        let run = Pick::Run {
1487            id: 1,
1488            clean: true,
1489            state: "ready".into(),
1490            project: "a".into(),
1491        };
1492        assert!(fits(Act::Dispatch, &task) && !fits(Act::ApproveRun, &task));
1493        assert!(fits(Act::ApproveRun, &run) && !fits(Act::Dispatch, &run));
1494        assert!(
1495            fits(Act::Scan, &Pick::Nothing),
1496            "a whole-view act fits anything"
1497        );
1498        assert!(!fits(
1499            Act::ApproveJob,
1500            &Pick::Job {
1501                id: 1,
1502                awaiting: false
1503            }
1504        ));
1505        for act in Act::ALL.into_iter().filter(|a| a.on_row()) {
1506            assert!(!fits(act, &Pick::Nothing), "{act:?} needs a row");
1507        }
1508        assert_eq!(run.project(), Some("a"));
1509    }
1510
1511    /// Rescanning a project scans that project alone, and needs one open.
1512    #[test]
1513    fn a_project_rescan_scans_only_it() {
1514        assert_eq!(
1515            action_in(Act::ScanProject, &Pick::Nothing, &[], "", "margo"),
1516            Ok(Action::Scan {
1517                of: Selection {
1518                    projects: vec!["margo".into()],
1519                    tags: vec![],
1520                },
1521                offline: false,
1522                deps: false,
1523            })
1524        );
1525        assert!(action(Act::ScanProject, &Pick::Nothing, &[], "").is_err());
1526    }
1527
1528    #[test]
1529    fn a_typed_workflow_start_reads_its_flags() {
1530        let Ok(Action::WorkflowRun {
1531            name,
1532            projects,
1533            local,
1534            unattended,
1535            worker,
1536            set,
1537            ..
1538        }) = action(
1539            Act::StartWorkflow,
1540            &Pick::Nothing,
1541            &[],
1542            "review-fix argdec --local --unattended --model=haiku --set=breadth=5",
1543        )
1544        else {
1545            panic!("a workflow run");
1546        };
1547        assert_eq!(
1548            (name.as_str(), projects),
1549            ("review-fix", vec!["argdec".to_string()])
1550        );
1551        assert!(local && unattended);
1552        assert_eq!(worker.model.as_deref(), Some("haiku"));
1553        assert_eq!(set, ["breadth=5"]);
1554        assert!(action(Act::StartWorkflow, &Pick::Nothing, &[], "w a --nope").is_err());
1555    }
1556
1557    #[test]
1558    fn prompts_and_questions_name_their_subject() {
1559        let note = Pick::Note {
1560            id: 3,
1561            text: "buy milk".into(),
1562        };
1563        assert_eq!(initial_input(Act::EditNote, &note), "buy milk");
1564        assert_eq!(question(Act::RemoveNote, &note), "remove note #3?");
1565        assert_eq!(
1566            question(Act::ApproveRuns, &Pick::Nothing),
1567            "approve every clean run?"
1568        );
1569    }
1570}