Files
codex/codex-rs/tui/src/clipboard_copy.rs
Felipe Coury ef330eff6d feat(tui): Ctrl+O copy hotkey and harden copy-as-markdown behavior (#16966)
## TL;DR

- New `Ctrl+O` shortcut on top of the existing `/copy` command, allowing
users to copy the latest agent response without having to cancel a plan
or type `/copy`
- Copy server clipboard to the client over SSH (OSC 52)
- Fixes linux copy behavior: a clipboard handle has to be kept alive
while the paste happens for the contents to be preserved
- Uses arboard as primary mechanism on Windows, falling back to
PowerShell copy clipboard function
- Works with resumes, rolling back during a session, etc.

Tested on macOS, Linux/X11, Windows WSL2, Windows cmd.exe, Windows
PowerShell, Windows VSCode PowerShell, Windows VSCode WSL2, SSH (macOS
-> macOS).

## Problem

The TUI's `/copy` command was fragile. It relied on a single
`last_copyable_output` field that was bluntly cleared on every rollback
and thread reconfiguration, making copied content unavailable after
common operations like backtracking. It also had no keyboard shortcut,
requiring users to type `/copy` each time. The previous clipboard
backend mixed platform selection policy with low-level I/O in a way that
was hard to test, and it did not keep the Linux clipboard owner alive —
meaning pasted content could vanish once the process that wrote it
dropped its `arboard::Clipboard`.

This addresses the text-copy failure modes reported in #12836, #15452,
and #15663: native Linux clipboard access failing in remote or
unreachable-display environments, copy state going blank even after
visible assistant output, and local Linux X11 reporting success while
leaving the clipboard empty.

## Shortcut rationale

The copy hotkey is `Ctrl+O` rather than `Alt+C` because Alt/Option
combinations are not delivered consistently by macOS terminal emulators.
Terminal.app and iTerm2 can treat Option as text input or as a
configurable Meta/Esc prefix, and Option+C may be consumed or
transformed before the TUI sees an `Alt+C` key event. `Ctrl+O` is a
stable control-key chord in Terminal.app, iTerm2, SSH, and the existing
cross-platform terminal stack.

## Mental model

Agent responses are now tracked as a bounded, ordinal-indexed history
(`agent_turn_markdowns: Vec<AgentTurnMarkdown>`) rather than a single
nullable string. Each completed agent turn appends an entry keyed by its
ordinal (the number of user turns seen so far). Rollbacks pop entries
whose ordinal exceeds the remaining turn count, then use the visible
transcript cells as a best-effort fallback if the ordinal history no
longer has a surviving entry. This means `/copy` and `Ctrl+O` reflect
the most recent surviving agent response after a backtrack, instead of
going blank.

The clipboard backend was rewritten as `clipboard_copy.rs` with a
strategy-injection design: `copy_to_clipboard_with` accepts closures for
the OSC 52, arboard, and WSL PowerShell paths, making the selection
logic fully unit-testable without touching real clipboards. On Linux,
the `Clipboard` handle is returned as a `ClipboardLease` stored on
`ChatWidget`, keeping X11/Wayland clipboard ownership alive for the
lifetime of the TUI. When native copy fails under WSL, the backend now
tries the Windows clipboard through PowerShell before falling back to
OSC 52.

## Non-goals

- This change does not introduce rich-text (HTML) clipboard support; the
copied content is raw markdown.
- It does not add a paste-from-history picker or multi-entry clipboard
ring.
- WSL support remains a best-effort fallback, not a new configuration
surface or guarantee for every terminal/host combination.

## Tradeoffs

- **Bounded history (256 entries)**: `MAX_AGENT_COPY_HISTORY` caps
memory. For sessions with thousands of turns this silently drops the
oldest entries. The cap is generous enough for realistic sessions.
- **`saw_copy_source_this_turn` flag**: Prevents double-recording when
both `AgentMessage` and `TurnComplete.last_agent_message` fire for the
same turn. The flag is reset on turn start and on turn complete,
creating a narrow window where a race between the two events could
theoretically skip recording. In practice the protocol delivers them
sequentially.
- **Transcript fallback on rollback**:
`last_agent_markdown_from_transcript` walks the visible transcript cells
to reconstruct plain text when the ordinal history has been fully
truncated. This path uses `AgentMessageCell::plain_text()` which joins
rendered spans, so it reconstructs display text rather than the original
raw markdown. It keeps visible text copyable after rollback, but
responses with markdown-specific syntax can diverge from the original
source.
- **Clipboard fallback ordering**: SSH still uses OSC 52 exclusively
because native/PowerShell clipboard access would target the wrong
machine. Local sessions try native clipboard first, then WSL PowerShell
when running under WSL, then OSC 52. This adds one process-spawn
fallback for WSL users but keeps the normal desktop and SSH paths
simple.

## Architecture

```
chatwidget.rs
├── agent_turn_markdowns: Vec<AgentTurnMarkdown>  // ordinal-indexed history
├── last_agent_markdown: Option<String>            // always == last entry's markdown
├── completed_turn_count: usize                    // incremented when user turns enter history
├── saw_copy_source_this_turn: bool                // dedup guard
├── clipboard_lease: Option<ClipboardLease>        // keeps Linux clipboard owner alive
│
├── record_agent_markdown(&str)                    // append/update history entry
├── truncate_agent_turn_markdowns_to_turn_count()  // rollback support
├── copy_last_agent_markdown()                     // public entry point (slash + hotkey)
└── copy_last_agent_markdown_with(fn)              // testable core

clipboard_copy.rs
├── copy_to_clipboard(text) -> Result<Option<ClipboardLease>>
├── copy_to_clipboard_with(text, ssh, wsl, osc52_fn, arboard_fn, wsl_fn)
├── ClipboardLease { _clipboard on linux }
├── arboard_copy(text)          // platform-conditional native clipboard path
├── wsl_clipboard_copy(text)    // WSL PowerShell fallback
├── osc52_copy(text)            // /dev/tty -> stdout fallback
├── SuppressStderr              // macOS stderr redirect guard
├── is_ssh_session()
└── is_wsl_session()

app_backtrack.rs
├── last_agent_markdown_from_transcript()  // reconstruct from visible cells
└── truncate call sites in trim/apply_confirmed_rollback
```

## Observability

- `tracing::warn!` on native clipboard failure before OSC 52 fallback.
- `tracing::debug!` on `/dev/tty` open/write failure before stdout
fallback.
- History cell messages: "Copied last message to clipboard", "Copy
failed: {error}", "No agent response to copy" appear in the TUI
transcript.

## Tests

- `clipboard_copy.rs`: Unit tests cover OSC 52 encoding roundtrip,
payload size rejection, writer output, SSH-only OSC52 routing, non-WSL
native-to-OSC52 fallback, WSL native-to-PowerShell fallback, WSL
PowerShell-to-OSC52 fallback, and all-error reporting via strategy
injection.
- `chatwidget/tests/slash_commands.rs`: Updated existing `/copy` tests
to use `last_agent_markdown_text()` accessor. Added coverage for the
Linux clipboard lease lifecycle, missing
`TurnComplete.last_agent_message` fallback through completed assistant
items, replayed legacy agent messages, stale-output prevention after
rollback, and the `Ctrl+O` no-output hotkey path.
- `app_backtrack.rs`: Added
`agent_group_count_ignores_context_compacted_marker` verifying that
info-event cells don't inflate the agent group count.

---------

Co-authored-by: Felipe Coury <felipe.coury@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:10:38 -03:00

639 lines
21 KiB
Rust

//! Clipboard copy backend for the TUI's `/copy` command and `Ctrl+O` hotkey.
//!
//! This module decides *how* to get text onto the user's clipboard based on the
//! current environment. The selection order is:
//!
//! 1. **SSH session** (`SSH_TTY` / `SSH_CONNECTION` set): use OSC 52 exclusively,
//! because the native clipboard belongs to the remote machine.
//! 2. **Local session**: try `arboard` (native clipboard) first. On WSL, fall back
//! to the Windows clipboard through PowerShell if `arboard` fails. Finally, fall
//! back to OSC 52 if no native/WSL clipboard path succeeds.
//!
//! On Linux, X11 and some Wayland compositors require the process that wrote the
//! clipboard to keep its handle open. `ClipboardLease` wraps the `arboard::Clipboard`
//! so callers can store it for the lifetime of the TUI. On other platforms the lease
//! is always `None`.
//!
//! The module is intentionally narrow: text copy only, user-facing error strings,
//! no reusable clipboard abstraction. Image paste lives in `clipboard_paste`.
use base64::Engine;
use std::io::Write;
/// Maximum raw bytes we will base64-encode into an OSC 52 sequence.
/// Large payloads are rejected before encoding to avoid overwhelming the terminal.
const OSC52_MAX_RAW_BYTES: usize = 100_000;
#[cfg(target_os = "macos")]
static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock<std::sync::Mutex<()>> =
std::sync::OnceLock::new();
/// Copy text to the system clipboard.
///
/// Over SSH, uses OSC 52 so the text reaches the *local* terminal emulator's
/// clipboard rather than a remote X11/Wayland clipboard that the user cannot
/// access. On a local session, tries `arboard` (native clipboard) first and
/// falls back to WSL PowerShell, then OSC 52, if needed.
///
/// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others.
pub(crate) fn copy_to_clipboard(text: &str) -> Result<Option<ClipboardLease>, String> {
copy_to_clipboard_with(
text,
is_ssh_session(),
is_wsl_session(),
osc52_copy,
arboard_copy,
wsl_clipboard_copy,
)
}
/// Keeps a platform clipboard owner alive when the backend requires one.
///
/// On Linux/X11 and some Wayland compositors, clipboard contents are served by the
/// owning process. Dropping the `arboard::Clipboard` before the user pastes causes
/// the content to vanish. Store this lease on the widget that triggered the copy so
/// the handle lives as long as the TUI does. On non-Linux native paths and OSC 52
/// paths the lease is `None` — those backends do not require process-lifetime
/// ownership.
pub(crate) struct ClipboardLease {
#[cfg(target_os = "linux")]
_clipboard: Option<arboard::Clipboard>,
}
impl ClipboardLease {
#[cfg(target_os = "linux")]
fn native_linux(clipboard: arboard::Clipboard) -> Self {
Self {
_clipboard: Some(clipboard),
}
}
#[cfg(test)]
pub(crate) fn test() -> Self {
Self {
#[cfg(target_os = "linux")]
_clipboard: None,
}
}
}
/// Core copy logic with injected backends, enabling deterministic unit tests
/// without touching real clipboards or terminal I/O.
fn copy_to_clipboard_with(
text: &str,
ssh_session: bool,
wsl_session: bool,
osc52_copy_fn: impl Fn(&str) -> Result<(), String>,
arboard_copy_fn: impl Fn(&str) -> Result<Option<ClipboardLease>, String>,
wsl_copy_fn: impl Fn(&str) -> Result<(), String>,
) -> Result<Option<ClipboardLease>, String> {
if ssh_session {
// Over SSH the native clipboard writes to the remote machine which is
// useless. Use OSC 52, which travels through the SSH tunnel to the
// local terminal emulator.
return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| {
tracing::warn!("OSC 52 clipboard copy failed over SSH: {osc_err}");
format!("OSC 52 clipboard copy failed over SSH: {osc_err}")
});
}
match arboard_copy_fn(text) {
Ok(lease) => Ok(lease),
Err(native_err) => {
if wsl_session {
tracing::warn!(
"native clipboard copy failed: {native_err}, falling back to WSL PowerShell"
);
match wsl_copy_fn(text) {
Ok(()) => return Ok(None),
Err(wsl_err) => {
tracing::warn!(
"WSL PowerShell clipboard copy failed: {wsl_err}, falling back to OSC 52"
);
return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| {
format!(
"native clipboard: {native_err}; WSL fallback: {wsl_err}; OSC 52 fallback: {osc_err}"
)
});
}
}
}
tracing::warn!("native clipboard copy failed: {native_err}, falling back to OSC 52");
osc52_copy_fn(text).map(|()| None).map_err(|osc_err| {
format!("native clipboard: {native_err}; OSC 52 fallback: {osc_err}")
})
}
}
}
/// Detect whether the current process is running inside an SSH session.
fn is_ssh_session() -> bool {
std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some()
}
#[cfg(target_os = "linux")]
fn is_wsl_session() -> bool {
crate::clipboard_paste::is_probably_wsl()
}
#[cfg(not(target_os = "linux"))]
fn is_wsl_session() -> bool {
false
}
/// Run arboard with stderr suppressed.
///
/// On macOS, `arboard::Clipboard::new()` initializes `NSPasteboard` which
/// triggers `os_log` / `NSLog` output on stderr. Because the TUI owns the
/// terminal, that stray output corrupts the display. We temporarily redirect
/// fd 2 to `/dev/null` around the call to keep the screen clean.
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
fn arboard_copy(text: &str) -> Result<Option<ClipboardLease>, String> {
#[cfg(target_os = "macos")]
let _stderr_lock = STDERR_SUPPRESSION_MUTEX
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.map_err(|_| "stderr suppression lock poisoned".to_string())?;
let _guard = SuppressStderr::new();
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?;
clipboard
.set_text(text)
.map_err(|e| format!("failed to set clipboard text: {e}"))?;
Ok(None)
}
/// Run arboard with stderr suppressed.
///
/// On Linux/X11 and some Wayland setups, clipboard contents are served by the
/// process that last wrote them. Keep the `Clipboard` alive so the copied text
/// remains pasteable while the TUI is running.
#[cfg(target_os = "linux")]
fn arboard_copy(text: &str) -> Result<Option<ClipboardLease>, String> {
let _guard = SuppressStderr::new();
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?;
clipboard
.set_text(text)
.map_err(|e| format!("failed to set clipboard text: {e}"))?;
Ok(Some(ClipboardLease::native_linux(clipboard)))
}
#[cfg(target_os = "android")]
fn arboard_copy(_text: &str) -> Result<Option<ClipboardLease>, String> {
Err("native clipboard unavailable on Android".to_string())
}
/// Copy text into the Windows clipboard from a WSL process.
#[cfg(target_os = "linux")]
fn wsl_clipboard_copy(text: &str) -> Result<(), String> {
let mut child = std::process::Command::new("powershell.exe")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::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!("failed to spawn powershell.exe: {e}"))?;
let Some(mut stdin) = child.stdin.take() else {
let _ = child.kill();
let _ = child.wait();
return Err("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!("failed to write to powershell.exe: {err}"));
}
drop(stdin);
let output = child
.wait_with_output()
.map_err(|e| format!("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!("powershell.exe exited with status {status}"))
} else {
Err(format!("powershell.exe failed: {stderr}"))
}
}
}
#[cfg(not(target_os = "linux"))]
fn wsl_clipboard_copy(_text: &str) -> Result<(), String> {
Err("WSL clipboard fallback unavailable on this platform".to_string())
}
/// RAII guard that redirects stderr (fd 2) to `/dev/null` on creation and
/// restores the original fd on drop.
#[cfg(target_os = "macos")]
struct SuppressStderr {
saved_fd: Option<libc::c_int>,
}
#[cfg(target_os = "macos")]
impl SuppressStderr {
fn new() -> Self {
unsafe {
// Save the current stderr fd.
let saved = libc::dup(2);
if saved < 0 {
return Self { saved_fd: None };
}
// Open /dev/null and point fd 2 at it.
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
if devnull < 0 {
libc::close(saved);
return Self { saved_fd: None };
}
if libc::dup2(devnull, 2) < 0 {
libc::close(saved);
libc::close(devnull);
return Self { saved_fd: None };
}
libc::close(devnull);
Self {
saved_fd: Some(saved),
}
}
}
}
#[cfg(target_os = "macos")]
impl Drop for SuppressStderr {
fn drop(&mut self) {
if let Some(saved) = self.saved_fd {
unsafe {
libc::dup2(saved, 2);
libc::close(saved);
}
}
}
}
#[cfg(not(target_os = "macos"))]
struct SuppressStderr;
#[cfg(not(target_os = "macos"))]
impl SuppressStderr {
fn new() -> Self {
Self
}
}
/// 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())?;
#[cfg(unix)]
{
match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
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"
),
},
Err(err) => {
tracing::debug!("failed to open /dev/tty for OSC 52: {err}; falling back to stdout")
}
}
}
write_osc52_to_writer(std::io::stdout().lock(), &sequence)
}
fn write_osc52_to_writer(mut writer: impl Write, sequence: &str) -> Result<(), String> {
writer
.write_all(sequence.as_bytes())
.map_err(|e| format!("failed to write OSC 52: {e}"))?;
writer
.flush()
.map_err(|e| format!("failed to flush OSC 52: {e}"))
}
fn osc52_sequence(text: &str, tmux: bool) -> Result<String, String> {
let raw_bytes = text.len();
if raw_bytes > OSC52_MAX_RAW_BYTES {
return Err(format!(
"OSC 52 payload too large ({raw_bytes} bytes; max {OSC52_MAX_RAW_BYTES})"
));
}
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"))
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use std::cell::Cell;
use super::OSC52_MAX_RAW_BYTES;
use super::copy_to_clipboard_with;
use super::osc52_sequence;
use super::write_osc52_to_writer;
#[test]
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 encoded = sequence
.trim_start_matches("\u{1b}]52;c;")
.trim_end_matches('\u{7}');
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
assert_eq!(decoded, text.as_bytes());
}
#[test]
fn osc52_rejects_payload_larger_than_limit() {
let text = "x".repeat(OSC52_MAX_RAW_BYTES + 1);
assert_eq!(
osc52_sequence(&text, /*tmux*/ false),
Err(format!(
"OSC 52 payload too large ({} bytes; max {OSC52_MAX_RAW_BYTES})",
OSC52_MAX_RAW_BYTES + 1
))
);
}
#[test]
fn osc52_wraps_tmux_passthrough() {
assert_eq!(
osc52_sequence("hello", /*tmux*/ true),
Ok("\u{1b}Ptmux;\u{1b}\u{1b}]52;c;aGVsbG8=\u{7}\u{1b}\\".to_string())
);
}
#[test]
fn write_osc52_to_writer_emits_sequence_verbatim() {
let sequence = "\u{1b}]52;c;aGVsbG8=\u{7}";
let mut output = Vec::new();
assert_eq!(write_osc52_to_writer(&mut output, sequence), Ok(()));
assert_eq!(output, sequence.as_bytes());
}
#[test]
fn ssh_uses_osc52_and_skips_native_on_success() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ true,
/*wsl_session*/ true,
|_| {
osc_calls.set(osc_calls.get() + 1);
Ok(())
},
|_| {
native_calls.set(native_calls.get() + 1);
Ok(None)
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Ok(())
},
);
assert!(matches!(result, Ok(None)));
assert_eq!(osc_calls.get(), 1);
assert_eq!(native_calls.get(), 0);
assert_eq!(wsl_calls.get(), 0);
}
#[test]
fn ssh_returns_osc52_error_and_skips_native() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ true,
/*wsl_session*/ true,
|_| {
osc_calls.set(osc_calls.get() + 1);
Err("blocked".into())
},
|_| {
native_calls.set(native_calls.get() + 1);
Ok(None)
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Ok(())
},
);
let Err(error) = result else {
panic!("expected OSC 52 error");
};
assert_eq!(error, "OSC 52 clipboard copy failed over SSH: blocked");
assert_eq!(osc_calls.get(), 1);
assert_eq!(native_calls.get(), 0);
assert_eq!(wsl_calls.get(), 0);
}
#[test]
fn local_uses_native_clipboard_first() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ false,
/*wsl_session*/ true,
|_| {
osc_calls.set(osc_calls.get() + 1);
Ok(())
},
|_| {
native_calls.set(native_calls.get() + 1);
Ok(Some(super::ClipboardLease::test()))
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Ok(())
},
);
assert!(matches!(result, Ok(Some(_))));
assert_eq!(osc_calls.get(), 0);
assert_eq!(native_calls.get(), 1);
assert_eq!(wsl_calls.get(), 0);
}
#[test]
fn local_non_wsl_falls_back_to_osc52_when_native_fails() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ false,
/*wsl_session*/ false,
|_| {
osc_calls.set(osc_calls.get() + 1);
Ok(())
},
|_| {
native_calls.set(native_calls.get() + 1);
Err("native unavailable".into())
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Ok(())
},
);
assert!(matches!(result, Ok(None)));
assert_eq!(osc_calls.get(), 1);
assert_eq!(native_calls.get(), 1);
assert_eq!(wsl_calls.get(), 0);
}
#[test]
fn local_wsl_native_failure_uses_powershell_and_skips_osc52_on_success() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ false,
/*wsl_session*/ true,
|_| {
osc_calls.set(osc_calls.get() + 1);
Ok(())
},
|_| {
native_calls.set(native_calls.get() + 1);
Err("native unavailable".into())
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Ok(())
},
);
assert!(matches!(result, Ok(None)));
assert_eq!(osc_calls.get(), 0);
assert_eq!(native_calls.get(), 1);
assert_eq!(wsl_calls.get(), 1);
}
#[test]
fn local_wsl_falls_back_to_osc52_when_native_and_powershell_fail() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ false,
/*wsl_session*/ true,
|_| {
osc_calls.set(osc_calls.get() + 1);
Ok(())
},
|_| {
native_calls.set(native_calls.get() + 1);
Err("native unavailable".into())
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Err("powershell unavailable".into())
},
);
assert!(matches!(result, Ok(None)));
assert_eq!(osc_calls.get(), 1);
assert_eq!(native_calls.get(), 1);
assert_eq!(wsl_calls.get(), 1);
}
#[test]
fn local_reports_both_errors_when_native_and_osc52_fail() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ false,
/*wsl_session*/ false,
|_| {
osc_calls.set(osc_calls.get() + 1);
Err("osc blocked".into())
},
|_| {
native_calls.set(native_calls.get() + 1);
Err("native unavailable".into())
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Ok(())
},
);
let Err(error) = result else {
panic!("expected native and OSC 52 errors");
};
assert_eq!(
error,
"native clipboard: native unavailable; OSC 52 fallback: osc blocked"
);
assert_eq!(osc_calls.get(), 1);
assert_eq!(native_calls.get(), 1);
assert_eq!(wsl_calls.get(), 0);
}
#[test]
fn local_wsl_reports_native_powershell_and_osc52_errors_when_all_fail() {
let osc_calls = Cell::new(0_u8);
let native_calls = Cell::new(0_u8);
let wsl_calls = Cell::new(0_u8);
let result = copy_to_clipboard_with(
"hello",
/*ssh_session*/ false,
/*wsl_session*/ true,
|_| {
osc_calls.set(osc_calls.get() + 1);
Err("osc blocked".into())
},
|_| {
native_calls.set(native_calls.get() + 1);
Err("native unavailable".into())
},
|_| {
wsl_calls.set(wsl_calls.get() + 1);
Err("powershell unavailable".into())
},
);
let Err(error) = result else {
panic!("expected native, WSL, and OSC 52 errors");
};
assert_eq!(
error,
"native clipboard: native unavailable; WSL fallback: powershell unavailable; OSC 52 fallback: osc blocked"
);
assert_eq!(osc_calls.get(), 1);
assert_eq!(native_calls.get(), 1);
assert_eq!(wsl_calls.get(), 1);
}
}