All posts

Developer tooling

I Just Wanted to Paste a File Into My Terminal

And that's how it turned into a VS Code extension.

Clip2Remote icon

Clip2Remote

The extension this post is about — published on the VS Code Marketplace.

jaycverg.clip2remote

First, the setup

My laptop is a Mac. My code is not on my Mac.

Everything that matters — the repos, the containers, the database that only behaves properly on Linux — lives on an Ubuntu box. The Mac is where my hands and my eyes are. The Ubuntu box is where the actual work happens. It's been like this for years and it works fine.

Lately it's been working even better, because I run Claude Code on the remote box over SSH. The agent lives where the codebase lives. Makes sense, right? No syncing, no "works on my machine," no pretending my laptop is a build server.

The setup was iTerm + tmux. Honestly, it was beautiful. Fast, keyboard-only, makes you feel like you know what you're doing. I loved it.

Right up until Claude finishes writing a markdown doc and says "here, go read it."

Because in tmux, "go read it" means:

  1. Squint at the path.
  2. Split a pane. Or don't, and lose your view.
  3. less it. Or vim it — and now you're in vim, inside tmux, inside SSH, inside iTerm. Four layers of "who owns the Escape key right now."
  4. Try to scroll with your mouse. Watch tmux interpret that as something else entirely.

None of it is hard. It's just forty tiny annoyances a day. And forty tiny annoyances a day is how you slowly start resenting a setup you used to brag about.

Then VS Code Remote-SSH happened

So I drifted. Not all at once — one project, then another — over to VS Code with Remote-SSH.

And here's the funny part: the thing that converted me wasn't the editor. It was one small behavior.

VS Code makes file paths in the terminal clickable.

Claude prints docs/architecture.md, I click it, and there it is — real editor tab, syntax highlighting, no modal drama. Ctrl-click a src/foo.ts:42 and you land on line 42.

That's it. That's the entire feature that moved me. Point, click, read. My tmux muscle memory sulked for about a week and then got over it like nothing happened.

But I left something behind

Back in the iTerm days I had this small zsh script called clip2remote. One job only: copy a file in Finder, press my keybinding in the terminal, and it would scp the file up to the Ubuntu box and type the remote path into my prompt. Then I'd write:

summarize this recording for me: /tmp/clip2remote/a1b2c3/standup.mov

...and Claude Code, sitting on the remote box, could just read the file. No "please describe what's in it." No manual scp in another tab, then pwd, then copy, then paste, then still get the filename wrong.

Maybe thirty lines of shell. Thirty lines I never thought were that important to me.

In VS Code, that keybinding did nothing. Cmd+V in the integrated terminal pasted the filename as text if I was lucky, and usually nothing at all. Which stung a little — my nice new setup still had a hole in it, shaped exactly like my favorite shortcut.

"Somebody must have built this already"

Somebody had. Sort of.

There are a few "paste image into terminal" extensions out there, and I installed them with real hope. They all had the same two problems — and as it turned out, the two problems were really just one.

Problem one: images only. Screenshots, yes. But a .zip of logs? A .mov screen recording? A client's 40-page PDF? No. And honestly, "here's a picture" is maybe one-fifth of what I actually want to hand an agent.

Problem two — the interesting one: they're running on the wrong computer.

A VS Code extension in a Remote-SSH window runs, by default, on the remote host. Which means it cannot see your Mac's clipboard. So these extensions do something clever but doomed — they open a hidden webview (which renders locally, on your Mac), call navigator.clipboard.read() there, then ship the bytes back to the remote extension host as base64 over the RPC channel.

That's fine for a 200 KB screenshot. For a 200 MB video it's a base64 balloon squeezed through a straw. Which is exactly why every single one of them ships a ~10 MB cap.

And even if you fixed the size problem, you'd still be stuck — because of a detail I still find kind of beautiful:

navigator.clipboard.read() cannot see Finder-copied files. At all.

Text, HTML, image flavors. That's the spec. A file you copied in Finder simply isn't on the menu.

The whole approach is standing on a floor that doesn't reach the room I was trying to get into.

And that's when this got fun. It wasn't "nobody bothered." It was "the obvious way genuinely cannot work."

Turns out it was one line

VS Code lets an extension say where it wants to live:

"extensionKind": ["ui"]

That's it. ui means: even in a remote window, run me on the user's local machine.

Flip that switch and the whole problem turns inside out. The extension now runs on my Mac, where:

  • It can read the real macOS pasteboard — files, any type, exactly as Finder left them.
  • It can scp the file up itself, streaming, like a normal program.
  • No webview. No base64. No RPC channel. No size ceiling.

And suddenly the remote side became boring — a couple of shell commands over SSH. Which is exactly what you want the hard part to turn into.

The messy bits (my favorite part)

Every project worth writing about has a few spots where your clean plan meets reality and reality wins. Here are mine.

Reading the pasteboard takes two APIs working together

The extension bundles a JXA script (JavaScript for Automation — AppleScript's less famous sibling) that talks straight to AppKit. And it reads the file list twice, two different ways, then keeps whichever answer is longer:

  • The modern NSURL read can under-report when you copy multiple files.
  • The legacy NSFilenamesPboardType is officially deprecated — but macOS still synthesizes it, and it reliably catches Finder multi-selections.

Neither one is trustworthy alone. Together they're fine. Belt and suspenders, where the belt is deprecated and the suspenders occasionally lie about how many files you have.

Bonus landmine: JXA returns the Objective-C count as a string. Don't ask how long it took me to notice.

Your screenshots aren't PNGs

Take a screenshot on a Mac and what's actually sitting on your pasteboard is very often only public.tiff. No PNG flavor anywhere. So the extension converts TIFF → PNG through NSBitmapImageRep before staging it.

The classic AppleScript «class PNGf» coercion everyone reaches for first? Not enough. Learned that one the hard way.

Typing into a terminal you don't technically own

The extension runs locally. The terminal lives on the remote host. So the friendly Terminal.sendText API might not even see the thing I'm trying to type into.

When I actually measured it, window.activeTerminal was visible from the local extension host — nice, but the API contract never promises that, and I'm not building a keybinding on a coincidence.

So it uses workbench.action.terminal.sendSequence instead — a core command handled renderer-side that genuinely does not care which extension host asked. It's free, and it doesn't depend on a happy accident. (There's still a clipboard + terminal.paste fallback, because of course there is.)

Don't hash the video

Every uploaded file gets a short fingerprint, so if you paste the same unchanged file again it resolves to the same remote path and skips the upload entirely.

The tempting move is a content hash. The tempting move is wrong. SHA-256 over a 400 MB screen recording burns real seconds on every single paste, and the whole point of this thing is that it should feel instant. So the fingerprint is just size:mtime:name. Cheap, stable, and more than good enough for "is this the same file I pasted eight seconds ago?"

Files land at <remoteDir>/<fingerprint>/<original name> — a per-fingerprint directory, so report.zip stays report.zip for the agent to read, while two different report.zips can never collide.

SSH multiplexing: 397 ms → 38 ms

Every paste needs one round trip to mkdir the destination and check whether the file is already there. A fresh SSH handshake for that costs about 397 ms. You feel that. It reads as a little hang.

Reuse a shared control connection and it's 38 ms. Ten times faster, one config flag. It's on by default — and it's the difference between "I have a tool for that" and "this is a reflex now."

The art of doing nothing

This is the design decision I'm quietly proudest of.

In a local (non-SSH) window, the extension does absolutely nothing. Cmd+V returns to VS Code's own paste before the clipboard is even probed. Zero added latency, zero behavior change.

Same for non-file clipboard content: copy some text, hit Cmd+V, and the keystroke goes straight back to VS Code. Normal paste. It's always a normal paste, unless there's genuinely a file sitting there.

When you rebind Cmd+V — the single most-pressed shortcut in computing — the highest virtue available to you is being invisible.

So how is it now?

Copy a .mov in Finder. Click into the terminal. Cmd+V.

/tmp/clip2remote/a1b2c3d4e5f6/screen recording.mov 

(Yes, with the trailing space, so I can keep typing my prompt without thinking about it. And yes, filenames with spaces work — there's a reason that's a test case.)

Claude Code reads it off the remote disk immediately. Zip of logs? Same. Client PDF? Same. 200 MB video? Same — there's a confirmation prompt past 100 MB, and then it just goes.

The hole is closed. My favorite iTerm shortcut lives in VS Code now, and it does more than the original ever did.

The honest caveats

  • macOS clients only. The pasteboard reader is JXA/AppKit. On a Windows or Linux client the extension deliberately does nothing and Cmd+V is stock VS Code. Not "untested" — not built yet, and I'd rather say that plainly.
  • Everything else is platform-agnostic, though. Porting means writing one file that emits the same small JSON contract — Get-Clipboard -Format FileDropList on Windows, wl-paste/xclip on Linux — and dispatching on process.platform. PRs welcome; I kept that seam deliberately narrow.
  • Key-based SSH required. Uploads run with BatchMode=yes, so you're never stuck waiting on a password prompt you can't even see.

The lesson, if there is one

One line of JSON in a manifest was the actual fix. Everything else — the two pasteboard APIs, the TIFF conversion, the sendSequence choice, the deliberately-not-a-content-hash — all of it came downstream of asking one question:

Which machine should this code be running on?

Instead of: how do I get the bytes from over there to over here?

Every existing extension answered the second question really cleverly. The first question just made most of that cleverness unnecessary.

And one more thing: pay attention to the small tools you miss. Thirty lines of zsh told me exactly what to build, because I'd already spent a year proving to myself that I needed it.

Clip2Remote is MIT-licensed, published on the VS Code Marketplace, and up on GitHub. It owes a debt to a zsh script that did the same job back in my iTerm days, and to claude-paste for showing me the webview approach — including exactly why I needed a different one.