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 @@
+