Skip to content
Open
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ No ImageMagick. No luarocks. No external binaries. Pure Lua on Neovim >= 0.10.
- tmux is explicitly unsupported in v0.x (blit no-ops under tmux)
- GUI frontends / `--embed` (e.g. Neovide) are unsupported (blit no-ops)

Run `:checkhealth blit` to see detection results for your environment.
Run `:checkhealth blit` to see detection results for your environment. On
Neovim >= 0.12 it also lists any error the terminal sent back for an image
(e.g. a PNG it rejected); on 0.10 / 0.11 those errors are not available.

## Install

Expand Down Expand Up @@ -89,6 +91,10 @@ general questions go in [Discussions](https://github.com/optiflowic/blit.nvim/di
documented as an anchor byte column and silently ignored; it is now
validated as a non-negative integer, so a negative or fractional `col`
that used to be accepted raises an argument error.
- On Neovim >= 0.12, error responses from the terminal (e.g. a rejected
PNG) are recorded and listed by `:checkhealth blit`
([#12](https://github.com/optiflowic/blit.nvim/issues/12)). Neovim 0.10 /
0.11 behave as before.

### v0.2.0 (2026-08-08)

Expand Down
4 changes: 3 additions & 1 deletion doc/blit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ hidden only once it has no overlap with the window at all. See
- tmux is explicitly unsupported in v0.x; blit no-ops under tmux
- GUI frontends and `--embed` (e.g. Neovide) are unsupported; blit no-ops

Run `:checkhealth blit` to see detection results for your environment.
Run `:checkhealth blit` to see detection results for your environment. On
Neovim >= 0.12 it also lists any error the terminal sent back for an image
(e.g. a PNG it rejected); on 0.10 / 0.11 those errors are not available.

==============================================================================
3. Setup *blit-setup*
Expand Down
7 changes: 7 additions & 0 deletions docs/manual-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ WezTerm (and Ghostty when available) before tagging a release.
original tab restores it (issue #16).
- [ ] Quitting Neovim (`:qa`) leaves no stray image on screen after exit.
- [ ] `:checkhealth blit` reports this terminal as supported.
- [ ] (Neovim >= 0.12) After normal use — show, scroll, resize, clear —
typing still works normally (no stray characters inserted) and
`:checkhealth blit` lists no "terminal rejected image" error.
- [ ] (Neovim >= 0.12) `show()` a file with a valid PNG signature and IHDR
but corrupt image data: no image appears, and `:checkhealth blit`
(run while the handle is still shown) lists a "terminal rejected
image" error naming that file.

## WezTerm

Expand Down
49 changes: 38 additions & 11 deletions docs/spec/kitty-graphics.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Every command is an APC (Application Program Command) escape sequence:
| `f` | pixel format | `100` (PNG) — only format blit ever sends, per the PNG-only v0.x constraint |
| `t` | transmission medium | `d` (direct, i.e. the payload is in the escape code itself) — blit never uses file-based (`t=f`) or shared-memory transmission, to avoid any filesystem/IPC surface beyond reading the source PNG |
| `i` | image id | one of blit's reserved range, see below |
| `q` | quiet | `2` (suppress all responses) always, see "Response handling" below |
| `q` | quiet | on transmits (`a=T`/`a=t`) only: `1` (suppress `OK`, keep errors) when responses can be received, else `2` (suppress everything) — see "Response handling" below |
| `m` | more chunks | `1` (more chunks follow) / `0` (last chunk) |
| `p` | placement id | a per-handle id, distinct across every concurrently-live placement — see "Placement" below |
| `c`, `r` | placement columns/rows | shrink to the visible cell span when a placement is partially clipped, see "Source-rectangle cropping" below |
Expand Down Expand Up @@ -199,16 +199,43 @@ facts; the stateful counter that actually hands out ids from this range is

## Response handling

blit always sets `q=2` (suppress all responses — no `OK` and no error
response is sent back by the terminal). This is a deliberate v0.x
limitation: blit does not read stdin asynchronously to parse protocol
responses, so any response bytes that did arrive would otherwise leak into
Neovim's normal input stream. The consequence is that blit cannot currently
detect terminal-side transmission errors (e.g. malformed PNG rejected by the
terminal) — failures are only visible if they cause a visible rendering
problem. Revisiting this (async stdin reader surfacing errors through
`:checkhealth` or return values) is a future-version consideration, not
implemented speculatively here.
The terminal answers a graphics command that carries an `i=` with an APC
of its own: `ESC _ G i=<id>[,p=<placement id>] ; <message> ESC \`, where
`<message>` is `OK` or `<CODE>:<text>` (e.g. `EBADPNG:...`, `ENOENT:...`).
The `q` key controls which of these are sent: `q=1` suppresses `OK`, `q=2`
suppresses errors too.

blit never reads stdin itself — Neovim's TUI owns it, and a second reader
would race it for input bytes. The only safe channel is Neovim's own
`TermResponse` event, which delivers APC responses from Neovim 0.12 onward
(0.10/0.11 deliver OSC/DCS only). The sequence arrives as
`ESC _ G <control> ; <message>` with the terminating ST already stripped.
`terminal.has_response_support()` reports whether that channel exists, and
`terminal.parse_response()` decodes one sequence, returning nil for
anything that is not a graphics response about an id in blit's reserved
range.

- **Neovim >= 0.12**: transmits carry `q=1`, so only error responses come
back. `renderer.lua` records them (see
`docs/spec/renderer-placement.md`'s "Terminal error responses") and
`:checkhealth blit` lists them.
- **Neovim 0.10 / 0.11**: transmits carry `q=2`, exactly as before; a
terminal-side transmission error stays invisible unless it shows up as a
rendering problem.

Placement (`a=p`) and delete (`a=d`) commands carry no `q` key on any
version, so the terminal's default applies and it may answer a placement
with `OK` or an error. These bytes have always been emitted this way,
and no answer has been observed leaking into Neovim's input as
keystrokes on any supported version (`docs/manual-testing.md` checks
this). On 0.12+ the same listener records the error ones, which
surfaces e.g. an `ENOENT` for a placement against an id whose pixel data
the terminal has dropped.

Responses are diagnostic only. They arrive asynchronously, after `show()`
has returned, so they cannot become a `nil, err` return value, and no
recovery path (Ghostty retransmit, delete retries) waits on or reacts to
them.

## Per-terminal quirks

Expand Down
23 changes: 23 additions & 0 deletions docs/spec/renderer-placement.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,29 @@ created for them. Like `debounce_timer`, `ghostty_retransmit_timer` is
stopped and closed by `maybe_teardown_autocmds()` once zero handles remain,
preserving the "no timers active when zero images are displayed" rule.

## Terminal error responses

On Neovim >= 0.12 (`terminal.has_response_support()`, see
`docs/spec/kitty-graphics.md`'s "Response handling"), `ensure_autocmds`
adds one `TermResponse` autocmd to the `blit` augroup. Its callback hands
every sequence to `terminal.parse_response()` and records the result when
it is an error (`ok == false`) for an id blit currently owns (`used_ids`);
`OK` responses, foreign ids, and unrelated OSC/DCS responses are ignored.

Recorded errors live in a bounded list (the most recent 20, oldest
dropped first) of `{ id, placement_id?, path?, message }`; `path` comes
from a live handle using that id and is nil once none does.
`renderer.response_errors()` returns a copy, and `:checkhealth blit`
prints each entry. The list is never cleared during a session — it is a
diagnostic log, not handle state — and recording an error changes nothing
about the handle, its extmark, or the transmission cache.

The listener belongs to the handle-gated augroup, so it is removed with
the last handle like every other autocmd (no listener while idle). An
error that arrives after that teardown is not recorded.

On Neovim 0.10 / 0.11 no listener is registered and the list stays empty.

## Lifecycle

Two distinct kinds of state transition, kept separate:
Expand Down
13 changes: 7 additions & 6 deletions docs/spec/terminal-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ rather than re-deriving detection logic ad hoc.
blit must never emit escape sequences to a terminal that won't understand
them (garbage on screen, or worse, sequences interpreted as something
else). Detection is env-var based — no runtime protocol query (`a=q`) is
used, since that would require asynchronously reading stdin for the
terminal's response, which blit does not do in v0.x (see the "Response
handling" section of `docs/spec/kitty-graphics.md`).
used: a query answer can only be received through Neovim's `TermResponse`
event on Neovim >= 0.12 (see the "Response handling" section of
`docs/spec/kitty-graphics.md`), and detection must work on 0.10 as well.

## Detection matrix

Expand Down Expand Up @@ -143,6 +143,7 @@ to consume the whole buffer in one call regardless of blocking mode, so

DA1 (`\x1b[c`) or XTGETTCAP queries could provide a stronger capability
check than env vars alone, but require reading a terminal response
asynchronously — the same complexity blit avoids for protocol responses in
general (see `docs/spec/kitty-graphics.md`). Out of scope until a measured
need (real-world false detection reports) justifies the added complexity.
asynchronously. The `TermResponse` channel blit uses for graphics error
responses (see `docs/spec/kitty-graphics.md`) could carry these too, on
Neovim >= 0.12 only. Out of scope until a measured need (real-world false
detection reports) justifies the added complexity.
17 changes: 17 additions & 0 deletions lua/blit/health.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
-- docs/spec/terminal-detection.md for the detection matrix this reflects.

local terminal = require("blit.terminal")
local renderer = require("blit.renderer")

local M = {}

Expand Down Expand Up @@ -39,6 +40,22 @@ function M.check()
vim.health.warn(WEZTERM_CRASH_WARNING)
end

if terminal.has_response_support() then
vim.health.ok("Terminal error responses are reported here (Neovim >= 0.12)")
else
vim.health.info("Terminal error responses need Neovim >= 0.12; they stay suppressed")
end

for _, response_error in ipairs(renderer.response_errors()) do
vim.health.error(
("terminal rejected image id %d (%s): %s"):format(
response_error.id,
response_error.path or "no longer displayed",
response_error.message
)
)
end

if caps.supported then
vim.health.ok("blit is supported in this environment")
else
Expand Down
92 changes: 86 additions & 6 deletions lua/blit/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,42 @@ M._redraw_fn = function()
vim.cmd("redraw")
end

-- Overridable seam for tests: production code always asks terminal.lua
-- whether this Neovim can deliver protocol responses at all.
M._has_response_support_fn = terminal.has_response_support

-- Terminal error responses ----------------------------------------------------
-- See docs/spec/renderer-placement.md's "Terminal error responses" section.

local MAX_RESPONSE_ERRORS = 20

---@class blit.ResponseError
---@field id integer
---@field placement_id? integer
---@field path? string nil when no live handle references the id anymore
---@field message string the terminal's `<CODE>:<text>` error string

---@type blit.ResponseError[]
local response_errors = {}

-- `q=1` keeps `OK` suppressed but lets error responses through; only worth
-- asking for when something can actually receive them.
---@return 1|2
local function transmit_quiet()
return M._has_response_support_fn() and 1 or 2
end

---@param id integer
---@return string?
local function path_for_id(id)
for _, handle in ipairs(M._handles) do
if handle.id == id then
return handle.path
end
end
return nil
end

-- Image id allocation ---------------------------------------------------------
-- Pure logic over terminal.lua's reserved range. Ids are handed out
-- sequentially and only returned to the free pool by destroy_handle's
Expand Down Expand Up @@ -729,6 +765,7 @@ local function retransmit_and_place_group(handles)
terminal.build_transmit(bytes, {
id = new_id,
action = "T",
quiet = transmit_quiet(),
placement = placement_opts(display_handle, placements[display_handle]),
})
)
Expand All @@ -737,7 +774,10 @@ local function retransmit_and_place_group(handles)
-- No handle sharing this id is currently visible; keep the data ready
-- (transmit-only) so whichever handle becomes visible next places
-- correctly against the new id without needing its own re-transmit.
vim.list_extend(sequences, terminal.build_transmit(bytes, { id = new_id, action = "t" }))
vim.list_extend(
sequences,
terminal.build_transmit(bytes, { id = new_id, action = "t", quiet = transmit_quiet() })
)
end

for _, h in ipairs(handles) do
Expand Down Expand Up @@ -1117,6 +1157,23 @@ local function on_vim_leave_pre()
terminal.reset_writer()
end

---@param sequence any the TermResponse event's `sequence`
local function record_response_error(sequence)
local response = terminal.parse_response(sequence)
if not response or response.ok or not used_ids[response.id] then
return
end
table.insert(response_errors, {
id = response.id,
placement_id = response.placement_id,
path = path_for_id(response.id),
message = response.message,
})
if #response_errors > MAX_RESPONSE_ERRORS then
table.remove(response_errors, 1)
end
end

local function ensure_autocmds()
if autocmds_ready then
return
Expand Down Expand Up @@ -1154,6 +1211,15 @@ local function ensure_autocmds()
group = group,
callback = on_vim_leave_pre,
})

if M._has_response_support_fn() then
vim.api.nvim_create_autocmd("TermResponse", {
group = group,
callback = function(args)
record_response_error(type(args.data) == "table" and args.data.sequence or nil)
end,
})
end
end

---@param v any
Expand Down Expand Up @@ -1383,14 +1449,19 @@ function M.show(path, opts)
)
vim.list_extend(
sequences,
terminal.build_transmit(
bytes,
{ id = id, action = "T", placement = placement_opts(handle, placement) }
)
terminal.build_transmit(bytes, {
id = id,
action = "T",
quiet = transmit_quiet(),
placement = placement_opts(handle, placement),
})
)
table.insert(sequences, terminal.build_restore_cursor())
else
vim.list_extend(sequences, terminal.build_transmit(bytes, { id = id, action = "t" }))
vim.list_extend(
sequences,
terminal.build_transmit(bytes, { id = id, action = "t", quiet = transmit_quiet() })
)
end
local ok, err = M._write_fn(sequences)
if not ok then
Expand Down Expand Up @@ -1420,6 +1491,13 @@ function M.show(path, opts)
return handle
end

-- Error responses the terminal sent back for blit's own ids, oldest first
-- (bounded to the most recent few). Always empty on Neovim < 0.12.
---@return blit.ResponseError[]
function M.response_errors()
return vim.deepcopy(response_errors)
end

---@param handle blit.Handle
function M.clear(handle)
vim.validate({ handle = { handle, "table" } })
Expand Down Expand Up @@ -1453,6 +1531,8 @@ function M._reset()
used_ids = {}
next_id = terminal.ID_RANGE_START
next_placement_id = 1
response_errors = {}
M._has_response_support_fn = terminal.has_response_support
M._write_fn = function(sequences)
return terminal.write(sequences)
end
Expand Down
Loading
Loading