Skip to content

Commit de38869

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 de38869

6 files changed

Lines changed: 874 additions & 38 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: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,67 @@ function M.add_permission(permission)
1515
return
1616
end
1717

18+
if permission.tool then
19+
permission._message_id = permission.tool.messageID
20+
permission._call_id = permission.tool.callID
21+
end
22+
1823
-- Update if exists, otherwise add
1924
for i, existing in ipairs(M._permission_queue) do
2025
if existing.id == permission.id then
2126
M._permission_queue[i] = permission
22-
M._setup_dialog() -- Refresh dialog when permission is updated
27+
M._setup_dialog()
2328
return
2429
end
2530
end
2631

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

3181
---Remove permission from queue
@@ -71,12 +121,24 @@ function M.format_display(output)
71121
progress = string.format(' (%d/%d)', 1, #M._permission_queue)
72122
end
73123

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

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

81143
local options = {
82144
{ label = 'Allow once' },
@@ -88,6 +150,9 @@ function M.format_display(output)
88150
if perm_type == 'edit' and permission.metadata and permission.metadata.diff then
89151
render_content = function(out)
90152
out:add_line(content[1])
153+
if content[2] then
154+
out:add_line(content[2])
155+
end
91156
out:add_line('')
92157

93158
local file_type = permission.metadata.filepath and vim.fn.fnamemodify(permission.metadata.filepath, ':e') or ''
@@ -112,6 +177,11 @@ function M._setup_dialog()
112177
return
113178
end
114179

180+
local saved_selection = nil
181+
if M._dialog then
182+
saved_selection = M._dialog:get_selection()
183+
end
184+
115185
M._clear_dialog()
116186

117187
if not state.windows or not state.windows.output_buf then
@@ -176,6 +246,10 @@ function M._setup_dialog()
176246
})
177247

178248
M._dialog:setup()
249+
250+
if saved_selection then
251+
M._dialog:set_selection(saved_selection)
252+
end
179253
end
180254

181255
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)