feat: <Esc> dismisses, and it counts - #8
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —InsertLeaveclears 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: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), clobberim-selectand snippet mappings, and has no clean fallthrough —feedkeyswith"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
handleKeyDownForCppKeysis a rawdocument.addEventListener("keydown", …), not a registered keybinding, and the suggestion branch never callspreventDefault— 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 (
maybeShowHintLineWidgetfires immediately after); only a second press, with nothing showing, reachesclearCursorPrediction.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_rejectsTTL, after two ordinary trips through normal mode.flow_specround 4 caught it.Churn gate
Ports
hasRejectedTooManySuggestions, which the per-suggestion ledger cannot express:HEURISTIC_SUGGESTING_RECENTLY_REJECTED_EDITPast 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 viaisOnShortestEditPath, 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.maxNumberOfClearedSuggestionsSinceLastAcceptnow arrives with the rest ofCppConfig; the live backend returns 20, which the hardcoded default independently matched.:NeocursorLogshows the tally and flips toMUTEDat 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.