Skip to main content

anchor_cli/debugger/
tui.rs

1//! `anchor debugger` TUI.
2//!
3//! Two screens drive the whole experience:
4//!
5//! - [`Screen::Picker`] — startup. Lists every captured `(test, tx)` pair
6//!   with total CU; user picks one to step into. Also reachable from the
7//!   stepper via `t`, so jumping across txs within a session is one keypress.
8//! - [`Screen::Stepper`] — foundry-style instruction stepper with panes for
9//!   the instruction list, registers, call stack, and source.
10//!
11//! Keybinds mirror `forge test --debug` where it makes sense:
12//!
13//! ```text
14//! j / k / ↑ / ↓       step ± 1 instruction
15//! s / a               step over next/prev call
16//! c / C               previous / next CPI invocation
17//! g / G               first / last step in current node
18//! t                   return to tx picker (or select in picker)
19//! K / J               scroll call stack
20//! q                   quit
21//! 10k                 repeat count (e.g. move up 10 steps)
22//! ```
23
24use {
25    super::{
26        highlight::highlight_rust,
27        model::{DebugSession, DebugStep, DebugTx},
28        path_label::{classify, PathLabel},
29    },
30    ratatui::{
31        backend::CrosstermBackend,
32        crossterm::{
33            event::{
34                self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent,
35                KeyModifiers,
36            },
37            execute,
38            terminal::{
39                disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
40            },
41        },
42        layout::{Alignment, Constraint, Direction, Layout, Rect},
43        style::{Color, Modifier, Style},
44        text::{Line, Span},
45        widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
46        Frame, Terminal,
47    },
48    std::{collections::HashMap, io, path::PathBuf},
49};
50
51type DebugTerm = Terminal<CrosstermBackend<io::Stdout>>;
52
53/// Run the debugger TUI over a fully-populated [`DebugSession`]. Blocks
54/// until the user hits `q`.
55pub fn run(session: DebugSession) -> anyhow::Result<()> {
56    if session.txs.is_empty() {
57        anyhow::bail!(
58            "no traces to debug — did your tests call `anchor_v2_testing::svm()` and complete at \
59             least one transaction?"
60        );
61    }
62
63    let backend = CrosstermBackend::new(io::stdout());
64    let terminal = Terminal::new(backend)?;
65    let mut guard = TerminalGuard::new(terminal);
66    let mut app = App::new(session);
67    loop {
68        guard.term.draw(|f| app.draw(f))?;
69        // Block for the first event of the burst, then drain anything else
70        // crossterm has buffered before the next redraw. Holding j/k
71        // generates one event per OS keyboard-repeat tick, which used to
72        // trigger one full redraw per event — now they collapse into one
73        // step delta and one render. Safety cap of 256 events keeps a
74        // pathological event flood from starving the redraw entirely.
75        let mut flow = app.handle(event::read()?);
76        let mut drained = 0;
77        while flow == Flow::Continue && drained < 256 && event::poll(std::time::Duration::ZERO)? {
78            flow = app.handle(event::read()?);
79            drained += 1;
80        }
81        if flow == Flow::Quit {
82            break;
83        }
84    }
85    Ok(())
86}
87
88enum Screen {
89    Picker,
90    Stepper,
91}
92
93#[derive(PartialEq, Eq)]
94enum Flow {
95    Continue,
96    Quit,
97}
98
99struct App {
100    session: DebugSession,
101    screen: Screen,
102    /// Picker list state.
103    picker: ListState,
104    /// Index into `session.txs`.
105    current_tx: usize,
106    /// Index into `session.txs[current_tx].nodes` — the active call context
107    /// (0 = top-level, 1.. = CPIs in order).
108    current_node: usize,
109    /// Index into the active node's `steps`.
110    current_step: usize,
111    /// Digit buffer for `10k`-style repeats.
112    key_buffer: String,
113    /// File contents cache. Source files are read on first access (path
114    /// resolution + disk read) and kept for the session — frame-rate
115    /// stepping was bottlenecked on `read_to_string` per redraw.
116    file_cache: HashMap<PathBuf, FileEntry>,
117    /// Per-(file, line) highlighted-span cache. Avoids re-running syntect
118    /// on the same source line every redraw while you hold j/k.
119    highlight_cache: HashMap<(PathBuf, u32), Vec<Span<'static>>>,
120    /// Per-path label cache (crate / stdlib / workspace classification).
121    /// `classify` walks Cargo.toml siblings on workspace files, so we only
122    /// want to do that once per file across the whole session.
123    label_cache: HashMap<PathBuf, PathLabel>,
124    /// Pre-baked picker rows: a flat sequence of `Header(test_name)` /
125    /// `Tx(tx_idx)` entries, sorted so each test's children sit under it.
126    /// Built once at App-init from the (test, tx)-sorted `session.txs`.
127    picker_rows: Vec<PickerRow>,
128}
129
130#[derive(Clone)]
131enum PickerRow {
132    /// Test name banner — non-selectable. Skipped by j/k navigation.
133    Header(String),
134    /// Selectable row pointing back into `session.txs`.
135    Tx(usize),
136}
137
138/// One entry in [`App::file_cache`]: either the file's lines, or the error
139/// we hit reading it (so we don't retry the disk every frame for missing
140/// stdlib paths).
141enum FileEntry {
142    Loaded(Vec<String>),
143    Missing(String),
144}
145
146impl App {
147    fn new(session: DebugSession) -> Self {
148        // Build the grouped row list once: emit a header row whenever the
149        // test_name changes, then a Tx row per tx in that group. Picker
150        // selection navigates this list with `next_selectable_*` to skip
151        // header rows so the user never lands on one.
152        let mut picker_rows: Vec<PickerRow> = Vec::with_capacity(session.txs.len() * 2);
153        let mut last_test: Option<&str> = None;
154        for (i, tx) in session.txs.iter().enumerate() {
155            if last_test != Some(tx.test_name.as_str()) {
156                picker_rows.push(PickerRow::Header(tx.test_name.clone()));
157                last_test = Some(tx.test_name.as_str());
158            }
159            picker_rows.push(PickerRow::Tx(i));
160        }
161        let initial = picker_rows
162            .iter()
163            .position(|r| matches!(r, PickerRow::Tx(_)))
164            .unwrap_or(0);
165
166        let mut picker = ListState::default();
167        picker.select(Some(initial));
168        Self {
169            session,
170            screen: Screen::Picker,
171            picker,
172            current_tx: 0,
173            current_node: 0,
174            current_step: 0,
175            key_buffer: String::new(),
176            file_cache: HashMap::new(),
177            highlight_cache: HashMap::new(),
178            label_cache: HashMap::new(),
179            picker_rows,
180        }
181    }
182
183    fn draw(&mut self, f: &mut Frame<'_>) {
184        match self.screen {
185            Screen::Picker => self.draw_picker(f),
186            Screen::Stepper => self.draw_stepper(f),
187        }
188    }
189
190    fn handle(&mut self, ev: Event) -> Flow {
191        match (ev, &self.screen) {
192            (Event::Key(k), Screen::Picker) => self.handle_picker_key(k),
193            (Event::Key(k), Screen::Stepper) => self.handle_stepper_key(k),
194            _ => Flow::Continue,
195        }
196    }
197
198    // --- picker --------------------------------------------------------------
199
200    fn draw_picker(&mut self, f: &mut Frame<'_>) {
201        let area = f.area();
202        let [title, list, footer] = Layout::new(
203            Direction::Vertical,
204            [
205                Constraint::Length(3),
206                Constraint::Min(3),
207                Constraint::Length(3),
208            ],
209        )
210        .areas(area);
211
212        let title_block = Paragraph::new(Line::from(vec![
213            Span::styled("anchor debugger", Style::new().add_modifier(Modifier::BOLD)),
214            Span::raw(format!("  —  {} transaction(s)", self.session.txs.len())),
215        ]))
216        .block(Block::default().borders(Borders::ALL));
217        f.render_widget(title_block, title);
218
219        let test_count = self
220            .picker_rows
221            .iter()
222            .filter(|r| matches!(r, PickerRow::Header(_)))
223            .count();
224        let items: Vec<ListItem> = self
225            .picker_rows
226            .iter()
227            .map(|row| match row {
228                PickerRow::Header(name) => {
229                    // Compact header: indented less than the tx rows so it
230                    // visually sits "above" them. Dim by default so the
231                    // selected tx still pops.
232                    ListItem::new(Line::from(vec![Span::styled(
233                        name.to_string(),
234                        Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
235                    )]))
236                    // Disable selection-highlight on header rows so j/k
237                    // visually skips them even mid-frame; the
238                    // `next_selectable_*` helpers do the actual skipping.
239                    .style(Style::new())
240                }
241                PickerRow::Tx(idx) => {
242                    let tx = &self.session.txs[*idx];
243                    let top = tx
244                        .nodes
245                        .first()
246                        .map(|n| n.program_label.as_str())
247                        .unwrap_or("");
248                    let cpis = tx.nodes.len().saturating_sub(1);
249                    let cpi_badge = if cpis > 0 {
250                        format!("  +{cpis} CPI")
251                    } else {
252                        String::new()
253                    };
254                    ListItem::new(Line::from(vec![
255                        // Tree-style indent so the parent test reads as a
256                        // group header.
257                        Span::styled("  ├─ ", Style::new().fg(Color::DarkGray)),
258                        Span::styled(
259                            format!("tx{:<3}", tx.tx_seq),
260                            Style::new().fg(Color::Yellow),
261                        ),
262                        Span::raw(format!("  {:>8} CU  ", tx.total_cu)),
263                        Span::raw(top.to_string()),
264                        Span::styled(cpi_badge, Style::new().fg(Color::Magenta)),
265                    ]))
266                }
267            })
268            .collect();
269
270        let title = format!(
271            " {} test(s), {} tx(s) — select one to step into ",
272            test_count,
273            self.session.txs.len()
274        );
275        let list_widget = List::new(items)
276            .block(Block::default().title(title).borders(Borders::ALL))
277            .highlight_style(
278                Style::new()
279                    .bg(Color::DarkGray)
280                    .add_modifier(Modifier::BOLD),
281            )
282            .highlight_symbol("> ");
283        f.render_stateful_widget(list_widget, list, &mut self.picker);
284
285        let help = Paragraph::new("j/k or ↑/↓  select    enter/t  open    q  quit")
286            .block(Block::default().borders(Borders::ALL))
287            .alignment(Alignment::Center);
288        f.render_widget(help, footer);
289    }
290
291    fn handle_picker_key(&mut self, k: KeyEvent) -> Flow {
292        match k.code {
293            KeyCode::Char('q') | KeyCode::Esc => return Flow::Quit,
294            KeyCode::Char('j') | KeyCode::Down => self.picker_next(),
295            KeyCode::Char('k') | KeyCode::Up => self.picker_prev(),
296            KeyCode::Char('g') | KeyCode::Home => {
297                self.picker.select(self.first_selectable());
298            }
299            KeyCode::Char('G') | KeyCode::End => {
300                self.picker.select(self.last_selectable());
301            }
302            KeyCode::Enter | KeyCode::Char('t') | KeyCode::Char('l') | KeyCode::Right => {
303                self.open_selected();
304            }
305            _ => {}
306        }
307        Flow::Continue
308    }
309
310    /// Step the picker down to the next `Tx` row, skipping headers.
311    /// Stops at the last selectable row instead of wrapping.
312    fn picker_next(&mut self) {
313        let from = self.picker.selected().unwrap_or(0);
314        let next = self
315            .picker_rows
316            .iter()
317            .enumerate()
318            .skip(from + 1)
319            .find(|(_, r)| matches!(r, PickerRow::Tx(_)))
320            .map(|(i, _)| i)
321            .unwrap_or(from);
322        self.picker.select(Some(next));
323    }
324
325    fn picker_prev(&mut self) {
326        let from = self.picker.selected().unwrap_or(0);
327        let next = self
328            .picker_rows
329            .iter()
330            .enumerate()
331            .take(from)
332            .rev()
333            .find(|(_, r)| matches!(r, PickerRow::Tx(_)))
334            .map(|(i, _)| i)
335            .unwrap_or(from);
336        self.picker.select(Some(next));
337    }
338
339    fn first_selectable(&self) -> Option<usize> {
340        self.picker_rows
341            .iter()
342            .position(|r| matches!(r, PickerRow::Tx(_)))
343    }
344
345    fn last_selectable(&self) -> Option<usize> {
346        self.picker_rows
347            .iter()
348            .rposition(|r| matches!(r, PickerRow::Tx(_)))
349    }
350
351    fn open_selected(&mut self) {
352        let Some(i) = self.picker.selected() else {
353            return;
354        };
355        if let Some(PickerRow::Tx(tx_idx)) = self.picker_rows.get(i) {
356            self.current_tx = *tx_idx;
357            self.current_node = 0;
358            self.current_step = 0;
359            self.screen = Screen::Stepper;
360        }
361    }
362
363    // --- stepper -------------------------------------------------------------
364
365    fn current_tx(&self) -> &DebugTx {
366        &self.session.txs[self.current_tx]
367    }
368
369    fn current_steps(&self) -> &[DebugStep] {
370        &self.current_tx().nodes[self.current_node].steps
371    }
372
373    fn draw_stepper(&mut self, f: &mut Frame<'_>) {
374        // (existing body — `&mut self` already in scope so the source pane
375        // can write through to `file_cache` / `highlight_cache`.)
376        let area = f.area();
377        if area.width < 80 || area.height < 20 {
378            let msg = Paragraph::new(format!(
379                "terminal too small ({}x{}) — need at least 80x20",
380                area.width, area.height
381            ))
382            .alignment(Alignment::Center)
383            .wrap(Wrap { trim: true });
384            f.render_widget(msg, area);
385            return;
386        }
387
388        // Header (title + invocation breadcrumb), main region, footer.
389        let [header, main, footer] = Layout::new(
390            Direction::Vertical,
391            [
392                Constraint::Length(4),
393                Constraint::Min(10),
394                Constraint::Length(3),
395            ],
396        )
397        .areas(area);
398
399        self.draw_stepper_header(f, header);
400
401        // Main: left = instructions, right = (regs over source).
402        let [left, right] = Layout::new(
403            Direction::Horizontal,
404            [Constraint::Percentage(55), Constraint::Percentage(45)],
405        )
406        .areas(main);
407
408        self.draw_instructions(f, left);
409
410        let [right_top, right_bot] = Layout::new(
411            Direction::Vertical,
412            [Constraint::Length(14), Constraint::Min(5)],
413        )
414        .areas(right);
415        self.draw_registers(f, right_top);
416        self.draw_source(f, right_bot);
417
418        let footer_text = Paragraph::new(
419            "j/k step   s/a step-over   c/C prev/next CPI   g/G first/last   t tx picker   q quit",
420        )
421        .block(Block::default().borders(Borders::ALL))
422        .alignment(Alignment::Center);
423        f.render_widget(footer_text, footer);
424    }
425
426    fn draw_stepper_header(&self, f: &mut Frame<'_>, area: Rect) {
427        let tx = self.current_tx();
428        let node = &tx.nodes[self.current_node];
429        let n_nodes = tx.nodes.len();
430        let step_total = node.steps.len();
431
432        // Line 1: test/tx breadcrumb + step + cu.
433        let title_line = Line::from(vec![
434            Span::styled(
435                format!("{} ", tx.test_name),
436                Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
437            ),
438            Span::raw(format!("· tx{} ", tx.tx_seq)),
439            Span::styled(
440                format!(
441                    "· step {}/{}  cu {}",
442                    self.current_step + 1,
443                    step_total.max(1),
444                    node.steps
445                        .get(self.current_step)
446                        .map(|s| s.cu_cumulative)
447                        .unwrap_or(0)
448                ),
449                Style::new().fg(Color::Yellow),
450            ),
451        ]);
452
453        // Line 2: invocation breadcrumb. Renders all nodes with the
454        // current one inverted so c/C navigation is obvious even when
455        // every node is the same program (the self-CPI case).
456        let invocations_line = if n_nodes <= 1 {
457            Line::from(vec![Span::styled(
458                format!(
459                    "invocations: 1/1 ({})  — single invocation, c/C disabled",
460                    node.program_label
461                ),
462                Style::new().fg(Color::DarkGray),
463            )])
464        } else {
465            // Inactive items render in the default foreground (terminal's
466            // own white-ish), active item is inverted so it pops without
467            // imposing a hard-coded color the user's theme didn't pick.
468            // Separators stay dim so the eye lands on the items themselves.
469            let mut spans: Vec<Span<'static>> = vec![Span::styled(
470                "invocations: ",
471                Style::new().fg(Color::DarkGray),
472            )];
473            for (i, n) in tx.nodes.iter().enumerate() {
474                let is_cur = i == self.current_node;
475                let kind = if i == 0 { "top" } else { "cpi" };
476                let label = format!(" #{} {} {} ", i + 1, kind, n.program_label);
477                let style = if is_cur {
478                    Style::new().add_modifier(Modifier::REVERSED | Modifier::BOLD)
479                } else {
480                    Style::new()
481                };
482                spans.push(Span::styled(label, style));
483                if i + 1 < tx.nodes.len() {
484                    spans.push(Span::styled(" → ", Style::new().fg(Color::DarkGray)));
485                }
486            }
487            Line::from(spans)
488        };
489
490        let header = Paragraph::new(vec![title_line, invocations_line])
491            .block(Block::default().borders(Borders::ALL));
492        f.render_widget(header, area);
493    }
494
495    fn draw_instructions(&mut self, f: &mut Frame<'_>, area: Rect) {
496        let tx = self.current_tx();
497        let node = &tx.nodes[self.current_node];
498        let Some(step) = node.steps.get(self.current_step) else {
499            f.render_widget(
500                Paragraph::new("(no steps)").block(
501                    Block::default()
502                        .title(" instructions ")
503                        .borders(Borders::ALL),
504                ),
505                area,
506            );
507            return;
508        };
509
510        // Static-disasm view: render the program's text section in PC
511        // order, centered on the current step's PC. j/k stepping over a
512        // call/branch jumps the PC; the view re-centers each frame so
513        // you always see the actual code layout around what executed.
514        if let Some(disasm) = self.session.programs.get(&node.program_id) {
515            self.draw_static_disasm(f, area, node, step, disasm);
516            return;
517        }
518
519        // Fallback: program ELF wasn't resolvable (e.g. third-party
520        // deploy) so we have no static disasm. Drop back to the trace
521        // stream view — same data the flamegraph consumes.
522        self.draw_trace_stream(f, area);
523    }
524
525    fn draw_static_disasm(
526        &self,
527        f: &mut Frame<'_>,
528        area: Rect,
529        node: &super::model::DebugNode,
530        step: &DebugStep,
531        disasm: &super::model::ProgramDisasm,
532    ) {
533        // Locate the current PC in the static index. Falls back to the
534        // nearest preceding PC if the exact one isn't there (shouldn't
535        // happen for in-text PCs, but we never want to panic on a weird
536        // trace).
537        let center_idx = disasm
538            .pc_to_idx
539            .get(&step.pc)
540            .copied()
541            .or_else(|| {
542                disasm
543                    .pc_to_idx
544                    .range(..=step.pc)
545                    .next_back()
546                    .map(|(_, i)| *i)
547            })
548            .unwrap_or(0);
549
550        let window = area.height.saturating_sub(2) as usize;
551        let half = window / 2;
552        let start = center_idx.saturating_sub(half);
553        let end = (start + window).min(disasm.insns.len());
554
555        let mut rows: Vec<ListItem> = Vec::with_capacity(end - start);
556        for insn in &disasm.insns[start..end] {
557            // Symbol header above the function entrypoint, when known.
558            // Costs one row per visible function boundary inside the
559            // window — rare enough to ignore for sizing.
560            if let Some(label) = &insn.func_label {
561                rows.push(ListItem::new(Line::from(vec![
562                    Span::styled("   ", Style::new()),
563                    Span::styled(
564                        format!("┌── {label}"),
565                        Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
566                    ),
567                ])));
568            }
569
570            let is_current = insn.pc == step.pc;
571            let marker = if is_current { ">" } else { " " };
572            let mut spans = vec![
573                Span::raw(format!("{marker} ")),
574                Span::styled(
575                    format!("pc {:>5} ", insn.pc),
576                    Style::new().fg(Color::DarkGray),
577                ),
578            ];
579            spans.extend(insn.disasm_spans.iter().cloned());
580            let line = Line::from(spans);
581            let style = if is_current {
582                Style::new()
583                    .bg(Color::DarkGray)
584                    .add_modifier(Modifier::BOLD)
585            } else {
586                Style::new()
587            };
588            rows.push(ListItem::new(line).style(style));
589        }
590
591        let title = format!(
592            " {} · pc {} · trace step {}/{} ",
593            node.program_label,
594            step.pc,
595            self.current_step + 1,
596            node.steps.len()
597        );
598        let widget = List::new(rows).block(Block::default().title(title).borders(Borders::ALL));
599        f.render_widget(widget, area);
600    }
601
602    /// Fallback for unresolved programs: chronological trace view.
603    fn draw_trace_stream(&self, f: &mut Frame<'_>, area: Rect) {
604        let steps = self.current_steps();
605        let window = area.height.saturating_sub(2) as usize;
606        let half = window / 2;
607        let start = self.current_step.saturating_sub(half);
608        let end = (start + window).min(steps.len());
609
610        let items: Vec<ListItem> = steps[start..end]
611            .iter()
612            .enumerate()
613            .map(|(i, s)| {
614                let idx = start + i;
615                let marker = if idx == self.current_step { ">" } else { " " };
616                let mut spans = vec![
617                    Span::raw(format!("{marker} ")),
618                    Span::styled(format!("{:>6} ", idx), Style::new().fg(Color::DarkGray)),
619                    Span::styled(format!("pc {:>5} ", s.pc), Style::new().fg(Color::DarkGray)),
620                ];
621                spans.extend(s.disasm_spans.iter().cloned());
622                let line = Line::from(spans);
623                let style = if idx == self.current_step {
624                    Style::new()
625                        .bg(Color::DarkGray)
626                        .add_modifier(Modifier::BOLD)
627                } else {
628                    Style::new()
629                };
630                ListItem::new(line).style(style)
631            })
632            .collect();
633
634        let title = format!(
635            " trace stream — no static disasm  ({} / {}) ",
636            self.current_step + 1,
637            steps.len()
638        );
639        let widget = List::new(items).block(Block::default().title(title).borders(Borders::ALL));
640        f.render_widget(widget, area);
641    }
642
643    fn draw_registers(&self, f: &mut Frame<'_>, area: Rect) {
644        let Some(step) = self.current_steps().get(self.current_step) else {
645            let widget = Paragraph::new("(no steps)")
646                .block(Block::default().title(" registers ").borders(Borders::ALL));
647            f.render_widget(widget, area);
648            return;
649        };
650
651        let prev = self
652            .current_step
653            .checked_sub(1)
654            .and_then(|i| self.current_steps().get(i));
655
656        let mut lines = Vec::with_capacity(12);
657        for r in 0..11 {
658            let val = step.regs[r];
659            let changed = prev.is_some_and(|p| p.regs[r] != val);
660            let style = if changed {
661                Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
662            } else {
663                Style::new()
664            };
665            lines.push(Line::from(vec![
666                Span::raw(format!("r{:<2} ", r)),
667                Span::styled(format!("{:#018x}", val), style),
668                Span::styled(
669                    format!("  ({})", val as i64),
670                    Style::new().fg(Color::DarkGray),
671                ),
672            ]));
673        }
674        lines.push(Line::from(vec![
675            Span::raw("pc  "),
676            Span::styled(
677                format!("{:#010x}  ({})", step.pc, step.pc),
678                Style::new().fg(Color::Cyan),
679            ),
680        ]));
681
682        let widget = Paragraph::new(lines)
683            .block(Block::default().title(" registers ").borders(Borders::ALL));
684        f.render_widget(widget, area);
685    }
686
687    fn draw_source(&mut self, f: &mut Frame<'_>, area: Rect) {
688        let block = Block::default().title(" source ").borders(Borders::ALL);
689        let Some(step) = self.current_steps().get(self.current_step).cloned() else {
690            f.render_widget(Paragraph::new("(no steps)").block(block), area);
691            return;
692        };
693        let Some(loc) = step.src_loc.clone() else {
694            // Two distinct failure modes — distinguishing them keeps us
695            // from telling users to rebuild a binary that's already
696            // built with debug info.
697            let node = &self.current_tx().nodes[self.current_node];
698            let program_disasm = self.session.programs.get(&node.program_id);
699            let msg = match program_disasm {
700                Some(d) if d.has_dwarf => format!(
701                    "pc {pc:#x} has no DWARF line entry.\n\nThis is normal for hand-written asm \
702                     entrypoints, inlined\nframes, compiler-generated stubs, and `.text` padding \
703                     —\nLLVM only emits (file, line) tuples for Rust source.\nOther PCs in \
704                     {program} resolve fine; stepping forward\nshould re-enter mapped code.\n\nIf \
705                     you wrote an asm entrypoint, you can ignore this for\nthe asm region — \
706                     registers + disasm above stay live.",
707                    pc = step.pc,
708                    program = node.program_label,
709                ),
710                Some(_) => format!(
711                    "{program}: ELF has no DWARF line info.\n\nRebuild with debug info:\n  \
712                     CARGO_PROFILE_RELEASE_DEBUG=2 anchor build --no-idl\n\n(`anchor debugger` \
713                     sets this for you when it rebuilds.\nThe flag only sticks if the .so we read \
714                     came from that build.)",
715                    program = node.program_label,
716                ),
717                None => format!(
718                    "no static disasm or DWARF for {program} (pc {pc:#x}).\n\nThe program's \
719                     deployed `.so` wasn't resolvable from the\nworkspace's Anchor.toml — \
720                     third-party deploy, or\nmismatched program-id mapping.",
721                    program = node.program_label,
722                    pc = step.pc,
723                ),
724            };
725            f.render_widget(
726                Paragraph::new(msg).block(block).wrap(Wrap { trim: true }),
727                area,
728            );
729            return;
730        };
731
732        let resolved_path = resolve_src_path(
733            &loc.file,
734            &self.session.src_roots,
735            &self.session.path_rewrites,
736            loc.line,
737        );
738        let Some(path) = resolved_path else {
739            let msg = format!(
740                "can't read {}:{}\nno candidate path resolved\n\ntried roots: {}",
741                loc.file.display(),
742                loc.line,
743                self.session
744                    .src_roots
745                    .iter()
746                    .map(|p| p.display().to_string())
747                    .collect::<Vec<_>>()
748                    .join(", ")
749            );
750            f.render_widget(
751                Paragraph::new(msg).block(block).wrap(Wrap { trim: true }),
752                area,
753            );
754            return;
755        };
756
757        // Pull file contents from cache, populating on first access. Errors
758        // are cached too so we don't retry the disk every frame for paths
759        // that resolved but can't be read (rare — usually a permissions
760        // issue).
761        let file_lines = match self.load_file(&path) {
762            Ok(lines) => lines,
763            Err(e) => {
764                let msg = format!("can't read {}:{}\n{e}", path.display(), loc.line);
765                f.render_widget(
766                    Paragraph::new(msg).block(block).wrap(Wrap { trim: true }),
767                    area,
768                );
769                return;
770            }
771        };
772        let lines = window_from_lines(file_lines, loc.line, area.height.saturating_sub(2) as u32);
773
774        // Pull the cached label or compute it once. Title format:
775        //   " stdlib · core · src/array/equality.rs:150 "
776        //   " pinocchio v0.11.1 · src/cpi.rs:42 "
777        //   " debugger-testing · programs/debugger-testing/src/lib.rs:12 "
778        let label = if let Some(cached) = self.label_cache.get(&path) {
779            cached.clone()
780        } else {
781            let l = classify(
782                &path,
783                &self.session.src_roots,
784                &self.session.path_rewrites,
785                self.session.cwd.as_deref(),
786            );
787            self.label_cache.insert(path.clone(), l.clone());
788            l
789        };
790        let title = format!(" {} · {}:{} ", label.label, label.path_display, loc.line);
791        let is_rust = loc
792            .file
793            .extension()
794            .and_then(|s| s.to_str())
795            .is_some_and(|e| e.eq_ignore_ascii_case("rs"));
796        let text: Vec<Line> = lines
797            .into_iter()
798            .map(|(n, content, is_current)| {
799                let mut spans = vec![Span::styled(
800                    format!("{n:>5}  "),
801                    Style::new().fg(Color::DarkGray),
802                )];
803                if is_rust {
804                    // Per-line highlight cache keyed by (resolved-path,
805                    // line-number). Means a held-down j/k that scrolls
806                    // through a 30-line window only ever pays for the
807                    // first time each line is shown.
808                    let key = (path.clone(), n);
809                    let highlighted: Vec<Span<'static>> =
810                        if let Some(cached) = self.highlight_cache.get(&key) {
811                            cached.clone()
812                        } else {
813                            let h = highlight_rust(&content).spans;
814                            self.highlight_cache.insert(key, h.clone());
815                            h
816                        };
817                    if is_current {
818                        for span in highlighted {
819                            let mut style = span.style;
820                            style.bg = Some(Color::DarkGray);
821                            style = style.add_modifier(Modifier::BOLD);
822                            spans.push(Span::styled(span.content.into_owned(), style));
823                        }
824                    } else {
825                        spans.extend(highlighted);
826                    }
827                } else {
828                    let style = if is_current {
829                        Style::new()
830                            .bg(Color::DarkGray)
831                            .add_modifier(Modifier::BOLD)
832                    } else {
833                        Style::new()
834                    };
835                    spans.push(Span::styled(content, style));
836                }
837                Line::from(spans)
838            })
839            .collect();
840
841        let widget =
842            Paragraph::new(text).block(Block::default().title(title).borders(Borders::ALL));
843        f.render_widget(widget, area);
844    }
845
846    fn handle_stepper_key(&mut self, k: KeyEvent) -> Flow {
847        let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
848        match k.code {
849            KeyCode::Char('q') => return Flow::Quit,
850            KeyCode::Char('t') | KeyCode::Esc => {
851                // Map back from `current_tx` (index into session.txs) to
852                // the picker row that points at it, so the user lands on
853                // the same entry they entered through.
854                let row = self
855                    .picker_rows
856                    .iter()
857                    .position(|r| matches!(r, PickerRow::Tx(i) if *i == self.current_tx));
858                self.picker.select(row);
859                self.screen = Screen::Picker;
860            }
861            KeyCode::Char(d @ '0'..='9') => {
862                self.key_buffer.push(d);
863                return Flow::Continue;
864            }
865            KeyCode::Char('j') | KeyCode::Down => self.repeat(|app| app.step_forward(1)),
866            KeyCode::Char('k') | KeyCode::Up => self.repeat(|app| app.step_back(1)),
867            KeyCode::Char('s') => self.repeat(App::step_over_forward),
868            KeyCode::Char('a') => self.repeat(App::step_over_back),
869            KeyCode::Char('g') => self.current_step = 0,
870            KeyCode::Char('G') => {
871                self.current_step = self.current_steps().len().saturating_sub(1);
872            }
873            KeyCode::Char('c') if !ctrl => {
874                self.current_node = self.current_node.saturating_sub(1);
875                self.current_step = 0;
876            }
877            KeyCode::Char('C') => {
878                let max = self.current_tx().nodes.len().saturating_sub(1);
879                self.current_node = (self.current_node + 1).min(max);
880                self.current_step = 0;
881            }
882            _ => {}
883        }
884        self.key_buffer.clear();
885        Flow::Continue
886    }
887
888    fn repeat(&mut self, mut f: impl FnMut(&mut Self)) {
889        let n = self
890            .key_buffer
891            .parse::<usize>()
892            .unwrap_or(1)
893            .clamp(1, 100_000);
894        for _ in 0..n {
895            f(self);
896        }
897    }
898
899    fn step_forward(&mut self, n: usize) {
900        let last = self.current_steps().len().saturating_sub(1);
901        self.current_step = (self.current_step + n).min(last);
902    }
903
904    fn step_back(&mut self, n: usize) {
905        self.current_step = self.current_step.saturating_sub(n);
906    }
907
908    /// Step-over: advance until call_depth returns to the current level (or
909    /// shallower). Falls back to a single step when there's no nested frame.
910    fn step_over_forward(&mut self) {
911        let steps = self.current_steps();
912        let Some(cur) = steps.get(self.current_step) else {
913            return;
914        };
915        let base = cur.call_depth;
916        let start = self.current_step + 1;
917        let idx = steps[start..]
918            .iter()
919            .position(|s| s.call_depth <= base)
920            .map(|off| start + off)
921            .unwrap_or_else(|| steps.len().saturating_sub(1));
922        self.current_step = idx;
923    }
924
925    fn step_over_back(&mut self) {
926        let steps = self.current_steps();
927        let Some(cur) = steps.get(self.current_step) else {
928            return;
929        };
930        let base = cur.call_depth;
931        let idx = steps[..self.current_step]
932            .iter()
933            .rposition(|s| s.call_depth <= base)
934            .unwrap_or(0);
935        self.current_step = idx;
936    }
937}
938
939struct TerminalGuard {
940    term: DebugTerm,
941}
942
943impl TerminalGuard {
944    fn new(mut term: DebugTerm) -> Self {
945        let _ = enable_raw_mode();
946        let _ = execute!(term.backend_mut(), EnterAlternateScreen, EnableMouseCapture);
947        let _ = term.hide_cursor();
948        let _ = term.clear();
949        Self { term }
950    }
951}
952
953impl Drop for TerminalGuard {
954    fn drop(&mut self) {
955        let _ = disable_raw_mode();
956        let _ = execute!(
957            self.term.backend_mut(),
958            LeaveAlternateScreen,
959            DisableMouseCapture
960        );
961        let _ = self.term.show_cursor();
962    }
963}
964
965impl App {
966    /// Read a source file from the cache, populating it on first miss.
967    /// Returns the file's lines verbatim. Errors are cached as
968    /// `Missing(msg)` so we don't hit the disk repeatedly for paths that
969    /// resolved but failed to read.
970    fn load_file(&mut self, path: &std::path::Path) -> Result<&[String], String> {
971        if !self.file_cache.contains_key(path) {
972            let entry = match std::fs::read_to_string(path) {
973                Ok(s) => FileEntry::Loaded(s.lines().map(str::to_owned).collect()),
974                Err(e) => FileEntry::Missing(e.to_string()),
975            };
976            self.file_cache.insert(path.to_path_buf(), entry);
977        }
978        match self.file_cache.get(path).expect("just inserted") {
979            FileEntry::Loaded(lines) => Ok(lines.as_slice()),
980            FileEntry::Missing(msg) => Err(msg.clone()),
981        }
982    }
983}
984
985/// Slice a centered window around `target_line` from already-loaded lines.
986/// Returns `(line_number, content, is_current)` triples for the source
987/// pane to render — does no I/O.
988fn window_from_lines(lines: &[String], target_line: u32, height: u32) -> Vec<(u32, String, bool)> {
989    let target_idx = target_line.saturating_sub(1) as usize;
990    let half = (height / 2) as usize;
991    let start = target_idx.saturating_sub(half).min(lines.len());
992    let end = (start + height as usize).min(lines.len());
993    lines[start..end]
994        .iter()
995        .enumerate()
996        .map(|(i, l)| {
997            let n = (start + i + 1) as u32;
998            (n, l.clone(), n == target_line)
999        })
1000        .collect()
1001}
1002
1003/// Resolves a DWARF-reported source path to one we can actually read.
1004///
1005/// Tried in order:
1006/// 1. The path as-is if it's absolute and exists (local build).
1007/// 2. Prefix rewrites — used for stdlib frames whose DWARF path points at
1008///    the CI machine that built `platform-tools`.
1009/// 3. Join against each configured source root (workspace root etc.) for
1010///    paths DWARF left relative.
1011///
1012/// Returns `None` when nothing hits. The caller surfaces that as a
1013/// "source not available" notice in the TUI source pane.
1014fn resolve_src_path(
1015    file: &std::path::Path,
1016    roots: &[std::path::PathBuf],
1017    rewrites: &[(std::path::PathBuf, std::path::PathBuf)],
1018    line: u32,
1019) -> Option<std::path::PathBuf> {
1020    if file.is_absolute() && file.exists() {
1021        return Some(file.to_path_buf());
1022    }
1023
1024    // Prefix rewrites (e.g. `platform-tools` CI path → local stdlib cache).
1025    if let Some(file_str) = file.to_str() {
1026        for (prefix, replacement) in rewrites {
1027            if let Some(prefix_str) = prefix.to_str() {
1028                if let Some(tail) = file_str.strip_prefix(prefix_str) {
1029                    let candidate = replacement.join(tail.trim_start_matches('/'));
1030                    if candidate.exists() {
1031                        return Some(candidate);
1032                    }
1033                }
1034            }
1035        }
1036    }
1037
1038    // SBF DWARF omits DW_AT_comp_dir (-Zremap-cwd-prefix= strips it),
1039    // so relative paths like `src/lib.rs` are ambiguous across crates.
1040    // When multiple roots contain a matching file, prefer the one that
1041    // actually has enough lines for the DWARF-referenced line number.
1042    // This correctly disambiguates e.g. anchor-lang-v2's `src/cpi.rs`
1043    // (538 lines) from pinocchio's `src/cpi.rs` (683 lines) when the
1044    // DWARF says line 668.
1045    let mut fallback: Option<std::path::PathBuf> = None;
1046    for root in roots {
1047        let candidate = root.join(file);
1048        if candidate.exists() {
1049            if line == 0 {
1050                return Some(candidate);
1051            }
1052            if let Ok(contents) = std::fs::read(&candidate) {
1053                let line_count = contents.iter().filter(|&&b| b == b'\n').count() + 1;
1054                if line as usize <= line_count {
1055                    return Some(candidate);
1056                }
1057            }
1058            if fallback.is_none() {
1059                fallback = Some(candidate);
1060            }
1061        }
1062    }
1063
1064    if let Some(fb) = fallback {
1065        return Some(fb);
1066    }
1067
1068    if file.exists() {
1069        return Some(file.to_path_buf());
1070    }
1071    None
1072}