|
| 1 | +//! Interactive TUI Graph Viewer for Transfer Relationships |
| 2 | +//! |
| 3 | +//! This module provides a Cursive-based interactive viewer for visualizing complex |
| 4 | +//! wallet transfer graphs with support for: |
| 5 | +//! - Collapsible tree navigation for deep graphs (depth > 4) |
| 6 | +//! - Wallet convergence detection (same wallet via multiple paths) |
| 7 | +//! - Keyboard navigation (j/k, Enter, arrows) |
| 8 | +//! - Search and filter capabilities |
| 9 | +//! - Integration with existing OSVM themes |
| 10 | +
|
| 11 | +use anyhow::Result; |
| 12 | +use cursive::traits::*; |
| 13 | +use cursive::views::{ |
| 14 | + Dialog, LinearLayout, Panel, ScrollView, SelectView, TextView, |
| 15 | +}; |
| 16 | +use cursive::{Cursive, CursiveExt}; |
| 17 | +use std::collections::{HashMap, HashSet}; |
| 18 | + |
| 19 | +use super::research_agent::{Transfer, TransferGraph}; |
| 20 | + |
| 21 | +/// Interactive TUI state for graph navigation |
| 22 | +#[derive(Clone)] |
| 23 | +pub struct GraphViewerState { |
| 24 | + /// The transfer graph being visualized |
| 25 | + pub graph: TransferGraph, |
| 26 | + /// Currently collapsed nodes (address -> bool) |
| 27 | + pub collapsed_nodes: HashMap<String, bool>, |
| 28 | + /// Selected node address |
| 29 | + pub selected_node: Option<String>, |
| 30 | + /// Search filter text |
| 31 | + pub search_filter: String, |
| 32 | +} |
| 33 | + |
| 34 | +impl GraphViewerState { |
| 35 | + pub fn new(graph: TransferGraph) -> Self { |
| 36 | + Self { |
| 37 | + graph, |
| 38 | + collapsed_nodes: HashMap::new(), |
| 39 | + selected_node: None, |
| 40 | + search_filter: String::new(), |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + /// Toggle collapse state of a node |
| 45 | + pub fn toggle_collapse(&mut self, address: &str) { |
| 46 | + let is_collapsed = self.collapsed_nodes.get(address).copied().unwrap_or(false); |
| 47 | + self.collapsed_nodes.insert(address.to_string(), !is_collapsed); |
| 48 | + } |
| 49 | + |
| 50 | + /// Check if a node is collapsed |
| 51 | + pub fn is_collapsed(&self, address: &str) -> bool { |
| 52 | + self.collapsed_nodes.get(address).copied().unwrap_or(false) |
| 53 | + } |
| 54 | + |
| 55 | + /// Count convergence (how many paths lead to this wallet) |
| 56 | + pub fn count_convergence(&self, address: &str) -> usize { |
| 57 | + self.graph |
| 58 | + .nodes |
| 59 | + .values() |
| 60 | + .filter(|node| { |
| 61 | + node.outgoing |
| 62 | + .iter() |
| 63 | + .any(|transfer| transfer.to == address) |
| 64 | + }) |
| 65 | + .count() |
| 66 | + } |
| 67 | +} |
| 68 | + |
| 69 | +/// Render tree node with collapsible indicators |
| 70 | +fn render_tree_node( |
| 71 | + state: &GraphViewerState, |
| 72 | + addr: &str, |
| 73 | + depth: usize, |
| 74 | + visited: &mut HashSet<String>, |
| 75 | + parent_prefix: String, |
| 76 | +) -> Vec<String> { |
| 77 | + let mut lines = Vec::new(); |
| 78 | + |
| 79 | + if visited.contains(addr) { |
| 80 | + // Convergence detected - show annotation |
| 81 | + let convergence_count = state.count_convergence(addr); |
| 82 | + let indent = " ".repeat(depth); |
| 83 | + lines.push(format!( |
| 84 | + "{}{}↗ CONVERGENCE: {} ({} paths lead here)", |
| 85 | + parent_prefix, |
| 86 | + indent, |
| 87 | + state.graph.truncate_address(addr, 8), |
| 88 | + convergence_count |
| 89 | + )); |
| 90 | + return lines; |
| 91 | + } |
| 92 | + |
| 93 | + visited.insert(addr.to_string()); |
| 94 | + |
| 95 | + let is_origin = state.graph.origin.as_ref().map(|o| o == addr).unwrap_or(false); |
| 96 | + let is_target = state.graph.target.as_ref().map(|t| t == addr).unwrap_or(false); |
| 97 | + let is_collapsed = state.is_collapsed(addr); |
| 98 | + |
| 99 | + let node = match state.graph.nodes.get(addr) { |
| 100 | + Some(n) => n, |
| 101 | + None => return lines, |
| 102 | + }; |
| 103 | + |
| 104 | + let indent = " ".repeat(depth); |
| 105 | + let truncated = state.graph.truncate_address(addr, 8); |
| 106 | + |
| 107 | + // Node line with collapse indicator |
| 108 | + let collapse_icon = if node.outgoing.is_empty() { |
| 109 | + " " // Leaf node |
| 110 | + } else if is_collapsed { |
| 111 | + "▶" // Collapsed |
| 112 | + } else { |
| 113 | + "▼" // Expanded |
| 114 | + }; |
| 115 | + |
| 116 | + let node_icon = if is_origin { |
| 117 | + "🏦" |
| 118 | + } else if is_target { |
| 119 | + "🎯" |
| 120 | + } else { |
| 121 | + "○" |
| 122 | + }; |
| 123 | + |
| 124 | + let convergence_count = state.count_convergence(addr); |
| 125 | + let convergence_indicator = if convergence_count > 1 { |
| 126 | + format!(" [×{} paths]", convergence_count) |
| 127 | + } else { |
| 128 | + String::new() |
| 129 | + }; |
| 130 | + |
| 131 | + lines.push(format!( |
| 132 | + "{}{}{} {} {}{}", |
| 133 | + parent_prefix, indent, collapse_icon, node_icon, truncated, convergence_indicator |
| 134 | + )); |
| 135 | + |
| 136 | + // Show transfers if expanded |
| 137 | + if !is_collapsed { |
| 138 | + for (i, transfer) in node.outgoing.iter().enumerate() { |
| 139 | + let is_last = i == node.outgoing.len() - 1; |
| 140 | + let connector = if is_last { "└─" } else { "├─" }; |
| 141 | + let child_prefix = if is_last { |
| 142 | + format!("{}{} ", parent_prefix, indent) |
| 143 | + } else { |
| 144 | + format!("{}{}│ ", parent_prefix, indent) |
| 145 | + }; |
| 146 | + |
| 147 | + // Transfer info |
| 148 | + lines.push(format!( |
| 149 | + "{} {}──→ [{}] {}", |
| 150 | + child_prefix, |
| 151 | + connector, |
| 152 | + state.graph.format_amount(transfer.amount), |
| 153 | + transfer.token_symbol |
| 154 | + )); |
| 155 | + |
| 156 | + // Recurse to child |
| 157 | + let child_lines = render_tree_node( |
| 158 | + state, |
| 159 | + &transfer.to, |
| 160 | + depth + 1, |
| 161 | + visited, |
| 162 | + child_prefix.clone(), |
| 163 | + ); |
| 164 | + lines.extend(child_lines); |
| 165 | + } |
| 166 | + } else { |
| 167 | + // Show count of hidden children |
| 168 | + let child_count = node.outgoing.len(); |
| 169 | + if child_count > 0 { |
| 170 | + lines.push(format!( |
| 171 | + "{}{} [... {} hidden transfers]", |
| 172 | + parent_prefix, indent, child_count |
| 173 | + )); |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + lines |
| 178 | +} |
| 179 | + |
| 180 | +/// Build the tree view content |
| 181 | +fn build_tree_view(state: &GraphViewerState) -> String { |
| 182 | + let mut output = String::new(); |
| 183 | + |
| 184 | + // Header |
| 185 | + output.push_str("╔══════════════════════════════════════════════════════════════════════════╗\n"); |
| 186 | + output.push_str("║ INTERACTIVE WALLET TRANSFER GRAPH VIEWER ║\n"); |
| 187 | + output.push_str("╚══════════════════════════════════════════════════════════════════════════╝\n\n"); |
| 188 | + |
| 189 | + // Instructions |
| 190 | + output.push_str("📖 Controls:\n"); |
| 191 | + output.push_str(" j/k or ↑/↓ - Navigate nodes\n"); |
| 192 | + output.push_str(" Enter/Space - Toggle collapse/expand\n"); |
| 193 | + output.push_str(" / - Search (coming soon)\n"); |
| 194 | + output.push_str(" q or Esc - Exit viewer\n\n"); |
| 195 | + |
| 196 | + output.push_str("═══════════════════════════════════════════════════════════════════════════\n\n"); |
| 197 | + |
| 198 | + // Find origin |
| 199 | + let origin_addr = match &state.graph.origin { |
| 200 | + Some(addr) => addr.clone(), |
| 201 | + None => { |
| 202 | + // Find any node with no incoming transfers |
| 203 | + state |
| 204 | + .graph |
| 205 | + .nodes |
| 206 | + .keys() |
| 207 | + .find(|addr| { |
| 208 | + !state.graph.nodes.values().any(|node| { |
| 209 | + node.outgoing.iter().any(|t| &t.to == *addr) |
| 210 | + }) |
| 211 | + }) |
| 212 | + .cloned() |
| 213 | + .unwrap_or_default() |
| 214 | + } |
| 215 | + }; |
| 216 | + |
| 217 | + if origin_addr.is_empty() { |
| 218 | + output.push_str("⚠️ No graph data available\n"); |
| 219 | + return output; |
| 220 | + } |
| 221 | + |
| 222 | + // Render tree |
| 223 | + let mut visited = HashSet::new(); |
| 224 | + let lines = render_tree_node(state, &origin_addr, 0, &mut visited, String::new()); |
| 225 | + for line in lines { |
| 226 | + output.push_str(&line); |
| 227 | + output.push('\n'); |
| 228 | + } |
| 229 | + |
| 230 | + output.push_str("\n═══════════════════════════════════════════════════════════════════════════\n"); |
| 231 | + |
| 232 | + // Stats |
| 233 | + let total_nodes = state.graph.nodes.len(); |
| 234 | + let total_transfers: usize = state.graph.nodes.values().map(|n| n.outgoing.len()).sum(); |
| 235 | + let convergence_nodes: Vec<_> = state |
| 236 | + .graph |
| 237 | + .nodes |
| 238 | + .keys() |
| 239 | + .filter(|addr| state.count_convergence(addr) > 1) |
| 240 | + .collect(); |
| 241 | + |
| 242 | + output.push_str(&format!("\n📊 Graph Statistics:\n")); |
| 243 | + output.push_str(&format!(" Total Nodes: {}\n", total_nodes)); |
| 244 | + output.push_str(&format!(" Total Transfers: {}\n", total_transfers)); |
| 245 | + output.push_str(&format!( |
| 246 | + " Convergence Points: {} (wallets reached via multiple paths)\n", |
| 247 | + convergence_nodes.len() |
| 248 | + )); |
| 249 | + |
| 250 | + if !convergence_nodes.is_empty() { |
| 251 | + output.push_str("\n⚠️ Convergence Detected:\n"); |
| 252 | + for addr in convergence_nodes.iter().take(5) { |
| 253 | + let count = state.count_convergence(addr); |
| 254 | + output.push_str(&format!( |
| 255 | + " • {} ({} paths)\n", |
| 256 | + state.graph.truncate_address(addr, 12), |
| 257 | + count |
| 258 | + )); |
| 259 | + } |
| 260 | + if convergence_nodes.len() > 5 { |
| 261 | + output.push_str(&format!(" ... and {} more\n", convergence_nodes.len() - 5)); |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + output |
| 266 | +} |
| 267 | + |
| 268 | +/// Launch the interactive TUI graph viewer |
| 269 | +pub fn launch_graph_viewer(graph: TransferGraph) -> Result<()> { |
| 270 | + let mut siv = Cursive::default(); |
| 271 | + |
| 272 | + // Create initial state |
| 273 | + let state = GraphViewerState::new(graph); |
| 274 | + |
| 275 | + // Build initial view |
| 276 | + let tree_text = build_tree_view(&state); |
| 277 | + let text_view = TextView::new(tree_text).with_name("tree_view"); |
| 278 | + |
| 279 | + let dialog = Dialog::around( |
| 280 | + ScrollView::new(text_view) |
| 281 | + .scroll_x(true) |
| 282 | + .scroll_y(true) |
| 283 | + .min_width(100) |
| 284 | + .min_height(30), |
| 285 | + ) |
| 286 | + .title("Transfer Graph Viewer") |
| 287 | + .button("Quit (q)", |s| s.quit()); |
| 288 | + |
| 289 | + siv.add_layer(dialog); |
| 290 | + |
| 291 | + // Store state in user_data |
| 292 | + siv.set_user_data(state); |
| 293 | + |
| 294 | + // Global key handlers |
| 295 | + siv.add_global_callback('q', |s| s.quit()); |
| 296 | + siv.add_global_callback(cursive::event::Key::Esc, |s| s.quit()); |
| 297 | + |
| 298 | + // TODO: Add interactive node selection and toggling |
| 299 | + // For now, this provides a scrollable view |
| 300 | + |
| 301 | + siv.run(); |
| 302 | + |
| 303 | + Ok(()) |
| 304 | +} |
| 305 | + |
| 306 | +/// Quick test function to demo the viewer |
| 307 | +#[cfg(test)] |
| 308 | +pub fn demo_graph_viewer() -> Result<()> { |
| 309 | + use super::research_agent::RenderConfig; |
| 310 | + |
| 311 | + // Create a test graph |
| 312 | + let config = RenderConfig::default(); |
| 313 | + let mut graph = TransferGraph::with_config(config); |
| 314 | + |
| 315 | + // Add test data - complex convergence scenario |
| 316 | + let transfers = vec![ |
| 317 | + Transfer { |
| 318 | + from: "ExchangeWallet111111111111111111111111111".to_string(), |
| 319 | + to: "MixerHub222222222222222222222222222222222".to_string(), |
| 320 | + amount: 100000.0, |
| 321 | + token_symbol: "SOL".to_string(), |
| 322 | + timestamp: Some("2025-01-01T00:00:00Z".to_string()), |
| 323 | + note: Some("Initial funding".to_string()), |
| 324 | + }, |
| 325 | + Transfer { |
| 326 | + from: "MixerHub222222222222222222222222222222222".to_string(), |
| 327 | + to: "BurnerA3333333333333333333333333333333333".to_string(), |
| 328 | + amount: 30000.0, |
| 329 | + token_symbol: "SOL".to_string(), |
| 330 | + timestamp: Some("2025-01-01T00:05:00Z".to_string()), |
| 331 | + note: None, |
| 332 | + }, |
| 333 | + Transfer { |
| 334 | + from: "MixerHub222222222222222222222222222222222".to_string(), |
| 335 | + to: "BurnerB4444444444444444444444444444444444".to_string(), |
| 336 | + amount: 30000.0, |
| 337 | + token_symbol: "SOL".to_string(), |
| 338 | + timestamp: Some("2025-01-01T00:05:00Z".to_string()), |
| 339 | + note: None, |
| 340 | + }, |
| 341 | + Transfer { |
| 342 | + from: "BurnerA3333333333333333333333333333333333".to_string(), |
| 343 | + to: "Destination555555555555555555555555555555".to_string(), |
| 344 | + amount: 15000.0, |
| 345 | + token_symbol: "SOL".to_string(), |
| 346 | + timestamp: Some("2025-01-01T00:10:00Z".to_string()), |
| 347 | + note: None, |
| 348 | + }, |
| 349 | + Transfer { |
| 350 | + from: "BurnerB4444444444444444444444444444444444".to_string(), |
| 351 | + to: "Destination555555555555555555555555555555".to_string(), // Convergence! |
| 352 | + amount: 15000.0, |
| 353 | + token_symbol: "SOL".to_string(), |
| 354 | + timestamp: Some("2025-01-01T00:10:00Z".to_string()), |
| 355 | + note: None, |
| 356 | + }, |
| 357 | + ]; |
| 358 | + |
| 359 | + for transfer in transfers { |
| 360 | + graph.add_transfer(transfer); |
| 361 | + } |
| 362 | + |
| 363 | + graph.origin = Some("ExchangeWallet111111111111111111111111111".to_string()); |
| 364 | + graph.target = Some("Destination555555555555555555555555555555".to_string()); |
| 365 | + |
| 366 | + launch_graph_viewer(graph) |
| 367 | +} |
0 commit comments