diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index 665b36d7..f0384ceb 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -273,14 +273,27 @@ function EventManager:_on_drained_events(events) if event.type == 'message.part.updated' and event.properties.part then local part_id = event.properties.part.id if part_update_indices[part_id] then - -- vim.notify('collapsing: ' .. part_id .. ' text: ' .. vim.inspect(event.properties.part.text)) - -- put this event in the earlier slot - - -- move this newer part to the position of the original part - collapsed_events[part_update_indices[part_id]] = event - - -- clear out this parts now unneeded position - collapsed_events[i] = nil + local previous_index = part_update_indices[part_id] + + -- Preserve ordering dependencies for permission events. + -- Moving a later part update earlier can break correlation when + -- permission.updated/permission.asked sits between the two updates. + local has_intervening_permission_event = false + for j = previous_index + 1, i - 1 do + if events[j] and (events[j].type == 'permission.updated' or events[j].type == 'permission.asked') then + has_intervening_permission_event = true + break + end + end + + if has_intervening_permission_event then + collapsed_events[previous_index] = nil + collapsed_events[i] = event + part_update_indices[part_id] = i + else + collapsed_events[previous_index] = event + collapsed_events[i] = nil + end else part_update_indices[part_id] = i collapsed_events[i] = event diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index 30880a9e..9a9d095b 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -15,17 +15,67 @@ function M.add_permission(permission) return end + if permission.tool then + permission._message_id = permission.tool.messageID + permission._call_id = permission.tool.callID + end + -- Update if exists, otherwise add for i, existing in ipairs(M._permission_queue) do if existing.id == permission.id then M._permission_queue[i] = permission - M._setup_dialog() -- Refresh dialog when permission is updated + M._setup_dialog() return end end table.insert(M._permission_queue, permission) - M._setup_dialog() -- Setup dialog when first permission is added + M._setup_dialog() +end + +---Update permission from message part data +---@param permission_id string +---@param part table +---@return boolean +function M.update_permission_from_part(permission_id, part) + if not permission_id or not part then + return false + end + + local permission = nil + for i, existing in ipairs(M._permission_queue) do + if existing.id == permission_id then + permission = existing + break + end + end + + if not permission then + return false + end + + if part.state and part.state.input then + local input = part.state.input + local updated = false + + if input.description and input.description ~= '' then + permission._description = input.description + updated = true + end + + if input.command and input.command ~= '' then + permission._command = input.command + updated = true + end + + if updated and M._dialog then + M._setup_dialog() + end + + return true + end + + return false end ---Remove permission from queue @@ -71,12 +121,33 @@ function M.format_display(output) progress = string.format(' (%d/%d)', 1, #M._permission_queue) end - local title = permission.title - or table.concat(permission.patterns or {}, '`, `'):gsub('\r', '\\r'):gsub('\n', '\\n') - or 'Unknown Permission' - local perm_type = permission.permission or permission.type or 'unknown' + local content = {} + local perm_type = permission.permission or permission.type or '' + + if permission._description and permission._description ~= '' then + table.insert(content, (icons.get(perm_type)) .. ' *' .. perm_type .. '* ' .. permission._description) + elseif permission.title then + table.insert(content, (icons.get(perm_type)) .. ' *' .. perm_type .. '* `' .. permission.title .. '`') + else + table.insert(content, (icons.get(perm_type)) .. ' *' .. perm_type .. '*') + local lines = permission.patterns or {} + table.insert(content, string.format('```%s', perm_type)) + for i, line in ipairs(lines) do + table.insert(content, line) + end + table.insert(content, '```') + end - local content = { (icons.get(perm_type) or '') .. ' *' .. (perm_type or '') .. '*' .. ' `' .. title .. '`' } + table.insert(content, '') + + if permission._command and permission._command ~= '' then + local lines = vim.split(permission._command, '\n') + table.insert(content, string.format('```%s', perm_type)) + for _, line in ipairs(lines) do + table.insert(content, line) + end + table.insert(content, '```') + end local options = { { label = 'Allow once' }, @@ -88,6 +159,9 @@ function M.format_display(output) if perm_type == 'edit' and permission.metadata and permission.metadata.diff then render_content = function(out) out:add_line(content[1]) + if content[2] then + out:add_line(content[2]) + end out:add_line('') local file_type = permission.metadata.filepath and vim.fn.fnamemodify(permission.metadata.filepath, ':e') or '' @@ -112,6 +186,11 @@ function M._setup_dialog() return end + local saved_selection = nil + if M._dialog then + saved_selection = M._dialog:get_selection() + end + M._clear_dialog() if not state.windows or not state.windows.output_buf then @@ -176,6 +255,10 @@ function M._setup_dialog() }) M._dialog:setup() + + if saved_selection then + M._dialog:set_selection(saved_selection) + end end function M._clear_dialog() diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index b26557c4..833a349a 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -727,6 +727,19 @@ function M.on_part_updated(properties, revert_index) local formatted = formatter.format_part(part, message, is_last_part) + if part.callID and state.pending_permissions then + for _, permission in ipairs(state.pending_permissions) do + local tool = permission.tool + local perm_callID = tool and tool.callID or permission.callID + local perm_messageID = tool and tool.messageID or permission.messageID + + if perm_callID == part.callID and perm_messageID == part.messageID then + require('opencode.ui.permission_window').update_permission_from_part(permission.id, part) + break + end + end + end + if revert_index and is_new_part then return end diff --git a/tests/data/ansi-codes.expected.json b/tests/data/ansi-codes.expected.json index 672d3171..0fea652e 100644 --- a/tests/data/ansi-codes.expected.json +++ b/tests/data/ansi-codes.expected.json @@ -8553,5 +8553,5 @@ "", "" ], - "timestamp": 1770748573 + "timestamp": 1770751422 } \ No newline at end of file diff --git a/tests/data/api-abort.expected.json b/tests/data/api-abort.expected.json index 2d65695b..e75b0bf6 100644 --- a/tests/data/api-abort.expected.json +++ b/tests/data/api-abort.expected.json @@ -177,5 +177,5 @@ "", "" ], - "timestamp": 1770748573 + "timestamp": 1770751423 } \ No newline at end of file diff --git a/tests/data/api-error.expected.json b/tests/data/api-error.expected.json index 808c7815..d81010a3 100644 --- a/tests/data/api-error.expected.json +++ b/tests/data/api-error.expected.json @@ -221,5 +221,5 @@ "", "" ], - "timestamp": 1770748573 + "timestamp": 1770751423 } \ No newline at end of file diff --git a/tests/data/cursor_data.expected.json b/tests/data/cursor_data.expected.json index 49a6b011..4d37132d 100644 --- a/tests/data/cursor_data.expected.json +++ b/tests/data/cursor_data.expected.json @@ -308,5 +308,5 @@ "", "" ], - "timestamp": 1770748573 + "timestamp": 1770751423 } \ No newline at end of file diff --git a/tests/data/diagnostics.expected.json b/tests/data/diagnostics.expected.json index bfebbd9a..d4a4d9d5 100644 --- a/tests/data/diagnostics.expected.json +++ b/tests/data/diagnostics.expected.json @@ -11190,5 +11190,5 @@ "", "" ], - "timestamp": 1770748574 + "timestamp": 1770751424 } \ No newline at end of file diff --git a/tests/data/diff.expected.json b/tests/data/diff.expected.json index 5303cfd8..51526f2e 100644 --- a/tests/data/diff.expected.json +++ b/tests/data/diff.expected.json @@ -468,5 +468,5 @@ "", "" ], - "timestamp": 1770748574 + "timestamp": 1770751424 } \ No newline at end of file diff --git a/tests/data/markdown-codefence.expected.json b/tests/data/markdown-codefence.expected.json index 36e74b11..437c70d7 100644 --- a/tests/data/markdown-codefence.expected.json +++ b/tests/data/markdown-codefence.expected.json @@ -794,5 +794,5 @@ "", "" ], - "timestamp": 1770748574 + "timestamp": 1770751424 } \ No newline at end of file diff --git a/tests/data/mentions-with-ranges.expected.json b/tests/data/mentions-with-ranges.expected.json index 2d776a92..35b8e7f6 100644 --- a/tests/data/mentions-with-ranges.expected.json +++ b/tests/data/mentions-with-ranges.expected.json @@ -497,5 +497,5 @@ "", "" ], - "timestamp": 1770748575 + "timestamp": 1770751424 } \ No newline at end of file diff --git a/tests/data/message-removal.expected.json b/tests/data/message-removal.expected.json index 369ffb5d..efd44c78 100644 --- a/tests/data/message-removal.expected.json +++ b/tests/data/message-removal.expected.json @@ -309,5 +309,5 @@ "", "" ], - "timestamp": 1770748575 + "timestamp": 1770751425 } \ No newline at end of file diff --git a/tests/data/multiple-messages-synthetic.expected.json b/tests/data/multiple-messages-synthetic.expected.json index 6ef78f06..65215d0c 100644 --- a/tests/data/multiple-messages-synthetic.expected.json +++ b/tests/data/multiple-messages-synthetic.expected.json @@ -405,5 +405,5 @@ "", "" ], - "timestamp": 1770748575 + "timestamp": 1770751425 } \ No newline at end of file diff --git a/tests/data/multiple-messages.expected.json b/tests/data/multiple-messages.expected.json index 786f5912..633b8213 100644 --- a/tests/data/multiple-messages.expected.json +++ b/tests/data/multiple-messages.expected.json @@ -416,5 +416,5 @@ "", "" ], - "timestamp": 1770748575 + "timestamp": 1770751425 } \ No newline at end of file diff --git a/tests/data/multiple-question-ask-reply-all.expected.json b/tests/data/multiple-question-ask-reply-all.expected.json index 6872b4b9..d3b8afa5 100644 --- a/tests/data/multiple-question-ask-reply-all.expected.json +++ b/tests/data/multiple-question-ask-reply-all.expected.json @@ -537,5 +537,5 @@ "", "" ], - "timestamp": 1770748576 + "timestamp": 1770751425 } \ No newline at end of file diff --git a/tests/data/multiple-question-ask.expected.json b/tests/data/multiple-question-ask.expected.json index 562af240..254ddcd1 100644 --- a/tests/data/multiple-question-ask.expected.json +++ b/tests/data/multiple-question-ask.expected.json @@ -468,5 +468,5 @@ "", "" ], - "timestamp": 1770748576 + "timestamp": 1770751426 } \ No newline at end of file diff --git a/tests/data/perf.expected.json b/tests/data/perf.expected.json index 9f981cb1..a0a08b26 100644 --- a/tests/data/perf.expected.json +++ b/tests/data/perf.expected.json @@ -479,5 +479,5 @@ "", "" ], - "timestamp": 1770748576 + "timestamp": 1770751426 } \ No newline at end of file diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json index d7394599..e98b61cc 100644 --- a/tests/data/permission-ask-new-approve.expected.json +++ b/tests/data/permission-ask-new-approve.expected.json @@ -816,5 +816,5 @@ "", "" ], - "timestamp": 1770748577 + "timestamp": 1770751427 } \ No newline at end of file diff --git a/tests/data/permission-ask-new-deny.expected.json b/tests/data/permission-ask-new-deny.expected.json index 644f997a..37e5adb9 100644 --- a/tests/data/permission-ask-new-deny.expected.json +++ b/tests/data/permission-ask-new-deny.expected.json @@ -342,5 +342,5 @@ "", "" ], - "timestamp": 1770748577 + "timestamp": 1770751427 } \ No newline at end of file diff --git a/tests/data/permission-ask-new.expected.json b/tests/data/permission-ask-new.expected.json index 1c2520a8..6ecad2aa 100644 --- a/tests/data/permission-ask-new.expected.json +++ b/tests/data/permission-ask-new.expected.json @@ -474,15 +474,24 @@ 26, 0, { - "line_hl_group": "OpencodeDialogOptionHover", "ns_id": 3, "priority": 4096, - "right_gravity": true + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 } ], [ 23, - 26, + 27, 0, { "ns_id": 3, @@ -502,7 +511,78 @@ ], [ 24, + 28, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } + ], + [ + 25, + 29, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } + ], + [ 26, + 30, + 0, + { + "line_hl_group": "OpencodeDialogOptionHover", + "ns_id": 3, + "priority": 4096, + "right_gravity": true + } + ], + [ + 27, + 30, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } + ], + [ + 28, + 30, 2, { "ns_id": 3, @@ -520,8 +600,8 @@ } ], [ - 25, - 27, + 29, + 31, 0, { "ns_id": 3, @@ -540,8 +620,8 @@ } ], [ - 26, - 28, + 30, + 32, 0, { "ns_id": 3, @@ -560,8 +640,8 @@ } ], [ - 27, - 29, + 31, + 33, 0, { "ns_id": 3, @@ -580,8 +660,8 @@ } ], [ - 28, - 30, + 32, + 34, 0, { "ns_id": 3, @@ -625,7 +705,11 @@ "", " Permission Required", "", - " *bash* `git status`", + " *bash*", + "```bash", + "git status", + "```", + "", "", " 1. Allow once ", " 2. Reject", @@ -635,5 +719,5 @@ "", "" ], - "timestamp": 1770748577 + "timestamp": 1770751427 } \ No newline at end of file diff --git a/tests/data/permission-prompt.expected.json b/tests/data/permission-prompt.expected.json index 88b0001f..d0715f7c 100644 --- a/tests/data/permission-prompt.expected.json +++ b/tests/data/permission-prompt.expected.json @@ -295,15 +295,24 @@ 19, 0, { - "line_hl_group": "OpencodeDialogOptionHover", "ns_id": 3, "priority": 4096, - "right_gravity": true + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 } ], [ 15, - 19, + 20, 0, { "ns_id": 3, @@ -323,7 +332,78 @@ ], [ 16, + 21, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } + ], + [ + 17, + 22, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } + ], + [ + 18, + 23, + 0, + { + "line_hl_group": "OpencodeDialogOptionHover", + "ns_id": 3, + "priority": 4096, + "right_gravity": true + } + ], + [ 19, + 23, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } + ], + [ + 20, + 23, 2, { "ns_id": 3, @@ -341,8 +421,8 @@ } ], [ - 17, - 20, + 21, + 24, 0, { "ns_id": 3, @@ -361,8 +441,8 @@ } ], [ - 18, - 21, + 22, + 25, 0, { "ns_id": 3, @@ -381,8 +461,8 @@ } ], [ - 19, - 22, + 23, + 26, 0, { "ns_id": 3, @@ -401,8 +481,8 @@ } ], [ - 20, - 23, + 24, + 27, 0, { "ns_id": 3, @@ -439,7 +519,11 @@ "", " 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`", + " *bash* 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", + "```", "", " 1. Allow once ", " 2. Reject", @@ -449,5 +533,5 @@ "", "" ], - "timestamp": 1770748578 + "timestamp": 1770751427 } \ No newline at end of file diff --git a/tests/data/permission.expected.json b/tests/data/permission.expected.json index 1e8833ed..aa137235 100644 --- a/tests/data/permission.expected.json +++ b/tests/data/permission.expected.json @@ -321,5 +321,5 @@ "", "" ], - "timestamp": 1770748578 + "timestamp": 1770751428 } \ No newline at end of file diff --git a/tests/data/planning.expected.json b/tests/data/planning.expected.json index 63300cef..738e9aaa 100644 --- a/tests/data/planning.expected.json +++ b/tests/data/planning.expected.json @@ -523,5 +523,5 @@ "", "" ], - "timestamp": 1770748578 + "timestamp": 1770751428 } \ No newline at end of file diff --git a/tests/data/question-ask-replied.expected.json b/tests/data/question-ask-replied.expected.json index 2b615069..ed6b6ec6 100644 --- a/tests/data/question-ask-replied.expected.json +++ b/tests/data/question-ask-replied.expected.json @@ -322,5 +322,5 @@ "", "" ], - "timestamp": 1770748578 + "timestamp": 1770751428 } \ No newline at end of file diff --git a/tests/data/question-ask.expected.json b/tests/data/question-ask.expected.json index 03f33a89..b990279b 100644 --- a/tests/data/question-ask.expected.json +++ b/tests/data/question-ask.expected.json @@ -468,5 +468,5 @@ "", "" ], - "timestamp": 1770748579 + "timestamp": 1770751428 } \ No newline at end of file diff --git a/tests/data/reasoning.expected.json b/tests/data/reasoning.expected.json index 75c4f6ce..19d03ae9 100644 --- a/tests/data/reasoning.expected.json +++ b/tests/data/reasoning.expected.json @@ -385,5 +385,5 @@ "", "" ], - "timestamp": 1770748579 + "timestamp": 1770751429 } \ No newline at end of file diff --git a/tests/data/redo-all.expected.json b/tests/data/redo-all.expected.json index 58dd7d81..e3148fa9 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -2,117 +2,117 @@ "actions": [ { "args": [ - "d988cc85565b99017d40ad8baea20225165be9d5" + "57d83f5596cb1f142fbc681d3d93b7184f7f73cd" ], - "display_line": 90, + "display_line": 56, "key": "R", "range": { - "from": 90, - "to": 90 + "from": 56, + "to": 56 }, "text": "[R]evert file", "type": "diff_revert_selected_file" }, { "args": [ - "d988cc85565b99017d40ad8baea20225165be9d5" + "57d83f5596cb1f142fbc681d3d93b7184f7f73cd" ], - "display_line": 90, + "display_line": 56, "key": "A", "range": { - "from": 90, - "to": 90 + "from": 56, + "to": 56 }, "text": "Revert [A]ll", "type": "diff_revert_all" }, { "args": [ - "d988cc85565b99017d40ad8baea20225165be9d5" + "57d83f5596cb1f142fbc681d3d93b7184f7f73cd" ], - "display_line": 90, + "display_line": 56, "key": "D", "range": { - "from": 90, - "to": 90 + "from": 56, + "to": 56 }, "text": "[D]iff", "type": "diff_open" }, { "args": [ - "1b6ba655c6c0d899965adff278ac6320d5fc3b12" + "d988cc85565b99017d40ad8baea20225165be9d5" ], - "display_line": 22, + "display_line": 90, "key": "R", "range": { - "from": 22, - "to": 22 + "from": 90, + "to": 90 }, "text": "[R]evert file", "type": "diff_revert_selected_file" }, { "args": [ - "1b6ba655c6c0d899965adff278ac6320d5fc3b12" + "d988cc85565b99017d40ad8baea20225165be9d5" ], - "display_line": 22, + "display_line": 90, "key": "A", "range": { - "from": 22, - "to": 22 + "from": 90, + "to": 90 }, "text": "Revert [A]ll", "type": "diff_revert_all" }, { "args": [ - "1b6ba655c6c0d899965adff278ac6320d5fc3b12" + "d988cc85565b99017d40ad8baea20225165be9d5" ], - "display_line": 22, + "display_line": 90, "key": "D", "range": { - "from": 22, - "to": 22 + "from": 90, + "to": 90 }, "text": "[D]iff", "type": "diff_open" }, { "args": [ - "57d83f5596cb1f142fbc681d3d93b7184f7f73cd" + "1b6ba655c6c0d899965adff278ac6320d5fc3b12" ], - "display_line": 56, + "display_line": 22, "key": "R", "range": { - "from": 56, - "to": 56 + "from": 22, + "to": 22 }, "text": "[R]evert file", "type": "diff_revert_selected_file" }, { "args": [ - "57d83f5596cb1f142fbc681d3d93b7184f7f73cd" + "1b6ba655c6c0d899965adff278ac6320d5fc3b12" ], - "display_line": 56, + "display_line": 22, "key": "A", "range": { - "from": 56, - "to": 56 + "from": 22, + "to": 22 }, "text": "Revert [A]ll", "type": "diff_revert_all" }, { "args": [ - "57d83f5596cb1f142fbc681d3d93b7184f7f73cd" + "1b6ba655c6c0d899965adff278ac6320d5fc3b12" ], - "display_line": 56, + "display_line": 22, "key": "D", "range": { - "from": 56, - "to": 56 + "from": 22, + "to": 22 }, "text": "[D]iff", "type": "diff_open" @@ -1493,5 +1493,5 @@ "", "" ], - "timestamp": 1770748580 + "timestamp": 1770751429 } \ No newline at end of file diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index 71f26d3f..59a7ceb3 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -1043,5 +1043,5 @@ " test.txt: +1 -1", "" ], - "timestamp": 1770748580 + "timestamp": 1770751430 } \ No newline at end of file diff --git a/tests/data/revert.expected.json b/tests/data/revert.expected.json index c5df5321..4f7519ea 100644 --- a/tests/data/revert.expected.json +++ b/tests/data/revert.expected.json @@ -747,5 +747,5 @@ " poem.md: -20", "" ], - "timestamp": 1770748581 + "timestamp": 1770751430 } \ No newline at end of file diff --git a/tests/data/selection.expected.json b/tests/data/selection.expected.json index 5b771198..b619a9ce 100644 --- a/tests/data/selection.expected.json +++ b/tests/data/selection.expected.json @@ -389,5 +389,5 @@ "", "" ], - "timestamp": 1770748581 + "timestamp": 1770751430 } \ No newline at end of file diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index 611cad1c..3d4c2ac8 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -630,43 +630,43 @@ 136, 0, { - "end_col": 0, - "end_right_gravity": false, - "end_row": 137, - "hl_eol": true, - "hl_group": "OpencodeDiffAdd", "ns_id": 3, - "priority": 5000, + "priority": 4096, "right_gravity": true, "virt_text": [ [ - "+", - "OpencodeDiffAdd" + "▌", + "OpencodePermissionBorder" ] ], "virt_text_hide": false, - "virt_text_pos": "overlay", - "virt_text_repeat_linebreak": false + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 } ], [ 27, - 136, + 137, 0, { + "end_col": 0, + "end_right_gravity": false, + "end_row": 138, + "hl_eol": true, + "hl_group": "OpencodeDiffAdd", "ns_id": 3, - "priority": 4096, + "priority": 5000, "right_gravity": true, "virt_text": [ [ - "▌", - "OpencodePermissionBorder" + "+", + "OpencodeDiffAdd" ] ], "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_pos": "overlay", + "virt_text_repeat_linebreak": false } ], [ @@ -813,6 +813,26 @@ 35, 144, 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } + ], + [ + 36, + 145, + 0, { "line_hl_group": "OpencodeDialogOptionHover", "ns_id": 3, @@ -821,8 +841,8 @@ } ], [ - 36, - 144, + 37, + 145, 0, { "ns_id": 3, @@ -841,8 +861,8 @@ } ], [ - 37, - 144, + 38, + 145, 2, { "ns_id": 3, @@ -860,8 +880,8 @@ } ], [ - 38, - 145, + 39, + 146, 0, { "ns_id": 3, @@ -880,8 +900,8 @@ } ], [ - 39, - 146, + 40, + 147, 0, { "ns_id": 3, @@ -900,8 +920,8 @@ } ], [ - 40, - 147, + 41, + 148, 0, { "ns_id": 3, @@ -920,8 +940,8 @@ } ], [ - 41, - 148, + 42, + 149, 0, { "ns_id": 3, @@ -1072,6 +1092,7 @@ "", " *edit* `Edit this file: /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`", "", + "", "`````", " M._part_cache = {}", " M._prev_line_count = 0", @@ -1093,5 +1114,5 @@ "", "" ], - "timestamp": 1770748581 + "timestamp": 1770751431 } \ No newline at end of file diff --git a/tests/data/simple-session.expected.json b/tests/data/simple-session.expected.json index bd107d06..f5cf50aa 100644 --- a/tests/data/simple-session.expected.json +++ b/tests/data/simple-session.expected.json @@ -217,5 +217,5 @@ "", "" ], - "timestamp": 1770748581 + "timestamp": 1770751431 } \ No newline at end of file diff --git a/tests/data/tool-invalid.expected.json b/tests/data/tool-invalid.expected.json index 222f8caf..8c0bd584 100644 --- a/tests/data/tool-invalid.expected.json +++ b/tests/data/tool-invalid.expected.json @@ -174,5 +174,5 @@ "", "" ], - "timestamp": 1770748582 + "timestamp": 1770751431 } \ No newline at end of file diff --git a/tests/data/updating-text.expected.json b/tests/data/updating-text.expected.json index bbff6aa0..9a45ccb1 100644 --- a/tests/data/updating-text.expected.json +++ b/tests/data/updating-text.expected.json @@ -224,5 +224,5 @@ "", "" ], - "timestamp": 1770748582 + "timestamp": 1770751431 } \ No newline at end of file diff --git a/tests/unit/permission_integration_spec.lua b/tests/unit/permission_integration_spec.lua new file mode 100644 index 00000000..3b5a7f65 --- /dev/null +++ b/tests/unit/permission_integration_spec.lua @@ -0,0 +1,403 @@ +local renderer = require('opencode.ui.renderer') +local state = require('opencode.state') +local permission_window = require('opencode.ui.permission_window') + +describe('permission_integration', function() + local mock_update_permission_from_part + local captured_calls + + before_each(function() + state.messages = {} + state.pending_permissions = {} + state.active_session = { id = 'session_123' } + + permission_window._permission_queue = {} + permission_window._dialog = nil + permission_window._processing = false + + renderer._render_state:reset() + renderer._prev_line_count = 0 + + captured_calls = {} + mock_update_permission_from_part = permission_window.update_permission_from_part + permission_window.update_permission_from_part = function(permission_id, part) + table.insert(captured_calls, { permission_id = permission_id, part = part }) + return true + end + end) + + after_each(function() + permission_window.update_permission_from_part = mock_update_permission_from_part + end) + + describe('on_part_updated permission correlation', function() + it('correlates part with pending permission by callID and messageID', function() + state.pending_permissions = { + { + id = 'per_test_123', + permission = 'bash', + tool = { + messageID = 'msg_abc', + callID = 'call_xyz', + }, + }, + } + + local message = { + info = { id = 'msg_abc', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_456', + messageID = 'msg_abc', + sessionID = 'session_123', + callID = 'call_xyz', + type = 'tool_use', + state = { + input = { + description = 'Execute bash command', + command = 'echo hello', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(1, #captured_calls) + assert.are.equal('per_test_123', captured_calls[1].permission_id) + assert.are.equal(part, captured_calls[1].part) + end) + + it('supports backward compatibility with root-level callID/messageID', function() + state.pending_permissions = { + { + id = 'per_legacy_456', + permission = 'bash', + messageID = 'msg_legacy', + callID = 'call_legacy', + }, + } + + local message = { + info = { id = 'msg_legacy', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_789', + messageID = 'msg_legacy', + sessionID = 'session_123', + callID = 'call_legacy', + type = 'tool_use', + state = { + input = { + description = 'Legacy permission', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(1, #captured_calls) + assert.are.equal('per_legacy_456', captured_calls[1].permission_id) + end) + + it('does not call update_permission_from_part when callID does not match', function() + state.pending_permissions = { + { + id = 'per_test_123', + permission = 'bash', + tool = { + messageID = 'msg_abc', + callID = 'call_xyz', + }, + }, + } + + local message = { + info = { id = 'msg_abc', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_456', + messageID = 'msg_abc', + sessionID = 'session_123', + callID = 'call_different', + type = 'tool_use', + state = { + input = { + description = 'Different command', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(0, #captured_calls) + end) + + it('does not call update_permission_from_part when messageID does not match', function() + state.pending_permissions = { + { + id = 'per_test_123', + permission = 'bash', + tool = { + messageID = 'msg_abc', + callID = 'call_xyz', + }, + }, + } + + local message = { + info = { id = 'msg_different', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_456', + messageID = 'msg_different', + sessionID = 'session_123', + callID = 'call_xyz', + type = 'tool_use', + state = { + input = { + description = 'Different message', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(0, #captured_calls) + end) + + it('skips correlation when part has no callID', function() + state.pending_permissions = { + { + id = 'per_test_123', + permission = 'bash', + tool = { + messageID = 'msg_abc', + callID = 'call_xyz', + }, + }, + } + + local message = { + info = { id = 'msg_abc', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_456', + messageID = 'msg_abc', + sessionID = 'session_123', + type = 'text', + content = 'Some text content', + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(0, #captured_calls) + end) + + it('skips iteration when no pending permissions', function() + state.pending_permissions = {} + + local message = { + info = { id = 'msg_abc', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_456', + messageID = 'msg_abc', + sessionID = 'session_123', + callID = 'call_xyz', + type = 'tool_use', + state = { + input = { + description = 'Some command', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(0, #captured_calls) + end) + + it('matches correct permission when multiple pending permissions exist', function() + state.pending_permissions = { + { + id = 'per_first', + permission = 'bash', + tool = { + messageID = 'msg_first', + callID = 'call_first', + }, + }, + { + id = 'per_second', + permission = 'bash', + tool = { + messageID = 'msg_second', + callID = 'call_second', + }, + }, + { + id = 'per_third', + permission = 'bash', + tool = { + messageID = 'msg_third', + callID = 'call_third', + }, + }, + } + + local message = { + info = { id = 'msg_second', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_789', + messageID = 'msg_second', + sessionID = 'session_123', + callID = 'call_second', + type = 'tool_use', + state = { + input = { + description = 'Second command', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(1, #captured_calls) + assert.are.equal('per_second', captured_calls[1].permission_id) + end) + + it('breaks after first match to avoid duplicate updates', function() + state.pending_permissions = { + { + id = 'per_first', + permission = 'bash', + tool = { + messageID = 'msg_abc', + callID = 'call_xyz', + }, + }, + { + id = 'per_second', + permission = 'bash', + tool = { + messageID = 'msg_abc', + callID = 'call_xyz', + }, + }, + } + + local message = { + info = { id = 'msg_abc', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_456', + messageID = 'msg_abc', + sessionID = 'session_123', + callID = 'call_xyz', + type = 'tool_use', + state = { + input = { + description = 'Shared command', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(1, #captured_calls) + assert.are.equal('per_first', captured_calls[1].permission_id) + end) + + it('prefers tool.callID over root callID when both present', function() + state.pending_permissions = { + { + id = 'per_test_123', + permission = 'bash', + callID = 'root_call_id', + messageID = 'root_msg_id', + tool = { + messageID = 'tool_msg_id', + callID = 'tool_call_id', + }, + }, + } + + local message = { + info = { id = 'tool_msg_id', sessionID = 'session_123' }, + parts = {}, + } + renderer._render_state:set_message(message, 1, 1) + table.insert(state.messages, message) + + local part = { + id = 'part_456', + messageID = 'tool_msg_id', + sessionID = 'session_123', + callID = 'tool_call_id', + type = 'tool_use', + state = { + input = { + description = 'Tool level match', + }, + }, + } + + renderer.on_part_updated({ part = part }) + + assert.are.equal(1, #captured_calls) + assert.are.equal('per_test_123', captured_calls[1].permission_id) + + captured_calls = {} + + local part_root = { + id = 'part_789', + messageID = 'root_msg_id', + sessionID = 'session_123', + callID = 'root_call_id', + type = 'tool_use', + state = { + input = { + description = 'Root level no match', + }, + }, + } + + renderer.on_part_updated({ part = part_root }) + + assert.are.equal(0, #captured_calls) + end) + end) +end) diff --git a/tests/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index 6a14c9d9..ac49e31b 100644 --- a/tests/unit/permission_window_spec.lua +++ b/tests/unit/permission_window_spec.lua @@ -8,34 +8,383 @@ describe('permission_window', function() permission_window._processing = false end) - it('escapes line breaks in permission titles for display', function() - local captured_opts = nil - permission_window._dialog = { - format_dialog = function(_, _, opts) - captured_opts = opts - end, - } - - permission_window._permission_queue = { - { + describe('format_display', function() + local function setup_mock_dialog() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + return captured_opts + end + + it('renders patterns in a fenced block when title and description are missing', function() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + patterns = { + "python3 - <<'PY'\nprint('hello')\nPY", + }, + }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.is_not_nil(captured_opts) + assert.is_not_nil(captured_opts.content) + assert.are.equal(5, #captured_opts.content) + assert.is_true(captured_opts.content[1]:find('*bash*', 1, true) ~= nil) + assert.are.equal('```bash', captured_opts.content[2]) + assert.are.equal("python3 - <<'PY'\nprint('hello')\nPY", captured_opts.content[3]) + assert.are.equal('```', captured_opts.content[4]) + assert.are.equal('', captured_opts.content[5]) + end) + + it('displays description when available', function() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + title = 'Some Title', + patterns = { 'some pattern' }, + _description = 'Run Python script to analyze data', + }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.is_not_nil(captured_opts) + assert.is_not_nil(captured_opts.content) + assert.are.equal(2, #captured_opts.content) + assert.is_true(captured_opts.content[1]:find('Run Python script to analyze data', 1, true) ~= nil) + assert.are.equal('', captured_opts.content[2]) + end) + + it('displays command on second line when available', function() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + title = 'Some Title', + _command = 'python3 analyze.py --input data.csv', + }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.is_not_nil(captured_opts) + assert.is_not_nil(captured_opts.content) + assert.are.equal(5, #captured_opts.content) + assert.is_true(captured_opts.content[1]:find('Some Title', 1, true) ~= nil) + assert.are.equal('', captured_opts.content[2]) + assert.are.equal('```bash', captured_opts.content[3]) + assert.are.equal('python3 analyze.py --input data.csv', captured_opts.content[4]) + assert.are.equal('```', captured_opts.content[5]) + end) + + it('displays both description and command when available', function() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + _description = 'Run Python script to analyze data', + _command = 'python3 analyze.py --input data.csv', + }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.is_not_nil(captured_opts) + assert.is_not_nil(captured_opts.content) + assert.are.equal(5, #captured_opts.content) + assert.is_true(captured_opts.content[1]:find('Run Python script to analyze data', 1, true) ~= nil) + assert.are.equal('', captured_opts.content[2]) + assert.are.equal('```bash', captured_opts.content[3]) + assert.are.equal('python3 analyze.py --input data.csv', captured_opts.content[4]) + assert.are.equal('```', captured_opts.content[5]) + end) + + it('falls back to title when description is not available', function() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + title = 'My Permission Title', + patterns = { 'some pattern' }, + }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.is_not_nil(captured_opts) + assert.is_not_nil(captured_opts.content) + assert.are.equal(2, #captured_opts.content) + assert.is_true(captured_opts.content[1]:find('My Permission Title', 1, true) ~= nil) + assert.are.equal('', captured_opts.content[2]) + end) + + it('falls back to patterns when neither description nor title available', function() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + patterns = { 'pattern1', 'pattern2' }, + }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.is_not_nil(captured_opts) + assert.is_not_nil(captured_opts.content) + assert.are.equal(6, #captured_opts.content) + assert.is_true(captured_opts.content[1]:find('*bash*', 1, true) ~= nil) + assert.are.equal('```bash', captured_opts.content[2]) + assert.are.equal('pattern1', captured_opts.content[3]) + assert.are.equal('pattern2', captured_opts.content[4]) + assert.are.equal('```', captured_opts.content[5]) + assert.are.equal('', captured_opts.content[6]) + end) + + it('renders multiline commands as separate lines in fenced block', function() + local captured_opts = nil + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + _command = "echo 'line1'\necho 'line2'", + }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.is_not_nil(captured_opts) + assert.is_not_nil(captured_opts.content) + assert.are.equal(8, #captured_opts.content) + local command_start = #captured_opts.content - 3 + assert.are.equal('```bash', captured_opts.content[command_start]) + assert.are.equal("echo 'line1'", captured_opts.content[command_start + 1]) + assert.are.equal("echo 'line2'", captured_opts.content[command_start + 2]) + assert.are.equal('```', captured_opts.content[command_start + 3]) + end) + end) + + describe('update_permission_from_part', function() + it('updates permission with description and command from part', function() + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + title = 'Original Title', + }, + } + + local part = { + state = { + input = { + description = 'Execute Python script', + command = 'python3 script.py', + }, + }, + } + + local result = permission_window.update_permission_from_part('per_test', part) + + assert.is_true(result) + assert.are.equal('Execute Python script', permission_window._permission_queue[1]._description) + assert.are.equal('python3 script.py', permission_window._permission_queue[1]._command) + end) + + it('returns true when permission found and updated', function() + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + }, + } + + local part = { + state = { + input = { + description = 'Some description', + }, + }, + } + + local result = permission_window.update_permission_from_part('per_test', part) + assert.is_true(result) + end) + + it('returns false when permission not found', function() + permission_window._permission_queue = { + { + id = 'per_other', + permission = 'bash', + }, + } + + local part = { + state = { + input = { + description = 'Some description', + }, + }, + } + + local result = permission_window.update_permission_from_part('per_test', part) + assert.is_false(result) + end) + + it('returns false when part has no state.input', function() + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + }, + } + + local result = permission_window.update_permission_from_part('per_test', {}) + assert.is_false(result) + end) + + it('returns true when permission found even with empty description/command', function() + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + }, + } + + local part = { + state = { + input = { + other_field = 'value', + }, + }, + } + + local result = permission_window.update_permission_from_part('per_test', part) + assert.is_true(result) + assert.is_nil(permission_window._permission_queue[1]._description) + assert.is_nil(permission_window._permission_queue[1]._command) + end) + + it('handles nil permission_id gracefully', function() + local result = permission_window.update_permission_from_part(nil, { state = { input = {} } }) + assert.is_false(result) + end) + + it('handles nil part gracefully', function() + permission_window._permission_queue = { + { + id = 'per_test', + permission = 'bash', + }, + } + + local result = permission_window.update_permission_from_part('per_test', nil) + assert.is_false(result) + end) + end) + + describe('add_permission correlation', function() + it('stores messageID and callID from permission.tool', function() + local permission = { id = 'per_test', permission = 'bash', - patterns = { - "python3 - <<'PY'\nprint('hello')\nPY", + tool = { + messageID = 'msg_123', + callID = 'call_456', }, - }, - } + } + + permission_window.add_permission(permission) - local output = Output.new() - permission_window.format_display(output) + assert.are.equal('msg_123', permission_window._permission_queue[1]._message_id) + assert.are.equal('call_456', permission_window._permission_queue[1]._call_id) + end) + + it('handles permission without tool field', function() + local permission = { + id = 'per_test', + permission = 'bash', + } + + permission_window.add_permission(permission) + + assert.is_nil(permission_window._permission_queue[1]._message_id) + assert.is_nil(permission_window._permission_queue[1]._call_id) + end) + + it('handles permission.tool without messageID or callID', function() + local permission = { + id = 'per_test', + permission = 'bash', + tool = { + name = 'some_tool', + }, + } - assert.is_not_nil(captured_opts) - assert.is_not_nil(captured_opts.content) - assert.are.equal(1, #captured_opts.content) + permission_window.add_permission(permission) - local rendered_title = captured_opts.content[1] - local expected_pattern = "python3 - <<'PY'\\nprint('hello')\\nPY" - assert.is_true(rendered_title:find('`' .. expected_pattern .. '`', 1, true) ~= nil) - assert.is_nil(rendered_title:find('\n', 1, true)) + assert.is_nil(permission_window._permission_queue[1]._message_id) + assert.is_nil(permission_window._permission_queue[1]._call_id) + end) end) end)