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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,18 @@ herdr server reload-config
| `Ctrl+B Ctrl+A` | copy all annotations as Markdown, then archive them |
| `Ctrl+B Ctrl+V` | paste all annotations into the focused agent's prompt without sending, then archive them |
| `Ctrl+B Ctrl+S` | send all annotations as the focused agent's next message, then archive them |
| `Ctrl+B M` | manage · `e` edit the selected comment · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) |
| `Ctrl+B M` | manage · `Enter` jump to the pane it came from · `e` edit the selected comment · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) |

In the manager's active view, `e` opens the selected comment in the editor. `Ctrl+S` saves it;
`Esc` cancels. Editing leaves the selected text, capture metadata, and original ordering unchanged.
If another pane saves that comment first, saving your stale draft is rejected and the draft stays
open. To edit the latest version, cancel with `Esc`, then press `r` in the manager to reload.

`Enter` focuses the Herdr pane the selected annotation was captured from and closes the manager.
Agent panes are focused directly; for any other pane, Herdr focuses the tab that holds it. If the
pane has closed, or the annotation has no pane recorded, the manager says so and stays open. Herdr
cannot scroll a pane to the annotated text, so the jump stops at the pane.

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
Expand Down
2 changes: 2 additions & 0 deletions docs/lite-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ Deliberate re-records so far:
- The manager's active view offers `e` to edit the selected comment. `Ctrl+S` saves without changing
the annotation's selected text or provenance; `Esc` cancels. If another manager saves a different
comment first, a stale save is rejected without closing the draft or overwriting the newer comment.
- The active view's footer adds `↵ jump`: `Enter` focuses the pane an annotation came from. No other
screen line changes.

## Everything else

Expand Down
2 changes: 1 addition & 1 deletion herdr-annotate.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.2
0.2.3
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.9.3"
version = "0.9.4"
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 links/herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

