diff --git a/scripts/build.mjs b/scripts/build.mjs index c8c979e5..e6750ba4 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -93,6 +93,7 @@ console.log('\n[build] content-hash cache busting'); 'ralph-wizard.js', 'api-client.js', 'subagent-windows.js', + 'image-input.js', 'vendor/xterm-zerolag-input.js', ]; const manifest = {}; diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js new file mode 100644 index 00000000..553bc5c6 --- /dev/null +++ b/src/web/public/image-input.js @@ -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; + }, + +}); diff --git a/src/web/public/index.html b/src/web/public/index.html index 0a744e40..05b48b00 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1804,5 +1804,6 @@

Save Respawn Preset

+ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 042552fb..ac331541 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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; +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f804098e..ab66dc16 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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) @@ -341,6 +350,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; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 61773906..e7645532 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,7 +5,7 @@ */ import { FastifyInstance } from 'fastify'; -import { join, dirname } from 'node:path'; +import { join, dirname, extname } from 'node:path'; import { homedir } from 'node:os'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; @@ -1437,4 +1437,110 @@ export function registerSessionRoutes( 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', '.svg']); + + 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|svg\+xml)/i); + if (ctMatch) { + const map: Record = { + png: '.png', + jpeg: '.jpg', + jpg: '.jpg', + webp: '.webp', + gif: '.gif', + bmp: '.bmp', + 'svg+xml': '.svg', + }; + 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 }; + }); } diff --git a/src/web/server.ts b/src/web/server.ts index 57c6a55b..4050bde5 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -34,7 +34,7 @@ import fastifyStatic from '@fastify/static'; import fastifyWebsocket from '@fastify/websocket'; import { join, dirname } from 'node:path'; 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 { execSync } from 'node:child_process'; import { homedir, hostname as getHostname } from 'node:os'; @@ -926,6 +926,15 @@ export class WebServer extends EventEmitter { fileStreamManager.closeSessionStreams(sessionId); // Stop watching for images in this session's directory 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); this.sessions.delete(sessionId); // Only remove from state.json if we're also killing the mux session.