Skip to content

Draw Kitty ribbons inside tmux through DCS passthrough - #15

Merged
CoreyKaylor merged 1 commit into
CoreyKaylor:mainfrom
filipurminsky:kitty-tmux-passthrough
Nov 4, 2026
Merged

CoreyKaylor merged 1 commit into
CoreyKaylor:mainfrom
filipurminsky:kitty-tmux-passthrough

Conversation

@filipurminsky

Copy link
Copy Markdown

Manual validation: Kitty ribbons in Ghostty + tmux

Dates: 2026-11-03 – 2026-11-04

Environment: macOS 26 (Darwin 25.6), Ghostty 1.4.0, tmux 3.8, Neovim 0.12.6.

Setup: a dedicated tmux server (tmux -L dbtest -f tmux.conf) attached from
its own Ghostty window, with this config:

set -g allow-passthrough on
set -s focus-events on
set -as terminal-features ',xterm-ghostty:RGB'

Neovim was started with nvim --clean -u init.lua, where init.lua puts the
plugin on the runtimepath with termguicolors on and default config
(ui.connector_renderer = "auto"). It was driven over --listen RPC and
tmux send-keys, and the results were checked by screenshots and by reading
require("diffbandit.kitty.detect").status() plus the canvas transmit
counters.

Preliminary: what tmux relays

A raw-mode probe script, run in a tmux 3.8 pane inside Ghostty, recorded:

Sent from the pane Reply seen by the pane
wrapped a=q graphics query + wrapped DA1 nothing
wrapped a=q graphics query + plain DA1 tmux's own DA1 only
wrapped XTVERSION nothing
plain XTVERSION tmux 3.8 (tmux answers itself)
plain CSI 16 t 6;34;16t (tmux answers itself)

So the outer terminal's replies never reach the pane, and the in-band probe
can only fail there. This is why detection inside tmux asks tmux
(display-message) instead. capture-pane -e showed that tmux stores
U+10EEEE plus both diacritics as a single width-1 cell and keeps the 24-bit
foreground color. The pane's TIOCGWINSZ reported a 16x34 px cell, matching
client_cell_width/height.

Scenarios

Scenarios 1–12 were run on 2026-11-03, before DiffBandit raised its own pane
to allow-passthrough all. With today's default
(ui.kitty.tmux_passthrough_all = true), scenarios 5 and 6 would not drop the
hidden transmit, because a pane in another window of the same session is still
delivered under all. They describe the allow-passthrough on path, which now
applies only with tmux_passthrough_all = false.

# Scenario Result
1 :DiffBandit left right, auto mode Pass. Status kitty, transport tmux, cell 16x34 measured, outer terminal ghostty 1.4.0; ribbons drawn.
2 Independent scroll (right pane +12 lines) Pass. Change and add ribbons bend to their true offscreen rows, and the band ends line up with the changed lines.
3 Chunk navigation (]c) Pass. Jumps to the add at right 62 (left 60 deleted); active ribbon aligned.
4 Pane resize (split beside the diff, and the host window resized by the window manager) Pass. Ribbons re-laid out to the new gutter; the neighbouring pane shows no image.
5 Zoom another pane, then unzoom (allow-passthrough on, pre-raise) Pass. Nothing is drawn while hidden; after unzoom the ribbons are correct.
6 Switch to another tmux window, change colorscheme while hidden, switch back (allow-passthrough on, pre-raise) Pass. The hidden transmit is dropped by tmux; FocusGained re-sends it (transmit counter 10 → 11), and the ribbon shows the new palette. Under the current default (all) the hidden transmit is delivered, and FocusGained still re-sends.
7 detach-client, reattach from Ghostty (new client pid) Pass. Client change detected, images re-sent (11 → 12), and ribbons correct.
8 Three-way merge (:DiffBanditMerge on a real conflict) in a second Neovim process, result pane scrolled independently Pass. Both pair connectors drawn and aligned (conflict at row 10, result 31 ↔ local 31, result 41 ↔ remote 41). Image ids 0x7b7001/0x7b7002 vs 0x78001 in the first process, so no collision.
9 Close the 2-way diff (q) while the merge in the other process stays open Pass. The closed view's ribbon is gone, the merge ribbons are untouched, and no diffbandit-tty-graphics-protocol-* files remain in $TMPDIR.
10 Fresh Neovim with allow-passthrough off Pass. Status cells (tmux allow-passthrough is off …); glyph connectors, and no escape sequences on screen.
11 Toggle passthrough on, press a key, then toggle it off and press a key Pass. The view flips to ribbons, then back to cells; no stale image is left.
12 Direct Ghostty window, no tmux Pass. Status kitty, transport none, temp-file transmission; ribbons drawn as before.

