diff --git a/codex-rs/tui/src/clipboard_text.rs b/codex-rs/tui/src/clipboard_text.rs index ec8a7b112db..019cbdba13c 100644 --- a/codex-rs/tui/src/clipboard_text.rs +++ b/codex-rs/tui/src/clipboard_text.rs @@ -1,11 +1,215 @@ +//! Clipboard text copy support for `/copy` in the TUI. +//! +//! This module owns the policy for getting plain text from the running Codex +//! process into the user's system clipboard. It prefers the direct native +//! clipboard path when the current machine is also the user's desktop, but it +//! intentionally changes strategy in environments where a "local" clipboard +//! would be the wrong one: SSH sessions use OSC 52 so the user's terminal can +//! proxy the copy back to the client, and WSL shells fall back to +//! `powershell.exe` because Linux-side clipboard providers often cannot reach +//! the Windows clipboard reliably. +//! +//! The module is deliberately narrow. It only handles text copy, returns +//! user-facing error strings for the chat UI, and does not try to expose a +//! reusable clipboard abstraction for the rest of the application. Image paste +//! and WSL environment detection live in neighboring modules. +//! +//! The main operational contract is that callers get one best-effort copy +//! attempt and a readable failure message. The selection between native copy, +//! OSC 52, and WSL fallback is centralized here so `/copy` does not have to +//! understand platform-specific clipboard behavior. + +#[cfg(not(target_os = "android"))] +use base64::Engine as _; +#[cfg(all(not(target_os = "android"), unix))] +use std::fs::OpenOptions; +#[cfg(not(target_os = "android"))] +use std::io::Write; +#[cfg(all(not(target_os = "android"), windows))] +use std::io::stdout; +#[cfg(all(not(target_os = "android"), target_os = "linux"))] +use std::process::Stdio; + +#[cfg(all(not(target_os = "android"), target_os = "linux"))] +use crate::clipboard_paste::is_probably_wsl; + +/// Copies user-visible text into the most appropriate clipboard for the +/// current environment. +/// +/// In a normal desktop session this targets the host clipboard through +/// `arboard`. In SSH sessions it emits an OSC 52 sequence instead, because the +/// process-local clipboard would belong to the remote machine rather than the +/// user's terminal. On Linux under WSL, a failed native copy falls back to +/// `powershell.exe` so the Windows clipboard still works when Linux clipboard +/// integrations are unavailable. +/// +/// The returned error is intended for display in the TUI rather than for +/// programmatic branching. Callers should treat it as user-facing text. A +/// caller that assumes a specific substring means a stable failure category +/// will be brittle if the fallback policy or wording changes later. +/// +/// # Errors +/// +/// Returns a descriptive error string when the selected clipboard mechanism is +/// unavailable or the fallback path also fails. #[cfg(not(target_os = "android"))] pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> { - let mut cb = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?; - cb.set_text(text.to_string()) - .map_err(|e| format!("clipboard unavailable: {e}")) + if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() { + return copy_via_osc52(text); + } + + let error = match arboard::Clipboard::new() { + Ok(mut clipboard) => match clipboard.set_text(text.to_string()) { + Ok(()) => return Ok(()), + Err(err) => format!("clipboard unavailable: {err}"), + }, + Err(err) => format!("clipboard unavailable: {err}"), + }; + + #[cfg(target_os = "linux")] + let error = if is_probably_wsl() { + match copy_via_wsl_clipboard(text) { + Ok(()) => return Ok(()), + Err(wsl_err) => format!("{error}; WSL fallback failed: {wsl_err}"), + } + } else { + error + }; + + Err(error) +} + +/// Writes text through OSC 52 so the controlling terminal can own the copy. +/// +/// This path exists for remote sessions where the process-local clipboard is +/// not the clipboard the user actually wants. On Unix it writes directly to the +/// controlling TTY so the escape sequence reaches the terminal even if stdout +/// is redirected; on Windows it writes to stdout because the console is the +/// transport. +#[cfg(not(target_os = "android"))] +fn copy_via_osc52(text: &str) -> Result<(), String> { + let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some()); + #[cfg(unix)] + let mut tty = OpenOptions::new() + .write(true) + .open("/dev/tty") + .map_err(|e| { + format!("clipboard unavailable: failed to open /dev/tty for OSC 52 copy: {e}") + })?; + #[cfg(unix)] + tty.write_all(sequence.as_bytes()).map_err(|e| { + format!("clipboard unavailable: failed to write OSC 52 escape sequence: {e}") + })?; + #[cfg(unix)] + tty.flush().map_err(|e| { + format!("clipboard unavailable: failed to flush OSC 52 escape sequence: {e}") + })?; + #[cfg(windows)] + stdout().write_all(sequence.as_bytes()).map_err(|e| { + format!("clipboard unavailable: failed to write OSC 52 escape sequence: {e}") + })?; + #[cfg(windows)] + stdout().flush().map_err(|e| { + format!("clipboard unavailable: failed to flush OSC 52 escape sequence: {e}") + })?; + Ok(()) } +/// Copies text into the Windows clipboard from a WSL process. +/// +/// This is a Linux-only fallback for the case where `arboard` cannot talk to +/// the Windows clipboard from inside WSL. It shells out to `powershell.exe`, +/// streams the text over stdin as UTF-8, and waits for the process to report +/// success before returning to the caller. +#[cfg(all(not(target_os = "android"), target_os = "linux"))] +fn copy_via_wsl_clipboard(text: &str) -> Result<(), String> { + let mut child = std::process::Command::new("powershell.exe") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .args([ + "-NoProfile", + "-Command", + "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; $ErrorActionPreference = 'Stop'; $text = [Console]::In.ReadToEnd(); Set-Clipboard -Value $text", + ]) + .spawn() + .map_err(|e| format!("clipboard unavailable: failed to spawn powershell.exe: {e}"))?; + + let Some(mut stdin) = child.stdin.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("clipboard unavailable: failed to open powershell.exe stdin".to_string()); + }; + + if let Err(err) = stdin.write_all(text.as_bytes()) { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "clipboard unavailable: failed to write to powershell.exe: {err}" + )); + } + + drop(stdin); + + let output = child + .wait_with_output() + .map_err(|e| format!("clipboard unavailable: failed to wait for powershell.exe: {e}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + let status = output.status; + Err(format!( + "clipboard unavailable: powershell.exe exited with status {status}" + )) + } else { + Err(format!( + "clipboard unavailable: powershell.exe failed: {stderr}" + )) + } + } +} + +/// Encodes text as an OSC 52 clipboard sequence. +/// +/// When `tmux` is true the sequence is wrapped in the tmux passthrough form so +/// nested terminals still receive the clipboard escape. +#[cfg(not(target_os = "android"))] +fn osc52_sequence(text: &str, tmux: bool) -> String { + let payload = base64::engine::general_purpose::STANDARD.encode(text); + if tmux { + format!("\x1bPtmux;\x1b\x1b]52;c;{payload}\x07\x1b\\") + } else { + format!("\x1b]52;c;{payload}\x07") + } +} + +/// Reports that clipboard text copy is unavailable on Android builds. +/// +/// The TUI's clipboard implementation depends on host integrations that are not +/// available in the supported Android/Termux environment. #[cfg(target_os = "android")] pub fn copy_text_to_clipboard(_text: &str) -> Result<(), String> { Err("clipboard text copy is unsupported on Android".into()) } + +#[cfg(all(test, not(target_os = "android")))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn osc52_sequence_encodes_text_for_terminal_clipboard() { + assert_eq!(osc52_sequence("hello", false), "\u{1b}]52;c;aGVsbG8=\u{7}"); + } + + #[test] + fn osc52_sequence_wraps_tmux_passthrough() { + assert_eq!( + osc52_sequence("hello", true), + "\u{1b}Ptmux;\u{1b}\u{1b}]52;c;aGVsbG8=\u{7}\u{1b}\\" + ); + } +}