diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 0a78ce3..7dcded0 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -114,6 +114,20 @@ local defaults = { permissions = { enabled = true, idle_delay_ms = 1000, + confirm = { + enabled = false, + window = { + config = {}, + options = {}, + mappings = { + ["a"] = "Once", + [""] = "Once", + ["A"] = "Always", + ["r"] = "Reject", + ["q"] = "Close", + }, + }, + }, }, }, provider = { diff --git a/lua/opencode/ui/confirm.lua b/lua/opencode/ui/confirm.lua new file mode 100644 index 0000000..60d38a8 --- /dev/null +++ b/lua/opencode/ui/confirm.lua @@ -0,0 +1,159 @@ +local M = {} + +local Output = require("opencode.ui.output") +local formatter = require("opencode.ui.formatter") +local output_window = require("opencode.ui.output_window") +local util = require("opencode.util") + +---Format diff content with proper syntax highlighting +---@param content string The diff content +---@param file_type string The file type for syntax highlighting +---@return Output +local function format_diff_content(content, file_type) + local output = Output.new() + + -- Format the diff with proper syntax highlighting + formatter._format_diff(output, content, file_type) + + return output +end + +---Render formatted data to a buffer +---@param bufid integer Buffer ID +---@param output Output +local function render_to_buffer(bufid, output) + -- Set lines + output_window.set_lines(bufid, output.lines, 0, -1) + + -- Apply extmarks for syntax highlighting + local extmarks = output.extmarks + if extmarks then + output_window.set_extmarks(bufid, extmarks, 0) + end +end + +---@param event table +---@param on_choice? fun(choice?: string) +function M.confirm(event, on_choice) + local title = "Permit opencode to: " + .. event.properties.permission + .. " " + .. table.concat(event.properties.patterns, ", ") + .. "?" + local content = event.properties.metadata.diff + local file_type = util.get_markdown_filetype(event.properties.metadata.filepath or event.properties.patterns[1]) + content = format_diff_content(content, file_type) + M._confirm(title, content, file_type, on_choice) +end + +---@param title string +---@param content string|Output +---@param file_type string +---@param on_choice? fun(choice?: string) +function M._confirm(title, content, file_type, on_choice) + -- Format the diff content with syntax highlighting + + local output = content + if type(content) == "string" then + output = Output.new() + output:add_line("`````" .. file_type) + output:add_lines(vim.split(content, "\n", { plain = true })) + output:add_line("`````") + end + + local win_config = require("opencode.config").opts.events.permissions.confirm.window.config + if type(win_config) == "function" then + win_config = win_config() + end + local win_options = require("opencode.config").opts.events.permissions.confirm.window.options + + -- Build dynamic footer from mappings + local mappings = require("opencode.config").opts.events.permissions.confirm.window.mappings + local footer = {} + local seen_actions = {} + for key, action in pairs(mappings) do + if type(action) == "string" and action ~= "close" then + local action_lower = action:lower() + if not seen_actions[action_lower] then + seen_actions[action_lower] = {} + table.insert(seen_actions[action_lower], key) + else + table.insert(seen_actions[action_lower], key) + end + end + end + + -- Sort actions in desired order and build footer + local action_order = { "once", "always", "reject" } + for _, action in ipairs(action_order) do + if seen_actions[action] then + -- Sort keys to ensure consistent display order + table.sort(seen_actions[action]) + local keys = table.concat(seen_actions[action], "/") + table.insert(footer, { " " .. keys .. " ", "Title" }) + table.insert(footer, { "- " .. action:sub(1, 1):upper() .. action:sub(2) .. " ", "Comment" }) + end + end + + local bufid, winid = util.create_scratch_floatwin( + title, + vim.tbl_deep_extend("force", { + footer = footer, + footer_pos = "center", + }, win_config) + ) + + ---@cast output Output + render_to_buffer(bufid, output) + + vim.bo.modifiable = false + -- Set filetype to enable syntax highlighting + vim.bo.filetype = "markdown" + + for option, value in pairs(win_options) do + vim.api.nvim_set_option_value(option, value, { scope = "local", win = winid }) + end + + local done = false + + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = bufid, + callback = function() + if not done then + if on_choice then + on_choice() + end + end + end, + }) + + local function finish(choice) + if on_choice then + on_choice(choice) + end + done = true + vim.api.nvim_win_close(winid, false) + end + + local function close_window() + done = true + vim.api.nvim_win_close(winid, false) + end + + for key, action in pairs(mappings) do + if type(action) == "string" then + local action_lower = action:lower() + if action_lower == "close" then + -- Close window without calling callback + vim.keymap.set("n", key, close_window, { buffer = bufid, remap = false, nowait = true }) + else + -- Pass the action (once/always/reject) to the callback + vim.keymap.set("n", key, function() + finish(action_lower) + end, { buffer = bufid, remap = false, nowait = true }) + end + end + end +end + +return M diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua new file mode 100644 index 0000000..a0de88e --- /dev/null +++ b/lua/opencode/ui/formatter.lua @@ -0,0 +1,42 @@ +local M = {} + +---@param output Output +---@param code string +---@param file_type string +function M._format_diff(output, code, file_type) + --- NOTE: use longer code fence because code could contain ``` + output:add_line("`````" .. file_type) + local lines = vim.split(code, "\n") + if #lines > 5 then + lines = vim.list_slice(lines, 6) + end + + for _, line in ipairs(lines) do + local first_char = line:sub(1, 1) + if first_char == "+" or first_char == "-" then + local hl_group = first_char == "+" and "DiffAdd" or "DiffDelete" + output:add_line(" " .. line:sub(2)) + local line_idx = output:get_line_count() + output:add_extmark(line_idx - 1, function() + return { + end_col = 0, + end_row = line_idx, + virt_text = { { first_char, hl_group } }, + hl_group = hl_group, + hl_eol = true, + priority = 5000, + right_gravity = true, + end_right_gravity = false, + virt_text_hide = false, + virt_text_pos = "overlay", + virt_text_repeat_linebreak = false, + } + end) + else + output:add_line(line) + end + end + output:add_line("`````") +end + +return M diff --git a/lua/opencode/ui/output.lua b/lua/opencode/ui/output.lua new file mode 100644 index 0000000..a69b5c9 --- /dev/null +++ b/lua/opencode/ui/output.lua @@ -0,0 +1,164 @@ +local Output = {} +Output.__index = Output + +---@alias OutputExtmarkType vim.api.keyset.set_extmark & {start_col:0} +---@alias OutputExtmark OutputExtmarkType|fun():OutputExtmarkType + +---@class OutputAction +---@field text string Action text +---@field type 'diff_revert_all'|'diff_revert_selected_file'|'diff_open'|'diff_restore_snapshot_file'|'diff_restore_snapshot_all'|'select_child_session' Type of action +---@field args? string[] Optional arguments for the command +---@field key string keybinding for the action +---@field display_line number Line number to display the action +---@field range? { from: number, to: number } Optional range for the action + +---@class Output +---@field lines string[] +---@field extmarks table +---@field actions OutputAction[] +---@field add_line fun(self: Output, line: string, fit?: boolean): number +---@field get_line fun(self: Output, idx: number): string? +---@field merge_line fun(self: Output, idx: number, text: string) +---@field add_lines fun(self: Output, lines: string[], prefix?: string) +---@field add_empty_line fun(self: Output): number? +---@field clear fun(self: Output) +---@field get_line_count fun(self: Output): number +---@field get_lines fun(self: Output): string[] +---@field add_extmark fun(self: Output, idx: number, extmark: OutputExtmark|fun(): OutputExtmark) +---@field get_extmarks fun(self: Output): table +---@field add_actions fun(self: Output, actions: OutputAction[]) +---@field add_action fun(self: Output, action: OutputAction) +---@field get_actions_for_line fun(self: Output, line: number): OutputAction[]? +---@return self Output +function Output.new() + local self = setmetatable({}, Output) + self.lines = {} + self.extmarks = {} + self.actions = {} + return self +end + +---Add a new line +---@param line string +---@return number index The index of the added line +function Output:add_line(line) + table.insert(self.lines, line) + return #self.lines +end + +---Get line by index +---@param idx number +---@return string? +function Output:get_line(idx) + return self.lines[idx] +end + +---Merge text into an existing line +---@param idx number +---@param text string +function Output:merge_line(idx, text) + if self.lines[idx] then + self.lines[idx] = self.lines[idx] .. text + end +end + +---Add multiple lines +---@param lines string[] +---@param prefix? string Optional prefix for each line +function Output:add_lines(lines, prefix) + for _, line in ipairs(lines) do + if line == "" then + table.insert(self.lines, "") + else + prefix = prefix or "" + table.insert(self.lines, prefix .. line) + end + end +end + +---Add an empty line if the last line is not empty +---@return number? index The index of the added line, or nil if no line was added +function Output:add_empty_line() + local line_count = #self.lines + if line_count == 0 or self.lines[line_count] ~= "" then + table.insert(self.lines, "") + return line_count + 1 + end + return nil +end + +---Clear all lines, extmarks, and actions +function Output:clear() + self.lines = {} + self.extmarks = {} + self.actions = {} +end + +---Get the number of lines +---@return number +function Output:get_line_count() + return #self.lines +end + +---Get all lines as a table +---@return string[] +function Output:get_lines() + return vim.deepcopy(self.lines) +end + +---Add an extmark for a specific line +---@param idx number The line index +---@param extmark OutputExtmark|fun(): OutputExtmark The extmark data or a function returning it +function Output:add_extmark(idx, extmark) + if not self.extmarks[idx] then + self.extmarks[idx] = {} + end + table.insert(self.extmarks[idx], extmark) +end + +---Get all extmarks +---@return table +function Output:get_extmarks() + return vim.deepcopy(self.extmarks) +end + +---Add contextual actions +---@param actions OutputAction[] The actions to add +function Output:add_actions(actions) + for _, action in ipairs(actions) do + table.insert(self.actions, action) + end +end + +---Add contextual action +---@param action OutputAction The actions to add +function Output:add_action(action) + if not action.display_line then + action.display_line = #self.lines - 1 + end + if not action.range then + action.range = { from = #self.lines, to = #self.lines } + end + table.insert(self.actions, action) +end + +---Get actions for a line matching a range +---@param line number The line index to check +---@return OutputAction[]|nil +function Output:get_actions_for_line(line) + local actions = {} + for _, action in pairs(self.actions) do + if not action.range then + if line == action.display_line then + table.insert(actions, vim.deepcopy(action)) + end + elseif action.range then + if line >= action.range.from and line <= action.range.to then + table.insert(actions, vim.deepcopy(action)) + end + end + end + return #actions > 0 and actions or nil +end + +return Output diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua new file mode 100644 index 0000000..f2717a8 --- /dev/null +++ b/lua/opencode/ui/output_window.lua @@ -0,0 +1,46 @@ +local M = {} +M.namespace = vim.api.nvim_create_namespace("opencode_ui") + +---Apply extmarks to a buffer +---@param buf integer Buffer ID +---@param extmarks table Extmarks indexed by line +---@param line_offset? integer Line offset to apply to extmarks, defaults to 0 +function M.set_extmarks(buf, extmarks, line_offset) + if not extmarks or type(extmarks) ~= "table" then + return + end + + line_offset = line_offset or 0 + + for line_idx, marks in pairs(extmarks) do + for _, mark in ipairs(marks) do + local actual_mark = type(mark) == "function" and mark() or mark + local target_line = line_offset + line_idx --[[@as integer]] + if actual_mark.end_row then + actual_mark.end_row = actual_mark.end_row + line_offset + end + local start_col = actual_mark.start_col + if actual_mark.start_col then + actual_mark.start_col = nil ---@diagnostic disable-line: inject-field + end + ---@cast actual_mark vim.api.keyset.set_extmark + pcall(vim.api.nvim_buf_set_extmark, buf, M.namespace, target_line, start_col or 0, actual_mark) + end + end +end + +---Set the buffer contents +---@param buf integer Buffer ID +---@param lines string[] The lines to set +---@param start_line? integer The starting line to set, defaults to 0 +---@param end_line? integer The last line to set, defaults to -1 +function M.set_lines(buf, lines, start_line, end_line) + start_line = start_line or 0 + end_line = end_line or -1 + + vim.api.nvim_set_option_value("modifiable", true, { buf = buf }) + vim.api.nvim_buf_set_lines(buf, start_line, end_line, false, lines) + vim.api.nvim_set_option_value("modifiable", false, { buf = buf }) +end + +return M diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua new file mode 100644 index 0000000..5b3a4c8 --- /dev/null +++ b/lua/opencode/util.lua @@ -0,0 +1,67 @@ +local M = {} + +--- Get the markdown type to use based on the filename. First gets the neovim type +--- for the file. Then apply any specific overrides. Falls back to using the file +--- extension if nothing else matches +--- @param filename string filename, possibly including path +--- @return string markdown_filetype +function M.get_markdown_filetype(filename) + if not filename or filename == "" then + return "" + end + + local file_type_overrides = { + javascriptreact = "jsx", + typescriptreact = "tsx", + sh = "bash", + yaml = "yml", + text = "txt", -- nvim 0.12-nightly returns text as the type which breaks our unit tests + } + + local file_type = vim.filetype.match({ filename = filename }) or "" + + if file_type_overrides[file_type] then + return file_type_overrides[file_type] + end + + if file_type and file_type ~= "" then + return file_type + end + + return vim.fn.fnamemodify(filename, ":e") +end + +---@param title string +---@param opts? table config of nvim_open_win() +---@return integer bufid +---@return integer winid +function M.create_scratch_floatwin(title, opts) + title = string.format(" %s ", title) + local bufid = vim.api.nvim_create_buf(false, true) + local bo = vim.bo[bufid] + bo.bufhidden = "wipe" + bo.buftype = "nofile" + bo.swapfile = false + local width = math.min(vim.o.columns, 100) + local col = math.floor((vim.o.columns - width) / 2) + local winid = vim.api.nvim_open_win( + bufid, + true, + vim.tbl_deep_extend("force", { + relative = "editor", + row = math.floor((vim.o.lines - 2) / 4), + col = col, + width = width, + height = math.floor(vim.o.lines / 2), + border = "rounded", + title = title, + title_pos = "center", + }, opts or {}) + ) + -- basic setup + vim.opt_local.number = false + vim.opt_local.colorcolumn = {} + return bufid, winid +end + +return M diff --git a/plugin/events/permissions.lua b/plugin/events/permissions.lua index 25cd235..b8d2c3b 100644 --- a/plugin/events/permissions.lua +++ b/plugin/events/permissions.lua @@ -5,6 +5,35 @@ --- ---Amount of user idle time before showing permission requests. ---@field idle_delay_ms number +--- +---Options for permission confirmation dialog. +---@field confirm opencode.events.permissions.confirm.Opts + +---@class opencode.events.permissions.confirm.Opts +--- +---Whether to show the confirmation dialog for edit permissions. +---When false, uses the default `vim.ui.select` interface. +---@field enabled boolean +--- +---Window configuration for the confirmation dialog. +---@field window opencode.events.permissions.confirm.window.Opts + +---@class opencode.events.permissions.confirm.window.Opts +--- +---Configuration for the confirmation window. +---Passed to `vim.api.nvim_open_win()` as the config parameter. +---Can be a table or a function that returns a table. +---@field config table|fun():table +--- +---Window-local options to set on the confirmation window. +---Key-value pairs passed to `vim.api.nvim_set_option_value()` with scope "local". +---@field options table +--- +---Key mappings for the confirmation window. +---Keys are Vim key notation (e.g., "a", "", "q"). +---Values are action names: "Once", "Always", "Reject", or "Close" (case-insensitive). +---"Close" closes the window without calling the callback. +---@field mappings table ---@param delay_ms number ---@param callback function @@ -58,6 +87,14 @@ vim.api.nvim_create_autocmd("User", { ) on_user_idle(idle_delay_ms, function() is_permission_request_open = true + if opts.confirm.enabled and event.properties.permission == "edit" then + return require("opencode.ui.confirm").confirm(event, function(choice) + is_permission_request_open = false + if choice then + require("opencode.cli.client").permit(port, event.properties.id, choice:lower()) + end + end) + end vim.ui.select({ "Once", "Always", "Reject" }, { prompt = "Permit opencode to: " .. event.properties.permission .. " " .. table.concat( event.properties.patterns,