Skip to content
Fran Gonzalez
← Back to blog
(updated Sep 30, 2026)·Clanker·7 min read

Fixing pi's TUI: clipboard and file drops over SSH, and scrollback wipes

How I got image paste and file drag-and-drop working in pi over SSH from kitty, and stopped pi's regular TUI mode from wiping scrollback, without forking pi.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

I use pi as my coding agent, running in kitty on a Mac, with SSH into Linux hosts for remote work. Over SSH two things broke: pasting images with Ctrl+V, and moving files between machines. A third problem showed up locally on the Mac, no SSH involved: in pi’s regular (non-fullscreen) TUI mode, scrollback periodically jumped to the bottom while pi was streaming. All three are fixable without forking pi.

Note

Tested with kitty 0.48.2, pi 0.85.1, and Node 26 on macOS, SSHing into Linux hosts, on 2026-09-16. The clipboard and file fixes were exercised over SSH; the scrollback investigation and fix ran on the Mac.

The symptoms

pi reads the clipboard with local tools (wl-paste, xclip, native APIs). On a remote host those read the remote machine’s clipboard, find nothing, and fail silently. So Ctrl+V over SSH was a no-op.

Drag-and-drop from Finder onto an SSH window pasted the local file path as text into the remote shell, where it doesn’t exist.

And in pi’s regular (non-fullscreen) TUI mode, output sometimes redrew the whole screen and yanked my viewport to the bottom, reliably while I was scrolled up reading history and pi was streaming a response. I prefer inline mode with real terminal scrollback, so switching to fullscreen mode was out.

Clipboard over SSH: OSC 5522

kitty has a clipboard protocol over the terminal stream that supports arbitrary data types, including images, and works over SSH: the client emits ESC]5522;type=read;<base64 mimes>ESC\ and kitty replies with status=DATA:mime=<b64>;<b64 data>. Since it’s just bytes on the same stream, it works from any host in the session. I found this independently; while verifying links for this post I also found that opencode hit the same wall, with an open issue requesting an OSC 5522 read path for image paste over SSH.

Two things were needed:

  1. clipboard_control in kitty.conf defaults to read-clipboard-ask, which asks for confirmation on reads. Granting reads explicitly makes the flow prompt-free: clipboard_control write-clipboard write-primary read-clipboard read-primary.
  2. Something in pi must speak the protocol. My first idea (shelling out to kitten clipboard) fails because pi’s subprocesses have no controlling TTY (/dev/tty: Device not configured). The request has to be written to pi’s own stdout.

pi’s extension API turned out to be enough: a CustomEditor subclass overriding onPasteImage, a ctx.ui.onTerminalInput listener that parses the OSC 5522 replies, and a process.stdout.write for the request (the same mechanism pi uses for its OSC 52 copies). The extension is 232 lines, guards on TERM=xterm-kitty or KITTY_WINDOW_ID, saves the image to /tmp, and inserts the path: the same contract as local paste. Ctrl+V now works over plain ssh, with no kitten ssh required.

For non-pi use there’s a clipimg script that wraps kitten clipboard into “save clipboard image to file, print path”.

Files over SSH: transfer and receive mode

kitty’s file transfer works over the same stream. Two gotchas:

  • kitten ssh bootstraps kitten into ~/.local/share/kitty-ssh-kitten but only puts it on PATH for that session. A plain SSH session has no kitten. One conditional line in the shell rc fixes it permanently:
# .commonrc: usable in plain ssh sessions too
[[ -d "$HOME/.local/share/kitty-ssh-kitten/kitty/bin" ]] &&
  export PATH="$HOME/.local/share/kitty-ssh-kitten/kitty/bin:$PATH"
  • Dropping a file from Finder onto a kitty window pastes its local path as text; that’s the default and there’s no config to flip it globally. Uploading is opt-in per session with the dnd kitten: run kitten dnd, the window enters receive mode, and drops upload to that directory. A small wrapper makes it one command:
