diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..1037a1c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -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
diff --git a/README.md b/README.md
index 9a1ee26..8415a74 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,10 @@
Inspired by
99
- :
- I, like Prime, wanted a tool to allow for me to still be in the driver seat if I am going to spawn an AI to edit my code.
+
-
+
@@ -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**: `` 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
+- **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
@@ -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({
@@ -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 |
-| ----------- | --------------------------------------------------- |
-| `j` | Open prompt |
-| `` | Submit prompt (insert mode); `` inserts newline |
-| `` | Submit prompt (normal mode) |
-| `` / `` | Cycle prompt history (session) |
-| `]h` / `[h` | Next / previous hunk |
-| `a` | Accept hunk |
-| `x` | Reject hunk |
-| `A` | Accept all hunks |
-| `X` | Reject all hunks |
-| `r` | Reprompt the hunk under cursor |
-| `q` | Review pending hunks in quickfix |
-
-In the prompt float, `` starts a new line and `` sends the request.
-Use `` / `` 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 |
+| ----------------- | --------------------------------------------------- |
+| `j` | Open prompt |
+| `` | Submit prompt (insert mode); `` inserts newline |
+| `` | Submit prompt (normal mode) |
+| `` / `` | Cycle prompt history (session) |
+| `]h` / `[h` | Next / previous hunk |
+| `a` | Accept hunk |
+| `x` | Reject hunk |
+| `A` | Accept all hunks |
+| `X` | Reject all hunks |
+| `r` | Reprompt the hunk under cursor |
+| `q` | Review pending hunks in quickfix |
+| `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` (`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 |
+| ------------- | ----------------------------------------------------- |
+| `` | 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` / `` | Close the sidebar |
+
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua
index 8b81239..8a6c381 100644
--- a/lua/jumpy/init.lua
+++ b/lua/jumpy/init.lua
@@ -47,11 +47,16 @@ M.config = {
reject_all = "X",
reprompt = "r",
quickfix = "q",
+ sidebar = "s",
},
highlights = {
added = "JumpyAdded",
removed = "JumpyRemoved",
},
+ sidebar = {
+ position = "left", -- "left" or "right"
+ width = 42,
+ },
}
local provider_defaults = {
@@ -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()
@@ -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()
@@ -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
diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua
index a4adc3a..280260a 100644
--- a/lua/jumpy/llm.lua
+++ b/lua/jumpy/llm.lua
@@ -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
@@ -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"
@@ -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
diff --git a/lua/jumpy/loading.lua b/lua/jumpy/loading.lua
index 2c5181f..28c351f 100644
--- a/lua/jumpy/loading.lua
+++ b/lua/jumpy/loading.lua
@@ -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 })
diff --git a/lua/jumpy/navigate.lua b/lua/jumpy/navigate.lua
index f1192f0..c33f92c 100644
--- a/lua/jumpy/navigate.lua
+++ b/lua/jumpy/navigate.lua
@@ -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"
@@ -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
@@ -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
@@ -282,6 +285,7 @@ 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
@@ -289,12 +293,15 @@ 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
@@ -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
diff --git a/lua/jumpy/persist.lua b/lua/jumpy/persist.lua
new file mode 100644
index 0000000..fb5afd6
--- /dev/null
+++ b/lua/jumpy/persist.lua
@@ -0,0 +1,209 @@
+-- Disk persistence for Jumpy sessions, organized per project root under
+-- stdpath("data")/jumpy/sessions//.json. Pure helpers
+-- (strip_runtime, summarize, root_key) are Neovim-free and unit-tested; the
+-- actual IO is guarded so this module loads and no-ops outside Neovim.
+local M = {}
+
+local DEBOUNCE_MS = 250
+
+local pending_session = nil
+local debounce_timer = nil
+
+local function has_vim()
+ return type(vim) == "table"
+end
+
+local function deep_copy(value)
+ if type(value) ~= "table" then
+ return value
+ end
+ local out = {}
+ for k, v in pairs(value) do
+ out[k] = deep_copy(v)
+ end
+ return out
+end
+
+--- Copy a session with runtime-only fields (bufnr) removed so it is safe to
+--- serialize and later reload without stale buffer references.
+function M.strip_runtime(session)
+ local copy = deep_copy(session)
+ for _, entry in ipairs(copy.entries or {}) do
+ for _, result in ipairs(entry.results or {}) do
+ result.bufnr = nil
+ end
+ end
+ return copy
+end
+
+--- Compact metadata for a session, used by the picker list.
+function M.summarize(session)
+ local summary = ""
+ local entries = session.entries or {}
+ for _, entry in ipairs(entries) do
+ if entry.kind == "prompt" and entry.text and entry.text ~= "" then
+ summary = entry.text
+ break
+ end
+ end
+ return {
+ id = session.id,
+ root = session.root,
+ started = session.started,
+ entry_count = #entries,
+ summary = summary,
+ }
+end
+
+--- Deterministic, filesystem-safe directory name for a project root: a readable
+--- basename plus a djb2 hash so distinct roots never collide.
+function M.root_key(root)
+ root = root or "."
+ local h = 5381
+ for i = 1, #root do
+ h = (h * 33 + root:byte(i)) % 4294967296
+ end
+ local base = root:gsub("[/\\]+$", ""):match("[^/\\]+$") or "root"
+ base = base:gsub("[^%w%-_]", "_")
+ return string.format("%s-%08x", base, h)
+end
+
+local function sessions_root()
+ return vim.fn.stdpath("data") .. "/jumpy/sessions"
+end
+
+function M.dir_for(root)
+ return sessions_root() .. "/" .. M.root_key(root)
+end
+
+local function file_for(root, id)
+ return M.dir_for(root) .. "/" .. id .. ".json"
+end
+
+local function write_session(session)
+ if not session or not session.entries or #session.entries == 0 then
+ return
+ end
+ local dir = M.dir_for(session.root)
+ pcall(vim.fn.mkdir, dir, "p")
+
+ local ok, encoded = pcall(vim.json.encode, M.strip_runtime(session))
+ if not ok then
+ return
+ end
+
+ local f = io.open(file_for(session.root, session.id), "w")
+ if not f then
+ return
+ end
+ f:write(encoded)
+ f:close()
+end
+
+--- Debounced save of the current session; rapid accepts collapse into one write.
+function M.save(session)
+ if not has_vim() then
+ return
+ end
+ pending_session = session
+
+ if not (vim.loop and vim.loop.new_timer) then
+ write_session(pending_session)
+ pending_session = nil
+ return
+ end
+
+ if debounce_timer then
+ debounce_timer:stop()
+ else
+ debounce_timer = vim.loop.new_timer()
+ end
+
+ debounce_timer:start(
+ DEBOUNCE_MS,
+ 0,
+ vim.schedule_wrap(function()
+ if pending_session then
+ write_session(pending_session)
+ pending_session = nil
+ end
+ end)
+ )
+end
+
+--- Write any pending session immediately (call on VimLeavePre).
+function M.flush()
+ if not has_vim() then
+ return
+ end
+ if debounce_timer then
+ debounce_timer:stop()
+ end
+ if pending_session then
+ write_session(pending_session)
+ pending_session = nil
+ end
+end
+
+--- List saved sessions for a project root, newest-first, as summaries plus id.
+function M.list(root)
+ if not has_vim() then
+ return {}
+ end
+ local dir = M.dir_for(root)
+ if vim.fn.isdirectory(dir) == 0 then
+ return {}
+ end
+ local ok, names = pcall(vim.fn.readdir, dir)
+ if not ok or type(names) ~= "table" then
+ return {}
+ end
+
+ local sessions = {}
+ for _, name in ipairs(names) do
+ if name:match("%.json$") then
+ local id = name:gsub("%.json$", "")
+ local session = M.load(root, id)
+ if session then
+ sessions[#sessions + 1] = M.summarize(session)
+ end
+ end
+ end
+
+ table.sort(sessions, function(a, b)
+ return (a.started or 0) > (b.started or 0)
+ end)
+ return sessions
+end
+
+--- Decode a saved session into a table, or nil on failure.
+function M.load(root, id)
+ if not has_vim() then
+ return nil
+ end
+ local f = io.open(file_for(root, id), "r")
+ if not f then
+ return nil
+ end
+ local content = f:read("*a")
+ f:close()
+
+ local ok, decoded = pcall(vim.json.decode, content)
+ if not ok or type(decoded) ~= "table" then
+ return nil
+ end
+ return decoded
+end
+
+--- Test helper: drop debounce state.
+function M._reset()
+ if debounce_timer then
+ pcall(function()
+ debounce_timer:stop()
+ end)
+ debounce_timer = nil
+ end
+ pending_session = nil
+end
+
+return M
diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua
index 70dd4c2..4f37484 100644
--- a/lua/jumpy/prompt.lua
+++ b/lua/jumpy/prompt.lua
@@ -37,6 +37,25 @@ local function buffer_for_tagged_file(file)
return tags.open_buffer(file.abs_path)
end
+-- Build session-log descriptors from a diff hunk list. The idx matches the
+-- render state's hunk key (diff.compute returns a 1..n array), and line/preview
+-- let the sidebar render and jump without the live render state.
+local function hunk_descriptors(hunks)
+ local out = {}
+ for idx, hunk in ipairs(hunks) do
+ local preview
+ if #hunk.added_lines > 0 then
+ preview = "+ " .. hunk.added_lines[1]
+ elseif #hunk.removed_lines > 0 then
+ preview = "- " .. hunk.removed_lines[1]
+ else
+ preview = "(change)"
+ end
+ out[#out + 1] = { idx = idx, line = hunk.old_start, preview = preview }
+ end
+ return out
+end
+
local function highlight_mentions(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
@@ -317,11 +336,13 @@ function M._set_submit_keymap()
M._submit()
end, { buffer = state.buf, silent = true })
+ -- Buffer edits are forbidden while an expr mapping evaluates (E565), so the
+ -- history swap is deferred with vim.schedule; the pum case returns the key.
vim.keymap.set({ "i", "n" }, "", function()
if vim.fn.mode():sub(1, 1) == "i" and vim.fn.pumvisible() == 1 then
return ""
end
- history_prev()
+ vim.schedule(history_prev)
return ""
end, { buffer = state.buf, silent = true, expr = true })
@@ -329,7 +350,7 @@ function M._set_submit_keymap()
if vim.fn.mode():sub(1, 1) == "i" and vim.fn.pumvisible() == 1 then
return ""
end
- history_next()
+ vim.schedule(history_next)
return ""
end, { buffer = state.buf, silent = true, expr = true })
@@ -401,6 +422,16 @@ function M._submit()
push_history(prompt_text)
+ local session = require("jumpy.session")
+ local mode = (reprompt_idx and "reprompt")
+ or (is_multi_file and "multi_file")
+ or (visual_selection and "visual")
+ or "buffer"
+ local session_entry = session.record_prompt({
+ text = cleaned_prompt ~= "" and cleaned_prompt or prompt_text,
+ mode = mode,
+ })
+
local request_opts = { targets = targets, label = source_rel }
local function send_request(symbols)
@@ -487,6 +518,11 @@ function M._submit()
if #hunks > 0 then
require("jumpy.navigate")._clear_undo_history(bufnr)
render.show(bufnr, hunks, original, result.lines)
+ session.record_result(session_entry, {
+ path = file_path,
+ bufnr = bufnr,
+ hunks = hunk_descriptors(hunks),
+ })
total_hunks = total_hunks + #hunks
target_bufs[bufnr] = true
end
@@ -562,6 +598,11 @@ function M._submit()
require("jumpy.navigate")._clear_undo_history(source_buf)
render.show(source_buf, hunks, original, proposed_lines)
+ session.record_result(session_entry, {
+ path = source_rel,
+ bufnr = source_buf,
+ hunks = hunk_descriptors(hunks),
+ })
local nav = require("jumpy.navigate")
nav._refresh_quickfix()
diff --git a/lua/jumpy/session.lua b/lua/jumpy/session.lua
new file mode 100644
index 0000000..ecfa910
--- /dev/null
+++ b/lua/jumpy/session.lua
@@ -0,0 +1,267 @@
+-- In-memory log of Jumpy activity for the current Neovim run. Records each
+-- prompt and its per-file hunk outcomes so the sidebar can present a reviewable,
+-- iterable history. Entries are plain, JSON-serializable tables (see persist.lua)
+-- and carry a generic `kind` so future agentic steps (tool_read/tool_edit) can
+-- be appended without reshaping this module.
+local M = {}
+
+local HUNK_STATUS = {
+ pending = true,
+ accepted = true,
+ rejected = true,
+ superseded = true,
+}
+
+local current = nil
+local next_entry_id = 0
+local observers = {}
+
+local function has_vim()
+ return type(vim) == "table"
+end
+
+local function now()
+ return os.time()
+end
+
+local function project_root()
+ local ok, root = pcall(function()
+ return require("jumpy.path").project_root()
+ end)
+ if ok and type(root) == "string" and root ~= "" then
+ return root
+ end
+ return "."
+end
+
+local function generate_id()
+ local stamp = os.date("!%Y%m%dT%H%M%S")
+ local pid = 0
+ if has_vim() and vim.fn and vim.fn.getpid then
+ pid = vim.fn.getpid()
+ end
+ return string.format("%s-%d", stamp, pid)
+end
+
+local function notify()
+ for _, fn in ipairs(observers) do
+ pcall(fn, current)
+ end
+end
+
+-- Persist is a no-op outside Neovim (tests) and best-effort otherwise so a
+-- write failure never breaks the editing flow.
+local function save()
+ if not has_vim() or not current then
+ return
+ end
+ pcall(function()
+ require("jumpy.persist").save(current)
+ end)
+end
+
+local function ensure_session()
+ if not current then
+ current = {
+ id = generate_id(),
+ root = project_root(),
+ started = now(),
+ entries = {},
+ }
+ end
+ return current
+end
+
+--- Find the single live result tracking `bufnr`, searching newest-first.
+local function live_result_for_buf(bufnr)
+ if not current then
+ return nil
+ end
+ for i = #current.entries, 1, -1 do
+ local results = current.entries[i].results
+ for _, result in ipairs(results) do
+ if result.live and result.bufnr == bufnr then
+ return result
+ end
+ end
+ end
+ return nil
+end
+
+--- A new proposal for a buffer replaces whatever was pending there, so the
+--- previous live result is frozen: still-pending hunks become "superseded" and
+--- it stops matching future mark_hunk/mark_all calls (which key on bufnr).
+local function supersede_buf(bufnr)
+ if not current then
+ return
+ end
+ for _, entry in ipairs(current.entries) do
+ for _, result in ipairs(entry.results) do
+ if result.live and result.bufnr == bufnr then
+ for _, hunk in pairs(result.hunks) do
+ if hunk.status == "pending" then
+ hunk.status = "superseded"
+ end
+ end
+ result.live = false
+ end
+ end
+ end
+end
+
+--- @param opts table { text = string, mode = string }
+--- @return table entry
+function M.record_prompt(opts)
+ opts = opts or {}
+ local session = ensure_session()
+ next_entry_id = next_entry_id + 1
+ local entry = {
+ id = next_entry_id,
+ kind = "prompt",
+ text = opts.text or "",
+ mode = opts.mode or "buffer",
+ time = now(),
+ results = {},
+ }
+ table.insert(session.entries, entry)
+ notify()
+ save()
+ return entry
+end
+
+--- Attach a proposed-hunks result to `entry`, superseding any prior live result
+--- for the same buffer first.
+--- @param entry table entry returned by record_prompt
+--- @param opts table { path = string, bufnr = number|nil, hunks = { { idx, line, preview } } }
+function M.record_result(entry, opts)
+ if not entry then
+ return
+ end
+ opts = opts or {}
+
+ if opts.bufnr ~= nil then
+ supersede_buf(opts.bufnr)
+ end
+
+ local hunks = {}
+ for _, h in ipairs(opts.hunks or {}) do
+ hunks[h.idx] = {
+ status = "pending",
+ line = h.line,
+ preview = h.preview or "",
+ }
+ end
+
+ table.insert(entry.results, {
+ path = opts.path,
+ bufnr = opts.bufnr,
+ live = true,
+ hunks = hunks,
+ })
+ notify()
+ save()
+end
+
+--- @return boolean whether a live hunk was updated
+function M.mark_hunk(bufnr, idx, status)
+ if not HUNK_STATUS[status] then
+ return false
+ end
+ local result = live_result_for_buf(bufnr)
+ if not result then
+ return false
+ end
+ local hunk = result.hunks[idx]
+ if not hunk then
+ return false
+ end
+ hunk.status = status
+ notify()
+ save()
+ return true
+end
+
+--- Mark every still-pending hunk of a buffer's live result.
+function M.mark_all(bufnr, status)
+ if not HUNK_STATUS[status] then
+ return false
+ end
+ local result = live_result_for_buf(bufnr)
+ if not result then
+ return false
+ end
+ local changed = false
+ for _, hunk in pairs(result.hunks) do
+ if hunk.status == "pending" then
+ hunk.status = status
+ changed = true
+ end
+ end
+ if changed then
+ notify()
+ save()
+ end
+ return changed
+end
+
+--- Keep a live hunk's preview in sync when it is reprompted in place.
+function M.update_hunk_preview(bufnr, idx, preview)
+ local result = live_result_for_buf(bufnr)
+ if not result or not result.hunks[idx] then
+ return false
+ end
+ result.hunks[idx].preview = preview or result.hunks[idx].preview
+ notify()
+ save()
+ return true
+end
+
+--- Derived counts for a result, for display.
+function M.status_counts(result)
+ local counts = { pending = 0, accepted = 0, rejected = 0, superseded = 0, total = 0 }
+ for _, hunk in pairs(result.hunks or {}) do
+ counts[hunk.status] = (counts[hunk.status] or 0) + 1
+ counts.total = counts.total + 1
+ end
+ return counts
+end
+
+function M.get_session()
+ return current
+end
+
+function M.get_entries()
+ return current and current.entries or {}
+end
+
+--- Persist and end the current run's session; the next activity starts fresh.
+function M.clear()
+ if current then
+ save()
+ end
+ current = nil
+ notify()
+end
+
+--- Register an observer called with the current session on every change.
+--- Returns an unsubscribe function.
+function M.subscribe(fn)
+ table.insert(observers, fn)
+ return function()
+ for i, o in ipairs(observers) do
+ if o == fn then
+ table.remove(observers, i)
+ return
+ end
+ end
+ end
+end
+
+--- Test helper: drop all state.
+function M._reset()
+ current = nil
+ next_entry_id = 0
+ observers = {}
+end
+
+return M
diff --git a/lua/jumpy/sidebar.lua b/lua/jumpy/sidebar.lua
new file mode 100644
index 0000000..f129b4b
--- /dev/null
+++ b/lua/jumpy/sidebar.lua
@@ -0,0 +1,435 @@
+-- Session sidebar: a vertical split that renders the jumpy.session log and lets
+-- you jump to, accept/reject, and re-prompt hunks from one place. It is a pure
+-- view over session state (live) or a reloaded past session (read-only); all
+-- mutations are delegated back to jumpy.navigate.
+local M = {}
+
+local session = require("jumpy.session")
+
+local ns = vim.api.nvim_create_namespace("jumpy_session_sidebar")
+
+local DEFAULT_WIDTH = 42
+
+local state = {
+ win = nil,
+ buf = nil,
+ width = DEFAULT_WIDTH,
+ unsubscribe = nil,
+ readonly = nil, -- a loaded past-session table, or nil for the live session
+ targets = {}, -- [lnum] = { kind, bufnr, idx, result, path, line }
+}
+
+--- Read the user's sidebar layout config, falling back to defaults.
+local function layout()
+ local ok, jumpy = pcall(require, "jumpy")
+ local cfg = (ok and jumpy.config and jumpy.config.sidebar) or {}
+ return {
+ position = cfg.position == "right" and "right" or "left",
+ width = tonumber(cfg.width) or DEFAULT_WIDTH,
+ }
+end
+
+local STATUS = {
+ pending = { glyph = "○", label = "pending", hl = "JumpySessionPending" },
+ accepted = { glyph = "●", label = "accepted", hl = "JumpySessionAccepted" },
+ rejected = { glyph = "✕", label = "rejected", hl = "JumpySessionRejected" },
+ superseded = { glyph = "·", label = "superseded", hl = "JumpySessionSuperseded" },
+}
+
+local function truncate(s, width)
+ s = s:gsub("\n", " ")
+ if vim.api.nvim_strwidth(s) <= width then
+ return s
+ end
+ return vim.fn.strcharpart(s, 0, math.max(1, width - 1)) .. "…"
+end
+
+local function rel_time(t)
+ if not t then
+ return ""
+ end
+ local secs = os.time() - t
+ if secs < 60 then
+ return "just now"
+ elseif secs < 3600 then
+ return math.floor(secs / 60) .. "m ago"
+ elseif secs < 86400 then
+ return math.floor(secs / 3600) .. "h ago"
+ end
+ return math.floor(secs / 86400) .. "d ago"
+end
+
+local function sorted_hunks(result)
+ local arr = {}
+ for idx, hunk in pairs(result.hunks or {}) do
+ arr[#arr + 1] = { idx = idx, hunk = hunk }
+ end
+ table.sort(arr, function(a, b)
+ local la, lb = a.hunk.line or 0, b.hunk.line or 0
+ if la ~= lb then
+ return la < lb
+ end
+ return tostring(a.idx) < tostring(b.idx)
+ end)
+ return arr
+end
+
+local function counts_summary(result)
+ local counts = session.status_counts(result)
+ local parts = {}
+ for _, s in ipairs({ "pending", "accepted", "rejected", "superseded" }) do
+ if (counts[s] or 0) > 0 then
+ parts[#parts + 1] = counts[s] .. " " .. s
+ end
+ end
+ return table.concat(parts, " · ")
+end
+
+--- Collect display lines + per-line targets + highlight spans for the buffer.
+local function build_lines()
+ local lines = {}
+ local targets = {}
+ local highlights = {}
+
+ local function add(text, target, hl)
+ lines[#lines + 1] = text
+ local lnum = #lines
+ if target then
+ targets[lnum] = target
+ end
+ if hl then
+ highlights[#highlights + 1] = { line = lnum - 1, group = hl }
+ end
+ end
+
+ local width = state.width or DEFAULT_WIDTH
+ local sess = state.readonly or session.get_session()
+ local entries = sess and sess.entries or {}
+
+ if state.readonly then
+ add(truncate("jumpy session (saved · read-only)", width), nil, "JumpySessionBanner")
+ else
+ add(truncate("jumpy session", width), nil, "JumpySessionBanner")
+ end
+ add(string.rep("─", width - 1), nil, "JumpySessionBanner")
+
+ if #entries == 0 then
+ add("")
+ add(" No jumpy activity yet.")
+ return lines, targets, highlights
+ end
+
+ for i = #entries, 1, -1 do
+ local entry = entries[i]
+ add("")
+ local meta = string.format("[%s · %s]", entry.mode or "buffer", rel_time(entry.time))
+ add(truncate("▍ " .. (entry.text ~= "" and entry.text or "(no prompt)"), width - #meta - 1) .. " " .. meta, {
+ kind = "entry",
+ }, "JumpySessionHeader")
+
+ for _, result in ipairs(entry.results or {}) do
+ local summary = counts_summary(result)
+ add(truncate(" " .. (result.path or "?"), width - #summary - 4) .. " " .. summary, {
+ kind = "file",
+ bufnr = result.bufnr,
+ result = result,
+ path = result.path,
+ }, "JumpySessionFile")
+
+ for _, item in ipairs(sorted_hunks(result)) do
+ local st = STATUS[item.hunk.status] or STATUS.pending
+ add(truncate(" " .. st.glyph .. " " .. (item.hunk.preview or ""), width), {
+ kind = "hunk",
+ bufnr = result.bufnr,
+ idx = item.idx,
+ path = result.path,
+ line = item.hunk.line,
+ }, st.hl)
+ end
+ end
+ end
+
+ return lines, targets, highlights
+end
+
+function M.render()
+ if not (state.buf and vim.api.nvim_buf_is_valid(state.buf)) then
+ return
+ end
+
+ local cursor
+ if state.win and vim.api.nvim_win_is_valid(state.win) then
+ cursor = vim.api.nvim_win_get_cursor(state.win)
+ end
+
+ local lines, targets, highlights = build_lines()
+ state.targets = targets
+
+ vim.bo[state.buf].modifiable = true
+ vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, lines)
+ vim.bo[state.buf].modifiable = false
+
+ vim.api.nvim_buf_clear_namespace(state.buf, ns, 0, -1)
+ for _, hl in ipairs(highlights) do
+ vim.api.nvim_buf_add_highlight(state.buf, ns, hl.group, hl.line, 0, -1)
+ end
+
+ if cursor and state.win and vim.api.nvim_win_is_valid(state.win) then
+ local target_line = math.min(cursor[1], #lines)
+ pcall(vim.api.nvim_win_set_cursor, state.win, { math.max(1, target_line), 0 })
+ end
+end
+
+--- Switch focus to a normal (non-sidebar, non-floating) window, creating a
+--- split if the sidebar is the only window left.
+local function focus_editor_window()
+ for _, win in ipairs(vim.api.nvim_list_wins()) do
+ if win ~= state.win and vim.api.nvim_win_get_config(win).relative == "" then
+ vim.api.nvim_set_current_win(win)
+ return true
+ end
+ end
+ vim.cmd("rightbelow vsplit")
+ return true
+end
+
+local function open_readonly_target(target)
+ if not target.path then
+ return
+ end
+ local root = (state.readonly and state.readonly.root) or require("jumpy.path").project_root()
+ local abs = target.path
+ if not abs:match("^/") then
+ abs = root .. "/" .. target.path
+ end
+ focus_editor_window()
+ vim.cmd("edit " .. vim.fn.fnameescape(abs))
+ if target.line then
+ pcall(vim.api.nvim_win_set_cursor, 0, { target.line, 0 })
+ vim.cmd("normal! zz")
+ end
+end
+
+local function first_pending_idx(result)
+ local best
+ for _, item in ipairs(sorted_hunks(result)) do
+ if item.hunk.status == "pending" then
+ best = item.idx
+ break
+ end
+ end
+ return best
+end
+
+local function target_at_cursor()
+ if not (state.win and vim.api.nvim_win_is_valid(state.win)) then
+ return nil
+ end
+ local lnum = vim.api.nvim_win_get_cursor(state.win)[1]
+ return state.targets[lnum]
+end
+
+local function on_enter()
+ local target = target_at_cursor()
+ if not target then
+ return
+ end
+
+ if state.readonly then
+ if target.kind == "hunk" or target.kind == "file" then
+ open_readonly_target(target)
+ end
+ return
+ end
+
+ local navigate = require("jumpy.navigate")
+ if target.kind == "hunk" then
+ focus_editor_window()
+ if not navigate.jump_to_hunk(target.bufnr, target.idx) then
+ vim.notify("jumpy: hunk is no longer pending", vim.log.levels.WARN)
+ end
+ elseif target.kind == "file" and target.result then
+ local idx = first_pending_idx(target.result)
+ if idx then
+ focus_editor_window()
+ navigate.jump_to_hunk(target.bufnr, idx)
+ end
+ end
+end
+
+local function act_on_target(status)
+ if state.readonly then
+ vim.notify("jumpy: saved sessions are read-only", vim.log.levels.INFO)
+ return
+ end
+ local target = target_at_cursor()
+ if not target then
+ return
+ end
+
+ local navigate = require("jumpy.navigate")
+ local action = status == "accepted" and navigate.accept_hunk or navigate.reject_hunk
+
+ if target.kind == "hunk" then
+ if not action(target.bufnr, target.idx) then
+ vim.notify("jumpy: hunk is no longer pending", vim.log.levels.WARN)
+ end
+ elseif target.kind == "file" and target.result then
+ -- Accept/reject from highest line down so earlier hunks keep their indices.
+ local items = sorted_hunks(target.result)
+ for j = #items, 1, -1 do
+ if items[j].hunk.status == "pending" then
+ action(target.bufnr, items[j].idx)
+ end
+ end
+ end
+end
+
+local function on_reprompt()
+ if state.readonly then
+ return
+ end
+ local target = target_at_cursor()
+ if not target or target.kind ~= "hunk" then
+ return
+ end
+ local navigate = require("jumpy.navigate")
+ focus_editor_window()
+ if navigate.jump_to_hunk(target.bufnr, target.idx) then
+ require("jumpy.prompt").reprompt()
+ end
+end
+
+local function setup_keymaps()
+ local opts = { buffer = state.buf, silent = true, nowait = true }
+ vim.keymap.set("n", "", on_enter, opts)
+ vim.keymap.set("n", "a", function()
+ act_on_target("accepted")
+ end, opts)
+ vim.keymap.set("n", "x", function()
+ act_on_target("rejected")
+ end, opts)
+ vim.keymap.set("n", "r", on_reprompt, opts)
+ vim.keymap.set("n", "R", function()
+ if not state.readonly then
+ session.clear()
+ end
+ end, opts)
+ vim.keymap.set("n", "q", M.close, opts)
+ vim.keymap.set("n", "", M.close, opts)
+end
+
+local function create_window()
+ state.buf = vim.api.nvim_create_buf(false, true)
+ vim.bo[state.buf].buftype = "nofile"
+ vim.bo[state.buf].bufhidden = "wipe"
+ vim.bo[state.buf].swapfile = false
+ vim.bo[state.buf].filetype = "jumpy_session"
+
+ local cfg = layout()
+ state.width = cfg.width
+ vim.cmd((cfg.position == "right" and "botright" or "topleft") .. " vsplit")
+ state.win = vim.api.nvim_get_current_win()
+ vim.api.nvim_win_set_buf(state.win, state.buf)
+ vim.api.nvim_win_set_width(state.win, state.width)
+
+ vim.wo[state.win].number = false
+ vim.wo[state.win].relativenumber = false
+ vim.wo[state.win].wrap = false
+ vim.wo[state.win].cursorline = true
+ vim.wo[state.win].winfixwidth = true
+ vim.wo[state.win].signcolumn = "no"
+
+ setup_keymaps()
+
+ vim.api.nvim_create_autocmd("WinClosed", {
+ buffer = state.buf,
+ callback = function()
+ M._teardown()
+ end,
+ })
+end
+
+function M._teardown()
+ if state.unsubscribe then
+ state.unsubscribe()
+ state.unsubscribe = nil
+ end
+ state.win = nil
+ state.buf = nil
+ state.targets = {}
+ state.readonly = nil
+end
+
+function M.close()
+ if state.win and vim.api.nvim_win_is_valid(state.win) then
+ vim.api.nvim_win_close(state.win, true)
+ end
+ M._teardown()
+end
+
+--- Open the live session sidebar (subscribes for live updates).
+function M.open()
+ state.readonly = nil
+ if not (state.win and vim.api.nvim_win_is_valid(state.win)) then
+ create_window()
+ else
+ vim.api.nvim_set_current_win(state.win)
+ end
+ if not state.unsubscribe then
+ state.unsubscribe = session.subscribe(function()
+ vim.schedule(M.render)
+ end)
+ end
+ M.render()
+end
+
+--- Open a reloaded past session in read-only mode.
+function M.open_session(saved)
+ M.close()
+ create_window()
+ state.readonly = saved
+ M.render()
+end
+
+function M.toggle()
+ if state.win and vim.api.nvim_win_is_valid(state.win) then
+ M.close()
+ else
+ M.open()
+ end
+end
+
+--- Present a picker of saved sessions for the project root and open the choice.
+function M.pick()
+ local path = require("jumpy.path")
+ local persist = require("jumpy.persist")
+ local root = path.project_root()
+ local sessions = persist.list(root)
+
+ if #sessions == 0 then
+ vim.notify("jumpy: no saved sessions for this project", vim.log.levels.INFO)
+ return
+ end
+
+ vim.ui.select(sessions, {
+ prompt = "Jumpy sessions",
+ format_item = function(item)
+ local when = os.date("%Y-%m-%d %H:%M", item.started or 0)
+ local summary = item.summary ~= "" and item.summary or "(no prompt)"
+ return string.format("%s · %d prompt(s) · %s", when, item.entry_count or 0, summary)
+ end,
+ }, function(choice)
+ if not choice then
+ return
+ end
+ local saved = persist.load(root, choice.id)
+ if not saved then
+ vim.notify("jumpy: could not load session", vim.log.levels.WARN)
+ return
+ end
+ M.open_session(saved)
+ end)
+end
+
+return M
diff --git a/tests/llm_spec.lua b/tests/llm_spec.lua
index 496cd4b..19b17bb 100644
--- a/tests/llm_spec.lua
+++ b/tests/llm_spec.lua
@@ -25,6 +25,7 @@ describe("llm Claude Code command", function()
"Return only SEARCH/REPLACE blocks.",
"--model",
"sonnet",
+ "--",
"Change this function.",
}, cmd)
end)
@@ -48,7 +49,29 @@ describe("llm Claude Code command", function()
"--no-session-persistence",
"--system-prompt",
"system",
+ "--",
"user",
}, cmd)
end)
end)
+
+describe("llm multi-file messages", function()
+ it("names the current file so relative references resolve", function()
+ local msgs = llm._build_messages({
+ tagged_files = {
+ { path = "a.lua", lines = { "local a = 1" } },
+ { path = "b.lua", lines = { "local b = 2" } },
+ },
+ primary_path = "a.lua",
+ prompt = "add a comment in this file",
+ })
+
+ local user = msgs[2].content
+ assert.is_truthy(user:find("current file", 1, true))
+ assert.is_truthy(user:find('The current file (what "this file" refers to) is: a.lua', 1, true))
+ -- FILE headers stay verbatim so SEARCH paths still match
+ assert.is_truthy(user:find("--- FILE: a.lua ---", 1, true))
+ assert.is_truthy(user:find("--- FILE: b.lua ---", 1, true))
+ assert.is_truthy(user:find("Instruction: add a comment in this file", 1, true))
+ end)
+end)
diff --git a/tests/persist_spec.lua b/tests/persist_spec.lua
new file mode 100644
index 0000000..64ce8b6
--- /dev/null
+++ b/tests/persist_spec.lua
@@ -0,0 +1,76 @@
+-- Add lua/ to package.path so require works outside Neovim
+package.path = package.path .. ";lua/?.lua;lua/?/init.lua"
+
+local persist = require("jumpy.persist")
+
+local function sample_session()
+ return {
+ id = "20260720T101010-1234",
+ root = "/home/user/proj",
+ started = 1000,
+ entries = {
+ {
+ id = 1,
+ kind = "prompt",
+ text = "first prompt",
+ mode = "buffer",
+ time = 1001,
+ results = {
+ {
+ path = "a.lua",
+ bufnr = 7,
+ live = true,
+ hunks = { [1] = { status = "pending", line = 3, preview = "+ x" } },
+ },
+ },
+ },
+ {
+ id = 2,
+ kind = "prompt",
+ text = "second prompt",
+ mode = "multi_file",
+ time = 1002,
+ results = {},
+ },
+ },
+ }
+end
+
+describe("persist pure helpers", function()
+ it("strips runtime-only bufnr fields for serialization", function()
+ local stripped = persist.strip_runtime(sample_session())
+ assert.is_nil(stripped.entries[1].results[1].bufnr)
+ -- original preserved (deep copy, not mutation)
+ local original = sample_session()
+ assert.are.equal(7, original.entries[1].results[1].bufnr)
+ -- other fields survive
+ assert.are.equal("+ x", stripped.entries[1].results[1].hunks[1].preview)
+ end)
+
+ it("summarizes a session using the first prompt text", function()
+ local summary = persist.summarize(sample_session())
+ assert.are.equal("20260720T101010-1234", summary.id)
+ assert.are.equal("/home/user/proj", summary.root)
+ assert.are.equal(1000, summary.started)
+ assert.are.equal(2, summary.entry_count)
+ assert.are.equal("first prompt", summary.summary)
+ end)
+
+ it("produces distinct, filesystem-safe keys per root", function()
+ local a = persist.root_key("/home/user/projectA")
+ local b = persist.root_key("/home/user/projectB")
+
+ assert.are_not.equal(a, b)
+ assert.is_nil(a:find("/"))
+ -- deterministic
+ assert.are.equal(a, persist.root_key("/home/user/projectA"))
+ -- readable basename prefix
+ assert.is_truthy(a:find("^projectA%-"))
+ end)
+
+ it("summary is empty when there are no prompt entries", function()
+ local summary = persist.summarize({ id = "x", root = "/r", started = 1, entries = {} })
+ assert.are.equal("", summary.summary)
+ assert.are.equal(0, summary.entry_count)
+ end)
+end)
diff --git a/tests/session_spec.lua b/tests/session_spec.lua
new file mode 100644
index 0000000..ac90ac2
--- /dev/null
+++ b/tests/session_spec.lua
@@ -0,0 +1,142 @@
+-- Add lua/ to package.path so require works outside Neovim
+package.path = package.path .. ";lua/?.lua;lua/?/init.lua"
+
+local session = require("jumpy.session")
+
+local function result_of(entry, i)
+ return entry.results[i]
+end
+
+describe("session log", function()
+ before_each(function()
+ session._reset()
+ end)
+
+ it("records a prompt entry with mode and text", function()
+ local entry = session.record_prompt({ text = "fix bug", mode = "buffer" })
+
+ assert.are.equal("prompt", entry.kind)
+ assert.are.equal("fix bug", entry.text)
+ assert.are.equal("buffer", entry.mode)
+ assert.are.equal(1, #session.get_entries())
+ end)
+
+ it("attaches results with per-hunk pending status", function()
+ local entry = session.record_prompt({ text = "x", mode = "buffer" })
+ session.record_result(entry, {
+ path = "a.lua",
+ bufnr = 1,
+ hunks = {
+ { idx = 1, line = 10, preview = "+ foo" },
+ { idx = 2, line = 20, preview = "- bar" },
+ },
+ })
+
+ local result = result_of(entry, 1)
+ assert.are.equal("a.lua", result.path)
+ assert.is_true(result.live)
+ assert.are.equal("pending", result.hunks[1].status)
+ assert.are.equal("+ foo", result.hunks[1].preview)
+ assert.are.equal(10, result.hunks[1].line)
+ end)
+
+ it("marks a single hunk on the live result", function()
+ local entry = session.record_prompt({ text = "x", mode = "buffer" })
+ session.record_result(entry, {
+ path = "a.lua",
+ bufnr = 1,
+ hunks = { { idx = 1, line = 1, preview = "+ a" }, { idx = 2, line = 2, preview = "+ b" } },
+ })
+
+ assert.is_true(session.mark_hunk(1, 1, "accepted"))
+ assert.are.equal("accepted", result_of(entry, 1).hunks[1].status)
+ assert.are.equal("pending", result_of(entry, 1).hunks[2].status)
+ end)
+
+ it("mark_all only touches pending hunks", function()
+ local entry = session.record_prompt({ text = "x", mode = "buffer" })
+ session.record_result(entry, {
+ path = "a.lua",
+ bufnr = 1,
+ hunks = { { idx = 1, line = 1, preview = "+ a" }, { idx = 2, line = 2, preview = "+ b" } },
+ })
+ session.mark_hunk(1, 1, "rejected")
+
+ session.mark_all(1, "accepted")
+ assert.are.equal("rejected", result_of(entry, 1).hunks[1].status)
+ assert.are.equal("accepted", result_of(entry, 1).hunks[2].status)
+ end)
+
+ it("supersedes a prior live result for the same buffer on a new proposal", function()
+ local first = session.record_prompt({ text = "one", mode = "buffer" })
+ session.record_result(first, {
+ path = "a.lua",
+ bufnr = 1,
+ hunks = { { idx = 1, line = 1, preview = "+ a" }, { idx = 2, line = 2, preview = "+ b" } },
+ })
+ session.mark_hunk(1, 1, "accepted")
+
+ local second = session.record_prompt({ text = "two", mode = "buffer" })
+ session.record_result(second, {
+ path = "a.lua",
+ bufnr = 1,
+ hunks = { { idx = 1, line = 5, preview = "+ c" } },
+ })
+
+ local old = result_of(first, 1)
+ assert.is_false(old.live)
+ assert.are.equal("accepted", old.hunks[1].status) -- already resolved stays
+ assert.are.equal("superseded", old.hunks[2].status) -- leftover pending frozen
+
+ local new = result_of(second, 1)
+ assert.is_true(new.live)
+ -- New live result owns bufnr 1 now; marks route to it.
+ assert.is_true(session.mark_hunk(1, 1, "rejected"))
+ assert.are.equal("rejected", new.hunks[1].status)
+ end)
+
+ it("does not supersede results for a different buffer", function()
+ local e1 = session.record_prompt({ text = "one", mode = "multi_file" })
+ session.record_result(e1, { path = "a.lua", bufnr = 1, hunks = { { idx = 1, line = 1, preview = "a" } } })
+ session.record_result(e1, { path = "b.lua", bufnr = 2, hunks = { { idx = 1, line = 1, preview = "b" } } })
+
+ assert.is_true(result_of(e1, 1).live)
+ assert.is_true(result_of(e1, 2).live)
+ end)
+
+ it("derives status counts", function()
+ local entry = session.record_prompt({ text = "x", mode = "buffer" })
+ session.record_result(entry, {
+ path = "a.lua",
+ bufnr = 1,
+ hunks = {
+ { idx = 1, line = 1, preview = "a" },
+ { idx = 2, line = 2, preview = "b" },
+ { idx = 3, line = 3, preview = "c" },
+ },
+ })
+ session.mark_hunk(1, 1, "accepted")
+
+ local counts = session.status_counts(result_of(entry, 1))
+ assert.are.equal(1, counts.accepted)
+ assert.are.equal(2, counts.pending)
+ assert.are.equal(3, counts.total)
+ end)
+
+ it("notifies observers on change", function()
+ local calls = 0
+ session.subscribe(function()
+ calls = calls + 1
+ end)
+ session.record_prompt({ text = "x", mode = "buffer" })
+ assert.is_true(calls >= 1)
+ end)
+
+ it("update_hunk_preview refreshes a live hunk in place", function()
+ local entry = session.record_prompt({ text = "x", mode = "buffer" })
+ session.record_result(entry, { path = "a.lua", bufnr = 1, hunks = { { idx = 1, line = 1, preview = "old" } } })
+
+ assert.is_true(session.update_hunk_preview(1, 1, "new"))
+ assert.are.equal("new", result_of(entry, 1).hunks[1].preview)
+ end)
+end)