From 390eb6de039beca25288a4c5fe97b1d0c239958c Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sat, 1 Aug 2026 22:01:55 +0800 Subject: [PATCH 01/39] chore: fresh rebrand and rework. vibe coded tau.nvim and although i found it useful, it was overblown and i did not feel like i owned the code. reworking it to be more in line with my vision: a single-use utility. --- .gitignore | 2 ++ CONTRIBUTING.md | 1 + LICENSE | 21 +++++++++++++++++++++ README.md | 11 +++++++++++ 4 files changed, 35 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..646ac51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e712304 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +wip diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6dc9dc1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 tim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..85df4ab --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# patch + +A simple agentic inline code editor for NeoVim. + +Built with [pi-ai](#https://github.com/earendil-works/pi/tree/main/packages/ai) package. + +## How it works + +1. Enter Visual Line (`Shift + v`) mode, highlight some text or empty space. +2. Send an instruction (e.g. "Refactor this function", "Add doc strings.", "Add type hints"). +3. Wait for suggestion. Accept,Reject,Retry. From aff97e88782f9b37545a0cabecd2c22b5f268cc1 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sat, 1 Aug 2026 22:05:16 +0800 Subject: [PATCH 02/39] feat(patch): add visual selection capture module --- lua/patch/init.lua | 54 ++++++++++++++++++++++++++++++++++++++++++++++ plugin/patch.lua | 3 +++ 2 files changed, 57 insertions(+) create mode 100644 lua/patch/init.lua create mode 100644 plugin/patch.lua diff --git a/lua/patch/init.lua b/lua/patch/init.lua new file mode 100644 index 0000000..98b73a5 --- /dev/null +++ b/lua/patch/init.lua @@ -0,0 +1,54 @@ +local M = {} + +--- Exit visual mode and reset the selection highlight. +local function leave_visual_mode() + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), + "nx", + false + ) +end + +--- Read the most recent visual selection from the '< and '> marks. +--- +--- Normalises the start and end so that first_line <= last_line. +--- +--- @return integer|nil first_line 1-indexed first line of the selection +--- @return integer|nil last_line 1-indexed last line of the selection +local function get_selection_lines() + local _, start_line = unpack(vim.fn.getpos("'<")) + local _, end_line = unpack(vim.fn.getpos("'>")) + + if start_line == 0 then + return nil, nil + end + + return math.min(start_line, end_line), math.max(start_line, end_line) +end + +--- Create a listed scratch buffer containing the given lines. +--- +--- @param lines string[] lines to write into the buffer +local function write_scratch_buffer(lines) + local buf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) +end + +--- Capture the current visual selection into a scratch buffer and print it. +function M.capture_selection() + leave_visual_mode() + + local first_line, last_line = get_selection_lines() + if not first_line then + print("patch: no visual selection found") + return + end + + local lines = vim.api.nvim_buf_get_lines(0, first_line - 1, last_line, false) + + write_scratch_buffer(lines) + + print(table.concat(lines, "\n")) +end + +return M diff --git a/plugin/patch.lua b/plugin/patch.lua new file mode 100644 index 0000000..ddfff53 --- /dev/null +++ b/plugin/patch.lua @@ -0,0 +1,3 @@ +vim.keymap.set("x", "p", function() + require("patch").capture_selection() +end, { silent = true, desc = "Patch: capture selection" }) From c5565179aae20701aafe9fc6612ee36981361eb7 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 3 Aug 2026 22:16:41 +0800 Subject: [PATCH 03/39] feat(patch): capture selection context --- lua/patch/init.lua | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 98b73a5..c5a3f44 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -26,16 +26,19 @@ local function get_selection_lines() return math.min(start_line, end_line), math.max(start_line, end_line) end ---- Create a listed scratch buffer containing the given lines. +--- Format a named capture group for display. --- ---- @param lines string[] lines to write into the buffer -local function write_scratch_buffer(lines) - local buf = vim.api.nvim_create_buf(true, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) +--- @param name +--- @param lines string[] captured lines +--- @return string formatted_group +local function format_capture_group(name, lines) + local contents = #lines > 0 and table.concat(lines, "\n") or "(empty)" + return string.format("--- %s ---\n%s", name, contents) end ---- Capture the current visual selection into a scratch buffer and print it. +--- Capture the current buffer around its visual selection and print each group. function M.capture_selection() + local source_buf = vim.api.nvim_get_current_buf() leave_visual_mode() local first_line, last_line = get_selection_lines() @@ -44,11 +47,16 @@ function M.capture_selection() return end - local lines = vim.api.nvim_buf_get_lines(0, first_line - 1, last_line, false) - - write_scratch_buffer(lines) - - print(table.concat(lines, "\n")) -end + local capture = { + before = vim.api.nvim_buf_get_lines(source_buf, 0, first_line - 1, false), + selected = vim.api.nvim_buf_get_lines(source_buf, first_line - 1, last_line, false), + after = vim.api.nvim_buf_get_lines(source_buf, last_line, -1, false) + } + + print(table.concat({ + format_capture_group("BEFORE", capture.before), + format_capture_group("SELECTED", capture.selected), + format_capture_group("AFTER", capture.after), + }, "\n"))end return M From d8121befd4cf6f2981174ecc24938edaaba831cb Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 3 Aug 2026 22:34:25 +0800 Subject: [PATCH 04/39] refactor(patch): extract selection capture helpers --- lua/patch/client.lua | 0 lua/patch/init.lua | 63 +++++++---------------------------------- lua/patch/prompt.lua | 25 ++++++++++++++++ lua/patch/selection.lua | 53 ++++++++++++++++++++++++++++++++++ lua/patch/ui.lua | 0 5 files changed, 89 insertions(+), 52 deletions(-) create mode 100644 lua/patch/client.lua create mode 100644 lua/patch/prompt.lua create mode 100644 lua/patch/selection.lua create mode 100644 lua/patch/ui.lua diff --git a/lua/patch/client.lua b/lua/patch/client.lua new file mode 100644 index 0000000..e69de29 diff --git a/lua/patch/init.lua b/lua/patch/init.lua index c5a3f44..127c0f7 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -1,62 +1,21 @@ -local M = {} - ---- Exit visual mode and reset the selection highlight. -local function leave_visual_mode() - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes("", true, false, true), - "nx", - false - ) -end - ---- Read the most recent visual selection from the '< and '> marks. ---- ---- Normalises the start and end so that first_line <= last_line. ---- ---- @return integer|nil first_line 1-indexed first line of the selection ---- @return integer|nil last_line 1-indexed last line of the selection -local function get_selection_lines() - local _, start_line = unpack(vim.fn.getpos("'<")) - local _, end_line = unpack(vim.fn.getpos("'>")) - - if start_line == 0 then - return nil, nil - end +local prompt = require("patch.prompt") +local selection = require("patch.selection") - return math.min(start_line, end_line), math.max(start_line, end_line) -end - ---- Format a named capture group for display. ---- ---- @param name ---- @param lines string[] captured lines ---- @return string formatted_group -local function format_capture_group(name, lines) - local contents = #lines > 0 and table.concat(lines, "\n") or "(empty)" - return string.format("--- %s ---\n%s", name, contents) -end +local M = {} --- Capture the current buffer around its visual selection and print each group. function M.capture_selection() - local source_buf = vim.api.nvim_get_current_buf() - leave_visual_mode() - - local first_line, last_line = get_selection_lines() - if not first_line then + -- local source_buf = vim.api.nvim_get_current_buf() + -- leave_visual_mode() + -- + -- local first_line, last_line = get_selection_lines() + local capture = selection.capture() + if not capture then print("patch: no visual selection found") return end - local capture = { - before = vim.api.nvim_buf_get_lines(source_buf, 0, first_line - 1, false), - selected = vim.api.nvim_buf_get_lines(source_buf, first_line - 1, last_line, false), - after = vim.api.nvim_buf_get_lines(source_buf, last_line, -1, false) - } - - print(table.concat({ - format_capture_group("BEFORE", capture.before), - format_capture_group("SELECTED", capture.selected), - format_capture_group("AFTER", capture.after), - }, "\n"))end + print(prompt.format_capture(capture)) + end return M diff --git a/lua/patch/prompt.lua b/lua/patch/prompt.lua new file mode 100644 index 0000000..807adbf --- /dev/null +++ b/lua/patch/prompt.lua @@ -0,0 +1,25 @@ +local M = {} + +--- Format a named capture group for display. +--- +--- @param name string group name +--- @param lines string[] captured lines +--- @return string formatted_group +local function format_group(name, lines) + local contents = #lines > 0 and table.concat(lines, "\n") or "(empty)" + return string.format("--- %s ---\n%s", name, contents) +end + +--- Format captured buffer context for display or inclusion in a prompt. +--- +--- @param capture PatchCapture captured buffer context +--- @return string formatted_capture +function M.format_capture(capture) + return table.concat({ + format_group("BEFORE", capture.before), + format_group("SELECTED", capture.selected), + format_group("AFTER", capture.after), + }, "\n") +end + +return M diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua new file mode 100644 index 0000000..bd4f982 --- /dev/null +++ b/lua/patch/selection.lua @@ -0,0 +1,53 @@ +local M = {} + +--- @class PatchCapture +--- @field before string[] lines before the selection +--- @field selected string[] selected lines +--- @field after string[] lines after the selection + +--- Exit visual mode and reset the selection highlight. +local function leave_visual_mode() + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), + "nx", + false + ) +end + +--- Read the most recent visual selection from the '< and '> marks. +--- +--- Normalises the start and end so that first_line <= last_line. +--- +--- @return integer|nil first_line 1-indexed first line of the selection +--- @return integer|nil last_line 1-indexed last line of the selection +local function get_selection_lines() + local _, start_line = unpack(vim.fn.getpos("'<")) + local _, end_line = unpack(vim.fn.getpos("'>")) + + if start_line == 0 then + return nil, nil + end + + return math.min(start_line, end_line), math.max(start_line, end_line) +end + +--- Capture the current buffer around its visual selection. +--- +--- @return PatchCapture|nil capture +function M.capture() + local source_buf = vim.api.nvim_get_current_buf() + leave_visual_mode() + + local first_line, last_line = get_selection_lines() + if not first_line then + return nil + end + + return { + before = vim.api.nvim_buf_get_lines(source_buf, 0, first_line - 1, false), + selected = vim.api.nvim_buf_get_lines(source_buf, first_line - 1, last_line, false), + after = vim.api.nvim_buf_get_lines(source_buf, last_line, -1, false), + } +end + +return M diff --git a/lua/patch/ui.lua b/lua/patch/ui.lua new file mode 100644 index 0000000..e69de29 From cfd7f989b52dae9a07d862542b95f1cd29e906a7 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 3 Aug 2026 23:45:44 +0800 Subject: [PATCH 05/39] refactor(patch): remove dead selection code --- lua/patch/init.lua | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 127c0f7..8443c32 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -5,11 +5,8 @@ local M = {} --- Capture the current buffer around its visual selection and print each group. function M.capture_selection() - -- local source_buf = vim.api.nvim_get_current_buf() - -- leave_visual_mode() - -- - -- local first_line, last_line = get_selection_lines() local capture = selection.capture() + if not capture then print("patch: no visual selection found") return From 4da98dbf7e958c1a5fa6599d1ef0941d6574b58b Mon Sep 17 00:00:00 2001 From: timothyckl Date: Tue, 4 Aug 2026 21:09:49 +0800 Subject: [PATCH 06/39] feat(patch): request inline edits from Pi --- lua/patch/client.lua | 98 ++++++++++++++++++++++++++++++++++++++++++++ lua/patch/init.lua | 6 ++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index e69de29..97f00bb 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -0,0 +1,98 @@ +local M = {} + +local SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." + +function M.request(message) + local process + + local stdout_buffer = "" + local assistant_text + local finished = false + + vim.api.nvim_echo({ { "Generating...", "Comment" } }, false, {}) + + + local function display(message) + vim.schedule(function() + print(message) + end) + end + + local function extract_text(message) + local parts = {} + + for _, content in ipairs(message.content) do + if content.type == "text" then + table.insert(parts, content.text) + end + end + + return table.concat(parts, "\n") + end + + local function close_process() + if process then + process:write(nil) + end + end + + local function handle_record(record) + if record.type == "response" and record.command == "prompt" and not record.success then + finished = true + display("Pi rejected the prompt: " .. record.error) + close_process() + return + end + + if record.type == "message_end" and record.message.role == "assistant" then + assistant_text = extract_text(record.message) + return + end + + if record.type == "agent_settled" then + finished = true + display(assistant_text or "Pi returned empty response.") + close_process() + end + end + + local function handle_stdout(error_message, data) + if error_message then + display("Failed to read Pi output: " .. error_message) + return + end + + stdout_buffer = stdout_buffer .. (data or "") + + while true do + local newline = stdout_buffer:find("\n", 1, true) + if not newline then + return + end + + local line = stdout_buffer:sub(1, newline -1):gsub("\r$", "") + stdout_buffer = stdout_buffer:sub(newline + 1) + + if line ~= "" then + handle_record(vim.json.decode(line)) + end + end + end + + process = vim.system( + { "pi", "--mode", "rpc", "--system-prompt", SYSTEM_PROMPT, "--no-session" }, + { stdin = true, stdout = handle_stdout }, + function(result) + if result.code ~= 0 and not finished then + display("Pi exited with code " .. result.code) + end + end + ) + + process:write(vim.json.encode({ + type = "prompt", + message = "Repeat what the selected content is.\n" .. message, + }) .. "\n") +end + +return M diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 8443c32..53facf0 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -1,5 +1,6 @@ local prompt = require("patch.prompt") local selection = require("patch.selection") +local client = require("patch.client") local M = {} @@ -12,7 +13,8 @@ function M.capture_selection() return end - print(prompt.format_capture(capture)) - end + -- print(prompt.format_capture(capture)) + client.request(prompt.format_capture(capture)) +end return M From 4c9728be416d7b2af5931a144c638fcfb65abacc Mon Sep 17 00:00:00 2001 From: timothyckl Date: Wed, 5 Aug 2026 01:23:25 +0800 Subject: [PATCH 07/39] feat(patch): support cancelling Pi requests --- lua/patch/client.lua | 77 ++++++++++++++++++++++++++++++++++++++------ lua/patch/init.lua | 7 +++- plugin/patch.lua | 8 +++-- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 97f00bb..e54e778 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -1,7 +1,30 @@ local M = {} +--- @type vim.SystemObj|nil +M.process = nil + local SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." +--- Check whether a Pi process is active. +--- +--- @return boolean is_running +function M.is_running() + return M.process ~= nil +end + +--- Abort the active Pi request, if one exists. +function M.cancel() + if M.process then + M.process:write(vim.json.encode({ type = "abort" }) .. "\n") + vim.api.nvim_echo({ { "Cancelled", "Comment" } }, false, {}) + else + vim.api.nvim_echo({ { "Nothing to cancel", "Comment" } }, false, {}) + end +end + +--- Send a prompt to Pi and display its final response. +--- +--- @param message string prompt containing the selection and surrounding context function M.request(message) local process @@ -11,13 +34,19 @@ function M.request(message) vim.api.nvim_echo({ { "Generating...", "Comment" } }, false, {}) - + --- Schedule a message for display on Neovim's main event loop. + --- + --- @param message string local function display(message) vim.schedule(function() print(message) end) end + --- Join the text parts of an assistant message. + --- + --- @param message table Pi assistant message + --- @return string text local function extract_text(message) local parts = {} @@ -30,14 +59,40 @@ function M.request(message) return table.concat(parts, "\n") end + --- Clear references to this request's process. + local function clear_process() + if M.process == process then + M.process = nil + end + + process = nil + end + + --- Close the process input stream and clear its references. local function close_process() if process then process:write(nil) end + + clear_process() end + --- Report an unexpected process failure and release the process. + --- + --- @param result vim.SystemCompleted + local function handle_exit(result) + clear_process() + + if result.code ~= 0 and not finished then + display("Pi exited with code " .. result.code) + end + end + + --- Handle a decoded record from Pi's JSONL output. + --- + --- @param record table local function handle_record(record) - if record.type == "response" and record.command == "prompt" and not record.success then + if record.type == "response" and record.command == "prompt" and not record.success then finished = true display("Pi rejected the prompt: " .. record.error) close_process() @@ -49,15 +104,19 @@ function M.request(message) return end - if record.type == "agent_settled" then + if record.type == "agent_settled" then finished = true display(assistant_text or "Pi returned empty response.") close_process() end end + --- Buffer Pi output and dispatch each complete JSONL record. + --- + --- @param error_message string|nil + --- @param data string|nil local function handle_stdout(error_message, data) - if error_message then + if error_message then display("Failed to read Pi output: " .. error_message) return end @@ -80,15 +139,13 @@ function M.request(message) end process = vim.system( - { "pi", "--mode", "rpc", "--system-prompt", SYSTEM_PROMPT, "--no-session" }, + { "pi", "--mode", "rpc", "--system-prompt", SYSTEM_PROMPT, "--no-session", "--thinking", "off" }, { stdin = true, stdout = handle_stdout }, - function(result) - if result.code ~= 0 and not finished then - display("Pi exited with code " .. result.code) - end - end + handle_exit ) + M.process = process + process:write(vim.json.encode({ type = "prompt", message = "Repeat what the selected content is.\n" .. message, diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 53facf0..2d7a79a 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -4,7 +4,12 @@ local client = require("patch.client") local M = {} ---- Capture the current buffer around its visual selection and print each group. +--- Abort the active patch request, if one exists. +function M.cancel() + client.cancel() +end + +--- Capture the visual selection and send it to the patch client with buffer context. function M.capture_selection() local capture = selection.capture() diff --git a/plugin/patch.lua b/plugin/patch.lua index ddfff53..bae3e41 100644 --- a/plugin/patch.lua +++ b/plugin/patch.lua @@ -1,3 +1,5 @@ -vim.keymap.set("x", "p", function() - require("patch").capture_selection() -end, { silent = true, desc = "Patch: capture selection" }) +--- Submit the current visual selection and its buffer context for patching. +vim.keymap.set("x", "p", require("patch").capture_selection) + +--- Cancel the active patch request from normal mode. +vim.keymap.set("n", "", require("patch").cancel) From 8b32f0ea3170410654a031864fccb9900f21ef97 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Wed, 5 Aug 2026 12:11:34 +0800 Subject: [PATCH 08/39] refactor(patch): encapsulate active request state --- lua/patch/client.lua | 20 ++++++-------------- lua/patch/init.lua | 1 - 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index e54e778..997235b 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -1,21 +1,13 @@ local M = {} ---- @type vim.SystemObj|nil -M.process = nil - local SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." ---- Check whether a Pi process is active. ---- ---- @return boolean is_running -function M.is_running() - return M.process ~= nil -end +local active_process = nil --- Abort the active Pi request, if one exists. function M.cancel() - if M.process then - M.process:write(vim.json.encode({ type = "abort" }) .. "\n") + if active_process then + active_process:write(vim.json.encode({ type = "abort" }) .. "\n") vim.api.nvim_echo({ { "Cancelled", "Comment" } }, false, {}) else vim.api.nvim_echo({ { "Nothing to cancel", "Comment" } }, false, {}) @@ -61,8 +53,8 @@ function M.request(message) --- Clear references to this request's process. local function clear_process() - if M.process == process then - M.process = nil + if active_process == process then + active_process = nil end process = nil @@ -144,7 +136,7 @@ function M.request(message) handle_exit ) - M.process = process + active_process = process process:write(vim.json.encode({ type = "prompt", diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 2d7a79a..64af4c5 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -18,7 +18,6 @@ function M.capture_selection() return end - -- print(prompt.format_capture(capture)) client.request(prompt.format_capture(capture)) end From c72880d3d85a8dc3a2b531a952e77d559a69acc8 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 6 Aug 2026 00:29:51 +0800 Subject: [PATCH 09/39] feat(patch): prompt for patch instructions --- lua/patch/client.lua | 21 +++++++++++++--- lua/patch/init.lua | 20 +++++++++------- lua/patch/prompt.lua | 7 ++++++ lua/patch/selection.lua | 1 + lua/patch/ui.lua | 0 lua/patch/ui/init.lua | 11 +++++++++ lua/patch/ui/input.lua | 53 +++++++++++++++++++++++++++++++++++++++++ lua/patch/ui/menu.lua | 53 +++++++++++++++++++++++++++++++++++++++++ plugin/patch.lua | 8 +++---- 9 files changed, 158 insertions(+), 16 deletions(-) delete mode 100644 lua/patch/ui.lua create mode 100644 lua/patch/ui/init.lua create mode 100644 lua/patch/ui/input.lua create mode 100644 lua/patch/ui/menu.lua diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 997235b..982a579 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -17,7 +17,7 @@ end --- Send a prompt to Pi and display its final response. --- --- @param message string prompt containing the selection and surrounding context -function M.request(message) +function M.request(message, on_result) local process local stdout_buffer = "" @@ -96,10 +96,25 @@ function M.request(message) return end + -- if record.type == "agent_settled" then + -- finished = true + -- display(assistant_text or "Pi returned empty response.") + -- close_process() + -- end + if record.type == "agent_settled" then finished = true - display(assistant_text or "Pi returned empty response.") + + local replacement = assistant_text close_process() + + if replacement then + vim.schedule(function() + on_result(replacement) + end) + else + display("Pi returned empty response.") + end end end @@ -140,7 +155,7 @@ function M.request(message) process:write(vim.json.encode({ type = "prompt", - message = "Repeat what the selected content is.\n" .. message, + message = message, }) .. "\n") end diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 64af4c5..60095ec 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -1,24 +1,26 @@ local prompt = require("patch.prompt") local selection = require("patch.selection") local client = require("patch.client") +local ui = require("patch.ui") local M = {} ---- Abort the active patch request, if one exists. -function M.cancel() - client.cancel() -end - ---- Capture the visual selection and send it to the patch client with buffer context. -function M.capture_selection() +function M.start() local capture = selection.capture() if not capture then - print("patch: no visual selection found") + print("patch: no selection found.") return end - client.request(prompt.format_capture(capture)) + ui.open_input(function(instruction) + local message = prompt.build(capture, instruction) + + client.request(message, function(replacement) + print(replacement) + end) + end) + end return M diff --git a/lua/patch/prompt.lua b/lua/patch/prompt.lua index 807adbf..a5f3a35 100644 --- a/lua/patch/prompt.lua +++ b/lua/patch/prompt.lua @@ -22,4 +22,11 @@ function M.format_capture(capture) }, "\n") end +function M.build(capture, instruction) + return table.concat({ + M.format_capture(capture), + string.format("--- INSTRUCTION ---\n%s", instruction), + }, "\n") +end + return M diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua index bd4f982..a4aaa13 100644 --- a/lua/patch/selection.lua +++ b/lua/patch/selection.lua @@ -43,6 +43,7 @@ function M.capture() return nil end + -- TODO: Retain the source buffer and line range for applying reviewed patches. return { before = vim.api.nvim_buf_get_lines(source_buf, 0, first_line - 1, false), selected = vim.api.nvim_buf_get_lines(source_buf, first_line - 1, last_line, false), diff --git a/lua/patch/ui.lua b/lua/patch/ui.lua deleted file mode 100644 index e69de29..0000000 diff --git a/lua/patch/ui/init.lua b/lua/patch/ui/init.lua new file mode 100644 index 0000000..b9c5e9e --- /dev/null +++ b/lua/patch/ui/init.lua @@ -0,0 +1,11 @@ +local input = require("patch.ui.input") +local menu = require("patch.ui.menu") + + +local M = {} + +function M.open_input(on_submit) + input.open(on_submit) +end + +return M diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua new file mode 100644 index 0000000..7afc464 --- /dev/null +++ b/lua/patch/ui/input.lua @@ -0,0 +1,53 @@ +local M = {} + +local Input = require("nui.input") + +local popup_options = { + relative = "cursor", + size = 50, + position = { + row = 2, + col = 1, + }, + border = { + style = "single", + text = { + top = " Instruction ", + top_align = "left", + }, + }, + win_options = { + cursorline = true, + wrap = false, + } +} + +local function handle_close() + print("Input closed!") +end + +-- local function handle_submit(value) +-- print("Value submitted: ", value) +-- end + +local function handle_update() end + +function M.open(on_submit) + local input = Input(popup_options, { + prompt = "> ", + default_value = "", + on_close = handle_close, + on_submit = on_submit, + on_change = handle_update, + }) + + -- unmount input by pressing `` in normal mode + input:map("n", "", function() + input:unmount() + end, { noremap = true }) + + input:mount() + vim.schedule(vim.cmd.startinsert) +end + +return M diff --git a/lua/patch/ui/menu.lua b/lua/patch/ui/menu.lua new file mode 100644 index 0000000..0f1bfb5 --- /dev/null +++ b/lua/patch/ui/menu.lua @@ -0,0 +1,53 @@ +local M = {} + +-- local Menu = require("nui.menu") +-- +-- local menu = Menu({ +-- position = "50%", +-- size = { +-- width = 40, +-- height = 5, +-- }, +-- border = { +-- style = "single", +-- padding = { 0, 2 }, +-- text = { +-- top = "[Choose-an-Element]", +-- top_align = "center", +-- }, +-- }, +-- win_options = { +-- winhighlight = "Normal:Normal,FloatBorder:Normal", +-- }, +-- }, { +-- lines = { +-- Menu.item("Hydrogen (H)"), +-- Menu.item("Carbon (C)"), +-- Menu.item("Nitrogen (N)"), +-- Menu.separator("Noble-Gases", { +-- char = "-", +-- text_align = "center", +-- }), +-- Menu.item("Helium (He)"), +-- Menu.item("Neon (Ne)"), +-- Menu.item("Argon (Ar)"), +-- }, +-- max_width = 20, +-- keymap = { +-- focus_next = { "j", "", "" }, +-- focus_prev = { "k", "", "" }, +-- close = { "", "" }, +-- submit = { "", "" }, +-- }, +-- on_close = function() +-- print("Menu Closed!") +-- end, +-- on_submit = function(item) +-- print("Menu Submitted: ", item.text) +-- end, +-- }) +-- +-- -- mount the component +-- menu:mount() + +return M diff --git a/plugin/patch.lua b/plugin/patch.lua index bae3e41..7c7cddf 100644 --- a/plugin/patch.lua +++ b/plugin/patch.lua @@ -1,5 +1,5 @@ ---- Submit the current visual selection and its buffer context for patching. -vim.keymap.set("x", "p", require("patch").capture_selection) +--- NOTE: Cancelling still occurs globally. Need to revisit this. +--- For now, assume patches cannot be cancelled mid-flight. +-- vim.keymap.set("n", "", require("patch").cancel) ---- Cancel the active patch request from normal mode. -vim.keymap.set("n", "", require("patch").cancel) +vim.keymap.set("x", "p", require("patch").start) From 5b3d408bd8f42fe0046810af42bfb3fe35784512 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 6 Aug 2026 00:43:48 +0800 Subject: [PATCH 10/39] chore(patch): remove unused ui scaffolding --- lua/patch/context.lua | 0 lua/patch/init.lua | 2 ++ lua/patch/ui/init.lua | 1 - lua/patch/ui/input.lua | 4 ---- lua/patch/ui/menu.lua | 50 ------------------------------------------ 5 files changed, 2 insertions(+), 55 deletions(-) create mode 100644 lua/patch/context.lua diff --git a/lua/patch/context.lua b/lua/patch/context.lua new file mode 100644 index 0000000..e69de29 diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 60095ec..4c79ecc 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -5,6 +5,8 @@ local ui = require("patch.ui") local M = {} +-- TODO: If the submitted instruction is empty or whitespace-only, halt without starting generation. +-- See lua/patch/ui/ function M.start() local capture = selection.capture() diff --git a/lua/patch/ui/init.lua b/lua/patch/ui/init.lua index b9c5e9e..8e3cec4 100644 --- a/lua/patch/ui/init.lua +++ b/lua/patch/ui/init.lua @@ -1,7 +1,6 @@ local input = require("patch.ui.input") local menu = require("patch.ui.menu") - local M = {} function M.open_input(on_submit) diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua index 7afc464..ebd2810 100644 --- a/lua/patch/ui/input.lua +++ b/lua/patch/ui/input.lua @@ -26,10 +26,6 @@ local function handle_close() print("Input closed!") end --- local function handle_submit(value) --- print("Value submitted: ", value) --- end - local function handle_update() end function M.open(on_submit) diff --git a/lua/patch/ui/menu.lua b/lua/patch/ui/menu.lua index 0f1bfb5..5044d6f 100644 --- a/lua/patch/ui/menu.lua +++ b/lua/patch/ui/menu.lua @@ -1,53 +1,3 @@ local M = {} --- local Menu = require("nui.menu") --- --- local menu = Menu({ --- position = "50%", --- size = { --- width = 40, --- height = 5, --- }, --- border = { --- style = "single", --- padding = { 0, 2 }, --- text = { --- top = "[Choose-an-Element]", --- top_align = "center", --- }, --- }, --- win_options = { --- winhighlight = "Normal:Normal,FloatBorder:Normal", --- }, --- }, { --- lines = { --- Menu.item("Hydrogen (H)"), --- Menu.item("Carbon (C)"), --- Menu.item("Nitrogen (N)"), --- Menu.separator("Noble-Gases", { --- char = "-", --- text_align = "center", --- }), --- Menu.item("Helium (He)"), --- Menu.item("Neon (Ne)"), --- Menu.item("Argon (Ar)"), --- }, --- max_width = 20, --- keymap = { --- focus_next = { "j", "", "" }, --- focus_prev = { "k", "", "" }, --- close = { "", "" }, --- submit = { "", "" }, --- }, --- on_close = function() --- print("Menu Closed!") --- end, --- on_submit = function(item) --- print("Menu Submitted: ", item.text) --- end, --- }) --- --- -- mount the component --- menu:mount() - return M From e49e161e6ba84800a1c89e1527fa71cc306bd396 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 6 Aug 2026 14:34:24 +0800 Subject: [PATCH 11/39] refactor(patch): preserve selection metadata --- lua/patch/client.lua | 6 ------ lua/patch/context.lua | 1 + lua/patch/prompt.lua | 6 +++--- lua/patch/selection.lua | 35 +++++++++++++++++++++++++---------- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 982a579..43a45c7 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -96,12 +96,6 @@ function M.request(message, on_result) return end - -- if record.type == "agent_settled" then - -- finished = true - -- display(assistant_text or "Pi returned empty response.") - -- close_process() - -- end - if record.type == "agent_settled" then finished = true diff --git a/lua/patch/context.lua b/lua/patch/context.lua index e69de29..ee3b80b 100644 --- a/lua/patch/context.lua +++ b/lua/patch/context.lua @@ -0,0 +1 @@ + -- TODO: Build codebase relevant context. this is different from building the prompt diff --git a/lua/patch/prompt.lua b/lua/patch/prompt.lua index a5f3a35..4da8e05 100644 --- a/lua/patch/prompt.lua +++ b/lua/patch/prompt.lua @@ -16,9 +16,9 @@ end --- @return string formatted_capture function M.format_capture(capture) return table.concat({ - format_group("BEFORE", capture.before), - format_group("SELECTED", capture.selected), - format_group("AFTER", capture.after), + format_group("BEFORE", capture.content.before), + format_group("SELECTED", capture.content.selected), + format_group("AFTER", capture.content.after), }, "\n") end diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua index a4aaa13..cb0fa36 100644 --- a/lua/patch/selection.lua +++ b/lua/patch/selection.lua @@ -1,10 +1,19 @@ local M = {} ---- @class PatchCapture +--- @class PatchLocation +--- @field source_buf integer source buffer handle +--- @field start_line integer 1-indexed first selected line +--- @field end_line integer 1-indexed last selected line + +--- @class PatchContent --- @field before string[] lines before the selection --- @field selected string[] selected lines --- @field after string[] lines after the selection +--- @class PatchCapture +--- @field location PatchLocation source buffer and selected range +--- @field content PatchContent captured buffer contents + --- Exit visual mode and reset the selection highlight. local function leave_visual_mode() vim.api.nvim_feedkeys( @@ -16,10 +25,10 @@ end --- Read the most recent visual selection from the '< and '> marks. --- ---- Normalises the start and end so that first_line <= last_line. +--- Normalises the range so that start_line <= end_line. --- ---- @return integer|nil first_line 1-indexed first line of the selection ---- @return integer|nil last_line 1-indexed last line of the selection +--- @return integer|nil start_line 1-indexed first line of the selection +--- @return integer|nil end_line 1-indexed last line of the selection local function get_selection_lines() local _, start_line = unpack(vim.fn.getpos("'<")) local _, end_line = unpack(vim.fn.getpos("'>")) @@ -38,16 +47,22 @@ function M.capture() local source_buf = vim.api.nvim_get_current_buf() leave_visual_mode() - local first_line, last_line = get_selection_lines() - if not first_line then + local start_line, end_line = get_selection_lines() + if not start_line then return nil end - -- TODO: Retain the source buffer and line range for applying reviewed patches. return { - before = vim.api.nvim_buf_get_lines(source_buf, 0, first_line - 1, false), - selected = vim.api.nvim_buf_get_lines(source_buf, first_line - 1, last_line, false), - after = vim.api.nvim_buf_get_lines(source_buf, last_line, -1, false), + location = { + source_buf = source_buf, + start_line = start_line, + end_line = end_line, + }, + content = { + before = vim.api.nvim_buf_get_lines(source_buf, 0, start_line - 1, false), + selected = vim.api.nvim_buf_get_lines(source_buf, start_line - 1, end_line, false), + after = vim.api.nvim_buf_get_lines(source_buf, end_line, -1, false), + }, } end From 6fa007106f2bcc5151f9e437977b8f61af444052 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 6 Aug 2026 20:56:12 +0800 Subject: [PATCH 12/39] feat(patch): apply generated replacement --- lua/patch/client.lua | 18 +++++++++++------- lua/patch/init.lua | 35 ++++++++++++++++++++++++++++++++++- lua/patch/prompt.lua | 5 +++++ lua/patch/selection.lua | 25 +++++++++++++++++++++---- lua/patch/ui/init.lua | 3 +++ lua/patch/ui/input.lua | 3 +++ 6 files changed, 77 insertions(+), 12 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 43a45c7..6eabfda 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -14,9 +14,10 @@ function M.cancel() end end ---- Send a prompt to Pi and display its final response. +--- Send a prompt to Pi and deliver its final assistant text. --- --- @param message string prompt containing the selection and surrounding context +--- @param on_result fun(replacement: string) receives the generated replacement function M.request(message, on_result) local process @@ -102,13 +103,16 @@ function M.request(message, on_result) local replacement = assistant_text close_process() - if replacement then - vim.schedule(function() + vim.schedule(function() + vim.api.nvim_echo({ { "" } }, false, {}) + + if replacement == nil then + print("Pi returned empty response.") + else on_result(replacement) - end) - else - display("Pi returned empty response.") - end + end + end) + return end end diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 4c79ecc..bede82c 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -7,6 +7,7 @@ local M = {} -- TODO: If the submitted instruction is empty or whitespace-only, halt without starting generation. -- See lua/patch/ui/ +--- Capture a visual selection, request a replacement, and apply it to the tracked range. function M.start() local capture = selection.capture() @@ -19,7 +20,39 @@ function M.start() local message = prompt.build(capture, instruction) client.request(message, function(replacement) - print(replacement) + -- Resolve the extmark after generation so edits outside the selection are reflected. + local position = vim.api.nvim_buf_get_extmark_by_id( + capture.location.source_buf, + capture.location.namespace, + capture.location.extmark_id, + { details = true } + ) + + local start_row = position[1] + local details = position[3] + local end_row = details.end_row + local strict_indexing = true + + -- A terminal newline belongs to the textual response, not a new buffer line. + local stripped = replacement:gsub("\r?\n$", "") + + local replacement_lines = stripped == "" + and {} + or vim.split(stripped, "\n", { plain = true }) + + vim.api.nvim_buf_set_lines( + capture.location.source_buf, + start_row, + end_row, + strict_indexing, + replacement_lines + ) + + vim.api.nvim_buf_del_extmark( + capture.location.source_buf, + capture.location.namespace, + capture.location.extmark_id + ) end) end) diff --git a/lua/patch/prompt.lua b/lua/patch/prompt.lua index 4da8e05..f57cfc4 100644 --- a/lua/patch/prompt.lua +++ b/lua/patch/prompt.lua @@ -22,6 +22,11 @@ function M.format_capture(capture) }, "\n") end +--- Build a model prompt from captured buffer context and the user's instruction. +--- +--- @param capture PatchCapture captured buffer context +--- @param instruction string requested transformation +--- @return string message function M.build(capture, instruction) return table.concat({ M.format_capture(capture), diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua index cb0fa36..39349bb 100644 --- a/lua/patch/selection.lua +++ b/lua/patch/selection.lua @@ -1,9 +1,11 @@ local M = {} +local namespace = vim.api.nvim_create_namespace("patch") + --- @class PatchLocation --- @field source_buf integer source buffer handle ---- @field start_line integer 1-indexed first selected line ---- @field end_line integer 1-indexed last selected line +--- @field namespace integer extmark namespace +--- @field extmark_id integer selection range extmark --- @class PatchContent --- @field before string[] lines before the selection @@ -42,6 +44,8 @@ end --- Capture the current buffer around its visual selection. --- +--- The extmark keeps the selected line range anchored while surrounding lines change. +--- --- @return PatchCapture|nil capture function M.capture() local source_buf = vim.api.nvim_get_current_buf() @@ -52,11 +56,24 @@ function M.capture() return nil end + local extmark_id = vim.api.nvim_buf_set_extmark( + source_buf, + namespace, + start_line - 1, + 0, + { + end_row = end_line, + end_col = 0, + right_gravity = true, + end_right_gravity = false, + } + ) + return { location = { source_buf = source_buf, - start_line = start_line, - end_line = end_line, + namespace = namespace, + extmark_id = extmark_id, }, content = { before = vim.api.nvim_buf_get_lines(source_buf, 0, start_line - 1, false), diff --git a/lua/patch/ui/init.lua b/lua/patch/ui/init.lua index 8e3cec4..3962ed7 100644 --- a/lua/patch/ui/init.lua +++ b/lua/patch/ui/init.lua @@ -3,6 +3,9 @@ local menu = require("patch.ui.menu") local M = {} +--- Open the instruction input and deliver its submitted value. +--- +--- @param on_submit fun(instruction: string) function M.open_input(on_submit) input.open(on_submit) end diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua index ebd2810..7c8b543 100644 --- a/lua/patch/ui/input.lua +++ b/lua/patch/ui/input.lua @@ -28,6 +28,9 @@ end local function handle_update() end +--- Open the instruction input and deliver its submitted value. +--- +--- @param on_submit fun(instruction: string) function M.open(on_submit) local input = Input(popup_options, { prompt = "> ", From 6aa3327fd4f7c60c8a46e1887ee7e5c9b8ebc350 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 10 Aug 2026 01:08:03 +0800 Subject: [PATCH 13/39] feat(patch): preview generated replacement --- lua/patch/init.lua | 38 ++--------------- lua/patch/replacement.lua | 87 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 35 deletions(-) create mode 100644 lua/patch/replacement.lua diff --git a/lua/patch/init.lua b/lua/patch/init.lua index bede82c..92f9c4e 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -1,6 +1,7 @@ local prompt = require("patch.prompt") local selection = require("patch.selection") local client = require("patch.client") +local replacement = require("patch.replacement") local ui = require("patch.ui") local M = {} @@ -19,43 +20,10 @@ function M.start() ui.open_input(function(instruction) local message = prompt.build(capture, instruction) - client.request(message, function(replacement) - -- Resolve the extmark after generation so edits outside the selection are reflected. - local position = vim.api.nvim_buf_get_extmark_by_id( - capture.location.source_buf, - capture.location.namespace, - capture.location.extmark_id, - { details = true } - ) - - local start_row = position[1] - local details = position[3] - local end_row = details.end_row - local strict_indexing = true - - -- A terminal newline belongs to the textual response, not a new buffer line. - local stripped = replacement:gsub("\r?\n$", "") - - local replacement_lines = stripped == "" - and {} - or vim.split(stripped, "\n", { plain = true }) - - vim.api.nvim_buf_set_lines( - capture.location.source_buf, - start_row, - end_row, - strict_indexing, - replacement_lines - ) - - vim.api.nvim_buf_del_extmark( - capture.location.source_buf, - capture.location.namespace, - capture.location.extmark_id - ) + client.request(message, function(response) + replacement.apply(capture.location, response) end) end) - end return M diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua new file mode 100644 index 0000000..6167145 --- /dev/null +++ b/lua/patch/replacement.lua @@ -0,0 +1,87 @@ +local M = {} + +local namespace = vim.api.nvim_create_namespace("patch-replacement") + +--- Convert model output into lines accepted by nvim_buf_set_lines. +--- +--- A terminal newline belongs to the textual response, not a new buffer line. +--- +--- @param response string +--- @return string[] lines +local function to_lines(response) + local stripped = response:gsub("\r?\n$", "") + + if stripped == "" then + return {} + end + + return vim.split(stripped, "\n", { plain = true }) +end + +--- Resolve the current line range of a tracked selection. +--- +--- @param location PatchLocation +--- @return table range +local function resolve_range(location) + local position = vim.api.nvim_buf_get_extmark_by_id( + location.source_buf, + location.namespace, + location.extmark_id, + { details = true } + ) + + return { + start_row = position[1], + end_row = position[3].end_row, + } +end + +--- Resolve a tracked selection and replace its current line range. +--- +--- @param location PatchLocation +--- @param response string +function M.apply(location, response) + local range = resolve_range(location) + local lines = to_lines(response) + + vim.api.nvim_buf_set_lines( + location.source_buf, + range.end_row, + range.end_row, + true, + lines + ) + + vim.api.nvim_buf_set_extmark( + location.source_buf, + location.namespace, + range.start_row, + 0, + { + id = location.extmark_id, + end_row = range.end_row, + end_col = 0, + right_gravity = true, + end_right_gravity = false, + hl_group = "DiffDelete", + hl_eol = true, + } + ) + + if #lines > 0 then + vim.api.nvim_buf_set_extmark( + location.source_buf, + namespace, + range.end_row, + 0, + { + end_row = range.end_row + #lines, + end_col = 0, + hl_group = "DiffAdd", + hl_eol = true, + } + ) + end +end + +return M From c737f312250e1d000dd693d5dae37d494f2e83e7 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 10 Aug 2026 16:41:25 +0800 Subject: [PATCH 14/39] feat(patch): add replacement proposal actions --- lua/patch/init.lua | 8 ++- lua/patch/replacement.lua | 108 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 92f9c4e..0f29986 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -21,7 +21,13 @@ function M.start() local message = prompt.build(capture, instruction) client.request(message, function(response) - replacement.apply(capture.location, response) + local proposal = replacement.apply(capture.location, response) + + -- TODO: Map these to key bindings + -- replacement.accept(proposal) + -- replacement.reject(proposal) + -- replacement.retry(proposal) + end) end) end diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index 6167145..bf15c76 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -2,10 +2,14 @@ local M = {} local namespace = vim.api.nvim_create_namespace("patch-replacement") +---@class PatchProposal +---@field location PatchLocation +---@field generated_mark integer|nil +---@field actions_mark integer +---@field status "pending"|"retrying"|"finished" + --- Convert model output into lines accepted by nvim_buf_set_lines. --- ---- A terminal newline belongs to the textual response, not a new buffer line. ---- --- @param response string --- @return string[] lines local function to_lines(response) @@ -36,10 +40,13 @@ local function resolve_range(location) } end +--- TODO: Map actions to options +--- TODO: Document this function --- Resolve a tracked selection and replace its current line range. --- --- @param location PatchLocation --- @param response string +--- @return PatchProposal function M.apply(location, response) local range = resolve_range(location) local lines = to_lines(response) @@ -68,8 +75,10 @@ function M.apply(location, response) } ) + local patch_mark + if #lines > 0 then - vim.api.nvim_buf_set_extmark( + patch_mark = vim.api.nvim_buf_set_extmark( location.source_buf, namespace, range.end_row, @@ -82,6 +91,99 @@ function M.apply(location, response) } ) end + + local options_mark = vim.api.nvim_buf_set_extmark( + location.source_buf, + namespace, + range.end_row + #lines - 1, + 0, + { + virt_lines = { + { + { "[a] Accept [r] Reject [R] Retry", "Comment" }, + }, + }, + } + ) + + return { + location = location, + generated_mark = patch_mark, + actions_mark = options_mark, + status = "pending" + } +end + +function M.accept(proposal) + if proposal.status ~= "pending" then + return + end + + local location = proposal.location + local source_buf = location.source_buf + local range = resolve_range(location) + + -- remove original selected lines. replacement will take over. + vim.api.nvim_buf_set_lines( + source_buf, + range.start_row, + range.end_row, + false, + {} + ) + + -- clear "patch" namespace + vim.api.nvim_buf_clear_namespace( + source_buf, + location.namespace, + range.start_row, + range.end_row + ) + + -- delete extmark tracking selected lines in "patch" namespace + vim.api.nvim_buf_del_extmark( + source_buf, + location.namespace, + location.extmark_id + ) + + -- clear generated replacement and action prompt extmarks + if proposal.generated_mark then + vim.api.nvim_buf_del_extmark( + source_buf, + namespace, + proposal.generated_mark + ) + end + + -- remove action prompt extmark + vim.api.nvim_buf_del_extmark( + source_buf, + namespace, + proposal.actions_mark + ) + + proposal.status = "finished" +end + +function M.reject(proposal) + if proposal.status ~= "pending" then + return + end + + -- ... + + proposal.status = "finished" +end + +function M.retry(proposal) + if proposal.status ~= "pending" then + return + end + + -- ... + + proposal.status = "retrying" end return M From 19831a4b575f619a128e6b20e1117296edab007c Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 10 Aug 2026 18:17:43 +0800 Subject: [PATCH 15/39] fix(patch): clean up accepted and rejected replacements --- lua/patch/replacement.lua | 85 +++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index bf15c76..a4d0bde 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -112,6 +112,7 @@ function M.apply(location, response) actions_mark = options_mark, status = "pending" } + end function M.accept(proposal) @@ -123,6 +124,29 @@ function M.accept(proposal) local source_buf = location.source_buf local range = resolve_range(location) + -- delete location.extmark_id + vim.api.nvim_buf_del_extmark( + source_buf, + location.namespace, + location.extmark_id + ) + + -- clear generated replacement and action prompt extmarks + if proposal.generated_mark then + vim.api.nvim_buf_del_extmark( + source_buf, + namespace, + proposal.generated_mark + ) + end + + -- remove action prompt extmark + vim.api.nvim_buf_del_extmark( + source_buf, + namespace, + proposal.actions_mark + ) + -- remove original selected lines. replacement will take over. vim.api.nvim_buf_set_lines( source_buf, @@ -132,22 +156,49 @@ function M.accept(proposal) {} ) - -- clear "patch" namespace - vim.api.nvim_buf_clear_namespace( - source_buf, - location.namespace, - range.start_row, - range.end_row + proposal.status = "finished" +end + +local function resolve_generated_range(proposal) + if not proposal.generated_mark then + return + end + + local position = vim.api.nvim_buf_get_extmark_by_id( + proposal.location.source_buf, + namespace, + proposal.generated_mark, + { details = true } ) - -- delete extmark tracking selected lines in "patch" namespace + if #position == 0 then + return + end + + return { + start_row = position[1], + end_row = position[3].end_row + } +end + + +function M.reject(proposal) + if proposal.status ~= "pending" then + return + end + + local location = proposal.location + local source_buf = location.source_buf + local gen_range = resolve_generated_range(proposal) + + -- delete location.extmark_id vim.api.nvim_buf_del_extmark( source_buf, location.namespace, location.extmark_id ) - -- clear generated replacement and action prompt extmarks + -- delete proposal.generated_mark if proposal.generated_mark then vim.api.nvim_buf_del_extmark( source_buf, @@ -156,23 +207,23 @@ function M.accept(proposal) ) end - -- remove action prompt extmark + -- delete proposal.actions_mark vim.api.nvim_buf_del_extmark( source_buf, namespace, proposal.actions_mark ) - proposal.status = "finished" -end - -function M.reject(proposal) - if proposal.status ~= "pending" then - return + if gen_range then + vim.api.nvim_buf_set_lines( + source_buf, + gen_range.start_row, + gen_range.end_row, + false, + {} + ) end - -- ... - proposal.status = "finished" end From 143c2f974e181f9a81909676bf75befe3cb282c4 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 10 Aug 2026 22:10:44 +0800 Subject: [PATCH 16/39] fix(patch): handle failed replacement requests --- lua/patch/client.lua | 107 ++++++++---- lua/patch/init.lua | 19 ++- lua/patch/replacement.lua | 341 ++++++++++++++++++++++++-------------- 3 files changed, 311 insertions(+), 156 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 6eabfda..3d686ef 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -17,13 +17,13 @@ end --- Send a prompt to Pi and deliver its final assistant text. --- --- @param message string prompt containing the selection and surrounding context ---- @param on_result fun(replacement: string) receives the generated replacement -function M.request(message, on_result) +--- @param on_complete fun(res: string|nil, err: string|nil) +function M.request(message, on_complete) local process local stdout_buffer = "" local assistant_text - local finished = false + local completed = false vim.api.nvim_echo({ { "Generating...", "Comment" } }, false, {}) @@ -36,6 +36,35 @@ function M.request(message, on_result) end) end + --- Complete this request exactly once on Neovim's main event loop. + --- + --- @param res string|nil + --- @param err string|nil + local function complete(res, err) + if completed then + return + end + + completed = true + + vim.schedule(function() + vim.api.nvim_echo({ { "" } }, false, {}) + on_complete(res, err) + end) + end + + --- Report a request failure and complete it. + --- + --- @param err string + local function fail(err) + if completed then + return + end + + complete(nil, err) + display(err) + end + --- Join the text parts of an assistant message. --- --- @param message table Pi assistant message @@ -63,11 +92,13 @@ function M.request(message, on_result) --- Close the process input stream and clear its references. local function close_process() - if process then - process:write(nil) - end + local current_process = process clear_process() + + if current_process then + pcall(current_process.write, current_process, nil) + end end --- Report an unexpected process failure and release the process. @@ -76,8 +107,12 @@ function M.request(message, on_result) local function handle_exit(result) clear_process() - if result.code ~= 0 and not finished then - display("Pi exited with code " .. result.code) + if not completed then + if result.code ~= 0 then + fail("Pi exited with code " .. result.code) + else + fail("Pi exited before returning a response") + end end end @@ -86,8 +121,7 @@ function M.request(message, on_result) --- @param record table local function handle_record(record) if record.type == "response" and record.command == "prompt" and not record.success then - finished = true - display("Pi rejected the prompt: " .. record.error) + fail("Pi rejected the prompt: " .. record.error) close_process() return end @@ -98,20 +132,15 @@ function M.request(message, on_result) end if record.type == "agent_settled" then - finished = true - local replacement = assistant_text - close_process() - vim.schedule(function() - vim.api.nvim_echo({ { "" } }, false, {}) + if replacement == nil then + fail("Pi returned no response") + else + complete(replacement, nil) + end - if replacement == nil then - print("Pi returned empty response.") - else - on_result(replacement) - end - end) + close_process() return end end @@ -122,7 +151,8 @@ function M.request(message, on_result) --- @param data string|nil local function handle_stdout(error_message, data) if error_message then - display("Failed to read Pi output: " .. error_message) + fail("Failed to read Pi output: " .. error_message) + close_process() return end @@ -138,23 +168,44 @@ function M.request(message, on_result) stdout_buffer = stdout_buffer:sub(newline + 1) if line ~= "" then - handle_record(vim.json.decode(line)) + local decoded, record = pcall(vim.json.decode, line) + + if not decoded then + fail("Failed to decode Pi output: " .. record) + close_process() + return + end + + handle_record(record) end end end - process = vim.system( + local started, process_or_error = pcall(vim.system, { "pi", "--mode", "rpc", "--system-prompt", SYSTEM_PROMPT, "--no-session", "--thinking", "off" }, { stdin = true, stdout = handle_stdout }, handle_exit ) + if not started then + fail("Failed to start Pi: " .. tostring(process_or_error)) + return + end + + process = process_or_error active_process = process - process:write(vim.json.encode({ - type = "prompt", - message = message, - }) .. "\n") + local sent, write_error = pcall(function() + process:write(vim.json.encode({ + type = "prompt", + message = message, + }) .. "\n") + end) + + if not sent then + fail("Failed to send prompt to Pi: " .. tostring(write_error)) + close_process() + end end return M diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 0f29986..88dc554 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -20,14 +20,21 @@ function M.start() ui.open_input(function(instruction) local message = prompt.build(capture, instruction) - client.request(message, function(response) - local proposal = replacement.apply(capture.location, response) - - -- TODO: Map these to key bindings + client.request(message, function(res, err) + if err then + return + end + + local proposal = replacement.apply(capture.location, res) + if not proposal then + vim.notify("patch: selection no longer exists", vim.log.levels.ERROR) + return + end + + -- TODO: Connect these actions to user input. -- replacement.accept(proposal) -- replacement.reject(proposal) - -- replacement.retry(proposal) - + -- replacement.retry(proposal, client, message) end) end) end diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index a4d0bde..a52234d 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -5,13 +5,15 @@ local namespace = vim.api.nvim_create_namespace("patch-replacement") ---@class PatchProposal ---@field location PatchLocation ---@field generated_mark integer|nil ----@field actions_mark integer +---@field actions_mark integer|nil ---@field status "pending"|"retrying"|"finished" --- Convert model output into lines accepted by nvim_buf_set_lines. --- ---- @param response string ---- @return string[] lines +--- A terminal newline belongs to the textual response, not a new buffer line. +--- +---@param response string +---@return string[] lines local function to_lines(response) local stripped = response:gsub("\r?\n$", "") @@ -24,9 +26,13 @@ end --- Resolve the current line range of a tracked selection. --- ---- @param location PatchLocation ---- @return table range +---@param location PatchLocation +---@return table|nil range local function resolve_range(location) + if not vim.api.nvim_buf_is_valid(location.source_buf) then + return nil + end + local position = vim.api.nvim_buf_get_extmark_by_id( location.source_buf, location.namespace, @@ -34,28 +40,120 @@ local function resolve_range(location) { details = true } ) + if #position == 0 or not position[3] or position[3].end_row == nil then + return nil + end + return { start_row = position[1], end_row = position[3].end_row, } end ---- TODO: Map actions to options ---- TODO: Document this function ---- Resolve a tracked selection and replace its current line range. +--- Resolve the current generated line range of a proposal. --- ---- @param location PatchLocation ---- @param response string ---- @return PatchProposal +---@param proposal PatchProposal +---@return table|nil range +local function resolve_generated_range(proposal) + if not proposal.generated_mark then + return nil + end + + local source_buf = proposal.location.source_buf + if not vim.api.nvim_buf_is_valid(source_buf) then + return nil + end + + local position = vim.api.nvim_buf_get_extmark_by_id( + source_buf, + namespace, + proposal.generated_mark, + { details = true } + ) + + if #position == 0 or not position[3] or position[3].end_row == nil then + return nil + end + + return { + start_row = position[1], + end_row = position[3].end_row, + } +end + +---@param proposal PatchProposal +local function delete_preview_marks(proposal) + local source_buf = proposal.location.source_buf + if not vim.api.nvim_buf_is_valid(source_buf) then + return + end + + if proposal.generated_mark then + vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) + proposal.generated_mark = nil + end + + if proposal.actions_mark then + vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.actions_mark) + proposal.actions_mark = nil + end +end + +--- Create the generated-range and action-line extmarks for a proposal. +--- +---@param proposal PatchProposal +---@param generated_start integer +---@param original_end integer +---@param line_count integer +local function create_preview_marks(proposal, generated_start, original_end, line_count) + local source_buf = proposal.location.source_buf + + if line_count > 0 then + proposal.generated_mark = vim.api.nvim_buf_set_extmark( + source_buf, + namespace, + generated_start, + 0, + { + end_row = generated_start + line_count, + end_col = 0, + hl_group = "DiffAdd", + hl_eol = true, + } + ) + end + + local actions_row = line_count > 0 + and generated_start + line_count - 1 + or math.max(original_end - 1, 0) + + proposal.actions_mark = vim.api.nvim_buf_set_extmark( + source_buf, + namespace, + actions_row, + 0, + { virt_lines = { { { "[a] Accept [r] Reject [R] Retry", "Comment" } } } } + ) +end + +--- Insert a generated replacement after a tracked selection. +--- +---@param location PatchLocation +---@param response string +---@return PatchProposal|nil proposal function M.apply(location, response) local range = resolve_range(location) + if not range then + return nil + end + local lines = to_lines(response) vim.api.nvim_buf_set_lines( location.source_buf, range.end_row, range.end_row, - true, + false, lines ) @@ -75,166 +173,165 @@ function M.apply(location, response) } ) - local patch_mark + local proposal = { + location = location, + generated_mark = nil, + actions_mark = nil, + status = "pending", + } - if #lines > 0 then - patch_mark = vim.api.nvim_buf_set_extmark( - location.source_buf, - namespace, - range.end_row, - 0, - { - end_row = range.end_row + #lines, - end_col = 0, - hl_group = "DiffAdd", - hl_eol = true, - } - ) + create_preview_marks(proposal, range.end_row, range.end_row, #lines) + return proposal +end + +--- Replace a proposal's generated span with a new response. +--- +---@param proposal PatchProposal +---@param response string +---@return PatchProposal|nil proposal +---@return string|nil error_message +function M.update(proposal, response) + local original_range = resolve_range(proposal.location) + if not original_range then + return nil, "patch: original selection no longer exists" end - local options_mark = vim.api.nvim_buf_set_extmark( - location.source_buf, - namespace, - range.end_row + #lines - 1, - 0, - { - virt_lines = { - { - { "[a] Accept [r] Reject [R] Retry", "Comment" }, - }, - }, - } - ) + local generated_range = resolve_generated_range(proposal) + if proposal.generated_mark and not generated_range then + return nil, "patch: generated replacement no longer exists" + end - return { - location = location, - generated_mark = patch_mark, - actions_mark = options_mark, - status = "pending" + generated_range = generated_range or { + start_row = original_range.end_row, + end_row = original_range.end_row, } + local lines = to_lines(response) + delete_preview_marks(proposal) + + vim.api.nvim_buf_set_lines( + proposal.location.source_buf, + generated_range.start_row, + generated_range.end_row, + false, + lines + ) + + create_preview_marks( + proposal, + generated_range.start_row, + original_range.end_row, + #lines + ) + + return proposal end +--- Accept a proposal by removing the original selected lines. +--- +---@param proposal PatchProposal +---@return boolean accepted function M.accept(proposal) if proposal.status ~= "pending" then - return + return false end - local location = proposal.location - local source_buf = location.source_buf - local range = resolve_range(location) - - -- delete location.extmark_id - vim.api.nvim_buf_del_extmark( - source_buf, - location.namespace, - location.extmark_id - ) - - -- clear generated replacement and action prompt extmarks - if proposal.generated_mark then - vim.api.nvim_buf_del_extmark( - source_buf, - namespace, - proposal.generated_mark - ) + local range = resolve_range(proposal.location) + if not range then + return false end - -- remove action prompt extmark + proposal.status = "finished" + delete_preview_marks(proposal) vim.api.nvim_buf_del_extmark( - source_buf, - namespace, - proposal.actions_mark + proposal.location.source_buf, + proposal.location.namespace, + proposal.location.extmark_id ) - -- remove original selected lines. replacement will take over. vim.api.nvim_buf_set_lines( - source_buf, + proposal.location.source_buf, range.start_row, range.end_row, false, {} ) - proposal.status = "finished" + return true end -local function resolve_generated_range(proposal) - if not proposal.generated_mark then - return - end - - local position = vim.api.nvim_buf_get_extmark_by_id( - proposal.location.source_buf, - namespace, - proposal.generated_mark, - { details = true } - ) - - if #position == 0 then - return - end - - return { - start_row = position[1], - end_row = position[3].end_row - } -end - - +--- Reject a proposal by removing its generated lines. +--- +---@param proposal PatchProposal +---@return boolean rejected function M.reject(proposal) if proposal.status ~= "pending" then - return + return false end - local location = proposal.location - local source_buf = location.source_buf - local gen_range = resolve_generated_range(proposal) - - -- delete location.extmark_id - vim.api.nvim_buf_del_extmark( - source_buf, - location.namespace, - location.extmark_id - ) + local generated_range = resolve_generated_range(proposal) + if proposal.generated_mark and not generated_range then + return false + end - -- delete proposal.generated_mark - if proposal.generated_mark then - vim.api.nvim_buf_del_extmark( - source_buf, - namespace, - proposal.generated_mark - ) + local source_buf = proposal.location.source_buf + if not vim.api.nvim_buf_is_valid(source_buf) then + return false end - -- delete proposal.actions_mark + proposal.status = "finished" + delete_preview_marks(proposal) vim.api.nvim_buf_del_extmark( source_buf, - namespace, - proposal.actions_mark + proposal.location.namespace, + proposal.location.extmark_id ) - if gen_range then + if generated_range then vim.api.nvim_buf_set_lines( source_buf, - gen_range.start_row, - gen_range.end_row, + generated_range.start_row, + generated_range.end_row, false, {} ) end - proposal.status = "finished" + return true end -function M.retry(proposal) +--- Request another response and update a proposal in place. +--- +---@param proposal PatchProposal +---@param client table client exposing request(message, on_complete) +---@param message string original prompt message +---@return boolean started +function M.retry(proposal, client, message) if proposal.status ~= "pending" then - return + return false end - -- ... - proposal.status = "retrying" + + client.request(message, function(res, err) + if proposal.status ~= "retrying" then + return + end + + if err then + proposal.status = "pending" + return + end + + local updated, update_error = M.update(proposal, res) + proposal.status = "pending" + + if not updated then + vim.notify(tostring(update_error), vim.log.levels.ERROR) + end + end) + + return true end return M From 55c6b8e45981317d02c5cb209aa153a33f2af2d9 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 13 Aug 2026 23:19:33 +0800 Subject: [PATCH 17/39] feat(patch): add commands and configurable notifications --- README.md | 50 ++++++++++++++++++--- lua/patch/client.lua | 10 ++--- lua/patch/init.lua | 91 ++++++++++++++++++++++++++++++++++++--- lua/patch/notify.lua | 19 ++++++++ lua/patch/replacement.lua | 44 +++++-------------- lua/patch/ui/input.lua | 5 --- plugin/patch.lua | 24 +++++++++-- 7 files changed, 184 insertions(+), 59 deletions(-) create mode 100644 lua/patch/notify.lua diff --git a/README.md b/README.md index 85df4ab..5b68c85 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,49 @@ -# patch +# patch -A simple agentic inline code editor for NeoVim. +A simple agentic inline code editor for Neovim. -Built with [pi-ai](#https://github.com/earendil-works/pi/tree/main/packages/ai) package. +Built with the [pi-ai](https://github.com/earendil-works/pi/tree/main/packages/ai) package. ## How it works -1. Enter Visual Line (`Shift + v`) mode, highlight some text or empty space. -2. Send an instruction (e.g. "Refactor this function", "Add doc strings.", "Add type hints"). -3. Wait for suggestion. Accept,Reject,Retry. +1. Enter Visual Line (`Shift + v`) mode and highlight some text or empty space. +2. Send an instruction, such as “Refactor this function” or “Add type hints.” +3. Wait for the suggestion, then accept, reject, or retry it. + +## Authentication + +Authenticate through the Pi coding agent before using patch.nvim. Start Pi, enter `/login` (or `/login `), and follow the prompts: + +```sh +pi +``` + +Alternatively, authenticate an OAuth provider from the command line: + +```sh +cd ~/.pi/agent +npx @earendil-works/pi-ai login [provider] +``` + +## Configuration + +```lua +{ + "tim/tau.nvim", + dependencies = { + "MunifTanjim/nui.nvim", + "rcarriga/nvim-notify", + }, + config = function() + require("patch").setup({ + notify = require("notify"), + }) + + vim.keymap.set("x", "p", "PatchCapture", { desc = "Capture selection and request a patch" }) + vim.keymap.set("n", "pa", "PatchAccept", { desc = "Accept the active patch proposal" }) + vim.keymap.set("n", "pr", "PatchReject", { desc = "Reject the active patch and restore the original" }) + vim.keymap.set("n", "pR", "PatchRetry", { desc = "Generate a new active patch proposal" }) + vim.keymap.set("n", "pc", "PatchCancel", { desc = "Cancel the active patch generation request" }) + end, +} +``` diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 3d686ef..5b076c2 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -1,4 +1,5 @@ local M = {} +local notify = require("patch.notify") local SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." @@ -8,9 +9,9 @@ local active_process = nil function M.cancel() if active_process then active_process:write(vim.json.encode({ type = "abort" }) .. "\n") - vim.api.nvim_echo({ { "Cancelled", "Comment" } }, false, {}) + notify.send("patch: cancelled", vim.log.levels.INFO) else - vim.api.nvim_echo({ { "Nothing to cancel", "Comment" } }, false, {}) + notify.send("patch: nothing to cancel", vim.log.levels.WARN) end end @@ -25,14 +26,14 @@ function M.request(message, on_complete) local assistant_text local completed = false - vim.api.nvim_echo({ { "Generating...", "Comment" } }, false, {}) + notify.send("patch: generating...", vim.log.levels.INFO) --- Schedule a message for display on Neovim's main event loop. --- --- @param message string local function display(message) vim.schedule(function() - print(message) + notify.send(message, vim.log.levels.ERROR) end) end @@ -48,7 +49,6 @@ function M.request(message, on_complete) completed = true vim.schedule(function() - vim.api.nvim_echo({ { "" } }, false, {}) on_complete(res, err) end) end diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 88dc554..9af3da9 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -3,40 +3,119 @@ local selection = require("patch.selection") local client = require("patch.client") local replacement = require("patch.replacement") local ui = require("patch.ui") +local notify = require("patch.notify") local M = {} +local active_proposal = nil +local active_message = nil +local requesting = false + +---@class PatchOptions +---@field notify? function|table notification provider compatible with vim.notify + +---@param opts? PatchOptions +function M.setup(opts) + opts = opts or {} + notify.setup(opts.notify) +end + +---@param message string +local function warn(message) + notify.send("patch: " .. message, vim.log.levels.WARN) +end + +local function clear_active_patch() + active_proposal = nil + active_message = nil +end -- TODO: If the submitted instruction is empty or whitespace-only, halt without starting generation. -- See lua/patch/ui/ --- Capture a visual selection, request a replacement, and apply it to the tracked range. function M.start() + if requesting then + warn("a replacement is already being generated") + return + end + + if active_proposal then + warn("accept or reject the active proposal first") + return + end + local capture = selection.capture() if not capture then - print("patch: no selection found.") + warn("no selection found") return end ui.open_input(function(instruction) local message = prompt.build(capture, instruction) + requesting = true client.request(message, function(res, err) + requesting = false + if err then return end local proposal = replacement.apply(capture.location, res) if not proposal then - vim.notify("patch: selection no longer exists", vim.log.levels.ERROR) + notify.send("patch: selection no longer exists", vim.log.levels.ERROR) return end - -- TODO: Connect these actions to user input. - -- replacement.accept(proposal) - -- replacement.reject(proposal) - -- replacement.retry(proposal, client, message) + active_proposal = proposal + active_message = message end) end) end +--- Accept the active replacement proposal. +function M.accept() + if not active_proposal then + warn("no active proposal to accept") + return + end + + if replacement.accept(active_proposal) then + clear_active_patch() + else + warn("the active proposal cannot be accepted") + end +end + +--- Reject the active replacement proposal. +function M.reject() + if not active_proposal then + warn("no active proposal to reject") + return + end + + if replacement.reject(active_proposal) then + clear_active_patch() + else + warn("the active proposal cannot be rejected") + end +end + +--- Request another replacement for the active proposal. +function M.retry() + if not active_proposal or not active_message then + warn("no active proposal to retry") + return + end + + if not replacement.retry(active_proposal, client, active_message) then + warn("the active proposal cannot be retried") + end +end + +--- Cancel the active replacement request, if one exists. +function M.cancel() + client.cancel() +end + return M diff --git a/lua/patch/notify.lua b/lua/patch/notify.lua new file mode 100644 index 0000000..dae5889 --- /dev/null +++ b/lua/patch/notify.lua @@ -0,0 +1,19 @@ +local M = {} + +local provider = nil + +---@param notify_provider function|table|nil +function M.setup(notify_provider) + provider = notify_provider +end + +---@param message string +---@param level integer +---@param opts? table +function M.send(message, level, opts) + opts = vim.tbl_extend("keep", opts or {}, { title = "patch.nvim" }) + local notifier = provider or vim.notify + notifier(message, level, opts) +end + +return M diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index a52234d..a211fc2 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -1,11 +1,11 @@ local M = {} +local notify = require("patch.notify") local namespace = vim.api.nvim_create_namespace("patch-replacement") ---@class PatchProposal ---@field location PatchLocation ---@field generated_mark integer|nil ----@field actions_mark integer|nil ---@field status "pending"|"retrying"|"finished" --- Convert model output into lines accepted by nvim_buf_set_lines. @@ -82,7 +82,7 @@ local function resolve_generated_range(proposal) end ---@param proposal PatchProposal -local function delete_preview_marks(proposal) +local function delete_generated_mark(proposal) local source_buf = proposal.location.source_buf if not vim.api.nvim_buf_is_valid(source_buf) then return @@ -92,20 +92,14 @@ local function delete_preview_marks(proposal) vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) proposal.generated_mark = nil end - - if proposal.actions_mark then - vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.actions_mark) - proposal.actions_mark = nil - end end ---- Create the generated-range and action-line extmarks for a proposal. +--- Create the generated-range extmark for a proposal. --- ---@param proposal PatchProposal ---@param generated_start integer ----@param original_end integer ---@param line_count integer -local function create_preview_marks(proposal, generated_start, original_end, line_count) +local function create_generated_mark(proposal, generated_start, line_count) local source_buf = proposal.location.source_buf if line_count > 0 then @@ -122,18 +116,6 @@ local function create_preview_marks(proposal, generated_start, original_end, lin } ) end - - local actions_row = line_count > 0 - and generated_start + line_count - 1 - or math.max(original_end - 1, 0) - - proposal.actions_mark = vim.api.nvim_buf_set_extmark( - source_buf, - namespace, - actions_row, - 0, - { virt_lines = { { { "[a] Accept [r] Reject [R] Retry", "Comment" } } } } - ) end --- Insert a generated replacement after a tracked selection. @@ -176,11 +158,10 @@ function M.apply(location, response) local proposal = { location = location, generated_mark = nil, - actions_mark = nil, status = "pending", } - create_preview_marks(proposal, range.end_row, range.end_row, #lines) + create_generated_mark(proposal, range.end_row, #lines) return proposal end @@ -207,7 +188,7 @@ function M.update(proposal, response) } local lines = to_lines(response) - delete_preview_marks(proposal) + delete_generated_mark(proposal) vim.api.nvim_buf_set_lines( proposal.location.source_buf, @@ -217,12 +198,7 @@ function M.update(proposal, response) lines ) - create_preview_marks( - proposal, - generated_range.start_row, - original_range.end_row, - #lines - ) + create_generated_mark(proposal, generated_range.start_row, #lines) return proposal end @@ -242,7 +218,7 @@ function M.accept(proposal) end proposal.status = "finished" - delete_preview_marks(proposal) + delete_generated_mark(proposal) vim.api.nvim_buf_del_extmark( proposal.location.source_buf, proposal.location.namespace, @@ -280,7 +256,7 @@ function M.reject(proposal) end proposal.status = "finished" - delete_preview_marks(proposal) + delete_generated_mark(proposal) vim.api.nvim_buf_del_extmark( source_buf, proposal.location.namespace, @@ -327,7 +303,7 @@ function M.retry(proposal, client, message) proposal.status = "pending" if not updated then - vim.notify(tostring(update_error), vim.log.levels.ERROR) + notify.send(tostring(update_error), vim.log.levels.ERROR) end end) diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua index 7c8b543..de5febe 100644 --- a/lua/patch/ui/input.lua +++ b/lua/patch/ui/input.lua @@ -22,10 +22,6 @@ local popup_options = { } } -local function handle_close() - print("Input closed!") -end - local function handle_update() end --- Open the instruction input and deliver its submitted value. @@ -35,7 +31,6 @@ function M.open(on_submit) local input = Input(popup_options, { prompt = "> ", default_value = "", - on_close = handle_close, on_submit = on_submit, on_change = handle_update, }) diff --git a/plugin/patch.lua b/plugin/patch.lua index 7c7cddf..5322277 100644 --- a/plugin/patch.lua +++ b/plugin/patch.lua @@ -1,5 +1,23 @@ +local patch = require("patch") + +vim.api.nvim_create_user_command("PatchCapture", patch.start, { + desc = "Capture a selection and generate a replacement", +}) + +vim.api.nvim_create_user_command("PatchAccept", patch.accept, { + desc = "Accept the active replacement proposal", +}) + +vim.api.nvim_create_user_command("PatchReject", patch.reject, { + desc = "Reject the active replacement proposal", +}) + +vim.api.nvim_create_user_command("PatchRetry", patch.retry, { + desc = "Retry the active replacement proposal", +}) + --- NOTE: Cancelling still occurs globally. Need to revisit this. --- For now, assume patches cannot be cancelled mid-flight. --- vim.keymap.set("n", "", require("patch").cancel) - -vim.keymap.set("x", "p", require("patch").start) +vim.api.nvim_create_user_command("PatchCancel", patch.cancel, { + desc = "Cancel the active replacement request", +}) From 50fbfcfe35d5abb8fcc583881faa352df927b15b Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 13 Aug 2026 23:23:37 +0800 Subject: [PATCH 18/39] docs: add project to-do list --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 5b68c85..a6b573f 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,13 @@ npx @earendil-works/pi-ai login [provider] end, } ``` + +## To-do + +- [ ] Build codebase-relevant context separately from prompt construction. +- [ ] Reject empty or whitespace-only instructions before starting generation. +- [ ] Make cancellation request-scoped instead of global. +- [ ] Add a model-selection UI and mappable commands that let users choose or cycle the model used for Patch requests. By default, Patch uses Pi's primary model. +- [ ] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. +- [ ] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. +- [ ] Expose the system prompt to users as a setup configuration option. From e0a43c78bdc92bfe2c905e26ba3c82ed79a37726 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 13 Aug 2026 23:41:40 +0800 Subject: [PATCH 19/39] docs: track replacement notification task --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a6b573f..9f039e9 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,4 @@ npx @earendil-works/pi-ai login [provider] - [ ] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. - [ ] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. - [ ] Expose the system prompt to users as a setup configuration option. +- [ ] Show a notification when a replacement has finished generating and is ready for review. From aa0849727c65557ac7c62c08b5b22a51cda003ac Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sat, 15 Aug 2026 19:48:21 +0800 Subject: [PATCH 20/39] feat(patch): show completion notification on patch success --- lua/patch/init.lua | 1 + lua/patch/replacement.lua | 3 +++ 2 files changed, 4 insertions(+) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 9af3da9..5782fb3 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -69,6 +69,7 @@ function M.start() active_proposal = proposal active_message = message + notify.send("patch: complete", vim.log.levels.INFO) end) end) end diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index a211fc2..00fc0ac 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -304,7 +304,10 @@ function M.retry(proposal, client, message) if not updated then notify.send(tostring(update_error), vim.log.levels.ERROR) + return end + + notify.send("patch: complete", vim.log.levels.INFO) end) return true From 96c52e2bc860e12af732e404c9b62385fae1c4ca Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sat, 15 Aug 2026 19:49:23 +0800 Subject: [PATCH 21/39] docs: mark completion notification as done --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f039e9..703222e 100644 --- a/README.md +++ b/README.md @@ -57,4 +57,4 @@ npx @earendil-works/pi-ai login [provider] - [ ] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. - [ ] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. - [ ] Expose the system prompt to users as a setup configuration option. -- [ ] Show a notification when a replacement has finished generating and is ready for review. +- [x] Show a notification when a replacement has finished generating and is ready for review. From 79f372f8d4bfe0fe311713cbde968d25325b05bc Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sun, 16 Aug 2026 11:51:59 +0800 Subject: [PATCH 22/39] fix(patch): preserve undo when accepting replacements --- README.md | 2 +- lua/patch/replacement.lua | 187 +++++++++++++++----------------------- 2 files changed, 75 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 703222e..6c217de 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ npx @earendil-works/pi-ai login [provider] - [ ] Reject empty or whitespace-only instructions before starting generation. - [ ] Make cancellation request-scoped instead of global. - [ ] Add a model-selection UI and mappable commands that let users choose or cycle the model used for Patch requests. By default, Patch uses Pi's primary model. -- [ ] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. +- [x] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. - [ ] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. - [ ] Expose the system prompt to users as a setup configuration option. - [x] Show a notification when a replacement has finished generating and is ready for review. diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index 00fc0ac..3346c81 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -2,10 +2,12 @@ local M = {} local notify = require("patch.notify") local namespace = vim.api.nvim_create_namespace("patch-replacement") +local previews = {} ---@class PatchProposal ---@field location PatchLocation ---@field generated_mark integer|nil +---@field generated_lines string[] ---@field status "pending"|"retrying"|"finished" --- Convert model output into lines accepted by nvim_buf_set_lines. @@ -50,75 +52,88 @@ local function resolve_range(location) } end ---- Resolve the current generated line range of a proposal. ---- ---@param proposal PatchProposal ----@return table|nil range -local function resolve_generated_range(proposal) - if not proposal.generated_mark then - return nil +---@return boolean rendered +local function render_preview(proposal) + local range = resolve_range(proposal.location) + if not range then + return false end local source_buf = proposal.location.source_buf - if not vim.api.nvim_buf_is_valid(source_buf) then - return nil + if #proposal.generated_lines == 0 then + if proposal.generated_mark then + vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) + proposal.generated_mark = nil + end + + return true end - local position = vim.api.nvim_buf_get_extmark_by_id( - source_buf, - namespace, - proposal.generated_mark, - { details = true } - ) + -- Neovim clips the highlighted padding at each window edge, matching hl_eol. + local padding = string.rep(" ", vim.o.columns) + local virtual_lines = {} - if #position == 0 or not position[3] or position[3].end_row == nil then - return nil + for index, line in ipairs(proposal.generated_lines) do + virtual_lines[index] = { + { line, "DiffAdd" }, + { padding, "DiffAdd" }, + } end - return { - start_row = position[1], - end_row = position[3].end_row, + local options = { + right_gravity = true, + virt_lines = virtual_lines, } -end - ----@param proposal PatchProposal -local function delete_generated_mark(proposal) - local source_buf = proposal.location.source_buf - if not vim.api.nvim_buf_is_valid(source_buf) then - return - end if proposal.generated_mark then - vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) - proposal.generated_mark = nil + options.id = proposal.generated_mark end + + proposal.generated_mark = vim.api.nvim_buf_set_extmark( + source_buf, + namespace, + math.max(range.end_row - 1, 0), + 0, + options + ) + + return true end ---- Create the generated-range extmark for a proposal. ---- +local group = vim.api.nvim_create_augroup("patch-replacement", { clear = true }) +vim.api.nvim_create_autocmd("VimResized", { + group = group, + callback = function() + for _, proposal in pairs(previews) do + render_preview(proposal) + end + end, +}) + ---@param proposal PatchProposal ----@param generated_start integer ----@param line_count integer -local function create_generated_mark(proposal, generated_start, line_count) +local function clear_preview(proposal) local source_buf = proposal.location.source_buf - if line_count > 0 then - proposal.generated_mark = vim.api.nvim_buf_set_extmark( + if previews[source_buf] == proposal then + previews[source_buf] = nil + end + + if vim.api.nvim_buf_is_valid(source_buf) then + if proposal.generated_mark then + vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) + proposal.generated_mark = nil + end + + vim.api.nvim_buf_del_extmark( source_buf, - namespace, - generated_start, - 0, - { - end_row = generated_start + line_count, - end_col = 0, - hl_group = "DiffAdd", - hl_eol = true, - } + proposal.location.namespace, + proposal.location.extmark_id ) end end ---- Insert a generated replacement after a tracked selection. +--- Preview a generated replacement after a tracked selection. --- ---@param location PatchLocation ---@param response string @@ -129,16 +144,6 @@ function M.apply(location, response) return nil end - local lines = to_lines(response) - - vim.api.nvim_buf_set_lines( - location.source_buf, - range.end_row, - range.end_row, - false, - lines - ) - vim.api.nvim_buf_set_extmark( location.source_buf, location.namespace, @@ -158,14 +163,16 @@ function M.apply(location, response) local proposal = { location = location, generated_mark = nil, + generated_lines = to_lines(response), status = "pending", } - create_generated_mark(proposal, range.end_row, #lines) + previews[location.source_buf] = proposal + render_preview(proposal) return proposal end ---- Replace a proposal's generated span with a new response. +--- Replace a proposal's generated preview with a new response. --- ---@param proposal PatchProposal ---@param response string @@ -177,33 +184,12 @@ function M.update(proposal, response) return nil, "patch: original selection no longer exists" end - local generated_range = resolve_generated_range(proposal) - if proposal.generated_mark and not generated_range then - return nil, "patch: generated replacement no longer exists" - end - - generated_range = generated_range or { - start_row = original_range.end_row, - end_row = original_range.end_row, - } - - local lines = to_lines(response) - delete_generated_mark(proposal) - - vim.api.nvim_buf_set_lines( - proposal.location.source_buf, - generated_range.start_row, - generated_range.end_row, - false, - lines - ) - - create_generated_mark(proposal, generated_range.start_row, #lines) - + proposal.generated_lines = to_lines(response) + render_preview(proposal) return proposal end ---- Accept a proposal by removing the original selected lines. +--- Accept a proposal by replacing the original selected lines. --- ---@param proposal PatchProposal ---@return boolean accepted @@ -217,26 +203,22 @@ function M.accept(proposal) return false end + local source_buf = proposal.location.source_buf proposal.status = "finished" - delete_generated_mark(proposal) - vim.api.nvim_buf_del_extmark( - proposal.location.source_buf, - proposal.location.namespace, - proposal.location.extmark_id - ) + clear_preview(proposal) vim.api.nvim_buf_set_lines( - proposal.location.source_buf, + source_buf, range.start_row, range.end_row, false, - {} + proposal.generated_lines ) return true end ---- Reject a proposal by removing its generated lines. +--- Reject a proposal by removing its preview. --- ---@param proposal PatchProposal ---@return boolean rejected @@ -245,34 +227,13 @@ function M.reject(proposal) return false end - local generated_range = resolve_generated_range(proposal) - if proposal.generated_mark and not generated_range then - return false - end - local source_buf = proposal.location.source_buf if not vim.api.nvim_buf_is_valid(source_buf) then return false end proposal.status = "finished" - delete_generated_mark(proposal) - vim.api.nvim_buf_del_extmark( - source_buf, - proposal.location.namespace, - proposal.location.extmark_id - ) - - if generated_range then - vim.api.nvim_buf_set_lines( - source_buf, - generated_range.start_row, - generated_range.end_row, - false, - {} - ) - end - + clear_preview(proposal) return true end From 246737598371d5ab04b42019123103b055d5e358 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sun, 16 Aug 2026 12:32:58 +0800 Subject: [PATCH 23/39] fix(patch): scope cancellation to active request --- README.md | 2 +- lua/patch/client.lua | 85 ++++++++++++++++++++++++++------------- lua/patch/init.lua | 29 +++++++++++-- lua/patch/replacement.lua | 23 ++++++++--- lua/patch/selection.lua | 13 ++++++ plugin/patch.lua | 2 - 6 files changed, 116 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 6c217de..09e24e6 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ npx @earendil-works/pi-ai login [provider] - [ ] Build codebase-relevant context separately from prompt construction. - [ ] Reject empty or whitespace-only instructions before starting generation. -- [ ] Make cancellation request-scoped instead of global. +- [x] Make cancellation request-scoped instead of global. - [ ] Add a model-selection UI and mappable commands that let users choose or cycle the model used for Patch requests. By default, Patch uses Pi's primary model. - [x] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. - [ ] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 5b076c2..b021d18 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -3,28 +3,51 @@ local notify = require("patch.notify") local SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." -local active_process = nil - ---- Abort the active Pi request, if one exists. -function M.cancel() - if active_process then - active_process:write(vim.json.encode({ type = "abort" }) .. "\n") - notify.send("patch: cancelled", vim.log.levels.INFO) - else - notify.send("patch: nothing to cancel", vim.log.levels.WARN) +---@class PatchRequest +---@field state "pending"|"cancelled"|"completed" +---@field process? vim.SystemObj +---@field on_complete fun(res: string|nil, err: string|nil) + +--- Abort a Pi request. +--- +---@param request PatchRequest +---@return boolean cancelled +function M.cancel(request) + if request.state ~= "pending" then + return false + end + + request.state = "cancelled" + + if request.process then + pcall( + request.process.write, + request.process, + vim.json.encode({ type = "abort" }) .. "\n" + ) end + + vim.schedule(function() + request.on_complete(nil, "cancelled") + end) + + return true end --- Send a prompt to Pi and deliver its final assistant text. --- --- @param message string prompt containing the selection and surrounding context --- @param on_complete fun(res: string|nil, err: string|nil) +--- @return PatchRequest request function M.request(message, on_complete) - local process + local request = { + state = "pending", + process = nil, + on_complete = on_complete, + } local stdout_buffer = "" local assistant_text - local completed = false notify.send("patch: generating...", vim.log.levels.INFO) @@ -42,14 +65,14 @@ function M.request(message, on_complete) --- @param res string|nil --- @param err string|nil local function complete(res, err) - if completed then + if request.state ~= "pending" then return end - completed = true + request.state = "completed" vim.schedule(function() - on_complete(res, err) + request.on_complete(res, err) end) end @@ -57,7 +80,7 @@ function M.request(message, on_complete) --- --- @param err string local function fail(err) - if completed then + if request.state ~= "pending" then return end @@ -83,16 +106,12 @@ function M.request(message, on_complete) --- Clear references to this request's process. local function clear_process() - if active_process == process then - active_process = nil - end - - process = nil + request.process = nil end --- Close the process input stream and clear its references. local function close_process() - local current_process = process + local current_process = request.process clear_process() @@ -107,7 +126,7 @@ function M.request(message, on_complete) local function handle_exit(result) clear_process() - if not completed then + if request.state == "pending" then if result.code ~= 0 then fail("Pi exited with code " .. result.code) else @@ -120,6 +139,14 @@ function M.request(message, on_complete) --- --- @param record table local function handle_record(record) + if request.state == "cancelled" then + if record.type == "agent_settled" then + close_process() + end + + return + end + if record.type == "response" and record.command == "prompt" and not record.success then fail("Pi rejected the prompt: " .. record.error) close_process() @@ -151,7 +178,10 @@ function M.request(message, on_complete) --- @param data string|nil local function handle_stdout(error_message, data) if error_message then - fail("Failed to read Pi output: " .. error_message) + if request.state == "pending" then + fail("Failed to read Pi output: " .. error_message) + end + close_process() return end @@ -189,14 +219,13 @@ function M.request(message, on_complete) if not started then fail("Failed to start Pi: " .. tostring(process_or_error)) - return + return request end - process = process_or_error - active_process = process + request.process = process_or_error local sent, write_error = pcall(function() - process:write(vim.json.encode({ + request.process:write(vim.json.encode({ type = "prompt", message = message, }) .. "\n") @@ -206,6 +235,8 @@ function M.request(message, on_complete) fail("Failed to send prompt to Pi: " .. tostring(write_error)) close_process() end + + return request end return M diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 5782fb3..869bb2e 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -8,6 +8,7 @@ local notify = require("patch.notify") local M = {} local active_proposal = nil local active_message = nil +local active_request = nil local requesting = false ---@class PatchOptions @@ -54,10 +55,17 @@ function M.start() local message = prompt.build(capture, instruction) requesting = true - client.request(message, function(res, err) + local request + request = client.request(message, function(res, err) + if active_request ~= request then + return + end + + active_request = nil requesting = false if err then + selection.clear(capture.location) return end @@ -71,6 +79,7 @@ function M.start() active_message = message notify.send("patch: complete", vim.log.levels.INFO) end) + active_request = request end) end @@ -109,14 +118,28 @@ function M.retry() return end - if not replacement.retry(active_proposal, client, active_message) then + local request = replacement.retry(active_proposal, client, active_message, function(settled_request) + if active_request == settled_request then + active_request = nil + end + end) + + if not request then warn("the active proposal cannot be retried") + return end + + active_request = request end --- Cancel the active replacement request, if one exists. function M.cancel() - client.cancel() + if not active_request or not client.cancel(active_request) then + warn("nothing to cancel") + return + end + + notify.send("patch: cancelled", vim.log.levels.INFO) end return M diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index 3346c81..53510a0 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -242,21 +242,32 @@ end ---@param proposal PatchProposal ---@param client table client exposing request(message, on_complete) ---@param message string original prompt message ----@return boolean started -function M.retry(proposal, client, message) +---@param on_settled? fun(request: PatchRequest) +---@return PatchRequest|nil request +function M.retry(proposal, client, message, on_settled) if proposal.status ~= "pending" then - return false + return nil end proposal.status = "retrying" - client.request(message, function(res, err) + local request + + local function settle() + if on_settled then + on_settled(request) + end + end + + request = client.request(message, function(res, err) if proposal.status ~= "retrying" then + settle() return end if err then proposal.status = "pending" + settle() return end @@ -265,13 +276,15 @@ function M.retry(proposal, client, message) if not updated then notify.send(tostring(update_error), vim.log.levels.ERROR) + settle() return end notify.send("patch: complete", vim.log.levels.INFO) + settle() end) - return true + return request end return M diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua index 39349bb..edb0245 100644 --- a/lua/patch/selection.lua +++ b/lua/patch/selection.lua @@ -83,4 +83,17 @@ function M.capture() } end +--- Remove a tracked selection. +--- +--- @param location PatchLocation +function M.clear(location) + if vim.api.nvim_buf_is_valid(location.source_buf) then + vim.api.nvim_buf_del_extmark( + location.source_buf, + location.namespace, + location.extmark_id + ) + end +end + return M diff --git a/plugin/patch.lua b/plugin/patch.lua index 5322277..8f9c851 100644 --- a/plugin/patch.lua +++ b/plugin/patch.lua @@ -16,8 +16,6 @@ vim.api.nvim_create_user_command("PatchRetry", patch.retry, { desc = "Retry the active replacement proposal", }) ---- NOTE: Cancelling still occurs globally. Need to revisit this. ---- For now, assume patches cannot be cancelled mid-flight. vim.api.nvim_create_user_command("PatchCancel", patch.cancel, { desc = "Cancel the active replacement request", }) From 14ecd26b372648352802424ed3152f3799d361c7 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sun, 16 Aug 2026 14:16:45 +0800 Subject: [PATCH 24/39] fix(patch): hide preview while retrying --- README.md | 2 +- lua/patch/init.lua | 7 +++- lua/patch/replacement.lua | 82 ++++++++++++++++++++++++++++++--------- 3 files changed, 70 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 09e24e6..dcd26f3 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,6 @@ npx @earendil-works/pi-ai login [provider] - [x] Make cancellation request-scoped instead of global. - [ ] Add a model-selection UI and mappable commands that let users choose or cycle the model used for Patch requests. By default, Patch uses Pi's primary model. - [x] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. -- [ ] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. +- [x] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. - [ ] Expose the system prompt to users as a setup configuration option. - [x] Show a notification when a replacement has finished generating and is ready for review. diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 869bb2e..c82412c 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -118,10 +118,15 @@ function M.retry() return end - local request = replacement.retry(active_proposal, client, active_message, function(settled_request) + local retried_proposal = active_proposal + local request = replacement.retry(retried_proposal, client, active_message, function(settled_request) if active_request == settled_request then active_request = nil end + + if active_proposal == retried_proposal and retried_proposal.status == "finished" then + clear_active_patch() + end end) if not request then diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index 53510a0..57b707a 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -61,6 +61,22 @@ local function render_preview(proposal) end local source_buf = proposal.location.source_buf + vim.api.nvim_buf_set_extmark( + source_buf, + proposal.location.namespace, + range.start_row, + 0, + { + id = proposal.location.extmark_id, + end_row = range.end_row, + end_col = 0, + right_gravity = true, + end_right_gravity = false, + hl_group = "DiffDelete", + hl_eol = true, + } + ) + if #proposal.generated_lines == 0 then if proposal.generated_mark then vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) @@ -106,11 +122,48 @@ vim.api.nvim_create_autocmd("VimResized", { group = group, callback = function() for _, proposal in pairs(previews) do - render_preview(proposal) + if proposal.status == "pending" then + render_preview(proposal) + end end end, }) +--- Remove a proposal's decorations while retaining its tracked selection. +--- +---@param proposal PatchProposal +---@return boolean hidden +local function hide_preview(proposal) + local range = resolve_range(proposal.location) + if not range then + return false + end + + local source_buf = proposal.location.source_buf + + if proposal.generated_mark then + vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) + proposal.generated_mark = nil + end + + vim.api.nvim_buf_set_extmark( + source_buf, + proposal.location.namespace, + range.start_row, + 0, + { + id = proposal.location.extmark_id, + end_row = range.end_row, + end_col = 0, + right_gravity = true, + end_right_gravity = false, + } + ) + + proposal.generated_lines = {} + return true +end + ---@param proposal PatchProposal local function clear_preview(proposal) local source_buf = proposal.location.source_buf @@ -144,22 +197,6 @@ function M.apply(location, response) return nil end - vim.api.nvim_buf_set_extmark( - location.source_buf, - location.namespace, - range.start_row, - 0, - { - id = location.extmark_id, - end_row = range.end_row, - end_col = 0, - right_gravity = true, - end_right_gravity = false, - hl_group = "DiffDelete", - hl_eol = true, - } - ) - local proposal = { location = location, generated_mark = nil, @@ -249,6 +286,10 @@ function M.retry(proposal, client, message, on_settled) return nil end + if not hide_preview(proposal) then + return nil + end + proposal.status = "retrying" local request @@ -266,20 +307,23 @@ function M.retry(proposal, client, message, on_settled) end if err then - proposal.status = "pending" + proposal.status = "finished" + clear_preview(proposal) settle() return end local updated, update_error = M.update(proposal, res) - proposal.status = "pending" if not updated then + proposal.status = "finished" + clear_preview(proposal) notify.send(tostring(update_error), vim.log.levels.ERROR) settle() return end + proposal.status = "pending" notify.send("patch: complete", vim.log.levels.INFO) settle() end) From 000029f342865317dcd15ffaba2da5dedf44d1c5 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sun, 16 Aug 2026 16:31:19 +0800 Subject: [PATCH 25/39] fix(input): reject blank instructions --- README.md | 2 +- lua/patch/ui/input.lua | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dcd26f3..ecd52b0 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ npx @earendil-works/pi-ai login [provider] ## To-do - [ ] Build codebase-relevant context separately from prompt construction. -- [ ] Reject empty or whitespace-only instructions before starting generation. +- [x] Reject empty or whitespace-only instructions before starting generation. - [x] Make cancellation request-scoped instead of global. - [ ] Add a model-selection UI and mappable commands that let users choose or cycle the model used for Patch requests. By default, Patch uses Pi's primary model. - [x] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua index de5febe..3c371c5 100644 --- a/lua/patch/ui/input.lua +++ b/lua/patch/ui/input.lua @@ -1,6 +1,7 @@ local M = {} local Input = require("nui.input") +local PROMPT = "> " local popup_options = { relative = "cursor", @@ -22,19 +23,30 @@ local popup_options = { } } -local function handle_update() end +---@param input NuiInput +---@return string key +local function submit_key(input) + local line = vim.api.nvim_buf_get_lines(input.bufnr, 0, 1, false)[1] or "" + local instruction = line:sub(#PROMPT + 1) + return instruction:find("%S") and "\r" or "" +end --- Open the instruction input and deliver its submitted value. --- --- @param on_submit fun(instruction: string) function M.open(on_submit) local input = Input(popup_options, { - prompt = "> ", + prompt = PROMPT, default_value = "", on_submit = on_submit, - on_change = handle_update, }) + for _, mode in ipairs({ "i", "n" }) do + input:map(mode, "", function() + return submit_key(input) + end, { expr = true, noremap = true }) + end + -- unmount input by pressing `` in normal mode input:map("n", "", function() input:unmount() From d681a8275c208cb35cd5204a5994f0baa9a6cb23 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Sun, 16 Aug 2026 19:37:14 +0800 Subject: [PATCH 26/39] feat: allow custom system prompts --- README.md | 5 ++++- lua/patch/client.lua | 14 ++++++++++++-- lua/patch/init.lua | 2 ++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ecd52b0..b6cd35b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ npx @earendil-works/pi-ai login [provider] config = function() require("patch").setup({ notify = require("notify"), + -- system_prompt = "Custom system instructions...", }) vim.keymap.set("x", "p", "PatchCapture", { desc = "Capture selection and request a patch" }) @@ -48,6 +49,8 @@ npx @earendil-works/pi-ai login [provider] } ``` +`system_prompt` is optional. When set, it completely replaces Patch's built-in system prompt. + ## To-do - [ ] Build codebase-relevant context separately from prompt construction. @@ -56,5 +59,5 @@ npx @earendil-works/pi-ai login [provider] - [ ] Add a model-selection UI and mappable commands that let users choose or cycle the model used for Patch requests. By default, Patch uses Pi's primary model. - [x] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. - [x] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. -- [ ] Expose the system prompt to users as a setup configuration option. +- [x] Expose the system prompt to users as a setup configuration option. - [x] Show a notification when a replacement has finished generating and is ready for review. diff --git a/lua/patch/client.lua b/lua/patch/client.lua index b021d18..9cbf48b 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -1,7 +1,17 @@ local M = {} local notify = require("patch.notify") -local SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." +local DEFAULT_SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." +local system_prompt = DEFAULT_SYSTEM_PROMPT + +---@class PatchClientOptions +---@field system_prompt? string + +---@param opts? PatchClientOptions +function M.setup(opts) + opts = opts or {} + system_prompt = opts.system_prompt or DEFAULT_SYSTEM_PROMPT +end ---@class PatchRequest ---@field state "pending"|"cancelled"|"completed" @@ -212,7 +222,7 @@ function M.request(message, on_complete) end local started, process_or_error = pcall(vim.system, - { "pi", "--mode", "rpc", "--system-prompt", SYSTEM_PROMPT, "--no-session", "--thinking", "off" }, + { "pi", "--mode", "rpc", "--system-prompt", system_prompt, "--no-session", "--thinking", "off" }, { stdin = true, stdout = handle_stdout }, handle_exit ) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index c82412c..0584b4a 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -13,11 +13,13 @@ local requesting = false ---@class PatchOptions ---@field notify? function|table notification provider compatible with vim.notify +---@field system_prompt? string custom Pi system prompt ---@param opts? PatchOptions function M.setup(opts) opts = opts or {} notify.setup(opts.notify) + client.setup({ system_prompt = opts.system_prompt }) end ---@param message string From 82d00c789dfd401248d26da75d7b887af06632b3 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 17 Aug 2026 13:04:20 +0800 Subject: [PATCH 27/39] feat: add model selection --- README.md | 6 +- lua/patch/client.lua | 259 +++++++++++++++++++++++++++++++++++------ lua/patch/init.lua | 108 +++++++++++++---- lua/patch/ui/init.lua | 14 ++- lua/patch/ui/input.lua | 11 +- lua/patch/ui/menu.lua | 68 +++++++++++ plugin/patch.lua | 4 + 7 files changed, 408 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index b6cd35b..b1985d4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ npx @earendil-works/pi-ai login [provider] require("patch").setup({ notify = require("notify"), -- system_prompt = "Custom system instructions...", + -- model = "openai/gpt-5.6-sol", }) vim.keymap.set("x", "p", "PatchCapture", { desc = "Capture selection and request a patch" }) @@ -45,18 +46,21 @@ npx @earendil-works/pi-ai login [provider] vim.keymap.set("n", "pr", "PatchReject", { desc = "Reject the active patch and restore the original" }) vim.keymap.set("n", "pR", "PatchRetry", { desc = "Generate a new active patch proposal" }) vim.keymap.set("n", "pc", "PatchCancel", { desc = "Cancel the active patch generation request" }) + vim.keymap.set("n", "pm", "PatchMenu", { desc = "Select the model used for patch requests" }) end, } ``` `system_prompt` is optional. When set, it completely replaces Patch's built-in system prompt. +`model` is optional. When omitted, Patch uses Pi's primary model. A model selected from `PatchMenu` overrides the configured model for the current Neovim session. + ## To-do - [ ] Build codebase-relevant context separately from prompt construction. - [x] Reject empty or whitespace-only instructions before starting generation. - [x] Make cancellation request-scoped instead of global. -- [ ] Add a model-selection UI and mappable commands that let users choose or cycle the model used for Patch requests. By default, Patch uses Pi's primary model. +- [x] Add a model-selection UI for choosing the model used by Patch requests, with optional configuration and Pi's primary model as the default. - [x] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. - [x] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. - [x] Expose the system prompt to users as a setup configuration option. diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 9cbf48b..3fafff9 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -3,14 +3,162 @@ local notify = require("patch.notify") local DEFAULT_SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." local system_prompt = DEFAULT_SYSTEM_PROMPT +local configured_model +local session_model +local primary_model ---@class PatchClientOptions ---@field system_prompt? string +---@field model? string ---@param opts? PatchClientOptions function M.setup(opts) opts = opts or {} system_prompt = opts.system_prompt or DEFAULT_SYSTEM_PROMPT + configured_model = opts.model and vim.trim(opts.model) or nil + configured_model = configured_model ~= "" and configured_model or nil + session_model = nil + primary_model = nil +end + +---@class PatchModel +---@field id string +---@field name string +---@field provider string + +--- Create a callback that decodes newline-delimited JSON records. +--- +---@param on_record fun(record: table) +---@param on_error fun(err: string) +---@return fun(error_message: string|nil, data: string|nil) +local function read_jsonl(on_record, on_error) + local buffer = "" + local stopped = false + + return function(error_message, data) + if stopped then + return + end + + if error_message then + stopped = true + on_error("Failed to read Pi output: " .. error_message) + return + end + + buffer = buffer .. (data or "") + + while true do + local newline = buffer:find("\n", 1, true) + if not newline then + return + end + + local line = buffer:sub(1, newline - 1):gsub("\r$", "") + buffer = buffer:sub(newline + 1) + + if line ~= "" then + local decoded, record = pcall(vim.json.decode, line) + + if not decoded then + stopped = true + on_error("Failed to decode Pi output: " .. record) + return + end + + on_record(record) + end + end + end +end + +--- Run a one-shot Pi RPC command and return its response. +--- +---@param command table +---@param on_complete fun(record: table|nil, err: string|nil) +local function request_rpc(command, on_complete) + local process + local completed = false + + command.id = command.id or command.type + + local function close_process() + local current_process = process + process = nil + + if current_process then + pcall(current_process.write, current_process, nil) + end + end + + ---@param record table|nil + ---@param err string|nil + local function complete(record, err) + if completed then + return + end + + completed = true + close_process() + + vim.schedule(function() + on_complete(record, err) + end) + end + + ---@param result vim.SystemCompleted + local function handle_exit(result) + process = nil + + if completed then + return + end + + if result.code ~= 0 then + complete(nil, "Pi exited with code " .. result.code) + else + complete(nil, "Pi exited before returning " .. command.type) + end + end + + ---@param record table + local function handle_record(record) + if record.type ~= "response" or record.command ~= command.type or record.id ~= command.id then + return + end + + if not record.success then + complete(nil, "Pi rejected " .. command.type .. ": " .. tostring(record.error)) + return + end + + complete(record, nil) + end + + local handle_stdout = read_jsonl(handle_record, function(err) + complete(nil, err) + end) + + local started, process_or_error = pcall(vim.system, + { "pi", "--mode", "rpc", "--no-session" }, + { stdin = true, stdout = handle_stdout }, + handle_exit + ) + + if not started then + complete(nil, "Failed to start Pi: " .. tostring(process_or_error)) + return + end + + process = process_or_error + + local sent, write_error = pcall(function() + process:write(vim.json.encode(command) .. "\n") + end) + + if not sent then + complete(nil, "Failed to send " .. command.type .. " to Pi: " .. tostring(write_error)) + end end ---@class PatchRequest @@ -44,6 +192,71 @@ function M.cancel(request) return true end +-- TODO: Cache available models per session to avoid RPC startup latency on every menu open. +--- Retrieve the models available to Pi. +--- +---@param on_complete fun(models: PatchModel[]|nil, err: string|nil) +function M.get_available_models(on_complete) + request_rpc({ type = "get_available_models" }, function(record, err) + if err then + on_complete(nil, err) + return + end + + if type(record.data) ~= "table" or type(record.data.models) ~= "table" then + on_complete(nil, "Pi returned an invalid model list") + return + end + + on_complete(record.data.models, nil) + end) +end + +--- Use a model for the remainder of the current Neovim session. +--- +---@param model PatchModel +---@return string selector +function M.select_model(model) + session_model = model.provider .. "/" .. model.id + return session_model +end + +--- Resolve the model label used by Patch. +--- +---@param on_complete fun(model: string|nil, err: string|nil) +function M.resolve_model(on_complete) + local override = session_model or configured_model + if override then + vim.schedule(function() + on_complete(override, nil) + end) + return + end + + if primary_model then + vim.schedule(function() + on_complete(primary_model, nil) + end) + return + end + + request_rpc({ type = "get_state" }, function(record, err) + if err then + on_complete(nil, err) + return + end + + local model = type(record.data) == "table" and record.data.model or nil + if type(model) ~= "table" or type(model.provider) ~= "string" or type(model.id) ~= "string" then + on_complete(nil, "Pi returned no primary model") + return + end + + primary_model = model.provider .. "/" .. model.id + on_complete(primary_model, nil) + end) +end + --- Send a prompt to Pi and deliver its final assistant text. --- --- @param message string prompt containing the selection and surrounding context @@ -56,7 +269,6 @@ function M.request(message, on_complete) on_complete = on_complete, } - local stdout_buffer = "" local assistant_text notify.send("patch: generating...", vim.log.levels.INFO) @@ -182,47 +394,24 @@ function M.request(message, on_complete) end end - --- Buffer Pi output and dispatch each complete JSONL record. - --- - --- @param error_message string|nil - --- @param data string|nil - local function handle_stdout(error_message, data) - if error_message then - if request.state == "pending" then - fail("Failed to read Pi output: " .. error_message) - end - - close_process() - return + local handle_stdout = read_jsonl(handle_record, function(err) + if request.state == "pending" then + fail(err) end - stdout_buffer = stdout_buffer .. (data or "") - - while true do - local newline = stdout_buffer:find("\n", 1, true) - if not newline then - return - end - - local line = stdout_buffer:sub(1, newline -1):gsub("\r$", "") - stdout_buffer = stdout_buffer:sub(newline + 1) - - if line ~= "" then - local decoded, record = pcall(vim.json.decode, line) + close_process() + end) - if not decoded then - fail("Failed to decode Pi output: " .. record) - close_process() - return - end + local command = { "pi", "--mode", "rpc", "--system-prompt", system_prompt, "--no-session", "--thinking", "off" } + local model = session_model or configured_model - handle_record(record) - end - end + if model then + table.insert(command, "--model") + table.insert(command, model) end local started, process_or_error = pcall(vim.system, - { "pi", "--mode", "rpc", "--system-prompt", system_prompt, "--no-session", "--thinking", "off" }, + command, { stdin = true, stdout = handle_stdout }, handle_exit ) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 0584b4a..db1b5d8 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -9,17 +9,21 @@ local M = {} local active_proposal = nil local active_message = nil local active_request = nil -local requesting = false +local active_capture = nil ---@class PatchOptions ---@field notify? function|table notification provider compatible with vim.notify ---@field system_prompt? string custom Pi system prompt +---@field model? string Pi model selector in provider/model format ---@param opts? PatchOptions function M.setup(opts) opts = opts or {} notify.setup(opts.notify) - client.setup({ system_prompt = opts.system_prompt }) + client.setup({ + system_prompt = opts.system_prompt, + model = opts.model, + }) end ---@param message string @@ -27,6 +31,36 @@ local function warn(message) notify.send("patch: " .. message, vim.log.levels.WARN) end +--- Open a menu containing the models available to Pi. +function M.open_menu() + if active_capture then + warn("submit or close the active instruction first") + return + end + + client.get_available_models(function(models, err) + if active_capture then + warn("submit or close the active instruction first") + return + end + + if err then + notify.send("patch: " .. err, vim.log.levels.ERROR) + return + end + + if not models or #models == 0 then + notify.send("patch: Pi reported no available models", vim.log.levels.WARN) + return + end + + ui.open_menu(models, function(model) + local selected_model = client.select_model(model) + notify.send("patch: using " .. selected_model, vim.log.levels.INFO) + end) + end) +end + local function clear_active_patch() active_proposal = nil active_message = nil @@ -36,7 +70,12 @@ end -- See lua/patch/ui/ --- Capture a visual selection, request a replacement, and apply it to the tracked range. function M.start() - if requesting then + if active_capture then + warn("submit or close the active instruction first") + return + end + + if active_request then warn("a replacement is already being generated") return end @@ -53,35 +92,60 @@ function M.start() return end - ui.open_input(function(instruction) - local message = prompt.build(capture, instruction) - requesting = true + active_capture = capture - local request - request = client.request(message, function(res, err) - if active_request ~= request then - return - end + client.resolve_model(function(model, model_error) + if active_capture ~= capture then + return + end - active_request = nil - requesting = false + if model_error then + active_capture = nil + selection.clear(capture.location) + notify.send("patch: " .. model_error, vim.log.levels.ERROR) + return + end - if err then - selection.clear(capture.location) + ui.open_input(model, function(instruction) + if active_capture ~= capture then return end - local proposal = replacement.apply(capture.location, res) - if not proposal then - notify.send("patch: selection no longer exists", vim.log.levels.ERROR) + active_capture = nil + local message = prompt.build(capture, instruction) + + local request + request = client.request(message, function(res, err) + if active_request ~= request then + return + end + + active_request = nil + + if err then + selection.clear(capture.location) + return + end + + local proposal = replacement.apply(capture.location, res) + if not proposal then + notify.send("patch: selection no longer exists", vim.log.levels.ERROR) + return + end + + active_proposal = proposal + active_message = message + notify.send("patch: complete", vim.log.levels.INFO) + end) + active_request = request + end, function() + if active_capture ~= capture then return end - active_proposal = proposal - active_message = message - notify.send("patch: complete", vim.log.levels.INFO) + active_capture = nil + selection.clear(capture.location) end) - active_request = request end) end diff --git a/lua/patch/ui/init.lua b/lua/patch/ui/init.lua index 3962ed7..7885a59 100644 --- a/lua/patch/ui/init.lua +++ b/lua/patch/ui/init.lua @@ -5,9 +5,19 @@ local M = {} --- Open the instruction input and deliver its submitted value. --- +--- @param model string --- @param on_submit fun(instruction: string) -function M.open_input(on_submit) - input.open(on_submit) +--- @param on_close fun() +function M.open_input(model, on_submit, on_close) + input.open(model, on_submit, on_close) +end + +--- Open a menu containing Pi models. +--- +---@param models PatchModel[] +---@param on_submit fun(model: PatchModel) +function M.open_menu(models, on_submit) + menu.open(models, on_submit) end return M diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua index 3c371c5..c67d8d2 100644 --- a/lua/patch/ui/input.lua +++ b/lua/patch/ui/input.lua @@ -33,12 +33,19 @@ end --- Open the instruction input and deliver its submitted value. --- +--- @param model string --- @param on_submit fun(instruction: string) -function M.open(on_submit) - local input = Input(popup_options, { +--- @param on_close fun() +function M.open(model, on_submit, on_close) + local options = vim.deepcopy(popup_options) + options.border.text.bottom = { { " " .. model .. " ", "Comment" } } + options.border.text.bottom_align = "right" + + local input = Input(options, { prompt = PROMPT, default_value = "", on_submit = on_submit, + on_close = on_close, }) for _, mode in ipairs({ "i", "n" }) do diff --git a/lua/patch/ui/menu.lua b/lua/patch/ui/menu.lua index 5044d6f..70240c0 100644 --- a/lua/patch/ui/menu.lua +++ b/lua/patch/ui/menu.lua @@ -1,3 +1,71 @@ +local Menu = require("nui.menu") +local event = require("nui.utils.autocmd").event + local M = {} +local popup_options = { + relative = "editor", + position = "50%", + size = { + width = 40, + }, + padding = { + top = 1, + right = 2, + bottom = 1, + left = 2, + }, + border = { + style = "single", + text = { + top = "[Choose Item]", + top_align = "center", + }, + }, + win_options = { + winhighlight = "Normal:Normal", + }, +} + +--- Open a menu containing Pi models. +--- +---@param models PatchModel[] +---@param on_submit fun(model: PatchModel) +function M.open(models, on_submit) + local lines = {} + + for _, model in ipairs(models) do + table.insert(lines, Menu.item(model.id, { + model = model, + })) + end + + local menu = Menu(popup_options, { + lines = lines, + max_width = 20, + keymap = { + focus_next = { "j", "", "" }, + focus_prev = { "k", "", "" }, + close = { "", "" }, + submit = { "", "" }, + }, + on_submit = function(item) + on_submit(item.model) + end, + }) + + menu:on(event.BufLeave, function() + menu:unmount() + end) + + menu:mount() + + for row, model in ipairs(models) do + vim.api.nvim_buf_set_extmark(menu.bufnr, menu.ns_id, row - 1, 0, { + virt_text = { { model.provider, "Comment" } }, + virt_text_pos = "right_align", + }) + end +end + return M diff --git a/plugin/patch.lua b/plugin/patch.lua index 8f9c851..20cb471 100644 --- a/plugin/patch.lua +++ b/plugin/patch.lua @@ -19,3 +19,7 @@ vim.api.nvim_create_user_command("PatchRetry", patch.retry, { vim.api.nvim_create_user_command("PatchCancel", patch.cancel, { desc = "Cancel the active replacement request", }) + +vim.api.nvim_create_user_command("PatchMenu", patch.open_menu, { + desc = "List models available to Pi", +}) From dff2526e4981682735393b2d7fa8fc98bed55d3d Mon Sep 17 00:00:00 2001 From: timothyckl Date: Mon, 17 Aug 2026 16:29:05 +0800 Subject: [PATCH 28/39] refactor: separate patch workflow responsibilities --- README.md | 4 +- lua/patch/client.lua | 272 +++++++++----------------------------- lua/patch/context.lua | 28 +++- lua/patch/init.lua | 254 ++++++++++++++++++++++------------- lua/patch/prompt.lua | 20 +-- lua/patch/replacement.lua | 172 +++++++++--------------- lua/patch/rpc.lua | 194 +++++++++++++++++++++++++++ lua/patch/selection.lua | 88 +++++++++--- lua/patch/ui/init.lua | 10 +- lua/patch/ui/input.lua | 9 +- lua/patch/ui/menu.lua | 12 +- 11 files changed, 597 insertions(+), 466 deletions(-) create mode 100644 lua/patch/rpc.lua diff --git a/README.md b/README.md index b1985d4..172856f 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,11 @@ npx @earendil-works/pi-ai login [provider] `system_prompt` is optional. When set, it completely replaces Patch's built-in system prompt. -`model` is optional. When omitted, Patch uses Pi's primary model. A model selected from `PatchMenu` overrides the configured model for the current Neovim session. +`model` is optional. When omitted, Patch uses Pi's primary model. `PatchMenu` marks the effective model and allows selecting an override for the current Neovim session. ## To-do -- [ ] Build codebase-relevant context separately from prompt construction. +- [ ] Add codebase-relevant context gathering on top of the separate buffer-context and prompt-construction stages. - [x] Reject empty or whitespace-only instructions before starting generation. - [x] Make cancellation request-scoped instead of global. - [x] Add a model-selection UI for choosing the model used by Patch requests, with optional configuration and Pi's primary model as the default. diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 3fafff9..84719e4 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -1,5 +1,5 @@ local M = {} -local notify = require("patch.notify") +local rpc = require("patch.rpc") local DEFAULT_SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." local system_prompt = DEFAULT_SYSTEM_PROMPT @@ -7,6 +7,15 @@ local configured_model local session_model local primary_model +---@param callback function +---@param value any +---@param err string|nil +local function schedule_complete(callback, value, err) + vim.schedule(function() + callback(value, err) + end) +end + ---@class PatchClientOptions ---@field system_prompt? string ---@field model? string @@ -26,144 +35,9 @@ end ---@field name string ---@field provider string ---- Create a callback that decodes newline-delimited JSON records. ---- ----@param on_record fun(record: table) ----@param on_error fun(err: string) ----@return fun(error_message: string|nil, data: string|nil) -local function read_jsonl(on_record, on_error) - local buffer = "" - local stopped = false - - return function(error_message, data) - if stopped then - return - end - - if error_message then - stopped = true - on_error("Failed to read Pi output: " .. error_message) - return - end - - buffer = buffer .. (data or "") - - while true do - local newline = buffer:find("\n", 1, true) - if not newline then - return - end - - local line = buffer:sub(1, newline - 1):gsub("\r$", "") - buffer = buffer:sub(newline + 1) - - if line ~= "" then - local decoded, record = pcall(vim.json.decode, line) - - if not decoded then - stopped = true - on_error("Failed to decode Pi output: " .. record) - return - end - - on_record(record) - end - end - end -end - ---- Run a one-shot Pi RPC command and return its response. ---- ----@param command table ----@param on_complete fun(record: table|nil, err: string|nil) -local function request_rpc(command, on_complete) - local process - local completed = false - - command.id = command.id or command.type - - local function close_process() - local current_process = process - process = nil - - if current_process then - pcall(current_process.write, current_process, nil) - end - end - - ---@param record table|nil - ---@param err string|nil - local function complete(record, err) - if completed then - return - end - - completed = true - close_process() - - vim.schedule(function() - on_complete(record, err) - end) - end - - ---@param result vim.SystemCompleted - local function handle_exit(result) - process = nil - - if completed then - return - end - - if result.code ~= 0 then - complete(nil, "Pi exited with code " .. result.code) - else - complete(nil, "Pi exited before returning " .. command.type) - end - end - - ---@param record table - local function handle_record(record) - if record.type ~= "response" or record.command ~= command.type or record.id ~= command.id then - return - end - - if not record.success then - complete(nil, "Pi rejected " .. command.type .. ": " .. tostring(record.error)) - return - end - - complete(record, nil) - end - - local handle_stdout = read_jsonl(handle_record, function(err) - complete(nil, err) - end) - - local started, process_or_error = pcall(vim.system, - { "pi", "--mode", "rpc", "--no-session" }, - { stdin = true, stdout = handle_stdout }, - handle_exit - ) - - if not started then - complete(nil, "Failed to start Pi: " .. tostring(process_or_error)) - return - end - - process = process_or_error - - local sent, write_error = pcall(function() - process:write(vim.json.encode(command) .. "\n") - end) - - if not sent then - complete(nil, "Failed to send " .. command.type .. " to Pi: " .. tostring(write_error)) - end -end - ---@class PatchRequest ---@field state "pending"|"cancelled"|"completed" ----@field process? vim.SystemObj +---@field connection? PatchRpcConnection ---@field on_complete fun(res: string|nil, err: string|nil) --- Abort a Pi request. @@ -177,12 +51,8 @@ function M.cancel(request) request.state = "cancelled" - if request.process then - pcall( - request.process.write, - request.process, - vim.json.encode({ type = "abort" }) .. "\n" - ) + if request.connection then + request.connection:send({ type = "abort" }) end vim.schedule(function() @@ -192,23 +62,23 @@ function M.cancel(request) return true end --- TODO: Cache available models per session to avoid RPC startup latency on every menu open. +-- TODO: Cache available models and effective model state per session to avoid RPC startup latency when opening the menu. --- Retrieve the models available to Pi. --- ---@param on_complete fun(models: PatchModel[]|nil, err: string|nil) function M.get_available_models(on_complete) - request_rpc({ type = "get_available_models" }, function(record, err) + rpc.request({ type = "get_available_models" }, function(record, err) if err then - on_complete(nil, err) + schedule_complete(on_complete, nil, err) return end if type(record.data) ~= "table" or type(record.data.models) ~= "table" then - on_complete(nil, "Pi returned an invalid model list") + schedule_complete(on_complete, nil, "Pi returned an invalid model list") return end - on_complete(record.data.models, nil) + schedule_complete(on_complete, record.data.models, nil) end) end @@ -221,39 +91,35 @@ function M.select_model(model) return session_model end ---- Resolve the model label used by Patch. +--- Resolve the effective model selector used by Patch. --- ---@param on_complete fun(model: string|nil, err: string|nil) function M.resolve_model(on_complete) local override = session_model or configured_model if override then - vim.schedule(function() - on_complete(override, nil) - end) + schedule_complete(on_complete, override, nil) return end if primary_model then - vim.schedule(function() - on_complete(primary_model, nil) - end) + schedule_complete(on_complete, primary_model, nil) return end - request_rpc({ type = "get_state" }, function(record, err) + rpc.request({ type = "get_state" }, function(record, err) if err then - on_complete(nil, err) + schedule_complete(on_complete, nil, err) return end local model = type(record.data) == "table" and record.data.model or nil if type(model) ~= "table" or type(model.provider) ~= "string" or type(model.id) ~= "string" then - on_complete(nil, "Pi returned no primary model") + schedule_complete(on_complete, nil, "Pi returned no primary model") return end primary_model = model.provider .. "/" .. model.id - on_complete(primary_model, nil) + schedule_complete(on_complete, primary_model, nil) end) end @@ -265,23 +131,12 @@ end function M.request(message, on_complete) local request = { state = "pending", - process = nil, + connection = nil, on_complete = on_complete, } local assistant_text - notify.send("patch: generating...", vim.log.levels.INFO) - - --- Schedule a message for display on Neovim's main event loop. - --- - --- @param message string - local function display(message) - vim.schedule(function() - notify.send(message, vim.log.levels.ERROR) - end) - end - --- Complete this request exactly once on Neovim's main event loop. --- --- @param res string|nil @@ -307,7 +162,6 @@ function M.request(message, on_complete) end complete(nil, err) - display(err) end --- Join the text parts of an assistant message. @@ -326,19 +180,19 @@ function M.request(message, on_complete) return table.concat(parts, "\n") end - --- Clear references to this request's process. - local function clear_process() - request.process = nil + --- Clear references to this request's connection. + local function clear_connection() + request.connection = nil end - --- Close the process input stream and clear its references. - local function close_process() - local current_process = request.process + --- Close the connection and clear its references. + local function close_connection() + local connection = request.connection - clear_process() + clear_connection() - if current_process then - pcall(current_process.write, current_process, nil) + if connection then + connection:close() end end @@ -346,7 +200,7 @@ function M.request(message, on_complete) --- --- @param result vim.SystemCompleted local function handle_exit(result) - clear_process() + clear_connection() if request.state == "pending" then if result.code ~= 0 then @@ -363,7 +217,7 @@ function M.request(message, on_complete) local function handle_record(record) if request.state == "cancelled" then if record.type == "agent_settled" then - close_process() + close_connection() end return @@ -371,7 +225,7 @@ function M.request(message, on_complete) if record.type == "response" and record.command == "prompt" and not record.success then fail("Pi rejected the prompt: " .. record.error) - close_process() + close_connection() return end @@ -389,50 +243,46 @@ function M.request(message, on_complete) complete(replacement, nil) end - close_process() + close_connection() return end end - local handle_stdout = read_jsonl(handle_record, function(err) - if request.state == "pending" then - fail(err) - end - - close_process() - end) - - local command = { "pi", "--mode", "rpc", "--system-prompt", system_prompt, "--no-session", "--thinking", "off" } + local args = { "--system-prompt", system_prompt, "--no-session", "--thinking", "off" } local model = session_model or configured_model if model then - table.insert(command, "--model") - table.insert(command, model) + table.insert(args, "--model") + table.insert(args, model) end - local started, process_or_error = pcall(vim.system, - command, - { stdin = true, stdout = handle_stdout }, - handle_exit - ) + local connection, start_error = rpc.start(args, { + on_record = handle_record, + on_error = function(err) + if request.state == "pending" then + fail(err) + end - if not started then - fail("Failed to start Pi: " .. tostring(process_or_error)) + close_connection() + end, + on_exit = handle_exit, + }) + + if not connection then + fail(tostring(start_error)) return request end - request.process = process_or_error + request.connection = connection - local sent, write_error = pcall(function() - request.process:write(vim.json.encode({ - type = "prompt", - message = message, - }) .. "\n") - end) + local sent, write_error = connection:send({ + type = "prompt", + message = message, + }) if not sent then fail("Failed to send prompt to Pi: " .. tostring(write_error)) - close_process() + close_connection() end return request diff --git a/lua/patch/context.lua b/lua/patch/context.lua index ee3b80b..dba2526 100644 --- a/lua/patch/context.lua +++ b/lua/patch/context.lua @@ -1 +1,27 @@ - -- TODO: Build codebase relevant context. this is different from building the prompt +local M = {} + +local selection = require("patch.selection") + +--- @class PatchContent +--- @field before string[] lines before the selection +--- @field selected string[] selected lines +--- @field after string[] lines after the selection + +--- Capture the current buffer contents around a tracked selection. +--- +--- @param location PatchLocation +--- @return PatchContent|nil content +function M.capture(location) + local range = selection.resolve(location) + if not range then + return nil + end + + return { + before = vim.api.nvim_buf_get_lines(range.source_buf, 0, range.start_row, false), + selected = vim.api.nvim_buf_get_lines(range.source_buf, range.start_row, range.end_row, false), + after = vim.api.nvim_buf_get_lines(range.source_buf, range.end_row, -1, false), + } +end + +return M diff --git a/lua/patch/init.lua b/lua/patch/init.lua index db1b5d8..338055c 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -1,15 +1,23 @@ local prompt = require("patch.prompt") local selection = require("patch.selection") +local context = require("patch.context") local client = require("patch.client") local replacement = require("patch.replacement") local ui = require("patch.ui") local notify = require("patch.notify") local M = {} -local active_proposal = nil -local active_message = nil -local active_request = nil -local active_capture = nil + +---@class PatchWorkflow +---@field phase "input"|"generating"|"reviewing"|"retrying" +---@field location PatchLocation +---@field content? PatchContent +---@field message? string +---@field request? PatchRequest +---@field proposal? PatchProposal + +---@type PatchWorkflow|nil +local active_workflow ---@class PatchOptions ---@field notify? function|table notification provider compatible with vim.notify @@ -31,133 +39,168 @@ local function warn(message) notify.send("patch: " .. message, vim.log.levels.WARN) end +---@param message string +local function report_error(message) + notify.send("patch: " .. message, vim.log.levels.ERROR) +end + +---@param message string +local function report_info(message) + notify.send("patch: " .. message, vim.log.levels.INFO) +end + +---@param err string|nil +local function report_request_error(err) + if err and err ~= "cancelled" then + report_error(err) + end +end + --- Open a menu containing the models available to Pi. function M.open_menu() - if active_capture then + if active_workflow and active_workflow.phase == "input" then warn("submit or close the active instruction first") return end - client.get_available_models(function(models, err) - if active_capture then + client.resolve_model(function(selected_model, model_error) + if active_workflow and active_workflow.phase == "input" then warn("submit or close the active instruction first") return end - if err then - notify.send("patch: " .. err, vim.log.levels.ERROR) + if model_error then + report_error(model_error) return end - if not models or #models == 0 then - notify.send("patch: Pi reported no available models", vim.log.levels.WARN) - return - end + client.get_available_models(function(models, err) + if active_workflow and active_workflow.phase == "input" then + warn("submit or close the active instruction first") + return + end - ui.open_menu(models, function(model) - local selected_model = client.select_model(model) - notify.send("patch: using " .. selected_model, vim.log.levels.INFO) + if err then + report_error(err) + return + end + + if not models or #models == 0 then + warn("Pi reported no available models") + return + end + + ui.open_menu(models, selected_model, function(model) + local selected = client.select_model(model) + report_info("using " .. selected) + end) end) end) end -local function clear_active_patch() - active_proposal = nil - active_message = nil -end +---@return boolean blocked +local function block_start_for_active_workflow() + if not active_workflow then + return false + end --- TODO: If the submitted instruction is empty or whitespace-only, halt without starting generation. --- See lua/patch/ui/ ---- Capture a visual selection, request a replacement, and apply it to the tracked range. -function M.start() - if active_capture then + if active_workflow.phase == "input" then warn("submit or close the active instruction first") - return + elseif active_workflow.phase == "reviewing" then + warn("accept or reject the active proposal first") + else + warn("a replacement is already being generated") end - if active_request then - warn("a replacement is already being generated") + return true +end + +--- Capture a visual selection, request a replacement, and preview it at the tracked range. +function M.start() + if block_start_for_active_workflow() then return end - if active_proposal then - warn("accept or reject the active proposal first") + local location = selection.capture() + if not location then + warn("no selection found") return end - local capture = selection.capture() - - if not capture then + local content = context.capture(location) + if not content then + selection.clear(location) warn("no selection found") return end - active_capture = capture + local workflow = { + phase = "input", + location = location, + content = content, + } + active_workflow = workflow - client.resolve_model(function(model, model_error) - if active_capture ~= capture then + ui.open_input(function(instruction) + if active_workflow ~= workflow or workflow.phase ~= "input" then return end - if model_error then - active_capture = nil - selection.clear(capture.location) - notify.send("patch: " .. model_error, vim.log.levels.ERROR) - return - end + workflow.message = prompt.build(workflow.content, instruction) + workflow.content = nil + workflow.phase = "generating" + report_info("generating...") - ui.open_input(model, function(instruction) - if active_capture ~= capture then + local request + request = client.request(workflow.message, function(response, err) + if active_workflow ~= workflow + or workflow.phase ~= "generating" + or workflow.request ~= request then return end - active_capture = nil - local message = prompt.build(capture, instruction) - - local request - request = client.request(message, function(res, err) - if active_request ~= request then - return - end + workflow.request = nil - active_request = nil - - if err then - selection.clear(capture.location) - return - end - - local proposal = replacement.apply(capture.location, res) - if not proposal then - notify.send("patch: selection no longer exists", vim.log.levels.ERROR) - return - end + if err then + active_workflow = nil + selection.clear(workflow.location) + report_request_error(err) + return + end - active_proposal = proposal - active_message = message - notify.send("patch: complete", vim.log.levels.INFO) - end) - active_request = request - end, function() - if active_capture ~= capture then + local proposal = replacement.apply(workflow.location, response) + if not proposal then + active_workflow = nil + selection.clear(workflow.location) + report_error("selection no longer exists") return end - active_capture = nil - selection.clear(capture.location) + workflow.phase = "reviewing" + workflow.proposal = proposal + report_info("complete") end) + workflow.request = request + end, function() + if active_workflow ~= workflow or workflow.phase ~= "input" then + return + end + + active_workflow = nil + selection.clear(workflow.location) end) end --- Accept the active replacement proposal. function M.accept() - if not active_proposal then + local workflow = active_workflow + if not workflow or not workflow.proposal then warn("no active proposal to accept") return end - if replacement.accept(active_proposal) then - clear_active_patch() + if replacement.accept(workflow.proposal) then + active_workflow = nil else warn("the active proposal cannot be accepted") end @@ -165,13 +208,14 @@ end --- Reject the active replacement proposal. function M.reject() - if not active_proposal then + local workflow = active_workflow + if not workflow or not workflow.proposal then warn("no active proposal to reject") return end - if replacement.reject(active_proposal) then - clear_active_patch() + if replacement.reject(workflow.proposal) then + active_workflow = nil else warn("the active proposal cannot be rejected") end @@ -179,38 +223,60 @@ end --- Request another replacement for the active proposal. function M.retry() - if not active_proposal or not active_message then + local workflow = active_workflow + if not workflow or not workflow.proposal or not workflow.message then warn("no active proposal to retry") return end - local retried_proposal = active_proposal - local request = replacement.retry(retried_proposal, client, active_message, function(settled_request) - if active_request == settled_request then - active_request = nil + if not replacement.begin_retry(workflow.proposal) then + warn("the active proposal cannot be retried") + return + end + + workflow.phase = "retrying" + report_info("generating...") + + local request + request = client.request(workflow.message, function(response, err) + if active_workflow ~= workflow + or workflow.phase ~= "retrying" + or workflow.request ~= request then + return end - if active_proposal == retried_proposal and retried_proposal.status == "finished" then - clear_active_patch() + workflow.request = nil + + if err then + replacement.abort_retry(workflow.proposal) + active_workflow = nil + report_request_error(err) + return end - end) - if not request then - warn("the active proposal cannot be retried") - return - end + local updated, update_error = replacement.complete_retry(workflow.proposal, response) + if not updated then + replacement.abort_retry(workflow.proposal) + active_workflow = nil + report_error(tostring(update_error)) + return + end - active_request = request + workflow.phase = "reviewing" + report_info("complete") + end) + workflow.request = request end --- Cancel the active replacement request, if one exists. function M.cancel() - if not active_request or not client.cancel(active_request) then + local workflow = active_workflow + if not workflow or not workflow.request or not client.cancel(workflow.request) then warn("nothing to cancel") return end - notify.send("patch: cancelled", vim.log.levels.INFO) + report_info("cancelled") end return M diff --git a/lua/patch/prompt.lua b/lua/patch/prompt.lua index f57cfc4..48f2ed4 100644 --- a/lua/patch/prompt.lua +++ b/lua/patch/prompt.lua @@ -10,26 +10,26 @@ local function format_group(name, lines) return string.format("--- %s ---\n%s", name, contents) end ---- Format captured buffer context for display or inclusion in a prompt. +--- Format buffer context for display or inclusion in a prompt. --- ---- @param capture PatchCapture captured buffer context ---- @return string formatted_capture -function M.format_capture(capture) +--- @param content PatchContent captured buffer context +--- @return string formatted_context +function M.format_context(content) return table.concat({ - format_group("BEFORE", capture.content.before), - format_group("SELECTED", capture.content.selected), - format_group("AFTER", capture.content.after), + format_group("BEFORE", content.before), + format_group("SELECTED", content.selected), + format_group("AFTER", content.after), }, "\n") end --- Build a model prompt from captured buffer context and the user's instruction. --- ---- @param capture PatchCapture captured buffer context +--- @param content PatchContent captured buffer context --- @param instruction string requested transformation --- @return string message -function M.build(capture, instruction) +function M.build(content, instruction) return table.concat({ - M.format_capture(capture), + M.format_context(content), string.format("--- INSTRUCTION ---\n%s", instruction), }, "\n") end diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index 57b707a..fafe67a 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -1,5 +1,5 @@ local M = {} -local notify = require("patch.notify") +local selection = require("patch.selection") local namespace = vim.api.nvim_create_namespace("patch-replacement") local previews = {} @@ -26,56 +26,21 @@ local function to_lines(response) return vim.split(stripped, "\n", { plain = true }) end ---- Resolve the current line range of a tracked selection. ---- ----@param location PatchLocation ----@return table|nil range -local function resolve_range(location) - if not vim.api.nvim_buf_is_valid(location.source_buf) then - return nil - end - - local position = vim.api.nvim_buf_get_extmark_by_id( - location.source_buf, - location.namespace, - location.extmark_id, - { details = true } - ) - - if #position == 0 or not position[3] or position[3].end_row == nil then - return nil - end - - return { - start_row = position[1], - end_row = position[3].end_row, - } -end - ---@param proposal PatchProposal ---@return boolean rendered local function render_preview(proposal) - local range = resolve_range(proposal.location) + local range = selection.resolve(proposal.location) if not range then return false end - local source_buf = proposal.location.source_buf - vim.api.nvim_buf_set_extmark( - source_buf, - proposal.location.namespace, - range.start_row, - 0, - { - id = proposal.location.extmark_id, - end_row = range.end_row, - end_col = 0, - right_gravity = true, - end_right_gravity = false, - hl_group = "DiffDelete", - hl_eol = true, - } - ) + local source_buf = range.source_buf + if not selection.decorate(proposal.location, { + hl_group = "DiffDelete", + hl_eol = true, + }) then + return false + end if #proposal.generated_lines == 0 then if proposal.generated_mark then @@ -134,31 +99,21 @@ vim.api.nvim_create_autocmd("VimResized", { ---@param proposal PatchProposal ---@return boolean hidden local function hide_preview(proposal) - local range = resolve_range(proposal.location) + local range = selection.resolve(proposal.location) if not range then return false end - local source_buf = proposal.location.source_buf + local source_buf = range.source_buf if proposal.generated_mark then vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) proposal.generated_mark = nil end - vim.api.nvim_buf_set_extmark( - source_buf, - proposal.location.namespace, - range.start_row, - 0, - { - id = proposal.location.extmark_id, - end_row = range.end_row, - end_col = 0, - right_gravity = true, - end_right_gravity = false, - } - ) + if not selection.decorate(proposal.location) then + return false + end proposal.generated_lines = {} return true @@ -178,11 +133,7 @@ local function clear_preview(proposal) proposal.generated_mark = nil end - vim.api.nvim_buf_del_extmark( - source_buf, - proposal.location.namespace, - proposal.location.extmark_id - ) + selection.clear(proposal.location) end end @@ -192,7 +143,7 @@ end ---@param response string ---@return PatchProposal|nil proposal function M.apply(location, response) - local range = resolve_range(location) + local range = selection.resolve(location) if not range then return nil end @@ -205,7 +156,11 @@ function M.apply(location, response) } previews[location.source_buf] = proposal - render_preview(proposal) + if not render_preview(proposal) then + previews[location.source_buf] = nil + return nil + end + return proposal end @@ -216,13 +171,16 @@ end ---@return PatchProposal|nil proposal ---@return string|nil error_message function M.update(proposal, response) - local original_range = resolve_range(proposal.location) + local original_range = selection.resolve(proposal.location) if not original_range then - return nil, "patch: original selection no longer exists" + return nil, "original selection no longer exists" end proposal.generated_lines = to_lines(response) - render_preview(proposal) + if not render_preview(proposal) then + return nil, "original selection no longer exists" + end + return proposal end @@ -235,7 +193,7 @@ function M.accept(proposal) return false end - local range = resolve_range(proposal.location) + local range = selection.resolve(proposal.location) if not range then return false end @@ -274,61 +232,55 @@ function M.reject(proposal) return true end ---- Request another response and update a proposal in place. +--- Hide a proposal while another response is requested. --- ---@param proposal PatchProposal ----@param client table client exposing request(message, on_complete) ----@param message string original prompt message ----@param on_settled? fun(request: PatchRequest) ----@return PatchRequest|nil request -function M.retry(proposal, client, message, on_settled) +---@return boolean started +function M.begin_retry(proposal) if proposal.status ~= "pending" then - return nil + return false end if not hide_preview(proposal) then - return nil + return false end proposal.status = "retrying" + return true +end - local request - - local function settle() - if on_settled then - on_settled(request) - end +--- Render a successful retry response. +--- +---@param proposal PatchProposal +---@param response string +---@return PatchProposal|nil proposal +---@return string|nil error_message +function M.complete_retry(proposal, response) + if proposal.status ~= "retrying" then + return nil, "proposal is not being retried" end - request = client.request(message, function(res, err) - if proposal.status ~= "retrying" then - settle() - return - end - - if err then - proposal.status = "finished" - clear_preview(proposal) - settle() - return - end - - local updated, update_error = M.update(proposal, res) + local updated, update_error = M.update(proposal, response) + if not updated then + return nil, update_error + end - if not updated then - proposal.status = "finished" - clear_preview(proposal) - notify.send(tostring(update_error), vim.log.levels.ERROR) - settle() - return - end + proposal.status = "pending" + return proposal +end - proposal.status = "pending" - notify.send("patch: complete", vim.log.levels.INFO) - settle() - end) +--- Remove a proposal whose retry did not complete. +--- +---@param proposal PatchProposal +---@return boolean aborted +function M.abort_retry(proposal) + if proposal.status ~= "retrying" then + return false + end - return request + proposal.status = "finished" + clear_preview(proposal) + return true end return M diff --git a/lua/patch/rpc.lua b/lua/patch/rpc.lua new file mode 100644 index 0000000..fbd732b --- /dev/null +++ b/lua/patch/rpc.lua @@ -0,0 +1,194 @@ +local M = {} + +---@class PatchRpcConnection +---@field process? vim.SystemObj +---@field closed boolean +---@field send fun(self: PatchRpcConnection, record: table): boolean, string|nil +---@field close fun(self: PatchRpcConnection) + +---@class PatchRpcHandlers +---@field on_record fun(record: table) +---@field on_error fun(err: string) +---@field on_exit fun(result: vim.SystemCompleted) + +--- Create a callback that decodes newline-delimited JSON records. +--- +---@param on_record fun(record: table) +---@param on_error fun(err: string) +---@return fun(error_message: string|nil, data: string|nil) +local function read_jsonl(on_record, on_error) + local buffer = "" + local stopped = false + + return function(error_message, data) + if stopped then + return + end + + if error_message then + stopped = true + on_error("Failed to read Pi output: " .. error_message) + return + end + + buffer = buffer .. (data or "") + + while true do + local newline = buffer:find("\n", 1, true) + if not newline then + return + end + + local line = buffer:sub(1, newline - 1):gsub("\r$", "") + buffer = buffer:sub(newline + 1) + + if line ~= "" then + local decoded, record = pcall(vim.json.decode, line) + if not decoded then + stopped = true + on_error("Failed to decode Pi output: " .. record) + return + end + + on_record(record) + end + end + end +end + +--- Start a Pi RPC connection. +--- +---@param args string[] additional Pi command arguments +---@param handlers PatchRpcHandlers +---@return PatchRpcConnection|nil connection +---@return string|nil error_message +function M.start(args, handlers) + local connection = { + process = nil, + closed = false, + } + + function connection:send(record) + if self.closed or not self.process then + return false, "Pi connection is closed" + end + + local sent, write_error = pcall(function() + self.process:write(vim.json.encode(record) .. "\n") + end) + + if not sent then + return false, tostring(write_error) + end + + return true, nil + end + + function connection:close() + if self.closed then + return + end + + self.closed = true + local process = self.process + self.process = nil + + if process then + pcall(process.write, process, nil) + end + end + + local function handle_error(err) + connection:close() + handlers.on_error(err) + end + + local handle_stdout = read_jsonl(handlers.on_record, handle_error) + local command = { "pi", "--mode", "rpc" } + vim.list_extend(command, args) + + local started, process_or_error = pcall( + vim.system, + command, + { stdin = true, stdout = handle_stdout }, + function(result) + connection.process = nil + connection.closed = true + handlers.on_exit(result) + end + ) + + if not started then + connection.closed = true + return nil, "Failed to start Pi: " .. tostring(process_or_error) + end + + connection.process = process_or_error + return connection, nil +end + +--- Run a one-shot Pi RPC command and return its response. +--- +---@param command table +---@param on_complete fun(record: table|nil, err: string|nil) +function M.request(command, on_complete) + command = vim.deepcopy(command) + command.id = command.id or command.type + + local connection + local connection_error + local completed = false + + local function complete(record, err) + if completed then + return + end + + completed = true + if connection then + connection:close() + end + on_complete(record, err) + end + + connection, connection_error = M.start({ "--no-session" }, { + on_record = function(record) + if record.type ~= "response" or record.command ~= command.type or record.id ~= command.id then + return + end + + if not record.success then + complete(nil, "Pi rejected " .. command.type .. ": " .. tostring(record.error)) + return + end + + complete(record, nil) + end, + on_error = function(err) + complete(nil, err) + end, + on_exit = function(result) + if completed then + return + end + + if result.code ~= 0 then + complete(nil, "Pi exited with code " .. result.code) + else + complete(nil, "Pi exited before returning " .. command.type) + end + end, + }) + + if not connection then + complete(nil, tostring(connection_error)) + return + end + + local sent, write_error = connection:send(command) + if not sent then + complete(nil, "Failed to send " .. command.type .. " to Pi: " .. tostring(write_error)) + end +end + +return M diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua index edb0245..0af6615 100644 --- a/lua/patch/selection.lua +++ b/lua/patch/selection.lua @@ -4,17 +4,12 @@ local namespace = vim.api.nvim_create_namespace("patch") --- @class PatchLocation --- @field source_buf integer source buffer handle ---- @field namespace integer extmark namespace --- @field extmark_id integer selection range extmark ---- @class PatchContent ---- @field before string[] lines before the selection ---- @field selected string[] selected lines ---- @field after string[] lines after the selection - ---- @class PatchCapture ---- @field location PatchLocation source buffer and selected range ---- @field content PatchContent captured buffer contents +--- @class PatchRange +--- @field source_buf integer source buffer handle +--- @field start_row integer 0-indexed first row of the selection +--- @field end_row integer 0-indexed exclusive end row of the selection --- Exit visual mode and reset the selection highlight. local function leave_visual_mode() @@ -42,11 +37,11 @@ local function get_selection_lines() return math.min(start_line, end_line), math.max(start_line, end_line) end ---- Capture the current buffer around its visual selection. +--- Track the current buffer's visual selection. --- --- The extmark keeps the selected line range anchored while surrounding lines change. --- ---- @return PatchCapture|nil capture +--- @return PatchLocation|nil location function M.capture() local source_buf = vim.api.nvim_get_current_buf() leave_visual_mode() @@ -70,19 +65,68 @@ function M.capture() ) return { - location = { - source_buf = source_buf, - namespace = namespace, - extmark_id = extmark_id, - }, - content = { - before = vim.api.nvim_buf_get_lines(source_buf, 0, start_line - 1, false), - selected = vim.api.nvim_buf_get_lines(source_buf, start_line - 1, end_line, false), - after = vim.api.nvim_buf_get_lines(source_buf, end_line, -1, false), - }, + source_buf = source_buf, + extmark_id = extmark_id, + } +end + +--- Resolve the current line range of a tracked selection. +--- +--- @param location PatchLocation +--- @return PatchRange|nil range +function M.resolve(location) + if not vim.api.nvim_buf_is_valid(location.source_buf) then + return nil + end + + local position = vim.api.nvim_buf_get_extmark_by_id( + location.source_buf, + namespace, + location.extmark_id, + { details = true } + ) + + if #position == 0 or not position[3] or position[3].end_row == nil then + return nil + end + + return { + source_buf = location.source_buf, + start_row = position[1], + end_row = position[3].end_row, } end +--- Update a tracked selection's extmark decoration while preserving its range. +--- +--- @param location PatchLocation +--- @param decoration? table +--- @return boolean updated +function M.decorate(location, decoration) + local range = M.resolve(location) + if not range then + return false + end + + local options = vim.tbl_extend("force", { + id = location.extmark_id, + end_row = range.end_row, + end_col = 0, + right_gravity = true, + end_right_gravity = false, + }, decoration or {}) + + vim.api.nvim_buf_set_extmark( + location.source_buf, + namespace, + range.start_row, + 0, + options + ) + + return true +end + --- Remove a tracked selection. --- --- @param location PatchLocation @@ -90,7 +134,7 @@ function M.clear(location) if vim.api.nvim_buf_is_valid(location.source_buf) then vim.api.nvim_buf_del_extmark( location.source_buf, - location.namespace, + namespace, location.extmark_id ) end diff --git a/lua/patch/ui/init.lua b/lua/patch/ui/init.lua index 7885a59..d27a3b2 100644 --- a/lua/patch/ui/init.lua +++ b/lua/patch/ui/init.lua @@ -5,19 +5,19 @@ local M = {} --- Open the instruction input and deliver its submitted value. --- ---- @param model string --- @param on_submit fun(instruction: string) --- @param on_close fun() -function M.open_input(model, on_submit, on_close) - input.open(model, on_submit, on_close) +function M.open_input(on_submit, on_close) + input.open(on_submit, on_close) end --- Open a menu containing Pi models. --- ---@param models PatchModel[] +---@param selected_model string effective model selector ---@param on_submit fun(model: PatchModel) -function M.open_menu(models, on_submit) - menu.open(models, on_submit) +function M.open_menu(models, selected_model, on_submit) + menu.open(models, selected_model, on_submit) end return M diff --git a/lua/patch/ui/input.lua b/lua/patch/ui/input.lua index c67d8d2..cff2c7f 100644 --- a/lua/patch/ui/input.lua +++ b/lua/patch/ui/input.lua @@ -33,15 +33,10 @@ end --- Open the instruction input and deliver its submitted value. --- ---- @param model string --- @param on_submit fun(instruction: string) --- @param on_close fun() -function M.open(model, on_submit, on_close) - local options = vim.deepcopy(popup_options) - options.border.text.bottom = { { " " .. model .. " ", "Comment" } } - options.border.text.bottom_align = "right" - - local input = Input(options, { +function M.open(on_submit, on_close) + local input = Input(vim.deepcopy(popup_options), { prompt = PROMPT, default_value = "", on_submit = on_submit, diff --git a/lua/patch/ui/menu.lua b/lua/patch/ui/menu.lua index 70240c0..b364fed 100644 --- a/lua/patch/ui/menu.lua +++ b/lua/patch/ui/menu.lua @@ -18,7 +18,7 @@ local popup_options = { border = { style = "single", text = { - top = "[Choose Item]", + top = " Select model ", top_align = "center", }, }, @@ -30,19 +30,23 @@ local popup_options = { --- Open a menu containing Pi models. --- ---@param models PatchModel[] +---@param selected_model string effective model selector ---@param on_submit fun(model: PatchModel) -function M.open(models, on_submit) +function M.open(models, selected_model, on_submit) local lines = {} for _, model in ipairs(models) do - table.insert(lines, Menu.item(model.id, { + local selector = model.provider .. "/" .. model.id + local marker = selector == selected_model and "● " or " " + + table.insert(lines, Menu.item(marker .. model.id, { model = model, })) end local menu = Menu(popup_options, { lines = lines, - max_width = 20, + max_width = 22, keymap = { focus_next = { "j", "", "" }, focus_prev = { "k", "", "" }, From 20f152c983504fdfed1e3d4b36eccd8d1ca4ceab Mon Sep 17 00:00:00 2001 From: timothyckl Date: Tue, 18 Aug 2026 09:56:39 +0800 Subject: [PATCH 29/39] fix: release proposals after source buffer deletion --- lua/patch/init.lua | 11 +++++++++++ lua/patch/replacement.lua | 29 ++++++++++++----------------- lua/patch/selection.lua | 6 ++++-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 338055c..c3ee0c7 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -201,7 +201,11 @@ function M.accept() if replacement.accept(workflow.proposal) then active_workflow = nil + elseif workflow.phase == "retrying" then + warn("the active proposal cannot be accepted") else + -- The proposal was finalized because its source buffer is unavailable. + active_workflow = nil warn("the active proposal cannot be accepted") end end @@ -230,6 +234,13 @@ function M.retry() end if not replacement.begin_retry(workflow.proposal) then + if workflow.phase == "retrying" then + warn("the active proposal cannot be retried") + return + end + + -- The proposal was finalized because its source buffer is unavailable. + active_workflow = nil warn("the active proposal cannot be retried") return end diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index fafe67a..907f03c 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -10,9 +10,7 @@ local previews = {} ---@field generated_lines string[] ---@field status "pending"|"retrying"|"finished" ---- Convert model output into lines accepted by nvim_buf_set_lines. ---- ---- A terminal newline belongs to the textual response, not a new buffer line. +--- Convert model output into lines accepted by nvim_buf_set_lines, excluding a terminal newline from creating a new buffer line. --- ---@param response string ---@return string[] lines @@ -127,14 +125,12 @@ local function clear_preview(proposal) previews[source_buf] = nil end - if vim.api.nvim_buf_is_valid(source_buf) then - if proposal.generated_mark then - vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) - proposal.generated_mark = nil - end - - selection.clear(proposal.location) + if proposal.generated_mark and vim.api.nvim_buf_is_loaded(source_buf) then + vim.api.nvim_buf_del_extmark(source_buf, namespace, proposal.generated_mark) end + + proposal.generated_mark = nil + selection.clear(proposal.location) end --- Preview a generated replacement after a tracked selection. @@ -184,7 +180,7 @@ function M.update(proposal, response) return proposal end ---- Accept a proposal by replacing the original selected lines. +--- Accept a proposal by replacing the original selected lines, finalising it if the source buffer is no longer available. --- ---@param proposal PatchProposal ---@return boolean accepted @@ -195,6 +191,8 @@ function M.accept(proposal) local range = selection.resolve(proposal.location) if not range then + proposal.status = "finished" + clear_preview(proposal) return false end @@ -222,17 +220,12 @@ function M.reject(proposal) return false end - local source_buf = proposal.location.source_buf - if not vim.api.nvim_buf_is_valid(source_buf) then - return false - end - proposal.status = "finished" clear_preview(proposal) return true end ---- Hide a proposal while another response is requested. +--- Hide a proposal while another response is requested, finalising it if its tracked selection no longer exists. --- ---@param proposal PatchProposal ---@return boolean started @@ -242,6 +235,8 @@ function M.begin_retry(proposal) end if not hide_preview(proposal) then + proposal.status = "finished" + clear_preview(proposal) return false end diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua index 0af6615..274f199 100644 --- a/lua/patch/selection.lua +++ b/lua/patch/selection.lua @@ -72,10 +72,12 @@ end --- Resolve the current line range of a tracked selection. --- +--- Returns nil when the source buffer is no longer loaded. +--- --- @param location PatchLocation --- @return PatchRange|nil range function M.resolve(location) - if not vim.api.nvim_buf_is_valid(location.source_buf) then + if not vim.api.nvim_buf_is_loaded(location.source_buf) then return nil end @@ -131,7 +133,7 @@ end --- --- @param location PatchLocation function M.clear(location) - if vim.api.nvim_buf_is_valid(location.source_buf) then + if vim.api.nvim_buf_is_loaded(location.source_buf) then vim.api.nvim_buf_del_extmark( location.source_buf, namespace, From 5dc5b4ecc9c68a31c5757b5bc5eef0e957a50823 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Tue, 18 Aug 2026 10:00:57 +0800 Subject: [PATCH 30/39] docs(selection): streamline API comments --- lua/patch/selection.lua | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lua/patch/selection.lua b/lua/patch/selection.lua index 274f199..7024d6b 100644 --- a/lua/patch/selection.lua +++ b/lua/patch/selection.lua @@ -20,9 +20,7 @@ local function leave_visual_mode() ) end ---- Read the most recent visual selection from the '< and '> marks. ---- ---- Normalises the range so that start_line <= end_line. +--- Return the normalised line range of the most recent visual selection. --- --- @return integer|nil start_line 1-indexed first line of the selection --- @return integer|nil end_line 1-indexed last line of the selection @@ -37,9 +35,7 @@ local function get_selection_lines() return math.min(start_line, end_line), math.max(start_line, end_line) end ---- Track the current buffer's visual selection. ---- ---- The extmark keeps the selected line range anchored while surrounding lines change. +--- Track the current buffer's visual selection with an extmark that anchors its line range. --- --- @return PatchLocation|nil location function M.capture() @@ -70,9 +66,7 @@ function M.capture() } end ---- Resolve the current line range of a tracked selection. ---- ---- Returns nil when the source buffer is no longer loaded. +--- Resolve a tracked selection’s current line range, or nil if its source buffer is unloaded. --- --- @param location PatchLocation --- @return PatchRange|nil range From cdafd24e5c2d3b789a6631efdfe2c003631c4921 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Tue, 18 Aug 2026 10:20:08 +0800 Subject: [PATCH 31/39] fix: preserve proposal when buffer update fails --- lua/patch/init.lua | 10 ++++++---- lua/patch/replacement.lua | 11 ++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index c3ee0c7..2f2badf 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -201,13 +201,15 @@ function M.accept() if replacement.accept(workflow.proposal) then active_workflow = nil - elseif workflow.phase == "retrying" then - warn("the active proposal cannot be accepted") - else + return + end + + if workflow.proposal.status == "finished" then -- The proposal was finalized because its source buffer is unavailable. active_workflow = nil - warn("the active proposal cannot be accepted") end + + warn("the active proposal cannot be accepted") end --- Reject the active replacement proposal. diff --git a/lua/patch/replacement.lua b/lua/patch/replacement.lua index 907f03c..5824145 100644 --- a/lua/patch/replacement.lua +++ b/lua/patch/replacement.lua @@ -197,10 +197,9 @@ function M.accept(proposal) end local source_buf = proposal.location.source_buf - proposal.status = "finished" - clear_preview(proposal) - vim.api.nvim_buf_set_lines( + local ok = pcall( + vim.api.nvim_buf_set_lines, source_buf, range.start_row, range.end_row, @@ -208,6 +207,12 @@ function M.accept(proposal) proposal.generated_lines ) + if not ok then + return false + end + + proposal.status = "finished" + clear_preview(proposal) return true end From c36929041a4b473081cef8b330337c54513f2f42 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Tue, 18 Aug 2026 11:00:09 +0800 Subject: [PATCH 32/39] fix(rpc): retain final output and diagnostics --- lua/patch/client.lua | 2 +- lua/patch/rpc.lua | 55 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 84719e4..57a9fa9 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -204,7 +204,7 @@ function M.request(message, on_complete) if request.state == "pending" then if result.code ~= 0 then - fail("Pi exited with code " .. result.code) + fail(rpc.format_exit_error(result)) else fail("Pi exited before returning a response") end diff --git a/lua/patch/rpc.lua b/lua/patch/rpc.lua index fbd732b..8aa40cf 100644 --- a/lua/patch/rpc.lua +++ b/lua/patch/rpc.lua @@ -20,6 +20,23 @@ local function read_jsonl(on_record, on_error) local buffer = "" local stopped = false + local function emit(line) + line = line:gsub("\r$", "") + if line == "" then + return true + end + + local decoded, record = pcall(vim.json.decode, line) + if not decoded then + stopped = true + on_error("Failed to decode Pi output: " .. record) + return false + end + + on_record(record) + return true + end + return function(error_message, data) if stopped then return @@ -36,24 +53,38 @@ local function read_jsonl(on_record, on_error) while true do local newline = buffer:find("\n", 1, true) if not newline then - return + break end - local line = buffer:sub(1, newline - 1):gsub("\r$", "") + local line = buffer:sub(1, newline - 1) buffer = buffer:sub(newline + 1) - if line ~= "" then - local decoded, record = pcall(vim.json.decode, line) - if not decoded then - stopped = true - on_error("Failed to decode Pi output: " .. record) - return - end - - on_record(record) + if not emit(line) then + return end end + + if data == nil and buffer ~= "" then + local line = buffer + buffer = "" + emit(line) + end + end +end + +--- Format a non-zero Pi process result, including captured stderr when available. +--- +---@param result vim.SystemCompleted +---@return string message +function M.format_exit_error(result) + local message = "Pi exited with code " .. result.code + local stderr = vim.trim(result.stderr or "") + + if stderr ~= "" then + return message .. ": " .. stderr end + + return message end --- Start a Pi RPC connection. @@ -173,7 +204,7 @@ function M.request(command, on_complete) end if result.code ~= 0 then - complete(nil, "Pi exited with code " .. result.code) + complete(nil, M.format_exit_error(result)) else complete(nil, "Pi exited before returning " .. command.type) end From 98bb057345929902f94c542c991a12f0102b06a8 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Tue, 18 Aug 2026 13:14:47 +0800 Subject: [PATCH 33/39] refactor: reduce workflow orchestration duplication --- lua/patch/init.lua | 126 ++++++++++++++++++++++----------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/lua/patch/init.lua b/lua/patch/init.lua index 2f2badf..67e30b6 100644 --- a/lua/patch/init.lua +++ b/lua/patch/init.lua @@ -56,16 +56,24 @@ local function report_request_error(err) end end +---@return boolean blocked +local function block_menu_for_active_input() + if not active_workflow or active_workflow.phase ~= "input" then + return false + end + + warn("submit or close the active instruction first") + return true +end + --- Open a menu containing the models available to Pi. function M.open_menu() - if active_workflow and active_workflow.phase == "input" then - warn("submit or close the active instruction first") + if block_menu_for_active_input() then return end client.resolve_model(function(selected_model, model_error) - if active_workflow and active_workflow.phase == "input" then - warn("submit or close the active instruction first") + if block_menu_for_active_input() then return end @@ -75,8 +83,7 @@ function M.open_menu() end client.get_available_models(function(models, err) - if active_workflow and active_workflow.phase == "input" then - warn("submit or close the active instruction first") + if block_menu_for_active_input() then return end @@ -115,6 +122,47 @@ local function block_start_for_active_workflow() return true end +--- Run a generation request and return the workflow to review when it succeeds. +--- +---@param workflow PatchWorkflow +---@param phase "generating"|"retrying" +---@param apply_response fun(response: string): PatchProposal|nil, string|nil +---@param abort fun() +local function generate(workflow, phase, apply_response, abort) + workflow.phase = phase + report_info("generating...") + + local request + request = client.request(workflow.message, function(response, err) + if active_workflow ~= workflow + or workflow.phase ~= phase + or workflow.request ~= request then + return + end + + workflow.request = nil + + if err then + abort() + active_workflow = nil + report_request_error(err) + return + end + + local completed, completion_error = apply_response(response) + if not completed then + abort() + active_workflow = nil + report_error(tostring(completion_error)) + return + end + + workflow.phase = "reviewing" + report_info("complete") + end) + workflow.request = request +end + --- Capture a visual selection, request a replacement, and preview it at the tracked range. function M.start() if block_start_for_active_workflow() then @@ -148,39 +196,18 @@ function M.start() workflow.message = prompt.build(workflow.content, instruction) workflow.content = nil - workflow.phase = "generating" - report_info("generating...") - - local request - request = client.request(workflow.message, function(response, err) - if active_workflow ~= workflow - or workflow.phase ~= "generating" - or workflow.request ~= request then - return - end - - workflow.request = nil - - if err then - active_workflow = nil - selection.clear(workflow.location) - report_request_error(err) - return - end + generate(workflow, "generating", function(response) local proposal = replacement.apply(workflow.location, response) if not proposal then - active_workflow = nil - selection.clear(workflow.location) - report_error("selection no longer exists") - return + return nil, "selection no longer exists" end - workflow.phase = "reviewing" workflow.proposal = proposal - report_info("complete") + return proposal + end, function() + selection.clear(workflow.location) end) - workflow.request = request end, function() if active_workflow ~= workflow or workflow.phase ~= "input" then return @@ -247,38 +274,11 @@ function M.retry() return end - workflow.phase = "retrying" - report_info("generating...") - - local request - request = client.request(workflow.message, function(response, err) - if active_workflow ~= workflow - or workflow.phase ~= "retrying" - or workflow.request ~= request then - return - end - - workflow.request = nil - - if err then - replacement.abort_retry(workflow.proposal) - active_workflow = nil - report_request_error(err) - return - end - - local updated, update_error = replacement.complete_retry(workflow.proposal, response) - if not updated then - replacement.abort_retry(workflow.proposal) - active_workflow = nil - report_error(tostring(update_error)) - return - end - - workflow.phase = "reviewing" - report_info("complete") + generate(workflow, "retrying", function(response) + return replacement.complete_retry(workflow.proposal, response) + end, function() + replacement.abort_retry(workflow.proposal) end) - workflow.request = request end --- Cancel the active replacement request, if one exists. From 16c804777c9ee66596286390c2cc2c9e4c047431 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Tue, 18 Aug 2026 13:32:04 +0800 Subject: [PATCH 34/39] fix(rpc): surface settled provider errors --- lua/patch/client.lua | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 57a9fa9..2add015 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -135,7 +135,7 @@ function M.request(message, on_complete) on_complete = on_complete, } - local assistant_text + local assistant_message --- Complete this request exactly once on Neovim's main event loop. --- @@ -230,17 +230,24 @@ function M.request(message, on_complete) end if record.type == "message_end" and record.message.role == "assistant" then - assistant_text = extract_text(record.message) + -- Pi may retry failed turns, so judge only the final message once settled. + assistant_message = record.message return end if record.type == "agent_settled" then - local replacement = assistant_text - - if replacement == nil then + if assistant_message == nil then fail("Pi returned no response") + elseif assistant_message.stopReason == "error" then + local error_message = assistant_message.errorMessage + if type(error_message) ~= "string" or error_message == "" then + error_message = "Pi request failed" + end + + fail(error_message) else - complete(replacement, nil) + -- Empty successful output is a valid deletion replacement. + complete(extract_text(assistant_message), nil) end close_connection() From 9bf199b8d109735d18777af998cf24a7b97728e4 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Wed, 19 Aug 2026 14:14:28 +0800 Subject: [PATCH 35/39] test: add automated Neovim coverage --- .gitignore | 1 + tests/helpers.lua | 36 ++++ tests/minimal_init.lua | 23 +++ tests/patch/client_spec.lua | 285 +++++++++++++++++++++++++++++++ tests/patch/context_spec.lua | 50 ++++++ tests/patch/init_spec.lua | 279 ++++++++++++++++++++++++++++++ tests/patch/notify_spec.lua | 41 +++++ tests/patch/plugin_spec.lua | 59 +++++++ tests/patch/prompt_spec.lua | 43 +++++ tests/patch/replacement_spec.lua | 113 ++++++++++++ tests/patch/rpc_spec.lua | 240 ++++++++++++++++++++++++++ tests/patch/selection_spec.lua | 77 +++++++++ 12 files changed, 1247 insertions(+) create mode 100644 tests/helpers.lua create mode 100644 tests/minimal_init.lua create mode 100644 tests/patch/client_spec.lua create mode 100644 tests/patch/context_spec.lua create mode 100644 tests/patch/init_spec.lua create mode 100644 tests/patch/notify_spec.lua create mode 100644 tests/patch/plugin_spec.lua create mode 100644 tests/patch/prompt_spec.lua create mode 100644 tests/patch/replacement_spec.lua create mode 100644 tests/patch/rpc_spec.lua create mode 100644 tests/patch/selection_spec.lua diff --git a/.gitignore b/.gitignore index 646ac51..dee976e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store +.deps/ node_modules/ diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..eefda14 --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,36 @@ +local M = {} + +function M.new_buffer(lines) + local buffer = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(buffer) + + local undolevels = vim.bo[buffer].undolevels + vim.bo[buffer].undolevels = -1 + vim.api.nvim_buf_set_lines(buffer, 0, -1, false, lines or {}) + vim.bo[buffer].undolevels = undolevels + + return buffer +end + +function M.set_visual_lines(buffer, start_line, end_line) + vim.fn.setpos("'<", { buffer, start_line, 1, 0 }) + vim.fn.setpos("'>", { buffer, end_line, 1, 0 }) +end + +function M.delete_buffer(buffer) + if buffer and vim.api.nvim_buf_is_valid(buffer) then + vim.api.nvim_buf_delete(buffer, { force = true }) + end +end + +function M.wait_for(predicate) + assert.is_true(vim.wait(500, predicate, 5), "timed out waiting for scheduled callback") +end + +function M.unload_modules(names) + for _, name in ipairs(names) do + package.loaded[name] = nil + end +end + +return M diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..5d14bb6 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,23 @@ +local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") + +local function dependency_path(environment_name, directory_name) + local configured = vim.env[environment_name] + if configured and configured ~= "" then + return configured + end + + local local_dependency = root .. "/.deps/" .. directory_name + if vim.fn.isdirectory(local_dependency) == 1 then + return local_dependency + end + + return vim.fn.stdpath("data") .. "/lazy/" .. directory_name +end + +vim.opt.runtimepath:prepend(root) +vim.opt.runtimepath:prepend(dependency_path("PLENARY_DIR", "plenary.nvim")) +vim.opt.runtimepath:prepend(dependency_path("NUI_DIR", "nui.nvim")) +vim.cmd("runtime plugin/plenary.vim") + +vim.o.swapfile = false +vim.o.writebackup = false diff --git a/tests/patch/client_spec.lua b/tests/patch/client_spec.lua new file mode 100644 index 0000000..1dc1812 --- /dev/null +++ b/tests/patch/client_spec.lua @@ -0,0 +1,285 @@ +local helpers = require("tests.helpers") + +describe("patch.client", function() + local client + local rpc + local connection + local handlers + local start_args + local sent + + before_each(function() + sent = {} + connection = { closed = false } + function connection:send(record) + table.insert(sent, record) + return true + end + function connection:close() + self.closed = true + end + + rpc = { + start = function(args, supplied_handlers) + start_args = args + handlers = supplied_handlers + return connection + end, + request = function() end, + format_exit_error = function(result) + return "exit " .. result.code + end, + } + + package.loaded["patch.rpc"] = rpc + package.loaded["patch.client"] = nil + client = require("patch.client") + client.setup() + end) + + after_each(function() + package.loaded["patch.client"] = nil + package.loaded["patch.rpc"] = nil + end) + + local function wait_for_result(result) + helpers.wait_for(function() + return result.called + end) + end + + local function record(result, values) + result.called = true + for key, value in pairs(values) do + result[key] = value + end + end + + local function assistant_message(text, overrides) + return vim.tbl_extend("force", { + role = "assistant", + content = { { type = "text", text = text } }, + stopReason = "stop", + }, overrides or {}) + end + + it("sends a prompt and completes only after the agent settles", function() + local result = {} + local request = client.request("replace this", function(value, err) + record(result, { value = value, err = err }) + end) + + assert.are.equal("pending", request.state) + assert.are.same({ type = "prompt", message = "replace this" }, sent[1]) + assert.are.same({ "--system-prompt", start_args[2], "--no-session", "--thinking", "off" }, start_args) + + handlers.on_record({ type = "message_end", message = assistant_message("replacement") }) + vim.wait(20) + assert.is_nil(result.called) + + handlers.on_record({ type = "agent_settled" }) + wait_for_result(result) + + assert.are.equal("replacement", result.value) + assert.is_nil(result.err) + assert.are.equal("completed", request.state) + assert.is_true(connection.closed) + end) + + it("uses the last assistant message and joins text parts", function() + local result = {} + client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + handlers.on_record({ type = "message_end", message = assistant_message("discarded") }) + handlers.on_record({ + type = "message_end", + message = assistant_message("ignored", { + content = { + { type = "text", text = "first" }, + { type = "toolCall", name = "tool" }, + { type = "text", text = "second" }, + }, + }), + }) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(result) + + assert.are.equal("first\nsecond", result.value) + assert.is_nil(result.err) + end) + + it("passes configured system prompt and model arguments", function() + client.setup({ system_prompt = "custom prompt", model = " openai/model " }) + client.request("prompt", function() end) + + assert.are.same({ + "--system-prompt", "custom prompt", "--no-session", "--thinking", "off", + "--model", "openai/model", + }, start_args) + end) + + it("treats empty assistant output as a deletion", function() + local result = {} + client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + handlers.on_record({ type = "message_end", message = assistant_message("", { content = {} }) }) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(result) + + assert.are.equal("", result.value) + assert.is_nil(result.err) + end) + + it("cancels a pending request once", function() + local result = {} + local request = client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + assert.is_true(client.cancel(request)) + assert.is_false(client.cancel(request)) + wait_for_result(result) + + assert.are.equal("cancelled", request.state) + assert.are.same({ type = "abort" }, sent[2]) + assert.is_nil(result.value) + assert.are.equal("cancelled", result.err) + + handlers.on_record({ type = "agent_settled" }) + assert.is_true(connection.closed) + end) + + it("reports prompt rejection", function() + local result = {} + client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + handlers.on_record({ type = "response", command = "prompt", success = false, error = "denied" }) + wait_for_result(result) + + assert.is_nil(result.value) + assert.are.equal("Pi rejected the prompt: denied", result.err) + assert.is_true(connection.closed) + end) + + it("reports assistant and missing-response failures", function() + local error_result = {} + client.request("prompt", function(_, err) + record(error_result, { err = err }) + end) + handlers.on_record({ + type = "message_end", + message = assistant_message("", { stopReason = "error", errorMessage = "model failed" }), + }) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(error_result) + assert.are.equal("model failed", error_result.err) + + client.setup() + connection.closed = false + local missing_result = {} + client.request("prompt", function(_, err) + record(missing_result, { err = err }) + end) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(missing_result) + assert.are.equal("Pi returned no response", missing_result.err) + end) + + it("reports process start and exit failures", function() + rpc.start = function() + return nil, "cannot start" + end + local start_result = {} + client.request("prompt", function(_, err) + record(start_result, { err = err }) + end) + wait_for_result(start_result) + assert.are.equal("cannot start", start_result.err) + + rpc.start = function(args, supplied_handlers) + handlers = supplied_handlers + return connection + end + local exit_result = {} + client.request("prompt", function(_, err) + record(exit_result, { err = err }) + end) + handlers.on_exit({ code = 7 }) + wait_for_result(exit_result) + assert.are.equal("exit 7", exit_result.err) + end) + + it("retrieves and validates available models", function() + local callback + rpc.request = function(command, on_complete) + assert.are.same({ type = "get_available_models" }, command) + callback = on_complete + end + + local result = {} + client.get_available_models(function(models, err) + record(result, { models = models, err = err }) + end) + local models = { { provider = "openai", id = "model", name = "Model" } } + callback({ data = { models = models } }, nil) + wait_for_result(result) + assert.are.same(models, result.models) + + result = {} + client.get_available_models(function(value, err) + record(result, { value = value, err = err }) + end) + callback({ data = {} }, nil) + wait_for_result(result) + assert.is_nil(result.value) + assert.are.equal("Pi returned an invalid model list", result.err) + end) + + it("resolves configured, session, and cached primary models", function() + local result = {} + client.setup({ model = "configured/model" }) + client.resolve_model(function(value, err) + record(result, { value = value, err = err }) + end) + wait_for_result(result) + assert.are.equal("configured/model", result.value) + + assert.are.equal("provider/session", client.select_model({ provider = "provider", id = "session" })) + result = {} + client.resolve_model(function(value) + record(result, { value = value }) + end) + wait_for_result(result) + assert.are.equal("provider/session", result.value) + + local requests = 0 + local state_callback + rpc.request = function(command, on_complete) + requests = requests + 1 + assert.are.same({ type = "get_state" }, command) + state_callback = on_complete + end + client.setup() + result = {} + client.resolve_model(function(value, err) + record(result, { value = value, err = err }) + end) + state_callback({ data = { model = { provider = "primary", id = "model" } } }, nil) + wait_for_result(result) + assert.are.equal("primary/model", result.value) + + result = {} + client.resolve_model(function(value) + record(result, { value = value }) + end) + wait_for_result(result) + assert.are.equal("primary/model", result.value) + assert.are.equal(1, requests) + end) +end) diff --git a/tests/patch/context_spec.lua b/tests/patch/context_spec.lua new file mode 100644 index 0000000..0692df8 --- /dev/null +++ b/tests/patch/context_spec.lua @@ -0,0 +1,50 @@ +local helpers = require("tests.helpers") +local context = require("patch.context") +local selection = require("patch.selection") + +describe("patch.context", function() + local buffer + + after_each(function() + helpers.delete_buffer(buffer) + end) + + local function capture(start_line, end_line, lines) + buffer = helpers.new_buffer(lines) + helpers.set_visual_lines(buffer, start_line, end_line) + return context.capture(assert(selection.capture())) + end + + it("splits the buffer around the selection", function() + assert.are.same({ + before = { "one" }, + selected = { "two", "three" }, + after = { "four" }, + }, capture(2, 3, { "one", "two", "three", "four" })) + end) + + it("captures a selection at the start of the buffer", function() + assert.are.same({ + before = {}, + selected = { "one" }, + after = { "two" }, + }, capture(1, 1, { "one", "two" })) + end) + + it("captures a selection at the end of the buffer", function() + assert.are.same({ + before = { "one" }, + selected = { "two" }, + after = {}, + }, capture(2, 2, { "one", "two" })) + end) + + it("returns nil for a missing tracked selection", function() + buffer = helpers.new_buffer({ "one" }) + helpers.set_visual_lines(buffer, 1, 1) + local location = assert(selection.capture()) + selection.clear(location) + + assert.is_nil(context.capture(location)) + end) +end) diff --git a/tests/patch/init_spec.lua b/tests/patch/init_spec.lua new file mode 100644 index 0000000..3b0934a --- /dev/null +++ b/tests/patch/init_spec.lua @@ -0,0 +1,279 @@ +describe("patch workflow", function() + local patch + local mocks + local notifications + local input + local requests + local proposal + local location + + before_each(function() + notifications = {} + requests = {} + proposal = { status = "pending" } + location = { source_buf = 1, extmark_id = 2 } + + mocks = { + prompt = { + build = function(_, instruction) + return "built:" .. instruction + end, + }, + selection = { + capture = function() + return location + end, + clear = function(value) + mocks.selection.cleared = value + end, + }, + context = { + capture = function(value) + assert.are.equal(location, value) + return { before = {}, selected = { "old" }, after = {} } + end, + }, + client = { + setup = function(options) + mocks.client.setup_options = options + end, + request = function(message, on_complete) + local request = { message = message, on_complete = on_complete, state = "pending" } + table.insert(requests, request) + return request + end, + cancel = function(request) + request.cancelled = true + return true + end, + resolve_model = function(on_complete) + on_complete("provider/current", nil) + end, + get_available_models = function(on_complete) + on_complete({ { provider = "provider", id = "model", name = "Model" } }, nil) + end, + select_model = function(model) + return model.provider .. "/" .. model.id + end, + }, + replacement = { + apply = function(value, response) + mocks.replacement.applied = { value, response } + proposal.location = value + return proposal + end, + accept = function(value) + mocks.replacement.accepted = value + return true + end, + reject = function(value) + mocks.replacement.rejected = value + return true + end, + begin_retry = function(value) + mocks.replacement.retry_started = value + value.status = "retrying" + return true + end, + complete_retry = function(value, response) + mocks.replacement.retry_completed = { value, response } + value.status = "pending" + return value + end, + abort_retry = function(value) + mocks.replacement.retry_aborted = value + value.status = "finished" + return true + end, + }, + ui = { + open_input = function(on_submit, on_close) + input = { submit = on_submit, close = on_close } + end, + open_menu = function(models, selected, on_submit) + mocks.ui.menu = { models = models, selected = selected, submit = on_submit } + end, + }, + notify = { + setup = function(provider) + mocks.notify.provider = provider + end, + send = function(message, level) + table.insert(notifications, { message = message, level = level }) + end, + }, + } + + package.loaded["patch.prompt"] = mocks.prompt + package.loaded["patch.selection"] = mocks.selection + package.loaded["patch.context"] = mocks.context + package.loaded["patch.client"] = mocks.client + package.loaded["patch.replacement"] = mocks.replacement + package.loaded["patch.ui"] = mocks.ui + package.loaded["patch.notify"] = mocks.notify + package.loaded["patch"] = nil + patch = require("patch") + end) + + after_each(function() + for _, name in ipairs({ + "patch", "patch.prompt", "patch.selection", "patch.context", "patch.client", + "patch.replacement", "patch.ui", "patch.notify", + }) do + package.loaded[name] = nil + end + end) + + local function notification(message, level) + for _, item in ipairs(notifications) do + if item.message == message and (not level or item.level == level) then + return item + end + end + end + + local function start_and_submit() + patch.start() + assert.is_table(input) + input.submit("change it") + assert.are.equal(1, #requests) + end + + local function complete_generation(response, err) + requests[#requests].on_complete(response, err) + end + + it("passes configuration to notification and client modules", function() + local provider = function() end + patch.setup({ notify = provider, system_prompt = "system", model = "provider/model" }) + + assert.are.equal(provider, mocks.notify.provider) + assert.are.same({ system_prompt = "system", model = "provider/model" }, mocks.client.setup_options) + end) + + it("runs capture, generation, review, and acceptance", function() + start_and_submit() + + assert.are.equal("built:change it", requests[1].message) + assert.is_truthy(notification("patch: generating...", vim.log.levels.INFO)) + + complete_generation("replacement", nil) + assert.are.same({ location, "replacement" }, mocks.replacement.applied) + assert.is_truthy(notification("patch: complete", vim.log.levels.INFO)) + + patch.accept() + assert.are.equal(proposal, mocks.replacement.accepted) + + patch.accept() + assert.is_truthy(notification("patch: no active proposal to accept", vim.log.levels.WARN)) + end) + + it("rejects a reviewed proposal", function() + start_and_submit() + complete_generation("replacement", nil) + + patch.reject() + + assert.are.equal(proposal, mocks.replacement.rejected) + patch.reject() + assert.is_truthy(notification("patch: no active proposal to reject", vim.log.levels.WARN)) + end) + + it("retries a proposal and applies the new response", function() + start_and_submit() + complete_generation("first", nil) + + patch.retry() + assert.are.equal(proposal, mocks.replacement.retry_started) + assert.are.equal(2, #requests) + + complete_generation("second", nil) + assert.are.same({ proposal, "second" }, mocks.replacement.retry_completed) + assert.is_truthy(notification("patch: complete", vim.log.levels.INFO)) + end) + + it("aborts a failed retry and reports the error", function() + start_and_submit() + complete_generation("first", nil) + patch.retry() + + complete_generation(nil, "request failed") + + assert.are.equal(proposal, mocks.replacement.retry_aborted) + assert.is_truthy(notification("patch: request failed", vim.log.levels.ERROR)) + end) + + it("cancels an active request and suppresses the cancellation error", function() + start_and_submit() + + patch.cancel() + assert.is_true(requests[1].cancelled) + assert.is_truthy(notification("patch: cancelled", vim.log.levels.INFO)) + + complete_generation(nil, "cancelled") + assert.are.equal(location, mocks.selection.cleared) + assert.is_nil(notification("patch: cancelled", vim.log.levels.ERROR)) + end) + + it("cleans up when input closes or generation fails", function() + patch.start() + input.close() + assert.are.equal(location, mocks.selection.cleared) + + mocks.selection.cleared = nil + start_and_submit() + complete_generation(nil, "failed") + assert.are.equal(location, mocks.selection.cleared) + assert.is_truthy(notification("patch: failed", vim.log.levels.ERROR)) + end) + + it("blocks overlapping workflows", function() + patch.start() + patch.start() + assert.is_truthy(notification("patch: submit or close the active instruction first", vim.log.levels.WARN)) + + input.submit("change") + patch.start() + assert.is_truthy(notification("patch: a replacement is already being generated", vim.log.levels.WARN)) + + complete_generation("replacement", nil) + patch.start() + assert.is_truthy(notification("patch: accept or reject the active proposal first", vim.log.levels.WARN)) + end) + + it("ignores stale completion callbacks", function() + start_and_submit() + local stale = requests[1].on_complete + patch.cancel() + stale(nil, "cancelled") + + patch.start() + input.submit("new change") + stale("stale response", nil) + + assert.is_nil(mocks.replacement.applied) + assert.are.equal(2, #requests) + end) + + it("opens the model menu and records a selection", function() + patch.open_menu() + + assert.are.equal("provider/current", mocks.ui.menu.selected) + assert.are.equal("model", mocks.ui.menu.models[1].id) + mocks.ui.menu.submit(mocks.ui.menu.models[1]) + + assert.is_truthy(notification("patch: using provider/model", vim.log.levels.INFO)) + end) + + it("warns when actions have no active workflow", function() + patch.accept() + patch.reject() + patch.retry() + patch.cancel() + + assert.is_truthy(notification("patch: no active proposal to accept", vim.log.levels.WARN)) + assert.is_truthy(notification("patch: no active proposal to reject", vim.log.levels.WARN)) + assert.is_truthy(notification("patch: no active proposal to retry", vim.log.levels.WARN)) + assert.is_truthy(notification("patch: nothing to cancel", vim.log.levels.WARN)) + end) +end) diff --git a/tests/patch/notify_spec.lua b/tests/patch/notify_spec.lua new file mode 100644 index 0000000..56a9011 --- /dev/null +++ b/tests/patch/notify_spec.lua @@ -0,0 +1,41 @@ +describe("patch.notify", function() + local notify + local original_notify + + before_each(function() + package.loaded["patch.notify"] = nil + notify = require("patch.notify") + original_notify = vim.notify + end) + + after_each(function() + vim.notify = original_notify + notify.setup(nil) + end) + + it("uses vim.notify with the default title", function() + local received + vim.notify = function(message, level, options) + received = { message, level, options } + end + + notify.send("message", vim.log.levels.INFO) + + assert.are.equal("message", received[1]) + assert.are.equal(vim.log.levels.INFO, received[2]) + assert.are.same({ title = "patch.nvim" }, received[3]) + end) + + it("uses a configured callable provider and preserves options", function() + local received + notify.setup(function(message, level, options) + received = { message, level, options } + end) + + notify.send("warning", vim.log.levels.WARN, { title = "custom", timeout = 10 }) + + assert.are.equal("warning", received[1]) + assert.are.equal(vim.log.levels.WARN, received[2]) + assert.are.same({ title = "custom", timeout = 10 }, received[3]) + end) +end) diff --git a/tests/patch/plugin_spec.lua b/tests/patch/plugin_spec.lua new file mode 100644 index 0000000..6b5812a --- /dev/null +++ b/tests/patch/plugin_spec.lua @@ -0,0 +1,59 @@ +describe("plugin/patch.lua", function() + local calls + local command_names = { + "PatchCapture", "PatchAccept", "PatchReject", "PatchRetry", "PatchCancel", "PatchMenu", + } + + local function clear_commands() + for _, name in ipairs(command_names) do + pcall(vim.api.nvim_del_user_command, name) + end + end + + before_each(function() + clear_commands() + calls = {} + package.loaded["patch"] = { + start = function() calls.start = true end, + accept = function() calls.accept = true end, + reject = function() calls.reject = true end, + retry = function() calls.retry = true end, + cancel = function() calls.cancel = true end, + open_menu = function() calls.open_menu = true end, + } + + dofile(vim.fn.getcwd() .. "/plugin/patch.lua") + end) + + after_each(function() + clear_commands() + package.loaded["patch"] = nil + end) + + it("registers the documented user commands", function() + local commands = vim.api.nvim_get_commands({}) + + for _, name in ipairs(command_names) do + assert.is_table(commands[name]) + assert.is_string(commands[name].definition) + end + end) + + it("routes commands to the plugin entrypoints", function() + vim.cmd.PatchCapture() + vim.cmd.PatchAccept() + vim.cmd.PatchReject() + vim.cmd.PatchRetry() + vim.cmd.PatchCancel() + vim.cmd.PatchMenu() + + assert.are.same({ + start = true, + accept = true, + reject = true, + retry = true, + cancel = true, + open_menu = true, + }, calls) + end) +end) diff --git a/tests/patch/prompt_spec.lua b/tests/patch/prompt_spec.lua new file mode 100644 index 0000000..ba76e29 --- /dev/null +++ b/tests/patch/prompt_spec.lua @@ -0,0 +1,43 @@ +local prompt = require("patch.prompt") + +describe("patch.prompt", function() + it("formats captured context in order", function() + local result = prompt.format_context({ + before = { "local before = true", "" }, + selected = { "old()" }, + after = { "return before" }, + }) + + assert.are.equal(table.concat({ + "--- BEFORE ---", + "local before = true", + "", + "--- SELECTED ---", + "old()", + "--- AFTER ---", + "return before", + }, "\n"), result) + end) + + it("marks empty context groups", function() + assert.are.equal(table.concat({ + "--- BEFORE ---", + "(empty)", + "--- SELECTED ---", + "(empty)", + "--- AFTER ---", + "(empty)", + }, "\n"), prompt.format_context({ before = {}, selected = {}, after = {} })) + end) + + it("appends a multiline instruction", function() + local message = prompt.build({ + before = {}, + selected = { "value" }, + after = {}, + }, "Rename this.\nKeep its type.") + + assert.is_truthy(message:find("--- SELECTED ---\nvalue", 1, true)) + assert.is_truthy(message:find("--- INSTRUCTION ---\nRename this.\nKeep its type.", 1, true)) + end) +end) diff --git a/tests/patch/replacement_spec.lua b/tests/patch/replacement_spec.lua new file mode 100644 index 0000000..29bad38 --- /dev/null +++ b/tests/patch/replacement_spec.lua @@ -0,0 +1,113 @@ +local helpers = require("tests.helpers") +local replacement = require("patch.replacement") +local selection = require("patch.selection") + +describe("patch.replacement", function() + local buffer + local location + + before_each(function() + buffer = helpers.new_buffer({ "before", "old one", "old two", "after" }) + helpers.set_visual_lines(buffer, 2, 3) + location = assert(selection.capture()) + end) + + after_each(function() + helpers.delete_buffer(buffer) + end) + + local function lines() + return vim.api.nvim_buf_get_lines(buffer, 0, -1, false) + end + + it("previews generated lines without changing buffer text", function() + local proposal = assert(replacement.apply(location, "new one\nnew two\n")) + + assert.are.same({ "before", "old one", "old two", "after" }, lines()) + assert.are.same({ "new one", "new two" }, proposal.generated_lines) + assert.are.equal("pending", proposal.status) + assert.is_number(proposal.generated_mark) + end) + + it("removes a CRLF terminal newline", function() + local proposal = assert(replacement.apply(location, "new one\r\n")) + + assert.are.same({ "new one" }, proposal.generated_lines) + end) + + it("accepts a proposal and replaces the original lines", function() + local proposal = assert(replacement.apply(location, "new one\nnew two")) + + assert.is_true(replacement.accept(proposal)) + assert.are.same({ "before", "new one", "new two", "after" }, lines()) + assert.are.equal("finished", proposal.status) + assert.is_nil(selection.resolve(location)) + end) + + it("supports an empty response as a deletion", function() + local proposal = assert(replacement.apply(location, "")) + + assert.are.same({}, proposal.generated_lines) + assert.is_true(replacement.accept(proposal)) + assert.are.same({ "before", "after" }, lines()) + end) + + it("leaves the buffer unchanged when rejected", function() + local proposal = assert(replacement.apply(location, "replacement")) + + assert.is_true(replacement.reject(proposal)) + assert.are.same({ "before", "old one", "old two", "after" }, lines()) + assert.are.equal("finished", proposal.status) + end) + + it("undoes an accepted proposal as one buffer change", function() + local proposal = assert(replacement.apply(location, "replacement")) + assert.is_true(replacement.accept(proposal)) + + vim.cmd.undo() + + assert.are.same({ "before", "old one", "old two", "after" }, lines()) + end) + + it("hides the old preview and completes a retry", function() + local proposal = assert(replacement.apply(location, "first")) + + assert.is_true(replacement.begin_retry(proposal)) + assert.are.equal("retrying", proposal.status) + assert.are.same({}, proposal.generated_lines) + assert.is_nil(proposal.generated_mark) + + assert.are.equal(proposal, replacement.complete_retry(proposal, "second\nthird")) + assert.are.equal("pending", proposal.status) + assert.are.same({ "second", "third" }, proposal.generated_lines) + end) + + it("finalises an aborted retry", function() + local proposal = assert(replacement.apply(location, "first")) + assert.is_true(replacement.begin_retry(proposal)) + + assert.is_true(replacement.abort_retry(proposal)) + assert.are.equal("finished", proposal.status) + assert.is_nil(selection.resolve(location)) + end) + + it("rejects lifecycle operations in the wrong state", function() + local proposal = assert(replacement.apply(location, "first")) + assert.is_true(replacement.reject(proposal)) + + assert.is_false(replacement.accept(proposal)) + assert.is_false(replacement.reject(proposal)) + assert.is_false(replacement.begin_retry(proposal)) + assert.is_false(replacement.abort_retry(proposal)) + assert.is_nil(replacement.complete_retry(proposal, "second")) + end) + + it("fails safely after the source buffer is unloaded", function() + local proposal = assert(replacement.apply(location, "replacement")) + vim.api.nvim_buf_delete(buffer, { force = true }) + buffer = nil + + assert.is_false(replacement.accept(proposal)) + assert.are.equal("finished", proposal.status) + end) +end) diff --git a/tests/patch/rpc_spec.lua b/tests/patch/rpc_spec.lua new file mode 100644 index 0000000..5e00be0 --- /dev/null +++ b/tests/patch/rpc_spec.lua @@ -0,0 +1,240 @@ +describe("patch.rpc", function() + local rpc + local original_system + + before_each(function() + package.loaded["patch.rpc"] = nil + rpc = require("patch.rpc") + original_system = vim.system + end) + + after_each(function() + vim.system = original_system + end) + + local function fake_system() + local invocation = {} + local process = { writes = {} } + + function process:write(value) + table.insert(self.writes, value == nil and vim.NIL or value) + end + + vim.system = function(command, options, on_exit) + invocation.command = command + invocation.options = options + invocation.on_exit = on_exit + invocation.process = process + return process + end + + return invocation + end + + it("starts Pi in RPC mode and sends JSONL records", function() + local invocation = fake_system() + local connection = assert(rpc.start({ "--no-session" }, { + on_record = function() end, + on_error = function() end, + on_exit = function() end, + })) + + assert.are.same({ "pi", "--mode", "rpc", "--no-session" }, invocation.command) + assert.is_true(invocation.options.stdin) + assert.is_function(invocation.options.stdout) + assert.is_true(connection:send({ type = "prompt", message = "hello" })) + + local sent = vim.json.decode(invocation.process.writes[1]) + assert.are.same({ type = "prompt", message = "hello" }, sent) + end) + + it("decodes records split across stdout chunks", function() + local invocation = fake_system() + local records = {} + assert(rpc.start({}, { + on_record = function(record) + table.insert(records, record) + end, + on_error = function() end, + on_exit = function() end, + })) + + invocation.options.stdout(nil, '{"type":"fir') + invocation.options.stdout(nil, 'st"}\n{"type":"second"}\n') + + assert.are.same({ { type = "first" }, { type = "second" } }, records) + end) + + it("decodes a final record without a newline", function() + local invocation = fake_system() + local records = {} + assert(rpc.start({}, { + on_record = function(record) + table.insert(records, record) + end, + on_error = function() end, + on_exit = function() end, + })) + + invocation.options.stdout(nil, '{"type":"final"}') + invocation.options.stdout(nil, nil) + + assert.are.same({ { type = "final" } }, records) + end) + + it("reports malformed JSON once and closes the connection", function() + local invocation = fake_system() + local errors = {} + local connection = assert(rpc.start({}, { + on_record = function() end, + on_error = function(err) + table.insert(errors, err) + end, + on_exit = function() end, + })) + + invocation.options.stdout(nil, "not-json\n") + invocation.options.stdout(nil, "still-not-json\n") + + assert.are.equal(1, #errors) + assert.is_truthy(errors[1]:find("Failed to decode Pi output:", 1, true)) + assert.is_true(connection.closed) + end) + + it("reports stdout errors", function() + local invocation = fake_system() + local received + assert(rpc.start({}, { + on_record = function() end, + on_error = function(err) + received = err + end, + on_exit = function() end, + })) + + invocation.options.stdout("broken pipe", nil) + + assert.are.equal("Failed to read Pi output: broken pipe", received) + end) + + it("closes idempotently and rejects later writes", function() + local invocation = fake_system() + local connection = assert(rpc.start({}, { + on_record = function() end, + on_error = function() end, + on_exit = function() end, + })) + + connection:close() + connection:close() + local sent, err = connection:send({ type = "prompt" }) + + assert.is_false(sent) + assert.are.equal("Pi connection is closed", err) + assert.are.equal(1, #invocation.process.writes) + assert.are.equal(vim.NIL, invocation.process.writes[1]) + end) + + it("reports process start failures", function() + vim.system = function() + error("not executable") + end + + local connection, err = rpc.start({}, { + on_record = function() end, + on_error = function() end, + on_exit = function() end, + }) + + assert.is_nil(connection) + assert.is_truthy(err:find("Failed to start Pi:", 1, true)) + assert.is_truthy(err:find("not executable", 1, true)) + end) + + it("formats process errors with trimmed stderr", function() + assert.are.equal("Pi exited with code 3: failure", rpc.format_exit_error({ code = 3, stderr = " failure\n" })) + assert.are.equal("Pi exited with code 2", rpc.format_exit_error({ code = 2, stderr = "" })) + end) + + describe("one-shot requests", function() + local handlers + local connection + local sent + + before_each(function() + connection = { closed = false } + function connection:send(record) + sent = record + return true + end + function connection:close() + self.closed = true + end + + rpc.start = function(args, supplied_handlers) + assert.are.same({ "--no-session" }, args) + handlers = supplied_handlers + return connection + end + end) + + it("matches the command and request id", function() + local result + rpc.request({ type = "get_state" }, function(record, err) + result = { record, err } + end) + + assert.are.same({ type = "get_state", id = "get_state" }, sent) + handlers.on_record({ type = "event" }) + assert.is_nil(result) + handlers.on_record({ type = "response", command = "get_state", id = "other", success = true }) + assert.is_nil(result) + + local record = { type = "response", command = "get_state", id = "get_state", success = true } + handlers.on_record(record) + assert.are.equal(record, result[1]) + assert.is_nil(result[2]) + assert.is_true(connection.closed) + end) + + it("reports a rejected command", function() + local received + rpc.request({ type = "get_state" }, function(_, err) + received = err + end) + + handlers.on_record({ + type = "response", + command = "get_state", + id = "get_state", + success = false, + error = "denied", + }) + + assert.are.equal("Pi rejected get_state: denied", received) + end) + + it("reports an early process exit", function() + local received + rpc.request({ type = "get_state" }, function(_, err) + received = err + end) + + handlers.on_exit({ code = 0, stderr = "" }) + + assert.are.equal("Pi exited before returning get_state", received) + end) + + it("completes only once", function() + local completions = 0 + rpc.request({ type = "get_state" }, function() + completions = completions + 1 + end) + + handlers.on_error("failed") + handlers.on_exit({ code = 1, stderr = "later" }) + + assert.are.equal(1, completions) + end) + end) +end) diff --git a/tests/patch/selection_spec.lua b/tests/patch/selection_spec.lua new file mode 100644 index 0000000..6501764 --- /dev/null +++ b/tests/patch/selection_spec.lua @@ -0,0 +1,77 @@ +local helpers = require("tests.helpers") +local selection = require("patch.selection") + +describe("patch.selection", function() + local buffer + + before_each(function() + buffer = helpers.new_buffer({ "one", "two", "three", "four" }) + end) + + after_each(function() + helpers.delete_buffer(buffer) + end) + + it("captures and resolves a visual-line selection", function() + helpers.set_visual_lines(buffer, 2, 3) + + local location = assert(selection.capture()) + local range = assert(selection.resolve(location)) + + assert.are.same({ source_buf = buffer, start_row = 1, end_row = 3 }, range) + end) + + it("normalises reversed visual marks", function() + helpers.set_visual_lines(buffer, 4, 2) + + local range = assert(selection.resolve(assert(selection.capture()))) + + assert.are.same({ source_buf = buffer, start_row = 1, end_row = 4 }, range) + end) + + it("returns nil when no visual selection has been recorded", function() + vim.fn.setpos("'<", { buffer, 0, 0, 0 }) + vim.fn.setpos("'>", { buffer, 0, 0, 0 }) + + assert.is_nil(selection.capture()) + end) + + it("tracks the selected lines when text is inserted before them", function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + + vim.api.nvim_buf_set_lines(buffer, 0, 0, false, { "zero" }) + + assert.are.same({ source_buf = buffer, start_row = 2, end_row = 4 }, selection.resolve(location)) + end) + + it("decorates without changing the tracked range", function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + local original = selection.resolve(location) + + assert.is_true(selection.decorate(location, { hl_group = "DiffDelete", hl_eol = true })) + assert.are.same(original, selection.resolve(location)) + end) + + it("clears the tracked selection", function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + + selection.clear(location) + + assert.is_nil(selection.resolve(location)) + end) + + it("does not resolve a selection in an unloaded buffer", function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + vim.api.nvim_buf_delete(buffer, { force = true }) + buffer = nil + + assert.is_nil(selection.resolve(location)) + assert.has_no.errors(function() + selection.clear(location) + end) + end) +end) From 144c8372d38e2cdf5efdcd1c97c3631799746712 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 20 Aug 2026 20:55:11 +0800 Subject: [PATCH 36/39] fix(rpc): isolate inline generation environment --- lua/patch/client.lua | 2 +- lua/patch/rpc.lua | 13 ++++++++++++- tests/patch/rpc_spec.lua | 14 +++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lua/patch/client.lua b/lua/patch/client.lua index 2add015..c48ed90 100644 --- a/lua/patch/client.lua +++ b/lua/patch/client.lua @@ -1,7 +1,7 @@ local M = {} local rpc = require("patch.rpc") -local DEFAULT_SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences." +local DEFAULT_SYSTEM_PROMPT = "You are an inline code editor. Given the selection and an instruction, reply with only the replacement code for the SELECTED region. No commentary, no explanations, no markdown fences.\n" local system_prompt = DEFAULT_SYSTEM_PROMPT local configured_model local session_model diff --git a/lua/patch/rpc.lua b/lua/patch/rpc.lua index 8aa40cf..81f9ccf 100644 --- a/lua/patch/rpc.lua +++ b/lua/patch/rpc.lua @@ -135,7 +135,18 @@ function M.start(args, handlers) end local handle_stdout = read_jsonl(handlers.on_record, handle_error) - local command = { "pi", "--mode", "rpc" } + local command = { + "pi", + "--mode", "rpc", + "--no-context-files", + "--no-skills", + "--no-extensions", + "--no-prompt-templates", + "--no-themes", + "--no-tools", + "--no-approve", + "--append-system-prompt", "", + } vim.list_extend(command, args) local started, process_or_error = pcall( diff --git a/tests/patch/rpc_spec.lua b/tests/patch/rpc_spec.lua index 5e00be0..2ed07ec 100644 --- a/tests/patch/rpc_spec.lua +++ b/tests/patch/rpc_spec.lua @@ -39,7 +39,19 @@ describe("patch.rpc", function() on_exit = function() end, })) - assert.are.same({ "pi", "--mode", "rpc", "--no-session" }, invocation.command) + assert.are.same({ + "pi", + "--mode", "rpc", + "--no-context-files", + "--no-skills", + "--no-extensions", + "--no-prompt-templates", + "--no-themes", + "--no-tools", + "--no-approve", + "--append-system-prompt", "", + "--no-session", + }, invocation.command) assert.is_true(invocation.options.stdin) assert.is_function(invocation.options.stdout) assert.is_true(connection:send({ type = "prompt", message = "hello" })) From 7f8827178a0395f9cbde6610ee7b242986042c41 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 20 Aug 2026 21:03:08 +0800 Subject: [PATCH 37/39] docs: clarify runtime requirements --- README.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/README.md b/README.md index 172856f..099a16c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A simple agentic inline code editor for Neovim. -Built with the [pi-ai](https://github.com/earendil-works/pi/tree/main/packages/ai) package. +Requires Neovim 0.10+, [nui.nvim](https://github.com/MunifTanjim/nui.nvim), and an authenticated Pi installation. ## How it works @@ -54,14 +54,3 @@ npx @earendil-works/pi-ai login [provider] `system_prompt` is optional. When set, it completely replaces Patch's built-in system prompt. `model` is optional. When omitted, Patch uses Pi's primary model. `PatchMenu` marks the effective model and allows selecting an override for the current Neovim session. - -## To-do - -- [ ] Add codebase-relevant context gathering on top of the separate buffer-context and prompt-construction stages. -- [x] Reject empty or whitespace-only instructions before starting generation. -- [x] Make cancellation request-scoped instead of global. -- [x] Add a model-selection UI for choosing the model used by Patch requests, with optional configuration and Pi's primary model as the default. -- [x] Clean up undo behavior so undoing an accepted patch restores the original text like rejection does, without leaving or duplicating generated content. -- [x] Improve retry feedback by temporarily turning off the existing diff preview while a new replacement is being generated, then displaying the updated preview when the retry completes. -- [x] Expose the system prompt to users as a setup configuration option. -- [x] Show a notification when a replacement has finished generating and is ready for review. From 1d4aa4691affe70d10b89904678314a3db4aab03 Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 20 Aug 2026 21:47:55 +0800 Subject: [PATCH 38/39] test: run suite with mini.test in CI --- .github/workflows/tests.yml | 48 +++++ CONTRIBUTING.md | 18 +- tests/helpers.lua | 2 +- tests/minimal_init.lua | 5 +- tests/patch/client_spec.lua | 285 ----------------------------- tests/patch/context_spec.lua | 50 ----- tests/patch/init_spec.lua | 279 ---------------------------- tests/patch/notify_spec.lua | 41 ----- tests/patch/plugin_spec.lua | 59 ------ tests/patch/prompt_spec.lua | 43 ----- tests/patch/replacement_spec.lua | 113 ------------ tests/patch/rpc_spec.lua | 252 ------------------------- tests/patch/selection_spec.lua | 77 -------- tests/patch/test_client.lua | 303 +++++++++++++++++++++++++++++++ tests/patch/test_context.lua | 54 ++++++ tests/patch/test_init.lua | 285 +++++++++++++++++++++++++++++ tests/patch/test_notify.lua | 45 +++++ tests/patch/test_plugin.lua | 63 +++++++ tests/patch/test_prompt.lua | 46 +++++ tests/patch/test_replacement.lua | 116 ++++++++++++ tests/patch/test_rpc.lua | 262 ++++++++++++++++++++++++++ tests/patch/test_selection.lua | 80 ++++++++ 22 files changed, 1322 insertions(+), 1204 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 tests/patch/client_spec.lua delete mode 100644 tests/patch/context_spec.lua delete mode 100644 tests/patch/init_spec.lua delete mode 100644 tests/patch/notify_spec.lua delete mode 100644 tests/patch/plugin_spec.lua delete mode 100644 tests/patch/prompt_spec.lua delete mode 100644 tests/patch/replacement_spec.lua delete mode 100644 tests/patch/rpc_spec.lua delete mode 100644 tests/patch/selection_spec.lua create mode 100644 tests/patch/test_client.lua create mode 100644 tests/patch/test_context.lua create mode 100644 tests/patch/test_init.lua create mode 100644 tests/patch/test_notify.lua create mode 100644 tests/patch/test_plugin.lua create mode 100644 tests/patch/test_prompt.lua create mode 100644 tests/patch/test_replacement.lua create mode 100644 tests/patch/test_rpc.lua create mode 100644 tests/patch/test_selection.lua diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..32d5804 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,48 @@ +name: Tests + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test: + name: Neovim ${{ matrix.neovim }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + neovim: [v0.10.4, stable] + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install Neovim + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.neovim }} + + - name: Checkout mini.test + uses: actions/checkout@v7 + with: + repository: nvim-mini/mini.test + ref: 35c67cb7f2dc697ed0c7c06400fcf29a8a521d7d + path: .deps/mini.test + + - name: Run tests + run: nvim --headless --noplugin -u tests/minimal_init.lua -c "lua MiniTest.run()" + + - name: Compile runtime Lua + run: >- + nvim --headless -u NONE + -c "lua for _, f in ipairs(vim.fn.glob('lua/**/*.lua', false, true)) do assert(loadfile(f)) end; assert(loadfile('plugin/patch.lua'))" + -c qa diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e712304..3ec48e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1,17 @@ -wip +# Contributing + +## Pull Requests + +1. Fork the repository. +2. Create a focused branch for your change. +3. Make your changes and add or update relevant tests. +4. Open a pull request targeting `main` and describe what changed. +5. Address any CI failures or review feedback. + +## Tests + +Tests run automatically on pull requests targeting `main`. + +## Issues and Questions + +For bug reports, issues, or questions, please [open an issue](https://github.com/timothyckl/tau.nvim/issues/new). diff --git a/tests/helpers.lua b/tests/helpers.lua index eefda14..ddfd48c 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -24,7 +24,7 @@ function M.delete_buffer(buffer) end function M.wait_for(predicate) - assert.is_true(vim.wait(500, predicate, 5), "timed out waiting for scheduled callback") + assert(vim.wait(500, predicate, 5), "timed out waiting for scheduled callback") end function M.unload_modules(names) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index 5d14bb6..e8a25c1 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -15,9 +15,8 @@ local function dependency_path(environment_name, directory_name) end vim.opt.runtimepath:prepend(root) -vim.opt.runtimepath:prepend(dependency_path("PLENARY_DIR", "plenary.nvim")) -vim.opt.runtimepath:prepend(dependency_path("NUI_DIR", "nui.nvim")) -vim.cmd("runtime plugin/plenary.vim") +vim.opt.runtimepath:prepend(dependency_path("MINITEST_DIR", "mini.test")) +require("mini.test").setup({ collect = { emulate_busted = false } }) vim.o.swapfile = false vim.o.writebackup = false diff --git a/tests/patch/client_spec.lua b/tests/patch/client_spec.lua deleted file mode 100644 index 1dc1812..0000000 --- a/tests/patch/client_spec.lua +++ /dev/null @@ -1,285 +0,0 @@ -local helpers = require("tests.helpers") - -describe("patch.client", function() - local client - local rpc - local connection - local handlers - local start_args - local sent - - before_each(function() - sent = {} - connection = { closed = false } - function connection:send(record) - table.insert(sent, record) - return true - end - function connection:close() - self.closed = true - end - - rpc = { - start = function(args, supplied_handlers) - start_args = args - handlers = supplied_handlers - return connection - end, - request = function() end, - format_exit_error = function(result) - return "exit " .. result.code - end, - } - - package.loaded["patch.rpc"] = rpc - package.loaded["patch.client"] = nil - client = require("patch.client") - client.setup() - end) - - after_each(function() - package.loaded["patch.client"] = nil - package.loaded["patch.rpc"] = nil - end) - - local function wait_for_result(result) - helpers.wait_for(function() - return result.called - end) - end - - local function record(result, values) - result.called = true - for key, value in pairs(values) do - result[key] = value - end - end - - local function assistant_message(text, overrides) - return vim.tbl_extend("force", { - role = "assistant", - content = { { type = "text", text = text } }, - stopReason = "stop", - }, overrides or {}) - end - - it("sends a prompt and completes only after the agent settles", function() - local result = {} - local request = client.request("replace this", function(value, err) - record(result, { value = value, err = err }) - end) - - assert.are.equal("pending", request.state) - assert.are.same({ type = "prompt", message = "replace this" }, sent[1]) - assert.are.same({ "--system-prompt", start_args[2], "--no-session", "--thinking", "off" }, start_args) - - handlers.on_record({ type = "message_end", message = assistant_message("replacement") }) - vim.wait(20) - assert.is_nil(result.called) - - handlers.on_record({ type = "agent_settled" }) - wait_for_result(result) - - assert.are.equal("replacement", result.value) - assert.is_nil(result.err) - assert.are.equal("completed", request.state) - assert.is_true(connection.closed) - end) - - it("uses the last assistant message and joins text parts", function() - local result = {} - client.request("prompt", function(value, err) - record(result, { value = value, err = err }) - end) - - handlers.on_record({ type = "message_end", message = assistant_message("discarded") }) - handlers.on_record({ - type = "message_end", - message = assistant_message("ignored", { - content = { - { type = "text", text = "first" }, - { type = "toolCall", name = "tool" }, - { type = "text", text = "second" }, - }, - }), - }) - handlers.on_record({ type = "agent_settled" }) - wait_for_result(result) - - assert.are.equal("first\nsecond", result.value) - assert.is_nil(result.err) - end) - - it("passes configured system prompt and model arguments", function() - client.setup({ system_prompt = "custom prompt", model = " openai/model " }) - client.request("prompt", function() end) - - assert.are.same({ - "--system-prompt", "custom prompt", "--no-session", "--thinking", "off", - "--model", "openai/model", - }, start_args) - end) - - it("treats empty assistant output as a deletion", function() - local result = {} - client.request("prompt", function(value, err) - record(result, { value = value, err = err }) - end) - - handlers.on_record({ type = "message_end", message = assistant_message("", { content = {} }) }) - handlers.on_record({ type = "agent_settled" }) - wait_for_result(result) - - assert.are.equal("", result.value) - assert.is_nil(result.err) - end) - - it("cancels a pending request once", function() - local result = {} - local request = client.request("prompt", function(value, err) - record(result, { value = value, err = err }) - end) - - assert.is_true(client.cancel(request)) - assert.is_false(client.cancel(request)) - wait_for_result(result) - - assert.are.equal("cancelled", request.state) - assert.are.same({ type = "abort" }, sent[2]) - assert.is_nil(result.value) - assert.are.equal("cancelled", result.err) - - handlers.on_record({ type = "agent_settled" }) - assert.is_true(connection.closed) - end) - - it("reports prompt rejection", function() - local result = {} - client.request("prompt", function(value, err) - record(result, { value = value, err = err }) - end) - - handlers.on_record({ type = "response", command = "prompt", success = false, error = "denied" }) - wait_for_result(result) - - assert.is_nil(result.value) - assert.are.equal("Pi rejected the prompt: denied", result.err) - assert.is_true(connection.closed) - end) - - it("reports assistant and missing-response failures", function() - local error_result = {} - client.request("prompt", function(_, err) - record(error_result, { err = err }) - end) - handlers.on_record({ - type = "message_end", - message = assistant_message("", { stopReason = "error", errorMessage = "model failed" }), - }) - handlers.on_record({ type = "agent_settled" }) - wait_for_result(error_result) - assert.are.equal("model failed", error_result.err) - - client.setup() - connection.closed = false - local missing_result = {} - client.request("prompt", function(_, err) - record(missing_result, { err = err }) - end) - handlers.on_record({ type = "agent_settled" }) - wait_for_result(missing_result) - assert.are.equal("Pi returned no response", missing_result.err) - end) - - it("reports process start and exit failures", function() - rpc.start = function() - return nil, "cannot start" - end - local start_result = {} - client.request("prompt", function(_, err) - record(start_result, { err = err }) - end) - wait_for_result(start_result) - assert.are.equal("cannot start", start_result.err) - - rpc.start = function(args, supplied_handlers) - handlers = supplied_handlers - return connection - end - local exit_result = {} - client.request("prompt", function(_, err) - record(exit_result, { err = err }) - end) - handlers.on_exit({ code = 7 }) - wait_for_result(exit_result) - assert.are.equal("exit 7", exit_result.err) - end) - - it("retrieves and validates available models", function() - local callback - rpc.request = function(command, on_complete) - assert.are.same({ type = "get_available_models" }, command) - callback = on_complete - end - - local result = {} - client.get_available_models(function(models, err) - record(result, { models = models, err = err }) - end) - local models = { { provider = "openai", id = "model", name = "Model" } } - callback({ data = { models = models } }, nil) - wait_for_result(result) - assert.are.same(models, result.models) - - result = {} - client.get_available_models(function(value, err) - record(result, { value = value, err = err }) - end) - callback({ data = {} }, nil) - wait_for_result(result) - assert.is_nil(result.value) - assert.are.equal("Pi returned an invalid model list", result.err) - end) - - it("resolves configured, session, and cached primary models", function() - local result = {} - client.setup({ model = "configured/model" }) - client.resolve_model(function(value, err) - record(result, { value = value, err = err }) - end) - wait_for_result(result) - assert.are.equal("configured/model", result.value) - - assert.are.equal("provider/session", client.select_model({ provider = "provider", id = "session" })) - result = {} - client.resolve_model(function(value) - record(result, { value = value }) - end) - wait_for_result(result) - assert.are.equal("provider/session", result.value) - - local requests = 0 - local state_callback - rpc.request = function(command, on_complete) - requests = requests + 1 - assert.are.same({ type = "get_state" }, command) - state_callback = on_complete - end - client.setup() - result = {} - client.resolve_model(function(value, err) - record(result, { value = value, err = err }) - end) - state_callback({ data = { model = { provider = "primary", id = "model" } } }, nil) - wait_for_result(result) - assert.are.equal("primary/model", result.value) - - result = {} - client.resolve_model(function(value) - record(result, { value = value }) - end) - wait_for_result(result) - assert.are.equal("primary/model", result.value) - assert.are.equal(1, requests) - end) -end) diff --git a/tests/patch/context_spec.lua b/tests/patch/context_spec.lua deleted file mode 100644 index 0692df8..0000000 --- a/tests/patch/context_spec.lua +++ /dev/null @@ -1,50 +0,0 @@ -local helpers = require("tests.helpers") -local context = require("patch.context") -local selection = require("patch.selection") - -describe("patch.context", function() - local buffer - - after_each(function() - helpers.delete_buffer(buffer) - end) - - local function capture(start_line, end_line, lines) - buffer = helpers.new_buffer(lines) - helpers.set_visual_lines(buffer, start_line, end_line) - return context.capture(assert(selection.capture())) - end - - it("splits the buffer around the selection", function() - assert.are.same({ - before = { "one" }, - selected = { "two", "three" }, - after = { "four" }, - }, capture(2, 3, { "one", "two", "three", "four" })) - end) - - it("captures a selection at the start of the buffer", function() - assert.are.same({ - before = {}, - selected = { "one" }, - after = { "two" }, - }, capture(1, 1, { "one", "two" })) - end) - - it("captures a selection at the end of the buffer", function() - assert.are.same({ - before = { "one" }, - selected = { "two" }, - after = {}, - }, capture(2, 2, { "one", "two" })) - end) - - it("returns nil for a missing tracked selection", function() - buffer = helpers.new_buffer({ "one" }) - helpers.set_visual_lines(buffer, 1, 1) - local location = assert(selection.capture()) - selection.clear(location) - - assert.is_nil(context.capture(location)) - end) -end) diff --git a/tests/patch/init_spec.lua b/tests/patch/init_spec.lua deleted file mode 100644 index 3b0934a..0000000 --- a/tests/patch/init_spec.lua +++ /dev/null @@ -1,279 +0,0 @@ -describe("patch workflow", function() - local patch - local mocks - local notifications - local input - local requests - local proposal - local location - - before_each(function() - notifications = {} - requests = {} - proposal = { status = "pending" } - location = { source_buf = 1, extmark_id = 2 } - - mocks = { - prompt = { - build = function(_, instruction) - return "built:" .. instruction - end, - }, - selection = { - capture = function() - return location - end, - clear = function(value) - mocks.selection.cleared = value - end, - }, - context = { - capture = function(value) - assert.are.equal(location, value) - return { before = {}, selected = { "old" }, after = {} } - end, - }, - client = { - setup = function(options) - mocks.client.setup_options = options - end, - request = function(message, on_complete) - local request = { message = message, on_complete = on_complete, state = "pending" } - table.insert(requests, request) - return request - end, - cancel = function(request) - request.cancelled = true - return true - end, - resolve_model = function(on_complete) - on_complete("provider/current", nil) - end, - get_available_models = function(on_complete) - on_complete({ { provider = "provider", id = "model", name = "Model" } }, nil) - end, - select_model = function(model) - return model.provider .. "/" .. model.id - end, - }, - replacement = { - apply = function(value, response) - mocks.replacement.applied = { value, response } - proposal.location = value - return proposal - end, - accept = function(value) - mocks.replacement.accepted = value - return true - end, - reject = function(value) - mocks.replacement.rejected = value - return true - end, - begin_retry = function(value) - mocks.replacement.retry_started = value - value.status = "retrying" - return true - end, - complete_retry = function(value, response) - mocks.replacement.retry_completed = { value, response } - value.status = "pending" - return value - end, - abort_retry = function(value) - mocks.replacement.retry_aborted = value - value.status = "finished" - return true - end, - }, - ui = { - open_input = function(on_submit, on_close) - input = { submit = on_submit, close = on_close } - end, - open_menu = function(models, selected, on_submit) - mocks.ui.menu = { models = models, selected = selected, submit = on_submit } - end, - }, - notify = { - setup = function(provider) - mocks.notify.provider = provider - end, - send = function(message, level) - table.insert(notifications, { message = message, level = level }) - end, - }, - } - - package.loaded["patch.prompt"] = mocks.prompt - package.loaded["patch.selection"] = mocks.selection - package.loaded["patch.context"] = mocks.context - package.loaded["patch.client"] = mocks.client - package.loaded["patch.replacement"] = mocks.replacement - package.loaded["patch.ui"] = mocks.ui - package.loaded["patch.notify"] = mocks.notify - package.loaded["patch"] = nil - patch = require("patch") - end) - - after_each(function() - for _, name in ipairs({ - "patch", "patch.prompt", "patch.selection", "patch.context", "patch.client", - "patch.replacement", "patch.ui", "patch.notify", - }) do - package.loaded[name] = nil - end - end) - - local function notification(message, level) - for _, item in ipairs(notifications) do - if item.message == message and (not level or item.level == level) then - return item - end - end - end - - local function start_and_submit() - patch.start() - assert.is_table(input) - input.submit("change it") - assert.are.equal(1, #requests) - end - - local function complete_generation(response, err) - requests[#requests].on_complete(response, err) - end - - it("passes configuration to notification and client modules", function() - local provider = function() end - patch.setup({ notify = provider, system_prompt = "system", model = "provider/model" }) - - assert.are.equal(provider, mocks.notify.provider) - assert.are.same({ system_prompt = "system", model = "provider/model" }, mocks.client.setup_options) - end) - - it("runs capture, generation, review, and acceptance", function() - start_and_submit() - - assert.are.equal("built:change it", requests[1].message) - assert.is_truthy(notification("patch: generating...", vim.log.levels.INFO)) - - complete_generation("replacement", nil) - assert.are.same({ location, "replacement" }, mocks.replacement.applied) - assert.is_truthy(notification("patch: complete", vim.log.levels.INFO)) - - patch.accept() - assert.are.equal(proposal, mocks.replacement.accepted) - - patch.accept() - assert.is_truthy(notification("patch: no active proposal to accept", vim.log.levels.WARN)) - end) - - it("rejects a reviewed proposal", function() - start_and_submit() - complete_generation("replacement", nil) - - patch.reject() - - assert.are.equal(proposal, mocks.replacement.rejected) - patch.reject() - assert.is_truthy(notification("patch: no active proposal to reject", vim.log.levels.WARN)) - end) - - it("retries a proposal and applies the new response", function() - start_and_submit() - complete_generation("first", nil) - - patch.retry() - assert.are.equal(proposal, mocks.replacement.retry_started) - assert.are.equal(2, #requests) - - complete_generation("second", nil) - assert.are.same({ proposal, "second" }, mocks.replacement.retry_completed) - assert.is_truthy(notification("patch: complete", vim.log.levels.INFO)) - end) - - it("aborts a failed retry and reports the error", function() - start_and_submit() - complete_generation("first", nil) - patch.retry() - - complete_generation(nil, "request failed") - - assert.are.equal(proposal, mocks.replacement.retry_aborted) - assert.is_truthy(notification("patch: request failed", vim.log.levels.ERROR)) - end) - - it("cancels an active request and suppresses the cancellation error", function() - start_and_submit() - - patch.cancel() - assert.is_true(requests[1].cancelled) - assert.is_truthy(notification("patch: cancelled", vim.log.levels.INFO)) - - complete_generation(nil, "cancelled") - assert.are.equal(location, mocks.selection.cleared) - assert.is_nil(notification("patch: cancelled", vim.log.levels.ERROR)) - end) - - it("cleans up when input closes or generation fails", function() - patch.start() - input.close() - assert.are.equal(location, mocks.selection.cleared) - - mocks.selection.cleared = nil - start_and_submit() - complete_generation(nil, "failed") - assert.are.equal(location, mocks.selection.cleared) - assert.is_truthy(notification("patch: failed", vim.log.levels.ERROR)) - end) - - it("blocks overlapping workflows", function() - patch.start() - patch.start() - assert.is_truthy(notification("patch: submit or close the active instruction first", vim.log.levels.WARN)) - - input.submit("change") - patch.start() - assert.is_truthy(notification("patch: a replacement is already being generated", vim.log.levels.WARN)) - - complete_generation("replacement", nil) - patch.start() - assert.is_truthy(notification("patch: accept or reject the active proposal first", vim.log.levels.WARN)) - end) - - it("ignores stale completion callbacks", function() - start_and_submit() - local stale = requests[1].on_complete - patch.cancel() - stale(nil, "cancelled") - - patch.start() - input.submit("new change") - stale("stale response", nil) - - assert.is_nil(mocks.replacement.applied) - assert.are.equal(2, #requests) - end) - - it("opens the model menu and records a selection", function() - patch.open_menu() - - assert.are.equal("provider/current", mocks.ui.menu.selected) - assert.are.equal("model", mocks.ui.menu.models[1].id) - mocks.ui.menu.submit(mocks.ui.menu.models[1]) - - assert.is_truthy(notification("patch: using provider/model", vim.log.levels.INFO)) - end) - - it("warns when actions have no active workflow", function() - patch.accept() - patch.reject() - patch.retry() - patch.cancel() - - assert.is_truthy(notification("patch: no active proposal to accept", vim.log.levels.WARN)) - assert.is_truthy(notification("patch: no active proposal to reject", vim.log.levels.WARN)) - assert.is_truthy(notification("patch: no active proposal to retry", vim.log.levels.WARN)) - assert.is_truthy(notification("patch: nothing to cancel", vim.log.levels.WARN)) - end) -end) diff --git a/tests/patch/notify_spec.lua b/tests/patch/notify_spec.lua deleted file mode 100644 index 56a9011..0000000 --- a/tests/patch/notify_spec.lua +++ /dev/null @@ -1,41 +0,0 @@ -describe("patch.notify", function() - local notify - local original_notify - - before_each(function() - package.loaded["patch.notify"] = nil - notify = require("patch.notify") - original_notify = vim.notify - end) - - after_each(function() - vim.notify = original_notify - notify.setup(nil) - end) - - it("uses vim.notify with the default title", function() - local received - vim.notify = function(message, level, options) - received = { message, level, options } - end - - notify.send("message", vim.log.levels.INFO) - - assert.are.equal("message", received[1]) - assert.are.equal(vim.log.levels.INFO, received[2]) - assert.are.same({ title = "patch.nvim" }, received[3]) - end) - - it("uses a configured callable provider and preserves options", function() - local received - notify.setup(function(message, level, options) - received = { message, level, options } - end) - - notify.send("warning", vim.log.levels.WARN, { title = "custom", timeout = 10 }) - - assert.are.equal("warning", received[1]) - assert.are.equal(vim.log.levels.WARN, received[2]) - assert.are.same({ title = "custom", timeout = 10 }, received[3]) - end) -end) diff --git a/tests/patch/plugin_spec.lua b/tests/patch/plugin_spec.lua deleted file mode 100644 index 6b5812a..0000000 --- a/tests/patch/plugin_spec.lua +++ /dev/null @@ -1,59 +0,0 @@ -describe("plugin/patch.lua", function() - local calls - local command_names = { - "PatchCapture", "PatchAccept", "PatchReject", "PatchRetry", "PatchCancel", "PatchMenu", - } - - local function clear_commands() - for _, name in ipairs(command_names) do - pcall(vim.api.nvim_del_user_command, name) - end - end - - before_each(function() - clear_commands() - calls = {} - package.loaded["patch"] = { - start = function() calls.start = true end, - accept = function() calls.accept = true end, - reject = function() calls.reject = true end, - retry = function() calls.retry = true end, - cancel = function() calls.cancel = true end, - open_menu = function() calls.open_menu = true end, - } - - dofile(vim.fn.getcwd() .. "/plugin/patch.lua") - end) - - after_each(function() - clear_commands() - package.loaded["patch"] = nil - end) - - it("registers the documented user commands", function() - local commands = vim.api.nvim_get_commands({}) - - for _, name in ipairs(command_names) do - assert.is_table(commands[name]) - assert.is_string(commands[name].definition) - end - end) - - it("routes commands to the plugin entrypoints", function() - vim.cmd.PatchCapture() - vim.cmd.PatchAccept() - vim.cmd.PatchReject() - vim.cmd.PatchRetry() - vim.cmd.PatchCancel() - vim.cmd.PatchMenu() - - assert.are.same({ - start = true, - accept = true, - reject = true, - retry = true, - cancel = true, - open_menu = true, - }, calls) - end) -end) diff --git a/tests/patch/prompt_spec.lua b/tests/patch/prompt_spec.lua deleted file mode 100644 index ba76e29..0000000 --- a/tests/patch/prompt_spec.lua +++ /dev/null @@ -1,43 +0,0 @@ -local prompt = require("patch.prompt") - -describe("patch.prompt", function() - it("formats captured context in order", function() - local result = prompt.format_context({ - before = { "local before = true", "" }, - selected = { "old()" }, - after = { "return before" }, - }) - - assert.are.equal(table.concat({ - "--- BEFORE ---", - "local before = true", - "", - "--- SELECTED ---", - "old()", - "--- AFTER ---", - "return before", - }, "\n"), result) - end) - - it("marks empty context groups", function() - assert.are.equal(table.concat({ - "--- BEFORE ---", - "(empty)", - "--- SELECTED ---", - "(empty)", - "--- AFTER ---", - "(empty)", - }, "\n"), prompt.format_context({ before = {}, selected = {}, after = {} })) - end) - - it("appends a multiline instruction", function() - local message = prompt.build({ - before = {}, - selected = { "value" }, - after = {}, - }, "Rename this.\nKeep its type.") - - assert.is_truthy(message:find("--- SELECTED ---\nvalue", 1, true)) - assert.is_truthy(message:find("--- INSTRUCTION ---\nRename this.\nKeep its type.", 1, true)) - end) -end) diff --git a/tests/patch/replacement_spec.lua b/tests/patch/replacement_spec.lua deleted file mode 100644 index 29bad38..0000000 --- a/tests/patch/replacement_spec.lua +++ /dev/null @@ -1,113 +0,0 @@ -local helpers = require("tests.helpers") -local replacement = require("patch.replacement") -local selection = require("patch.selection") - -describe("patch.replacement", function() - local buffer - local location - - before_each(function() - buffer = helpers.new_buffer({ "before", "old one", "old two", "after" }) - helpers.set_visual_lines(buffer, 2, 3) - location = assert(selection.capture()) - end) - - after_each(function() - helpers.delete_buffer(buffer) - end) - - local function lines() - return vim.api.nvim_buf_get_lines(buffer, 0, -1, false) - end - - it("previews generated lines without changing buffer text", function() - local proposal = assert(replacement.apply(location, "new one\nnew two\n")) - - assert.are.same({ "before", "old one", "old two", "after" }, lines()) - assert.are.same({ "new one", "new two" }, proposal.generated_lines) - assert.are.equal("pending", proposal.status) - assert.is_number(proposal.generated_mark) - end) - - it("removes a CRLF terminal newline", function() - local proposal = assert(replacement.apply(location, "new one\r\n")) - - assert.are.same({ "new one" }, proposal.generated_lines) - end) - - it("accepts a proposal and replaces the original lines", function() - local proposal = assert(replacement.apply(location, "new one\nnew two")) - - assert.is_true(replacement.accept(proposal)) - assert.are.same({ "before", "new one", "new two", "after" }, lines()) - assert.are.equal("finished", proposal.status) - assert.is_nil(selection.resolve(location)) - end) - - it("supports an empty response as a deletion", function() - local proposal = assert(replacement.apply(location, "")) - - assert.are.same({}, proposal.generated_lines) - assert.is_true(replacement.accept(proposal)) - assert.are.same({ "before", "after" }, lines()) - end) - - it("leaves the buffer unchanged when rejected", function() - local proposal = assert(replacement.apply(location, "replacement")) - - assert.is_true(replacement.reject(proposal)) - assert.are.same({ "before", "old one", "old two", "after" }, lines()) - assert.are.equal("finished", proposal.status) - end) - - it("undoes an accepted proposal as one buffer change", function() - local proposal = assert(replacement.apply(location, "replacement")) - assert.is_true(replacement.accept(proposal)) - - vim.cmd.undo() - - assert.are.same({ "before", "old one", "old two", "after" }, lines()) - end) - - it("hides the old preview and completes a retry", function() - local proposal = assert(replacement.apply(location, "first")) - - assert.is_true(replacement.begin_retry(proposal)) - assert.are.equal("retrying", proposal.status) - assert.are.same({}, proposal.generated_lines) - assert.is_nil(proposal.generated_mark) - - assert.are.equal(proposal, replacement.complete_retry(proposal, "second\nthird")) - assert.are.equal("pending", proposal.status) - assert.are.same({ "second", "third" }, proposal.generated_lines) - end) - - it("finalises an aborted retry", function() - local proposal = assert(replacement.apply(location, "first")) - assert.is_true(replacement.begin_retry(proposal)) - - assert.is_true(replacement.abort_retry(proposal)) - assert.are.equal("finished", proposal.status) - assert.is_nil(selection.resolve(location)) - end) - - it("rejects lifecycle operations in the wrong state", function() - local proposal = assert(replacement.apply(location, "first")) - assert.is_true(replacement.reject(proposal)) - - assert.is_false(replacement.accept(proposal)) - assert.is_false(replacement.reject(proposal)) - assert.is_false(replacement.begin_retry(proposal)) - assert.is_false(replacement.abort_retry(proposal)) - assert.is_nil(replacement.complete_retry(proposal, "second")) - end) - - it("fails safely after the source buffer is unloaded", function() - local proposal = assert(replacement.apply(location, "replacement")) - vim.api.nvim_buf_delete(buffer, { force = true }) - buffer = nil - - assert.is_false(replacement.accept(proposal)) - assert.are.equal("finished", proposal.status) - end) -end) diff --git a/tests/patch/rpc_spec.lua b/tests/patch/rpc_spec.lua deleted file mode 100644 index 2ed07ec..0000000 --- a/tests/patch/rpc_spec.lua +++ /dev/null @@ -1,252 +0,0 @@ -describe("patch.rpc", function() - local rpc - local original_system - - before_each(function() - package.loaded["patch.rpc"] = nil - rpc = require("patch.rpc") - original_system = vim.system - end) - - after_each(function() - vim.system = original_system - end) - - local function fake_system() - local invocation = {} - local process = { writes = {} } - - function process:write(value) - table.insert(self.writes, value == nil and vim.NIL or value) - end - - vim.system = function(command, options, on_exit) - invocation.command = command - invocation.options = options - invocation.on_exit = on_exit - invocation.process = process - return process - end - - return invocation - end - - it("starts Pi in RPC mode and sends JSONL records", function() - local invocation = fake_system() - local connection = assert(rpc.start({ "--no-session" }, { - on_record = function() end, - on_error = function() end, - on_exit = function() end, - })) - - assert.are.same({ - "pi", - "--mode", "rpc", - "--no-context-files", - "--no-skills", - "--no-extensions", - "--no-prompt-templates", - "--no-themes", - "--no-tools", - "--no-approve", - "--append-system-prompt", "", - "--no-session", - }, invocation.command) - assert.is_true(invocation.options.stdin) - assert.is_function(invocation.options.stdout) - assert.is_true(connection:send({ type = "prompt", message = "hello" })) - - local sent = vim.json.decode(invocation.process.writes[1]) - assert.are.same({ type = "prompt", message = "hello" }, sent) - end) - - it("decodes records split across stdout chunks", function() - local invocation = fake_system() - local records = {} - assert(rpc.start({}, { - on_record = function(record) - table.insert(records, record) - end, - on_error = function() end, - on_exit = function() end, - })) - - invocation.options.stdout(nil, '{"type":"fir') - invocation.options.stdout(nil, 'st"}\n{"type":"second"}\n') - - assert.are.same({ { type = "first" }, { type = "second" } }, records) - end) - - it("decodes a final record without a newline", function() - local invocation = fake_system() - local records = {} - assert(rpc.start({}, { - on_record = function(record) - table.insert(records, record) - end, - on_error = function() end, - on_exit = function() end, - })) - - invocation.options.stdout(nil, '{"type":"final"}') - invocation.options.stdout(nil, nil) - - assert.are.same({ { type = "final" } }, records) - end) - - it("reports malformed JSON once and closes the connection", function() - local invocation = fake_system() - local errors = {} - local connection = assert(rpc.start({}, { - on_record = function() end, - on_error = function(err) - table.insert(errors, err) - end, - on_exit = function() end, - })) - - invocation.options.stdout(nil, "not-json\n") - invocation.options.stdout(nil, "still-not-json\n") - - assert.are.equal(1, #errors) - assert.is_truthy(errors[1]:find("Failed to decode Pi output:", 1, true)) - assert.is_true(connection.closed) - end) - - it("reports stdout errors", function() - local invocation = fake_system() - local received - assert(rpc.start({}, { - on_record = function() end, - on_error = function(err) - received = err - end, - on_exit = function() end, - })) - - invocation.options.stdout("broken pipe", nil) - - assert.are.equal("Failed to read Pi output: broken pipe", received) - end) - - it("closes idempotently and rejects later writes", function() - local invocation = fake_system() - local connection = assert(rpc.start({}, { - on_record = function() end, - on_error = function() end, - on_exit = function() end, - })) - - connection:close() - connection:close() - local sent, err = connection:send({ type = "prompt" }) - - assert.is_false(sent) - assert.are.equal("Pi connection is closed", err) - assert.are.equal(1, #invocation.process.writes) - assert.are.equal(vim.NIL, invocation.process.writes[1]) - end) - - it("reports process start failures", function() - vim.system = function() - error("not executable") - end - - local connection, err = rpc.start({}, { - on_record = function() end, - on_error = function() end, - on_exit = function() end, - }) - - assert.is_nil(connection) - assert.is_truthy(err:find("Failed to start Pi:", 1, true)) - assert.is_truthy(err:find("not executable", 1, true)) - end) - - it("formats process errors with trimmed stderr", function() - assert.are.equal("Pi exited with code 3: failure", rpc.format_exit_error({ code = 3, stderr = " failure\n" })) - assert.are.equal("Pi exited with code 2", rpc.format_exit_error({ code = 2, stderr = "" })) - end) - - describe("one-shot requests", function() - local handlers - local connection - local sent - - before_each(function() - connection = { closed = false } - function connection:send(record) - sent = record - return true - end - function connection:close() - self.closed = true - end - - rpc.start = function(args, supplied_handlers) - assert.are.same({ "--no-session" }, args) - handlers = supplied_handlers - return connection - end - end) - - it("matches the command and request id", function() - local result - rpc.request({ type = "get_state" }, function(record, err) - result = { record, err } - end) - - assert.are.same({ type = "get_state", id = "get_state" }, sent) - handlers.on_record({ type = "event" }) - assert.is_nil(result) - handlers.on_record({ type = "response", command = "get_state", id = "other", success = true }) - assert.is_nil(result) - - local record = { type = "response", command = "get_state", id = "get_state", success = true } - handlers.on_record(record) - assert.are.equal(record, result[1]) - assert.is_nil(result[2]) - assert.is_true(connection.closed) - end) - - it("reports a rejected command", function() - local received - rpc.request({ type = "get_state" }, function(_, err) - received = err - end) - - handlers.on_record({ - type = "response", - command = "get_state", - id = "get_state", - success = false, - error = "denied", - }) - - assert.are.equal("Pi rejected get_state: denied", received) - end) - - it("reports an early process exit", function() - local received - rpc.request({ type = "get_state" }, function(_, err) - received = err - end) - - handlers.on_exit({ code = 0, stderr = "" }) - - assert.are.equal("Pi exited before returning get_state", received) - end) - - it("completes only once", function() - local completions = 0 - rpc.request({ type = "get_state" }, function() - completions = completions + 1 - end) - - handlers.on_error("failed") - handlers.on_exit({ code = 1, stderr = "later" }) - - assert.are.equal(1, completions) - end) - end) -end) diff --git a/tests/patch/selection_spec.lua b/tests/patch/selection_spec.lua deleted file mode 100644 index 6501764..0000000 --- a/tests/patch/selection_spec.lua +++ /dev/null @@ -1,77 +0,0 @@ -local helpers = require("tests.helpers") -local selection = require("patch.selection") - -describe("patch.selection", function() - local buffer - - before_each(function() - buffer = helpers.new_buffer({ "one", "two", "three", "four" }) - end) - - after_each(function() - helpers.delete_buffer(buffer) - end) - - it("captures and resolves a visual-line selection", function() - helpers.set_visual_lines(buffer, 2, 3) - - local location = assert(selection.capture()) - local range = assert(selection.resolve(location)) - - assert.are.same({ source_buf = buffer, start_row = 1, end_row = 3 }, range) - end) - - it("normalises reversed visual marks", function() - helpers.set_visual_lines(buffer, 4, 2) - - local range = assert(selection.resolve(assert(selection.capture()))) - - assert.are.same({ source_buf = buffer, start_row = 1, end_row = 4 }, range) - end) - - it("returns nil when no visual selection has been recorded", function() - vim.fn.setpos("'<", { buffer, 0, 0, 0 }) - vim.fn.setpos("'>", { buffer, 0, 0, 0 }) - - assert.is_nil(selection.capture()) - end) - - it("tracks the selected lines when text is inserted before them", function() - helpers.set_visual_lines(buffer, 2, 3) - local location = assert(selection.capture()) - - vim.api.nvim_buf_set_lines(buffer, 0, 0, false, { "zero" }) - - assert.are.same({ source_buf = buffer, start_row = 2, end_row = 4 }, selection.resolve(location)) - end) - - it("decorates without changing the tracked range", function() - helpers.set_visual_lines(buffer, 2, 3) - local location = assert(selection.capture()) - local original = selection.resolve(location) - - assert.is_true(selection.decorate(location, { hl_group = "DiffDelete", hl_eol = true })) - assert.are.same(original, selection.resolve(location)) - end) - - it("clears the tracked selection", function() - helpers.set_visual_lines(buffer, 2, 3) - local location = assert(selection.capture()) - - selection.clear(location) - - assert.is_nil(selection.resolve(location)) - end) - - it("does not resolve a selection in an unloaded buffer", function() - helpers.set_visual_lines(buffer, 2, 3) - local location = assert(selection.capture()) - vim.api.nvim_buf_delete(buffer, { force = true }) - buffer = nil - - assert.is_nil(selection.resolve(location)) - assert.has_no.errors(function() - selection.clear(location) - end) - end) -end) diff --git a/tests/patch/test_client.lua b/tests/patch/test_client.lua new file mode 100644 index 0000000..236222e --- /dev/null +++ b/tests/patch/test_client.lua @@ -0,0 +1,303 @@ +local helpers = require("tests.helpers") +local eq = MiniTest.expect.equality + +local function is_nil(value) + eq(value, nil) +end + +local function is_true(value) + eq(value, true) +end + +local function is_false(value) + eq(value, false) +end + +local client +local rpc +local connection +local handlers +local start_args +local sent + +local function reset() + sent = {} + connection = { closed = false } + function connection:send(record) + table.insert(sent, record) + return true + end + function connection:close() + self.closed = true + end + + rpc = { + start = function(args, supplied_handlers) + start_args = args + handlers = supplied_handlers + return connection + end, + request = function() end, + format_exit_error = function(result) + return "exit " .. result.code + end, + } + + package.loaded["patch.rpc"] = rpc + package.loaded["patch.client"] = nil + client = require("patch.client") + client.setup() +end + +local function cleanup() + package.loaded["patch.client"] = nil + package.loaded["patch.rpc"] = nil +end + +local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) + +local function test(name, action) + T[name] = action +end + +local function wait_for_result(result) + helpers.wait_for(function() + return result.called + end) +end + +local function record(result, values) + result.called = true + for key, value in pairs(values) do + result[key] = value + end +end + +local function assistant_message(text, overrides) + return vim.tbl_extend("force", { + role = "assistant", + content = { { type = "text", text = text } }, + stopReason = "stop", + }, overrides or {}) +end + +test("sends a prompt and completes only after the agent settles", function() + local result = {} + local request = client.request("replace this", function(value, err) + record(result, { value = value, err = err }) + end) + + eq(request.state, "pending") + eq(sent[1], { type = "prompt", message = "replace this" }) + eq(start_args, { "--system-prompt", start_args[2], "--no-session", "--thinking", "off" }) + + handlers.on_record({ type = "message_end", message = assistant_message("replacement") }) + vim.wait(20) + is_nil(result.called) + + handlers.on_record({ type = "agent_settled" }) + wait_for_result(result) + + eq(result.value, "replacement") + is_nil(result.err) + eq(request.state, "completed") + is_true(connection.closed) +end) + +test("uses the last assistant message and joins text parts", function() + local result = {} + client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + handlers.on_record({ type = "message_end", message = assistant_message("discarded") }) + handlers.on_record({ + type = "message_end", + message = assistant_message("ignored", { + content = { + { type = "text", text = "first" }, + { type = "toolCall", name = "tool" }, + { type = "text", text = "second" }, + }, + }), + }) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(result) + + eq(result.value, "first\nsecond") + is_nil(result.err) +end) + +test("passes configured system prompt and model arguments", function() + client.setup({ system_prompt = "custom prompt", model = " openai/model " }) + client.request("prompt", function() end) + + eq(start_args, { + "--system-prompt", "custom prompt", "--no-session", "--thinking", "off", + "--model", "openai/model", + }) +end) + +test("treats empty assistant output as a deletion", function() + local result = {} + client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + handlers.on_record({ type = "message_end", message = assistant_message("", { content = {} }) }) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(result) + + eq(result.value, "") + is_nil(result.err) +end) + +test("cancels a pending request once", function() + local result = {} + local request = client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + is_true(client.cancel(request)) + is_false(client.cancel(request)) + wait_for_result(result) + + eq(request.state, "cancelled") + eq(sent[2], { type = "abort" }) + is_nil(result.value) + eq(result.err, "cancelled") + + handlers.on_record({ type = "agent_settled" }) + is_true(connection.closed) +end) + +test("reports prompt rejection", function() + local result = {} + client.request("prompt", function(value, err) + record(result, { value = value, err = err }) + end) + + handlers.on_record({ type = "response", command = "prompt", success = false, error = "denied" }) + wait_for_result(result) + + is_nil(result.value) + eq(result.err, "Pi rejected the prompt: denied") + is_true(connection.closed) +end) + +test("reports assistant and missing-response failures", function() + local error_result = {} + client.request("prompt", function(_, err) + record(error_result, { err = err }) + end) + handlers.on_record({ + type = "message_end", + message = assistant_message("", { stopReason = "error", errorMessage = "model failed" }), + }) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(error_result) + eq(error_result.err, "model failed") + + client.setup() + connection.closed = false + local missing_result = {} + client.request("prompt", function(_, err) + record(missing_result, { err = err }) + end) + handlers.on_record({ type = "agent_settled" }) + wait_for_result(missing_result) + eq(missing_result.err, "Pi returned no response") +end) + +test("reports process start and exit failures", function() + rpc.start = function() + return nil, "cannot start" + end + local start_result = {} + client.request("prompt", function(_, err) + record(start_result, { err = err }) + end) + wait_for_result(start_result) + eq(start_result.err, "cannot start") + + rpc.start = function(args, supplied_handlers) + handlers = supplied_handlers + return connection + end + local exit_result = {} + client.request("prompt", function(_, err) + record(exit_result, { err = err }) + end) + handlers.on_exit({ code = 7 }) + wait_for_result(exit_result) + eq(exit_result.err, "exit 7") +end) + +test("retrieves and validates available models", function() + local callback + rpc.request = function(command, on_complete) + eq(command, { type = "get_available_models" }) + callback = on_complete + end + + local result = {} + client.get_available_models(function(models, err) + record(result, { models = models, err = err }) + end) + local models = { { provider = "openai", id = "model", name = "Model" } } + callback({ data = { models = models } }, nil) + wait_for_result(result) + eq(result.models, models) + + result = {} + client.get_available_models(function(value, err) + record(result, { value = value, err = err }) + end) + callback({ data = {} }, nil) + wait_for_result(result) + is_nil(result.value) + eq(result.err, "Pi returned an invalid model list") +end) + +test("resolves configured, session, and cached primary models", function() + local result = {} + client.setup({ model = "configured/model" }) + client.resolve_model(function(value, err) + record(result, { value = value, err = err }) + end) + wait_for_result(result) + eq(result.value, "configured/model") + + eq(client.select_model({ provider = "provider", id = "session" }), "provider/session") + result = {} + client.resolve_model(function(value) + record(result, { value = value }) + end) + wait_for_result(result) + eq(result.value, "provider/session") + + local requests = 0 + local state_callback + rpc.request = function(command, on_complete) + requests = requests + 1 + eq(command, { type = "get_state" }) + state_callback = on_complete + end + client.setup() + result = {} + client.resolve_model(function(value, err) + record(result, { value = value, err = err }) + end) + state_callback({ data = { model = { provider = "primary", id = "model" } } }, nil) + wait_for_result(result) + eq(result.value, "primary/model") + + result = {} + client.resolve_model(function(value) + record(result, { value = value }) + end) + wait_for_result(result) + eq(result.value, "primary/model") + eq(requests, 1) +end) +return T diff --git a/tests/patch/test_context.lua b/tests/patch/test_context.lua new file mode 100644 index 0000000..9abdd95 --- /dev/null +++ b/tests/patch/test_context.lua @@ -0,0 +1,54 @@ +local helpers = require("tests.helpers") +local context = require("patch.context") +local selection = require("patch.selection") +local eq = MiniTest.expect.equality + +local buffer +local T = MiniTest.new_set({ + hooks = { + post_case = function() + helpers.delete_buffer(buffer) + end, + }, +}) + +local function capture(start_line, end_line, lines) + buffer = helpers.new_buffer(lines) + helpers.set_visual_lines(buffer, start_line, end_line) + return context.capture(assert(selection.capture())) +end + +T["splits the buffer around the selection"] = function() + eq(capture(2, 3, { "one", "two", "three", "four" }), { + before = { "one" }, + selected = { "two", "three" }, + after = { "four" }, + }) +end + +T["captures a selection at the start of the buffer"] = function() + eq(capture(1, 1, { "one", "two" }), { + before = {}, + selected = { "one" }, + after = { "two" }, + }) +end + +T["captures a selection at the end of the buffer"] = function() + eq(capture(2, 2, { "one", "two" }), { + before = { "one" }, + selected = { "two" }, + after = {}, + }) +end + +T["returns nil for a missing tracked selection"] = function() + buffer = helpers.new_buffer({ "one" }) + helpers.set_visual_lines(buffer, 1, 1) + local location = assert(selection.capture()) + selection.clear(location) + + eq(context.capture(location), nil) +end + +return T diff --git a/tests/patch/test_init.lua b/tests/patch/test_init.lua new file mode 100644 index 0000000..df043bb --- /dev/null +++ b/tests/patch/test_init.lua @@ -0,0 +1,285 @@ +local eq = MiniTest.expect.equality +local patch +local mocks +local notifications +local input +local requests +local proposal +local location + +local function reset() + notifications = {} + requests = {} + proposal = { status = "pending" } + location = { source_buf = 1, extmark_id = 2 } + + mocks = { + prompt = { + build = function(_, instruction) + return "built:" .. instruction + end, + }, + selection = { + capture = function() + return location + end, + clear = function(value) + mocks.selection.cleared = value + end, + }, + context = { + capture = function(value) + eq(value, location) + return { before = {}, selected = { "old" }, after = {} } + end, + }, + client = { + setup = function(options) + mocks.client.setup_options = options + end, + request = function(message, on_complete) + local request = { message = message, on_complete = on_complete, state = "pending" } + table.insert(requests, request) + return request + end, + cancel = function(request) + request.cancelled = true + return true + end, + resolve_model = function(on_complete) + on_complete("provider/current", nil) + end, + get_available_models = function(on_complete) + on_complete({ { provider = "provider", id = "model", name = "Model" } }, nil) + end, + select_model = function(model) + return model.provider .. "/" .. model.id + end, + }, + replacement = { + apply = function(value, response) + mocks.replacement.applied = { value, response } + proposal.location = value + return proposal + end, + accept = function(value) + mocks.replacement.accepted = value + return true + end, + reject = function(value) + mocks.replacement.rejected = value + return true + end, + begin_retry = function(value) + mocks.replacement.retry_started = value + value.status = "retrying" + return true + end, + complete_retry = function(value, response) + mocks.replacement.retry_completed = { value, response } + value.status = "pending" + return value + end, + abort_retry = function(value) + mocks.replacement.retry_aborted = value + value.status = "finished" + return true + end, + }, + ui = { + open_input = function(on_submit, on_close) + input = { submit = on_submit, close = on_close } + end, + open_menu = function(models, selected, on_submit) + mocks.ui.menu = { models = models, selected = selected, submit = on_submit } + end, + }, + notify = { + setup = function(provider) + mocks.notify.provider = provider + end, + send = function(message, level) + table.insert(notifications, { message = message, level = level }) + end, + }, + } + + package.loaded["patch.prompt"] = mocks.prompt + package.loaded["patch.selection"] = mocks.selection + package.loaded["patch.context"] = mocks.context + package.loaded["patch.client"] = mocks.client + package.loaded["patch.replacement"] = mocks.replacement + package.loaded["patch.ui"] = mocks.ui + package.loaded["patch.notify"] = mocks.notify + package.loaded["patch"] = nil + patch = require("patch") +end + +local function cleanup() + for _, name in ipairs({ + "patch", "patch.prompt", "patch.selection", "patch.context", "patch.client", + "patch.replacement", "patch.ui", "patch.notify", + }) do + package.loaded[name] = nil +end +end + +local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) + +local function test(name, action) + T[name] = action +end + +local function notification(message, level) + for _, item in ipairs(notifications) do + if item.message == message and (not level or item.level == level) then + return item + end + end +end + +local function start_and_submit() + patch.start() + eq(type(input), "table") + input.submit("change it") + eq(#requests, 1) +end + +local function complete_generation(response, err) + requests[#requests].on_complete(response, err) +end + +test("passes configuration to notification and client modules", function() + local provider = function() end + patch.setup({ notify = provider, system_prompt = "system", model = "provider/model" }) + + eq(mocks.notify.provider, provider) + eq(mocks.client.setup_options, { system_prompt = "system", model = "provider/model" }) +end) + +test("runs capture, generation, review, and acceptance", function() + start_and_submit() + + eq(requests[1].message, "built:change it") + assert(notification("patch: generating...", vim.log.levels.INFO)) + + complete_generation("replacement", nil) + eq(mocks.replacement.applied, { location, "replacement" }) + assert(notification("patch: complete", vim.log.levels.INFO)) + + patch.accept() + eq(mocks.replacement.accepted, proposal) + + patch.accept() + assert(notification("patch: no active proposal to accept", vim.log.levels.WARN)) +end) + +test("rejects a reviewed proposal", function() + start_and_submit() + complete_generation("replacement", nil) + + patch.reject() + + eq(mocks.replacement.rejected, proposal) + patch.reject() + assert(notification("patch: no active proposal to reject", vim.log.levels.WARN)) +end) + +test("retries a proposal and applies the new response", function() + start_and_submit() + complete_generation("first", nil) + + patch.retry() + eq(mocks.replacement.retry_started, proposal) + eq(#requests, 2) + + complete_generation("second", nil) + eq(mocks.replacement.retry_completed, { proposal, "second" }) + assert(notification("patch: complete", vim.log.levels.INFO)) +end) + +test("aborts a failed retry and reports the error", function() + start_and_submit() + complete_generation("first", nil) + patch.retry() + + complete_generation(nil, "request failed") + + eq(mocks.replacement.retry_aborted, proposal) + assert(notification("patch: request failed", vim.log.levels.ERROR)) +end) + +test("cancels an active request and suppresses the cancellation error", function() + start_and_submit() + + patch.cancel() + eq(requests[1].cancelled, true) + assert(notification("patch: cancelled", vim.log.levels.INFO)) + + complete_generation(nil, "cancelled") + eq(mocks.selection.cleared, location) + eq(notification("patch: cancelled", vim.log.levels.ERROR), nil) +end) + +test("cleans up when input closes or generation fails", function() + patch.start() + input.close() + eq(mocks.selection.cleared, location) + + mocks.selection.cleared = nil + start_and_submit() + complete_generation(nil, "failed") + eq(mocks.selection.cleared, location) + assert(notification("patch: failed", vim.log.levels.ERROR)) +end) + +test("blocks overlapping workflows", function() + patch.start() + patch.start() + assert(notification("patch: submit or close the active instruction first", vim.log.levels.WARN)) + + input.submit("change") + patch.start() + assert(notification("patch: a replacement is already being generated", vim.log.levels.WARN)) + + complete_generation("replacement", nil) + patch.start() + assert(notification("patch: accept or reject the active proposal first", vim.log.levels.WARN)) +end) + +test("ignores stale completion callbacks", function() + start_and_submit() + local stale = requests[1].on_complete + patch.cancel() + stale(nil, "cancelled") + + patch.start() + input.submit("new change") + stale("stale response", nil) + + eq(mocks.replacement.applied, nil) + eq(#requests, 2) +end) + +test("opens the model menu and records a selection", function() + patch.open_menu() + + eq(mocks.ui.menu.selected, "provider/current") + eq(mocks.ui.menu.models[1].id, "model") + mocks.ui.menu.submit(mocks.ui.menu.models[1]) + + assert(notification("patch: using provider/model", vim.log.levels.INFO)) +end) + +test("warns when actions have no active workflow", function() + patch.accept() + patch.reject() + patch.retry() + patch.cancel() + + assert(notification("patch: no active proposal to accept", vim.log.levels.WARN)) + assert(notification("patch: no active proposal to reject", vim.log.levels.WARN)) + assert(notification("patch: no active proposal to retry", vim.log.levels.WARN)) + assert(notification("patch: nothing to cancel", vim.log.levels.WARN)) +end) +return T diff --git a/tests/patch/test_notify.lua b/tests/patch/test_notify.lua new file mode 100644 index 0000000..d1717fb --- /dev/null +++ b/tests/patch/test_notify.lua @@ -0,0 +1,45 @@ +local eq = MiniTest.expect.equality +local notify +local original_notify + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + package.loaded["patch.notify"] = nil + notify = require("patch.notify") + original_notify = vim.notify + end, + post_case = function() + vim.notify = original_notify + notify.setup(nil) + end, + }, +}) + +T["uses vim.notify with the default title"] = function() + local received + vim.notify = function(message, level, options) + received = { message, level, options } + end + + notify.send("message", vim.log.levels.INFO) + + eq(received[1], "message") + eq(received[2], vim.log.levels.INFO) + eq(received[3], { title = "patch.nvim" }) +end + +T["uses a configured callable provider and preserves options"] = function() + local received + notify.setup(function(message, level, options) + received = { message, level, options } + end) + + notify.send("warning", vim.log.levels.WARN, { title = "custom", timeout = 10 }) + + eq(received[1], "warning") + eq(received[2], vim.log.levels.WARN) + eq(received[3], { title = "custom", timeout = 10 }) +end + +return T diff --git a/tests/patch/test_plugin.lua b/tests/patch/test_plugin.lua new file mode 100644 index 0000000..a466843 --- /dev/null +++ b/tests/patch/test_plugin.lua @@ -0,0 +1,63 @@ +local eq = MiniTest.expect.equality +local calls +local command_names = { + "PatchCapture", "PatchAccept", "PatchReject", "PatchRetry", "PatchCancel", "PatchMenu", +} + +local function clear_commands() + for _, name in ipairs(command_names) do + pcall(vim.api.nvim_del_user_command, name) + end +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + clear_commands() + calls = {} + package.loaded["patch"] = { + start = function() calls.start = true end, + accept = function() calls.accept = true end, + reject = function() calls.reject = true end, + retry = function() calls.retry = true end, + cancel = function() calls.cancel = true end, + open_menu = function() calls.open_menu = true end, + } + + dofile(vim.fn.getcwd() .. "/plugin/patch.lua") + end, + post_case = function() + clear_commands() + package.loaded["patch"] = nil + end, + }, +}) + +T["registers the documented user commands"] = function() + local commands = vim.api.nvim_get_commands({}) + + for _, name in ipairs(command_names) do + eq(type(commands[name]), "table") + eq(type(commands[name].definition), "string") + end +end + +T["routes commands to the plugin entrypoints"] = function() + vim.cmd.PatchCapture() + vim.cmd.PatchAccept() + vim.cmd.PatchReject() + vim.cmd.PatchRetry() + vim.cmd.PatchCancel() + vim.cmd.PatchMenu() + + eq(calls, { + start = true, + accept = true, + reject = true, + retry = true, + cancel = true, + open_menu = true, + }) +end + +return T diff --git a/tests/patch/test_prompt.lua b/tests/patch/test_prompt.lua new file mode 100644 index 0000000..25840fc --- /dev/null +++ b/tests/patch/test_prompt.lua @@ -0,0 +1,46 @@ +local prompt = require("patch.prompt") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set() + +T["formats captured context in order"] = function() + local result = prompt.format_context({ + before = { "local before = true", "" }, + selected = { "old()" }, + after = { "return before" }, + }) + + eq(result, table.concat({ + "--- BEFORE ---", + "local before = true", + "", + "--- SELECTED ---", + "old()", + "--- AFTER ---", + "return before", + }, "\n")) +end + +T["marks empty context groups"] = function() + eq(prompt.format_context({ before = {}, selected = {}, after = {} }), table.concat({ + "--- BEFORE ---", + "(empty)", + "--- SELECTED ---", + "(empty)", + "--- AFTER ---", + "(empty)", + }, "\n")) +end + +T["appends a multiline instruction"] = function() + local message = prompt.build({ + before = {}, + selected = { "value" }, + after = {}, + }, "Rename this.\nKeep its type.") + + assert(message:find("--- SELECTED ---\nvalue", 1, true)) + assert(message:find("--- INSTRUCTION ---\nRename this.\nKeep its type.", 1, true)) +end + +return T diff --git a/tests/patch/test_replacement.lua b/tests/patch/test_replacement.lua new file mode 100644 index 0000000..2bfd334 --- /dev/null +++ b/tests/patch/test_replacement.lua @@ -0,0 +1,116 @@ +local helpers = require("tests.helpers") +local replacement = require("patch.replacement") +local selection = require("patch.selection") +local eq = MiniTest.expect.equality + +local buffer +local location +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + buffer = helpers.new_buffer({ "before", "old one", "old two", "after" }) + helpers.set_visual_lines(buffer, 2, 3) + location = assert(selection.capture()) + end, + post_case = function() + helpers.delete_buffer(buffer) + end, + }, +}) + +local function lines() + return vim.api.nvim_buf_get_lines(buffer, 0, -1, false) +end + +T["previews generated lines without changing buffer text"] = function() + local proposal = assert(replacement.apply(location, "new one\nnew two\n")) + + eq(lines(), { "before", "old one", "old two", "after" }) + eq(proposal.generated_lines, { "new one", "new two" }) + eq(proposal.status, "pending") + eq(type(proposal.generated_mark), "number") +end + +T["removes a CRLF terminal newline"] = function() + local proposal = assert(replacement.apply(location, "new one\r\n")) + + eq(proposal.generated_lines, { "new one" }) +end + +T["accepts a proposal and replaces the original lines"] = function() + local proposal = assert(replacement.apply(location, "new one\nnew two")) + + eq(replacement.accept(proposal), true) + eq(lines(), { "before", "new one", "new two", "after" }) + eq(proposal.status, "finished") + eq(selection.resolve(location), nil) +end + +T["supports an empty response as a deletion"] = function() + local proposal = assert(replacement.apply(location, "")) + + eq(proposal.generated_lines, {}) + eq(replacement.accept(proposal), true) + eq(lines(), { "before", "after" }) +end + +T["leaves the buffer unchanged when rejected"] = function() + local proposal = assert(replacement.apply(location, "replacement")) + + eq(replacement.reject(proposal), true) + eq(lines(), { "before", "old one", "old two", "after" }) + eq(proposal.status, "finished") +end + +T["undoes an accepted proposal as one buffer change"] = function() + local proposal = assert(replacement.apply(location, "replacement")) + eq(replacement.accept(proposal), true) + + vim.cmd.undo() + + eq(lines(), { "before", "old one", "old two", "after" }) +end + +T["hides the old preview and completes a retry"] = function() + local proposal = assert(replacement.apply(location, "first")) + + eq(replacement.begin_retry(proposal), true) + eq(proposal.status, "retrying") + eq(proposal.generated_lines, {}) + eq(proposal.generated_mark, nil) + + eq(replacement.complete_retry(proposal, "second\nthird"), proposal) + eq(proposal.status, "pending") + eq(proposal.generated_lines, { "second", "third" }) +end + +T["finalises an aborted retry"] = function() + local proposal = assert(replacement.apply(location, "first")) + eq(replacement.begin_retry(proposal), true) + + eq(replacement.abort_retry(proposal), true) + eq(proposal.status, "finished") + eq(selection.resolve(location), nil) +end + +T["rejects lifecycle operations in the wrong state"] = function() + local proposal = assert(replacement.apply(location, "first")) + eq(replacement.reject(proposal), true) + + eq(replacement.accept(proposal), false) + eq(replacement.reject(proposal), false) + eq(replacement.begin_retry(proposal), false) + eq(replacement.abort_retry(proposal), false) + eq(replacement.complete_retry(proposal, "second"), nil) +end + +T["fails safely after the source buffer is unloaded"] = function() + local proposal = assert(replacement.apply(location, "replacement")) + vim.api.nvim_buf_delete(buffer, { force = true }) + buffer = nil + + eq(replacement.accept(proposal), false) + eq(proposal.status, "finished") +end + +return T diff --git a/tests/patch/test_rpc.lua b/tests/patch/test_rpc.lua new file mode 100644 index 0000000..40e1dac --- /dev/null +++ b/tests/patch/test_rpc.lua @@ -0,0 +1,262 @@ +local eq = MiniTest.expect.equality +local rpc +local original_system + +local function reset() + package.loaded["patch.rpc"] = nil + rpc = require("patch.rpc") + original_system = vim.system +end + +local function cleanup() + vim.system = original_system +end + +local T = MiniTest.new_set({ hooks = { pre_case = reset, post_case = cleanup } }) + +local function test(name, action) + T[name] = action +end + +local function fake_system() + local invocation = {} + local process = { writes = {} } + + function process:write(value) + table.insert(self.writes, value == nil and vim.NIL or value) + end + + vim.system = function(command, options, on_exit) + invocation.command = command + invocation.options = options + invocation.on_exit = on_exit + invocation.process = process + return process + end + + return invocation +end + +test("starts Pi in RPC mode and sends JSONL records", function() + local invocation = fake_system() + local connection = assert(rpc.start({ "--no-session" }, { + on_record = function() end, + on_error = function() end, + on_exit = function() end, + })) + + eq(invocation.command, { + "pi", + "--mode", "rpc", + "--no-context-files", + "--no-skills", + "--no-extensions", + "--no-prompt-templates", + "--no-themes", + "--no-tools", + "--no-approve", + "--append-system-prompt", "", + "--no-session", + }) + eq(invocation.options.stdin, true) + eq(type(invocation.options.stdout), "function") + eq(connection:send({ type = "prompt", message = "hello" }), true) + + local sent = vim.json.decode(invocation.process.writes[1]) + eq(sent, { type = "prompt", message = "hello" }) +end) + +test("decodes records split across stdout chunks", function() + local invocation = fake_system() + local records = {} + assert(rpc.start({}, { + on_record = function(record) + table.insert(records, record) + end, + on_error = function() end, + on_exit = function() end, + })) + + invocation.options.stdout(nil, '{"type":"fir') + invocation.options.stdout(nil, 'st"}\n{"type":"second"}\n') + + eq(records, { { type = "first" }, { type = "second" } }) +end) + +test("decodes a final record without a newline", function() + local invocation = fake_system() + local records = {} + assert(rpc.start({}, { + on_record = function(record) + table.insert(records, record) + end, + on_error = function() end, + on_exit = function() end, + })) + + invocation.options.stdout(nil, '{"type":"final"}') + invocation.options.stdout(nil, nil) + + eq(records, { { type = "final" } }) +end) + +test("reports malformed JSON once and closes the connection", function() + local invocation = fake_system() + local errors = {} + local connection = assert(rpc.start({}, { + on_record = function() end, + on_error = function(err) + table.insert(errors, err) + end, + on_exit = function() end, + })) + + invocation.options.stdout(nil, "not-json\n") + invocation.options.stdout(nil, "still-not-json\n") + + eq(#errors, 1) + assert(errors[1]:find("Failed to decode Pi output:", 1, true)) + eq(connection.closed, true) +end) + +test("reports stdout errors", function() + local invocation = fake_system() + local received + assert(rpc.start({}, { + on_record = function() end, + on_error = function(err) + received = err + end, + on_exit = function() end, + })) + + invocation.options.stdout("broken pipe", nil) + + eq(received, "Failed to read Pi output: broken pipe") +end) + +test("closes idempotently and rejects later writes", function() + local invocation = fake_system() + local connection = assert(rpc.start({}, { + on_record = function() end, + on_error = function() end, + on_exit = function() end, + })) + + connection:close() + connection:close() + local sent, err = connection:send({ type = "prompt" }) + + eq(sent, false) + eq(err, "Pi connection is closed") + eq(#invocation.process.writes, 1) + eq(invocation.process.writes[1], vim.NIL) +end) + +test("reports process start failures", function() + vim.system = function() + error("not executable") + end + + local connection, err = rpc.start({}, { + on_record = function() end, + on_error = function() end, + on_exit = function() end, + }) + + eq(connection, nil) + assert(err:find("Failed to start Pi:", 1, true)) + assert(err:find("not executable", 1, true)) +end) + +test("formats process errors with trimmed stderr", function() + eq(rpc.format_exit_error({ code = 3, stderr = " failure\n" }), "Pi exited with code 3: failure") + eq(rpc.format_exit_error({ code = 2, stderr = "" }), "Pi exited with code 2") +end) + +local handlers +local connection +local sent + +local function reset_one_shot() + connection = { closed = false } + function connection:send(record) + sent = record + return true + end + function connection:close() + self.closed = true + end + + rpc.start = function(args, supplied_handlers) + eq(args, { "--no-session" }) + handlers = supplied_handlers + return connection + end +end + +local one_shot = MiniTest.new_set({ hooks = { pre_case = reset_one_shot } }) +T["one-shot requests"] = one_shot +local function one_shot_test(name, action) + one_shot[name] = action +end + +one_shot_test("matches the command and request id", function() + local result + rpc.request({ type = "get_state" }, function(record, err) + result = { record, err } + end) + + eq(sent, { type = "get_state", id = "get_state" }) + handlers.on_record({ type = "event" }) + eq(result, nil) + handlers.on_record({ type = "response", command = "get_state", id = "other", success = true }) + eq(result, nil) + + local record = { type = "response", command = "get_state", id = "get_state", success = true } + handlers.on_record(record) + eq(result[1], record) + eq(result[2], nil) + eq(connection.closed, true) +end) + +one_shot_test("reports a rejected command", function() + local received + rpc.request({ type = "get_state" }, function(_, err) + received = err + end) + + handlers.on_record({ + type = "response", + command = "get_state", + id = "get_state", + success = false, + error = "denied", + }) + + eq(received, "Pi rejected get_state: denied") +end) + +one_shot_test("reports an early process exit", function() + local received + rpc.request({ type = "get_state" }, function(_, err) + received = err + end) + + handlers.on_exit({ code = 0, stderr = "" }) + + eq(received, "Pi exited before returning get_state") +end) + +one_shot_test("completes only once", function() + local completions = 0 + rpc.request({ type = "get_state" }, function() + completions = completions + 1 + end) + + handlers.on_error("failed") + handlers.on_exit({ code = 1, stderr = "later" }) + + eq(completions, 1) +end) +return T diff --git a/tests/patch/test_selection.lua b/tests/patch/test_selection.lua new file mode 100644 index 0000000..803ae04 --- /dev/null +++ b/tests/patch/test_selection.lua @@ -0,0 +1,80 @@ +local helpers = require("tests.helpers") +local selection = require("patch.selection") +local eq = MiniTest.expect.equality + +local buffer +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + buffer = helpers.new_buffer({ "one", "two", "three", "four" }) + end, + post_case = function() + helpers.delete_buffer(buffer) + end, + }, +}) + +T["captures and resolves a visual-line selection"] = function() + helpers.set_visual_lines(buffer, 2, 3) + + local location = assert(selection.capture()) + local range = assert(selection.resolve(location)) + + eq(range, { source_buf = buffer, start_row = 1, end_row = 3 }) +end + +T["normalises reversed visual marks"] = function() + helpers.set_visual_lines(buffer, 4, 2) + + local range = assert(selection.resolve(assert(selection.capture()))) + + eq(range, { source_buf = buffer, start_row = 1, end_row = 4 }) +end + +T["returns nil when no visual selection has been recorded"] = function() + vim.fn.setpos("'<", { buffer, 0, 0, 0 }) + vim.fn.setpos("'>", { buffer, 0, 0, 0 }) + + eq(selection.capture(), nil) +end + +T["tracks the selected lines when text is inserted before them"] = function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + + vim.api.nvim_buf_set_lines(buffer, 0, 0, false, { "zero" }) + + eq(selection.resolve(location), { source_buf = buffer, start_row = 2, end_row = 4 }) +end + +T["decorates without changing the tracked range"] = function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + local original = selection.resolve(location) + + eq(selection.decorate(location, { hl_group = "DiffDelete", hl_eol = true }), true) + eq(selection.resolve(location), original) +end + +T["clears the tracked selection"] = function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + + selection.clear(location) + + eq(selection.resolve(location), nil) +end + +T["does not resolve a selection in an unloaded buffer"] = function() + helpers.set_visual_lines(buffer, 2, 3) + local location = assert(selection.capture()) + vim.api.nvim_buf_delete(buffer, { force = true }) + buffer = nil + + eq(selection.resolve(location), nil) + MiniTest.expect.no_error(function() + selection.clear(location) + end) +end + +return T From cf9f3db6483d81e30e677310c68f47aad0c05e7b Mon Sep 17 00:00:00 2001 From: timothyckl Date: Thu, 20 Aug 2026 21:54:43 +0800 Subject: [PATCH 39/39] docs: clarify installation and repository links --- CONTRIBUTING.md | 2 +- README.md | 63 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ec48e3..dda95e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,4 +14,4 @@ Tests run automatically on pull requests targeting `main`. ## Issues and Questions -For bug reports, issues, or questions, please [open an issue](https://github.com/timothyckl/tau.nvim/issues/new). +For bug reports, issues, or questions, please [open an issue](https://github.com/timothyckl/patch.nvim/issues/new). diff --git a/README.md b/README.md index 099a16c..c8c2664 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# patch +# patch.nvim A simple agentic inline code editor for Neovim. -Requires Neovim 0.10+, [nui.nvim](https://github.com/MunifTanjim/nui.nvim), and an authenticated Pi installation. +Requires Neovim 0.10+, [nui.nvim](https://github.com/MunifTanjim/nui.nvim), and an authenticated [Pi](https://github.com/earendil-works/pi) installation. ## How it works @@ -25,32 +25,59 @@ cd ~/.pi/agent npx @earendil-works/pi-ai login [provider] ``` -## Configuration +## Installation + +[nvim-notify](https://github.com/rcarriga/nvim-notify) is optional. Omit it from the examples below and remove the `notify` option from the configuration to use `vim.notify` instead. + +### lazy.nvim ```lua { - "tim/tau.nvim", + "tim/patch.nvim", dependencies = { "MunifTanjim/nui.nvim", "rcarriga/nvim-notify", }, - config = function() - require("patch").setup({ - notify = require("notify"), - -- system_prompt = "Custom system instructions...", - -- model = "openai/gpt-5.6-sol", - }) - - vim.keymap.set("x", "p", "PatchCapture", { desc = "Capture selection and request a patch" }) - vim.keymap.set("n", "pa", "PatchAccept", { desc = "Accept the active patch proposal" }) - vim.keymap.set("n", "pr", "PatchReject", { desc = "Reject the active patch and restore the original" }) - vim.keymap.set("n", "pR", "PatchRetry", { desc = "Generate a new active patch proposal" }) - vim.keymap.set("n", "pc", "PatchCancel", { desc = "Cancel the active patch generation request" }) - vim.keymap.set("n", "pm", "PatchMenu", { desc = "Select the model used for patch requests" }) - end, } ``` +### packer.nvim + +```lua +use({ + "tim/patch.nvim", + requires = { + "MunifTanjim/nui.nvim", + "rcarriga/nvim-notify", + }, +}) +``` + +### vim-plug + +```vim +Plug 'MunifTanjim/nui.nvim' +Plug 'rcarriga/nvim-notify' +Plug 'tim/patch.nvim' +``` + +## Configuration + +```lua +require("patch").setup({ + notify = require("notify"), + -- system_prompt = "Custom system instructions...", + -- model = "openai/gpt-5.6-sol", +}) + +vim.keymap.set("x", "p", "PatchCapture", { desc = "Capture selection and request a patch" }) +vim.keymap.set("n", "pa", "PatchAccept", { desc = "Accept the active patch proposal" }) +vim.keymap.set("n", "pr", "PatchReject", { desc = "Reject the active patch and restore the original" }) +vim.keymap.set("n", "pR", "PatchRetry", { desc = "Generate a new active patch proposal" }) +vim.keymap.set("n", "pc", "PatchCancel", { desc = "Cancel the active patch generation request" }) +vim.keymap.set("n", "pm", "PatchMenu", { desc = "Select the model used for patch requests" }) +``` + `system_prompt` is optional. When set, it completely replaces Patch's built-in system prompt. `model` is optional. When omitted, Patch uses Pi's primary model. `PatchMenu` marks the effective model and allows selecting an override for the current Neovim session.