From d1f6442cc5cabb1ef27bde1ab17f1dc8ce5518a4 Mon Sep 17 00:00:00 2001 From: Hisanari Kikuchi Date: Sun, 4 Oct 2026 22:43:42 +0900 Subject: [PATCH 1/3] feat: surface terminal error responses via TermResponse on Neovim >= 0.12 Transmits now carry q=1 (suppress OK, keep errors) when Neovim can deliver APC responses through TermResponse (0.12+). renderer.lua records error responses for ids it owns in a bounded list, and :checkhealth blit lists them. Neovim 0.10 / 0.11 keep q=2 and register no listener, so their behavior is unchanged. Responses are diagnostic only: they arrive after show() has returned and no recovery path reacts to them. Closes #12 --- README.md | 8 ++- doc/blit.txt | 4 +- docs/manual-testing.md | 7 +++ docs/spec/kitty-graphics.md | 49 ++++++++++++++---- docs/spec/renderer-placement.md | 23 +++++++++ docs/spec/terminal-detection.md | 13 ++--- lua/blit/health.lua | 17 ++++++ lua/blit/renderer.lua | 92 ++++++++++++++++++++++++++++++--- lua/blit/terminal.lua | 49 ++++++++++++++++++ tests/test_renderer.lua | 76 +++++++++++++++++++++++++++ tests/test_terminal_escape.lua | 50 ++++++++++++++++++ 11 files changed, 363 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7efc728..d8a4327 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/doc/blit.txt b/doc/blit.txt index e964e9b..d0e81d8 100644 --- a/doc/blit.txt +++ b/doc/blit.txt @@ -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* diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 5adbc71..25a791f 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -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 diff --git a/docs/spec/kitty-graphics.md b/docs/spec/kitty-graphics.md index ea1f53f..6e5548b 100644 --- a/docs/spec/kitty-graphics.md +++ b/docs/spec/kitty-graphics.md @@ -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 | @@ -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=[,p=] ; ESC \`, where +`` is `OK` or `:` (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 ; ` 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 diff --git a/docs/spec/renderer-placement.md b/docs/spec/renderer-placement.md index b7a6ba7..778000a 100644 --- a/docs/spec/renderer-placement.md +++ b/docs/spec/renderer-placement.md @@ -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: diff --git a/docs/spec/terminal-detection.md b/docs/spec/terminal-detection.md index 83a2764..1e9734d 100644 --- a/docs/spec/terminal-detection.md +++ b/docs/spec/terminal-detection.md @@ -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 @@ -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. diff --git a/lua/blit/health.lua b/lua/blit/health.lua index 7f84cd6..639bba1 100644 --- a/lua/blit/health.lua +++ b/lua/blit/health.lua @@ -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 = {} @@ -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 diff --git a/lua/blit/renderer.lua b/lua/blit/renderer.lua index 99c83e7..56d721b 100644 --- a/lua/blit/renderer.lua +++ b/lua/blit/renderer.lua @@ -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 `:` 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 @@ -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]), }) ) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" } }) @@ -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 diff --git a/lua/blit/terminal.lua b/lua/blit/terminal.lua index 25f26bd..c1bfa06 100644 --- a/lua/blit/terminal.lua +++ b/lua/blit/terminal.lua @@ -223,6 +223,55 @@ function M.build_delete(id, opts) return APC_START .. build_control(parts) .. APC_END end +-- Response parsing (pure, no I/O) --------------------------------------------- +-- See docs/spec/kitty-graphics.md's "Response handling" section. + +---@class blit.terminal.Response +---@field id integer +---@field placement_id? integer +---@field ok boolean +---@field message string `OK`, or the terminal's `:` error string + +-- Parses one kitty graphics protocol response as Neovim's TermResponse +-- event delivers it: `ESC _ G ; `, with the trailing ST +-- already stripped (tolerated here anyway). Returns nil for anything that +-- is not a graphics response about an id in blit's reserved range, so a +-- caller can feed it every TermResponse sequence unfiltered. +---@param sequence any +---@return blit.terminal.Response? +function M.parse_response(sequence) + if type(sequence) ~= "string" or sequence:sub(1, #APC_START) ~= APC_START then + return nil + end + local body = sequence:sub(#APC_START + 1) + if body:sub(-#APC_END) == APC_END then + body = body:sub(1, -#APC_END - 1) + end + local separator = body:find(";", 1, true) + if not separator then + return nil + end + local keys = {} + for key, value in body:sub(1, separator - 1):gmatch("(%a)=(%d+)") do + keys[key] = tonumber(value) + end + if not M.is_valid_id(keys.i) then + return nil + end + local message = body:sub(separator + 1) + return { id = keys.i, placement_id = keys.p, ok = message == "OK", message = message } +end + +-- Whether this Neovim delivers APC responses through TermResponse (0.12+). +-- Older versions only deliver OSC/DCS, so blit keeps every response +-- suppressed there — see docs/spec/kitty-graphics.md's "Response handling". +---@param has? fun(feature: string): integer +---@return boolean +function M.has_response_support(has) + has = has or vim.fn.has + return has("nvim-0.12") == 1 +end + -- Cursor positioning ---------------------------------------------------------- -- Regular (non-unicode-placeholder) kitty placements render at the -- terminal's current cursor position at the moment the placement command is diff --git a/tests/test_renderer.lua b/tests/test_renderer.lua index 84c9694..63caa0d 100644 --- a/tests/test_renderer.lua +++ b/tests/test_renderer.lua @@ -1341,4 +1341,80 @@ T["redraw"]["non-ghostty: never retransmits a still-visible handle after a resiz MiniTest.expect.equality(first_all:find("a=p", 1, true) ~= nil, true) end +T["response_errors"] = MiniTest.new_set() + +---@param sequence string +local function deliver_response(sequence) + vim.api.nvim_exec_autocmds("TermResponse", { group = "blit", data = { sequence = sequence } }) +end + +---@param supported boolean +---@return blit.Handle +local function show_with_response_support(supported) + renderer._has_response_support_fn = function() + return supported + end + local buf, win = setup_floating(numbered_lines(10), 20, 10) + return renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 0 }) +end + +T["response_errors"]["supported: transmits with q=1 and records an error response"] = function() + local handle = show_with_response_support(true) + MiniTest.expect.equality(table.concat(captured[1], ""):find(",q=1,", 1, true) ~= nil, true) + + deliver_response(ESC .. "_Gi=" .. handle.id .. ";EBADPNG:bad data") + + MiniTest.expect.equality(renderer.response_errors(), { + { id = handle.id, path = tmp_path, message = "EBADPNG:bad data" }, + }) +end + +T["response_errors"]["ignores OK, unowned ids, and unrelated sequences"] = function() + local handle = show_with_response_support(true) + + deliver_response(ESC .. "_Gi=" .. handle.id .. ",p=" .. handle.placement_id .. ";OK") + deliver_response(ESC .. "_Gi=" .. (handle.id + 1) .. ";ENOENT:not ours") + deliver_response(ESC .. "]11;rgb:0000/0000/0000") + + MiniTest.expect.equality(renderer.response_errors(), {}) +end + +T["response_errors"]["keeps only the most recent errors"] = function() + local handle = show_with_response_support(true) + + for n = 1, 25 do + deliver_response(ESC .. "_Gi=" .. handle.id .. ";EINVAL:" .. n) + end + + local errors = renderer.response_errors() + MiniTest.expect.equality(#errors, 20) + MiniTest.expect.equality(errors[1].message, "EINVAL:6") + MiniTest.expect.equality(errors[20].message, "EINVAL:25") +end + +T["response_errors"]["unsupported: transmits with q=2 and never listens"] = function() + show_with_response_support(false) + + MiniTest.expect.equality(table.concat(captured[1], ""):find(",q=2,", 1, true) ~= nil, true) + MiniTest.expect.equality( + #vim.api.nvim_get_autocmds({ group = "blit", event = "TermResponse" }), + 0 + ) +end + +T["response_errors"]["listener is torn down with the last handle"] = function() + local handle = show_with_response_support(true) + MiniTest.expect.equality( + #vim.api.nvim_get_autocmds({ group = "blit", event = "TermResponse" }), + 1 + ) + + renderer.clear(handle) + vim.wait(200, function() + return not pcall(vim.api.nvim_get_autocmds, { group = "blit" }) + end) + + MiniTest.expect.equality(pcall(vim.api.nvim_get_autocmds, { group = "blit" }), false) +end + return T diff --git a/tests/test_terminal_escape.lua b/tests/test_terminal_escape.lua index 1d802ee..2641635 100644 --- a/tests/test_terminal_escape.lua +++ b/tests/test_terminal_escape.lua @@ -261,4 +261,54 @@ T["is_valid_id"]["rejects non-integer and non-number input"] = function() MiniTest.expect.equality(terminal.is_valid_id(nil), false) end +T["parse_response"] = MiniTest.new_set() + +T["parse_response"]["error response, as TermResponse delivers it (no ST)"] = function() + local response = terminal.parse_response(ESC .. "_Gi=" .. ID .. ";EBADPNG:bad data") + MiniTest.expect.equality( + response, + { id = ID, placement_id = nil, ok = false, message = "EBADPNG:bad data" } + ) +end + +T["parse_response"]["OK response with a placement id and trailing ST"] = function() + local response = terminal.parse_response(ESC .. "_Gi=" .. ID .. ",p=7;OK" .. ESC .. "\\") + MiniTest.expect.equality(response, { id = ID, placement_id = 7, ok = true, message = "OK" }) +end + +T["parse_response"]["message containing a semicolon is kept whole"] = function() + local response = terminal.parse_response(ESC .. "_Gi=" .. ID .. ";EINVAL:a;b") + MiniTest.expect.equality(response.message, "EINVAL:a;b") +end + +T["parse_response"]["ignores ids outside blit's reserved range"] = function() + MiniTest.expect.equality(terminal.parse_response(ESC .. "_Gi=1;ENOENT:x"), nil) + MiniTest.expect.equality(terminal.parse_response(ESC .. "_GI=3;ENOENT:x"), nil) +end + +T["parse_response"]["ignores non-graphics and malformed sequences"] = function() + MiniTest.expect.equality(terminal.parse_response(ESC .. "]11;rgb:0000/0000/0000"), nil) + MiniTest.expect.equality(terminal.parse_response(ESC .. "_Gi=" .. ID), nil) + MiniTest.expect.equality(terminal.parse_response(""), nil) + MiniTest.expect.equality(terminal.parse_response(nil), nil) +end + +T["has_response_support"] = MiniTest.new_set() + +T["has_response_support"]["true only when nvim-0.12 is available"] = function() + local asked + local supported = terminal.has_response_support(function(feature) + asked = feature + return 1 + end) + MiniTest.expect.equality(supported, true) + MiniTest.expect.equality(asked, "nvim-0.12") + MiniTest.expect.equality( + terminal.has_response_support(function() + return 0 + end), + false + ) +end + return T From a49ecf203824115744e46d6f03dd34f1391e238f Mon Sep 17 00:00:00 2001 From: Hisanari Kikuchi Date: Sat, 10 Oct 2026 17:45:58 +0900 Subject: [PATCH 2/3] feat: quiet placement OKs and timestamp recorded terminal errors Apply review findings for the TermResponse error-response work (#12): - Standalone `a=p` placements carry `q=1` on Neovim >= 0.12, so the terminal no longer acknowledges every re-placement with an `OK` that fires TermResponse only to be discarded. Bytes on 0.10 / 0.11 are unchanged. - Recorded error responses carry the time they arrived, and `:checkhealth blit` prints it so a stale entry can be told apart from a current one. - `:checkhealth blit` reports response support and recorded errors only when the environment is supported. - Document that `has_response_support()` assumes a released 0.12.0+. --- docs/spec/kitty-graphics.md | 30 +++++++++++++++++++++--------- docs/spec/renderer-placement.md | 8 +++++--- lua/blit/health.lua | 19 ++++++++++--------- lua/blit/renderer.lua | 21 +++++++++++++++++++-- lua/blit/terminal.lua | 13 +++++++++++++ tests/test_renderer.lua | 5 ++++- tests/test_terminal_escape.lua | 5 +++++ 7 files changed, 77 insertions(+), 24 deletions(-) diff --git a/docs/spec/kitty-graphics.md b/docs/spec/kitty-graphics.md index 6e5548b..cc0608a 100644 --- a/docs/spec/kitty-graphics.md +++ b/docs/spec/kitty-graphics.md @@ -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 | 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 | +| `q` | quiet | when responses can be received: `1` (suppress `OK`, keep errors) on transmits (`a=T`/`a=t`) and standalone placements (`a=p`). Otherwise `2` (suppress everything) on transmits and no `q` key on placements. Never on deletes — 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 | @@ -223,14 +223,26 @@ range. 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. +Standalone placement (`a=p`) commands follow the same split, with one +difference on old versions: + +- **Neovim >= 0.12**: `a=p` carries `q=1`. Without it the terminal may + acknowledge every re-placement with an `OK`, i.e. one `TermResponse` + (for blit's listener and every other plugin's) per re-placed handle on + every redraw pass, only to be discarded. Error answers still come back + and the same listener records them, which surfaces e.g. an `ENOENT` for + a placement against an id whose pixel data the terminal has dropped. +- **Neovim 0.10 / 0.11**: `a=p` carries no `q` key, so the terminal's + default applies and it may answer 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 (`docs/manual-testing.md` + checks this). + +Delete (`a=d`) commands carry no `q` key on any version. + +`has_response_support()` is a version check (`has("nvim-0.12")`), so it +assumes a released 0.12.0 or later; 0.12 pre-release builds that predate +the APC `TermResponse` support are not accounted for. Responses are diagnostic only. They arrive asynchronously, after `show()` has returned, so they cannot become a `nil, err` return value, and no diff --git a/docs/spec/renderer-placement.md b/docs/spec/renderer-placement.md index 778000a..4fd16d1 100644 --- a/docs/spec/renderer-placement.md +++ b/docs/spec/renderer-placement.md @@ -489,10 +489,12 @@ 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. +dropped first) of `{ id, placement_id?, path?, message, time }`; `path` +comes from a live handle using that id and is nil once none does, and +`time` is the `os.time()` the response arrived at. `renderer.response_errors()` returns a copy, and `:checkhealth blit` -prints each entry. The list is never cleared during a session — it is a +prints each entry prefixed with that time, so an old entry can be told +apart from a current one. 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. diff --git a/lua/blit/health.lua b/lua/blit/health.lua index 639bba1..08596ed 100644 --- a/lua/blit/health.lua +++ b/lua/blit/health.lua @@ -40,6 +40,14 @@ function M.check() vim.health.warn(WEZTERM_CRASH_WARNING) end + if not caps.supported then + vim.health.warn( + REASON_MESSAGES[caps.reason] or ("blit is unsupported: " .. tostring(caps.reason)) + ) + return + end + vim.health.ok("blit is supported in this environment") + if terminal.has_response_support() then vim.health.ok("Terminal error responses are reported here (Neovim >= 0.12)") else @@ -48,21 +56,14 @@ function M.check() for _, response_error in ipairs(renderer.response_errors()) do vim.health.error( - ("terminal rejected image id %d (%s): %s"):format( + ("[%s] terminal rejected image id %d (%s): %s"):format( + os.date("%H:%M:%S", response_error.time), 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 - vim.health.warn( - REASON_MESSAGES[caps.reason] or ("blit is unsupported: " .. tostring(caps.reason)) - ) - end end return M diff --git a/lua/blit/renderer.lua b/lua/blit/renderer.lua index 56d721b..046caa3 100644 --- a/lua/blit/renderer.lua +++ b/lua/blit/renderer.lua @@ -80,6 +80,7 @@ local MAX_RESPONSE_ERRORS = 20 ---@field placement_id? integer ---@field path? string nil when no live handle references the id anymore ---@field message string the terminal's `:` error string +---@field time integer `os.time()` when the response arrived ---@type blit.ResponseError[] local response_errors = {} @@ -631,6 +632,21 @@ local function placement_opts(handle, placement) } end +-- Options for a standalone `a=p`: with a response listener in place, `q=1` +-- drops the per-placement `OK` (one TermResponse per re-placed handle on +-- every redraw pass) while keeping errors. Without one the bytes stay as +-- they always were (no `q` key). +---@param handle blit.Handle +---@param placement blit.PlacementResult +---@return blit.terminal.PlacementOpts +local function standalone_placement_opts(handle, placement) + local opts = placement_opts(handle, placement) + if M._has_response_support_fn() then + opts.quiet = 1 + end + return opts +end + ---@param handle blit.Handle ---@param placement blit.PlacementResult ---@return boolean ok @@ -639,7 +655,7 @@ local function place_existing(handle, placement) local sequences = { terminal.build_save_cursor(), terminal.build_move_cursor(placement.screen_row, placement.screen_col), - terminal.build_placement(handle.id, placement_opts(handle, placement)), + terminal.build_placement(handle.id, standalone_placement_opts(handle, placement)), terminal.build_restore_cursor(), } local ok, err = M._write_fn(sequences) @@ -785,7 +801,7 @@ local function retransmit_and_place_group(handles) vim.list_extend(sequences, { terminal.build_save_cursor(), terminal.build_move_cursor(placements[h].screen_row, placements[h].screen_col), - terminal.build_placement(new_id, placement_opts(h, placements[h])), + terminal.build_placement(new_id, standalone_placement_opts(h, placements[h])), terminal.build_restore_cursor(), }) end @@ -1168,6 +1184,7 @@ local function record_response_error(sequence) placement_id = response.placement_id, path = path_for_id(response.id), message = response.message, + time = os.time(), }) if #response_errors > MAX_RESPONSE_ERRORS then table.remove(response_errors, 1) diff --git a/lua/blit/terminal.lua b/lua/blit/terminal.lua index c1bfa06..16e90d1 100644 --- a/lua/blit/terminal.lua +++ b/lua/blit/terminal.lua @@ -22,6 +22,7 @@ local APC_END = ESC .. "\\" ---@field crop_y? integer source rectangle pixel offset from the transmitted image's top edge ---@field crop_w? integer source rectangle pixel width ---@field crop_h? integer source rectangle pixel height +---@field quiet? 1|2 `q=` for a standalone `a=p` (build_placement only); omitted = no `q` key ---@class blit.terminal.TransmitOpts ---@field id integer @@ -189,8 +190,18 @@ function M.build_placement(id, opts) id = { id, M.is_valid_id, "a valid id in blit's reserved range" }, opts = { opts, "table" }, placement_id = { opts.placement_id, is_positive_integer, "a positive integer" }, + quiet = { + opts.quiet, + function(v) + return v == nil or v == 1 or v == 2 + end, + "1, 2, or nil", + }, }) local parts = { { "a", "p" }, { "i", id }, { "p", opts.placement_id } } + if opts.quiet then + parts[#parts + 1] = { "q", opts.quiet } + end append_placement_parts(parts, opts) return APC_START .. build_control(parts) .. APC_END end @@ -263,6 +274,8 @@ function M.parse_response(sequence) end -- Whether this Neovim delivers APC responses through TermResponse (0.12+). +-- Assumes a released 0.12.0 or later: `has("nvim-0.12")` is also true on +-- 0.12 pre-release builds that predate the APC support. -- Older versions only deliver OSC/DCS, so blit keeps every response -- suppressed there — see docs/spec/kitty-graphics.md's "Response handling". ---@param has? fun(feature: string): integer diff --git a/tests/test_renderer.lua b/tests/test_renderer.lua index 63caa0d..eded319 100644 --- a/tests/test_renderer.lua +++ b/tests/test_renderer.lua @@ -1364,7 +1364,10 @@ T["response_errors"]["supported: transmits with q=1 and records an error respons deliver_response(ESC .. "_Gi=" .. handle.id .. ";EBADPNG:bad data") - MiniTest.expect.equality(renderer.response_errors(), { + local errors = renderer.response_errors() + MiniTest.expect.equality(type(errors[1].time), "number") + errors[1].time = nil + MiniTest.expect.equality(errors, { { id = handle.id, path = tmp_path, message = "EBADPNG:bad data" }, }) end diff --git a/tests/test_terminal_escape.lua b/tests/test_terminal_escape.lua index 2641635..158fa6b 100644 --- a/tests/test_terminal_escape.lua +++ b/tests/test_terminal_escape.lua @@ -151,6 +151,11 @@ T["build_placement"]["minimal"] = function() MiniTest.expect.equality(seq, ESC .. "_Ga=p,i=" .. ID .. ",p=3" .. ESC .. "\\") end +T["build_placement"]["quiet adds q right after the placement id"] = function() + local seq = terminal.build_placement(ID, { placement_id = 3, quiet = 1, columns = 10, rows = 5 }) + MiniTest.expect.equality(seq, ESC .. "_Ga=p,i=" .. ID .. ",p=3,q=1,c=10,r=5" .. ESC .. "\\") +end + T["build_placement"]["requires a positive integer placement_id"] = function() local ok = pcall(terminal.build_placement, ID, {}) MiniTest.expect.equality(ok, false) From b695e0465531bc1118a1d353ffdaa19f40c53e91 Mon Sep 17 00:00:00 2001 From: Hisanari Kikuchi Date: Sat, 10 Oct 2026 17:55:35 +0900 Subject: [PATCH 3/3] docs: record per-terminal error-response behavior under q=1 Observed with a real-terminal probe on Neovim 0.12.5 (kitty 0.48.2, Ghostty 1.3.1, WezTerm 20260716-195552-76b606ec): kitty and Ghostty answer a corrupt-IDAT transmit and the following placement with terminal-specific error strings, while WezTerm sends nothing. Note the WezTerm limitation in the spec memo, the manual-testing checklist, the README and the vimdoc. --- README.md | 1 + doc/blit.txt | 1 + docs/manual-testing.md | 8 +++++--- docs/spec/kitty-graphics.md | 17 +++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d8a4327..b944754 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ No ImageMagick. No luarocks. No external binaries. Pure Lua on Neovim >= 0.10. 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. +WezTerm sends no such error for a rejected PNG, so none is listed there. ## Install diff --git a/doc/blit.txt b/doc/blit.txt index d0e81d8..0faff17 100644 --- a/doc/blit.txt +++ b/doc/blit.txt @@ -34,6 +34,7 @@ hidden only once it has no overlap with the window at all. See 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. +WezTerm sends no such error for a rejected PNG, so none is listed there. ============================================================================== 3. Setup *blit-setup* diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 25a791f..761f89b 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -79,9 +79,11 @@ WezTerm (and Ghostty when available) before tagging a release. 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. + but corrupt image data: no image appears, and on kitty and Ghostty + `:checkhealth blit` lists a timestamped "terminal rejected image" + error naming that file. WezTerm sends no error response for this + case, so nothing is listed there (see + `docs/spec/kitty-graphics.md`'s "Per-terminal quirks"). ## WezTerm diff --git a/docs/spec/kitty-graphics.md b/docs/spec/kitty-graphics.md index cc0608a..092d321 100644 --- a/docs/spec/kitty-graphics.md +++ b/docs/spec/kitty-graphics.md @@ -262,6 +262,23 @@ them. battle-tested of the three supported terminals. `docs/manual-testing.md` should weight Ghostty checks accordingly before any release. +Error responses under `q=1`, observed on Neovim 0.12.5 by transmitting a +PNG with a valid signature and IHDR but a corrupt IDAT (`a=T`), then +letting the next redraw pass re-place it (`a=p`): + +| terminal | answer to the transmit | answer to the later `a=p` | +|---|---|---| +| kitty 0.48.2 | `EBADPNG:IDAT: incorrect header check` | `ENOENT:Put command refers to image with id: that could not load its data` | +| Ghostty 1.3.1 | `EINVAL: invalid data` | `ENOENT: image not found` | +| WezTerm 20260716-195552-76b606ec | none | none | + +So the error code and text are terminal-specific (blit records the message +verbatim and never matches on it), and WezTerm reports nothing for this +case: on WezTerm a rejected PNG stays invisible to `:checkhealth blit`, +exactly as on Neovim 0.10 / 0.11. On all three, show + scroll + split +with a valid PNG produced no response at all (no `OK` under `q=1`, no +error) and nothing leaked into Neovim's input. + ## Source This memo is derived from the upstream [kitty graphics protocol