-
Notifications
You must be signed in to change notification settings - Fork 187
Expand file tree
/
Copy pathselection.lua
More file actions
749 lines (628 loc) · 23.7 KB
/
selection.lua
File metadata and controls
749 lines (628 loc) · 23.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
---Manages selection tracking and communication with the Claude server.
---@module 'claudecode.selection'
local M = {}
local logger = require("claudecode.logger")
local terminal = require("claudecode.terminal")
local uv = vim.uv or vim.loop
M.state = {
latest_selection = nil,
tracking_enabled = false,
debounce_timer = nil,
debounce_ms = 100,
last_active_visual_selection = nil,
demotion_timer = nil,
visual_demotion_delay_ms = 50,
}
---Enables selection tracking.
---@param server table The server object to use for communication.
---@param visual_demotion_delay_ms number The delay for visual selection demotion.
function M.enable(server, visual_demotion_delay_ms)
if M.state.tracking_enabled then
return
end
M.state.tracking_enabled = true
M.server = server
M.state.visual_demotion_delay_ms = visual_demotion_delay_ms
M._create_autocommands()
end
---Disables selection tracking.
---Clears autocommands, resets internal state, and stops any active debounce or
---demotion timers.
function M.disable()
if not M.state.tracking_enabled then
return
end
M.state.tracking_enabled = false
M._clear_autocommands()
M.state.latest_selection = nil
M.state.last_active_visual_selection = nil
M.server = nil
M._cancel_debounce_timer()
M._cancel_demotion_timer()
end
---Cancels and closes the current debounce timer, if any.
---@local
function M._cancel_debounce_timer()
local timer = M.state.debounce_timer
if not timer then
return
end
-- Clear state before stopping/closing so any already-scheduled callback is a no-op.
M.state.debounce_timer = nil
timer:stop()
timer:close()
end
---Cancels and closes the current demotion timer, if any.
---@local
function M._cancel_demotion_timer()
local timer = M.state.demotion_timer
if not timer then
return
end
-- Clear state before stopping/closing so any already-scheduled callback is a no-op.
M.state.demotion_timer = nil
timer:stop()
timer:close()
end
---Creates autocommands for tracking selections.
---Sets up listeners for CursorMoved, CursorMovedI, BufEnter, ModeChanged, and TextChanged events.
---@local
function M._create_autocommands()
local group = vim.api.nvim_create_augroup("ClaudeCodeSelection", { clear = true })
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "BufEnter" }, {
group = group,
callback = function()
M.on_cursor_moved()
end,
})
vim.api.nvim_create_autocmd("ModeChanged", {
group = group,
callback = function()
M.on_mode_changed()
end,
})
vim.api.nvim_create_autocmd("TextChanged", {
group = group,
callback = function()
M.on_text_changed()
end,
})
end
---Clears the autocommands related to selection tracking.
---@local
function M._clear_autocommands()
vim.api.nvim_clear_autocmds({ group = "ClaudeCodeSelection" })
end
---Handles cursor movement events.
---Triggers a debounced update of the selection.
function M.on_cursor_moved()
M.debounce_update()
end
---Handles mode change events.
---Triggers an immediate update of the selection.
function M.on_mode_changed()
M.debounce_update()
end
---Handles text change events.
---Triggers a debounced update of the selection.
function M.on_text_changed()
M.debounce_update()
end
---Debounces selection updates.
---Ensures that `update_selection` is not called too frequently by deferring
---its execution.
function M.debounce_update()
M._cancel_debounce_timer()
assert(type(M.state.debounce_ms) == "number", "Expected debounce_ms to be a number")
local timer = uv.new_timer()
assert(timer, "Expected uv.new_timer() to return a timer handle")
assert(timer.start, "Expected debounce timer to have :start()")
assert(timer.stop, "Expected debounce timer to have :stop()")
assert(timer.close, "Expected debounce timer to have :close()")
M.state.debounce_timer = timer
timer:start(
M.state.debounce_ms,
0, -- 0 repeat = one-shot
vim.schedule_wrap(function()
-- Ignore stale timers (e.g., cancelled and replaced before callback runs)
if M.state.debounce_timer ~= timer then
return
end
-- Clear state so _cancel_debounce_timer() is a no-op if called after firing.
M.state.debounce_timer = nil
timer:stop()
timer:close()
M.update_selection()
end)
)
end
---Updates the current selection state.
---Determines the current selection based on the editor mode (visual or normal)
---and sends an update to the server if the selection has changed.
function M.update_selection()
if not M.state.tracking_enabled then
return
end
local current_buf = vim.api.nvim_get_current_buf()
local buf_name = vim.api.nvim_buf_get_name(current_buf)
-- If the buffer name starts with "term://" and contains "claude", do not update selection
if buf_name and buf_name:match("^term://") and buf_name:lower():find("claude", 1, true) then
-- Optionally, cancel demotion timer like for the terminal
M._cancel_demotion_timer()
return
end
-- If the current buffer is the Claude terminal, do not update selection
if terminal then
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
if claude_term_bufnr and current_buf == claude_term_bufnr then
-- Cancel any pending demotion if we switch to the Claude terminal
M._cancel_demotion_timer()
return
end
end
local current_mode_info = vim.api.nvim_get_mode()
local current_mode = current_mode_info.mode
local current_selection
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then
-- If a new visual selection is made, cancel any pending demotion
M._cancel_demotion_timer()
current_selection = M.get_visual_selection()
if current_selection then
M.state.last_active_visual_selection = {
bufnr = current_buf,
selection_data = vim.deepcopy(current_selection), -- Store a copy
timestamp = vim.loop.now(),
}
else
-- No valid visual selection (e.g., get_visual_selection returned nil)
-- Clear last_active_visual if it was for this buffer
if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == current_buf then
M.state.last_active_visual_selection = nil
end
end
else
local last_visual = M.state.last_active_visual_selection
if M.state.demotion_timer then
-- A demotion is already pending. For this specific update_selection call (e.g. cursor moved),
-- current_selection reflects the immediate cursor position.
-- M.state.latest_selection (the one that might be sent) is still the visual one until timer resolves.
current_selection = M.get_cursor_position()
elseif
last_visual
and last_visual.bufnr == current_buf
and last_visual.selection_data
and not last_visual.selection_data.selection.isEmpty
then
-- We just exited visual mode in this buffer, and no demotion timer is running for it.
-- Keep M.state.latest_selection as is (it's the visual one from the previous update).
-- The 'current_selection' for comparison should also be this visual one.
current_selection = M.state.latest_selection
local timer = uv.new_timer()
assert(timer, "Expected uv.new_timer() to return a timer handle")
M.state.demotion_timer = timer
timer:start(
M.state.visual_demotion_delay_ms,
0, -- 0 repeat = one-shot
vim.schedule_wrap(function()
-- Ignore stale timers (e.g., cancelled and replaced before callback runs)
if M.state.demotion_timer ~= timer then
return
end
-- Clear state so _cancel_demotion_timer() is a no-op if called after firing.
M.state.demotion_timer = nil
timer:stop()
timer:close()
M.handle_selection_demotion(current_buf) -- Pass buffer at time of scheduling
end)
)
else
-- Genuinely in normal mode, no recent visual exit, no pending demotion.
current_selection = M.get_cursor_position()
if last_visual and last_visual.bufnr == current_buf then
M.state.last_active_visual_selection = nil -- Clear it as it's no longer relevant for demotion
end
end
end
-- If current_selection could not be determined (e.g. get_visual_selection was nil and no other path set it)
-- default to cursor position to avoid errors.
if not current_selection then
current_selection = M.get_cursor_position()
end
local changed = M.has_selection_changed(current_selection)
if changed then
M.state.latest_selection = current_selection
if M.server then
M.send_selection_update(current_selection)
end
end
end
---Handles the demotion of a visual selection after a delay.
---Called by the demotion_timer.
---@param original_bufnr_when_scheduled number The buffer number that was active when demotion was scheduled.
function M.handle_selection_demotion(original_bufnr_when_scheduled)
-- Timer object is already stopped and cleared by its own callback wrapper or cancellation points.
-- M.state.demotion_timer should be nil here if it fired normally or was cancelled.
if not M.state.tracking_enabled then
return
end
local current_buf = vim.api.nvim_get_current_buf()
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
-- Condition 1: Switched to Claude Terminal
if claude_term_bufnr and current_buf == claude_term_bufnr then
-- Visual selection is preserved (M.state.latest_selection is still the visual one).
-- The "pending" status of last_active_visual_selection is resolved.
if
M.state.last_active_visual_selection
and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled
then
M.state.last_active_visual_selection = nil
end
return
end
local current_mode_info = vim.api.nvim_get_mode()
-- Condition 2: Back in Visual Mode in the Original Buffer
if
current_buf == original_bufnr_when_scheduled
and (current_mode_info.mode == "v" or current_mode_info.mode == "V" or current_mode_info.mode == "\022")
then
-- A new visual selection will take precedence. M.state.latest_selection will be updated by main flow.
if
M.state.last_active_visual_selection
and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled
then
M.state.last_active_visual_selection = nil
end
return
end
-- Condition 3: Still in Original Buffer & Not Visual & Not Claude Term -> Demote
if current_buf == original_bufnr_when_scheduled then
local new_sel_for_demotion = M.get_cursor_position()
-- Check if this new cursor position is actually different from the (visual) latest_selection
if M.has_selection_changed(new_sel_for_demotion) then
M.state.latest_selection = new_sel_for_demotion
if M.server then
M.send_selection_update(M.state.latest_selection)
end
end
-- No change detected in selection
end
-- User switched to different buffer
-- Always clear last_active_visual_selection for the original buffer as its pending demotion is resolved.
if
M.state.last_active_visual_selection
and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled
then
M.state.last_active_visual_selection = nil
end
end
---Validates if we're in a valid visual selection mode
---@return boolean valid, string? error - true if valid, false and error message if not
local function validate_visual_mode()
local current_nvim_mode = vim.api.nvim_get_mode().mode
local fixed_anchor_pos_raw = vim.fn.getpos("v")
if not (current_nvim_mode == "v" or current_nvim_mode == "V" or current_nvim_mode == "\22") then
return false, "not in visual mode"
end
if fixed_anchor_pos_raw[2] == 0 then
return false, "no visual selection mark"
end
return true, nil
end
---Determines the effective visual mode character
---@return string|nil - the visual mode character or nil if invalid
local function get_effective_visual_mode()
local current_nvim_mode = vim.api.nvim_get_mode().mode
local visual_fn_mode_char = vim.fn.visualmode()
if visual_fn_mode_char and visual_fn_mode_char ~= "" then
return visual_fn_mode_char
end
-- Fallback to current mode
if current_nvim_mode == "V" then
return "V"
elseif current_nvim_mode == "v" then
return "v"
elseif current_nvim_mode == "\22" then -- Ctrl-V, blockwise
return "\22"
end
return nil
end
---Gets the start and end coordinates of the visual selection
---@return table, table - start_coords and end_coords with lnum and col fields
local function get_selection_coordinates()
local fixed_anchor_pos_raw = vim.fn.getpos("v")
local current_cursor_nvim = vim.api.nvim_win_get_cursor(0)
-- Convert to 1-indexed line and 1-indexed column for consistency
local p1 = { lnum = fixed_anchor_pos_raw[2], col = fixed_anchor_pos_raw[3] }
local p2 = { lnum = current_cursor_nvim[1], col = current_cursor_nvim[2] + 1 }
-- Determine chronological start/end based on line, then column
if p1.lnum < p2.lnum or (p1.lnum == p2.lnum and p1.col <= p2.col) then
return p1, p2
else
return p2, p1
end
end
---Extracts text for linewise visual selection
---@param lines_content table - array of line strings
---@param start_coords table - start coordinates
---@return string text - the extracted text
local function extract_linewise_text(lines_content, start_coords)
start_coords.col = 1 -- Linewise selection effectively starts at column 1
return table.concat(lines_content, "\n")
end
---Extracts text for characterwise visual selection
---@param lines_content table - array of line strings
---@param start_coords table - start coordinates
---@param end_coords table - end coordinates
---@return string|nil text - the extracted text or nil if invalid
local function extract_characterwise_text(lines_content, start_coords, end_coords)
if start_coords.lnum == end_coords.lnum then
if not lines_content[1] then
return nil
end
return string.sub(lines_content[1], start_coords.col, end_coords.col)
else
if not lines_content[1] or not lines_content[#lines_content] then
return nil
end
local text_parts = {}
table.insert(text_parts, string.sub(lines_content[1], start_coords.col))
for i = 2, #lines_content - 1 do
table.insert(text_parts, lines_content[i])
end
table.insert(text_parts, string.sub(lines_content[#lines_content], 1, end_coords.col))
return table.concat(text_parts, "\n")
end
end
---Calculates LSP-compatible position coordinates
---@param start_coords table - start coordinates
---@param end_coords table - end coordinates
---@param visual_mode string - the visual mode character
---@param lines_content table - array of line strings
---@return table position - LSP position object with start and end fields
local function calculate_lsp_positions(start_coords, end_coords, visual_mode, lines_content)
local lsp_start_line = start_coords.lnum - 1
local lsp_end_line = end_coords.lnum - 1
local lsp_start_char, lsp_end_char
if visual_mode == "V" then
lsp_start_char = 0 -- Linewise selection always starts at character 0
-- For linewise, LSP end char is length of the last selected line
if #lines_content > 0 and lines_content[#lines_content] then
lsp_end_char = #lines_content[#lines_content]
else
lsp_end_char = 0
end
else
lsp_start_char = start_coords.col - 1
lsp_end_char = end_coords.col
end
return {
start = { line = lsp_start_line, character = lsp_start_char },
["end"] = { line = lsp_end_line, character = lsp_end_char },
}
end
---Gets the current visual selection details.
---@return table|nil selection A table containing selection text, file path, URL, and
---start/end positions, or nil if no visual selection exists.
function M.get_visual_selection()
local valid = validate_visual_mode()
if not valid then
return nil
end
local visual_mode = get_effective_visual_mode()
if not visual_mode then
return nil
end
local start_coords, end_coords = get_selection_coordinates()
local current_buf = vim.api.nvim_get_current_buf()
local file_path = vim.api.nvim_buf_get_name(current_buf)
local lines_content = vim.api.nvim_buf_get_lines(
current_buf,
start_coords.lnum - 1, -- Convert to 0-indexed
end_coords.lnum, -- nvim_buf_get_lines end is exclusive
false
)
if #lines_content == 0 then
return nil
end
local final_text
if visual_mode == "V" then
final_text = extract_linewise_text(lines_content, start_coords)
elseif visual_mode == "v" or visual_mode == "\22" then
final_text = extract_characterwise_text(lines_content, start_coords, end_coords)
if not final_text then
return nil
end
else
return nil
end
local lsp_positions = calculate_lsp_positions(start_coords, end_coords, visual_mode, lines_content)
return {
text = final_text or "",
filePath = file_path,
fileUrl = "file://" .. file_path,
selection = {
start = lsp_positions.start,
["end"] = lsp_positions["end"],
isEmpty = (not final_text or #final_text == 0),
},
}
end
---Gets the current cursor position when no visual selection is active.
---@return table A table containing an empty text, file path, URL, and cursor
---position as start/end, with isEmpty set to true.
function M.get_cursor_position()
local cursor_pos = vim.api.nvim_win_get_cursor(0)
local current_buf = vim.api.nvim_get_current_buf()
local file_path = vim.api.nvim_buf_get_name(current_buf)
return {
text = "",
filePath = file_path,
fileUrl = "file://" .. file_path,
selection = {
start = { line = cursor_pos[1] - 1, character = cursor_pos[2] },
["end"] = { line = cursor_pos[1] - 1, character = cursor_pos[2] },
isEmpty = true,
},
}
end
---Checks if the selection has changed compared to the latest stored selection.
---@param new_selection table|nil The new selection object to compare.
---@return boolean changed true if the selection has changed, false otherwise.
function M.has_selection_changed(new_selection)
local old_selection = M.state.latest_selection
if not new_selection then
return old_selection ~= nil
end
if not old_selection then
return true
end
if old_selection.filePath ~= new_selection.filePath then
return true
end
if old_selection.text ~= new_selection.text then
return true
end
if
old_selection.selection.start.line ~= new_selection.selection.start.line
or old_selection.selection.start.character ~= new_selection.selection.start.character
or old_selection.selection["end"].line ~= new_selection.selection["end"].line
or old_selection.selection["end"].character ~= new_selection.selection["end"].character
then
return true
end
return false
end
---Sends the selection update to the Claude server.
---@param selection table The selection object to send.
function M.send_selection_update(selection)
M.server.broadcast("selection_changed", selection)
end
---Gets the latest recorded selection.
---@return table|nil The latest selection object, or nil if none recorded.
function M.get_latest_selection()
return M.state.latest_selection
end
---Sends the current selection to Claude.
---This function is typically invoked by a user command. It forces an immediate
---update and sends the latest selection.
function M.send_current_selection()
if not M.state.tracking_enabled or not M.server then
logger.error("selection", "Claude Code is not running")
return
end
M.update_selection()
local selection = M.state.latest_selection
if not selection then
logger.error("selection", "No selection available")
return
end
M.send_selection_update(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.
---@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 then
logger.error("selection", "Selection tracking is not enabled.")
return false
end
-- Check if Claude Code integration is running (server may or may not have clients)
local claudecode_main = require("claudecode")
if not claudecode_main.state.server then
logger.error("selection", "Claude Code integration is not running.")
return false
end
local sel_to_send
-- 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
local current_buf_name = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf())
if sel_to_send.filePath ~= current_buf_name then
logger.warn(
"selection",
"Tracked selection is for '"
.. sel_to_send.filePath
.. "', but current buffer is '"
.. current_buf_name
.. "'. Not sending."
)
return false
end
-- Use connection-aware broadcasting from main module
local file_path = sel_to_send.filePath
local start_line = sel_to_send.selection.start.line -- Already 0-indexed from selection module
local end_line = sel_to_send.selection["end"].line -- Already 0-indexed
local success, error_msg = claudecode_main.send_at_mention(file_path, start_line, end_line, "ClaudeCodeSend")
if success then
logger.debug("selection", "Visual selection sent as at-mention.")
return true
else
logger.error("selection", "Failed to send at-mention: " .. (error_msg or "unknown error"))
return false
end
end
return M