Skip to content

Commit d683fee

Browse files
committed
refactor(permisson-window): behave like the question-window
- Refactor permission_window to use the same dialog as question-window
1 parent 2867629 commit d683fee

11 files changed

+549
-352
lines changed

lua/opencode/config.lua

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,6 @@ M.defaults = {
8585
['<leader>oO'] = { 'debug_output' },
8686
['<leader>ods'] = { 'debug_session' },
8787
},
88-
permission = {
89-
accept = 'a',
90-
accept_all = 'A',
91-
deny = 'd',
92-
},
9388
session_picker = {
9489
rename_session = { '<C-r>' },
9590
delete_session = { '<C-d>' },

lua/opencode/ui/dialog.lua

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
---@class DialogConfig
2+
---@field buffer integer Buffer ID where keymaps should be set
3+
---@field on_select function(index: integer) Called when an option is selected
4+
---@field on_dismiss? function() Called when dialog is dismissed
5+
---@field on_navigate? function() Called when selection changes
6+
---@field get_option_count function(): integer Returns the total number of options
7+
---@field check_focused? function(): boolean Returns whether dialog should be active
8+
---@field keymaps? DialogKeymaps Custom keymap configuration
9+
---@field namespace_prefix? string Prefix for vim.on_key namespace (default: 'opencode_dialog')
10+
---@field hide_input? boolean Whether to hide the input window when dialog is active (default: true)
11+
12+
---@class DialogKeymaps
13+
---@field up? string[] Keys for navigating up (default: {'k', '<Up>'})
14+
---@field down? string[] Keys for navigating down (default: {'j', '<Down>'})
15+
---@field select? string Key for selecting current option (default: '<CR>')
16+
---@field dismiss? string Key for dismissing dialog (default: '<Esc>')
17+
---@field number_shortcuts? boolean Enable 1-9 number shortcuts (default: true)
18+
19+
---@class Dialog
20+
---@field private _config DialogConfig
21+
---@field private _keymaps integer[] List of keymap IDs for cleanup
22+
---@field private _key_capture_ns integer? Namespace for vim.on_key
23+
---@field private _selected_index integer Currently selected option index
24+
---@field private _active boolean Whether dialog is currently active
25+
local Dialog = {}
26+
Dialog.__index = Dialog
27+
28+
---Create a new dialog instance
29+
---@param config DialogConfig Dialog configuration
30+
---@return Dialog
31+
function Dialog.new(config)
32+
local self = setmetatable({}, Dialog)
33+
34+
-- Set up default keymaps if not provided
35+
local default_keymaps = {
36+
up = { 'k', '<Up>' },
37+
down = { 'j', '<Down>' },
38+
select = '<CR>',
39+
dismiss = '<Esc>',
40+
number_shortcuts = true,
41+
}
42+
43+
self._config = vim.tbl_deep_extend('force', {
44+
keymaps = default_keymaps,
45+
namespace_prefix = 'opencode_dialog',
46+
check_focused = function()
47+
return true
48+
end,
49+
hide_input = true,
50+
} --[[@as DialogConfig]], config)
51+
52+
self._keymaps = {}
53+
self._key_capture_ns = nil
54+
self._selected_index = 1
55+
self._active = false
56+
57+
return self
58+
end
59+
60+
---Get the currently selected option index
61+
---@return integer
62+
function Dialog:get_selection()
63+
return self._selected_index
64+
end
65+
66+
---Set the selected option index
67+
---@param index integer Option index to select
68+
function Dialog:set_selection(index)
69+
local option_count = self._config.get_option_count()
70+
if index >= 1 and index <= option_count then
71+
self._selected_index = index
72+
end
73+
end
74+
75+
---Navigate selection by delta (positive for down, negative for up)
76+
---@param delta integer Amount to move selection
77+
function Dialog:navigate(delta)
78+
if not self._active or not self._config.check_focused() then
79+
return
80+
end
81+
82+
local option_count = self._config.get_option_count()
83+
if option_count == 0 then
84+
return
85+
end
86+
87+
self._selected_index = self._selected_index + delta
88+
89+
-- Wrap around selection
90+
if self._selected_index < 1 then
91+
self._selected_index = option_count
92+
elseif self._selected_index > option_count then
93+
self._selected_index = 1
94+
end
95+
96+
if self._config.on_navigate then
97+
self._config.on_navigate()
98+
end
99+
end
100+
101+
---Select the current option
102+
function Dialog:select()
103+
if not self._active or not self._config.check_focused() then
104+
return
105+
end
106+
107+
local option_count = self._config.get_option_count()
108+
if option_count == 0 then
109+
return
110+
end
111+
112+
self._config.on_select(self._selected_index)
113+
end
114+
115+
---Dismiss the dialog
116+
function Dialog:dismiss()
117+
if not self._active or not self._config.check_focused() then
118+
return
119+
end
120+
121+
if self._config.on_dismiss then
122+
self._config.on_dismiss()
123+
end
124+
end
125+
126+
---Set up keymaps and activate the dialog
127+
function Dialog:setup()
128+
if self._active then
129+
self:teardown()
130+
end
131+
132+
self._active = true
133+
134+
-- Hide input window if configured
135+
if self._config.hide_input then
136+
local input_window = require('opencode.ui.input_window')
137+
input_window._hide()
138+
end
139+
140+
self:_setup_keymaps()
141+
end
142+
143+
---Clean up keymaps and deactivate the dialog
144+
function Dialog:teardown()
145+
self._active = false
146+
self:_clear_keymaps()
147+
148+
-- Show input window if it was hidden
149+
if self._config.hide_input then
150+
local input_window = require('opencode.ui.input_window')
151+
input_window._show()
152+
end
153+
end
154+
155+
---Check if dialog is currently active
156+
---@return boolean
157+
function Dialog:is_active()
158+
return self._active
159+
end
160+
161+
---Format the legend/instructions for this dialog
162+
---@param output Output Output object to write to
163+
---@param options? table Options for legend formatting
164+
function Dialog:format_legend(output, options)
165+
options = options or {}
166+
local ui = require('opencode.ui.ui')
167+
168+
if not self._active then
169+
return
170+
end
171+
172+
local option_count = self._config.get_option_count()
173+
if option_count == 0 then
174+
return
175+
end
176+
177+
if ui.is_opencode_focused() then
178+
local legend_parts = {}
179+
local keymaps = self._config.keymaps
180+
if not keymaps then
181+
return
182+
end
183+
184+
if keymaps.up and #keymaps.up > 0 and keymaps.down and #keymaps.down > 0 then
185+
table.insert(legend_parts, string.format('Navigate: `%s`/`%s` or `↑`/`↓`', keymaps.down[1], keymaps.up[1]))
186+
end
187+
188+
if keymaps.select and keymaps.select ~= '' then
189+
local select_text = string.format('Select: `%s`', keymaps.select)
190+
if keymaps.number_shortcuts and option_count > 0 then
191+
local max_shortcut = math.min(option_count, 9)
192+
select_text = select_text .. string.format(' or `1-%d`', max_shortcut)
193+
end
194+
table.insert(legend_parts, select_text)
195+
end
196+
197+
if keymaps.dismiss and keymaps.dismiss ~= '' then
198+
table.insert(legend_parts, string.format('Dismiss: `%s`', keymaps.dismiss))
199+
end
200+
201+
if #legend_parts > 0 then
202+
output:add_line(table.concat(legend_parts, ' '))
203+
end
204+
else
205+
local message = options.unfocused_message or 'Focus Opencode window to interact'
206+
output:add_line(message)
207+
end
208+
end
209+
210+
---Format a complete dialog with title, options, legend, and border
211+
---@param output Output Output object to write to
212+
---@param config table Configuration for dialog rendering
213+
--- - title: string - Dialog title
214+
--- - title_hl: string - Highlight group for title
215+
--- - border_hl: string - Highlight group for border
216+
--- - options: table[] - Array of option objects with {label: string, description?: string}
217+
--- - unfocused_message: string - Message to show when not focused
218+
--- - progress?: string - Progress indicator (e.g., "(1/3)")
219+
--- - content?: string[] - Array of lines to render before options
220+
--- - render_content?: function(output: Output) - Custom function to render content before options
221+
function Dialog:format_dialog(output, config)
222+
if not self._active then
223+
return
224+
end
225+
226+
local formatter = require('opencode.ui.formatter')
227+
local icons = require('opencode.ui.icons')
228+
229+
local start_line = output:get_line_count()
230+
231+
local title = config.title or 'Dialog'
232+
if config.progress then
233+
title = title .. config.progress
234+
end
235+
236+
output:add_line(title)
237+
if config.title_hl then
238+
output:add_extmark(start_line, { line_hl_group = config.title_hl } --[[@as OutputExtmark]])
239+
end
240+
output:add_line('')
241+
242+
if config.render_content then
243+
config.render_content(output)
244+
output:add_line('')
245+
elseif config.content then
246+
for _, line in ipairs(config.content) do
247+
output:add_line(line)
248+
end
249+
output:add_line('')
250+
end
251+
252+
self:format_options(output, config.options or {})
253+
254+
output:add_line('')
255+
256+
self:format_legend(output, { unfocused_message = config.unfocused_message })
257+
258+
local end_line = output:get_line_count()
259+
260+
-- Border
261+
if config.border_hl then
262+
formatter.add_vertical_border(output, start_line + 1, end_line, config.border_hl, -2)
263+
end
264+
265+
output:add_line('')
266+
end
267+
268+
---Format options list with selection indicator
269+
---@param output Output Output object to write to
270+
---@param options table[] Array of option objects with {label: string, description?: string}
271+
function Dialog:format_options(output, options)
272+
for i, option in ipairs(options) do
273+
local label = option.label
274+
if option.description and option.description ~= '' then
275+
label = label .. ' - ' .. option.description
276+
end
277+
278+
local line_idx = output:get_line_count()
279+
local is_selected = self._selected_index == i
280+
local line_text = is_selected and string.format(' %d. %s ', i, label) or string.format(' %d. %s', i, label)
281+
282+
output:add_line(line_text)
283+
284+
if is_selected then
285+
output:add_extmark(line_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]])
286+
output:add_extmark(line_idx, {
287+
start_col = 2,
288+
virt_text = { { '', 'OpencodeDialogOptionHover' } },
289+
virt_text_pos = 'overlay',
290+
} --[[@as OutputExtmark]])
291+
end
292+
end
293+
end
294+
295+
---Set up buffer-scoped keymaps
296+
function Dialog:_setup_keymaps()
297+
self:_clear_keymaps()
298+
299+
local buf = self._config.buffer
300+
if not buf or not vim.api.nvim_buf_is_valid(buf) then
301+
return
302+
end
303+
304+
local keymaps = self._config.keymaps
305+
local keymap_opts = { buffer = buf, silent = true }
306+
307+
if keymaps.up then
308+
for _, key in ipairs(keymaps.up) do
309+
if key and key ~= '' then
310+
local id = vim.keymap.set('n', key, function()
311+
self:navigate(-1)
312+
end, keymap_opts)
313+
table.insert(self._keymaps, id)
314+
end
315+
end
316+
end
317+
318+
if keymaps.down then
319+
for _, key in ipairs(keymaps.down) do
320+
if key and key ~= '' then
321+
local id = vim.keymap.set('n', key, function()
322+
self:navigate(1)
323+
end, keymap_opts)
324+
table.insert(self._keymaps, id)
325+
end
326+
end
327+
end
328+
329+
if keymaps.select and keymaps.select ~= '' then
330+
local id = vim.keymap.set('n', keymaps.select, function()
331+
self:select()
332+
end, keymap_opts)
333+
table.insert(self._keymaps, id)
334+
end
335+
336+
if keymaps.dismiss and keymaps.dismiss ~= '' then
337+
local id = vim.keymap.set('n', keymaps.dismiss, function()
338+
self:dismiss()
339+
end, keymap_opts)
340+
table.insert(self._keymaps, id)
341+
end
342+
343+
if keymaps.number_shortcuts then
344+
local option_count = self._config.get_option_count()
345+
local number_keymap_opts = vim.tbl_extend('force', keymap_opts, { nowait = true })
346+
for i = 1, math.min(option_count, 9) do
347+
local id = vim.keymap.set('n', tostring(i), function()
348+
if not self._active or not self._config.check_focused() then
349+
return
350+
end
351+
self._selected_index = i
352+
self._config.on_select(i)
353+
end, number_keymap_opts)
354+
table.insert(self._keymaps, id)
355+
end
356+
end
357+
end
358+
359+
---Clear all buffer-scoped keymaps
360+
function Dialog:_clear_keymaps()
361+
for _, keymap_id in ipairs(self._keymaps) do
362+
pcall(vim.keymap.del, 'n', keymap_id, { buffer = self._config.buffer })
363+
end
364+
self._keymaps = {}
365+
end
366+
367+
return Dialog

lua/opencode/ui/highlight.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ function M.setup()
4040
vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#1976D2', default = true })
4141
vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true })
4242
vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true })
43+
vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#E3F2FD', fg = '#1976D2', default = true })
4344
vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true })
44-
vim.api.nvim_set_hl(0, 'OpencodeQuestionOptionHover', { bg = '#E3F2FD', fg = '#1976D2', default = true })
4545
vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#E3F2FD', default = true })
4646
vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true })
4747
else
@@ -80,8 +80,8 @@ function M.setup()
8080
vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#7AA2F7', default = true })
8181
vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true })
8282
vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true })
83+
vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#2B3A5A', fg = '#61AFEF', default = true })
8384
vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true })
84-
vim.api.nvim_set_hl(0, 'OpencodeQuestionOptionHover', { bg = '#2B3A5A', fg = '#61AFEF', default = true })
8585
vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#2B3A5A', default = true })
8686
vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true })
8787
end

0 commit comments

Comments
 (0)