Zellij (2026-11-04, Zellij 0.45.1 in Ghostty 1.4.0)

A raw probe showed that Zellij answers the plain a=q query with OK but
rejects the same query with U=1, and also rejects real U=1 images, with
ENOTSUPPORTED:unicode placeholders are not supported. Ghostty answers OK
to both queries. The capability query now carries U=1.

# Scenario Result
13 Zellij, auto mode, clean environment Pass. Status cells (terminal rejected the graphics query: ENOTSUPPORTED…); no image sent; glyph connectors.
14 Zellij started from inside tmux (inherited $TMUX), auto mode Pass after a fix. Before the fix, writes were tmux-wrapped and the outer tmux was asked about its client. Now pane_tty (outer pane) ≠ Neovim's tty, so $TMUX is ignored: no wrapping, no tmux options touched, and the in-band probe gives cells.
15 Same, forced kitty Pass. Transport none; the first image is rejected by Zellij and the view falls back to cells.
16 Regression: direct Ghostty Pass. U=1 query accepted; status kitty, ribbons drawn.
17 Regression: attached tmux in Ghostty Pass. pane_tty matches Neovim's tty; status kitty; pane raised to all and restored on exit.

Review follow-up (2026-11-04, tmux 3.8, forced kitty, script pty client)

# Scenario Result
18 Only client switches to another session; recolor while away; switch back; key Pass. The image sent while no client showed the session (dropped even with all) was re-sent (transmits 3 → 4); session_last_attached moved while the client pid stayed the same.
19 Pane reset to on after the raise; key Pass. Raised to all again.
20 Pane set to off explicitly; Neovim exits Pass. off kept; the exit restore only applies while the pane still holds our all.
21 Normal exit after the raise Pass. Pane-local value unset again (inherits on).
22 Only client switches away and straight back with a background recolor in between (focus-events on) Pass. tmux sent a focus-in on the switch back, which re-sent the image (transmits 3 → 4).
23 Background send with no key just before it (passthrough all) Pass. Stays unconfirmed whatever tmux reports; the next key re-sends it (2 → 3 → 4).
24 Neovim exits after tmux applied the raise but before the completion was processed Pass. Restore ownership and the exit hook exist before set-option is sent; the pane is restored.
25 Reviewer's sequence ×3 with focus-events off: key → switch away → background recolor → switch back within one second Pass. tmux 3.8 still sent a focus-in on every return; each image was re-sent (+2 transmits per round). This was observed on tmux 3.8 and is not guaranteed; the README still recommends focus-events on.
26 Neovim quits before a 300 ms-delayed set-option all lands Pass. The restore waits for the pending command; the pane is restored. Committed code before the fix: pane became all after exit.
27 set-option all applies, then the command is killed at the timeout (exit 124) Pass. Treated as outcome unknown; ownership kept; restored on exit. Before the fix: ownership dropped, pane left at all.

Scenario 11 found a bug: activity checks were tied to CursorMoved, which does
not fire when focus is in a one-line header window. The check now hooks
vim.on_key, throttled to one tmux query per second.

