-- 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 = { { "mp", function() require("md-render").preview.show({ max_width = 140 }) end, ft = "markdown", desc = "Markdown preview (wide float toggle)", }, { "mt", function() require("md-render").preview.show_tab({ max_width = 140 }) end, ft = "markdown", desc = "Markdown preview (wide tab toggle)", }, { "ms", function() require("md-render").preview.split({ max_width = 140 }) end, ft = "markdown", desc = "Markdown source/render split", }, { "mr", function() require("md-render").preview.toggle({ max_width = 140 }) end, ft = "markdown", desc = "Markdown render toggle in-place", }, { "md", "(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, }, }