1use 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#[derive(Debug, Clone, PartialEq)]
15pub enum Pick {
16 Nothing,
17 Task {
19 project: String,
20 target: String,
21 retry: bool,
22 },
23 Manual {
25 project: String,
26 },
27 Run {
28 id: i64,
29 clean: bool,
31 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Tone {
57 Plain,
58 Muted,
60 Accent,
62 Good,
64 Warn,
66 Bad,
68 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum Kind {
89 Text,
91 Wide,
93 Number,
95 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
111pub 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 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 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 pub empty: &'static str,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Section {
169 Prose,
171 Diff,
173 Log,
175}
176
177#[derive(Debug, Clone, PartialEq)]
178pub struct Detail {
179 pub title: String,
180 pub badge: Option<Cell>,
182 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 pub tab: Option<Tab>,
198 pub header: Option<String>,
200 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
224pub 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
254fn 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
265fn 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
316fn 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
422fn 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
468pub 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 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 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
1164pub 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
1172pub 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
1197pub fn action(act: Act, pick: &Pick, pane: &[Item], input: &str) -> Result<Action, String> {
1200 action_in(act, pick, pane, input, "")
1201}
1202
1203pub 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 #[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 #[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, ¬e), "buy milk");
1564 assert_eq!(question(Act::RemoveNote, ¬e), "remove note #3?");
1565 assert_eq!(
1566 question(Act::ApproveRuns, &Pick::Nothing),
1567 "approve every clean run?"
1568 );
1569 }
1570}