From 4845708f218cd397df764a3f763a2e63df410d80 Mon Sep 17 00:00:00 2001 From: norhua Date: Wed, 8 Apr 2026 11:13:25 +0800 Subject: [PATCH 1/2] feat(edit, config): add diff renderer and mini.diff helper --- README.md | 82 ++++- lua/opencode.lua | 1 + lua/opencode/config.lua | 10 + lua/opencode/integrations/diff.lua | 96 ++++++ plugin/events/permissions/edits.lua | 456 +++++++++++++++++++++++----- 5 files changed, 576 insertions(+), 69 deletions(-) create mode 100644 lua/opencode/integrations/diff.lua diff --git a/README.md b/README.md index 56fcf5f2..4e224aba 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,87 @@ When `opencode` requests a permission, `opencode.nvim` waits for idle to ask you #### Edits -For edit requests, `opencode.nvim` opens the target file in a new tab and uses Neovim's `:diffpatch` to display the proposed changes side-by-side. See `:h 'diffopt'` for customization. +For edit requests, `opencode.nvim` opens the target file in a new tab and displays the proposed changes with its built-in `:diffpatch` renderer by default. See `:h 'diffopt'` for customization. + +You can replace the renderer with a custom function. The renderer receives a context and can return a session with optional hunk navigation, hunk actions, and cleanup hooks. + +The context provides: + +- `ctx.request_id`: the edit request id. +- `ctx.filepath`: normalized target filepath. +- `ctx.diff`: the unified diff from `opencode`. +- `ctx.proposed_text()`: lazily computes the patched file contents. +- `ctx.permit(reply)`: sends `"once"` or `"reject"`. +- `ctx.close()`: closes the active diff view. +- `ctx.open_default()`: opens the built-in `:diffpatch` renderer and returns its session. + +The returned session can provide: + +- `bufnr` +- `close()` +- `next_hunk()` / `prev_hunk()` +- `accept_hunk()` / `reject_hunk()` + +Edit keymaps are global and operate on the currently active `opencode` edit session instead of being buffer-local. If there is no active edit diff, they show a notification instead of failing silently. + +Default keymaps: + +- `da`: accept the edit request +- `dr`: reject the edit request +- `q`: close the edit diff +- `dp`: accept the current hunk and reject the request +- `do`: reject the current hunk and reject the request +- `]c` / `[c`: next / previous hunk + +You can override or disable them: + +```lua +vim.g.opencode_opts = { + events = { + permissions = { + edits = { + keymaps = { + accept = "oa", + reject = "or", + close = "oq", + accept_hunk = false, + }, + }, + }, + }, +} +``` + +```lua +vim.g.opencode_opts = { + events = { + permissions = { + edits = { + renderer = require("opencode").diff_renderers.mini_diff(), + }, + }, + }, +} +``` + +`mini.diff` users can optionally tweak the helper: + +```lua +vim.g.opencode_opts = { + events = { + permissions = { + edits = { + renderer = require("opencode").diff_renderers.mini_diff({ + open_cmd = "tabnew", + ensure_overlay = true, + }), + }, + }, + }, +} +``` + +If you need full control, `renderer` still accepts a custom function using the `ctx` helpers above. | Keymap | Function | | ------- | ----------------------------------------------------------------------------- | diff --git a/lua/opencode.lua b/lua/opencode.lua index 7dfc0295..22ee0756 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -166,5 +166,6 @@ end -------------------- M.snacks_picker_send = require("opencode.integrations.pickers.snacks").send +M.diff_renderers = require("opencode.integrations.diff") return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index e658499e..7ea268ff 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -172,6 +172,16 @@ local defaults = { idle_delay_ms = 1000, edits = { enabled = true, + renderer = nil, + keymaps = { + accept = "da", + reject = "dr", + close = "q", + accept_hunk = "dp", + reject_hunk = "do", + next_hunk = "]c", + prev_hunk = "[c", + }, }, }, }, diff --git a/lua/opencode/integrations/diff.lua b/lua/opencode/integrations/diff.lua new file mode 100644 index 00000000..a111040b --- /dev/null +++ b/lua/opencode/integrations/diff.lua @@ -0,0 +1,96 @@ +local M = {} + +---@class opencode.integrations.diff.MiniDiffOpts +---@field open_cmd? string Ex command used to open the target file. Defaults to `tabnew`. +---@field ensure_overlay? boolean Whether to enable `mini.diff` overlay while the edit session is active. Defaults to `true`. + +---@param buf integer +---@param line integer +local function accept_hunk(buf, line) + local mini_diff = require("mini.diff") + mini_diff.do_hunks(buf, "reset", { line_start = line, line_end = line }) +end + +---Create an edit renderer backed by `mini.diff`. +--- +---Falls back to the built-in `:diffpatch` renderer when `mini.diff` is not available +---or the proposed text cannot be computed. +--- +---@param opts? opencode.integrations.diff.MiniDiffOpts +---@return fun(ctx: opencode.events.permissions.edits.Context): opencode.events.permissions.edits.Session? +function M.mini_diff(opts) + opts = vim.tbl_extend("keep", opts or {}, { + open_cmd = "tabnew", + ensure_overlay = true, + }) + + return function(ctx) + local ok, mini_diff = pcall(require, "mini.diff") + if not ok then + return ctx.open_default() + end + + local proposed = ctx.proposed_text() + if not proposed then + return ctx.open_default() + end + + vim.cmd(("%s %s"):format(opts.open_cmd, vim.fn.fnameescape(ctx.filepath))) + local bufnr = vim.api.nvim_get_current_buf() + local previous_state = mini_diff.get_buf_data(bufnr) + local previous_config = vim.deepcopy(vim.b[bufnr].minidiff_config) + + pcall(mini_diff.disable, bufnr) + vim.b[bufnr].minidiff_config = { source = mini_diff.gen_source.none() } + + local enabled = pcall(mini_diff.enable, bufnr) + local ref_ok = enabled and pcall(mini_diff.set_ref_text, bufnr, proposed) + if not ref_ok then + pcall(vim.cmd, "tabclose") + return ctx.open_default() + end + + if opts.ensure_overlay then + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local buf_data = mini_diff.get_buf_data(bufnr) or {} + if not buf_data.overlay then + pcall(mini_diff.toggle_overlay, bufnr) + end + end) + end + + return { + bufnr = bufnr, + close = function() + pcall(mini_diff.disable, bufnr) + vim.b[bufnr].minidiff_config = previous_config + + if previous_state then + pcall(mini_diff.enable, bufnr) + if previous_state.ref_text ~= nil then + pcall(mini_diff.set_ref_text, bufnr, previous_state.ref_text) + end + if previous_state.overlay then + pcall(mini_diff.toggle_overlay, bufnr) + end + end + end, + next_hunk = function() + mini_diff.goto_hunk("next") + end, + prev_hunk = function() + mini_diff.goto_hunk("prev") + end, + accept_hunk = function() + local line = vim.api.nvim_win_get_cursor(0)[1] + accept_hunk(bufnr, line) + end, + } + end +end + +return M diff --git a/plugin/events/permissions/edits.lua b/plugin/events/permissions/edits.lua index 1c3fd055..3a178cfb 100644 --- a/plugin/events/permissions/edits.lua +++ b/plugin/events/permissions/edits.lua @@ -2,11 +2,365 @@ local current_edit_request_id = nil ---@type nil|integer local diff_tabpage = nil +---@type nil|integer +local diff_tabpage_number = nil +---@type nil|integer +local diff_bufnr = nil +---@type opencode.events.permissions.edits.Session? +local diff_session = nil +---@type opencode.events.permissions.edits.ActiveRequest? +local active_request = nil +local keymaps_registered = false + +---@class opencode.events.permissions.edits.ActiveRequest +---@field request_id string +---@field port number + +---@type opencode.events.permissions.edits.Keymaps +local default_keymaps = { + accept = "da", + reject = "dr", + close = "q", + accept_hunk = "dp", + reject_hunk = "do", + next_hunk = "]c", + prev_hunk = "[c", +} ---@class opencode.events.permissions.edits.Opts --- ---Whether to display proposed edits from `opencode` and allow accepting/rejecting them from within Neovim. ---@field enabled? boolean +--- +---Custom renderer for proposed edits. Defaults to Neovim's built-in `:diffpatch`. +---Receives a render context and can return a session with hunk actions and cleanup hooks. +---@field renderer? fun(ctx: opencode.events.permissions.edits.Context): opencode.events.permissions.edits.Session? +---@field keymaps? opencode.events.permissions.edits.Keymaps + +---@class opencode.events.permissions.edits.Keymaps +---@field accept? string|false +---@field reject? string|false +---@field close? string|false +---@field accept_hunk? string|false +---@field reject_hunk? string|false +---@field next_hunk? string|false +---@field prev_hunk? string|false + +---@class opencode.events.permissions.edits.Session +---@field bufnr? integer +---@field close? fun() +---@field next_hunk? fun() +---@field prev_hunk? fun() +---@field accept_hunk? fun() +---@field reject_hunk? fun() + +---@class opencode.events.permissions.edits.Context +---@field request_id string +---@field filepath string +---@field diff string +---@field state table +---@field proposed_text fun(): string? +---@field permit fun(reply: opencode.server.permission.Reply) +---@field close fun() +---@field open_default fun(): opencode.events.permissions.edits.Session? + +---@return integer? +local function current_line() + if not diff_bufnr or not vim.api.nvim_buf_is_valid(diff_bufnr) then + return nil + end + + local cursor = vim.api.nvim_win_get_cursor(0) + return cursor[1] +end + +---@param reply opencode.server.permission.Reply +---@param port number +---@param request_id string +local function permit(reply, port, request_id) + require("opencode.server").new(port):next(function(server) ---@param server opencode.server.Server + server:permit(request_id, reply) + end) +end + +local function cleanup_diff() + if diff_session and diff_session.close then + pcall(diff_session.close) + end + + diff_session = nil + diff_bufnr = nil + active_request = nil +end + +local function close_diff() + current_edit_request_id = nil + cleanup_diff() + + if diff_tabpage and vim.api.nvim_tabpage_is_valid(diff_tabpage) then + vim.api.nvim_set_current_tabpage(diff_tabpage) + vim.cmd("tabclose") + end + + diff_tabpage = nil + diff_tabpage_number = nil +end + +---@param msg string +local function notify_info(msg) + vim.notify(msg, vim.log.levels.INFO, { title = "opencode" }) +end + +---@return opencode.events.permissions.edits.Session?, opencode.events.permissions.edits.ActiveRequest? +local function get_active_diff() + if not diff_session or not active_request or not current_edit_request_id then + notify_info("No active opencode edit diff") + return nil, nil + end + + return diff_session, active_request +end + +---@param filepath string +---@return string +local function normalize_filepath(filepath) + local normalized = vim.fs.normalize(filepath) + if vim.startswith(normalized, "/") then + return normalized + end + + local absolute_like = vim.fs.normalize("/" .. normalized) + if vim.uv.fs_stat(absolute_like) then + return absolute_like + end + + local cwd_relative = vim.fs.normalize(vim.fn.getcwd() .. "/" .. normalized) + if vim.uv.fs_stat(cwd_relative) then + return cwd_relative + end + + return absolute_like +end + +---@param filepath string +---@param diff string +---@return string? +local function patched_text(filepath, diff) + local patch_filepath = vim.fn.tempname() .. ".patch" + local output_filepath = vim.fn.tempname() + filepath = normalize_filepath(filepath) + + if vim.fn.filereadable(filepath) ~= 1 then + vim.notify("Target file for opencode edit renderer does not exist: " .. filepath, vim.log.levels.ERROR, { title = "opencode" }) + return nil + end + + if vim.fn.writefile(vim.split(diff, "\n"), patch_filepath) ~= 0 then + vim.notify("Failed to write patch file for opencode edit renderer", vim.log.levels.ERROR, { title = "opencode" }) + return nil + end + + local result = + vim.system({ "patch", "--silent", "--output", output_filepath, filepath, patch_filepath }, { text = true }):wait() + if result.code ~= 0 then + vim.notify( + "Failed to compute proposed text for opencode edit renderer: " .. filepath, + vim.log.levels.ERROR, + { title = "opencode" } + ) + return nil + end + + return table.concat(vim.fn.readfile(output_filepath), "\n") +end + +---@param filepath string +---@param diff string +---@return opencode.events.permissions.edits.Session? +local function open_with_diffpatch(filepath, diff) + filepath = normalize_filepath(filepath) + local patch_filepath = vim.fn.tempname() .. ".patch" + if vim.fn.writefile(vim.split(diff, "\n"), patch_filepath) ~= 0 then + vim.notify("Failed to write patch file to diff opencode edit request", vim.log.levels.ERROR, { title = "opencode" }) + return nil + end + + vim.cmd("silent! bwipeout " .. vim.fn.fnameescape(filepath .. ".new")) + vim.cmd("tabnew " .. vim.fn.fnameescape(filepath)) + local ok = pcall(vim.cmd, "silent vert diffpatch " .. vim.fn.fnameescape(patch_filepath)) + if not ok then + vim.cmd("tabclose") + return nil + end + + return { bufnr = vim.api.nvim_get_current_buf() } +end + +---@param session opencode.events.permissions.edits.Session +local function activate_session(session) + diff_session = session + diff_bufnr = session.bufnr or vim.api.nvim_get_current_buf() + diff_tabpage = vim.api.nvim_get_current_tabpage() + diff_tabpage_number = vim.api.nvim_tabpage_get_number(diff_tabpage) + + local tabclosed_group = vim.api.nvim_create_augroup("OpencodeEditTabClose", { clear = false }) + vim.api.nvim_create_autocmd("TabClosed", { + group = tabclosed_group, + pattern = tostring(diff_tabpage_number), + once = true, + callback = function() + cleanup_diff() + diff_tabpage = nil + diff_tabpage_number = nil + current_edit_request_id = nil + end, + desc = "Clean up opencode edit diff state", + }) +end + +local function accept_edit() + local _, request = get_active_diff() + if not request then + return + end + + current_edit_request_id = nil + permit("once", request.port, request.request_id) +end + +local function reject_edit() + local _, request = get_active_diff() + if not request then + return + end + + current_edit_request_id = nil + permit("reject", request.port, request.request_id) +end + +local function accept_hunk() + local session, request = get_active_diff() + if not session or not request then + return + end + + current_edit_request_id = nil + permit("reject", request.port, request.request_id) + + if session.accept_hunk then + session.accept_hunk() + return + end + + if vim.wo.diff then + vim.cmd.normal({ "dp", bang = true }) + return + end + + notify_info("Active opencode edit renderer does not support accepting hunks") +end + +local function reject_hunk() + local session, request = get_active_diff() + if not session or not request then + return + end + + current_edit_request_id = nil + permit("reject", request.port, request.request_id) + + if session.reject_hunk then + session.reject_hunk() + return + end + + if vim.wo.diff then + vim.cmd.normal({ "do", bang = true }) + return + end + + notify_info("Active opencode edit renderer does not support rejecting hunks") +end + +---@param direction "next"|"prev" +local function goto_hunk(direction) + local session = get_active_diff() + if not session then + return + end + + local method = direction == "next" and session.next_hunk or session.prev_hunk + if method then + method() + return + end + + if vim.wo.diff then + vim.cmd.normal({ direction == "next" and "]c" or "[c", bang = true }) + return + end + + notify_info("Active opencode edit renderer does not support hunk navigation") +end + +local function register_keymaps() + if keymaps_registered then + return + end + + local opts = require("opencode.config").opts.events.permissions.edits or {} + local keymaps = vim.tbl_extend("force", default_keymaps, opts.keymaps or {}) + local mappings = { + { lhs = keymaps.accept, rhs = accept_edit, desc = "Accept opencode edit" }, + { lhs = keymaps.reject, rhs = reject_edit, desc = "Reject opencode edit" }, + { lhs = keymaps.close, rhs = close_diff, desc = "Close opencode edit diff" }, + { lhs = keymaps.accept_hunk, rhs = accept_hunk, desc = "Accept opencode edit hunk" }, + { lhs = keymaps.reject_hunk, rhs = reject_hunk, desc = "Reject opencode edit hunk" }, + { lhs = keymaps.next_hunk, rhs = function() goto_hunk("next") end, desc = "Next opencode edit hunk" }, + { lhs = keymaps.prev_hunk, rhs = function() goto_hunk("prev") end, desc = "Previous opencode edit hunk" }, + } + + for _, mapping in ipairs(mappings) do + if mapping.lhs then + vim.keymap.set("n", mapping.lhs, mapping.rhs, { desc = mapping.desc }) + end + end + + keymaps_registered = true +end + +---@param filepath string +---@param diff string +---@param port number +---@param request_id string +---@return opencode.events.permissions.edits.Context +local function build_context(filepath, diff, port, request_id) + filepath = normalize_filepath(filepath) + local state = {} + + return { + request_id = request_id, + filepath = filepath, + diff = diff, + state = state, + proposed_text = function() + if state.proposed_text == nil then + state.proposed_text = patched_text(filepath, diff) + end + + return state.proposed_text + end, + permit = function(reply) + permit(reply, port, request_id) + end, + close = function() + close_diff() + end, + open_default = function() + return open_with_diffpatch(filepath, diff) + end, + } +end vim.api.nvim_create_autocmd("User", { group = vim.api.nvim_create_augroup("OpencodeEdits", { clear = true }), @@ -30,81 +384,47 @@ vim.api.nvim_create_autocmd("User", { { title = "opencode", timeout = idle_delay_ms } ) require("opencode.util").on_user_idle(idle_delay_ms, function() - -- TODO: Handle multi-file edits? - -- When would opencode even do that? - -- for _, file in ipairs(event.properties.metadata.diff) do - - local diff = event.properties.metadata.diff - - local patch_filepath = vim.fn.tempname() .. ".patch" - if vim.fn.writefile(vim.split(diff, "\n"), patch_filepath) ~= 0 then - vim.notify( - "Failed to write patch file to diff opencode edit request", - vim.log.levels.ERROR, - { title = "opencode" } - ) - return - end - - local filepath = event.properties.metadata.filepath - -- Close any buffer with the same name, to avoid "Buffer with this name already exists" error when successive edit requests come in for the same file. - vim.cmd("silent! bwipeout " .. filepath .. ".new") + local ok, err = pcall(function() + register_keymaps() + local filepath = event.properties.metadata.filepath + local diff = event.properties.metadata.diff + local renderer = opts.edits.renderer - -- Diffing changes some of the buffer's display options (namely folding) to make it easier to compare side-by-side, - -- so open the target file in a new tab first. - vim.cmd("tabnew " .. filepath) - -- FIX: Sometimes rejects? Or displays no changes? Malformed patch? - vim.cmd("silent vert diffpatch " .. patch_filepath) + cleanup_diff() + local ctx = build_context(filepath, diff, port, event.properties.id) + local session = nil - diff_tabpage = vim.api.nvim_get_current_tabpage() - current_edit_request_id = event.properties.id - - ---@param reply opencode.server.permission.Reply - local function permit(reply) - require("opencode.server").new(port):next(function(server) ---@param server opencode.server.Server - server:permit(event.properties.id, reply) - end) - end + if renderer then + local renderer_ok, result = pcall(renderer, ctx) + if renderer_ok then + session = result + else + vim.notify("Custom opencode edit renderer failed; falling back to diffpatch", vim.log.levels.WARN, { + title = "opencode", + }) + end + end - -- Override native accept/reject keymaps to reject the edit as a whole first, if it hasn't been already - vim.keymap.set("n", "dp", function() - if current_edit_request_id then - -- Clear so we don't close the tabpage in the "permission.replied" handler - -- and user can continue accepting/rejecting individual hunks (and then close the tabpage manually) - current_edit_request_id = nil - permit("reject") + if not session then + session = ctx.open_default() end - return "dp" - end, { buffer = true, desc = "Accept opencode edit hunk", expr = true }) - vim.keymap.set("n", "do", function() - if current_edit_request_id then - current_edit_request_id = nil - permit("reject") + + if not session then + vim.notify("Failed to display opencode edit diff", vim.log.levels.ERROR, { title = "opencode" }) + return end - return "do" - end, { buffer = true, desc = "Reject opencode edit hunk", expr = true }) - -- Accept/reject edit as a whole - vim.keymap.set("n", "da", function() - permit("once") - end, { buffer = true, desc = "Accept opencode edit" }) - vim.keymap.set("n", "dr", function() - permit("reject") - end, { buffer = true, desc = "Reject opencode edit" }) - -- Close diff - vim.keymap.set("n", "q", function() - vim.cmd("tabclose") - current_edit_request_id = nil - diff_tabpage = nil - end, { buffer = true, desc = "Close opencode edit diff" }) + + activate_session(session) + current_edit_request_id = event.properties.id + active_request = { request_id = event.properties.id, port = port } + end) + + if not ok then + vim.notify("Failed to handle opencode edit request: " .. err, vim.log.levels.ERROR, { title = "opencode" }) + end end) elseif event.type == "permission.replied" and current_edit_request_id == event.properties.requestID then - -- Entire edit was accepted or rejected, either in the plugin or TUI; close the diff - current_edit_request_id = nil - if diff_tabpage and vim.api.nvim_tabpage_is_valid(diff_tabpage) then - vim.api.nvim_set_current_tabpage(diff_tabpage) - vim.cmd("tabclose") - diff_tabpage = nil - end + close_diff() end end, desc = "Display opencode proposed edits", From 3dde2129e60670c1ea23ab0da3766cb8c386be0b Mon Sep 17 00:00:00 2001 From: norhua Date: Wed, 8 Apr 2026 16:00:45 +0800 Subject: [PATCH 2/2] fix(edit, config): Fall back to diffpatch when mini.diff is unavailable. --- lua/opencode/integrations/diff.lua | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lua/opencode/integrations/diff.lua b/lua/opencode/integrations/diff.lua index a111040b..c361d2c5 100644 --- a/lua/opencode/integrations/diff.lua +++ b/lua/opencode/integrations/diff.lua @@ -4,13 +4,20 @@ local M = {} ---@field open_cmd? string Ex command used to open the target file. Defaults to `tabnew`. ---@field ensure_overlay? boolean Whether to enable `mini.diff` overlay while the edit session is active. Defaults to `true`. +---@param mini_diff table ---@param buf integer ---@param line integer -local function accept_hunk(buf, line) - local mini_diff = require("mini.diff") +local function accept_hunk(mini_diff, buf, line) mini_diff.do_hunks(buf, "reset", { line_start = line, line_end = line }) end +---@return fun(ctx: opencode.events.permissions.edits.Context): opencode.events.permissions.edits.Session? +function M.default() + return function(ctx) + return ctx.open_default() + end +end + ---Create an edit renderer backed by `mini.diff`. --- ---Falls back to the built-in `:diffpatch` renderer when `mini.diff` is not available @@ -24,15 +31,17 @@ function M.mini_diff(opts) ensure_overlay = true, }) + local fallback = M.default() + return function(ctx) local ok, mini_diff = pcall(require, "mini.diff") - if not ok then - return ctx.open_default() + if not ok or type(rawget(_G, "MiniDiff")) ~= "table" then + return fallback(ctx) end local proposed = ctx.proposed_text() if not proposed then - return ctx.open_default() + return fallback(ctx) end vim.cmd(("%s %s"):format(opts.open_cmd, vim.fn.fnameescape(ctx.filepath))) @@ -47,7 +56,7 @@ function M.mini_diff(opts) local ref_ok = enabled and pcall(mini_diff.set_ref_text, bufnr, proposed) if not ref_ok then pcall(vim.cmd, "tabclose") - return ctx.open_default() + return fallback(ctx) end if opts.ensure_overlay then @@ -87,7 +96,7 @@ function M.mini_diff(opts) end, accept_hunk = function() local line = vim.api.nvim_win_get_cursor(0)[1] - accept_hunk(bufnr, line) + accept_hunk(mini_diff, bufnr, line) end, } end