use anyhow::{Context, Result}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use ratatui::{ Frame, layout::{Constraint, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}, }; use crate::{action::ClassificationAction, lesson::LessonInput}; const MAX_NOTE_CHARS: usize = 500; #[derive(Clone, Debug)] pub struct ReviewItem { pub id: String, pub subject: String, pub sender: String, pub content_excerpt: String, pub action: ClassificationAction, pub probability: f64, pub confidence: f64, approved: bool, corrected_action: Option, note: String, } impl ReviewItem { pub fn new( id: String, subject: String, sender: String, content_excerpt: String, action: ClassificationAction, probability: f64, confidence: f64, ) -> Self { Self { id, subject, sender, content_excerpt, action, probability, confidence, approved: false, corrected_action: None, note: String::new(), } } fn effective_action(&self) -> ClassificationAction { self.corrected_action.unwrap_or(self.action) } fn is_candidate(&self) -> bool { self.action.is_mailbox_action() || self.effective_action().is_mailbox_action() } } #[derive(Debug, Default, PartialEq, Eq)] pub struct ReviewSelection { pub delete_ids: Vec, pub archive_ids: Vec, pub lessons: Vec, } #[derive(Debug, PartialEq, Eq)] enum UiAction { Continue, Cancel, Apply(ReviewSelection), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ReviewMode { Browse, ChooseCorrection, EditNote, Confirm, } struct ReviewApp { items: Vec, model: String, list_state: ListState, show_all: bool, mode: ReviewMode, correction_draft: Option, note_draft: String, } impl ReviewApp { fn new(items: Vec, model: String) -> Self { let show_all = !items.iter().any(ReviewItem::is_candidate); let mut app = Self { items, model, list_state: ListState::default(), show_all, mode: ReviewMode::Browse, correction_draft: None, note_draft: String::new(), }; if app.visible_count() > 0 { app.list_state.select(Some(0)); } app } fn visible_indices(&self) -> Vec { self.items .iter() .enumerate() .filter_map(|(index, item)| (self.show_all || item.is_candidate()).then_some(index)) .collect() } fn visible_count(&self) -> usize { if self.show_all { self.items.len() } else { self.items.iter().filter(|item| item.is_candidate()).count() } } fn selected_item_index(&self) -> Option { let visible_position = self.list_state.selected()?; self.visible_indices().get(visible_position).copied() } #[cfg(test)] fn selected_item(&self) -> Option<&ReviewItem> { self.selected_item_index() .and_then(|index| self.items.get(index)) } fn select_next(&mut self) { let Some(selected) = self.list_state.selected() else { return; }; self.list_state .select(Some((selected + 1).min(self.visible_count() - 1))); } fn select_previous(&mut self) { let Some(selected) = self.list_state.selected() else { return; }; self.list_state.select(Some(selected.saturating_sub(1))); } fn toggle_view(&mut self) { self.show_all = !self.show_all; self.list_state .select((self.visible_count() > 0).then_some(0)); } fn set_current(&mut self, approved: bool) { if let Some(index) = self.selected_item_index() { self.items[index].approved = approved && self.items[index].effective_action().is_mailbox_action(); } } fn toggle_current(&mut self) { if let Some(index) = self.selected_item_index() && self.items[index].effective_action().is_mailbox_action() { self.items[index].approved = !self.items[index].approved; } } fn set_all(&mut self, approved: bool) { for index in self.visible_indices() { self.items[index].approved = approved && self.items[index].effective_action().is_mailbox_action(); } } fn selected_counts(&self) -> (usize, usize) { self.items .iter() .filter(|item| item.approved) .fold((0, 0), |(delete, archive), item| { match item.effective_action() { ClassificationAction::Delete => (delete + 1, archive), ClassificationAction::Archive => (delete, archive + 1), ClassificationAction::Reply | ClassificationAction::Review => (delete, archive), } }) } fn lesson_count(&self) -> usize { self.items .iter() .filter(|item| item.corrected_action.is_some()) .count() } fn selection(&self) -> ReviewSelection { let mut selection = ReviewSelection::default(); for item in &self.items { if item.approved { match item.effective_action() { ClassificationAction::Delete => selection.delete_ids.push(item.id.clone()), ClassificationAction::Archive => selection.archive_ids.push(item.id.clone()), ClassificationAction::Reply | ClassificationAction::Review => {} } } if let Some(corrected_action) = item.corrected_action { selection.lessons.push(LessonInput { message_id: item.id.clone(), subject: item.subject.clone(), sender: item.sender.clone(), content_excerpt: item.content_excerpt.clone(), predicted_action: item.action, corrected_action, note: (!item.note.trim().is_empty()).then(|| item.note.trim().to_owned()), }); } } selection } fn begin_correction(&mut self) { if self.selected_item_index().is_some() { self.correction_draft = None; self.note_draft.clear(); self.mode = ReviewMode::ChooseCorrection; } } fn choose_correction(&mut self, action: ClassificationAction) { let Some(index) = self.selected_item_index() else { self.mode = ReviewMode::Browse; return; }; self.correction_draft = Some(action); self.note_draft.clone_from(&self.items[index].note); self.mode = ReviewMode::EditNote; } fn commit_correction(&mut self) { let Some(index) = self.selected_item_index() else { self.mode = ReviewMode::Browse; return; }; let Some(action) = self.correction_draft.take() else { self.mode = ReviewMode::Browse; return; }; self.items[index].corrected_action = Some(action); self.items[index].note = self.note_draft.trim().to_owned(); if !action.is_mailbox_action() { self.items[index].approved = false; } self.note_draft.clear(); self.mode = ReviewMode::Browse; } fn clear_correction(&mut self) { if let Some(index) = self.selected_item_index() { self.items[index].corrected_action = None; self.items[index].note.clear(); if !self.items[index].action.is_mailbox_action() { self.items[index].approved = false; } } } fn handle_key(&mut self, key: KeyEvent) -> UiAction { match self.mode { ReviewMode::Confirm => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => UiAction::Apply(self.selection()), KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.mode = ReviewMode::Browse; UiAction::Continue } _ => UiAction::Continue, }, ReviewMode::ChooseCorrection => { match key.code { KeyCode::Char('1') | KeyCode::Char('r') => { self.choose_correction(ClassificationAction::Reply) } KeyCode::Char('2') | KeyCode::Char('v') => { self.choose_correction(ClassificationAction::Review) } KeyCode::Char('3') | KeyCode::Char('a') => { self.choose_correction(ClassificationAction::Archive) } KeyCode::Char('4') | KeyCode::Char('d') => { self.choose_correction(ClassificationAction::Delete) } KeyCode::Esc => self.mode = ReviewMode::Browse, _ => {} } UiAction::Continue } ReviewMode::EditNote => { match key.code { KeyCode::Enter => self.commit_correction(), KeyCode::Esc => { self.correction_draft = None; self.note_draft.clear(); self.mode = ReviewMode::Browse; } KeyCode::Backspace => { self.note_draft.pop(); } KeyCode::Char(character) if !key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) && self.note_draft.chars().count() < MAX_NOTE_CHARS => { self.note_draft.push(character); } _ => {} } UiAction::Continue } ReviewMode::Browse => match key.code { KeyCode::Down | KeyCode::Char('j') => { self.select_next(); UiAction::Continue } KeyCode::Up | KeyCode::Char('k') => { self.select_previous(); UiAction::Continue } KeyCode::Char(' ') => { self.toggle_current(); UiAction::Continue } KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('y') => { self.set_current(true); UiAction::Continue } KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('n') => { self.set_current(false); UiAction::Continue } KeyCode::Char('a') => { self.set_all(true); UiAction::Continue } KeyCode::Char('r') => { self.set_all(false); UiAction::Continue } KeyCode::Char('v') => { self.toggle_view(); UiAction::Continue } KeyCode::Char('t') => { self.begin_correction(); UiAction::Continue } KeyCode::Char('x') => { self.clear_correction(); UiAction::Continue } KeyCode::Enter => { let (delete, archive) = self.selected_counts(); if delete + archive + self.lesson_count() == 0 { UiAction::Apply(ReviewSelection::default()) } else { self.mode = ReviewMode::Confirm; UiAction::Continue } } KeyCode::Char('q') | KeyCode::Esc => UiAction::Cancel, _ => UiAction::Continue, }, } } } pub fn run(items: Vec, model: String) -> Result> { let mut app = ReviewApp::new(items, model); ratatui::run(|terminal| -> std::io::Result> { loop { terminal.draw(|frame| render(frame, &mut app))?; if let Event::Key(key) = event::read()? && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { match app.handle_key(key) { UiAction::Continue => {} UiAction::Cancel => break Ok(None), UiAction::Apply(selection) => break Ok(Some(selection)), } } } }) .context("failed to run bulk review interface") } fn render(frame: &mut Frame<'_>, app: &mut ReviewApp) { let [header_area, list_area, footer_area] = Layout::vertical([ Constraint::Length(3), Constraint::Min(1), Constraint::Length(4), ]) .areas(frame.area()); let (delete_count, archive_count) = app.selected_counts(); let header = Paragraph::new(Line::from(vec![ Span::styled( "Inbox review", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!( " {}/{} shown | {} selected: {} delete, {} archive | {} lessons | {}", app.visible_count(), app.items.len(), delete_count + archive_count, delete_count, archive_count, app.lesson_count(), app.model )), ])) .block(Block::bordered()); frame.render_widget(header, header_area); let visible_indices = app.visible_indices(); let rows: Vec> = visible_indices .iter() .map(|&index| { let item = &app.items[index]; let effective_action = item.effective_action(); let action_color = match effective_action { ClassificationAction::Delete => Color::Red, ClassificationAction::Archive => Color::Blue, ClassificationAction::Reply => Color::Green, ClassificationAction::Review => Color::Yellow, }; let action = if effective_action == item.action { item.action.to_string() } else { format!("{}→{}", item.action, effective_action) }; let disposition = if effective_action.is_mailbox_action() { if item.approved { "○ keep ◉ apply" } else { "◉ keep ○ apply" } } else { "◉ keep no mailbox action" }; let lesson_marker = item.corrected_action.map_or("", |_| " [lesson]"); ListItem::new(Line::from(vec![ Span::styled( format!("{action:<15}"), Style::default() .fg(action_color) .add_modifier(Modifier::BOLD), ), Span::raw(format!( " {disposition} p={:.2} c={:.2} {} — {}{lesson_marker}", item.probability, item.confidence, item.subject, item.sender )), ])) }) .collect(); let title = if app.show_all { "All classifications — v action candidates" } else { "Archive/delete candidates — v show all classifications" }; let list = List::new(rows) .block(Block::bordered().title(title)) .highlight_symbol("› ") .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .scroll_padding(2); frame.render_stateful_widget(list, list_area, &mut app.list_state); let footer_text = match app.mode { ReviewMode::Confirm => { let (delete, archive) = app.selected_counts(); format!( "Apply {delete} deletes and {archive} archives; save {} lessons? y confirm n/Esc return", app.lesson_count() ) } ReviewMode::ChooseCorrection => { "Teach correct class: 1/r reply 2/v review 3/a archive 4/d delete Esc cancel" .to_owned() } ReviewMode::EditNote => format!( "Optional lesson note (Enter save, Esc cancel): {}", app.note_draft ), ReviewMode::Browse => { "↑/↓ move ←/→ keep/apply Space toggle a/r all v view t teach x clear lesson Enter finish q cancel" .to_owned() } }; let footer = Paragraph::new(footer_text) .block(Block::bordered()) .wrap(Wrap { trim: true }); frame.render_widget(footer, footer_area); } #[cfg(test)] mod tests { use super::*; use crossterm::event::KeyModifiers; use ratatui::{Terminal, backend::TestBackend}; fn items() -> Vec { vec![ ReviewItem::new( "delete-id".to_owned(), "Newsletter".to_owned(), "Publisher".to_owned(), "Weekly sale".to_owned(), ClassificationAction::Delete, 0.9, 0.8, ), ReviewItem::new( "archive-id".to_owned(), "Order confirmed".to_owned(), "Store".to_owned(), "Order 42".to_owned(), ClassificationAction::Archive, 0.95, 0.9, ), ReviewItem::new( "review-id".to_owned(), "Shipping details needed".to_owned(), "Campaign".to_owned(), "Confirm your address".to_owned(), ClassificationAction::Review, 0.8, 0.7, ), ReviewItem::new( "reply-id".to_owned(), "Can we talk?".to_owned(), "Ada".to_owned(), "Please reply".to_owned(), ClassificationAction::Reply, 0.85, 0.75, ), ] } fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } #[test] fn selections_default_to_keep_and_support_bulk_controls() { let mut app = ReviewApp::new(items(), "jev-test".to_owned()); assert_eq!(app.visible_count(), 2); assert_eq!(app.selection(), ReviewSelection::default()); app.handle_key(key(KeyCode::Char('a'))); assert_eq!(app.selection().delete_ids, ["delete-id"]); assert_eq!(app.selection().archive_ids, ["archive-id"]); app.handle_key(key(KeyCode::Char('r'))); assert_eq!(app.selection(), ReviewSelection::default()); } #[test] fn toggles_between_candidates_and_all_classifications() { let mut app = ReviewApp::new(items(), "jev-test".to_owned()); assert_eq!(app.visible_count(), 2); app.handle_key(key(KeyCode::Char('v'))); assert_eq!(app.visible_count(), 4); assert_eq!(app.selected_item().unwrap().id, "delete-id"); } #[test] fn correction_with_note_is_saved_without_a_mailbox_action() { let mut app = ReviewApp::new(items(), "jev-test".to_owned()); app.handle_key(key(KeyCode::Right)); app.handle_key(key(KeyCode::Char('t'))); app.handle_key(key(KeyCode::Char('2'))); for character in "needs shipping details".chars() { app.handle_key(key(KeyCode::Char(character))); } app.handle_key(key(KeyCode::Enter)); let selection = app.selection(); assert!(selection.delete_ids.is_empty()); assert!(selection.archive_ids.is_empty()); assert_eq!(selection.lessons.len(), 1); assert_eq!( selection.lessons[0].predicted_action, ClassificationAction::Delete ); assert_eq!( selection.lessons[0].corrected_action, ClassificationAction::Review ); assert_eq!( selection.lessons[0].note.as_deref(), Some("needs shipping details") ); } #[test] fn corrected_destructive_action_controls_the_applied_operation() { let mut app = ReviewApp::new(items(), "jev-test".to_owned()); app.handle_key(key(KeyCode::Down)); app.handle_key(key(KeyCode::Char('t'))); app.handle_key(key(KeyCode::Char('4'))); app.handle_key(key(KeyCode::Enter)); app.handle_key(key(KeyCode::Right)); let selection = app.selection(); assert_eq!(selection.delete_ids, ["archive-id"]); assert!(selection.archive_ids.is_empty()); assert_eq!( selection.lessons[0].corrected_action, ClassificationAction::Delete ); } #[test] fn enter_requires_confirmation_when_only_lessons_are_selected() { let mut app = ReviewApp::new(items(), "jev-test".to_owned()); app.handle_key(key(KeyCode::Char('t'))); app.handle_key(key(KeyCode::Char('2'))); app.handle_key(key(KeyCode::Enter)); assert_eq!(app.handle_key(key(KeyCode::Enter)), UiAction::Continue); assert_eq!(app.mode, ReviewMode::Confirm); assert_eq!( app.handle_key(key(KeyCode::Char('y'))), UiAction::Apply(app.selection()) ); } #[test] fn renders_candidates_and_learning_controls() { let backend = TestBackend::new(120, 12); let mut terminal = Terminal::new(backend).unwrap(); let mut app = ReviewApp::new(items(), "jev-test".to_owned()); terminal.draw(|frame| render(frame, &mut app)).unwrap(); let buffer = terminal.backend().buffer(); let rendered: String = (0..buffer.area.height) .flat_map(|y| (0..buffer.area.width).map(move |x| buffer[(x, y)].symbol().to_owned())) .collect(); assert!(rendered.contains("Newsletter")); assert!(rendered.contains("Order confirmed")); assert!(!rendered.contains("Shipping details needed")); assert!(rendered.contains("t teach")); assert!(rendered.contains("v show all")); } }