From b0f7520ce492497b850a20551bba0cb904779ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rasmus=20Nordstr=C3=B6m?= Date: Mon, 2 Feb 2026 15:12:11 +0100 Subject: [PATCH 1/2] fix(terminal): avoid pty resize on floating terminal toggle When Snacks toggles a floating terminal, it destroys and recreates the window. This sends SIGWINCH to the terminal process, causing Claude CLI to re-render its TUI. The re-render during window creation caused two bugs: a "T" character leak (first char of placeholder text injected as input) and viewport climbing (cursor drifts up one row per toggle). On Neovim >= 0.10, patch the Snacks terminal instance to use nvim_win_set_config({hide=true/false}) instead of destroying the window. This keeps the window alive but invisible, so no pty resize occurs and terminal state is perfectly preserved between toggles. The backdrop window is hidden/shown alongside the main float. Split terminals are unaffected as they did not exhibit these issues. --- lua/claudecode/terminal/snacks.lua | 142 ++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 23 deletions(-) diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 2b4c7c98..6b311e89 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -51,14 +51,31 @@ local function build_opts(config, env_table, focus) return { env = env_table, cwd = config.cwd, - start_insert = focus, - auto_insert = focus, + start_insert = false, + auto_insert = false, auto_close = false, win = vim.tbl_deep_extend("force", { position = config.split_side, width = config.split_width_percentage, height = 0, relative = "editor", + on_win = focus + and function(self) + -- Scroll to bottom so viewport shows latest terminal output. + local lc = vim.api.nvim_buf_line_count(self.buf) + if lc > 0 then + pcall(vim.api.nvim_win_set_cursor, self.win, { lc, 0 }) + end + -- Defer startinsert: entering terminal mode during show() while + -- a pty resize is in flight causes the first character of Claude + -- CLI's placeholder text to leak as input. + vim.defer_fn(function() + if self:win_valid() and self:buf_valid() and vim.api.nvim_get_current_buf() == self.buf then + vim.cmd.startinsert() + end + end, 50) + end + or nil, keys = { claude_new_line = { "", @@ -76,6 +93,100 @@ local function build_opts(config, env_table, focus) } --[[@as snacks.terminal.Opts]] end +---Patch a floating terminal to hide/show via nvim_win_set_config instead of +---destroying and recreating the window. Destroying the window sends SIGWINCH +---to the pty, which makes Claude CLI re-render its TUI. That re-render during +---window creation causes two bugs: +--- 1. "T" character leak — the first char of placeholder text leaks as input +--- 2. Viewport climbing — cursor drifts up one row per toggle cycle +--- +---Using {hide=true/false} keeps the window alive (invisible), so no pty resize +---occurs. Requires Neovim >= 0.10 for the hide window config field. +---@param term_instance table The Snacks terminal instance to patch +local function patch_floating_toggle(term_instance) + if vim.fn.has("nvim-0.10") ~= 1 then + return + end + + if not term_instance.win or not vim.api.nvim_win_is_valid(term_instance.win) then + return + end + + local win_config = vim.api.nvim_win_get_config(term_instance.win) + if not win_config.relative or win_config.relative == "" then + return -- not a floating window, no patch needed + end + + local logger = require("claudecode.logger") + logger.debug("terminal", "Patching floating terminal for hide-based toggle") + + local orig_win_valid = term_instance.win_valid + local orig_hide = term_instance.hide + local orig_show = term_instance.show + + --- Check whether the floating window exists but is config-hidden. + local function is_hidden() + if not term_instance.win or not vim.api.nvim_win_is_valid(term_instance.win) then + return false + end + local cfg = vim.api.nvim_win_get_config(term_instance.win) + return cfg.hide == true + end + + -- A config-hidden window should be treated as "not valid" so that the + -- existing toggle logic takes the "terminal not visible" branch. + function term_instance:win_valid() + if is_hidden() then + return false + end + return orig_win_valid(self) + end + + --- Hide or show the backdrop window that Snacks places behind the float. + local function set_backdrop_hidden(self, hidden) + if self.backdrop and self.backdrop.win and vim.api.nvim_win_is_valid(self.backdrop.win) then + vim.api.nvim_win_set_config(self.backdrop.win, { hide = hidden }) + end + end + + function term_instance:hide() + if self.win and vim.api.nvim_win_is_valid(self.win) and not is_hidden() then + logger.debug("terminal", "Float: hiding window via config") + set_backdrop_hidden(self, true) + vim.api.nvim_win_set_config(self.win, { hide = true }) + -- Neovim auto-focuses another window when the current one is hidden; + -- fall back to wincmd if that did not happen. + if vim.api.nvim_get_current_win() == self.win then + pcall(vim.cmd, "wincmd p") + end + return self + end + return orig_hide(self) + end + + function term_instance:show() + if is_hidden() then + logger.debug("terminal", "Float: showing window via config") + vim.api.nvim_win_set_config(self.win, { hide = false }) + set_backdrop_hidden(self, false) + self:focus() + vim.cmd.startinsert() + return self + end + -- Window was truly closed (not just hidden) — fall back to Snacks' show + -- which recreates the window. + return orig_show(self) + end + + function term_instance:toggle() + if self.win and vim.api.nvim_win_is_valid(self.win) and not is_hidden() then + return self:hide() + else + return self:show() + end + end +end + function M.setup() -- No specific setup needed for Snacks provider end @@ -94,34 +205,18 @@ function M.open(cmd_string, env_table, config, focus) focus = utils.normalize_focus(focus) if terminal and terminal:buf_valid() then - -- Check if terminal exists but is hidden (no window) - if not terminal.win or not vim.api.nvim_win_is_valid(terminal.win) then - -- Terminal is hidden, show it using snacks toggle + if not terminal:win_valid() then + -- Terminal is hidden, show it terminal:toggle() - if focus then + if focus and terminal:win_valid() then terminal:focus() - local term_buf_id = terminal.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then - vim.api.nvim_win_call(terminal.win, function() - vim.cmd("startinsert") - end) - end - end + vim.cmd.startinsert() end else -- Terminal is already visible if focus then terminal:focus() - local term_buf_id = terminal.buf - if term_buf_id and vim.api.nvim_buf_get_option(term_buf_id, "buftype") == "terminal" then - -- Check if window is valid before calling nvim_win_call - if terminal.win and vim.api.nvim_win_is_valid(terminal.win) then - vim.api.nvim_win_call(terminal.win, function() - vim.cmd("startinsert") - end) - end - end + vim.cmd.startinsert() end end return @@ -131,6 +226,7 @@ function M.open(cmd_string, env_table, config, focus) local term_instance = Snacks.terminal.open(cmd_string, opts) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) + patch_floating_toggle(term_instance) terminal = term_instance else terminal = nil From d7e96f0618f035203e9ed3c8b9734100fc5a2a26 Mon Sep 17 00:00:00 2001 From: Hsiu-Chieh Lee Date: Wed, 11 Mar 2026 03:24:37 +0800 Subject: [PATCH 2/2] feat: Ensure ClaudeCodeAdd correctly escapes argument --- lua/claudecode/init.lua | 42 +++++++++++- tests/unit/claudecode_add_command_spec.lua | 80 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index c4b7744e..ce83c867 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -900,6 +900,46 @@ function M._create_commands() desc = "Add selected file(s) from tree explorer to Claude Code context (supports visual selection)", }) + --- Parse a command-line string respecting backslash-escaped spaces. + --- Treats `\ ` as a literal space within a token and unescaped whitespace as delimiters. + --- @param input string + --- @return string[] + local function parse_escaped_args(input) + local args = {} + local current = {} + local i = 1 + local len = #input + while i <= len do + local ch = input:sub(i, i) + if ch == "\\" and i < len then + local next_ch = input:sub(i + 1, i + 1) + if next_ch == " " then + current[#current + 1] = " " + i = i + 2 + elseif next_ch == "\\" then + current[#current + 1] = "\\" + i = i + 2 + else + current[#current + 1] = ch + i = i + 1 + end + elseif ch:match("%s") then + if #current > 0 then + args[#args + 1] = table.concat(current) + current = {} + end + i = i + 1 + else + current[#current + 1] = ch + i = i + 1 + end + end + if #current > 0 then + args[#args + 1] = table.concat(current) + end + return args + end + vim.api.nvim_create_user_command("ClaudeCodeAdd", function(opts) if not M.state.server then logger.error("command", "ClaudeCodeAdd: Claude Code integration is not running.") @@ -911,7 +951,7 @@ function M._create_commands() return end - local args = vim.split(opts.args, "%s+") + local args = parse_escaped_args(opts.args) local file_path = args[1] local start_line = args[2] and tonumber(args[2]) or nil local end_line = args[3] and tonumber(args[3]) or nil diff --git a/tests/unit/claudecode_add_command_spec.lua b/tests/unit/claudecode_add_command_spec.lua index 5f98f652..1f3cb4e2 100644 --- a/tests/unit/claudecode_add_command_spec.lua +++ b/tests/unit/claudecode_add_command_spec.lua @@ -187,6 +187,86 @@ describe("ClaudeCodeAdd command", function() end) end) + describe("escaped path handling", function() + it("should parse backslash-escaped spaces in file paths", function() + vim.fn.filereadable = spy.new(function(path) + return path == "file name.lua" and 1 or 0 + end) + vim.fn.expand = spy.new(function(path) + return path + end) + + command_handler({ args = "file\\ name.lua" }) + + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "file name.lua", + lineStart = nil, + lineEnd = nil, + }) + end) + + it("should not treat 'file\\ 1' as path with line number", function() + vim.fn.filereadable = spy.new(function(path) + return path == "file 1" and 1 or 0 + end) + vim.fn.expand = spy.new(function(path) + return path + end) + + command_handler({ args = "file\\ 1" }) + + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "file 1", + lineStart = nil, + lineEnd = nil, + }) + end) + + it("should parse escaped path with line range", function() + vim.fn.filereadable = spy.new(function(path) + return path == "my test file.lua" and 1 or 0 + end) + vim.fn.expand = spy.new(function(path) + return path + end) + + command_handler({ args = "my\\ test\\ file.lua 10 20" }) + + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "my test file.lua", + lineStart = 9, + lineEnd = 19, + }) + end) + + it("should parse escaped path with start line only", function() + vim.fn.filereadable = spy.new(function(path) + return path == "file name.lua" and 1 or 0 + end) + vim.fn.expand = spy.new(function(path) + return path + end) + + command_handler({ args = "file\\ name.lua 5" }) + + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "file name.lua", + lineStart = 4, + lineEnd = nil, + }) + end) + + it("should still handle paths without spaces", function() + command_handler({ args = "/existing/file.lua 10" }) + + assert.spy(mock_server.broadcast).was_called_with("at_mentioned", { + filePath = "/existing/file.lua", + lineStart = 9, + lineEnd = nil, + }) + end) + end) + describe("path handling", function() it("should expand tilde paths", function() command_handler({ args = "~/test.lua" })