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 @@ -145,6 +145,11 @@ herdr server reload-config
| `Ctrl+B Ctrl+A` | copy all annotations as Markdown, 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
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
have no terminal to write to.

### 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).
Expand Down
95 changes: 62 additions & 33 deletions docs/rust-lite-parity-proof.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion docs/rust-lite-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ separately so test coverage is not confused with behavior observed inside Herdr.
save delay, Esc/Ctrl+C cancellation, raw terminal restoration.
- [x] `manager.ts` → `herdr-annotate manager`: active/archive views, newest-first lists, detail panes,
navigation, copy one/all, copy-and-archive, delete, double-confirm clear, restore, double-confirm
permanent archive deletion, reload, status messages, Esc/Tab/q/Ctrl+C behavior.
permanent archive deletion, reload, status messages, Esc/Tab/q/Ctrl+C behavior. Every pane copy also
emits OSC 52 so Herdr 0.9.0 forwards it to the viewing client's terminal.
- [x] `lite-rs/herdr-plugin.toml` preserves plugin id `annotate`, action ids, pane ids, placements,
dimensions, contexts, and supported platform declarations. All commands are the one native binary.
`scripts/parity-lite.py` compares those declarations against `lite/herdr-plugin.toml` and the root
Expand All @@ -43,6 +44,10 @@ separately so test coverage is not confused with behavior observed inside Herdr.
splitting truncation/wrapping, terminal-cell cursor coordinates.
- [x] `handoff.ts`: per-user runtime/temp path, 15-second freshness, read-once removal, blank rejection.
- [x] `clipboard.ts`: `pbpaste`/`pbcopy`; PowerShell raw get/set; Linux Wayland then xclip then xsel.
- [x] `pane-clipboard.ts`: identical `ESC ] 52 ; c ; <base64> BEL` bytes over the raw UTF-8 text,
emitted after the native write at the same two manager call sites, the same rule that either
destination landing is a successful copy, and the same advisory 74994-byte payload limit with no
truncation.
- [x] `herdr.ts`: `HERDR_BIN_PATH` override, stderr projection, best-effort notifications.
- [x] `archive-workflow.ts` and `manager-copy.ts`: operation order and partial-failure states, including
preserving a concurrently saved annotation.
Expand All @@ -64,6 +69,7 @@ document-anchor/API wire shape, not Lite's existing terminal-selection JSONL sha
| `test/archive-workflow.test.ts` | `archive_workflow::tests` (8 tests) |
| `test/export-archive.test.ts` | `cli::tests::copy_archive_maps_every_outcome_to_its_notification_and_exit_status` |
| `test/manager-copy.test.ts` | `manager_copy::tests` (3 tests) |
| `test/pane-clipboard.test.ts` | `pane_clipboard::tests` (8 tests, including base64 chunk padding) |

Additional Rust-only coverage:

Expand Down
1 change: 1 addition & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod herdr;
pub mod layout;
pub mod manager;
pub mod manager_copy;
pub mod pane_clipboard;
pub mod paths;
pub mod store;
pub mod types;
Expand Down
10 changes: 8 additions & 2 deletions rust/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::clipboard::write_clipboard;
use crate::editor::now_iso_for_manager;
use crate::format::{sanitize_terminal_text, wrap_text};
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,
Expand Down Expand Up @@ -544,7 +545,7 @@ impl ManagerApp {
}

fn copy(&mut self, items: &[Annotation]) {
match copy_annotations(items, write_clipboard) {
match copy_annotations(items, pane_clipboard_write) {
ManagerCopyOutcome::Close => self.quit = true,
ManagerCopyOutcome::StayOpen { message } => self.status = message,
}
Expand All @@ -554,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| write_clipboard(&text),
write_clipboard: |text: String| pane_clipboard_write(&text),
save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive),
remove_active: |ids: Vec<String>| remove_annotations_by_id(&dir, &ids),
create_archive_id: || Uuid::new_v4().to_string(),
Expand Down Expand Up @@ -698,6 +699,11 @@ fn format_timestamp(value: &str) -> String {
)
}

/// Write a pane copy to the native clipboard and to the viewing client through OSC 52.
fn pane_clipboard_write(text: &str) -> Result<(), String> {
write_pane_clipboard(text, write_clipboard, emit_to_terminal)
}

