Repository navigation
Draw Kitty ribbons inside tmux through DCS passthrough - #15
Merged
CoreyKaylor merged 1 commit intoNov 4, 2026
Merged
Conversation
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
force-pushed
the
kitty-tmux-passthrough
branch
from
November 4, 2026 10:21
c8fd154 to
99e4f80
Compare
Owner
|
Thanks for the help! |
Author
thanks for the valuable tool, I have really been missing intellij style diff/merge in neovim |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 fromits own Ghostty window, with this config:
Neovim was started with
nvim --clean -u init.lua, whereinit.luaputs theplugin on the runtimepath with
termguicolorson and default config(
ui.connector_renderer = "auto"). It was driven over--listenRPC andtmux send-keys, and the results were checked by screenshots and by readingrequire("diffbandit.kitty.detect").status()plus the canvas transmitcounters.
Preliminary: what tmux relays
A raw-mode probe script, run in a tmux 3.8 pane inside Ghostty, recorded:
a=qgraphics query + wrapped DA1a=qgraphics query + plain DA1tmux 3.8(tmux answers itself)CSI 16 t6;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 -eshowed that tmux storesU+10EEEEplus both diacritics as a single width-1 cell and keeps the 24-bitforeground color. The pane's
TIOCGWINSZreported a 16x34 px cell, matchingclient_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 thehidden transmit, because a pane in another window of the same session is still
delivered under
all. They describe theallow-passthrough onpath, which nowapplies only with
tmux_passthrough_all = false.:DiffBandit left right, auto modekitty, transporttmux, cell 16x34 measured, outer terminalghostty 1.4.0; ribbons drawn.]c)allow-passthrough on, pre-raise)allow-passthrough on, pre-raise)all) the hidden transmit is delivered, and FocusGained still re-sends.detach-client, reattach from Ghostty (new client pid):DiffBanditMergeon a real conflict) in a second Neovim process, result pane scrolled independently0x7b7001/0x7b7002vs0x78001in the first process, so no collision.q) while the merge in the other process stays opendiffbandit-tty-graphics-protocol-*files remain in$TMPDIR.allow-passthrough offcells(tmux allow-passthrough is off …); glyph connectors, and no escape sequences on screen.kitty, transportnone, 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=qquery withOKbutrejects the same query with
U=1, and also rejects realU=1images, withENOTSUPPORTED:unicode placeholders are not supported. Ghostty answersOKto both queries. The capability query now carries
U=1.cells(terminal rejected the graphics query: ENOTSUPPORTED…); no image sent; glyph connectors.$TMUX), auto modepane_tty(outer pane) ≠ Neovim's tty, so$TMUXis ignored: no wrapping, no tmux options touched, and the in-band probe givescells.kittynone; the first image is rejected by Zellij and the view falls back to cells.U=1query accepted; statuskitty, ribbons drawn.pane_ttymatches Neovim's tty; statuskitty; pane raised toalland restored on exit.Review follow-up (2026-11-04, tmux 3.8, forced
kitty,scriptpty client)all) was re-sent (transmits 3 → 4);session_last_attachedmoved while the client pid stayed the same.onafter the raise; keyallagain.offexplicitly; Neovim exitsoffkept; the exit restore only applies while the pane still holds ourall.on).focus-events on)all)set-optionis sent; the pane is restored.focus-events off: key → switch away → background recolor → switch back within one secondfocus-events on.set-option alllandsallafter exit.set-option allapplies, then the command is killed at the timeout (exit 124)all.Scenario 11 found a bug: activity checks were tied to
CursorMoved, which doesnot 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)
nvim_feedkeyswithout thetflag,:normalor plugin timers areignored. Keys from
tmux send-keysor RPCnvim_inputstill count. Ifscenarios 11, 18, 19 or 23 were driven with non-typed keys, re-run them with
tmux send-keys.after a pause (long
]c, conflict jumps, hunk actions) used to send everyimage twice; it now sends once.
(
show-options) no longer letsset-option … allgo out after the exitrestore. There is no manual scenario for this yet; the spec covers it.
Automated coverage
tests/specs/46_kitty_tmux.luacovers:allow-list, visibility under zoom, window switch and detach);
auto/kitty/cells, transport overrides);tmux, and silenttmux(bounded timeout);
after the pane was hidden, FocusGained;
changes;
$TMUX(auto and forced) and Zellij's placeholder rejection;all, and re-sendingafter the only client switches sessions and back;
confirm a send;
on,a user's
offkept on exit, exit before or after the raise lands, exitduring the first
show-options(no raise sent, none started afterwards),a timed-out raise, opt-out, and a failed raise;
and a SIGKILLed owner.
🤖 Generated with Claude Code