mod bands;
mod bead;
mod foot;
mod groups;
mod project;
mod tail;
pub(crate) mod tone;
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::Frame;
use crate::app::Awaited;
use crate::model::types::PaneStatus;
use crate::view::fitted::{columns, Fitted, GAP};
use crate::view::forest::Forest;
use crate::view::lines::{self, Content, Note, ProjectLine};
use crate::view::palette;
use crate::view::phrase;
use crate::view::row::{AGENT, WARNING};
use crate::view::{Freshness, Notice, Said};
pub use bands::{line_at, regions};
pub use tail::{draw_tail, Band};
use bead::{bead_line, elided_run};
use foot::{notices, status_bar};
use groups::{group_line, item_line, scoped_line};
use project::{project_line, unread_line};
pub(super) struct Reads<'a> {
read_at: &'a BTreeMap<String, DateTime<Utc>>,
collecting: &'a [Awaited],
now: DateTime<Utc>,
}
impl<'a> Reads<'a> {
pub(super) fn new(
read_at: &'a BTreeMap<String, DateTime<Utc>>,
collecting: &'a [Awaited],
now: DateTime<Utc>,
) -> Self {
Self {
read_at,
collecting,
now,
}
}
fn of(&self, project: &ProjectLine) -> Option<Freshness> {
Freshness::of(
self.read_at.get(&project.project).copied(),
self.collecting
.iter()
.filter(|awaited| awaited.wanted.names(&project.project))
.min_by_key(|awaited| awaited.asked_at),
project.every_root_read,
self.now,
)
}
}
pub struct Foot<'a> {
pub standing: &'a [Notice],
pub said: Option<&'a Said>,
pub prompt: Option<&'a str>,
pub keys: &'a str,
}
pub fn draw(
frame: &mut Frame,
area: Rect,
forest: &Forest,
collecting: &[Awaited],
now: DateTime<Utc>,
foot: Foot,
) {
let bands = regions(area);
let lines = forest.lines();
let selected = forest.selected_line();
let height = bands.forest.height as usize;
let ids = id_width(lines);
let reads = Reads::new(&forest.snapshot().read_at, collecting, now);
for (row, (at, line)) in lines
.iter()
.enumerate()
.skip(forest.from())
.take(height)
.enumerate()
{
let drawn = fitted(line, ids, &reads);
let drawn = if at == selected {
drawn.selected()
} else {
drawn
};
frame.render_widget(
drawn,
Rect {
y: bands.forest.y + row as u16,
height: 1,
..bands.forest
},
);
}
frame.render_widget(
status_bar(
¬ices(forest.snapshot(), foot.standing),
foot.said,
foot.prompt,
foot.keys,
bands.keys.width as usize,
),
bands.keys,
);
}
fn id_width(lines: &[lines::Line]) -> usize {
lines
.iter()
.filter_map(|line| match &line.content {
Content::Bead(row) => Some(columns(&[Span::raw(row.id.clone())])),
_ => None,
})
.max()
.unwrap_or(0)
}
pub(super) fn fitted(line: &lines::Line, id_width: usize, reads: &Reads) -> Fitted {
match &line.content {
Content::Project(project) => {
project_line(project, &line.prefix, reads.of(project), reads.now)
}
Content::Unread(unread) => unread_line(unread, &line.prefix, id_width),
Content::Bead(row) => bead_line(row, &line.prefix, id_width),
Content::Elided { count, .. } => elided_run(&line.prefix, *count),
Content::Note(note) => {
let (said, style) = finding(*note);
sentence(&line.prefix, said, style)
}
Content::Group(group) => group_line(&line.prefix, group),
Content::Item(item) => item_line(&line.prefix, item),
Content::Scoped { project } => scoped_line(&line.prefix, project),
}
}
pub(super) fn sentence(prefix: &str, said: String, style: Style) -> Fitted {
Fitted::new(
vec![Span::raw(prefix.to_string()), Span::styled(said, style)],
Vec::new(),
Vec::new(),
)
}
fn finding(note: Note) -> (String, Style) {
let said = match note {
Note::Dangling(count) => phrase::dangling(count),
Note::Cycle(count) => phrase::cycle(count),
Note::NoRoots => return (phrase::no_roots().to_string(), palette::PLAIN),
};
(format!("{WARNING} {said}"), palette::ATTENTION)
}
pub(super) fn done(closed: usize, total: usize) -> String {
format!("{closed}/{total}")
}
pub(super) fn beside(state: &mut Vec<Span<'static>>, cell: Span<'static>) {
if !state.is_empty() {
state.push(Span::raw(" ".repeat(GAP)));
}
state.push(cell);
}
pub(super) fn pane_marker(pane: &str, status: &PaneStatus) -> String {
format!("{AGENT} {pane} {}", phrase::pane_state(status))
}
pub(super) fn structure(prefix: &str) -> Span<'static> {
Span::styled(prefix.to_string(), palette::STRUCTURE)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::types::testing::key;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use ratatui::style::Modifier;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::app::Wanted;
use crate::config::Scope;
use crate::model::join::{AgentRef, BeadKey, JoinSource};
use crate::model::snapshot::{
a_provider, Counts, Filter, HiddenTree, LoosePane, Node, ProviderState, Snapshot,
TrackerFailure, TrackerState, Tree,
};
use crate::model::tree::Link;
use crate::model::types::{Edge, Status};
use crate::view::forest::flatten;
use crate::view::row::{self, Row};
use crate::view::{Action, Motion};
use chrono::{DateTime, TimeZone, Utc};
pub(super) use crate::view::painted::Painted;
pub(super) use crate::view::tests::{does_not_say, says};
pub(super) const OPEN: &str = "▾ ";
pub(super) const SHUT: &str = "▸ ";
pub(super) const BRANCH: &str = " ├── ";
pub(super) const LAST: &str = " └── ";
pub(super) fn elided(count: usize) -> Content {
Content::Elided {
count,
under: lines::Place::root(BeadKey {
project: "orbital".into(),
id: "orb-7".into(),
}),
}
}
pub(super) fn under(prefix: &str, content: Content) -> lines::Line {
lines::Line {
prefix: prefix.into(),
depth: 1,
folded: None,
place: None,
content,
}
}
pub(super) fn counts(
closed: usize,
total: usize,
live_agents: usize,
anomalies: usize,
) -> Counts {
Counts {
total,
closed,
live_agents,
anomalies,
}
}
pub(super) fn tree(project: &str, root: &str, title: &str, counts: Counts) -> Tree {
Tree {
project: project.into(),
root: root.into(),
title: title.into(),
counts,
tracker: TrackerState::Ok,
beads: Vec::new(),
children: Vec::new(),
dangling: Vec::new(),
cycles: Vec::new(),
}
}
pub(super) fn node(id: &str, title: &str, status: Status) -> Node {
Node {
id: id.into(),
title: title.into(),
status,
issue_type: "task".into(),
priority: 2,
ready: false,
blocked_by: Vec::new(),
started_at: None,
closed_at: None,
badges: Vec::new(),
undrawn: Vec::new(),
agent: None,
anomalies: Vec::new(),
description: String::new(),
notes: String::new(),
owner: None,
parent: None,
depends_on: Vec::new(),
blocks: Vec::new(),
}
}
pub(super) fn pane(pane: &str, status: PaneStatus) -> LoosePane {
LoosePane {
pane: key(pane),
project: "summit-works".into(),
cwd: "/tmp/bdi-ground/summit-works".into(),
pane_status: status,
display_agent: None,
title: None,
claim_refused: false,
}
}
pub(super) fn row(node: &Node) -> Row {
row::cells(node, "smt-4kd3p", None, None)
}
pub(super) fn project(name: &str, counts: Counts) -> ProjectLine {
ProjectLine {
project: name.into(),
counts,
every_root_read: true,
}
}
pub(super) fn a_pane() -> AgentRef {
AgentRef {
pane: key("wCM:p9"),
pane_status: PaneStatus::Working,
title: None,
source: JoinSource::AgentPane,
}
}
pub(super) const A_KEY_ROW: &str = "Enter focus a all ? keys ^R refresh q quit";
#[test]
fn a_note_leaves_its_box_drawing_in_the_terminals_own_colour() {
let painted = Painted::of(
fitted(
&under(LAST, Content::Note(Note::Dangling(2))),
0,
&at_rest(),
),
96,
1,
)
.row(0);
assert_eq!(painted[0].said, LAST);
assert_eq!(painted[0].style.fg, Some(Color::Reset));
assert_eq!(painted[1].style.fg, palette::ATTENTION.fg);
}
#[test]
fn the_line_for_an_empty_forest_is_drawn_in_the_terminals_own_colour() {
let painted = Painted::of(
fitted(&under("", Content::Note(Note::NoRoots)), 0, &at_rest()),
96,
1,
)
.row(0);
assert_eq!(painted.len(), 1, "{painted:?}");
assert_eq!(painted[0].style.fg, Some(Color::Reset));
assert!(!painted[0].said.contains(WARNING), "{painted:?}");
}
pub(super) fn snapshot(
trees: Vec<Tree>,
unattributed: Vec<LoosePane>,
agents: ProviderState,
) -> Snapshot {
let mut projects: Vec<String> = Vec::new();
for tree in &trees {
if projects.last() != Some(&tree.project) {
projects.push(tree.project.clone());
}
}
let trees: Vec<Arc<Tree>> = trees.into_iter().map(Arc::new).collect();
Snapshot {
generated_at: Utc.with_ymd_and_hms(2026, 8, 30, 10, 22, 14).unwrap(),
agents: a_provider(agents),
filter: Filter::All,
collected: trees.clone(),
trees,
projects,
scope: Scope::default(),
hidden_trees: Vec::new(),
failed_projects: Vec::new(),
unattributed,
unconfigured: Vec::new(),
conflicts: Vec::new(),
projects_named_without_git: Vec::new(),
read_at: BTreeMap::from([("summit-works".to_string(), read_at())]),
}
}
pub(super) fn read_at() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 30, 10, 21, 44).unwrap()
}
pub(super) fn drawn_at() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 30, 10, 22, 14).unwrap()
}
static NOTHING_READ: BTreeMap<String, DateTime<Utc>> = BTreeMap::new();
pub(super) fn at_rest() -> Reads<'static> {
Reads::new(&NOTHING_READ, &[], drawn_at())
}
pub(super) fn grove(children: usize) -> Tree {
let mut beads = vec![node(
"smt-4kd3p",
"lift the ground station",
Status::InProgress,
)];
for child in 1..=children {
beads.push(node(
&format!("smt-4kd3p.{child}"),
&format!("bead number {child}"),
Status::Open,
));
}
Tree {
counts: counts(0, beads.len(), 0, 0),
children: under_the_root(&beads),
beads,
..tree(
"summit-works",
"smt-4kd3p",
"lift the ground station",
counts(0, 0, 0, 0),
)
}
}
pub(super) fn under_the_root(beads: &[Node]) -> Vec<Vec<Link>> {
let mut children = vec![(1..beads.len())
.map(|bead| Link {
bead,
edge: Edge::ParentChild,
first: true,
})
.collect()];
children.resize(beads.len(), Vec::new());
children
}
pub(super) fn opened(snapshot: &Snapshot) -> Forest {
let mut forest = flatten(snapshot.clone());
forest.apply(Action::ToggleFold);
forest
}
pub(super) fn frame_of(forest: &Forest, width: u16, height: u16) -> Painted {
frame_collecting(forest, &[], width, height)
}
pub(super) fn reading(wanted: Wanted) -> Awaited {
Awaited {
wanted,
asked_at: drawn_at(),
patience: PATIENCE,
}
}
pub(super) const PATIENCE: chrono::TimeDelta = chrono::TimeDelta::seconds(30);
pub(super) fn frame_collecting(
forest: &Forest,
collecting: &[Awaited],
width: u16,
height: u16,
) -> Painted {
frame_with(forest, &[], collecting, width, height)
}
fn frame_with(
forest: &Forest,
standing: &[Notice],
collecting: &[Awaited],
width: u16,
height: u16,
) -> Painted {
Painted::drawn_by(width, height, |frame| {
draw(
frame,
frame.area(),
forest,
collecting,
drawn_at(),
Foot {
standing,
said: None,
prompt: None,
keys: A_KEY_ROW,
},
);
})
}
#[test]
fn a_frame_is_the_forest_the_tails_reserved_band_and_the_foot() {
let forest = opened(&snapshot(
vec![grove(2)],
Vec::new(),
ProviderState::NotAnswering,
));
assert_eq!(
frame_of(&forest, 60, 10).rows(),
vec![
"▾ summit-works ✓ 30s ago 0/3",
" └── ◐ smt-4kd3p lift the ground station 0/3",
" ├── ○ .1 bead number 1 ",
" └── ○ .2 bead number 2 ",
" ",
" ",
" ",
" ",
" ",
"⚠ no herdr session · which agents are alive is unknown ",
]
);
}
#[test]
fn a_projects_fraction_counts_the_tree_the_filter_holds_back() {
let beads = vec![node("smt-7bv1n", "raise the mast", Status::Open)];
let mut held_back = tree(
"summit-works",
"smt-7bv1n",
"raise the mast",
Counts::over(&beads),
);
held_back.children = under_the_root(&beads);
held_back.beads = beads;
let shown = grove(2);
let mut snapshot = snapshot(
vec![shown.clone(), held_back.clone()],
Vec::new(),
ProviderState::Answering,
);
snapshot.filter = Filter::LiveAgents;
snapshot.trees = vec![Arc::new(shown)];
snapshot.hidden_trees = vec![HiddenTree::of(&held_back)];
let frame = frame_of(&flatten(snapshot), 60, 8).rows();
assert!(
frame
.iter()
.any(|row| row.contains("1 tree with no live agent")),
"{frame:#?}"
);
assert!(
frame[0].contains("0/4"),
"three beads on screen and one held back is four: {frame:#?}"
);
}
#[test]
fn a_socket_that_would_not_open_is_said_at_the_foot_of_the_frame() {
let forest = opened(&snapshot(
vec![grove(2)],
Vec::new(),
ProviderState::Answering,
));
assert_eq!(
frame_with(&forest, &[Notice::NoInboundChannel], &[], 80, 10).rows(),
vec![
"▾ summit-works ✓ 30s ago 0/3",
" └── ◐ smt-4kd3p lift the ground station 0/3",
" ├── ○ .1 bead number 1 ",
" └── ○ .2 bead number 2 ",
" ",
" ",
" ",
" ",
" ",
"⚠ nothing can tell bdi a project changed · every project is polled instead ",
]
);
}
#[test]
fn a_narrow_frame_cuts_every_row_and_wraps_none() {
let forest = opened(&snapshot(
vec![grove(2)],
Vec::new(),
ProviderState::Answering,
));
let frame = frame_of(&forest, 24, 10).rows();
assert_eq!(
frame[..4].to_vec(),
vec![
"▾ summit-works 0/3",
" └── ◐ smt-4kd3p 0/3",
" ├── ○ .1 …",
" └── ○ .2 …",
]
);
assert!(
frame[4..9].iter().all(|row| row.trim().is_empty()),
"{frame:?}"
);
}
#[test]
fn the_selected_row_is_drawn_wherever_the_selection_has_moved_to() {
let mut forest = flatten(snapshot(
vec![grove(40)],
Vec::new(),
ProviderState::Answering,
));
for motion in [Motion::LastRow, Motion::FirstRow, Motion::HalfScreenDown] {
forest.apply(Action::Move(motion));
let at = forest.selected_line();
let said = match &forest.lines()[at].content {
Content::Bead(row) => row.title.clone(),
Content::Project(line) => line.project.clone(),
other => panic!("unexpected line under the selection: {other:?}"),
};
let frame = frame_of(&forest, 60, 10).rows();
assert!(
frame.iter().any(|row| row.contains(&said)),
"{motion:?} put line {at} ({said}) off screen: {frame:?}"
);
}
}
#[test]
fn the_row_under_the_cursor_is_the_only_one_drawn_reversed() {
let mut forest = opened(&snapshot(
vec![grove(2)],
Vec::new(),
ProviderState::Answering,
));
forest.apply(Action::Move(Motion::FirstRow));
forest.apply(Action::Move(Motion::NextRow));
let selected = forest.selected_line();
let lines = forest.lines().len();
let frame = frame_of(&forest, 60, 10);
for at in 0..lines {
let reversed = frame
.row(at)
.iter()
.all(|run| run.style.add_modifier.contains(Modifier::REVERSED));
assert_eq!(
reversed,
at == selected,
"row {at} of {lines}, cursor on {selected}: {:?}",
frame.row(at)
);
}
}
#[test]
fn a_root_that_would_not_read_draws_its_reason_and_its_projects_panes() {
let failed =
Tree::tracker_unreachable("summit-works", "smt-4kd3p", TrackerFailure::Unavailable);
let forest = flatten(snapshot(
vec![failed],
vec![pane("wCM:p9", PaneStatus::Working)],
ProviderState::Answering,
));
let frame = frame_of(&forest, 77, 8).rows();
assert_eq!(
frame[..4],
[
"▾ summit-works ⚠ 30s ago ",
" ├── ⚠ smt-4kd3p the tracker did not answer ",
" └── ⚠ 1 unattributed pane ",
" └── ◍ wCM:p9 working /tmp/bdi-ground/summit-works ",
]
);
}
#[test]
fn a_root_with_nothing_under_it_draws_no_marker_and_still_lines_up() {
let unreadable =
Tree::tracker_unreachable("summit-works", "smt-4kd3p", TrackerFailure::Unavailable);
let forest = flatten(snapshot(
vec![grove(2), unreadable],
Vec::new(),
ProviderState::Answering,
));
let frame = frame_of(&forest, 90, 5).rows();
let unread = frame
.iter()
.position(|row| row.contains(WARNING) && row.contains("smt-4kd3p"))
.expect("the root that would not read");
let column = |row: &str| {
let byte = row.find("smt-4kd3p").expect("the root on the row");
row[..byte].chars().count()
};
assert!(
!frame[unread].contains(SHUT.trim()),
"nothing opens this root, so nothing should say it is shut: {:?}",
frame[unread]
);
assert_eq!(
column(&frame[unread]),
column(&frame[1]),
"the mark stands where a status glyph does:\n{}\n{}",
frame[1],
frame[unread]
);
}
}