Skip to content

Commit 62c9f70

Browse files
committed
feat(at-mention): implement :ClaudeCodeSend command for visual selection notifications
Change-Id: Ibb3d887ee78fc63ed851ecfcf1ef0ad49bbda532 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 66077d6 commit 62c9f70

File tree

12 files changed

+190
-112
lines changed

12 files changed

+190
-112
lines changed

ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ The plugin monitors text selections in Neovim:
103103
- Debounces updates to avoid flooding Claude
104104
- Formats selection data according to MCP protocol
105105
- Sends updates to Claude via WebSocket
106+
- Supports sending `at_mentioned` notifications for visual selections using the `:ClaudeCodeSend` command, providing focused context to Claude.
106107

107108
### 5. Terminal Integration
108109

@@ -166,7 +167,7 @@ The plugin manages the environment for Claude CLI:
166167
User ──► Make Selection in Neovim
167168
Neovim Plugin ──► Detect Selection Change
168169
──► Format Selection Data
169-
──► Send Update to Claude
170+
──► Send Update to Claude (e.g., `selection_changed` or `at_mentioned` via `:ClaudeCodeSend`)
170171
```
171172

172173
## Module Structure

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- Development environment with Nix flakes
2929
- Comprehensive luacheck linting with zero warnings
3030
- **Selection Tracking**: Added a configurable delay (`visual_demotion_delay_ms`) before a visual selection is "demoted" after exiting visual mode. This helps preserve visual context when quickly switching to the Claude terminal.
31+
- **At-Mention Feature**: Implemented the `:ClaudeCodeSend` command to send visual selections as `at_mentioned` notifications to Claude, providing focused code context. This includes updates to selection tracking and server broadcasting logic.
3132

3233
### Changed
3334

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ A Neovim plugin that integrates with Claude Code CLI to provide a seamless AI co
1515
- 📝 Support for file operations and diagnostics
1616
- 🖥️ Interactive vertical split terminal for Claude sessions (supports `folke/snacks.nvim` or native Neovim terminal)
1717
- 🔒 Automatic cleanup on exit - server shutdown and lockfile removal
18+
- 💬 **At-Mentions**: Send visual selections as `at_mentioned` context to Claude using `:'<,'>ClaudeCodeSend`.
1819

1920
## Requirements
2021

@@ -201,13 +202,14 @@ require("claudecode").setup({
201202

202203
2. You can now interact with Claude in the terminal window. To provide code context, you can:
203204

204-
- Make a selection in visual mode (`v` key), then run:
205+
- **Send Visual Selection as At-Mention**: Make a selection in visual mode (`v`, `V`, or `Ctrl-V`), then run:
205206

206-
```
207-
:ClaudeCodeSend
207+
```vim
208+
:'<,'>ClaudeCodeSend
208209
```
209210
210-
- This sends the selected lines with file references to Claude
211+
This sends the selected file path and line range as an `at_mentioned` notification to Claude,
212+
allowing Claude to focus on that specific part of your code.
211213
212214
3. Switch between your code and the Claude terminal:
213215

lua/claudecode/init.lua

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,16 +228,20 @@ function M._create_commands()
228228
desc = "Show Claude Code integration status",
229229
})
230230

