Skip to content

Commit 041385e

Browse files
bassamsdataBassam Data
andauthored
Improve NES Suggestion Clearing with Smarter Cursor-Aware Behavior (#37)
* feat: intelligent mechanism to handle clearing suggestion see #36 * docs: update Readme to include the new command * fix: fix unused variable to satisfy luacheck * tests: Adding cursor-aware suggestion tests * feat: Adding new unified counter for clearing nes * tests: Fixing tests for new clearing nes * fix: Adding buffer validations to prevent errors * docs: update readme with default config * chore: clear unused variable * fix: correct variable name * chore: making opts inside nes table * fix: satisfy the stylua test * feat: Add type annotations * docs: make nes options private --------- Co-authored-by: Bassam Data <[email protected]>
1 parent a45b3d9 commit 041385e

13 files changed

+643
-5
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,36 @@ return {
3939
}
4040
```
4141

42+
43+
#### Clearing suggestions with Escape
44+
45+
You can map the `<Esc>` key to clear suggestions while preserving its other functionality:
46+
47+
```lua
48+
-- Clear copilot suggestion with Esc if visible, otherwise preserve default Esc behavior
49+
vim.keymap.set("n", "<esc>", function()
50+
if not require('copilot-lsp.nes').clear() then
51+
-- fallback to other functionality
52+
end
53+
end, { desc = "Clear Copilot suggestion or fallback" })
54+
```
55+
56+
## Default Configuration
57+
58+
59+
### NES (Next Edit Suggestion) Smart Clearing
60+
You don’t need to configure anything, but you can customize the defaults:
61+
`move_count_threshold` is the most important. It controls how many cursor moves happen before suggestions are cleared. Higher = slower to clear.
62+
63+
```lua
64+
require('copilot-lsp').setup({
65+
nes = {
66+
move_count_threshold = 3, -- Clear after 3 cursor movements
67+
}
68+
})
69+
```
70+
71+
4272
### Blink Integration
4373

4474
```lua

lua/copilot-lsp/config.lua

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---@class copilotlsp.config.nes
2+
---@field move_count_threshold integer Number of cursor movements before clearing suggestion
3+
---@field distance_threshold integer Maximum line distance before clearing suggestion
4+
---@field clear_on_large_distance boolean Whether to clear suggestion when cursor is far away
5+
---@field count_horizontal_moves boolean Whether to count horizontal cursor movements
6+
---@field reset_on_approaching boolean Whether to reset counter when approaching suggestion
7+
8+
local M = {}
9+
10+
---@class copilotlsp.config
11+
---@field nes copilotlsp.config.nes
12+
M.defaults = {
13+
nes = {
14+
move_count_threshold = 3,
15+
distance_threshold = 40,
16+
clear_on_large_distance = true,
17+
count_horizontal_moves = true,
18+
reset_on_approaching = true,
19+
},
20+
}
21+
22+
---@type copilotlsp.config
23+
M.config = vim.deepcopy(M.defaults)
24+
25+
---@param opts? copilotlsp.config configuration to merge with defaults
26+
function M.setup(opts)
27+
opts = opts or {}
28+
M.config = vim.tbl_deep_extend("force", M.defaults, opts)
29+
end
30+
31+
return M

lua/copilot-lsp/init.lua

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
local config = require("copilot-lsp.config")
2+
3+
---@class copilotlsp
4+
---@field defaults copilotlsp.config
5+
---@field config copilotlsp.config
6+
---@field setup fun(opts?: copilotlsp.config): nil
7+
local M = {}
8+
9+
M.defaults = config.defaults
10+
M.config = config.config
11+
12+
---@param opts? copilotlsp.config configuration to merge with defaults
13+
function M.setup(opts)
14+
config.setup(opts)
15+
M.config = config.config
16+
end
17+
18+
return M

lua/copilot-lsp/nes/init.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ local function handle_nes_response(err, result, ctx)
1414
-- vim.notify(err.message)
1515
return
1616
end
17+
-- Validate buffer still exists before processing response
18+
if not vim.api.nvim_buf_is_valid(ctx.bufnr) then
19+
return
20+
end
1721
for _, edit in ipairs(result.edits) do
1822
--- Convert to textEdit fields
1923
edit.newText = edit.text
@@ -119,4 +123,16 @@ function M.clear_suggestion(bufnr)
119123
nes_ui.clear_suggestion(bufnr, nes_ns)
120124
end
121125

126+
--- Clear the current suggestion if it exists
127+
---@return boolean -- true if a suggestion was cleared, false if no suggestion existed
128+
function M.clear()
129+
local buf = vim.api.nvim_get_current_buf()
130+
if vim.b[buf].nes_state then
131+
local ns = vim.b[buf].copilotlsp_nes_namespace_id or nes_ns
132+
nes_ui.clear_suggestion(buf, ns)
133+
return true
134+
end
135+
return false
136+
end
137+
122138
return M

lua/copilot-lsp/nes/ui.lua

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local M = {}
2+
local config = require("copilot-lsp.config").config
23

34
---@param bufnr integer
45
---@param ns_id integer
@@ -10,6 +11,10 @@ end
1011
---@param ns_id integer
1112
function M.clear_suggestion(bufnr, ns_id)
1213
bufnr = bufnr and bufnr > 0 and bufnr or vim.api.nvim_get_current_buf()
14+
-- Validate buffer exists before accessing buffer-scoped variables
15+
if not vim.api.nvim_buf_is_valid(bufnr) then
16+
return
17+
end
1318
if vim.b[bufnr].nes_jump then
1419
vim.b[bufnr].nes_jump = false
1520
return
@@ -21,7 +26,11 @@ function M.clear_suggestion(bufnr, ns_id)
2126
return
2227
end
2328

29+
-- Clear buffer variables
2430
vim.b[bufnr].nes_state = nil
31+
vim.b[bufnr].copilotlsp_nes_cursor_moves = nil
32+
vim.b[bufnr].copilotlsp_nes_last_line = nil
33+
vim.b[bufnr].copilotlsp_nes_last_col = nil
2534
end
2635

2736
---@private
@@ -159,26 +168,110 @@ end
159168
function M._display_next_suggestion(bufnr, ns_id, edits)
160169
M.clear_suggestion(bufnr, ns_id)
161170
if not edits or #edits == 0 then
162-
-- vim.notify("No suggestion available", vim.log.levels.INFO)
163171
return
164172
end
165173

166174
local suggestion = edits[1]
167-
168175
local preview = M._calculate_preview(bufnr, suggestion)
169176
M._display_preview(bufnr, ns_id, preview)
170177

171178
vim.b[bufnr].nes_state = suggestion
179+
vim.b[bufnr].copilotlsp_nes_namespace_id = ns_id
180+
vim.b[bufnr].copilotlsp_nes_cursor_moves = 1
172181

173182
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
174183
buffer = bufnr,
175184
callback = function()
176-
if not vim.b.nes_state then
185+
if not vim.b[bufnr].nes_state then
186+
return true
187+
end
188+
189+
-- Get cursor position
190+
local cursor = vim.api.nvim_win_get_cursor(0)
191+
local cursor_line = cursor[1] - 1 -- 0-indexed
192+
local cursor_col = cursor[2]
193+
local suggestion_line = suggestion.range.start.line
194+
195+
-- Store previous position
196+
local last_line = vim.b[bufnr].copilotlsp_nes_last_line or cursor_line
197+
local last_col = vim.b[bufnr].copilotlsp_nes_last_col or cursor_col
198+
199+
-- Update stored position
200+
vim.b[bufnr].copilotlsp_nes_last_line = cursor_line
201+
vim.b[bufnr].copilotlsp_nes_last_col = cursor_col
202+
203+
-- Calculate distance to suggestion
204+
local line_distance = math.abs(cursor_line - suggestion_line)
205+
local last_line_distance = math.abs(last_line - suggestion_line)
206+
207+
-- Check if cursor changed position on same line
208+
local moved_horizontally = (cursor_line == last_line) and (cursor_col ~= last_col)
209+
210+
-- Get current mode
211+
local mode = vim.api.nvim_get_mode().mode
212+
213+
-- Determine if we should count this movement
214+
local should_count = false
215+
local first_char = mode:sub(1, 1)
216+
217+
-- In insert mode, only count cursor movements, not text changes
218+
if first_char == "i" then
219+
if moved_horizontally or line_distance ~= last_line_distance then
220+
should_count = true
221+
end
222+
elseif first_char == "v" or first_char == "V" or mode == "\22" then
223+
should_count = true
224+
-- In normal mode with horizontal movement
225+
elseif moved_horizontally and config.nes.count_horizontal_moves then
226+
should_count = true
227+
-- In normal mode with line changes
228+
elseif line_distance > last_line_distance then
229+
should_count = true
230+
-- Moving toward suggestion in normal mode
231+
elseif line_distance < last_line_distance and config.nes.reset_on_approaching then
232+
if line_distance > 1 then -- Don't reset if 0 or 1 line away
233+
vim.b[bufnr].copilotlsp_nes_cursor_moves = 0
234+
end
235+
end
236+
237+
-- Update counter if needed
238+
if should_count then
239+
vim.b[bufnr].copilotlsp_nes_cursor_moves = (vim.b[bufnr].copilotlsp_nes_cursor_moves or 0) + 1
240+
end
241+
242+
-- Clear if counter threshold reached
243+
if vim.b[bufnr].copilotlsp_nes_cursor_moves >= config.nes.move_count_threshold then
244+
vim.b[bufnr].copilotlsp_nes_cursor_moves = 0
245+
vim.schedule(function()
246+
M.clear_suggestion(bufnr, ns_id)
247+
end)
177248
return true
178249
end
179250

180-
M.clear_suggestion(bufnr, ns_id)
181-
return true
251+
-- Optional: Clear on large distance
252+
if config.nes.clear_on_large_distance and line_distance > config.nes.distance_threshold then
253+
M.clear_suggestion(bufnr, ns_id)
254+
return true
255+
end
256+
257+
return false -- Keep the autocmd
258+
end,
259+
})
260+
-- Also clear on text changes that affect the suggestion area
261+
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
262+
buffer = bufnr,
263+
callback = function()
264+
if not vim.b[bufnr].nes_state then
265+
return true
266+
end
267+
-- Check if the text at the suggestion position has changed
268+
local start_line = suggestion.range.start.line
269+
-- If the lines are no longer in the buffer, clear the suggestion
270+
if start_line >= vim.api.nvim_buf_line_count(bufnr) then
271+
M.clear_suggestion(bufnr, ns_id)
272+
return true
273+
end
274+
return false -- Keep the autocmd
182275
end,
183276
})
184277
end

