Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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
Expand Down
142 changes: 119 additions & 23 deletions lua/claudecode/terminal/snacks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
"<S-CR>",
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/claudecode_add_command_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down