fn render_line(frame: &mut Frame<'_>, x: usize, y: usize, text: &str, width: usize, style: Style) {
let (Ok(x), Ok(y), Ok(width)) = (u16::try_from(x), u16::try_from(y), u16::try_from(width))
else {
Expand Down
178 changes: 178 additions & 0 deletions rust/src/pane_clipboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! Clipboard writes performed from inside a Herdr pane.
//!
//! Herdr 0.9.0 forwards OSC 52 sequences emitted by pane output to the viewing client, so a copy
//! made inside a pane can reach the clipboard of the machine the person is sitting at rather than
//! only the machine the plugin runs on. Panes emit the sequence in addition to the native clipboard
//! write; the global actions run with piped stdout and no terminal, so they cannot use this path.

use std::io::Write;

/// Base64 payload size that terminals commonly refuse beyond. Herdr forwards whatever the terminal
/// accepts, so this is advisory only: oversized text is still emitted in full, never truncated.
pub const OSC52_COMMON_PAYLOAD_LIMIT_BYTES: usize = 74_994;

const BASE64_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

fn base64(bytes: &[u8]) -> String {
let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let first = u32::from(chunk.first().copied().unwrap_or(0));
let second = u32::from(chunk.get(1).copied().unwrap_or(0));
let third = u32::from(chunk.get(2).copied().unwrap_or(0));
let value = (first << 16) | (second << 8) | third;
let indexes = [
(value >> 18) & 0x3F,
(value >> 12) & 0x3F,
(value >> 6) & 0x3F,
value & 0x3F,
];
for (offset, index) in indexes.into_iter().enumerate() {
if offset > chunk.len() {
encoded.push('=');
} else if let Some(symbol) = BASE64_ALPHABET.get(index as usize) {
encoded.push(char::from(*symbol));
}
}
}
encoded
}

/// Build the OSC 52 sequence that sets the terminal clipboard selection to `text`.
pub fn osc52_clipboard_sequence(text: &str) -> String {
format!("\x1b]52;c;{}\x07", base64(text.as_bytes()))
}

/// Whether the encoded payload is larger than terminals commonly accept.
pub fn exceeds_common_osc52_limit(text: &str) -> bool {
base64(text.as_bytes()).len() > OSC52_COMMON_PAYLOAD_LIMIT_BYTES
}

/// Write the OSC 52 sequence to the pane's terminal, reporting whether it was written.
///
/// Best effort: a closed or broken stdout must not fail a copy with an I/O error.
pub fn emit_to_terminal(sequence: &str) -> bool {
let mut stdout = std::io::stdout();
stdout
.write_all(sequence.as_bytes())
.and_then(|()| stdout.flush())
.is_ok()
}

/// Perform a pane clipboard write: the native write first, then the OSC 52 sequence.
///
/// Either one landing is a successful copy: a remote server commonly has no clipboard tool at all,
/// and the sequence is the copy that actually reached the person, so it must not be reported as a
/// failure or block the rest of a copy-and-archive. Only a copy that reached neither destination
/// fails, with the native error.
pub fn write_pane_clipboard(
text: &str,
write_clipboard: impl FnOnce(&str) -> Result<(), String>,
emit: impl FnOnce(&str) -> bool,
) -> Result<(), String> {
let native = write_clipboard(text);
let emitted = emit(&osc52_clipboard_sequence(text));
if native.is_ok() || emitted {
return Ok(());
}
native
}

#[cfg(test)]
mod tests {
use std::cell::RefCell;

use super::*;

#[test]
fn plain_text_encodes_to_the_exact_osc52_bytes() {
let sequence = osc52_clipboard_sequence("hi");
assert_eq!(sequence, "\x1b]52;c;aGk=\x07");
assert_eq!(
sequence.as_bytes(),
&[
0x1b, 0x5d, 0x35, 0x32, 0x3b, 0x63, 0x3b, 0x61, 0x47, 0x6b, 0x3d, 0x07
]
);
}

#[test]
fn an_empty_string_encodes_to_an_empty_payload() {
assert_eq!(osc52_clipboard_sequence(""), "\x1b]52;c;\x07");
}

#[test]
fn multi_byte_text_encodes_its_raw_utf8_bytes() {
assert_eq!(
osc52_clipboard_sequence("한글 · é"),
"\x1b]52;c;7ZWc6riAIMK3IMOp\x07"
);
}

#[test]
fn base64_pads_every_chunk_length() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"a"), "YQ==");
assert_eq!(base64(b"ab"), "YWI=");
assert_eq!(base64(b"abc"), "YWJj");
assert_eq!(base64(b"abcd"), "YWJjZA==");
assert_eq!(base64(&[0xFF, 0xFE, 0xFD]), "//79");
}

#[test]
fn oversized_payloads_are_reported_without_truncation() {
let text = "a".repeat(OSC52_COMMON_PAYLOAD_LIMIT_BYTES);
assert!(!exceeds_common_osc52_limit("hi"));
assert!(exceeds_common_osc52_limit(&text));
assert!(osc52_clipboard_sequence(&text).contains(&base64(text.as_bytes())));
}

#[test]
fn a_successful_native_write_emits_the_sequence_afterwards() {
let order = RefCell::new(Vec::new());
let result = write_pane_clipboard(
"hi",
|text| {
order.borrow_mut().push(format!("native:{text}"));
Ok(())
},
|sequence| {
order.borrow_mut().push(format!("emit:{sequence}"));
true
},
);
assert_eq!(result, Ok(()));
assert_eq!(
*order.borrow(),
vec!["native:hi".to_owned(), "emit:\x1b]52;c;aGk=\x07".to_owned()]
);
}

#[test]
fn a_copy_the_terminal_received_succeeds_even_when_the_native_write_failed() {
let emitted = RefCell::new(Vec::new());
let result = write_pane_clipboard(
"hi",
|_| Err("No supported clipboard writer is available".to_owned()),
|sequence| {
emitted.borrow_mut().push(sequence.to_owned());
true
},
);
assert_eq!(result, Ok(()));
assert_eq!(*emitted.borrow(), vec!["\x1b]52;c;aGk=\x07".to_owned()]);
}

#[test]
fn a_copy_that_reached_neither_destination_fails_with_the_native_error() {
let result = write_pane_clipboard(
"hi",
|_| Err("No supported clipboard writer is available".to_owned()),
|_| false,
);
assert_eq!(
result,
Err("No supported clipboard writer is available".to_owned())
);
}
}
Loading
Loading