Skip to content

Commit 72a4a41

Browse files
Merge pull request #26 from coder/fix-visual-selection-range
1 parent b822036 commit 72a4a41

File tree

5 files changed

+564
-32
lines changed

5 files changed

+564
-32
lines changed

lua/claudecode/init.lua

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,11 @@ function M.setup(opts)
102102
-- even if terminal_opts (for split_side etc.) are not provided.
103103
local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal")
104104
if terminal_setup_ok then
105-
-- terminal_opts might be nil if user only configured top-level terminal_cmd
106-
-- and not specific terminal appearance options.
107-
-- The terminal.setup function handles nil for its first argument.
108-
terminal_module.setup(terminal_opts, M.state.config.terminal_cmd)
105+
-- Guard in case tests or user replace the module with a minimal stub without `setup`.
106+
if type(terminal_module.setup) == "function" then
107+
-- terminal_opts might be nil, which the setup function should handle gracefully.
108+
terminal_module.setup(terminal_opts, M.state.config.terminal_cmd)
109+
end
109110
else
110111
logger.error("init", "Failed to load claudecode.terminal module for setup.")
111112
end
@@ -403,8 +404,8 @@ function M._create_commands()
403404
return
404405
end
405406

406-
local current_ft = vim.bo.filetype
407-
local current_bufname = vim.api.nvim_buf_get_name(0)
407+
local current_ft = (vim.bo and vim.bo.filetype) or ""
408+
local current_bufname = (vim.api and vim.api.nvim_buf_get_name and vim.api.nvim_buf_get_name(0)) or ""
408409

409410
local is_tree_buffer = current_ft == "NvimTree"
410411
or current_ft == "neo-tree"
@@ -434,14 +435,23 @@ function M._create_commands()
434435

435436
local selection_module_ok, selection_module = pcall(require, "claudecode.selection")
436437
if selection_module_ok then
437-
local sent_successfully = selection_module.send_at_mention_for_visual_selection()
438+
-- Pass range information if available (for :'<,'> commands)
439+
local line1, line2 = nil, nil
440+
if opts and opts.range and opts.range > 0 then
441+
line1, line2 = opts.line1, opts.line2
442+
end
443+
local sent_successfully = selection_module.send_at_mention_for_visual_selection(line1, line2)
438444
if sent_successfully then
445+
-- Exit any potential visual mode (for consistency) and focus Claude terminal
446+
pcall(function()
447+
if vim.api and vim.api.nvim_feedkeys then
448+
local esc = vim.api.nvim_replace_termcodes("<Esc>", true, false, true)
449+
vim.api.nvim_feedkeys(esc, "i", true)
450+
end
451+
end)
439452
local terminal_ok, terminal = pcall(require, "claudecode.terminal")
440453
if terminal_ok then
441454
terminal.open({})
442-
logger.debug("command", "ClaudeCodeSend: Focused Claude Code terminal after selection send.")
443-
else
444-
logger.warn("command", "ClaudeCodeSend: Failed to load terminal module for focusing.")
445455
end
446456
end
447457
else

lua/claudecode/selection.lua

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -573,25 +573,91 @@ function M.send_current_selection()
573573
vim.api.nvim_echo({ { "Selection sent to Claude", "Normal" } }, false, {})
574574
end
575575

