Skip to content

Commit 2009601

Browse files
committed
feat: Add clipboard image paste support and fix image previews in CC sessions
- Add save_clipboard_image and cleanup_temp_images commands in Rust backend - Implement paste event handler in FloatingPromptInput to capture pasted images - Save pasted images to .claude_temp/session_id/ directory - Add automatic cleanup of temp images when session ends - Fix image preview display for file paths containing spaces - Update regex patterns to handle both quoted (@"path with spaces") and unquoted (@path) mentions - Automatically wrap paths with spaces in quotes when inserting - Update remove handler to properly handle both quoted and unquoted paths Users can now paste images directly from clipboard (e.g., screenshots) and see proper previews for all image files regardless of filename format.
1 parent 10628fc commit 2009601

4 files changed

Lines changed: 232 additions & 15 deletions

File tree

src-tauri/src/commands/claude.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,105 @@ pub async fn load_session_history(
922922
Ok(messages)
923923
}
924924

925+
/// Saves a clipboard image to a temporary location for a session
926+
///
927+
/// This command handles pasted images by saving them to a temp directory
928+
/// within the project structure, making them accessible for Claude.
929+
#[tauri::command]
930+
pub async fn save_clipboard_image(
931+
project_path: String,
932+
session_id: String,
933+
image_data: String, // Base64 encoded image data
934+
mime_type: String,
935+
) -> Result<String, String> {
936+
log::info!(
937+
"Saving clipboard image for session: {} in project: {}",
938+
session_id,
939+
project_path
940+
);
941+
942+
// Parse the base64 data (remove data URL prefix if present)
943+
let base64_data = if image_data.starts_with("data:") {
944+
image_data
945+
.split(',')
946+
.nth(1)
947+
.ok_or("Invalid data URL format")?
948+
} else {
949+
&image_data
950+
};
951+
952+
// Decode base64 to bytes
953+
use base64::Engine;
954+
let image_bytes = base64::engine::general_purpose::STANDARD
955+
.decode(base64_data)
956+
.map_err(|e| format!("Failed to decode base64 image: {}", e))?;
957+
958+
// Determine file extension from MIME type
959+
let extension = match mime_type.as_str() {
960+
"image/png" => "png",
961+
"image/jpeg" | "image/jpg" => "jpg",
962+
"image/gif" => "gif",
963+
"image/webp" => "webp",
964+
"image/svg+xml" => "svg",
965+
_ => "png", // Default to PNG
966+
};
967+
968+
// Create temp directory for the project if it doesn't exist
969+
let project_path_buf = PathBuf::from(&project_path);
970+
let temp_dir = project_path_buf.join(".claude_temp").join(&session_id);
971+
fs::create_dir_all(&temp_dir)
972+
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
973+
974+
// Generate unique filename with timestamp
975+
let timestamp = SystemTime::now()
976+
.duration_since(SystemTime::UNIX_EPOCH)
977+
.unwrap_or_default()
978+
.as_millis();
979+
let filename = format!("pasted_image_{}.{}", timestamp, extension);
980+
let file_path = temp_dir.join(&filename);
981+
982+
// Write image to file
983+
fs::write(&file_path, image_bytes)
984+
.map_err(|e| format!("Failed to write image file: {}", e))?;
985+
986+
// Return the absolute path
987+
let absolute_path = file_path
988+
.canonicalize()
989+
.map_err(|e| format!("Failed to get absolute path: {}", e))?
990+
.to_string_lossy()
991+
.to_string();
992+
993+
log::info!("Saved clipboard image to: {}", absolute_path);
994+
Ok(absolute_path)
995+
}
996+
997+
/// Cleans up temporary images for a session
998+
///
999+
/// This command removes the temporary directory created for pasted images
1000+
/// when a session ends to avoid accumulating temporary files.
1001+
#[tauri::command]
1002+
pub async fn cleanup_temp_images(
1003+
project_path: String,
1004+
session_id: String,
1005+
) -> Result<(), String> {
1006+
log::info!(
1007+
"Cleaning up temp images for session: {} in project: {}",
1008+
session_id,
1009+
project_path
1010+
);
1011+
1012+
let project_path_buf = PathBuf::from(&project_path);
1013+
let temp_dir = project_path_buf.join(".claude_temp").join(&session_id);
1014+
1015+
if temp_dir.exists() {
1016+
fs::remove_dir_all(&temp_dir)
1017+
.map_err(|e| format!("Failed to remove temp directory: {}", e))?;
1018+
log::info!("Cleaned up temp directory: {}", temp_dir.display());
1019+
}
1020+
1021+
Ok(())
1022+
}
1023+
9251024
/// Execute a new interactive Claude Code session with streaming output
9261025
#[tauri::command]
9271026
pub async fn execute_claude_code(

src-tauri/src/main.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ use commands::agents::{
1818
};
1919
use commands::claude::{
2020
cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints,
21-
clear_checkpoint_manager, continue_claude_code, create_checkpoint, execute_claude_code,
21+
cleanup_temp_images, clear_checkpoint_manager, continue_claude_code, create_checkpoint, execute_claude_code,
2222
find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, get_checkpoint_settings,
2323
get_checkpoint_state_stats, get_claude_session_output, get_claude_settings, get_project_sessions,
2424
get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints,
2525
list_directory_contents, list_projects, list_running_claude_sessions, load_session_history,
2626
open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code,
27-
save_claude_md_file, save_claude_settings, save_system_prompt, search_files,
27+
save_claude_md_file, save_claude_settings, save_clipboard_image, save_system_prompt, search_files,
2828
track_checkpoint_message, track_session_messages, update_checkpoint_settings,
2929
get_hooks_config, update_hooks_config, validate_hook_command,
3030
ClaudeProcessState,
@@ -101,6 +101,8 @@ fn main() {
101101
find_claude_md_files,
102102
read_claude_md_file,
103103
save_claude_md_file,
104+
save_clipboard_image,
105+
cleanup_temp_images,
104106
load_session_history,
105107
execute_claude_code,
106108
continue_claude_code,

src/components/ClaudeCodeSession.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { api, type Session } from "@/lib/api";
2020
import { cn } from "@/lib/utils";
2121
import { open } from "@tauri-apps/plugin-dialog";
2222
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
23+
import { invoke } from "@tauri-apps/api/core";
2324
import { StreamMessage } from "./StreamMessage";
2425
import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput";
2526
import { ErrorBoundary } from "./ErrorBoundary";
@@ -827,9 +828,19 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
827828
api.clearCheckpointManager(effectiveSession.id).catch(err => {
828829
console.error("Failed to clear checkpoint manager:", err);
829830
});
831+
832+
// Clean up temporary images
833+
if (projectPath) {
834+
invoke('cleanup_temp_images', {
835+
projectPath,
836+
sessionId: effectiveSession.id
837+
}).catch((err: any) => {
838+
console.error("Failed to cleanup temp images:", err);
839+
});
840+
}
830841
}
831842
};
832-
}, [effectiveSession]);
843+
}, [effectiveSession, projectPath]);
833844

