From d71b55761db074b2340b27f78eec53f50b8c7fbb Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 20 Jul 2026 18:00:22 -0400 Subject: [PATCH 1/2] feat: async/parallel prompts with per-request lifecycle (#13) Replace the global loading/job state with a request registry so multiple prompts can run in parallel across buffers. Requests targeting the same file are blocked while one is in flight, responses apply against fresh buffer lines, completions no longer steal the cursor from other buffers, and the jumpy quickfix list refreshes as hunks land. Adds :JumpyCancel and a refcounted spinner that shows in-flight request count. --- README.md | 4 ++ lua/jumpy/init.lua | 3 + lua/jumpy/llm.lua | 143 +++++++++++++++++++++++----------------- lua/jumpy/loading.lua | 137 +++++++++++++++++++++++--------------- lua/jumpy/prompt.lua | 79 +++++++++++++++++----- lua/jumpy/requests.lua | 98 +++++++++++++++++++++++++++ tests/requests_spec.lua | 68 +++++++++++++++++++ 7 files changed, 404 insertions(+), 128 deletions(-) create mode 100644 lua/jumpy/requests.lua create mode 100644 tests/requests_spec.lua diff --git a/README.md b/README.md index 8dd1e6f..d55966c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ The main difference between jumpy and other tools, like 99, are: Some of the existing features are to be fleshed out, but the sole purpose is to know exactly what I am letting the LLM write into my code. I have no interest in letting it change everything, and only _then_ can I go back and review every change. That being said, here are the features of jumpy: - **inline prompt**: describe a change without leaving the buffer +- **async & parallel prompts**: requests run in the background — prompt one buffer, move to another and prompt again; each response lands in its own buffer independently (`:JumpyCancel` aborts everything in flight) +- **multi-file prompts**: `@mention` several files in one prompt and review the cross-file hunks via quickfix - **search/replace hunks**: LLM returns only changed sections, not whole files - **in-buffer diff review**: proposed edits shown inline with accept/reject before anything is written - **per-hunk control**: accept, reject, or reprompt individual hunks @@ -107,6 +109,8 @@ user to API billing. Set `claude_code_command` in `setup()` if `claude` is not o In the Jumpy quickfix list, use `a` to accept or `x` to reject the selected hunk. Jumpy advances to the next pending hunk, including hunks in other files. +`:JumpyCancel` cancels every request currently in flight. + ## License MIT diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua index d37fc5e..8b81239 100644 --- a/lua/jumpy/init.lua +++ b/lua/jumpy/init.lua @@ -100,6 +100,9 @@ function M.auto_setup() vim.api.nvim_create_user_command("Jumpy", function() require("jumpy.prompt").open() end, { desc = "Open Jumpy prompt" }) + vim.api.nvim_create_user_command("JumpyCancel", function() + require("jumpy.loading").cancel() + end, { desc = "Cancel all in-flight Jumpy requests" }) end function M._setup_highlights() diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index 3859a5a..eb20f5b 100644 --- a/lua/jumpy/llm.lua +++ b/lua/jumpy/llm.lua @@ -161,10 +161,12 @@ function M._build_claude_code_cmd(messages, config) return cmd end -local function make_claude_code_request(messages, callback) +local function make_claude_code_request(messages, callback, opts) + opts = opts or {} local config = get_config() local command = config.claude_code_command or "claude" local loading = require("jumpy.loading") + local requests = require("jumpy.requests") if vim.fn.executable(command) ~= 1 then loading.error("Claude Code CLI not found; install it or set claude_code_command") @@ -172,9 +174,29 @@ local function make_claude_code_request(messages, callback) end local env = claude_code_env() - local cancelled = false + + local request_id = requests.begin({ label = opts.label, targets = opts.targets }) loading.start() + -- Every terminal path must release exactly one spinner refcount and drop + -- the request from the registry. + local finished = false + local function finish() + if finished then + return + end + finished = true + requests.finish(request_id) + loading.stop() + end + + local function fail(msg) + vim.schedule(function() + finish() + loading.error(msg) + end) + end + local auth_jid auth_jid = vim.fn.jobstart({ command, "auth", "status" }, { env = env, @@ -182,17 +204,12 @@ local function make_claude_code_request(messages, callback) stdout_buffered = true, stderr_buffered = true, on_exit = function(_, exit_code) - if not loading.is_active() then - cancelled = true - end - if cancelled then - loading.stop() + if requests.is_cancelled(request_id) then + finish() return end if exit_code ~= 0 then - vim.schedule(function() - loading.error("Claude Code is not authenticated; run 'claude login' and select your Claude plan") - end) + fail("Claude Code is not authenticated; run 'claude login' and select your Claude plan") return end @@ -221,23 +238,18 @@ local function make_claude_code_request(messages, callback) end end, on_exit = function(_, request_exit_code) - if not loading.is_active() then - cancelled = true - end - if cancelled then - loading.stop() + if requests.is_cancelled(request_id) then + finish() return end local stderr_text = table.concat(stderr_chunks, "\n") if request_exit_code ~= 0 then - vim.schedule(function() - local msg = "Claude Code request failed (exit " .. request_exit_code .. ")" - if stderr_text ~= "" then - msg = msg .. " — " .. stderr_text - end - loading.error(msg) - end) + local msg = "Claude Code request failed (exit " .. request_exit_code .. ")" + if stderr_text ~= "" then + msg = msg .. " — " .. stderr_text + end + fail(msg) return end @@ -245,38 +257,39 @@ local function make_claude_code_request(messages, callback) local ok, parsed = pcall(vim.fn.json_decode, raw) local content = ok and extract_content_claude_code(parsed) or nil if type(content) ~= "string" or content == "" then - vim.schedule(function() - loading.error("Claude Code returned an invalid response (update the CLI and try again)") - end) + fail("Claude Code returned an invalid response (update the CLI and try again)") return end - loading.stop() + finish() content = content:gsub("^```[%w]*\n", ""):gsub("\n```%s*$", "") callback(content) end, }) if request_jid <= 0 then + finish() loading.error("failed to start Claude Code CLI") else - loading.set_job(request_jid) + requests.set_job(request_id, request_jid) end end, }) if auth_jid <= 0 then + finish() loading.error("failed to start Claude Code CLI") else - loading.set_job(auth_jid) + requests.set_job(request_id, auth_jid) end end -local function make_request(messages, callback) +local function make_request(messages, callback, opts) + opts = opts or {} local config = get_config() if is_claude_code() then - make_claude_code_request(messages, callback) + make_claude_code_request(messages, callback, opts) return end @@ -322,9 +335,30 @@ local function make_request(messages, callback) local response_chunks = {} local stderr_chunks = {} local loading = require("jumpy.loading") - local cancelled = false + local requests = require("jumpy.requests") + + local request_id = requests.begin({ label = opts.label, targets = opts.targets }) loading.start() + -- Every terminal path must release exactly one spinner refcount and drop + -- the request from the registry. + local finished = false + local function finish() + if finished then + return + end + finished = true + requests.finish(request_id) + loading.stop() + end + + local function fail(msg) + vim.schedule(function() + finish() + loading.error(msg) + end) + end + local jid jid = vim.fn.jobstart(cmd, { stdout_buffered = true, @@ -346,25 +380,19 @@ local function make_request(messages, callback) end end, on_exit = function(_, exit_code) - if not loading.is_active() then - cancelled = true - end - - if cancelled then - loading.stop() + if requests.is_cancelled(request_id) then + finish() return end local stderr_text = table.concat(stderr_chunks, "\n") if exit_code ~= 0 then - vim.schedule(function() - local msg = "request failed (curl exit " .. exit_code .. ")" - if stderr_text ~= "" then - msg = msg .. " — " .. stderr_text - end - loading.error(msg) - end) + local msg = "request failed (curl exit " .. exit_code .. ")" + if stderr_text ~= "" then + msg = msg .. " — " .. stderr_text + end + fail(msg) return end @@ -374,17 +402,15 @@ local function make_request(messages, callback) if not ok then vim.schedule(function() local preview = vim.fn.strcharpart(vim.fn.substitute(raw, "\n", " ", "g"), 0, 120) - loading.error("response was not JSON: " .. preview) + fail("response was not JSON: " .. preview) end) return end if parsed.error then - vim.schedule(function() - local err = parsed.error - local msg = type(err) == "table" and (err.message or vim.inspect(err)) or tostring(err) - loading.error("API error: " .. msg) - end) + local err = parsed.error + local msg = type(err) == "table" and (err.message or vim.inspect(err)) or tostring(err) + fail("API error: " .. msg) return end @@ -396,13 +422,11 @@ local function make_request(messages, callback) end if not content then - vim.schedule(function() - loading.error("empty response from LLM (check model / response shape)") - end) + fail("empty response from LLM (check model / response shape)") return end - loading.stop() + finish() content = content:gsub("^```[%w]*\n", ""):gsub("\n```%s*$", "") callback(content) @@ -410,18 +434,19 @@ local function make_request(messages, callback) }) if jid <= 0 then + finish() loading.error("failed to start curl — is it installed?") else - loading.set_job(jid) + requests.set_job(request_id, jid) end end -function M.request(context, callback) +function M.request(context, callback, opts) local messages = build_messages(context) - make_request(messages, callback) + make_request(messages, callback, opts) end -function M.reprompt(context, callback) +function M.reprompt(context, callback, opts) local messages = build_reprompt_messages(context) make_request(messages, function(content) local patch = require("jumpy.patch") @@ -432,7 +457,7 @@ function M.reprompt(context, callback) end) end callback(new_lines) - end) + end, opts) end return M diff --git a/lua/jumpy/loading.lua b/lua/jumpy/loading.lua index 7f3f9f7..2c5181f 100644 --- a/lua/jumpy/loading.lua +++ b/lua/jumpy/loading.lua @@ -2,23 +2,33 @@ local M = {} local FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } +-- Refcounted spinner: each in-flight request calls start()/stop() once, so +-- parallel requests share one spinner that reports how many are running. +local count = 0 local timer local dismiss_timer -local active = false local frame_idx = 1 local start_time = 0 -local win -local buf -local current_job_id = nil +local spin_win, spin_buf +local err_win, err_buf -local function close_ui() +local function close_float(win, buf) if win and vim.api.nvim_win_is_valid(win) then pcall(vim.api.nvim_win_close, win, true) end if buf and vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end - win, buf = nil, nil +end + +local function close_spinner() + close_float(spin_win, spin_buf) + spin_win, spin_buf = nil, nil +end + +local function close_error() + close_float(err_win, err_buf) + err_win, err_buf = nil, nil end local function cancel_dismiss() @@ -29,22 +39,27 @@ local function cancel_dismiss() end end -local function open_float(text) - close_ui() +local function float_width(text) + local w = math.min(vim.api.nvim_strwidth(text) + 4, vim.o.columns - 4) + return math.max(w, 20) +end - buf = vim.api.nvim_create_buf(false, true) - vim.bo[buf].buftype = "nofile" +local function spinner_row() + return math.max(0, vim.o.lines - vim.o.cmdheight - 4) +end - local w = math.min(vim.api.nvim_strwidth(text) + 4, vim.o.columns - 4) - w = math.max(w, 20) +local function open_float(text, row) + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + local w = float_width(text) vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text }) - win = vim.api.nvim_open_win(buf, false, { + local win = vim.api.nvim_open_win(buf, false, { relative = "editor", width = w, height = 1, - row = math.max(0, vim.o.lines - vim.o.cmdheight - 4), + row = row, col = math.max(0, math.floor((vim.o.columns - w) / 2)), style = "minimal", border = "rounded", @@ -54,84 +69,102 @@ local function open_float(text) title_pos = "center", }) vim.wo[win].wrap = false + + return win, buf end -local function update_text(text) - if not buf or not vim.api.nvim_buf_is_valid(buf) then - return - end - local w = math.min(vim.api.nvim_strwidth(text) + 4, vim.o.columns - 4) - w = math.max(w, 20) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text }) - if win and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_set_width(win, w) +local function spinner_text() + local secs = math.floor((vim.loop.now() - start_time) / 1000) + local elapsed = secs >= 1 and string.format(" %ds", secs) or "" + local frame = FRAMES[frame_idx] + if count > 1 then + return string.format(" %s waiting for %d requests…%s ", frame, count, elapsed) end + return string.format(" %s waiting for model…%s ", frame, elapsed) end -local function format_elapsed() - local secs = math.floor((vim.loop.now() - start_time) / 1000) - if secs < 1 then - return "" +local function update_spinner() + if not spin_buf or not vim.api.nvim_buf_is_valid(spin_buf) then + return + end + local text = spinner_text() + vim.api.nvim_buf_set_lines(spin_buf, 0, -1, false, { text }) + if spin_win and vim.api.nvim_win_is_valid(spin_win) then + vim.api.nvim_win_set_width(spin_win, float_width(text)) end - return string.format(" %ds", secs) end function M.start() - M.stop() - active = true + count = count + 1 + + if count > 1 then + update_spinner() + return + end + frame_idx = 1 start_time = vim.loop.now() - - local text = string.format(" %s waiting for model… ", FRAMES[1]) - open_float(text) + close_spinner() + spin_win, spin_buf = open_float(spinner_text(), spinner_row()) timer = vim.loop.new_timer() timer:start( 80, 80, vim.schedule_wrap(function() - if not active then + if count == 0 then return end frame_idx = frame_idx % #FRAMES + 1 - local line = string.format(" %s waiting for model…%s ", FRAMES[frame_idx], format_elapsed()) - update_text(line) + update_spinner() end) ) end function M.stop() - active = false - cancel_dismiss() + if count == 0 then + return + end + count = count - 1 + + if count > 0 then + update_spinner() + return + end + if timer then timer:stop() timer:close() timer = nil end - close_ui() - current_job_id = nil + close_spinner() end function M.cancel() - if current_job_id and vim.fn.jobwait({ current_job_id }, 0)[1] == -1 then - vim.fn.jobstop(current_job_id) + local requests = require("jumpy.requests") + local n = requests.cancel_all() + if n > 0 then + vim.notify(string.format("jumpy: cancelled %d request(s)", n), vim.log.levels.INFO) + else + vim.notify("jumpy: no requests in flight", vim.log.levels.INFO) end - M.stop() -end - -function M.set_job(jid) - current_job_id = jid end function M.is_active() - return active + return count > 0 end +-- Transient error toast; independent of the spinner so one failed request +-- doesn't hide the progress of others still running. function M.error(msg) - M.stop() + cancel_dismiss() + close_error() - local text = string.format(" ✗ %s ", msg) - open_float(text) + local row = spinner_row() + if count > 0 then + row = math.max(0, row - 3) + end + err_win, err_buf = open_float(string.format(" ✗ %s ", msg), row) dismiss_timer = vim.loop.new_timer() dismiss_timer:start( @@ -139,7 +172,7 @@ function M.error(msg) 0, vim.schedule_wrap(function() cancel_dismiss() - close_ui() + close_error() end) ) end diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index 747ec7e..03c2f2c 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -222,6 +222,7 @@ function M.reprompt() state.source_buf = vim.api.nvim_get_current_buf() state.reprompt_hunk_idx = hunk_idx + state.visual_selection = nil state.paths = path.list_files() state.buf, state.win = create_float(" jumpy: reprompt this hunk ") @@ -261,8 +262,9 @@ function M._submit() end local source_buf = state.source_buf + local visual_selection = state.visual_selection - local source_lines = state.visual_selection and vim.split(state.visual_selection.text, "\n", { plain = true }) + 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) local source_name = vim.api.nvim_buf_get_name(source_buf) @@ -291,6 +293,23 @@ function M._submit() local is_multi_file = #tagged_files > 1 local llm = require("jumpy.llm") + local requests = require("jumpy.requests") + + -- One in-flight request per file: a second request against the same file + -- would clobber the first one's pending hunks when it lands. + local targets = {} + for _, file in ipairs(tagged_files) do + local key = requests.target_key(file.bufnr, file.abs_path) + if key then + if requests.is_target_busy(key) then + vim.notify("jumpy: a request is already running for " .. file.path, vim.log.levels.WARN) + return + end + targets[key] = true + end + end + + local request_opts = { targets = targets, label = source_rel } local function send_request(symbols) symbols = symbols or "" @@ -328,7 +347,7 @@ function M._submit() vim.notify("jumpy: hunk updated", vim.log.levels.INFO) end) - end) + end, request_opts) elseif is_multi_file then local context = { tagged_files = tagged_files, @@ -344,9 +363,16 @@ function M._submit() local render = require("jumpy.render") local patch = require("jumpy.patch") + -- The buffer may have been edited while the request was in flight + -- (e.g. reviewing hunks from a parallel prompt), so apply against + -- the freshest lines we can get, not the snapshot from submit time. local files_by_path = {} for _, file in ipairs(tagged_files) do - files_by_path[file.path] = file.lines + local lines = file.lines + if file.bufnr and vim.api.nvim_buf_is_valid(file.bufnr) then + lines = vim.api.nvim_buf_get_lines(file.bufnr, 0, -1, false) + end + files_by_path[file.path] = lines end local results, total_unmatched = patch.apply_by_file(files_by_path, response_text, source_rel) @@ -357,17 +383,20 @@ function M._submit() local tagged_by_path = index_tagged_files(tagged_files) local total_hunks = 0 + local target_bufs = {} for file_path, result in pairs(results) do local file = tagged_by_path[file_path] if file then local bufnr = buffer_for_tagged_file(file) if bufnr then - local hunks = diff.compute(file.lines, result.lines) + local original = files_by_path[file_path] + local hunks = diff.compute(original, result.lines) if #hunks > 0 then require("jumpy.navigate")._clear_undo_history(bufnr) - render.show(bufnr, hunks, file.lines, result.lines) + render.show(bufnr, hunks, original, result.lines) total_hunks = total_hunks + #hunks + target_bufs[bufnr] = true end else vim.notify("jumpy: could not open " .. file_path .. ", skipping", vim.log.levels.WARN) @@ -380,15 +409,21 @@ function M._submit() return end + local nav = require("jumpy.navigate") + nav._refresh_quickfix() + vim.notify( string.format("jumpy: %d hunk(s) proposed across %d file(s)", total_hunks, vim.tbl_count(results)), vim.log.levels.INFO ) - local nav = require("jumpy.navigate") - nav.first_hunk_any_buf() + -- Only steal the cursor if the user is still in one of the target + -- buffers; otherwise they may be busy with another prompt/review. + if target_bufs[vim.api.nvim_get_current_buf()] then + nav.first_hunk_any_buf() + end end) - end) + end, request_opts) else local context = { file_contents = table.concat(source_lines, "\n"), @@ -408,15 +443,21 @@ function M._submit() local render = require("jumpy.render") local patch = require("jumpy.patch") - local proposed_lines, unmatched = patch.apply(source_lines, response_text) + -- 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) + + local proposed_lines, unmatched = patch.apply(original, response_text) if unmatched > 0 then vim.notify(string.format("jumpy: %d block(s) could not be matched", unmatched), vim.log.levels.WARN) end - local hunks = diff.compute(source_lines, proposed_lines) - if state.visual_selection then - local offset = state.visual_selection.start_line - 1 + 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 @@ -429,14 +470,18 @@ function M._submit() end require("jumpy.navigate")._clear_undo_history(source_buf) - render.show(source_buf, hunks, source_lines, proposed_lines) - - vim.notify(string.format("jumpy: %d hunk(s) proposed", #hunks), vim.log.levels.INFO) + render.show(source_buf, hunks, original, proposed_lines) local nav = require("jumpy.navigate") - nav.next_hunk() + nav._refresh_quickfix() + + vim.notify(string.format("jumpy: %d hunk(s) proposed in %s", #hunks, source_rel), vim.log.levels.INFO) + + if vim.api.nvim_get_current_buf() == source_buf then + nav.next_hunk() + end end) - end) + end, request_opts) end end diff --git a/lua/jumpy/requests.lua b/lua/jumpy/requests.lua new file mode 100644 index 0000000..6ffa46b --- /dev/null +++ b/lua/jumpy/requests.lua @@ -0,0 +1,98 @@ +-- Registry of in-flight LLM requests. Each request owns its own id, job and +-- target files, so multiple prompts can run in parallel without sharing the +-- single global "loading" state that made jumpy single-flight. +local M = {} + +local next_id = 0 +local inflight = {} + +--- Register a new request. +--- @param opts table|nil { label = string, targets = { [key] = true } } +--- @return number request id +function M.begin(opts) + opts = opts or {} + next_id = next_id + 1 + inflight[next_id] = { + label = opts.label, + targets = opts.targets or {}, + job_id = nil, + cancelled = false, + } + return next_id +end + +function M.set_job(id, job_id) + local req = inflight[id] + if req then + req.job_id = job_id + end +end + +function M.finish(id) + inflight[id] = nil +end + +--- Unknown/finished ids count as cancelled so late callbacks are discarded. +function M.is_cancelled(id) + local req = inflight[id] + return req == nil or req.cancelled +end + +--- A target key is a normalized absolute file path, or "buf:N" for +--- unnamed buffers. +function M.target_key(bufnr, abs_path) + if abs_path and abs_path ~= "" then + return abs_path + end + if bufnr then + local name = vim.api.nvim_buf_get_name(bufnr) + if name ~= "" then + return require("jumpy.path").normalize_abs(name) + end + return "buf:" .. bufnr + end + return nil +end + +function M.is_target_busy(target) + for _, req in pairs(inflight) do + if not req.cancelled and req.targets[target] then + return true + end + end + return false +end + +function M.count() + local n = 0 + for _, req in pairs(inflight) do + if not req.cancelled then + n = n + 1 + end + end + return n +end + +--- Cancel every in-flight request. Jobs are stopped; each request is cleaned +--- up by its own on_exit handler. Returns how many requests were cancelled. +function M.cancel_all() + local n = 0 + for _, req in pairs(inflight) do + if not req.cancelled then + req.cancelled = true + n = n + 1 + if req.job_id and vim and vim.fn and vim.fn.jobstop then + pcall(vim.fn.jobstop, req.job_id) + end + end + end + return n +end + +--- Test helper: drop all state. +function M._reset() + next_id = 0 + inflight = {} +end + +return M diff --git a/tests/requests_spec.lua b/tests/requests_spec.lua new file mode 100644 index 0000000..a66f278 --- /dev/null +++ b/tests/requests_spec.lua @@ -0,0 +1,68 @@ +-- Add lua/ to package.path so require works outside Neovim +package.path = package.path .. ";lua/?.lua;lua/?/init.lua" + +local requests = require("jumpy.requests") + +describe("requests registry", function() + before_each(function() + requests._reset() + end) + + it("assigns unique ids and counts in-flight requests", function() + local a = requests.begin() + local b = requests.begin() + + assert.are_not.equal(a, b) + assert.are.equal(2, requests.count()) + + requests.finish(a) + assert.are.equal(1, requests.count()) + + requests.finish(b) + assert.are.equal(0, requests.count()) + end) + + it("treats unknown or finished ids as cancelled", function() + assert.is_true(requests.is_cancelled(999)) + + local id = requests.begin() + assert.is_false(requests.is_cancelled(id)) + + requests.finish(id) + assert.is_true(requests.is_cancelled(id)) + end) + + it("tracks busy targets per request", function() + local id = requests.begin({ targets = { ["/tmp/a.lua"] = true, ["/tmp/b.lua"] = true } }) + + assert.is_true(requests.is_target_busy("/tmp/a.lua")) + assert.is_true(requests.is_target_busy("/tmp/b.lua")) + assert.is_false(requests.is_target_busy("/tmp/c.lua")) + + requests.finish(id) + assert.is_false(requests.is_target_busy("/tmp/a.lua")) + end) + + it("allows parallel requests on different targets", function() + requests.begin({ targets = { ["/tmp/a.lua"] = true } }) + requests.begin({ targets = { ["/tmp/b.lua"] = true } }) + + assert.are.equal(2, requests.count()) + assert.is_true(requests.is_target_busy("/tmp/a.lua")) + assert.is_true(requests.is_target_busy("/tmp/b.lua")) + end) + + it("cancel_all marks every request cancelled and frees targets", function() + local a = requests.begin({ targets = { ["/tmp/a.lua"] = true } }) + local b = requests.begin({ targets = { ["/tmp/b.lua"] = true } }) + + assert.are.equal(2, requests.cancel_all()) + + assert.is_true(requests.is_cancelled(a)) + assert.is_true(requests.is_cancelled(b)) + assert.are.equal(0, requests.count()) + assert.is_false(requests.is_target_busy("/tmp/a.lua")) + + assert.are.equal(0, requests.cancel_all()) + end) +end) From 7937052af53a1931d3d102226b106d25ad3ddb48 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 20 Jul 2026 18:00:22 -0400 Subject: [PATCH 2/2] fix: satisfy luacheck and stylua in prompt.lua Rename the shadowed `lines` upvalue in the multi-file apply loop and collapse the visual-selection ternary onto one line for stylua. --- lua/jumpy/prompt.lua | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index 03c2f2c..9cc18d4 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -368,11 +368,11 @@ function M._submit() -- the freshest lines we can get, not the snapshot from submit time. local files_by_path = {} for _, file in ipairs(tagged_files) do - local lines = file.lines + local file_lines = file.lines if file.bufnr and vim.api.nvim_buf_is_valid(file.bufnr) then - lines = vim.api.nvim_buf_get_lines(file.bufnr, 0, -1, false) + file_lines = vim.api.nvim_buf_get_lines(file.bufnr, 0, -1, false) end - files_by_path[file.path] = lines + files_by_path[file.path] = file_lines end local results, total_unmatched = patch.apply_by_file(files_by_path, response_text, source_rel) @@ -446,8 +446,7 @@ function M._submit() -- 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) + local original = visual_selection and source_lines or vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) local proposed_lines, unmatched = patch.apply(original, response_text)