Skip to content

Commit 934b84d

Browse files
committed
feat: <Esc> dismisses, and it counts
There was no discoverable way to say no. `<C-]>` existed and was bound, but it appeared in exactly one un-highlighted README table row, never beside a suggestion, and users concluded dismissal was unimplemented. The fix is not a new keymap. `<Esc>` already dismissed — InsertLeave clears the suggestion — it just did so silently, filing nothing, so the identical edit came straight back on re-entering insert. Now InsertLeave records the rejection, and the hint advertises the key that was working all along: ⟪neocursor · <Tab> accept · <Esc> dismiss⟫ No insert-mode <Esc> mapping is installed, deliberately. In VS Code, Escape is a free key; in neovim it is the mode transition, and a conditional Esc would make macros non-deterministic (whether a ghost is showing depends on a debounced network reply), would clobber im-select and snippet mappings, and has no clean fallthrough — feedkeys with "n" skips the mapping the user actually wanted, with "m" re-enters ours. Advertising behaviour that already exists costs none of that. <C-]> stays as the dismiss-without-leaving-insert variant; it is the key copilot.vim, copilot.lua and avante.nvim all use. This also happens to be what Cursor does under a vim layer. Their handleKeyDownForCppKeys is a raw document keydown listener, not a registered keybinding, and the suggestion branch never calls preventDefault — only the auto-import branch does. So in Cursor + vscode-neovim, Escape dismisses the suggestion AND propagates to neovim, which leaves insert. Both happen. Their tiering collapses to one tier for vim users by accident. Dismissal is tiered where it can be. Cursor's first Escape files the suggestion as rejected and deliberately keeps the jump target alive (their handler calls maybeShowHintLineWidget immediately after); only a second press, with nothing showing, reaches clearCursorPrediction. reject_suggestion() and reject_prediction() split accordingly, and <C-]> walks them in that order. Collapsing the two — which the first draft of this change did — mutes jump targets the user never said no to, for the 30s pred_rejects TTL, after two ordinary trips through normal mode. flow_spec round 4 caught it. Ports Cursor's hasRejectedTooManySuggestions, the churn gate the per-suggestion ledger cannot express: HEURISTIC_SUGGESTING_RECENTLY_REJECTED_EDIT catches the model repeating itself, this catches it being wrong in a new way every time. Past the budget the passive triggers stop firing — entering insert, moving to another line — while typing still asks. That split is Cursor's: their content-change and linter-error paths never consult the gate. Accepting anything refills it, as does leaving the buffer (their onDidBlurEditorText; merely leaving insert is not the analogue, that happens constantly here). One deliberate divergence. Cursor increments on every clearSuggestions(); this counts dismissals instead. Their suggestion survives typing via isOnShortestEditPath, ours only does for inline ghosts — a diff is dropped and refetched per keystroke, so counting clears would mute after ~20 characters rather than ~20 ignored suggestions. maxNumberOfClearedSuggestionsSinceLastAccept now comes down with the rest of CppConfig; the live backend returns 20, which is what the hardcoded default was set to independently. :NeocursorLog shows the tally and flips to MUTED at the threshold.
1 parent 19044b3 commit 934b84d

4 files changed

Lines changed: 114 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ Type, pause, and a suggestion appears. Then:
9999
|---|---|
100100
| `<Tab>` | Accept · or **jump** to the predicted next edit · or chain to the next one |
101101
| `<M-Right>` | Accept the suggestion word-by-word |
102-
| `<C-]>` | Dismiss |
102+
| `<Esc>` | Dismiss — leaving insert mode discards the suggestion and tells the model no |
103+
| `<C-]>` | Dismiss **without leaving insert**, for when you want to keep typing |
103104
| `:NeocursorSuggest` | Force a request right now |
104105
| `:NeocursorLog` | Toggle the live state dashboard |
105106
| `:NeocursorDebug` | Print diagnostics |
@@ -124,6 +125,24 @@ The loop back through *jump → accept* is what makes it feel like Cursor rather
124125
than a completion engine: you keep pressing the same key and the edits come to
125126
you.
126127

128+
### Saying no
129+
130+
`<Esc>` is the dismiss key, and it needs no mapping — neocursor never touches
131+
`<Esc>`, so your macros, your snippet plugin and your IME switcher all keep it.
132+
Leaving insert mode already discards the suggestion; what neocursor adds is that
133+
it *counts*, so the same rejected edit isn't offered straight back. This is also
134+
exactly what Cursor does under a vim layer: its Escape handler dismisses the
135+
suggestion and lets the keypress through, so the mode change happens too.
136+
137+
Dismissing is tiered, like Cursor's. The first dismiss clears the edit and
138+
**keeps** the jump target; dismiss again with nothing showing and the jump target
139+
goes too.
140+
141+
Keep refusing and neocursor takes the hint: after 20 dismissals with nothing
142+
accepted in between, it stops volunteering on the passive triggers — entering
143+
insert, moving to another line. Typing still asks, and accepting anything (or
144+
switching buffers) clears the count. `:NeocursorLog` shows the tally live.
145+
127146
---
128147

