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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Esc> dropped, <C-c> dismisses, <C-o> 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"
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Esc> is dropped, <C-c> dismisses like
# <Esc>, <C-o> 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"
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<Esc>`,
`<C-c>`, `<C-\><C-n>` — 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 `<C-o>`: 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.
Expand Down
11 changes: 7 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
`<C-o>:NeocursorSuggest<CR>` — 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.
Expand All @@ -124,8 +126,9 @@ Work down this list in order:

## `<Tab>` does nothing

Almost always another plugin owns the mapping. nvim-cmp, blink.cmp, LuaSnip and
most snippet engines map `<Tab>` in insert mode.
In Normal mode, that's by design: suggestions only exist in insert mode, and so
does the `<Tab>` mapping. In insert mode, almost always another plugin owns the
mapping. nvim-cmp, blink.cmp, LuaSnip and most snippet engines map `<Tab>` there.

Check who has it:

Expand Down
79 changes: 61 additions & 18 deletions lua/neocursor/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <Tab>,
-- <M-Right>, <C-]> — 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 <Esc> used to leave on screen (#10). Replace mode counts
-- as inserting; <C-o>'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, <Tab> jumps here; when true, <Tab> accepts.
local function cursor_at(start0, end0_excl)
Expand Down Expand Up @@ -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 <Esc>, not <C-]>: leaving insert already dismisses (InsertLeave
-- below files the rejection), so the label is true without mapping a key —
-- Advertise <Esc>, not <C-]>: 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 "<Tab> accept" or "<Tab> jump") .. " · <Esc> dismiss")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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. <Esc> does the same thing and then exits
-- insert; this is the variant for when you want to keep typing. <C-]> is the
Expand Down Expand Up @@ -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" <Esc>, <C-c>, <C-\><C-n> — 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" <C-o> — 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: <C-c> 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 <C-c>
-- (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
-- <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
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)
Expand Down
184 changes: 184 additions & 0 deletions test/modes_spec.lua
Original file line number Diff line number Diff line change
@@ -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 <C-c> 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: <C-c> 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 <Esc>. The request leaves on the
-- debounce, the fake answers 300ms later, and <Esc> falls in between. That
-- reply used to be painted into normal mode — a diff labelled "<Tab> accept ·
-- <Esc> 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("<Esc>") -- 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 <Esc> 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 <Esc> 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: <C-c>. 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:
-- <C-c> is how a lot of people say <Esc>.
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("<C-c> clears the suggestion before any other key", painted(), 0)
check("<C-c> lands in normal mode", mode(), "n")
check("<C-c> counts as a dismiss, like <Esc>", logged("DISMISS", mark), true)
done()
end, function()
check("<C-c> clears the suggestion before any other key", nc.has_suggestion(), false)
done()
end)
input("<C-c>") -- last statement: this sets got_int for the rest of the tick
end, function()
check("suggestion arrives (round 2)", false, true)
input("<Esc>")
done()
end)
input("A2")
end

-- Round 3: <C-o> 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. <Esc> 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("<C-o>zz")
poll(function() return not nc.has_suggestion() end, 2000, function()
check("<C-o> clears the display", painted(), 0)
check("<C-o> 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("<Esc>")
poll(function() return not nc.has_suggestion() end, 2000, function()
check("<Esc> after the detour still counts", logged("DISMISS", mark), true)
check("nothing on screen after <Esc>", painted(), 0)
done()
end, function()
check("<Esc> after the detour clears", nc.has_suggestion(), false)
done()
end)
end, function()
check("suggestion returns after the detour", false, true)
input("<Esc>")
done()
end)
end, function()
check("<C-o> clears the display", nc.has_suggestion(), false)
input("<Esc>")
done()
end)
end, function()
check("suggestion arrives (round 3)", false, true)
input("<Esc>")
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)
Loading