From 1db0d88b88e339d70797988021d83d64b7a01239 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Thu, 23 Jul 2026 14:29:45 -0400 Subject: [PATCH] feat: selection-scoped edits with full-file context Visually select a region and prompt to scope an edit to it. The whole buffer is always sent as context (so the model sees surrounding code), with the selection marked as the region to focus on; edits are patched against the full buffer, dropping the old selection-offset bookkeeping. Also bind the prompt keymap in visual mode ("x"), not just normal mode -- previously j did nothing on a selection, so the scoping path was unreachable. --- README.md | 1 + lua/jumpy/init.lua | 9 +++++---- lua/jumpy/llm.lua | 20 +++++++++++++++++++- lua/jumpy/prompt.lua | 30 +++++++++++++++++------------- tests/llm_spec.lua | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 18 deletions(-) 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({