feat(nvim): send the fzf-lua selection to Claude with <C-y> - #318
Conversation
The tree-based @-mention (<leader>as in nvim-tree/oil) adds one file, so putting
six files into Claude's context meant six tree navigations. fzf is already
multi-select — --multi is on for the file pickers, <Tab> marks, <A-a> toggles
all — so this turns "the files involved in this change" into one gesture.
From live_grep each entry carries a line number, so a hit is sent as its own
location rather than the whole file. fzf-lua reports 1-indexed lines and
send_at_mention documents its range as 0-indexed, so the action converts.
Entries go through fzf-lua.path.entry_to_file rather than being parsed by hand:
they carry devicon prefixes, and grep entries are file:line:col:text.
One actions.files entry covers <leader>ff / fg / fb / fr, since that is
fzf-lua's shared action set for file-ish pickers.
THE LEADING `true` IN THE ACTION TABLE IS LOAD-BEARING. Without it, a user
action table REPLACES fzf-lua's defaults wholesale instead of extending them —
dropping enter, ctrl-s/v/t and alt-q/Q/i/h/f, i.e. the ability to open a file at
all. `[1] == true` is fzf-lua's inheritance marker (config.lua switches the
merge to tbl_deep_extend("keep", yours, defaults) and then strips the [1]). This
was caught by diffing the resolved action set against a baseline build, not by
reading the docs — the audit would not have caught it.
Deliberately NOT cond-gated, unlike plugins/claudecode-nvim.lua: fzf-lua is a
core finder that must load everywhere, and probing for `claude` while building
the spec would cost a startup executable() on every box and still miss a
mid-session install. The action fails soft instead — without the CLI, claudecode
is not on the runtimepath, the pcall(require, …) fails, and you get one notify
explaining why.
Verified: defaults survive across files/grep/buffers with ctrl-y added and the
[1] marker stripped; a plain entry sends as a whole file and a grep entry at
line 10 sends as start=end=9; multi-select sends one mention per marked entry;
and with claude off PATH the action emits a single notify, does not error, and
leaves the default actions intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvMkCtfJugUuxz6HCa1NSZ
There was a problem hiding this comment.
Pull request overview
Adds multi-select fzf-lua entries to Claude Code context via <C-y>.
Changes:
- Adds file and grep selection forwarding.
- Documents picker shortcuts and behavior.
- Updates the unreleased changelog.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
nvim/lua/gerrrt/plugins/fzf-lua.lua |
Implements the Claude action. |
nvim/lua/gerrrt/cheatsheet.lua |
Documents picker shortcuts. |
CHANGELOG.md |
Records the feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| -- NOT gated at spec level, unlike plugins/claudecode-nvim.lua: fzf-lua is a core finder that must | ||
| -- load everywhere, and probing for `claude` while building this spec would cost a startup | ||
| -- `executable()` on every box AND miss a mid-session install. The action instead fails soft — on a | ||
| -- box without the CLI, claudecode is not on the runtimepath, `require` fails, and you get one | ||
| -- notify saying why rather than a stack trace. |
There was a problem hiding this comment.
Correct — removed in b8fcee8. The claim was wrong and, worse, contradicted a comment I wrote in the same feature: plugins/claudecode-nvim.lua already documents its cond as "Re-probed at startup only. Installing the CLI mid-session takes effect on the next launch." A spec gated off at startup is never added to the runtimepath, so a deferred require can't rescue it no matter when the key is pressed.
The remaining rationale stands without it: fzf-lua is a core finder that must load everywhere, so gating this spec would mean an executable() probe at startup on every box in the fleet.
The correction also improved the feature rather than just deleting a sentence — since the action runs only on a keypress, it can afford to probe properly, so "CLI installed after Neovim started → restart" is now one of the three distinct messages it reports (see the thread on line 39).
Generated by Claude Code
| if file and file.path and file.path ~= "" then | ||
| -- fzf-lua reports 1-indexed lines; send_at_mention documents its range as 0-indexed for | ||
| -- Claude. `files` entries have no line (0 here) — pass nil so it means the whole file. | ||
| local line = (file.line and file.line > 0) and (file.line - 1) or nil |
There was a problem hiding this comment.
Confirmed and fixed in b8fcee8 — this was a real bug, and the most important of the five.
providers/buffers.lua:183 serializes the cursor line into the entry:
bname = make_entry.lcol({ filename = bname, lnum = buf.info.lnum }, opts):gsub(":$", "")so <leader>fb → <C-y> was sending file + the one line you happened to be parked on, as a 1-line mention, instead of the buffer.
Fixed by gating on the picker rather than enumerating one, since the grep family has several members:
local sends_location = tostring(opts and opts.__resume_key or ""):find("grep", 1, true) ~= nil__resume_key is the provider identity. Every grep variant lands on a key containing grep — live_grep routes through M.grep → normalize_opts(opts, "grep"), and grep_curbuf → "grep_curbuf" — while files, buffers and oldfiles do not. Conservative on purpose: an unrecognised picker sends the whole file, because over-sending context is recoverable and silently sending one wrong line is not.
Verified per picker, feeding each an entry that carries a :lnum::
| Picker | Entry | Line sent |
|---|---|---|
buffers |
README.md:42: |
nil (whole file) |
files |
README.md |
nil |
oldfiles |
README.md:7: |
nil |
grep |
README.md:42:3:some match |
41 (0-indexed) |
Generated by Claude Code
| all. And unlike `plugins/claudecode-nvim.lua` this is **not** `cond`-gated: fzf-lua is a core | ||
| finder that must load everywhere, and probing for `claude` while building the spec would cost a | ||
| startup `executable()` on every box and still miss a mid-session install. The action fails soft | ||
| instead — without the CLI, `claudecode` is not on the runtimepath, the `pcall(require, …)` fails, | ||
| and you get one notify explaining why rather than a stack trace. |
There was a problem hiding this comment.
Fixed in b8fcee8 — same error, same removal. The CHANGELOG paragraph now reads:
unlike
plugins/claudecode-nvim.luathis is notcond-gated: fzf-lua is a core finder that must load everywhere, so gating the spec would mean anexecutable()probe at startup on every box in the fleet. The action fails soft instead, and since it only runs on a keypress it can afford to probe properly and name which failure it hit — CLI absent, CLI installed after Neovim started (thecondis evaluated once at startup, so a restart is needed), or the plugin genuinely failing to load, in which case the real error is shown rather than swallowed.
It also picks up the buffers-line correction as a third recorded detail. Worth fixing in the changelog specifically rather than only in the comment: /release-notes drafts from this file and the commit history, so an inaccurate rationale here would have propagated into the next release's notes.
Generated by Claude Code
| local ok, claudecode = pcall(require, "claudecode") | ||
| if not ok then | ||
| vim.notify( | ||
| "Claude Code is not available — the `claude` CLI is not installed on this machine, " | ||
| .. "so plugins/claudecode-nvim.lua is gated off (see :checkhealth gerrrt).", | ||
| vim.log.levels.WARN, | ||
| { title = "fzf-lua" } | ||
| ) | ||
| return | ||
| end |
There was a problem hiding this comment.
Agreed and implemented in b8fcee8. The message now separates three cases instead of assuming one:
| Condition | Message |
|---|---|
claude not on PATH |
"the claude CLI is not installed on this machine, so plugins/claudecode-nvim.lua is gated off (see :checkhealth gerrrt)." |
| On PATH, but spec absent from lazy | "claude is on PATH but was not there when Neovim started, and the plugin's cond is evaluated once at startup. Restart Neovim to enable it." |
On PATH, spec present, require failed |
"claudecode.nvim failed to load: <captured error>" |
The middle case falls out of the same correction you raised on line 28 — a CLI installed mid-session leaves the spec gated off, and that is worth saying explicitly rather than reporting as "not installed", since the fix (restart) is different.
Distinguishing case 2 from case 3 asks lazy whether the spec exists at all, pcall-guarded so a change in lazy's internals degrades to the load-error message rather than throwing inside the notify path:
local ok_lazy, lazy_cfg = pcall(require, "lazy.core.config")
if ok_lazy and not (lazy_cfg.plugins or {})["claudecode.nvim"] thenVerified all three by forcing each condition — stripping PATH, stubbing executable to report a just-installed binary, and injecting a package.preload that errors. Case 3 surfaces the real message: claudecode.nvim failed to load: [string ":lua"]:5: simulated: attempt to index nil (upstream regression).
Generated by Claude Code
…real failure Two real defects from review, plus a typo. 1. <leader>fb sent the wrong thing. entry_to_file reports a positive line for BUFFER entries too — providers/buffers.lua serializes each buffer's current cursor lnum into the entry via make_entry.lcol — so forwarding it unconditionally made the buffers picker send the single line you happened to be parked on instead of the file. Line forwarding is now restricted to the grep family (resume key containing "grep"; live_grep routes through M.grep → "grep", plus "grep_curbuf"), which is the only family where the location is meaningful. Conservative by design: over-sending context is recoverable, silently sending one wrong line is not. 2. The unavailable-message always blamed a missing CLI and discarded the actual error, so a broken or incompatible plugin looked like an installation problem. Because this path only runs on a keypress it can afford to probe properly, and now separates three cases: CLI absent; CLI present but installed after Neovim started (the spec's `cond` is evaluated once at startup, so lazy never put it on the runtimepath — restart); and the plugin genuinely failing to load, where the captured error is shown. 3. "LOad-BEARING" → "LOAD-BEARING". Also drops the claim that a deferred require rescues a mid-session install. It does not, and it contradicted the header of plugins/claudecode-nvim.lua, which already documents the cond as startup-only. The remaining rationale stands on its own: fzf-lua is a core finder that must load everywhere, so gating the spec would mean an executable() probe at startup on every box in the fleet. Verified: buffers/files/oldfiles entries carrying a `:lnum:` now send line=nil while a grep entry at line 42 still sends 41; and all three unavailable paths produce distinct messages, with the load-error case surfacing the real error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvMkCtfJugUuxz6HCa1NSZ
Follow-up to #316 / #317.
Why
The tree-based @-mention (
<leader>asin nvim-tree/oil) adds one file, so putting six files into Claude's context meant six tree navigations. fzf is already multi-select —--multiis on for the file pickers,<Tab>marks,<A-a>toggles all — so this turns "the files involved in this change" into one gesture.Two workflows it enables:
<leader>ff→<Tab>through the files in a refactor →<C-y>→ all of them in context.<leader>fg→ search a symbol → mark the call sites you care about → each is sent as its own location, not as the whole file.How
One
actions.filesentry covers<leader>ff/fg/fb/fr, since that's fzf-lua's shared action set for file-ish pickers.Entries go through
fzf-lua.path.entry_to_filerather than being parsed by hand — they carry devicon prefixes, and grep entries arefile:line:col:text. fzf-lua reports 1-indexed lines whilesend_at_mentiondocuments its range as 0-indexed, so the action converts.The bug this nearly shipped with
My first version set
actions.files = { ["ctrl-y"] = … }, which replaces fzf-lua's default action set instead of extending it. Diffing the resolved actions against a baseline build:That would have broken opening a file at all, plus every split/tab/quickfix action — and
make auditwould not have caught it.The fix is the leading
truein the action table, fzf-lua's inheritance marker (config.luaswitches the merge totbl_deep_extend("keep", yours, defaults)when[1] == true, then strips it). After:Key choice
ctrl-yis free in both layers — fzf's own keymap bindsctrl-atobeginning-of-lineandalt-atotoggle-all, and fzf-lua's default file actions never claimctrl-y(only the git pickers do, for yank-commit, which is a different action set).Not
cond-gated, unlike the plugin specfzf-lua is a core finder that must load everywhere. Probing for
claudewhile building the spec would cost a startupexecutable()on every box in the fleet and still miss a mid-session install. The action fails soft instead: without the CLI,claudecodeisn't on the runtimepath,pcall(require, …)fails, and you get one notify explaining why rather than a stack trace.Verification
make audit— 246 pass, 0 skip, 0 fail.luacheckclean across 98 files. (markdownlint caught an asterisk-emphasis violation in my CHANGELOG prose; fixed.)Exercised headlessly against the real config, with
send_at_mentionstubbed to observe exactly what gets sent:README.mdpath=/…/README.md start=nil end=nil(whole file)nvim/init.lua:10:3:…path=/…/nvim/init.lua start=9 end=9(0-indexed)claudeoffPATHAlso confirmed the
[1]marker is stripped from the resolved table, so it never reaches fzf as a bind.Generated by Claude Code