feat: image paste (Ctrl+V) and drag-and-drop into terminal (#84)

* feat: add image paste and drag-and-drop support

Clipboard paste (Ctrl+V) and drag-and-drop of image files into the
terminal. Images are saved to {workdir}/.claude-images/ and the
absolute path is inserted into the terminal input for Claude to read.

- POST /api/sessions/:id/paste-image endpoint (hand-parsed multipart)
- image-input.js mixin with paste trap technique (works on HTTP)
- Ctrl+V intercepted at xterm keyboard level, routes through hidden
  contenteditable div to capture both image and text clipboard data
- Drag-and-drop on terminal container with visual overlay
- Session cleanup deletes .claude-images/ on destroy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: remove SVG from paste-image allowlist

Drops .svg / image/svg+xml from the paste-image endpoint. SVGs are
served as image/svg+xml via /api/sessions/:id/file-raw, same-origin,
under a CSP that permits inline scripts — which would execute on view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
This commit is contained in:
aakhter
2026-05-17 05:57:07 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 arkon
parent 98966def03
commit 94bcf524a2
7 changed files with 293 additions and 2 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* Image Input Mixin - Clipboard paste and drag-and-drop image support
*
* For paste: intercepts Ctrl+V at the xterm keyboard level, creates a temporary
* hidden contenteditable div ("paste trap"), lets the browser's native paste fill
* it, then checks for image data. This works on HTTP (no secure context needed).
*
* For drag-and-drop: listens on the terminal container for file drops.
*
* @dependency app.js (uses global `app` for sendInput, activeSessionId, showToast)
* @dependency panels-ui.js (provides showToast)
*/
Object.assign(CodemanApp.prototype, {
initImageInput() {
// Drag-and-drop handlers on terminal container
const container = document.getElementById('terminalContainer');
if (!container) return;
container.addEventListener('dragover', (e) => {
e.preventDefault();
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
container.classList.add('drag-active');
}
});
container.addEventListener('dragleave', (e) => {
if (!container.contains(e.relatedTarget)) {
container.classList.remove('drag-active');
}
});
container.addEventListener('drop', (e) => {
e.preventDefault();
container.classList.remove('drag-active');
if (!this.activeSessionId) return;
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
this.showToast('Only image files are supported', 'error');
return;
}
this._uploadAndInsertImages(imageFiles);
});
},
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects
// the result for images. Works on plain HTTP (no Clipboard API needed).
_handleImagePaste() {
const self = this;
// Create a hidden contenteditable div to receive the paste
const trap = document.createElement('div');
trap.contentEditable = 'true';
trap.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;overflow:hidden';
document.body.appendChild(trap);
trap.focus();
// Listen for the paste event on our trap
trap.addEventListener('paste', function(e) {
e.stopPropagation();
// Check for images in clipboard items
var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
for (var i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
var blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
}
}
// Clean up the trap
setTimeout(function() {
if (trap.parentNode) trap.parentNode.removeChild(trap);
// Refocus the terminal
if (self.terminal) self.terminal.focus();
}, 0);
if (imageFiles.length > 0) {
e.preventDefault();
self._uploadAndInsertImages(imageFiles);
} else {
// No image -- extract text and send to terminal
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
e.preventDefault();
if (text) self.sendInput(text);
}
});
// Trigger the browser's native paste via execCommand
// (this fires the paste event on our focused trap element)
document.execCommand('paste');
},
async _uploadAndInsertImages(files) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
const paths = [];
for (const file of files) {
try {
const path = await this._uploadPasteImage(sessionId, file);
paths.push(path);
} catch (err) {
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
}
}
if (paths.length > 0) {
const pathStr = paths.join(' ');
await this.sendInput(pathStr);
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
}
},
async _uploadPasteImage(sessionId, file) {
const form = new FormData();
form.append('image', file);
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
method: 'POST',
body: form,
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || 'HTTP ' + resp.status);
}
const data = await resp.json();
return data.path;
},
});
+1
View File
@@ -1804,5 +1804,6 @@
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
<script defer src="image-input.js"></script>
</body>
</html>
+20
View File
@@ -8591,3 +8591,23 @@ kbd {
margin-top: 4px;
font-size: 0.7rem;
}
/* Image drag-and-drop overlay */
#terminalContainer.drag-active {
outline: 2px dashed #4a9eff;
outline-offset: -2px;
position: relative;
}
#terminalContainer.drag-active::after {
content: 'Drop image here';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(74, 158, 255, 0.08);
color: #4a9eff;
font-size: 1.2rem;
pointer-events: none;
z-index: 100;
}
+12
View File
@@ -83,6 +83,15 @@ Object.assign(CodemanApp.prototype, {
// Let Alt+digit pass through to browser (tab switching)
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
// Route through our paste trap which handles both images and text.
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
if (this.activeSessionId && this._handleImagePaste) {
this._handleImagePaste();
}
return false;
}
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
@@ -367,6 +376,9 @@ Object.assign(CodemanApp.prototype, {
// Welcome message
this.showWelcome();
// Image paste and drag-and-drop support
this.initImageInput();
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;