返回 DeepSeek-TUI-2026
clipboard.rs
根目录 / crates / tui / src / tui / clipboard.rs
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
329 lines RUST