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
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,27 +50,44 @@ plannotator-tui last # your coding agent's recent replies, pick on
```

Drag with the mouse (or `v` and move) to select, then `a` 👍 · `c` 💬 · `d` ✗. `E` copies the
review to the clipboard as numbered annotations (`# Annotations on plan.md`, `## Annotation 1
feedback to the clipboard as numbered annotations (`# Annotations on plan.md`, `## Annotation 1
(line 12)`, …). Every annotation is saved as JSON the moment you make it; `q` closes.

Copies go to the clipboard as OSC 52, which is the terminal you are looking at, so on Herdr 0.9.0
they reach your own machine even when the app runs on a remote server; Herdr Annotate's global
`copy-context` and `copy-archive` actions do not, because they run outside a pane.

For file and folder reviews, `E` sends only new or edited annotations. Send A and B, then
add C: the next send includes just C. Sent notes stay visible with a marker; editing one
makes it pending again, including after a restart. `R` **Resend all** includes every active
note. With nothing pending, `E` reports “nothing new to send”. A failed send keeps the notes
pending for retry.

`F` **Finish review** archives sent, unchanged notes and leaves pending ones in place.
`U` undoes the last finish during this session. `H` opens the archive, where Enter or a
click restores a note even after reopening the app. Restoring keeps its original id and
sent status. The archive is stored with the annotations and works even when feedback
history is turned off. The header holds the send button and a `Review ▾ (m)` button whose
menu lists these four actions with live counts (`R` resend all · 3 sent, `F` finish review
· archive 3 sent, `U` undo finish, `H` archive · 2 notes); rows with nothing to act on are
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` `$` |
| toolbar | `a` looks good · `c` comment · `d` delete · `Esc` |
| notes | `j`/`k`; `e` edit; `x` remove; click a bubble |
| tree | `j`/`k`; `Enter` open; `E` sends every annotated file |
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
| tree | `j`/`k`; `Enter` open; `E` sends new notes across all reviewed files, including collapsed folders |

## Inside Herdr

Install [Herdr Annotate](https://github.com/plannotator/herdr-annotate); it bundles this binary,
opens it in a pane with `prefix+o` (folder) or `prefix+shift+o` (agent's last reply) or by
Ctrl-clicking a `file://…md` link, and the header button sends the review straight back to
the agent as its next message: `Send 3 to claude in w1:p2 ▸`.
the agent as its next message: `Send 3 new ▸ claude in w1:p2 (E)`. Folder reviews show
`Send 3 new across 2 files` and send one combined feedback message.

```toml
# ~/.config/plannotator-tui/config.toml
Expand Down Expand Up @@ -134,7 +151,7 @@ browser app writes, so both tools share one history. To turn it off, set
## Headless

```sh
plannotator-tui --export <file|folder> # the review, to stdout
plannotator-tui --export <file|folder> # all active notes, to stdout (no delivery recorded)
plannotator-tui --annotate <file> <quote> <text> [comment|looks_good|delete]
plannotator-tui --snapshot <file|folder> [cols rows scroll] [quote] # one frame as text
plannotator-tui --bench <file> # parse / layout timings
Expand Down
1 change: 1 addition & 0 deletions crates/plannotator-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ serde_json.workspace = true
similar.workspace = true
toml.workspace = true
unicode-width.workspace = true
time = { version = "0.3", features = ["formatting", "parsing"] }
pulldown-cmark = { version = "0.13.4", default-features = false }
ratatui = "0.30"
# highlight-code pulls syntect + a C oniguruma build; plain code blocks keep the build pure Rust.
Expand Down
14 changes: 12 additions & 2 deletions crates/plannotator-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@ cargo build --release
| 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 |
| 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 |
| tree | `j`/`k` move · `Enter` open · `E` send feedback for every annotated file · counts show per file |
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
| tree | `j`/`k` move · `Enter` open · `E` send new feedback across files, including collapsed folders · counts show active notes per file |
| archive | `j`/`k` or ↑/↓ select · `Enter` or click restore · `Esc` close |

Selections and exports are copied to the terminal clipboard (OSC 52).

