From 985a0c315c8012793a4a32ba96eac1c86fc73d26 Mon Sep 17 00:00:00 2001 From: Marywase <207442981+Marywase@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:22:29 +0100 Subject: [PATCH] feat(tui): implement debugger controls and recorded replay navigation --- README.md | 15 ++ crates/cli/src/tui/app.rs | 261 ++++++++++++++++++++++--- crates/cli/src/tui/state.rs | 137 ++++++++++++- crates/cli/src/tui/widgets/controls.rs | 147 +++++++++++++- 4 files changed, 532 insertions(+), 28 deletions(-) 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 946f70eb..31882549 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -1,31 +1,246 @@ +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; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, + Frame, Terminal, +}; + +use super::{ + state::{Panel, TuiState}, + widgets::controls, +}; + +/// 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(guard) + } +} + +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 { - tx_hash: tx_hash.to_string(), - selected_panel: crate::tui::state::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(), + anyhow::ensure!( + io::stdin().is_terminal() && io::stdout().is_terminal(), + "Interactive replay requires a terminal; use grat trace for redirected 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::new(trace); + let _guard = TerminalGuard::enter()?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + loop { + terminal.draw(|frame| render(frame, &state))?; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if handle_key(&mut state, key) => break, + Event::Resize(_, _) => terminal.autoresize()?, + _ => {} + } } } - println!( - "TUI debugger launching for {} on {:?}...", - state.tx_hash, network.network + Ok(()) +} + +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; + } + 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 +} + +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 + })) +} + +fn render(frame: &mut Frame<'_>, state: &TuiState) { + let area = frame.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 {} | {}", state.tx_hash, state.network)), + sections[0], + ); + let panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(sections[1]); + let items: Vec> = state + .entries + .iter() + .map(|entry| { + ListItem::new(format!( + "{}{}{}", + " ".repeat(entry.depth.min(20)), + entry.function, + if entry.is_error { " [error]" } else { "" } + )) + }) + .collect(); + let mut selection = ListState::default(); + if !state.entries.is_empty() { + selection.select(Some(state.scroll_offset)); + } + frame.render_stateful_widget( + List::new(items) + .block(panel("Timeline", state.selected_panel == Panel::Timeline)) + .highlight_symbol("> ") + .highlight_style(Style::default().fg(Color::Yellow)), + panels[0], + &mut selection, ); - println!( - "Selected panel: {:?} (scroll offset: {})", - state.selected_panel, state.scroll_offset + 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 + ) + }, ); - println!("(Not yet implemented — requires ratatui setup)"); - Ok(()) + 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 ratatui::backend::TestBackend; + + fn press(state: &mut TuiState, code: KeyCode) -> bool { + handle_key(state, KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + 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_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); + 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); + assert_eq!(state.replay_status(), "Replay: no invocations"); + } + + #[test] + 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 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")); + } + if width == 120 { + println!("{}", terminal.backend()); + } + } + } } diff --git a/crates/cli/src/tui/state.rs b/crates/cli/src/tui/state.rs index d26a1db6..206f6a58 100644 --- a/crates/cli/src/tui/state.rs +++ b/crates/cli/src/tui/state.rs @@ -1,15 +1,146 @@ -#![allow(dead_code)] +use grat_core::types::trace::{ContractInvocation, ExecutionTrace, ResourceProfile}; -#[derive(Debug)] pub struct TuiState { pub tx_hash: String, + pub network: String, pub selected_panel: Panel, pub scroll_offset: usize, + pub entries: 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, + selected_panel: Panel::Timeline, + scroll_offset: 0, + entries, + 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")); + } + } + } +}