mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
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:
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user