From db51abb970d0d8874f5cee22d03a01b8f8a5088d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Mar 2026 10:52:08 -0800 Subject: [PATCH] Add image paste support for xterm.js terminal Intercept clipboard paste events containing images in the terminal, upload them into the Docker container via bollard's tar upload API, and inject the resulting file path into terminal stdin so Claude Code can reference the image. Co-Authored-By: Claude Opus 4.6 --- .../src/commands/terminal_commands.rs | 20 +++++++ app/src-tauri/src/docker/exec.rs | 48 +++++++++++++++++ app/src-tauri/src/lib.rs | 1 + app/src/components/terminal/TerminalView.tsx | 53 ++++++++++++++++++- app/src/hooks/useTerminal.ts | 9 ++++ app/src/lib/tauri-commands.ts | 2 + 6 files changed, 132 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index f5e587f..62fe95a 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -72,3 +72,23 @@ pub async fn close_terminal_session( state.exec_manager.close_session(&session_id).await; Ok(()) } + +#[tauri::command] +pub async fn paste_image_to_terminal( + session_id: String, + image_data: Vec, + state: State<'_, AppState>, +) -> Result { + let container_id = state.exec_manager.get_container_id(&session_id).await?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let file_name = format!("clipboard_{}.png", timestamp); + + state + .exec_manager + .write_file_to_container(&container_id, &file_name, &image_data) + .await +} diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index 104bc29..cdb9ac8 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -1,3 +1,4 @@ +use bollard::container::UploadToContainerOptions; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; use futures_util::StreamExt; use std::collections::HashMap; @@ -212,4 +213,51 @@ impl ExecSessionManager { session.shutdown(); } } + + pub async fn get_container_id(&self, session_id: &str) -> Result { + let sessions = self.sessions.lock().await; + let session = sessions + .get(session_id) + .ok_or_else(|| format!("Session {} not found", session_id))?; + Ok(session.container_id.clone()) + } + + pub async fn write_file_to_container( + &self, + container_id: &str, + file_name: &str, + data: &[u8], + ) -> Result { + let docker = get_docker()?; + + // Build a tar archive in memory containing the file + let mut tar_buf = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_buf); + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, file_name, data) + .map_err(|e| format!("Failed to create tar entry: {}", e))?; + builder + .finish() + .map_err(|e| format!("Failed to finalize tar: {}", e))?; + } + + docker + .upload_to_container( + container_id, + Some(UploadToContainerOptions { + path: "/tmp".to_string(), + ..Default::default() + }), + tar_buf.into(), + ) + .await + .map_err(|e| format!("Failed to upload file to container: {}", e))?; + + Ok(format!("/tmp/{}", file_name)) + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index e8a9d74..3507b58 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -90,6 +90,7 @@ pub fn run() { commands::terminal_commands::terminal_input, commands::terminal_commands::terminal_resize, commands::terminal_commands::close_terminal_session, + commands::terminal_commands::paste_image_to_terminal, // Updates commands::update_commands::get_app_version, commands::update_commands::check_for_updates, diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 1b54e5a..6725dc4 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -21,9 +21,10 @@ export default function TerminalView({ sessionId, active }: Props) { const fitRef = useRef(null); const webglRef = useRef(null); const detectorRef = useRef(null); - const { sendInput, resize, onOutput, onExit } = useTerminal(); + const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const [detectedUrl, setDetectedUrl] = useState(null); + const [imagePasteMsg, setImagePasteMsg] = useState(null); useEffect(() => { if (!containerRef.current) return; @@ -85,6 +86,40 @@ export default function TerminalView({ sessionId, active }: Props) { sendInput(sessionId, data); }); + // Handle image paste: intercept paste events with image data, + // upload to the container, and inject the file path into terminal input. + const handlePaste = (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + e.stopPropagation(); + + const blob = item.getAsFile(); + if (!blob) return; + + blob.arrayBuffer().then(async (buf) => { + try { + setImagePasteMsg("Uploading image..."); + const data = new Uint8Array(buf); + const filePath = await pasteImage(sessionId, data); + // Inject the file path into terminal stdin + sendInput(sessionId, filePath); + setImagePasteMsg(`Image saved to ${filePath}`); + } catch (err) { + console.error("Image paste failed:", err); + setImagePasteMsg("Image paste failed"); + } + }); + return; // Only handle the first image + } + } + }; + + containerRef.current.addEventListener("paste", handlePaste, { capture: true }); + // Handle backend output -> terminal let aborted = false; @@ -129,6 +164,7 @@ export default function TerminalView({ sessionId, active }: Props) { detector.dispose(); detectorRef.current = null; inputDisposable.dispose(); + containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); outputPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.()); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); @@ -179,6 +215,13 @@ export default function TerminalView({ sessionId, active }: Props) { return () => clearTimeout(timer); }, [detectedUrl]); + // Auto-dismiss image paste message after 3 seconds + useEffect(() => { + if (!imagePasteMsg) return; + const timer = setTimeout(() => setImagePasteMsg(null), 3_000); + return () => clearTimeout(timer); + }, [imagePasteMsg]); + const handleOpenUrl = useCallback(() => { if (detectedUrl) { openUrl(detectedUrl).catch((e) => @@ -200,6 +243,14 @@ export default function TerminalView({ sessionId, active }: Props) { onDismiss={() => setDetectedUrl(null)} /> )} + {imagePasteMsg && ( +
setImagePasteMsg(null)} + > + {imagePasteMsg} +
+ )}
{ + const bytes = Array.from(imageData); + return commands.pasteImageToTerminal(sessionId, bytes); + }, + [], + ); + const onOutput = useCallback( (sessionId: string, callback: (data: Uint8Array) => void) => { const eventName = `terminal-output-${sessionId}`; @@ -76,6 +84,7 @@ export function useTerminal() { open, close, sendInput, + pasteImage, resize, onOutput, onExit, diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 88f25bd..6197a50 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -47,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) => invoke("terminal_resize", { sessionId, cols, rows }); export const closeTerminalSession = (sessionId: string) => invoke("close_terminal_session", { sessionId }); +export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => + invoke("paste_image_to_terminal", { sessionId, imageData }); // Updates export const getAppVersion = () => invoke("get_app_version");