-
Notifications
You must be signed in to change notification settings - Fork 8.9k
Slash copy osc52 wsl support #13201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Slash copy osc52 wsl support #13201
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
ad6e4c2
osc-52
won-openai 568c787
/dev/tty
won-openai d69e32a
WSL parsing err fix
won-openai ae8b2aa
clipboard logic + fallbacks for remote terminals
won-openai f761e3c
overengineered
won-openai 7f13c2b
simplified logic
won-openai ff1846b
Merge branch 'main' into slash-copy-osc52-wsl-support
won-openai 4288206
Merge branch 'main' into slash-copy-osc52-wsl-support
won-openai cc85712
feedback implemented
won-openai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> { | ||
won-openai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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> { | ||
won-openai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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(()) | ||
won-openai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /// 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}\\" | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.