Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ Type, pause, and a suggestion appears. Then:
|---|---|
| `<Tab>` | Accept · or **jump** to the predicted next edit · or chain to the next one |
| `<M-Right>` | Accept the suggestion word-by-word |
| `<C-]>` | Dismiss |
| `<Esc>` | Dismiss — leaving insert mode discards the suggestion and tells the model no |
| `<C-]>` | Dismiss **without leaving insert**, for when you want to keep typing |
| `:NeocursorSuggest` | Force a request right now |
| `:NeocursorLog` | Toggle the live state dashboard |
| `:NeocursorDebug` | Print diagnostics |
Expand All @@ -124,6 +125,24 @@ The loop back through *jump → accept* is what makes it feel like Cursor rather
than a completion engine: you keep pressing the same key and the edits come to
you.

### Saying no

`<Esc>` is the dismiss key, and it needs no mapping — neocursor never touches
`<Esc>`, so your macros, your snippet plugin and your IME switcher all keep it.
Leaving insert mode already discards the suggestion; what neocursor adds is that
it *counts*, so the same rejected edit isn't offered straight back. This is also
exactly what Cursor does under a vim layer: its Escape handler dismisses the
suggestion and lets the keypress through, so the mode change happens too.

Dismissing is tiered, like Cursor's. The first dismiss clears the edit and
**keeps** the jump target; dismiss again with nothing showing and the jump target
goes too.

Keep refusing and neocursor takes the hint: after 20 dismissals with nothing
accepted in between, it stops volunteering on the passive triggers — entering
insert, moving to another line. Typing still asks, and accepting anything (or
switching buffers) clears the count. `:NeocursorLog` shows the tally live.

---

## Configuration
Expand Down
11 changes: 6 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ purely what you see.

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

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

### Before you hide the prediction pill

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

