Something went wrong. Try again.
A personal email screening utility using Himalaya, Jev, and Ratatui
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662use 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<ClassificationAction>, 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<String>, pub archive_ids: Vec<String>, pub lessons: Vec<LessonInput>,}
#[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<ReviewItem>, model: String, list_state: ListState, show_all: bool, mode: ReviewMode, correction_draft: Option<ClassificationAction>, note_draft: String,}
impl ReviewApp { fn new(items: Vec<ReviewItem>, 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<usize> { 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<usize> { 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<ReviewItem>, model: String) -> Result<Option<ReviewSelection>> { let mut app = ReviewApp::new(items, model); ratatui::run(|terminal| -> std::io::Result<Option<ReviewSelection>> { 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<ListItem<'_>> = 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<ReviewItem> { 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")); }}