Skip to content

Fix visual selection range handling for :'<,'>ClaudeCodeSend #26

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 11, 2025
Merged
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
30 changes: 20 additions & 10 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ function M.setup(opts)
-- even if terminal_opts (for split_side etc.) are not provided.
local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal")
if terminal_setup_ok then
-- terminal_opts might be nil if user only configured top-level terminal_cmd
-- and not specific terminal appearance options.
-- The terminal.setup function handles nil for its first argument.
terminal_module.setup(terminal_opts, M.state.config.terminal_cmd)
-- Guard in case tests or user replace the module with a minimal stub without `setup`.
if type(terminal_module.setup) == "function" then
-- terminal_opts might be nil, which the setup function should handle gracefully.
terminal_module.setup(terminal_opts, M.state.config.terminal_cmd)
end
else
logger.error("init", "Failed to load claudecode.terminal module for setup.")
end
Expand Down Expand Up @@ -403,8 +404,8 @@ function M._create_commands()
return
end

local current_ft = vim.bo.filetype
local current_bufname = vim.api.nvim_buf_get_name(0)
local current_ft = (vim.bo and vim.bo.filetype) or ""
local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or ""

local is_tree_buffer = current_ft == "NvimTree"
or current_ft == "neo-tree"
Expand Down Expand Up @@ -434,14 +435,23 @@ function M._create_commands()

local selection_module_ok, selection_module = pcall(require, "claudecode.selection")
if selection_module_ok then
local sent_successfully = selection_module.send_at_mention_for_visual_selection()
-- Pass range information if available (for :'<,'> commands)
local line1, line2 = nil, nil
if opts and opts.range and opts.range > 0 then
line1, line2 = opts.line1, opts.line2
end
local sent_successfully = selection_module.send_at_mention_for_visual_selection(line1, line2)
if sent_successfully then
-- Exit any potential visual mode (for consistency) and focus Claude terminal
pcall(function()
if vim.api and vim.api.nvim_feedkeys then
local esc = vim.api.nvim_replace_termcodes("<Esc>", true, false, true)
vim.api.nvim_feedkeys(esc, "i", true)
end
end)
local terminal_ok, terminal = pcall(require, "claudecode.terminal")
if terminal_ok then
terminal.open({})
logger.debug("command", "ClaudeCodeSend: Focused Claude Code terminal after selection send.")
else
logger.warn("command", "ClaudeCodeSend: Failed to load terminal module for focusing.")
end
end
else
Expand Down
86 changes: 76 additions & 10 deletions lua/claudecode/selection.lua
Original file line number Diff line number Diff line change
Expand Up @@ -573,25 +573,91 @@ function M.send_current_selection()
vim.api.nvim_echo({ { "Selection sent to Claude", "Normal" } }, false, {})
end

--- Gets selection from range marks (e.g., when using :'<,'> commands)
-- @param line1 number The start line (1-indexed)
-- @param line2 number The end line (1-indexed)
-- @return table|nil A table containing selection text, file path, URL, and
-- start/end positions, or nil if invalid range
function M.get_range_selection(line1, line2)
if not line1 or not line2 or line1 < 1 or line2 < 1 or line1 > line2 then
return nil
end

local current_buf = vim.api.nvim_get_current_buf()
local file_path = vim.api.nvim_buf_get_name(current_buf)

-- Get the total number of lines in the buffer
local total_lines = vim.api.nvim_buf_line_count(current_buf)

-- Ensure line2 doesn't exceed buffer bounds
if line2 > total_lines then
line2 = total_lines
end

local lines_content = vim.api.nvim_buf_get_lines(
current_buf,
line1 - 1, -- Convert to 0-indexed
line2, -- nvim_buf_get_lines end is exclusive
false
)

if #lines_content == 0 then
return nil
end

local final_text = table.concat(lines_content, "\n")

-- For range selections, we treat them as linewise
local lsp_start_line = line1 - 1 -- Convert to 0-indexed
local lsp_end_line = line2 - 1
local lsp_start_char = 0
local lsp_end_char = #lines_content[#lines_content]

