Skip to main content

agx/
diff_tui.rs

1//! Two-pane diff TUI. Consumes the pure alignment from
2//! `diff_align::align` and renders it as two synchronized lists with
3//! one `AlignRow` per display line. Color coding:
4//!
5//! - Match    — default foreground, both sides populated
6//! - Differ   — yellow (structure aligns, content drifted)
7//! - LeftOnly — left side dim, right side shows the gray "(absent)"
8//! - RightOnly — symmetric
9//!
10//! Navigation mirrors the per-session and corpus TUIs — j/k/g/G/
11//! Home/End/PgUp/PgDn with a ?/F1 help overlay and q/Esc quit.
12//!
13//! Synchronized scrolling trick: we share one `ListState` across both
14//! panes' `render_stateful_widget` calls. The two panes are the same
15//! height (horizontal split of a single vertical slot), so ratatui's
16//! "keep selected visible" offset adjustment produces identical
17//! offsets on both sides — the panes scroll together for free without
18//! any manual top/height bookkeeping.
19
20use crate::diff_align::{AlignKind, AlignRow, align};
21use crate::timeline::{SessionTotals, Step, compute_session_totals};
22use anyhow::Result;
23use crossterm::event::{self, Event, KeyCode, KeyEventKind};
24use crossterm::execute;
25use crossterm::terminal::{
26    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
27};
28use ratatui::Terminal;
29use ratatui::backend::CrosstermBackend;
30use ratatui::layout::{Constraint, Direction, Layout, Rect};
31use ratatui::style::{Color, Modifier, Style};
32use ratatui::text::{Line, Span};
33use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
34use std::io;
35use std::path::Path;
36use std::time::Duration;
37
38const PAGE_STEP: usize = 10;
39const HELP_POPUP_WIDTH: u16 = 56;
40
41struct App<'a> {
42    left: &'a [Step],
43    right: &'a [Step],
44    rows: Vec<AlignRow>,
45    list_state: ListState,
46    show_help: bool,
47    left_totals: SessionTotals,
48    right_totals: SessionTotals,
49    left_label: String,
50    right_label: String,
51    no_cost: bool,
52}
53
54impl<'a> App<'a> {
55    fn new(
56        left: &'a [Step],
57        right: &'a [Step],
58        left_path: &Path,
59        right_path: &Path,
60        left_format: &str,
61        right_format: &str,
62        no_cost: bool,
63    ) -> Self {
64        let rows = align(left, right);
65        let mut list_state = ListState::default();
66        if !rows.is_empty() {
67            list_state.select(Some(0));
68        }
69        App {
70            left,
71            right,
72            rows,
73            list_state,
74            show_help: false,
75            left_totals: compute_session_totals(left),
76            right_totals: compute_session_totals(right),
77            left_label: format!(
78                "{} · {}",
79                left_format,
80                left_path
81                    .file_name()
82                    .map(|n| n.to_string_lossy().into_owned())
83                    .unwrap_or_else(|| left_path.display().to_string())
84            ),
85            right_label: format!(
86                "{} · {}",
87                right_format,
88                right_path
89                    .file_name()
90                    .map(|n| n.to_string_lossy().into_owned())
91                    .unwrap_or_else(|| right_path.display().to_string())
92            ),
93            no_cost,
94        }
95    }
96
97    fn next(&mut self, n: usize) {
98        if self.rows.is_empty() {
99            return;
100        }
101        let i = self.list_state.selected().unwrap_or(0);
102        let next = (i + n).min(self.rows.len() - 1);
103        self.list_state.select(Some(next));
104    }
105
106    fn prev(&mut self, n: usize) {
107        if self.rows.is_empty() {
108            return;
109        }
110        let i = self.list_state.selected().unwrap_or(0);
111        self.list_state.select(Some(i.saturating_sub(n)));
112    }
113
114    fn home(&mut self) {
115        if !self.rows.is_empty() {
116            self.list_state.select(Some(0));
117        }
118    }
119
120    fn end(&mut self) {
121        if !self.rows.is_empty() {
122            self.list_state.select(Some(self.rows.len() - 1));
123        }
124    }
125}
126
127struct TerminalGuard;
128impl Drop for TerminalGuard {
129    fn drop(&mut self) {
130        let _ = disable_raw_mode();
131        let _ = execute!(io::stdout(), LeaveAlternateScreen);
132    }
133}
134
135/// Launch the diff TUI. Caller supplies the already-loaded step vecs
136/// so `main.rs` can reuse the existing `load_session` path and error
137/// handling. Returns when the user quits with q or Esc.
138pub fn run(
139    left: &[Step],
140    right: &[Step],
141    left_path: &Path,
142    right_path: &Path,
143    left_format: &str,
144    right_format: &str,
145    no_cost: bool,
146) -> Result<()> {
147    enable_raw_mode()?;
148    execute!(io::stdout(), EnterAlternateScreen)?;
149    let _guard = TerminalGuard;
150
151    let backend = CrosstermBackend::new(io::stdout());
152    let mut terminal = Terminal::new(backend)?;
153    let mut app = App::new(
154        left,
155        right,
156        left_path,
157        right_path,
158        left_format,
159        right_format,
160        no_cost,
161    );
162
163    event_loop(&mut terminal, &mut app)?;
164    let _ = terminal.show_cursor();
165    Ok(())
166}
167
168#[allow(clippy::too_many_lines)]
169fn event_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
170    loop {
171        terminal.draw(|f| {
172            let outer = Layout::default()
173                .direction(Direction::Vertical)
174                .constraints([
175                    Constraint::Length(1),
176                    Constraint::Min(1),
177                    Constraint::Length(1),
178                ])
179                .split(f.area());
180
181            // Header: side labels + per-side totals + alignment summary.
182            let counts = count_align_kinds(&app.rows);
183            let header = format!(
184                " agx diff — A: {} ({}{})   ↔   B: {} ({}{})   [{} match · {} differ · {} only-A · {} only-B]",
185                app.left_label,
186                format_totals_short(&app.left_totals),
187                if !app.no_cost {
188                    format_cost(&app.left_totals)
189                } else {
190                    String::new()
191                },
192                app.right_label,
193                format_totals_short(&app.right_totals),
194                if !app.no_cost {
195                    format_cost(&app.right_totals)
196                } else {
197                    String::new()
198                },
199                counts.matches,
200                counts.differs,
201                counts.left_only,
202                counts.right_only,
203            );
204            f.render_widget(
205                Paragraph::new(header).style(
206                    Style::default()
207                        .fg(Color::Black)
208                        .bg(Color::Cyan)
209                        .add_modifier(Modifier::BOLD),
210                ),
211                outer[0],
212            );
213
214            // Two-pane body.
215            let panes = Layout::default()
216                .direction(Direction::Horizontal)
217                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
218                .split(outer[1]);
219
220            let left_items = build_items(&app.rows, Side::Left, app.left, app.right);
221            let right_items = build_items(&app.rows, Side::Right, app.left, app.right);
222
223            let left_list = List::new(left_items)
224                .block(Block::default().borders(Borders::ALL).title(" A "))
225                .highlight_style(
226                    Style::default()
227                        .add_modifier(Modifier::REVERSED)
228                        .add_modifier(Modifier::BOLD),
229                );
230            let right_list = List::new(right_items)
231                .block(Block::default().borders(Borders::ALL).title(" B "))
232                .highlight_style(
233                    Style::default()
234                        .add_modifier(Modifier::REVERSED)
235                        .add_modifier(Modifier::BOLD),
236                );
237
238            // Render both panes with the same ListState. Because the
239            // two panes are horizontally split from a single vertical
240            // slot they share a height, and ratatui's "keep selected
241            // visible" offset math produces the same offset both times
242            // → the panes scroll in lockstep with no manual
243            // bookkeeping.
244            f.render_stateful_widget(left_list, panes[0], &mut app.list_state);
245            f.render_stateful_widget(right_list, panes[1], &mut app.list_state);
246
247            let hints = "j/k navigate · g/G first/last · PgUp/PgDn page · ? help · q quit";
248            f.render_widget(
249                Paragraph::new(hints).style(Style::default().fg(Color::DarkGray)),
250                outer[2],
251            );
252
253            if app.show_help {
254                render_help(f);
255            }
256        })?;
257
258        if !event::poll(Duration::from_secs(60))? {
259            continue;
260        }
261        let Event::Key(key) = event::read()? else {
262            continue;
263        };
264        if key.kind != KeyEventKind::Press {
265            continue;
266        }
267        if app.show_help {
268            app.show_help = false;
269            continue;
270        }
271        match key.code {
272            KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
273            KeyCode::Char('?') | KeyCode::F(1) => app.show_help = true,
274            KeyCode::Down | KeyCode::Char('j') => app.next(1),
275            KeyCode::Up | KeyCode::Char('k') => app.prev(1),
276            KeyCode::PageDown | KeyCode::Char('d') => app.next(PAGE_STEP),
277            KeyCode::PageUp | KeyCode::Char('u') => app.prev(PAGE_STEP),
278            KeyCode::Home | KeyCode::Char('g') => app.home(),
279            KeyCode::End | KeyCode::Char('G') => app.end(),
280            _ => {}
281        }
282    }
283}
284
285#[derive(Copy, Clone)]
286enum Side {
287    Left,
288    Right,
289}
290
291fn build_items<'a>(
292    rows: &[AlignRow],
293    side: Side,
294    left: &'a [Step],
295    right: &'a [Step],
296) -> Vec<ListItem<'a>> {
297    rows.iter()
298        .map(|row| {
299            let (step_idx, steps) = match side {
300                Side::Left => (row.left, left),
301                Side::Right => (row.right, right),
302            };
303            let (prefix, color) = row_style(row.kind, side);
304            match step_idx {
305                Some(i) => {
306                    let label = steps
307                        .get(i)
308                        .map(|s| s.label.clone())
309                        .unwrap_or_else(|| "(?)".into());
310                    ListItem::new(Line::from(vec![
311                        Span::styled(prefix, Style::default().fg(color)),
312                        Span::styled(label, Style::default().fg(color)),
313                    ]))
314                }
315                None => ListItem::new(Line::from(Span::styled(
316                    "  (absent)",
317                    Style::default().fg(Color::DarkGray),
318                ))),
319            }
320        })
321        .collect()
322}
323
324/// Return the two-char prefix plus the foreground color for a row on
325/// the given side. Prefixes are ASCII so terminals without nerd-font
326/// support still render correctly (terminal-native principle).
327fn row_style(kind: AlignKind, side: Side) -> (&'static str, Color) {
328    match (kind, side) {
329        (AlignKind::Match, _) => ("= ", Color::Green),
330        (AlignKind::Differ, _) => ("~ ", Color::Yellow),
331        (AlignKind::LeftOnly, Side::Left) => ("- ", Color::Red),
332        (AlignKind::LeftOnly, Side::Right) => ("  ", Color::DarkGray),
333        (AlignKind::RightOnly, Side::Left) => ("  ", Color::DarkGray),
334        (AlignKind::RightOnly, Side::Right) => ("+ ", Color::Green),
335    }
336}
337
338#[derive(Default)]
339struct AlignCounts {
340    matches: usize,
341    differs: usize,
342    left_only: usize,
343    right_only: usize,
344}
345
346fn count_align_kinds(rows: &[AlignRow]) -> AlignCounts {
347    let mut c = AlignCounts::default();
348    for row in rows {
349        match row.kind {
350            AlignKind::Match => c.matches += 1,
351            AlignKind::Differ => c.differs += 1,
352            AlignKind::LeftOnly => c.left_only += 1,
353            AlignKind::RightOnly => c.right_only += 1,
354        }
355    }
356    c
357}
358
359fn format_totals_short(t: &SessionTotals) -> String {
360    format!("{}/{} tok", t.tokens_in, t.tokens_out)
361}
362
363fn format_cost(t: &SessionTotals) -> String {
364    match t.cost_usd {
365        Some(c) => format!(" · ${c:.4}"),
366        None => String::new(),
367    }
368}
369
370fn render_help(f: &mut ratatui::Frame) {
371    let lines = vec![
372        Line::from(Span::styled(
373            "agx diff — keybindings",
374            Style::default().add_modifier(Modifier::BOLD),
375        )),
376        Line::from(""),
377        Line::from("  ↓ / j           next row"),
378        Line::from("  ↑ / k           prev row"),
379        Line::from("  PgDn / d        jump 10 forward"),
380        Line::from("  PgUp / u        jump 10 back"),
381        Line::from("  Home / g        first"),
382        Line::from("  End  / G        last"),
383        Line::from("  ? / F1          toggle this help"),
384        Line::from("  q / Esc         quit"),
385        Line::from(""),
386        Line::from(Span::styled(
387            "Row color legend",
388            Style::default().add_modifier(Modifier::BOLD),
389        )),
390        Line::from(vec![
391            Span::styled("  = ", Style::default().fg(Color::Green)),
392            Span::raw("match: same kind, same tool, identical content"),
393        ]),
394        Line::from(vec![
395            Span::styled("  ~ ", Style::default().fg(Color::Yellow)),
396            Span::raw("differ: same kind, same tool, content drifted"),
397        ]),
398        Line::from(vec![
399            Span::styled("  - ", Style::default().fg(Color::Red)),
400            Span::raw("only in A (left)"),
401        ]),
402        Line::from(vec![
403            Span::styled("  + ", Style::default().fg(Color::Green)),
404            Span::raw("only in B (right)"),
405        ]),
406        Line::from(""),
407        Line::from(Span::styled(
408            "Press any key to dismiss",
409            Style::default().fg(Color::DarkGray),
410        )),
411    ];
412    let height = u16::try_from(lines.len())
413        .unwrap_or(u16::MAX)
414        .saturating_add(2);
415    let area = centered_rect(HELP_POPUP_WIDTH, height, f.area());
416    let widget = Paragraph::new(lines)
417        .block(
418            Block::default()
419                .borders(Borders::ALL)
420                .title(" help ")
421                .border_style(Style::default().fg(Color::White)),
422        )
423        .wrap(Wrap { trim: false });
424    f.render_widget(Clear, area);
425    f.render_widget(widget, area);
426}
427
428fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
429    let width = width.min(area.width);
430    let height = height.min(area.height);
431    let x = area.x + (area.width.saturating_sub(width)) / 2;
432    let y = area.y + (area.height.saturating_sub(height)) / 2;
433    Rect {
434        x,
435        y,
436        width,
437        height,
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::timeline::{tool_use_step, user_text_step};
445
446    #[test]
447    fn count_align_kinds_sums_categories() {
448        let rows = vec![
449            AlignRow {
450                left: Some(0),
451                right: Some(0),
452                kind: AlignKind::Match,
453            },
454            AlignRow {
455                left: Some(1),
456                right: Some(1),
457                kind: AlignKind::Differ,
458            },
459            AlignRow {
460                left: Some(2),
461                right: None,
462                kind: AlignKind::LeftOnly,
463            },
464            AlignRow {
465                left: None,
466                right: Some(2),
467                kind: AlignKind::RightOnly,
468            },
469            AlignRow {
470                left: None,
471                right: Some(3),
472                kind: AlignKind::RightOnly,
473            },
474        ];
475        let c = count_align_kinds(&rows);
476        assert_eq!(c.matches, 1);
477        assert_eq!(c.differs, 1);
478        assert_eq!(c.left_only, 1);
479        assert_eq!(c.right_only, 2);
480    }
481
482    #[test]
483    fn app_new_selects_first_row_when_nonempty() {
484        let left = vec![user_text_step("hi")];
485        let right = vec![user_text_step("hi")];
486        let app = App::new(
487            &left,
488            &right,
489            Path::new("a.jsonl"),
490            Path::new("b.jsonl"),
491            "Claude Code",
492            "Claude Code",
493            false,
494        );
495        assert_eq!(app.list_state.selected(), Some(0));
496        assert_eq!(app.rows.len(), 1);
497    }
498
499    #[test]
500    fn app_new_leaves_selection_none_when_empty() {
501        let app = App::new(
502            &[],
503            &[],
504            Path::new("a.jsonl"),
505            Path::new("b.jsonl"),
506            "x",
507            "y",
508            false,
509        );
510        assert_eq!(app.list_state.selected(), None);
511        assert!(app.rows.is_empty());
512    }
513
514    #[test]
515    fn navigation_clamps_to_bounds() {
516        let left = vec![
517            user_text_step("a"),
518            user_text_step("b"),
519            user_text_step("c"),
520        ];
521        let right = left.clone();
522        let mut app = App::new(
523            &left,
524            &right,
525            Path::new("a"),
526            Path::new("b"),
527            "x",
528            "x",
529            false,
530        );
531        // Start at 0, go past the end, should clamp to last.
532        app.next(999);
533        assert_eq!(app.list_state.selected(), Some(2));
534        // Go way back, should clamp to 0.
535        app.prev(999);
536        assert_eq!(app.list_state.selected(), Some(0));
537        // Single step.
538        app.next(1);
539        assert_eq!(app.list_state.selected(), Some(1));
540    }
541
542    #[test]
543    fn row_style_distinguishes_side_for_one_sided_rows() {
544        // LeftOnly should be styled on the left, blank-gray on the right.
545        let (prefix_l, color_l) = row_style(AlignKind::LeftOnly, Side::Left);
546        let (prefix_r, color_r) = row_style(AlignKind::LeftOnly, Side::Right);
547        assert_eq!(prefix_l, "- ");
548        assert_eq!(color_l, Color::Red);
549        assert_eq!(prefix_r, "  ");
550        assert_eq!(color_r, Color::DarkGray);
551
552        // RightOnly: mirror.
553        let (prefix_l2, _) = row_style(AlignKind::RightOnly, Side::Left);
554        let (prefix_r2, color_r2) = row_style(AlignKind::RightOnly, Side::Right);
555        assert_eq!(prefix_l2, "  ");
556        assert_eq!(prefix_r2, "+ ");
557        assert_eq!(color_r2, Color::Green);
558    }
559
560    #[test]
561    fn build_items_uses_absent_sentinel_for_gap_side() {
562        // RightOnly row → left side should render "(absent)" at DarkGray.
563        let left = vec![user_text_step("hi")];
564        let right = vec![user_text_step("hi"), tool_use_step("t1", "Bash", "{}")];
565        let rows = align(&left, &right);
566        // Expect: Match (user), RightOnly (Bash tool_use).
567        assert_eq!(rows.len(), 2);
568        let items_left = build_items(&rows, Side::Left, &left, &right);
569        let items_right = build_items(&rows, Side::Right, &left, &right);
570        assert_eq!(items_left.len(), 2);
571        assert_eq!(items_right.len(), 2);
572        // We can't inspect Line spans easily — the test exists to
573        // ensure build_items doesn't panic on gaps and produces the
574        // same length on both sides (invariant the two-pane render
575        // depends on).
576    }
577}