From 6847d263abee0e86eb88c446a15a69d21bcea642 Mon Sep 17 00:00:00 2001 From: teocns Date: Wed, 2 Sep 2026 15:18:45 +0300 Subject: [PATCH] fix: a suggestion never outlives insert mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a suggestion ended up painted in Normal mode, where , and — all insert mappings — can't reach it: - The reply race. The request leaves on the debounce, lands a few hundred ms later, and the reply was painted into Normal mode anyway: the buffer tick hadn't changed, so the stale-response check let it through. render_result now drops any reply that lands outside insert/replace mode (logged as "DROP … outside insert"); the next InsertEnter asks afresh. - . Unmapped, it is an interrupt, and :h i_CTRL-C says it never fires InsertLeave — so the leave handler never ran. The handler now hangs off ModeChanged, which sees every exit: for an interrupt Neovim defers it past got_int and fires it from normal_check, still before any other key is read. While there, how you leave now decides whether it counts. , and file the rejection as before. is a detour: the display clears (the command may edit the buffer under it), nothing is filed, and the return trip re-offers the edit. Before, zz twice would hard-reject a suggestion you never said no to. test/modes_spec.lua drives Neovim's real main loop with nvim_input — the blocked-feedkeys harness in flow_spec never reaches normal-mode events — and covers all three. Against the pre-fix code it fails 6 of 15 assertions. Closes #10 --- .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 4 + README.md | 7 ++ docs/troubleshooting.md | 11 ++- lua/neocursor/init.lua | 79 +++++++++++++---- test/modes_spec.lua | 184 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 test/modes_spec.lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39a2785..241ee0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: hint chrome spec (show_hints rendering + normalization) run: nvim --headless -u NONE -c "luafile test/hints_spec.lua" + - name: insert-only spec (reply after dropped, dismisses, detours) + run: nvim --headless -u NONE -c "luafile test/modes_spec.lua" + - name: tab-flow spec with hints off (chrome must not change behavior) env: NEOCURSOR_SPEC_NO_HINTS: "1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6784b6f..07c3e05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,10 @@ nvim --headless -u NONE -c "luafile test/flow_spec.lua" # hint chrome: rendering + show_hints normalization nvim --headless -u NONE -c "luafile test/hints_spec.lua" +# insert-only: a reply landing after is dropped, dismisses like +# , is a detour — driven through Neovim's real main loop +nvim --headless -u NONE -c "luafile test/modes_spec.lua" + # the same behavioral suite with hint chrome disabled — chrome must never # change behavior, so these assertions must pass identically NEOCURSOR_SPEC_NO_HINTS=1 nvim --headless -u NONE -c "luafile test/flow_spec.lua" diff --git a/README.md b/README.md index c2eff95..db8d1f3 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,13 @@ 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. +Suggestions live in insert mode, full stop. However you leave — ``, +``, `` — the suggestion leaves with you, and a reply that +arrives after you've gone is dropped rather than painted into Normal mode, +where no key could reach it. The one exception is ``: a single Normal +command is a detour, not a no. The suggestion clears while the command runs +and is re-offered when you land back in insert, without counting against it. + 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ba11363..90793b7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -114,8 +114,10 @@ Work down this list in order: navigate rather than firing on every cursor move. Type a little, then pause. 4. **Force one.** `:NeocursorSuggest` bypasses the debounce and requests - immediately. If that produces a suggestion, the plumbing is fine and you were - hitting gating, not a bug. + immediately. Suggestions only render in insert mode, so run it from there — + `:NeocursorSuggest` — or just watch the reply arrive in the log + (next step): a `RES` line carrying edits means the plumbing is fine and you + were hitting gating, not a bug. 5. **Watch it live.** `:NeocursorLog` shows requests going out and what comes back, including responses dropped as stale. @@ -124,8 +126,9 @@ Work down this list in order: ## `` does nothing -Almost always another plugin owns the mapping. nvim-cmp, blink.cmp, LuaSnip and -most snippet engines map `` in insert mode. +In Normal mode, that's by design: suggestions only exist in insert mode, and so +does the `` mapping. In insert mode, almost always another plugin owns the +mapping. nvim-cmp, blink.cmp, LuaSnip and most snippet engines map `` there. Check who has it: diff --git a/lua/neocursor/init.lua b/lua/neocursor/init.lua index b881f0c..99d561d 100644 --- a/lua/neocursor/init.lua +++ b/lua/neocursor/init.lua @@ -194,6 +194,15 @@ local function apply_config(cfg) state.cfg.debounce, #state.cfg.heuristics, #state.cfg.exclude_patterns)) end +-- Suggestions live in insert mode. Every key that acts on one — , +-- , — is an insert mapping, so a suggestion painted anywhere +-- else is chrome nobody can answer: that is exactly what a reply landing a few +-- hundred ms after used to leave on screen (#10). Replace mode counts +-- as inserting; 's temporary normal (niI) and everything else does not. +local function inserting() + return vim.api.nvim_get_mode().mode:match("^[iR]") ~= nil +end + -- Is the cursor currently on the edit's target region? This is Cursor's -- `cursorAtInlineEdit`: when false, jumps here; when true, accepts. local function cursor_at(start0, end0_excl) @@ -256,8 +265,9 @@ local function show_edit(edit) preview.inline(bufnr, row1 - 1, col0, ghost) else local at = cursor_at(start0, end0_excl) - -- Advertise , not : leaving insert already dismisses (InsertLeave - -- below files the rejection), so the label is true without mapping a key — + -- Advertise , not : leaving insert already dismisses (the + -- ModeChanged handler in setup 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 " accept" or " jump") .. " · dismiss") @@ -428,6 +438,13 @@ local function render_result(res) log("DROP stale response (buffer changed since request)") return end + -- the request went out in insert mode, but the reply is landing now — if + -- the user has since left insert there is nothing here that can act on it, + -- and the next InsertEnter asks afresh anyway + if not inserting() then + log(("DROP reply landed outside insert (mode=%s)"):format(vim.api.nvim_get_mode().mode)) + return + end -- adopt this response's prediction target (or drop a stale/muted one) preview.clear_prediction(bufnr) local pred = res.prediction @@ -700,7 +717,7 @@ local function collect_linter_errors(buf, path) end -- diff trajectory: baseline snapshot per buffer; unified diff baseline→current is --- the edit. commit_diff() coalesces at logical boundaries (InsertLeave/BufLeave). +-- the edit. commit_diff() coalesces at logical boundaries (leaving insert/BufLeave). local MAX_TRAJ, DIFF_CAP = 6, 4000 local function ensure_baseline(buf, path) @@ -1058,6 +1075,9 @@ function M.has_prediction() return state.prediction ~= nil end -- exposed for test/hints_spec.lua; pure, no state M._normalize_hints = normalize_hints +-- exposed for test/modes_spec.lua: the event log, oldest first, so a round can +-- assert *why* a suggestion is gone (DISMISS vs DROP), not just that it is +function M._log_lines() return vim.deepcopy(state.log) end -- Dismiss without leaving insert mode. does the same thing and then exits -- insert; this is the variant for when you want to keep typing. is the @@ -1180,25 +1200,48 @@ function M.setup(opts) schedule_request(true) -- request at the entry point end, }) - vim.api.nvim_create_autocmd({ "InsertLeave", "BufLeave" }, { + -- Leaving insert ends the suggestion. Whether it *counts* depends on how you + -- left: + -- "dismiss" , , — a "no". Cursor's Escape files a hard + -- rejection before clearing, so ours must too, or the identical + -- suggestion returns the moment you re-enter insert. + -- "detour" — one normal command, then straight back. Not a "no": + -- the display goes (the command may edit the buffer under it), + -- but nothing is filed, so the return trip re-offers it. + -- "buffer" BufLeave — our analogue of Cursor's onDidBlurEditorText: come + -- back to a clean slate. + -- The jump target is dropped every time but NEVER filed as rejected: leaving + -- insert is a mode change, not a "no" to where the model wanted to send you. + local function on_leave(buf, how) + commit_diff(buf) -- coalesce the just-finished edit into the trajectory + cancel_timer() -- don't fire a request for a mode/buffer we just left + if how == "detour" then clear_suggestion() else reject_suggestion() end + state.prediction = nil + preview.clear_prediction(buf) + -- Only a buffer switch resets the churn budget. Merely leaving insert + -- happens constantly in neovim, and resetting there would defang it. + if how == "buffer" then state.dismissed = 0 end + end + -- ModeChanged, not InsertLeave: exits insert WITHOUT firing InsertLeave + -- (:h i_CTRL-C), which left the suggestion painted in normal mode where no + -- key could reach it. ModeChanged sees every exit — for an unmapped + -- (an interrupt, not a keypress) Neovim defers it past got_int and fires it + -- from normal_check, still before any other key is read. The pattern admits + -- every insert/replace variant as the old mode; the callback then ignores + -- hops that stay inside insert (i → ic while a completion menu is up, and back). + vim.api.nvim_create_autocmd("ModeChanged", { group = grp, + pattern = "[iR]*:*", 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 - -- 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 + local new_mode = vim.v.event.new_mode + if new_mode:match("^[iR]") then return end + on_leave(args.buf, new_mode:match("^ni") and "detour" or "dismiss") end, }) + vim.api.nvim_create_autocmd("BufLeave", { + group = grp, + callback = function(args) on_leave(args.buf, "buffer") end, + }) vim.api.nvim_create_autocmd("BufEnter", { group = grp, callback = function(args) diff --git a/test/modes_spec.lua b/test/modes_spec.lua new file mode 100644 index 0000000..d3132fc --- /dev/null +++ b/test/modes_spec.lua @@ -0,0 +1,184 @@ +-- Insert-only spec: a suggestion never outlives insert mode, and HOW you leave +-- decides whether it counts. Run from the repo root: +-- nvim --headless -u NONE -c "luafile test/modes_spec.lua" +-- +-- flow_spec.lua blocks the main thread inside feedkeys(..., "x!"), which runs +-- the insert loop and nothing else. Everything asserted here happens AFTER +-- insert ends — a reply landing in normal mode, the ModeChanged that Neovim +-- defers past a interrupt until normal_check — and that only runs when +-- Neovim's own main loop is in charge. So this script installs a timer chain, +-- returns, and drives the editor with nvim_input exactly the way a terminal +-- does: in particular arrives as the real interrupt (got_int), the path +-- that fires neither InsertLeave nor a synchronous ModeChanged. +local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") +vim.opt.rtp:prepend(root) +vim.opt.swapfile = false +vim.opt.showmode = false -- "-- INSERT --" would land in the spec's stdout +io.stdout:setvbuf("no") + +local failed = 0 +local function check(desc, got, want) + local ok = vim.deep_equal(got, want) + if not ok then failed = failed + 1 end + io.stdout:write(("%s %s%s\n"):format(ok and "ok " or "FAIL", desc, + ok and "" or (" got=" .. vim.inspect(got) .. " want=" .. vim.inspect(want)))) +end + +local nc = require("neocursor") +local preview = require("neocursor.preview") +nc.setup({ + debounce = 30, + sidecar_cmd = { vim.fn.executable("python3") == 1 and "python3" or "python", root .. "/test/fake_sidecar.py" }, +}) + +vim.cmd("edit " .. root .. "/test/spec_scratch.py") +local seed = { "line1 = 1", "line2 = 2", "line3 = 3", "line4 = 4", "line5 = 5" } +local function reseed() + vim.api.nvim_buf_set_lines(0, 0, -1, false, seed) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) +end + +local function input(keys) vim.api.nvim_input(keys) end +local function later(ms, fn) vim.defer_fn(fn, ms) end +local function poll(cond, timeout_ms, on_ok, on_fail) + local waited = 0 + local function tick() + if cond() then return on_ok() end + waited = waited + 20 + if waited >= timeout_ms then return on_fail() end + later(20, tick) + end + tick() +end +local function mode() return vim.api.nvim_get_mode().mode end +-- everything neocursor has painted: the suggestion namespace plus the jump pill +local function painted() + return #vim.api.nvim_buf_get_extmarks(0, preview.namespace(), 0, -1, {}) + + #vim.api.nvim_buf_get_extmarks(0, preview.prediction_namespace(), 0, -1, {}) +end +-- a log line matching `pat` appended after the `since` snapshot (#log_lines) +local function logged(pat, since) + local lines = nc._log_lines() + for i = since + 1, #lines do + if lines[i]:find(pat, 1, true) then return true end + end + return false +end +local function log_mark() return #nc._log_lines() end + +-- Round 1: the reply that lands AFTER . The request leaves on the +-- debounce, the fake answers 300ms later, and falls in between. That +-- reply used to be painted into normal mode — a diff labelled " accept · +-- dismiss" that neither key could reach, both being insert mappings (#10). +-- It must be dropped, and dropped for the right reason: the buffer didn't +-- change, the mode did. +local function round1(done) + reseed() + local mark = log_mark() + poll(function() return logged("REQ", mark) end, 5000, function() + input("") -- the reply is now in flight; 300ms RTT keeps it there + poll(function() return logged("DROP", mark) or nc.has_suggestion() end, 5000, function() + check("reply after is not painted", nc.has_suggestion(), false) + check("nothing on screen after the drop", painted(), 0) + check("dropped for mode, not staleness", logged("outside insert", mark), true) + check("editor is in normal mode", mode(), "n") + done() + end, function() + check("reply after reaches the renderer at all", false, true) + done() + end) + end, function() + check("request sent (round 1)", false, true) + done() + end) + input("A1") +end + +-- Round 2: . Unmapped, it is an interrupt, not a keypress: InsertLeave +-- never fires (:h i_CTRL-C) and ModeChanged is skipped while got_int is set, +-- then fired from normal_check once the interrupt is cleared — before any +-- other key. A visible suggestion must be gone by then, and it must count: +-- is how a lot of people say . +local function round2(done) + reseed() + local mark = log_mark() + poll(nc.has_suggestion, 5000, function() + check("suggestion arrives (round 2)", true, true) + poll(function() return not nc.has_suggestion() end, 2000, function() + check(" clears the suggestion before any other key", painted(), 0) + check(" lands in normal mode", mode(), "n") + check(" counts as a dismiss, like ", logged("DISMISS", mark), true) + done() + end, function() + check(" clears the suggestion before any other key", nc.has_suggestion(), false) + done() + end) + input("") -- last statement: this sets got_int for the rest of the tick + end, function() + check("suggestion arrives (round 2)", false, true) + input("") + done() + end) + input("A2") +end + +-- Round 3: is a detour, not a no. One normal command, then straight back +-- into insert. The display clears while the command runs (it may edit the +-- buffer under the suggestion), but nothing is filed against the edit, so the +-- return trip's fresh request re-offers it. afterwards still counts. +local function round3(done) + reseed() + poll(nc.has_suggestion, 5000, function() + check("suggestion arrives (round 3)", true, true) + local mark = log_mark() + input("zz") + poll(function() return not nc.has_suggestion() end, 2000, function() + check(" clears the display", painted(), 0) + check(" is not filed as a dismiss", logged("DISMISS", mark), false) + poll(nc.has_suggestion, 5000, function() + check("suggestion returns after the detour", mode(), "i") + check("still not filed as a dismiss", logged("DISMISS", mark), false) + input("") + poll(function() return not nc.has_suggestion() end, 2000, function() + check(" after the detour still counts", logged("DISMISS", mark), true) + check("nothing on screen after ", painted(), 0) + done() + end, function() + check(" after the detour clears", nc.has_suggestion(), false) + done() + end) + end, function() + check("suggestion returns after the detour", false, true) + input("") + done() + end) + end, function() + check(" clears the display", nc.has_suggestion(), false) + input("") + done() + end) + end, function() + check("suggestion arrives (round 3)", false, true) + input("") + done() + end) + input("A3") +end + +local function finish() + io.stdout:write(failed == 0 and "ALL PASS\n" or (failed .. " FAILURES\n")) + vim.cmd(failed == 0 and "qall!" or "cquit!") +end + +-- hand control to the main loop; each round runs to completion before the next +later(50, function() + round1(function() + later(50, function() + round2(function() + later(50, function() + round3(finish) + end) + end) + end) + end) +end)