Skip to content

feat: <Esc> dismisses, and it counts - #8

Merged
teocns merged 1 commit into
mainfrom
feat/dismiss
Aug 4, 2026
Merged

feat: <Esc> dismisses, and it counts#8
teocns merged 1 commit into
mainfrom
feat/dismiss

Conversation

@teocns

@teocns teocns commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes the discoverability hole around dismissal, and ports the two rejection mechanisms behind Cursor's Escape.

The problem

<C-]> was bound and worked. It appeared in one un-highlighted README table row, never beside a suggestion, so the reasonable conclusion was that dismissal did not exist.

The fix is not a new keymap

<Esc> already dismissed — InsertLeave clears the suggestion — it just filed nothing, so the same edit returned the moment you re-entered insert. Now it records the rejection, and the hint names the key that was working all along:

⟪neocursor · <Tab> accept · <Esc> dismiss⟫

No insert-mode <Esc> mapping is installed. In VS Code Escape is a free key; in neovim it is the mode transition. A conditional Esc would make macros non-deterministic (whether a ghost is showing depends on a debounced network reply), clobber im-select and snippet mappings, and has no clean fallthrough — feedkeys with "n" skips the mapping the user wanted, "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 is what Cursor does under a vim layer

handleKeyDownForCppKeys is a raw document.addEventListener("keydown", …), 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.

Tiering, where it can be kept

Cursor's first Escape files the suggestion as rejected and deliberately keeps the jump target alive (maybeShowHintLineWidget fires immediately after); only a second press, with nothing showing, reaches clearCursorPrediction. reject_suggestion() / reject_prediction() split accordingly and <C-]> walks them in order.

Collapsing the two — which the first draft 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.

Churn gate

Ports hasRejectedTooManySuggestions, which the per-suggestion ledger cannot express:

catches
HEURISTIC_SUGGESTING_RECENTLY_REJECTED_EDIT the model repeating itself
this the model 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 (onDidBlurEditorText; merely leaving insert is not the analogue, that happens constantly here).

One deliberate divergence. Cursor increments on every clearSuggestions(); this counts dismissals. 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. Documented at the call site.

maxNumberOfClearedSuggestionsSinceLastAccept now arrives with the rest of CppConfig; the live backend returns 20, which the hardcoded default independently matched. :NeocursorLog shows the tally and flips to MUTED at the threshold.

Testing

Full CI suite green — flow_spec (both hint modes), hints_spec, paths_spec, handshake_spec, docs_links. No new spec: the behaviour is exercised through existing rounds, and round 4 proved it has teeth by failing on the first draft.

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.
@teocns
teocns merged commit 2e2ef13 into main Aug 4, 2026
7 checks passed
@teocns
teocns deleted the feat/dismiss branch August 4, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant