1use 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
135pub 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 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 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 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
324fn 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 app.next(999);
533 assert_eq!(app.list_state.selected(), Some(2));
534 app.prev(999);
536 assert_eq!(app.list_state.selected(), Some(0));
537 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 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 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 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 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 }
577}