There was no discoverable way to reject a suggestion. <C-]> was bound and worked, but it lived in one un-highlighted README table row and never appeared beside a suggestion — so the reasonable conclusion was that dismissal did not exist.
<Esc> now dismisses, and the suggestion says so:
⟪neocursor · <Tab> accept · <Esc> dismiss⟫
No <Esc> mapping was installed
This is the point. <Esc> already dismissed — leaving insert mode clears the suggestion — it just filed nothing, so the identical edit came straight back when you re-entered insert. What changed is that it now counts. The key was always working; nothing about your <Esc> is intercepted.
That matters, because a conditional <Esc> would have cost real things. In VS Code, Escape is a free key. In neovim it is the mode transition:
if <Esc> were mapped |
|
|---|---|
| macros | non-deterministic — whether a ghost is showing depends on a debounced network reply |
im-select, snippet plugins |
clobbered; their insert-mode <Esc> never runs |
| fallthrough | no clean form — feedkeys "n" skips their mapping, "m" re-enters ours |
Advertising behaviour that already exists costs none of that.
<C-]> stays, as the dismiss-without-leaving-insert variant for when you want to keep typing. It is the key copilot.vim, copilot.lua and avante.nvim all use.
Dismissal is tiered
The first dismiss clears the edit and keeps the jump target. Dismiss again with nothing showing and the target goes too. That is Cursor's split, not an invention: their Escape handler files the suggestion as rejected and then calls maybeShowHintLineWidget immediately after, deliberately keeping the prediction alive.
It takes the hint
Rejecting the same edit twice already suppressed it. New in this release is the gate that catches the model being wrong in a new way every time — Cursor's hasRejectedTooManySuggestions. After 20 dismissals with nothing accepted in between, neocursor stops volunteering on the passive triggers:
| trigger | after the budget |
|---|---|
| entering insert | silent |
| moving to another line | silent |
| typing | still asks |
Accepting anything refills it, as does switching buffers. :NeocursorLog shows the tally live and flips to MUTED at the threshold. The 20 is not invented — it now arrives with the rest of CppConfig, and the live backend returns exactly that.
On Cursor under a vim layer
Worth knowing if you also use Cursor: its handleKeyDownForCppKeys is a raw document keydown listener rather than a registered keybinding, and the suggestion branch never calls preventDefault — only the auto-import branch does. So with vscode-neovim, Escape dismisses the suggestion and propagates, leaving insert mode. Both happen. Cursor's tiering collapses to one tier for vim users by accident, which is precisely the behaviour this release makes deliberate here.
One deliberate divergence
Cursor increments its counter 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 you after ~20 characters rather than ~20 ignored suggestions. Same intent, adjusted for where the two renderers differ; documented at the call site.
On not muting jumps you never refused
The first draft of this change filed a prediction rejection on every InsertLeave. Two ordinary trips through normal mode and the jump target went dead for the 30s pred_rejects TTL. flow_spec round 4 caught it, and a baseline comparison confirmed the regression was mine rather than flake — which is the whole reason that round asserts the prediction survives a chain. Fixed by splitting reject_suggestion() from reject_prediction().
Full CI matrix green on ubuntu, windows and macos.
Full changelog: v0.3.0...v0.4.0