From 408ecf56e7a70b773be19ed470a0e8107344b5f0 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 20 Jul 2026 22:41:07 -0400 Subject: [PATCH 1/4] fix: defer prompt history swap to avoid E565 Up/Down history recall edited the buffer inside an expr mapping, which Vim forbids (E565: Not allowed to change text). Schedule the swap with vim.schedule so it runs outside expr evaluation. --- lua/jumpy/prompt.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index 70dd4c2..febf07c 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -317,11 +317,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 +331,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 }) From 67fd0a1f8c3648464f8eb4c1f3febd4c1f19344e Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 20 Jul 2026 23:25:05 -0400 Subject: [PATCH 2/4] feat: session sidebar with per-project persistence Add a session log that records every prompt and its per-file hunk outcomes, surfaced in an interactive sidebar (:JumpySidebar / s): jump to, accept/reject, and reprompt hunks from one panel. Position and width are configurable so it can sit opposite a file tree. Sessions are saved per project root under stdpath("data")/jumpy/sessions (debounced writes + VimLeavePre flush) and reopened read-only via :JumpySessions. - session.lua: prompt/result log, supersede-on-new-proposal, status counts - persist.lua: JSON save/load/list, bufnr stripped, per-root isolation - navigate/prompt: record outcomes; add navigate.jump_to_hunk - tests: session + persist specs - docs: condense README, move contributing guide to CONTRIBUTING.md --- CONTRIBUTING.md | 9 + README.md | 118 ++++++----- lua/jumpy/init.lua | 27 +++ lua/jumpy/navigate.lua | 22 +++ lua/jumpy/persist.lua | 209 ++++++++++++++++++++ lua/jumpy/prompt.lua | 39 ++++ lua/jumpy/session.lua | 267 +++++++++++++++++++++++++ lua/jumpy/sidebar.lua | 435 +++++++++++++++++++++++++++++++++++++++++ tests/persist_spec.lua | 76 +++++++ tests/session_spec.lua | 142 ++++++++++++++ 10 files changed, 1290 insertions(+), 54 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 lua/jumpy/persist.lua create mode 100644 lua/jumpy/session.lua create mode 100644 lua/jumpy/sidebar.lua create mode 100644 tests/persist_spec.lua create mode 100644 tests/session_spec.lua 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. +

- +
image @@ -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/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 febf07c..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 @@ -403,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) @@ -489,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 @@ -564,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/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) From cd27128bf36182c7cda7d1ec2593f24b07f6f866 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 20 Jul 2026 23:34:03 -0400 Subject: [PATCH 3/4] fix: multi-line error toasts and multi-file claude_code prompts Both surfaced while testing the session sidebar end to end: - loading: collapse newlines before rendering the single-line float, so multi-line error messages no longer crash nvim_buf_set_lines ("replacement string item contains newlines") - llm: pass a `--` end-of-options separator before the Claude Code prompt so multi-file payloads (which begin with "--- FILE: ...") are not parsed as CLI options ("unknown option --- FILE: ...") --- lua/jumpy/llm.lua | 3 +++ lua/jumpy/loading.lua | 4 ++++ tests/llm_spec.lua | 2 ++ 3 files changed, 9 insertions(+) diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index a4adc3a..74f51e8 100644 --- a/lua/jumpy/llm.lua +++ b/lua/jumpy/llm.lua @@ -161,6 +161,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/tests/llm_spec.lua b/tests/llm_spec.lua index 496cd4b..737c63c 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,6 +49,7 @@ describe("llm Claude Code command", function() "--no-session-persistence", "--system-prompt", "system", + "--", "user", }, cmd) end) From cc779295edcaa93821b84aa0eb93305408dfe3da Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Mon, 20 Jul 2026 23:39:59 -0400 Subject: [PATCH 4/4] fix: name the current file in multi-file prompts so "this file" resolves Multi-file payloads listed all tagged files without indicating which buffer the request came from, so relative references like "in this file" had no referent and the model defaulted to whichever file was named explicitly. Add a "current file" hint line naming primary_path before the instruction, leaving the FILE headers verbatim so SEARCH paths still match during patch application. --- lua/jumpy/llm.lua | 8 ++++++++ tests/llm_spec.lua | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index 74f51e8..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" diff --git a/tests/llm_spec.lua b/tests/llm_spec.lua index 737c63c..19b17bb 100644 --- a/tests/llm_spec.lua +++ b/tests/llm_spec.lua @@ -54,3 +54,24 @@ describe("llm Claude Code command", function() }, 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)