Forward clipboard copies to the terminal in Herdr sessions (#52557)

## Why

Herdr sessions can gain attached clients after Codex starts. Successful native clipboard copying should still forward the text to the attached terminal, even without inherited SSH variables.

## What changed

Detect Herdr sessions with `HERDR_ENV=1` and enable terminal forwarding for `/copy` and `Ctrl+O` even when native copying succeeds. Use OSC 52 directly when outside tmux, while preserving the native copy result.

## Testing

Add a routing test verifying that a Herdr session copies natively before sending OSC 52 and retains the successful native clipboard lease.

GitOrigin-RevId: c43c08ca037ff51e902267dc8636889493f5c30c
This commit is contained in:
Felipe Coury
2026-10-09 12:24:07 +00:00
committed by copyberry
parent a06545b311
commit 5fe4fc8f7c
2 changed files with 45 additions and 7 deletions
+13 -7
View File
@@ -1,12 +1,12 @@
//! Clipboard copy backend for the TUI's `/copy` command and `Ctrl+O` hotkey.
//!
//! Local copying uses the native clipboard, with WSL PowerShell as a fallback.
//! In tmux, also forward to the attached terminal so clients attached after Codex
//! started receive the copy. Over SSH without tmux, send OSC 52 directly.
//! In tmux and Herdr, also forward to the attached terminal so clients attached after Codex
//! started receive the copy. Over SSH without a multiplexer, send OSC 52 directly.
//!
//! Terminal writes have no delivery acknowledgement: a successful send must not
//! suppress native copying. Both the host and attached client's clipboards may
//! change. Outside tmux and SSH, use OSC 52 only if native copying fails.
//! change. Outside tmux, Herdr, and SSH, use OSC 52 only if native copying fails.
//!
//! On Linux, X11 and some Wayland compositors require the process that wrote the
//! clipboard to keep its handle open. `ClipboardLease` wraps the `arboard::Clipboard`
@@ -83,8 +83,8 @@ impl CopyStatus {
/// Copy text to the system clipboard.
///
/// Try native copying, then independently attempt terminal forwarding in tmux or
/// SSH. A terminal send is best effort and does not confirm clipboard delivery.
/// Try native copying, then independently attempt terminal forwarding in tmux,
/// Herdr, or SSH. A terminal send is best effort and does not confirm clipboard delivery.
/// Terminal forwarding may replace native HTML with plain text.
///
/// Native or WSL success returns `Copied`, even if terminal forwarding fails.
@@ -114,6 +114,7 @@ fn copy_to_clipboard(
text,
format,
CopyEnvironment {
herdr_session: std::env::var("HERDR_ENV").is_ok_and(|value| value == "1"),
ssh_session: is_ssh_session(),
wsl_session: is_wsl_session(),
tmux_session: is_tmux_session(),
@@ -159,6 +160,7 @@ impl ClipboardLease {
/// without touching real clipboards or terminal I/O.
#[derive(Clone, Copy)]
struct CopyEnvironment {
herdr_session: bool,
ssh_session: bool,
wsl_session: bool,
tmux_session: bool,
@@ -214,9 +216,11 @@ fn copy_to_clipboard_with(
});
// Copy natively first: an X11 SelectionClear from a terminal write can otherwise
// race with arboard reusing its ownership window and clear the new native data.
// Persistent tmux sessions may gain remote clients after Codex starts, so still
// Persistent multiplexer sessions may gain remote clients after Codex starts, so still
// forward even when no SSH variables were inherited or native copying succeeded.
let terminal_result = (environment.tmux_session || environment.ssh_session).then(terminal_copy);
let terminal_result =
(environment.tmux_session || environment.herdr_session || environment.ssh_session)
.then(terminal_copy);
match native_result {
Ok(lease) => Ok(CopyOutcome::Copied(lease)),
Err(native_error) => terminal_result
@@ -391,6 +395,7 @@ mod tests {
fn remote_environment() -> CopyEnvironment {
CopyEnvironment {
herdr_session: false,
ssh_session: true,
wsl_session: true,
tmux_session: false,
@@ -406,6 +411,7 @@ mod tests {
fn local_environment() -> CopyEnvironment {
CopyEnvironment {
herdr_session: false,
ssh_session: false,
wsl_session: false,
tmux_session: false,
@@ -17,6 +17,7 @@ fn local_tmux_preserves_native_html_when_terminal_silently_rejects_copy() {
"**hello**",
CopyFormat::Markdown,
CopyEnvironment {
herdr_session: false,
ssh_session: false,
tmux_session: true,
wsl_session: false,
@@ -48,6 +49,7 @@ fn copy_uses_osc52_when_tmux_fails_even_if_native_copy_succeeds() {
"hello",
CopyFormat::PlainText,
CopyEnvironment {
herdr_session: false,
ssh_session: false,
tmux_session: true,
wsl_session: false,
@@ -73,12 +75,42 @@ fn copy_uses_osc52_when_tmux_fails_even_if_native_copy_succeeds() {
fn local_tmux_environment() -> CopyEnvironment {
CopyEnvironment {
herdr_session: false,
tmux_session: true,
ssh_session: false,
wsl_session: false,
}
}
#[test]
fn herdr_forwards_to_osc52_even_when_native_copy_succeeds() {
let calls = RefCell::new(Vec::new());
let result = copy_to_clipboard_with(
"hello",
CopyFormat::PlainText,
CopyEnvironment {
herdr_session: true,
ssh_session: false,
tmux_session: false,
wsl_session: false,
},
|_| panic!("Herdr should use OSC 52 directly"),
|_| {
calls.borrow_mut().push("osc52");
Ok(())
},
|text, html| {
assert_eq!((text, html), ("hello", None));
calls.borrow_mut().push("native");
Ok(Some(ClipboardLease::test()))
},
|_| panic!("native copy succeeded"),
);
assert!(matches!(result, Ok(CopyOutcome::Copied(Some(_)))));
assert_eq!(calls.into_inner(), vec!["native", "osc52"]);
}
#[test]
fn oversized_tmux_copy_preserves_native_copy() {
for (size, expected_calls) in [