#[cfg(any(not(test), all(test, unix)))]
use std::io::Write;
#[cfg(not(test))]
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
#[cfg(any(
all(test, unix),
all(
any(target_os = "macos", target_os = "windows", target_os = "linux"),
not(test)
)
))]
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use arboard::{Clipboard, ImageData};
use base64::Engine as _;
use image::{ImageBuffer, Rgba};
const OSC52_MAX_BYTES: usize = 100 * 1024;
#[derive(Clone)]
pub struct PastedImage {
pub path: PathBuf,
pub width: u32,
pub height: u32,
pub byte_len: usize,
}
impl PastedImage {
pub fn short_label(&self) -> String {
format!("{}x{} PNG", self.width, self.height)
}
pub fn size_label(&self) -> String {
let kb = (self.byte_len as f64 / 1024.0).round() as u64;
format!("{kb}KB")
}
}
pub enum ClipboardContent {
Text(String),
Image(PastedImage),
}
pub struct ClipboardHandler {
clipboard: Option<Clipboard>,
clipboard_init_attempted: bool,
#[cfg(test)]
written_text: Vec<String>,
}
impl ClipboardHandler {
pub fn new() -> Self {
Self {
clipboard: None,
clipboard_init_attempted: false,
#[cfg(test)]
written_text: Vec::new(),
}
}
fn ensure_clipboard(&mut self) {
if self.clipboard_init_attempted {
return;
}
self.clipboard_init_attempted = true;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(Clipboard::new().ok());
});
self.clipboard = rx
.recv_timeout(std::time::Duration::from_millis(500))
.ok()
.flatten();
}
pub fn read(&mut self, workspace: &Path) -> Option<ClipboardContent> {
self.ensure_clipboard();
let clipboard = self.clipboard.as_mut()?;
if let Ok(text) = clipboard.get_text() {
return Some(ClipboardContent::Text(text));
}
if let Ok(image) = clipboard.get_image()
&& let Ok(pasted) = save_image_as_png(workspace, &image)
{
return Some(ClipboardContent::Image(pasted));
}
None
}
pub fn write_text(&mut self, text: &str) -> Result<()> {
#[cfg(test)]
{
self.written_text.push(text.to_string());
Ok(())
}
#[cfg(not(test))]
{
#[cfg(target_os = "linux")]
if write_text_with_wlcopy(text).is_ok() {
return Ok(());
}
self.ensure_clipboard();
if let Some(clipboard) = self.clipboard.as_mut()
&& clipboard.set_text(text.to_string()).is_ok()
{
return Ok(());
}
#[cfg(target_os = "macos")]
if write_text_with_pbcopy(text).is_ok() {
return Ok(());
}
#[cfg(target_os = "windows")]
if write_text_with_set_clipboard(text).is_ok() {
return Ok(());
}
write_text_with_osc52(text)
.map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}"))
}
}
#[cfg(test)]
pub fn last_written_text(&self) -> Option<&str> {
self.written_text.last().map(String::as_str)
}
}
#[cfg(all(target_os = "macos", not(test)))]
fn write_text_with_pbcopy(text: &str) -> Result<()> {
write_text_with_stdin_command("pbcopy", &[], text, "pbcopy")
}
#[cfg(all(target_os = "windows", not(test)))]
fn write_text_with_set_clipboard(text: &str) -> Result<()> {
write_text_with_stdin_command(
"powershell.exe",
&["-NoProfile", "-Command", "Set-Clipboard -Value $input"],
text,
"Set-Clipboard",
)
}
#[cfg(all(target_os = "linux", not(test)))]
fn write_text_with_wlcopy(text: &str) -> Result<()> {
write_text_with_wlcopy_using_argv("wl-copy", text)
}
#[cfg(target_os = "linux")]
fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> {
let mut child = Command::new(program)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to write to {program}: {e}"))?;
}
let status = child
.wait()
.map_err(|e| anyhow::anyhow!("Failed to wait on {program}: {e}"))?;
if !status.success() {
bail!("{program} exited with {status}");
}
Ok(())
}
#[cfg(any(
all(test, unix),
all(any(target_os = "macos", target_os = "windows"), not(test))
))]
fn write_text_with_stdin_command(
program: &str,
args: &[&str],
text: &str,
label: &str,
) -> Result<()> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to run {label}: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to write to {label}: {e}"))?;
}
let _ = std::thread::Builder::new()
.name("clipboard-wait".to_string())
.spawn(move || {
let _ = child.wait();
});
Ok(())
}
#[cfg(not(test))]
fn write_text_with_osc52(text: &str) -> Result<()> {
let mut stdout = io::stdout();
if !stdout.is_terminal() {
bail!("OSC 52 clipboard fallback requires a terminal");
}
let in_tmux = std::env::var_os("TMUX").is_some();
let sequence = osc52_sequence(text, in_tmux)?;
stdout
.write_all(sequence.as_bytes())
.context("write OSC 52 clipboard sequence")?;
stdout.flush().context("flush OSC 52 clipboard sequence")
}
fn osc52_sequence(text: &str, in_tmux: bool) -> Result<String> {
if text.len() > OSC52_MAX_BYTES {
bail!("selection is too large for OSC 52 clipboard fallback");
}
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
let sequence = format!("\x1b]52;c;{encoded}\x07");
if in_tmux {
return Ok(format!("\x1bPtmux;\x1b{sequence}\x1b\\"));
}
Ok(sequence)
}
pub(crate) fn clipboard_images_dir(workspace: &Path) -> PathBuf {
if let Some(home) = dirs::home_dir() {
return home.join(".deepseek").join("clipboard-images");
}
workspace.join("clipboard-images")
}
fn save_image_as_png(workspace: &Path, image: &ImageData) -> Result<PastedImage> {
save_image_as_png_in(&clipboard_images_dir(workspace), image)
}
fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result<PastedImage> {
std::fs::create_dir_all(dir).context("create clipboard-images dir")?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = dir.join(format!("clipboard-{timestamp}.png"));
let width = u32::try_from(image.width).context("clipboard image width too large")?;
let height = u32::try_from(image.height).context("clipboard image height too large")?;
let expected = (width as usize) * (height as usize) * 4;
let mut rgba = image.bytes.as_ref().to_vec();
if rgba.len() < expected {
rgba.resize(expected, 0);
} else if rgba.len() > expected {
rgba.truncate(expected);
}
let buffer: ImageBuffer<Rgba<u8>, _> = ImageBuffer::from_raw(width, height, rgba)
.context("clipboard image dimensions did not match buffer length")?;
buffer
.save_with_format(&path, image::ImageFormat::Png)
.context("write clipboard PNG")?;
let byte_len = std::fs::metadata(&path)
.map(|m| m.len() as usize)
.unwrap_or(0);
Ok(PastedImage {
path,
width,
height,
byte_len,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::borrow::Cow;
fn solid_rgba(width: u16, height: u16, rgba: [u8; 4]) -> ImageData<'static> {
let mut bytes = Vec::with_capacity((width as usize) * (height as usize) * 4);
for _ in 0..(width as usize * height as usize) {
bytes.extend_from_slice(&rgba);
}
ImageData {
width: width as usize,
height: height as usize,
bytes: Cow::Owned(bytes),
}
}
#[test]
fn save_image_as_png_writes_valid_png() {
let dir = tempfile::tempdir().unwrap();
let img = solid_rgba(8, 4, [255, 0, 0, 255]);
let pasted = save_image_as_png_in(dir.path(), &img).expect("encode png");
assert_eq!(pasted.width, 8);
assert_eq!(pasted.height, 4);
assert!(pasted.byte_len > 0);
assert_eq!(
pasted.path.extension().and_then(|s| s.to_str()),
Some("png")
);
let header = std::fs::read(&pasted.path).unwrap();
assert_eq!(&header[..8], b"\x89PNG\r\n\x1a\n");
}
#[cfg(unix)]
#[test]
fn stdin_clipboard_command_returns_before_helper_exits() {
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("clipboard.txt");
let script = dir.path().join("slow-clipboard.sh");
std::fs::write(&script, "#!/bin/sh\ncat > \"$1\"\nsleep 1\n").unwrap();
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&script, permissions).unwrap();
let started = Instant::now();
write_text_with_stdin_command(
script.to_str().unwrap(),
&[marker.to_str().unwrap()],
"copied",
"test-clipboard",
)
.unwrap();
assert!(
started.elapsed() < Duration::from_millis(250),
"clipboard helper wait leaked onto caller path"
);
let deadline = Instant::now() + Duration::from_secs(2);
let mut last_body = String::new();
while Instant::now() < deadline {
if let Ok(body) = std::fs::read_to_string(&marker) {
if body == "copied" {
return;
}
last_body = body;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("clipboard helper did not receive stdin; last body: {last_body:?}");
}
#[test]
fn pasted_image_labels_format_correctly() {
let p = PastedImage {
path: PathBuf::from("/tmp/x.png"),
width: 1024,
height: 768,
byte_len: 235 * 1024,
};
assert_eq!(p.short_label(), "1024x768 PNG");
assert_eq!(p.size_label(), "235KB");
}
#[cfg(target_os = "linux")]
#[test]
fn wlcopy_helper_errors_when_binary_missing() {
let result =
write_text_with_wlcopy_using_argv("/nonexistent/path/to/wlcopy_binary_xyz", "test");
assert!(result.is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn wlcopy_helper_errors_when_binary_exits_nonzero() {
let result = write_text_with_wlcopy_using_argv("false", "test");
assert!(result.is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn wlcopy_helper_succeeds_when_binary_returns_zero() {
let result = write_text_with_wlcopy_using_argv("true", "test");
assert!(result.is_ok());
}
#[test]
fn osc52_sequence_encodes_text_clipboard_write() {
let sequence = osc52_sequence("hello", false).expect("sequence");
assert_eq!(sequence, "\x1b]52;c;aGVsbG8=\x07");
}
#[test]
fn osc52_sequence_wraps_for_tmux_passthrough() {
let sequence = osc52_sequence("copy", true).expect("sequence");
assert_eq!(sequence, "\x1bPtmux;\x1b\x1b]52;c;Y29weQ==\x07\x1b\\");
}
#[test]
fn osc52_sequence_rejects_oversized_selection() {
let text = "x".repeat(OSC52_MAX_BYTES + 1);
let err = osc52_sequence(&text, false).expect_err("oversized should fail");
assert!(
err.to_string().contains("too large"),
"unexpected error: {err}"
);
}
}