Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions lua/jumpy/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
143 changes: 84 additions & 59 deletions lua/jumpy/llm.lua
Original file line number Diff line number Diff line change
Expand Up @@ -161,38 +161,55 @@ 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")
return
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,
clear_env = true,
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

Expand Down Expand Up @@ -221,62 +238,58 @@ 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

local raw = table.concat(response_chunks, "\n")
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

Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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

Expand All @@ -396,32 +422,31 @@ 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)
end,
})

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")
Expand All @@ -432,7 +457,7 @@ function M.reprompt(context, callback)
end)
end
callback(new_lines)
end)
end, opts)
end

return M
Loading
Loading