Support OSC 52 clipboard forwarding through GNU screen (#58716)

## What changed

Detect GNU screen via `STY` when `TMUX` is absent and wrap OSC 52 clipboard sequences in DCS passthrough strings. Split the sequence into 252-byte chunks to stay below screen's older 256-byte string limit while forwarding one continuous OSC 52 sequence to the outer terminal.

Prepare the terminal sequence in the clipboard worker and retain it for deferred delivery.

## Testing

Add coverage for short text and a longer Unicode payload, checking that each passthrough chunk stays below the limit and that reassembling the chunks reproduces the complete OSC 52 sequence.

GitOrigin-RevId: 1a042ed4fed817179e98459f4f4b2b847b160721
This commit is contained in:
Eric Traut
2026-11-01 15:39:52 +00:00
committed by copyberry
parent f7f1f0196c
commit 5e07d1f694
4 changed files with 73 additions and 27 deletions
+34 -14
View File
@@ -3,7 +3,7 @@
//! Local copying uses the native clipboard, or the Windows clipboard directly on WSL.
//! WSL sessions over SSH with a forwarded X11 display use the native clipboard instead.
//! 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.
//! started receive the copy. Over SSH, wrap OSC 52 for GNU screen when detected.
//!
//! Terminal writes have no delivery acknowledgement: a successful send must not
//! suppress native copying. Both the host and attached client's clipboards may
@@ -296,12 +296,11 @@ fn arboard_copy(
}
/// Write text to the clipboard via the OSC 52 terminal escape sequence.
fn osc52_copy(text: &str) -> Result<(), String> {
let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
fn write_osc52(sequence: &str) -> Result<(), String> {
#[cfg(unix)]
{
match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
Ok(tty) => match write_osc52_to_writer(tty, &sequence) {
Ok(tty) => match write_osc52_to_writer(tty, sequence) {
Ok(()) => return Ok(()),
Err(err) => tracing::debug!(
"failed to write OSC 52 to /dev/tty: {err}; falling back to stdout"
@@ -313,7 +312,7 @@ fn osc52_copy(text: &str) -> Result<(), String> {
}
}
write_osc52_to_writer(std::io::stdout().lock(), &sequence)
write_osc52_to_writer(std::io::stdout().lock(), sequence)
}
fn write_osc52_to_writer(mut writer: impl Write, sequence: &str) -> Result<(), String> {
@@ -325,7 +324,13 @@ fn write_osc52_to_writer(mut writer: impl Write, sequence: &str) -> Result<(), S
.map_err(|e| format!("failed to flush OSC 52: {e}"))
}
fn osc52_sequence(text: &str, tmux: bool) -> Result<String, String> {
enum Osc52Target {
Direct,
Tmux,
Screen,
}
fn osc52_sequence(text: &str, target: Osc52Target) -> Result<String, String> {
let raw_bytes = text.len();
if raw_bytes > OSC52_MAX_RAW_BYTES {
return Err(format!(
@@ -334,11 +339,25 @@ fn osc52_sequence(text: &str, tmux: bool) -> Result<String, String> {
}
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
if tmux {
Ok(format!("\x1bPtmux;\x1b\x1b]52;c;{encoded}\x07\x1b\\"))
} else {
Ok(format!("\x1b]52;c;{encoded}\x07"))
}
let osc = format!("\x1b]52;c;{encoded}\x07");
Ok(match target {
Osc52Target::Direct => osc,
Osc52Target::Tmux => format!("\x1bPtmux;\x1b{osc}\x1b\\"),
Osc52Target::Screen => {
// Screen buffers each DCS separately. Stay below even its older 256-byte
// string limit while forwarding one continuous OSC 52 to the outer terminal.
const CHUNK_SIZE: usize = 252;
let mut wrapped = String::new();
for start in (0..osc.len()).step_by(CHUNK_SIZE) {
let end = (start + CHUNK_SIZE).min(osc.len());
// OSC 52 contains only ASCII, so every byte offset is a character boundary.
wrapped.push_str("\x1bP");
wrapped.push_str(&osc[start..end]);
wrapped.push_str("\x1b\\");
}
wrapped
}
})
}
#[cfg(test)]
@@ -350,6 +369,7 @@ mod tests {
use super::CopyFormat;
use super::CopyOutcome;
use super::OSC52_MAX_RAW_BYTES;
use super::Osc52Target;
use super::copy_to_clipboard_with;
use super::osc52_sequence;
use super::write_osc52_to_writer;
@@ -392,7 +412,7 @@ mod tests {
fn osc52_encoding_roundtrips() {
use base64::Engine;
let text = "# Hello\n\n```rust\nfn main() {}\n```\n";
let sequence = osc52_sequence(text, /*tmux*/ false).expect("OSC 52 sequence");
let sequence = osc52_sequence(text, Osc52Target::Direct).expect("OSC 52 sequence");
let encoded = sequence
.trim_start_matches("\u{1b}]52;c;")
.trim_end_matches('\u{7}');
@@ -406,7 +426,7 @@ mod tests {
fn osc52_rejects_payload_larger_than_limit() {
let text = "x".repeat(OSC52_MAX_RAW_BYTES + 1);
assert_eq!(
osc52_sequence(&text, /*tmux*/ false),
osc52_sequence(&text, Osc52Target::Direct),
Err(format!(
"OSC 52 payload too large ({} bytes; max {OSC52_MAX_RAW_BYTES})",
OSC52_MAX_RAW_BYTES + 1
@@ -417,7 +437,7 @@ mod tests {
#[test]
fn osc52_wraps_tmux_passthrough() {
assert_eq!(
osc52_sequence("hello", /*tmux*/ true),
osc52_sequence("hello", Osc52Target::Tmux),
Ok("\u{1b}Ptmux;\u{1b}\u{1b}]52;c;aGVsbG8=\u{7}\u{1b}\\".to_string())
);
}
@@ -172,3 +172,24 @@ fn confirmed_copy_without_new_lease_preserves_native_ownership() {
);
assert!(lease.is_some());
}
#[test]
fn screen_forwards_one_complete_osc52_across_bounded_passthrough_strings() {
for text in ["hello", &"世界".repeat(500)] {
let sequence = super::osc52_sequence(text, super::Osc52Target::Screen).unwrap();
let chunks = sequence
.strip_prefix("\x1bP")
.unwrap()
.strip_suffix("\x1b\\")
.unwrap();
let mut forwarded = String::new();
for chunk in chunks.split("\x1b\\\x1bP") {
assert!(chunk.len() < 256);
forwarded.push_str(chunk);
}
assert_eq!(
forwarded,
super::osc52_sequence(text, super::Osc52Target::Direct).unwrap()
);
}
}
+15 -10
View File
@@ -122,7 +122,7 @@ struct PendingRead {
struct Response {
result: CopyResult,
terminal_text: Option<String>,
terminal_sequence: Option<String>,
}
#[derive(Default)]
@@ -237,19 +237,24 @@ impl ClipboardWorker {
self.start(
frames,
|text, format, setup| {
let terminal_text = Cell::new(/*value*/ None);
let terminal_sequence = Cell::new(/*value*/ None);
let result = super::copy_to_clipboard(
text,
format,
|| setup.begin_delivery(),
|text| {
// Validate the limit before accepting a deferred terminal send.
super::osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
terminal_text.set(Some(text.to_owned()));
let target = if std::env::var_os("TMUX").is_some() {
super::Osc52Target::Tmux
} else if std::env::var_os("STY").is_some() {
super::Osc52Target::Screen
} else {
super::Osc52Target::Direct
};
terminal_sequence.set(Some(super::osc52_sequence(text, target)?));
Ok(())
},
);
(result, terminal_text.into_inner())
(result, terminal_sequence.into_inner())
},
crate::clipboard_paste::text::reader,
)?;
@@ -377,7 +382,7 @@ impl ClipboardWorker {
// An ordinary copy must not relinquish PRIMARY, or vice versa.
let mut primary_lease: Option<ClipboardLease> = None;
while let Ok(request) = incoming.recv() {
let (outcome, terminal_text) = match request {
let (outcome, terminal_sequence) = match request {
Request::WarmTextReader => {
LazyCell::force(&read);
continue;
@@ -425,7 +430,7 @@ impl ClipboardWorker {
if outgoing
.send(Response {
result,
terminal_text,
terminal_sequence,
})
.is_err()
{
@@ -501,8 +506,8 @@ impl ClipboardWorker {
return self.completed.as_ref();
}
let mut result = response.result;
if let Some(text) = response.terminal_text
&& let Err(error) = super::osc52_copy(&text)
if let Some(sequence) = response.terminal_sequence
&& let Err(error) = super::write_osc52(&sequence)
&& result != Ok(CopyStatus::Confirmed)
{
result = Err(error);
@@ -254,7 +254,7 @@ fn drop_waits_for_response_channel_disconnect() {
outgoing
.send(Response {
result: Ok(CopyStatus::Confirmed),
terminal_text: None,
terminal_sequence: None,
})
.unwrap();
}
@@ -522,7 +522,7 @@ fn latest_primary_is_published_before_read_but_does_not_preempt_explicit_copy()
outgoing
.send(Response {
result: Ok(CopyStatus::Confirmed),
terminal_text: None,
terminal_sequence: None,
})
.unwrap();
};
@@ -625,7 +625,7 @@ fn cancelling_owners_after_poll_drops_deferred_work_without_releasing_worker_ear
outgoing
.send(Response {
result: Ok(CopyStatus::Confirmed),
terminal_text: None,
terminal_sequence: None,
})
.unwrap();
worker.poll();