129148
## Configuration

‎docs/configuration.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ purely what you see.
9494

9595
| Surface | Looks like | Marks | Hiding it costs |
9696
|---|---|---|---|
97-
| `edit` | `⟪neocursor · <Tab> accept⟫` | a pending edit | nothing — the diff beside it already shows the change |
97+
| `edit` | `⟪neocursor · <Tab> accept · <Esc> dismiss⟫` | a pending edit | the diff still shows the change, but `<Esc>` stops advertising itself |
9898
| `prediction` | `⟪<Tab> → L42⟫` | a jump target | the only on-screen sign a jump is queued |
9999

100100
```lua
@@ -109,10 +109,11 @@ An omitted key in the table form defaults to visible, so `{ edit = false }` and
109109

110110
### Before you hide the prediction pill
111111

112-
The two surfaces are not symmetrical. The `edit` label is decoration — the diff
113-
underneath already tells you what will happen. The `prediction` pill is the
114-
*only* indication that a jump is queued; hide it and `<Tab>` will still jump,
115-
you just won't know where until it lands.
112+
The two surfaces are not symmetrical. The `edit` label is mostly decoration —
113+
the diff underneath already tells you what will happen, though the label is also
114+
where `<Esc> dismiss` is advertised. The `prediction` pill is the *only*
115+
indication that a jump is queued; hide it and `<Tab>` will still jump, you just
116+
won't know where until it lands.
116117

117118
If you want a quieter buffer without losing that, hide the label and keep the
118119
pill:

‎lua/neocursor/init.lua‎

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ local state = {
2828
dbase = {}, -- [bufnr] = { path, text } baseline snapshot for diffing
2929
dtraj = {}, -- [path] = { {diff, ts}, ... } committed edit trajectory
3030
rejects = {}, -- [key] = times the user dismissed this exact suggestion
31+
dismissed = 0, -- dismissals since the last accept (Cursor:
32+
-- numberOfClearedSuggestionsSinceLastAccept — see the note
33+
-- on rejected_too_many for why we count dismissals, not clears)
3134
log = {}, -- ring buffer of event strings for :NeocursorLog
3235
log_buf = nil,
3336
log_dirty = false,
@@ -150,7 +153,10 @@ local function log_refresh()
150153
(" chain %-16s heur %d · excl %d · fused %s"):format(chain,
151154
c.heuristics and #c.heuristics or 0, c.exclude_patterns and #c.exclude_patterns or 0, fused),
152155
(" predict %-16s guard(seen) %s"):format(pred, seen),
153-
(" err %s · suppress %s"):format(tostring(state.last_error or "none"), tostring(state.last_suppressed or "none")),
156+
(" err %s · suppress %s · dismissed %d/%s%s"):format(
157+
tostring(state.last_error or "none"), tostring(state.last_suppressed or "none"),
158+
state.dismissed, tostring(c.max_cleared or 20),
159+
state.dismissed > (c.max_cleared or 20) and " MUTED (accept one to resume)" or ""),
154160
"├─ log ─ newest first " .. string.rep("─", 37) .. "┤",
155161
}
156162
for i = #state.log, 1, -1 do lines[#lines + 1] = state.log[i] end
@@ -182,6 +188,7 @@ local function apply_config(cfg)
182188
if type(cfg.exclude_patterns) == "table" then state.cfg.exclude_patterns = cfg.exclude_patterns end
183189
if type(cfg.heuristics) == "table" then state.cfg.heuristics = cfg.heuristics end
184190
if type(cfg.reject_hard) == "number" then state.cfg.reject_hard = cfg.reject_hard end
191+
if type(cfg.max_cleared) == "number" then state.cfg.max_cleared = cfg.max_cleared end
185192
if type(cfg.is_fused) == "boolean" then state.cfg.is_fused = cfg.is_fused end
186193
log(("CONFIG debounce=%sms heuristics=%d excludes=%d"):format(
187194
state.cfg.debounce, #state.cfg.heuristics, #state.cfg.exclude_patterns))
@@ -249,7 +256,12 @@ local function show_edit(edit)
249256
preview.inline(bufnr, row1 - 1, col0, ghost)
250257
else
251258
local at = cursor_at(start0, end0_excl)
252-
local label = hints().edit and (at and "<Tab> accept" or "<Tab> jump") or nil
259+
-- Advertise <Esc>, not <C-]>: leaving insert already dismisses (InsertLeave
260+
-- below files the rejection), so the label is true without mapping a key —
261+
-- and it names the one key every neovim user presses without thinking.
262+
local label = hints().edit
263+
and ((at and "<Tab> accept" or "<Tab> jump") .. " · <Esc> dismiss")
264+
or nil
253265
preview.diff(bufnr, start0, cur_range, lines, label)
254266
end
255267
log(("SHOW %-6s L%d (%d ln)"):format(mode, start0 + 1, #lines))
@@ -260,6 +272,9 @@ end
260272
-- no network. Adjust the line numbers of edits below by the applied line delta,
261273
-- jump the cursor there, and render it. This is the "tab, tab, tab" loop.
262274
local function advance_after_apply(applied)
275+
-- Every accept path lands here — Tab, word-at-a-time, and typing the ghost
276+
-- out in full — so this is the one place the churn budget refills.
277+
state.dismissed = 0
263278
local q = state.queue
264279
if not q then return end
265280
local delta = #applied.lines - (applied.end0_excl - applied.start0)
@@ -330,6 +345,52 @@ local function pred_recently_rejected(p)
330345
return r ~= nil and r.count >= 2
331346
end
332347

348+
-- Cursor's hasRejectedTooManySuggestions. The per-suggestion ledger above only
349+
-- catches the model REPEATING itself; this catches it being wrong in a new way
350+
-- every time. Past the budget, stop volunteering — but only on the passive
351+
-- triggers (entering insert, moving to another line). Typing still asks, which
352+
-- is Cursor's split too: their content-change and linter-error paths never
353+
-- consult this gate. Reset by accepting anything, or by leaving the buffer.
354+
--
355+
-- Cursor counts every clearSuggestions(); we count dismissals instead. Their
356+
-- suggestion survives typing (isOnShortestEditPath), ours only survives it for
357+
-- inline ghosts — a diff is dropped and refetched on each keystroke, so
358+
-- counting clears here would mute after ~20 characters rather than ~20 ignored
359+
-- suggestions. Same intent, adjusted for where the two renderers differ.
360+
local function rejected_too_many()
361+
return state.dismissed > ((state.cfg and state.cfg.max_cleared) or 20)
362+
end
363+
364+
-- Cursor's Escape is TIERED, and the tiers matter: their first press files the
365+
-- suggestion as rejected and deliberately keeps the jump target alive (the
366+
-- handler calls maybeShowHintLineWidget right after); only a second press, with
367+
-- nothing showing, reaches clearCursorPrediction. Collapsing the two would mute
368+
-- jump targets the user never actually said no to.
369+
--
370+
-- Tier 1 — file the visible edit as rejected and clear it. Returns false when
371+
-- there was nothing to reject.
372+
local function reject_suggestion()
373+
local s = state.suggestion
374+
if not s then return false end
375+
local key = reject_key(buf_relpath(s.bufnr) or "", s)
376+
state.rejects[key] = (state.rejects[key] or 0) + 1
377+
state.dismissed = state.dismissed + 1
378+
log(("DISMISS L%d (rejected ×%d · %d/%s before mute)"):format(
379+
s.start0 + 1, state.rejects[key], state.dismissed,
380+
tostring((state.cfg and state.cfg.max_cleared) or 20)))
381+
clear_suggestion()
382+
return true
383+
end
384+
385+
-- Tier 2 — reject the jump target itself (30s TTL, muted at 2).
386+
local function reject_prediction(bufnr)
387+
if not state.prediction then return false end
388+
record_pred_reject(state.prediction)
389+
state.prediction = nil
390+
preview.clear_prediction(bufnr or 0)
391+
return true
392+
end
393+
333394
-- Paint the "Tab →" hint at the prediction target (Cursor's hint widget).
334395
-- Returns false when there is nothing worth jumping to (no prediction, or it
335396
-- points at the line the cursor is already on).
@@ -998,19 +1059,15 @@ function M.has_prediction() return state.prediction ~= nil end
9981059
-- exposed for test/hints_spec.lua; pure, no state
9991060
M._normalize_hints = normalize_hints
10001061

1062+
-- Dismiss without leaving insert mode. <Esc> does the same thing and then exits
1063+
-- insert; this is the variant for when you want to keep typing. <C-]> is the
1064+
-- key copilot.vim, copilot.lua and avante.nvim all use for it.
10011065
function M.dismiss()
1002-
local s = state.suggestion
1003-
if s then
1004-
local key = reject_key(vim.fn.expand("%:."), s)
1005-
state.rejects[key] = (state.rejects[key] or 0) + 1
1006-
log(("DISMISS L%d (rejected ×%d)"):format(s.start0 + 1, state.rejects[key]))
1007-
end
1008-
if state.prediction then
1009-
record_pred_reject(state.prediction) -- muted after 2 rejections within 30s
1010-
state.prediction = nil
1011-
preview.clear_prediction(0)
1012-
end
1013-
clear_suggestion()
1066+
local bufnr = vim.api.nvim_get_current_buf()
1067+
-- tier 1 first, tier 2 only when there was no edit to dismiss — press it
1068+
-- twice to clear an edit and then its jump target, exactly like Cursor.
1069+
if not reject_suggestion() then reject_prediction(bufnr) end
1070+
cancel_timer() -- a request already in the debounce would repaint what we just cleared
10141071
end
10151072

10161073
function M.log()
@@ -1059,6 +1116,7 @@ function M.setup(opts)
10591116
exclude_patterns = {}, -- filled from CppConfig (skip .env/.pem/... as context)
10601117
heuristics = {}, -- filled from CppConfig (active suppression rules)
10611118
reject_hard = 2,
1119+
max_cleared = 20, -- CppConfig maxNumberOfClearedSuggestionsSinceLastAccept
10621120
is_fused = nil, -- CppConfig isFusedCursorPredictionModel (nil = unknown)
10631121
map_partial = opts.map_partial ~= false
10641122
and (type(opts.map_partial) == "string" and opts.map_partial or "<M-Right>")
@@ -1107,7 +1165,7 @@ function M.setup(opts)
11071165
else
11081166
local line_changed = prev_line ~= cur[1]
11091167
local reading = not state.last_edit_at or (os.time() - state.last_edit_at) >= 60
1110-
if line_changed and not reading then
1168+
if line_changed and not reading and not rejected_too_many() then
11111169
schedule_request()
11121170
end
11131171
end
@@ -1118,17 +1176,27 @@ function M.setup(opts)
11181176
group = grp,
11191177
callback = function()
11201178
state.last_line = vim.api.nvim_win_get_cursor(0)[1] -- baseline; first move isn't a "line change"
1121-
schedule_request(true) -- request at the entry point
1179+
if rejected_too_many() then return end -- Cursor gates its EditorChange trigger the same way
1180+
schedule_request(true) -- request at the entry point
11221181
end,
11231182
})
11241183
vim.api.nvim_create_autocmd({ "InsertLeave", "BufLeave" }, {
11251184
group = grp,
11261185
callback = function(args)
11271186
commit_diff(args.buf) -- coalesce the just-finished edit into the trajectory
11281187
cancel_timer() -- don't fire a request for a buffer we just left
1129-
clear_suggestion()
1188+
-- <Esc> lands here, and this is the whole reason it counts as a dismiss:
1189+
-- Cursor's Escape files a hard rejection before clearing, so ours must
1190+
-- too, or the identical suggestion returns the moment you re-enter insert.
1191+
reject_suggestion()
1192+
-- The jump target is dropped but NOT filed as rejected: leaving insert is
1193+
-- a mode change, not a "no" to where the model wanted to send you.
11301194
state.prediction = nil
11311195
preview.clear_prediction(args.buf)
1196+
-- Leaving the buffer is our analogue of Cursor's onDidBlurEditorText:
1197+
-- come back to a clean slate. Merely leaving insert is not — that happens
1198+
-- constantly in neovim, and resetting there would defang the budget.
1199+
if args.event == "BufLeave" then state.dismissed = 0 end
11321200
end,
11331201
})
11341202
vim.api.nvim_create_autocmd("BufEnter", {
@@ -1181,6 +1249,8 @@ function M.setup(opts)
11811249
.. " excludes=" .. #state.cfg.exclude_patterns,
11821250
"hints : edit=" .. tostring(hints().edit) .. " prediction=" .. tostring(hints().prediction),
11831251
"last suppress: " .. tostring(state.last_suppressed or "none"),
1252+
"dismissed : " .. state.dismissed .. "/" .. tostring(state.cfg.max_cleared or 20)
1253+
.. (rejected_too_many() and " (muted — passive triggers off until you accept)" or ""),
11841254
"buffer : buftype='" .. vim.bo.buftype .. "' filetype='" .. vim.bo.filetype .. "'",
11851255
"attach ok : " .. tostring(should_attach(vim.api.nvim_get_current_buf())),
11861256
"ctx files : " .. tostring(#collect_additional_files(dbuf)),

‎sidecar.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ def main():
326326
"exclude_patterns": cfg.get("excludeRecentlyViewedFilesPatterns") or [],
327327
"heuristics": cfg.get("heuristics") or [],
328328
"reject_hard": (cfg.get("recentlyRejectedEditThresholds") or {}).get("hardRejectThreshold"),
329+
"max_cleared": cfg.get("maxNumberOfClearedSuggestionsSinceLastAccept"),
329330
"is_fused": cfg.get("isFusedCursorPredictionModel"),
330331
}}) + "\n")
331332
sys.stdout.flush()

0 commit comments

Comments
 (0)