mirror of
https://github.com/openai/codex.git
synced 2026-05-03 04:42:20 +03:00
This PR replicates the `tui` code directory and creates a temporary parallel `tui_app_server` directory. It also implements a new feature flag `tui_app_server` to select between the two tui implementations. Once the new app-server-based TUI is stabilized, we'll delete the old `tui` directory and feature flag.
216 lines
8.2 KiB
Rust
216 lines
8.2 KiB
Rust
//! 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> {
|
|
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}\\"
|
|
);
|
|
}
|
|
}
|