Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 140 additions & 12 deletions src/tui/app/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use crate::todo::TodoItem;
use crate::tui::info_widget::{AmbientWidgetData, GitInfo, MemoryInfo};
use crate::tui::session_picker::ResumeTarget;
use crossterm::event::{KeyCode, KeyModifiers};
use std::ffi::OsStr;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;

Expand Down Expand Up @@ -247,27 +250,152 @@ pub(super) fn format_tokens(tokens: u64) -> String {
}
}

/// Copy text to clipboard, trying wl-copy first (Wayland), then arboard as fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClipboardCommand {
program: &'static str,
args: &'static [&'static str],
}

/// Copy text to the clipboard, preferring native tools and falling back to OSC 52.
///
/// Resolution order:
/// 1. If the session looks like SSH (SSH_CONNECTION or SSH_TTY set), skip
/// native helpers and write OSC 52 directly so the clipboard ends up on the
/// user's local machine instead of the remote host.
/// 2. Otherwise try platform-native helpers in order (pbcopy on macOS;
/// wl-copy / xclip / xsel based on `WAYLAND_DISPLAY` / `DISPLAY` on Linux;
/// arboard on Windows).
/// 3. As a final fallback, emit OSC 52 — most modern terminals (kitty,
/// alacritty, foot, wezterm, iTerm2, Ghostty, recent xterm) honor it.
pub(super) fn copy_to_clipboard(text: &str) -> bool {
if let Ok(mut child) = std::process::Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
if !should_prefer_osc52() {
for command in native_clipboard_commands() {
if run_clipboard_command(command, text.as_bytes()) {
return true;
}
}

#[cfg(target_os = "windows")]
if copy_with_arboard(text) {
return true;
}
}

write_osc52_clipboard(text.as_bytes())
}

fn run_clipboard_command(command: ClipboardCommand, bytes: &[u8]) -> bool {
let mut child = match Command::new(command.program)
.args(command.args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut()
&& stdin.write_all(text.as_bytes()).is_ok()
{
drop(child.stdin.take());
return child.wait().map(|s| s.success()).unwrap_or(false);
}
Ok(child) => child,
Err(_) => return false,
};

let Some(mut stdin) = child.stdin.take() else {
let _ = child.kill();
let _ = child.wait();
return false;
};

if stdin.write_all(bytes).is_err() {
let _ = child.kill();
let _ = child.wait();
return false;
}
drop(stdin);

child.wait().map(|status| status.success()).unwrap_or(false)
}

#[cfg(target_os = "macos")]
fn native_clipboard_commands() -> Vec<ClipboardCommand> {
vec![ClipboardCommand {
program: "pbcopy",
args: &[],
}]
}

#[cfg(all(unix, not(target_os = "macos")))]
fn native_clipboard_commands() -> Vec<ClipboardCommand> {
native_clipboard_commands_for_env(
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
std::env::var_os("DISPLAY").as_deref(),
)
}

#[cfg(all(unix, not(target_os = "macos")))]
pub(super) fn native_clipboard_commands_for_env(
wayland_display: Option<&OsStr>,
display: Option<&OsStr>,
) -> Vec<ClipboardCommand> {
let mut commands = Vec::new();

if wayland_display.is_some() {
commands.push(ClipboardCommand {
program: "wl-copy",
args: &["--type", "text/plain;charset=utf-8"],
});
}

if display.is_some() {
commands.push(ClipboardCommand {
program: "xclip",
args: &["-selection", "clipboard", "-in"],
});
commands.push(ClipboardCommand {
program: "xsel",
args: &["--clipboard", "--input"],
});
}

commands
}

#[cfg(not(any(unix, target_os = "macos")))]
fn native_clipboard_commands() -> Vec<ClipboardCommand> {
Vec::new()
}

#[cfg(target_os = "windows")]
fn copy_with_arboard(text: &str) -> bool {
arboard::Clipboard::new()
.and_then(|mut cb| cb.set_text(text.to_string()))
.is_ok()
}

fn write_osc52_clipboard(bytes: &[u8]) -> bool {
write_osc52_clipboard_to(bytes, io::stdout()).is_ok()
}

fn should_prefer_osc52() -> bool {
should_prefer_osc52_for_env(
std::env::var_os("SSH_CONNECTION").as_deref(),
std::env::var_os("SSH_TTY").as_deref(),
)
}

pub(super) fn should_prefer_osc52_for_env(
ssh_connection: Option<&OsStr>,
ssh_tty: Option<&OsStr>,
) -> bool {
ssh_connection.is_some() || ssh_tty.is_some()
}

pub(super) fn write_osc52_clipboard_to(bytes: &[u8], mut writer: impl Write) -> io::Result<()> {
use base64::Engine;

let mut sequence = String::from("\x1b]52;c;");
sequence.push_str(&base64::engine::general_purpose::STANDARD.encode(bytes));
sequence.push('\x07');
writer.write_all(sequence.as_bytes())?;
writer.flush()
}

pub(super) fn effort_display_label(effort: &str) -> &str {
match effort {
"xhigh" => "Max",
Expand Down
51 changes: 51 additions & 0 deletions src/tui/app/helpers_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#[cfg(all(unix, not(target_os = "macos")))]
use super::native_clipboard_commands_for_env;
use super::{
build_resume_command, clear_ambient_info_cache_for_tests, extract_bracketed_system_message,
format_countdown_until, gather_ambient_info, partition_queued_messages, resume_invocation_args,
should_prefer_osc52_for_env, write_osc52_clipboard_to,
};
use crate::ambient::{AmbientManager, Priority, ScheduleRequest, ScheduleTarget};
use crate::terminal_launch::{detected_resume_terminal, shell_command};
Expand Down Expand Up @@ -265,3 +268,51 @@ fn gather_ambient_info_filters_to_session_reminders_when_ambient_disabled() {
.is_some_and(|text| text.starts_with("in 4m") || text.starts_with("in 5m"))
);
}

// ---------------------------------------------------------------------------
// Regression tests for issue #65 / upstream PR #68 — clipboard backends and
// OSC 52 fallback. These exercise the pure helpers and avoid touching the
// real clipboard/process state.
// ---------------------------------------------------------------------------

#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn native_clipboard_commands_prefer_wayland_before_x11() {
let commands = native_clipboard_commands_for_env(
Some(std::ffi::OsStr::new("wayland-0")),
Some(std::ffi::OsStr::new(":0")),
);
let programs = commands
.iter()
.map(|command| command.program)
.collect::<Vec<_>>();

assert_eq!(programs, vec!["wl-copy", "xclip", "xsel"]);
assert_eq!(commands[0].args, &["--type", "text/plain;charset=utf-8"]);
}

#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn native_clipboard_commands_are_empty_without_display_env() {
assert!(native_clipboard_commands_for_env(None, None).is_empty());
}

#[test]
fn osc52_clipboard_writer_emits_base64_bel_sequence() {
let mut output = Vec::new();
write_osc52_clipboard_to(b"hello", &mut output).expect("write osc52");
assert_eq!(output, b"\x1b]52;c;aGVsbG8=\x07");
}

#[test]
fn ssh_sessions_prefer_osc52_clipboard() {
assert!(should_prefer_osc52_for_env(
Some(std::ffi::OsStr::new("1 2 3 4")),
None
));
assert!(should_prefer_osc52_for_env(
None,
Some(std::ffi::OsStr::new("/dev/pts/1"))
));
assert!(!should_prefer_osc52_for_env(None, None));
}