231-
vim.api.nvim_create_user_command("ClaudeCodeSend", function()
231+
vim.api.nvim_create_user_command("ClaudeCodeSend", function(opts)
232232
if not M.state.server then
233233
vim.notify("Claude Code integration is not running", vim.log.levels.ERROR)
234234
return
235235
end
236236

237-
local selection = require("claudecode.selection")
238-
selection.send_current_selection()
237+
-- The selection.send_at_mention_for_visual_selection() function itself
238+
-- will check for a valid visual selection and show an error if none exists.
239+
-- This simplifies handling calls from keymaps vs. direct :'<,'>Cmd invocations.
240+
local selection_module = require("claudecode.selection")
241+
selection_module.send_at_mention_for_visual_selection()
239242
end, {
240-
desc = "Send current selection to Claude Code",
243+
desc = "Send current visual selection as an at_mention to Claude Code",
244+
range = true, -- Important: This makes the command expect a range (visual selection)
241245
})
242246
end
243247

lua/claudecode/meta/vim.lua

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
---@class vim_buffer_options_table: table<string, any>
3131

3232
---@class vim_bo_proxy: vim_buffer_options_table
33-
---@operator index (key: number): vim_buffer_options_table # Allows vim.bo[bufnr]
33+
---@operator #index(bufnr: number): vim_buffer_options_table Allows vim.bo[bufnr]
3434

3535
---@class vim_diagnostic_info
3636
---@field bufnr number
@@ -50,12 +50,13 @@
5050
---@class vim_global_api
5151
---@field notify fun(msg: string | string[], level?: number, opts?: vim_notify_opts):nil
5252
---@field log vim_log
53-
---@field _last_echo table[]? # table of tables, e.g. { {"message", "HighlightGroup"} }
53+
---@field _last_echo table[]? table of tables, e.g. { {"message", "HighlightGroup"} }
5454
---@field _last_error string?
55-
---@field o vim_options_table # For vim.o.option_name
56-
---@field bo vim_bo_proxy # For vim.bo.option_name and vim.bo[bufnr].option_name
57-
---@field diagnostic vim_diagnostic_module # For vim.diagnostic.*
58-
---@field empty_dict fun(): table # For vim.empty_dict()
55+
---@field o vim_options_table For vim.o.option_name
56+
---@field bo vim_bo_proxy For vim.bo.option_name and vim.bo[bufnr].option_name
57+
---@field diagnostic vim_diagnostic_module For vim.diagnostic.*
58+
---@field empty_dict fun(): table For vim.empty_dict()
59+
---@field schedule_wrap fun(fn: function): function For vim.schedule_wrap()
5960
-- Add other vim object definitions here if they cause linting issues
6061
-- e.g. vim.fn, vim.api, vim.loop, vim.deepcopy, etc.
6162

lua/claudecode/selection.lua

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ M.state = {
1414
latest_selection = nil,
1515
tracking_enabled = false,
1616
debounce_timer = nil,
17-
debounce_ms = 300, -- Default debounce time in milliseconds
17+
debounce_ms = 300,
1818

1919
-- New state for delayed visual demotion
2020
last_active_visual_selection = nil, -- Stores { bufnr, selection_data, timestamp }
21-
demotion_timer = nil, -- Timer object for visual demotion delay
21+
demotion_timer = nil,
2222
visual_demotion_delay_ms = 50, -- Default, will be overridden by config in M.enable
2323
}
2424

@@ -157,7 +157,7 @@ function M.update_selection()
157157
local current_mode = current_mode_info.mode
158158
local current_selection -- This will be the candidate for M.state.latest_selection
159159

160-
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then -- Visual modes
160+
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then
161161
-- If a new visual selection is made, cancel any pending demotion
162162
if M.state.demotion_timer then
163163
M.state.demotion_timer:stop()
@@ -180,7 +180,7 @@ function M.update_selection()
180180
M.state.last_active_visual_selection = nil
181181
end
182182
end
183-
else -- Not in visual mode
183+
else
184184
local last_visual = M.state.last_active_visual_selection
185185

186186
if M.state.demotion_timer then
@@ -307,12 +307,10 @@ local function validate_visual_mode()
307307
local current_nvim_mode = vim.api.nvim_get_mode().mode
308308
local fixed_anchor_pos_raw = vim.fn.getpos("v")
309309

310-
-- Must be in a visual mode
311310
if not (current_nvim_mode == "v" or current_nvim_mode == "V" or current_nvim_mode == "\22") then
312311
return false, "not in visual mode"
313312
end
314313

315-
-- The 'v' mark must have a non-zero line number
316314
if fixed_anchor_pos_raw[2] == 0 then
317315
return false, "no visual selection mark"
318316
end
@@ -388,13 +386,10 @@ local function extract_characterwise_text(lines_content, start_coords, end_coord
388386
end
389387

390388
local text_parts = {}
391-
-- First line: from start_coords.col to end of line
392389
table.insert(text_parts, string.sub(lines_content[1], start_coords.col))
393-
-- Middle lines (if any)
394390
for i = 2, #lines_content - 1 do
395391
table.insert(text_parts, lines_content[i])
396392
end
397-
-- Last line: from beginning to end_coords.col
398393
table.insert(text_parts, string.sub(lines_content[#lines_content], 1, end_coords.col))
399394
return table.concat(text_parts, "\n")
400395
end
@@ -420,7 +415,6 @@ local function calculate_lsp_positions(start_coords, end_coords, visual_mode, li
420415
lsp_end_char = 0
421416
end
422417
else
423-
-- For characterwise/blockwise
424418
lsp_start_char = start_coords.col - 1
425419
lsp_end_char = end_coords.col
426420
end
@@ -435,26 +429,21 @@ end
435429
-- @return table|nil A table containing selection text, file path, URL, and
436430
-- start/end positions, or nil if no visual selection exists.
437431
function M.get_visual_selection()
438-
-- Validate visual mode
439432
local valid = validate_visual_mode()
440433
if not valid then
441434
return nil
442435
end
443436

444-
-- Get effective visual mode
445437
local visual_mode = get_effective_visual_mode()
446438
if not visual_mode then
447439
return nil
448440
end
449441

450-
-- Get selection coordinates
451442
local start_coords, end_coords = get_selection_coordinates()
452443

453-
-- Get buffer information
454444
local current_buf = vim.api.nvim_get_current_buf()
455445
local file_path = vim.api.nvim_buf_get_name(current_buf)
456446

457-
-- Fetch lines content
458447
local lines_content = vim.api.nvim_buf_get_lines(
459448
current_buf,
460449
start_coords.lnum - 1, -- Convert to 0-indexed
@@ -466,7 +455,6 @@ function M.get_visual_selection()
466455
return nil
467456
end
468457

469-
-- Extract text based on visual mode
470458
local final_text
471459
if visual_mode == "V" then
472460
final_text = extract_linewise_text(lines_content, start_coords)
@@ -479,7 +467,6 @@ function M.get_visual_selection()
479467
return nil
480468
end
481469

482-
-- Calculate LSP positions
483470
local lsp_positions = calculate_lsp_positions(start_coords, end_coords, visual_mode, lines_content)
484471

485472
return {
@@ -584,4 +571,45 @@ function M.send_current_selection()
584571
vim.api.nvim_echo({ { "Selection sent to Claude", "Normal" } }, false, {})
585572
end
586573

574+
--- Sends an at_mentioned notification for the current visual selection.
575+
function M.send_at_mention_for_visual_selection()
576+
if not M.state.tracking_enabled or not M.server then
577+
vim.api.nvim_err_writeln("Claude Code is not running or server not available.")
578+
return
579+
end
580+
581+
local visual_sel = M.get_visual_selection()
582+
583+
if not visual_sel or visual_sel.selection.isEmpty then
584+
vim.api.nvim_err_writeln("No visual selection to send as at-mention.")
585+
return
586+
end
587+
588+
-- Pre-calculate line numbers, breaking down access for the linter
589+
local current_selection = visual_sel["selection"]
590+
local start_pos = current_selection["start"]
591+
local end_pos = current_selection["end"]
592+
-- Send 0-indexed LSP line numbers directly, assuming the at_mentioned protocol expects this.
593+
local start_line_val = start_pos["line"]
594+
local end_line_val = end_pos["line"]
595+
596+
local params = {}
597+
params["filePath"] = visual_sel["filePath"]
598+
params["lineStart"] = start_line_val
599+
params["lineEnd"] = end_line_val
600+
601+
-- M.server is set in M.enable() and used by M.send_selection_update()
602+
-- It refers to the server instance from lua/claudecode/server/init.lua
603+
local success = M.server.broadcast("at_mentioned", params)
604+
605+
if success then
606+
vim.api.nvim_echo(
607+
{ { "At-mention sent to Claude for " .. vim.fn.fnamemodify(params.filePath, ":t"), "Normal" } },
608+
false,
609+
{}
610+
)
611+
else
612+
vim.api.nvim_err_writeln("Failed to send at-mention.")
613+
end
614+
end
587615
return M

plugin/claudecode.lua

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,31 @@ if vim.g.loaded_claudecode then
88
end
99
vim.g.loaded_claudecode = 1
1010

11-
-- Example in init.lua: vim.g.claudecode_auto_setup = { auto_start = true }
11+
--- Example: In your `init.lua`, you can set `vim.g.claudecode_auto_setup = { auto_start = true }`
12+
--- to automatically start ClaudeCode when Neovim loads.
1213
if vim.g.claudecode_auto_setup then
1314
vim.defer_fn(function()
1415
require("claudecode").setup(vim.g.claudecode_auto_setup)
1516
end, 0)
1617
end
1718

1819
local terminal_ok, terminal = pcall(require, "claudecode.terminal")
20+
local selection_module_ok, selection = pcall(require, "claudecode.selection")
1921

2022
if terminal_ok then
2123
vim.api.nvim_create_user_command("ClaudeCode", function(_opts)
2224
local current_mode = vim.fn.mode()
23-
if current_mode == "v" or current_mode == "V" or current_mode == "\22" then -- \22 is CTRL-V for blockwise visual
25+
if current_mode == "v" or current_mode == "V" or current_mode == "\22" then -- \22 is CTRL-V (blockwise visual mode)
2426
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "n", false)
2527
end
26-
terminal.toggle({}) -- opts.fargs could be used for future enhancements
28+
terminal.toggle({}) -- `opts.fargs` can be used for future enhancements, e.g., passing initial prompts.
2729
end, {
28-
nargs = "?", -- Allow optional arguments for future enhancements
30+
nargs = "?", -- Allows optional arguments, useful for future command enhancements.
2931
desc = "Toggle the Claude Code terminal window",
3032
})
3133

3234
vim.api.nvim_create_user_command("ClaudeCodeOpen", function(_opts)
33-
terminal.open({}) -- opts.fargs could be used for future enhancements
35+
terminal.open({}) -- `opts.fargs` can be used for future enhancements.
3436
end, {
3537
nargs = "?",
3638
desc = "Open the Claude Code terminal window",
@@ -44,3 +46,19 @@ if terminal_ok then
4446
else
4547
vim.notify("ClaudeCode Terminal module not found. Commands not registered.", vim.log.levels.ERROR)
4648
end
49+
if selection_module_ok then
50+
vim.api.nvim_create_user_command("ClaudeCodeSend", function(opts)
51+
if opts.range == 0 then
52+
vim.api.nvim_err_writeln("ClaudeCodeSend requires a visual selection.")
53+
return
54+
end
55+
-- While `opts.line1` and `opts.line2` provide the selected line range,
56+
-- the `selection` module is preferred for obtaining precise character data if needed.
57+
selection.send_at_mention_for_visual_selection()
58+
end, {
59+
desc = "Send the current visual selection as an at_mention to Claude",
60+
range = true, -- This is crucial for commands that operate on a visual selection (e.g., :'&lt;,'&gt;Cmd).
61+
})
62+
else
63+
vim.notify("ClaudeCode Selection module not found. ClaudeCodeSend command not registered.", vim.log.levels.ERROR)
64+
end

tests/config_test.lua

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
-- Simple config module tests that don't rely on the vim API
22

3-
-- Create minimal vim mock
43
_G.vim = { ---@type vim_global_api
54
deepcopy = function(t)
6-
-- Basic deepcopy for testing
5+
-- Basic deepcopy implementation for testing purposes
76
local copy = {}
87
for k, v in pairs(t) do
98
if type(v) == "table" then
@@ -29,27 +28,21 @@ _G.vim = { ---@type vim_global_api
2928
bo = setmetatable({}, { -- Mock for vim.bo and vim.bo[bufnr]
3029
__index = function(t, k)
3130
if type(k) == "number" then
32-
-- vim.bo[bufnr] accessed, return a new proxy table for this buffer
3331
if not t[k] then
3432
t[k] = {} ---@type vim_buffer_options_table
3533
end
3634
return t[k]
3735
end
38-
-- vim.bo.option_name (global buffer option)
39-
-- Return nil or a default mock value if needed
4036
return nil
4137
end,
4238
__newindex = function(t, k, v)
4339
if type(k) == "number" then
44-
-- vim.bo[bufnr] = val (should not happen for options table itself)
45-
-- or vim.bo[bufnr].opt = val
46-
-- For simplicity, allow setting on the dynamic buffer table
40+
-- For mock simplicity, allows direct setting for vim.bo[bufnr].opt = val or similar assignments.
4741
if not t[k] then
4842
t[k] = {}
4943
end
5044
rawset(t[k], v) -- Assuming v is the option name if k is bufnr, this is simplified
5145
else
52-
-- vim.bo.option_name = val
5346
rawset(t, k, v)
5447
end
5548
end,
@@ -58,7 +51,7 @@ _G.vim = { ---@type vim_global_api
5851
get = function()
5952
return {}
6053
end,
61-
-- Add other vim.diagnostic functions as needed for tests
54+
-- Add other vim.diagnostic functions as needed for these tests
6255
},
6356
empty_dict = function()
6457
return {}
@@ -85,12 +78,10 @@ _G.vim = { ---@type vim_global_api
8578
describe("Config module", function()
8679
local config
8780

88-
-- Set up before each test
8981
setup(function()
90-
-- Reset the module
82+
-- Reset the module to ensure a clean state for each test
9183
package.loaded["claudecode.config"] = nil
9284

93-
-- Load module
9485
config = require("claudecode.config")
9586
end)
9687

@@ -111,6 +102,7 @@ describe("Config module", function()
111102
terminal_cmd = "toggleterm",
112103
log_level = "debug",
113104
track_selection = false,
105+
visual_demotion_delay_ms = 50,
114106
}
115107

116108
local success, _ = pcall(function()

0 commit comments

Comments
 (0)