Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/plannotator-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<usize, Open>,
message_host: String,
message_transcript: String,
compose: Compose,
Expand Down Expand Up @@ -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(),
Expand Down
56 changes: 47 additions & 9 deletions crates/plannotator-tui/src/app/pick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,35 +40,70 @@ 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(())
}

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;
}
}

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,
_ => {}
}
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions crates/plannotator-tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
Loading