From c04dbbbed2f870d7a4dfec6e681f20b34afb0579 Mon Sep 17 00:00:00 2001 From: chomo Date: Wed, 21 Oct 2026 18:31:34 +0100 Subject: [PATCH] feat(tui): implement Ratatui invocation timeline widget Render the transaction invocation stack of a replayed transaction in the interactive debugger instead of printing a placeholder message. The timeline widget flattens the invocation tree depth first, the same order used by `grat trace`, and renders every frame with its nesting depth, tree connector, execution status, function name and elided contract id. The panel title tracks the focused frame, the focused frame is highlighted and an empty replay reports that no invocation was recorded. The debugger now sets up a ratatui terminal, replays the transaction before entering raw mode and handles the timeline navigation keys, with the terminal restored on exit. --- crates/cli/src/tui/app.rs | 342 +++++++++++++++++++++-- crates/cli/src/tui/widgets/timeline.rs | 366 ++++++++++++++++++++++++- 2 files changed, 686 insertions(+), 22 deletions(-) diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 946f70eb..f0b7338d 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -1,31 +1,331 @@ -use grat_core::types::config::NetworkConfig; +use std::{ + io::{self, IsTerminal}, + time::Duration, +}; + +use crossterm::{ + cursor::{Hide, Show}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use grat_core::types::{config::NetworkConfig, trace::ExecutionTrace}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Layout}, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, Terminal, +}; + +use super::{ + state::{Panel, TuiState}, + widgets::timeline, +}; + +/// Restores the terminal on normal exit, I/O errors and panic unwinding. +struct TerminalGuard; + +impl TerminalGuard { + fn enter() -> io::Result { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, Hide)?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, Show); + } +} pub async fn launch(tx_hash: &str, network: &NetworkConfig) -> anyhow::Result<()> { - let state = crate::tui::state::TuiState { + anyhow::ensure!( + io::stdin().is_terminal() && io::stdout().is_terminal(), + "Interactive replay requires a terminal; use `grat trace ` for redirected output." + ); + + // The transaction is replayed before the terminal enters raw mode so that + // replay failures keep the regular terminal output. + let trace = grat_core::replay::replay_transaction(tx_hash, network).await?; + + let mut state = TuiState { tx_hash: tx_hash.to_string(), - selected_panel: crate::tui::state::Panel::Timeline, + selected_panel: Panel::Timeline, scroll_offset: 0, }; - for panel in [ - crate::tui::state::Panel::Timeline, - crate::tui::state::Panel::Inspector, - crate::tui::state::Panel::Controls, - ] { - match panel { - crate::tui::state::Panel::Timeline => crate::tui::widgets::timeline::render(), - crate::tui::state::Panel::Inspector => crate::tui::widgets::inspector::render(), - crate::tui::state::Panel::Controls => crate::tui::widgets::controls::render(), + let _guard = TerminalGuard::enter()?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + + loop { + terminal.draw(|frame| render(frame, &state, &trace))?; + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + if handle_key(&mut state, key, &trace) { + break; + } + } } } - println!( - "TUI debugger launching for {} on {:?}...", - state.tx_hash, network.network - ); - println!( - "Selected panel: {:?} (scroll offset: {})", - state.selected_panel, state.scroll_offset - ); - println!("(Not yet implemented — requires ratatui setup)"); + Ok(()) } + +/// Handles a key press and reports whether the debugger should exit. +fn handle_key(state: &mut TuiState, key: KeyEvent, trace: &ExecutionTrace) -> bool { + if key.kind == KeyEventKind::Release { + return false; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return true; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => true, + KeyCode::Down | KeyCode::Char('j') => { + move_focus(state, trace, 1); + false + } + KeyCode::Up | KeyCode::Char('k') => { + move_focus(state, trace, -1); + false + } + _ => false, + } +} + +/// Moves the focused frame by `delta` positions inside the invocation timeline. +/// +/// `TuiState::scroll_offset` holds the index of the focused frame. +fn move_focus(state: &mut TuiState, trace: &ExecutionTrace, delta: isize) { + let frames = timeline::count(&trace.invocations); + if frames == 0 { + state.scroll_offset = 0; + return; + } + + let last = frames - 1; + let current = state.scroll_offset.min(last); + let next = if delta < 0 { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta.unsigned_abs()) + }; + + state.scroll_offset = next.min(last); +} + +/// Draws the debugger screen. +fn render(frame: &mut Frame<'_>, state: &TuiState, trace: &ExecutionTrace) { + let area = frame.area(); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .areas(area); + + frame.render_widget(header(state, trace), header_area); + + timeline::render_with_focus( + frame, + body_area, + &trace.invocations, + state.scroll_offset, + matches!(state.selected_panel, Panel::Timeline), + ); + + frame.render_widget(footer(state, trace), footer_area); +} + +/// Renders the transaction summary shown above the timeline. +fn header(state: &TuiState, trace: &ExecutionTrace) -> Paragraph<'static> { + Paragraph::new(Line::from(vec![ + Span::styled("Replay ", Style::default().fg(Color::Cyan)), + Span::raw(state.tx_hash.clone()), + Span::raw(format!( + " · {} · ledger {}", + trace.network, trace.ledger_sequence + )), + ])) +} + +/// Renders the timeline position and the available key bindings. +fn footer(state: &TuiState, trace: &ExecutionTrace) -> Paragraph<'static> { + let frames = timeline::count(&trace.invocations); + let position = if frames == 0 { + 0 + } else { + state.scroll_offset.min(frames - 1) + 1 + }; + + Paragraph::new(Line::from(vec![ + Span::raw(format!(" Frame {position}/{frames} ")), + Span::styled("↑/↓ navigate", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled("q quit", Style::default().fg(Color::DarkGray)), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + use grat_core::types::trace::{ + ContractInvocation, HostFunctionCall, ResourceProfile, StateDiff, TraceId, + }; + use ratatui::backend::TestBackend; + + fn invocation(function_name: &str) -> ContractInvocation { + ContractInvocation { + trace_id: TraceId::new(), + contract_id: "CONTRACT_A".to_string(), + function_name: function_name.to_string(), + arguments: Vec::new(), + return_value: None, + host_calls: Vec::::new(), + sub_invocations: Vec::new(), + total_cpu_instructions: 0, + total_memory_bytes: 0, + is_error: false, + } + } + + fn trace(invocations: Vec) -> ExecutionTrace { + ExecutionTrace { + tx_hash: "tx-hash".to_string(), + ledger_sequence: 42, + network: "testnet".to_string(), + invocations, + state_diff: StateDiff::default(), + resource_profile: ResourceProfile::default(), + diagnostic_events: Vec::new(), + } + } + + fn state() -> TuiState { + TuiState { + tx_hash: "tx-hash".to_string(), + selected_panel: Panel::Timeline, + scroll_offset: 0, + } + } + + fn press(state: &mut TuiState, key: KeyEvent, trace: &ExecutionTrace) -> bool { + handle_key(state, key, trace) + } + + #[test] + fn quit_keys_leave_the_debugger() { + let trace = trace(Vec::new()); + let mut state = state(); + + assert!(press( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + &trace + )); + assert!(press( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + &trace + )); + assert!(press( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + &trace + )); + } + + #[test] + fn navigation_moves_the_focused_frame() { + let trace = trace(vec![invocation("transfer"), invocation("swap")]); + let mut state = state(); + + assert!(!press( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + &trace + )); + assert_eq!(state.scroll_offset, 1); + + assert!(!press( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + &trace + )); + assert_eq!(state.scroll_offset, 1); + + assert!(!press( + &mut state, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + &trace + )); + assert_eq!(state.scroll_offset, 0); + } + + #[test] + fn navigation_stays_inside_the_timeline() { + let trace = trace(vec![invocation("transfer"), invocation("swap")]); + let mut state = state(); + + for _ in 0..5 { + press( + &mut state, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + &trace, + ); + } + assert_eq!(state.scroll_offset, 0); + + for _ in 0..5 { + press( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + &trace, + ); + } + assert_eq!(state.scroll_offset, 1); + } + + #[test] + fn an_empty_trace_keeps_the_focus_at_the_first_frame() { + let trace = trace(Vec::new()); + let mut state = state(); + + press( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + &trace, + ); + assert_eq!(state.scroll_offset, 0); + } + + #[test] + fn the_screen_shows_the_invocation_timeline() { + let trace = trace(vec![invocation("transfer"), invocation("swap")]); + let state = state(); + + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal + .draw(|frame| render(frame, &state, &trace)) + .unwrap(); + + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(text.contains("tx-hash")); + assert!(text.contains("Timeline")); + assert!(text.contains("transfer")); + assert!(text.contains("swap")); + assert!(text.contains("Frame 1/2")); + } +} diff --git a/crates/cli/src/tui/widgets/timeline.rs b/crates/cli/src/tui/widgets/timeline.rs index 579a2ec4..04e457f9 100644 --- a/crates/cli/src/tui/widgets/timeline.rs +++ b/crates/cli/src/tui/widgets/timeline.rs @@ -1,3 +1,367 @@ #![allow(dead_code)] -pub fn render() {} +use grat_core::types::trace::ContractInvocation; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph}, + Frame, +}; + +/// Number of columns used to indent a single level of the call hierarchy. +const INDENT_WIDTH: usize = 2; + +/// Contract ids longer than this are elided in the middle. +const CONTRACT_ID_MAX_LEN: usize = 16; + +/// Number of leading characters kept when a contract id is elided. +const CONTRACT_ID_HEAD_LEN: usize = 8; + +/// Number of trailing characters kept when a contract id is elided. +const CONTRACT_ID_TAIL_LEN: usize = 4; + +/// Symbol rendered in front of the focused frame. +const FOCUS_SYMBOL: &str = "▶ "; + +/// Message rendered when the replay did not record any invocation. +const EMPTY_MESSAGE: &str = "No contract invocations recorded"; + +/// A single entry of the invocation timeline. +#[derive(Debug, Clone, Copy)] +pub struct TimelineRow<'a> { + /// Nesting depth of the invocation; `0` is a top level transaction call. + pub depth: usize, + + /// `true` when the invocation is the last one of its sibling group. + pub is_last: bool, + + /// The invocation rendered by this row. + pub invocation: &'a ContractInvocation, +} + +/// Counts the invocations of a tree without flattening it. +pub fn count(invocations: &[ContractInvocation]) -> usize { + invocations + .iter() + .map(|invocation| 1 + count(&invocation.sub_invocations)) + .sum() +} + +/// Flattens the invocation tree into timeline order. +/// +/// Invocations are listed depth first, exactly like the trace tree printed by +/// `grat trace`, so a timeline index always points at the same invocation. +pub fn flatten(invocations: &[ContractInvocation]) -> Vec> { + let mut rows = Vec::new(); + collect_rows(invocations, 0, &mut rows); + rows +} + +fn collect_rows<'a>( + invocations: &'a [ContractInvocation], + depth: usize, + rows: &mut Vec>, +) { + let last_index = invocations.len().saturating_sub(1); + for (index, invocation) in invocations.iter().enumerate() { + rows.push(TimelineRow { + depth, + is_last: index == last_index, + invocation, + }); + collect_rows(&invocation.sub_invocations, depth + 1, rows); + } +} + +/// Renders the invocation timeline of a replayed transaction. +/// +/// `current_index` points at the focused frame inside the flattened timeline; +/// that frame is highlighted and scrolled into view. +pub fn render( + frame: &mut Frame<'_>, + area: Rect, + invocations: &[ContractInvocation], + current_index: usize, +) { + render_with_focus(frame, area, invocations, current_index, true); +} + +/// Renders the invocation timeline of a replayed transaction. +/// +/// The panel border is highlighted while the timeline owns the keyboard focus. +pub fn render_with_focus( + frame: &mut Frame<'_>, + area: Rect, + invocations: &[ContractInvocation], + current_index: usize, + focused: bool, +) { + let rows = flatten(invocations); + let block = panel(focused, current_index, rows.len()); + + if rows.is_empty() { + frame.render_widget(Paragraph::new(EMPTY_MESSAGE).block(block), area); + return; + } + + let selected = current_index.min(rows.len() - 1); + let items: Vec> = rows + .iter() + .map(|row| ListItem::new(row_line(row))) + .collect(); + let mut state = ListState::default().with_selected(Some(selected)); + + let list = List::new(items) + .block(block) + .highlight_symbol(FOCUS_SYMBOL) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_spacing(HighlightSpacing::Always); + + frame.render_stateful_widget(list, area, &mut state); +} + +/// Builds the panel that frames the timeline. +fn panel(focused: bool, current_index: usize, total: usize) -> Block<'static> { + let position = if total == 0 { + 0 + } else { + current_index.min(total - 1) + 1 + }; + + Block::default() + .borders(Borders::ALL) + .title(format!(" Timeline ({position}/{total}) ")) + .border_style(Style::default().fg(if focused { + Color::Cyan + } else { + Color::DarkGray + })) +} + +/// Renders a single invocation as a timeline row. +fn row_line(row: &TimelineRow<'_>) -> Line<'static> { + let invocation = row.invocation; + let indent = " ".repeat(INDENT_WIDTH * row.depth); + let connector = if row.is_last { "└─ " } else { "├─ " }; + + let (status, status_style) = if invocation.is_error { + ( + "✘ ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } else { + ("✔ ", Style::default().fg(Color::Green)) + }; + + Line::from(vec![ + Span::styled( + format!("{indent}{connector}"), + Style::default().fg(Color::DarkGray), + ), + Span::styled(status, status_style), + Span::styled( + invocation.function_name.clone(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + shorten_contract_id(&invocation.contract_id), + Style::default().fg(Color::Cyan), + ), + ]) +} + +/// Shortens a contract id so that it fits next to the call hierarchy. +/// +/// Stellar contract ids are 56 characters long, which does not fit in a +/// terminal panel, so long ids are elided in the middle while keeping their +/// prefix and suffix recognisable. +fn shorten_contract_id(contract_id: &str) -> String { + let length = contract_id.chars().count(); + if length <= CONTRACT_ID_MAX_LEN { + return contract_id.to_string(); + } + + let head: String = contract_id.chars().take(CONTRACT_ID_HEAD_LEN).collect(); + let tail: String = contract_id + .chars() + .skip(length - CONTRACT_ID_TAIL_LEN) + .collect(); + + format!("{head}…{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + use grat_core::types::trace::{HostFunctionCall, TraceId}; + use ratatui::{backend::TestBackend, Terminal}; + + fn invocation(contract_id: &str, function_name: &str, is_error: bool) -> ContractInvocation { + ContractInvocation { + trace_id: TraceId::new(), + contract_id: contract_id.to_string(), + function_name: function_name.to_string(), + arguments: Vec::new(), + return_value: None, + host_calls: Vec::::new(), + sub_invocations: Vec::new(), + total_cpu_instructions: 0, + total_memory_bytes: 0, + is_error, + } + } + + fn sample_tree() -> Vec { + let mut transfer = invocation("CONTRACT_A", "transfer", false); + transfer.sub_invocations = vec![invocation("CONTRACT_B", "mint", false)]; + + vec![ + transfer, + invocation("CONTRACT_C", "swap", true), + invocation("CONTRACT_D", "deposit", false), + ] + } + + fn render_to_buffer( + invocations: &[ContractInvocation], + current_index: usize, + width: u16, + height: u16, + ) -> Terminal { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), invocations, current_index)) + .unwrap(); + terminal + } + + fn buffer_text(terminal: &Terminal) -> String { + terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect() + } + + #[test] + fn flatten_lists_invocations_depth_first() { + let invocations = sample_tree(); + let rows = flatten(&invocations); + + let names: Vec<&str> = rows + .iter() + .map(|row| row.invocation.function_name.as_str()) + .collect(); + assert_eq!(names, vec!["transfer", "mint", "swap", "deposit"]); + + let depths: Vec = rows.iter().map(|row| row.depth).collect(); + assert_eq!(depths, vec![0, 1, 0, 0]); + } + + #[test] + fn flatten_marks_the_last_sibling_of_every_group() { + let invocations = sample_tree(); + let rows = flatten(&invocations); + + let last_flags: Vec = rows.iter().map(|row| row.is_last).collect(); + assert_eq!(last_flags, vec![false, true, false, true]); + } + + #[test] + fn count_matches_the_number_of_flattened_rows() { + let invocations = sample_tree(); + + assert_eq!(count(&invocations), 4); + assert_eq!(count(&invocations), flatten(&invocations).len()); + assert_eq!(count(&[]), 0); + } + + #[test] + fn render_shows_hierarchy_functions_contracts_and_status() { + let invocations = sample_tree(); + let terminal = render_to_buffer(&invocations, 0, 60, 10); + let text = buffer_text(&terminal); + + assert!(text.contains("transfer")); + assert!(text.contains("mint")); + assert!(text.contains("swap")); + assert!(text.contains("CONTRACT_A")); + assert!(text.contains("CONTRACT_B")); + assert!(text.contains("✔")); + assert!(text.contains("✘")); + } + + #[test] + fn render_indents_nested_invocations() { + let invocations = sample_tree(); + let terminal = render_to_buffer(&invocations, 0, 60, 10); + let buffer = terminal.backend().buffer(); + + // Rows start after the panel border and the focus symbol. + let top_level = buffer.cell((3, 1)).unwrap().symbol().to_string(); + let nested_indent = buffer.cell((3, 2)).unwrap().symbol().to_string(); + let nested_connector = buffer.cell((5, 2)).unwrap().symbol().to_string(); + + assert_eq!(top_level, "├"); + assert_eq!(nested_indent, " "); + assert_eq!(nested_connector, "└"); + } + + #[test] + fn render_highlights_the_focused_frame() { + let invocations = sample_tree(); + let terminal = render_to_buffer(&invocations, 2, 60, 10); + let buffer = terminal.backend().buffer(); + + // The focused frame is the third row of the list, its function name + // starts after the connector and the status marker. + let focused = buffer.cell((8, 3)).unwrap(); + assert_eq!(focused.symbol(), "s"); + assert_eq!(focused.bg, Color::DarkGray); + assert!(focused.modifier.contains(Modifier::BOLD)); + + let unfocused = buffer.cell((8, 1)).unwrap(); + assert_ne!(unfocused.bg, Color::DarkGray); + } + + #[test] + fn render_clamps_an_out_of_bounds_index() { + let invocations = sample_tree(); + let terminal = render_to_buffer(&invocations, 99, 60, 10); + let text = buffer_text(&terminal); + + assert!(text.contains("Timeline (4/4)")); + assert!(text.contains("deposit")); + } + + #[test] + fn render_reports_an_empty_timeline() { + let terminal = render_to_buffer(&[], 0, 60, 10); + let text = buffer_text(&terminal); + + assert!(text.contains("No contract invocations recorded")); + assert!(text.contains("Timeline (0/0)")); + } + + #[test] + fn short_contract_ids_are_kept_as_they_are() { + assert_eq!(shorten_contract_id("CB...123"), "CB...123"); + assert_eq!(shorten_contract_id("unknown"), "unknown"); + } + + #[test] + fn long_contract_ids_are_elided_in_the_middle() { + let contract_id = "CBQYN5XZ6DK2GSHYPG5M3AEH2M4HKZLK5M5VPGLMIYUV4WXV6RSOMCC5"; + assert_eq!(shorten_contract_id(contract_id), "CBQYN5XZ…MCC5"); + } +}