Skip to content

Commit f1cad48

Browse files
committed
feat(selection): add configurable visual selection demotion delay
Change-Id: I920b058004b12f130d1ce05a0c0dea58069e122d Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent a4b9a68 commit f1cad48

File tree

5 files changed

+191
-5
lines changed

5 files changed

+191
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Testing framework with Busted (55 tests passing)
2828
- Development environment with Nix flakes
2929
- Comprehensive luacheck linting with zero warnings
30+
- **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.
3031

3132
### Changed
3233

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ require("claudecode").setup({
162162
-- Enable sending selection updates to Claude
163163
track_selection = true,
164164

165+
-- Milliseconds to wait before "demoting" a visual selection to a cursor/file selection
166+
-- when exiting visual mode. This helps preserve visual context if quickly switching
167+
-- to the Claude terminal. (Default: 50)
168+
visual_demotion_delay_ms = 50,
169+
165170
-- Configuration for the interactive terminal (passed to claudecode.terminal.setup by the main setup function)
166171
terminal = {
167172
-- Side for the vertical split ('left' or 'right')

lua/claudecode/config.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ M.defaults = {
88
terminal_cmd = nil,
99
log_level = "info",
1010
track_selection = true,
11+
visual_demotion_delay_ms = 50, -- Milliseconds to wait before demoting a visual selection
1112
}
1213

1314
--- Validates the provided configuration table.
@@ -42,6 +43,11 @@ function M.validate(config)
4243

4344
assert(type(config.track_selection) == "boolean", "track_selection must be a boolean")
4445

46+
assert(
47+
type(config.visual_demotion_delay_ms) == "number" and config.visual_demotion_delay_ms >= 0,
48+
"visual_demotion_delay_ms must be a non-negative number"
49+
)
50+
4551
return true
4652
end
4753

lua/claudecode/selection.lua

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@
66
-- @module claudecode.selection
77
local M = {}
88

9+
local config_module = require("claudecode.config")
10+
local terminal = require("claudecode.terminal")
11+
912
-- Selection state
1013
M.state = {
1114
latest_selection = nil,
1215
tracking_enabled = false,
1316
debounce_timer = nil,
1417
debounce_ms = 300, -- Default debounce time in milliseconds
18+
19+
-- New state for delayed visual demotion
20+
last_active_visual_selection = nil, -- Stores { bufnr, selection_data, timestamp }
21+
demotion_timer = nil, -- Timer object for visual demotion delay
22+
visual_demotion_delay_ms = 50, -- Default, will be overridden by config in M.enable
1523
}
1624

1725
--- Enables selection tracking.
@@ -25,6 +33,11 @@ function M.enable(server)
2533
M.state.tracking_enabled = true
2634
M.server = server
2735

36+
-- Get the full configuration to access visual_demotion_delay_ms
37+
local user_config = vim.g.claudecode_user_config or {}
38+
local full_config = config_module.apply(user_config)
39+
M.state.visual_demotion_delay_ms = full_config.visual_demotion_delay_ms
40+
2841
M._create_autocommands()
2942
end
3043

@@ -124,26 +137,170 @@ function M.update_selection()
124137
return
125138
end
126139

127-
local current_mode = vim.api.nvim_get_mode().mode
140+
local current_buf = vim.api.nvim_get_current_buf() -- Get current buffer early
141+
142+
-- If the current buffer is the Claude terminal, do not update selection
143+
if terminal then
144+
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
145+
if claude_term_bufnr and current_buf == claude_term_bufnr then
146+
-- Cancel any pending demotion if we switch to the Claude terminal
147+
if M.state.demotion_timer then
148+
M.state.demotion_timer:stop()
149+
M.state.demotion_timer:close()
150+
M.state.demotion_timer = nil
151+
end
152+
return
153+
end
154+
end
155+
156+
local current_mode_info = vim.api.nvim_get_mode()
157+
local current_mode = current_mode_info.mode
158+
local current_selection -- This will be the candidate for M.state.latest_selection
159+
160+
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then -- Visual modes
161+
-- If a new visual selection is made, cancel any pending demotion
162+
if M.state.demotion_timer then
163+
M.state.demotion_timer:stop()
164+
M.state.demotion_timer:close()
165+
M.state.demotion_timer = nil
166+
end
128167

129-
local current_selection
130-
if current_mode == "v" or current_mode == "V" or current_mode == "\022" then
131168
current_selection = M.get_visual_selection()
132-
else
169+
170+
if current_selection then
171+
M.state.last_active_visual_selection = {
172+
bufnr = current_buf,
173+
selection_data = vim.deepcopy(current_selection), -- Store a copy
174+
timestamp = vim.loop.now(),
175+
}
176+
else
177+
-- No valid visual selection (e.g., get_visual_selection returned nil)
178+
-- Clear last_active_visual if it was for this buffer
179+
if M.state.last_active_visual_selection and M.state.last_active_visual_selection.bufnr == current_buf then
180+
M.state.last_active_visual_selection = nil
181+
end
182+
end
183+
else -- Not in visual mode
184+
local last_visual = M.state.last_active_visual_selection
185+
186+
if M.state.demotion_timer then
187+
-- A demotion is already pending. For this specific update_selection call (e.g. cursor moved),
188+
-- current_selection reflects the immediate cursor position.
189+
-- M.state.latest_selection (the one that might be sent) is still the visual one until timer resolves.
190+
current_selection = M.get_cursor_position()
191+
elseif
192+
last_visual
193+
and last_visual.bufnr == current_buf
194+
and last_visual.selection_data
195+
and not last_visual.selection_data.selection.isEmpty
196+
then
197+
-- We just exited visual mode in this buffer, and no demotion timer is running for it.
198+
-- Keep M.state.latest_selection as is (it's the visual one from the previous update).
199+
-- The 'current_selection' for comparison should also be this visual one.
200+
current_selection = M.state.latest_selection -- This should hold the visual selection
201+
202+
if M.state.demotion_timer then -- Should not happen due to elseif, but as safeguard
203+
M.state.demotion_timer:stop()
204+
M.state.demotion_timer:close()
205+
end
206+
M.state.demotion_timer = vim.loop.new_timer()
207+
M.state.demotion_timer:start(
208+
M.state.visual_demotion_delay_ms,
209+
0, -- 0 repeat = one-shot
210+
vim.schedule_wrap(function()
211+
if M.state.demotion_timer then -- Check if it wasn't cancelled right before firing
212+
M.state.demotion_timer:stop() -- Ensure it's stopped
213+
M.state.demotion_timer:close()
214+
M.state.demotion_timer = nil
215+
end
216+
M.handle_selection_demotion(current_buf) -- Pass buffer at time of scheduling
217+
end)
218+
)
219+
else
220+
-- Genuinely in normal mode, no recent visual exit, no pending demotion.
221+
current_selection = M.get_cursor_position()
222+
if last_visual and last_visual.bufnr == current_buf then
223+
M.state.last_active_visual_selection = nil -- Clear it as it's no longer relevant for demotion
224+
end
225+
end
226+
end
227+
228+
-- If current_selection could not be determined (e.g. get_visual_selection was nil and no other path set it)
229+
-- default to cursor position to avoid errors.
230+
if not current_selection then
133231
current_selection = M.get_cursor_position()
134232
end
135233

136234
local changed = M.has_selection_changed(current_selection)
137235

138236
if changed then
139237
M.state.latest_selection = current_selection
140-
141238
if M.server then
142239
M.send_selection_update(current_selection)
143240
end
144241
end
145242
end
146243

244+
--- Handles the demotion of a visual selection after a delay.
245+
-- Called by the demotion_timer.
246+
-- @param original_bufnr_when_scheduled number The buffer number that was active when demotion was scheduled.
247+
function M.handle_selection_demotion(original_bufnr_when_scheduled)
248+
-- Timer object is already stopped and cleared by its own callback wrapper or cancellation points.
249+
-- M.state.demotion_timer should be nil here if it fired normally or was cancelled.
250+
251+
local current_buf = vim.api.nvim_get_current_buf()
252+
local claude_term_bufnr = terminal.get_active_terminal_bufnr()
253+
254+
-- Condition 1: Switched to Claude Terminal
255+
if claude_term_bufnr and current_buf == claude_term_bufnr then
256+
-- Visual selection is preserved (M.state.latest_selection is still the visual one).
257+
-- The "pending" status of last_active_visual_selection is resolved.
258+
if
259+
M.state.last_active_visual_selection
260+
and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled
261+
then
262+
M.state.last_active_visual_selection = nil
263+
end
264+
return
265+
end
266+
267+
local current_mode_info = vim.api.nvim_get_mode()
268+
-- Condition 2: Back in Visual Mode in the Original Buffer
269+
if
270+
current_buf == original_bufnr_when_scheduled
271+
and (current_mode_info.mode == "v" or current_mode_info.mode == "V" or current_mode_info.mode == "\022")
272+
then
273+
-- A new visual selection will take precedence. M.state.latest_selection will be updated by main flow.
274+
if
275+
M.state.last_active_visual_selection
276+
and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled
277+
then
278+
M.state.last_active_visual_selection = nil
279+
end
280+
return
281+
end
282+
283+
-- Condition 3: Still in Original Buffer & Not Visual & Not Claude Term -> Demote
284+
if current_buf == original_bufnr_when_scheduled then
285+
local new_sel_for_demotion = M.get_cursor_position() -- Demote to current cursor position
286+
-- Check if this new cursor position is actually different from the (visual) latest_selection
287+
if M.has_selection_changed(new_sel_for_demotion) then
288+
M.state.latest_selection = new_sel_for_demotion
289+
if M.server then
290+
M.send_selection_update(M.state.latest_selection)
291+
end
292+
end
293+
end
294+
295+
-- Always clear last_active_visual_selection for the original buffer as its pending demotion is resolved.
296+
if
297+
M.state.last_active_visual_selection
298+
and M.state.last_active_visual_selection.bufnr == original_bufnr_when_scheduled
299+
then
300+
M.state.last_active_visual_selection = nil
301+
end
302+
end
303+
147304
--- Validates if we're in a valid visual selection mode
148305
-- @return boolean, string|nil - true if valid, false and error message if not
149306
local function validate_visual_mode()

lua/claudecode/terminal.lua

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,4 +420,21 @@ function M._get_managed_terminal_for_test()
420420
return managed_snacks_terminal
421421
end
422422

423+
--- Gets the buffer number of the currently active Claude Code terminal.
424+
-- This checks both Snacks and native fallback terminals.
425+
-- @return number|nil The buffer number if an active terminal is found, otherwise nil.
426+
function M.get_active_terminal_bufnr()
427+
if managed_snacks_terminal and managed_snacks_terminal:valid() and managed_snacks_terminal.buf then
428+
if vim.api.nvim_buf_is_valid(managed_snacks_terminal.buf) then
429+
return managed_snacks_terminal.buf
430+
end
431+
end
432+
433+
if is_fallback_terminal_valid() then -- This checks bufnr and winid validity
434+
return managed_fallback_terminal_bufnr
435+
end
436+
437+
return nil
438+
end
439+
423440
return M

0 commit comments

Comments
 (0)