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: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ copies the feedback to the clipboard as numbered annotations (`# Annotations on
`## Annotation 1 (line 12)`, …). Every annotation is saved as JSON the moment you make it; `q`
closes.

`I` attaches a local image to the selected note (in the notes, or the selected block's note in
the document): type, paste or drop its path, then `Enter`. Quoted, `~` and `file://` paths
work; a relative path starts at the document's folder. PNG, JPEG, GIF, WebP, BMP and SVG files
are accepted. The feedback lists each one as `Image: /absolute/path.png` under its note, so the
agent opens it from your disk; nothing is copied or uploaded. `D` removes the last image added.
Adding or removing an image makes a sent note pending again.

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.
Expand Down Expand Up @@ -90,9 +97,9 @@ dimmed. The keys also work without opening the menu.
|---|---|
| anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit |
| reply review | `S` send and close |
| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there |
| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note |
| toolbar | `a` looks good · `c` comment · `d` delete · `Esc` |
| notes | `j`/`k`; `e` edit; `x` remove; click a bubble |
| notes | `j`/`k`; `e` edit; `x` remove; `I` attach an image, `D` remove the last; click a bubble |
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
| folder review | `Ctrl+P` open a file by name, including in collapsed folders: type, `↑`/`↓` or click, `Enter`, `Esc` |
| tree | `j`/`k`; `Enter` open; `.` show/hide dot-prefixed entries (`.agents/`, `.github/`); `E` sends new notes across all reviewed files, including collapsed folders |
Expand Down Expand Up @@ -237,6 +244,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key
| `resend-all` `finish-review` `undo-finish` `archive` | `R` `F` `U` `H` | file and folder review actions |
| `toggle-hidden` | `.` | show/hide dot-prefixed entries in the tree |
| `edit` `remove` | `e` `x`, `Delete` | the selected note |
| `attach-image` `remove-image` | `I` `D` | an image on the selected note, or the selected block's |
| `visual` `move-cursor` | `v` `i` | select with the cursor; move it first |
| `comment` `clear-block` | `c` `x` | on the block; clear its annotations |
| `clear-all` | `X` | every annotation on the document, after `yes` |
Expand Down
53 changes: 53 additions & 0 deletions crates/plannotator-tui-schema/src/annotation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ pub struct Annotation {
pub state: State,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<String>,
/// plannotator-tui's own fields. Workspaces stores annotations opaquely beyond its own
/// keys; an empty namespace is omitted, so a note without them keeps the server shape.
#[serde(default, skip_serializing_if = "NoteExtras::is_empty")]
pub plannotator_tui: NoteExtras,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
Expand All @@ -58,6 +62,24 @@ pub struct Annotation {
pub other: BTreeMap<String, Value>,
}

/// The `plannotator_tui` namespace on a root annotation.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoteExtras {
/// Absolute paths of local image files attached to the note, in the order attached.
/// References only: the files are never copied or uploaded, and Workspaces' own
/// `attachments` (uploaded `https://` URLs) is a different field.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<String>,
#[serde(flatten)]
pub other: BTreeMap<String, Value>,
}

impl NoteExtras {
pub fn is_empty(&self) -> bool {
self.images.is_empty() && self.other.is_empty()
}
}

/// Body of `POST .../annotations`. The whole request is capped at 32 KiB; `body` at 8 KiB;
/// the serialized `anchor` at 16 KiB; `author` at 120 characters.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -103,3 +125,34 @@ impl std::fmt::Display for ApiError {
}

impl std::error::Error for ApiError {}

#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::*;

fn note(extras: &str) -> String {
format!(
r#"{{"id":"a1","document_id":"d1","anchor":{{"originalText":"hello"}},"body":"b","author":null,
"state":"open","created_at":"t","updated_at":"t","replies":[]{extras}}}"#
)
}

