diff --git a/README.md b/README.md index 3ce373ad..878257ee 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,21 @@ Grat is organized as a modular monorepo: ## Documentation +### Interactive replay controls + +Run `grat replay --interactive` in a terminal to reconstruct a replay +and browse its recorded invocations. `Tab` / `Shift+Tab` cycles panel focus; +`j` / `k` (or arrow keys) navigates the trace. `s` advances into the next +invocation, while `o` skips nested invocations to the next sibling or ancestor. +Use `q`, `Esc`, or `Ctrl+C` to quit. These controls browse the captured replay; +they do not pause or execute a live contract VM. + +The footer shows the replay position and total CPU instructions / memory bytes. +Percentages use the limits supplied by the replay profile; `n/a` means the +profile did not provide a limit, not zero usage. The current replay profiler +does not populate those limits. Terminal setup happens after reconstruction, +and raw mode, cursor visibility, and the alternate screen are restored on exit. + For full technical specifications, architecture deep-dives, and API references, visit our [Live Documentation](https://grat-ddf93e61.mintlify.app/docs/introduction). diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index f0b7338d..60d2580e 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -9,29 +9,29 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use grat_core::types::{config::NetworkConfig, trace::ExecutionTrace}; +use grat_core::types::config::NetworkConfig; use ratatui::{ backend::CrosstermBackend, - layout::{Constraint, Layout}, + layout::{Constraint, Direction, Layout}, style::{Color, Style}, - text::{Line, Span}, - widgets::Paragraph, + widgets::{Block, Borders, Paragraph, Wrap}, Frame, Terminal, }; use super::{ state::{Panel, TuiState}, - widgets::timeline, + widgets::{controls, timeline}, }; -/// Restores the terminal on normal exit, I/O errors and panic unwinding. +/// Restore the terminal on normal exit, I/O errors, and panic unwinding. struct TerminalGuard; impl TerminalGuard { fn enter() -> io::Result { enable_raw_mode()?; + let guard = Self; execute!(io::stdout(), EnterAlternateScreen, Hide)?; - Ok(Self) + Ok(guard) } } @@ -45,275 +45,175 @@ impl Drop for TerminalGuard { pub async fn launch(tx_hash: &str, network: &NetworkConfig) -> anyhow::Result<()> { anyhow::ensure!( io::stdin().is_terminal() && io::stdout().is_terminal(), - "Interactive replay requires a terminal; use `grat trace ` for redirected output." + "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. + // Fetch/reconstruct before entering raw mode: replay errors retain normal 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: Panel::Timeline, - scroll_offset: 0, - }; - + let mut state = TuiState::new(trace); let _guard = TerminalGuard::enter()?; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; - loop { - terminal.draw(|frame| render(frame, &state, &trace))?; + terminal.draw(|frame| render(frame, &state))?; if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - if handle_key(&mut state, key, &trace) { - break; - } + match event::read()? { + Event::Key(key) if handle_key(&mut state, key) => break, + Event::Resize(_, _) => terminal.autoresize()?, + _ => {} } } } - Ok(()) } -/// Handles a key press and reports whether the debugger should exit. -fn handle_key(state: &mut TuiState, key: KeyEvent, trace: &ExecutionTrace) -> bool { +fn handle_key(state: &mut TuiState, key: KeyEvent) -> 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, + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + return false; } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Tab => state.switch_panel(key.modifiers.contains(KeyModifiers::SHIFT)), + KeyCode::BackTab => state.switch_panel(true), + KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('s') => state.next(), + KeyCode::Char('k') | KeyCode::Up => state.previous(), + KeyCode::Char('o') => state.step_over(), + _ => {} + } + 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); +fn panel(title: &str, selected: bool) -> Block<'_> { + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(if selected { + Color::Cyan + } else { + Color::DarkGray + })) } -/// Draws the debugger screen. -fn render(frame: &mut Frame<'_>, state: &TuiState, trace: &ExecutionTrace) { +fn render(frame: &mut Frame<'_>, state: &TuiState) { 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); - + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(controls::height(area.width).min(area.height.saturating_sub(1))), + ]) + .split(area); + frame.render_widget( + Paragraph::new(format!( + "Replay {} | {} | ledger {}", + state.tx_hash, state.network, state.ledger_sequence + )), + sections[0], + ); + let panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(sections[1]); timeline::render_with_focus( frame, - body_area, - &trace.invocations, + panels[0], + &state.invocations, state.scroll_offset, - matches!(state.selected_panel, Panel::Timeline), + 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)), - ])) + let details = state.entries.get(state.scroll_offset).map_or_else( + || "No recorded invocations. [q] quits.".to_owned(), + |entry| { + format!( + "Contract: {}\nFunction: {}\nArguments: {}\nReturn: {}\nError: {}", + entry.contract, + entry.function, + entry.arguments.join(", "), + entry.return_value.as_deref().unwrap_or("unavailable"), + entry.is_error + ) + }, + ); + frame.render_widget( + Paragraph::new(details) + .wrap(Wrap { trim: true }) + .block(panel("Inspector", state.selected_panel == Panel::Inspector)), + panels[1], + ); + controls::render(frame, sections[2], state); } #[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) + fn press(state: &mut TuiState, code: KeyCode) -> bool { + handle_key(state, KeyEvent::new(code, KeyModifiers::NONE)) } #[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 - )); + fn panels_cycle_in_both_directions() { + let mut state = super::super::state::fixture(); + for expected in [Panel::Inspector, Panel::Controls, Panel::Timeline] { + assert!(!press(&mut state, KeyCode::Tab)); + assert_eq!(state.selected_panel, expected); + } + press(&mut state, KeyCode::BackTab); + assert_eq!(state.selected_panel, Panel::Controls); } #[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 - )); + fn navigation_and_steps_use_real_trace_boundaries_and_depth() { + let mut state = super::super::state::fixture(); + press(&mut state, KeyCode::Char('k')); 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, - ); + press(&mut state, KeyCode::Char('s')); + assert_eq!(state.entries[state.scroll_offset].function, "child"); + press(&mut state, KeyCode::Up); + press(&mut state, KeyCode::Char('o')); + assert_eq!(state.entries[state.scroll_offset].function, "sibling"); + press(&mut state, KeyCode::Char('j')); + assert_eq!(state.scroll_offset, 2); + assert_eq!(state.replay_status(), "Replay: End 3/3"); + state.entries.clear(); + state.scroll_offset = 0; + for code in [KeyCode::Char('s'), KeyCode::Char('o'), KeyCode::Char('k')] { + press(&mut state, code); } 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); + assert_eq!(state.replay_status(), "Replay: no invocations"); } #[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, - ); + fn releases_are_ignored_and_quit_keys_work() { + let mut state = super::super::state::fixture(); + let mut release = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + release.kind = KeyEventKind::Release; + assert!(!handle_key(&mut state, release)); assert_eq!(state.scroll_offset, 0); + assert!(press(&mut state, KeyCode::Char('q'))); + assert!(press(&mut state, KeyCode::Esc)); + assert!(handle_key( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) + )); } #[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(); - + fn timeline_and_inspector_follow_the_controls_selection() { + let mut state = super::super::state::fixture(); + press(&mut state, KeyCode::Char('s')); + let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap(); + terminal.draw(|frame| render(frame, &state)).unwrap(); let text: String = terminal .backend() .buffer() @@ -321,11 +221,33 @@ mod tests { .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")); + assert!(text.contains("Function: child")); + assert!(text.contains("ledger 1")); + assert!(text.contains("Replay: Paused 2/3")); + assert_eq!(timeline::count(&state.invocations), state.entries.len()); + } + + #[test] + fn layout_resizes_and_keeps_footer_visible() { + let state = super::super::state::fixture(); + for (width, height) in [(120, 24), (60, 20), (40, 15), (1, 1), (0, 0)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, &state)).unwrap(); + if width >= 40 { + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(text.contains("[q] Quit") && text.contains("Replay: Paused")); + assert!(text.contains("Timeline")); + } + if width == 120 { + println!("{}", terminal.backend()); + } + } } } diff --git a/crates/cli/src/tui/state.rs b/crates/cli/src/tui/state.rs index d26a1db6..b5c658ee 100644 --- a/crates/cli/src/tui/state.rs +++ b/crates/cli/src/tui/state.rs @@ -1,15 +1,150 @@ -#![allow(dead_code)] +use grat_core::types::trace::{ContractInvocation, ExecutionTrace, ResourceProfile}; -#[derive(Debug)] pub struct TuiState { pub tx_hash: String, + pub network: String, + pub ledger_sequence: u32, pub selected_panel: Panel, pub scroll_offset: usize, + pub entries: Vec, + pub invocations: Vec, + pub resources: ResourceProfile, } -#[derive(Clone, Copy, Debug)] +pub struct TraceEntry { + pub depth: usize, + pub contract: String, + pub function: String, + pub arguments: Vec, + pub return_value: Option, + pub is_error: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Panel { Timeline, Inspector, Controls, } + +impl TuiState { + pub fn new(trace: ExecutionTrace) -> Self { + let mut entries = Vec::new(); + append_invocations(&trace.invocations, 0, &mut entries); + Self { + tx_hash: trace.tx_hash, + network: trace.network, + ledger_sequence: trace.ledger_sequence, + selected_panel: Panel::Timeline, + scroll_offset: 0, + entries, + invocations: trace.invocations, + resources: trace.resource_profile, + } + } + + pub fn switch_panel(&mut self, reverse: bool) { + self.selected_panel = match (self.selected_panel, reverse) { + (Panel::Timeline, false) | (Panel::Controls, true) => Panel::Inspector, + (Panel::Inspector, false) | (Panel::Timeline, true) => Panel::Controls, + (Panel::Controls, false) | (Panel::Inspector, true) => Panel::Timeline, + }; + } + + pub fn next(&mut self) { + self.scroll_offset = self + .scroll_offset + .saturating_add(1) + .min(self.entries.len().saturating_sub(1)); + } + + pub fn previous(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + /// Advance past descendants of the selected recorded invocation. + pub fn step_over(&mut self) { + let Some(current) = self.entries.get(self.scroll_offset) else { + return; + }; + self.scroll_offset = self + .entries + .iter() + .enumerate() + .skip(self.scroll_offset + 1) + .find(|(_, entry)| entry.depth <= current.depth) + .map_or(self.entries.len().saturating_sub(1), |(index, _)| index); + } + + pub fn can_step(&self) -> bool { + self.scroll_offset.saturating_add(1) < self.entries.len() + } + + pub fn replay_status(&self) -> String { + if self.entries.is_empty() { + "Replay: no invocations".to_owned() + } else { + format!( + "Replay: {} {}/{}", + if self.can_step() { "Paused" } else { "End" }, + self.scroll_offset + 1, + self.entries.len() + ) + } + } +} + +fn append_invocations( + invocations: &[ContractInvocation], + depth: usize, + entries: &mut Vec, +) { + for invocation in invocations { + entries.push(TraceEntry { + depth, + contract: invocation.contract_id.clone(), + function: invocation.function_name.clone(), + arguments: invocation.arguments.clone(), + return_value: invocation.return_value.clone(), + is_error: invocation.is_error, + }); + append_invocations(&invocation.sub_invocations, depth + 1, entries); + } +} + +#[cfg(test)] +pub fn fixture() -> TuiState { + use grat_core::types::trace::{StateDiff, TraceId}; + fn invocation(name: &str, children: Vec) -> ContractInvocation { + ContractInvocation { + trace_id: TraceId("fixture".to_owned()), + contract_id: "CEXAMPLE".to_owned(), + function_name: name.to_owned(), + arguments: vec![], + return_value: None, + host_calls: vec![], + sub_invocations: children, + total_cpu_instructions: 0, + total_memory_bytes: 0, + is_error: false, + } + } + TuiState::new(ExecutionTrace { + tx_hash: "abc123".to_owned(), + network: "testnet".to_owned(), + ledger_sequence: 1, + invocations: vec![ + invocation("parent", vec![invocation("child", vec![])]), + invocation("sibling", vec![]), + ], + state_diff: StateDiff::default(), + diagnostic_events: vec![], + resource_profile: ResourceProfile { + total_cpu: 250, + cpu_limit: 1000, + total_memory: 500, + memory_limit: 1000, + ..ResourceProfile::default() + }, + }) +} diff --git a/crates/cli/src/tui/widgets/controls.rs b/crates/cli/src/tui/widgets/controls.rs index 579a2ec4..8d0cea61 100644 --- a/crates/cli/src/tui/widgets/controls.rs +++ b/crates/cli/src/tui/widgets/controls.rs @@ -1,3 +1,146 @@ -#![allow(dead_code)] +use crate::tui::state::{Panel, TuiState}; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; -pub fn render() {} +pub fn height(width: u16) -> u16 { + if width >= 110 { + 5 + } else if width >= 55 { + 7 + } else { + 10 + } +} + +fn percentage(used: u64, limit: u64) -> String { + if limit == 0 { + "n/a".to_owned() + } else { + format!("{:.0}%", used as f64 / limit as f64 * 100.0) + } +} + +pub fn render(frame: &mut Frame<'_>, area: Rect, state: &TuiState) { + let active = Style::default().fg(Color::Cyan); + let step = if state.can_step() { + active + } else { + Style::default().fg(Color::DarkGray) + }; + let cpu = format!( + "CPU: {} insns ({})", + state.resources.total_cpu, + percentage(state.resources.total_cpu, state.resources.cpu_limit) + ); + let memory = format!( + "Mem: {} bytes ({})", + state.resources.total_memory, + percentage(state.resources.total_memory, state.resources.memory_limit) + ); + let mut lines = if area.width >= 110 { + vec![ + Line::from(vec![ + Span::styled("[Tab] Switch Panel [j/k] Navigate ", active), + Span::styled("[s] Step Into [o] Step Over ", step), + Span::styled("[q] Quit", active), + ]), + Line::from(format!("{cpu} | {memory}")), + ] + } else if area.width >= 55 { + vec![ + Line::styled("[Tab] Switch Panel [j/k] Navigate [q] Quit", active), + Line::styled("[s] Step Into [o] Step Over", step), + Line::from(cpu), + Line::from(memory), + ] + } else { + vec![ + Line::styled("[Tab] Panel [q] Quit", active), + Line::styled("[j/k] Navigate", active), + Line::styled("[s] Step Into", step), + Line::styled("[o] Step Over", step), + Line::from(cpu), + Line::from(memory), + ] + }; + lines.push(Line::from(state.replay_status())); + let border = if state.selected_panel == Panel::Controls { + Color::Cyan + } else { + Color::DarkGray + }; + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: true }).block( + Block::default() + .borders(Borders::ALL) + .title("Controls | recorded replay totals") + .border_style(Style::default().fg(border)), + ), + area, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{backend::TestBackend, Terminal}; + + #[test] + fn footer_renders_controls_status_and_real_percentages() { + let mut terminal = Terminal::new(TestBackend::new(120, 5)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), &crate::tui::state::fixture())) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + for expected in [ + "[Tab] Switch Panel", + "[j/k] Navigate", + "[s] Step Into", + "[o] Step Over", + "[q] Quit", + "250 insns (25%)", + "500 bytes (50%)", + "Replay: Paused 1/3", + ] { + assert!(text.contains(expected), "missing {expected}: {text}"); + } + } + + #[test] + fn missing_limits_are_not_reported_as_zero_and_overruns_are_visible() { + assert_eq!(percentage(25, 0), "n/a"); + assert_eq!(percentage(0, 100), "0%"); + assert_eq!(percentage(150, 100), "150%"); + } + + #[test] + fn footer_fits_narrow_terminal_and_survives_tiny_sizes() { + for (width, rows) in [(60, 7), (40, 10), (1, 1), (0, 0)] { + let mut terminal = Terminal::new(TestBackend::new(width, rows)).unwrap(); + terminal + .draw(|frame| render(frame, frame.area(), &crate::tui::state::fixture())) + .unwrap(); + if width >= 40 { + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(text.contains("[q] Quit") && text.contains("Replay: Paused")); + } + } + } +}