From 99e4f80c05d261f0658c07d04292254ab675637d Mon Sep 17 00:00:00 2001 From: Filip Urminsky Date: Wed, 4 Nov 2026 08:02:55 +0100 Subject: [PATCH] Draw Kitty ribbons inside tmux through DCS passthrough 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- 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. --- README.md | 66 ++- doc/diffbandit.txt | 5 + lua/diffbandit/config.lua | 13 + lua/diffbandit/kitty/canvas.lua | 29 +- lua/diffbandit/kitty/color.lua | 10 +- lua/diffbandit/kitty/detect.lua | 408 ++++++++++++- lua/diffbandit/kitty/ids.lua | 135 +++++ lua/diffbandit/kitty/init.lua | 93 ++- lua/diffbandit/kitty/protocol.lua | 5 + lua/diffbandit/kitty/tmux.lua | 448 ++++++++++++++ tests/run.lua | 1 + tests/specs/45_kitty.lua | 25 +- tests/specs/46_kitty_tmux.lua | 956 ++++++++++++++++++++++++++++++ 13 files changed, 2148 insertions(+), 46 deletions(-) create mode 100644 lua/diffbandit/kitty/ids.lua create mode 100644 lua/diffbandit/kitty/tmux.lua create mode 100644 tests/specs/46_kitty_tmux.lua diff --git a/README.md b/README.md index 71ee3ed..c51931a 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,10 @@ placeholders: - Rex - anything based on libghostty -Detection is automatic (Neovim 0.12+). Everywhere else, including tmux, -iTerm2, and Terminal.app, the cell renderer draws the same routes with Unicode -glyphs. +Detection is automatic (Neovim 0.12+), also inside tmux 3.3+ with +`allow-passthrough` enabled ([tmux](#tmux)). Everywhere else, including +iTerm2, Terminal.app, and Zellij, the cell renderer draws the same routes with +Unicode glyphs. Details and options: [Image connectors](#image-connectors-kitty-graphics). ## Installation @@ -526,6 +527,9 @@ require("diffbandit").setup({ transmission = "auto", -- "auto" | "file" | "direct" compress = true, probe_timeout_ms = 300, + passthrough = "auto", -- "auto" | "tmux" | "none" + tmux_terminals = nil, -- terminals trusted behind tmux (default Ghostty, Kitty) + tmux_passthrough_all = true, -- see tmux below max_image_px = 4000000, cell_px = nil, -- { w, h } override; measured via the tty, else a 20x40 (HiDPI) estimate divider_width = 1.45, -- curve width in line heights (IntelliJ's 24px divider) @@ -574,8 +578,8 @@ override specific `DiffBandit*` highlight groups. With `ui.connector_renderer = "auto"` (the default), DiffBandit probes the terminal for the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) when a diff opens. It sends a capability query followed by a device-attributes -sentinel, so detection never depends on terminal names. When the terminal -answers, each connector is drawn as an anti-aliased image of filled bezier +sentinel, so detection does not depend on terminal names (tmux is the +exception, see [tmux](#tmux)). When the terminal answers, each connector is drawn as an anti-aliased image of filled bezier ribbons between the two panes, modelled on IntelliJ's diff divider. The curve also replaces the wedge glyphs in the number-pane edge columns. Otherwise the cell renderer is used, unchanged. @@ -602,9 +606,9 @@ cell renderer is used, unchanged. the panes. `ui.kitty.image_colorspace = "auto"` converts the ribbon palette for Ghostty on macOS. Set `"srgb"` if your Ghostty uses `window-colorspace = display-p3`. -- Without a pixel size from the tty (common under multiplexers and SSH), images - are rasterized at a 20x40 HiDPI cell estimate and scaled down by the terminal. - Set `ui.kitty.cell_px` to match your font exactly. +- The cell size is measured from the tty (or tmux). Without it (common over + SSH), images use a 20x40 HiDPI estimate scaled down by the terminal. Set + `ui.kitty.cell_px` to match your font exactly. - Repaints ride the same debounced viewport path as the cell renderer. The rail solver is skipped entirely, and an image is re-sent only when the ribbon geometry, size, or theme changes. @@ -612,8 +616,52 @@ cell renderer is used, unchanged. zlib-compressed chunked data otherwise (SSH, remote multiplexers). - `"kitty"` forces the image renderer and `"cells"` disables it. You can also set `vim.g.diffbandit_connector_renderer` or - `DIFFBANDIT_CONNECTOR_RENDERER`. Inside tmux, auto mode uses cells. + `DIFFBANDIT_CONNECTOR_RENDERER`. - If the terminal rejects an image later, every open view falls back to cells. +- Zellij (0.45) has no Unicode placeholder support, so it uses the cell + renderer. Ribbons will be detected once it adds them (zellij-org/zellij#5531). +- Neovim instances sharing one terminal (e.g. tmux panes) use separate image + ids. Closing a view deletes only its own images. + +#### tmux + +Ribbons work inside tmux 3.3+ when the terminal running tmux supports them +(tested with Ghostty and tmux 3.8). Add this to `~/.tmux.conf`, then reload it +and reattach, because tmux applies `terminal-features` only when a client +attaches: + +```tmux +set -g allow-passthrough on +set -as terminal-features ',xterm-ghostty:RGB' # ',xterm-kitty:RGB' for Kitty +set -g focus-events on # recommended: ribbons are re-sent when you return +``` + +- tmux does not pass the terminal's answer back, so detection asks tmux + instead. Ghostty and Kitty are recognised; add other terminals with + `ui.kitty.tmux_terminals`. +- tmux drops images sent while a pane is hidden, so DiffBandit sets + `allow-passthrough all` on its own pane and restores it on exit (unless you + changed it meanwhile). Opt out with `ui.kitty.tmux_passthrough_all = false`. +- Images can still be lost while no client shows the session (switched away, + detached). They are re-sent when Neovim's pane regains focus, so keep focus + events on. Without them, DiffBandit re-sends on a later key press, which can + miss a switch made within a fraction of a second of typing. +- Reattaching from a terminal without graphics switches to cells until you + return. +- Limitations: tmux does not pass terminal errors back, so a rejected image + cannot trigger the fallback to cells. Every client attached to the session + must support Kitty graphics. Nested tmux and screen are not supported. + +If ribbons do not appear, `:lua print(require("diffbandit.kitty.detect").status())` +shows the reason: + +| Reason | Fix | +| --- | --- | +| `tmux allow-passthrough is off` | `set -g allow-passthrough on`, then press a key in Neovim | +| `tmux client lacks the RGB feature` | add the `terminal-features` line above and reattach | +| `… is not a known Kitty graphics terminal` | add it to `ui.kitty.tmux_terminals` | +| `tmux … is too old` | upgrade to tmux 3.3+ | +| wrong ribbon size or offset | set `ui.kitty.cell_px = { w, h }` | `DIFFBANDIT_PERF=1` adds `kitty curves/raster/transmit` stage timings to the render log. `tools/bench_kitty.lua` compares repaint cost against the cell diff --git a/doc/diffbandit.txt b/doc/diffbandit.txt index 570a590..58b0f77 100644 --- a/doc/diffbandit.txt +++ b/doc/diffbandit.txt @@ -62,6 +62,11 @@ Requirements: - Git for Git diff workflows and hunk actions. - tmux only for running integration tests. - Nerd Fonts are optional. +- Image (Kitty graphics) connectors: Neovim 0.12+, 'termguicolors', and a + terminal with Kitty graphics + Unicode placeholders (Ghostty, Kitty, ...). + Inside tmux 3.3+ they also need `set -g allow-passthrough on` and the RGB + terminal feature; see the README section "tmux" for setup, limitations and + troubleshooting. `ui.connector_renderer = "cells"` disables them. ============================================================================== COMMANDS *diffbandit-commands* diff --git a/lua/diffbandit/config.lua b/lua/diffbandit/config.lua index 11055d2..9b62565 100644 --- a/lua/diffbandit/config.lua +++ b/lua/diffbandit/config.lua @@ -35,7 +35,20 @@ local defaults = { transmission = "auto", -- zlib-compress direct transmissions (o=z) when libz is available. compress = true, + -- In-band probe timeout. Inside tmux detection queries tmux instead + -- (bounded by max(probe_timeout_ms, 500)). probe_timeout_ms = 300, + -- Graphics transport: "auto" (tmux DCS passthrough when $TMUX is + -- set), "tmux" (always wrap), or "none" (never wrap). + passthrough = "auto", + -- Outer terminals trusted behind tmux (tmux cannot relay the probe + -- reply), matched against the client's XTVERSION / TERM. Replaces the + -- default list when set, e.g. { "ghostty", "kitty", "rex" }. + tmux_terminals = nil, + -- With tmux allow-passthrough "on", images written while the pane is + -- hidden are silently dropped. Raise it to "all" for Neovim's own pane + -- only (restored on exit; a pane set to "off" is left alone). + tmux_passthrough_all = true, -- Pixel cap per connector image; larger viewports render at reduced -- resolution and the terminal scales them up. max_image_px = 4000000, diff --git a/lua/diffbandit/kitty/canvas.lua b/lua/diffbandit/kitty/canvas.lua index 0378d4c..2c180b0 100644 --- a/lua/diffbandit/kitty/canvas.lua +++ b/lua/diffbandit/kitty/canvas.lua @@ -12,15 +12,25 @@ local connector_width = require("diffbandit.connector.width") local color = require("diffbandit.kitty.color") local curves = require("diffbandit.connector.curves") local detect = require("diffbandit.kitty.detect") +local ids = require("diffbandit.kitty.ids") local protocol = require("diffbandit.kitty.protocol") local raster = require("diffbandit.kitty.raster") local zlib = require("diffbandit.kitty.zlib") local M = {} -local ID_BASE = 0xDB0000 +-- Image ids are 24-bit (the placeholder fg colour carries them): a 12-bit +-- prefix claimed per process (kitty/ids.lua), so Neovim instances sharing one +-- terminal never collide, and a 12-bit slot counted per process. +local SLOTS = 0xFFF local next_slot = 0 local live_states = setmetatable({}, { __mode = "k" }) +local live_ids = {} + +--- Image id for per-process slot n (1..4095) and prefix (1..4095). +function M.image_id(slot, prefix) + return (prefix or ids.prefix()) * 0x1000 + slot +end local function now() local uv = vim.uv or vim.loop @@ -34,8 +44,16 @@ end --- Fresh per-connector state. Merge hosts keep one per pair slot so pair --- renderer rebuilds reuse the same terminal image id. function M.new_state() - next_slot = (next_slot % 0xFFFF) + 1 - local id = ID_BASE + next_slot + local id + local tries = 0 + repeat + next_slot = (next_slot % SLOTS) + 1 + id = M.image_id(next_slot) + tries = tries + 1 + -- Skip the probe ids and slots still held by a live view (after the + -- counter wraps). + until (id ~= detect.QUERY_ID and id ~= detect.QUERY_ID + 1 and not live_ids[id]) or tries > SLOTS + live_ids[id] = true local st = { id = id, hl = "DiffBanditKittyImage" .. id, @@ -190,7 +208,9 @@ local function transmit(host, st, bytes, width, height, layout) }) end st.transmits = st.transmits + 1 - return detect.send(table.concat(chunks)) + local ok = detect.send(chunks) + detect.note_transmit() + return ok end -- --- canvas float --------------------------------------------------------- @@ -614,6 +634,7 @@ function M.destroy_state(st) st.edges = nil detect.send(protocol.delete(st.id)) detect.unregister_id(st.id) + live_ids[st.id] = nil for _, path in ipairs(st.files or {}) do pcall(os.remove, path) end diff --git a/lua/diffbandit/kitty/color.lua b/lua/diffbandit/kitty/color.lua index 2b482e1..069b192 100644 --- a/lua/diffbandit/kitty/color.lua +++ b/lua/diffbandit/kitty/color.lua @@ -57,8 +57,16 @@ function M.image_colorspace(config, env) if cfg == "srgb" or cfg == "display-p3" then return cfg end + local outer = nil + if not env then + local ok, detect = pcall(require, "diffbandit.kitty.detect") + outer = ok and detect.outer_terminal() or nil + end env = env or { - ghostty = vim.env.GHOSTTY_RESOURCES_DIR ~= nil and vim.env.GHOSTTY_RESOURCES_DIR ~= "", + -- Inside tmux the client terminal is known exactly; outside it, Ghostty + -- exports GHOSTTY_RESOURCES_DIR (also inherited by multiplexers). + ghostty = (outer and outer:lower():find("ghostty", 1, true) ~= nil) + or (outer == nil and vim.env.GHOSTTY_RESOURCES_DIR ~= nil and vim.env.GHOSTTY_RESOURCES_DIR ~= ""), mac = vim.fn.has("mac") == 1, ssh = vim.env.SSH_CONNECTION ~= nil and vim.env.SSH_CONNECTION ~= "", } diff --git a/lua/diffbandit/kitty/detect.lua b/lua/diffbandit/kitty/detect.lua index de8632e..718fdfa 100644 --- a/lua/diffbandit/kitty/detect.lua +++ b/lua/diffbandit/kitty/detect.lua @@ -11,7 +11,15 @@ -- with cells and flip once on success. The result is cached per Neovim -- process (reset on UIEnter) and any later error reply for one of our image -- ids downgrades every session back to cells. +-- +-- Inside tmux (see kitty/tmux.lua) every command is wrapped for DCS +-- passthrough and, because tmux swallows the outer terminal's replies, "auto" +-- asks tmux itself (allow-passthrough, client terminal, RGB) instead of +-- probing in band. Later tmux queries follow client changes (detach / +-- reattach) and pane visibility so images can be re-sent or the renderer +-- flipped to match the terminal now attached. local protocol = require("diffbandit.kitty.protocol") +local tmux = require("diffbandit.kitty.tmux") local M = {} @@ -20,16 +28,19 @@ local QUERY_ID = 0xDB0F01 local state = { status = "unknown", -- unknown | probing | kitty | cells reason = nil, + generation = 0, transmission = nil, -- file | direct (from the probe) cell = nil, -- { w, h } px when measured } local listeners = {} local retransmit_listeners = {} +local cell_listeners = {} local owned_ids = {} local sink_override = nil local env_override = nil local measure_override = nil local response_group = nil +local passthrough_cfg = "auto" local function notify(status) for _, fn in ipairs(vim.list_extend({}, listeners)) do @@ -37,17 +48,87 @@ local function notify(status) end end ---- Write raw bytes to the host terminal (tests inject a capture sink). +local own_tty_override = nil + +local function inside_tmux() + if state.tmux_foreign then + return false + end + if env_override then + return env_override.tmux == true + end + return vim.env.TMUX ~= nil and vim.env.TMUX ~= "" +end + +--- Path of the terminal Neovim is attached to (its standard fds; the +--- --embed server inherits them from the TUI), or nil when unknown. +local own_tty_cache = nil +local function own_tty() + if own_tty_override then + return own_tty_override() + end + if own_tty_cache ~= nil then + return own_tty_cache or nil + end + own_tty_cache = false + local ok_ffi, ffi = pcall(require, "ffi") + if ok_ffi then + pcall(ffi.cdef, "int isatty(int fd);") + pcall(ffi.cdef, "char *ttyname(int fd);") + pcall(function() + for fd = 0, 2 do + if ffi.C.isatty(fd) == 1 then + local name = ffi.C.ttyname(fd) + if name ~= nil then + own_tty_cache = ffi.string(name) + return + end + end + end + end) + end + return own_tty_cache or nil +end + +--- $TMUX can be inherited by a program that is not running in that tmux +--- pane at all (Zellij, a terminal emulator or ssh started from inside +--- tmux). Trust it only when tmux's pane tty is Neovim's own tty; when either +--- is unknown, keep trusting $TMUX. +local function foreign_tmux(info) + local mine = own_tty() + local theirs = info and info.pane_tty + return mine ~= nil and theirs ~= nil and theirs ~= "" and mine ~= theirs +end + +--- Graphics transport: "tmux" (DCS passthrough) or "none". Config +--- ui.kitty.passthrough = "auto" (tmux when $TMUX is set) | "tmux" | "none". +function M.passthrough() + if passthrough_cfg == "tmux" or passthrough_cfg == "none" then + return passthrough_cfg + end + return inside_tmux() and "tmux" or "none" +end + +--- Write graphics commands to the host terminal: one escape sequence (a +--- string) or a list of them, each wrapped for the active transport. Tests +--- inject a capture sink that sees the exact wire bytes. function M.send(data) + local commands = type(data) == "table" and data or { data } + local wire + if M.passthrough() == "tmux" then + wire = tmux.wrap_all(commands) + else + wire = table.concat(commands) + end if sink_override then - sink_override(data) + sink_override(wire) return true end local send = vim.api.nvim_ui_send if not send then return false end - return pcall(send, data) + return pcall(send, wire) end local function env() @@ -64,7 +145,7 @@ local function env() has_ui_send = vim.api.nvim_ui_send ~= nil, has_tty_ui = has_tty_ui, termguicolors = vim.o.termguicolors == true, - tmux = vim.env.TMUX ~= nil and vim.env.TMUX ~= "", + tmux = inside_tmux(), } end @@ -126,12 +207,81 @@ local function finish(result) state.status = result.ok and "kitty" or "cells" state.reason = result.reason state.transmission = result.file and "file" or "direct" + state.source = result.source notify(state.status) end -local function start_probe(config) +local function probe_timeout(config) + return tonumber(kitty_config(config).probe_timeout_ms) or 300 +end + +-- tmux spawns a process: give it a little more room than an in-band reply. +local function tmux_timeout(config) + return math.max(probe_timeout(config), 500) +end + +-- ui.kitty.tmux_passthrough_all (default true): raise this pane's +-- allow-passthrough from "on" to "all" so images written while the pane is +-- hidden are not dropped (see tmux.raise_passthrough). cb() runs when done. +local function maybe_raise(info, config, cb) + if kitty_config(config).tmux_passthrough_all == false or not info or info.passthrough ~= "on" + or state.tmux_raise_failed then + cb() + return + end + local generation = state.generation + tmux.raise_passthrough(tmux_timeout(config), function(ok) + if generation ~= state.generation then + return + end + if ok then + if state.tmux_info then + state.tmux_info.passthrough = "all" + end + else + state.tmux_raise_failed = true + end + cb() + end) +end + +-- The terminal behind tmux answers nothing, so temp-file support can never be +-- confirmed and a t=t failure would go unnoticed: always transmit directly. +local start_probe + +local function start_tmux_probe(config) state.status = "probing" - local got = { direct = false, file = false } + tmux.query(tmux_timeout(config), function(info, err) + if info and foreign_tmux(info) then + -- Inherited $TMUX: the terminal in front of us is something else. + -- Never wrap or touch that tmux; probe our own terminal in band. + state.tmux_foreign = true + start_probe(config) + return + end + if info then + state.tmux_info = info + state.tmux_client = info.client + end + local ok, reason = false, err + if info then + ok, reason = tmux.evaluate(info, config) + end + local function done() + finish({ ok = ok, file = false, reason = reason, source = "tmux" }) + end + if ok then + -- Before the first image goes out, so none can be dropped. + maybe_raise(info, config, done) + else + done() + end + end) +end + +function start_probe(config) + state.status = "probing" + local got = { direct = false, file = false, error = nil } local file = M.temp_path("probe") local fh = io.open(file, "wb") if fh then @@ -151,6 +301,8 @@ local function start_probe(config) if reply then if reply.id == QUERY_ID and reply.ok then got.direct = true + elseif reply.id == QUERY_ID and not reply.ok then + got.error = reply.message elseif reply.id == QUERY_ID + 1 and reply.ok then got.file = true end @@ -167,13 +319,18 @@ local function start_probe(config) -- ignored the query), but it may be a stray answer to one of Neovim's -- own startup queries. Give our graphics reply a short grace window. vim.defer_fn(function() - finish({ ok = got.direct, file = got.file, reason = got.direct and nil or "no graphics reply before DA1" }) + local reason = nil + if not got.direct then + reason = got.error and ("terminal rejected the graphics query: " .. got.error) + or "no graphics reply before DA1" + end + finish({ ok = got.direct, file = got.file, reason = reason }) end, 60) end end, }) - local timeout = tonumber(kitty_config(config).probe_timeout_ms) or 300 + local timeout = probe_timeout(config) local uv = vim.uv or vim.loop local timer = uv.new_timer() state.probe_timer = timer @@ -181,11 +338,12 @@ local function start_probe(config) finish({ ok = got.direct, file = got.file, reason = got.direct and nil or "probe timed out" }) end)) - local payload = protocol.query(QUERY_ID, "d") + local payload = { protocol.query(QUERY_ID, "d") } if file then - payload = payload .. protocol.query(QUERY_ID + 1, "t", file) + payload[#payload + 1] = protocol.query(QUERY_ID + 1, "t", file) end - if not M.send(payload .. protocol.DA1) then + payload[#payload + 1] = protocol.DA1 + if not M.send(payload) then finish({ ok = false, reason = "nvim_ui_send failed" }) end end @@ -247,8 +405,16 @@ function M.resolve(config) if mode == "cells" then return "cells" end + local pt = kitty_config(config).passthrough + passthrough_cfg = (pt == "tmux" or pt == "none") and pt or "auto" + state.last_config = config install_response_watch() if mode == "kitty" then + -- Forced: still learn the tmux client (cell size, colour space, + -- visibility) so the transport can re-send images when needed. + if M.passthrough() == "tmux" and not state.tmux_info then + M.tmux_check(config) + end return "kitty" end if state.status == "kitty" or state.status == "cells" then @@ -269,14 +435,16 @@ function M.resolve(config) return "cells" elseif not e.termguicolors then reason = "termguicolors is off" - elseif e.tmux then - reason = "inside tmux" end if reason then state.status = "cells" state.reason = reason return "cells" end + if M.passthrough() == "tmux" then + start_tmux_probe(config) + return state.status == "kitty" and "kitty" or "cells" + end start_probe(config) -- Synchronous terminals may already have answered. if state.status == "kitty" then @@ -298,6 +466,197 @@ function M.on_change(fn) end end +local function retransmit(reason) + for _, fn in ipairs(vim.list_extend({}, retransmit_listeners)) do + pcall(fn, reason) + end +end + +local function apply_tmux_info(info, config, opts) + if foreign_tmux(info) then + -- Forced "kitty" with an inherited $TMUX: stop wrapping and re-send + -- anything already written for the wrong transport. + state.tmux_foreign = true + state.tmux_info = nil + retransmit("inherited $TMUX ignored") + return + end + local prev = state.tmux_info + state.tmux_info = info + local client_changed = state.tmux_client ~= nil and info.client ~= state.tmux_client + -- A client attached or switched back to this session (same client pid): + -- images sent while nobody showed the session were dropped, even with + -- allow-passthrough "all". tmux reports this in whole seconds, so leaving + -- and returning within the same second can go unnoticed. + local reattached = prev ~= nil and info.last_attached ~= nil and info.last_attached ~= "" + and info.last_attached ~= prev.last_attached + state.tmux_client = info.client + -- A new client (reattach from another terminal or font) or a changed + -- client cell size invalidates any cached measurement, including one that + -- came from tmux itself; VimResized may have re-measured before this + -- asynchronous answer arrived, so the re-render is triggered here. + local prev_cell = prev and prev.cell + local cell_moved = info.cell ~= nil + and not (prev_cell and prev_cell.w == info.cell.w and prev_cell.h == info.cell.h) + local cell_changed = false + if state.cell and (client_changed or cell_moved) then + cell_changed = M.remeasure(config) + end + -- Re-evaluate only a verdict that came from tmux (never one caused by + -- termguicolors, a missing TUI, or a runtime rejection), so a client from + -- another terminal flips the renderer and returning flips it back. + if M.requested_mode(config) == "auto" and state.source == "tmux" and not state.failed + and (state.status == "kitty" or state.status == "cells") then + local ok, reason = tmux.evaluate(info, config) + local status = ok and "kitty" or "cells" + if status ~= state.status or (not ok and reason ~= state.reason) then + local flipped = status ~= state.status + state.status = status + state.reason = reason + if flipped then + state.tmux_stale = false + state.tmux_unconfirmed = false + if status == "kitty" then + maybe_raise(info, config, function() + notify(status) + end) + else + notify(status) + end + return + end + end + end + local kitty_active = state.status == "kitty" or M.requested_mode(config) == "kitty" + if kitty_active and info.passthrough == "on" then + -- Forced mode, or the option was reset since: raise again; anything sent + -- meanwhile is re-sent once delivery is guaranteed. + maybe_raise(info, config, function() + if tmux.raised() and state.tmux_unconfirmed then + state.tmux_unconfirmed = false + retransmit("passthrough raised to all") + end + end) + end + if cell_changed and kitty_active then + -- Hosts re-size and re-raster (which re-sends every image). + state.tmux_stale = not info.visible and info.passthrough ~= "all" + for _, fn in ipairs(vim.list_extend({}, cell_listeners)) do + pcall(fn) + end + return + end + local became_visible = info.visible and (state.tmux_stale or (prev ~= nil and not prev.visible)) + if not info.visible and info.passthrough ~= "all" then + -- With allow-passthrough "on", tmux drops passthrough from hidden panes: + -- anything transmitted now must be sent again once the pane shows. + state.tmux_stale = true + elseif info.visible then + state.tmux_stale = false + end + if (client_changed or reattached or became_visible or (opts or {}).retransmit) + and info.visible and kitty_active then + state.tmux_unconfirmed = false + retransmit((client_changed and "tmux client changed") or (reattached and "tmux session reattached") + or "tmux pane shown") + end +end + +--- Ask tmux about the pane and client (async, bounded) and react: flip the +--- renderer when the attached terminal changed support, re-send images after +--- a reattach or once a pane that was hidden during a transmission shows. +--- opts.retransmit re-sends whenever the pane is visible (FocusGained). +function M.tmux_check(config, opts) + if M.passthrough() ~= "tmux" or M.requested_mode(config or state.last_config) == "cells" then + return false + end + config = config or state.last_config + if state.tmux_checking then + state.tmux_pending = state.tmux_pending or {} + state.tmux_pending.retransmit = state.tmux_pending.retransmit or (opts or {}).retransmit + return true + end + state.tmux_checking = true + local generation = state.generation + tmux.query(tmux_timeout(config), function(info) + if generation ~= state.generation then + return + end + state.tmux_checking = false + if info then + apply_tmux_info(info, config, opts) + end + local pending = state.tmux_pending + state.tmux_pending = nil + if pending then + M.tmux_check(config, pending) + end + end) + return true +end + +-- A transmission counts as delivered only if a key reached Neovim or focus +-- was gained this recently before it (a tmux query is only a sample and +-- proves nothing about the moments around it). tmux drops passthrough from hidden panes when +-- allow-passthrough is "on", and a pane can be hidden and shown again +-- between any two samples, so anything else is re-sent on the next key. +M.TMUX_CONFIRM_NS = 250 * 1e6 + +--- A client shows this pane right now (key input, FocusGained). +function M.note_visible() + state.tmux_visible_at = (vim.uv or vim.loop).hrtime() +end + +--- A key or mouse event reached Neovim: the pane is visible. Re-send images +--- whose delivery was uncertain. +function M.note_key() + M.note_visible() + if state.tmux_unconfirmed then + state.tmux_unconfirmed = false + retransmit("unconfirmed transmission") + end +end + +--- Called after each transmission: under tmux, remember it if nothing shows +--- the pane was visible when it was sent. +--- Also with allow-passthrough "all": tmux still drops passthrough while no +--- client shows the session (switched to another one), and tmux's own +--- snapshot (client pid, visibility, a whole-second attach time) can look +--- identical before and after a quick switch away and back. Only a key or a +--- focus event proves a client is on this session, so anything sent without +--- one just before it is re-sent on the next. +function M.note_transmit() + if M.passthrough() ~= "tmux" then + return + end + local seen = state.tmux_visible_at + if not seen or (vim.uv or vim.loop).hrtime() - seen > M.TMUX_CONFIRM_NS then + state.tmux_unconfirmed = true + end +end + +--- True while some transmission still awaits proof of delivery. +function M.tmux_unconfirmed() + return state.tmux_unconfirmed == true +end + +--- Subscribe to cell-size changes learned from tmux (client change). +function M.on_cell_change(fn) + cell_listeners[#cell_listeners + 1] = fn +end + +--- Outer terminal name behind tmux (client_termtype or termname), if known. +function M.outer_terminal() + local info = state.tmux_info + if not info then + return nil + end + if info.termtype and info.termtype ~= "" then + return info.termtype + end + return info.termname +end + --- Runtime failure: downgrade to cells for the rest of the process. function M.fail(reason) if state.status == "cells" and state.reason == reason then @@ -326,9 +685,7 @@ function M.reject(reason) if state.transmission == "file" and not state.file_fallback_at then state.transmission = "direct" state.file_fallback_at = now - for _, fn in ipairs(vim.list_extend({}, retransmit_listeners)) do - pcall(fn, reason) - end + retransmit(reason) return end M.fail(reason) @@ -360,6 +717,9 @@ function M.transmission(config) if t == "file" or t == "direct" then return t end + if M.passthrough() == "tmux" then + return "direct" + end return state.transmission or "direct" end @@ -431,6 +791,11 @@ function M.cell_size(config) elseif not env_override then measured = measure_cell() end + -- Older tmux leaves the pane's pixel size at 0; the client's cell size + -- (tmux 3.4+ formats) is the next best measurement. + if not measured and state.tmux_info and state.tmux_info.cell then + measured = { w = state.tmux_info.cell.w, h = state.tmux_info.cell.h } + end state.cell = measured or { w = 20, h = 40, estimated = true } end return state.cell @@ -474,9 +839,13 @@ function M._reset() if state.probe_file then pcall(os.remove, state.probe_file) end - state = { status = "unknown" } + state = { status = "unknown", generation = (state.generation or 0) + 1 } listeners = {} owned_ids = {} + passthrough_cfg = "auto" + own_tty_override = nil + tmux._set_runner(nil) + tmux._reset() end function M._set_sink(fn) @@ -487,6 +856,11 @@ function M._set_env(e) env_override = e end +--- Test hook: fn() -> tty path | nil replaces Neovim's own tty lookup. +function M._set_own_tty(fn) + own_tty_override = fn +end + M.QUERY_ID = QUERY_ID return M diff --git a/lua/diffbandit/kitty/ids.lua b/lua/diffbandit/kitty/ids.lua new file mode 100644 index 0000000..5471739 --- /dev/null +++ b/lua/diffbandit/kitty/ids.lua @@ -0,0 +1,135 @@ +-- Per-process image-id namespace. +-- +-- Image ids are 24-bit (the placeholder fg colour carries them): a 12-bit +-- process prefix and a 12-bit per-process slot. Several Neovim processes can +-- share one outer terminal (tmux panes, nested terminals), where an id +-- collision lets one process overwrite or delete another's ribbon. Hashing +-- the pid alone aliases (pids 10000 and 14095 agree mod 4095), so each +-- process claims a prefix in a per-user registry directory by holding an +-- exclusive flock(2) on that prefix's file for its whole lifetime. +-- +-- The kernel arbitrates the lock atomically and drops it when the process +-- exits or crashes, so there is no stale-claim cleanup, no pid file to read, +-- and no window in which two processes can both win a prefix. Files are left +-- in place (empty, at most 4095); only the locks matter. Without LuaJIT ffi +-- or a usable directory the pid hash is used as a best-effort fallback. +local M = {} + +M.PREFIXES = 0xFFF -- usable prefixes: 1..4095 + +local uv = vim.uv or vim.loop +local LOCK_EX, LOCK_NB = 2, 4 + +local claimed = nil -- { prefix = n, fd = int|nil } +local held = {} -- prefix -> fd, every lock this process holds (tests may hold several) +local dir_override = nil +local pid_override = nil + +local flock = nil +local function get_flock() + if flock ~= nil then + return flock or nil + end + local ok, ffi = pcall(require, "ffi") + if ok then + pcall(ffi.cdef, "int flock(int fd, int operation);") + local ok_fn, fn = pcall(function() + return ffi.C.flock + end) + if ok_fn and fn then + flock = function(fd, op) + return ffi.C.flock(fd, op) == 0 + end + return flock + end + end + flock = false + return nil +end + +function M.dir() + if dir_override then + return dir_override + end + local tmp = vim.env.TMPDIR + if not tmp or tmp == "" then + tmp = "/tmp" + end + local uid = (uv.getuid and uv.getuid()) or 0 + return (tmp:gsub("/+$", "")) .. "/diffbandit-image-ids-" .. tostring(uid) +end + +--- Try to lock `prefix` in `dir`. Returns the open fd on success. +local function try_lock(dir, prefix, lock) + if held[dir .. "/" .. prefix] then + return nil -- already ours (another simulated process in tests) + end + -- libuv opens with O_CLOEXEC: jobs Neovim spawns never inherit the lock. + local fd = uv.fs_open(string.format("%s/%03x", dir, prefix), "a", 384) + if not fd then + return nil + end + if lock(fd, LOCK_EX + LOCK_NB) then + held[dir .. "/" .. prefix] = fd + return fd + end + uv.fs_close(fd) + return nil +end + +--- This process's prefix (1..4095), claimed on first use. +function M.prefix() + if claimed then + return claimed.prefix + end + local pid = pid_override or vim.fn.getpid() + local start = pid % M.PREFIXES + local lock = get_flock() + local dir = M.dir() + if lock and pcall(vim.fn.mkdir, dir, "p", "0o700") and vim.fn.isdirectory(dir) == 1 then + for i = 0, M.PREFIXES - 1 do + local prefix = ((start + i) % M.PREFIXES) + 1 + local fd = try_lock(dir, prefix, lock) + if fd then + claimed = { prefix = prefix, fd = fd, key = dir .. "/" .. prefix } + break + end + end + end + if not claimed then + claimed = { prefix = start + 1 } + end + return claimed.prefix +end + +--- Release this process's claim (tests; the kernel does it on exit). +function M.release() + if claimed and claimed.fd then + uv.fs_close(claimed.fd) -- closing the fd drops the flock + held[claimed.key] = nil + end + claimed = nil +end + +--- Test hook: act as another process (opts = { dir, pid }). The current +--- claim stays held, as it would be by a still-running process. +function M._simulate(opts) + opts = opts or {} + claimed = nil + dir_override = opts.dir + pid_override = opts.pid +end + +--- Test hook: is `prefix` in `dir` locked by someone (probe from a fresh fd)? +function M._locked(dir, prefix) + local lock = get_flock() + local fd = uv.fs_open(string.format("%s/%03x", dir, prefix), "a", 384) + if not lock or not fd then + return nil + end + local free = lock(fd, LOCK_EX + LOCK_NB) + uv.fs_close(fd) + return not free +end + +return M diff --git a/lua/diffbandit/kitty/init.lua b/lua/diffbandit/kitty/init.lua index 9bce909..ba73f75 100644 --- a/lua/diffbandit/kitty/init.lua +++ b/lua/diffbandit/kitty/init.lua @@ -11,6 +11,9 @@ local detect = require("diffbandit.kitty.detect") local M = {} +-- Minimum interval between activity-driven tmux client checks. +M.TMUX_ACTIVITY_CHECK_MS = 1000 + local hosts = setmetatable({}, { __mode = "k" }) local theme_group = nil @@ -49,9 +52,82 @@ local function install_theme_refresh() detect.on_retransmit(function() vim.schedule(refresh_all) end) + -- tmux: the outer terminal never reports back, so follow the client from + -- tmux itself. Gaining focus (tmux focus-events) re-sends images, which + -- covers switching back to the window and reattaching; editor activity + -- runs a throttled check that catches a reattach (new client, perhaps a + -- different terminal) even without focus events. + local last_check = 0 + local function any_host() + for host in pairs(hosts) do + if not host.disposed then + return host + end + end + return nil + end + vim.api.nvim_create_autocmd("FocusGained", { + group = theme_group, + callback = function() + local host = any_host() + if host and detect.passthrough() == "tmux" then + detect.note_visible() + last_check = (vim.uv or vim.loop).now() + detect.tmux_check(host.config, { retransmit = true }) + end + end, + }) + local function activity() + if detect.passthrough() ~= "tmux" then + return + end + local now = (vim.uv or vim.loop).now() + if now - last_check < M.TMUX_ACTIVITY_CHECK_MS then + return + end + local host = any_host() + if host then + last_check = now + detect.tmux_check(host.config) + end + end + M._tmux_activity = activity + -- Any typed key or mouse event means someone is looking at this pane + -- (whatever window has focus, even one where the cursor cannot move). + -- Keys from :normal, feedkeys or timers (typed == "") prove nothing. + vim.on_key(function(_, typed) + if typed == nil or typed == "" then + return + end + -- Stamp now: the key's mapping runs before any scheduled callback and + -- may render (and transmit) synchronously; a stale stamp would mark that + -- transmission unconfirmed and re-send every image right after it. + if detect.passthrough() == "tmux" then + detect.note_visible() + end + vim.schedule(function() + if detect.passthrough() == "tmux" then + detect.note_key() + end + activity() + end) + end, vim.api.nvim_create_namespace("DiffBanditKittyTmuxActivity")) + vim.api.nvim_create_autocmd("VimResized", { group = theme_group, callback = activity }) -- Font size changes (and a terminal reporting its real pixel size after -- startup) arrive as resizes: re-measure the cell and, when it changed, -- re-size the IntelliJ-width core and re-raster through on_change. + local function rerender_for_cell() + vim.schedule(function() + for host, entry in pairs(hosts) do + if not host.disposed and host.connector_renderer == "kitty" then + M.invalidate(host) + if entry.on_change then + pcall(entry.on_change, "kitty") + end + end + end + end) + end vim.api.nvim_create_autocmd("VimResized", { group = theme_group, callback = function() @@ -66,21 +142,14 @@ local function install_theme_refresh() for host in pairs(hosts) do first = first or host end - if not detect.remeasure(first and first.config) then - return + if detect.remeasure(first and first.config) then + rerender_for_cell() end - vim.schedule(function() - for host, entry in pairs(hosts) do - if not host.disposed and host.connector_renderer == "kitty" then - M.invalidate(host) - if entry.on_change then - pcall(entry.on_change, "kitty") - end - end - end - end) end, }) + -- tmux reported a different cell size (new client, font change measured + -- only by tmux): detect already re-measured, re-size and re-raster. + detect.on_cell_change(rerender_for_cell) end --- opts = { on_change = fn(mode), refresh = fn() } diff --git a/lua/diffbandit/kitty/protocol.lua b/lua/diffbandit/kitty/protocol.lua index 97ca2aa..29fedb3 100644 --- a/lua/diffbandit/kitty/protocol.lua +++ b/lua/diffbandit/kitty/protocol.lua @@ -202,12 +202,17 @@ end --- Capability query: a 1x1 RGB image the terminal validates but never stores. --- medium "t" probes temp-file transmission (the file must exist). +--- U=1 asks about Unicode-placeholder (virtual) placements, the only kind +--- DiffBandit uses: a terminal with Kitty graphics but no placeholders (e.g. +--- Zellij 0.45: "ENOTSUPPORTED:unicode placeholders are not supported") +--- rejects it here, while Ghostty and Kitty answer OK. function M.query(id, medium, path) local keys = { { "i", id }, { "s", 1 }, { "v", 1 }, { "a", "q" }, + { "U", 1 }, { "t", medium or "d" }, { "f", 24 }, } diff --git a/lua/diffbandit/kitty/tmux.lua b/lua/diffbandit/kitty/tmux.lua new file mode 100644 index 0000000..f1ec610 --- /dev/null +++ b/lua/diffbandit/kitty/tmux.lua @@ -0,0 +1,448 @@ +-- tmux transport for the Kitty graphics renderer. +-- +-- Inside tmux every graphics command (transmit, delete, query) must reach +-- the OUTER terminal through tmux's DCS passthrough: +-- +-- ESC P tmux ; ESC \ +-- +-- tmux (checked up to 3.8) does not forward the outer terminal's replies +-- back to the pane: a graphics OK, a passthrough-wrapped DA1 and XTVERSION +-- answers are all swallowed, while an unwrapped DA1 / XTVERSION is answered +-- by tmux itself. The in-band probe therefore cannot work here; detection +-- instead asks tmux, out of band, about the pane and the attached client: +-- +-- * allow-passthrough must be "on" or "all" for this pane; +-- * the client terminal must be one known to implement Kitty graphics with +-- Unicode placeholders (client_termtype / client_termname: Ghostty, +-- Kitty, … — extend with ui.kitty.tmux_terminals); +-- * the client must have the RGB feature, because the placeholder +-- foreground colour carries the image id and tmux would otherwise +-- quantize it to the 256-colour palette. +-- +-- The query runs asynchronously (vim.system) under a hard timeout. The same +-- query later tells the renderer when another client attached (detach / +-- reattach, possibly from a different terminal) and whether the pane was +-- visible, so images sent while it was hidden can be re-sent. +local M = {} + +-- Passthrough was added (and made opt-in) in tmux 3.3. +M.MIN_VERSION = { 3, 3 } + +-- Outer terminals known to support Kitty graphics + Unicode placeholders. +-- Matched (case-insensitive, plain substring) against client_termtype +-- (XTVERSION, e.g. "ghostty 1.4.0") and client_termname (e.g. +-- "xterm-ghostty", "xterm-kitty"). +M.DEFAULT_TERMINALS = { "ghostty", "kitty" } + +local FIELDS = { + "version", + "allow-passthrough", + "client_termname", + "client_termtype", + "client_termfeatures", + "client_pid", + "client_cell_width", + "client_cell_height", + "session_attached", + "window_active", + "window_zoomed_flag", + "pane_active", + "pane_tty", + "session_last_attached", +} + +M.FORMAT = (function() + local parts = {} + for i, f in ipairs(FIELDS) do + parts[i] = "#{" .. f .. "}" + end + return table.concat(parts, "\t") +end)() + +--- Wrap one escape sequence for tmux passthrough. +function M.wrap(data) + return "\27Ptmux;" .. (data:gsub("\27", "\27\27")) .. "\27\\" +end + +--- Wrap each command separately (chunked transmissions stay well below any +--- tmux input-buffer limit, and one bad chunk cannot swallow the rest). +function M.wrap_all(commands) + local out = {} + for i, c in ipairs(commands) do + out[i] = M.wrap(c) + end + return table.concat(out) +end + +--- Parse "3.3a", "3.8", "next-3.6", "openbsd-7.4" -> { major, minor } | nil. +function M.parse_version(v) + if type(v) ~= "string" then + return nil + end + local major, minor = v:match("(%d+)%.(%d+)") + if not major then + return nil + end + return { tonumber(major), tonumber(minor) } +end + +local function version_ok(v) + local parsed = M.parse_version(v) + if not parsed then + return false + end + if parsed[1] ~= M.MIN_VERSION[1] then + return parsed[1] > M.MIN_VERSION[1] + end + return parsed[2] >= M.MIN_VERSION[2] +end + +--- Parse display-message output into an info table. +function M.parse(output) + if type(output) ~= "string" then + return nil + end + output = output:gsub("\r?\n$", "") + local values = vim.split(output, "\t", { plain = true }) + if #values < #FIELDS then + return nil + end + local raw = {} + for i, f in ipairs(FIELDS) do + raw[f] = values[i] + end + local features = {} + for feat in (raw.client_termfeatures or ""):gmatch("[^,]+") do + features[feat] = true + end + local cw, chh = tonumber(raw.client_cell_width), tonumber(raw.client_cell_height) + local attached = (tonumber(raw.session_attached) or 0) > 0 + local visible = attached and raw.window_active == "1" + and (raw.window_zoomed_flag ~= "1" or raw.pane_active == "1") + return { + version = raw.version, + passthrough = raw["allow-passthrough"], + termname = raw.client_termname, + termtype = raw.client_termtype, + features = features, + client = (raw.client_pid or "") .. "|" .. (raw.client_termtype or "") .. "|" .. (raw.client_termname or ""), + cell = (cw and chh and cw > 0 and chh > 0) and { w = cw, h = chh } or nil, + attached = attached, + visible = visible, + pane_tty = raw.pane_tty, + -- Changes whenever a client attaches or switches (back) to the session. + -- Passthrough written while no client shows the session is dropped even + -- with "all", and the client pid stays the same across switch-client. + last_attached = raw.session_last_attached, + } +end + +--- Terminal patterns: ui.kitty.tmux_terminals replaces the defaults. +function M.terminals(config) + local list = ((((config or {}).ui or {}).kitty) or {}).tmux_terminals + if type(list) == "table" and #list > 0 then + return list + end + return M.DEFAULT_TERMINALS +end + +--- Decide support from an info table. Returns ok, reason. +function M.evaluate(info, config) + if not info then + return false, "tmux query failed" + end + if not version_ok(info.version) then + return false, string.format("tmux %s is too old (needs %d.%d+ for allow-passthrough)", + tostring(info.version), M.MIN_VERSION[1], M.MIN_VERSION[2]) + end + if info.passthrough ~= "on" and info.passthrough ~= "all" then + return false, "tmux allow-passthrough is off (set -g allow-passthrough on)" + end + if not info.attached or ((info.termname or "") == "" and (info.termtype or "") == "") then + return false, "no tmux client attached" + end + local hay = ((info.termtype or "") .. " " .. (info.termname or "")):lower() + local known = false + for _, pat in ipairs(M.terminals(config)) do + if type(pat) == "string" and pat ~= "" and hay:find(pat:lower(), 1, true) then + known = true + break + end + end + if not known then + return false, string.format("tmux client terminal %q is not a known Kitty graphics terminal", + info.termtype ~= "" and info.termtype or info.termname) + end + if not info.features.RGB then + return false, "tmux client lacks the RGB feature (set -as terminal-features '," + .. (info.termname ~= "" and info.termname or "TERM") .. ":RGB')" + end + return true, nil +end + +local runner_override = nil + +--- Test hook: fn(args, on_exit(code, stdout)). It may never call on_exit +--- (to exercise the timeout). +function M._set_runner(fn) + runner_override = fn +end + +local function pane_target(args) + local pane = vim.env.TMUX_PANE + if pane and pane ~= "" then + vim.list_extend(args, { "-t", pane }) + end + return args +end + +--- Run a tmux command asynchronously. cb(code|nil, stdout, err) is called +--- exactly once, on the main loop, within timeout_ms (code nil on timeout or +--- spawn failure). +function M.run(args, timeout_ms, cb) + local done = false + local uv = vim.uv or vim.loop + local timer = uv.new_timer() + local function finish(code, stdout, err) + if done then + return + end + done = true + pcall(function() + timer:stop() + timer:close() + end) + cb(code, stdout, err) + end + timeout_ms = math.max(1, tonumber(timeout_ms) or 500) + -- vim.system kills tmux at timeout_ms; report only after that, so a + -- finished run means the command can no longer change anything. + timer:start(timeout_ms + 50, 0, vim.schedule_wrap(function() + finish(nil, nil, "timed out") + end)) + local function on_exit(code, stdout, signal) + vim.schedule(function() + -- vim.system reports a timeout as exit code 124 (killed by SIGTERM). + -- tmux itself never exits with 124: treat it, or any killing signal, + -- as "outcome unknown", like our own timer. + if code == 124 or (signal and signal ~= 0) then + finish(nil, stdout, "timed out") + return + end + finish(code, stdout) + end) + end + if runner_override then + runner_override(args, on_exit) + return + end + local ok = pcall(vim.system, args, { text = true, timeout = timeout_ms }, function(res) + on_exit(res.code, res.stdout, res.signal) + end) + if not ok then + vim.schedule(function() + finish(nil, nil, "cannot run tmux") + end) + end +end + +--- Run `tmux display-message -p` for this pane. cb(info|nil, err) is called +--- exactly once, on the main loop, within timeout_ms. +function M.query(timeout_ms, cb) + local args = pane_target({ "tmux", "display-message", "-p" }) + args[#args + 1] = M.FORMAT + M.run(args, timeout_ms, function(code, stdout, err) + if not code then + cb(nil, err == "timed out" and "tmux query timed out" or ("tmux query failed: " .. tostring(err))) + return + end + if code ~= 0 then + cb(nil, "tmux display-message failed") + return + end + local info = M.parse(stdout) + cb(info, info and nil or "unparseable tmux reply") + end) +end + +-- --- pane-local allow-passthrough "all" ----------------------------------- +-- +-- With allow-passthrough "on", tmux silently drops passthrough written while +-- the pane is not visible (another window, a zoomed neighbour), and nothing +-- tells the sender. Visibility can change between any two observations, so +-- no amount of sampling makes delivery certain. "all" forwards from hidden +-- panes too; Kitty placeholders only show where their cells are on screen, +-- so that is harmless. DiffBandit therefore raises the option to "all" for +-- its OWN pane only (set-option -p, never the global/window value), keeps +-- whatever pane-local value existed to restore it on exit, and never +-- overrides "off" (that is the user disabling graphics). +-- Restore ownership, separate from the option's current value (which every +-- tmux query reports): set once we first changed the pane, holding the +-- pane-local value we found ("" = inherited). +local raised = nil +-- Callbacks of an in-flight raise. Two overlapping raises would make the +-- second read back our own "all" as the value to restore on exit. +local raising = nil +local raising_timeout = 500 +-- Set once the exit restore ran (or Neovim is exiting): no raise may start +-- or be sent after it, or it could land with nothing left to undo it. +local exiting = false + +local function is_exiting() + return exiting or vim.v.exiting ~= vim.NIL +end + +--- True once this pane's passthrough was raised to "all" by us. +function M.raised() + return raised ~= nil +end + +--- Raise this pane's allow-passthrough from "on" to "all". cb(ok) after. +--- Callers decide from the current value (a fresh query), so this also +--- re-applies "all" after someone reset the pane to "on"; the value saved +--- for the exit restore stays the one found before our first change. +function M.raise_passthrough(timeout_ms, cb) + if is_exiting() then + cb(false) + return + end + if raising then + raising[#raising + 1] = cb + return + end + raising = { cb } + raising_timeout = math.max(1, tonumber(timeout_ms) or 500) + local function done(ok) + local waiting = raising or {} + raising = nil + for _, fn in ipairs(waiting) do + fn(ok) + end + end + local function set_all(previous) + -- show-options answered while exiting (another VimLeavePre handler + -- pumped the loop after our restore returned): never send the change. + if is_exiting() then + done(false) + return + end + -- Take restore ownership and install the exit hook BEFORE the change is + -- sent: tmux may apply it and Neovim exit before the completion runs. + -- The restore only acts while the pane holds "all", so exiting before + -- the change lands (or after it failed) leaves the pane untouched. + local owned_now = false + if not raised then + raised = { previous = previous } + owned_now = true + end + M.ensure_exit_restore() + local set = vim.list_extend(pane_target({ "tmux", "set-option", "-p" }), { "allow-passthrough", "all" }) + M.run(set, timeout_ms, function(set_code) + -- Give ownership up only on a definite failure. After a timeout tmux + -- may have applied the change before it was killed; the restore + -- checks the pane's value anyway. + if set_code ~= nil and set_code ~= 0 and owned_now then + raised = nil + end + done(set_code == 0) + end) + end + -- Pane-local value only (no -A): "" when the pane inherits. + local show = vim.list_extend(pane_target({ "tmux", "show-options", "-p", "-q", "-v" }), { "allow-passthrough" }) + M.run(show, timeout_ms, function(code, stdout) + if code ~= 0 then + done(false) + return + end + local previous = vim.trim(stdout or "") + if previous == "off" then + done(false) -- explicitly disabled for this pane: respect it + return + end + set_all(previous) + end) +end + +--- Undo raise_passthrough (VimLeavePre). Synchronous with a short timeout, +--- since Neovim is exiting. +-- Run a tmux command synchronously (exit path). Returns code, stdout. +local function run_sync(args) + if runner_override then + local code, out + runner_override(args, function(c, o) + code, out = c, o + end) + vim.wait(500, function() + return code ~= nil + end, 5) + return code, out + end + local ok, res = pcall(function() + return vim.system(args, { text = true }):wait(500) + end) + if not ok or not res then + return nil, nil + end + return res.code, res.stdout +end + +--- Undo raise_passthrough (VimLeavePre), but only while the pane still holds +--- the "all" we set: a change made since (someone set "off", or "on") is +--- the user's and is kept. +function M.restore_passthrough() + exiting = true + if not raised then + return + end + -- A set-option "all" may still be running: let it finish (or be killed by + -- its timeout) first, or it could land after the check below and leave + -- the pane at "all" once Neovim is gone. Each of the two raise steps is + -- bounded by the timeout. + if raising then + vim.wait(2 * raising_timeout + 100, function() + return raising == nil + end, 5) + end + if not raised then + return -- the in-flight raise failed and gave up ownership + end + local previous = raised.previous + raised = nil + local show = vim.list_extend(pane_target({ "tmux", "show-options", "-p", "-q", "-v" }), { "allow-passthrough" }) + local code, current = run_sync(show) + if code ~= 0 or vim.trim(current or "") ~= "all" then + return + end + local args + if previous == "" then + args = vim.list_extend(pane_target({ "tmux", "set-option", "-p", "-u" }), { "allow-passthrough" }) + else + args = vim.list_extend(pane_target({ "tmux", "set-option", "-p" }), { "allow-passthrough", previous }) + end + run_sync(args) +end + +local exit_group = nil + +--- Restore the pane on VimLeavePre (installed once, before any change). +function M.ensure_exit_restore() + if exit_group or runner_override then + return + end + exit_group = vim.api.nvim_create_augroup("DiffBanditKittyTmuxRestore", { clear = true }) + vim.api.nvim_create_autocmd("VimLeavePre", { + group = exit_group, + callback = function() + M.restore_passthrough() + end, + }) +end + +--- Test hook: forget the raised state. +function M._reset() + raised = nil + raising = nil + exiting = false +end + +return M diff --git a/tests/run.lua b/tests/run.lua index b3b6c2c..dac1c62 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -44,6 +44,7 @@ local specs = { "35_render_recovery.lua", "40_oracle.lua", "45_kitty.lua", + "46_kitty_tmux.lua", } local map_entries = {} diff --git a/tests/specs/45_kitty.lua b/tests/specs/45_kitty.lua index abb265d..4f526d5 100644 --- a/tests/specs/45_kitty.lua +++ b/tests/specs/45_kitty.lua @@ -9,6 +9,10 @@ do local detect = require("diffbandit.kitty.detect") local kitty_canvas = require("diffbandit.kitty.canvas") local nvim_util = require("diffbandit.util.nvim") + -- These specs exercise the raw (non-tmux) transport; the tmux transport + -- has its own spec. Do not inherit the developer's $TMUX. + local saved_tmux_env = vim.env.TMUX + vim.env.TMUX = nil local function nr(cp) return vim.fn.nr2char(cp) @@ -97,8 +101,8 @@ do assert_eq(zlib.uncompress(packed, #pixels), pixels, "zlib payload round-trips") end - assert_eq(protocol.query(5, "d"),"\27_Gi=5,s=1,v=1,a=q,t=d,f=24;AAAA\27\\", "query golden") - assert_eq(protocol.query(6, "t", "/tmp/p"), "\27_Gi=6,s=1,v=1,a=q,t=t,f=24;" .. vim.base64.encode("/tmp/p") .. "\27\\", + assert_eq(protocol.query(5, "d"),"\27_Gi=5,s=1,v=1,a=q,U=1,t=d,f=24;AAAA\27\\", "query golden (asks about Unicode placeholders)") + assert_eq(protocol.query(6, "t", "/tmp/p"), "\27_Gi=6,s=1,v=1,a=q,U=1,t=t,f=24;" .. vim.base64.encode("/tmp/p") .. "\27\\", "temp-file query golden") assert_eq(protocol.delete(5), "\27_Ga=d,d=I,i=5,q=2\27\\", "delete golden") @@ -336,7 +340,7 @@ do assert_eq(detect.status(), "probing", "probe started") assert_eq(#sent, 1, "one probe write") assert_eq(sent[1]:find(protocol.query(detect.QUERY_ID, "d"), 1, true) == 1, true, "direct query first") - assert_eq(sent[1]:find("a=q,t=t", 1, true) ~= nil, true, "temp-file query included") + assert_eq(sent[1]:find("a=q,U=1,t=t", 1, true) ~= nil, true, "temp-file query included") assert_eq(sent[1]:sub(-#protocol.DA1), protocol.DA1, "DA1 sentinel last") vim.api.nvim_exec_autocmds("TermResponse", { data = { sequence = "\27_Gi=" .. detect.QUERY_ID .. ";OK\27\\" } }) vim.api.nvim_exec_autocmds("TermResponse", { data = { sequence = "\27_Gi=" .. (detect.QUERY_ID + 1) .. ";OK\27\\" } }) @@ -415,6 +419,20 @@ do end), true, "DA1 without a graphics reply resolves to cells") assert_eq(detect.transmission({ ui = {} }), "direct", "no file reply keeps direct transmission") + -- Kitty graphics without Unicode placeholders (Zellij 0.45 answers the + -- U=1 query with an error before its DA1): cells, with the reason. + probe_env() + detect.resolve({ ui = {} }) + vim.api.nvim_exec_autocmds("TermResponse", { data = { sequence = "\27_Gi=" .. detect.QUERY_ID + .. ";ENOTSUPPORTED:unicode placeholders are not supported\27\\" } }) + vim.api.nvim_exec_autocmds("TermResponse", { data = { sequence = "\27[?62;52c" } }) + assert_eq(wait_for(function() + return detect.status() == "cells" + end), true, "placeholder rejection resolves to cells") + local _, rejected = detect.status() + assert_eq(rejected:find("ENOTSUPPORTED:unicode placeholders", 1, true) ~= nil, true, + "the terminal's rejection is the recorded reason") + -- No reply at all: the timeout resolves to cells. probe_env() detect.resolve({ ui = { kitty = { probe_timeout_ms = 20 } } }) @@ -935,5 +953,6 @@ do end detect._set_sink(nil) + vim.env.TMUX = saved_tmux_env kitty_canvas = nil end diff --git a/tests/specs/46_kitty_tmux.lua b/tests/specs/46_kitty_tmux.lua new file mode 100644 index 0000000..5d8dff0 --- /dev/null +++ b/tests/specs/46_kitty_tmux.lua @@ -0,0 +1,956 @@ +-- Kitty graphics under tmux: DCS passthrough escaping, out-of-band +-- detection through `tmux display-message` (tmux swallows the outer +-- terminal's replies), renderer selection, bounded timeouts and fallbacks, +-- wrapped image I/O, per-view cleanup, and client / visibility tracking. +do + local protocol = require("diffbandit.kitty.protocol") + local detect = require("diffbandit.kitty.detect") + local tmux = require("diffbandit.kitty.tmux") + local kitty_canvas = require("diffbandit.kitty.canvas") + + local saved_tmux_env = vim.env.TMUX + vim.env.TMUX = nil + + local function wait_for(pred, ms) + return vim.wait(ms or 1000, pred, 5) + end + + local sent = {} + local queries = 0 -- display-message calls + local reply = nil -- function() -> code, stdout | nil (never answers) + local tmux_cmds = {} -- other tmux commands (show-options / set-option) + local pane_local = "" -- fake pane-local allow-passthrough + local set_code = 0 -- exit code for set-option + local hold_set = false -- apply set-option but never report completion + local delay_set_ms = nil -- apply and complete set-option only after this + local delay_show_ms = nil -- answer show-options only after this + + local FIELD_ORDER = { + "version", "passthrough", "termname", "termtype", "features", "pid", + "cw", "ch", "attached", "window_active", "zoomed", "pane_active", "pane_tty", "last_attached", + } + local answer_defaults = {} + local function info_line(over) + local v = vim.tbl_extend("force", { + version = "3.8", passthrough = "on", termname = "xterm-ghostty", termtype = "ghostty 1.4.0", + features = "256,RGB,sync,focus", pid = "842", cw = "16", ch = "34", + attached = "1", window_active = "1", zoomed = "0", pane_active = "1", pane_tty = "", last_attached = "100", + }, answer_defaults, over or {}) + local parts = {} + for i, k in ipairs(FIELD_ORDER) do + parts[i] = v[k] + end + return table.concat(parts, "\t") .. "\n" + end + + local function answer(over) + reply = function() + return 0, info_line(over) + end + end + + local TMUX_ENV = { has_ui_send = true, has_tty_ui = true, termguicolors = true, tmux = true } + + local function reset_tmux(env) + detect._reset() + detect._set_env(env or TMUX_ENV) + sent = {} + queries = 0 + answer_defaults = {} + answer() + detect._set_sink(function(data) + sent[#sent + 1] = data + end) + tmux_cmds = {} + pane_local = "" + set_code = 0 + hold_set = false + delay_set_ms = nil + delay_show_ms = nil + tmux._set_runner(function(args, on_exit) + assert_eq(args[1], "tmux", "only tmux is run, never a terminal query") + if args[2] ~= "display-message" then + local line = table.concat(args, " ") + tmux_cmds[#tmux_cmds + 1] = line + local code, out = 0, "" + if args[2] == "show-options" and delay_show_ms then + vim.defer_fn(function() + on_exit(0, pane_local .. "\n") + end, delay_show_ms) + return + elseif args[2] == "show-options" then + out = pane_local .. "\n" + elseif args[2] == "set-option" and delay_set_ms and line:find("allow%-passthrough all$") then + vim.defer_fn(function() + pane_local = "all" + answer_defaults.passthrough = "all" + on_exit(set_code, "") + end, delay_set_ms) + return + elseif args[2] == "set-option" then + code = set_code + -- 124 = vim.system timeout: the change may have been applied. + if (code == 0 or code == 124) and line:find("allow%-passthrough all$") then + answer_defaults.passthrough = "all" + pane_local = "all" + elseif code == 0 and line:find(" %-u ") then + pane_local = "" + end + if hold_set and line:find("allow%-passthrough all$") then + return -- applied by tmux, completion not yet processed + end + end + vim.schedule(function() + on_exit(code, out) + end) + return + end + queries = queries + 1 + local r = reply + if not r then + return -- never answers: exercises the timeout + end + local code, out = r() + vim.schedule(function() + on_exit(code, out) + end) + end) + end + + -- Strip passthrough wrappers; returns the inner commands, or nil when any + -- byte sits outside a well-formed wrapper. + local function unwrap(wire) + local out = {} + local pos = 1 + while pos <= #wire do + if wire:sub(pos, pos + 6) ~= "\27Ptmux;" then + return nil + end + local i = pos + 7 + local inner = {} + while true do + local c = wire:sub(i, i) + if c == "" then + return nil + end + if c == "\27" then + local n = wire:sub(i + 1, i + 1) + if n == "\27" then + inner[#inner + 1] = "\27" + i = i + 2 + elseif n == "\\" then + i = i + 2 + break + else + return nil -- a lone ESC would end tmux's DCS early + end + else + inner[#inner + 1] = c + i = i + 1 + end + end + out[#out + 1] = table.concat(inner) + pos = i + end + return out + end + + -- ------------------------------------------------------------------ + -- passthrough escaping + -- ------------------------------------------------------------------ + do + local del = protocol.delete(5) + assert_eq(tmux.wrap(del), "\27Ptmux;\27\27_Ga=d,d=I,i=5,q=2\27\27\\\27\\", + "wrap doubles every ESC (including the inner ST) and terminates the DCS") + assert_eq(tmux.wrap("plain"), "\27Ptmux;plain\27\\", "payload without ESC is untouched") + local chunks = protocol.transmit({ id = 7, width = 2, height = 1, cols = 1, rows = 1, data = "\1\2\3\4\5\6\7\8", chunk_size = 4 }) + local wire = tmux.wrap_all(chunks) + local inner = unwrap(wire) + assert_eq(inner ~= nil, true, "wrapped stream is well formed") + assert_eq(#inner, #chunks, "each chunk gets its own passthrough wrapper") + for i, c in ipairs(chunks) do + assert_eq(inner[i], c, "chunk " .. i .. " round-trips through the wrapper") + end + assert_eq(unwrap(tmux.wrap("\27_Gx\27\\") .. "\27_G"), nil, "unwrap rejects bytes outside a wrapper") + end + + -- ------------------------------------------------------------------ + -- tmux info parsing and evaluation + -- ------------------------------------------------------------------ + do + assert_eq(table.concat(tmux.parse_version("3.3a"), "."), "3.3", "letter suffix") + assert_eq(table.concat(tmux.parse_version("next-3.6"), "."), "3.6", "next- prefix") + assert_eq(tmux.parse_version("master"), nil, "unparseable version") + + local info = tmux.parse(info_line()) + assert_eq(info.passthrough, "on", "passthrough value parsed") + assert_eq(info.features.RGB, true, "features parsed") + assert_eq(info.cell.w .. "x" .. info.cell.h, "16x34", "client cell size parsed") + assert_eq(info.visible, true, "active pane of the active window is visible") + assert_eq(tmux.parse(info_line({ window_active = "0" })).visible, false, "other window hidden") + assert_eq(tmux.parse(info_line({ zoomed = "1", pane_active = "0" })).visible, false, "pane hidden behind a zoom") + assert_eq(tmux.parse(info_line({ zoomed = "1", pane_active = "1" })).visible, true, "zoomed pane itself visible") + assert_eq(tmux.parse(info_line({ attached = "0" })).visible, false, "detached session hidden") + assert_eq(tmux.parse(info_line({ cw = "", ch = "" })).cell, nil, "old tmux: no cell size") + assert_eq(tmux.parse("3.8\ton\n"), nil, "short reply rejected") + + local function verdict(over, cfg) + local ok, why = tmux.evaluate(tmux.parse(info_line(over)), cfg) + return ok, why or "" + end + assert_eq((verdict()), true, "Ghostty + passthrough + RGB is supported") + assert_eq((verdict({ termtype = "", termname = "xterm-kitty" })), true, "kitty by TERM name") + local ok, why = verdict({ passthrough = "off" }) + assert_eq(ok, false, "passthrough off is unsupported") + assert_eq(why:find("allow-passthrough", 1, true) ~= nil, true, "reason names the option") + assert_eq((verdict({ passthrough = "all" })), true, "passthrough all is supported") + ok, why = verdict({ version = "3.2a", passthrough = "" }) + assert_eq(ok, false, "tmux < 3.3 is unsupported") + assert_eq(why:find("too old", 1, true) ~= nil, true, "reason names the version") + ok, why = verdict({ termtype = "iTerm2 3.5", termname = "xterm-256color" }) + assert_eq(ok, false, "unknown client terminal is unsupported") + assert_eq((verdict({ termtype = "Rex 1.0", termname = "xterm-256color" }, + { ui = { kitty = { tmux_terminals = { "rex" } } } })), true, "ui.kitty.tmux_terminals extends trust") + ok, why = verdict({ features = "256,sync" }) + assert_eq(ok, false, "client without RGB is unsupported (id rides the fg colour)") + assert_eq(why:find("RGB", 1, true) ~= nil, true, "reason names the RGB feature") + assert_eq((verdict({ attached = "0", termtype = "", termname = "" })), false, "no client attached") + assert_eq((tmux.evaluate(nil)), false, "failed query is unsupported") + end + + -- ------------------------------------------------------------------ + -- renderer selection and detection fallbacks + -- ------------------------------------------------------------------ + do + -- auto + supported client: detection completes through tmux, no bytes + -- are written to the terminal for it. + reset_tmux() + local flips = {} + detect.on_change(function(s) + flips[#flips + 1] = s + end) + assert_eq(detect.passthrough(), "tmux", "$TMUX selects the tmux transport") + assert_eq(detect.resolve({ ui = {} }), "cells", "cells while the tmux query runs") + assert_eq(detect.status(), "probing", "tmux probe in flight") + assert_eq(wait_for(function() + return detect.status() == "kitty" + end), true, "auto detects graphics through tmux") + assert_eq(flips[1], "kitty", "hosts are notified") + assert_eq(#sent, 0, "no in-band probe inside tmux (its reply would never arrive)") + assert_eq(queries, 1, "one tmux query") + assert_eq(detect.transmission({ ui = {} }), "direct", "tmux always transmits directly by default") + assert_eq(detect.outer_terminal(), "ghostty 1.4.0", "outer terminal known for colour space") + assert_eq(require("diffbandit.kitty.color").image_colorspace({}) == "display-p3", vim.fn.has("mac") == 1 + and (vim.env.SSH_CONNECTION or "") == "", "Ghostty behind tmux keeps the P3 conversion") + + -- Disabled passthrough falls back to cells. + reset_tmux() + answer({ passthrough = "off" }) + detect.resolve({ ui = {} }) + assert_eq(wait_for(function() + return detect.status() == "cells" + end), true, "passthrough off resolves to cells") + local _, why = detect.status() + assert_eq(why:find("allow-passthrough", 1, true) ~= nil, true, "reason recorded") + assert_eq(#sent, 0, "nothing written when unsupported") + + -- tmux never answers: bounded by the timeout. + reset_tmux() + reply = nil + local t0 = (vim.uv or vim.loop).now() + detect.resolve({ ui = { kitty = { probe_timeout_ms = 20 } } }) + assert_eq(wait_for(function() + return detect.status() == "cells" + end, 2000), true, "silent tmux times out to cells") + assert_eq((vim.uv or vim.loop).now() - t0 < 1500, true, "timeout is bounded (max(probe_timeout_ms, 500))") + _, why = detect.status() + assert_eq(why, "tmux query timed out", "timeout reason") + + -- tmux command fails. + reset_tmux() + reply = function() + return 1, "" + end + detect.resolve({ ui = {} }) + assert_eq(wait_for(function() + return detect.status() == "cells" + end), true, "failing tmux command resolves to cells") + + -- "cells" never touches tmux or the terminal. + reset_tmux() + assert_eq(detect.resolve({ ui = { connector_renderer = "cells" } }), "cells", "cells mode") + vim.wait(20) + assert_eq(queries + #sent, 0, "cells mode neither queries tmux nor writes") + + -- "kitty" forces images even when tmux says no, still via tmux. + reset_tmux() + answer({ passthrough = "off" }) + assert_eq(detect.resolve({ ui = { connector_renderer = "kitty" } }), "kitty", "forced kitty inside tmux") + assert_eq(detect.passthrough(), "tmux", "forced kitty keeps the tmux transport") + detect.send(protocol.delete(9)) + assert_eq(sent[#sent], tmux.wrap(protocol.delete(9)), "forced sends are wrapped") + + -- Transport overrides. + reset_tmux() + detect.resolve({ ui = { connector_renderer = "kitty", kitty = { passthrough = "none" } } }) + assert_eq(detect.passthrough(), "none", "passthrough = none disables wrapping") + detect.send(protocol.delete(9)) + assert_eq(sent[#sent], protocol.delete(9), "unwrapped with passthrough = none") + reset_tmux({ has_ui_send = true, has_tty_ui = true, termguicolors = true, tmux = false }) + detect.resolve({ ui = { connector_renderer = "kitty", kitty = { passthrough = "tmux" } } }) + assert_eq(detect.passthrough(), "tmux", "passthrough = tmux forces wrapping") + + -- Outside tmux the in-band probe and raw bytes are unchanged. + reset_tmux({ has_ui_send = true, has_tty_ui = true, termguicolors = true, tmux = false }) + detect.resolve({ ui = {} }) + assert_eq(queries, 0, "outside tmux nothing asks tmux") + assert_eq(sent[1]:find(protocol.query(detect.QUERY_ID, "d"), 1, true), 1, "direct Ghostty keeps the raw in-band probe") + detect._reset() + + -- Cell size: tmux client cell size when the pane reports no pixels. + reset_tmux() + detect._set_measure(function() + return nil + end) + detect.resolve({ ui = {} }) + wait_for(function() + return detect.status() == "kitty" + end) + local cell = detect.cell_size({}) + assert_eq(cell.w .. "x" .. cell.h, "16x34", "tmux client cell size used when the tty has none") + assert_eq(cell.estimated, nil, "tmux size counts as a measurement") + detect._set_measure(function() + return { w = 18, h = 36 } + end) + assert_eq(detect.cell_size({}).w, 18, "a real tty measurement wins") + detect._set_measure(nil) + assert_eq(detect.cell_size({ ui = { kitty = { cell_px = { 9, 19 } } } }).w, 9, "cell_px override wins") + end + + -- ------------------------------------------------------------------ + -- sessions under tmux: wrapped I/O, per-view cleanup, reattach + -- ------------------------------------------------------------------ + local function tmux_session_config(extra) + local cfg = vim.deepcopy(config) + cfg.ui.connector_renderer = "auto" + cfg.ui.kitty.cell_px = { 10, 20 } + -- These exercise delivery tracking for allow-passthrough "on"; raising + -- to "all" is covered separately. + cfg.ui.kitty.tmux_passthrough_all = false + cfg.ui.perf = { enabled = false } + return vim.tbl_deep_extend("force", cfg, extra or {}) + end + + local function lines() + local left, right = {}, {} + for i = 1, 200 do + left[i] = "line " .. i + right[i] = "line " .. i + end + right[5] = "changed five" + table.insert(right, 20, "added") + right[120] = "changed 120" + return left, right + end + + local function start(cfg) + local left, right = lines() + return assert((Session.start({ + left = source_mod.from_lines(left, nil, "left"), + right = source_mod.from_lines(right, nil, "right"), + }, cfg, {}))) + end + + local function all_wrapped() + for i, s in ipairs(sent) do + local inner = unwrap(s) + if not inner then + return false, i + end + for _, c in ipairs(inner) do + if c:sub(1, 3) ~= "\27_G" then + return false, i + end + end + end + return true + end + + local function deletes_of(id) + local n = 0 + for _, s in ipairs(sent) do + for _, c in ipairs(unwrap(s) or {}) do + if c == protocol.delete(id) then + n = n + 1 + end + end + end + return n + end + + do + reset_tmux() + local a = start(tmux_session_config()) + assert_eq(wait_for(function() + return a.connector_renderer == "kitty" + end), true, "auto session flips to kitty once tmux confirms support") + a:render() + local sta = a.kitty_state + assert_eq(sta ~= nil and sta.transmits >= 1, true, "image transmitted through tmux") + local ok, bad = all_wrapped() + assert_eq(ok, true, "every graphics write is passthrough-wrapped (bad write #" .. tostring(bad) .. ")") + local raw = table.concat(sent):gsub("\27\27", "") + assert_eq(raw:find("\27_G", 1, true), nil, "no bare (unescaped) APC ever reaches tmux") + + -- Alignment: same ribbons as without tmux; independent scroll follows. + local before = sta.transmits + vim.api.nvim_win_call(a.right_win, function() + vim.cmd("normal! 30\5") + end) + a:render() + assert_eq(wait_for(function() + return sta.transmits > before + end), true, "independent scroll retransmits inside tmux") + assert_eq((all_wrapped()), true, "scroll transmissions wrapped") + + -- A second view: closing the first deletes only the first image. + local b = start(tmux_session_config()) + b:render() + local stb = b.kitty_state + assert_ne(sta.id, stb.id, "views own distinct image ids") + a:close() + assert_eq(deletes_of(sta.id), 1, "closing a view deletes its own image (wrapped)") + assert_eq(deletes_of(stb.id), 0, "the other view's image is untouched") + assert_eq(stb.destroyed, nil, "other view keeps its canvas") + + -- Reattach from the same terminal (new client pid): images re-sent. + local sent_before = stb.transmits + answer({ pid = "999" }) + detect.tmux_check(b.config) + assert_eq(wait_for(function() + return stb.transmits > sent_before + end), true, "new tmux client (reattach) re-sends the images") + + -- Reattach from a terminal without graphics: cells, images deleted. + answer({ pid = "1000", termtype = "iTerm2 3.5", termname = "xterm-256color" }) + detect.tmux_check(b.config) + assert_eq(wait_for(function() + return b.connector_renderer == "cells" + end), true, "client without graphics flips the view to cells") + assert_eq(deletes_of(stb.id) >= 1, true, "flip deletes the image") + assert_eq(b.kitty_state, nil, "canvas released") + + -- Back in Ghostty: kitty again. + answer({ pid = "1001" }) + detect.tmux_check(b.config) + assert_eq(wait_for(function() + return b.connector_renderer == "kitty" + end), true, "returning to a graphics client restores the ribbons") + b:render() + local stb2 = b.kitty_state + assert_eq(stb2 ~= nil and stb2.transmits >= 1, true, "fresh image after returning") + + -- Pane hidden while transmitting (other window, allow-passthrough on): + -- re-sent once it is visible again. + answer({ pid = "1001", window_active = "0" }) + detect.tmux_check(b.config) + assert_eq(wait_for(function() + return queries >= 1 and detect.status() == "kitty" + end), true, "hidden check settles") + vim.wait(50) + local hidden_sent = stb2.transmits + answer({ pid = "1001", window_active = "1" }) + detect.tmux_check(b.config) + assert_eq(wait_for(function() + return stb2.transmits > hidden_sent + end), true, "pane shown again: images re-sent") + + -- FocusGained re-sends (switching back to the window / reattach). + local focus_sent = stb2.transmits + vim.api.nvim_exec_autocmds("FocusGained", {}) + assert_eq(wait_for(function() + return stb2.transmits > focus_sent + end), true, "FocusGained inside tmux re-sends the images") + + -- Pane resize (zoom) re-renders through the normal layout path. + assert_eq((all_wrapped()), true, "all later writes stay wrapped") + b:close() + assert_eq(deletes_of(stb2.id), 1, "close deletes the remaining image") + detect._reset() + end + + -- Temp-file transmissions (forced) are removed on close and only for the + -- closing view; image ids never collide across Neovim processes. + do + reset_tmux() + local cfg = tmux_session_config({ ui = { connector_renderer = "kitty", kitty = { transmission = "file" } } }) + local a = start(cfg) + local b = start(cfg) + a:render() + b:render() + local fa = vim.deepcopy(a.kitty_state.files) + local fb = vim.deepcopy(b.kitty_state.files) + assert_eq(#fa >= 1 and #fb >= 1, true, "file transmissions write temp files") + a:close() + for _, p in ipairs(fa) do + assert_eq(vim.uv.fs_stat(p), nil, "closed view's temp file removed") + end + for _, p in ipairs(fb) do + assert_ne(vim.uv.fs_stat(p), nil, "other view's temp file kept") + end + b:close() + for _, p in ipairs(fb) do + assert_eq(vim.uv.fs_stat(p), nil, "second view's temp file removed on its close") + end + detect._reset() + + assert_ne(kitty_canvas.image_id(1, 0x101), kitty_canvas.image_id(1, 0x102), + "the same slot in two processes maps to different ids") + assert_eq(kitty_canvas.image_id(0xFFF, 0xFFF) < 0x1000000, true, "ids fit the 24-bit fg colour") + assert_eq(kitty_canvas.image_id(1, 1) > 0, true, "ids are never 0") + end + + -- ------------------------------------------------------------------ + -- inherited $TMUX (Zellij, a terminal or ssh started from inside tmux) + -- ------------------------------------------------------------------ + do + -- Same tty: a real tmux pane, tmux transport as usual. + reset_tmux() + detect._set_own_tty(function() + return "/dev/ttys009" + end) + answer({ pane_tty = "/dev/ttys009" }) + detect.resolve({ ui = {} }) + assert_eq(wait_for(function() + return detect.status() == "kitty" + end), true, "matching pane tty: tmux detection") + assert_eq(detect.passthrough(), "tmux", "matching pane tty keeps the tmux transport") + + -- Different tty: $TMUX belongs to an outer tmux. Never wrap or touch it; + -- probe the terminal in front of us (here: Zellij, no placeholders). + reset_tmux() + detect._set_own_tty(function() + return "/dev/ttys016" + end) + answer({ pane_tty = "/dev/ttys009" }) + detect.resolve({ ui = {} }) + assert_eq(wait_for(function() + return #sent > 0 + end), true, "falls back to the in-band probe") + assert_eq(detect.passthrough(), "none", "inherited $TMUX is ignored for wrapping") + assert_eq(sent[1]:find(protocol.query(detect.QUERY_ID, "d"), 1, true), 1, "probe goes out unwrapped") + assert_eq(#tmux_cmds, 0, "the outer tmux's options are never touched") + vim.api.nvim_exec_autocmds("TermResponse", { data = { sequence = "\27_Gi=" .. detect.QUERY_ID + .. ";ENOTSUPPORTED:unicode placeholders are not supported\27\\" } }) + vim.api.nvim_exec_autocmds("TermResponse", { data = { sequence = "\27[?62;52c" } }) + assert_eq(wait_for(function() + return detect.status() == "cells" + end), true, "Zellij's placeholder rejection resolves to cells") + local _, why = detect.status() + assert_eq(why:find("ENOTSUPPORTED", 1, true) ~= nil, true, "reason is Zellij's answer") + + -- Forced kitty: stop wrapping once the mismatch is known, and re-send. + reset_tmux() + detect._set_own_tty(function() + return "/dev/ttys016" + end) + answer({ pane_tty = "/dev/ttys009" }) + local resent = 0 + detect.on_retransmit(function() + resent = resent + 1 + end) + detect.resolve({ ui = { connector_renderer = "kitty" } }) + assert_eq(wait_for(function() + return detect.passthrough() == "none" + end), true, "forced kitty drops the inherited tmux transport") + assert_eq(resent >= 1, true, "images written for the wrong transport are re-sent") + assert_eq(#tmux_cmds, 0, "forced kitty never raises the outer tmux's passthrough") + detect._reset() + end + + -- ------------------------------------------------------------------ + -- review regressions + -- ------------------------------------------------------------------ + + -- A cell size learned from tmux is re-measured when the client changes + -- (reattach from a terminal with another font), and views re-raster. + do + reset_tmux() + detect._set_measure(function() + return nil + end) + local cfg = tmux_session_config() + cfg.ui.kitty.cell_px = nil + local s1 = start(cfg) + assert_eq(wait_for(function() + return s1.connector_renderer == "kitty" + end), true, "auto session in tmux") + s1:render() + local st = s1.kitty_state + assert_eq(detect.cell_size(cfg).w .. "x" .. detect.cell_size(cfg).h, "16x34", "tmux cell size in use") + assert_eq(st.last_key:find("|16|34|", 1, true) ~= nil, true, "image rasterized at 16x34") + answer({ pid = "2000", cw = "10", ch = "20" }) + detect.tmux_check(cfg) + assert_eq(wait_for(function() + return detect.cell_size(cfg).w == 10 + end), true, "new client's cell size replaces the cached tmux measurement") + assert_eq(wait_for(function() + return s1.kitty_state and s1.kitty_state.last_key and s1.kitty_state.last_key:find("|10|20|", 1, true) ~= nil + end), true, "views re-raster at the new cell size") + -- Same client, font size changed (only tmux measures it). + answer({ pid = "2000", cw = "12", ch = "24" }) + detect.tmux_check(cfg) + assert_eq(wait_for(function() + return s1.kitty_state and s1.kitty_state.last_key and s1.kitty_state.last_key:find("|12|24|", 1, true) ~= nil + end), true, "a cell size change on the same client re-rasters too") + s1:close() + detect._set_measure(nil) + detect._reset() + end + + -- An image sent while the pane may have been hidden is re-sent on the next + -- key, even when every visibility sample happened to say "visible". + do + reset_tmux() + local s1 = start(tmux_session_config()) + assert_eq(wait_for(function() + return s1.connector_renderer == "kitty" + end), true, "auto session in tmux") + s1:render() + local st = s1.kitty_state + -- Background re-render with no recent proof of visibility (e.g. a resize + -- while another tmux window was shown, then switching back). + vim.wait(300) + kitty_canvas.invalidate(s1) + s1:render() + assert_eq(detect.tmux_unconfirmed(), true, "transmission without visibility proof is unconfirmed") + answer({ window_active = "1" }) + detect.tmux_check(s1.config) + vim.wait(50) + local before = st.transmits + detect.note_key() + assert_eq(wait_for(function() + return st.transmits > before + end), true, "next key re-sends the unconfirmed image") + assert_eq(detect.tmux_unconfirmed(), false, "re-send happens once") + -- A transmission right after a key is known delivered. + detect.note_key() + kitty_canvas.invalidate(s1) + s1:render() + assert_eq(detect.tmux_unconfirmed(), false, "transmission after a key needs no re-send") + + -- A typed key whose mapping renders synchronously (long ]c, conflict + -- jumps, hunk actions) after a pause: the key is stamped before the + -- mapping runs, so its transmission is confirmed and nothing is re-sent. + local function render_now() + kitty_canvas.invalidate(s1) + s1:render() + end + vim.keymap.set("n", "(DiffBanditSpecRender)", render_now) + local plug = vim.api.nvim_replace_termcodes("(DiffBanditSpecRender)", true, false, true) + vim.wait(300) + before = st.transmits + vim.api.nvim_feedkeys(plug, "mtx", false) -- "t": as if typed + vim.wait(100) + assert_eq(st.transmits - before, 1, "a typed key that renders synchronously transmits once") + assert_eq(detect.tmux_unconfirmed(), false, "its transmission is confirmed") + + -- Keys from :normal / feedkeys / timers are not typed: no proof of + -- visibility, the transmission stays unconfirmed. + vim.wait(300) + vim.api.nvim_feedkeys(plug, "mx", false) + vim.wait(50) + assert_eq(detect.tmux_unconfirmed(), true, "a non-typed key does not confirm a transmission") + vim.keymap.del("n", "(DiffBanditSpecRender)") + s1:close() + detect._reset() + end + + -- The only client switches to another session and back (same client pid, + -- same visibility). Passthrough written meanwhile had no recipient, even + -- with allow-passthrough "all": session_last_attached moves, so re-send. + do + reset_tmux() + local cfg = tmux_session_config() + cfg.ui.kitty.tmux_passthrough_all = nil -- default: raised to "all" + local s1 = start(cfg) + assert_eq(wait_for(function() + return s1.connector_renderer == "kitty" + end), true, "auto session in tmux") + s1:render() + local st = s1.kitty_state + detect.tmux_check(cfg) + vim.wait(50) + local before = st.transmits + detect.tmux_check(cfg) -- same snapshot: nothing to do + vim.wait(50) + assert_eq(st.transmits, before, "an unchanged tmux snapshot re-sends nothing") + answer({ last_attached = "107" }) + detect.tmux_check(cfg) + assert_eq(wait_for(function() + return st.transmits > before + end), true, "returning to the session re-sends images, even with passthrough all") + s1:close() + detect._reset() + end + + -- Image-id prefixes are claimed with flock(2), not hashed: pids 10000 and + -- 14095 (equal mod 4095) get different prefixes, a released or crashed + -- owner's prefix is free again, and concurrent claimers never share one. + do + local ids = require("diffbandit.kitty.ids") + local dir = vim.fn.tempname() .. "-ids" + ids._simulate({ dir = dir, pid = 10000 }) + local a = ids.prefix() + ids._simulate({ dir = dir, pid = 14095 }) + local b = ids.prefix() + assert_ne(a, b, "aliasing pids claim different prefixes") + assert_ne(kitty_canvas.image_id(1, a), kitty_canvas.image_id(1, b), "their first image ids differ") + assert_eq(ids._locked(dir, a), true, "a claim is a held lock") + ids.release() -- the 14095 claim + assert_eq(ids._locked(dir, b), false, "release drops the lock") + ids._simulate({ dir = dir, pid = 14095 }) + assert_eq(ids.prefix(), b, "a released prefix can be claimed again") + ids.release() + + -- Real processes: start several at once, all hashing to the same start + -- prefix, and hold their claims; then SIGKILL one. + local script = vim.fn.tempname() .. ".lua" + vim.fn.writefile({ + "package.path = package.path .. ';' .. arg[1] .. '/lua/?.lua'", + "local ids = require('diffbandit.kitty.ids')", + "ids._simulate({ dir = arg[2], pid = 4242 })", + "io.stdout:write(ids.prefix() .. '\\n')", + "io.stdout:flush()", + "vim.wait(20000, function() return false end)", + }, script) + local children, prefixes = {}, {} + for i = 1, 6 do + children[i] = vim.system({ vim.v.progpath, "--clean", "--headless", "-l", script, root, dir }, { + stdout = function(_, data) + local n = tonumber((data or ""):match("(%d+)")) + if n then + prefixes[i] = n + end + end, + }) + end + assert_eq(wait_for(function() + local n = 0 + for _ in pairs(prefixes) do + n = n + 1 + end + return n == 6 + end, 10000), true, "concurrent claimers all report a prefix") + local seen = {} + for i = 1, 6 do + assert_eq(seen[prefixes[i]], nil, "concurrent claimers never share a prefix") + seen[prefixes[i]] = true + assert_eq(ids._locked(dir, prefixes[i]), true, "each live claimer holds its lock") + end + children[1]:kill(9) + children[1]:wait(2000) + assert_eq(ids._locked(dir, prefixes[1]), false, "a crashed owner's prefix is free (kernel drops the lock)") + for i = 2, 6 do + children[i]:kill(9) + children[i]:wait(2000) + end + ids._simulate(nil) + vim.fn.delete(dir, "rf") + vim.fn.delete(script) + assert_eq(ids.prefix() >= 1 and ids.prefix() <= 0xFFF, true, "real process prefix in range") + end + + -- allow-passthrough "on" drops images written while the pane is hidden, + -- and visibility can change between any two samples: raise it to "all" + -- for this pane only, before the first image, and restore it on exit. + do + local function cmds_matching(pat) + local n = 0 + for _, c in ipairs(tmux_cmds) do + if c:find(pat) then + n = n + 1 + end + end + return n + end + + reset_tmux() + detect.resolve({ ui = {} }) + assert_eq(wait_for(function() + return detect.status() == "kitty" + end), true, "auto detects graphics") + assert_eq(cmds_matching("^tmux show%-options %-p .*allow%-passthrough$"), 1, "reads the pane-local value first") + assert_eq(cmds_matching("^tmux set%-option %-p .*allow%-passthrough all$"), 1, "raises this pane to all") + assert_eq(cmds_matching("%-g") + cmds_matching("%-w"), 0, "never touches global or window options") + assert_eq(tmux.raised(), true, "raise recorded") + -- Even with "all", tmux drops passthrough while no client shows the + -- session, and a quick switch away and back can leave tmux's snapshot + -- identical (same pid, visibility, whole-second attach time). A send + -- without a key just before it waits for the next key. + detect.tmux_check({ ui = {} }) + vim.wait(50) + detect.note_transmit() + detect.tmux_check({ ui = {} }) -- identical snapshot after the switch back + vim.wait(50) + assert_eq(detect.tmux_unconfirmed(), true, "a background send with passthrough all is still unconfirmed") + detect.note_key() + assert_eq(detect.tmux_unconfirmed(), false, "the next key re-sends it") + detect.note_transmit() + assert_eq(detect.tmux_unconfirmed(), false, "a send right after a key is confirmed") + + tmux.restore_passthrough() + assert_eq(cmds_matching("^tmux set%-option %-p %-u .*allow%-passthrough$"), 1, + "exit restores inheritance when the pane had no own value") + assert_eq(tmux.raised(), false, "restore clears the raise") + + reset_tmux() + pane_local = "on" + detect.resolve({ ui = {} }) + wait_for(function() + return detect.status() == "kitty" + end) + tmux.restore_passthrough() + assert_eq(cmds_matching("^tmux set%-option %-p .*allow%-passthrough on$"), 1, + "exit restores the pane's own previous value") + + -- Overlapping raises (two tmux checks in flight) share one: the second + -- must not read back our own "all" as the value to restore. + reset_tmux() + local results = {} + tmux.raise_passthrough(500, function(ok) + results[#results + 1] = ok + end) + tmux.raise_passthrough(500, function(ok) + results[#results + 1] = ok + end) + assert_eq(wait_for(function() + return #results == 2 + end), true, "both callers are answered") + assert_eq(cmds_matching("^tmux set%-option %-p .*allow%-passthrough all$"), 1, "one raise for overlapping callers") + tmux.restore_passthrough() + assert_eq(cmds_matching("^tmux set%-option %-p %-u .*allow%-passthrough$"), 1, + "restore still un-sets (previous value was not our own all)") + + -- The pane is reset to "on" after our raise: a fresh query reports "on", + -- so "all" is applied again (the restore value stays the original). + reset_tmux() + detect.resolve({ ui = {} }) + wait_for(function() + return detect.status() == "kitty" + end) + pane_local = "on" + answer_defaults.passthrough = "on" + detect.tmux_check({ ui = {} }) + assert_eq(wait_for(function() + return cmds_matching("^tmux set%-option %-p .*allow%-passthrough all$") == 2 + end), true, "a pane reset to on is raised to all again") + tmux.restore_passthrough() + assert_eq(cmds_matching("^tmux set%-option %-p %-u .*allow%-passthrough$"), 1, + "restore returns to the value found before the first raise") + + -- Exiting while the first raise is in flight (tmux applied it, Neovim has + -- not processed the completion): restore ownership already exists. + reset_tmux() + hold_set = true + tmux.raise_passthrough(500, function() end) + assert_eq(wait_for(function() + return cmds_matching("^tmux set%-option %-p .*allow%-passthrough all$") == 1 + end), true, "raise sent") + assert_eq(tmux.raised(), true, "restore ownership is taken before set-option completes") + tmux.restore_passthrough() + assert_eq(cmds_matching("^tmux set%-option %-p %-u .*allow%-passthrough$"), 1, + "an exit during the in-flight raise still restores the pane") + + -- tmux applies the raise, then vim.system times out (exit code 124): + -- the outcome is unknown, so ownership stays and the restore checks. + reset_tmux() + set_code = 124 + local raise_ok + tmux.raise_passthrough(500, function(ok) + raise_ok = ok + end) + assert_eq(wait_for(function() + return raise_ok ~= nil + end), true, "timed-out raise completes") + assert_eq(tmux.raised(), true, "a timeout keeps restore ownership") + set_code = 0 -- the exit-time restore itself succeeds + tmux.restore_passthrough() + assert_eq(pane_local, "", "a raise that timed out after applying is still restored") + + -- The opposite ordering: Neovim exits before tmux has applied the raise. + -- The restore waits for it, then sees "all" and restores. + reset_tmux() + delay_set_ms = 150 + tmux.raise_passthrough(500, function() end) + assert_eq(wait_for(function() + return cmds_matching("^tmux set%-option %-p .*allow%-passthrough all$") == 1 + end), true, "delayed raise sent") + assert_eq(pane_local, "", "raise not applied yet") + tmux.restore_passthrough() + assert_eq(pane_local, "", "an exit before the raise lands still ends with the pane restored") + assert_eq(cmds_matching("^tmux set%-option %-p %-u .*allow%-passthrough$"), 1, + "restore ran after the pending raise completed") + + -- Exiting while the FIRST raise is still reading the pane value (no + -- ownership yet): if anything pumps the loop after the restore returned + -- (another VimLeavePre handler), the raise must not be sent. + reset_tmux() + delay_show_ms = 100 + local late_ok + tmux.raise_passthrough(500, function(ok) + late_ok = ok + end) + tmux.restore_passthrough() + assert_eq(wait_for(function() + return late_ok ~= nil + end), true, "the pending raise completes") + assert_eq(late_ok, false, "a raise answered after the exit restore reports failure") + assert_eq(cmds_matching("^tmux set%-option %-p .*allow%-passthrough all$"), 0, + "no set-option is sent after the exit restore") + assert_eq(pane_local, "", "the pane is left untouched") + local after_exit + tmux.raise_passthrough(500, function(ok) + after_exit = ok + end) + assert_eq(after_exit, false, "no new raise starts once exiting") + + -- The user changes the pane after our raise: exit keeps their choice. + reset_tmux() + detect.resolve({ ui = {} }) + wait_for(function() + return detect.status() == "kitty" + end) + pane_local = "off" + tmux.restore_passthrough() + assert_eq(cmds_matching("%-u") + cmds_matching("allow%-passthrough on$"), 0, + "a later explicit off survives the exit restore") + + -- Opt-out keeps the user's value and falls back to delivery tracking. + reset_tmux() + detect.resolve({ ui = { kitty = { tmux_passthrough_all = false } } }) + wait_for(function() + return detect.status() == "kitty" + end) + assert_eq(#tmux_cmds, 0, "tmux_passthrough_all = false changes no tmux option") + detect.note_transmit() + assert_eq(detect.tmux_unconfirmed(), true, "without the raise, unproven transmissions await a re-send") + + -- A failed raise still renders, with delivery tracking. + reset_tmux() + set_code = 1 + detect.resolve({ ui = {} }) + assert_eq(wait_for(function() + return detect.status() == "kitty" + end), true, "failed raise still detects graphics") + assert_eq(tmux.raised(), false, "failed raise not recorded") + tmux.restore_passthrough() + assert_eq(cmds_matching("%-u"), 0, "nothing to restore after a failed raise") + detect._reset() + end + + detect._set_sink(nil) + detect._set_env(nil) + tmux._set_runner(nil) + vim.env.TMUX = saved_tmux_env +end