Skip to content

Commit 4ac0577

Browse files
epheph
authored andcommitted
feat(permissions): add confirmation dialog with diff preview and key bindings
Add a new confirmation dialog for edit permission requests that displays the diff with syntax highlighting and supports configurable key bindings. Features: - Diff preview with syntax highlighting using a custom formatter - Dynamic key bindings configurable via opts.events.permissions.confirm.window.mappings - Support for multiple keys per action (e.g., "a" and "<CR>" both map to "once") - Footer display that dynamically shows configured keys in sorted order - Window configuration and options customization - Type-safe implementation with full LuaLS type annotations - Fast key response with nowait option Configuration: Add the following to vim.g.opencode_opts to enable: events = { permissions = { confirm = { enabled = true, window = { config = {}, -- vim.api.nvim_open_win config options = {}, -- window-local options mappings = { -- key bindings ["a"] = "Once", ["<CR>"] = "Once", ["A"] = "Always", ["r"] = "Reject", ["q"] = "Close", }, }, }, }, } Changes: - Add lua/opencode/ui/confirm.lua for confirmation dialog UI - Add lua/opencode/ui/formatter.lua for diff syntax highlighting - Add lua/opencode/ui/output.lua and output_window.lua for text rendering - Add lua/opencode/util.lua with create_scratch_floatwin helper - Update plugin/events/permissions.lua to use confirmation dialog - Add confirm configuration to lua/opencode/config.lua - Add type definitions for opencode.events.permissions.confirm.Opts
1 parent 990c062 commit 4ac0577

7 files changed

Lines changed: 529 additions & 0 deletions

File tree