Changes after these runs (2026-11-04 review)

  • Only typed keys count as proof that the pane is visible. Keys from
    nvim_feedkeys without the t flag, :normal or plugin timers are
    ignored. Keys from tmux send-keys or RPC nvim_input still count. If
    scenarios 11, 18, 19 or 23 were driven with non-typed keys, re-run them with
    tmux send-keys.
  • The key is stamped before its mapping runs. A synchronous render right
    after a pause (long ]c, conflict jumps, hunk actions) used to send every
    image twice; it now sends once.
  • Quitting while the first raise is still reading the pane value
    (show-options) no longer lets set-option … all go out after the exit
    restore. There is no manual scenario for this yet; the spec covers it.

Automated coverage

tests/specs/46_kitty_tmux.lua covers:

  • passthrough escaping goldens and round-trips (per-chunk wrapping);
  • parsing and evaluation of tmux info (version, passthrough, RGB, terminal
    allow-list, visibility under zoom, window switch and detach);
  • renderer selection (auto/kitty/cells, transport overrides);
  • detection fallbacks: passthrough off, failing tmux, and silent tmux
    (bounded timeout);
  • wrapped I/O for whole sessions (no bare APC);
  • per-view image deletion and temp-file cleanup;
  • reattach re-send, flipping to cells and back on client change, re-send
    after the pane was hidden, FocusGained;
  • the cell-size fallback, and re-measuring when the client or its cell size
    changes;
  • inherited $TMUX (auto and forced) and Zellij's placeholder rejection;
  • unconfirmed sends re-sent on the next key, also under all, and re-sending
    after the only client switches sessions and back;
  • one send for a typed key that renders synchronously; non-typed keys never
    confirm a send;
  • the pane-local raise and restore: overlapping raises, a pane reset to on,
    a user's off kept on exit, exit before or after the raise lands, exit
    during the first show-options (no raise sent, none started afterwards),
    a timed-out raise, opt-out, and a failed raise;
  • image-id prefix claims via flock(2), including concurrent real processes
    and a SIGKILLed owner.

🤖 Generated with Claude Code

tmux (checked up to 3.8) never relays the outer terminal's replies to a
pane: a passthrough-wrapped graphics query or DA1 gets no answer, and an
unwrapped DA1 is answered by tmux itself. The in-band probe can
therefore only fail there, so auto mode used to give up on tmux
entirely.

- kitty/tmux.lua wraps every graphics command for passthrough (ESC P
  tmux; ... ESC \, inner ESCs doubled), one wrapper per command so
  chunked transmissions stay small. Detection asks tmux instead of the
  terminal: one async `tmux display-message` (bounded by
  max(probe_timeout_ms, 500) ms) checks tmux >= 3.3, allow-passthrough
  on/all, the client's RGB feature (the image id rides the 24-bit fg),
  and a known Kitty-graphics client terminal (Ghostty, Kitty; extend
  with ui.kitty.tmux_terminals). Any failure, timeout or tmux error
  keeps the cell renderer and writes nothing to the terminal.
- detect.send wraps transmit, delete and probe writes whenever the
  transport is tmux, including forced "kitty" mode; "cells" never
  touches tmux. ui.kitty.passthrough = "auto" | "tmux" | "none"
  overrides the transport. Inside tmux images are sent directly, since a
  temp-file failure behind tmux would go unreported.
- With allow-passthrough "on", tmux silently drops passthrough written
  while the pane is hidden. Before the first image, raise
  allow-passthrough to "all" for Neovim's own pane only (set-option -p;
  global and window values untouched, a pane set to "off" left alone).
  The raise follows the option's current value, so a pane reset to "on"
  is raised again. Restore ownership and the VimLeavePre hook are set up
  before set-option is sent, and the exit restore first waits for an
  in-flight set-option to finish (tmux is killed at its timeout, which
  is reported only after the kill), so the change cannot land after the
  restore; ownership is given up only on a definite failure, not on a
  timeout (our timer, or vim.system's exit code 124 / a killing signal,
  all mean the outcome is unknown); the restore uses the value found
  before the first change and applies only while the pane still holds
  our "all" (a later user change, e.g. "off", is kept). Overlapping
  raises share one. ui.kitty.tmux_passthrough_all = false opts out.
