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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ herdr server reload-config
| `Ctrl+B Ctrl+S` | send all annotations as the focused agent's next message, then archive them |
| `Ctrl+B M` | manage · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) |

Annotations are listed and handed off oldest first, in the order you wrote them, so the agent
reads your feedback in the same order you went through the text. The Markdown uses Plannotator's
feedback format: one numbered `## Annotation N` per note, headed with the workspace and tab it came
from, then `Comment on:` with the selected text and your comment as a `>` quote.

Copies made inside the manager pane also emit OSC 52, so on Herdr 0.9.0 they reach the clipboard of
the machine you are viewing from even when the plugin runs on a remote server with no clipboard tool
installed; `Ctrl+B Shift+A` and `Ctrl+B Ctrl+A` do not, because those actions run outside a pane and
Expand Down
7 changes: 7 additions & 0 deletions docs/lite-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ Wayland, then xclip, then xsel.
only for a deliberate behavior change, and read the diff: every rewritten line is a behavior the
retired runtime no longer defines.

Deliberate re-records so far:

- 0.8.0 (#66): annotations are listed and handed off oldest first instead of newest first, the
manager header reads `oldest first`, and the copied Markdown follows Plannotator's feedback
format (`# Annotations on terminal selections`, `## Annotation N (workspace / tab)`,
`Comment on:`, `>` comment). The store bytes and `bun-editor-annotations.jsonl` are unchanged.

## Everything else

| Check | Command |
Expand Down
2 changes: 1 addition & 1 deletion herdr-annotate.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.2.0
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "annotate"
name = "Annotate"
version = "0.7.0"
version = "0.8.0"
min_herdr_version = "0.8.0"
description = "Comment on terminal selections and copy annotations as agent context."
platforms = ["linux", "macos", "windows"]
Expand Down
2 changes: 1 addition & 1 deletion lite/herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

id = "annotate"
name = "Annotate"
version = "0.7.0"
version = "0.8.0"
min_herdr_version = "0.8.0"
description = "Comment on terminal selections and copy annotations as agent context."
platforms = ["linux", "macos", "windows"]
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.lock

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

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "herdr-annotate"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.96"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion rust/src/agent_delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,6 @@ mod tests {
*events.borrow(),
["load", "agent get", "agent prompt", "archive", "remove"]
);
assert!(prompted.borrow().find("selection two") < prompted.borrow().find("selection one"));
assert!(prompted.borrow().find("selection one") < prompted.borrow().find("selection two"));
}
}
6 changes: 3 additions & 3 deletions rust/src/archive_workflow.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Recoverable deliver/archive and restore transitions.

use crate::format::format_annotations;
use crate::store::{StoreResult, newest_first_annotations};
use crate::store::{StoreResult, capture_order_annotations};
use crate::types::{Annotation, ArchivedAnnotationSet};

/// Dependencies for one copy-and-archive transition.
Expand Down Expand Up @@ -48,7 +48,7 @@ where
};
}
if let Err(message) =
(dependencies.deliver)(format_annotations(&newest_first_annotations(&active)))
(dependencies.deliver)(format_annotations(&capture_order_annotations(&active)))
{
return CopyAndArchiveOutcome::StayOpen { message };
}
Expand Down Expand Up @@ -199,7 +199,7 @@ mod tests {
assert_eq!(outcome, CopyAndArchiveOutcome::Close { archived_count: 2 });
assert_eq!(*events.borrow(), ["load", "copy", "archive", "remove"]);
assert!(
clipboard.borrow().find("selection two") < clipboard.borrow().find("selection one")
clipboard.borrow().find("selection one") < clipboard.borrow().find("selection two")
);
assert_eq!(saved.borrow().as_ref(), Some(&archive(&["one", "two"])));
assert_eq!(*removed.borrow(), ["one", "two"]);
Expand Down
4 changes: 2 additions & 2 deletions rust/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::handoff::take_default_handoff;
use crate::herdr::{notify, run_herdr, run_herdr_output};
use crate::paths::{normalize_windows_path, plugin_root, state_dir};
use crate::store::{
append_archived_set, load_annotations, newest_first_annotations, remove_annotations_by_id,
append_archived_set, capture_order_annotations, load_annotations, remove_annotations_by_id,
};
use crate::types::{
ArchivedAnnotationSet, PendingAnnotation, javascript_trim, parse_invocation_context,
Expand Down Expand Up @@ -126,7 +126,7 @@ fn capture() -> Result<(), String> {

fn copy_context() -> Result<(), String> {
let dir = state_dir().ok_or_else(|| "HERDR_PLUGIN_STATE_DIR is not set".to_owned())?;
let annotations = newest_first_annotations(&load_annotations(&dir)?);
let annotations = capture_order_annotations(&load_annotations(&dir)?);
if annotations.is_empty() {
notify("No annotations", Some("There is nothing to copy yet."));
return Ok(());
Expand Down
89 changes: 55 additions & 34 deletions rust/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ fn fence_for(text: &str) -> String {
"`".repeat((longest + 1).max(3))
}

/// Format saved annotations as portable, agent-neutral Markdown context.
/// Format saved annotations as the Markdown feedback Plannotator hands a coding agent.
///
/// The shape follows plannotator-tui's export: `# Annotations on …`, then one numbered
/// `## Annotation N` per note in the order given, the quoted selection, and the comment as a
/// blockquote. Where Plannotator puts the line number in the heading, a terminal selection puts
/// the Herdr workspace and tab it came from. A one-line selection is quoted inline; a selection
/// spanning lines is fenced so its layout survives and quoted Markdown cannot escape.
pub fn format_annotations(annotations: &[Annotation]) -> String {
let sections = annotations
.iter()
Expand All @@ -77,41 +83,37 @@ pub fn format_annotations(annotations: &[Annotation]) -> String {
.flatten()
.collect::<Vec<_>>()
.join(" / ");
let fence = fence_for(&annotation.selected_text);
let metadata = if source.is_empty() {
let location = if source.is_empty() {
String::new()
} else {
format!("\nSource: {source}\n")
format!(" ({source})")
};
let lines = vec![
format!("## Annotation {}", index + 1),
metadata,
"Selected text:".to_owned(),
String::new(),
fence.clone(),
annotation.selected_text.clone(),
fence,
String::new(),
"Comment:".to_owned(),
String::new(),
annotation.comment.clone(),
];
let mut filtered = Vec::new();
for line in lines {
if line.is_empty() && filtered.last().is_some_and(String::is_empty) {
continue;
}
filtered.push(line);
}
filtered.join("\n")
let selection = annotation.selected_text.trim_matches(['\r', '\n']);
let quoted = if selection.contains('\n') {
let fence = fence_for(selection);
format!("Comment on:\n{fence}\n{selection}\n{fence}")
} else {
format!("Comment on: \"{}\"", selection.trim())
};
let comment = annotation
.comment
.trim()
.replace("\r\n", "\n")
.replace('\n', "\n> ");
format!(
"## Annotation {}{location}\n{quoted}\n> {comment}",
index + 1
)
})
.collect::<Vec<_>>()
.join("\n\n");
format!("# Annotated context\n\n{sections}\n")
format!("# Annotations on terminal selections\n\n{sections}\n")
}

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

use crate::types::{Annotation, InvocationContext};

use super::*;
Expand Down Expand Up @@ -148,17 +150,36 @@ mod tests {
}

#[test]
fn markdown_contains_source_selection_and_comment() {
let output = format_annotations(&[annotation("failed to connect")]);
assert!(output.contains("# Annotated context"));
assert!(output.contains("Source: api / server"));
assert!(output.contains("failed to connect"));
assert!(output.contains("Check the database first."));
fn markdown_matches_the_plannotator_feedback_shape() {
let mut second = annotation("line one\nline two\n");
second.comment = "Why twice?\nPick one.".to_owned();
second.context = InvocationContext::default();
let output = format_annotations(&[annotation("failed to connect"), second]);
assert_eq!(
output,
"# Annotations on terminal selections\n\n\
## Annotation 1 (api / server)\n\
Comment on: \"failed to connect\"\n\
> Check the database first.\n\n\
## Annotation 2\n\
Comment on:\n```\nline one\nline two\n```\n\
> Why twice?\n> Pick one.\n"
);
}

#[test]
fn markdown_keeps_the_given_order() {
let mut second = annotation("second");
second.comment = "comment two".to_owned();
let output = format_annotations(&[annotation("first"), second]);
let first = output.find("\"first\"").expect("first");
let second = output.find("\"second\"").expect("second");
assert!(first < second);
}

#[test]
fn markdown_uses_a_longer_fence_for_backticks() {
let output = format_annotations(&[annotation("```example```")]);
assert!(output.contains("````\n```example```\n````"));
let output = format_annotations(&[annotation("```example```\nmore")]);
assert!(output.contains("````\n```example```\nmore\n````"));
}
}
20 changes: 10 additions & 10 deletions rust/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ use crate::manager_copy::{ManagerCopyOutcome, copy_annotations};
use crate::pane_clipboard::{emit_to_terminal, write_pane_clipboard};
use crate::paths::state_dir;
use crate::store::{
append_archived_set, load_annotations, load_archived_sets, merge_annotations,
newest_first_annotations, newest_first_archived_sets, remove_annotations_by_id,
remove_archived_set,
append_archived_set, capture_order_annotations, load_annotations, load_archived_sets,
merge_annotations, newest_first_archived_sets, remove_annotations_by_id, remove_archived_set,
};
use crate::termination::Termination;
use crate::types::{Annotation, ArchivedAnnotationSet};
Expand Down Expand Up @@ -80,7 +79,7 @@ impl ManagerApp {
fn reload_active(&mut self) -> bool {
match load_annotations(&self.dir) {
Ok(annotations) => {
self.annotations = newest_first_annotations(&annotations);
self.annotations = capture_order_annotations(&annotations);
self.active_selected =
clamp_selection(self.active_selected, self.annotations.len());
true
Expand Down Expand Up @@ -172,7 +171,7 @@ impl ManagerApp {
frame,
1,
0,
&format!("Annotations ({}) newest first", self.annotations.len()),
&format!("Annotations ({}) oldest first", self.annotations.len()),
list_width.saturating_sub(1),
Style::default().add_modifier(Modifier::BOLD),
);
Expand Down Expand Up @@ -379,7 +378,7 @@ impl ManagerApp {
detail_width,
Style::default().add_modifier(Modifier::BOLD),
);
let visible = newest_first_annotations(&current.annotations);
let visible = capture_order_annotations(&current.annotations);
let preview_rows = rows.saturating_sub(8).max(1);
for (index, annotation) in visible.iter().take(preview_rows).enumerate() {
render_line(
Expand Down Expand Up @@ -532,7 +531,7 @@ impl ManagerApp {
let items = self
.archives
.get(self.archive_selected)
.map(|archive| newest_first_annotations(&archive.annotations))
.map(|archive| capture_order_annotations(&archive.annotations))
.unwrap_or_default();
self.copy(&items);
}
Expand Down Expand Up @@ -806,7 +805,7 @@ mod tests {
}

#[test]
fn active_frame_is_newest_first_and_has_detail_and_keys() {
fn active_frame_is_oldest_first_and_has_detail_and_keys() {
let dir = directory();
append_annotation(&dir, &annotation("one")).expect("one");
append_annotation(&dir, &annotation("two")).expect("two");
Expand All @@ -825,8 +824,9 @@ mod tests {
.iter()
.position(|row| row.contains("selection two"))
.expect("two");
assert!(two < one);
assert!(frame.iter().any(|row| row.contains("comment two")));
assert!(one < two);
assert!(frame.iter().any(|row| row.contains("oldest first")));
assert!(frame.iter().any(|row| row.contains("comment one")));
assert!(frame.iter().any(|row| row.contains("Shift+C copy+archive")));
let _ = fs::remove_dir_all(dir);
}
Expand Down
39 changes: 32 additions & 7 deletions rust/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use chrono::DateTime;
use serde::Serialize;
use serde_json::Value;
use uuid::Uuid;
Expand Down Expand Up @@ -61,9 +62,17 @@ impl Drop for StoreLockLease {
}
}

/// Present append-ordered annotations with the most recently saved first.
pub fn newest_first_annotations(annotations: &[Annotation]) -> Vec<Annotation> {
annotations.iter().rev().cloned().collect()
/// Present annotations in the order they were written, oldest first.
///
/// Plannotator hands feedback to the agent in document order. Terminal selections from different
/// panes share no document, so the order the user worked in stands in for it: saved time, oldest
/// first. The sort is stable, so equal times keep store order, and a restored archive, which is
/// appended to the store, still takes its place among the older annotations. A record whose time
/// does not parse sorts before the rest, in store order.
pub fn capture_order_annotations(annotations: &[Annotation]) -> Vec<Annotation> {
let mut ordered = annotations.to_vec();
ordered.sort_by_key(|annotation| DateTime::parse_from_rfc3339(&annotation.created_at).ok());
ordered
}

/// Load the complete active store, rejecting malformed records instead of dropping data.
Expand Down Expand Up @@ -459,15 +468,15 @@ mod tests {
}

#[test]
fn newest_first_does_not_mutate_storage_order() {
fn capture_order_keeps_store_order_for_equal_times_without_mutating_it() {
let stored = vec![annotation("one"), annotation("two"), annotation("three")];
let newest = newest_first_annotations(&stored);
let ordered = capture_order_annotations(&stored);
assert_eq!(
newest
ordered
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
["three", "two", "one"]
["one", "two", "three"]
);
assert_eq!(
stored
Expand All @@ -478,6 +487,22 @@ mod tests {
);
}

#[test]
fn capture_order_puts_restored_older_annotations_first() {
let mut newer = annotation("newer");
newer.created_at = "2026-08-09T10:11:13.000Z".to_owned();
let mut restored = annotation("restored");
restored.created_at = "2026-08-08T00:00:01.000Z".to_owned();
let ordered = capture_order_annotations(&[newer, restored]);
assert_eq!(
ordered
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
["restored", "newer"]
);
}

#[test]
fn annotations_append_load_remove_and_merge() {
let dir = temporary_directory();
Expand Down
Loading
Loading