# drop [dest-dir]: receive one file-drop, then exit
drop() {
  if [ -n "${1:-}" ]; then
    kitten dnd --exit-on drop-finish --confirm-drop-overwrite --drop-dest "$1"
  else
    kitten dnd --exit-on drop-finish --confirm-drop-overwrite
  fi
}

Scrollback: find the redraw before fixing it

pi’s regular mode paints into the main screen and lets the transcript flow into scrollback. The renderer diffs frames, but it has a bail-out: if any changed line sits above the viewport top, it does a full redraw (ESC[2J + ESC[3J, wiping the viewport and the scrollback) and re-emits the whole frame. Terminal scrollback cannot be edited in place, so any change to an already-scrolled line forces this.

Why did it fire mid-stream? Reading pi’s renderer source: a streaming message’s markdown is re-parsed on every chunk, so a closing **bold or code fence re-renders lines that have scrolled off-screen once the message is taller than the window. The tool-output preview does the same, sliding a fixed-size window over growing output. Every structural change above the viewport produces one scrollback wipe, and a reader scrolled up gets yanked.

I verified this headlessly instead of squinting at a terminal: instantiate pi’s TuiMainScreen from the installed package against an @xterm/headless instance that records every emitted byte, then diff frames on demand. Observed output, 80x24 virtual terminal, transcript taller than the viewport, five lines of pre-pi shell history in scrollback:

P0 first render (40 lines)   3Jwipes=0  prePiAlive=5
P1 append 3 (streaming)      3Jwipes=0  prePiAlive=5   // reader anchored while scrolled up
P2 mutate ABOVE viewport     3Jwipes=1  prePiAlive=0   // wipe: history destroyed
P3 mutate INSIDE viewport    3Jwipes=0  prePiAlive=0

P1 proves plain streaming does not move a scrolled-up reader; the wipe was the culprit in my testing. (One probe trap worth sharing: calling requestRender(true) means force, which resets the renderer’s state and makes the next frame look like a resize; my first probe induced the very redraws I was hunting.)

Patching the install instead of forking

The core fix is small: clamp the repaint to the viewport, so off-screen lines keep their frozen scrollback snapshot (the same tradeoff tmux makes with its pane history).

 if (firstChanged < prevViewportTop) {
-    logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
-    fullRender(true);
-    return;
+    // lines above the viewport cannot be repainted; clamp instead of wiping
+    logRedraw(`firstChanged clamped to viewportTop (${firstChanged} < ${prevViewportTop})`);
+    firstChanged = prevViewportTop;
+    if (lastChanged < firstChanged) lastChanged = firstChanged;
 }

Maintaining a fork for six lines costs more than it returns, and installing git refs as mise-managed tools is awkward. Instead, a patcher script runs before pi launches: it locates the compiled tui-main-screen.js inside the active mise install via createRequire, applies an exact-match string replacement (it must match exactly once or the script refuses), and keeps an .orig backup plus a .clamped marker for the fast path. When mise installs a new pi version, the first launch re-patches it; if upstream ever refactors that code, the script prints a warning and pi runs unpatched.

With the patch, the probe’s P2 line reads 3Jwipes=0 prePiAlive=5: no wipe, reader anchored.

End state

  • Ctrl+V pastes images into pi over plain SSH (extension + clipboard_control).
  • drop receives Finder drags on any host; kitten transfer covers explicit moves; scp remains the fallback.
  • No more scrollback wipes: pre-pi shell history survives, and a scrolled-up viewport stays anchored while pi streams.
  • Everything ships through my dotfiles, so pulling on another machine deploys the whole setup.

Stopgaps and tradeoffs

The scrollback patch is a stopgap by design; the right home for it is upstream, where the fix could also freeze the sliding tool preview while it’s off-screen. And the tradeoff should be stated plainly: lines that change while above the viewport now keep a stale snapshot in scrollback instead of being scrubbed. For my use, reading history that’s slightly behind beats having the history destroyed.

References

This post was written with AI assistance.