A CLI that listens for clipboard pastes via the kitty clipboard protocol and renders them as inline images or hexdumps.
  • Zig 88.3%
  • Nix 11.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-28 19:24:32 -05:00
.forgejo/workflows Add a release workflow that publishes tagged releases 2026-08-28 19:23:24 -05:00
LICENSES Initial commit: clipwatch 2026-08-28 11:21:32 -05:00
src Fall back to bracketed paste when the kitty clipboard protocol is absent 2026-08-28 17:08:36 -05:00
.gitignore Use zigwin32 bindings for the Windows console API 2026-08-28 12:41:17 -05:00
build.zig Sign release artifacts with minisign via a build-time step 2026-08-28 13:39:14 -05:00
build.zig.zon Bump version to 0.2.0 2026-08-28 19:24:32 -05:00
build.zig.zon.nix use zig 0.16 for deps 2026-08-28 14:51:58 -05:00
flake.lock Build offline with zon2nix-generated dependencies 2026-08-28 12:49:22 -05:00
flake.nix Bump version to 0.2.0 2026-08-28 19:24:32 -05:00
minisign.pub Add minisign public key and reference it for verification 2026-08-28 13:48:38 -05:00
README.md Fall back to bracketed paste when the kitty clipboard protocol is absent 2026-08-28 17:08:36 -05:00
REUSE.toml Add minisign public key and reference it for verification 2026-08-28 13:48:38 -05:00

clipwatch

A small CLI that listens for clipboard pastes using the kitty clipboard protocol and renders whatever was pasted:

  • Images (image/png) are drawn inline using the kitty graphics protocol with Unicode placeholders, so they also work inside tmux (which needs allow-passthrough on set).
  • Text (and any other data) is printed as a classic offset / hex / ASCII hexdump.

On terminals without the kitty clipboard protocol, clipwatch falls back to bracketed paste (mode 2004) when available: pastes are still hexdumped, but as plain text only — no MIME types and no images.

Source code is hosted at https://codeberg.org/jcollie/clipwatch.

Requirements

  • Zig 0.16
  • A terminal that implements the kitty clipboard protocol (OSC 5522) and reports mode 5522 via DECRQM — kitty itself, Ghostty 1.4 or newer, or another terminal with equivalent support.
  • Running inside tmux works too, provided the outer terminal qualifies and tmux has set -g allow-passthrough on: clipwatch talks to the outer terminal through tmux's passthrough sequence, and tmux forwards the outer terminal's replies and paste events to the active pane.

Building

zig build           # binaries land in zig-out/bin/
zig build test      # run unit tests

Or with Nix:

nix build           # binary lands in result/bin/

The Nix build runs in a sandbox with no network access, so it cannot let Zig fetch build.zig.zon dependencies itself. Instead they are resolved offline from build.zig.zon.nix, a package set generated by zon2nix. Whenever you change the dependencies in build.zig.zon, regenerate that file (zon2nix is provided in the dev shell):

zon2nix --nix=build.zig.zon.nix build.zig.zon

and commit the result, otherwise the Nix build will use stale dependencies. The plain zig build path is unaffected — it fetches dependencies directly.

Usage

Run the binary directly — it needs to own the tty, so don't run it through zig build run-style steps:

./zig-out/bin/clipwatch

Then paste into the terminal window (e.g. ctrl+shift+v). Each paste is reported with its available MIME types and rendered as an image or hexdump. Press q, ctrl+c, or ctrl+d to quit.

Hexdump output is capped at 20 lines by default; larger pastes end with a … N more bytes not shown note. Override the cap with --limit, taking a count of lines or bytes (a bare number means lines) or unlimited:

./zig-out/bin/clipwatch --limit 50lines
./zig-out/bin/clipwatch --limit 4096bytes
./zig-out/bin/clipwatch --limit unlimited

Verifying release binaries