return {
text = final_text or "",
filePath = file_path,
fileUrl = "file://" .. file_path,
selection = {
start = { line = lsp_start_line, character = lsp_start_char },
["end"] = { line = lsp_end_line, character = lsp_end_char },
isEmpty = (not final_text or #final_text == 0),
},
}
end

--- Sends an at_mentioned notification for the current visual selection.
function M.send_at_mention_for_visual_selection()
-- @param line1 number|nil Optional start line for range-based selection
-- @param line2 number|nil Optional end line for range-based selection
function M.send_at_mention_for_visual_selection(line1, line2)
if not M.state.tracking_enabled or not M.server then
logger.error("selection", "Claude Code is not running or server not available for send_at_mention.")
return false
end

local sel_to_send = M.state.latest_selection
local sel_to_send

if not sel_to_send or sel_to_send.selection.isEmpty then
-- Fallback: try to get current visual selection directly.
-- This helps if latest_selection was demoted or command was too fast.
local current_visual = M.get_visual_selection()
if current_visual and not current_visual.selection.isEmpty then
sel_to_send = current_visual
else
logger.warn("selection", "No visual selection to send as at-mention.")
-- If range parameters are provided, use them (for :'<,'> commands)
if line1 and line2 then
sel_to_send = M.get_range_selection(line1, line2)
if not sel_to_send or sel_to_send.selection.isEmpty then
logger.warn("selection", "Invalid range selection to send as at-mention.")
return false
end
else
-- Use existing logic for visual mode or tracked selection
sel_to_send = M.state.latest_selection

if not sel_to_send or sel_to_send.selection.isEmpty then
-- Fallback: try to get current visual selection directly.
-- This helps if latest_selection was demoted or command was too fast.
local current_visual = M.get_visual_selection()
if current_visual and not current_visual.selection.isEmpty then
sel_to_send = current_visual
else
logger.warn("selection", "No visual selection to send as at-mention.")
return false
end
end
end

-- Sanity check: ensure the selection is for the current buffer
Expand Down
32 changes: 20 additions & 12 deletions lua/claudecode/visual_commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
-- @module claudecode.visual_commands
local M = {}

--- Get current vim mode with fallback for test environments
--- @param full_mode boolean|nil Whether to get full mode info (passed to vim.fn.mode)
--- @return string current_mode The current vim mode
local function get_current_mode(full_mode)
local current_mode = "n" -- Default fallback

pcall(function()
if vim.api and vim.api.nvim_get_mode then
current_mode = vim.api.nvim_get_mode().mode
else
current_mode = vim.fn.mode(full_mode)
end
end)

return current_mode
end

-- ESC key constant matching neo-tree's implementation
local ESC_KEY
local success = pcall(function()
Expand Down Expand Up @@ -40,16 +57,7 @@ end
--- @return boolean true if in visual mode, false otherwise
--- @return string|nil error message if not in visual mode
function M.validate_visual_mode()
local current_mode = "n" -- Default fallback

-- Use pcall to handle test environments
local mode_success = pcall(function()
current_mode = vim.api.nvim_get_mode().mode
end)

if not mode_success then
return false, "Cannot determine current mode (test environment)"
end
local current_mode = get_current_mode(true)

local is_visual = current_mode == "v" or current_mode == "V" or current_mode == "\022"

Expand Down Expand Up @@ -78,7 +86,7 @@ function M.get_visual_range()
-- Use pcall to handle test environments
local range_success = pcall(function()
-- Check if we're currently in visual mode
local current_mode = vim.api.nvim_get_mode().mode
local current_mode = get_current_mode(true)
local is_visual = current_mode == "v" or current_mode == "V" or current_mode == "\022"

if is_visual then
Expand Down Expand Up @@ -177,7 +185,7 @@ end
--- @return function The wrapped command function
function M.create_visual_command_wrapper(normal_handler, visual_handler)
return function(...)
local current_mode = vim.api.nvim_get_mode().mode
local current_mode = get_current_mode(true)

if current_mode == "v" or current_mode == "V" or current_mode == "\022" then
-- Use the neo-tree pattern: exit visual mode, then schedule execution
Expand Down
174 changes: 174 additions & 0 deletions tests/selection_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,177 @@ describe("Selection module", function()
assert(selection.has_selection_changed(new_selection_diff_pos) == true)
end)
end)

-- Tests for range selection functionality (fix for issue #25)
describe("Range Selection Tests", function()
local selection

before_each(function()
-- Reset vim state
_G.vim._buffers = {
[1] = {
name = "/test/file.lua",
lines = {
"line 1",
"line 2",
"line 3",
"line 4",
"line 5",
"line 6",
"line 7",
"line 8",
"line 9",
"line 10",
},
},
}
_G.vim._windows = {
[1] = {
cursor = { 1, 0 },
},
}
_G.vim._current_mode = "n"

-- Add nvim_buf_line_count function
_G.vim.api.nvim_buf_line_count = function(bufnr)
return _G.vim._buffers[bufnr] and #_G.vim._buffers[bufnr].lines or 0
end

-- Reload the selection module
package.loaded["claudecode.selection"] = nil
selection = require("claudecode.selection")
end)

describe("get_range_selection", function()
it("should return valid selection for valid range", function()
local result = selection.get_range_selection(2, 4)

assert(result ~= nil)
assert(result.text == "line 2\nline 3\nline 4")
assert(result.filePath == "/test/file.lua")
assert(result.fileUrl == "file:///test/file.lua")
assert(result.selection.start.line == 1) -- 0-indexed
assert(result.selection.start.character == 0)
assert(result.selection["end"].line == 3) -- 0-indexed
assert(result.selection["end"].character == 6) -- length of "line 4"
assert(result.selection.isEmpty == false)
end)

it("should return valid selection for single line range", function()
local result = selection.get_range_selection(3, 3)

assert(result ~= nil)
assert(result.text == "line 3")
assert(result.selection.start.line == 2) -- 0-indexed
assert(result.selection["end"].line == 2) -- 0-indexed
assert(result.selection.isEmpty == false)
end)

it("should handle range that exceeds buffer bounds", function()
local result = selection.get_range_selection(8, 15) -- buffer only has 10 lines

assert(result ~= nil)
assert(result.text == "line 8\nline 9\nline 10")
assert(result.selection.start.line == 7) -- 0-indexed
assert(result.selection["end"].line == 9) -- 0-indexed, clamped to buffer size
end)

it("should return nil for invalid range (line1 > line2)", function()
local result = selection.get_range_selection(5, 3)
assert(result == nil)
end)

it("should return nil for invalid range (line1 < 1)", function()
local result = selection.get_range_selection(0, 3)
assert(result == nil)
end)

it("should return nil for invalid range (line2 < 1)", function()
local result = selection.get_range_selection(2, 0)
assert(result == nil)
end)

it("should return nil for nil parameters", function()
local result1 = selection.get_range_selection(nil, 3)
local result2 = selection.get_range_selection(2, nil)
local result3 = selection.get_range_selection(nil, nil)

assert(result1 == nil)
assert(result2 == nil)
assert(result3 == nil)
end)

it("should handle empty buffer", function()
_G.vim._buffers[1].lines = {}
local result = selection.get_range_selection(1, 1)
assert(result == nil)
end)
end)

describe("send_at_mention_for_visual_selection with range", function()
local mock_server

before_each(function()
mock_server = {
broadcast = function(event, params)
mock_server.last_broadcast = {
event = event,
params = params,
}
return true
end,
}

selection.state.tracking_enabled = true
selection.server = mock_server
end)

it("should send range selection successfully", function()
local result = selection.send_at_mention_for_visual_selection(2, 4)

assert(result == true)
assert(mock_server.last_broadcast ~= nil)
assert(mock_server.last_broadcast.event == "at_mentioned")
assert(mock_server.last_broadcast.params.filePath == "/test/file.lua")
assert(mock_server.last_broadcast.params.lineStart == 1) -- 0-indexed
assert(mock_server.last_broadcast.params.lineEnd == 3) -- 0-indexed
end)

it("should fail for invalid range", function()
local result = selection.send_at_mention_for_visual_selection(5, 3)
assert(result == false)
end)

it("should fall back to existing logic when no range provided", function()
-- Set up a tracked selection
selection.state.latest_selection = {
text = "tracked text",
filePath = "/test/file.lua",
fileUrl = "file:///test/file.lua",
selection = {
start = { line = 0, character = 0 },
["end"] = { line = 0, character = 12 },
isEmpty = false,
},
}

local result = selection.send_at_mention_for_visual_selection()

assert(result == true)
assert(mock_server.last_broadcast.params.lineStart == 0)
assert(mock_server.last_broadcast.params.lineEnd == 0)
end)

it("should fail when server is not available", function()
selection.server = nil
local result = selection.send_at_mention_for_visual_selection(2, 4)
assert(result == false)
end)

it("should fail when tracking is disabled", function()
selection.state.tracking_enabled = false
local result = selection.send_at_mention_for_visual_selection(2, 4)
assert(result == false)
end)
end)
end)
Loading