| 1 | //! Clipboard handling for paste support in TUI |
| 2 | //! |
| 3 | //! Supports text and image paste operations. Images on the clipboard are |
| 4 | //! encoded as PNG and persisted under `~/.deepseek/clipboard-images/` so the |
| 5 | //! model can reach them via the existing `@`-mention / file tools (DeepSeek |
| 6 | //! V4 does not currently accept inline image input on its Chat Completions |
| 7 | //! endpoint, so we materialize the bytes to disk instead of base64-embedding |
| 8 | //! them in the request). |
| 9 | |
| 10 | #[cfg(not(test))] |
| 11 | use std::io::{self, IsTerminal, Write}; |
| 12 | use std::path::{Path, PathBuf}; |
| 13 | #[cfg(all(any(target_os = "macos", target_os = "windows"), not(test)))] |
| 14 | use std::process::{Command, Stdio}; |
| 15 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 16 | |
| 17 | use anyhow::{Context, Result, bail}; |
| 18 | use arboard::{Clipboard, ImageData}; |
| 19 | use base64::Engine as _; |
| 20 | use image::{ImageBuffer, Rgba}; |
| 21 | |
| 22 | const OSC52_MAX_BYTES: usize = 100 * 1024; |
| 23 | |
| 24 | // === Types === |
| 25 | |
| 26 | /// Metadata captured for a pasted clipboard image. Used by the composer to |
| 27 | /// render a status hint like `Pasted 1024x768 image (235KB) → <path>`. |
| 28 | #[derive(Clone)] |
| 29 | pub struct PastedImage { |
| 30 | pub path: PathBuf, |
| 31 | pub width: u32, |
| 32 | pub height: u32, |
| 33 | pub byte_len: usize, |
| 34 | } |
| 35 | |
| 36 | impl PastedImage { |
| 37 | /// Short human-readable summary, e.g. `1024x768 PNG`. |
| 38 | pub fn short_label(&self) -> String { |
| 39 | format!("{}x{} PNG", self.width, self.height) |
| 40 | } |
| 41 | |
| 42 | /// Approximate file size suffix, e.g. `235KB`. |
| 43 | pub fn size_label(&self) -> String { |
| 44 | let kb = (self.byte_len as f64 / 1024.0).round() as u64; |
| 45 | format!("{kb}KB") |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | /// Clipboard payloads supported by the TUI. |
| 50 | pub enum ClipboardContent { |
| 51 | Text(String), |
| 52 | Image(PastedImage), |
| 53 | } |
| 54 | |
| 55 | /// Clipboard reader/writer helper. |
| 56 | pub struct ClipboardHandler { |
| 57 | clipboard: Option<Clipboard>, |
| 58 | #[cfg(test)] |
| 59 | written_text: Vec<String>, |
| 60 | } |
| 61 | |
| 62 | impl ClipboardHandler { |
| 63 | /// Create a new clipboard handler, falling back to a no-op when unavailable. |
| 64 | pub fn new() -> Self { |
| 65 | let clipboard = Clipboard::new().ok(); |
| 66 | Self { |
| 67 | clipboard, |
| 68 | #[cfg(test)] |
| 69 | written_text: Vec::new(), |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Read the clipboard and return the parsed content. |
| 74 | /// |
| 75 | /// `workspace` is used as a fallback location when `~/.deepseek/` cannot |
| 76 | /// be resolved (e.g. running with a stripped HOME in CI sandboxes). |
| 77 | pub fn read(&mut self, workspace: &Path) -> Option<ClipboardContent> { |
| 78 | let clipboard = self.clipboard.as_mut()?; |
| 79 | if let Ok(text) = clipboard.get_text() { |
| 80 | return Some(ClipboardContent::Text(text)); |
| 81 | } |
| 82 | |
| 83 | if let Ok(image) = clipboard.get_image() |
| 84 | && let Ok(pasted) = save_image_as_png(workspace, &image) |
| 85 | { |
| 86 | return Some(ClipboardContent::Image(pasted)); |
| 87 | } |
| 88 | |
| 89 | None |
| 90 | } |
| 91 | |
| 92 | /// Write text to the clipboard (no-op if unavailable). |
| 93 | pub fn write_text(&mut self, text: &str) -> Result<()> { |
| 94 | #[cfg(test)] |
| 95 | { |
| 96 | self.written_text.push(text.to_string()); |
| 97 | Ok(()) |
| 98 | } |
| 99 | |
| 100 | #[cfg(not(test))] |
| 101 | { |
| 102 | if let Some(clipboard) = self.clipboard.as_mut() |
| 103 | && clipboard.set_text(text.to_string()).is_ok() |
| 104 | { |
| 105 | return Ok(()); |
| 106 | } |
| 107 | |
| 108 | #[cfg(target_os = "macos")] |
| 109 | if write_text_with_pbcopy(text).is_ok() { |
| 110 | return Ok(()); |
| 111 | } |
| 112 | |
| 113 | #[cfg(target_os = "windows")] |
| 114 | if write_text_with_set_clipboard(text).is_ok() { |
| 115 | return Ok(()); |
| 116 | } |
| 117 | |
| 118 | write_text_with_osc52(text) |
| 119 | .map_err(|err| anyhow::anyhow!("Clipboard unavailable: {err}")) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | #[cfg(test)] |
| 124 | pub fn last_written_text(&self) -> Option<&str> { |
| 125 | self.written_text.last().map(String::as_str) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | #[cfg(all(target_os = "macos", not(test)))] |
| 130 | fn write_text_with_pbcopy(text: &str) -> Result<()> { |
| 131 | let mut child = Command::new("pbcopy") |
| 132 | .stdin(Stdio::piped()) |
| 133 | .spawn() |
| 134 | .map_err(|e| anyhow::anyhow!("Failed to run pbcopy: {e}"))?; |
| 135 | if let Some(mut stdin) = child.stdin.take() { |
| 136 | stdin |
| 137 | .write_all(text.as_bytes()) |
| 138 | .map_err(|e| anyhow::anyhow!("Failed to write to pbcopy: {e}"))?; |
| 139 | } |
| 140 | let status = child |
| 141 | .wait() |
| 142 | .map_err(|e| anyhow::anyhow!("Failed to wait for pbcopy: {e}"))?; |
| 143 | if status.success() { |
| 144 | return Ok(()); |
| 145 | } |
| 146 | Err(anyhow::anyhow!("pbcopy failed")) |
| 147 | } |
| 148 | |
| 149 | #[cfg(all(target_os = "windows", not(test)))] |
| 150 | fn write_text_with_set_clipboard(text: &str) -> Result<()> { |
| 151 | let mut child = Command::new("powershell.exe") |
| 152 | .args(["-NoProfile", "-Command", "Set-Clipboard -Value $input"]) |
| 153 | .stdin(Stdio::piped()) |
| 154 | .spawn() |
| 155 | .map_err(|e| anyhow::anyhow!("Failed to run Set-Clipboard: {e}"))?; |
| 156 | if let Some(mut stdin) = child.stdin.take() { |
| 157 | stdin |
| 158 | .write_all(text.as_bytes()) |
| 159 | .map_err(|e| anyhow::anyhow!("Failed to write to Set-Clipboard: {e}"))?; |
| 160 | } |
| 161 | let status = child |
| 162 | .wait() |
| 163 | .map_err(|e| anyhow::anyhow!("Failed to wait for Set-Clipboard: {e}"))?; |
| 164 | if status.success() { |
| 165 | return Ok(()); |
| 166 | } |
| 167 | Err(anyhow::anyhow!("Set-Clipboard failed")) |
| 168 | } |
| 169 | |
| 170 | #[cfg(not(test))] |
| 171 | fn write_text_with_osc52(text: &str) -> Result<()> { |
| 172 | let mut stdout = io::stdout(); |
| 173 | if !stdout.is_terminal() { |
| 174 | bail!("OSC 52 clipboard fallback requires a terminal"); |
| 175 | } |
| 176 | |
| 177 | let in_tmux = std::env::var_os("TMUX").is_some(); |
| 178 | let sequence = osc52_sequence(text, in_tmux)?; |
| 179 | stdout |
| 180 | .write_all(sequence.as_bytes()) |
| 181 | .context("write OSC 52 clipboard sequence")?; |
| 182 | stdout.flush().context("flush OSC 52 clipboard sequence") |
| 183 | } |
| 184 | |
| 185 | fn osc52_sequence(text: &str, in_tmux: bool) -> Result<String> { |
| 186 | if text.len() > OSC52_MAX_BYTES { |
| 187 | bail!("selection is too large for OSC 52 clipboard fallback"); |
| 188 | } |
| 189 | |
| 190 | let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); |
| 191 | let sequence = format!("\x1b]52;c;{encoded}\x07"); |
| 192 | if in_tmux { |
| 193 | return Ok(format!("\x1bPtmux;\x1b{sequence}\x1b\\")); |
| 194 | } |
| 195 | Ok(sequence) |
| 196 | } |
| 197 | |
| 198 | /// Resolve the directory pasted images should land in. Prefers |
| 199 | /// `~/.deepseek/clipboard-images/` so the path is stable across worktrees and |
| 200 | /// matches the location described in user-facing docs; falls back to |
| 201 | /// `<workspace>/clipboard-images/` if the home dir is unavailable. |
| 202 | fn clipboard_images_dir(workspace: &Path) -> PathBuf { |
| 203 | if let Some(home) = dirs::home_dir() { |
| 204 | return home.join(".deepseek").join("clipboard-images"); |
| 205 | } |
| 206 | workspace.join("clipboard-images") |
| 207 | } |
| 208 | |
| 209 | /// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns |
| 210 | /// the resulting path along with metadata used to render the paste hint. |
| 211 | fn save_image_as_png(workspace: &Path, image: &ImageData) -> Result<PastedImage> { |
| 212 | save_image_as_png_in(&clipboard_images_dir(workspace), image) |
| 213 | } |
| 214 | |
| 215 | /// Lower-level variant that writes into an explicit directory. Exposed so the |
| 216 | /// unit tests don't have to scribble inside the user's real home directory. |
| 217 | fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result<PastedImage> { |
| 218 | std::fs::create_dir_all(dir).context("create clipboard-images dir")?; |
| 219 | |
| 220 | let timestamp = SystemTime::now() |
| 221 | .duration_since(UNIX_EPOCH) |
| 222 | .unwrap_or_default() |
| 223 | .as_nanos(); |
| 224 | let path = dir.join(format!("clipboard-{timestamp}.png")); |
| 225 | |
| 226 | let width = u32::try_from(image.width).context("clipboard image width too large")?; |
| 227 | let height = u32::try_from(image.height).context("clipboard image height too large")?; |
| 228 | |
| 229 | // arboard hands us RGBA8 row-major. Copy into an ImageBuffer so we can |
| 230 | // run it through the `image` crate's PNG encoder. We pad / truncate any |
| 231 | // mismatched trailing bytes — defensive only, arboard already validates |
| 232 | // the buffer length on every supported backend. |
| 233 | let expected = (width as usize) * (height as usize) * 4; |
| 234 | let mut rgba = image.bytes.as_ref().to_vec(); |
| 235 | if rgba.len() < expected { |
| 236 | rgba.resize(expected, 0); |
| 237 | } else if rgba.len() > expected { |
| 238 | rgba.truncate(expected); |
| 239 | } |
| 240 | |
| 241 | let buffer: ImageBuffer<Rgba<u8>, _> = ImageBuffer::from_raw(width, height, rgba) |
| 242 | .context("clipboard image dimensions did not match buffer length")?; |
| 243 | buffer |
| 244 | .save_with_format(&path, image::ImageFormat::Png) |
| 245 | .context("write clipboard PNG")?; |
| 246 | |
| 247 | let byte_len = std::fs::metadata(&path) |
| 248 | .map(|m| m.len() as usize) |
| 249 | .unwrap_or(0); |
| 250 | Ok(PastedImage { |
| 251 | path, |
| 252 | width, |
| 253 | height, |
| 254 | byte_len, |
| 255 | }) |
| 256 | } |
| 257 | |
| 258 | #[cfg(test)] |
| 259 | mod tests { |
| 260 | use super::*; |
| 261 | use std::borrow::Cow; |
| 262 | |
| 263 | fn solid_rgba(width: u16, height: u16, rgba: [u8; 4]) -> ImageData<'static> { |
| 264 | let mut bytes = Vec::with_capacity((width as usize) * (height as usize) * 4); |
| 265 | for _ in 0..(width as usize * height as usize) { |
| 266 | bytes.extend_from_slice(&rgba); |
| 267 | } |
| 268 | ImageData { |
| 269 | width: width as usize, |
| 270 | height: height as usize, |
| 271 | bytes: Cow::Owned(bytes), |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | #[test] |
| 276 | fn save_image_as_png_writes_valid_png() { |
| 277 | let dir = tempfile::tempdir().unwrap(); |
| 278 | let img = solid_rgba(8, 4, [255, 0, 0, 255]); |
| 279 | let pasted = save_image_as_png_in(dir.path(), &img).expect("encode png"); |
| 280 | |
| 281 | assert_eq!(pasted.width, 8); |
| 282 | assert_eq!(pasted.height, 4); |
| 283 | assert!(pasted.byte_len > 0); |
| 284 | assert_eq!( |
| 285 | pasted.path.extension().and_then(|s| s.to_str()), |
| 286 | Some("png") |
| 287 | ); |
| 288 | |
| 289 | // The first eight bytes of any PNG file are the magic signature; if |
| 290 | // we ever regress to PPM or another format this will catch it. |
| 291 | let header = std::fs::read(&pasted.path).unwrap(); |
| 292 | assert_eq!(&header[..8], b"\x89PNG\r\n\x1a\n"); |
| 293 | } |
| 294 | |
| 295 | #[test] |
| 296 | fn pasted_image_labels_format_correctly() { |
| 297 | let p = PastedImage { |
| 298 | path: PathBuf::from("/tmp/x.png"), |
| 299 | width: 1024, |
| 300 | height: 768, |
| 301 | byte_len: 235 * 1024, |
| 302 | }; |
| 303 | assert_eq!(p.short_label(), "1024x768 PNG"); |
| 304 | assert_eq!(p.size_label(), "235KB"); |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | fn osc52_sequence_encodes_text_clipboard_write() { |
| 309 | let sequence = osc52_sequence("hello", false).expect("sequence"); |
| 310 | assert_eq!(sequence, "\x1b]52;c;aGVsbG8=\x07"); |
| 311 | } |
| 312 | |
| 313 | #[test] |
| 314 | fn osc52_sequence_wraps_for_tmux_passthrough() { |
| 315 | let sequence = osc52_sequence("copy", true).expect("sequence"); |
| 316 | assert_eq!(sequence, "\x1bPtmux;\x1b\x1b]52;c;Y29weQ==\x07\x1b\\"); |
| 317 | } |
| 318 | |
| 319 | #[test] |
| 320 | fn osc52_sequence_rejects_oversized_selection() { |
| 321 | let text = "x".repeat(OSC52_MAX_BYTES + 1); |
| 322 | let err = osc52_sequence(&text, false).expect_err("oversized should fail"); |
| 323 | assert!( |
| 324 | err.to_string().contains("too large"), |
| 325 | "unexpected error: {err}" |
| 326 | ); |
| 327 | } |
| 328 | } |
| 329 |