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()));