576+
--- Gets selection from range marks (e.g., when using :'<,'> commands)
577+
-- @param line1 number The start line (1-indexed)
578+
-- @param line2 number The end line (1-indexed)
579+
-- @return table|nil A table containing selection text, file path, URL, and
580+
-- start/end positions, or nil if invalid range
581+
function M.get_range_selection(line1, line2)
582+
if not line1 or not line2 or line1 < 1 or line2 < 1 or line1 > line2 then
583+
return nil
584+
end
585+
586+
local current_buf = vim.api.nvim_get_current_buf()
587+
local file_path = vim.api.nvim_buf_get_name(current_buf)
588+
589+
-- Get the total number of lines in the buffer
590+
local total_lines = vim.api.nvim_buf_line_count(current_buf)
591+
592+
-- Ensure line2 doesn't exceed buffer bounds
593+
if line2 > total_lines then
594+
line2 = total_lines
595+
end
596+
597+
local lines_content = vim.api.nvim_buf_get_lines(
598+
current_buf,
599+
line1 - 1, -- Convert to 0-indexed
600+
line2, -- nvim_buf_get_lines end is exclusive
601+
false
602+
)
603+
604+
if #lines_content == 0 then
605+
return nil
606+
end
607+
608+
local final_text = table.concat(lines_content, "\n")
609+
610+
-- For range selections, we treat them as linewise
611+
local lsp_start_line = line1 - 1 -- Convert to 0-indexed
612+
local lsp_end_line = line2 - 1
613+
local lsp_start_char = 0
614+
local lsp_end_char = #lines_content[#lines_content]
615+
616+
return {
617+
text = final_text or "",
618+
filePath = file_path,
619+
fileUrl = "file://" .. file_path,
620+
selection = {
621+
start = { line = lsp_start_line, character = lsp_start_char },
622+
["end"] = { line = lsp_end_line, character = lsp_end_char },
623+
isEmpty = (not final_text or #final_text == 0),
624+
},
625+
}
626+
end
627+
576628
--- Sends an at_mentioned notification for the current visual selection.
577-
function M.send_at_mention_for_visual_selection()
629+
-- @param line1 number|nil Optional start line for range-based selection
630+
-- @param line2 number|nil Optional end line for range-based selection
631+
function M.send_at_mention_for_visual_selection(line1, line2)
578632
if not M.state.tracking_enabled or not M.server then
579633
logger.error("selection", "Claude Code is not running or server not available for send_at_mention.")
580634
return false
581635
end
582636

583-
local sel_to_send = M.state.latest_selection
637+
local sel_to_send
584638

585-
if not sel_to_send or sel_to_send.selection.isEmpty then
586-
-- Fallback: try to get current visual selection directly.
587-
-- This helps if latest_selection was demoted or command was too fast.
588-
local current_visual = M.get_visual_selection()
589-
if current_visual and not current_visual.selection.isEmpty then
590-
sel_to_send = current_visual
591-
else
592-
logger.warn("selection", "No visual selection to send as at-mention.")
639+
-- If range parameters are provided, use them (for :'<,'> commands)
640+
if line1 and line2 then
641+
sel_to_send = M.get_range_selection(line1, line2)
642+
if not sel_to_send or sel_to_send.selection.isEmpty then
643+
logger.warn("selection", "Invalid range selection to send as at-mention.")
593644
return false
594645
end
646+
else
647+
-- Use existing logic for visual mode or tracked selection
648+
sel_to_send = M.state.latest_selection
649+
650+
if not sel_to_send or sel_to_send.selection.isEmpty then
651+
-- Fallback: try to get current visual selection directly.
652+
-- This helps if latest_selection was demoted or command was too fast.
653+
local current_visual = M.get_visual_selection()
654+
if current_visual and not current_visual.selection.isEmpty then
655+
sel_to_send = current_visual
656+
else
657+
logger.warn("selection", "No visual selection to send as at-mention.")
658+
return false
659+
end
660+
end
595661
end
596662

597663
-- Sanity check: ensure the selection is for the current buffer

lua/claudecode/visual_commands.lua

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@
44
-- @module claudecode.visual_commands
55
local M = {}
66

7+
--- Get current vim mode with fallback for test environments
8+
--- @param full_mode boolean|nil Whether to get full mode info (passed to vim.fn.mode)
9+
--- @return string current_mode The current vim mode
10+
local function get_current_mode(full_mode)
11+
local current_mode = "n" -- Default fallback
12+
13+
pcall(function()
14+
if vim.api and vim.api.nvim_get_mode then
15+
current_mode = vim.api.nvim_get_mode().mode
16+
else
17+
current_mode = vim.fn.mode(full_mode)
18+
end
19+
end)
20+
21+
return current_mode
22+
end
23+
724
-- ESC key constant matching neo-tree's implementation
825
local ESC_KEY
926
local success = pcall(function()
@@ -40,16 +57,7 @@ end
4057
--- @return boolean true if in visual mode, false otherwise
4158
--- @return string|nil error message if not in visual mode
4259
function M.validate_visual_mode()
43-
local current_mode = "n" -- Default fallback
44-
45-
-- Use pcall to handle test environments
46-
local mode_success = pcall(function()
47-
current_mode = vim.api.nvim_get_mode().mode
48-
end)
49-
50-
if not mode_success then
51-
return false, "Cannot determine current mode (test environment)"
52-
end
60+
local current_mode = get_current_mode(true)
5361

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

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

8492
if is_visual then
@@ -177,7 +185,7 @@ end
177185
--- @return function The wrapped command function
178186
function M.create_visual_command_wrapper(normal_handler, visual_handler)
179187
return function(...)
180-
local current_mode = vim.api.nvim_get_mode().mode
188+
local current_mode = get_current_mode(true)
181189

182190
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then
183191
-- Use the neo-tree pattern: exit visual mode, then schedule execution

tests/selection_test.lua

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,177 @@ describe("Selection module", function()
454454
assert(selection.has_selection_changed(new_selection_diff_pos) == true)
455455
end)
456456
end)
457+
458+
-- Tests for range selection functionality (fix for issue #25)
459+
describe("Range Selection Tests", function()
460+
local selection
461+
462+
before_each(function()
463+
-- Reset vim state
464+
_G.vim._buffers = {
465+
[1] = {
466+
name = "/test/file.lua",
467+
lines = {
468+
"line 1",
469+
"line 2",
470+
"line 3",
471+
"line 4",
472+
"line 5",
473+
"line 6",
474+
"line 7",
475+
"line 8",
476+
"line 9",
477+
"line 10",
478+
},
479+
},
480+
}
481+
_G.vim._windows = {
482+
[1] = {
483+
cursor = { 1, 0 },
484+
},
485+
}
486+
_G.vim._current_mode = "n"
487+
488+
-- Add nvim_buf_line_count function
489+
_G.vim.api.nvim_buf_line_count = function(bufnr)
490+
return _G.vim._buffers[bufnr] and #_G.vim._buffers[bufnr].lines or 0
491+
end
492+
493+
-- Reload the selection module
494+
package.loaded["claudecode.selection"] = nil
495+
selection = require("claudecode.selection")
496+
end)
497+
498+
describe("get_range_selection", function()
499+
it("should return valid selection for valid range", function()
500+
local result = selection.get_range_selection(2, 4)
501+
502+
assert(result ~= nil)
503+
assert(result.text == "line 2\nline 3\nline 4")
504+
assert(result.filePath == "/test/file.lua")
505+
assert(result.fileUrl == "file:///test/file.lua")
506+
assert(result.selection.start.line == 1) -- 0-indexed
507+
assert(result.selection.start.character == 0)
508+
assert(result.selection["end"].line == 3) -- 0-indexed
509+
assert(result.selection["end"].character == 6) -- length of "line 4"
510+
assert(result.selection.isEmpty == false)
511+
end)
512+
513+
it("should return valid selection for single line range", function()
514+
local result = selection.get_range_selection(3, 3)
515+
516+
assert(result ~= nil)
517+
assert(result.text == "line 3")
518+
assert(result.selection.start.line == 2) -- 0-indexed
519+
assert(result.selection["end"].line == 2) -- 0-indexed
520+
assert(result.selection.isEmpty == false)
521+
end)
522+
523+
it("should handle range that exceeds buffer bounds", function()
524+
local result = selection.get_range_selection(8, 15) -- buffer only has 10 lines
525+
526+
assert(result ~= nil)
527+
assert(result.text == "line 8\nline 9\nline 10")
528+
assert(result.selection.start.line == 7) -- 0-indexed
529+
assert(result.selection["end"].line == 9) -- 0-indexed, clamped to buffer size
530+
end)
531+
532+
it("should return nil for invalid range (line1 > line2)", function()
533+
local result = selection.get_range_selection(5, 3)
534+
assert(result == nil)
535+
end)
536+
537+
it("should return nil for invalid range (line1 < 1)", function()
538+
local result = selection.get_range_selection(0, 3)
539+
assert(result == nil)
540+
end)
541+
542+
it("should return nil for invalid range (line2 < 1)", function()
543+
local result = selection.get_range_selection(2, 0)
544+
assert(result == nil)
545+
end)
546+
547+
it("should return nil for nil parameters", function()
548+
local result1 = selection.get_range_selection(nil, 3)
549+
local result2 = selection.get_range_selection(2, nil)
550+
local result3 = selection.get_range_selection(nil, nil)
551+
552+
assert(result1 == nil)
553+
assert(result2 == nil)
554+
assert(result3 == nil)
555+
end)
556+
557+
it("should handle empty buffer", function()
558+
_G.vim._buffers[1].lines = {}
559+
local result = selection.get_range_selection(1, 1)
560+
assert(result == nil)
561+
end)
562+
end)
563+
564+
describe("send_at_mention_for_visual_selection with range", function()
565+
local mock_server
566+
567+
before_each(function()
568+
mock_server = {
569+
broadcast = function(event, params)
570+
mock_server.last_broadcast = {
571+
event = event,
572+
params = params,
573+
}
574+
return true
575+
end,
576+
}
577+
578+
selection.state.tracking_enabled = true
579+
selection.server = mock_server
580+
end)
581+
582+
it("should send range selection successfully", function()
583+
local result = selection.send_at_mention_for_visual_selection(2, 4)
584+
585+
assert(result == true)
586+
assert(mock_server.last_broadcast ~= nil)
587+
assert(mock_server.last_broadcast.event == "at_mentioned")
588+
assert(mock_server.last_broadcast.params.filePath == "/test/file.lua")
589+
assert(mock_server.last_broadcast.params.lineStart == 1) -- 0-indexed
590+
assert(mock_server.last_broadcast.params.lineEnd == 3) -- 0-indexed
591+
end)
592+
593+
it("should fail for invalid range", function()
594+
local result = selection.send_at_mention_for_visual_selection(5, 3)
595+
assert(result == false)
596+
end)
597+
598+
it("should fall back to existing logic when no range provided", function()
599+
-- Set up a tracked selection
600+
selection.state.latest_selection = {
601+
text = "tracked text",
602+
filePath = "/test/file.lua",
603+
fileUrl = "file:///test/file.lua",
604+
selection = {
605+
start = { line = 0, character = 0 },
606+
["end"] = { line = 0, character = 12 },
607+
isEmpty = false,
608+
},
609+
}
610+
611+
local result = selection.send_at_mention_for_visual_selection()
612+
613+
assert(result == true)
614+
assert(mock_server.last_broadcast.params.lineStart == 0)
615+
assert(mock_server.last_broadcast.params.lineEnd == 0)
616+
end)
617+
618+
it("should fail when server is not available", function()
619+
selection.server = nil
620+
local result = selection.send_at_mention_for_visual_selection(2, 4)
621+
assert(result == false)
622+
end)
623+
624+
it("should fail when tracking is disabled", function()
625+
selection.state.tracking_enabled = false
626+
local result = selection.send_at_mention_for_visual_selection(2, 4)
627+
assert(result == false)
628+
end)
629+
end)
630+
end)

0 commit comments

Comments
 (0)