Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ dimmed. The keys also work without opening the menu.
| Where | Keys |
|---|---|
| anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `q` quit |
| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `v` select with `hjkl` `w` `b` `0` `$` |
| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `v` select with `hjkl` `w` `b` `0` `$`; `i` move the cursor with those keys first, then `v` to select from there |
| toolbar | `a` looks good · `c` comment · `d` delete · `Esc` |
| notes | `j`/`k`; `e` edit; `x` remove; click a bubble |
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
Expand Down
2 changes: 1 addition & 1 deletion crates/plannotator-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cargo build --release
| Where | Keys |
|---|---|
| anywhere | `Tab` cycle focus (tree · document · rail) · `E` send feedback (clipboard) · `t` show/hide tree · `r` reload · `q` quit |
| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations |
| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations |
| selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears |
| rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · click a bubble to focus it |
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
Expand Down
10 changes: 7 additions & 3 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,11 @@ impl App {
}
}

// Keyboard cursor, visible while selecting with the keyboard.
if doc_focused && self.selection.is_some_and(|s| s.dragging) && row_index == self.cursor.0 {
// Keyboard cursor, visible while roaming or selecting with the keyboard.
if doc_focused
&& (self.roam || self.selection.is_some_and(|s| s.dragging))
&& row_index == self.cursor.0
{
let x = doc.x + (self.cursor.1.min(usize::from(doc.width).saturating_sub(1))) as u16;
buf.set_style(Rect { x, y: screen_y, width: 1, height: 1 }, Style::new().bg(CURSOR_BG));
}
Expand Down Expand Up @@ -416,7 +419,8 @@ impl App {
_ if self.pending.is_some() => "a looks good · c comment · d delete · esc clear ",
Focus::Tree => "j/k · enter open · E send · t hide · q quit ",
Focus::Rail => "j/k · e edit · x remove · tab · q quit ",
Focus::Document => "drag or v select · c comment · E send · tab · q quit ",
Focus::Document if self.roam => "hjkl move · v select · c comment · esc blocks · q quit ",
Focus::Document => "i move · v select · c comment · E send · tab · q quit ",
};
// The status must stay readable at any width, so the key help yields columns to it
// (and is clipped) rather than the other way round.
Expand Down
35 changes: 31 additions & 4 deletions crates/plannotator-tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ impl App {
self.visual_key(key);
return Ok(());
}
// Roaming (`i`): the cursor moves with nothing selected yet, so `v` can start
// anywhere. Other keys fall through to their block-mode meaning.
if self.roam && self.selection.is_none() {
if key.code == KeyCode::Esc {
self.roam = false;
self.status = None;
return Ok(());
}
if self.motion_key(key) {
return Ok(());
}
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
if self.pending.is_some() || self.selection.is_some() {
Expand All @@ -182,6 +194,11 @@ impl App {
self.selection = Some(Selection::start(self.cursor));
self.status = Some("visual: move to extend, enter to select, esc to cancel".into());
}
(KeyCode::Char('i'), _) => {
self.clear_selection();
self.roam = true;
self.status = Some("move: hjkl w b 0 $ · v select · esc back to blocks".into());
}
(KeyCode::Char('j') | KeyCode::Down, _) => self.select_block(self.selected + 1),
(KeyCode::Char('k') | KeyCode::Up, _) => self.select_block(self.selected.saturating_sub(1)),
(KeyCode::Char('h') | KeyCode::Left, _) => self.move_cursor(0, -1),
Expand Down Expand Up @@ -219,6 +236,18 @@ impl App {
match key.code {
KeyCode::Esc => self.clear_selection(),
KeyCode::Enter | KeyCode::Char('v') => self.finish_selection(),
_ => {
self.motion_key(key);
}
}
if let Some(sel) = self.selection.as_mut() {
sel.set_head(self.cursor);
}
}

/// The cursor motions shared by visual and roaming modes. True when `key` was one.
fn motion_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('h') | KeyCode::Left => self.move_cursor(0, -1),
KeyCode::Char('l') | KeyCode::Right => self.move_cursor(0, 1),
KeyCode::Char('j') | KeyCode::Down => self.move_cursor(1, 0),
Expand All @@ -230,12 +259,10 @@ impl App {
self.cursor.1 =
self.open.layout.row(self.cursor.0).map_or(0, |r| r.cells.len().saturating_sub(1));
}
_ => {}
}
if let Some(sel) = self.selection.as_mut() {
sel.set_head(self.cursor);
_ => return false,
}
self.ensure_cursor_visible();
true
}

/// Move the keyboard cursor by rows/columns, skipping gap rows and clamping to text.
Expand Down
5 changes: 5 additions & 0 deletions crates/plannotator-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ impl Open {

use self::compose::Compose;

#[allow(clippy::struct_excessive_bools, reason = "independent toggles, none of them a state machine")]
pub(crate) struct App {
open: Open,
/// Where annotations are stored and how this folder is named there.
Expand Down Expand Up @@ -153,6 +154,8 @@ pub(crate) struct App {
pending: Option<Pending>,
/// Keyboard cursor for visual selection, in document (row, col).
cursor: (usize, usize),
/// `i`: the cursor moves by row without selecting, so `v` can start mid-block.
roam: bool,
/// Index into the rail's placed annotations.
rail_cursor: usize,
mode: Mode,
Expand Down Expand Up @@ -229,6 +232,7 @@ impl App {
selection: None,
pending: None,
cursor: (0, 0),
roam: false,
rail_cursor: 0,
mode: Mode::Browse,
candidates: Vec::new(),
Expand Down Expand Up @@ -408,6 +412,7 @@ impl App {
return;
}
self.clear_selection();
self.roam = false;
self.selected = block.min(self.open.doc.blocks.len() - 1);
if let Some(rendered) = self.open.layout.blocks.get(self.selected) {
self.cursor = (rendered.first_row, 0);
Expand Down
45 changes: 45 additions & 0 deletions crates/plannotator-tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,48 @@ fn pasting_into_the_comment_box_keeps_newlines() {
let placed = app.open.store.placed();
assert_eq!(placed.last().expect("annotation").annotation.body, "pasted one\npasted two");
}

#[test]
fn roaming_moves_by_row_so_a_selection_can_start_mid_block() {
// A paragraph with a hard break (two rows), then a list: in block mode `j` from the top lands on "- one",
// roaming lands on "second line" of the same block.
let source = DocumentSource::new(
"first line\\\nsecond line\n\n- one\n- two\n".to_owned(),
"doc.md",
true,
Provenance::Stdin,
);
let mut app = App::open(source, 60, Box::new(Discard)).expect("app opens");
app.data_dir = scratch_data_dir();
draw(&mut app);
let j = key(KeyCode::Char('j'), KeyModifiers::NONE);
app.handle_event(&j).expect("block j");
assert_eq!((app.selected, app.cursor.0), (1, 3), "block mode: j skips to the list");
app.handle_event(&key(KeyCode::Char('g'), KeyModifiers::NONE)).expect("g");

app.handle_event(&key(KeyCode::Char('i'), KeyModifiers::NONE)).expect("i");
app.handle_event(&j).expect("roam j");
assert_eq!((app.selected, app.cursor.0), (0, 1), "roaming: j moves one row, same block");
app.handle_event(&key(KeyCode::Char('v'), KeyModifiers::NONE)).expect("v");
app.handle_event(&key(KeyCode::Char('$'), KeyModifiers::NONE)).expect("$");
app.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)).expect("enter");
let pending = app.pending.as_ref().expect("selection finished");
assert_eq!(app.open.doc.source.get(pending.range.clone()), Some("second line"));

// Esc clears the selection, a second Esc leaves roaming, and j jumps blocks again.
app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("clear");
app.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)).expect("leave roam");
app.handle_event(&j).expect("block j");
assert_eq!(app.selected, 1);
}

#[test]
fn a_block_key_ends_roaming() {
let mut app = app(Box::new(Discard));
draw(&mut app);
app.handle_event(&key(KeyCode::Char('i'), KeyModifiers::NONE)).expect("i");
assert!(app.roam);
app.handle_event(&key(KeyCode::Char('G'), KeyModifiers::NONE)).expect("G");
assert!(!app.roam, "jumping to a block puts the cursor back on its first row");
assert_eq!(app.cursor, (app.open.layout.blocks[app.selected].first_row, 0));
}
13 changes: 13 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,3 +363,16 @@ looked up in whichever table holds it. Verified against the `beta` source
(`packages/core/src/session/sql.ts`, `packages/schema/src/session-message.ts`,
`packages/util/src/global-roots.ts`) and a mixed-schema fixture reproducing the report.


## 15. The cursor can roam before selecting (2026-09-18)

Keyboard selection always anchored at the selected block's first row, because block mode
had no row movement: commenting on the seventh bullet of a list meant the mouse. `i` now
enters roaming: the visual-mode motions move the cursor with nothing selected, `v` anchors
there, `Esc` (or any block key) returns to block mode.

Two alternatives were rejected. A vim-style `o` that swaps anchor and head inside visual
mode still forces the selection to start at the top and be shrunk from below. Splitting a
top-level list into one block per item was built and dropped: it made `j`/`k` + `c` reach a
bullet directly, but changed rendering (a block gap between every bullet) and the block
model for everyone, when roaming already reaches any line.