diff --git a/README.md b/README.md index f337b5c..1a2e22c 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,18 @@ type = "plugin_action" command = "annotate.copy-archive" description = "copy annotations as context and archive them" +[[keys.command]] +key = "prefix+ctrl+v" +type = "plugin_action" +command = "annotate.paste-archive" +description = "paste annotations into the agent's prompt and archive them" + +[[keys.command]] +key = "prefix+ctrl+s" +type = "plugin_action" +command = "annotate.send-archive" +description = "send annotations to the agent and archive them" + [[keys.command]] key = "prefix+m" type = "plugin_action" @@ -135,6 +147,18 @@ type = "plugin_action" command = "annotate.copy-archive" description = "copy annotations as context and archive them" +[[keys.command]] +key = "prefix+ctrl+v" +type = "plugin_action" +command = "annotate.paste-archive" +description = "paste annotations into the agent's prompt and archive them" + +[[keys.command]] +key = "prefix+ctrl+s" +type = "plugin_action" +command = "annotate.send-archive" +description = "send annotations to the agent and archive them" + [[keys.command]] key = "prefix+m" type = "plugin_action" @@ -160,6 +184,8 @@ herdr server reload-config | `Ctrl+B A` | comment on the selected text · `Ctrl+S` saves | | `Ctrl+B Shift+A` | copy all annotations as Markdown | | `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 · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) | Copies made inside the manager pane also emit OSC 52, so on Herdr 0.9.0 they reach the clipboard of @@ -167,6 +193,11 @@ the machine you are viewing from even when the plugin runs on a remote server wi installed; `Ctrl+B Shift+A` and `Ctrl+B Ctrl+A` do not, because those actions run outside a pane and have no terminal to write to. +`Ctrl+B Ctrl+V` and `Ctrl+B Ctrl+S` skip the clipboard and put the same Markdown straight into the +prompt of the agent in the focused pane. Both first ask Herdr whether the agent is ready. When +that pane has no running agent Herdr recognises, the agent is still launching, or it is waiting at +an approval or question prompt, they refuse, type nothing, and leave your annotations active. + ### Review documents and agent replies Full install. Works with Claude Code, Codex, pi, Copilot CLI, Droid, Oh My Pi, Hermes CLI and OpenCode (1 and 2). diff --git a/docs/lite-testing.md b/docs/lite-testing.md index 921dc1a..c5b376e 100644 --- a/docs/lite-testing.md +++ b/docs/lite-testing.md @@ -6,10 +6,12 @@ by running both runtimes side by side. ## Recorded goldens -`scripts/lite-regression.py` drives all six entrypoints the Lite manifest declares and compares +`scripts/lite-regression.py` drives the six entrypoints the Bun runtime shipped and compares every observable with a recording checked in under `scripts/lite-goldens/`. The recording was taken from the Bun runtime at commit `2add0da`, the last commit that still contained `src/`, so a green -run means the native runtime still does what Bun did. +run means the native runtime still does what Bun did. `paste-archive` and `send-archive` came +later and have no recording; the harness checks their manifest entries, and `cargo test` covers +their behavior. ```sh bash scripts/lite-regression.sh diff --git a/docs/windows-full-acceptance.md b/docs/windows-full-acceptance.md index 8d03a5d..5d0a1b7 100644 --- a/docs/windows-full-acceptance.md +++ b/docs/windows-full-acceptance.md @@ -29,6 +29,8 @@ requirements, not completed verification. Every box starts unchecked. | `capture` | Annotate selection | `pane` | | `copy-context` | Copy annotations as context | `global` | | `copy-archive` | Copy annotations as context and archive them | `global` | + | `paste-archive` | Paste annotations into the agent's prompt and archive them | `pane` | + | `send-archive` | Send annotations to the agent and archive them | `pane` | | `manage` | Manage annotations | `global` | | `open` | Annotate: open here | `workspace`, `pane` | | `open-link` | Annotate this file | `pane` | diff --git a/herdr-plugin.toml b/herdr-plugin.toml index a99348f..7c619d9 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "annotate" name = "Annotate" -version = "0.6.0" +version = "0.7.0" min_herdr_version = "0.8.0" description = "Comment on terminal selections and copy annotations as agent context." platforms = ["linux", "macos", "windows"] @@ -41,6 +41,20 @@ description = "Copy all saved annotations to the clipboard as Markdown, then arc contexts = ["global"] command = ["./bin/herdr-annotate.exe", "copy-archive"] +[[actions]] +id = "paste-archive" +title = "Paste annotations into the agent's prompt and archive them" +description = "Paste all saved annotations into the focused agent's prompt without sending, then archive them." +contexts = ["pane"] +command = ["./bin/herdr-annotate.exe", "paste-archive"] + +[[actions]] +id = "send-archive" +title = "Send annotations to the agent and archive them" +description = "Send all saved annotations as the focused agent's next message, then archive them." +contexts = ["pane"] +command = ["./bin/herdr-annotate.exe", "send-archive"] + [[actions]] id = "manage" title = "Manage annotations" diff --git a/lite/herdr-plugin.toml b/lite/herdr-plugin.toml index 5bdc0dc..4730086 100644 --- a/lite/herdr-plugin.toml +++ b/lite/herdr-plugin.toml @@ -8,7 +8,7 @@ id = "annotate" name = "Annotate" -version = "0.6.0" +version = "0.7.0" min_herdr_version = "0.8.0" description = "Comment on terminal selections and copy annotations as agent context." platforms = ["linux", "macos", "windows"] @@ -46,6 +46,20 @@ description = "Copy all saved annotations to the clipboard as Markdown, then arc contexts = ["global"] command = ["../bin/herdr-annotate.exe", "copy-archive"] +[[actions]] +id = "paste-archive" +title = "Paste annotations into the agent's prompt and archive them" +description = "Paste all saved annotations into the focused agent's prompt without sending, then archive them." +contexts = ["pane"] +command = ["../bin/herdr-annotate.exe", "paste-archive"] + +[[actions]] +id = "send-archive" +title = "Send annotations to the agent and archive them" +description = "Send all saved annotations as the focused agent's next message, then archive them." +contexts = ["pane"] +command = ["../bin/herdr-annotate.exe", "send-archive"] + [[actions]] id = "manage" title = "Manage annotations" diff --git a/rust/src/agent_delivery.rs b/rust/src/agent_delivery.rs new file mode 100644 index 0000000..ff1c950 --- /dev/null +++ b/rust/src/agent_delivery.rs @@ -0,0 +1,560 @@ +//! Deliver annotations into the focused agent's prompt through the Herdr CLI. +//! +//! Sending uses `herdr agent prompt`, which pastes the text as a bracketed paste and presses +//! Enter. Herdr has no paste-without-Enter command, and `herdr pane send-text` writes raw bytes +//! without honouring the pane's bracketed-paste mode, so pasting wraps the text itself. Agent +//! TUIs enable bracketed paste; a bare shell may not and would show the markers as garbage. +//! +//! Both deliveries first ask `herdr agent get` whether the agent is ready, mirroring the checks +//! `herdr agent prompt` makes before writing. Herdr before 0.8.2 lets `agent prompt` type into an +//! approval dialog, so the plugin cannot rely on Herdr refusing a blocked agent by itself. + +use serde_json::Value; + +const PASTE_START: &str = "\x1b[200~"; +const PASTE_END: &str = "\x1b[201~"; + +/// How the annotations reach the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Delivery { + /// Into the prompt, left for the user to edit and submit. + Paste, + /// Submitted as the agent's next message. + Send, +} + +impl Delivery { + /// The action's verb, as in "nothing to paste". + pub const fn verb(self) -> &'static str { + match self { + Self::Paste => "paste", + Self::Send => "send", + } + } + + /// The verb's past participle, as in "nothing was pasted". + pub const fn past(self) -> &'static str { + match self { + Self::Paste => "pasted", + Self::Send => "sent", + } + } +} + +/// Herdr's `{"error":{"code","message"}}` refusal, or the raw stderr when there is no envelope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HerdrError { + pub code: Option, + pub message: String, +} + +/// Parse the error envelope Herdr prints on stderr when a command fails. +pub fn parse_herdr_error(stderr: &str) -> HerdrError { + let trimmed = stderr.trim(); + let envelope = serde_json::from_str::(trimmed).ok(); + let field = |pointer: &str| { + envelope + .as_ref() + .and_then(|value| value.pointer(pointer)) + .and_then(Value::as_str) + .map(str::to_owned) + }; + HerdrError { + code: field("/error/code"), + message: field("/error/message").unwrap_or_else(|| trimmed.to_owned()), + } +} + +/// Say in plain words why Herdr refused, without its JSON. +fn refusal_reason(error: &HerdrError) -> String { + let message = error.message.trim_end_matches('.'); + match error.code.as_deref() { + Some("agent_blocked") => "The agent is waiting on a prompt.".to_owned(), + Some("agent_not_found") => "No agent is running in the focused pane.".to_owned(), + Some("agent_not_ready") => "The agent is not ready for input yet.".to_owned(), + Some("pane_not_found") => "The focused pane no longer exists.".to_owned(), + Some(code) => format!("Herdr refused ({code}): {message}."), + None if message.is_empty() => "Herdr failed without saying why.".to_owned(), + None => format!("Herdr failed: {message}."), + } +} + +/// Decide from one `herdr agent get` result whether the agent can take text in its prompt. +/// +/// Mirrors what `herdr agent prompt` checks before it writes: the agent must not sit at an +/// approval or question dialog, where the text would answer the dialog instead of reaching the +/// prompt; Herdr must still recognise a running agent in the pane (`agent` is cleared when the +/// agent process exits, even though a named pane still answers `agent get`); and a managed launch +/// must have finished (`launch_pending` is omitted once it has). +pub fn agent_ready(agent_get: &Result) -> Result<(), String> { + let record = agent_get + .as_ref() + .map_err(|stderr| refusal_reason(&parse_herdr_error(stderr)))?; + let parsed = serde_json::from_str::(record).ok(); + let Some(agent) = parsed + .as_ref() + .and_then(|value| value.pointer("/result/agent")) + .filter(|agent| agent.get("agent_status").and_then(Value::as_str).is_some()) + else { + return Err("Herdr did not report the agent's state.".to_owned()); + }; + if agent.get("agent_status").and_then(Value::as_str) == Some("blocked") { + return Err("The agent is waiting on a prompt.".to_owned()); + } + let recognised = agent + .get("agent") + .and_then(Value::as_str) + .is_some_and(|name| !name.is_empty()); + if !recognised { + return Err("No agent is running in the focused pane.".to_owned()); + } + if agent + .get("launch_pending") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Err("The agent is not ready for input yet.".to_owned()); + } + Ok(()) +} + +/// Remove ESC so the text cannot end a bracketed paste early. +/// +/// Text holding the end marker would otherwise close the paste and type the rest as keystrokes, +/// possibly including a submit. `herdr agent prompt` wraps its text without this, so both +/// deliveries need it: a probe through it lost the text before the marker and never submitted. +pub fn without_escapes(text: &str) -> String { + text.chars() + .filter(|character| *character != '\x1b') + .collect() +} + +/// Wrap text as one bracketed paste, without the escapes that could end it early. +pub fn bracketed_paste(text: &str) -> String { + format!("{PASTE_START}{}{PASTE_END}", without_escapes(text)) +} + +/// Deliver text to the agent in `pane`, calling Herdr through `herdr`. +/// +/// `herdr` takes the argument list and returns stdout, or stderr on failure. A refusal is +/// returned as a notification body and guarantees nothing was typed into the pane. +pub fn deliver_to_agent( + delivery: Delivery, + pane: Option<&str>, + text: &str, + mut herdr: impl FnMut(&[String]) -> Result, +) -> Result<(), String> { + let refused = |reason: String| { + format!( + "{reason} Nothing was {}; your annotations are still active.", + delivery.past() + ) + }; + let Some(pane) = pane.filter(|pane| !pane.is_empty()) else { + return Err(refused( + "Herdr did not say which pane is focused.".to_owned(), + )); + }; + let herdr_refused = |stderr: String| refused(refusal_reason(&parse_herdr_error(&stderr))); + agent_ready(&herdr(&arguments(&["agent", "get", pane]))).map_err(refused)?; + let write = match delivery { + Delivery::Send => arguments(&["agent", "prompt", pane, &without_escapes(text)]), + Delivery::Paste => arguments(&["pane", "send-text", pane, &bracketed_paste(text)]), + }; + herdr(&write).map(drop).map_err(herdr_refused) +} + +fn arguments(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use crate::archive_workflow::{ + CopyAndArchiveDependencies, CopyAndArchiveOutcome, copy_and_archive_annotations, + }; + use crate::types::{Annotation, InvocationContext}; + + use super::*; + + fn envelope(code: &str, message: &str) -> String { + format!(r#"{{"error":{{"code":"{code}","message":"{message}"}},"id":"cli:agent:get"}}"#) + } + + fn agent_record(status: &str) -> String { + format!( + r#"{{"id":"cli:agent:get","result":{{"agent":{{"agent":"claude","agent_status":"{status}","pane_id":"w1:p2"}},"type":"agent_info"}}}}"# + ) + } + + fn command(args: &[String]) -> String { + args.iter().take(2).cloned().collect::>().join(" ") + } + + fn annotation(id: &str) -> Annotation { + Annotation { + selected_text: format!("selection {id}"), + context: InvocationContext::default(), + captured_at: "2026-09-24T00:00:00Z".to_owned(), + id: id.to_owned(), + comment: format!("comment {id}"), + created_at: "2026-09-24T00:00:01Z".to_owned(), + } + } + + #[test] + fn a_paste_is_the_text_between_bracketed_paste_markers() { + assert_eq!( + bracketed_paste("line one\nline two"), + "\x1b[200~line one\nline two\x1b[201~" + ); + assert_eq!(bracketed_paste(""), "\x1b[200~\x1b[201~"); + assert_eq!(bracketed_paste("한글 · é"), "\x1b[200~한글 · é\x1b[201~"); + } + + #[test] + fn an_end_marker_in_the_text_cannot_close_the_paste_early() { + let payload = bracketed_paste("before\x1b[201~\rafter\x1b[200~"); + assert_eq!(payload, "\x1b[200~before[201~\rafter[200~\x1b[201~"); + assert_eq!(payload.matches(PASTE_END).count(), 1); + assert!(payload.ends_with(PASTE_END)); + assert_eq!(payload.matches('\x1b').count(), 2); + } + + #[test] + fn herdr_error_envelopes_parse_to_code_and_message() { + assert_eq!( + parse_herdr_error(&envelope("agent_blocked", "agent w1:p2 is blocked")), + HerdrError { + code: Some("agent_blocked".to_owned()), + message: "agent w1:p2 is blocked".to_owned(), + } + ); + assert_eq!( + parse_herdr_error(&format!( + "{}\n", + envelope("agent_not_found", "agent target w1:p2 not found") + )), + HerdrError { + code: Some("agent_not_found".to_owned()), + message: "agent target w1:p2 not found".to_owned(), + } + ); + } + + #[test] + fn unparseable_stderr_is_kept_as_the_message() { + assert_eq!( + parse_herdr_error("connection refused\n"), + HerdrError { + code: None, + message: "connection refused".to_owned(), + } + ); + assert_eq!( + parse_herdr_error(""), + HerdrError { + code: None, + message: String::new(), + } + ); + } + + #[test] + fn refusals_read_as_plain_words() { + let reason = |stderr: &str| refusal_reason(&parse_herdr_error(stderr)); + assert_eq!( + reason(&envelope("agent_blocked", "agent w1:p2 is blocked")), + "The agent is waiting on a prompt." + ); + assert_eq!( + reason(&envelope("agent_not_found", "agent target w1:p2 not found")), + "No agent is running in the focused pane." + ); + assert_eq!( + reason(&envelope("socket_error", "boom.")), + "Herdr refused (socket_error): boom." + ); + assert_eq!( + reason("connection refused"), + "Herdr failed: connection refused." + ); + assert_eq!(reason(""), "Herdr failed without saying why."); + } + + fn agent_info(agent: &str) -> String { + format!(r#"{{"id":"cli:agent:get","result":{{"agent":{agent},"type":"agent_info"}}}}"#) + } + + #[test] + fn a_recognised_agent_not_at_a_dialog_is_ready() { + for status in ["idle", "working", "done", "unknown"] { + assert_eq!(agent_ready(&Ok(agent_record(status))), Ok(()), "{status}"); + } + assert_eq!( + agent_ready(&Ok(agent_info( + r#"{"agent":"pi","agent_status":"idle","launch_pending":false,"pane_id":"w1:p2"}"# + ))), + Ok(()) + ); + } + + #[test] + fn a_blocked_agent_is_not_ready() { + assert_eq!( + agent_ready(&Ok(agent_record("blocked"))), + Err("The agent is waiting on a prompt.".to_owned()) + ); + } + + #[test] + fn an_unknown_pane_without_an_agent_is_not_ready() { + assert_eq!( + agent_ready(&Ok(agent_info( + r#"{"agent_status":"unknown","pane_id":"w1:p2"}"# + ))), + Err("No agent is running in the focused pane.".to_owned()) + ); + assert_eq!( + agent_ready(&Ok(agent_info( + r#"{"agent":"","agent_status":"unknown","pane_id":"w1:p2"}"# + ))), + Err("No agent is running in the focused pane.".to_owned()) + ); + } + + #[test] + fn an_exited_named_agent_is_not_ready() { + // A named agent pane still answers `agent get` after its process exits, without `agent`. + assert_eq!( + agent_ready(&Ok(agent_info( + r#"{"name":"reviewer","agent_status":"idle","pane_id":"w1:p2"}"# + ))), + Err("No agent is running in the focused pane.".to_owned()) + ); + } + + #[test] + fn a_pending_launch_is_not_ready() { + assert_eq!( + agent_ready(&Ok(agent_info( + r#"{"agent":"claude","agent_status":"unknown","launch_pending":true,"pane_id":"w1:p2"}"# + ))), + Err("The agent is not ready for input yet.".to_owned()) + ); + } + + #[test] + fn a_refused_or_unreadable_agent_get_is_not_ready() { + assert_eq!( + agent_ready(&Err(envelope( + "agent_not_found", + "agent target w1:p2 not found" + ))), + Err("No agent is running in the focused pane.".to_owned()) + ); + assert_eq!( + agent_ready(&Ok("not json".to_owned())), + Err("Herdr did not report the agent's state.".to_owned()) + ); + assert_eq!( + agent_ready(&Ok(r#"{"result":{"agent":{}}}"#.to_owned())), + Err("Herdr did not report the agent's state.".to_owned()) + ); + } + + #[test] + fn send_prompts_the_focused_agent_with_the_text_minus_escapes() { + let calls = RefCell::new(Vec::new()); + let result = deliver_to_agent( + Delivery::Send, + Some("w1:p2"), + "hi\x1b[201~\rthere", + |args| { + calls.borrow_mut().push(args.to_vec()); + Ok(agent_record("idle")) + }, + ); + assert_eq!(result, Ok(())); + assert_eq!( + *calls.borrow(), + [ + vec!["agent", "get", "w1:p2"], + vec!["agent", "prompt", "w1:p2", "hi[201~\rthere"], + ] + ); + } + + #[test] + fn a_blocked_send_is_refused_before_prompting() { + // Herdr before 0.8.2 would type a prompt into the dialog, so the check must come first. + let calls = RefCell::new(Vec::new()); + let result = deliver_to_agent(Delivery::Send, Some("w1:p2"), "hi", |args| { + calls.borrow_mut().push(command(args)); + Ok(agent_record("blocked")) + }); + assert_eq!( + result, + Err("The agent is waiting on a prompt. Nothing was sent; your annotations are still active.".to_owned()) + ); + assert_eq!(*calls.borrow(), ["agent get"]); + } + + #[test] + fn herdrs_own_prompt_refusal_reports_why_and_that_nothing_was_sent() { + let result = deliver_to_agent(Delivery::Send, Some("w1:p2"), "hi", |args| { + if command(args) == "agent get" { + Ok(agent_record("idle")) + } else { + Err(envelope("agent_blocked", "agent w1:p2 is blocked")) + } + }); + assert_eq!( + result, + Err("The agent is waiting on a prompt. Nothing was sent; your annotations are still active.".to_owned()) + ); + } + + #[test] + fn a_missing_pane_is_refused_without_calling_herdr() { + for pane in [None, Some("")] { + let result = deliver_to_agent(Delivery::Paste, pane, "hi", |_| { + Err("herdr must not be called".to_owned()) + }); + assert_eq!( + result, + Err("Herdr did not say which pane is focused. Nothing was pasted; your annotations are still active.".to_owned()) + ); + } + } + + #[test] + fn paste_checks_the_agent_before_typing_the_bracketed_text() { + let calls = RefCell::new(Vec::new()); + let result = deliver_to_agent(Delivery::Paste, Some("w1:p2"), "hi", |args| { + calls.borrow_mut().push(args.to_vec()); + Ok(agent_record("idle")) + }); + assert_eq!(result, Ok(())); + assert_eq!( + *calls.borrow(), + [ + vec!["agent", "get", "w1:p2"], + vec!["pane", "send-text", "w1:p2", "\x1b[200~hi\x1b[201~"], + ] + ); + } + + #[test] + fn a_refused_paste_archives_nothing() { + let events = RefCell::new(Vec::new()); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || { + events.borrow_mut().push("load".to_owned()); + Ok(vec![annotation("one")]) + }, + deliver: |text: String| { + deliver_to_agent(Delivery::Paste, Some("w1:p2"), &text, |args| { + events.borrow_mut().push(command(args)); + Ok(agent_record("blocked")) + }) + }, + save_archive: |_| { + events.borrow_mut().push("archive".to_owned()); + Ok(()) + }, + remove_active: |_| { + events.borrow_mut().push("remove".to_owned()); + Ok(()) + }, + create_archive_id: || "archive-one".to_owned(), + now: || "now".to_owned(), + }); + assert_eq!( + outcome, + CopyAndArchiveOutcome::StayOpen { + message: "The agent is waiting on a prompt. Nothing was pasted; your annotations are still active.".to_owned() + } + ); + assert_eq!(*events.borrow(), ["load", "agent get"]); + } + + #[test] + fn a_send_to_an_agent_that_is_not_ready_archives_nothing() { + let events = RefCell::new(Vec::new()); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || { + events.borrow_mut().push("load".to_owned()); + Ok(vec![annotation("one")]) + }, + deliver: |text: String| { + deliver_to_agent(Delivery::Send, Some("w1:p2"), &text, |args| { + events.borrow_mut().push(command(args)); + Ok(agent_info( + r#"{"name":"reviewer","agent_status":"idle","pane_id":"w1:p2"}"#, + )) + }) + }, + save_archive: |_| { + events.borrow_mut().push("archive".to_owned()); + Ok(()) + }, + remove_active: |_| { + events.borrow_mut().push("remove".to_owned()); + Ok(()) + }, + create_archive_id: || "archive-one".to_owned(), + now: || "now".to_owned(), + }); + assert_eq!( + outcome, + CopyAndArchiveOutcome::StayOpen { + message: "No agent is running in the focused pane. Nothing was sent; your annotations are still active.".to_owned() + } + ); + assert_eq!(*events.borrow(), ["load", "agent get"]); + } + + #[test] + fn a_delivered_send_is_archived_afterwards() { + let events = RefCell::new(Vec::new()); + let prompted = RefCell::new(String::new()); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || { + events.borrow_mut().push("load".to_owned()); + Ok(vec![annotation("one"), annotation("two")]) + }, + deliver: |text: String| { + deliver_to_agent(Delivery::Send, Some("w1:p2"), &text, |args| { + events.borrow_mut().push(command(args)); + if command(args) == "agent get" { + return Ok(agent_record("idle")); + } + args.get(3) + .cloned() + .unwrap_or_default() + .clone_into(&mut prompted.borrow_mut()); + Ok(String::new()) + }) + }, + save_archive: |_| { + events.borrow_mut().push("archive".to_owned()); + Ok(()) + }, + remove_active: |_| { + events.borrow_mut().push("remove".to_owned()); + Ok(()) + }, + create_archive_id: || "archive-one".to_owned(), + now: || "now".to_owned(), + }); + assert_eq!(outcome, CopyAndArchiveOutcome::Close { archived_count: 2 }); + assert_eq!( + *events.borrow(), + ["load", "agent get", "agent prompt", "archive", "remove"] + ); + assert!(prompted.borrow().find("selection two") < prompted.borrow().find("selection one")); + } +} diff --git a/rust/src/archive_workflow.rs b/rust/src/archive_workflow.rs index c7c8825..50f34d6 100644 --- a/rust/src/archive_workflow.rs +++ b/rust/src/archive_workflow.rs @@ -1,14 +1,17 @@ -//! Recoverable copy/archive and restore transitions. +//! Recoverable deliver/archive and restore transitions. use crate::format::format_annotations; use crate::store::{StoreResult, newest_first_annotations}; use crate::types::{Annotation, ArchivedAnnotationSet}; /// Dependencies for one copy-and-archive transition. +/// +/// `deliver` receives the formatted annotations: the clipboard for copy-and-archive, or the +/// focused agent's prompt for the paste and send actions. #[derive(Debug)] -pub struct CopyAndArchiveDependencies { +pub struct CopyAndArchiveDependencies { pub load_active: Load, - pub write_clipboard: Write, + pub deliver: Deliver, pub save_archive: Save, pub remove_active: Remove, pub create_archive_id: Id, @@ -23,13 +26,13 @@ pub enum CopyAndArchiveOutcome { ArchivedActiveRetained { message: String }, } -/// Copy the active annotations, persist a recoverable archive, then remove active IDs. -pub fn copy_and_archive_annotations( - dependencies: CopyAndArchiveDependencies, +/// Deliver the active annotations, persist a recoverable archive, then remove active IDs. +pub fn copy_and_archive_annotations( + dependencies: CopyAndArchiveDependencies, ) -> CopyAndArchiveOutcome where Load: FnOnce() -> StoreResult>, - Write: FnOnce(String) -> Result<(), String>, + Deliver: FnOnce(String) -> Result<(), String>, Save: FnOnce(ArchivedAnnotationSet) -> StoreResult<()>, Remove: FnOnce(Vec) -> StoreResult<()>, Id: FnOnce() -> String, @@ -45,7 +48,7 @@ where }; } if let Err(message) = - (dependencies.write_clipboard)(format_annotations(&newest_first_annotations(&active))) + (dependencies.deliver)(format_annotations(&newest_first_annotations(&active))) { return CopyAndArchiveOutcome::StayOpen { message }; } @@ -175,7 +178,7 @@ mod tests { events.borrow_mut().push("load"); Ok(vec![annotation("one"), annotation("two")]) }, - write_clipboard: |text: String| { + deliver: |text: String| { events.borrow_mut().push("copy"); text.clone_into(&mut clipboard.borrow_mut()); Ok(()) @@ -208,7 +211,7 @@ mod tests { let removed = Cell::new(false); let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { load_active: || Ok(vec![annotation("one")]), - write_clipboard: |_| Err("Clipboard unavailable".to_owned()), + deliver: |_| Err("Clipboard unavailable".to_owned()), save_archive: |_| { archived.set(true); Ok(()) @@ -234,7 +237,7 @@ mod tests { let removed = Cell::new(false); let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { load_active: || Ok(vec![annotation("one")]), - write_clipboard: |_| Ok(()), + deliver: |_| Ok(()), save_archive: |_| Err("Archive unavailable".to_owned()), remove_active: |_| { removed.set(true); @@ -256,7 +259,7 @@ mod tests { fn clear_failure_reports_retained_active_data() { let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { load_active: || Ok(vec![annotation("one")]), - write_clipboard: |_| Ok(()), + deliver: |_| Ok(()), save_archive: |_| Ok(()), remove_active: |_| Err("Active store unavailable".to_owned()), create_archive_id: || "archive-one".to_owned(), @@ -336,7 +339,7 @@ mod tests { append_annotation(&dir, &annotation("snapshot")).expect("append"); let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { load_active: || load_annotations(&dir), - write_clipboard: |_| Ok(()), + deliver: |_| Ok(()), save_archive: |set| { append_archived_set(&dir, &set)?; append_annotation(&dir, &annotation("concurrent")) diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 3d9c729..7322ea3 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -1,4 +1,4 @@ -//! One native command boundary for the six Herdr entrypoints. +//! One native command boundary for the eight Herdr entrypoints. use std::cell::Cell; use std::fs::OpenOptions; @@ -10,13 +10,14 @@ use chrono::{SecondsFormat, Utc}; use serde_json::Value; use uuid::Uuid; +use crate::agent_delivery::{Delivery, deliver_to_agent}; use crate::archive_workflow::{ CopyAndArchiveDependencies, CopyAndArchiveOutcome, copy_and_archive_annotations, }; use crate::clipboard::{read_clipboard, write_clipboard}; use crate::format::format_annotations; use crate::handoff::take_default_handoff; -use crate::herdr::{notify, run_herdr}; +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, @@ -26,8 +27,8 @@ use crate::types::{ selected_text_from_invocation, }; -const USAGE: &str = - "Usage: herdr-annotate "; +const USAGE: &str = "Usage: herdr-annotate \ + "; /// Dispatch one native binary subcommand. pub fn run(args: &[String]) -> Result<(), String> { @@ -39,6 +40,8 @@ pub fn run(args: &[String]) -> Result<(), String> { notify("Copy failed", Some(message)); }), Some("copy-archive") if args.len() == 1 => copy_archive(), + Some("paste-archive") if args.len() == 1 => deliver_archive(Delivery::Paste), + Some("send-archive") if args.len() == 1 => deliver_archive(Delivery::Send), Some("manage") if args.len() == 1 => manage().inspect_err(|message| { notify("Unable to open annotations", Some(message)); }), @@ -140,9 +143,9 @@ fn copy_context() -> Result<(), String> { Ok(()) } -/// The notification and exit status one copy-and-archive action reports. +/// The notification and exit status one copy-, paste- or send-and-archive action reports. #[derive(Debug, Clone, PartialEq, Eq)] -struct CopyArchiveReport { +struct ArchiveReport { title: String, body: String, failure: bool, @@ -152,9 +155,9 @@ struct CopyArchiveReport { /// /// `loaded_empty` separates the nothing-to-do case from a real failure: both are `StayOpen`, /// but an empty store is reported like `copy-context` and returns success. -fn copy_archive_report(outcome: CopyAndArchiveOutcome, loaded_empty: bool) -> CopyArchiveReport { +fn copy_archive_report(outcome: CopyAndArchiveOutcome, loaded_empty: bool) -> ArchiveReport { match outcome { - CopyAndArchiveOutcome::Close { archived_count } => CopyArchiveReport { + CopyAndArchiveOutcome::Close { archived_count } => ArchiveReport { title: "Annotations copied and archived".to_owned(), body: format!( "{archived_count} annotation{} copied as Markdown and archived.", @@ -162,17 +165,17 @@ fn copy_archive_report(outcome: CopyAndArchiveOutcome, loaded_empty: bool) -> Co ), failure: false, }, - CopyAndArchiveOutcome::ArchivedActiveRetained { message } => CopyArchiveReport { + CopyAndArchiveOutcome::ArchivedActiveRetained { message } => ArchiveReport { title: "Copy and archive incomplete".to_owned(), body: format!("Copied and archived, but active annotations remain: {message}"), failure: true, }, - CopyAndArchiveOutcome::StayOpen { .. } if loaded_empty => CopyArchiveReport { + CopyAndArchiveOutcome::StayOpen { .. } if loaded_empty => ArchiveReport { title: "No annotations".to_owned(), body: "There is nothing to copy yet.".to_owned(), failure: false, }, - CopyAndArchiveOutcome::StayOpen { message } => CopyArchiveReport { + CopyAndArchiveOutcome::StayOpen { message } => ArchiveReport { title: "Copy and archive failed".to_owned(), body: message, failure: true, @@ -196,7 +199,7 @@ fn copy_archive() -> Result<(), String> { } loaded }, - write_clipboard: |text: String| write_clipboard(&text), + deliver: |text: String| write_clipboard(&text), save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive), remove_active: |ids: Vec| remove_annotations_by_id(&dir, &ids), create_archive_id: || Uuid::new_v4().to_string(), @@ -211,6 +214,109 @@ fn copy_archive() -> Result<(), String> { Ok(()) } +/// Map one paste- or send-and-archive outcome to the action's notification and exit status. +/// +/// `loaded_empty` is read as in `copy_archive_report`. `delivered` separates a refused delivery, +/// where nothing reached the agent, from an archive failure after the agent already has the text: +/// both are `StayOpen`, but only the second must not suggest trying again. +fn deliver_archive_report( + delivery: Delivery, + outcome: CopyAndArchiveOutcome, + loaded_empty: bool, + delivered: bool, +) -> ArchiveReport { + let (action, landed) = match delivery { + Delivery::Paste => ("Paste", "pasted into the agent's prompt"), + Delivery::Send => ("Send", "sent to the agent"), + }; + let past = delivery.past(); + match outcome { + CopyAndArchiveOutcome::Close { archived_count } => ArchiveReport { + title: format!("Annotations {past} and archived"), + body: format!( + "{archived_count} annotation{} {landed} and archived.", + if archived_count == 1 { "" } else { "s" } + ), + failure: false, + }, + CopyAndArchiveOutcome::ArchivedActiveRetained { message } => ArchiveReport { + title: format!("{action} and archive incomplete"), + body: format!( + "{} and archived, but active annotations remain: {message}", + capitalized(landed) + ), + failure: true, + }, + CopyAndArchiveOutcome::StayOpen { .. } if loaded_empty => ArchiveReport { + title: "No annotations".to_owned(), + body: format!("There is nothing to {} yet.", delivery.verb()), + failure: false, + }, + CopyAndArchiveOutcome::StayOpen { message } if delivered => ArchiveReport { + title: format!("Annotations {past}, not archived"), + body: format!( + "{}, but archiving failed, so your annotations are still active: {message}", + capitalized(landed) + ), + failure: true, + }, + CopyAndArchiveOutcome::StayOpen { message } => ArchiveReport { + title: format!("{action} failed"), + body: message, + failure: true, + }, + } +} + +fn capitalized(text: &str) -> String { + let mut characters = text.chars(); + characters + .next() + .map(|first| first.to_uppercase().chain(characters).collect()) + .unwrap_or_default() +} + +fn deliver_archive(delivery: Delivery) -> Result<(), String> { + let failed = match delivery { + Delivery::Paste => "Paste failed", + Delivery::Send => "Send failed", + }; + let Some(dir) = state_dir() else { + let message = "HERDR_PLUGIN_STATE_DIR is not set".to_owned(); + notify(failed, Some(&message)); + return Err(message); + }; + let pane = parse_invocation_context(&invocation_context()).focused_pane_id; + + let loaded_empty = Cell::new(false); + let delivered = Cell::new(false); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || { + let loaded = load_annotations(&dir); + if matches!(&loaded, Ok(active) if active.is_empty()) { + loaded_empty.set(true); + } + loaded + }, + deliver: |text: String| { + deliver_to_agent(delivery, pane.as_deref(), &text, run_herdr_output)?; + delivered.set(true); + Ok(()) + }, + save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive), + remove_active: |ids: Vec| remove_annotations_by_id(&dir, &ids), + create_archive_id: || Uuid::new_v4().to_string(), + now: now_iso, + }); + + let report = deliver_archive_report(delivery, outcome, loaded_empty.get(), delivered.get()); + notify(&report.title, Some(&report.body)); + if report.failure { + return Err(report.body); + } + Ok(()) +} + fn manage() -> Result<(), String> { let root = plugin_root().ok_or_else(|| "HERDR_PLUGIN_ROOT is not set".to_owned())?; run_herdr(&[ @@ -273,13 +379,20 @@ mod tests { run(&["copy-archive".to_owned(), "extra".to_owned()]), Err(USAGE.to_owned()) ); + for subcommand in ["paste-archive", "send-archive"] { + assert_eq!( + run(&[subcommand.to_owned(), "extra".to_owned()]), + Err(USAGE.to_owned()) + ); + assert!(USAGE.contains(subcommand), "{USAGE}"); + } } #[test] fn copy_archive_maps_every_outcome_to_its_notification_and_exit_status() { assert_eq!( copy_archive_report(CopyAndArchiveOutcome::Close { archived_count: 1 }, false), - CopyArchiveReport { + ArchiveReport { title: "Annotations copied and archived".to_owned(), body: "1 annotation copied as Markdown and archived.".to_owned(), failure: false, @@ -287,7 +400,7 @@ mod tests { ); assert_eq!( copy_archive_report(CopyAndArchiveOutcome::Close { archived_count: 3 }, false), - CopyArchiveReport { + ArchiveReport { title: "Annotations copied and archived".to_owned(), body: "3 annotations copied as Markdown and archived.".to_owned(), failure: false, @@ -300,7 +413,7 @@ mod tests { }, true, ), - CopyArchiveReport { + ArchiveReport { title: "No annotations".to_owned(), body: "There is nothing to copy yet.".to_owned(), failure: false, @@ -313,7 +426,7 @@ mod tests { }, false, ), - CopyArchiveReport { + ArchiveReport { title: "Copy and archive failed".to_owned(), body: "clipboard write failed".to_owned(), failure: true, @@ -326,7 +439,7 @@ mod tests { }, false, ), - CopyArchiveReport { + ArchiveReport { title: "Copy and archive incomplete".to_owned(), body: "Copied and archived, but active annotations remain: store is busy" .to_owned(), @@ -334,4 +447,104 @@ mod tests { } ); } + + #[test] + fn paste_and_send_archive_map_every_outcome_to_their_own_wording() { + assert_eq!( + deliver_archive_report( + Delivery::Paste, + CopyAndArchiveOutcome::Close { archived_count: 1 }, + false, + true + ), + ArchiveReport { + title: "Annotations pasted and archived".to_owned(), + body: "1 annotation pasted into the agent's prompt and archived.".to_owned(), + failure: false, + } + ); + assert_eq!( + deliver_archive_report( + Delivery::Send, + CopyAndArchiveOutcome::Close { archived_count: 3 }, + false, + true + ), + ArchiveReport { + title: "Annotations sent and archived".to_owned(), + body: "3 annotations sent to the agent and archived.".to_owned(), + failure: false, + } + ); + for (delivery, body) in [ + (Delivery::Paste, "There is nothing to paste yet."), + (Delivery::Send, "There is nothing to send yet."), + ] { + assert_eq!( + deliver_archive_report( + delivery, + CopyAndArchiveOutcome::StayOpen { + message: "Nothing to copy and archive.".to_owned(), + }, + true, + false + ), + ArchiveReport { + title: "No annotations".to_owned(), + body: body.to_owned(), + failure: false, + } + ); + } + let refusal = "The agent is waiting on a prompt. Nothing was sent; your annotations are still active."; + assert_eq!( + deliver_archive_report( + Delivery::Send, + CopyAndArchiveOutcome::StayOpen { + message: refusal.to_owned(), + }, + false, + false + ), + ArchiveReport { + title: "Send failed".to_owned(), + body: refusal.to_owned(), + failure: true, + } + ); + assert_eq!( + deliver_archive_report( + Delivery::Send, + CopyAndArchiveOutcome::StayOpen { + message: "archive store is busy".to_owned(), + }, + false, + true + ), + ArchiveReport { + title: "Annotations sent, not archived".to_owned(), + body: "Sent to the agent, but archiving failed, so your annotations are still \ + active: archive store is busy" + .to_owned(), + failure: true, + } + ); + assert_eq!( + deliver_archive_report( + Delivery::Paste, + CopyAndArchiveOutcome::ArchivedActiveRetained { + message: "store is busy".to_owned(), + }, + false, + true + ), + ArchiveReport { + title: "Paste and archive incomplete".to_owned(), + body: "Pasted into the agent's prompt and archived, but active annotations \ + remain: store is busy" + .to_owned(), + failure: true, + } + ); + } } diff --git a/rust/src/herdr.rs b/rust/src/herdr.rs index 3d7b069..65df322 100644 --- a/rust/src/herdr.rs +++ b/rust/src/herdr.rs @@ -40,6 +40,30 @@ pub fn run_herdr(args: &[String]) -> Result<(), String> { } } +/// Invoke Herdr synchronously and return its stdout, or its trimmed stderr on failure. +/// +/// Unlike `run_herdr`, a failure that leaves stderr empty names only the command, never the +/// remaining arguments, because callers pass annotation text as an argument. +pub fn run_herdr_output(args: &[String]) -> Result { + let command = args.iter().take(2).cloned().collect::>().join(" "); + let output = process() + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("cannot run herdr {command}: {error}"))?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + if stderr.is_empty() { + Err(format!("herdr {command} failed")) + } else { + Err(stderr) + } +} + /// Best-effort user notification; failures are intentionally non-fatal. pub fn notify(title: &str, body: Option<&str>) { let mut args = vec!["notification", "show", title]; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 82d60ac..04b4eab 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,5 +1,6 @@ //! Native implementation of Herdr Annotate Lite. +pub mod agent_delivery; pub mod archive_workflow; pub mod clipboard; pub mod edit_keys; diff --git a/rust/src/manager.rs b/rust/src/manager.rs index a8eb5d8..3a57f0f 100644 --- a/rust/src/manager.rs +++ b/rust/src/manager.rs @@ -555,7 +555,7 @@ impl ManagerApp { let dir = self.dir.clone(); let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { load_active: || load_annotations(&dir), - write_clipboard: |text: String| pane_clipboard_write(&text), + deliver: |text: String| pane_clipboard_write(&text), save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive), remove_active: |ids: Vec| remove_annotations_by_id(&dir, &ids), create_archive_id: || Uuid::new_v4().to_string(), diff --git a/rust/tests/commands.rs b/rust/tests/commands.rs index 116d930..1238c6c 100644 --- a/rust/tests/commands.rs +++ b/rust/tests/commands.rs @@ -143,3 +143,279 @@ fn failed_editor_open_removes_the_pending_file_and_reports_failure() { assert_eq!(pending_count, 0); let _ = fs::remove_dir_all(dir); } + +/// A fake Herdr that appends every call, answers `agent get` with `HERDR_TEST_AGENT_GET`, and +/// fails the one command named in `HERDR_TEST_FAIL` with `HERDR_TEST_STDERR`. +fn responding_herdr(dir: &Path) -> PathBuf { + let script = dir.join("responding-herdr"); + fs::write( + &script, + r#"#!/bin/sh +printf '%s\n' "$@" >> "$HERDR_TEST_LOG" +printf '%s\n' '--' >> "$HERDR_TEST_LOG" +if [ "$1 $2" = "${HERDR_TEST_FAIL:-}" ]; then + printf '%s\n' "$HERDR_TEST_STDERR" >&2 + exit 1 +fi +if [ "$1 $2" = "agent get" ]; then + printf '%s\n' "${HERDR_TEST_AGENT_GET:-}" +fi +exit 0 +"#, + ) + .expect("fake Herdr"); + fs::set_permissions(&script, fs::Permissions::from_mode(0o700)).expect("executable"); + script +} + +const SEEDED: &str = concat!( + r#"{"selectedText":"first selection","capturedAt":"2026-09-24T00:00:00.000Z","context":{},"#, + r#""id":"one","comment":"first comment","createdAt":"2026-09-24T00:00:01.000Z"}"#, + "\n", + r#"{"selectedText":"second selection","capturedAt":"2026-09-24T00:00:02.000Z","context":{},"#, + r#""id":"two","comment":"second comment","createdAt":"2026-09-24T00:00:03.000Z"}"#, + "\n", +); + +const FOCUSED: &str = r#"{"focused_pane_id":"w1:p2"}"#; + +fn agent_record(status: &str) -> String { + format!( + r#"{{"id":"cli:agent:get","result":{{"agent":{{"agent":"claude","agent_status":"{status}","pane_id":"w1:p2"}},"type":"agent_info"}}}}"# + ) +} + +/// Run one delivery subcommand against a seeded store, returning its output, calls and state. +fn deliver( + subcommand: &str, + context: Option<&str>, + variables: &[(&str, &str)], +) -> (std::process::Output, String, PathBuf) { + let dir = directory(); + let state = dir.join("state"); + fs::create_dir_all(&state).expect("state"); + fs::write(state.join("annotations.jsonl"), SEEDED).expect("seed"); + let log = dir.join("herdr.log"); + let mut command = Command::new(binary()); + command + .arg(subcommand) + .env("HERDR_BIN_PATH", responding_herdr(&dir)) + .env("HERDR_TEST_LOG", &log) + .env("HERDR_PLUGIN_STATE_DIR", &state) + .env_remove("HERDR_PLUGIN_CONTEXT_JSON"); + if let Some(context) = context { + command.env("HERDR_PLUGIN_CONTEXT_JSON", context); + } + command.envs(variables.iter().copied()); + let output = command.output().expect("run"); + let calls = fs::read_to_string(&log).unwrap_or_default(); + (output, calls, state) +} + +fn active(state: &Path) -> String { + fs::read_to_string(state.join("annotations.jsonl")).expect("active store") +} + +fn archived(state: &Path) -> usize { + fs::read_to_string(state.join("archives.jsonl")) + .unwrap_or_default() + .lines() + .count() +} + +fn remove(state: &Path) { + if let Some(dir) = state.parent() { + let _ = fs::remove_dir_all(dir); + } +} + +#[test] +fn send_archive_prompts_the_focused_agent_then_archives() { + let record = agent_record("idle"); + let (output, calls, state) = deliver( + "send-archive", + Some(FOCUSED), + &[("HERDR_TEST_AGENT_GET", &record)], + ); + assert!(output.status.success(), "{output:?}"); + assert!( + calls.starts_with( + "agent\nget\nw1:p2\n--\nagent\nprompt\nw1:p2\n# Annotated context\n\n## Annotation 1\n" + ), + "{calls}" + ); + assert!(calls.find("second selection") < calls.find("first selection")); + assert!(!calls.contains('\x1b'), "{calls}"); + assert!(calls.ends_with( + "--\nnotification\nshow\nAnnotations sent and archived\n--body\n\ + 2 annotations sent to the agent and archived.\n--\n" + )); + assert_eq!(active(&state), ""); + assert_eq!(archived(&state), 1); + remove(&state); +} + +#[test] +fn paste_archive_checks_the_agent_then_types_one_bracketed_paste() { + let record = agent_record("idle"); + let (output, calls, state) = deliver( + "paste-archive", + Some(FOCUSED), + &[("HERDR_TEST_AGENT_GET", &record)], + ); + assert!(output.status.success(), "{output:?}"); + assert!( + calls.starts_with( + "agent\nget\nw1:p2\n--\npane\nsend-text\nw1:p2\n\x1b[200~# Annotated context\n" + ), + "{calls}" + ); + assert!(calls.contains( + "first comment\n\x1b[201~\n--\nnotification\nshow\nAnnotations pasted and archived\n" + )); + assert_eq!(calls.matches('\x1b').count(), 2); + assert_eq!(active(&state), ""); + assert_eq!(archived(&state), 1); + remove(&state); +} + +#[test] +fn paste_archive_refuses_a_blocked_agent_and_keeps_the_store() { + let record = agent_record("blocked"); + let (output, calls, state) = deliver( + "paste-archive", + Some(FOCUSED), + &[("HERDR_TEST_AGENT_GET", &record)], + ); + assert!(!output.status.success()); + assert_eq!( + calls, + "agent\nget\nw1:p2\n--\nnotification\nshow\nPaste failed\n--body\n\ + The agent is waiting on a prompt. Nothing was pasted; your annotations are still active.\n--\n" + ); + assert_eq!(active(&state), SEEDED); + assert_eq!(archived(&state), 0); + remove(&state); +} + +#[test] +fn send_archive_turns_herdrs_refusal_into_words_and_keeps_the_store() { + let record = agent_record("idle"); + let (output, calls, state) = deliver( + "send-archive", + Some(FOCUSED), + &[ + ("HERDR_TEST_AGENT_GET", &record), + ("HERDR_TEST_FAIL", "agent prompt"), + ( + "HERDR_TEST_STDERR", + r#"{"error":{"code":"agent_not_found","message":"agent target w1:p2 not found"},"id":"cli:agent:prompt"}"#, + ), + ], + ); + assert!(!output.status.success()); + assert!( + calls.ends_with( + "--\nnotification\nshow\nSend failed\n--body\n\ + No agent is running in the focused pane. Nothing was sent; your annotations are still active.\n--\n" + ), + "{calls}" + ); + assert_eq!(active(&state), SEEDED); + assert_eq!(archived(&state), 0); + remove(&state); +} + +/// Herdr before 0.8.2 types `agent prompt` text into an approval dialog, so send-archive must +/// refuse on the `agent get` answer and never reach `agent prompt`. +#[test] +fn send_archive_refuses_an_agent_that_is_not_ready_without_prompting() { + let blocked = agent_record("blocked"); + let exited = r#"{"id":"cli:agent:get","result":{"agent":{"name":"reviewer","agent_status":"idle","pane_id":"w1:p2"},"type":"agent_info"}}"#; + let launching = r#"{"id":"cli:agent:get","result":{"agent":{"agent":"claude","agent_status":"unknown","launch_pending":true,"pane_id":"w1:p2"},"type":"agent_info"}}"#; + for (record, reason) in [ + (blocked.as_str(), "The agent is waiting on a prompt."), + (exited, "No agent is running in the focused pane."), + (launching, "The agent is not ready for input yet."), + ] { + let (output, calls, state) = deliver( + "send-archive", + Some(FOCUSED), + &[("HERDR_TEST_AGENT_GET", record)], + ); + assert!(!output.status.success(), "{reason}"); + assert_eq!( + calls, + format!( + "agent\nget\nw1:p2\n--\nnotification\nshow\nSend failed\n--body\n\ + {reason} Nothing was sent; your annotations are still active.\n--\n" + ) + ); + assert_eq!(active(&state), SEEDED); + assert_eq!(archived(&state), 0); + remove(&state); + } +} + +#[test] +fn paste_archive_refuses_a_pane_whose_agent_exited() { + let exited = r#"{"id":"cli:agent:get","result":{"agent":{"name":"reviewer","agent_status":"unknown","pane_id":"w1:p2"},"type":"agent_info"}}"#; + let (output, calls, state) = deliver( + "paste-archive", + Some(FOCUSED), + &[("HERDR_TEST_AGENT_GET", exited)], + ); + assert!(!output.status.success()); + assert_eq!( + calls, + "agent\nget\nw1:p2\n--\nnotification\nshow\nPaste failed\n--body\n\ + No agent is running in the focused pane. Nothing was pasted; your annotations are still active.\n--\n" + ); + assert_eq!(active(&state), SEEDED); + assert_eq!(archived(&state), 0); + remove(&state); +} + +#[test] +fn delivery_without_a_focused_pane_is_refused_before_calling_the_agent() { + for (subcommand, title, past) in [ + ("paste-archive", "Paste failed", "pasted"), + ("send-archive", "Send failed", "sent"), + ] { + let (output, calls, state) = deliver(subcommand, None, &[]); + assert!(!output.status.success()); + assert_eq!( + calls, + format!( + "notification\nshow\n{title}\n--body\nHerdr did not say which pane is focused. \ + Nothing was {past}; your annotations are still active.\n--\n" + ) + ); + assert_eq!(active(&state), SEEDED); + remove(&state); + } +} + +#[test] +fn delivery_with_an_empty_store_says_there_is_nothing_to_deliver() { + for (subcommand, verb) in [("paste-archive", "paste"), ("send-archive", "send")] { + let dir = directory(); + let log = dir.join("herdr.log"); + let output = Command::new(binary()) + .arg(subcommand) + .env("HERDR_BIN_PATH", responding_herdr(&dir)) + .env("HERDR_TEST_LOG", &log) + .env("HERDR_PLUGIN_STATE_DIR", dir.join("state")) + .env("HERDR_PLUGIN_CONTEXT_JSON", FOCUSED) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + fs::read_to_string(log).expect("notification"), + format!( + "notification\nshow\nNo annotations\n--body\nThere is nothing to {verb} yet.\n--\n" + ) + ); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/scripts/lite-regression.py b/scripts/lite-regression.py index 87ecb77..af23a04 100755 --- a/scripts/lite-regression.py +++ b/scripts/lite-regression.py @@ -48,8 +48,11 @@ UUID_PATTERN = re.compile( r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" ) -# The six entrypoints the Lite manifest declares, and the whole surface this harness drives. +# The six entrypoints the Bun runtime shipped, and the whole surface this harness drives. ENTRYPOINTS = ("capture", "copy-archive", "copy-context", "editor", "manage", "manager") +# Lite actions added after the Bun runtime retired. No recording exists to compare them with, so +# this harness checks only their manifest entries; the Rust tests cover their behavior. +NATIVE_ONLY_ENTRYPOINTS = ("paste-archive", "send-archive") # The Full manifest at the repository root starts the binary in its own bin/; the Lite variant # in lite/ shares that one staged binary, the way it shared the Bun sources. NATIVE_PROGRAM = "./bin/herdr-annotate.exe" @@ -1813,7 +1816,7 @@ def load(path: Path) -> dict[str, object]: goldens.equal( "manifest.harness-entrypoints", - list(ENTRYPOINTS), + sorted(ENTRYPOINTS + NATIVE_ONLY_ENTRYPOINTS), sorted(set(lite_actions) | set(lite_panes)), ) # Full adds the plannotator-tui review entrypoints; every Lite one must also be in Full. diff --git a/scripts/smoke.sh b/scripts/smoke.sh index db241c9..13289f5 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -97,7 +97,7 @@ trap restore EXIT echo "== fresh install: full" install "$spec" -check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,open-link,terminal" +check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,open-link,paste-archive,send-archive,terminal" check "action programs" "$(programs)" "./bin/herdr-annotate.exe,./bin/plannotator-tui.exe" check "review binary matches pin" "$(tui_version)" "$(tui_pin)" check "native binary matches pin" "$(native_version)" "$(native_pin)" @@ -126,7 +126,7 @@ check "native binary installed on upgrade" "$(native_version)" "$(native_pin)" echo "== fresh install: lite" install "$spec/lite" -check "actions" "$(actions)" "capture,copy-archive,copy-context,manage" +check "actions" "$(actions)" "capture,copy-archive,copy-context,manage,paste-archive,send-archive" check "action programs" "$(programs)" "../bin/herdr-annotate.exe" check "no review binary" "$(tui_version)" "none" check "native binary matches pin" "$(native_version)" "$(native_pin)" @@ -134,7 +134,7 @@ check "manager pane" "$(manager_renders)" "ok" echo "== swap: lite -> full" install "$spec" -check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,open-link,terminal" +check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,open-link,paste-archive,send-archive,terminal" check "review binary" "$(tui_version)" "$(tui_pin)" check "native binary" "$(native_version)" "$(native_pin)" diff --git a/scripts/test-herdr-windows-full-plugin.ps1 b/scripts/test-herdr-windows-full-plugin.ps1 index 0489029..310ba59 100644 --- a/scripts/test-herdr-windows-full-plugin.ps1 +++ b/scripts/test-herdr-windows-full-plugin.ps1 @@ -122,7 +122,7 @@ try { # variant installed and the review half silently unreachable, which is the state this # whole variant exists to end. $actionIds = @($plugin.actions | ForEach-Object { $_.id }) - foreach ($id in @("capture", "copy-context", "copy-archive", "manage", "open", "open-link", "last", "last-newest", "terminal")) { + foreach ($id in @("capture", "copy-context", "copy-archive", "paste-archive", "send-archive", "manage", "open", "open-link", "last", "last-newest", "terminal")) { Assert-True ($actionIds -contains $id) "Herdr $version omitted action $id" $action = @($plugin.actions | Where-Object { $_.id -ceq $id }) # @() at the call site: an empty array returned from a function unrolls to $null. @@ -130,7 +130,7 @@ try { Assert-True ($platforms.Count -eq 0 -or $platforms -contains "windows") ` "action $id is not effective on Windows: $($platforms -join ',')" } - Assert-True ($actionIds.Count -eq 9) "Herdr $version listed $($actionIds.Count) actions, expected 9" + Assert-True ($actionIds.Count -eq 11) "Herdr $version listed $($actionIds.Count) actions, expected 11" $paneIds = @($plugin.panes | ForEach-Object { $_.id }) foreach ($id in @("editor", "manager", "doc")) { @@ -155,7 +155,7 @@ try { $handler = @($plugin.link_handlers | Where-Object { $_.id -ceq "markdown-file" }) Assert-True ($handler.Count -eq 1) "Herdr $version omitted the markdown-file link handler" - Write-Output "herdr $version accepts windows-full: 9 actions, 3 panes, direct-argv doc pane" + Write-Output "herdr $version accepts windows-full: 11 actions, 3 panes, direct-argv doc pane" } } finally { foreach ($name in $isolatedNames) { diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py index f81f9f7..1210273 100644 --- a/scripts/test-windows-full-manifest.py +++ b/scripts/test-windows-full-manifest.py @@ -51,7 +51,14 @@ "scripts/fetch-herdr-annotate.ps1", ] NATIVE_ENTRIES = { - "actions": ("capture", "copy-context", "copy-archive", "manage"), + "actions": ( + "capture", + "copy-context", + "copy-archive", + "paste-archive", + "send-archive", + "manage", + ), "panes": ("editor", "manager"), } DISTRIBUTED_PANE = [ diff --git a/windows-full/herdr-plugin.toml b/windows-full/herdr-plugin.toml index 2c1cb01..be6f9c5 100644 --- a/windows-full/herdr-plugin.toml +++ b/windows-full/herdr-plugin.toml @@ -21,7 +21,7 @@ id = "annotate" name = "Annotate" -version = "0.6.0" +version = "0.7.0" min_herdr_version = "0.9.0" description = "Comment on terminal selections and copy annotations as agent context." platforms = ["windows"] @@ -57,6 +57,20 @@ description = "Copy all saved annotations to the clipboard as Markdown, then arc contexts = ["global"] command = ["../bin/herdr-annotate.exe", "copy-archive"] +[[actions]] +id = "paste-archive" +title = "Paste annotations into the agent's prompt and archive them" +description = "Paste all saved annotations into the focused agent's prompt without sending, then archive them." +contexts = ["pane"] +command = ["../bin/herdr-annotate.exe", "paste-archive"] + +[[actions]] +id = "send-archive" +title = "Send annotations to the agent and archive them" +description = "Send all saved annotations as the focused agent's next message, then archive them." +contexts = ["pane"] +command = ["../bin/herdr-annotate.exe", "send-archive"] + [[actions]] id = "manage" title = "Manage annotations"