mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
co-authored by
Claude Opus 4.7
arkon
parent
98966def03
commit
94bcf524a2
@@ -93,6 +93,7 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'ralph-wizard.js',
|
'ralph-wizard.js',
|
||||||
'api-client.js',
|
'api-client.js',
|
||||||
'subagent-windows.js',
|
'subagent-windows.js',
|
||||||
|
'image-input.js',
|
||||||
'vendor/xterm-zerolag-input.js',
|
'vendor/xterm-zerolag-input.js',
|
||||||
];
|
];
|
||||||
const manifest = {};
|
const manifest = {};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1804,5 +1804,6 @@
|
|||||||
<script defer src="ralph-wizard.js"></script>
|
<script defer src="ralph-wizard.js"></script>
|
||||||
<script defer src="api-client.js"></script>
|
<script defer src="api-client.js"></script>
|
||||||
<script defer src="subagent-windows.js"></script>
|
<script defer src="subagent-windows.js"></script>
|
||||||
|
<script defer src="image-input.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8591,3 +8591,23 @@ kbd {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 0.7rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Let Alt+digit pass through to browser (tab switching)
|
// Let Alt+digit pass through to browser (tab switching)
|
||||||
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
|
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.
|
// 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
|
// 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)
|
// 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
|
// Welcome message
|
||||||
this.showWelcome();
|
this.showWelcome();
|
||||||
|
|
||||||
|
// Image paste and drag-and-drop support
|
||||||
|
this.initImageInput();
|
||||||
|
|
||||||
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
||||||
this._chunkedWriteGen = 0;
|
this._chunkedWriteGen = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname, extname } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
@@ -1437,4 +1437,109 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
return { sessions: results.slice(0, 50) };
|
return { sessions: results.slice(0, 50) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Paste Image (clipboard / drag-drop upload)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const MAX_PASTE_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
||||||
|
|
||||||
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
|
const contentType = req.headers['content-type'] ?? '';
|
||||||
|
if (!contentType.includes('multipart/form-data')) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart boundary
|
||||||
|
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect raw body with size limit
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
for await (const chunk of req.raw) {
|
||||||
|
totalSize += chunk.length;
|
||||||
|
if (totalSize > MAX_PASTE_IMAGE_SIZE) {
|
||||||
|
reply.code(413);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
|
||||||
|
}
|
||||||
|
chunks.push(chunk as Buffer);
|
||||||
|
}
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Extract image from multipart body
|
||||||
|
const boundary = '--' + boundaryMatch[1];
|
||||||
|
const boundaryBuf = Buffer.from(boundary);
|
||||||
|
const parts: { headers: string; data: Buffer }[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos < body.length) {
|
||||||
|
const start = body.indexOf(boundaryBuf, pos);
|
||||||
|
if (start === -1) break;
|
||||||
|
const afterBoundary = start + boundaryBuf.length;
|
||||||
|
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break;
|
||||||
|
const headerStart = afterBoundary + 2;
|
||||||
|
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
|
||||||
|
if (headerEnd === -1) break;
|
||||||
|
const headers = body.subarray(headerStart, headerEnd).toString();
|
||||||
|
const dataStart = headerEnd + 4;
|
||||||
|
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
|
||||||
|
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
|
||||||
|
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
|
||||||
|
pos = nextBoundary === -1 ? body.length : nextBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePart = parts.find((p) => p.headers.includes('name="image"'));
|
||||||
|
if (!imagePart || imagePart.data.length === 0) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine extension from filename or Content-Type
|
||||||
|
let ext = '.png';
|
||||||
|
const filenameMatch = imagePart.headers.match(/filename="(.+?)"/);
|
||||||
|
if (filenameMatch) {
|
||||||
|
const origExt = extname(filenameMatch[1]).toLowerCase();
|
||||||
|
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
|
||||||
|
}
|
||||||
|
const ctMatch = imagePart.headers.match(/Content-Type:\s*image\/(png|jpeg|jpg|webp|gif|bmp)/i);
|
||||||
|
if (ctMatch) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
png: '.png',
|
||||||
|
jpeg: '.jpg',
|
||||||
|
jpg: '.jpg',
|
||||||
|
webp: '.webp',
|
||||||
|
gif: '.gif',
|
||||||
|
bmp: '.bmp',
|
||||||
|
};
|
||||||
|
ext = map[ctMatch[1].toLowerCase()] ?? ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`Unsupported image type: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTS].join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to {workingDir}/.claude-images/
|
||||||
|
const imageDir = join(session.workingDir, '.claude-images');
|
||||||
|
if (!existsSync(imageDir)) {
|
||||||
|
mkdirSync(imageDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const filename = `paste-${Date.now()}${ext}`;
|
||||||
|
const filepath = join(imageDir, filename);
|
||||||
|
await fs.writeFile(filepath, imagePart.data);
|
||||||
|
|
||||||
|
return { success: true, path: filepath, filename };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -34,7 +34,7 @@ import fastifyStatic from '@fastify/static';
|
|||||||
import fastifyWebsocket from '@fastify/websocket';
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { homedir, hostname as getHostname } from 'node:os';
|
import { homedir, hostname as getHostname } from 'node:os';
|
||||||
@@ -943,6 +943,15 @@ export class WebServer extends EventEmitter {
|
|||||||
fileStreamManager.closeSessionStreams(sessionId);
|
fileStreamManager.closeSessionStreams(sessionId);
|
||||||
// Stop watching for images in this session's directory
|
// Stop watching for images in this session's directory
|
||||||
imageWatcher.unwatchSession(sessionId);
|
imageWatcher.unwatchSession(sessionId);
|
||||||
|
// Clean up pasted images directory for this session
|
||||||
|
if (killMux && session.workingDir) {
|
||||||
|
const pasteImageDir = join(session.workingDir, '.claude-images');
|
||||||
|
try {
|
||||||
|
rmSync(pasteImageDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
await session.stop(killMux);
|
await session.stop(killMux);
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
// Only remove from state.json if we're also killing the mux session.
|
// Only remove from state.json if we're also killing the mux session.
|
||||||
|
|||||||
Reference in New Issue
Block a user