If you want a quieter buffer without losing that, hide the label and keep the
pill:
Expand Down
104 changes: 87 additions & 17 deletions lua/neocursor/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ local state = {
dbase = {}, -- [bufnr] = { path, text } baseline snapshot for diffing
dtraj = {}, -- [path] = { {diff, ts}, ... } committed edit trajectory
rejects = {}, -- [key] = times the user dismissed this exact suggestion
dismissed = 0, -- dismissals since the last accept (Cursor:
-- numberOfClearedSuggestionsSinceLastAccept — see the note
-- on rejected_too_many for why we count dismissals, not clears)
log = {}, -- ring buffer of event strings for :NeocursorLog
log_buf = nil,
log_dirty = false,
Expand Down Expand Up @@ -150,7 +153,10 @@ local function log_refresh()
(" chain %-16s heur %d · excl %d · fused %s"):format(chain,
c.heuristics and #c.heuristics or 0, c.exclude_patterns and #c.exclude_patterns or 0, fused),
(" predict %-16s guard(seen) %s"):format(pred, seen),
(" err %s · suppress %s"):format(tostring(state.last_error or "none"), tostring(state.last_suppressed or "none")),
(" err %s · suppress %s · dismissed %d/%s%s"):format(
tostring(state.last_error or "none"), tostring(state.last_suppressed or "none"),
state.dismissed, tostring(c.max_cleared or 20),
state.dismissed > (c.max_cleared or 20) and " MUTED (accept one to resume)" or ""),
"├─ log ─ newest first " .. string.rep("─", 37) .. "┤",
}
for i = #state.log, 1, -1 do lines[#lines + 1] = state.log[i] end
Expand Down Expand Up @@ -182,6 +188,7 @@ local function apply_config(cfg)
if type(cfg.exclude_patterns) == "table" then state.cfg.exclude_patterns = cfg.exclude_patterns end
if type(cfg.heuristics) == "table" then state.cfg.heuristics = cfg.heuristics end
if type(cfg.reject_hard) == "number" then state.cfg.reject_hard = cfg.reject_hard end
if type(cfg.max_cleared) == "number" then state.cfg.max_cleared = cfg.max_cleared end
if type(cfg.is_fused) == "boolean" then state.cfg.is_fused = cfg.is_fused end
log(("CONFIG debounce=%sms heuristics=%d excludes=%d"):format(
state.cfg.debounce, #state.cfg.heuristics, #state.cfg.exclude_patterns))
Expand Down Expand Up @@ -249,7 +256,12 @@ local function show_edit(edit)
preview.inline(bufnr, row1 - 1, col0, ghost)
else
local at = cursor_at(start0, end0_excl)
local label = hints().edit and (at and "<Tab> accept" or "<Tab> jump") or nil
-- Advertise <Esc>, not <C-]>: leaving insert already dismisses (InsertLeave
-- below files the rejection), so the label is true without mapping a key —
-- and it names the one key every neovim user presses without thinking.
local label = hints().edit
and ((at and "<Tab> accept" or "<Tab> jump") .. " · <Esc> dismiss")
or nil
preview.diff(bufnr, start0, cur_range, lines, label)
end
log(("SHOW %-6s L%d (%d ln)"):format(mode, start0 + 1, #lines))
Expand All @@ -260,6 +272,9 @@ end
-- no network. Adjust the line numbers of edits below by the applied line delta,
-- jump the cursor there, and render it. This is the "tab, tab, tab" loop.
local function advance_after_apply(applied)
-- Every accept path lands here — Tab, word-at-a-time, and typing the ghost
-- out in full — so this is the one place the churn budget refills.
state.dismissed = 0
local q = state.queue
if not q then return end
local delta = #applied.lines - (applied.end0_excl - applied.start0)
Expand Down Expand Up @@ -330,6 +345,52 @@ local function pred_recently_rejected(p)
return r ~= nil and r.count >= 2
end

-- Cursor's hasRejectedTooManySuggestions. The per-suggestion ledger above only
-- catches the model REPEATING itself; this catches it being wrong in a new way
-- every time. Past the budget, stop volunteering — but only on the passive
-- triggers (entering insert, moving to another line). Typing still asks, which
-- is Cursor's split too: their content-change and linter-error paths never
-- consult this gate. Reset by accepting anything, or by leaving the buffer.
--
-- Cursor counts every clearSuggestions(); we count dismissals instead. Their
-- suggestion survives typing (isOnShortestEditPath), ours only survives it for
-- inline ghosts — a diff is dropped and refetched on each keystroke, so
-- counting clears here would mute after ~20 characters rather than ~20 ignored
-- suggestions. Same intent, adjusted for where the two renderers differ.
local function rejected_too_many()
return state.dismissed > ((state.cfg and state.cfg.max_cleared) or 20)
end

-- Cursor's Escape is TIERED, and the tiers matter: their first press files the
-- suggestion as rejected and deliberately keeps the jump target alive (the
-- handler calls maybeShowHintLineWidget right after); only a second press, with
-- nothing showing, reaches clearCursorPrediction. Collapsing the two would mute
-- jump targets the user never actually said no to.
--
-- Tier 1 — file the visible edit as rejected and clear it. Returns false when
-- there was nothing to reject.
local function reject_suggestion()
local s = state.suggestion
if not s then return false end
local key = reject_key(buf_relpath(s.bufnr) or "", s)
state.rejects[key] = (state.rejects[key] or 0) + 1
state.dismissed = state.dismissed + 1
log(("DISMISS L%d (rejected ×%d · %d/%s before mute)"):format(
s.start0 + 1, state.rejects[key], state.dismissed,
tostring((state.cfg and state.cfg.max_cleared) or 20)))
clear_suggestion()
return true
end

-- Tier 2 — reject the jump target itself (30s TTL, muted at 2).
local function reject_prediction(bufnr)
if not state.prediction then return false end
record_pred_reject(state.prediction)
state.prediction = nil
preview.clear_prediction(bufnr or 0)
return true
end

-- Paint the "Tab →" hint at the prediction target (Cursor's hint widget).
-- Returns false when there is nothing worth jumping to (no prediction, or it
-- points at the line the cursor is already on).
Expand Down Expand Up @@ -998,19 +1059,15 @@ function M.has_prediction() return state.prediction ~= nil end
-- exposed for test/hints_spec.lua; pure, no state
M._normalize_hints = normalize_hints

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

function M.log()
Expand Down Expand Up @@ -1059,6 +1116,7 @@ function M.setup(opts)
exclude_patterns = {}, -- filled from CppConfig (skip .env/.pem/... as context)
heuristics = {}, -- filled from CppConfig (active suppression rules)
reject_hard = 2,
max_cleared = 20, -- CppConfig maxNumberOfClearedSuggestionsSinceLastAccept
is_fused = nil, -- CppConfig isFusedCursorPredictionModel (nil = unknown)
map_partial = opts.map_partial ~= false
and (type(opts.map_partial) == "string" and opts.map_partial or "<M-Right>")
Expand Down Expand Up @@ -1107,7 +1165,7 @@ function M.setup(opts)
else
local line_changed = prev_line ~= cur[1]
local reading = not state.last_edit_at or (os.time() - state.last_edit_at) >= 60
if line_changed and not reading then
if line_changed and not reading and not rejected_too_many() then
schedule_request()
end
end
Expand All @@ -1118,17 +1176,27 @@ function M.setup(opts)
group = grp,
callback = function()
state.last_line = vim.api.nvim_win_get_cursor(0)[1] -- baseline; first move isn't a "line change"
schedule_request(true) -- request at the entry point
if rejected_too_many() then return end -- Cursor gates its EditorChange trigger the same way
schedule_request(true) -- request at the entry point
end,
})
vim.api.nvim_create_autocmd({ "InsertLeave", "BufLeave" }, {
group = grp,
callback = function(args)
commit_diff(args.buf) -- coalesce the just-finished edit into the trajectory
cancel_timer() -- don't fire a request for a buffer we just left
clear_suggestion()
-- <Esc> lands here, and this is the whole reason it counts as a dismiss:
-- Cursor's Escape files a hard rejection before clearing, so ours must
-- too, or the identical suggestion returns the moment you re-enter insert.
reject_suggestion()
-- The jump target is dropped but NOT filed as rejected: leaving insert is
-- a mode change, not a "no" to where the model wanted to send you.
state.prediction = nil
preview.clear_prediction(args.buf)
-- Leaving the buffer is our analogue of Cursor's onDidBlurEditorText:
-- come back to a clean slate. Merely leaving insert is not — that happens
-- constantly in neovim, and resetting there would defang the budget.
if args.event == "BufLeave" then state.dismissed = 0 end
end,
})
vim.api.nvim_create_autocmd("BufEnter", {
Expand Down Expand Up @@ -1181,6 +1249,8 @@ function M.setup(opts)
.. " excludes=" .. #state.cfg.exclude_patterns,
"hints : edit=" .. tostring(hints().edit) .. " prediction=" .. tostring(hints().prediction),
"last suppress: " .. tostring(state.last_suppressed or "none"),
"dismissed : " .. state.dismissed .. "/" .. tostring(state.cfg.max_cleared or 20)
.. (rejected_too_many() and " (muted — passive triggers off until you accept)" or ""),
"buffer : buftype='" .. vim.bo.buftype .. "' filetype='" .. vim.bo.filetype .. "'",
"attach ok : " .. tostring(should_attach(vim.api.nvim_get_current_buf())),
"ctx files : " .. tostring(#collect_additional_files(dbuf)),
Expand Down
1 change: 1 addition & 0 deletions sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ def main():
"exclude_patterns": cfg.get("excludeRecentlyViewedFilesPatterns") or [],
"heuristics": cfg.get("heuristics") or [],
"reject_hard": (cfg.get("recentlyRejectedEditThresholds") or {}).get("hardRejectThreshold"),
"max_cleared": cfg.get("maxNumberOfClearedSuggestionsSinceLastAccept"),
"is_fused": cfg.get("isFusedCursorPredictionModel"),
}}) + "\n")
sys.stdout.flush()
Expand Down
Loading