use rataflow::{Edge, Flow, Handle, HandlePosition, Node, Reconnectable, Sugiyama, Theme};
use ratatui::style::Color;
use super::session::{AgentInfo, AgentKind, AgentStatus, SessionModel};
use crate::ui::edges::AgentEdge;
use crate::ui::nodes::{AgentNode, MAIN_NODE_DIMS, SUB_NODE_DIMS};
pub type AgentFlow = Flow<AgentNode, AgentEdge>;
pub fn new_flow() -> AgentFlow {
let mut palette = Theme::Dark.palette();
palette.accent = Color::Indexed(178);
let mut flow = Flow::new()
.with_theme(Theme::Custom(palette))
.with_deselect_on_pane_click(false)
.with_selection_reveal(rataflow::SelectionReveal::None)
.with_min_zoom(0.1);
flow.deselect_on_drag = false;
flow
}
fn node_title(info: &AgentInfo) -> String {
match info.kind {
AgentKind::Main => "claude".to_string(),
AgentKind::WorkflowGroup => info
.agent_type
.clone()
.unwrap_or_else(|| "workflow".to_string()),
AgentKind::Subagent => info
.agent_type
.clone()
.unwrap_or_else(|| "subagent".to_string()),
}
}
fn node_dims(kind: AgentKind) -> (f64, f64) {
match kind {
AgentKind::Main | AgentKind::WorkflowGroup => MAIN_NODE_DIMS,
AgentKind::Subagent => SUB_NODE_DIMS,
}
}
fn content_matches(info: &AgentInfo, node: &AgentNode) -> bool {
let title_ok = match info.kind {
AgentKind::Main => node.title == "claude",
AgentKind::WorkflowGroup => node.title == info.agent_type.as_deref().unwrap_or("workflow"),
AgentKind::Subagent => node.title == info.agent_type.as_deref().unwrap_or("subagent"),
};
title_ok
&& node.description.as_deref() == info.description.as_deref()
&& node.status == info.status
&& node.tool_count == info.tool_calls.len()
&& node.last_tool.as_deref() == info.last_tool()
&& node.output_tokens == info.output_tokens
&& node.interactive == info.is_interactive()
}
fn build_content(info: &AgentInfo) -> AgentNode {
AgentNode {
title: node_title(info),
description: info.description.clone(),
status: info.status,
tool_count: info.tool_calls.len(),
last_tool: info.last_tool().map(str::to_string),
output_tokens: info.output_tokens,
interactive: info.is_interactive(),
}
}
const LOCAL_H_GAP: f64 = 4.0;
const LOCAL_V_GAP: f64 = 5.0;
pub fn sync(flow: &mut AgentFlow, model: &SessionModel, relayout: bool) -> bool {
let mut structural = false;
for id in &model.spawn_order {
let Some(info) = model.agent(id) else {
continue;
};
if let Some(existing) = flow.node_content_mut(id) {
if !content_matches(info, existing) {
*existing = build_content(info);
}
} else {
let siblings = info
.parent
.as_deref()
.map(|p| {
model
.spawn_order
.iter()
.take_while(|x| *x != id)
.filter(|x| model.agent(x).and_then(|a| a.parent.as_deref()) == Some(p))
.count()
})
.unwrap_or(0);
let content = build_content(info);
let (w, h) = node_dims(info.kind);
let pos = info
.parent
.as_deref()
.and_then(|p| flow.node(p))
.map(|parent| {
(
parent.position.x + siblings as f64 * (w + LOCAL_H_GAP),
parent.position.y + parent.height + LOCAL_V_GAP,
)
})
.unwrap_or((0.0, 0.0));
let node = Node::new(id.clone(), pos, (w, h), content)
.with_deletable(false)
.with_connectable(false)
.with_handles(vec![
Handle::source(HandlePosition::Bottom).with_hidden(true),
Handle::target(HandlePosition::Top).with_hidden(true),
]);
if flow.add_node(node).is_ok() {
structural = true;
}
}
}
for id in &model.spawn_order {
let Some(info) = model.agent(id) else {
continue;
};
let Some(parent) = &info.parent else {
continue;
};
let animated = info.status == AgentStatus::Running;
let edge_id = edge_id(id);
if let Some(content) = flow.edge_content_mut(&edge_id) {
content.running = animated;
flow.set_edge_animated(&edge_id, animated);
} else {
let edge = Edge::new(edge_id.clone(), parent.clone(), id.clone())
.with_animated(animated)
.with_selectable(false)
.with_deletable(false)
.with_reconnectable(Reconnectable::None);
if flow.add_edge(edge).is_ok() {
structural = true;
}
if let Some(content) = flow.edge_content_mut(&edge_id) {
content.running = animated;
}
}
}
if structural && relayout {
self::relayout(flow);
}
structural
}
fn edge_id(child: &str) -> String {
format!("e-{child}")
}
pub fn relayout(flow: &mut AgentFlow) {
flow.apply_layout(Sugiyama::vertical());
}
pub fn restore_positions(
flow: &mut AgentFlow,
positions: &std::collections::HashMap<String, (f64, f64)>,
) {
flow.set_node_positions(positions.iter().map(|(id, &pos)| (id, pos)));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transcript::SubagentMeta;
fn model_with_subagent() -> SessionModel {
let mut m = SessionModel::new("s1".into());
let meta = SubagentMeta {
agent_type: Some("guide".into()),
description: Some("research".into()),
tool_use_id: Some("ag1".into()),
stopped_by_user: None,
};
m.apply_meta("abc123", None, &meta);
m
}
#[test]
fn sync_creates_nodes_and_edge() {
let model = model_with_subagent();
let mut flow = new_flow();
let structural = sync(&mut flow, &model, true);
assert!(structural);
assert!(flow.node_content_mut("main").is_some());
assert!(flow.node_content_mut("abc123").is_some());
assert_eq!(flow.edges().len(), 1);
}
#[test]
fn sync_idempotent() {
let model = model_with_subagent();
let mut flow = new_flow();
let first = sync(&mut flow, &model, true);
assert!(first);
let node_count = flow.nodes().count();
let edge_count = flow.edges().len();
let second = sync(&mut flow, &model, true);
assert!(!second);
assert_eq!(flow.nodes().count(), node_count);
assert_eq!(flow.edges().len(), edge_count);
}
#[test]
fn sync_preserves_selection() {
let model = model_with_subagent();
let mut flow = new_flow();
sync(&mut flow, &model, true);
flow.select_node("abc123");
assert_eq!(
flow.selected_nodes().next().map(|n| n.id.clone()),
Some("abc123".to_string())
);
let mut model2 = model;
if let Some(a) = model2.agents.get_mut("abc123") {
a.output_tokens += 100;
}
sync(&mut flow, &model2, true);
assert_eq!(
flow.selected_nodes().next().map(|n| n.id.clone()),
Some("abc123".to_string())
);
}
#[test]
fn edge_animation_follows_status() {
let mut model = model_with_subagent();
let mut flow = new_flow();
sync(&mut flow, &model, true);
let edge_id = edge_id("abc123");
let animated = flow
.edges()
.iter()
.find(|e| e.id == edge_id)
.map(|e| e.animated);
assert_eq!(animated, Some(true));
assert!(flow.edge_content_mut(&edge_id).unwrap().running);
if let Some(a) = model.agents.get_mut("abc123") {
a.status = AgentStatus::Done;
}
sync(&mut flow, &model, true);
let animated = flow
.edges()
.iter()
.find(|e| e.id == edge_id)
.map(|e| e.animated);
assert_eq!(animated, Some(false));
assert!(!flow.edge_content_mut(&edge_id).unwrap().running);
}
#[test]
fn graph_is_structurally_read_only() {
use rataflow::Reconnectable;
let model = model_with_subagent();
let mut flow = new_flow();
sync(&mut flow, &model, true);
for node in flow.nodes() {
assert!(node.selectable, "nodes stay selectable (detail panel)");
assert!(node.draggable, "nodes stay draggable (manual arranging)");
assert!(!node.deletable, "nodes must not be deletable");
assert!(!node.connectable, "nodes must not start connections");
}
for edge in flow.edges() {
assert!(!edge.selectable, "edges carry no selectable meaning");
assert!(!edge.deletable);
assert_eq!(edge.reconnectable, Reconnectable::None);
}
}
#[test]
fn manual_mode_local_placement_moves_nothing_existing() {
let mut model = model_with_subagent();
let mut flow = new_flow();
sync(&mut flow, &model, true);
let main_pos = flow.node("main").unwrap().position;
let first_sub = flow.node("abc123").unwrap().position;
let meta2 = SubagentMeta {
agent_type: Some("guide".into()),
description: None,
tool_use_id: Some("ag2".into()),
stopped_by_user: None,
};
model.apply_meta("def456", None, &meta2);
let structural = sync(&mut flow, &model, false);
assert!(structural);
assert_eq!(flow.node("main").unwrap().position, main_pos);
assert_eq!(flow.node("abc123").unwrap().position, first_sub);
let new_pos = flow.node("def456").unwrap().position;
assert!(new_pos.y > main_pos.y, "child placed below parent");
assert_ne!((new_pos.x, new_pos.y), (0.0, 0.0));
}
#[test]
fn cards_render_into_buffer() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
let model = model_with_subagent();
let mut flow = new_flow();
sync(&mut flow, &model, true);
flow.request_fit_view();
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
(&mut flow).render(area, &mut buf);
let mut text = String::new();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
assert!(
text.contains("claude"),
"main card title missing from render:\n{text}"
);
assert!(
text.contains("guide"),
"subagent card title missing from render:\n{text}"
);
}
}