id = "annotate-links"
name = "Annotate Links"
version = "0.9.3"
version = "0.9.4"
min_herdr_version = "0.8.0"
description = "Open Ctrl-clicked file:// Markdown links in Annotate (needs the Annotate Full plugin)."
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.9.3"
version = "0.9.4"
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.2.2"
version = "0.2.3"
edition = "2024"
rust-version = "1.96"
license = "MIT"
Expand Down
1 change: 1 addition & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod manager;
pub mod manager_copy;
pub mod pane_clipboard;
pub mod paths;
pub mod source_jump;
pub mod store;
pub mod types;
pub mod width;
Expand Down
144 changes: 143 additions & 1 deletion rust/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ use crate::archive_workflow::{
use crate::clipboard::write_clipboard;
use crate::editor::{EditorApp, now_iso_for_manager};
use crate::format::{sanitize_terminal_text, wrap_text};
use crate::herdr::run_herdr_output;
use crate::manager_copy::{ManagerCopyOutcome, copy_annotations};
use crate::pane_clipboard::{emit_to_terminal, write_pane_clipboard};
use crate::paths::state_dir;
use crate::source_jump::{JumpOutcome, jump_to_source};
use crate::store::{
append_archived_set, capture_order_annotations, load_annotations, load_archived_sets,
merge_annotations, newest_first_archived_sets, remove_annotations_by_id,
Expand Down Expand Up @@ -56,9 +58,13 @@ pub struct ManagerApp {
status: String,
confirmation: Confirmation,
editing: Option<EditorApp>,
herdr: HerdrCall,
quit: bool,
}

/// Run one Herdr CLI command, returning stdout or stderr; replaced by a fake in tests.
type HerdrCall = fn(&[String]) -> Result<String, String>;

impl ManagerApp {
/// Load both stores and start in the active-annotation view.
pub fn load(dir: PathBuf) -> Self {
Expand All @@ -72,6 +78,7 @@ impl ManagerApp {
status: String::new(),
confirmation: Confirmation::None,
editing: None,
herdr: run_herdr_output,
quit: false,
};
app.reload_active();
Expand Down Expand Up @@ -427,7 +434,7 @@ impl ManagerApp {
}
match self.view {
ManagerView::Active => {
"j/k · e edit · y copy · c all · C copy+archive · d delete · D clear · Tab archives · q"
"j/k · ↵ jump · e edit · y copy · c all · C copy+archive · d delete · D clear · Tab archives · q"
.to_owned()
}
ManagerView::Archives => {
Expand Down Expand Up @@ -493,6 +500,7 @@ impl ManagerApp {
}
KeyCode::Char('C') => self.copy_and_archive(),
KeyCode::Char('e') => self.edit_selected_annotation(),
KeyCode::Enter => self.jump_to_selected_source(),
KeyCode::Char('y') => {
let items = self
.annotations
Expand Down Expand Up @@ -587,6 +595,21 @@ impl ManagerApp {
self.editing = Some(EditorApp::edit(annotation));
}

/// Focus the Herdr pane the selected annotation came from, closing the manager on success.
fn jump_to_selected_source(&mut self) {
let Some(context) = self
.annotations
.get(self.active_selected)
.map(|item| item.context.clone())
else {
return;
};
match jump_to_source(&context, self.herdr) {
JumpOutcome::Focused => self.quit = true,
JumpOutcome::Unavailable(message) => self.status = message,
}
}

fn handle_edit_key(&mut self, key: KeyEvent) {
let Some(editor) = self.editing.as_mut() else {
return;
Expand Down Expand Up @@ -882,6 +905,7 @@ mod tests {
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("e edit")));
assert!(frame.iter().any(|row| row.contains("↵ jump")));
assert!(frame.iter().any(|row| row.contains("C copy+archive")));
let _ = fs::remove_dir_all(dir);
}
Expand Down Expand Up @@ -1178,6 +1202,124 @@ mod tests {
let _ = fs::remove_dir_all(dir);
}

static HERDR_CALLS: AtomicUsize = AtomicUsize::new(0);

fn herdr_must_not_run(_: &[String]) -> Result<String, String> {
HERDR_CALLS.fetch_add(1, Ordering::Relaxed);
Err("unexpected herdr call".to_owned())
}

fn herdr_without_the_pane(args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
Some("agent") => Err(
r#"{"error":{"code":"agent_not_found","message":"agent target w1:p9 not found"}}"#
.to_owned(),
),
Some("pane") => Err(
r#"{"error":{"code":"pane_not_found","message":"pane w1:p9 not found"}}"#
.to_owned(),
),
_ => Err(format!("unexpected herdr call: {args:?}")),
}
}

fn herdr_focuses(args: &[String]) -> Result<String, String> {
if args == ["agent", "focus", "w1:p2"] {
Ok("{}".to_owned())
} else {
Err(format!("unexpected herdr call: {args:?}"))
}
}

fn annotation_from_pane(id: &str, pane: &str) -> Annotation {
let mut item = annotation(id);
item.context.focused_pane_id = Some(pane.to_owned());
item
}

#[test]
fn jump_without_a_source_pane_id_reports_it_and_stays_open() {
let dir = directory();
let original = annotation("one");
append_annotation(&dir, &original).expect("annotation");
let mut app = ManagerApp::load(dir.clone());
app.herdr = herdr_must_not_run;

let calls = HERDR_CALLS.load(Ordering::Relaxed);

app.handle_key(KeyEvent::from(KeyCode::Enter));

assert!(!app.quit);
assert_eq!(HERDR_CALLS.load(Ordering::Relaxed), calls);
let frame = rows(&app);
assert!(
frame
.iter()
.any(|row| row.contains("This annotation has no source pane to jump to."))
);
assert!(frame.iter().any(|row| row.contains("Annotations (1)")));
assert_eq!(load_annotations(&dir).expect("unchanged"), [original]);
let _ = fs::remove_dir_all(dir);
}

#[test]
fn jump_to_a_closed_source_pane_reports_it_and_stays_open() {
let dir = directory();
let original = annotation_from_pane("one", "w1:p9");
append_annotation(&dir, &original).expect("annotation");
let mut app = ManagerApp::load(dir.clone());
app.herdr = herdr_without_the_pane;

app.handle_key(KeyEvent::from(KeyCode::Enter));

assert!(!app.quit);
let frame = rows(&app);
assert!(
frame
.iter()
.any(|row| row.contains("The source pane no longer exists."))
);
assert_eq!(load_annotations(&dir).expect("unchanged"), [original]);
let _ = fs::remove_dir_all(dir);
}

#[test]
fn jump_to_the_source_pane_closes_the_manager() {
let dir = directory();
append_annotation(&dir, &annotation_from_pane("one", "w1:p2")).expect("annotation");
let mut app = ManagerApp::load(dir.clone());
app.herdr = herdr_focuses;

app.handle_key(KeyEvent::from(KeyCode::Enter));

assert!(app.quit);
let _ = fs::remove_dir_all(dir);
}

#[test]
fn jump_is_ignored_without_an_active_selection() {
let dir = directory();
let mut app = ManagerApp::load(dir.clone());
app.herdr = herdr_must_not_run;
app.handle_key(KeyEvent::from(KeyCode::Enter));
assert!(!app.quit);
append_archived_set(
&dir,
&ArchivedAnnotationSet {
version: 1,
id: "archive-one".to_owned(),
archived_at: "2026-08-26T23:32:00Z".to_owned(),
annotations: vec![annotation_from_pane("one", "w1:p2")],
},
)
.expect("archive");
app.handle_key(KeyEvent::from(KeyCode::Tab));
app.handle_key(KeyEvent::from(KeyCode::Enter));
assert!(!app.quit);
assert!(rows(&app).iter().any(|row| row.contains("Archives (1)")));
let _ = fs::remove_dir_all(dir);
}

#[test]
fn archive_frame_and_delete_confirmation_render_headlessly() {
let dir = directory();
Expand Down
Loading
Loading