diff --git a/README.md b/README.md index 443585c..b1cbdaa 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The main difference between jumpy and other tools, like 99, are: # - **inline prompt**: describe a change without leaving the buffer +- **selection-scoped edits**: visually select a function (or any region) and prompt — the whole buffer is still sent as context so the model understands the surroundings, while it focuses on your selection - **multi-line prompt + history**: `` submits, `` adds a newline, ``/`` recall earlier prompts - **async & parallel prompts**: requests run in the background; each response lands in its own buffer (`:JumpyCancel` aborts everything in flight) - **multi-file prompts**: `@mention` several files in one prompt and review the cross-file hunks diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua index 96f1aec..afed2ad 100644 --- a/lua/jumpy/init.lua +++ b/lua/jumpy/init.lua @@ -130,7 +130,6 @@ function M._setup_highlights() hl(0, "JumpyRemoved", { bg = "#3a1a1a", strikethrough = true, default = true }) hl(0, "JumpyAddedSign", { fg = "#4ec94e", default = true }) hl(0, "JumpyRemovedSign", { fg = "#e05252", default = true }) - -- Inspect overlay: a read-only, yellow before/after view of a recorded hunk. hl(0, "JumpyInspect", { bg = "#3a3320", default = true }) hl(0, "JumpyInspectSign", { fg = "#d7b95a", default = true }) hl(0, "JumpyInspectOther", { fg = "#8a7a3a", italic = true, default = true }) @@ -149,8 +148,10 @@ function M._setup_keymaps() local opts = { silent = true } local c = M.config.keymaps + -- The prompt is also bound in visual mode ("x") so you can select a region + -- and scope the edit to it; everything else is normal-mode only. local keymaps = { - { c.prompt, "jumpy.prompt", "open" }, + { c.prompt, "jumpy.prompt", "open", { "n", "x" } }, { c.next_hunk, "jumpy.navigate", "next_hunk" }, { c.prev_hunk, "jumpy.navigate", "prev_hunk" }, { c.accept, "jumpy.navigate", "accept" }, @@ -163,8 +164,8 @@ function M._setup_keymaps() } for _, km in ipairs(keymaps) do - local key, mod, fn = km[1], km[2], km[3] - map("n", key, function() + local key, mod, fn, modes = km[1], km[2], km[3], km[4] or "n" + map(modes, key, function() require(mod)[fn]() end, opts) end diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index 280260a..01274ea 100644 --- a/lua/jumpy/llm.lua +++ b/lua/jumpy/llm.lua @@ -36,11 +36,29 @@ local function build_messages(context) } end + -- A visual selection marks the region to focus on. The full file is still + -- sent for context; the model is asked to prefer changing only the selection. + local selection_note = "" + local sel = context.selection + if sel and sel.lines and #sel.lines > 0 then + selection_note = string.format( + "\n\nFocus on the SELECTED region below (lines %d-%d). Use the rest of the " + .. "file as context and prefer to change only the selection:\n" + .. "--- SELECTION (lines %d-%d) ---\n%s\n--- END SELECTION ---", + sel.start_line or 0, + sel.end_line or 0, + sel.start_line or 0, + sel.end_line or 0, + table.concat(sel.lines, "\n") + ) + end + local user_content = string.format( - "File type: %s\n\n--- FILE CONTENTS ---\n%s\n--- END FILE ---%s\n\nInstruction: %s", + "File type: %s\n\n--- FILE CONTENTS ---\n%s\n--- END FILE ---%s%s\n\nInstruction: %s", context.filetype or "text", context.file_contents, context.symbols, + selection_note, context.prompt ) diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index ef40897..76d5363 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -399,8 +399,18 @@ function M._submit() local source_buf = state.source_buf local visual_selection = state.visual_selection - local source_lines = visual_selection and vim.split(visual_selection.text, "\n", { plain = true }) - or vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) + -- Always send the whole buffer so the model has surrounding context; a visual + -- selection just marks the region to focus on (see `selection` in the context + -- and jumpy.llm). Edits are patched against the full buffer. + local source_lines = vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) + + local selection = visual_selection + and { + start_line = visual_selection.start_line, + end_line = visual_selection.end_line, + lines = vim.split(visual_selection.text, "\n", { plain = true }), + } + or nil local source_name = vim.api.nvim_buf_get_name(source_buf) local source_rel = source_name ~= "" and path.rel_path(source_name, path.project_root()) or "current" @@ -582,6 +592,7 @@ function M._submit() prompt = cleaned_prompt, symbols = symbols, filetype = filetype, + selection = selection, } llm.request(context, function(response_text) @@ -595,10 +606,10 @@ function M._submit() local render = require("jumpy.render") local patch = require("jumpy.patch") - -- Full-buffer prompts re-read the buffer so parallel work done in - -- the meantime is respected; visual-selection prompts keep the - -- snapshot since they target a fixed region. - local original = visual_selection and source_lines or vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) + -- Re-read the buffer so parallel work done while the request was in + -- flight is respected. A selection only guided the model; the patch + -- and resulting hunks are computed against the whole buffer. + local original = vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) local proposed_lines, unmatched = patch.apply(original, response_text) @@ -607,13 +618,6 @@ function M._submit() end local hunks = diff.compute(original, proposed_lines) - if visual_selection then - local offset = visual_selection.start_line - 1 - - for _, hunk in ipairs(hunks) do - hunk.old_start = hunk.old_start + offset - end - end if #hunks == 0 then vim.notify("jumpy: no changes proposed", vim.log.levels.INFO) diff --git a/tests/llm_spec.lua b/tests/llm_spec.lua index 19b17bb..9335a62 100644 --- a/tests/llm_spec.lua +++ b/tests/llm_spec.lua @@ -55,6 +55,42 @@ describe("llm Claude Code command", function() end) end) +describe("llm single-file selection", function() + it("sends the whole file plus a marked selection region", function() + local msgs = llm._build_messages({ + filetype = "lua", + file_contents = "local a = 1\nlocal b = 2\nlocal c = 3", + symbols = "", + prompt = "rename b", + selection = { + start_line = 2, + end_line = 2, + lines = { "local b = 2" }, + }, + }) + + local user = msgs[2].content + -- full file is present as context + assert.is_truthy(user:find("local a = 1", 1, true)) + assert.is_truthy(user:find("local c = 3", 1, true)) + -- the selection is called out with its line range + assert.is_truthy(user:find("--- SELECTION (lines 2-2) ---", 1, true)) + assert.is_truthy(user:find("Focus on the SELECTED region", 1, true)) + assert.is_truthy(user:find("Instruction: rename b", 1, true)) + end) + + it("omits the selection note when there is no selection", function() + local msgs = llm._build_messages({ + filetype = "lua", + file_contents = "local a = 1", + symbols = "", + prompt = "do it", + }) + + assert.is_nil(msgs[2].content:find("SELECTION", 1, true)) + end) +end) + describe("llm multi-file messages", function() it("names the current file so relative references resolve", function() local msgs = llm._build_messages({