feat(tui): clipboard image paste (#92)
Save clipboard images as PNG under ~/.deepseek/clipboard-images/ instead of PPM in the workspace, and surface dimensions + size in the composer's [Attached image: WxH PNG (NkB) at <path>] token plus the post-paste status hint. DeepSeek V4 does not currently accept inline image input on its Chat Completions endpoint, so we materialize the bytes to disk and let the model reach them via the existing file tools rather than base64-embedding them in the request. Adds the `image` crate (PNG-only feature; already pulled in transitively via arboard, so no compile-time delta) plus unit tests covering PNG header round-trip and label formatting. Fixes #92 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -952,6 +952,7 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"ignore",
|
||||
"image",
|
||||
"indicatif",
|
||||
"libc",
|
||||
"multimap",
|
||||
|
||||
@@ -54,6 +54,7 @@ tiny_http = "0.12"
|
||||
portable-pty = "0.8"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
pdf-extract = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1603,9 +1603,15 @@ impl App {
|
||||
ClipboardContent::Text(text) => {
|
||||
self.insert_paste_text(&text);
|
||||
}
|
||||
ClipboardContent::Image { path, description } => {
|
||||
self.insert_media_attachment("image", &path, Some(&description));
|
||||
self.status_message = Some(format!("Attached image: {}", path.display()));
|
||||
ClipboardContent::Image(pasted) => {
|
||||
let description = format!("{} ({})", pasted.short_label(), pasted.size_label());
|
||||
self.insert_media_attachment("image", &pasted.path, Some(&description));
|
||||
self.status_message = Some(format!(
|
||||
"Pasted {} image ({}) -> {}",
|
||||
pasted.short_label(),
|
||||
pasted.size_label(),
|
||||
pasted.path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
-21
@@ -1,6 +1,11 @@
|
||||
//! Clipboard handling for paste support in TUI
|
||||
//!
|
||||
//! Supports text and image paste operations.
|
||||
//! Supports text and image paste operations. Images on the clipboard are
|
||||
//! encoded as PNG and persisted under `~/.deepseek/clipboard-images/` so the
|
||||
//! model can reach them via the existing `@`-mention / file tools (DeepSeek
|
||||
//! V4 does not currently accept inline image input on its Chat Completions
|
||||
//! endpoint, so we materialize the bytes to disk instead of base64-embedding
|
||||
//! them in the request).
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::io::Write;
|
||||
@@ -9,15 +14,39 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use arboard::{Clipboard, ImageData};
|
||||
use image::{ImageBuffer, Rgba};
|
||||
|
||||
// === Types ===
|
||||
|
||||
/// Metadata captured for a pasted clipboard image. Used by the composer to
|
||||
/// render a status hint like `Pasted 1024x768 image (235KB) → <path>`.
|
||||
#[derive(Clone)]
|
||||
pub struct PastedImage {
|
||||
pub path: PathBuf,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub byte_len: usize,
|
||||
}
|
||||
|
||||
impl PastedImage {
|
||||
/// Short human-readable summary, e.g. `1024x768 PNG`.
|
||||
pub fn short_label(&self) -> String {
|
||||
format!("{}x{} PNG", self.width, self.height)
|
||||
}
|
||||
|
||||
/// Approximate file size suffix, e.g. `235KB`.
|
||||
pub fn size_label(&self) -> String {
|
||||
let kb = (self.byte_len as f64 / 1024.0).round() as u64;
|
||||
format!("{kb}KB")
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipboard payloads supported by the TUI.
|
||||
pub enum ClipboardContent {
|
||||
Text(String),
|
||||
Image { path: PathBuf, description: String },
|
||||
Image(PastedImage),
|
||||
}
|
||||
|
||||
/// Clipboard reader/writer helper.
|
||||
@@ -33,6 +62,9 @@ impl ClipboardHandler {
|
||||
}
|
||||
|
||||
/// Read the clipboard and return the parsed content.
|
||||
///
|
||||
/// `workspace` is used as a fallback location when `~/.deepseek/` cannot
|
||||
/// be resolved (e.g. running with a stripped HOME in CI sandboxes).
|
||||
pub fn read(&mut self, workspace: &Path) -> Option<ClipboardContent> {
|
||||
let clipboard = self.clipboard.as_mut()?;
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
@@ -40,10 +72,9 @@ impl ClipboardHandler {
|
||||
}
|
||||
|
||||
if let Ok(image) = clipboard.get_image()
|
||||
&& let Ok(path) = save_image_to_workspace(workspace, &image)
|
||||
&& let Ok(pasted) = save_image_as_png(workspace, &image)
|
||||
{
|
||||
let description = format!("image {}x{}", image.width, image.height);
|
||||
return Some(ClipboardContent::Image { path, description });
|
||||
return Some(ClipboardContent::Image(pasted));
|
||||
}
|
||||
|
||||
None
|
||||
@@ -84,27 +115,112 @@ impl ClipboardHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_image_to_workspace(workspace: &Path, image: &ImageData) -> Result<PathBuf> {
|
||||
let dir = workspace.join("clipboard-images");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
/// Resolve the directory pasted images should land in. Prefers
|
||||
/// `~/.deepseek/clipboard-images/` so the path is stable across worktrees and
|
||||
/// matches the location described in user-facing docs; falls back to
|
||||
/// `<workspace>/clipboard-images/` if the home dir is unavailable.
|
||||
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")
|
||||
}
|
||||
|
||||
/// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns
|
||||
/// the resulting path along with metadata used to render the paste hint.
|
||||
fn save_image_as_png(workspace: &Path, image: &ImageData) -> Result<PastedImage> {
|
||||
save_image_as_png_in(&clipboard_images_dir(workspace), image)
|
||||
}
|
||||
|
||||
/// Lower-level variant that writes into an explicit directory. Exposed so the
|
||||
/// unit tests don't have to scribble inside the user's real home directory.
|
||||
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_millis();
|
||||
let path = dir.join(format!("clipboard-{timestamp}.ppm"));
|
||||
.as_nanos();
|
||||
let path = dir.join(format!("clipboard-{timestamp}.png"));
|
||||
|
||||
let mut data = Vec::with_capacity((image.width * image.height * 3) + 64);
|
||||
data.extend_from_slice(format!("P6\n{} {}\n255\n", image.width, image.height).as_bytes());
|
||||
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 bytes = image.bytes.as_ref();
|
||||
for chunk in bytes.chunks(4) {
|
||||
let r = chunk.first().copied().unwrap_or(0);
|
||||
let g = chunk.get(1).copied().unwrap_or(0);
|
||||
let b = chunk.get(2).copied().unwrap_or(0);
|
||||
data.extend_from_slice(&[r, g, b]);
|
||||
// arboard hands us RGBA8 row-major. Copy into an ImageBuffer so we can
|
||||
// run it through the `image` crate's PNG encoder. We pad / truncate any
|
||||
// mismatched trailing bytes — defensive only, arboard already validates
|
||||
// the buffer length on every supported backend.
|
||||
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);
|
||||
}
|
||||
|
||||
std::fs::write(&path, data)?;
|
||||
Ok(path)
|
||||
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")
|
||||
);
|
||||
|
||||
// The first eight bytes of any PNG file are the magic signature; if
|
||||
// we ever regress to PPM or another format this will catch it.
|
||||
let header = std::fs::read(&pasted.path).unwrap();
|
||||
assert_eq!(&header[..8], b"\x89PNG\r\n\x1a\n");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user