Imported from optiflowic/blit.nvim (
AGENTS.md). Install upstream withnpx skills add optiflowic/blit.nvim. Copyright stays with the author.
blit.nvim
Zero-dependency image rendering for Neovim via the kitty graphics protocol. No ImageMagick. No luarocks. No external binaries. Pure Lua on Neovim >= 0.10.
Non-Negotiable Constraints
- Zero external dependencies. Never add a requirement on ImageMagick, luarocks, Python, Node, or any external binary. If a feature cannot be built without one, it is out of scope (or becomes an optional, auto-detected enhancement — ask first).
- Neovim >= 0.10 only. Use built-ins:
vim.base64,vim.system,vim.uv,vim.api.nvim_buf_set_extmark. Never vendor polyfills for older versions. - PNG only (v0.x). The kitty protocol accepts PNG natively (
f=100). Other formats are out of scope until v2. - Supported terminals: kitty, WezTerm, Ghostty. Detect via env vars. On any
other terminal (or GUI/
--embed), silently no-op and report via:checkhealth. Never emit escape sequences to an unsupported terminal. - tmux is explicitly unsupported in v0.x. Detect (
$TMUX) and no-op.
Architecture
Dependency direction is strictly one-way: api → renderer → terminal.
Lower layers never require upper layers. config and health are leaf utilities.
lua/blit/
init.lua -- public API: setup(), show(), clear(), clear_all()
config.lua -- defaults + user config merge + validation
terminal.lua -- protocol layer: capability detection, escape sequence
-- construction (chunked base64, f=100), tty write. Knows
-- NOTHING about buffers, windows, or extmarks.
renderer.lua -- placement layer: buffer/window -> screen cell coordinate
-- conversion, extmark virtual lines, image id allocation,
-- redraw on WinScrolled/WinResized/BufWinLeave, lifecycle.
-- Knows NOTHING about escape sequence syntax.
health.lua -- :checkhealth blit — terminal detection result, protocol
-- support, Neovim version, tmux/GUI exclusion reasons.
Rules:
terminal.luaseparates sequence CONSTRUCTION (pure functions: given image data and geometry, return escape sequence strings — unit-testable byte-exact) from sequence TRANSPORT (a singlewrite()that owns the tty channel).- tty transport: never write to
io.stdoutdirectly (conflicts with Neovim's UI protocol and may be redirected). Open the controlling tty explicitly. The chosen mechanism and its rationale live indocs/spec/terminal-detection.md. - Image IDs: the kitty protocol ID space is shared terminal-wide with other
plugins (image.nvim, snacks.image). Allocate our IDs inside a fixed reserved
range (documented in the spec memo) to avoid clobbering foreign images. Never
use
a=d,d=a(delete all); only delete IDs we own. renderer.luaowns ALL autocmds and extmarks. No other module creates autocmds. Autocmds live in a singleaugroup("blit", ...); extmarks in onenvim_create_namespace("blit"). RegisterVimLeavePrecleanup that deletes every image we placed.init.luais thin orchestration only. No business logic.setup()must be idempotent (safe to call twice; re-entrant config merge, no duplicate autocmds).- One image = one handle table
{ id, buf, extmark_id, path, geometry }. Never spread this state across modules.
Coding Standards
- Format:
stylua(config in repo). Lint:selene. Both must pass before commit. - Every public function gets LuaLS annotations (
---@param,---@return).lua/blit/types.luamay hold shared---@classdefs if they grow. - Errors: library code never calls
error()for expected failures (missing file, unsupported terminal). Returnnil, err_msg. Reserveerror()for programmer mistakes (bad argument types) viavim.validate. - No
vim.notifyspam. Failures surface through return values and:checkhealth. - Naming: snake_case functions/locals, no abbreviations except conventional ones
(buf, win, col, ns). Boolean names read as predicates (
is_supported,has_tmux). - Guard clauses over nested ifs. Small functions. If a function needs a comment explaining "sections", split it.
Performance Rules
- Zero startup cost:
require("blit")andsetup()do no I/O, no terminal detection, no autocmd registration. Everything is deferred until the firstshow()call. Target: unmeasurable (<0.1ms) in lazy.nvim profile. - Debounce scroll-driven redraws (
WinScrolled): single deferred redraw per burst, never one redraw per event. Debounce interval is a config value with a sane default. Target: image re-placed within one frame (~16ms) after scroll settles. - Re-placement of an already-transmitted image must reuse its ID (
a=p) — never re-transmit pixel data on scroll/resize. Accepted exception: Ghostty silently discards a transmitted image's pixel data behind its id across a real terminal window resize, with no error response to detect it by (issues #24, #34).renderer.lua's debounced redraw path may re-transmit (a=T) a still-visible handle's data, but only when it detects this exact condition —caps.terminal == "ghostty"and the handle's cache entry was recorded against avim.o.lines/vim.o.columnsthat no longer matches the current values — seedocs/spec/renderer-placement.md's Transmission cache section. Confirmed via manual testing that simply re-a=T-ing under the same id Ghostty already discarded does not bring the placement back, so this retransmit always frees the old id and hands out a fresh one (mirroringacquire_idle_entry's existing Ghostty eviction), and is deferred onto its own short settle timer (M._ghostty_settle_ms, 100ms) rather than firing on every intermediateWinResizeda real drag-resize gesture fires — back-to-back retransmits were observed to make Ghostty's own recovery unreliable. Kitty, WezTerm, and scroll-only redraw passes on any terminal are unaffected. - Cache transmitted images by
(path, mtime): re-showing a cached image is a placement only. Drop base64 payloads after transmission; keep only IDs and geometry in Lua memory. - Synchronous file reads are allowed only under a size guard (config
max_file_bytes, default a few MB). Larger files: refuse withnil, err. - No timers or autocmds active when zero images are displayed (fully quiescent idle).
Accepted exception: after the last handle is destroyed, a bounded number of
self-scheduled delete retries (
DESTROY_DELETE_RETRIESinrenderer.lua) may keep the debounce timer alive for a few extra passes to work around a terminal dropping the delete escape sequence (issue #27) — seedocs/spec/renderer-placement.md's Lifecycle section. Bounded and self-terminating, never indefinite. - Measure before optimizing: use
vim.uv.hrtime()around suspected hot paths. No speculative optimization; every perf-motivated complexity increase must cite a measurement.
Spec Memo Workflow
Before implementing against an external spec, write/update a memo in docs/spec/:
docs/spec/kitty-graphics.md— the subset of the kitty graphics protocol we use: transmission (a=T,f=100, chunkedm=0/1, 4096-byte chunks), placement (c=,r=,z=), deletion (a=d), unicode placeholders (documented but unused in v0.x), quirks per terminal (WezTerm: no placeholder support, known scroll lag).docs/spec/terminal-detection.md— detection matrix: env vars, DA1 queries if used, GUI/embed exclusion logic.
Implementation must cite the memo, not raw upstream docs. If reality diverges from the memo, fix the memo in the same PR.
Testing
- Framework:
mini.test(dev-time only dependency, never a runtime one). - Unit test targets: escape sequence construction (byte-exact golden strings), chunking boundaries, geometry math (cell conversion, clipping), config validation, terminal detection (mock env vars).
- NOT tested automatically: actual pixel output (terminal-dependent). Manual test
checklist lives in
docs/manual-testing.md— run it on kitty + WezTerm before tagging a release. - Bug fixes must include a regression test for the fixed behavior when the bug is in
pure logic; if terminal-dependent, add a step to
docs/manual-testing.mdinstead. - No coverage metric. Quality gates are: unit tests for enumerated boundary conditions (see review checklist), lint/format clean, and the manual checklist before release.
- Run:
make test(headless nvim). Tests must pass on Linux and macOS.
Definition of Done (per feature)
- Spec memo updated if protocol behavior involved
- LuaLS annotations on all new public functions
- Unit tests for pure logic; manual checklist updated if visual behavior changed
stylua --check .andselene .clean:checkhealth blitreflects any new capability/exclusion- Vimdoc (
doc/blit.txt) updated for any public API change - README updated if user-facing (API, constraints, supported terminals)
Workflow
- Commits: Conventional Commits (
feat:,fix:,docs:,refactor:,test:). Small, single-purpose commits. - CI (GitHub Actions):
stylua --check,selene,make teston Linux + macOS. All green before merge. - v0.x: breaking API changes are allowed but must be called out in the commit body and README changelog section.
- When a spec is ambiguous or two valid designs conflict with these rules: STOP and ask. Do not pick silently. Present options with trade-offs.
- Releases: release-please
(
.github/workflows/release-please.yaml) tracks Conventional Commits onmainand keeps an up-to-date release PR open, bumping.release-please-manifest.jsonandCHANGELOG.md.bump-minor-pre-majoris enabled, sofeat:/breaking changes bump MINOR (not MAJOR) while the manifest major version stays0, matching the0.MINOR.PATCHpolicy above. Merging the release PR tags the release — rundocs/manual-testing.md's checklist first.
Out of Scope (do not implement without explicit approval)
- tmux passthrough, sixel, ueberzugpp backends
- Non-PNG decoding, ImageMagick/ffmpeg integration
- Markdown/filetype integration (belongs to a separate plugin built on top)
- Async image downloads from URLs Async image downloads from URLs