My nvim configuration
feat(claude): add AI-powered code search to quickfix
Adds `<leader>cf` find command that prompts Claude to search the codebase and populate the quickfix list with results. Also wires up `<leader>gx` to stage all and run Claude commit in one step.
3 files changed, +121 -0
+114-0claude-nvim/lua/claude/find.lua
| @@ -0,0 +1,114 @@ | ||
| 1 | +local M = {} | |
| 2 | + | |
| 3 | +local skip_permissions = false | |
| 4 | + | |
| 5 | +function M.setup(opts) | |
| 6 | + if opts and opts.skip_permissions then skip_permissions = true end | |
| 7 | +end | |
| 8 | + | |
| 9 | +local function strip_fences(text) | |
| 10 | + local lines = vim.split(text or "", "\n", { plain = true }) | |
| 11 | + while #lines > 0 and lines[#lines] == "" do table.remove(lines) end | |
| 12 | + if #lines >= 2 and lines[1]:match("^```") then | |
| 13 | + table.remove(lines, 1) | |
| 14 | + if lines[#lines] and lines[#lines]:match("^```%s*$") then | |
| 15 | + table.remove(lines) | |
| 16 | + end | |
| 17 | + end | |
| 18 | + return table.concat(lines, "\n") | |
| 19 | +end | |
| 20 | + | |
| 21 | +local function extract_json_array(text) | |
| 22 | + local s = text:find("%[") | |
| 23 | + local e = text:reverse():find("%]") | |
| 24 | + if not s or not e then return text end | |
| 25 | + return text:sub(s, #text - e + 1) | |
| 26 | +end | |
| 27 | + | |
| 28 | +local function open_popup(on_submit) | |
| 29 | + local buf = vim.api.nvim_create_buf(false, true) | |
| 30 | + vim.bo[buf].bufhidden = "wipe" | |
| 31 | + vim.bo[buf].filetype = "markdown" | |
| 32 | + local width = math.min(80, math.max(40, math.floor(vim.o.columns * 0.6))) | |
| 33 | + local height = 6 | |
| 34 | + local win = vim.api.nvim_open_win(buf, true, { | |
| 35 | + relative = "editor", | |
| 36 | + row = math.floor((vim.o.lines - height) / 2), | |
| 37 | + col = math.floor((vim.o.columns - width) / 2), | |
| 38 | + width = width, | |
| 39 | + height = height, | |
| 40 | + style = "minimal", | |
| 41 | + border = "rounded", | |
| 42 | + title = " Claude find — <C-s> search <C-c> cancel ", | |
| 43 | + title_pos = "center", | |
| 44 | + }) | |
| 45 | + vim.cmd("startinsert") | |
| 46 | + local closed = false | |
| 47 | + local function close() | |
| 48 | + if closed then return end | |
| 49 | + closed = true | |
| 50 | + pcall(vim.cmd, "stopinsert") | |
| 51 | + if vim.api.nvim_win_is_valid(win) then | |
| 52 | + vim.api.nvim_win_close(win, true) | |
| 53 | + end | |
| 54 | + end | |
| 55 | + local function submit() | |
| 56 | + if closed then return end | |
| 57 | + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) | |
| 58 | + local q = table.concat(lines, "\n"):gsub("^%s+", ""):gsub("%s+$", "") | |
| 59 | + close() | |
| 60 | + if q == "" then return end | |
| 61 | + on_submit(q) | |
| 62 | + end | |
| 63 | + local km = { buffer = buf, nowait = true, silent = true } | |
| 64 | + vim.keymap.set({ "n", "i" }, "<C-s>", submit, km) | |
| 65 | + vim.keymap.set({ "n", "i" }, "<C-c>", close, km) | |
| 66 | + vim.keymap.set("n", "q", close, km) | |
| 67 | + vim.keymap.set("n", "<Esc>", close, km) | |
| 68 | +end | |
| 69 | + | |
| 70 | +function M.find() | |
| 71 | + open_popup(function(query) | |
| 72 | + vim.notify("claude: searching for '" .. query .. "'…") | |
| 73 | + local prompt = "You are searching this codebase for snippets relevant to the user's query. " | |
| 74 | + .. "Use the Grep, Glob, and Read tools as needed to find them. Return up to 20 results. " | |
| 75 | + .. "Output ONLY a JSON array (no markdown fences, no commentary, no prose) of objects with keys: " | |
| 76 | + .. 'path (absolute file path string), lnum (1-indexed integer line number), snippet (the matching line content, trimmed of leading/trailing whitespace).\n\n' | |
| 77 | + .. "Query: " .. query | |
| 78 | + local cmd = { "claude", "-p", prompt } | |
| 79 | + if skip_permissions then table.insert(cmd, "--dangerously-skip-permissions") end | |
| 80 | + vim.system(cmd, { text = true }, vim.schedule_wrap(function(out) | |
| 81 | + if out.code ~= 0 then | |
| 82 | + vim.notify("claude failed: " .. (out.stderr or ""), vim.log.levels.ERROR) | |
| 83 | + return | |
| 84 | + end | |
| 85 | + local raw = strip_fences(out.stdout or "") | |
| 86 | + local json_text = extract_json_array(raw) | |
| 87 | + local ok, results = pcall(vim.json.decode, json_text) | |
| 88 | + if not ok or type(results) ~= "table" then | |
| 89 | + vim.notify("claude: could not parse JSON response", vim.log.levels.ERROR) | |
| 90 | + return | |
| 91 | + end | |
| 92 | + local items = { { text = "▌ " .. query, valid = 0 } } | |
| 93 | + for _, r in ipairs(results) do | |
| 94 | + if type(r) == "table" and r.path and r.lnum then | |
| 95 | + table.insert(items, { | |
| 96 | + filename = tostring(r.path), | |
| 97 | + lnum = tonumber(r.lnum) or 1, | |
| 98 | + text = ((r.snippet or "")):gsub("^%s+", ""):gsub("%s+$", ""), | |
| 99 | + }) | |
| 100 | + end | |
| 101 | + end | |
| 102 | + if #items <= 1 then | |
| 103 | + vim.notify("claude: no results for '" .. query .. "'", vim.log.levels.WARN) | |
| 104 | + return | |
| 105 | + end | |
| 106 | + vim.fn.setqflist({}, " ", { title = query, items = items }) | |
| 107 | + vim.cmd("copen") | |
| 108 | + pcall(vim.cmd, "cc 2") | |
| 109 | + pcall(function() require("claude.highlights").refresh_all() end) | |
| 110 | + end)) | |
| 111 | + end) | |
| 112 | +end | |
| 113 | + | |
| 114 | +return M |
+3-0claude-nvim/lua/claude/init.lua
| @@ -7,6 +7,7 @@ local pickers = require("claude.pickers") | ||
| 7 | 7 | local edit = require("claude.edit") |
| 8 | 8 | local timetravel = require("claude.timetravel") |
| 9 | 9 | local commit = require("claude.commit") |
| 10 | +local find = require("claude.find") | |
| 10 | 11 | |
| 11 | 12 | ---@class ClaudeConfig |
| 12 | 13 | local defaults = { |
| @@ -31,6 +32,7 @@ function M.setup(opts) | ||
| 31 | 32 | highlights.setup(opts) |
| 32 | 33 | edit.setup(opts) |
| 33 | 34 | commit.setup(opts) |
| 35 | + find.setup(opts) | |
| 34 | 36 | |
| 35 | 37 | vim.keymap.set("n", "<leader>c", "", { desc = "Claude" }) |
| 36 | 38 | |
| @@ -39,6 +41,7 @@ function M.setup(opts) | ||
| 39 | 41 | vim.keymap.set("n", "<leader>cu", timetravel.undo, { desc = "Claude: Undo last claude change" }) |
| 40 | 42 | vim.keymap.set("n", "<leader>cU", timetravel.redo, { desc = "Claude: Redo claude change" }) |
| 41 | 43 | vim.keymap.set("n", "<leader>cg", commit.commit, { desc = "Claude: Commit staged with generated message" }) |
| 44 | + vim.keymap.set("n", "<leader>cf", find.find, { desc = "Claude: Find code snippets to quickfix" }) | |
| 42 | 45 | vim.keymap.set("n", "<leader>ct", ui.toggle, { desc = "Claude: Toggle panel" }) |
| 43 | 46 | vim.keymap.set("n", "<leader>cn", ui.start_new, { desc = "Claude: New session" }) |
| 44 | 47 | vim.keymap.set("n", "<leader>cr", ui.resume_last, { desc = "Claude: Resume last session" }) |
+4-0lua/extensions/git-extensions.lua
| @@ -141,6 +141,10 @@ M.setup = function(opts) | ||
| 141 | 141 | M.register_keymap("c", ":Gitsigns setqflist<cr>", "Quickfix Current Buffer Hunks") |
| 142 | 142 | M.register_keymap("a", ":Git add -A<cr>", "Add All Files") |
| 143 | 143 | M.register_keymap("p", ":Git push<cr>", "Push") |
| 144 | + M.register_keymap("x", function() | |
| 145 | + vim.fn.system("git add -A") | |
| 146 | + require("claude.commit").commit() | |
| 147 | + end, "Add All + Claude Commit") | |
| 144 | 148 | |
| 145 | 149 | -- Jump maps |
| 146 | 150 | vim.keymap.set("n", "]h", ":Gitsigns next_hunk<cr>", { desc = "Next Hunk" }) |