Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 57 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -602,18 +606,62 @@ 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.
- Images go through a temp file when the terminal can read one (local), and as
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
Expand Down
5 changes: 5 additions & 0 deletions doc/diffbandit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
13 changes: 13 additions & 0 deletions lua/diffbandit/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 25 additions & 4 deletions lua/diffbandit/kitty/canvas.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 ---------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lua/diffbandit/kitty/color.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 ~= "",
}
Expand Down
Loading
Loading