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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
45 changes: 44 additions & 1 deletion crates/plannotator-tui/src/delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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());
Expand Down
Loading