Release artifacts are signed with minisign; each binary has a detached <name>.minisig beside it. The signatures are produced during the release build by a small helper that uses the minizign module, run through the build graph (never installed):

zig build sign -- <secret-key-file> <file>...

The project's minisign public key is in minisign.pub (key ID 3B011EBF652D3FA5). Verify a download with:

minisign -Vm clipwatch-x86_64-linux-musl -p minisign.pub

How it works

  1. Support probe. On startup clipwatch sends DECRQM queries for mode 5522 (CSI ? 5522 $ p) and mode 2004 (bracketed paste, the fallback), followed by DA1 (CSI c). Every terminal answers DA1, which bounds the wait: if the DA1 reply arrives without a positive DECRPM report for mode 5522, clipwatch falls back to bracketed paste when mode 2004 was reported (pastes arrive as plain text between CSI 200~ / CSI 201~ markers and are hexdumped — no images), and exits with an error when neither mode is available.

    Inside tmux (detected via $TMUX or a tmux/screen $TERM) the probe changes shape, because tmux would otherwise answer both queries itself (DECRPM "not recognized" and its own DA1): the DECRQM is wrapped in tmux's passthrough sequence so the outer terminal answers it, and the terminator becomes a wrapped DSR 5 (CSI 5 nCSI 0 n) — tmux swallows DA1 responses arriving from its client but forwards DECRPM and DSR replies (like other escape sequences it does not recognize) to the active pane verbatim. All later protocol writes — the mode set/reset and type=read requests — are passthrough-wrapped the same way, while paste events and data flow back as forwarded OSC 5522 input. This needs allow-passthrough on; without it the wrapped queries never reach the outer terminal and clipwatch waits forever (it prints a hint, and q still quits). The mode-2004 query stays unwrapped like the size queries: tmux implements bracketed paste per pane itself, so its answer is the authoritative one, and in fallback mode it re-brackets pastes coming from the outer terminal into the pane.

  2. Paste events, not polling. It then enables kitty's paste-events mode (CSI ? 5522 h). In this mode the terminal does not deliver pasted text as input. Instead, each user paste produces an unsolicited OSC 5522 read-response for the special . MIME type: the list of MIME types on the clipboard, plus a one-time password (pw).

  3. Reading the clipboard. clipwatch picks a MIME type — image/png if offered, else text/plain, else the first listed — and sends a type=read request authorized with the one-time password, so no permission prompt appears. The base64 DATA chunks are reassembled until DONE.

  4. Rendering. PNG data is transmitted with the graphics protocol as a virtual placement (ESC _G a=T,U=1,f=100,q=2,i=<id>,c=<cols>,r=<rows>, chunked at 4096 encoded bytes per escape), then displayed by printing a grid of U+10EEEE placeholder cells carrying the image id in their foreground color and their row/column in combining diacritics. The grid is sized from the image dimensions in the PNG header and the cell/window sizes reported during the handshake (CSI 16 t / CSI 18 t; inside tmux these stay unwrapped, and tmux answers them itself with pane-local values). Because the placement is plain text, this works inside tmux: when $TMUX (or a tmux/screen $TERM) is detected, the graphics escapes are wrapped in tmux's passthrough sequence (ESC Ptmux; with doubled ESCs), which requires set -g allow-passthrough on in the tmux config. Everything that isn't a PNG goes through the hexdump printer.

The tool puts the terminal into a raw mode with echo, line buffering, and the ctrl+c signal key disabled — via termios on POSIX systems, or SetConsoleMode (with virtual-terminal input/output enabled and UTF-8 code pages) on Windows. Quit keys are handled in-process, so the exit path always disables paste-events mode and restores the original terminal state. Newline output processing is left on for both platforms, so normal \n-terminated messages render correctly in raw mode.

License

MIT, © 2026 Jeffrey C. Ollie. The machine-generated flake.lock is dedicated to the public domain under CC0-1.0. The project follows the REUSE specification; per-file licensing is declared in SPDX headers and REUSE.toml.