Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ local defaults = {
permissions = {
enabled = true,
idle_delay_ms = 1000,
confirm = {
enabled = false,
window = {
config = {},
options = {},
mappings = {
["a"] = "Once",
["<CR>"] = "Once",
["A"] = "Always",
["r"] = "Reject",
["q"] = "Close",
},
},
},
},
},
provider = {
Expand Down
159 changes: 159 additions & 0 deletions lua/opencode/ui/confirm.lua
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
@@ -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
164 changes: 164 additions & 0 deletions lua/opencode/ui/output.lua
Original file line number Diff line number Diff line change
@@ -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<number, OutputExtmark[]>
---@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<number, 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<number, 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
Loading