#[test]
fn a_note_without_images_keeps_the_server_shape() {
let parsed: Annotation = serde_json::from_str(&note("")).expect("an older record parses");
assert_eq!(parsed.plannotator_tui, NoteExtras::default());
let json = serde_json::to_value(&parsed).expect("serializable");
assert!(json.get("plannotator_tui").is_none(), "{json}");
}

#[test]
fn images_round_trip_beside_unknown_namespace_fields() {
let text = note(r#","plannotator_tui":{"images":["/tmp/a.png"],"later":1}"#);
let parsed: Annotation = serde_json::from_str(&text).expect("parses");
assert_eq!(parsed.plannotator_tui.images, ["/tmp/a.png"]);
assert_eq!(parsed.attachments, Vec::<String>::new(), "local paths never become Workspaces URLs");
let back = serde_json::to_value(&parsed).expect("serializable");
assert_eq!(back, serde_json::from_str::<Value>(&text).expect("JSON"));
}
}
2 changes: 1 addition & 1 deletion crates/plannotator-tui-schema/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub mod source;
pub mod version;

pub use anchor::{Anchor, CONTEXT_CHARS, Extras, Kind, SourceRange};
pub use annotation::{Annotation, ApiError, CreateAnnotation, PatchAnnotation, Reply, State};
pub use annotation::{Annotation, ApiError, CreateAnnotation, NoteExtras, PatchAnnotation, Reply, State};
pub use datadir::{annotations_dir, data_dir, history_slug, project_name, sanitize_tag};
pub use resolve::{Resolution, resolve, web_will_match};
pub use source::{DocumentSource, Provenance};
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 @@ -15,7 +15,7 @@ cargo build --release
| 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 · `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 · `X` clears every annotation on the document after a `y` · wide Markdown tables wrap by cell |
| 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 |
| rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · `I` attach a local image by path · `D` remove the last image · click a bubble to focus it |
| reply review | `S` send feedback and quit; stays open if the agent is at a dialog |
| 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 · `.` show/hide hidden (dot-prefixed) entries · `E` send new feedback across files, including collapsed folders · counts show active notes per file |
Expand Down
64 changes: 45 additions & 19 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ impl App {
match &self.mode {
Mode::Compose => self.draw_compose(frame, &self.compose_title("comment")),
Mode::Edit(_) => self.draw_compose(frame, &self.compose_title("edit")),
Mode::AttachImage(_) => {
self.draw_compose(frame, " image path \u{b7} enter attaches \u{b7} esc cancels ");
}
Mode::Browse if self.pending.is_some() => self.draw_toolbar(frame),
Mode::Pick => self.draw_pick(frame),
Mode::FileSearch => self.draw_file_search(frame),
Expand Down Expand Up @@ -318,7 +321,7 @@ impl App {
}

fn edit_origin(&self, height: u16, width: u16) -> Option<Rect> {
let Mode::Edit(id) = &self.mode else { return None };
let (Mode::Edit(id) | Mode::AttachImage(id)) = &self.mode else { return None };
let (rect, _) = self.geometry.bubbles.iter().find(|(_, bubble_id)| bubble_id == id)?;
let area = self.geometry.doc.union(*rect);
let x = rect.right().saturating_sub(width).max(area.x);
Expand Down Expand Up @@ -366,8 +369,13 @@ impl App {
} else {
""
};
let images = match placed.annotation.plannotator_tui.images.len() {
0 => String::new(),
1 => " · 1 image".to_owned(),
n => format!(" · {n} images"),
};
let title = Span::styled(
format!(" {} {}{sent} ", glyph(kind), short_id(&placed.annotation.id)),
format!(" {} {}{images}{sent} ", glyph(kind), short_id(&placed.annotation.id)),
Style::new().fg(accent(kind)),
);
let bubble = Block::default()
Expand Down Expand Up @@ -445,6 +453,23 @@ impl App {
if self.viewport(frame.area()).width < RAIL_MIN_TOTAL_WIDTH {
parts.push("rail hidden: widen to ≥80 cols".into());
}
let help = self.footer_help();
let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) };
// 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)]).areas(area);
frame.render_widget(
Paragraph::new(Line::from(Span::raw(format!(" {}", parts.join(" · "))).dim())),
left_area,
);
frame.render_widget(Paragraph::new(Line::from(Span::raw(help).dim()).right_aligned()), right_area);
}

/// The key help for the focused pane, in the user's keys.
fn footer_help(&self) -> Vec<String> {
// Each hint names its action's first key; an unbound action drops out of the line.
let key = |action, label| self.keys.labelled(action, label);
let bare = |hint: String| (!hint.is_empty()).then_some(hint);
Expand All @@ -462,6 +487,10 @@ impl App {
let send_and_close =
if self.is_file_review() { None } else { key(Action::SendAndClose, "send+quit") };
let quit = [send_and_close, key(Action::Quit, "quit")];
// The image keys act on the current note; removing is offered only when it has one.
let note = self.current_note();
let has_images = note.as_deref().is_some_and(|id| self.open.store.has_images(id));
let remove_image = has_images.then(|| key(Action::RemoveImage, "remove image")).flatten();
let help: Vec<Option<String>> = match self.focus {
_ if self.pending.is_some() => vec![
key(Action::LooksGood, "looks good"),
Expand All @@ -477,10 +506,17 @@ impl App {
key(Action::ToggleTree, "hide"),
key(Action::Quit, "quit"),
],
Focus::Rail => [up_down, key(Action::Edit, "edit"), key(Action::Remove, "remove"), tab]
.into_iter()
.chain(quit)
.collect(),
Focus::Rail => [
up_down,
key(Action::Edit, "edit"),
key(Action::Remove, "remove"),
key(Action::AttachImage, "image"),
remove_image,
tab,
]
.into_iter()
.chain(quit)
.collect(),
Focus::Document if self.roam => [
bare(self.keys.directions_hint()).map(|keys| format!("{keys} move")),
key(Action::Visual, "select"),
Expand All @@ -494,6 +530,8 @@ impl App {
key(Action::MoveCursor, "move"),
key(Action::Visual, "select"),
key(Action::Comment, "comment"),
note.is_some().then(|| key(Action::AttachImage, "image")).flatten(),
remove_image,
key(Action::Send, "send"),
tab,
]
Expand All @@ -505,19 +543,7 @@ impl App {
// last.
let find =
if self.tree.is_some() && self.pending.is_none() { key(Action::QuickOpen, "find") } else { None };
let help: Vec<String> = help.into_iter().chain([find]).flatten().collect();
let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) };
// 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)]).areas(area);
frame.render_widget(
Paragraph::new(Line::from(Span::raw(format!(" {}", parts.join(" · "))).dim())),
left_area,
);
frame.render_widget(Paragraph::new(Line::from(Span::raw(help).dim()).right_aligned()), right_area);
help.into_iter().chain([find]).flatten().collect()
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/plannotator-tui/src/app/feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub(super) struct FeedbackPart {
pub(super) struct Feedback {
pub(super) text: String,
pub(super) count: usize,
/// Image paths across the sent notes, for the archive's counts.
pub(super) images: usize,
pub(super) parts: Vec<FeedbackPart>,
pub(super) annotations: Vec<AnnotationRecord>,
pub(super) counts: HashMap<PathBuf, ReviewCounts>,
Expand Down Expand Up @@ -93,6 +95,7 @@ impl Feedback {
self.text.push_str(&export::feedback(name, &entries));
let ids = entries.iter().map(|e| e.annotation.id.clone()).collect();
self.count += entries.len();
self.images += entries.iter().map(|e| e.annotation.plannotator_tui.images.len()).sum::<usize>();
self.annotations.extend(entries.iter().map(|entry| {
let a = entry.annotation;
AnnotationRecord {
Expand Down
Loading
Loading