Sent notes remain visible. File reviews send only new or edited notes by default;
`R` explicitly includes the sent ones too. `F` archives only sent notes that have not
changed since delivery. Archived notes survive restarts, and restoring them keeps their
ids and delivery history. Failed sends keep the feedback pending. The header shows the
send button and a `Review ▾ (m)` button; its menu lists the four actions with live counts,
dims the ones with nothing to act on, and runs a row on `Enter`, a click, or the row's own
key. The keys also work directly, without the menu.

## Where things live

Every annotation is saved the moment it is made, as JSON, in the Plannotator data directory:
Expand All @@ -37,7 +47,7 @@ documents (an agent's last message, stdin) are never persisted.
## Headless tools

```bash
plannotator-tui --export <file.md> # feedback markdown to stdout
plannotator-tui --export <file.md> # all active feedback to stdout; does not mark it sent
plannotator-tui --bench <file.md> # parse / render / reflow timings
plannotator-tui --blocks <file.md> # block index, kind, first row
plannotator-tui --annotate <file.md> <quote> <text> [comment|looks_good|delete]
Expand Down
103 changes: 103 additions & 0 deletions crates/plannotator-tui/src/app/archive_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! A small archive picker: identify a note by file, quote and body, then restore it.

use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};

use super::{App, Mode, label};

impl App {
pub(super) fn archive_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.move_archive_cursor(1),
KeyCode::Char('k') | KeyCode::Up => self.move_archive_cursor(-1),
KeyCode::Enter => self.restore_selected_archived(),
KeyCode::Esc | KeyCode::Char('H' | 'q') => self.mode = Mode::Browse,
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => self.mode = Mode::Browse,
_ => {}
}
}

fn move_archive_cursor(&mut self, delta: i64) {
let last = self.archive_items.len().saturating_sub(1);
self.archive_cursor = (self.archive_cursor as i64 + delta).clamp(0, last as i64) as usize;
}

pub(super) fn archive_mouse(&mut self, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollDown => self.move_archive_cursor(1),
MouseEventKind::ScrollUp => self.move_archive_cursor(-1),
MouseEventKind::Down(MouseButton::Left) => {
let hit = self.geometry.archive_rows.iter().find(|(rect, _)| {
mouse.column >= rect.x
&& mouse.column < rect.right()
&& mouse.row >= rect.y
&& mouse.row < rect.bottom()
});
if let Some((_, index)) = hit {
self.archive_cursor = *index;
self.restore_selected_archived();
}
}
_ => {}
}
}

pub(super) fn draw_archive(&mut self, frame: &mut Frame) {
let area = frame.area();
let width = area.width.saturating_sub(4).clamp(1, 100).min(area.width);
let height = area.height.saturating_sub(4).clamp(3, 22).min(area.height);
let rect = Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
};
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(Color::Cyan))
.title(format!(" Archived annotations ({}) ", self.archive_items.len()))
.title_bottom(" ↑↓ select · enter/click restore · esc close ");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if self.archive_items.is_empty() {
frame.render_widget(
Paragraph::new("No archived annotations. F finishes sent annotations."),
inner,
);
return;
}
let visible = (usize::from(inner.height) / 3).max(1);
let start = self.archive_cursor.saturating_sub(visible - 1);
for (index, item) in self.archive_items.iter().enumerate().skip(start).take(visible) {
let y = inner.y + ((index - start) * 3) as u16;
if y >= inner.bottom() {
break;
}
let path = self.review_file_name(&item.path);
let quote = item.annotation.anchor.original_text.split_whitespace().collect::<Vec<_>>().join(" ");
let body = item.annotation.body.split_whitespace().collect::<Vec<_>>().join(" ");
let style = if index == self.archive_cursor {
Style::new().fg(Color::Black).bg(Color::Cyan)
} else {
Style::new()
};
let rows = vec![
Line::from(Span::styled(
format!(" {path} · {}", label(item.annotation.anchor.kind())),
style,
)),
Line::from(Span::styled(format!(" “{quote}”"), style)),
Line::from(Span::styled(format!(" {body}"), style)),
];
let row_rect = Rect { x: inner.x, y, width: inner.width, height: 3.min(inner.bottom() - y) };
frame.render_widget(Paragraph::new(rows).style(style), row_rect);
self.geometry.archive_rows.push((row_rect, index));
}
}
}
42 changes: 29 additions & 13 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ fn priority(kind: Kind) -> u8 {
impl App {
pub(crate) fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
let [header, body, footer] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]).areas(area);
let [header, body, footer] = Layout::vertical([
Constraint::Length(self.header_height(area.width)),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(area);

let show_tree = self.tree_shown(area.width) || (self.tree.is_some() && self.focus == Focus::Tree);
let tree_width = if show_tree { TREE_WIDTH } else { 0 };
Expand All @@ -66,14 +70,7 @@ impl App {
Constraint::Length(rail_width),
])
.areas(body);
self.geometry = Geometry {
tree,
doc,
toolbar: None,
bubbles: Vec::new(),
send_button: None,
pick_rows: Vec::new(),
};
self.geometry = Geometry { tree, doc, ..Geometry::default() };

if self.open.layout.width != usize::from(doc.width) {
self.open.layout.reflow(usize::from(doc.width));
Expand All @@ -97,6 +94,8 @@ impl App {
Mode::Edit(_) => self.draw_compose(frame, &self.compose_title("edit")),
Mode::Browse if self.pending.is_some() => self.draw_toolbar(frame),
Mode::Pick => self.draw_pick(frame),
Mode::Archive => self.draw_archive(frame),
Mode::ReviewMenu => self.draw_review_menu(frame),
Mode::Browse | Mode::ConfirmQuit => {}
}
}
Expand Down Expand Up @@ -345,8 +344,13 @@ impl App {
let border =
if highlighted { Style::new().fg(accent(kind)) } else { Style::new().fg(Color::DarkGray) };
let border = if rail_focused && index == self.rail_cursor { border.bold() } else { border };
let sent = if self.is_file_review() && !self.open.store.is_pending(placed.annotation) {
" · sent"
} else {
""
};
let title = Span::styled(
format!(" {} {} ", glyph(kind), short_id(&placed.annotation.id)),
format!(" {} {}{sent} ", glyph(kind), short_id(&placed.annotation.id)),
Style::new().fg(accent(kind)),
);
let bubble = Block::default()
Expand All @@ -367,7 +371,7 @@ impl App {
self.geometry.bubbles = bubbles;
}

fn draw_footer(&self, frame: &mut Frame, area: Rect) {
fn draw_footer(&mut self, frame: &mut Frame, mut area: Rect) {
if self.mode == Mode::ConfirmQuit {
// The question owns the footer: the browse help would name keys that are not
// live while it is up.
Expand All @@ -378,6 +382,14 @@ impl App {
frame.render_widget(Paragraph::new(Line::from(Span::raw(question).bold())), area);
return;
}
if self.mode == Mode::Browse && !self.undo_archive.is_empty() {
let label = " U Undo finish ";
let width = (label.width() as u16).min(area.width);
let rect = Rect { x: area.right() - width, y: area.y, width, height: area.height };
frame.render_widget(Paragraph::new(label).style(Style::new().fg(Color::Cyan)), rect);
self.geometry.undo_button = Some(rect);
area.width = area.width.saturating_sub(width);
}
let orphans = self.open.store.orphans();
// The status leads: it is the transient half of the line, and the name and counters
// it pushes right are on screen for the whole session anyway.
Expand Down Expand Up @@ -406,8 +418,12 @@ impl App {
Focus::Rail => "j/k · e edit · x remove · tab · q quit ",
Focus::Document => "drag or 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.
let status_width = self.status.as_ref().map_or(0, |s| s.width() + 1) as u16;
let help_width = (help.width() as u16).min(area.width.saturating_sub(status_width.max(10)));
let [left_area, right_area] =
Layout::horizontal([Constraint::Min(10), Constraint::Length(help.width() as u16)]).areas(area);
Layout::horizontal([Constraint::Min(10), Constraint::Length(help_width)]).areas(area);
frame.render_widget(
Paragraph::new(Line::from(Span::raw(format!(" {}", parts.join(" · "))).dim())),
left_area,
Expand Down
Loading
Loading