Skip to content

Commit 90b2bcb

Browse files
committed
feat(permission_window): render description/command from part updates
Update permission window rendering to prefer message.part.updated description and command, wire renderer correlation for part-to-permission updates, and add regression coverage including replay expectations and collapsing-order handling.
1 parent 6f9dd55 commit 90b2bcb

6 files changed

Lines changed: 877 additions & 43 deletions

File tree

lua/opencode/event_manager.lua

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -273,14 +273,27 @@ function EventManager:_on_drained_events(events)
273273
if event.type == 'message.part.updated' and event.properties.part then
274274
local part_id = event.properties.part.id
275275
if part_update_indices[part_id] then
276-
-- vim.notify('collapsing: ' .. part_id .. ' text: ' .. vim.inspect(event.properties.part.text))
277-
-- put this event in the earlier slot
278-
279-
-- move this newer part to the position of the original part
280-
collapsed_events[part_update_indices[part_id]] = event
281-
282-
-- clear out this parts now unneeded position
283-
collapsed_events[i] = nil
276+
local previous_index = part_update_indices[part_id]
277+
278+
-- Preserve ordering dependencies for permission events.
279+
-- Moving a later part update earlier can break correlation when
280+
-- permission.updated/permission.asked sits between the two updates.
281+
local has_intervening_permission_event = false
282+
for j = previous_index + 1, i - 1 do
283+
if events[j] and (events[j].type == 'permission.updated' or events[j].type == 'permission.asked') then
284+
has_intervening_permission_event = true
285+
break
286+
end
287+
end
288+
289+
if has_intervening_permission_event then
290+
collapsed_events[previous_index] = nil
291+
collapsed_events[i] = event
292+
part_update_indices[part_id] = i
293+
else
294+
collapsed_events[previous_index] = event
295+
collapsed_events[i] = nil
296+
end
284297
else
285298
part_update_indices[part_id] = i
286299
collapsed_events[i] = event

lua/opencode/ui/permission_window.lua

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ local Dialog = require('opencode.ui.dialog')
33

44
local M = {}
55

6-
-- Simple state
76
M._permission_queue = {}
87
M._dialog = nil
98
M._processing = false
@@ -15,17 +14,66 @@ function M.add_permission(permission)
1514
return
1615
end
1716

18-
-- Update if exists, otherwise add
17+
if permission.tool then
18+
permission._message_id = permission.tool.messageID
19+
permission._call_id = permission.tool.callID
20+
end
21+
1922
for i, existing in ipairs(M._permission_queue) do
2023
if existing.id == permission.id then
2124
M._permission_queue[i] = permission
22-
M._setup_dialog() -- Refresh dialog when permission is updated
25+
M._setup_dialog()
2326
return
2427
end
2528
end
2629

2730
table.insert(M._permission_queue, permission)
28-
M._setup_dialog() -- Setup dialog when first permission is added
31+
M._setup_dialog()
32+
end
33+
34+
---Update permission from message part data
35+
---@param permission_id string
36+
---@param part table
37+
---@return boolean
38+
function M.update_permission_from_part(permission_id, part)
39+
if not permission_id or not part then
40+
return false
41+
end
42+
43+
local permission = nil
44+
for i, existing in ipairs(M._permission_queue) do
45+
if existing.id == permission_id then
46+
permission = existing
47+
break
48+
end
49+
end
50+
51+
if not permission then
52+
return false
53+
end
54+
55+
if part.state and part.state.input then
56+
local input = part.state.input
57+
local updated = false
58+
59+
if input.description and input.description ~= '' then
60+
permission._description = input.description
61+
updated = true
62+
end
63+
64+
if input.command and input.command ~= '' then
65+
permission._command = input.command
66+
updated = true
67+
end
68+
69+
if updated and M._dialog then
70+
M._setup_dialog()
71+
end
72+
73+
return true
74+
end
75+
76+
return false
2977
end
3078

3179
---Remove permission from queue
@@ -41,7 +89,7 @@ function M.remove_permission(permission_id)
4189
if #M._permission_queue == 0 then
4290
M._clear_dialog()
4391
else
44-
M._setup_dialog() -- Setup dialog for next permission
92+
M._setup_dialog()
4593
end
4694
end
4795

