From 2dfcc5d6b97a6bfa82c307998ccbae809256cd3d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 8 Sep 2026 12:50:53 -0700 Subject: [PATCH] feat: name and test the osc 52 copy path The clipboard delivery already emitted OSC 52 and no native clipboard write, so on Herdr 0.9.0 a copy already reaches the terminal the person is viewing from rather than the server the app runs on. Give the sequence its own function, say why in one place, and pin the bytes: plain text, empty, multi-byte UTF-8, and an oversized payload that is emitted whole instead of truncated. Also pin that headless runs never emit, so --print, --export and --snapshot cannot start writing escape sequences into their output. refs #40 --- README.md | 4 +++ crates/plannotator-tui/src/delivery.rs | 45 +++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d32c860..6e24345 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ Drag with the mouse (or `v` and move) to select, then `a` 👍 · `c` 💬 · `d review to the clipboard as numbered annotations (`# Annotations on plan.md`, `## Annotation 1 (line 12)`, …). Every annotation is saved as JSON the moment you make it; `q` closes. +Copies go to the clipboard as OSC 52, which is the terminal you are looking at, so on Herdr 0.9.0 +they reach your own machine even when the app runs on a remote server; Herdr Annotate's global +`copy-context` and `copy-archive` actions do not, because they run outside a pane. + | Where | Keys | |---|---| | anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `q` quit | diff --git a/crates/plannotator-tui/src/delivery.rs b/crates/plannotator-tui/src/delivery.rs index 35e6cbf..3ea5694 100644 --- a/crates/plannotator-tui/src/delivery.rs +++ b/crates/plannotator-tui/src/delivery.rs @@ -49,6 +49,16 @@ pub(crate) trait Delivery { fn deliver(&self, feedback: &str) -> Result<(), DeliveryError>; } +/// The OSC 52 sequence that hands `text` to the terminal's clipboard. +/// +/// The terminal the app draws on is the one the person is sitting at, so a copy lands on their +/// machine even when the app itself runs on a remote server: Herdr 0.9.0 forwards a pane's OSC 52 +/// to the viewing client. Terminals commonly refuse a base64 payload over 74994 bytes; the sequence +/// is still emitted whole, because truncating a copy silently is worse than one the terminal drops. +pub(crate) fn osc52_sequence(text: &str) -> String { + format!("\x1b]52;c;{}\x07", crate::base64::encode(text.as_bytes())) +} + /// OSC 52: hand text to the terminal's clipboard so Cmd-V works outside the app. #[derive(Debug, Default)] pub(crate) struct Clipboard; @@ -60,7 +70,8 @@ impl Delivery for Clipboard { fn deliver(&self, feedback: &str) -> Result<(), DeliveryError> { let mut out = std::io::stdout().lock(); - write!(out, "\x1b]52;c;{}\x07", crate::base64::encode(feedback.as_bytes()))?; + // Callers write between frames, so the sequence never lands inside one. + out.write_all(osc52_sequence(feedback).as_bytes())?; out.flush()?; Ok(()) } @@ -165,6 +176,38 @@ mod tests { format!(r#"{{"id":"cli:agent:prompt","error":{{"code":"{code}","message":"{message}"}}}}"#) } + #[test] + fn the_clipboard_sequence_is_osc52_over_the_raw_utf8_bytes() { + assert_eq!(osc52_sequence("hi"), "\x1b]52;c;aGk=\x07"); + assert_eq!( + osc52_sequence("hi").as_bytes(), + &[0x1b, 0x5d, 0x35, 0x32, 0x3b, 0x63, 0x3b, 0x61, 0x47, 0x6b, 0x3d, 0x07] + ); + assert_eq!(osc52_sequence(""), "\x1b]52;c;\x07"); + assert_eq!(osc52_sequence("한글 · é"), "\x1b]52;c;7ZWc6riAIMK3IMOp\x07"); + } + + #[test] + fn an_oversized_copy_is_emitted_whole_rather_than_truncated() { + // 74994 base64 bytes is the payload many terminals stop at; we never cut a copy to fit. + let text = "a".repeat(80_000); + let sequence = osc52_sequence(&text); + assert!(sequence.len() > 74_994); + assert!(sequence.starts_with("\x1b]52;c;") && sequence.ends_with('\x07')); + assert!(sequence.contains(&crate::base64::encode(text.as_bytes()))); + } + + #[test] + fn headless_runs_never_reach_the_terminal_clipboard() { + // `--print`, `--export` and `--snapshot` open the app non-interactively, and a freshly + // opened app has not enabled clipboard copies; only the interactive event loop does. + assert_eq!(crate::cli::delivery(false).describe(), Discard.describe()); + let source = + plannotator_tui_schema::DocumentSource::file(PathBuf::from("doc.md"), "# hi\n".to_owned()); + let app = crate::app::App::open(source, 80, crate::cli::delivery(false)).expect("open"); + assert!(!app.clipboard); + } + #[test] fn success_is_ok_regardless_of_output() { assert!(parse_response(true, r#"{"id":"x","result":{}}"#, "").is_ok());