- $TMUX is trusted only when tmux's #{pane_tty} is Neovim's own tty
  (ttyname of its standard fds). Programs started from inside tmux
  (Zellij, another terminal, ssh) inherit $TMUX; for them the tmux
  transport is ignored, the outer tmux's options are never touched, and
  the terminal in front of Neovim is probed in band.
- The capability query now asks about Unicode-placeholder placements
  (a=q with U=1). Zellij 0.45 implements Kitty graphics but answers this
  with ENOTSUPPORTED (it accepts a plain a=q), so it now resolves to
  cells up front instead of after the first rejected image; Ghostty
  answers OK. The terminal's rejection message becomes the fallback
  reason.
- Follow the tmux client: a reattach (new client) or a client switching
  back to the session (session_last_attached moved) re-sends images.
  Even with "all", tmux drops passthrough while no client shows the
  session, and a quick switch away and back can leave tmux's snapshot
  unchanged, so any transmission without a key press or FocusGained just
  before it stays unconfirmed and is re-sent on the next key or focus.
  tmux sends a focus-in when a client returns to the session, which
  makes this reliable; without focus events a switch made within a
  fraction of a second of typing can still be missed (tmux gives no
  delivery receipts). A client without graphics flips open views to
  cells, and returning flips them back. A new client or a changed client
  cell size invalidates the cached cell measurement (including one taken
  from tmux) and re-rasters.
- Image ids get a 12-bit per-process prefix claimed by holding an
  exclusive flock(2) on a file in $TMPDIR/diffbandit-image-ids-<uid> for
  the process lifetime. The kernel arbitrates claims atomically and
  drops them on exit or crash, so Neovim instances sharing one outer
  terminal never overwrite or delete each other's ribbons (pids that
  alias modulo 4095 included). Without LuaJIT ffi the pid hash is used
  as before. Slots still held by live views are skipped when the
  per-process counter wraps.
- Cell size falls back to tmux's client_cell_width/height when the pane
  reports no pixel size; the Display P3 palette conversion recognises
  Ghostty behind tmux from the client terminal type.
- Specs cover passthrough escaping, renderer selection, detection
  timeout/fallback, wrapped session I/O, per-view image and temp-file
  cleanup, client/visibility tracking, cell re-measurement on client
  change, the pane-local passthrough raise and restore (including
  overlapping raises and opt-out), id-prefix claims with concurrent real
  processes and a SIGKILLed owner, inherited $TMUX (auto and forced),
  Zellij's placeholder rejection, session switching, re-raising a reset
  pane, and keeping user changes on exit, unconfirmed sends under "all",
  exiting before or after an in-flight raise lands, and a raise that
  times out after applying. The kitty spec no longer inherits the
  developer's $TMUX.
- README: a short tmux section (required tmux.conf lines, the per-pane
  passthrough raise and its opt-out, recovery via focus events, reattach
  behaviour, limitations, troubleshooting) and notes on Zellij and
  per-instance image ids.
@filipurminsky
filipurminsky force-pushed the kitty-tmux-passthrough branch from c8fd154 to 99e4f80 Compare November 4, 2026 10:21
@CoreyKaylor
CoreyKaylor merged commit 644fa60 into CoreyKaylor:main Nov 4, 2026
1 of 2 checks passed
@CoreyKaylor

Copy link
Copy Markdown
Owner

Thanks for the help!

@filipurminsky

Copy link
Copy Markdown
Author

Thanks for the help!

thanks for the valuable tool, I have really been missing intellij style diff/merge in neovim

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants