My nvim configuration
feat: visual mode claude
3 files changed, +178 -19
+171-0claude-nvim/lua/claude/edit.lua
| @@ -0,0 +1,171 @@ | ||
| 1 | +local M = {} | |
| 2 | + | |
| 3 | +local spinner_frames = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } | |
| 4 | +local ns = vim.api.nvim_create_namespace("claude_edit") | |
| 5 | +local skip_permissions = false | |
| 6 | + | |
| 7 | +function M.setup(opts) | |
| 8 | + if opts and opts.skip_permissions then | |
| 9 | + skip_permissions = true | |
| 10 | + end | |
| 11 | +end | |
| 12 | + | |
| 13 | +local function strip_fences(text) | |
| 14 | + local lines = vim.split(text, "\n", { plain = true }) | |
| 15 | + while #lines > 0 and lines[#lines] == "" do | |
| 16 | + table.remove(lines) | |
| 17 | + end | |
| 18 | + if #lines >= 2 and lines[1]:match("^```") then | |
| 19 | + table.remove(lines, 1) | |
| 20 | + if lines[#lines] and lines[#lines]:match("^```%s*$") then | |
| 21 | + table.remove(lines) | |
| 22 | + end | |
| 23 | + end | |
| 24 | + return lines | |
| 25 | +end | |
| 26 | + | |
| 27 | +local function start_spinner(buf, row) | |
| 28 | + local idx = 1 | |
| 29 | + local function virt(i) | |
| 30 | + return { { " " .. spinner_frames[i] .. " claude…", "Comment" } } | |
| 31 | + end | |
| 32 | + local ok, mark = pcall(vim.api.nvim_buf_set_extmark, buf, ns, row, 0, { | |
| 33 | + virt_text = virt(idx), | |
| 34 | + virt_text_pos = "eol", | |
| 35 | + hl_mode = "combine", | |
| 36 | + }) | |
| 37 | + if not ok then return function() end end | |
| 38 | + | |
| 39 | + local timer = vim.uv.new_timer() | |
| 40 | + timer:start(80, 80, vim.schedule_wrap(function() | |
| 41 | + if not vim.api.nvim_buf_is_valid(buf) then | |
| 42 | + timer:stop(); timer:close() | |
| 43 | + return | |
| 44 | + end | |
| 45 | + idx = idx % #spinner_frames + 1 | |
| 46 | + pcall(vim.api.nvim_buf_set_extmark, buf, ns, row, 0, { | |
| 47 | + id = mark, | |
| 48 | + virt_text = virt(idx), | |
| 49 | + virt_text_pos = "eol", | |
| 50 | + hl_mode = "combine", | |
| 51 | + }) | |
| 52 | + end)) | |
| 53 | + | |
| 54 | + return function() | |
| 55 | + if not timer:is_closing() then | |
| 56 | + timer:stop(); timer:close() | |
| 57 | + end | |
| 58 | + pcall(vim.api.nvim_buf_del_extmark, buf, ns, mark) | |
| 59 | + end | |
| 60 | +end | |
| 61 | + | |
| 62 | +local function run_claude(prompt, selection, on_done) | |
| 63 | + local full = string.format( | |
| 64 | + "Apply the following instruction to the code below. Output ONLY the resulting replacement code. No explanation, no commentary, no markdown code fences.\n\nInstruction:\n%s\n\nCode:\n%s", | |
| 65 | + prompt, selection | |
| 66 | + ) | |
| 67 | + local cmd = { "claude", "-p", full } | |
| 68 | + if skip_permissions then | |
| 69 | + table.insert(cmd, "--dangerously-skip-permissions") | |
| 70 | + end | |
| 71 | + vim.system(cmd, { text = true }, vim.schedule_wrap(function(out) | |
| 72 | + if out.code ~= 0 then | |
| 73 | + on_done(nil, (out.stderr ~= "" and out.stderr) or ("claude exited " .. tostring(out.code))) | |
| 74 | + else | |
| 75 | + on_done(out.stdout or "", nil) | |
| 76 | + end | |
| 77 | + end)) | |
| 78 | +end | |
| 79 | + | |
| 80 | +function M.prompt_visual() | |
| 81 | + -- Capture selection text + range | |
| 82 | + local old_reg = vim.fn.getreg('"') | |
| 83 | + local old_regtype = vim.fn.getregtype('"') | |
| 84 | + vim.cmd('noautocmd normal! "vy') | |
| 85 | + local selection = vim.fn.getreg("v") | |
| 86 | + vim.fn.setreg('"', old_reg, old_regtype) | |
| 87 | + | |
| 88 | + local s = vim.fn.getpos("'<") | |
| 89 | + local e = vim.fn.getpos("'>") | |
| 90 | + local target_buf = vim.api.nvim_get_current_buf() | |
| 91 | + local start_row = s[2] - 1 | |
| 92 | + local end_row = e[2] - 1 | |
| 93 | + | |
| 94 | + if selection == nil or selection == "" then | |
| 95 | + vim.notify("claude: empty selection", vim.log.levels.WARN) | |
| 96 | + return | |
| 97 | + end | |
| 98 | + | |
| 99 | + -- Floating popup | |
| 100 | + local pop_buf = vim.api.nvim_create_buf(false, true) | |
| 101 | + vim.bo[pop_buf].bufhidden = "wipe" | |
| 102 | + vim.bo[pop_buf].filetype = "markdown" | |
| 103 | + | |
| 104 | + local width = math.min(80, math.max(40, math.floor(vim.o.columns * 0.6))) | |
| 105 | + local height = 8 | |
| 106 | + local pop_win = vim.api.nvim_open_win(pop_buf, true, { | |
| 107 | + relative = "editor", | |
| 108 | + row = math.floor((vim.o.lines - height) / 2), | |
| 109 | + col = math.floor((vim.o.columns - width) / 2), | |
| 110 | + width = width, | |
| 111 | + height = height, | |
| 112 | + style = "minimal", | |
| 113 | + border = "rounded", | |
| 114 | + title = " Claude prompt — <C-s> apply <C-c> cancel ", | |
| 115 | + title_pos = "center", | |
| 116 | + }) | |
| 117 | + | |
| 118 | + vim.cmd("startinsert") | |
| 119 | + | |
| 120 | + local closed = false | |
| 121 | + local function close_popup() | |
| 122 | + if closed then return end | |
| 123 | + closed = true | |
| 124 | + if vim.api.nvim_win_is_valid(pop_win) then | |
| 125 | + vim.api.nvim_win_close(pop_win, true) | |
| 126 | + end | |
| 127 | + end | |
| 128 | + | |
| 129 | + local function apply() | |
| 130 | + if closed then return end | |
| 131 | + local lines = vim.api.nvim_buf_get_lines(pop_buf, 0, -1, false) | |
| 132 | + local prompt = table.concat(lines, "\n") | |
| 133 | + close_popup() | |
| 134 | + if prompt:match("^%s*$") then | |
| 135 | + vim.notify("claude: empty prompt", vim.log.levels.WARN) | |
| 136 | + return | |
| 137 | + end | |
| 138 | + | |
| 139 | + if not vim.api.nvim_buf_is_valid(target_buf) then return end | |
| 140 | + local stop_spin = start_spinner(target_buf, start_row) | |
| 141 | + | |
| 142 | + run_claude(prompt, selection, function(stdout, err) | |
| 143 | + stop_spin() | |
| 144 | + if err then | |
| 145 | + vim.notify("claude: " .. err, vim.log.levels.ERROR) | |
| 146 | + return | |
| 147 | + end | |
| 148 | + if not vim.api.nvim_buf_is_valid(target_buf) then return end | |
| 149 | + local out_lines = strip_fences(stdout or "") | |
| 150 | + if #out_lines == 0 then | |
| 151 | + vim.notify("claude: empty response", vim.log.levels.WARN) | |
| 152 | + return | |
| 153 | + end | |
| 154 | + vim.api.nvim_buf_set_lines(target_buf, start_row, end_row + 1, false, out_lines) | |
| 155 | + end) | |
| 156 | + end | |
| 157 | + | |
| 158 | + local km = { buffer = pop_buf, nowait = true, silent = true } | |
| 159 | + vim.keymap.set({ "n", "i" }, "<C-s>", apply, km) | |
| 160 | + vim.keymap.set({ "n", "i" }, "<C-c>", close_popup, km) | |
| 161 | + vim.keymap.set("n", "q", close_popup, km) | |
| 162 | + vim.keymap.set("n", "<Esc>", close_popup, km) | |
| 163 | + | |
| 164 | + vim.api.nvim_create_autocmd("BufLeave", { | |
| 165 | + buffer = pop_buf, | |
| 166 | + once = true, | |
| 167 | + callback = close_popup, | |
| 168 | + }) | |
| 169 | +end | |
| 170 | + | |
| 171 | +return M |
+6-19claude-nvim/lua/claude/init.lua
| @@ -2,6 +2,7 @@ local M = {} | ||
| 2 | 2 | |
| 3 | 3 | local terminal = require("claude.terminal") |
| 4 | 4 | local session = require("claude.session") |
| 5 | +local edit = require("claude.edit") | |
| 5 | 6 | |
| 6 | 7 | ---@class ClaudeConfig |
| 7 | 8 | local defaults = { |
| @@ -19,31 +20,17 @@ function M.setup(opts) | ||
| 19 | 20 | opts = vim.tbl_deep_extend("force", defaults, opts or {}) |
| 20 | 21 | terminal.setup(opts) |
| 21 | 22 | session.setup(opts) |
| 23 | + edit.setup(opts) | |
| 22 | 24 | |
| 23 | 25 | vim.keymap.set("n", "<leader>c", "", { desc = "Claude" }) |
| 24 | 26 | |
| 25 | 27 | -- Toggle claude panel (resumes last session) |
| 26 | 28 | vim.keymap.set("n", "<leader>ct", session.continue, { desc = "Claude: Toggle panel" }) |
| 27 | 29 | |
| 28 | - -- Send visual selection to claude | |
| 29 | - vim.keymap.set("v", "<leader>cs", function() | |
| 30 | - -- Yank selection into register | |
| 31 | - local old_reg = vim.fn.getreg('"') | |
| 32 | - local old_regtype = vim.fn.getregtype('"') | |
| 33 | - vim.cmd('noautocmd normal! "vy') | |
| 34 | - local selection = vim.fn.getreg("v") | |
| 35 | - vim.fn.setreg('"', old_reg, old_regtype) | |
| 36 | - | |
| 37 | - if not terminal.is_open() then | |
| 38 | - session.continue() | |
| 39 | - -- Wait for terminal to be ready | |
| 40 | - vim.defer_fn(function() | |
| 41 | - terminal.send(selection .. "\n") | |
| 42 | - end, 500) | |
| 43 | - else | |
| 44 | - terminal.send(selection .. "\n") | |
| 45 | - end | |
| 46 | - end, { desc = "Claude: Send selection" }) | |
| 30 | + -- Visual <leader>c → prompt popup that edits selection in place via claude -p | |
| 31 | + vim.keymap.set("v", "<leader>c", function() | |
| 32 | + edit.prompt_visual() | |
| 33 | + end, { desc = "Claude: Edit selection with prompt" }) | |
| 47 | 34 | |
| 48 | 35 | -- Send current buffer to claude |
| 49 | 36 | vim.keymap.set("n", "<leader>cb", function() |
+1-0lua/keymaps.lua
| @@ -118,6 +118,7 @@ easymap("n", "g;", "m'A;<esc>`'", "Append Colon") | ||
| 118 | 118 | easymap("n", "g,", "m'A,<esc>`'", "Append Comma") |
| 119 | 119 | easymap("n", "gl", "", "LSP") |
| 120 | 120 | easymap("n", "glt", ":ToggleDiagnostics<cr>", "Toggle") |
| 121 | +vim.keymap.set("n", "gc", require("telescope.builtin").grep_string, { desc= "Telescope word"}) | |
| 121 | 122 | |
| 122 | 123 | -- ########################################################################## -- |
| 123 | 124 | -- Surroundings |