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/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)