lua/opencode/config.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,20 @@ local defaults = {
114114
permissions = {
115115
enabled = true,
116116
idle_delay_ms = 1000,
117+
confirm = {
118+
enabled = false,
119+
window = {
120+
config = {},
121+
options = {},
122+
mappings = {
123+
["a"] = "Once",
124+
["<CR>"] = "Once",
125+
["A"] = "Always",
126+
["r"] = "Reject",
127+
["q"] = "Close",
128+
},
129+
},
130+
},
117131
},
118132
},
119133
provider = {

lua/opencode/ui/confirm.lua

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
local M = {}
2+
3+
local Output = require("opencode.ui.output")
4+
local formatter = require("opencode.ui.formatter")
5+
local output_window = require("opencode.ui.output_window")
6+
local util = require("opencode.util")
7+
8+
---Format diff content with proper syntax highlighting
9+
---@param content string The diff content
10+
---@param file_type string The file type for syntax highlighting
11+
---@return Output
12+
local function format_diff_content(content, file_type)
13+
local output = Output.new()
14+
15+
-- Format the diff with proper syntax highlighting
16+
formatter._format_diff(output, content, file_type)
17+
18+
return output
19+
end
20+
21+
---Render formatted data to a buffer
22+
---@param bufid integer Buffer ID
23+
---@param output Output
24+
local function render_to_buffer(bufid, output)
25+
-- Set lines
26+
output_window.set_lines(bufid, output.lines, 0, -1)
27+
28+
-- Apply extmarks for syntax highlighting
29+
local extmarks = output.extmarks
30+
if extmarks then
31+
output_window.set_extmarks(bufid, extmarks, 0)
32+
end
33+
end
34+
35+
---@param event table
36+
---@param on_choice? fun(choice?: string)
37+
function M.confirm(event, on_choice)
38+
local title = "Permit opencode to: "
39+
.. event.properties.permission
40+
.. " "
41+
.. table.concat(event.properties.patterns, ", ")
42+
.. "?"
43+
local content = event.properties.metadata.diff
44+
local file_type = util.get_markdown_filetype(event.properties.metadata.filepath or event.properties.patterns[1])
45+
content = format_diff_content(content, file_type)
46+
M._confirm(title, content, file_type, on_choice)
47+
end
48+
49+
---@param title string
50+
---@param content string|Output
51+
---@param file_type string
52+
---@param on_choice? fun(choice?: string)
53+
function M._confirm(title, content, file_type, on_choice)
54+
-- Format the diff content with syntax highlighting
55+
56+
local output = content
57+
if type(content) == "string" then
58+
output = Output.new()
59+
output:add_line("`````" .. file_type)
60+
output:add_lines(vim.split(content, "\n", { plain = true }))
61+
output:add_line("`````")
62+
end
63+
64+
local win_config = require("opencode.config").opts.events.permissions.confirm.window.config
65+
if type(win_config) == "function" then
66+
win_config = win_config()
67+
end
68+
local win_options = require("opencode.config").opts.events.permissions.confirm.window.options
69+
70+
-- Build dynamic footer from mappings
71+
local mappings = require("opencode.config").opts.events.permissions.confirm.window.mappings
72+
local footer = {}
73+
local seen_actions = {}
74+
for key, action in pairs(mappings) do
75+
if type(action) == "string" and action ~= "close" then
76+
local action_lower = action:lower()
77+
if not seen_actions[action_lower] then
78+
seen_actions[action_lower] = {}
79+
table.insert(seen_actions[action_lower], key)
80+
else
81+
table.insert(seen_actions[action_lower], key)
82+
end
83+
end
84+
end
85+
86+
-- Sort actions in desired order and build footer
87+
local action_order = { "once", "always", "reject" }
88+
for _, action in ipairs(action_order) do
89+
if seen_actions[action] then
90+
-- Sort keys to ensure consistent display order
91+
table.sort(seen_actions[action])
92+
local keys = table.concat(seen_actions[action], "/")
93+
table.insert(footer, { " " .. keys .. " ", "Title" })
94+
table.insert(footer, { "- " .. action:sub(1, 1):upper() .. action:sub(2) .. " ", "Comment" })
95+
end
96+
end
97+
98+
local bufid, winid = util.create_scratch_floatwin(
99+
title,
100+
vim.tbl_deep_extend("force", {
101+
footer = footer,
102+
footer_pos = "center",
103+
}, win_config)
104+
)
105+
106+
---@cast output Output
107+
render_to_buffer(bufid, output)
108+
109+
vim.bo.modifiable = false
110+
-- Set filetype to enable syntax highlighting
111+
vim.bo.filetype = "markdown"
112+
113+
for option, value in pairs(win_options) do
114+
vim.api.nvim_set_option_value(option, value, { scope = "local", win = winid })
115+
end
116+
117+
local done = false
118+
119+
vim.api.nvim_create_autocmd("BufWipeout", {
120+
buffer = bufid,
121+
callback = function()
122+
if not done then
123+
if on_choice then
124+
on_choice()
125+
end
126+
end
127+
end,
128+
})
129+
130+
local function finish(choice)
131+
if on_choice then
132+
on_choice(choice)
133+
end
134+
done = true
135+
vim.api.nvim_win_close(winid, false)
136+
end
137+
138+
local function close_window()
139+
done = true
140+
vim.api.nvim_win_close(winid, false)
141+
end
142+
143+
for key, action in pairs(mappings) do
144+
if type(action) == "string" then
145+
local action_lower = action:lower()
146+
if action_lower == "close" then
147+
-- Close window without calling callback
148+
vim.keymap.set("n", key, close_window, { buffer = bufid, remap = false, nowait = true })
149+
else
150+
-- Pass the action (once/always/reject) to the callback
151+
vim.keymap.set("n", key, function()
152+
finish(action_lower)
153+
end, { buffer = bufid, remap = false, nowait = true })
154+
end
155+
end
156+
end
157+
end
158+
159+
return M

lua/opencode/ui/formatter.lua

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
local M = {}
2+
3+
---@param output Output
4+
---@param code string
5+
---@param file_type string
6+
function M._format_diff(output, code, file_type)
7+
--- NOTE: use longer code fence because code could contain ```
8+
output:add_line("`````" .. file_type)
9+
local lines = vim.split(code, "\n")
10+
if #lines > 5 then
11+
lines = vim.list_slice(lines, 6)
12+
end
13+
14+
for _, line in ipairs(lines) do
15+
local first_char = line:sub(1, 1)
16+
if first_char == "+" or first_char == "-" then
17+
local hl_group = first_char == "+" and "DiffAdd" or "DiffDelete"
18+
output:add_line(" " .. line:sub(2))
19+
local line_idx = output:get_line_count()
20+
output:add_extmark(line_idx - 1, function()
21+
return {
22+
end_col = 0,
23+
end_row = line_idx,
24+
virt_text = { { first_char, hl_group } },
25+
hl_group = hl_group,
26+
hl_eol = true,
27+
priority = 5000,
28+
right_gravity = true,
29+
end_right_gravity = false,
30+
virt_text_hide = false,
31+
virt_text_pos = "overlay",
32+
virt_text_repeat_linebreak = false,
33+
}
34+
end)
35+
else
36+
output:add_line(line)
37+
end
38+
end
39+
output:add_line("`````")
40+
end
41+
42+
return M

lua/opencode/ui/output.lua

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
local Output = {}
2+
Output.__index = Output
3+
4+
---@alias OutputExtmarkType vim.api.keyset.set_extmark & {start_col:0}
5+
---@alias OutputExtmark OutputExtmarkType|fun():OutputExtmarkType
6+
7+
---@class OutputAction
8+
---@field text string Action text
9+
---@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
10+
---@field args? string[] Optional arguments for the command
11+
---@field key string keybinding for the action
12+
---@field display_line number Line number to display the action
13+
---@field range? { from: number, to: number } Optional range for the action
14+
15+
---@class Output
16+
---@field lines string[]
17+
---@field extmarks table<number, OutputExtmark[]>
18+
---@field actions OutputAction[]
19+
---@field add_line fun(self: Output, line: string, fit?: boolean): number
20+
---@field get_line fun(self: Output, idx: number): string?
21+
---@field merge_line fun(self: Output, idx: number, text: string)
22+
---@field add_lines fun(self: Output, lines: string[], prefix?: string)
23+
---@field add_empty_line fun(self: Output): number?
24+
---@field clear fun(self: Output)
25+
---@field get_line_count fun(self: Output): number
26+
---@field get_lines fun(self: Output): string[]
27+
---@field add_extmark fun(self: Output, idx: number, extmark: OutputExtmark|fun(): OutputExtmark)
28+
---@field get_extmarks fun(self: Output): table<number, table[]>
29+
---@field add_actions fun(self: Output, actions: OutputAction[])
30+
---@field add_action fun(self: Output, action: OutputAction)
31+
---@field get_actions_for_line fun(self: Output, line: number): OutputAction[]?
32+
---@return self Output
33+
function Output.new()
34+
local self = setmetatable({}, Output)
35+
self.lines = {}
36+
self.extmarks = {}
37+
self.actions = {}
38+
return self
39+
end
40+
41+
---Add a new line
42+
---@param line string
43+
---@return number index The index of the added line
44+
function Output:add_line(line)
45+
table.insert(self.lines, line)
46+
return #self.lines
47+
end
48+
49+
---Get line by index
50+
---@param idx number
51+
---@return string?
52+
function Output:get_line(idx)
53+
return self.lines[idx]
54+
end
55+
56+
---Merge text into an existing line
57+
---@param idx number
58+
---@param text string
59+
function Output:merge_line(idx, text)
60+
if self.lines[idx] then
61+
self.lines[idx] = self.lines[idx] .. text
62+
end
63+
end
64+
65+
---Add multiple lines
66+
---@param lines string[]
67+
---@param prefix? string Optional prefix for each line
68+
function Output:add_lines(lines, prefix)
69+
for _, line in ipairs(lines) do
70+
if line == "" then
71+
table.insert(self.lines, "")
72+
else
73+
prefix = prefix or ""
74+
table.insert(self.lines, prefix .. line)
75+
end
76+
end
77+
end
78+
79+
---Add an empty line if the last line is not empty
80+
---@return number? index The index of the added line, or nil if no line was added
81+
function Output:add_empty_line()
82+
local line_count = #self.lines
83+
if line_count == 0 or self.lines[line_count] ~= "" then
84+
table.insert(self.lines, "")
85+
return line_count + 1
86+
end
87+
return nil
88+
end
89+
90+
---Clear all lines, extmarks, and actions
91+
function Output:clear()
92+
self.lines = {}
93+
self.extmarks = {}
94+
self.actions = {}
95+
end
96+
97+
---Get the number of lines
98+
---@return number
99+
function Output:get_line_count()
100+
return #self.lines
101+
end
102+
103+
---Get all lines as a table
104+
---@return string[]
105+
function Output:get_lines()
106+
return vim.deepcopy(self.lines)
107+
end
108+
109+
---Add an extmark for a specific line
110+
---@param idx number The line index
111+
---@param extmark OutputExtmark|fun(): OutputExtmark The extmark data or a function returning it
112+
function Output:add_extmark(idx, extmark)
113+
if not self.extmarks[idx] then
114+
self.extmarks[idx] = {}
115+
end
116+
table.insert(self.extmarks[idx], extmark)
117+
end
118+
119+
---Get all extmarks
120+
---@return table<number, table[]>
121+
function Output:get_extmarks()
122+
return vim.deepcopy(self.extmarks)
123+
end
124+
125+
---Add contextual actions
126+
---@param actions OutputAction[] The actions to add
127+
function Output:add_actions(actions)
128+
for _, action in ipairs(actions) do
129+
table.insert(self.actions, action)
130+
end
131+
end
132+
133+
---Add contextual action
134+
---@param action OutputAction The actions to add
135+
function Output:add_action(action)
136+
if not action.display_line then
137+
action.display_line = #self.lines - 1
138+
end
139+
if not action.range then
140+
action.range = { from = #self.lines, to = #self.lines }
141+
end
142+
table.insert(self.actions, action)
143+
end
144+
145+
---Get actions for a line matching a range
146+
---@param line number The line index to check
147+
---@return OutputAction[]|nil
148+
function Output:get_actions_for_line(line)
149+
local actions = {}
150+
for _, action in pairs(self.actions) do
151+
if not action.range then
152+
if line == action.display_line then
153+
table.insert(actions, vim.deepcopy(action))
154+
end
155+
elseif action.range then
156+
if line >= action.range.from and line <= action.range.to then
157+
table.insert(actions, vim.deepcopy(action))
158+
end
159+
end
160+
end
161+
return #actions > 0 and actions or nil
162+
end
163+
164+
return Output

0 commit comments

Comments
 (0)