diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 0b3221d9..eabdc020 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -119,7 +119,8 @@ export enum IPC { ReadFileText = 'read_file_text', // Clipboard - SaveClipboardImage = 'save_clipboard_image', + ResolveClipboardPaste = 'resolve_clipboard_paste', + SaveDroppedImage = 'save_dropped_image', // Notifications ShowNotification = 'show_notification', diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 1df97739..b369fcfc 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -1,4 +1,5 @@ import { ipcMain, dialog, shell, app, clipboard, BrowserWindow, Notification } from 'electron'; +import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import { fileURLToPath } from 'url'; @@ -131,6 +132,65 @@ function getOptionalImageTag(value: unknown): string | undefined { return imageTag; } +/** First file URL on the clipboard, or null if none. + * macOS uses `public.file-url` (one URL per call). + * Linux file managers vary: + * - Files (Nautilus), Nemo, etc. publish `x-special/gnome-copied-files` + * as `\nfile:///path1\nfile:///path2`, where is `copy` + * or `cut`. This is the dominant Linux desktop format and MUST be + * checked before `text/uri-list` because some apps publish both + * flavours and the GNOME flavour is the authoritative one. + * - Falls back to `text/uri-list` (newline-separated) for KDE, Xfce, + * and any cross-desktop publisher that follows RFC 2483. */ +function readClipboardFileUrl(formats: string[]): string | null { + if (formats.includes('public.file-url')) { + const url = clipboard.read('public.file-url').trim(); + if (url) return url; + } + if (formats.includes('x-special/gnome-copied-files')) { + const payload = clipboard.read('x-special/gnome-copied-files'); + // First line is the verb (copy/cut); subsequent lines are file URLs. + const lines = payload.split('\n'); + for (let i = 1; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('file://')) return trimmed; + } + } + if (formats.includes('text/uri-list')) { + const list = clipboard.read('text/uri-list'); + for (const line of list.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && trimmed.startsWith('file://')) return trimmed; + } + } + return null; +} + +/** Convert a file:// URL to an absolute path, returning '' on failure. */ +function fileUrlToPath(url: string): string { + try { + return fileURLToPath(url); + } catch { + return ''; + } +} + +/** Strip path separators and clamp to a sane length so a renderer-supplied + * filename can't escape the temp dir. Falls back to a generic name when empty. + * Always appends a 6-char random suffix so two same-name drops landing in the + * same millisecond don't overwrite each other. */ +function sanitizeDroppedName(name: string): string { + const base = name + // eslint-disable-next-line no-control-regex -- intentional NUL strip for filesystem safety + .replace(/[\\/\x00]/g, '_') + .replace(/^\.+/, '') + .trim() + .slice(0, 200); + const stamp = `${Date.now()}-${crypto.randomBytes(3).toString('hex')}`; + if (base) return `parallel-code-drop-${stamp}-${base}`; + return `parallel-code-drop-${stamp}.png`; +} + /** * Create a leading+trailing throttled event forwarder. * Fires immediately, suppresses for `intervalMs`, then fires once more @@ -644,19 +704,57 @@ export function registerAllHandlers(win: BrowserWindow): void { // --- Clipboard --- const clipboardImagePath = path.join(os.tmpdir(), 'parallel-code-clipboard.png'); - ipcMain.handle(IPC.SaveClipboardImage, async () => { + + // Resolve the most useful representation of the current clipboard contents + // for pasting into a terminal. Order of preference: + // 1. file references (Finder copy, Nautilus copy, etc.) → return absolute path + // 2. raster image (screenshot, image-app copy) → save PNG to tmp + return path + // 3. plain text → return as-is + // + // Without (1), copying an image file from Finder gives only the basename via + // navigator.clipboard.readText(), which is useless to a CLI agent that needs a + // path it can stat. macOS exposes file copies via the 'public.file-url' format, + // Linux via 'text/uri-list'. Windows is not a published target. + ipcMain.handle(IPC.ResolveClipboardPaste, async () => { try { + const formats = clipboard.availableFormats(); + const fileUrl = readClipboardFileUrl(formats); + if (fileUrl) { + const filePath = fileUrlToPath(fileUrl); + if (filePath) return { kind: 'file', path: filePath }; + } const img = clipboard.readImage(); - if (img.isEmpty()) return null; - const buf = img.toPNG(); - await fs.promises.writeFile(clipboardImagePath, buf); - return clipboardImagePath; + if (!img.isEmpty()) { + const buf = img.toPNG(); + await fs.promises.writeFile(clipboardImagePath, buf); + return { kind: 'image', path: clipboardImagePath }; + } + const text = clipboard.readText(); + if (text) return { kind: 'text', text }; + return { kind: 'empty' }; } catch (e) { - console.error('[clipboard] Failed to save clipboard image:', e); - return null; + console.error('[clipboard] resolveClipboardPaste failed:', e); + return { kind: 'empty' }; } }); + // Save image bytes that were dropped from a source without a filesystem path + // (e.g. dragged from a browser). The renderer reads the dropped File as + // an ArrayBuffer, base64-encodes it, and forwards it here so the CLI agent + // can read the result. We require base64 (not Uint8Array / ArrayBuffer) + // because the renderer's invoke() wrapper does a JSON.parse(JSON.stringify(args)) + // round-trip that destroys typed arrays. + ipcMain.handle(IPC.SaveDroppedImage, async (_e, args) => { + if (!args || typeof args !== 'object') throw new Error('invalid args'); + const { name, data } = args as { name?: unknown; data?: unknown }; + if (typeof data !== 'string') throw new Error('data must be a base64 string'); + const buf = Buffer.from(data, 'base64'); + const safeName = sanitizeDroppedName(typeof name === 'string' ? name : ''); + const filePath = path.join(os.tmpdir(), safeName); + await fs.promises.writeFile(filePath, buf); + return filePath; + }); + // --- System --- ipcMain.handle(IPC.GetSystemFonts, () => getSystemMonospaceFonts()); diff --git a/electron/ipc/trace.ts b/electron/ipc/trace.ts index b570a892..7a68b161 100644 --- a/electron/ipc/trace.ts +++ b/electron/ipc/trace.ts @@ -37,7 +37,8 @@ const NEVER_SAFE: ReadonlySet = new Set([ IPC.DialogOpen, IPC.OpenPath, IPC.ReadFileText, - IPC.SaveClipboardImage, + IPC.ResolveClipboardPaste, + IPC.SaveDroppedImage, IPC.CreateArenaWorktree, IPC.RemoveArenaWorktree, IPC.CheckPathExists, diff --git a/electron/preload.cjs b/electron/preload.cjs index dfd905d6..3bef0c03 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer, webFrame } = require('electron'); +const { contextBridge, ipcRenderer, webFrame, webUtils } = require('electron'); // Allowlist of valid IPC channels. // IMPORTANT: This list MUST stay in sync with the IPC enum in electron/ipc/channels.ts. @@ -108,7 +108,8 @@ const ALLOWED_CHANNELS = new Set([ 'open_path', 'read_file_text', // Clipboard - 'save_clipboard_image', + 'resolve_clipboard_paste', + 'save_dropped_image', // Notifications 'show_notification', 'notification_clicked', @@ -142,4 +143,14 @@ contextBridge.exposeInMainWorld('electron', { }, }, setZoomFactor: (factor) => webFrame.setZoomFactor(factor), + // Returns the absolute filesystem path for a File obtained from a drop event + // (or any DataTransfer / input[type=file]). Returns '' for File objects that + // have no backing path (e.g. images dragged from a browser tab). + getPathForFile: (file) => { + try { + return webUtils.getPathForFile(file) || ''; + } catch { + return ''; + } + }, }); diff --git a/openspec/changes/archive/2026-04-28-support-paste-images/design.md b/openspec/changes/archive/2026-04-28-support-paste-images/design.md new file mode 100644 index 00000000..f722339f --- /dev/null +++ b/openspec/changes/archive/2026-04-28-support-paste-images/design.md @@ -0,0 +1,203 @@ +# Design — Support Paste-Image into Terminal + +## Why a design doc + +The user-facing behaviour ("dropping or pasting an image types its absolute +path into the terminal") is one sentence; the spec covers the visible +contract. Three things need a written design because they are non-obvious +choices that future contributors will be tempted to "fix" the wrong way: + +1. Why clipboard resolution lives in the main process instead of the + renderer. +2. Why the drop handler runs in DOM capture phase rather than bubble. +3. Why dropped binary payloads cross the IPC boundary as base64 strings + instead of `Uint8Array` / `ArrayBuffer`. + +Plus a fourth: why `escapePath` uses backslash escaping and not +single-quote wrapping or any per-agent convention. + +## 1. Clipboard resolution lives in the main process + +The renderer has access to `navigator.clipboard.readText()` and +`navigator.clipboard.read()`. They are insufficient for our use case: + +- `readText()` on macOS returns the basename when Finder copied a file + (Finder writes the basename to the `text/plain` flavour of the + clipboard alongside the file URL). The renderer cannot tell that the + basename belongs to a real file on disk without also walking + `clipboard.read()` for the `Files` flavour, which is gated by + permissions, only available in secure contexts, and does not return + paths. +- The Electron main process can read OS-specific clipboard flavours via + `clipboard.read('public.file-url')` (macOS) and + `clipboard.read('text/uri-list')` (Linux) and call `fileURLToPath` to + recover the absolute path. + +So the resolution must live in main. A single IPC, `ResolveClipboardPaste`, +returns a tagged-union response (`{ kind: 'file' | 'image' | 'text' | 'empty', … }`) +that encodes the priority order: + +``` +1. public.file-url (macOS Finder copy) + or text/uri-list (Linux file managers) + → return { kind: 'file', path } +2. clipboard.readImage() + → save PNG to temp + return { kind: 'image', path } +3. clipboard.readText() + → return { kind: 'text', text } +4. nothing + → return { kind: 'empty' } +``` + +The renderer's Cmd+V handler is a `switch` on `kind`. One round-trip, +no `readText`-then-`readImage` race, no opportunity to insert a +basename when a file URL was available the whole time. + +`SaveClipboardImage` (the legacy single-purpose handler) becomes dead +code as soon as the renderer migrates. It is removed in this change to +keep the IPC surface tight and to prevent future callers re-introducing +the basename trap by accident. + +## 2. Drop handler runs in DOM capture phase + +xterm's element subscribes to `drop` and `dragover` itself. Its default +handler reads `dataTransfer.getData('text/plain')`, which the OS +populates with the basename when files are dragged from a file manager. +If the application's drop handler runs in the bubble phase (the default +for `addEventListener` and JSX `on:drop`), xterm's handler runs first and +inserts the basename before our handler ever runs. + +The fix is to register the listener in the DOM capture phase: + +```ts +containerRef.addEventListener('dragover', handleDragOver, true); // capture +containerRef.addEventListener('drop', handleDrop, true); +``` + +Plus `e.preventDefault()` and `e.stopPropagation()` inside the handler. +`stopPropagation` short-circuits the bubble phase entirely; xterm's +listeners never fire. The capture-phase registration is what makes the +two `preventDefault()` calls sufficient — without capture, xterm +sometimes runs synchronously before the bubble phase even starts. + +The handler also calls `term.focus()` before `enqueueInput()` so the +terminal is the active element when the typed input arrives. Without it, +dropping onto a non-focused terminal types into the previously-focused +field (which can be a text input elsewhere in the UI). + +## 3. Binary payloads cross IPC as base64 + +The repo's renderer-side `invoke()` wrapper does this: + +```ts +const safeArgs = args ? (JSON.parse(JSON.stringify(args)) as …) : undefined; +return window.electron.ipcRenderer.invoke(cmd, safeArgs); +``` + +The JSON round-trip exists to make `Channel` tokens work +(`Channel.toJSON` produces a plain `{__CHANNEL_ID__}` object). It also +silently destroys typed arrays: `JSON.stringify(new Uint8Array([137,80,78]))` +produces `'{"0":137,"1":80,"2":78}'`. The receiving handler's +`instanceof Uint8Array` check then fails and throws `data must be +ArrayBuffer or Uint8Array`. Browser-image drops therefore look like +they "do nothing" with no surfaced error. + +Two ways out: + +- **A. Base64 the payload in the renderer**, decode in the main process + via `Buffer.from(data, 'base64')`. Stays inside the JSON envelope; no + invasive change to the existing IPC wrapper; works through the + contextBridge as a plain string. +- **B. Bypass the JSON round-trip** for known-binary channels. Smaller + wire payload (~33 % overhead saved) but requires either a parallel + `invokeBinary()` API or a per-channel allowlist inside `invoke()`, + and risks mis-handling `Channel` tokens that legitimately appear in + the payload. + +We pick **A**. The blast radius is one channel and one helper. The +`SaveDroppedImage` handler accepts `{ name: string, data: string /* +base64 */ }` and reconstructs a `Buffer`. The 50 MB renderer-side cap +on dropped item size makes the base64 overhead negligible in practice. + +## 4. `escapePath` uses backslash escaping + +The path is going to one of two places: + +- A POSIX shell (when the terminal hosts `bash` / `zsh` directly). +- An agent's prompt parser (when the terminal hosts `claude`, `codex`, + etc.). + +Backslash escaping works in both: + +- A real shell parses `\` as a literal char, so + `My\ File.png` becomes the single argv `My File.png`. +- An agent reads the prompt as text. The backslashes are visible but + every popular agent's path parser strips them. The output also matches + what the user would see if they dragged the same file into a native + macOS Terminal session — least surprise. + +Single-quote wrapping was the first cut and was rejected for two +reasons: it produces ugly `'…'\''…'` quote walls when paths contain +apostrophes, and it does not match any native terminal's drag-insert +convention. + +The metaset escaped is intentionally wide: + +``` +[whitespace ' " \ $ ` ! ( ) ; & | < > * ? [ ] { } ~ #] +``` + +Filenames legitimately contain `&` `(` `)` etc., and a too-narrow set +will break in real shells (`ls foo&bar.png` backgrounds `ls foo`). +Backslash before a normal letter is a no-op in bash, so over-escaping +is harmless; under-escaping silently corrupts. + +## 5. Resolved payloads go through `term.paste()`, not the PTY directly + +The first cut wrote the resolved path straight to the PTY via +`enqueueInput`. The path arrived at the agent — but agents like Claude +Code do not act on a path that arrives byte-by-byte the same way they +act on a path that arrives inside a paste. CC enables bracketed-paste +mode (`\x1b[?2004h`) on startup; the terminal is then expected to +wrap pasted content in `\x1b[200~ … \x1b[201~`. CC's input parser +inspects the wrapped payload, recognises image-file paths, and +attaches them as `[Image #N]`. Direct PTY writes carry no such +wrapper, so CC sees literal typing and skips the attachment. + +The fix is to deliver paste / drop payloads via xterm's +`term.paste(data)`. xterm reads the agent's current bracketed-paste +setting and either emits the markers or not, automatically. The +`onData` event still fires (the existing PTY forwarding pipeline +keeps working), and the spec keeps the rule observable: when +bracketed-paste mode is on, the PTY sees the marker bytes. + +A second-order benefit: multi-line text pastes (e.g. a snippet +copied from a doc) now arrive as a single bracketed paste instead +of N separate "typed line" events, which is what shells and agents +expect for proper history and indentation handling. + +## Why the legacy SaveClipboardImage handler is removed + +Keeping it as a "backward compatibility" shim invites future callers to +reach for the simple `→ image path` API and re-create the basename +trap. The handler has exactly one historical caller (the old paste +flow), and that caller migrates in this change. Removing it now is +cheaper than removing it later. + +## Why no UX feedback when a drop is over the size cap or fails + +`dataTransferToShellArgs` silently filters out files that fail to +resolve (over the 50 MB cap, `getPathForFile` returns `''` and there +are no bytes to fall back on, etc.). The user-visible result of +"dropped a 200 MB video and nothing happened" is acceptable for the +v1 of this feature; a toast or status-bar notice is intentionally out +of scope. If real users hit it, it earns its own change. + +## Capture-phase listener teardown + +The two `addEventListener('…', …, true)` registrations are paired with +`removeEventListener('…', …, true)` inside `onCleanup`. The third +argument MUST match between add and remove or the listener leaks. +This is the kind of thing that breaks silently and only shows up +under HMR / repeated mount-unmount; the spec calls it out explicitly +to keep it from regressing. diff --git a/openspec/changes/archive/2026-04-28-support-paste-images/proposal.md b/openspec/changes/archive/2026-04-28-support-paste-images/proposal.md new file mode 100644 index 00000000..da96ace9 --- /dev/null +++ b/openspec/changes/archive/2026-04-28-support-paste-images/proposal.md @@ -0,0 +1,84 @@ +# Support Paste-Image into Terminal + +## Why + +Pasting or dragging an image into a terminal session (typically driving an +agent like Claude Code) used to insert only the file's basename. Agents then +either errored out (`No such file: foo.png`) or fell back to literal text +about a filename, which was useless. The user reported the regression against +v1.2.1 with: "I expected the behavior that Claude Code supports." + +Two underlying causes: + +1. The terminal had no `drop` handler. xterm fell back to the dragged item's + `text/plain` payload, which the OS populates with the basename only. +2. The Cmd+V handler called `navigator.clipboard.readText()` first. On + macOS, copying an image file in Finder puts the basename on the + clipboard as `text/plain`, so the existing image-fallback never ran — + the user got the same useless basename. + +The user-visible promise we want to make is the same one the macOS Terminal +makes: dragging or pasting a file into the terminal types its absolute path, +and pasting raw image bytes (e.g. a screenshot) saves the bytes to a temp +file and types that path. The receiving agent then reads the file from disk +exactly as if the user had typed the path themselves. + +## What changes + +- New IPC `ResolveClipboardPaste`: a single main-process call that picks the + most useful clipboard representation in priority order (file URL → image + → plain text → empty), so the renderer no longer has to ask twice and + no longer falls into the basename trap. +- New IPC `SaveDroppedImage`: receives bytes for a dropped item that has no + filesystem path (e.g. an `` dragged from a browser tab), writes them + to a sanitized temp file, and returns the absolute path. +- `webUtils.getPathForFile` exposed as `window.electron.getPathForFile` + through the existing preload bridge — the contextBridge-safe replacement + for the `File.path` field that Electron 32+ no longer ships. +- `TerminalView` registers capture-phase `dragover` / `drop` listeners on + its container so xterm's own listeners cannot win and insert the + basename. Multiple dropped files are resolved to absolute paths and + inserted as a space-joined, backslash-escaped string. +- New helper `src/lib/terminalDrop.ts` exporting `escapePath(p)` (was + `shellQuote` in the first cut) and `dataTransferToShellArgs(dt)`. + Backslash-escape is the single quoting rule across all targets — it + matches macOS Terminal / iTerm2 / VS Code muscle memory and renders + cleanly inside agent prompts. +- Cmd+V binding in `TerminalView` switches from the two-call + (`readText` → `SaveClipboardImage`) flow to the single-call + `ResolveClipboardPaste` flow and quotes file paths with `escapePath`. +- Drop payloads cross the contextBridge IPC boundary as base64-encoded + strings. The naive `Uint8Array` payload is destroyed by the existing + `JSON.parse(JSON.stringify(args))` round-trip in `src/lib/ipc.ts`, + silently breaking browser-image drops; base64 keeps the envelope + JSON-clean and is decoded back to a `Buffer` in the main process. +- The legacy `SaveClipboardImage` IPC and its main-process handler are + removed once `ResolveClipboardPaste` is shipping; nothing else calls it. + +## Impact + +- **New capability:** `terminal-image-paste`. No prior spec — the whole + capability is `## ADDED Requirements`. +- **IPC surface:** +`ResolveClipboardPaste`, +`SaveDroppedImage`, + −`SaveClipboardImage`. Preload allowlist, IPC enum, and `trace.ts` + `NEVER_SAFE` all updated. +- **Preload bridge:** new `getPathForFile` function exposed through + `contextBridge`, backed by `webUtils.getPathForFile`. No `nodeIntegration` + change — preload remains the only privileged surface. +- **Renderer:** new `src/lib/terminalDrop.ts`; `TerminalView.tsx` gains + capture-phase drop listeners and uses the new IPCs. No changes to the + PTY / output pipeline. +- **Type surface:** `Window['electron']` gains `getPathForFile(file: File): string`. +- **Persistence / state:** none. Dropped/pasted bytes are written to + `os.tmpdir()` with a `parallel-code-drop--` + prefix; lifetime tracking is intentionally not specified — the OS + cleans the temp dir on its own schedule. +- **Platforms:** macOS and Linux only, per project scope. Clipboard + format detection covers `public.file-url` (macOS) and `text/uri-list` + (Linux); no Windows clipboard reader. +- **Out of scope:** Windows support (clipboard format `FileNameW`, + Windows-reserved character sanitization, double-quote vs backslash + trade-off); per-agent insertion conventions (e.g. `@path` for Claude + Code, markdown image links); paste/drop into UI surfaces other than + the terminal (e.g. the new-task dialog has its own GitHub-URL drop + handler covered elsewhere). diff --git a/openspec/changes/archive/2026-04-28-support-paste-images/specs/terminal-image-paste/spec.md b/openspec/changes/archive/2026-04-28-support-paste-images/specs/terminal-image-paste/spec.md new file mode 100644 index 00000000..7abe15d6 --- /dev/null +++ b/openspec/changes/archive/2026-04-28-support-paste-images/specs/terminal-image-paste/spec.md @@ -0,0 +1,309 @@ +# Terminal Image Paste Specification + +## ADDED Requirements + +### Requirement: Cmd+V resolves the clipboard to its most useful representation + +The system SHALL resolve the clipboard to a single representation chosen +by priority — a filesystem path the agent can read, an image saved to a +temp file, or plain text — and never insert a bare basename when the +underlying file URL was available. Resolution happens when the user +invokes the paste keybinding while the terminal has focus. + +#### Scenario: Finder-copied image file pastes its absolute path + +- **WHEN** the user copies an image file in macOS Finder and presses + Cmd+V in the terminal +- **THEN** the absolute path to that file is typed into the terminal +- **AND** the typed value is escaped per the path-escaping requirement +- **AND** the basename alone is never inserted + +#### Scenario: Linux GNOME file-manager copy pastes its absolute path + +- **WHEN** the user copies a file in a GNOME-family file manager + (Nautilus, Nemo, Caja) and presses the paste binding +- **THEN** the system reads the `x-special/gnome-copied-files` clipboard + flavour, skips the leading `copy` / `cut` verb line, takes the first + remaining `file://` URL, converts it to an absolute path, and types + the escaped path +- **AND** the GNOME flavour is checked before `text/uri-list` so it + wins when both flavours are present + +#### Scenario: Other Linux file managers fall back to text/uri-list + +- **WHEN** the user copies a file from a non-GNOME Linux file manager + (KDE Dolphin, Xfce Thunar, etc.) that publishes only the cross-desktop + RFC 2483 format +- **THEN** the system reads the `text/uri-list` clipboard flavour, takes + the first non-comment `file://` URL, converts it to an absolute path, + and types the escaped path + +#### Scenario: Raw image on the clipboard is saved and pasted as a path + +- **WHEN** the clipboard does not contain a file URL but does contain + raster image bytes (e.g. a screenshot) +- **THEN** the system writes the image as PNG to the OS temp directory +- **AND** types the escaped absolute path of that file + +#### Scenario: Plain text on the clipboard pastes as text + +- **WHEN** the clipboard contains neither a file URL nor a raster image + but does contain plain text +- **THEN** that text is typed verbatim, with no quoting and no escaping + +#### Scenario: Empty clipboard does nothing + +- **WHEN** the clipboard contains no file, no image, and no text +- **THEN** no input is sent to the terminal +- **AND** the keypress is consumed (the keybinding's `preventDefault` + still runs so xterm does not insert a fallback) + +#### Scenario: Resolution happens in the main process via a single IPC + +- **WHEN** the renderer needs to resolve a paste +- **THEN** it makes exactly one IPC call (`ResolveClipboardPaste`) and + switches on the returned `kind` +- **AND** it does NOT call `navigator.clipboard.readText()` first + +### Requirement: File drop on the terminal types absolute path(s) + +The system SHALL type each dropped file's absolute path into the +terminal — space-joined and escaped — instead of allowing the browser +default to insert the basename. This applies whenever the user drops +one or more files onto the terminal viewport. + +#### Scenario: Single file dropped from the OS file manager + +- **WHEN** the user drags one file from Finder / Nautilus and drops it + on the terminal +- **THEN** the file's absolute path is typed into the terminal, escaped + per the path-escaping requirement +- **AND** the basename alone is never inserted + +#### Scenario: Multiple files dropped at once + +- **WHEN** the user drops two or more files in a single drop +- **THEN** every file's escaped path is typed +- **AND** the paths are joined with a single ASCII space, in the order + the OS reported them +- **AND** no trailing space is appended + +#### Scenario: Drop target is the terminal even when not focused + +- **WHEN** the user drops a file on the terminal while focus is in a + different element (e.g. the prompt input or sidebar) +- **THEN** the terminal is focused before the path is typed +- **AND** the resulting input arrives at the terminal, not at the + previously-focused element + +#### Scenario: Drop handler runs in DOM capture phase + +- **WHEN** a `drop` event fires on the terminal container +- **THEN** the application's handler runs in the capture phase, calls + `preventDefault()` and `stopPropagation()`, and xterm's own bubble + phase listener does not run + +#### Scenario: Capture-phase listeners are torn down on unmount + +- **WHEN** a `TerminalView` unmounts +- **THEN** the application's `dragover` and `drop` listeners are removed + with the same `{ capture: true }` setting they were registered with +- **AND** subsequent drops on the unmounted node do nothing + +#### Scenario: Drop without files is ignored + +- **WHEN** a `drop` fires on the terminal but `dataTransfer.files` is + empty (e.g. text-only drag, GitHub URL drag) +- **THEN** the application's terminal drop handler returns without + preventing default and without typing anything +- **AND** the surrounding application's URL-drop handler may still + process the drop normally + +### Requirement: Browser-origin items without a path are persisted to a temp file + +The system SHALL read a dropped item's bytes and persist them to the OS +temp directory, then type that temp path, whenever the item has no +backing filesystem path (e.g. an `` dragged from a browser tab, an +item from a virtual file system). + +#### Scenario: Browser image drop produces a usable temp path + +- **WHEN** the user drags an `` element from a browser into the + terminal +- **AND** `webUtils.getPathForFile(file)` returns the empty string +- **THEN** the renderer reads the file as an `ArrayBuffer`, + base64-encodes it, and calls `SaveDroppedImage` with `{ name, data }` +- **AND** the main process decodes the base64 to a `Buffer` and writes + it to the OS temp directory +- **AND** the returned absolute path is typed into the terminal, + escaped per the path-escaping requirement + +#### Scenario: Filename for the temp file is sanitized + +- **WHEN** the renderer-supplied filename contains path separators (`/`, + `\`), a NUL byte, leading dots, or extra surrounding whitespace +- **THEN** the main process strips those characters / runs before + writing +- **AND** clamps the remaining basename to 200 characters +- **AND** prefixes the basename with + `parallel-code-drop--<6-hex>-` where `<6-hex>` is a fresh + random suffix per call so two same-name drops landing in the same + millisecond never collide on disk +- **AND** when the sanitized basename is empty, falls back to + `parallel-code-drop--<6-hex>.png` + +#### Scenario: Renderer caps oversized drops without surfacing an error + +- **WHEN** the dropped item is larger than 50 MB +- **THEN** the renderer skips that item without sending it across IPC +- **AND** continues to process other items in the same drop + +#### Scenario: One failing item does not cancel the rest of the drop + +- **WHEN** a mixed drop contains both items that resolve successfully + (path-backed Files or path-less items whose bytes save cleanly) + and items whose resolution throws (oversized, `arrayBuffer()` rejects, + `SaveDroppedImage` rejects, base64 encoding fails) +- **THEN** the failing items are silently filtered out +- **AND** the successfully-resolved items are still typed into the + terminal as a space-joined escaped string +- **AND** no error is surfaced through the drop handler's catch + block (the failure is local to the failing item) + +#### Scenario: Binary payload survives the IPC envelope + +- **WHEN** binary bytes are sent over `SaveDroppedImage` +- **THEN** they are encoded as base64 in the renderer and decoded with + `Buffer.from(data, 'base64')` in the main process +- **AND** the file written to disk has the exact byte length of the + source payload +- **AND** they are NOT sent as `Uint8Array` or `ArrayBuffer` (the + application's `invoke()` wrapper destroys typed arrays via its + `JSON.parse(JSON.stringify(args))` round-trip) + +### Requirement: Paths are escaped with backslash before insertion + +Paths typed into the terminal SHALL be backslash-escaped before +insertion so they round-trip correctly through both POSIX shells (bash, +zsh) and CLI agents that parse paths from prompt text. + +#### Scenario: Safe paths pass through unchanged + +- **WHEN** a path contains only characters from + `[A-Za-z0-9_./@:+,%=-]` +- **THEN** it is typed verbatim with no escape characters added + +#### Scenario: Whitespace is escaped + +- **WHEN** a path contains a space or any other whitespace character +- **THEN** each whitespace character is preceded by a single backslash + +#### Scenario: Shell metacharacters are escaped + +- **WHEN** a path contains any of the characters + `' " \ $ \` ! ( ) ; & | < > \* ? [ ] { } ~ #` +- **THEN** each such character is preceded by a single backslash + +#### Scenario: Empty path renders as empty bash literal + +- **WHEN** a path is the empty string +- **THEN** it renders as `""` (two ASCII double quotes) so a real shell + receives an explicit empty argument rather than dropping the position + +#### Scenario: Multiple paths are space-joined after escaping + +- **WHEN** more than one resolved path is being inserted in one drop +- **THEN** each path is escaped individually +- **AND** the escaped paths are joined with a single ASCII space + +### Requirement: Resolved paste/drop content flows through xterm's paste pipeline + +The system SHALL deliver resolved paste and drop payloads (file paths, +image temp paths, plain text) to the agent through `term.paste()`, +never via a direct PTY write. This causes xterm to wrap the payload +with bracketed-paste markers (`\x1b[200~` … `\x1b[201~`) when the agent +has bracketed-paste mode enabled, which CLI agents like Claude Code use +to distinguish "the user pasted this" from "the user typed this +character by character" — the former is what triggers automatic +image-file recognition and attachment. + +#### Scenario: Cmd+V file path uses term.paste + +- **WHEN** the paste handler resolves the clipboard to `kind: 'file'` + or `kind: 'image'` +- **THEN** the escaped path is delivered via `term.paste(path)` +- **AND** is NOT delivered via direct PTY write + +#### Scenario: Cmd+V plain text uses term.paste + +- **WHEN** the paste handler resolves the clipboard to `kind: 'text'` +- **THEN** the text is delivered via `term.paste(text)` so a paste of + multiple lines into a bracketed-paste-aware shell is treated as a + single paste rather than a sequence of typed lines + +#### Scenario: Drop payload uses term.paste + +- **WHEN** the drop handler has resolved the dropped files into a + space-joined escaped path string +- **THEN** the string is delivered via `term.paste(args)` after + `term.focus()` + +#### Scenario: Bracketed-paste markers appear when the agent enables them + +- **WHEN** the agent has previously sent the bracketed-paste-mode + enable sequence (`\x1b[?2004h`) +- **THEN** the bytes the PTY actually receives for a paste/drop + delivery start with `\x1b[200~` and end with `\x1b[201~` +- **AND** when bracketed-paste mode is disabled (or never enabled), + the bytes the PTY receives are the payload alone with no marker + bytes + +### Requirement: IPC contract for clipboard and drop + +The system SHALL communicate clipboard and drop intents through dedicated +IPC channels declared in `electron/ipc/channels.ts` and allowlisted in +the preload bridge. + +#### Scenario: ResolveClipboardPaste channel exists + +- **WHEN** the renderer invokes `IPC.ResolveClipboardPaste` +- **THEN** the main process returns a tagged-union object with one of + the shapes `{ kind: 'file', path: string }`, + `{ kind: 'image', path: string }`, `{ kind: 'text', text: string }`, + or `{ kind: 'empty' }` +- **AND** no other shape is ever returned + +#### Scenario: SaveDroppedImage channel exists + +- **WHEN** the renderer invokes `IPC.SaveDroppedImage` with + `{ name: string, data: string /* base64 */ }` +- **THEN** the main process returns the absolute path of the file it + wrote +- **AND** rejects payloads whose `data` is not a string + +#### Scenario: getPathForFile is exposed via the preload bridge + +- **WHEN** the renderer calls `window.electron.getPathForFile(file)` + with a `File` object obtained from a drop event +- **THEN** the function returns the absolute filesystem path for files + that have one +- **AND** returns the empty string for files that do not (browser-origin + items, virtual file system items, etc.) +- **AND** returns the empty string instead of throwing on any internal + error from `webUtils.getPathForFile` + +#### Scenario: Both new channels are flagged NEVER_SAFE for tracing + +- **WHEN** the IPC trace module initialises +- **THEN** both `ResolveClipboardPaste` and `SaveDroppedImage` are + members of `NEVER_SAFE` +- **AND** their argument and return payloads are never written to the + debug log + +#### Scenario: The legacy SaveClipboardImage channel is removed + +- **WHEN** this change is shipped +- **THEN** the IPC enum no longer contains `SaveClipboardImage` +- **AND** the preload allowlist no longer contains + `'save_clipboard_image'` +- **AND** no main-process handler for the legacy channel is registered diff --git a/openspec/changes/archive/2026-04-28-support-paste-images/tasks.md b/openspec/changes/archive/2026-04-28-support-paste-images/tasks.md new file mode 100644 index 00000000..5a261f5a --- /dev/null +++ b/openspec/changes/archive/2026-04-28-support-paste-images/tasks.md @@ -0,0 +1,83 @@ +# Tasks — Support Paste-Image into Terminal + +Most of the code is already on the `feature/support-paste-images` branch. +The unchecked items are the gaps the spec exposed during retro-review. + +## Already implemented + +- [x] Add `IPC.ResolveClipboardPaste` and `IPC.SaveDroppedImage` to + `electron/ipc/channels.ts`. +- [x] Add the same channels to the preload allowlist in + `electron/preload.cjs`. +- [x] Add both channels to `NEVER_SAFE` in `electron/ipc/trace.ts` so + paths and bytes are never logged. +- [x] Implement `ResolveClipboardPaste` in `electron/ipc/register.ts`: + priority order file URL → image → text → empty; macOS reads + `public.file-url`, Linux reads `text/uri-list`; image fallback + writes a PNG to `os.tmpdir()`. +- [x] Implement `SaveDroppedImage` in `electron/ipc/register.ts`: + sanitize the supplied filename, write to `os.tmpdir()` with the + `parallel-code-drop--` prefix, return the + absolute path. +- [x] Expose `webUtils.getPathForFile(file)` from `electron/preload.cjs` + as `window.electron.getPathForFile`; update the `Window['electron']` + type in `src/lib/ipc.ts`. +- [x] Create `src/lib/terminalDrop.ts` with `shellQuote` (initial cut + using single-quote wrap) and `dataTransferToShellArgs`. +- [x] Add capture-phase `dragover` and `drop` listeners on + `TerminalView`'s container; teardown in `onCleanup`. +- [x] Migrate the Cmd+V handler in `TerminalView` to use + `ResolveClipboardPaste` and quote file paths through `shellQuote`. +- [x] Unit tests for `shellQuote` in `src/lib/terminalDrop.test.ts`. +- [x] `npx tsc --noEmit` (renderer + electron tsconfigs) and + `npx vitest run` pass. + +## Remaining + +- [x] Rewrite `shellQuote` in `src/lib/terminalDrop.ts` to + `escapePath`: backslash-escape the metaset + `[whitespace ' " \ $ \` ! ( ) ; & | < > \* ? [ ] { } ~ #]`. The +empty string becomes `""` (literal empty argv). Update callers +(`TerminalView`paste handler,`dataTransferToShellArgs`). +- [x] Update `src/lib/terminalDrop.test.ts` to cover the new escape + rule: empty path → `""`; safe path passes through; spaces escape; + embedded apostrophes escape; embedded `$` `` ` `` `(` `)` `&` + escape; mixed-meta path escapes every metachar; backslash in path + itself escapes. +- [x] Fix the binary-IPC bug: change `dataTransferToShellArgs` to + base64-encode the dropped bytes before calling `SaveDroppedImage`, + and update `SaveDroppedImage` to accept `data: string` and decode + via `Buffer.from(data, 'base64')`. Strengthen the main-process + validation accordingly. +- [x] Add a renderer-side regression test (vitest, no DOM) that exercises + `dataTransferToShellArgs` end-to-end with a stubbed + `window.electron.invoke` / `getPathForFile`, asserting the IPC is + called with a base64 string for a path-less File and the resulting + command line is correctly escaped. +- [x] Delete the legacy `SaveClipboardImage` flow: remove the + `IPC.SaveClipboardImage` enum entry, the `'save_clipboard_image'` + preload allowlist line, the entry in `trace.ts NEVER_SAFE`, and + the handler implementation in `electron/ipc/register.ts`. The + `clipboardImagePath` constant moves into the + `ResolveClipboardPaste` handler closure. +- [x] Codex review follow-up: parse `x-special/gnome-copied-files` + ahead of `text/uri-list` in `readClipboardFileUrl` so GNOME-family + file managers (Files / Nautilus, Nemo, Caja) get the absolute + path treatment they advertise rather than the basename fallback. +- [x] Codex review follow-up: wrap each per-file resolution in + `pathForDroppedItem` in its own try/catch so one unreadable + browser/virtual-file in a mixed drop never cancels the whole + `Promise.all` and silently drops the resolvable siblings. +- [x] Codex review follow-up: append a 6-char `crypto.randomBytes(3)` + suffix to the `parallel-code-drop-…` temp filename so two + same-name drops landing inside the same millisecond don't + overwrite each other on disk. +- [x] Switch paste/drop delivery from `enqueueInput` (direct PTY write) to + `term.paste()` so xterm emits bracketed-paste markers + (`\x1b[200~ … \x1b[201~`) when the agent has bracketed-paste mode + on. Without this, CLI agents like Claude Code see a dropped path + as literal typed text and skip the file-attachment recognition + that turns the path into an `[Image #N]` reference. +- [x] Validate with `npm run typecheck`, `npm test`, + `npm run format:check`, `npm run lint`, and + `openspec validate --all --strict`. diff --git a/openspec/specs/terminal-image-paste/spec.md b/openspec/specs/terminal-image-paste/spec.md new file mode 100644 index 00000000..46e51844 --- /dev/null +++ b/openspec/specs/terminal-image-paste/spec.md @@ -0,0 +1,318 @@ +# Terminal Image Paste Specification + +## Purpose + +Allow users to paste or drop images and files into a terminal so that CLI agents +(Claude Code, etc.) receive a usable absolute filesystem path — not a basename, +not raw bytes, and not a browser-default text fallback. Resolution covers the +macOS and Linux clipboard flavours that file managers actually publish, and +both Cmd+V paste and drag-and-drop entry points, delivering the result through +xterm's bracketed-paste pipeline so agents recognize it as a paste. + +## Requirements + +### Requirement: Cmd+V resolves the clipboard to its most useful representation + +The system SHALL resolve the clipboard to a single representation chosen +by priority — a filesystem path the agent can read, an image saved to a +temp file, or plain text — and never insert a bare basename when the +underlying file URL was available. Resolution happens when the user +invokes the paste keybinding while the terminal has focus. + +#### Scenario: Finder-copied image file pastes its absolute path + +- **WHEN** the user copies an image file in macOS Finder and presses + Cmd+V in the terminal +- **THEN** the absolute path to that file is typed into the terminal +- **AND** the typed value is escaped per the path-escaping requirement +- **AND** the basename alone is never inserted + +#### Scenario: Linux GNOME file-manager copy pastes its absolute path + +- **WHEN** the user copies a file in a GNOME-family file manager + (Nautilus, Nemo, Caja) and presses the paste binding +- **THEN** the system reads the `x-special/gnome-copied-files` clipboard + flavour, skips the leading `copy` / `cut` verb line, takes the first + remaining `file://` URL, converts it to an absolute path, and types + the escaped path +- **AND** the GNOME flavour is checked before `text/uri-list` so it + wins when both flavours are present + +#### Scenario: Other Linux file managers fall back to text/uri-list + +- **WHEN** the user copies a file from a non-GNOME Linux file manager + (KDE Dolphin, Xfce Thunar, etc.) that publishes only the cross-desktop + RFC 2483 format +- **THEN** the system reads the `text/uri-list` clipboard flavour, takes + the first non-comment `file://` URL, converts it to an absolute path, + and types the escaped path + +#### Scenario: Raw image on the clipboard is saved and pasted as a path + +- **WHEN** the clipboard does not contain a file URL but does contain + raster image bytes (e.g. a screenshot) +- **THEN** the system writes the image as PNG to the OS temp directory +- **AND** types the escaped absolute path of that file + +#### Scenario: Plain text on the clipboard pastes as text + +- **WHEN** the clipboard contains neither a file URL nor a raster image + but does contain plain text +- **THEN** that text is typed verbatim, with no quoting and no escaping + +#### Scenario: Empty clipboard does nothing + +- **WHEN** the clipboard contains no file, no image, and no text +- **THEN** no input is sent to the terminal +- **AND** the keypress is consumed (the keybinding's `preventDefault` + still runs so xterm does not insert a fallback) + +#### Scenario: Resolution happens in the main process via a single IPC + +- **WHEN** the renderer needs to resolve a paste +- **THEN** it makes exactly one IPC call (`ResolveClipboardPaste`) and + switches on the returned `kind` +- **AND** it does NOT call `navigator.clipboard.readText()` first + +### Requirement: File drop on the terminal types absolute path(s) + +The system SHALL type each dropped file's absolute path into the +terminal — space-joined and escaped — instead of allowing the browser +default to insert the basename. This applies whenever the user drops +one or more files onto the terminal viewport. + +#### Scenario: Single file dropped from the OS file manager + +- **WHEN** the user drags one file from Finder / Nautilus and drops it + on the terminal +- **THEN** the file's absolute path is typed into the terminal, escaped + per the path-escaping requirement +- **AND** the basename alone is never inserted + +#### Scenario: Multiple files dropped at once + +- **WHEN** the user drops two or more files in a single drop +- **THEN** every file's escaped path is typed +- **AND** the paths are joined with a single ASCII space, in the order + the OS reported them +- **AND** no trailing space is appended + +#### Scenario: Drop target is the terminal even when not focused + +- **WHEN** the user drops a file on the terminal while focus is in a + different element (e.g. the prompt input or sidebar) +- **THEN** the terminal is focused before the path is typed +- **AND** the resulting input arrives at the terminal, not at the + previously-focused element + +#### Scenario: Drop handler runs in DOM capture phase + +- **WHEN** a `drop` event fires on the terminal container +- **THEN** the application's handler runs in the capture phase, calls + `preventDefault()` and `stopPropagation()`, and xterm's own bubble + phase listener does not run + +#### Scenario: Capture-phase listeners are torn down on unmount + +- **WHEN** a `TerminalView` unmounts +- **THEN** the application's `dragover` and `drop` listeners are removed + with the same `{ capture: true }` setting they were registered with +- **AND** subsequent drops on the unmounted node do nothing + +#### Scenario: Drop without files is ignored + +- **WHEN** a `drop` fires on the terminal but `dataTransfer.files` is + empty (e.g. text-only drag, GitHub URL drag) +- **THEN** the application's terminal drop handler returns without + preventing default and without typing anything +- **AND** the surrounding application's URL-drop handler may still + process the drop normally + +### Requirement: Browser-origin items without a path are persisted to a temp file + +The system SHALL read a dropped item's bytes and persist them to the OS +temp directory, then type that temp path, whenever the item has no +backing filesystem path (e.g. an `` dragged from a browser tab, an +item from a virtual file system). + +#### Scenario: Browser image drop produces a usable temp path + +- **WHEN** the user drags an `` element from a browser into the + terminal +- **AND** `webUtils.getPathForFile(file)` returns the empty string +- **THEN** the renderer reads the file as an `ArrayBuffer`, + base64-encodes it, and calls `SaveDroppedImage` with `{ name, data }` +- **AND** the main process decodes the base64 to a `Buffer` and writes + it to the OS temp directory +- **AND** the returned absolute path is typed into the terminal, + escaped per the path-escaping requirement + +#### Scenario: Filename for the temp file is sanitized + +- **WHEN** the renderer-supplied filename contains path separators (`/`, + `\`), a NUL byte, leading dots, or extra surrounding whitespace +- **THEN** the main process strips those characters / runs before + writing +- **AND** clamps the remaining basename to 200 characters +- **AND** prefixes the basename with + `parallel-code-drop--<6-hex>-` where `<6-hex>` is a fresh + random suffix per call so two same-name drops landing in the same + millisecond never collide on disk +- **AND** when the sanitized basename is empty, falls back to + `parallel-code-drop--<6-hex>.png` + +#### Scenario: Renderer caps oversized drops without surfacing an error + +- **WHEN** the dropped item is larger than 50 MB +- **THEN** the renderer skips that item without sending it across IPC +- **AND** continues to process other items in the same drop + +#### Scenario: One failing item does not cancel the rest of the drop + +- **WHEN** a mixed drop contains both items that resolve successfully + (path-backed Files or path-less items whose bytes save cleanly) + and items whose resolution throws (oversized, `arrayBuffer()` rejects, + `SaveDroppedImage` rejects, base64 encoding fails) +- **THEN** the failing items are silently filtered out +- **AND** the successfully-resolved items are still typed into the + terminal as a space-joined escaped string +- **AND** no error is surfaced through the drop handler's catch + block (the failure is local to the failing item) + +#### Scenario: Binary payload survives the IPC envelope + +- **WHEN** binary bytes are sent over `SaveDroppedImage` +- **THEN** they are encoded as base64 in the renderer and decoded with + `Buffer.from(data, 'base64')` in the main process +- **AND** the file written to disk has the exact byte length of the + source payload +- **AND** they are NOT sent as `Uint8Array` or `ArrayBuffer` (the + application's `invoke()` wrapper destroys typed arrays via its + `JSON.parse(JSON.stringify(args))` round-trip) + +### Requirement: Paths are escaped with backslash before insertion + +Paths typed into the terminal SHALL be backslash-escaped before +insertion so they round-trip correctly through both POSIX shells (bash, +zsh) and CLI agents that parse paths from prompt text. + +#### Scenario: Safe paths pass through unchanged + +- **WHEN** a path contains only characters from + `[A-Za-z0-9_./@:+,%=-]` +- **THEN** it is typed verbatim with no escape characters added + +#### Scenario: Whitespace is escaped + +- **WHEN** a path contains a space or any other whitespace character +- **THEN** each whitespace character is preceded by a single backslash + +#### Scenario: Shell metacharacters are escaped + +- **WHEN** a path contains any of the characters + `' " \ $ \` ! ( ) ; & | < > \* ? [ ] { } ~ #` +- **THEN** each such character is preceded by a single backslash + +#### Scenario: Empty path renders as empty bash literal + +- **WHEN** a path is the empty string +- **THEN** it renders as `""` (two ASCII double quotes) so a real shell + receives an explicit empty argument rather than dropping the position + +#### Scenario: Multiple paths are space-joined after escaping + +- **WHEN** more than one resolved path is being inserted in one drop +- **THEN** each path is escaped individually +- **AND** the escaped paths are joined with a single ASCII space + +### Requirement: Resolved paste/drop content flows through xterm's paste pipeline + +The system SHALL deliver resolved paste and drop payloads (file paths, +image temp paths, plain text) to the agent through `term.paste()`, +never via a direct PTY write. This causes xterm to wrap the payload +with bracketed-paste markers (`\x1b[200~` … `\x1b[201~`) when the agent +has bracketed-paste mode enabled, which CLI agents like Claude Code use +to distinguish "the user pasted this" from "the user typed this +character by character" — the former is what triggers automatic +image-file recognition and attachment. + +#### Scenario: Cmd+V file path uses term.paste + +- **WHEN** the paste handler resolves the clipboard to `kind: 'file'` + or `kind: 'image'` +- **THEN** the escaped path is delivered via `term.paste(path)` +- **AND** is NOT delivered via direct PTY write + +#### Scenario: Cmd+V plain text uses term.paste + +- **WHEN** the paste handler resolves the clipboard to `kind: 'text'` +- **THEN** the text is delivered via `term.paste(text)` so a paste of + multiple lines into a bracketed-paste-aware shell is treated as a + single paste rather than a sequence of typed lines + +#### Scenario: Drop payload uses term.paste + +- **WHEN** the drop handler has resolved the dropped files into a + space-joined escaped path string +- **THEN** the string is delivered via `term.paste(args)` after + `term.focus()` + +#### Scenario: Bracketed-paste markers appear when the agent enables them + +- **WHEN** the agent has previously sent the bracketed-paste-mode + enable sequence (`\x1b[?2004h`) +- **THEN** the bytes the PTY actually receives for a paste/drop + delivery start with `\x1b[200~` and end with `\x1b[201~` +- **AND** when bracketed-paste mode is disabled (or never enabled), + the bytes the PTY receives are the payload alone with no marker + bytes + +### Requirement: IPC contract for clipboard and drop + +The system SHALL communicate clipboard and drop intents through dedicated +IPC channels declared in `electron/ipc/channels.ts` and allowlisted in +the preload bridge. + +#### Scenario: ResolveClipboardPaste channel exists + +- **WHEN** the renderer invokes `IPC.ResolveClipboardPaste` +- **THEN** the main process returns a tagged-union object with one of + the shapes `{ kind: 'file', path: string }`, + `{ kind: 'image', path: string }`, `{ kind: 'text', text: string }`, + or `{ kind: 'empty' }` +- **AND** no other shape is ever returned + +#### Scenario: SaveDroppedImage channel exists + +- **WHEN** the renderer invokes `IPC.SaveDroppedImage` with + `{ name: string, data: string /* base64 */ }` +- **THEN** the main process returns the absolute path of the file it + wrote +- **AND** rejects payloads whose `data` is not a string + +#### Scenario: getPathForFile is exposed via the preload bridge + +- **WHEN** the renderer calls `window.electron.getPathForFile(file)` + with a `File` object obtained from a drop event +- **THEN** the function returns the absolute filesystem path for files + that have one +- **AND** returns the empty string for files that do not (browser-origin + items, virtual file system items, etc.) +- **AND** returns the empty string instead of throwing on any internal + error from `webUtils.getPathForFile` + +#### Scenario: Both new channels are flagged NEVER_SAFE for tracing + +- **WHEN** the IPC trace module initialises +- **THEN** both `ResolveClipboardPaste` and `SaveDroppedImage` are + members of `NEVER_SAFE` +- **AND** their argument and return payloads are never written to the + debug log + +#### Scenario: The legacy SaveClipboardImage channel is removed + +- **WHEN** this change is shipped +- **THEN** the IPC enum no longer contains `SaveClipboardImage` +- **AND** the preload allowlist no longer contains + `'save_clipboard_image'` +- **AND** no main-process handler for the legacy channel is registered diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 00dcfb24..6b140e66 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -15,8 +15,15 @@ import { matchesKeyEvent } from '../lib/keybindings'; import { store, setTaskLastInputAt } from '../store/store'; import { warn as logWarn } from '../lib/log'; import { registerTerminal, unregisterTerminal, markDirty } from '../lib/terminalFitManager'; +import { dataTransferToShellArgs, escapePath } from '../lib/terminalDrop'; import type { PtyOutput } from '../ipc/types'; +type ClipboardPaste = + | { kind: 'file'; path: string } + | { kind: 'image'; path: string } + | { kind: 'text'; text: string } + | { kind: 'empty' }; + // Pre-computed base64 lookup table — avoids atob() intermediate string allocation. const B64_LOOKUP = new Uint8Array(128); for (let i = 0; i < 64; i++) { @@ -243,14 +250,24 @@ export function TerminalView(props: TerminalViewProps) { if (binding.action === 'paste') { (async () => { - const text = await navigator.clipboard.readText().catch(() => ''); - if (text) { - enqueueInput(text); + // Single round-trip resolver — main process picks the most useful + // representation: a file path (Finder copy), then a saved image + // (screenshot), then plain text. Pasting an image-file copy as + // its bare basename was the bug we're avoiding here. + // + // We funnel the result through term.paste() rather than writing + // to the PTY directly so xterm wraps the payload in bracketed + // paste markers (\x1b[200~ … \x1b[201~) when the agent has + // bracketed-paste mode on. CLI agents like Claude Code use that + // wrapper to recognise "the user pasted a file path", which is + // what triggers automatic image attachment instead of treating + // the path as literal typed text. + const paste = await invoke(IPC.ResolveClipboardPaste); + if (paste.kind === 'file' || paste.kind === 'image') { + term?.paste(escapePath(paste.path)); return; } - // Fall back to clipboard image → save to temp file and paste path - const filePath = await invoke(IPC.SaveClipboardImage); - if (filePath) enqueueInput(filePath); + if (paste.kind === 'text') term?.paste(paste.text); })().catch((err: unknown) => { logWarn('terminal.paste', 'paste handler failed', { err }); }); @@ -285,6 +302,46 @@ export function TerminalView(props: TerminalViewProps) { return true; }); + // Drag-and-drop support — when the user drops a file on the terminal, + // type the absolute path(s) into the agent so it can read the file. By + // default xterm/Browsers would only insert the basename (text/plain), + // which a CLI agent like Claude Code can't open. + function handleDragOver(e: DragEvent) { + if (!e.dataTransfer || e.dataTransfer.types.indexOf('Files') === -1) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + } + function handleDrop(e: DragEvent) { + if (!e.dataTransfer || e.dataTransfer.files.length === 0) return; + e.preventDefault(); + e.stopPropagation(); + const dt = e.dataTransfer; + void (async () => { + try { + const args = await dataTransferToShellArgs(dt); + if (args) { + term?.focus(); + // Use term.paste() so xterm emits bracketed-paste markers for + // agents that have bracketed-paste mode on (Claude Code, + // Codex). Without the markers the agent sees the path as + // literal typing and skips the file-attachment recognition. + term?.paste(args); + } + } catch (err) { + logWarn('terminal.drop', 'drop handler failed', { err }); + } + })(); + } + // Use capture so we run before xterm's own listeners (which would otherwise + // insert just the basename via the dragged item's text/plain payload). + containerRef.addEventListener('dragover', handleDragOver, true); + containerRef.addEventListener('drop', handleDrop, true); + onCleanup(() => { + containerRef.removeEventListener('dragover', handleDragOver, true); + containerRef.removeEventListener('drop', handleDrop, true); + }); + fitAddon.fit(); registerTerminal(agentId, containerRef, fitAddon, term); diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index a130c4bb..071b923f 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -11,6 +11,9 @@ declare global { removeAllListeners: (channel: string) => void; }; setZoomFactor: (factor: number) => void; + /** Returns the absolute filesystem path for a File from a drop event, + * or '' for File objects without a backing path (e.g. browser image). */ + getPathForFile: (file: File) => string; }; } } diff --git a/src/lib/terminalDrop.test.ts b/src/lib/terminalDrop.test.ts new file mode 100644 index 00000000..1340379a --- /dev/null +++ b/src/lib/terminalDrop.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { escapePath, dataTransferToShellArgs } from './terminalDrop'; + +describe('escapePath', () => { + it('passes safe paths through unchanged', () => { + expect(escapePath('/Users/foo/bar.png')).toBe('/Users/foo/bar.png'); + expect(escapePath('relative/path-1.txt')).toBe('relative/path-1.txt'); + expect(escapePath('a_b.c')).toBe('a_b.c'); + expect(escapePath('user@host:/some/path')).toBe('user@host:/some/path'); + }); + + it('escapes whitespace', () => { + expect(escapePath('/Users/foo/My Image.png')).toBe('/Users/foo/My\\ Image.png'); + expect(escapePath('a\tb')).toBe('a\\\tb'); + }); + + it('escapes embedded apostrophes', () => { + expect(escapePath(`/tmp/it's.png`)).toBe(`/tmp/it\\'s.png`); + }); + + it('escapes embedded double quotes', () => { + expect(escapePath('/tmp/say "hi".png')).toBe('/tmp/say\\ \\"hi\\".png'); + }); + + it('escapes shell metacharacters', () => { + expect(escapePath('/tmp/a$b.png')).toBe('/tmp/a\\$b.png'); + expect(escapePath('/tmp/a`b.png')).toBe('/tmp/a\\`b.png'); + expect(escapePath('/tmp/(weird).png')).toBe('/tmp/\\(weird\\).png'); + expect(escapePath('/tmp/a&b.png')).toBe('/tmp/a\\&b.png'); + expect(escapePath('/tmp/a|b.png')).toBe('/tmp/a\\|b.png'); + expect(escapePath('/tmp/a;b.png')).toBe('/tmp/a\\;b.png'); + expect(escapePath('/tmp/a*b.png')).toBe('/tmp/a\\*b.png'); + expect(escapePath('/tmp/a?b.png')).toBe('/tmp/a\\?b.png'); + expect(escapePath('/tmp/[a].png')).toBe('/tmp/\\[a\\].png'); + expect(escapePath('/tmp/{a}.png')).toBe('/tmp/\\{a\\}.png'); + expect(escapePath('/tmp/~a.png')).toBe('/tmp/\\~a.png'); + expect(escapePath('/tmp/#a.png')).toBe('/tmp/\\#a.png'); + expect(escapePath('/tmp/!a.png')).toBe('/tmp/\\!a.png'); + expect(escapePath('/tmp/a.png')).toBe('/tmp/a\\.png'); + }); + + it('escapes embedded backslash', () => { + expect(escapePath('a\\b')).toBe('a\\\\b'); + }); + + it('renders the empty string as an explicit empty argv', () => { + expect(escapePath('')).toBe('""'); + }); + + it('chains escapes for paths with multiple metacharacters', () => { + expect(escapePath(`/tmp/it's "weird" & cool.png`)).toBe( + `/tmp/it\\'s\\ \\"weird\\"\\ \\&\\ cool.png`, + ); + }); +}); + +// File / DataTransfer don't exist in jsdom-less vitest by default. Provide +// the smallest shape the helper actually reads. +type FakeFile = Pick; +function makeFakeFile(name: string, bytes: Uint8Array): FakeFile { + // Copy the bytes into a fresh ArrayBuffer so the test never returns a + // SharedArrayBuffer view (which the File.arrayBuffer typing forbids). + const ab = new ArrayBuffer(bytes.byteLength); + new Uint8Array(ab).set(bytes); + return { + name, + size: bytes.length, + arrayBuffer: () => Promise.resolve(ab), + }; +} +function makeFakeDt(files: FakeFile[]): DataTransfer { + return { files: files as unknown as FileList } as unknown as DataTransfer; +} + +describe('dataTransferToShellArgs', () => { + let getPathForFile: ReturnType; + let invoke: ReturnType; + let originalElectron: unknown; + + beforeEach(() => { + getPathForFile = vi.fn(); + invoke = vi.fn(); + // Vitest runs in node by default — there is no window. Define one with + // the only field the helpers touch (electron.{getPathForFile,ipcRenderer}). + const g = globalThis as { window?: { electron?: unknown }; electron?: unknown }; + originalElectron = g.window; + g.window = { + electron: { + getPathForFile, + // dataTransferToShellArgs calls invoke() from ./ipc, which itself + // dispatches via window.electron.ipcRenderer.invoke. Stub the + // whole chain so the helper never touches a real ipcRenderer. + ipcRenderer: { invoke }, + }, + }; + }); + afterEach(() => { + const g = globalThis as { window?: unknown }; + g.window = originalElectron; + }); + + it('returns "" for an empty DataTransfer', async () => { + expect(await dataTransferToShellArgs(makeFakeDt([]))).toBe(''); + expect(getPathForFile).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('uses getPathForFile when File has a backing path', async () => { + getPathForFile.mockReturnValue('/Users/foo/My Image.png'); + const dt = makeFakeDt([makeFakeFile('My Image.png', new Uint8Array([1, 2, 3]))]); + + const args = await dataTransferToShellArgs(dt); + + expect(args).toBe('/Users/foo/My\\ Image.png'); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('joins multiple resolved paths with a single space', async () => { + getPathForFile.mockReturnValueOnce('/a/b.png').mockReturnValueOnce('/c d/e.png'); + const dt = makeFakeDt([ + makeFakeFile('b.png', new Uint8Array()), + makeFakeFile('e.png', new Uint8Array()), + ]); + + expect(await dataTransferToShellArgs(dt)).toBe('/a/b.png /c\\ d/e.png'); + }); + + it('falls back to SaveDroppedImage IPC when File has no path', async () => { + getPathForFile.mockReturnValue(''); + invoke.mockResolvedValue('/tmp/parallel-code-drop-123-screenshot.png'); + const bytes = new Uint8Array([137, 80, 78, 71]); // PNG magic + const dt = makeFakeDt([makeFakeFile('screenshot.png', bytes)]); + + const args = await dataTransferToShellArgs(dt); + + expect(args).toBe('/tmp/parallel-code-drop-123-screenshot.png'); + expect(invoke).toHaveBeenCalledTimes(1); + const [channel, payload] = invoke.mock.calls[0]; + expect(channel).toBe('save_dropped_image'); + expect(payload.name).toBe('screenshot.png'); + expect(typeof payload.data).toBe('string'); + // Base64-encoded 4-byte PNG magic header. + expect(payload.data).toBe('iVBORw=='); + }); + + it('skips files larger than 50 MB', async () => { + getPathForFile.mockReturnValue(''); + const huge = makeFakeFile('huge.bin', new Uint8Array()); + // The helper reads .size before allocating an arrayBuffer. + Object.defineProperty(huge, 'size', { value: 50 * 1024 * 1024 + 1 }); + const dt = makeFakeDt([huge]); + + expect(await dataTransferToShellArgs(dt)).toBe(''); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('drops failed resolutions but keeps successful ones', async () => { + getPathForFile.mockReturnValueOnce('/good/a.png').mockReturnValueOnce(''); + invoke.mockRejectedValueOnce(new Error('main blew up')); + const dt = makeFakeDt([ + makeFakeFile('a.png', new Uint8Array()), + makeFakeFile('b.png', new Uint8Array([1])), + ]); + + expect(await dataTransferToShellArgs(dt)).toBe('/good/a.png'); + }); + + it('isolates a thrown arrayBuffer() from sibling files', async () => { + // Path-less file whose .arrayBuffer() rejects (e.g. revoked blob URL, + // permission flap). A naive Promise.all would reject the whole drop and + // lose the path-backed sibling. + getPathForFile.mockImplementation((file: File) => + file.name === 'good.png' ? '/good.png' : '', + ); + const broken: FakeFile = { + name: 'broken.png', + size: 10, + arrayBuffer: () => Promise.reject(new Error('blob revoked')), + }; + const dt = makeFakeDt([broken, makeFakeFile('good.png', new Uint8Array())]); + + expect(await dataTransferToShellArgs(dt)).toBe('/good.png'); + expect(invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/terminalDrop.ts b/src/lib/terminalDrop.ts new file mode 100644 index 00000000..83250304 --- /dev/null +++ b/src/lib/terminalDrop.ts @@ -0,0 +1,92 @@ +// Helpers for turning a DataTransfer into a list of paths the terminal can +// type into the active CLI agent. Used by TerminalView's drop handler. +// +// Two sources of files: +// • Files dragged from the OS file manager — File has a backing path that +// webUtils.getPathForFile() resolves to an absolute string. +// • Files dragged from a browser (e.g. an from a website) — no path, +// just bytes. These are forwarded to the main process which writes them +// to a temp file and returns the path. +// +// Paths are backslash-escaped before insertion so spaces, quotes, and other +// shell metacharacters round-trip correctly through both POSIX shells (bash, +// zsh) and CLI agents that parse paths from prompt text. Backslash escaping +// matches macOS Terminal / iTerm2 / VS Code muscle memory and renders +// cleanly inside agent prompts (no quote walls). + +import { invoke } from './ipc'; +import { IPC } from '../../electron/ipc/channels'; + +const SAFE_PATH = /^[A-Za-z0-9_./@:+,%=-]+$/; +// Conservative metaset: every byte that has special meaning in any common +// POSIX shell. Backslash-escaping a non-special character is harmless, so +// over-escaping is preferred to under-escaping. +// +// Note: `\` itself is in this set so an embedded backslash gets escaped to +// `\\`. The character class is in a single line (not split across lines) +// so the literal whitespace between brackets is part of the set. +// eslint-disable-next-line no-useless-escape -- explicit escapes document intent +const META_CHAR = /[\s'"\\$`!()<>;&|*?\[\]{}~#]/g; + +/** + * Backslash-escape a path so it survives insertion into a terminal that may + * be hosting either a POSIX shell or a CLI agent. The empty string renders + * as `""` so a real shell receives an explicit empty argument. + */ +export function escapePath(p: string): string { + if (p === '') return '""'; + if (SAFE_PATH.test(p)) return p; + return p.replace(META_CHAR, '\\$&'); +} + +async function pathForDroppedItem(file: File): Promise { + // Each item resolution is its own try/catch so one unreadable browser / + // virtual file in a mixed drop never cancels the resolution of the + // siblings. The caller filters nulls out before joining. + try { + const direct = window.electron.getPathForFile?.(file) ?? ''; + if (direct) return direct; + + // No filesystem path — buffer bytes and ask main to persist them. Anything + // the user drops here is small enough to fit in memory in practice (images, + // not multi-GB videos), but we still cap to avoid pathological cases. + const MAX_BYTES = 50 * 1024 * 1024; + if (file.size > MAX_BYTES) return null; + const data = bytesToBase64(new Uint8Array(await file.arrayBuffer())); + // base64 keeps the payload inside the JSON envelope of the renderer's + // invoke() wrapper. A naive Uint8Array would be destroyed by the + // JSON.parse(JSON.stringify(args)) round-trip in src/lib/ipc.ts. + const filePath = await invoke(IPC.SaveDroppedImage, { + name: file.name || 'image.png', + data, + }).catch(() => ''); + return filePath || null; + } catch { + return null; + } +} + +/** Resolve every File in a DataTransfer to an escaped absolute path, + * joined by spaces. Returns '' when nothing resolvable was dropped. */ +export async function dataTransferToShellArgs(dt: DataTransfer): Promise { + const files = Array.from(dt.files); + if (files.length === 0) return ''; + const paths = await Promise.all(files.map(pathForDroppedItem)); + return paths + .filter((p): p is string => Boolean(p)) + .map(escapePath) + .join(' '); +} + +/** Encode a byte array as a base64 string. Uses btoa with a binary-string + * shim because Uint8Array is not directly accepted. Chunked to avoid + * blowing the call stack on large drops (50 MB cap upstream). */ +function bytesToBase64(bytes: Uint8Array): string { + const CHUNK = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += CHUNK) { + const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length)); + binary += String.fromCharCode(...slice); + } + return btoa(binary); +}