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"); + } +}