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