diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index fee4d591a0..03ce890474 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -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 { +enum Osc52Target { + Direct, + Tmux, + Screen, +} + +fn osc52_sequence(text: &str, target: Osc52Target) -> Result { 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 { } 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()) ); } diff --git a/codex-rs/tui/src/clipboard_copy/routing_tests.rs b/codex-rs/tui/src/clipboard_copy/routing_tests.rs index 39c8b20a32..7406637767 100644 --- a/codex-rs/tui/src/clipboard_copy/routing_tests.rs +++ b/codex-rs/tui/src/clipboard_copy/routing_tests.rs @@ -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() + ); + } +} diff --git a/codex-rs/tui/src/clipboard_copy/worker.rs b/codex-rs/tui/src/clipboard_copy/worker.rs index 5cdeed4a20..28254a7af9 100644 --- a/codex-rs/tui/src/clipboard_copy/worker.rs +++ b/codex-rs/tui/src/clipboard_copy/worker.rs @@ -122,7 +122,7 @@ struct PendingRead { struct Response { result: CopyResult, - terminal_text: Option, + terminal_sequence: Option, } #[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 = 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); diff --git a/codex-rs/tui/src/clipboard_copy/worker_tests.rs b/codex-rs/tui/src/clipboard_copy/worker_tests.rs index 2088cfed4d..7834ac6a33 100644 --- a/codex-rs/tui/src/clipboard_copy/worker_tests.rs +++ b/codex-rs/tui/src/clipboard_copy/worker_tests.rs @@ -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();