From 4474810789c70b84e095c7fe4814ed204d4839d4 Mon Sep 17 00:00:00 2001 From: fl4vis Date: Sat, 10 Oct 2026 23:50:06 -0500 Subject: [PATCH 1/2] fix(image): support Kitty graphics tmux Kitty graphics do not render inside tmux even with passthrough enabled. The graphics sequences were sent directly to the terminal, but tmux intercepted them instead of forwarding them to Kitty. When running inside tmux, graphics commands are now wrapped in tmux DCS passthrough sequences, with embedded ESC bytes doubled. This allows Kitty to receive and render images without changing the existing rendering logic. --- lua/md-render/image.lua | 3 +++ tests/image_test.lua | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/md-render/image.lua b/lua/md-render/image.lua index a9319f0..3433fad 100644 --- a/lua/md-render/image.lua +++ b/lua/md-render/image.lua @@ -64,6 +64,9 @@ local function term_write(data) table.insert(_batch_buffer, data) return end + if vim.env.TMUX and data:find("\x1b_G", 1, true) then + data = "\x1bPtmux;" .. data:gsub("\x1b", "\x1b\x1b") .. "\x1b\\" + end vim.api.nvim_ui_send(data) end diff --git a/tests/image_test.lua b/tests/image_test.lua index df500e2..4fd548a 100644 --- a/tests/image_test.lua +++ b/tests/image_test.lua @@ -89,7 +89,9 @@ end --- Concatenate all captured writes into a single string local function captured_output() - return table.concat(captured) + local data = table.concat(captured) + if vim.env.TMUX then data = data:gsub("\x1bPtmux;", ""):gsub("\x1b\x1b", "\x1b") end + return data end --- Parse Kitty graphics APC sequences from captured output. From 90f538f561e1f40aea8b614c836b38818d7652b2 Mon Sep 17 00:00:00 2001 From: fl4vis Date: Tue, 13 Oct 2026 01:32:18 -0500 Subject: [PATCH 2/2] fix(image): route Kitty graphics through tmux Wrap each Kitty graphics command in tmux DCS passthrough instead of wrapping an entire output batch. Cursor movement remains handled by tmux, preserving pane-relative placement coordinates. Detect an xterm-kitty client attached through tmux when KITTY_WINDOW_ID is unavailable. --- lua/md-render/image.lua | 66 +++++++++++++++- tests/image_test.lua | 171 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 3 deletions(-) diff --git a/lua/md-render/image.lua b/lua/md-render/image.lua index 3433fad..b6a3f0e 100644 --- a/lua/md-render/image.lua +++ b/lua/md-render/image.lua @@ -57,6 +57,46 @@ end local _batch_buffer = nil local _batch_stack = nil +---@param kitty_command string +---@return string +local function wrap_tmux_passthrough(kitty_command) + return "\x1bPtmux;" .. kitty_command:gsub("\x1b", "\x1b\x1b") .. "\x1b\\" +end + +---@param data string +---@return string +local function wrap_kitty_commands_for_tmux(data) + local output = {} + local position = 1 + + while true do + local command_start = data:find("\x1b_G", position, true) + if not command_start then + table.insert(output, data:sub(position)) + break + end + + local command_end = data:find("\x1b\\", command_start + 3, true) + if not command_end then + -- Do not alter and incomplete Kitty command + table.insert(output, data:sub(position)) + break + end + + -- Terminal bytes before Kitty command starts + table.insert(output, data:sub(position, command_start - 1)) + + -- Wrap exactly ESC_G ... ESC\ + local kitty_command = data:sub(command_start, command_end + 1) + table.insert(output, wrap_tmux_passthrough(kitty_command)) + + -- ESC\ is two bytes, continue after it + position = command_end + 2 + end + + return table.concat(output) +end + ---@param data string local function term_write(data) if data == "" then return end @@ -64,9 +104,7 @@ local function term_write(data) table.insert(_batch_buffer, data) return end - if vim.env.TMUX and data:find("\x1b_G", 1, true) then - data = "\x1bPtmux;" .. data:gsub("\x1b", "\x1b\x1b") .. "\x1b\\" - end + if vim.env.TMUX and data:find("\x1b_G", 1, true) then data = wrap_kitty_commands_for_tmux(data) end vim.api.nvim_ui_send(data) end @@ -760,6 +798,23 @@ local function probe_kitty_via_apc() return supported end +--- Return whether the tmux client attached is kitty +---@return boolean +local function tmux_client_is_kitty() + if not vim.env.TMUX or not vim.env.TMUX_PANE then return false end + + local ok, termname = pcall(vim.fn.system, { + "tmux", + "display-message", + "-p", + "-t", + vim.env.TMUX_PANE, + "#{client_termname}", + }) + + return ok and vim.trim(termname) == "xterm-kitty" +end + function M.supports_kitty() if _kitty_supported ~= nil then return _kitty_supported end -- Windows lacks the POSIX TTY plumbing (isatty/ttyname/ioctl) this module @@ -797,6 +852,11 @@ function M.supports_kitty() _kitty_supported = true return true end + if tmux_client_is_kitty() then + _kitty_supported = true + return true + end + _kitty_supported = false return false end diff --git a/tests/image_test.lua b/tests/image_test.lua index 4fd548a..76af02e 100644 --- a/tests/image_test.lua +++ b/tests/image_test.lua @@ -94,6 +94,11 @@ local function captured_output() return data end +--- Concatenate captured writes +local function captured_raw_output() + return table.concat(captured) +end + --- Parse Kitty graphics APC sequences from captured output. --- Returns a list of { params = "a=t,...", payload = "..." } local function parse_kitty_sequences(data) @@ -206,6 +211,77 @@ test("calc_display_size: tall portrait image constrained by max_rows", function( image._test_cell_size = nil end) +-- ============================================================================ +-- tmux passthrough: filter kitty commands +-- ============================================================================ +test("tmux passthrough: wraps a Kitty graphics command", function() + -- Verifies raw protocol bytes + -- ESC P tmux; outer DCS starts + -- ESC ESC _ G inner Kitty ESC is doubled + -- ESC ESC \ inner Kitty terminator ESC is doubled + -- ESC \ outer tmux DCS terminator + setup_capture() + + local original_tmux = vim.env.TMUX + vim.env.TMUX = "/tmp/tmux-1000/default,123,0" + + local id = image.transmit_image(test_png) + local output = captured_raw_output() + + assert_true(id ~= nil, "image should transmit") + assert_true(output:find("\x1bPtmux;", 1, true) ~= nil, "starts tmux passthrough") + assert_true(output:find("\x1b\x1b_G", 1, true) ~= nil, "escapes the Kitty commad start") + assert_true(output:find("\x1b\x1b\\\x1b\\", 1, true) ~= nil, "escapes Kitty end and closes tmux passthrough") + + vim.env.TMUX = original_tmux + teardown() +end) + +test("tmux passthrough: leaves cursor controls outside the wrapper", function() + setup_capture() + + local original_tmux = vim.env.TMUX + vim.env.TMUX = "/tmp/tmux-1000/default,123,0" + + image.begin_batch() + image.put_image(101, vim.api.nvim_get_current_win(), 0, 0, 1, 1) + image.flush_batch() + + local output = captured_raw_output() + + -- put_image() writes: save cursor, move cursor, place image, restore cursor. + -- Only the Kitty placement command belong in the DCS wrapper + + assert_true(output:sub(1, 3) == "\x1b[s", "cursor save is sent normally before passthrough") + assert_true(output:find("\x1bPtmux;", 1, true) ~= nil, "Kitty placement is wrapped for tmux") + assert_true(output:sub(-3) == "\x1b[u", "cursor restore is sent normally after passthrough") + + vim.env.TMUX = original_tmux + teardown() + + -- put_image() +end) + +test("tmux passthrough: wraps each kitty command in a batch separately", function() + setup_capture() + + local original_tmux = vim.env.TMUX + vim.env.TMUX = "/tmp/tmux-1000/default,123,0" + + image.begin_batch() + image.clear_placements(101) + image.clear_placements(102) + image.flush_batch() + + local output = captured_raw_output() + local _, wrapper_count = output:gsub("\x1bPtmux;", "") + + assert_eq(wrapper_count, 2, "each Kitty command gets its own tmux wrapper") + + vim.env.TMUX = original_tmux + teardown() +end) + -- ============================================================================ -- transmit_image: escape sequence tests -- ============================================================================ @@ -585,15 +661,22 @@ test("supports_kitty: returns false for unknown terminal", function() local orig_kw = vim.env.KITTY_WINDOW_ID local orig_gr = vim.env.GHOSTTY_RESOURCES_DIR local orig_we = vim.env.WEZTERM_EXECUTABLE + local orig_tmux = vim.env.TMUX + local orig_tmux_pane = vim.env.TMUX_PANE + vim.env.TERM_PROGRAM = "xterm" vim.env.KITTY_WINDOW_ID = nil vim.env.GHOSTTY_RESOURCES_DIR = nil vim.env.WEZTERM_EXECUTABLE = nil + vim.env.TMUX = nil + vim.env.TMUX_PANE = nil assert_true(not image.supports_kitty(), "should return false for xterm") vim.env.TERM_PROGRAM = orig_tp vim.env.KITTY_WINDOW_ID = orig_kw vim.env.GHOSTTY_RESOURCES_DIR = orig_gr vim.env.WEZTERM_EXECUTABLE = orig_we + vim.env.TMUX = orig_tmux + vim.env.TMUX_PANE = orig_tmux_pane image.reset_cache() end) @@ -609,6 +692,94 @@ test("supports_kitty: detects via KITTY_WINDOW_ID env var", function() image.reset_cache() end) +test("supports_kitty: detects a Kitty client attached through tmux", function() + local original_system = vim.fn.system + local original_tmux = vim.env.TMUX + local original_tmux_pane = vim.env.TMUX_PANE + local original_term = vim.env.TERM + local original_term_program = vim.env.TERM_PROGRAM + local original_kitty_window_id = vim.env.KITTY_WINDOW_ID + local original_ghostty_resources_dir = vim.env.GHOSTTY_RESOURCES_DIR + local original_wezterm_executable = vim.env.WEZTERM_EXECUTABLE + + local command_seen = nil + vim.fn.system = function(command) + command_seen = command + return "xterm-kitty\n" + end + + -- Reproduce Kitty attached through tmux, but no KITTY_WINDOW_ID env variable + vim.env.TMUX = "/tmp/tmux-1000/default,123,0" + vim.env.TMUX_PANE = "%1" + vim.env.TERM = "xterm-256color" + vim.env.TERM_PROGRAM = "tmux" + vim.env.KITTY_WINDOW_ID = nil + vim.env.GHOSTTY_RESOURCES_DIR = nil + vim.env.WEZTERM_EXECUTABLE = nil + image.reset_cache() + + assert_true(image.supports_kitty(), "should detect a Kitty tmux client") + assert_eq( + command_seen, + { "tmux", "display-message", "-p", "-t", "%1", "#{client_termname}" }, + "should query tmux for the client terminal name" + ) + + vim.fn.system = original_system + vim.env.TMUX = original_tmux + vim.env.TMUX_PANE = original_tmux_pane + vim.env.TERM = original_term + vim.env.TERM_PROGRAM = original_term_program + vim.env.KITTY_WINDOW_ID = original_kitty_window_id + vim.env.GHOSTTY_RESOURCES_DIR = original_ghostty_resources_dir + vim.env.WEZTERM_EXECUTABLE = original_wezterm_executable + image.reset_cache() +end) + +test("supports_kitty: rejects a non-Kitty tmux client", function() + local original_system = vim.fn.system + local original_tmux = vim.env.TMUX + local original_tmux_pane = vim.env.TMUX_PANE + local original_term = vim.env.TERM + local original_term_program = vim.env.TERM_PROGRAM + local original_kitty_window_id = vim.env.KITTY_WINDOW_ID + local original_ghostty_resources_dir = vim.env.GHOSTTY_RESOURCES_DIR + local original_wezterm_executable = vim.env.WEZTERM_EXECUTABLE + + local command_seen = nil + vim.fn.system = function(command) + command_seen = command + return "xterm-256color\n" + end + + -- Reproduce issue: Kitty attached through tmux, but no Kitty env variable + vim.env.TMUX = "/tmp/tmux-1000/default,123,0" + vim.env.TMUX_PANE = "%1" + vim.env.TERM = "xterm-256color" + vim.env.TERM_PROGRAM = "tmux" + vim.env.KITTY_WINDOW_ID = nil + vim.env.GHOSTTY_RESOURCES_DIR = nil + vim.env.WEZTERM_EXECUTABLE = nil + image.reset_cache() + + assert_true(not image.supports_kitty(), "should reject a non-Kitty tmux client") + assert_eq( + command_seen, + { "tmux", "display-message", "-p", "-t", "%1", "#{client_termname}" }, + "should query tmux for the client terminal name" + ) + + vim.fn.system = original_system + vim.env.TMUX = original_tmux + vim.env.TMUX_PANE = original_tmux_pane + vim.env.TERM = original_term + vim.env.TERM_PROGRAM = original_term_program + vim.env.KITTY_WINDOW_ID = original_kitty_window_id + vim.env.GHOSTTY_RESOURCES_DIR = original_ghostty_resources_dir + vim.env.WEZTERM_EXECUTABLE = original_wezterm_executable + image.reset_cache() +end) + -- ============================================================================ -- is_badge_url tests -- ============================================================================