Add image paste support for xterm.js terminal
All checks were successful
Build App / build-linux (push) Successful in 2m41s
Build App / build-windows (push) Successful in 3m56s

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 10:52:08 -08:00
parent d947824436
commit db51abb970
6 changed files with 132 additions and 1 deletions

View File

@@ -21,9 +21,10 @@ export default function TerminalView({ sessionId, active }: Props) {
const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, resize, onOutput, onExit } = useTerminal();
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(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 && (
<div
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
onClick={() => setImagePasteMsg(null)}
>
{imagePasteMsg}
</div>
)}
<div
ref={containerRef}
className="w-full h-full"

View File

@@ -49,6 +49,14 @@ export function useTerminal() {
[],
);
const pasteImage = useCallback(
async (sessionId: string, imageData: Uint8Array) => {
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,

View File

@@ -47,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
invoke<void>("terminal_resize", { sessionId, cols, rows });
export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");