use std::collections::HashMap;
use blitz_control_protocol::SemanticNode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Surface {
pub name: &'static str,
pub opener: &'static str,
}
pub const SURFACES: &[Surface] = &[
Surface {
name: "project",
opener: PROJECT_TAB,
},
Surface {
name: "settings",
opener: "Settings",
},
Surface {
name: "analytics",
opener: "Analytics",
},
Surface {
name: "home",
opener: "Home",
},
];
pub const PROJECT_TAB: &str = "\u{0}project-tab";
pub fn project_opener(nodes: &[SemanticNode]) -> Option<String> {
let closes: Vec<String> = nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter_map(|n| n.name.strip_prefix("Close ").map(str::to_owned))
.collect();
let tab = nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter(|n| !PERMANENT.contains(&n.name.as_str()))
.find(|n| {
doubled(&n.name).is_some_and(|label| !PERMANENT.contains(&label))
|| closes
.iter()
.any(|subject| n.name == format!("{subject}{subject}"))
});
if let Some(tab) = tab {
return Some(tab.name.clone());
}
let rows = || {
nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter(|n| !n.name.starts_with("Close ") && !n.name.starts_with("Rename "))
};
let populated = rows().find(|n| {
n.name
.split(" open · ")
.next()
.and_then(|head| head.rsplit(')').next())
.and_then(|count| count.trim().rsplit(' ').next())
.and_then(|count| count.parse::<u32>().ok())
.is_some_and(|open| open > 0)
});
if let Some(row) = populated {
return Some(row.name.clone());
}
rows()
.find(|n| {
n.name.contains(" open · ")
|| n.name.contains("no working directory")
|| n.name.contains(" ago")
})
.map(|n| n.name.clone())
}
const PERMANENT: &[&str] = &["Home", "Settings", "Analytics", "HomeHome"];
pub fn onscreen(node: &SemanticNode) -> bool {
node.visible && node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0)
}
pub fn navigates(name: &str) -> bool {
const NAV: &[&str] = &["Home", "Settings", "Analytics"];
if NAV.iter().any(|entry| {
name == *entry || name == format!("{entry}{entry}")
}) {
return true;
}
doubled(name).is_some()
}
fn doubled(name: &str) -> Option<&str> {
if name.is_empty() || !name.len().is_multiple_of(2) {
return None;
}
let (left, right) = name.split_at(name.len() / 2);
(left == right && !left.trim().is_empty()).then_some(left)
}
pub fn on_surface(nodes: &[SemanticNode], surface: &Surface) -> bool {
let Some(marker) = surface_marker(surface) else {
return true;
};
nodes
.iter()
.any(|n| onscreen(n) && n.name.contains(marker))
}
pub fn on_surface_subtree(nodes: &[SemanticNode], surface: &Surface) -> Vec<u64> {
let Some(marker) = surface_marker(surface) else {
return nodes.iter().map(|n| n.id).collect();
};
let by_id: HashMap<u64, &SemanticNode> = nodes.iter().map(|n| (n.id, n)).collect();
let Some(anchor) = nodes
.iter()
.find(|n| onscreen(n) && n.name.contains(marker))
else {
return Vec::new();
};
let onscreen_total = nodes.iter().filter(|n| n.role == "button" && onscreen(n)).count();
let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
for node in nodes {
if let Some(parent) = node.parent {
children.entry(parent).or_default().push(node.id);
}
}
let subtree_of = |root: u64| -> Vec<u64> {
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
out.push(id);
if let Some(kids) = children.get(&id) {
stack.extend(kids.iter().copied());
}
}
out
};
let mut cursor = anchor.id;
let mut best: Vec<u64> = Vec::new();
let mut best_covered = 0usize;
for _ in 0..12 {
let Some(parent) = by_id.get(&cursor).and_then(|n| n.parent) else {
break;
};
cursor = parent;
let kept = subtree_of(cursor);
let covered = kept
.iter()
.filter(|id| {
by_id
.get(id)
.is_some_and(|n| n.role == "button" && onscreen(n))
})
.count();
if onscreen_total > 0 && covered >= onscreen_total {
break;
}
if covered > best_covered {
best_covered = covered;
best = kept;
}
}
best
}
fn surface_marker(surface: &Surface) -> Option<&'static str> {
match surface.name {
"home" => Some("Cycle Home sort"),
"settings" => Some("Appearance"),
"analytics" => Some("Outcome per dollar"),
"project" => Some("Send"),
_ => None,
}
}
pub fn opens_native_dialog(name: &str) -> bool {
NATIVE_CHOOSERS
.iter()
.any(|exception| name.starts_with(exception.label))
}
pub struct Exception {
pub label: &'static str,
pub command: &'static str,
}
pub const NATIVE_CHOOSERS: &[Exception] = &[
Exception { label: "Attach files", command: "choose_attachments" },
Exception { label: "Add dir", command: "choose_project_directory" },
Exception { label: "Choose a working directory", command: "choose_project_directory" },
Exception { label: "Choose the agencyzero data directory", command: "(startup data dir)" },
Exception { label: "Select backup file", command: "select_store_backup" },
Exception { label: "Back up & close", command: "create_store_backup" },
Exception { label: "Export", command: "export_study_events" },
Exception { label: "Restore", command: "select_store_backup" },
Exception { label: "Choose…", command: "choose_agent_proxy_binary" },
];
pub fn modal_open(nodes: &[SemanticNode]) -> bool {
nodes.iter().any(|node| {
onscreen(node)
&& node.role == "button"
&& (node.name == "Cancel" || node.name == "Dismiss")
})
}
pub fn dismissers(nodes: &[SemanticNode]) -> Vec<(u64, String)> {
let mut found: Vec<(u64, String)> = nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter(|n| matches!(n.name.as_str(), "Cancel" | "Dismiss" | "Close"))
.map(|n| (n.id, n.name.clone()))
.collect();
found.sort_by_key(|(_, name)| match name.as_str() {
"Cancel" => 0,
"Dismiss" => 1,
_ => 2,
});
found
}
pub fn restarts_the_app(name: &str) -> bool {
const DISRUPTIVE: &[&str] = &["Welcome Tutorial", "Restart", "Reset all", "Sign out"];
DISRUPTIVE.iter().any(|entry| name.starts_with(entry))
}
pub fn closes_a_surface(name: &str) -> bool {
name.starts_with("Close ")
}
pub fn enclosing_dialog(nodes: &[SemanticNode], dismiss_id: u64) -> Vec<u64> {
let by_id: HashMap<u64, &SemanticNode> = nodes.iter().map(|n| (n.id, n)).collect();
let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
for node in nodes {
if let Some(parent) = node.parent {
children.entry(parent).or_default().push(node.id);
}
}
let subtree_of = |root: u64| -> Vec<u64> {
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
out.push(id);
if let Some(kids) = children.get(&id) {
stack.extend(kids.iter().copied());
}
}
out
};
let mut cursor = dismiss_id;
let mut best = vec![dismiss_id];
for _ in 0..8 {
let Some(parent) = by_id.get(&cursor).and_then(|n| n.parent) else {
break;
};
cursor = parent;
let kept = subtree_of(cursor);
let buttons = kept
.iter()
.filter(|id| by_id.get(id).is_some_and(|n| n.role == "button"))
.count();
if buttons > 12 {
break;
}
best = kept;
}
best
}
pub fn may_open_native_chooser(name: &str) -> bool {
const NATIVE: &[&str] = &[
"Attach files",
"Add dir",
"Select backup file",
"Choose",
"Browse",
"Open folder",
"Import",
"Export",
];
NATIVE.iter().any(|entry| name.starts_with(entry))
}
pub fn folds_a_section(name: &str) -> bool {
const SECTIONS: &[&str] = &[
"Items",
"Running",
"Task log",
"Agent I/O",
"Kept across compaction",
"Recent",
];
SECTIONS.iter().any(|section| {
name.strip_prefix(section)
.is_some_and(|rest| rest.is_empty() || rest.chars().all(|c| c.is_ascii_digit()))
})
}
pub fn expanders(nodes: &[SemanticNode]) -> Vec<(u64, String)> {
nodes
.iter()
.filter(|node| node.role == "button" && onscreen(node))
.filter(|node| node.name.to_lowercase().starts_with("expand "))
.map(|node| (node.id, node.name.clone()))
.collect()
}
pub fn hover_points(
nodes: &[SemanticNode],
row_role: &str,
window: (f64, f64),
) -> Vec<(u64, String)> {
nodes
.iter()
.filter(|node| node.role == row_role && onscreen(node))
.filter_map(|node| {
let b = node.bounds?;
let (x, y) = (b[0] + b[2] / 2.0, b[1] + b[3] / 2.0);
(y >= window.0 && y <= window.1 && x >= 0.0).then(|| (node.id, format!("{x},{y}")))
})
.collect()
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Coverage {
pub in_tree: usize,
pub swept: usize,
pub unreachable: usize,
pub hidden: usize,
pub vanished: usize,
pub navigation: usize,
pub native: usize,
pub blocked: usize,
}
impl Coverage {
pub fn accounted(&self) -> bool {
self.bucketed() == self.in_tree
}
fn bucketed(&self) -> usize {
self.swept
+ self.unreachable
+ self.hidden
+ self.vanished
+ self.navigation
+ self.native
+ self.blocked
}
pub fn line(&self) -> String {
format!(
"{} buttons: {} swept, {} unreachable, {} hidden, {} vanished, {} nav, {} native, {} blocked{}",
self.in_tree,
self.swept,
self.unreachable,
self.hidden,
self.vanished,
self.navigation,
self.native,
self.blocked,
if self.accounted() {
String::new()
} else {
format!(
" (UNACCOUNTED {})",
self.in_tree as i64 - self.bucketed() as i64
)
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(id: u64, role: &str, name: &str, bounds: Option<[f64; 4]>) -> SemanticNode {
SemanticNode {
id,
parent: None,
role: role.to_owned(),
name: name.to_owned(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds,
}
}
#[test]
fn a_zero_box_control_is_not_onscreen() {
assert!(!onscreen(&node(1, "button", "Pin project", Some([0.0; 4]))));
assert!(onscreen(&node(
2,
"button",
"Pin project",
Some([10.0, 10.0, 20.0, 20.0])
)));
}
#[test]
fn only_onscreen_expanders_are_offered() {
let nodes = vec![
node(1, "button", "Expand Items", Some([0.0, 0.0, 20.0, 20.0])),
node(2, "button", "Expand Hidden", Some([0.0; 4])),
node(3, "button", "Collapse Running", Some([0.0, 0.0, 20.0, 20.0])),
];
let found = expanders(&nodes);
assert_eq!(found.len(), 1);
assert_eq!(found[0].1, "Expand Items");
}
#[test]
fn navigation_is_recognised_without_swallowing_its_neighbours() {
assert!(navigates("Home"));
assert!(navigates("HomeHome"));
assert!(navigates("Settings"));
assert!(navigates("ee"));
assert!(navigates("delta/east/cobaltdelta/east/cobalt"));
assert!(!navigates("Close Home"));
assert!(!navigates("Add dir"));
assert!(!navigates("Rename project"));
assert!(!navigates("Send"));
assert!(!navigates("Copy"));
}
#[test]
fn only_the_documented_file_panels_are_exempt() {
for name in ["Attach files", "Add dir", "Select backup file…", "Export"] {
assert!(opens_native_dialog(name), "{name} is a native panel");
}
for name in ["Choose…", "Back up & close", "Restore", "Export JSONL"] {
assert!(
opens_native_dialog(name),
"{name} raises a panel the harness cannot dismiss"
);
}
for name in ["Add item", "Send", "Cancel", "Fork this item", "New item"] {
assert!(!opens_native_dialog(name), "{name} must be swept");
}
assert!(NATIVE_CHOOSERS.iter().all(|e| !e.command.is_empty()));
}
#[test]
fn a_modal_is_recognised_and_offers_its_dismissers() {
let dialog = vec![
node(1, "button", "Cancel", Some([0.0, 0.0, 20.0, 20.0])),
node(2, "button", "Fork", Some([0.0, 0.0, 20.0, 20.0])),
node(3, "button", "Close", Some([0.0, 0.0, 20.0, 20.0])),
];
assert!(modal_open(&dialog));
let ways_out = dismissers(&dialog);
assert_eq!(ways_out[0].1, "Cancel");
assert_eq!(ways_out.len(), 2);
let ordinary = vec![node(1, "button", "Send", Some([0.0, 0.0, 20.0, 20.0]))];
assert!(!modal_open(&ordinary));
}
#[test]
fn onboarding_and_restart_are_left_alone() {
assert!(restarts_the_app("Welcome Tutorial"));
assert!(restarts_the_app("Restart"));
assert!(!restarts_the_app("Re-check"));
assert!(!restarts_the_app("Refresh"));
assert!(!restarts_the_app("Default model"));
}
#[test]
fn surface_tabs_are_not_closed_out_from_under_the_sweep() {
assert!(closes_a_surface("Close Settings"));
assert!(closes_a_surface("Close Analytics"));
assert!(closes_a_surface("Close delta/east/cobalt"));
assert!(!closes_a_surface("Collapse Recent"));
assert!(!closes_a_surface("Rename e"));
}
#[test]
fn rows_off_the_top_of_a_transcript_are_not_hovered() {
let nodes = vec![
node(1, "listitem", "visible row", Some([10.0, 100.0, 200.0, 40.0])),
node(2, "listitem", "scrolled off", Some([10.0, -9726.0, 200.0, 40.0])),
node(3, "listitem", "below the fold", Some([10.0, 5000.0, 200.0, 40.0])),
];
let points = hover_points(&nodes, "listitem", (0.0, 900.0));
assert_eq!(points.len(), 1);
assert_eq!(points[0].0, 1);
}
#[test]
fn coverage_reports_an_unaccounted_gap() {
let full = Coverage {
in_tree: 10,
swept: 3,
unreachable: 2,
hidden: 1,
vanished: 1,
navigation: 1,
native: 1,
blocked: 1,
};
assert!(full.accounted());
assert!(!full.line().contains("UNACCOUNTED"));
let leaky = Coverage {
in_tree: 10,
swept: 6,
unreachable: 0,
hidden: 0,
vanished: 0,
navigation: 0,
native: 0,
blocked: 0,
};
assert!(!leaky.accounted());
assert!(leaky.line().contains("UNACCOUNTED 4"));
}
}