@@ -71,12 +119,24 @@ function M.format_display(output)
71119
progress = string.format(' (%d/%d)', 1, #M._permission_queue)
72120
end
73121

74-
local title = permission.title
75-
or table.concat(permission.patterns or {}, '`, `'):gsub('\r', '\\r'):gsub('\n', '\\n')
76-
or 'Unknown Permission'
77122
local perm_type = permission.permission or permission.type or 'unknown'
78123

79-
local content = { (icons.get(perm_type) or '') .. ' *' .. (perm_type or '') .. '*' .. ' `' .. title .. '`' }
124+
local display_text
125+
if permission._description and permission._description ~= '' then
126+
display_text = (icons.get(perm_type) or '') .. ' *' .. (perm_type or '') .. '* ' .. permission._description
127+
elseif permission.title then
128+
display_text = (icons.get(perm_type) or '') .. ' *' .. (perm_type or '') .. '* `' .. permission.title .. '`'
129+
else
130+
local patterns = table.concat(permission.patterns or {}, '`, `'):gsub('\r', '\\r'):gsub('\n', '\\n')
131+
display_text = (icons.get(perm_type) or '') .. ' *' .. (perm_type or '') .. '* `' .. (patterns ~= '' and patterns or 'Unknown Permission') .. '`'
132+
end
133+
134+
local content = { display_text }
135+
136+
if permission._command and permission._command ~= '' then
137+
local escaped_command = permission._command:gsub('\r', '\\r'):gsub('\n', '\\n')
138+
table.insert(content, '> `' .. escaped_command .. '`')
139+
end
80140

81141
local options = {
82142
{ label = 'Allow once' },
@@ -88,6 +148,9 @@ function M.format_display(output)
88148
if perm_type == 'edit' and permission.metadata and permission.metadata.diff then
89149
render_content = function(out)
90150
out:add_line(content[1])
151+
if content[2] then
152+
out:add_line(content[2])
153+
end
91154
out:add_line('')
92155

93156
local file_type = permission.metadata.filepath and vim.fn.fnamemodify(permission.metadata.filepath, ':e') or ''
@@ -112,6 +175,11 @@ function M._setup_dialog()
112175
return
113176
end
114177

178+
local saved_selection = nil
179+
if M._dialog then
180+
saved_selection = M._dialog:get_selection()
181+
end
182+
115183
M._clear_dialog()
116184

117185
if not state.windows or not state.windows.output_buf then
@@ -160,7 +228,7 @@ function M._setup_dialog()
160228
end
161229

162230
local function get_option_count()
163-
return #M._permission_queue > 0 and 3 or 0 -- accept, deny, accept_all
231+
return #M._permission_queue > 0 and 3 or 0
164232
end
165233

166234
M._dialog = Dialog.new({
@@ -171,11 +239,15 @@ function M._setup_dialog()
171239
check_focused = check_focused,
172240
namespace_prefix = 'opencode_permission',
173241
keymaps = {
174-
dismiss = '', -- Disable dismiss keymap and legend
242+
dismiss = '',
175243
},
176244
})
177245

178246
M._dialog:setup()
247+
248+
if saved_selection then
249+
M._dialog:set_selection(saved_selection)
250+
end
179251
end
180252

181253
function M._clear_dialog()

lua/opencode/ui/renderer.lua

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,19 @@ function M.on_part_updated(properties, revert_index)
727727

728728
local formatted = formatter.format_part(part, message, is_last_part)
729729

730+
if part.callID and state.pending_permissions then
731+
for _, permission in ipairs(state.pending_permissions) do
732+
local tool = permission.tool
733+
local perm_callID = tool and tool.callID or permission.callID
734+
local perm_messageID = tool and tool.messageID or permission.messageID
735+
736+
if perm_callID == part.callID and perm_messageID == part.messageID then
737+
require('opencode.ui.permission_window').update_permission_from_part(permission.id, part)
738+
break
739+
end
740+
end
741+
end
742+
730743
if revert_index and is_new_part then
731744
return
732745
end
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"lines":["----","","","Perfect! Now I understand how it works. The message headers have extmarks with `virt_text` where the first element contains the icon (either `header_user` or `header_assistant`). Let me check the output_window module to understand the extmark namespace:","","** run** `Find extmark namespace usage`","","`````bash","> rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2","","`````","","----","",""," Permission Required",""," *bash* `rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2`",""," 1. Allow once "," 2. Reject"," 3. Allow always","","Navigate: `j`/`k` or `↑`/`↓` Select: `<CR>` or `1-3`","",""],"extmarks":[[1,1,0,{"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-16 04:27:36)","OpencodeHint"],[" [msg_9eb45fbe60020xE560OGH3Vdoo]","OpencodeHint"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3}],[2,5,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[3,6,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[4,7,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[5,8,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[6,9,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[7,10,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[8,13,0,{"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3}],[9,15,0,{"priority":4096,"line_hl_group":"OpencodePermissionTitle","ns_id":3,"right_gravity":true}],[10,15,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[11,16,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[12,17,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[13,18,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[14,19,0,{"priority":4096,"line_hl_group":"OpencodeDialogOptionHover","ns_id":3,"right_gravity":true}],[15,19,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[16,19,2,{"virt_text":[["› ","OpencodeDialogOptionHover"]],"virt_text_pos":"overlay","ns_id":3,"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":4096}],[17,20,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[18,21,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[19,22,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[20,23,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}]],"timestamp":1769449859,"actions":[]}
1+
{"lines":["----","","","Perfect! Now I understand how it works. The message headers have extmarks with `virt_text` where the first element contains the icon (either `header_user` or `header_assistant`). Let me check the output_window module to understand the extmark namespace:","","** run** `Find extmark namespace usage`","","`````bash","> rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2","","`````","","----","",""," Permission Required",""," *bash* Find extmark namespace usage","> `rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2`",""," 1. Allow once "," 2. Reject"," 3. Allow always","","Navigate: `j`/`k` or `↑`/`↓` Select: `<CR>` or `1-3`","",""],"extmarks":[[1,1,0,{"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-16 04:27:36)","OpencodeHint"],[" [msg_9eb45fbe60020xE560OGH3Vdoo]","OpencodeHint"]],"virt_text_repeat_linebreak":false,"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_pos":"win_col","right_gravity":true}],[2,5,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[3,6,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[4,7,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[5,8,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[6,9,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[7,10,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[8,13,0,{"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"virt_text_repeat_linebreak":false,"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_pos":"win_col","right_gravity":true}],[9,15,0,{"ns_id":3,"priority":4096,"right_gravity":true,"line_hl_group":"OpencodePermissionTitle"}],[10,15,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[11,16,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[12,17,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[13,18,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[14,19,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[15,20,0,{"ns_id":3,"priority":4096,"right_gravity":true,"line_hl_group":"OpencodeDialogOptionHover"}],[16,20,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[17,20,2,{"virt_text_hide":false,"virt_text":[["› ","OpencodeDialogOptionHover"]],"virt_text_repeat_linebreak":false,"ns_id":3,"priority":4096,"virt_text_pos":"overlay","right_gravity":true}],[18,21,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[19,22,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[20,23,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}],[21,24,0,{"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_pos":"win_col","right_gravity":true}]],"actions":[]}

0 commit comments

Comments
 (0)