Files
bcherb2 b487b0e855 feat: public dotfiles tier — no credential, no identity, one installer
Fresh history. This is the repo a throwaway VM clones anonymously: it brings a
machine to a working baseline and carries nothing that makes it mine.

56 files. 50 land in $HOME, 3 are chezmoi metadata, 2 are repo documentation,
1 is the manifest, and a 15-file test harness stays behind in .tests/.

What did not travel, and why:

  encrypted_private_bws-token.age   a real credential; age is dropped entirely
  .chezmoidata/bws.toml             env-var -> secret-id map; belongs with the
                                    tier that can use it
  SECRETS.md                        documentation of the rules, not config
  finish-setup.sh.tmpl              superseded by dotup
  nvim/init.lua.backup              dead file
  dot_claude/**, dot_codex/**,      120 files of agent config, private tier
  dot_pi/**

De-identified rather than dropped:

  .gitconfig   [user], the GitHub ssh rewrite and both Gitea host rewrites are
               identity, not configuration. They move behind an [include] of
               ~/.config/git/config.local, which the private tier writes. Git
               treats a missing include as a no-op, so a public-only machine
               reads the file and stops.
  .zshrc       the two gitea aliases carried a personal domain and a LAN IP.
               They move behind a guarded source of ~/.config/zsh/local.zsh,
               the sibling of the secrets.zsh seam phase 2 established.
  nvim         a commented-out LM Studio endpoint naming a LAN address.
  ghostty      a stale auto-generated header naming an absolute home directory.

Newly captured, never tracked before: ~/.zshenv, ~/.config/gh/config.yml. The
former sourced ~/.cargo/env unguarded, so every zsh on a machine without rustup
printed an error -- the same shape as the unguarded oh-my-zsh source phase 2
fixed. It is guarded now.

.chezmoiexternal.toml grows from one entry to six. oh-my-zsh, powerlevel10k,
zsh-autosuggestions, zsh-ai and tpm were hand-installed and declared nowhere,
which is why `chezmoi init --apply` on a clean box produced a .zshrc that broke
the shell it configures. The theme and both plugins nest under
.oh-my-zsh/custom/, which is what $ZSH_CUSTOM resolves to.

dotup gains an install engine. It resolves each selected package to a channel
(apt, brew, npm, uv, snap, deb, flatpak, tarball, script, builtin) through one
function every consumer reads, probes apt-cache before batching so a name apt
does not know moves to brew instead of failing all thirty, and retries
individually if a batch still fails -- which earned its keep on the first real
container run, where mermaid-cli's puppeteer dependency failed and the other
twelve npm packages installed anyway. --unattended computes safe defaults fresh
from the manifest rather than inheriting a state file, and refuses private and
invasive rows outright even when a stale state file ticks them.

The manifest gains @spec, a second directive kind alongside @needs, carrying the
argument a channel needs but a package name cannot supply -- the scoped npm
name, the flatpak app id, the .deb source. The TSV stays five columns wide.

Three bugs the container runs found, all fixed here:

  * `apt install nodejs` gives you node WITHOUT npm on Ubuntu, so all thirteen
    npm packages failed on a fresh box. The manifest asks apt for both names.
  * A tool installed a moment ago is not on this process's PATH -- uv lands in
    ~/.local/bin, npm -g honours the ~/.npmrc prefix, linuxbrew is outside a
    non-login PATH. Resolved by looking in the places we just wrote to, never by
    exporting a modified PATH.
  * `A || { B && C; }` is one || list, so when `command -v sudo` failed the list
    failed and `set -e` killed dotup at load. On a non-root machine with no
    sudo it died before printing anything. There is a regression test.

.zshenv and .p10k.zsh are marked private_. Both are shell code the login shell
executes and both applied at 664, group-writable. Third occurrence of the class
of bug phase 1 found on .pi/agent/auth.json and phase 2 found on .zshrc; the
first one found on purpose rather than by accident.

Verification: 81 assertions, 81/81 on this box and in ubuntu:24.04, ubuntu:22.04
and debian:12. The installer is driven against a directory of fake package
managers that record what they were asked to do and install nothing, so the
engine is exercised end to end without a package landing on the test machine.
`gitleaks detect` over the full history and the working tree: no leaks found,
with no allowlist and no .gitleaks.toml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:24:41 -04:00

862 lines
32 KiB
Lua

-- Markdown rendering with inline mermaid via md-render.nvim.
--
-- Why not snacks.image / diagram.nvim / image.nvim?
-- Those all try to overlay the rendered image on top of the source fenced
-- code block. snacks issue #1384 ("mermaid blocks text behind it") was
-- closed as "not planned" — overlap-in-source is a fundamental limitation
-- of the current kitty-graphics-protocol design in nvim.
--
-- md-render takes the opposite approach: render mode is a *separate buffer*.
-- Normal mode -> auto-swap to render (full diagrams, no source text in the way).
-- Insert mode -> swap back to source so we can edit. The hop is per-buffer.
--
-- Ghostty itself is verified by the plugin; the SSH transport shim below is
-- local glue so the terminal never has to read remote-only image paths.
local function use_direct_kitty_bytes()
return true
end
local function image_debug(message)
if vim.env.MD_RENDER_IMAGE_DEBUG ~= "1" then return end
local path = vim.fn.stdpath("cache") .. "/md-render-image-debug.log"
local line = os.date("%Y-%m-%d %H:%M:%S") .. " " .. message .. "\n"
vim.fn.writefile({ line }, path, "a")
end
return {
{
"delphinus/md-render.nvim",
version = "*",
ft = { "markdown" },
dependencies = {
{ "nvim-tree/nvim-web-devicons", version = "*" },
},
init = function()
-- Ghostty does not normally forward TERM_PROGRAM /
-- GHOSTTY_RESOURCES_DIR into the remote shell. Neovim 0.12 therefore
-- cannot prove Kitty graphics support from the remote side on its own.
-- Herdr panes expose HERDR_ENV=1, but the underlying display path is
-- still Ghostty-compatible when kitty_graphics is enabled.
--
-- Keep the terminal identity as Ghostty. md-render uses that identity to
-- avoid Ghostty's flaky stored-image re-placement path during redraws.
if use_direct_kitty_bytes() and not vim.env.TERM_PROGRAM then
vim.env.TERM_PROGRAM = "ghostty"
end
-- In auto mode the render view is a separate, nomodifiable buffer, so
-- normal-mode editing (dd/x/ciw/p/…) does nothing — you'd have to toggle
-- to source first. md-render already solves this for insert-entry keys
-- (i/I/a/A/o/O): pressing one hops to the source at the mapped line and
-- replays the key (see AUTO_INSERT_KEYS in the plugin). It intentionally
-- leaves operator/normal-mode keys alone.
--
-- We extend that same hop to editing keys, scoped buffer-locally to the
-- render buffer (ft=md-render) only — the real source buffer is never
-- touched. Press dd/x/p/etc. while viewing the render and it transparently
-- drops to source on the right line and performs the edit. (Pure-motion
-- keys h/j/k/w/G/… are deliberately NOT mapped, so you can still navigate
-- the render freely.) Yank keys stay in the read-only render buffer so
-- copying text does not dismiss rendered diagrams. Visual-entry keys hop
-- to source so the resulting selection can be changed safely.
-- NOTE: the plugin sets the render buffer's filetype under
-- `eventignore=all` (preview.lua, so markdown ftplugins/treesitter don't
-- clobber the pre-rendered content), so a `FileType md-render` autocmd
-- never fires. Hook `BufEnter` instead — it fires when `toggle` swaps the
-- render buffer into the window — and guard on filetype + a buffer flag so
-- the maps install exactly once per render buffer.
local HOP_KEYS = {
"d", "D", "c", "C", "x", "X", "s", "S", "r", "R",
"p", "P", "J", ">", "<", "=", "u", "v", "V", ".",
}
local hop_group = vim.api.nvim_create_augroup("MdRenderEditHop", { clear = true })
vim.api.nvim_create_autocmd({ "BufEnter", "BufWinEnter" }, {
group = hop_group,
desc = "md-render: editing keys hop to source then replay",
callback = function(ev)
if vim.bo[ev.buf].filetype ~= "md-render" then return end
if vim.b[ev.buf].md_render_hop_installed then return end
vim.b[ev.buf].md_render_hop_installed = true
for _, key in ipairs(HOP_KEYS) do
vim.keymap.set("n", key, function()
-- Preserve a leading count and explicit register (e.g. 3dd, "ayy)
-- so the replayed operator behaves like the keystroke you typed.
local count = vim.v.count
local reg = vim.v.register
local prefix = (reg and reg ~= '"') and ('"' .. reg) or ""
if count > 0 then prefix = prefix .. tostring(count) end
-- toggle() is synchronous: on return the window already shows the
-- source buffer with the cursor on the mapped line. Feed the key
-- *synchronously* (no vim.schedule) so it enters the typeahead
-- before any follow-up keystroke. That keeps multi-key operators
-- in order — `d` becomes operator-pending and your next key (e.g.
-- the second `d`, or `w`, `ip`) completes it natively in source.
-- (The plugin schedules its insert-entry hop because those are
-- single keys that immediately switch to Insert mode; operators
-- need the ordering guarantee a sync feed provides.)
require("md-render").preview.toggle()
if key == "v" or key == "V" then
-- Enter Visual mode before the user's next queued key arrives.
-- feedkeys() would put v/V behind that key, turning `Vd` into
-- `dV`; :normal! establishes the selection synchronously.
local visual_count = count > 0 and tostring(count) or ""
vim.cmd("normal! " .. visual_count .. key)
else
vim.api.nvim_feedkeys(prefix .. key, "n", false)
end
end, {
buffer = ev.buf,
noremap = true,
silent = true,
desc = "md-render: hop to source then " .. key,
})
end
end,
})
end,
keys = {
{
"<leader>mp",
function() require("md-render").preview.show({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown preview (wide float toggle)",
},
{
"<leader>mt",
function() require("md-render").preview.show_tab({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown preview (wide tab toggle)",
},
{
"<leader>ms",
function() require("md-render").preview.split({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown source/render split",
},
{
"<leader>mr",
function() require("md-render").preview.toggle({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown render toggle in-place",
},
{ "<leader>md", "<Plug>(md-render-demo)", desc = "Markdown render demo" },
},
config = function()
local function patch_mermaid_rendering()
local img = require("md-render.image")
if img._thembones_mermaid_patch then return end
img._thembones_mermaid_patch = true
local MERMAID_SCALE = "4"
local MERMAID_ROWS = 45
local original_calc_display_size = img.calc_display_size
img.calc_display_size = function(img_w, img_h, max_cols, max_rows)
if max_rows == 25 then
max_rows = MERMAID_ROWS
end
return original_calc_display_size(img_w, img_h, max_cols, max_rows)
end
local function mermaid_cache_dir()
local dir = vim.fn.stdpath("cache") .. "/md-render/mermaid"
vim.fn.mkdir(dir, "p")
return dir
end
local function mermaid_theme_args()
local bg = vim.o.background
local hl = vim.api.nvim_get_hl(0, { name = "NormalFloat", link = false })
if not hl.bg then
hl = vim.api.nvim_get_hl(0, { name = "Normal", link = false })
end
local bg_color = hl.bg
if bg == "dark" then
return "dark", bg_color and string.format("#%06x", bg_color) or "#1e1e2e"
end
return "default", bg_color and string.format("#%06x", bg_color) or "#ffffff"
end
local function mermaid_cache_path(source)
local theme, bg_hex = mermaid_theme_args()
local key = table.concat({ source, theme, bg_hex, "scale=" .. MERMAID_SCALE }, "|")
local hash = vim.fn.sha256(key):sub(1, 16)
return mermaid_cache_dir() .. "/" .. hash .. ".png"
end
local function mmdc_cmd(input_path, output_path)
local cmd
if vim.fn.executable("mmdc") == 1 then
cmd = { "mmdc" }
elseif vim.fn.executable("npx") == 1 then
cmd = { "npx", "-y", "@mermaid-js/mermaid-cli" }
else
return nil
end
local theme, bg_hex = mermaid_theme_args()
vim.list_extend(cmd, {
"-i", input_path,
"-o", output_path,
"-t", theme,
"-b", bg_hex,
"-s", MERMAID_SCALE,
})
return cmd
end
img.get_mermaid_cached = function(source)
local path = mermaid_cache_path(source)
if vim.fn.filereadable(path) == 1 then
return path
end
return nil
end
img.render_mermaid = function(source)
local cache_path = mermaid_cache_path(source)
if vim.fn.filereadable(cache_path) == 1 then
return cache_path
end
local tmp_input = vim.fn.tempname() .. ".mmd"
local f = io.open(tmp_input, "w")
if not f then return nil end
f:write(source)
f:close()
local cmd = mmdc_cmd(tmp_input, cache_path)
if not cmd then
os.remove(tmp_input)
return nil
end
vim.system(cmd, { text = true, timeout = 30000 }):wait()
os.remove(tmp_input)
if vim.fn.filereadable(cache_path) == 1 then
return cache_path
end
return nil
end
-- Callbacks per in-flight cache path, so concurrent requests for the
-- same diagram share one mmdc process instead of racing on the output.
local mermaid_inflight = {}
img.render_mermaid_async = function(source, callback)
local cache_path = mermaid_cache_path(source)
if vim.fn.filereadable(cache_path) == 1 then
callback(cache_path)
return
end
if mermaid_inflight[cache_path] then
table.insert(mermaid_inflight[cache_path], callback)
return
end
mermaid_inflight[cache_path] = { callback }
local function finish(result)
local callbacks = mermaid_inflight[cache_path]
mermaid_inflight[cache_path] = nil
for _, cb in ipairs(callbacks) do
cb(result)
end
end
local tmp_input = vim.fn.tempname() .. ".mmd"
local f = io.open(tmp_input, "w")
if not f then
finish(nil)
return
end
f:write(source)
f:close()
local cmd = mmdc_cmd(tmp_input, cache_path)
if not cmd then
os.remove(tmp_input)
finish(nil)
return
end
vim.system(cmd, { text = true, timeout = 30000 }, function()
vim.schedule(function()
os.remove(tmp_input)
if vim.fn.filereadable(cache_path) == 1 then
finish(cache_path)
else
finish(nil)
end
end)
end)
end
end
patch_mermaid_rendering()
-- md-render only processes placements near the viewport on first paint,
-- and its WinScrolled retry loop skips placements without a `path`.
-- Uncached mermaid placements carry only `mermaid_source`, so a diagram
-- that starts off-screen never renders at all, no matter how far you
-- scroll. Eagerly kick off rendering for every mermaid placement when
-- images are set up: the plugin's own rebuild-on-complete then swaps in
-- a normal `path` placement, which its retry loop handles.
local function patch_offscreen_mermaid()
local du = require("md-render.display_utils")
if du._thembones_offscreen_mermaid_patch then return end
du._thembones_offscreen_mermaid_patch = true
local original_setup_images = du.setup_images
du.setup_images = function(win, content, ns, opts)
local state = original_setup_images(win, content, ns, opts)
if state and state.placements and state.process_placement then
vim.schedule(function()
for _, placement in ipairs(state.placements) do
if placement.mermaid_source then
local ok, err = pcall(state.process_placement, placement)
if not ok then
image_debug("offscreen mermaid eager render err=" .. tostring(err))
end
end
end
end)
end
return state
end
end
patch_offscreen_mermaid()
-- md-render's Ghostty path intentionally re-transmits images on every
-- placement so redraws stay visible. Its stock transport uses `t=f`,
-- which means "the terminal reads this file path". Over SSH that path is
-- remote-only, so Ghostty cannot load it. In Herdr/Ghostty, file-path
-- transfer can reserve the image area without painting the pixels, so
-- avoid that path there too. Keep Ghostty's placement model, but switch
-- transport to direct PNG bytes (`t=d`).
if use_direct_kitty_bytes() then
image_debug("direct-byte override enabled")
local img = require("md-render.image")
local tty = require("md-render.tty")
local original_begin_batch = img.begin_batch
local original_flush_batch = img.flush_batch
local original_clear_all = img.clear_all
local original_delete_image = img.delete_image
local original_delete_images = img.delete_images
local original_put_image = img.put_image
local original_get_tty_path = tty.get_tty_path
local CHUNK = 4096
local next_image_id = 800000
local direct_images = {}
local batch_depth = 0
local after_flush = {}
tty.get_tty_path = function()
if vim.env.SSH_TTY and vim.fn.filereadable(vim.env.SSH_TTY) == 1 then
return vim.env.SSH_TTY
end
return original_get_tty_path()
end
local function queue_terminal(data)
image_debug("queue_terminal bytes=" .. tostring(#data) .. " batch_depth=" .. tostring(batch_depth))
if batch_depth > 0 then
table.insert(after_flush, data)
else
vim.api.nvim_ui_send(data)
end
end
local function flush_direct_queue()
if batch_depth == 0 and #after_flush > 0 then
image_debug("flush_direct_queue chunks=" .. tostring(#after_flush))
for _, data in ipairs(after_flush) do
vim.api.nvim_ui_send(data)
end
after_flush = {}
end
end
img.begin_batch = function(...)
batch_depth = batch_depth + 1
return original_begin_batch(...)
end
img.flush_batch = function(...)
local result = original_flush_batch(...)
batch_depth = math.max(0, batch_depth - 1)
flush_direct_queue()
return result
end
local function next_direct_id()
next_image_id = next_image_id + 1
return next_image_id
end
local function read_png_base64(path)
local f = io.open(path, "rb")
if not f then
return nil
end
local data = f:read("*a")
f:close()
return vim.base64.encode(data)
end
local function send_chunked(header, payload)
image_debug("send_chunked header=" .. header .. " payload_len=" .. tostring(#payload))
local i = 1
local first = true
while i <= #payload do
local piece = payload:sub(i, i + CHUNK - 1)
i = i + CHUNK
local more = i <= #payload and 1 or 0
local hdr
if first then
hdr = string.format("%s,m=%d", header, more)
first = false
else
hdr = string.format("m=%d,q=2", more)
end
queue_terminal("\27_G" .. hdr .. ";" .. piece .. "\27\\")
end
end
local function crop_params_for(win, row, col, display_cols, display_rows, img_w, img_h)
if not vim.api.nvim_win_is_valid(win) then return nil end
local win_pos = vim.api.nvim_win_get_position(win)
local wininfo = vim.fn.getwininfo(win)[1]
if not wininfo then return nil end
local win_height = wininfo.height
local topline = wininfo.topline - 1
local leftcol = wininfo.leftcol or 0
local textoff = wininfo.textoff or 0
local img_end_row = row + display_rows - 1
if img_end_row < topline or row >= topline + win_height then return nil end
local visual_row = row - topline
local border_left_width = 0
local border_top_height = 0
local ok_cfg, win_cfg = pcall(vim.api.nvim_win_get_config, win)
if ok_cfg and win_cfg.border then
local border = win_cfg.border
if type(border) == "table" then
local left = border[8]
if type(left) == "table" then left = left[1] end
if left and left ~= "" then
border_left_width = vim.api.nvim_strwidth(left)
end
local top = border[2]
if type(top) == "table" then top = top[1] end
if top and top ~= "" then
border_top_height = 1
end
elseif border ~= "none" and border ~= "" then
border_left_width = vim.api.nvim_strwidth("|")
border_top_height = 1
end
end
local visual_col = col - leftcol
local visible_text_cols = vim.api.nvim_win_get_width(win) - textoff
local img_end_col = col + display_cols - 1
if img_end_col < leftcol or col >= leftcol + visible_text_cols then return nil end
local screen_col = win_pos[2] + visual_col + border_left_width + textoff + 1
local src_x, src_y, src_w, src_h
if visual_col < 0 then
local hidden_cols = -visual_col
if img_w then
src_x = math.floor(img_w * hidden_cols / display_cols)
src_w = img_w - src_x
end
display_cols = display_cols - hidden_cols
visual_col = 0
screen_col = win_pos[2] + border_left_width + textoff + 1
end
if visual_row < 0 then
local hidden_rows = -visual_row
if img_h then
src_y = math.floor(img_h * hidden_rows / display_rows)
src_h = img_h - src_y
end
display_rows = display_rows - hidden_rows
visual_row = 0
end
local winbar_height = 0
local ok_wb, wb = pcall(function() return vim.wo[win].winbar end)
if ok_wb and wb and wb ~= "" then
winbar_height = 1
end
local screen_row = wininfo.winrow + visual_row + border_top_height + winbar_height
local visible_rows = win_height - visual_row
if visible_rows <= 0 then return nil end
if display_rows > visible_rows and img_h then
local remaining_h = src_h or img_h
src_h = math.floor(remaining_h * visible_rows / display_rows)
display_rows = visible_rows
end
local visible_cols = visible_text_cols - visual_col
if visible_cols <= 0 then return nil end
if display_cols > visible_cols and img_w then
local remaining_w = src_w or img_w
src_w = math.floor(remaining_w * visible_cols / display_cols)
display_cols = visible_cols
end
local crop_params = ""
if (src_x or src_y or src_w or src_h) and img_w and img_h then
crop_params = string.format(
",x=%d,y=%d,w=%d,h=%d",
src_x or 0,
src_y or 0,
src_w or img_w,
src_h or img_h
)
end
return {
screen_col = screen_col,
screen_row = screen_row,
display_cols = display_cols,
display_rows = display_rows,
crop_params = crop_params,
}
end
local function transmit_direct(path, callback)
image_debug("transmit_direct path=" .. tostring(path))
local payload = read_png_base64(path)
if not payload then
image_debug("transmit_direct no payload")
callback(nil)
return
end
local id = next_direct_id()
local img_w, img_h = img.image_dimensions(path)
image_debug("transmit_direct id=" .. tostring(id) .. " dims=" .. tostring(img_w) .. "x" .. tostring(img_h))
direct_images[id] = {
path = path,
payload = payload,
width = img_w,
height = img_h,
}
send_chunked(
string.format("a=t,f=100,t=d,i=%d,q=2", id),
payload
)
callback(id, img_w, img_h)
end
-- Herdr re-encodes every visible placement as base64 RGBA of the FULL
-- source image (bytes ≈ w*h*4*4/3, ~16x the PNG size) when forwarding
-- frames to its client, and drops ALL graphics for any frame whose
-- total exceeds 32MiB ("dropping oversized graphics payload" in
-- herdr-server.log). A scale-4 mermaid PNG alone is ~60MB on that
-- wire, so nothing paints. Cap source pixels inside herdr panes; the
-- render view is ≤45 rows so the downscale is visually lossless.
local HERDR_MAX_PIXELS = 1500000
local function herdr_scaled_path(png_path, max_dim)
local mtime = vim.fn.getftime(png_path)
local key = png_path .. "|" .. tostring(mtime) .. "|" .. tostring(max_dim)
local dir = vim.fn.stdpath("cache") .. "/md-render/herdr-scaled"
vim.fn.mkdir(dir, "p")
return dir .. "/" .. vim.fn.sha256(key):sub(1, 16) .. ".png"
end
-- Downscale tool detection, same priority as md-render's own
-- find_convert_tool(): sips (macOS) → ffmpeg → magick (IM7) →
-- convert (IM6, Linux distro ImageMagick installs).
local _downscale_tool = nil
local _downscale_checked = false
local function find_downscale_tool()
if _downscale_checked then return _downscale_tool end
_downscale_checked = true
if vim.fn.has("mac") == 1 and vim.fn.executable("sips") == 1 then
_downscale_tool = "sips"
elseif vim.fn.executable("ffmpeg") == 1 then
_downscale_tool = "ffmpeg"
elseif vim.fn.executable("magick") == 1 then
_downscale_tool = "magick"
elseif vim.fn.executable("convert") == 1 then
_downscale_tool = "convert"
end
return _downscale_tool
end
local function build_downscale_cmd(tool, src, dst, max_dim)
local dim = tostring(max_dim)
if tool == "sips" then
return { "sips", "-Z", dim, src, "--out", dst }
elseif tool == "ffmpeg" then
return {
"ffmpeg", "-y", "-i", src,
"-vframes", "1",
"-vf", "scale='min(" .. dim .. ",iw)':'min(" .. dim .. ",ih)':force_original_aspect_ratio=decrease",
dst,
}
else -- magick / convert share the same argument shape
return { tool, src, "-resize", dim .. "x" .. dim .. ">", dst }
end
end
local function downscale_for_herdr(png_path, callback)
if vim.env.HERDR_ENV ~= "1" then
callback(png_path)
return
end
local w, h = img.image_dimensions(png_path)
if not w or not h or w * h <= HERDR_MAX_PIXELS then
callback(png_path)
return
end
local scale = math.sqrt(HERDR_MAX_PIXELS / (w * h))
local max_dim = math.floor(math.max(w, h) * scale)
local out = herdr_scaled_path(png_path, max_dim)
if vim.fn.filereadable(out) == 1 then
image_debug("downscale_for_herdr cached " .. out)
callback(out)
return
end
-- If no tool is available or it fails, fall back to the original
-- (which herdr will drop, same as before this patch).
local tool = find_downscale_tool()
if not tool then
image_debug("downscale_for_herdr no downscale tool")
callback(png_path)
return
end
vim.system(
build_downscale_cmd(tool, png_path, out, max_dim),
{ text = true, timeout = 15000 },
function()
vim.schedule(function()
if vim.fn.filereadable(out) == 1 then
image_debug("downscale_for_herdr " .. tool .. " " .. w .. "x" .. h .. " -> max_dim=" .. max_dim)
callback(out)
else
image_debug("downscale_for_herdr " .. tool .. " failed")
callback(png_path)
end
end)
end
)
end
img.transmit_image_async = function(path, callback)
image_debug("transmit_image_async path=" .. tostring(path))
if not img.supports_kitty() then
image_debug("transmit_image_async no kitty support")
callback(nil)
return
end
img.ensure_png_async(path, function(png_path)
if not png_path then
image_debug("transmit_image_async no png_path")
callback(nil)
return
end
image_debug("transmit_image_async png_path=" .. tostring(png_path))
downscale_for_herdr(png_path, function(final_path)
transmit_direct(final_path, callback)
end)
end)
end
img.put_image = function(image_id, win, row, col, display_cols, display_rows, anim_path, img_w, img_h)
image_debug(
"put_image id=" .. tostring(image_id)
.. " row=" .. tostring(row)
.. " col=" .. tostring(col)
.. " cols=" .. tostring(display_cols)
.. " rows=" .. tostring(display_rows)
.. " anim=" .. tostring(anim_path)
)
local direct = direct_images[image_id]
if not direct or anim_path then
image_debug("put_image fallback direct=" .. tostring(direct ~= nil) .. " anim=" .. tostring(anim_path))
return original_put_image(image_id, win, row, col, display_cols, display_rows, anim_path, img_w, img_h)
end
if not img.supports_kitty() then return end
local placement = crop_params_for(
win,
row,
col,
display_cols,
display_rows,
img_w or direct.width,
img_h or direct.height
)
if not placement then
image_debug("put_image no placement")
return
end
image_debug(
"put_image placement screen="
.. tostring(placement.screen_row)
.. ","
.. tostring(placement.screen_col)
.. " cells="
.. tostring(placement.display_cols)
.. "x"
.. tostring(placement.display_rows)
.. " crop="
.. tostring(placement.crop_params)
)
queue_terminal("\27[s")
queue_terminal(string.format("\27[%d;%dH", placement.screen_row, placement.screen_col))
send_chunked(
string.format(
"a=T,f=100,t=d,i=%d,c=%d,r=%d%s,C=1,q=2",
image_id,
placement.display_cols,
placement.display_rows,
placement.crop_params
),
direct.payload
)
queue_terminal("\27[u")
end
img.clear_all = function(...)
direct_images = {}
return original_clear_all(...)
end
img.delete_image = function(image_id, ...)
direct_images[image_id] = nil
return original_delete_image(image_id, ...)
end
img.delete_images = function(image_ids, ...)
for _, image_id in ipairs(image_ids or {}) do
direct_images[image_id] = nil
end
return original_delete_images(image_ids, ...)
end
end
local function mermaid_sources(buf)
local sources = {}
local in_mermaid = false
local block = {}
for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
if not in_mermaid then
if line:match("^%s*```%s*mermaid%s*$") then
in_mermaid = true
block = {}
end
elseif line:match("^%s*```%s*$") then
if #block > 0 then
table.insert(sources, table.concat(block, "\n"))
end
in_mermaid = false
block = {}
else
table.insert(block, line)
end
end
return sources
end
local function prime_mermaid_cache(buf)
if not vim.api.nvim_buf_is_valid(buf) then return end
local ok, image = pcall(require, "md-render.image")
if not ok or not image.has_mmdc() then return end
for _, source in ipairs(mermaid_sources(buf)) do
if not image.get_mermaid_cached(source) then
image.render_mermaid(source)
end
end
end
local function ensure_md_render_auto(buf)
if not vim.api.nvim_buf_is_valid(buf) then return end
if vim.bo[buf].filetype ~= "markdown" then return end
if not vim.b[buf].md_render_auto then
local cache_group = vim.api.nvim_create_augroup("MdRenderMermaidCache" .. buf, { clear = true })
vim.api.nvim_create_autocmd({ "InsertLeave", "BufWritePost" }, {
group = cache_group,
buffer = buf,
callback = function()
prime_mermaid_cache(buf)
end,
desc = "Prime md-render Mermaid cache before redraw",
})
end
prime_mermaid_cache(buf)
if vim.b[buf].md_render_auto then return end
local ok, md = pcall(require, "md-render")
if ok then
local win = vim.api.nvim_get_current_win()
local ok_state, state = pcall(vim.api.nvim_win_get_var, win, "md_render_state")
if ok_state and type(state) == "table" and state.source_buf ~= buf then
pcall(vim.api.nvim_win_del_var, win, "md_render_state")
end
pcall(md.preview.auto_on, { max_width = 140 })
end
end
local group = vim.api.nvim_create_augroup("MdRenderMarkdownAuto", { clear = true })
vim.api.nvim_create_autocmd("FileType", {
group = group,
pattern = { "markdown", "*.md", "*.markdown" },
callback = function(ev)
-- Defer so the FileType pass that loads md-render completes first.
vim.schedule(function()
if vim.api.nvim_get_current_buf() == ev.buf then
ensure_md_render_auto(ev.buf)
end
end)
end,
desc = "Enable md-render auto mode for markdown buffers",
})
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
vim.schedule(function()
ensure_md_render_auto(buf)
end)
end
end,
},
-- Disable the previous attempts so they don't fight md-render for the same
-- terminal cells (the "1 frame then gone" / "behind the text" symptoms).
{ "3rd/image.nvim", enabled = false },
{ "3rd/diagram.nvim", enabled = false },
{
"folke/snacks.nvim",
opts = function(_, opts)
opts.image = vim.tbl_deep_extend("force", opts.image or {}, {
enabled = false,
})
return opts
end,
},
}