diff --git a/packages/codev/dashboard/__tests__/Terminal.clipboard.test.tsx b/packages/codev/dashboard/__tests__/Terminal.clipboard.test.tsx index c2dcb612..82236379 100644 --- a/packages/codev/dashboard/__tests__/Terminal.clipboard.test.tsx +++ b/packages/codev/dashboard/__tests__/Terminal.clipboard.test.tsx @@ -1,5 +1,6 @@ /** * Regression test for GitHub Issue #203: Copy/paste text in dashboard terminals + * Extended for Issue #252: Image paste support and mobile clipboard * * Verifies that the Terminal component registers a custom key event handler * on xterm.js for explicit clipboard operations via navigator.clipboard API. @@ -10,6 +11,7 @@ import { render, cleanup } from '@testing-library/react'; // Capture the custom key event handler registered on the xterm instance let capturedKeyHandler: ((event: KeyboardEvent) => boolean) | null = null; let mockPaste: ReturnType; +let mockWrite: ReturnType; let mockGetSelection: ReturnType; // Mock @xterm/xterm — use a class so `new Terminal(...)` works @@ -28,8 +30,10 @@ vi.mock('@xterm/xterm', () => { attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { capturedKeyHandler = handler; }); + registerLinkProvider = vi.fn(); constructor() { mockPaste = this.paste; + mockWrite = this.write; mockGetSelection = this.getSelection; } } @@ -50,6 +54,12 @@ vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class { dispose = vi.fn(); constructor(_handler?: unknown, _opts?: unknown) {} }, })); +// Mock uploadPasteImage from api.ts (Issue #252) +const mockUploadPasteImage = vi.fn(); +vi.mock('../src/lib/api.js', () => ({ + uploadPasteImage: (...args: unknown[]) => mockUploadPasteImage(...args), +})); + // Mock WebSocket as a class vi.stubGlobal('WebSocket', class { static OPEN = 1; @@ -72,20 +82,27 @@ vi.stubGlobal('ResizeObserver', class { // Import after mocks are set up import { Terminal } from '../src/components/Terminal.js'; -describe('Terminal clipboard handling (Issue #203)', () => { +describe('Terminal clipboard handling (Issue #203, #252)', () => { let clipboardReadText: ReturnType; let clipboardWriteText: ReturnType; + let clipboardRead: ReturnType; beforeEach(() => { capturedKeyHandler = null; clipboardReadText = vi.fn().mockResolvedValue('pasted text'); clipboardWriteText = vi.fn().mockResolvedValue(undefined); + // Default: clipboard.read() returns text-only items (no images) + clipboardRead = vi.fn().mockResolvedValue([ + { types: ['text/plain'], getType: vi.fn() }, + ]); Object.defineProperty(navigator, 'clipboard', { - value: { readText: clipboardReadText, writeText: clipboardWriteText }, + value: { readText: clipboardReadText, writeText: clipboardWriteText, read: clipboardRead }, writable: true, configurable: true, }); + + mockUploadPasteImage.mockReset(); }); afterEach(cleanup); @@ -105,7 +122,7 @@ describe('Terminal clipboard handling (Issue #203)', () => { expect(typeof capturedKeyHandler).toBe('function'); }); - describe('paste (Cmd+V on Mac)', () => { + describe('text paste (Cmd+V on Mac)', () => { beforeEach(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true }); }); @@ -126,7 +143,7 @@ describe('Terminal clipboard handling (Issue #203)', () => { }); }); - describe('paste (Ctrl+Shift+V on Linux/Windows)', () => { + describe('text paste (Ctrl+Shift+V on Linux/Windows)', () => { beforeEach(() => { Object.defineProperty(navigator, 'platform', { value: 'Linux x86_64', configurable: true }); }); @@ -147,6 +164,182 @@ describe('Terminal clipboard handling (Issue #203)', () => { }); }); + describe('image paste (Cmd+V on Mac) — Issue #252', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true }); + }); + + it('detects image in clipboard, uploads, and pastes the file path', async () => { + const mockBlob = new Blob(['fake-image'], { type: 'image/png' }); + clipboardRead.mockResolvedValue([ + { + types: ['image/png'], + getType: vi.fn().mockResolvedValue(mockBlob), + }, + ]); + mockUploadPasteImage.mockResolvedValue({ path: '/tmp/codev-paste/paste-123.png' }); + + const handler = renderTerminal(); + const event = makeKeyEvent('v', { metaKey: true }); + handler(event); + + await vi.waitFor(() => { + expect(clipboardRead).toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(mockUploadPasteImage).toHaveBeenCalledWith(mockBlob); + }); + await vi.waitFor(() => { + expect(mockPaste).toHaveBeenCalledWith('/tmp/codev-paste/paste-123.png'); + }); + // Should NOT fall back to text paste + expect(clipboardReadText).not.toHaveBeenCalled(); + }); + + it('falls back to text paste when clipboard.read() is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: { readText: clipboardReadText, writeText: clipboardWriteText }, + writable: true, + configurable: true, + }); + + const handler = renderTerminal(); + handler(makeKeyEvent('v', { metaKey: true })); + + await vi.waitFor(() => { + expect(clipboardReadText).toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(mockPaste).toHaveBeenCalledWith('pasted text'); + }); + }); + + it('shows uploading status and clears it after success', async () => { + const mockBlob = new Blob(['fake-image'], { type: 'image/png' }); + clipboardRead.mockResolvedValue([ + { + types: ['image/png'], + getType: vi.fn().mockResolvedValue(mockBlob), + }, + ]); + mockUploadPasteImage.mockResolvedValue({ path: '/tmp/codev-paste/paste-456.png' }); + + const handler = renderTerminal(); + handler(makeKeyEvent('v', { metaKey: true })); + + await vi.waitFor(() => { + expect(mockWrite).toHaveBeenCalledWith(expect.stringContaining('[Uploading image...]')); + }); + await vi.waitFor(() => { + // Clear line after upload completes + expect(mockWrite).toHaveBeenCalledWith('\r\x1b[2K'); + }); + }); + + it('shows error message when image upload fails', async () => { + const mockBlob = new Blob(['fake-image'], { type: 'image/png' }); + clipboardRead.mockResolvedValue([ + { + types: ['image/png'], + getType: vi.fn().mockResolvedValue(mockBlob), + }, + ]); + mockUploadPasteImage.mockRejectedValue(new Error('Upload failed: 500')); + + const handler = renderTerminal(); + handler(makeKeyEvent('v', { metaKey: true })); + + await vi.waitFor(() => { + expect(mockUploadPasteImage).toHaveBeenCalledWith(mockBlob); + }); + await vi.waitFor(() => { + expect(mockWrite).toHaveBeenCalledWith(expect.stringContaining('[Image upload failed]')); + }); + // Should NOT fall back to text paste when image was detected + expect(clipboardReadText).not.toHaveBeenCalled(); + }); + }); + + describe('native paste event (mobile/context menu) — Issue #252', () => { + it('uploads image from native paste event and pastes path', async () => { + const mockFile = new File(['fake-image'], 'screenshot.png', { type: 'image/png' }); + mockUploadPasteImage.mockResolvedValue({ path: '/tmp/codev-paste/paste-789.png' }); + + render(); + + // Get the terminal container and dispatch a native paste event + const container = document.querySelector('.terminal-container'); + expect(container).not.toBeNull(); + + const pasteEvent = new Event('paste', { bubbles: true }) as ClipboardEvent; + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + items: [ + { type: 'image/png', getAsFile: () => mockFile }, + ], + }, + }); + Object.defineProperty(pasteEvent, 'preventDefault', { value: vi.fn() }); + + container!.dispatchEvent(pasteEvent); + + await vi.waitFor(() => { + expect(mockUploadPasteImage).toHaveBeenCalledWith(mockFile); + }); + await vi.waitFor(() => { + expect(mockPaste).toHaveBeenCalledWith('/tmp/codev-paste/paste-789.png'); + }); + }); + + it('does not preventDefault when image-type item yields null from getAsFile()', () => { + render(); + + const container = document.querySelector('.terminal-container'); + expect(container).not.toBeNull(); + + const preventDefault = vi.fn(); + const pasteEvent = new Event('paste', { bubbles: true }) as ClipboardEvent; + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + items: [ + { type: 'image/png', getAsFile: () => null }, + ], + }, + }); + Object.defineProperty(pasteEvent, 'preventDefault', { value: preventDefault }); + + container!.dispatchEvent(pasteEvent); + + // If getAsFile() returns null, we should NOT block default paste behavior + expect(preventDefault).not.toHaveBeenCalled(); + expect(mockUploadPasteImage).not.toHaveBeenCalled(); + }); + + it('does not preventDefault for text-only paste events', () => { + render(); + + const container = document.querySelector('.terminal-container'); + expect(container).not.toBeNull(); + + const preventDefault = vi.fn(); + const pasteEvent = new Event('paste', { bubbles: true }) as ClipboardEvent; + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + items: [ + { type: 'text/plain', getAsFile: () => null }, + ], + }, + }); + Object.defineProperty(pasteEvent, 'preventDefault', { value: preventDefault }); + + container!.dispatchEvent(pasteEvent); + + // Text paste: should let xterm handle natively (no preventDefault) + expect(preventDefault).not.toHaveBeenCalled(); + expect(mockUploadPasteImage).not.toHaveBeenCalled(); + }); + }); + describe('copy (Cmd+C on Mac)', () => { beforeEach(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true }); diff --git a/packages/codev/dashboard/src/components/Terminal.tsx b/packages/codev/dashboard/src/components/Terminal.tsx index 307df5cb..a19f49b0 100644 --- a/packages/codev/dashboard/src/components/Terminal.tsx +++ b/packages/codev/dashboard/src/components/Terminal.tsx @@ -9,6 +9,7 @@ import { FilePathLinkProvider, FilePathDecorationManager } from '../lib/filePath import { VirtualKeyboard, type ModifierState } from './VirtualKeyboard.js'; import { useMediaQuery } from '../hooks/useMediaQuery.js'; import { MOBILE_BREAKPOINT } from '../lib/constants.js'; +import { uploadPasteImage } from '../lib/api.js'; /** WebSocket frame prefixes matching packages/codev/src/terminal/ws-protocol.ts */ const FRAME_CONTROL = 0x00; @@ -23,6 +24,81 @@ interface TerminalProps { persistent?: boolean; } +const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp']; + +/** + * Try to read an image from the clipboard and upload it. Returns true if an + * image was found and handled, false otherwise (caller should fall back to text). + */ +async function tryPasteImage(term: XTerm): Promise { + if (!navigator.clipboard?.read) return false; + let imageFound = false; + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + const imageType = item.types.find((t) => IMAGE_TYPES.includes(t)); + if (imageType) { + imageFound = true; + const blob = await item.getType(imageType); + term.write('\r\n\x1b[90m[Uploading image...]\x1b[0m'); + const { path } = await uploadPasteImage(blob); + term.write('\r\x1b[2K'); + term.paste(path); + return true; + } + } + } catch { + if (imageFound) { + // Upload failed after image was detected — show error and clear status + term.write('\r\x1b[2K\x1b[31m[Image upload failed]\x1b[0m\r\n'); + return true; // Don't fall back to text — the user intended to paste an image + } + // clipboard.read() denied or unavailable — fall back to text + } + return false; +} + +/** + * Handle paste: try image first (via Clipboard API), fall back to text. + * Used by both the keyboard shortcut handler and the native paste event. + */ +async function handlePaste(term: XTerm): Promise { + if (await tryPasteImage(term)) return; + // Fall back to text paste + try { + const text = await navigator.clipboard?.readText(); + if (text) term.paste(text); + } catch { + // clipboard access denied + } +} + +/** + * Handle a native paste event (e.g. from mobile long-press menu or context menu). + * Checks clipboardData for image files, then falls back to text. + */ +function handleNativePaste(event: ClipboardEvent, term: XTerm): void { + const items = event.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (IMAGE_TYPES.includes(item.type)) { + const blob = item.getAsFile(); + if (!blob) continue; + event.preventDefault(); + term.write('\r\n\x1b[90m[Uploading image...]\x1b[0m'); + uploadPasteImage(blob).then(({ path }) => { + term.write('\r\x1b[2K'); + term.paste(path); + }).catch(() => { + term.write('\r\x1b[2K\x1b[31m[Image upload failed]\x1b[0m\r\n'); + }); + return; + } + } + // Text paste: let xterm.js handle it natively (no preventDefault) +} + /** * Terminal component — renders an xterm.js instance connected to the * node-pty backend via WebSocket using the hybrid binary protocol. @@ -120,6 +196,13 @@ export function Terminal({ wsPath, onFileOpen, persistent }: TerminalProps) { term.attachCustomKeyEventHandler((event: KeyboardEvent) => { if (event.type !== 'keydown') return true; + // Shift+Enter: insert backslash + newline for line continuation + if (event.key === 'Enter' && event.shiftKey) { + event.preventDefault(); + term.paste('\\\n'); + return false; + } + const modKey = isMac ? event.metaKey : event.ctrlKey && event.shiftKey; if (!modKey) return true; @@ -136,15 +219,19 @@ export function Terminal({ wsPath, onFileOpen, persistent }: TerminalProps) { if (event.key === 'v' || event.key === 'V') { event.preventDefault(); - navigator.clipboard?.readText().then((text) => { - if (text) term.paste(text); - }).catch(() => {}); + handlePaste(term); return false; } return true; }); + // Native paste event listener for mobile browsers and context-menu paste. + // On mobile, users paste via long-press menu which fires a native paste event + // rather than a keyboard shortcut. This also handles image paste from context menu. + const onNativePaste = (e: Event) => handleNativePaste(e as ClipboardEvent, term); + containerRef.current.addEventListener('paste', onNativePaste); + // Debounced fit: coalesce multiple fit() triggers into one resize event. // This prevents resize storms from multiple sources (initial fit, CSS // layout settling, ResizeObserver, visibility change, buffer flush). @@ -362,6 +449,7 @@ export function Terminal({ wsPath, onFileOpen, persistent }: TerminalProps) { } decorationManager?.dispose(); linkProviderDisposable?.dispose(); + containerRef.current?.removeEventListener('paste', onNativePaste); resizeObserver.disconnect(); document.removeEventListener('visibilitychange', handleVisibility); ws.close(); diff --git a/packages/codev/dashboard/src/lib/api.ts b/packages/codev/dashboard/src/lib/api.ts index 79b5d4be..8396ceb0 100644 --- a/packages/codev/dashboard/src/lib/api.ts +++ b/packages/codev/dashboard/src/lib/api.ts @@ -154,6 +154,20 @@ export async function stopAll(): Promise { if (!res.ok) throw new Error(await res.text()); } +/** Upload a pasted image to the server and return the temp file path (Issue #252). */ +export async function uploadPasteImage(blob: Blob): Promise<{ path: string }> { + const res = await fetch(apiUrl('api/paste-image'), { + method: 'POST', + headers: { + 'Content-Type': blob.type || 'image/png', + ...getAuthHeaders(), + }, + body: blob, + }); + if (!res.ok) throw new Error(`Image upload failed: ${res.status}`); + return res.json(); +} + /** Get WebSocket path for a terminal tab's node-pty session. */ export function getTerminalWsPath(tab: { type: string; terminalId?: string }): string | null { if (tab.terminalId) { diff --git a/packages/codev/package-lock.json b/packages/codev/package-lock.json index 780c8fb5..697c814d 100644 --- a/packages/codev/package-lock.json +++ b/packages/codev/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cluesmith/codev", - "version": "2.0.0-rc.71", + "version": "2.0.0-rc.72", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cluesmith/codev", - "version": "2.0.0-rc.71", + "version": "2.0.0-rc.72", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 692beb6f..b70c5d27 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -17,7 +17,7 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { execSync } from 'node:child_process'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import type { SessionManager } from '../../terminal/session-manager.js'; import type { PtySessionInfo } from '../../terminal/pty-session.js'; @@ -916,6 +916,60 @@ async function handleProjectRoutes( return handleProjectAnnotate(req, res, ctx, url, projectPath, annotateMatch); } + // POST /api/paste-image - Upload pasted image to temp file (Issue #252) + if (req.method === 'POST' && apiPath === 'paste-image') { + const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB + let size = 0; + const chunks: Buffer[] = []; + let aborted = false; + + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_IMAGE_SIZE) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Image too large (max 10 MB)' })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + if (aborted) return; + try { + const buffer = Buffer.concat(chunks); + const contentType = req.headers['content-type'] || 'image/png'; + const ext = contentType.includes('jpeg') || contentType.includes('jpg') ? '.jpg' + : contentType.includes('gif') ? '.gif' + : contentType.includes('webp') ? '.webp' + : '.png'; + const filename = `paste-${crypto.randomUUID()}${ext}`; + const pasteDir = path.join(tmpdir(), 'codev-paste'); + fs.mkdirSync(pasteDir, { recursive: true }); + const filePath = path.join(pasteDir, filename); + fs.writeFileSync(filePath, buffer); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ path: filePath })); + } catch (err) { + if (!res.headersSent) { + const status = (err as Error).message.includes('too large') ? 413 : 500; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + } + }); + + req.on('error', (err) => { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } + }); + return; + } + // Unhandled API route res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));