tests/nes/test_ui_preview.lua

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,97 @@ do
258258
end
259259
end
260260

261+
T["ui_preview"]["cursor_aware_suggestion_clearing"] = function()
262+
set_content("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8")
263+
ref(child.get_screenshot())
264+
265+
-- Create a suggestion at line 3
266+
local edit = {
267+
range = {
268+
start = { line = 2, character = 0 },
269+
["end"] = { line = 2, character = 0 },
270+
},
271+
newText = "suggested text ",
272+
}
273+
274+
-- Display suggestion
275+
child.g.test_edit = edit
276+
child.lua_func(function()
277+
local ns_id = vim.api.nvim_create_namespace("nes_test")
278+
local edits = { vim.g.test_edit }
279+
require("copilot-lsp.nes.ui")._display_next_suggestion(0, ns_id, edits)
280+
end)
281+
ref(child.get_screenshot())
282+
283+
-- Test 1: Moving cursor nearby shouldn't clear the suggestion (within counter threshold)
284+
child.cmd("normal! gg") -- Move to first line (counter: 1 -> 2)
285+
child.cmd("normal! j") -- Move to line 2 (counter: 2 -> 3)
286+
child.lua_func(function()
287+
vim.uv.sleep(500) -- Give time for autocmd to process
288+
end)
289+
290+
-- Verify suggestion still exists (should be at threshold but not exceeded)
291+
local suggestion_exists = child.lua_func(function()
292+
return vim.b[0].nes_state ~= nil
293+
end)
294+
eq(suggestion_exists, true)
295+
ref(child.get_screenshot())
296+
297+
-- Test 2: One more move should clear the suggestion (exceeds counter threshold)
298+
child.cmd("normal! j")
299+
child.cmd("normal! j")
300+
child.cmd("normal! j")
301+
child.cmd("normal! j")
302+
child.cmd("normal! j")
303+
child.lua_func(function()
304+
vim.uv.sleep(500) -- Give time for autocmd to process
305+
end)
306+
307+
-- Verify suggestion is cleared
308+
local suggestion_cleared = child.lua_func(function()
309+
return vim.b[0].nes_state == nil
310+
end)
311+
eq(suggestion_cleared, true)
312+
ref(child.get_screenshot())
313+
end
314+
315+
T["ui_preview"]["suggestion_preserves_on_movement_towards"] = function()
316+
set_content("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8")
317+
ref(child.get_screenshot())
318+
319+
-- Position cursor at line 8
320+
child.cmd("normal! gg7j")
321+
322+
-- Create a suggestion at line 3
323+
local edit = {
324+
range = {
325+
start = { line = 2, character = 0 },
326+
["end"] = { line = 2, character = 0 },
327+
},
328+
newText = "suggested text ",
329+
}
330+
331+
-- Display suggestion
332+
child.g.test_edit = edit
333+
child.lua_func(function()
334+
local ns_id = vim.api.nvim_create_namespace("nes_test")
335+
local edits = { vim.g.test_edit }
336+
require("copilot-lsp.nes.ui")._display_next_suggestion(0, ns_id, edits)
337+
end)
338+
ref(child.get_screenshot())
339+
340+
-- Test: Moving cursor towards the suggestion (even outside buffer zone) shouldn't clear it
341+
child.cmd("normal! 4k") -- Move to line 4, moving towards the suggestion
342+
child.lua_func(function()
343+
vim.uv.sleep(500)
344+
end)
345+
346+
-- Verify suggestion still exists
347+
local suggestion_exists = child.lua_func(function()
348+
return vim.b[0].nes_state ~= nil
349+
end)
350+
eq(suggestion_exists, true)
351+
ref(child.get_screenshot())
352+
end
353+
261354
return T

0 commit comments

Comments
 (0)