diff --git a/lua/md-render/image.lua b/lua/md-render/image.lua index a9319f0..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,6 +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 = wrap_kitty_commands_for_tmux(data) end vim.api.nvim_ui_send(data) end @@ -757,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 @@ -794,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 df500e2..76af02e 100644 --- a/tests/image_test.lua +++ b/tests/image_test.lua @@ -89,6 +89,13 @@ end --- Concatenate all captured writes into a single string local function captured_output() + local data = table.concat(captured) + if vim.env.TMUX then data = data:gsub("\x1bPtmux;", ""):gsub("\x1b\x1b", "\x1b") end + return data +end + +--- Concatenate captured writes +local function captured_raw_output() return table.concat(captured) end @@ -204,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 -- ============================================================================ @@ -583,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) @@ -607,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 -- ============================================================================