From 7bad5b19a32ffaff321f2fcafaab50497b3a3abf Mon Sep 17 00:00:00 2001 From: Triyambak Date: Tue, 1 Sep 2026 14:31:32 +0530 Subject: [PATCH] feat(pick): preview the highlighted message while the picker is open The picker listed candidates but always showed the newest behind them, so choosing meant opening a message, reading it, backing out and trying the next one. Moving the cursor now swaps the document underneath and stays in the picker, which is what the list is for. The document being left is kept rather than dropped. A reply review's store is transient, so previewing away from an annotated message and back would otherwise lose the annotations; Enter had the same hole. Esc returns to whatever was open when the picker appeared, so escaping still leaves the newest message open in the common case, and returns you where you were when `p` reopened the picker mid-review. Footer updated to match. --- crates/plannotator-tui/src/app/mod.rs | 11 +++++ crates/plannotator-tui/src/app/pick.rs | 56 +++++++++++++++++++++---- crates/plannotator-tui/src/app/tests.rs | 28 +++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index 10eb1be..e18e65c 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -12,6 +12,7 @@ mod send; #[cfg(test)] mod tests; +use std::collections::HashMap; use std::fmt::Write as _; use std::ops::Range; use std::path::{Path, PathBuf}; @@ -139,6 +140,13 @@ pub(crate) struct App { /// Minutes east of UTC used to draw message times. Pinned in tests so the picker /// renders the same on any machine. clock_offset: i32, + /// The candidate currently on screen, and the one Esc goes back to. + pick_open: usize, + pick_return: usize, + /// Documents already built for candidates. Previewing swaps `open`, and a reply + /// review's annotations live only in memory, so the one being left is kept here + /// rather than dropped. + pick_cache: HashMap, message_host: String, message_transcript: String, compose: Compose, @@ -195,6 +203,9 @@ impl App { candidates: Vec::new(), pick_cursor: 0, clock_offset: pick::local_offset_minutes(), + pick_open: 0, + pick_return: 0, + pick_cache: HashMap::new(), message_host: String::new(), message_transcript: String::new(), compose: Compose::default(), diff --git a/crates/plannotator-tui/src/app/pick.rs b/crates/plannotator-tui/src/app/pick.rs index 6ea80b9..47dbaab 100644 --- a/crates/plannotator-tui/src/app/pick.rs +++ b/crates/plannotator-tui/src/app/pick.rs @@ -40,17 +40,40 @@ impl App { Ok(app) } - /// Swap the open document for candidate `index`. - fn open_candidate(&mut self, index: usize) -> Result<()> { - let Some(message) = self.candidates.get(index) else { return Ok(()) }; - let source = message_source(&self.message_host, &self.message_transcript, message); - self.open = Open::new(source, self.open.layout.width, &self.data_dir, &self.project)?; + /// Show candidate `index` behind the picker, staying in the picker. + /// + /// The document being left is kept rather than dropped. A reply review's + /// annotations live only in memory, so moving away from one and back again must + /// not lose them. + fn show_candidate(&mut self, index: usize) -> Result<()> { + if index == self.pick_open { + return Ok(()); + } + let next = if let Some(open) = self.pick_cache.remove(&index) { + open + } else { + let Some(message) = self.candidates.get(index) else { return Ok(()) }; + let source = message_source(&self.message_host, &self.message_transcript, message); + Open::new(source, self.open.layout.width, &self.data_dir, &self.project)? + }; + let leaving = std::mem::replace(&mut self.open, next); + self.pick_cache.insert(self.pick_open, leaving); + self.pick_open = index; self.scroll = 0; self.selected = 0; self.cursor = (0, 0); self.rail_cursor = 0; self.clear_selection(); self.derive_send_state(); + Ok(()) + } + + /// Swap the open document for candidate `index` and leave the picker. + fn open_candidate(&mut self, index: usize) -> Result<()> { + if self.candidates.get(index).is_none() { + return Ok(()); + } + self.show_candidate(index)?; self.mode = Mode::Browse; self.status = Some(format!("message {} of {}", index + 1, self.candidates.len())); Ok(()) @@ -58,6 +81,8 @@ impl App { pub(super) fn reopen_picker(&mut self) { if self.candidates.len() > 1 { + self.pick_return = self.pick_open; + self.pick_cursor = self.pick_open; self.mode = Mode::Pick; } } @@ -65,10 +90,20 @@ impl App { pub(super) fn pick_key(&mut self, key: KeyEvent) -> Result<()> { let last = self.candidates.len().saturating_sub(1); match key.code { - KeyCode::Char('j') | KeyCode::Down => self.pick_cursor = (self.pick_cursor + 1).min(last), - KeyCode::Char('k') | KeyCode::Up => self.pick_cursor = self.pick_cursor.saturating_sub(1), + KeyCode::Char('j') | KeyCode::Down => { + self.pick_cursor = (self.pick_cursor + 1).min(last); + return self.show_candidate(self.pick_cursor); + } + KeyCode::Char('k') | KeyCode::Up => { + self.pick_cursor = self.pick_cursor.saturating_sub(1); + return self.show_candidate(self.pick_cursor); + } KeyCode::Enter => return self.open_candidate(self.pick_cursor), - KeyCode::Esc => self.mode = Mode::Browse, + KeyCode::Esc => { + self.show_candidate(self.pick_return)?; + self.pick_cursor = self.pick_return; + self.mode = Mode::Browse; + } KeyCode::Char('q') => self.quit = true, _ => {} } @@ -101,7 +136,10 @@ impl App { .border_type(BorderType::Rounded) .border_style(Style::new().fg(Color::Cyan)) .title(Span::styled(" which message? ", Style::new().dim())) - .title_bottom(Span::styled(" ↑↓ choose · enter open · esc newest · q quit ", Style::new().dim())); + .title_bottom(Span::styled( + " ↑↓ preview · enter open · esc cancel · q quit ", + Style::new().dim(), + )); let inner = boxed.inner(rect); frame.render_widget(boxed, rect); let mut pick_rows = Vec::new(); diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 9288a8d..406a053 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -138,6 +138,34 @@ fn escaping_the_picker_keeps_the_newest_message() { assert_eq!(app.mode, Mode::Pick, "p reopens the picker"); } +#[test] +fn moving_the_picker_cursor_previews_that_message() { + let mut app = App::open_message("claude", "/tmp/transcript.jsonl", candidates(), 60, Box::new(Discard)) + .expect("opens"); + assert_eq!(app.open.doc.source, "# Third\n\nnewest message\n", "the newest opens behind the picker"); + + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('j')))).expect("j"); + + assert_eq!(app.mode, Mode::Pick, "previewing does not leave the picker"); + assert_eq!(app.open.doc.source, "# Second\n\nmiddle message\n", "the document follows the cursor"); +} + +#[test] +fn previewing_away_and_back_keeps_annotations() { + let mut app = App::open_message("claude", "/tmp/transcript.jsonl", candidates(), 60, Box::new(Discard)) + .expect("opens"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Esc))).expect("esc"); + app.add_block_annotation(0, Kind::Comment, "keep me".to_owned()).expect("annotate"); + assert_eq!(app.open.store.placed().len(), 1); + + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('p')))).expect("p"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('j')))).expect("j"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('k')))).expect("k"); + + assert_eq!(app.open.doc.source, "# Third\n\nnewest message\n", "back where we started"); + assert_eq!(app.open.store.placed().len(), 1, "a reply review only holds annotations in memory"); +} + /// A folder of `count` Markdown files named `f00.md`, `f01.md`, … in a fresh temp dir. fn folder(count: usize) -> PathBuf { let root = std::env::temp_dir().join(format!("plannotator-tui-folder-{}", std::process::id()));