Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
112 changes: 105 additions & 7 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 `<verb>\nfile:///path1\nfile:///path2`, where <verb> 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
Expand Down Expand Up @@ -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. <img> 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());

Expand Down
3 changes: 2 additions & 1 deletion electron/ipc/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ const NEVER_SAFE: ReadonlySet<string> = new Set<string>([
IPC.DialogOpen,
IPC.OpenPath,
IPC.ReadFileText,
IPC.SaveClipboardImage,
IPC.ResolveClipboardPaste,
IPC.SaveDroppedImage,
IPC.CreateArenaWorktree,
IPC.RemoveArenaWorktree,
IPC.CheckPathExists,
Expand Down
15 changes: 13 additions & 2 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 '';
}
},
});
203 changes: 203 additions & 0 deletions openspec/changes/archive/2026-04-28-support-paste-images/design.md
Original file line number Diff line number Diff line change
@@ -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 `\<char>` 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.
Loading
Loading