1use crate::bundle::types::{
8 DefinitionSnapshot, EdgeDef, NodeOutcome, RunState, RunStatus, StepRecord,
9};
10use crate::canvas::{CanvasStyle, CharCanvas};
11use crate::format::{format_duration, parse_timestamp_ms, sanitize_text};
12use crate::layout::{layout_graph, GraphCell, GraphEdge, GraphLayout, GraphSegment};
13use serde_json::Value;
14use std::collections::HashSet;
15
16pub struct GraphView<'a> {
18 pub state: &'a RunState,
19 pub snapshot: Option<&'a DefinitionSnapshot>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum NodeStatus {
24 Completed,
25 Failed,
26 TimedOut,
27 Active,
28 ReplayFocus,
29 Waiting,
30 Queued,
31 Cancelled,
32}
33
34impl NodeStatus {
35 fn glyph(self) -> char {
36 match self {
37 NodeStatus::Completed => '✓',
38 NodeStatus::Failed => '✗',
39 NodeStatus::TimedOut => '×',
40 NodeStatus::Active => '◐',
41 NodeStatus::ReplayFocus => '◆',
42 NodeStatus::Waiting => '⏸',
43 NodeStatus::Cancelled => '~',
44 NodeStatus::Queued => '·',
45 }
46 }
47
48 fn label(self) -> &'static str {
49 match self {
50 NodeStatus::Completed => "completed",
51 NodeStatus::Failed => "failed",
52 NodeStatus::TimedOut => "timed out",
53 NodeStatus::Active => "running",
54 NodeStatus::ReplayFocus => "replay focus",
55 NodeStatus::Waiting => "waiting",
56 NodeStatus::Cancelled => "cancelled",
57 NodeStatus::Queued => "queued",
58 }
59 }
60
61 fn style(self) -> CanvasStyle {
62 match self {
63 NodeStatus::Completed => CanvasStyle::Ok,
64 NodeStatus::Failed => CanvasStyle::Fail,
65 NodeStatus::TimedOut => CanvasStyle::TimedOut,
66 NodeStatus::Active => CanvasStyle::Active,
67 NodeStatus::ReplayFocus => CanvasStyle::Replay,
68 NodeStatus::Waiting => CanvasStyle::Warn,
69 NodeStatus::Cancelled => CanvasStyle::Cancelled,
70 NodeStatus::Queued => CanvasStyle::NodeDim,
71 }
72 }
73
74 fn border_style(self) -> CanvasStyle {
75 match self {
76 NodeStatus::Completed => CanvasStyle::NodeBorderOk,
77 NodeStatus::Failed => CanvasStyle::NodeBorderFail,
78 NodeStatus::TimedOut => CanvasStyle::NodeBorderTimedOut,
79 NodeStatus::Active => CanvasStyle::NodeBorderActive,
80 NodeStatus::ReplayFocus => CanvasStyle::NodeBorderReplay,
81 NodeStatus::Waiting => CanvasStyle::NodeBorderWarn,
82 NodeStatus::Cancelled => CanvasStyle::NodeBorderCancelled,
83 NodeStatus::Queued => CanvasStyle::NodeBorderDim,
84 }
85 }
86
87 fn is_focused(self) -> bool {
88 matches!(self, NodeStatus::Active | NodeStatus::ReplayFocus)
89 }
90}
91
92const CELL_GAP: i64 = 6;
93const GUTTER_GAP: i64 = 2;
94const GRAPH_SIDE_MARGIN: i64 = 2;
95const CARD_MIN_CONTENT_WIDTH: i64 = 28;
96const CARD_DYNAMIC_RESERVE: &str = "↻ 100 ◷ 9999d 23h 59m 59s";
97
98fn node_type_glyph(node_type: &str, action_execution: Option<&str>) -> char {
99 match (node_type, action_execution) {
100 ("agent", _) => '●',
101 ("compute", _) => 'ƒ',
102 ("notify", _) => '!',
103 ("action", Some("shell")) => '$',
104 ("action", _) => '*',
105 ("checkpoint", _) => '◆',
106 _ => '?',
107 }
108}
109
110fn node_type_style(node_type: &str, focused: bool) -> CanvasStyle {
111 match (node_type, focused) {
112 ("agent", false) => CanvasStyle::Agent,
113 ("agent", true) => CanvasStyle::AgentFocus,
114 ("compute", false) => CanvasStyle::Compute,
115 ("compute", true) => CanvasStyle::ComputeFocus,
116 ("notify", false) => CanvasStyle::Action,
117 ("notify", true) => CanvasStyle::ActionFocus,
118 ("action", false) => CanvasStyle::Action,
119 ("action", true) => CanvasStyle::ActionFocus,
120 ("checkpoint", false) => CanvasStyle::Checkpoint,
121 ("checkpoint", true) => CanvasStyle::CheckpointFocus,
122 (_, false) => CanvasStyle::NodeDim,
123 (_, true) => CanvasStyle::NodeFocusText,
124 }
125}
126
127fn node_type_badge(node_type: &str, action_execution: Option<&str>) -> String {
128 format!(
129 "{} {node_type}",
130 node_type_glyph(node_type, action_execution)
131 )
132}
133
134fn fit_text(text: &str, width: usize) -> String {
135 let chars: Vec<char> = text.chars().collect();
136 if chars.len() <= width {
137 return text.to_string();
138 }
139 if width <= 1 {
140 return chars.into_iter().take(width).collect();
141 }
142 format!("{}…", chars.into_iter().take(width - 1).collect::<String>())
143}
144
145fn centered_text(text: &str, width: usize) -> String {
146 let fitted = fit_text(text, width);
147 let left = width.saturating_sub(fitted.chars().count()) / 2;
148 format!("{}{fitted}", " ".repeat(left))
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum GraphNodeStyle {
153 Line,
154 Box,
155}
156
157#[derive(Debug, Clone, Copy)]
158struct CardMetrics {
159 width: i64,
160 height: i64,
161 branch_rows: usize,
162}
163
164fn cell_height(node_style: GraphNodeStyle, box_height: i64) -> i64 {
165 match node_style {
166 GraphNodeStyle::Box => box_height,
167 GraphNodeStyle::Line => 1,
168 }
169}
170
171fn js_len(text: &str) -> i64 {
173 text.encode_utf16().count() as i64
174}
175
176fn latest_visible_attempt<'a>(steps: &'a [StepRecord], node_id: &str) -> Option<&'a StepRecord> {
177 steps.iter().rev().find(|step| step.node_id == node_id)
178}
179
180fn derive_node_status(
181 view: &GraphView,
182 node_id: &str,
183 visible_steps: &[StepRecord],
184 at_latest_step: bool,
185) -> NodeStatus {
186 let state = view.state;
187 if at_latest_step && state.current_node.as_deref() == Some(node_id) {
188 return NodeStatus::Active;
189 }
190 if at_latest_step && state.waiting_on.as_deref() == Some(node_id) {
191 return NodeStatus::Waiting;
192 }
193 let Some(attempt) = latest_visible_attempt(visible_steps, node_id) else {
194 return NodeStatus::Queued;
195 };
196 if !at_latest_step && visible_steps.last().map(|step| step.node_id.as_str()) == Some(node_id) {
198 return NodeStatus::ReplayFocus;
199 }
200 match attempt.outcome {
201 NodeOutcome::Ok => NodeStatus::Completed,
202 NodeOutcome::TimedOut => NodeStatus::TimedOut,
203 NodeOutcome::Cancelled => NodeStatus::Cancelled,
204 NodeOutcome::Failed => NodeStatus::Failed,
205 }
206}
207
208fn node_branch_labels(view: &GraphView, node_id: &str) -> Vec<String> {
209 view.snapshot
210 .map(|snapshot| {
211 snapshot
212 .edges
213 .iter()
214 .flat_map(|edge| match edge {
215 EdgeDef::Switch { from, switch } if from == node_id => switch
216 .cases
217 .keys()
218 .map(|label| sanitize_text(label))
219 .collect::<Vec<_>>(),
220 _ => Vec::new(),
221 })
222 .collect()
223 })
224 .unwrap_or_default()
225}
226
227fn hierarchical_node_label(node_id: &str, node: Option<&Value>) -> String {
228 let Some(node) = node else {
229 return sanitize_text(node_id);
230 };
231 let Some(mount_path) = node.get("mountPath").and_then(Value::as_array) else {
232 return sanitize_text(node_id);
233 };
234 let path = mount_path
235 .iter()
236 .filter_map(Value::as_str)
237 .map(sanitize_text)
238 .collect::<Vec<_>>()
239 .join(" › ");
240 let Some(local_node_id) = node.get("localNodeId").and_then(Value::as_str) else {
241 return sanitize_text(node_id);
242 };
243 match node.get("includeTransition").and_then(Value::as_str) {
244 Some("entry") => format!("{path} · enter"),
245 Some("exit") => format!("{path} · {} exit", sanitize_text(local_node_id)),
246 _ => format!("{path} › {}", sanitize_text(local_node_id)),
247 }
248}
249
250fn card_metrics(view: &GraphView) -> CardMetrics {
251 let Some(snapshot) = view.snapshot else {
252 return CardMetrics {
253 width: CARD_MIN_CONTENT_WIDTH + 4,
254 height: 7,
255 branch_rows: 0,
256 };
257 };
258 let mut content_width = CARD_MIN_CONTENT_WIDTH.max(js_len(CARD_DYNAMIC_RESERVE));
259 let mut branch_rows = 0usize;
260 for status in [
261 NodeStatus::Completed,
262 NodeStatus::Failed,
263 NodeStatus::TimedOut,
264 NodeStatus::Active,
265 NodeStatus::ReplayFocus,
266 NodeStatus::Waiting,
267 NodeStatus::Queued,
268 NodeStatus::Cancelled,
269 ] {
270 content_width = content_width.max(js_len(&format!(
271 "{} {} {}",
272 node_type_badge("checkpoint", None),
273 status.glyph(),
274 status.label()
275 )));
276 }
277 for (node_id, node) in &snapshot.nodes {
278 content_width = content_width.max(js_len(&hierarchical_node_label(node_id, Some(node))));
279 if let Some(node_type) = node.get("nodeType").and_then(Value::as_str) {
280 let action_execution = node.get("actionExecution").and_then(Value::as_str);
281 content_width =
282 content_width.max(js_len(&node_type_badge(node_type, action_execution)));
283 }
284 let labels = node_branch_labels(view, node_id);
285 branch_rows = branch_rows.max(labels.len());
286 for label in labels {
287 content_width = content_width.max(js_len(&format!("◇ {label}")));
288 }
289 }
290 CardMetrics {
291 width: content_width + 4,
292 height: 7 + branch_rows as i64,
293 branch_rows,
294 }
295}
296
297fn human_decision_detail(state: &RunState, node_id: &str, node: Option<&Value>) -> Option<String> {
298 let human = node?.get("humanDecision")?;
299 let choices = human.get("choices")?.as_object()?;
300 if state.waiting_on.as_deref() == Some(node_id) {
301 let audience = state
302 .final_output
303 .as_ref()
304 .and_then(|request| request.get("audience"))
305 .and_then(Value::as_str)
306 .or_else(|| human.get("audience").and_then(Value::as_str))
307 .unwrap_or("operator");
308 let labels = choices
309 .values()
310 .filter_map(|choice| choice.get("label").and_then(Value::as_str))
311 .map(sanitize_text)
312 .collect::<Vec<_>>()
313 .join(" / ");
314 let summary = state
315 .final_output
316 .as_ref()
317 .and_then(|request| request.pointer("/presentation/summary"))
318 .and_then(Value::as_str)
319 .map(sanitize_text);
320 let fingerprint = state
321 .final_output
322 .as_ref()
323 .and_then(|request| request.get("presentationDigest"))
324 .and_then(Value::as_str)
325 .and_then(|digest| digest.strip_prefix("sha256:"))
326 .map(|digest| digest.chars().take(12).collect::<String>());
327 return Some(format!(
328 "human decision · {}{} · {}{}",
329 sanitize_text(audience),
330 summary
331 .map(|value| format!(" · {value}"))
332 .unwrap_or_default(),
333 labels,
334 fingerprint
335 .map(|value| format!(" · {value}"))
336 .unwrap_or_default()
337 ));
338 }
339 state
340 .human_decision
341 .as_ref()
342 .filter(|decision| decision.get("nodeId").and_then(Value::as_str) == Some(node_id))
343 .and_then(|decision| decision.pointer("/response/choice"))
344 .and_then(Value::as_str)
345 .and_then(|choice| choices.get(choice))
346 .and_then(|choice| choice.get("label"))
347 .and_then(Value::as_str)
348 .map(|label| format!("human: {}", sanitize_text(label)))
349}
350
351struct RenderedCell {
352 cell: GraphCell,
353 text: String,
354 node_id: String,
355 node_type: String,
356 type_badge: String,
357 status: Option<NodeStatus>,
358 attempts: usize,
359 elapsed: String,
360 detail: String,
361 branch_lines: Vec<String>,
362 is_start: bool,
363 is_end: bool,
364 width: i64,
365}
366
367fn render_cell_text(
368 view: &GraphView,
369 cell: &GraphCell,
370 visible_steps: &[StepRecord],
371 at_latest_step: bool,
372 now_ms: i64,
373 node_style: GraphNodeStyle,
374 metrics: CardMetrics,
375) -> RenderedCell {
376 let GraphCell::Node { node_id } = cell else {
377 return RenderedCell {
378 cell: cell.clone(),
379 text: String::new(),
380 node_id: String::new(),
381 node_type: String::new(),
382 type_badge: String::new(),
383 status: None,
384 attempts: 0,
385 elapsed: String::new(),
386 detail: String::new(),
387 branch_lines: Vec::new(),
388 is_start: false,
389 is_end: false,
390 width: 1,
391 };
392 };
393 let state = view.state;
394 let status = derive_node_status(view, node_id, visible_steps, at_latest_step);
395 let node = view
396 .snapshot
397 .and_then(|snapshot| snapshot.nodes.get(node_id));
398 let node_type = view
399 .snapshot
400 .and_then(|snapshot| snapshot.node_type(node_id))
401 .unwrap_or("?");
402 let action_execution = view
403 .snapshot
404 .and_then(|snapshot| snapshot.node_action_execution(node_id));
405 let attempt = latest_visible_attempt(visible_steps, node_id);
406 let attempts = visible_steps
407 .iter()
408 .filter(|step| step.node_id == *node_id)
409 .count();
410 let labels = node_branch_labels(view, node_id);
411 let outgoing = view.snapshot.map_or(0, |snapshot| {
412 snapshot
413 .edges
414 .iter()
415 .filter_map(|edge| match edge {
416 EdgeDef::Simple { from, .. } if from == node_id => Some(1),
417 EdgeDef::Switch { from, switch } if from == node_id => Some(switch.cases.len()),
418 _ => None,
419 })
420 .sum::<usize>()
421 });
422 let is_start = view
423 .snapshot
424 .is_some_and(|snapshot| snapshot.start_at == *node_id);
425 let is_end = outgoing == 0;
426 let elapsed = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
427 let started_at = state
428 .current_node_started_at
429 .as_deref()
430 .and_then(parse_timestamp_ms)
431 .unwrap_or(now_ms);
432 format_duration(now_ms - started_at)
433 } else if let Some(attempt) = attempt {
434 let duration_ms = parse_timestamp_ms(&attempt.finished_at).unwrap_or(0)
435 - parse_timestamp_ms(&attempt.started_at).unwrap_or(0);
436 format_duration(duration_ms)
437 } else {
438 "—".to_string()
439 };
440 let detail = human_decision_detail(state, node_id, node).unwrap_or_else(|| {
441 if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
442 state
443 .status_detail
444 .as_deref()
445 .map(sanitize_text)
446 .unwrap_or_default()
447 } else {
448 node.and_then(|node| {
449 node.get("statusDetail")
450 .or_else(|| node.get("summary"))
451 .and_then(Value::as_str)
452 })
453 .map(sanitize_text)
454 .unwrap_or_default()
455 }
456 });
457 let mut branch_lines: Vec<String> = labels
458 .into_iter()
459 .map(|label| format!("◇ {label}"))
460 .collect();
461 branch_lines.resize(metrics.branch_rows, String::new());
462 let count = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
463 attempts.max(1)
464 } else {
465 attempts
466 };
467 let timing = if attempt.is_some() || count > 0 {
468 format!(
469 "{count} attempt{} · {elapsed}",
470 if count == 1 { "" } else { "s" }
471 )
472 } else {
473 "not visited".to_string()
474 };
475 let display_node_id = hierarchical_node_label(node_id, node);
476 let text = format!("{display_node_id} [{node_type}] {timing}");
477 RenderedCell {
478 cell: cell.clone(),
479 text: text.clone(),
480 node_id: display_node_id,
481 node_type: node_type.to_string(),
482 type_badge: if node.is_some() {
483 node_type_badge(node_type, action_execution)
484 } else {
485 "? unknown".to_string()
486 },
487 status: Some(status),
488 attempts: count,
489 elapsed,
490 detail,
491 branch_lines,
492 is_start,
493 is_end,
494 width: match node_style {
495 GraphNodeStyle::Box => metrics.width,
496 GraphNodeStyle::Line => js_len(&text) + 2,
497 },
498 }
499}
500
501struct RankGeometry {
502 cells: Vec<RenderedCell>,
503 centers: Vec<i64>,
504}
505
506struct PlacedRank {
507 cells: Vec<RenderedCell>,
508 centers: Vec<i64>,
509 y: i64,
510}
511
512struct GeomSegment {
514 edge_id: String,
515 label: Option<String>,
516 from_x: i64,
517 to_x: i64,
518 track: i64,
519 target_is_node: bool,
520}
521
522struct StripGeometry {
523 segments: Vec<GeomSegment>,
524 track_count: i64,
525 has_labels: bool,
526 straight: bool,
528}
529
530fn taken_transitions(visible_steps: &[StepRecord]) -> HashSet<String> {
532 visible_steps
533 .windows(2)
534 .map(|pair| format!("{}->{}", pair[0].node_id, pair[1].node_id))
535 .collect()
536}
537
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct NodeBounds {
540 pub node_id: String,
541 pub x: i64,
542 pub y: i64,
543 pub width: i64,
544 pub height: i64,
545}
546
547pub struct RenderedGraph {
548 pub canvas: CharCanvas,
549 pub node_bounds: Vec<NodeBounds>,
550}
551
552pub fn render_graph(
556 view: &GraphView,
557 selected_step_index: i64,
558 at_latest_step: bool,
559 now_ms: i64,
560 node_style: GraphNodeStyle,
561) -> Option<RenderedGraph> {
562 let snapshot = view.snapshot?;
563 let metrics = card_metrics(view);
564 let layout = layout_graph(snapshot);
565 let steps = &view.state.steps;
566 let bounded_index = selected_step_index.max(-1).min(steps.len() as i64 - 1);
567 let visible_steps = &steps[0..(bounded_index + 1) as usize];
568 let transitions = taken_transitions(visible_steps);
569 let active_pair = derive_pair_in_flight(view, visible_steps, at_latest_step);
570
571 let rendered: Vec<Vec<RenderedCell>> = layout
572 .ranks
573 .iter()
574 .map(|rank| {
575 rank.iter()
576 .map(|cell| {
577 render_cell_text(
578 view,
579 cell,
580 visible_steps,
581 at_latest_step,
582 now_ms,
583 node_style,
584 metrics,
585 )
586 })
587 .collect()
588 })
589 .collect();
590
591 let rank_widths: Vec<i64> = rendered
594 .iter()
595 .map(|cells| {
596 cells.iter().map(|cell| cell.width).sum::<i64>()
597 + 0.max(cells.len() as i64 - 1) * CELL_GAP
598 })
599 .collect();
600 let graph_width = rank_widths.iter().copied().max().unwrap_or(0).max(0) + GRAPH_SIDE_MARGIN * 2;
601 let geometry: Vec<RankGeometry> = rendered
602 .into_iter()
603 .enumerate()
604 .map(|(rank_index, cells)| {
605 let mut centers = Vec::with_capacity(cells.len());
606 let mut x = (graph_width - rank_widths[rank_index]) / 2;
607 for cell in &cells {
608 centers.push(if cells.len() == 1 {
611 graph_width / 2
612 } else {
613 x + cell.width / 2
614 });
615 x += cell.width + CELL_GAP;
616 }
617 RankGeometry { cells, centers }
618 })
619 .collect();
620
621 let strips: Vec<StripGeometry> = (0..geometry.len())
624 .map(|rank_index| compute_strip_geometry(&layout, rank_index, &geometry))
625 .collect();
626
627 let lanes = BackEdgeLanes::new(&layout);
628 let mut placed: Vec<PlacedRank> = Vec::new();
629 let top_lanes = lanes.above(0).len() as i64;
631 let mut y = if top_lanes > 0 { top_lanes + 1 } else { 0 };
632 let rank_count = geometry.len();
633 for (rank_index, rank) in geometry.into_iter().enumerate() {
634 placed.push(PlacedRank {
635 cells: rank.cells,
636 centers: rank.centers,
637 y,
638 });
639 y += cell_height(node_style, metrics.height)
640 + lanes.below(rank_index).len() as i64
641 + gap_rows(&strips[rank_index], rank_index, rank_count)
642 + lanes.above(rank_index + 1).len() as i64;
643 }
644
645 let node_bounds = placed
646 .iter()
647 .flat_map(|rank| {
648 rank.cells
649 .iter()
650 .zip(&rank.centers)
651 .filter_map(|(cell, center)| match &cell.cell {
652 GraphCell::Node { node_id } => Some(NodeBounds {
653 node_id: node_id.clone(),
654 x: center - cell.width / 2,
655 y: rank.y,
656 width: cell.width,
657 height: cell_height(node_style, metrics.height),
658 }),
659 GraphCell::Virtual { .. } => None,
660 })
661 })
662 .collect();
663
664 let mut canvas = CharCanvas::new();
665 draw_nodes(
666 &mut canvas,
667 &placed,
668 &layout,
669 &transitions,
670 node_style,
671 metrics.height,
672 );
673 let labels = draw_segments(
674 &mut canvas,
675 &placed,
676 &strips,
677 &layout,
678 &transitions,
679 active_pair.as_deref(),
680 graph_width,
681 node_style,
682 metrics.height,
683 &lanes,
684 );
685 draw_back_edges(
686 &mut canvas,
687 &placed,
688 &layout,
689 &transitions,
690 graph_width,
691 node_style,
692 metrics.height,
693 &lanes,
694 );
695 for label in labels {
698 draw_segment_label(&mut canvas, &label);
699 }
700 Some(RenderedGraph {
701 canvas,
702 node_bounds,
703 })
704}
705
706pub fn render_graph_canvas(
708 view: &GraphView,
709 selected_step_index: i64,
710 at_latest_step: bool,
711 now_ms: i64,
712 node_style: GraphNodeStyle,
713) -> Option<CharCanvas> {
714 render_graph(
715 view,
716 selected_step_index,
717 at_latest_step,
718 now_ms,
719 node_style,
720 )
721 .map(|rendered| rendered.canvas)
722}
723
724pub fn render_graph_lines(
728 view: &GraphView,
729 selected_step_index: i64,
730 now_ms: i64,
731 node_style: GraphNodeStyle,
732) -> Vec<String> {
733 let at_latest_step = selected_step_index >= view.state.steps.len() as i64 - 1;
734 match render_graph_canvas(
735 view,
736 selected_step_index,
737 at_latest_step,
738 now_ms,
739 node_style,
740 ) {
741 Some(canvas) => canvas.render_plain(),
742 None => Vec::new(),
743 }
744}
745
746struct BackEdgeLanes {
749 edges: Vec<GraphEdge>,
750 rank_of_node: std::collections::HashMap<String, usize>,
751}
752
753impl BackEdgeLanes {
754 fn new(layout: &GraphLayout) -> Self {
755 Self {
756 edges: layout
757 .edges
758 .iter()
759 .filter(|edge| edge.is_back_edge)
760 .cloned()
761 .collect(),
762 rank_of_node: layout.rank_of_node.clone(),
763 }
764 }
765
766 fn below(&self, rank: usize) -> Vec<&GraphEdge> {
767 self.edges
768 .iter()
769 .filter(|edge| self.rank_of_node.get(&edge.from) == Some(&rank))
770 .collect()
771 }
772
773 fn above(&self, rank: usize) -> Vec<&GraphEdge> {
774 self.edges
775 .iter()
776 .filter(|edge| self.rank_of_node.get(&edge.to) == Some(&rank))
777 .collect()
778 }
779}
780
781fn derive_pair_in_flight(
783 view: &GraphView,
784 visible_steps: &[StepRecord],
785 at_latest_step: bool,
786) -> Option<String> {
787 let state = view.state;
788 if at_latest_step {
789 if state.status == RunStatus::Running {
790 if let (Some(current), Some(last)) = (
791 state.current_node.as_deref().filter(|id| !id.is_empty()),
792 visible_steps.last(),
793 ) {
794 return Some(format!("{}->{current}", last.node_id));
795 }
796 }
797 return None;
798 }
799 if visible_steps.len() >= 2 {
800 let previous = &visible_steps[visible_steps.len() - 2];
801 let last = &visible_steps[visible_steps.len() - 1];
802 return Some(format!("{}->{}", previous.node_id, last.node_id));
803 }
804 None
805}
806
807fn gap_rows(strip: &StripGeometry, rank: usize, rank_count: usize) -> i64 {
809 if strip.segments.is_empty() {
810 return if rank < rank_count - 1 { 1 } else { 0 };
811 }
812 if strip.straight {
814 return 2;
815 }
816 2 + strip.track_count + if strip.has_labels { 1 } else { 0 }
819}
820
821fn compute_strip_geometry(
824 layout: &GraphLayout,
825 rank: usize,
826 geometry: &[RankGeometry],
827) -> StripGeometry {
828 let strip: Vec<&GraphSegment> = layout
829 .segments
830 .iter()
831 .filter(|segment| segment.rank == rank)
832 .collect();
833 let (Some(top), Some(bottom)) = (geometry.get(rank), geometry.get(rank + 1)) else {
834 return StripGeometry {
835 segments: Vec::new(),
836 track_count: 1,
837 has_labels: false,
838 straight: true,
839 };
840 };
841 if strip.is_empty() {
842 return StripGeometry {
843 segments: Vec::new(),
844 track_count: 1,
845 has_labels: false,
846 straight: true,
847 };
848 }
849 let exit_offsets = fan_offsets(&strip, FanSide::From, top, bottom);
850 let entry_offsets = fan_offsets(&strip, FanSide::To, top, bottom);
851 struct Resolved {
852 edge_id: String,
853 label: Option<String>,
854 from_x: i64,
855 to_x: i64,
856 target_is_node: bool,
857 }
858 let mut resolved: Vec<Resolved> = strip
859 .iter()
860 .map(|segment| {
861 let from_x = top.centers[segment.from_cell]
862 + exit_offsets.get(&segment.edge_id).copied().unwrap_or(0);
863 let mut to_x = bottom.centers[segment.to_cell]
864 + entry_offsets.get(&segment.edge_id).copied().unwrap_or(0);
865 let target_is_node = bottom.cells[segment.to_cell].cell.is_node();
866 if target_is_node && (to_x - from_x).abs() <= 1 {
871 to_x = from_x;
872 }
873 Resolved {
874 edge_id: segment.edge_id.clone(),
875 label: segment.label.clone(),
876 from_x,
877 to_x,
878 target_is_node,
879 }
880 })
881 .collect();
882
883 resolved.sort_by_key(|segment| segment.from_x);
886 let mut segments: Vec<GeomSegment> = Vec::new();
887 let mut track_ranges: Vec<Vec<(i64, i64)>> = Vec::new();
888 for segment in resolved {
889 let mut track = 0i64;
890 if segment.from_x != segment.to_x || segment.label.is_some() {
891 let span = (
892 segment.from_x.min(segment.to_x),
893 segment.from_x.max(segment.to_x),
894 );
895 let found = track_ranges.iter().position(|ranges| {
896 ranges
897 .iter()
898 .all(|&(start, end)| span.1 < start || span.0 > end)
899 });
900 track = match found {
901 Some(index) => index as i64,
902 None => {
903 track_ranges.push(Vec::new());
904 track_ranges.len() as i64 - 1
905 }
906 };
907 track_ranges[track as usize].push(span);
908 }
909 segments.push(GeomSegment {
910 edge_id: segment.edge_id,
911 label: segment.label,
912 from_x: segment.from_x,
913 to_x: segment.to_x,
914 track,
915 target_is_node: segment.target_is_node,
916 });
917 }
918 StripGeometry {
919 track_count: (track_ranges.len() as i64).max(1),
920 has_labels: segments.iter().any(|segment| segment.label.is_some()),
921 straight: segments
922 .iter()
923 .all(|segment| segment.from_x == segment.to_x && segment.label.is_none()),
924 segments,
925 }
926}
927
928#[derive(Clone, Copy, PartialEq)]
929enum FanSide {
930 From,
931 To,
932}
933
934fn fan_offsets(
938 strip: &[&GraphSegment],
939 side: FanSide,
940 top: &RankGeometry,
941 bottom: &RankGeometry,
942) -> std::collections::HashMap<String, i64> {
943 let (own_rank, far_rank) = match side {
944 FanSide::From => (top, bottom),
945 FanSide::To => (bottom, top),
946 };
947 let own_cell = |segment: &GraphSegment| match side {
948 FanSide::From => segment.from_cell,
949 FanSide::To => segment.to_cell,
950 };
951 let far_cell = |segment: &GraphSegment| match side {
952 FanSide::From => segment.to_cell,
953 FanSide::To => segment.from_cell,
954 };
955 let mut offsets = std::collections::HashMap::new();
956 let mut group_order: Vec<usize> = Vec::new();
958 let mut groups: std::collections::HashMap<usize, Vec<&GraphSegment>> =
959 std::collections::HashMap::new();
960 for segment in strip {
961 if own_rank.cells[own_cell(segment)].cell.is_node() {
963 let key = own_cell(segment);
964 if !groups.contains_key(&key) {
965 group_order.push(key);
966 }
967 groups.entry(key).or_default().push(segment);
968 }
969 }
970 for cell_index in group_order {
971 let group = &groups[&cell_index];
972 if group.len() < 2 {
973 continue;
974 }
975 let cell = &own_rank.cells[cell_index];
976 let max_offset = 1.max(cell.width / 2 - 1);
977 let mut ordered: Vec<&&GraphSegment> = group.iter().collect();
978 ordered.sort_by_key(|segment| far_rank.centers[far_cell(segment)]);
979 let count = ordered.len() as i64;
980 for (index, segment) in ordered.into_iter().enumerate() {
981 let offset = -2 * (count - 1 - index as i64);
982 offsets.insert(segment.edge_id.clone(), offset.max(-max_offset));
983 }
984 }
985 offsets
986}
987
988struct BoxChars {
989 tl: char,
990 tr: char,
991 ml: char,
992 mr: char,
993 bl: char,
994 br: char,
995 h: char,
996 v: char,
997}
998
999const BOX_LIGHT: BoxChars = BoxChars {
1000 tl: '┌',
1001 tr: '┐',
1002 ml: '├',
1003 mr: '┤',
1004 bl: '└',
1005 br: '┘',
1006 h: '─',
1007 v: '│',
1008};
1009
1010const BOX_HEAVY: BoxChars = BoxChars {
1011 tl: '┏',
1012 tr: '┓',
1013 ml: '┣',
1014 mr: '┫',
1015 bl: '┗',
1016 br: '┛',
1017 h: '━',
1018 v: '┃',
1019};
1020
1021fn draw_nodes(
1022 canvas: &mut CharCanvas,
1023 placed: &[PlacedRank],
1024 layout: &GraphLayout,
1025 transitions: &HashSet<String>,
1026 node_style: GraphNodeStyle,
1027 box_height: i64,
1028) {
1029 for rank in placed {
1030 for (index, rendered) in rank.cells.iter().enumerate() {
1031 let center = rank.centers[index];
1032 match &rendered.cell {
1033 GraphCell::Virtual { edge_id } => {
1034 let edge = layout
1035 .edges
1036 .iter()
1037 .find(|candidate| candidate.edge_id == *edge_id);
1038 let taken = edge.is_some_and(|edge| {
1039 transitions.contains(&format!("{}->{}", edge.from, edge.to))
1040 });
1041 canvas.vline(
1044 center,
1045 rank.y,
1046 rank.y + cell_height(node_style, box_height) - 1,
1047 if taken {
1048 CanvasStyle::Taken
1049 } else {
1050 CanvasStyle::Dim
1051 },
1052 );
1053 }
1054 GraphCell::Node { .. } => {
1055 let status = rendered.status.unwrap_or(NodeStatus::Queued);
1056 let start_x = center - rendered.width / 2;
1057 if node_style == GraphNodeStyle::Box {
1058 draw_node_box(canvas, start_x, rank.y, rendered, status);
1059 } else {
1060 if rendered.is_start {
1061 canvas.put(start_x - 2, rank.y, '▶', status.border_style());
1062 }
1063 canvas.put(start_x, rank.y, status.glyph(), status.border_style());
1064 canvas.text(
1065 start_x + 2,
1066 rank.y,
1067 &rendered.text,
1068 if status == NodeStatus::Queued {
1069 CanvasStyle::Dim
1070 } else {
1071 CanvasStyle::Plain
1072 },
1073 );
1074 if rendered.is_end {
1075 canvas.put(
1076 start_x + rendered.width + 1,
1077 rank.y,
1078 '■',
1079 status.border_style(),
1080 );
1081 }
1082 }
1083 }
1084 }
1085 }
1086 }
1087}
1088
1089fn draw_node_box(
1091 canvas: &mut CharCanvas,
1092 start_x: i64,
1093 y: i64,
1094 rendered: &RenderedCell,
1095 status: NodeStatus,
1096) {
1097 let chars = if status.is_focused() {
1098 &BOX_HEAVY
1099 } else {
1100 &BOX_LIGHT
1101 };
1102 let border_style = status.border_style();
1103 let status_style = status.style();
1104 let type_style = node_type_style(&rendered.node_type, status.is_focused());
1105 let branch_style = if status.is_focused() {
1106 CanvasStyle::BranchFocus
1107 } else {
1108 CanvasStyle::Branch
1109 };
1110 let content_style = if status.is_focused() {
1111 CanvasStyle::NodeFocusText
1112 } else if status == NodeStatus::Queued {
1113 CanvasStyle::NodeDim
1114 } else {
1115 CanvasStyle::NodeText
1116 };
1117 let height = 7 + rendered.branch_lines.len() as i64;
1118 let inner_width = (rendered.width - 2) as usize;
1119 let right_x = start_x + rendered.width - 1;
1120 canvas.fill_rect(
1121 start_x + 1,
1122 y + 1,
1123 rendered.width - 2,
1124 1,
1125 CanvasStyle::NodeHeader,
1126 );
1127 canvas.fill_rect(
1128 start_x + 1,
1129 y + 3,
1130 rendered.width - 2,
1131 height - 4,
1132 content_style,
1133 );
1134 let horizontal: String = std::iter::repeat_n(chars.h, inner_width).collect();
1135
1136 canvas.text(
1137 start_x,
1138 y,
1139 &format!("{}{horizontal}{}", chars.tl, chars.tr),
1140 border_style,
1141 );
1142 canvas.text(start_x, y + 1, &chars.v.to_string(), border_style);
1143 canvas.text(right_x, y + 1, &chars.v.to_string(), border_style);
1144 canvas.text(
1145 start_x + 1,
1146 y + 1,
1147 ¢ered_text(&rendered.node_id, inner_width),
1148 CanvasStyle::NodeHeader,
1149 );
1150 canvas.text(
1151 start_x,
1152 y + 2,
1153 &format!("{}{horizontal}{}", chars.ml, chars.mr),
1154 border_style,
1155 );
1156
1157 let type_badge = fit_text(&rendered.type_badge, inner_width - 2);
1158 let status_badge = fit_text(
1159 &format!("{} {}", status.glyph(), status.label()),
1160 inner_width - 2,
1161 );
1162 canvas.text(start_x, y + 3, &chars.v.to_string(), border_style);
1163 canvas.text(right_x, y + 3, &chars.v.to_string(), border_style);
1164 canvas.text(start_x + 2, y + 3, &type_badge, type_style);
1165 canvas.text(
1166 right_x - 1 - status_badge.chars().count() as i64,
1167 y + 3,
1168 &status_badge,
1169 status_style,
1170 );
1171
1172 let attempts = format!("↻ {}", rendered.attempts);
1173 let elapsed = format!("◷ {}", rendered.elapsed);
1174 canvas.text(start_x, y + 4, &chars.v.to_string(), border_style);
1175 canvas.text(right_x, y + 4, &chars.v.to_string(), border_style);
1176 canvas.text(start_x + 2, y + 4, &attempts, content_style);
1177 canvas.text(
1178 right_x - 1 - elapsed.chars().count() as i64,
1179 y + 4,
1180 &elapsed,
1181 content_style,
1182 );
1183
1184 for (index, branch) in rendered.branch_lines.iter().enumerate() {
1185 let row = y + 5 + index as i64;
1186 canvas.text(start_x, row, &chars.v.to_string(), border_style);
1187 canvas.text(right_x, row, &chars.v.to_string(), border_style);
1188 canvas.text(
1189 start_x + 2,
1190 row,
1191 &fit_text(branch, inner_width - 2),
1192 branch_style,
1193 );
1194 }
1195 let detail_row = y + 5 + rendered.branch_lines.len() as i64;
1196 canvas.text(start_x, detail_row, &chars.v.to_string(), border_style);
1197 canvas.text(right_x, detail_row, &chars.v.to_string(), border_style);
1198 if !rendered.detail.is_empty() {
1199 canvas.text(
1200 start_x + 2,
1201 detail_row,
1202 &fit_text(&format!("… {}", rendered.detail), inner_width - 2),
1203 content_style,
1204 );
1205 }
1206 canvas.text(
1207 start_x,
1208 y + height - 1,
1209 &format!("{}{horizontal}{}", chars.bl, chars.br),
1210 border_style,
1211 );
1212 if rendered.is_start {
1213 canvas.put(start_x - 2, y + 1, '▶', border_style);
1214 }
1215 if rendered.is_end {
1216 canvas.put(start_x + rendered.width + 1, y + 1, '■', border_style);
1217 }
1218}
1219
1220fn edge_style(
1221 pair_key: &str,
1222 transitions: &HashSet<String>,
1223 active_pair: Option<&str>,
1224) -> CanvasStyle {
1225 if active_pair == Some(pair_key) {
1226 return CanvasStyle::ActiveEdge;
1227 }
1228 if transitions.contains(pair_key) {
1229 return CanvasStyle::Taken;
1230 }
1231 CanvasStyle::Dim
1232}
1233
1234struct PendingLabel {
1235 text: String,
1236 style: CanvasStyle,
1237 from_x: i64,
1238 to_x: i64,
1239 track_y: i64,
1240 label_row: i64,
1241 graph_width: i64,
1242}
1243
1244#[allow(clippy::too_many_arguments)]
1245fn draw_segments(
1246 canvas: &mut CharCanvas,
1247 placed: &[PlacedRank],
1248 strips: &[StripGeometry],
1249 layout: &GraphLayout,
1250 transitions: &HashSet<String>,
1251 active_pair: Option<&str>,
1252 graph_width: i64,
1253 node_style: GraphNodeStyle,
1254 box_height: i64,
1255 lanes: &BackEdgeLanes,
1256) -> Vec<PendingLabel> {
1257 let mut labels = Vec::new();
1258 for rank in 0..placed.len().saturating_sub(1) {
1259 let strip = &strips[rank];
1260 if strip.segments.is_empty() {
1261 continue;
1262 }
1263 let top = &placed[rank];
1264 let bottom = &placed[rank + 1];
1265 let stub_top = top.y + cell_height(node_style, box_height);
1269 let strip_top = stub_top + lanes.below(rank).len() as i64;
1270 let arrow_y = bottom.y - 1;
1271 let strip_bottom = arrow_y - 1 - lanes.above(rank + 1).len() as i64;
1272 for segment in &strip.segments {
1273 let Some(edge) = layout
1274 .edges
1275 .iter()
1276 .find(|candidate| candidate.edge_id == segment.edge_id)
1277 else {
1278 continue;
1279 };
1280 let style = edge_style(
1281 &format!("{}->{}", edge.from, edge.to),
1282 transitions,
1283 active_pair,
1284 );
1285 let (from_x, to_x) = (segment.from_x, segment.to_x);
1286 let track_y = strip_top + segment.track;
1287 if from_x == to_x {
1288 canvas.vline(from_x, stub_top, arrow_y, style);
1289 } else {
1290 if track_y > stub_top {
1291 canvas.vline(from_x, stub_top, track_y - 1, style);
1292 }
1293 canvas.put(
1294 from_x,
1295 track_y,
1296 if to_x > from_x { '└' } else { '┘' },
1297 style,
1298 );
1299 canvas.hline(track_y, from_x.min(to_x) + 1, from_x.max(to_x) - 1, style);
1300 canvas.put(to_x, track_y, if to_x > from_x { '┐' } else { '┌' }, style);
1301 if arrow_y > track_y {
1302 canvas.vline(to_x, track_y + 1, arrow_y, style);
1303 }
1304 }
1305 if segment.target_is_node {
1306 canvas.put(to_x, arrow_y, '▼', style);
1307 }
1308 if let Some(label) = &segment.label {
1309 labels.push(PendingLabel {
1310 text: label.clone(),
1311 style,
1312 from_x,
1313 to_x,
1314 track_y,
1315 label_row: (strip_top + strip.track_count).min(strip_bottom),
1316 graph_width,
1317 });
1318 }
1319 }
1320 }
1321 labels
1322}
1323
1324fn draw_segment_label(canvas: &mut CharCanvas, label: &PendingLabel) {
1328 let padded = format!(" {} ", label.text);
1329 let padded_len = js_len(&padded);
1330 let text_len = js_len(&label.text);
1331 if label.from_x != label.to_x {
1332 let run_start = label.from_x.min(label.to_x) + 1;
1333 let run_end = label.from_x.max(label.to_x) - 1;
1334 let center = (run_start + run_end) / 2 - padded_len / 2;
1335 if run_end - run_start + 1 >= padded_len + 2
1336 && canvas.text_over_run(center, label.track_y, &padded, label.style)
1337 {
1338 return;
1339 }
1340 }
1341 let left = (label.to_x - text_len - 1, label.label_row);
1342 let right = (label.to_x + 2, label.label_row);
1343 let candidates = if label.to_x >= label.graph_width / 2 {
1344 [left, right]
1345 } else {
1346 [right, left]
1347 };
1348 for (x, y) in candidates {
1349 if canvas.text_if_empty(x, y, &label.text, label.style) {
1350 return;
1351 }
1352 }
1353 canvas.text_if_empty(label.from_x + 2, label.track_y, &label.text, label.style);
1355}
1356
1357#[allow(clippy::too_many_arguments)]
1361fn draw_back_edges(
1362 canvas: &mut CharCanvas,
1363 placed: &[PlacedRank],
1364 layout: &GraphLayout,
1365 transitions: &HashSet<String>,
1366 graph_width: i64,
1367 node_style: GraphNodeStyle,
1368 box_height: i64,
1369 lanes: &BackEdgeLanes,
1370) {
1371 let mut gutter_x = graph_width + GUTTER_GAP;
1372 for edge in &lanes.edges {
1373 let (Some(&from_rank), Some(&to_rank)) = (
1374 layout.rank_of_node.get(&edge.from),
1375 layout.rank_of_node.get(&edge.to),
1376 ) else {
1377 continue;
1378 };
1379 let from = &placed[from_rank];
1380 let to = &placed[to_rank];
1381 let below = lanes.below(from_rank);
1382 let above = lanes.above(to_rank);
1383 let (Some(exit), Some(entry)) = (
1384 cell_anchor(from, &edge.from, &below, edge),
1385 cell_anchor(to, &edge.to, &above, edge),
1386 ) else {
1387 continue;
1388 };
1389 let style = if transitions.contains(&format!("{}->{}", edge.from, edge.to)) {
1390 CanvasStyle::Taken
1391 } else {
1392 CanvasStyle::Back
1393 };
1394 let exit_lane_y = from.y + cell_height(node_style, box_height) + exit.lane;
1395 let above_count = above.len() as i64;
1396 let arrow_y = to.y - 1;
1397 let entry_lane_y = arrow_y - above_count + entry.lane;
1398
1399 if exit_lane_y > from.y + cell_height(node_style, box_height) {
1401 canvas.vline(
1402 exit.x,
1403 from.y + cell_height(node_style, box_height),
1404 exit_lane_y - 1,
1405 style,
1406 );
1407 }
1408 canvas.put(exit.x, exit_lane_y, '└', style);
1409 canvas.hline(exit_lane_y, exit.x + 1, gutter_x - 1, style);
1410 canvas.put(gutter_x, exit_lane_y, '┘', style);
1411 canvas.put(gutter_x, entry_lane_y, '┐', style);
1413 if exit_lane_y - entry_lane_y > 1 {
1414 canvas.vline(gutter_x, entry_lane_y + 1, exit_lane_y - 1, style);
1415 }
1416 canvas.hline(entry_lane_y, entry.x + 1, gutter_x - 1, style);
1417 canvas.put(entry.x, entry_lane_y, '┌', style);
1418 if arrow_y - entry_lane_y > 1 {
1419 canvas.vline(entry.x, entry_lane_y + 1, arrow_y - 1, style);
1420 }
1421 canvas.put(entry.x, arrow_y, '▼', style);
1422 if let Some(label) = &edge.label {
1423 canvas.text(gutter_x + 2, entry_lane_y, label, style);
1424 }
1425 gutter_x += 2 + edge.label.as_deref().map_or(0, |label| js_len(label) + 1);
1427 }
1428}
1429
1430struct Anchor {
1431 x: i64,
1432 lane: i64,
1433}
1434
1435fn cell_anchor(
1439 rank: &PlacedRank,
1440 node_id: &str,
1441 lane_edges: &[&GraphEdge],
1442 edge: &GraphEdge,
1443) -> Option<Anchor> {
1444 let index = rank
1445 .cells
1446 .iter()
1447 .position(|cell| matches!(&cell.cell, GraphCell::Node { node_id: id } if id == node_id))?;
1448 let lane = lane_edges
1449 .iter()
1450 .position(|candidate| candidate.edge_id == edge.edge_id)? as i64;
1451 let cell = &rank.cells[index];
1452 let center = rank.centers[index];
1453 let rightmost = center + cell.width / 2 - 1;
1454 Some(Anchor {
1455 x: (center + 2 + lane * 2).min(rightmost),
1456 lane,
1457 })
1458}