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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ jobs:

- name: headless tab-flow spec (canned sidecar, real jobstart plumbing)
run: nvim --headless -u NONE -c "luafile test/flow_spec.lua"

- name: hint chrome spec (show_hints rendering + normalization)
run: nvim --headless -u NONE -c "luafile test/hints_spec.lua"

- name: tab-flow spec with hints off (chrome must not change behavior)
env:
NEOCURSOR_SPEC_NO_HINTS: "1"
run: nvim --headless -u NONE -c "luafile test/flow_spec.lua"
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,29 @@ require("neocursor").setup({
map_tab = true, -- false → another plugin owns <Tab>
map_partial = "<M-Right>", -- word-by-word accept
filetypes = nil, -- allow-list, e.g. { "python", "lua" }; nil = all
show_hints = true, -- false → no hint chrome (see below)
})
```

**`show_hints`** controls the two labels neocursor paints. Suggestions themselves
are never affected — the ghost text, the diff, and every `<Tab>` behavior are
identical either way.

| Surface | Looks like | What it marks |
|---|---|---|
| `edit` | `⟪neocursor · <Tab> accept⟫` | a pending edit; the diff beside it already shows the change |
| `prediction` | `⟪<Tab> → L42⟫` | a jump target — the *only* on-screen sign one is queued |

```lua
show_hints = false -- hide both
show_hints = { edit = false } -- hide the label, keep the jump pill
show_hints = { prediction = false } -- hide the pill, keep the label
```

Turning the prediction pill off makes pending jumps invisible: `<Tab>` still
jumps, you just won't see where until it does. `:NeocursorDebug` prints the
resolved setting.

</details>

---
Expand Down
35 changes: 32 additions & 3 deletions lua/neocursor/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ local state = {
stderr_tail = {}, -- last stderr lines, replayed if the sidecar dies before ready
}

-- Hint chrome — the ⟪neocursor · <Tab> …⟫ label on an edit and the ⟪<Tab> → L42⟫
-- pill at a jump target — is display-only. Hiding it changes what you see, never
-- what <Tab> does, so every gate below still fires exactly as it did.
local HINTS_ALL = { edit = true, prediction = true }

--- show_hints: true/nil = all, false = none, table = per-surface.
local function normalize_hints(v)
if v == false then return { edit = false, prediction = false } end
if type(v) == "table" then
return { edit = v.edit ~= false, prediction = v.prediction ~= false }
end
return { edit = true, prediction = true }
end

-- state.cfg is nil until setup() runs; default to showing everything.
local function hints()
return (state.cfg and state.cfg.show_hints) or HINTS_ALL
end

local function plugin_root()
local src = debug.getinfo(1, "S").source:sub(2)
return vim.fn.fnamemodify(src, ":h:h:h") -- .../lua/neocursor/init.lua -> root
Expand Down Expand Up @@ -230,7 +249,8 @@ local function show_edit(edit)
preview.inline(bufnr, row1 - 1, col0, ghost)
else
local at = cursor_at(start0, end0_excl)
preview.diff(bufnr, start0, cur_range, lines, at and "<Tab> accept" or "<Tab> jump")
local label = hints().edit and (at and "<Tab> accept" or "<Tab> jump") or nil
preview.diff(bufnr, start0, cur_range, lines, label)
end
log(("SHOW %-6s L%d (%d ln)"):format(mode, start0 + 1, #lines))
return true
Expand Down Expand Up @@ -318,15 +338,19 @@ local function show_prediction()
if not (p and p.line) then return false end
local bufnr = vim.api.nvim_get_current_buf()
local rel = buf_relpath(bufnr)
-- painting is optional (show_hints); the jump target itself is not, so the
-- true/false this returns must stay the same either way
local paint = hints().prediction
if not paint then preview.clear_prediction(bufnr) end
if (not p.path) or p.path == rel then
local lc = vim.api.nvim_buf_line_count(bufnr)
local row1 = math.min(math.max(p.line, 1), lc)
if row1 == vim.api.nvim_win_get_cursor(0)[1] then return false end
preview.prediction(bufnr, row1 - 1, "<Tab> → L" .. row1)
if paint then preview.prediction(bufnr, row1 - 1, "<Tab> → L" .. row1) end
else
-- cross-file target: anchor the hint at the cursor, name the destination
local cur0 = vim.api.nvim_win_get_cursor(0)[1] - 1
preview.prediction(bufnr, cur0, ("<Tab> → %s:%d"):format(p.path, p.line))
if paint then preview.prediction(bufnr, cur0, ("<Tab> → %s:%d"):format(p.path, p.line)) end
end
log(("PRED → %s:%d"):format(p.path or "·", p.line))
return true
Expand Down Expand Up @@ -971,6 +995,9 @@ function M.accept_partial()
end
function M.has_prediction() return state.prediction ~= nil end

-- exposed for test/hints_spec.lua; pure, no state
M._normalize_hints = normalize_hints

function M.dismiss()
local s = state.suggestion
if s then
Expand Down Expand Up @@ -1028,6 +1055,7 @@ function M.setup(opts)
sidecar_cmd = opts.sidecar_cmd or { "uv", "run", "--with", "httpx[http2]" },
map_tab = opts.map_tab ~= false, -- set false when another plugin (cmp) owns <Tab>
filetypes = opts.filetypes, -- optional allow-list; nil = all normal buffers
show_hints = normalize_hints(opts.show_hints), -- display-only chrome; see normalize_hints
exclude_patterns = {}, -- filled from CppConfig (skip .env/.pem/... as context)
heuristics = {}, -- filled from CppConfig (active suppression rules)
reject_hard = 2,
Expand Down Expand Up @@ -1151,6 +1179,7 @@ function M.setup(opts)
"chain : " .. (state.queue and (state.queue.idx .. "/" .. #state.queue.list) or "none"),
"config : debounce=" .. state.cfg.debounce .. "ms heuristics=" .. #state.cfg.heuristics
.. " excludes=" .. #state.cfg.exclude_patterns,
"hints : edit=" .. tostring(hints().edit) .. " prediction=" .. tostring(hints().prediction),
"last suppress: " .. tostring(state.last_suppressed or "none"),
"buffer : buftype='" .. vim.bo.buftype .. "' filetype='" .. vim.bo.filetype .. "'",
"attach ok : " .. tostring(should_attach(vim.api.nvim_get_current_buf())),
Expand Down
20 changes: 14 additions & 6 deletions lua/neocursor/preview.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ function M.namespace()
return ns
end

function M.prediction_namespace()
return ns_pred
end

function M.clear(bufnr)
-- diff mode paints many extmarks, so clear the whole namespace, not one id
pcall(vim.api.nvim_buf_clear_namespace, bufnr or 0, ns, 0, -1)
Expand Down Expand Up @@ -78,6 +82,7 @@ end

--- Diff overlay for edits that replace existing lines (overrides / rewrites).
--- old_lines = current content of [start0, start0+#old_lines); new_lines = suggestion.
--- hint = label for the discoverability pill, or nil to omit it (show_hints).
function M.diff(bufnr, start0, old_lines, new_lines, hint)
M.clear(bufnr)
local hunks = vim.diff(
Expand Down Expand Up @@ -128,12 +133,15 @@ function M.diff(bufnr, start0, old_lines, new_lines, hint)
end
end

-- discoverability hint on the region's first line ("jump" vs "accept")
local hint_line = math.max(0, math.min(start0, nbuf - 1))
vim.api.nvim_buf_set_extmark(bufnr, ns, hint_line, 0, {
virt_text = { { " ⟪neocursor · " .. (hint or "<Tab> accept") .. "⟫", HINT_HL } },
virt_text_pos = "eol",
})
-- discoverability hint on the region's first line ("jump" vs "accept").
-- nil when hints are off: the diff itself already shows what the edit does.
if hint then
local hint_line = math.max(0, math.min(start0, nbuf - 1))
vim.api.nvim_buf_set_extmark(bufnr, ns, hint_line, 0, {
virt_text = { { " ⟪neocursor · " .. hint .. "⟫", HINT_HL } },
virt_text_pos = "eol",
})
end
end

return M
6 changes: 6 additions & 0 deletions test/flow_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ local function check(desc, got, want)
end

local nc = require("neocursor")
-- NEOCURSOR_SPEC_NO_HINTS=1 reruns this whole spec with the hint chrome off.
-- Hiding hints is display-only, so every behavioral assertion below — jump,
-- accept, chain advance — must hold identically in both modes.
local no_hints = os.getenv("NEOCURSOR_SPEC_NO_HINTS") == "1"
io.stdout:write(no_hints and "-- show_hints = false --\n" or "-- show_hints = default --\n")
nc.setup({
debounce = 30,
show_hints = not no_hints,
-- "python3" doesn't exist on Windows; the canned sidecar is stdlib-only
sidecar_cmd = { vim.fn.executable("python3") == 1 and "python3" or "python", root .. "/test/fake_sidecar.py" },
})
Expand Down
121 changes: 121 additions & 0 deletions test/hints_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
-- Spec for `show_hints`: the hint chrome is display-only. Run from the repo root:
-- nvim --headless -u NONE -c "luafile test/hints_spec.lua"
--
-- Two surfaces are covered: the ⟪neocursor · <Tab> …⟫ label painted beside a diff
-- (preview.diff) and the ⟪<Tab> → L42⟫ pill at a jump target (preview.prediction).
-- The property that matters is asymmetric: turning hints off must remove the
-- label and nothing else — the deletions, the additions, and (in init.lua) the
-- jump gating all stay exactly as they were.
local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h")
vim.opt.rtp:prepend(root)
vim.opt.swapfile = false
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 preview = require("neocursor.preview")
local ns = preview.namespace()

local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(bufnr)
local seed = { "line1 = 1", "line2 = 2", "line3 = 3" }

local function reseed()
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, seed)
preview.clear(bufnr)
preview.clear_prediction(bufnr)
end

-- every extmark in a namespace, with its virt_text flattened to a plain string
local function marks(namespace)
local out = {}
for _, m in ipairs(vim.api.nvim_buf_get_extmarks(bufnr, namespace, 0, -1, { details = true })) do
local text = ""
for _, chunk in ipairs(m[4].virt_text or {}) do text = text .. chunk[1] end
out[#out + 1] = { row = m[2], text = text, details = m[4] }
end
return out
end

local function hint_marks()
local n = 0
for _, m in ipairs(marks(ns)) do
if m.text:find("neocursor ·", 1, true) then n = n + 1 end
end
return n
end

-- a mark counts as diff body if it strikes a deletion or adds virt_lines
local function body_marks()
local n = 0
for _, m in ipairs(marks(ns)) do
if m.details.hl_group == "NeocursorDelete" or m.details.virt_lines then n = n + 1 end
end
return n
end

-- ─── preview.diff ────────────────────────────────────────────────────────────
local old_lines = { "line1 = 1", "line2 = 2" }
local new_lines = { "line1 = 111", "line2 = 2" }

reseed()
preview.diff(bufnr, 0, old_lines, new_lines, "<Tab> accept")
local body_with_hint = body_marks()
check("hint shown when a label is passed", hint_marks(), 1)
check("diff body rendered alongside the hint", body_with_hint > 0, true)

reseed()
preview.diff(bufnr, 0, old_lines, new_lines, nil)
check("hint absent when the label is nil", hint_marks(), 0)
check("diff body identical without the hint", body_marks(), body_with_hint)

reseed()
preview.diff(bufnr, 0, old_lines, new_lines, "<Tab> jump")
local jump_label = ""
for _, m in ipairs(marks(ns)) do
if m.text:find("neocursor ·", 1, true) then jump_label = m.text end
end
check("label text is the caller's, verbatim", jump_label, " ⟪neocursor · <Tab> jump⟫")

-- ─── preview.prediction ──────────────────────────────────────────────────────
-- The pill lives in its own namespace so a suggestion clear never kills it.
local ns_pred = preview.prediction_namespace()
local function pred_marks()
return #vim.api.nvim_buf_get_extmarks(bufnr, ns_pred, 0, -1, {})
end

reseed()
preview.prediction(bufnr, 1, "<Tab> → L2")
check("prediction pill painted", pred_marks(), 1)

preview.diff(bufnr, 0, old_lines, new_lines, "<Tab> accept")
check("pill survives a suggestion render (separate namespace)", pred_marks(), 1)
preview.clear(bufnr)
check("pill survives a suggestion clear", pred_marks(), 1)

preview.clear_prediction(bufnr)
check("prediction pill cleared on request", pred_marks(), 0)

-- ─── show_hints normalization ────────────────────────────────────────────────
-- Pure function, so assert it directly — no setup(), no sidecar spawn.
local normalize = require("neocursor")._normalize_hints

check("default (nil) shows both", normalize(nil), { edit = true, prediction = true })
check("true shows both", normalize(true), { edit = true, prediction = true })
check("false hides both", normalize(false), { edit = false, prediction = false })
check("granular: edit off leaves prediction on", normalize({ edit = false }),
{ edit = false, prediction = true })
check("granular: prediction off leaves edit on", normalize({ prediction = false }),
{ edit = true, prediction = false })
check("granular: both off", normalize({ edit = false, prediction = false }),
{ edit = false, prediction = false })
check("empty table = both on", normalize({}), { edit = true, prediction = true })

io.stdout:write(failed == 0 and "ALL PASS\n" or (failed .. " FAILURES\n"))
vim.cmd(failed == 0 and "qall!" or "cquit!")
Loading