834845
const messagesList = (
835846
<div

src/components/FloatingPromptInput.tsx

Lines changed: 117 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { FilePicker } from "./FilePicker";
1919
import { ImagePreview } from "./ImagePreview";
2020
import { type FileEntry } from "@/lib/api";
2121
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
22+
import { invoke } from "@tauri-apps/api/core";
2223

2324
interface FloatingPromptInputProps {
2425
/**
@@ -199,7 +200,8 @@ const FloatingPromptInputInner = (
199200
return currentPrompt; // Image already added
200201
}
201202

202-
const mention = `@${imagePath}`;
203+
// Wrap path in quotes if it contains spaces
204+
const mention = imagePath.includes(' ') ? `@"${imagePath}"` : `@${imagePath}`;
203205
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' ';
204206

205207
// Focus the textarea
@@ -225,19 +227,49 @@ const FloatingPromptInputInner = (
225227
// Extract image paths from prompt text
226228
const extractImagePaths = (text: string): string[] => {
227229
console.log('[extractImagePaths] Input text:', text);
228-
const regex = /@([^\s]+)/g;
229-
const matches = Array.from(text.matchAll(regex));
230-
console.log('[extractImagePaths] Regex matches:', matches.map(m => m[0]));
230+
231+
// Updated regex to handle both quoted and unquoted paths
232+
// Pattern 1: @"path with spaces" - quoted paths
233+
// Pattern 2: @path - unquoted paths (continues until @ or end)
234+
const quotedRegex = /@"([^"]+)"/g;
235+
const unquotedRegex = /@([^@\n\s]+)/g;
236+
231237
const pathsSet = new Set<string>(); // Use Set to ensure uniqueness
232-
238+
239+
// First, extract quoted paths
240+
let matches = Array.from(text.matchAll(quotedRegex));
241+
console.log('[extractImagePaths] Quoted matches:', matches.map(m => m[0]));
242+
233243
for (const match of matches) {
234-
const path = match[1];
235-
console.log('[extractImagePaths] Processing path:', path);
244+
const path = match[1]; // No need to trim, quotes preserve exact path
245+
console.log('[extractImagePaths] Processing quoted path:', path);
246+
236247
// Convert relative path to absolute if needed
237248
const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path);
238249
console.log('[extractImagePaths] Full path:', fullPath, 'Is image:', isImageFile(fullPath));
250+
239251
if (isImageFile(fullPath)) {
240-
pathsSet.add(fullPath); // Add to Set (automatically handles duplicates)
252+
pathsSet.add(fullPath);
253+
}
254+
}
255+
256+
// Remove quoted mentions from text to avoid double-matching
257+
let textWithoutQuoted = text.replace(quotedRegex, '');
258+
259+
// Then extract unquoted paths
260+
matches = Array.from(textWithoutQuoted.matchAll(unquotedRegex));
261+
console.log('[extractImagePaths] Unquoted matches:', matches.map(m => m[0]));
262+
263+
for (const match of matches) {
264+
const path = match[1].trim();
265+
console.log('[extractImagePaths] Processing unquoted path:', path);
266+
267+
// Convert relative path to absolute if needed
268+
const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path);
269+
console.log('[extractImagePaths] Full path:', fullPath, 'Is image:', isImageFile(fullPath));
270+
271+
if (isImageFile(fullPath)) {
272+
pathsSet.add(fullPath);
241273
}
242274
}
243275

@@ -295,7 +327,14 @@ const FloatingPromptInputInner = (
295327
return currentPrompt; // All dropped images are already in the prompt
296328
}
297329

298-
const mentionsToAdd = newPaths.map(p => `@${p}`).join(' ');
330+
// Wrap paths with spaces in quotes for clarity
331+
const mentionsToAdd = newPaths.map(p => {
332+
// If path contains spaces, wrap in quotes
333+
if (p.includes(' ')) {
334+
return `@"${p}"`;
335+
}
336+
return `@${p}`;
337+
}).join(' ');
299338
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' ';
300339

301340
setTimeout(() => {
@@ -438,6 +477,60 @@ const FloatingPromptInputInner = (
438477
}
439478
};
440479

480+
const handlePaste = async (e: React.ClipboardEvent) => {
481+
const items = e.clipboardData?.items;
482+
if (!items || !projectPath) return;
483+
484+
for (const item of items) {
485+
if (item.type.startsWith('image/')) {
486+
e.preventDefault();
487+
488+
// Get the image blob
489+
const blob = item.getAsFile();
490+
if (!blob) continue;
491+
492+
try {
493+
// Convert blob to base64
494+
const reader = new FileReader();
495+
reader.onload = async () => {
496+
const base64Data = reader.result as string;
497+
498+
// Generate a session-specific ID for the image
499+
const sessionId = `paste-${Date.now()}`;
500+
501+
// Save the image via Tauri command
502+
const imagePath = await invoke<string>('save_clipboard_image', {
503+
projectPath,
504+
sessionId,
505+
imageData: base64Data,
506+
mimeType: item.type
507+
});
508+
509+
// Add the image path as a mention to the prompt
510+
setPrompt(currentPrompt => {
511+
// Wrap path in quotes if it contains spaces
512+
const mention = imagePath.includes(' ') ? `@"${imagePath}"` : `@${imagePath}`;
513+
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' ';
514+
515+
// Focus the textarea and move cursor to end
516+
setTimeout(() => {
517+
const target = isExpanded ? expandedTextareaRef.current : textareaRef.current;
518+
target?.focus();
519+
target?.setSelectionRange(newPrompt.length, newPrompt.length);
520+
}, 0);
521+
522+
return newPrompt;
523+
});
524+
};
525+
526+
reader.readAsDataURL(blob);
527+
} catch (error) {
528+
console.error('Failed to paste image:', error);
529+
}
530+
}
531+
}
532+
};
533+
441534
// Browser drag and drop handlers - just prevent default behavior
442535
// Actual file handling is done via Tauri's window-level drag-drop events
443536
const handleDrag = (e: React.DragEvent) => {
@@ -455,9 +548,19 @@ const FloatingPromptInputInner = (
455548
const handleRemoveImage = (index: number) => {
456549
// Remove the corresponding @mention from the prompt
457550
const imagePath = embeddedImages[index];
551+
const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
552+
const escapedRelativePath = imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
553+
554+
// Create patterns for both quoted and unquoted mentions
458555
const patterns = [
459-
new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'),
460-
new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g')
556+
// Quoted full path
557+
new RegExp(`@"${escapedPath}"\\s?`, 'g'),
558+
// Unquoted full path
559+
new RegExp(`@${escapedPath}\\s?`, 'g'),
560+
// Quoted relative path
561+
new RegExp(`@"${escapedRelativePath}"\\s?`, 'g'),
562+
// Unquoted relative path
563+
new RegExp(`@${escapedRelativePath}\\s?`, 'g')
461564
];
462565

463566
let newPrompt = prompt;
@@ -514,6 +617,7 @@ const FloatingPromptInputInner = (
514617
ref={expandedTextareaRef}
515618
value={prompt}
516619
onChange={handleTextChange}
620+
onPaste={handlePaste}
517621
placeholder="Type your prompt here..."
518622
className="min-h-[200px] resize-none"
519623
disabled={disabled}
@@ -756,6 +860,7 @@ const FloatingPromptInputInner = (
756860
value={prompt}
757861
onChange={handleTextChange}
758862
onKeyDown={handleKeyDown}
863+
onPaste={handlePaste}
759864
placeholder={dragActive ? "Drop images here..." : "Ask Claude anything..."}
760865
disabled={disabled}
761866
className={cn(
@@ -808,7 +913,7 @@ const FloatingPromptInputInner = (
808913
</div>
809914

810915
<div className="mt-2 text-xs text-muted-foreground">
811-
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop images"}
916+
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop or paste images"}
812917
</div>
813918
</div>
814919
</div>

0 commit comments

Comments
 (0)