Skip to content
Draft
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
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Contributing

Very open to PRs, issues, etc.! There is no concrete set of guidelines right now. I am quite welcoming to pretty much anything [1]!

[1]: If your PR is very clearly vibe coded slop I am just gonna close it without warning.

**Some notes:**
- Your PR will be run against the checks here: [`.github/workflows/ci.yml`](https://github.com/cachebag/jumpy.nvim/blob/master/.github/workflows/ci.yml)
- Larger features require an issue to be filed first, thus, please refrain from "drive-by" PRs
118 changes: 64 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
Inspired by
<a href="https://github.com/ThePrimeagen/99.git" target="_blank" rel="noopener noreferrer">
99
</a>:
I, like Prime, wanted a tool to allow for <i>me</i> to still be in the driver seat if I am going to spawn an AI to edit my code.
</a>
</b>
</p>

<br>

<img width="1309" height="888" alt="image" src="https://github.com/user-attachments/assets/519cb828-5a3f-4e6d-8173-5c427c414b41" />
Expand All @@ -31,34 +30,19 @@ 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
- **multi-line prompt + history**: `<C-CR>` submits, `<CR>` adds a newline, `<Up>`/`<Down>` 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
- **search/replace hunks**: the 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
- **hunk navigation**: jump between proposed changes with `]h` / `[h`
- **quickfix integration**: collect pending hunks across buffers into one list
- **token-efficient**: sends only the targeted edit context, not full-file rewrites
- **multi-provider**: openrouter, openai, Anthropic API, Claude Code subscriptions
- **session sidebar**: a panel logging every prompt and its per-file hunk outcomes; jump to, accept/reject, and reprompt from one place (`:JumpySidebar`)
- **persistent sessions**: sessions are saved per project root and can be reopened later (`:JumpySessions`)
- **`@lsp` context**: pull workspace symbols into the prompt when needed
- **zero context switch**: no sidebar, no terminal agent, no leaving your file

#

So the philosophy here is: yes, I still want to handwrite my code _AND_ use AI, but I prefer that I have _full_ control over what the AI is writing.
I don't want to have to wait until it finishes in order to review its changes.

## Contributing
Very open to PRs, issues, etc.! There is no concrete set of guidelines right now. I am quite welcoming to pretty much anything [1]!

[1]: If your PR is very clearly vibe coded slop I am just gonna close it without warning.

**Some notes:**
- Your PR will be run against the checks here: [`.github/workflows/ci.yml`](https://github.com/cachebag/jumpy.nvim/blob/master/.github/workflows/ci.yml)
- I may at times use AI to review your PRs. **A human will still read your PR and have final say**
- **multi-provider**: openrouter, openai, Anthropic API, Claude Code subscriptions

## Install

Expand All @@ -74,12 +58,12 @@ Very open to PRs, issues, etc.! There is no concrete set of guidelines right now
}
```

set your API key: `export ANTHROPIC_API_KEY="sk-ant-..."` (or `OPENAI_API_KEY`, `JUMPY_API_KEY` for openrouter).
Set your API key: `export ANTHROPIC_API_KEY="sk-ant-..."` (or `OPENAI_API_KEY`, `JUMPY_API_KEY` for openrouter).

### Claude Code / Max

Claude Pro and Max subscribers can use Jumpy without an API key. Install Claude Code,
run `claude login`, then configure Jumpy to use the local CLI:
run `claude login`, then:

```lua
require("jumpy").setup({
Expand All @@ -88,35 +72,61 @@ require("jumpy").setup({
})
```

Jumpy invokes Claude Code in non-interactive mode with its tools, MCP servers, and
session persistence disabled. Claude Code only returns proposed SEARCH/REPLACE blocks;
Jumpy remains the only process that can apply them. Jumpy removes `ANTHROPIC_API_KEY`
from the Claude Code child process so an inherited API key cannot switch a subscription
user to API billing. Set `claude_code_command` in `setup()` if `claude` is not on `PATH`.
Jumpy runs Claude Code non-interactively (tools, MCP servers, and session persistence
disabled) and remains the only process that can apply changes. It removes
`ANTHROPIC_API_KEY` from the child process so an inherited key cannot switch a
subscription user to API billing. Set `claude_code_command` if `claude` is not on `PATH`.

## Use
| Keybind | Action |
| ----------- | --------------------------------------------------- |
| `<leader>j` | Open prompt |
| `<C-CR>` | Submit prompt (insert mode); `<CR>` inserts newline |
| `<CR>` | Submit prompt (normal mode) |
| `<Up>` / `<Down>` | Cycle prompt history (session) |
| `]h` / `[h` | Next / previous hunk |
| `<leader>a` | Accept hunk |
| `<leader>x` | Reject hunk |
| `<leader>A` | Accept all hunks |
| `<leader>X` | Reject all hunks |
| `<leader>r` | Reprompt the hunk under cursor |
| `<leader>q` | Review pending hunks in quickfix |

In the prompt float, `<CR>` starts a new line and `<C-CR>` sends the request.
Use `<Up>` / `<Down>` to recall earlier prompts from the current Neovim session
(including `@file` mentions as typed).

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.

| Keybind | Action |
| ----------------- | --------------------------------------------------- |
| `<leader>j` | Open prompt |
| `<C-CR>` | Submit prompt (insert mode); `<CR>` inserts newline |
| `<CR>` | Submit prompt (normal mode) |
| `<Up>` / `<Down>` | Cycle prompt history (session) |
| `]h` / `[h` | Next / previous hunk |
| `<leader>a` | Accept hunk |
| `<leader>x` | Reject hunk |
| `<leader>A` | Accept all hunks |
| `<leader>X` | Reject all hunks |
| `<leader>r` | Reprompt the hunk under cursor |
| `<leader>q` | Review pending hunks in quickfix |
| `<leader>s` | Toggle the session sidebar |

In the quickfix list, `a` accepts and `x` rejects the selected hunk, advancing to
the next pending hunk (including hunks in other files). `:JumpyCancel` cancels every
request currently in flight.

### Session sidebar

`:JumpySidebar` (`<leader>s`) toggles a panel logging every prompt and the hunks it
proposed, with each hunk's status. `:JumpySessions` reopens a saved session (sessions
are persisted per project root under `stdpath("data")/jumpy/sessions/`; reopened ones
are read-only).

Configure where it opens (handy if a left-side file tree is in the way):

```lua
require("jumpy").setup({
sidebar = {
position = "left", -- or "right"
width = 42,
},
})
```

| Key | Action |
| ------------- | ----------------------------------------------------- |
| `<CR>` | Jump to the file + hunk under the cursor |
| `a` / `x` | Accept / reject the hunk (or all pending on a file) |
| `r` | Reprompt the hunk under the cursor |
| `R` | Clear the current session |
| `q` / `<Esc>` | Close the sidebar |

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md).

## License

Expand Down
27 changes: 27 additions & 0 deletions lua/jumpy/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,16 @@ M.config = {
reject_all = "<leader>X",
reprompt = "<leader>r",
quickfix = "<leader>q",
sidebar = "<leader>s",
},
highlights = {
added = "JumpyAdded",
removed = "JumpyRemoved",
},
sidebar = {
position = "left", -- "left" or "right"
width = 42,
},
}

local provider_defaults = {
Expand Down Expand Up @@ -103,6 +108,20 @@ function M.auto_setup()
vim.api.nvim_create_user_command("JumpyCancel", function()
require("jumpy.loading").cancel()
end, { desc = "Cancel all in-flight Jumpy requests" })
vim.api.nvim_create_user_command("JumpySidebar", function()
require("jumpy.sidebar").toggle()
end, { desc = "Toggle Jumpy session sidebar" })
vim.api.nvim_create_user_command("JumpySessions", function()
require("jumpy.sidebar").pick()
end, { desc = "Open a saved Jumpy session" })
vim.api.nvim_create_autocmd("VimLeavePre", {
callback = function()
pcall(function()
require("jumpy.persist").flush()
end)
end,
desc = "Flush pending Jumpy session to disk",
})
end

function M._setup_highlights()
Expand All @@ -111,6 +130,13 @@ 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 })
hl(0, "JumpySessionBanner", { link = "Comment", default = true })
hl(0, "JumpySessionHeader", { link = "Title", default = true })
hl(0, "JumpySessionFile", { link = "Directory", default = true })
hl(0, "JumpySessionPending", { link = "WarningMsg", default = true })
hl(0, "JumpySessionAccepted", { link = "MoreMsg", default = true })
hl(0, "JumpySessionRejected", { link = "ErrorMsg", default = true })
hl(0, "JumpySessionSuperseded", { link = "Comment", default = true })
end

function M._setup_keymaps()
Expand All @@ -128,6 +154,7 @@ function M._setup_keymaps()
{ c.reject_all, "jumpy.navigate", "reject_all" },
{ c.reprompt, "jumpy.prompt", "reprompt" },
{ c.quickfix, "jumpy.navigate", "add_hunks_to_quickfix" },
{ c.sidebar, "jumpy.sidebar", "toggle" },
}

for _, km in ipairs(keymaps) do
Expand Down
11 changes: 11 additions & 0 deletions lua/jumpy/llm.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ local function build_messages(context)
table.insert(parts, context.symbols)
end
table.insert(parts, "")
-- Name the active buffer so relative references ("this file", "here")
-- resolve. The FILE headers are left verbatim since SEARCH paths must
-- match them exactly.
if context.primary_path and context.primary_path ~= "" then
table.insert(parts, string.format('The current file (what "this file" refers to) is: %s', context.primary_path))
end
table.insert(parts, "Instruction: " .. context.prompt)
local user_content = table.concat(parts, "\n")
local system = config.system_prompt .. "\n\n" .. config.system_prompt_multi_file
Expand Down Expand Up @@ -67,6 +73,8 @@ local function build_reprompt_messages(context)
}
end

M._build_messages = build_messages

local function is_anthropic()
local config = get_config()
return config.provider == "anthropic"
Expand Down Expand Up @@ -161,6 +169,9 @@ function M._build_claude_code_cmd(messages, config)
table.insert(cmd, "--model")
table.insert(cmd, config.model)
end
-- End-of-options separator: multi-file prompts start with "--- FILE: ...",
-- which the CLI would otherwise parse as an unknown option.
table.insert(cmd, "--")
table.insert(cmd, user_text)
return cmd
end
Expand Down
4 changes: 4 additions & 0 deletions lua/jumpy/loading.lua
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ local function open_float(text, row)
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].buftype = "nofile"

-- The float is a single line; nvim_buf_set_lines rejects embedded newlines,
-- so collapse any (multi-line error messages) into a single spaced line.
text = tostring(text):gsub("%s*\n%s*", " ")

local w = float_width(text)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { text })

Expand Down
22 changes: 22 additions & 0 deletions lua/jumpy/navigate.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
local M = {}

local render = require("jumpy.render")
local session = require("jumpy.session")

local MSG_NO_HUNKS = "jumpy: no hunks"
local MSG_NO_HUNK_UNDER_CURSOR = "jumpy: no hunk under cursor"
Expand Down Expand Up @@ -212,6 +213,7 @@ function M.accept_hunk(bufnr, hunk_idx)
end
record_undo_state(bufnr, before_state, before_offsets, before_lines)
syncing_undo[bufnr] = nil
session.mark_hunk(bufnr, hunk_idx, "accepted")
M._refresh_quickfix()
return true
end
Expand All @@ -235,6 +237,7 @@ function M.reject_hunk(bufnr, hunk_idx)
return false
end
render.clear_hunk(bufnr, hunk_idx)
session.mark_hunk(bufnr, hunk_idx, "rejected")
M._refresh_quickfix()
return true
end
Expand Down Expand Up @@ -282,19 +285,23 @@ function M.accept_all()
offset_table[bufnr] = nil
record_undo_state(bufnr, before_state, before_offsets, before_lines)
syncing_undo[bufnr] = nil
session.mark_all(bufnr, "accepted")
M._refresh_quickfix()
vim.notify("jumpy: all hunks accepted", vim.log.levels.INFO)
end

function M.reject_all()
local bufnr = vim.api.nvim_get_current_buf()
render.clear(bufnr)
session.mark_all(bufnr, "rejected")
M._refresh_quickfix()
vim.notify("jumpy: all hunks rejected", vim.log.levels.INFO)
end

function M.replace_hunk(bufnr, hunk_idx, new_lines)
render.update_hunk_lines(bufnr, hunk_idx, new_lines)
local preview = new_lines[1] and ("+ " .. new_lines[1]) or "(change)"
session.update_hunk_preview(bufnr, hunk_idx, preview)
M._refresh_quickfix()
end

Expand Down Expand Up @@ -503,6 +510,21 @@ function M.first_hunk_any_buf()
jump_to(entry.bufnr, entry.hunk.old_start)
end

--- Jump to a specific pending hunk (used by the session sidebar). Returns
--- whether the hunk still exists in the live render state.
function M.jump_to_hunk(bufnr, hunk_idx)
if not vim.api.nvim_buf_is_valid(bufnr) then
return false
end
local state = render.get_state(bufnr)
if not state or not state.hunks[hunk_idx] then
return false
end
local line = current_hunk_line(bufnr, hunk_idx, state.hunks[hunk_idx])
jump_to(bufnr, line)
return true
end

function M._advance_to_next(bufnr)
local active = get_active_hunks(bufnr)
if #active > 0 then
Expand Down
Loading
Loading