From 93a3beec36b4823fb83c1b9a527b2a5e6ec4203e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 5 Jan 2026 09:22:31 -0500 Subject: [PATCH 1/2] fix(permissions): fix permission request with sub-agents The permission request seems to have changed it's format I updated the permission handling to support both format This should fix #156 --- lua/opencode/event_manager.lua | 1 + lua/opencode/ui/formatter.lua | 11 ++++++++-- lua/opencode/ui/renderer.lua | 21 +++++++++++++++---- .../permission-ask-new-approve.expected.json | 1 + tests/data/permission-ask-new-approve.json | 1 + .../permission-ask-new-deny.expected.json | 1 + tests/data/permission-ask-new-deny.json | 1 + tests/data/permission-ask-new.expected.json | 1 + tests/data/permission-ask-new.json | 1 + tests/replay/renderer_spec.lua | 1 + 10 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 tests/data/permission-ask-new-approve.expected.json create mode 100644 tests/data/permission-ask-new-approve.json create mode 100644 tests/data/permission-ask-new-deny.expected.json create mode 100644 tests/data/permission-ask-new-deny.json create mode 100644 tests/data/permission-ask-new.expected.json create mode 100644 tests/data/permission-ask-new.json diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index e8da1e31..c9b1afbc 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -52,6 +52,7 @@ local util = require('opencode.util') --- @field type string --- @field pattern string|string[] --- @field sessionID string +--- @field tool? {messageID: string, callID: string} --- @field messageID string --- @field callID? string --- @field title string diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 26aa3b51..cb52b906 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -683,8 +683,15 @@ function M._format_tool(output, part) if state.current_permission - and state.current_permission.messageID == part.messageID - and state.current_permission.callID == part.callID + and ( + ( + state.current_permission.tool + and state.current_permission.tool.callID == part.callID + and state.current_permission.tool.messageID == part.messageID + ) + ---@TODO this is for backward compatibility, remove later + or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID) + ) then M._handle_permission_request(output, part) end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 827caab8..832e7145 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -80,6 +80,7 @@ function M.setup_subscriptions(subscribe) { 'message.part.updated', M.on_part_updated }, { 'message.part.removed', M.on_part_removed }, { 'permission.updated', M.on_permission_updated }, + { 'permission.asked', M.on_permission_updated }, { 'permission.replied', M.on_permission_replied }, { 'file.edited', M.on_file_edited }, { 'custom.restore_point.created', M.on_restore_points }, @@ -768,7 +769,13 @@ end ---Re-renders part that requires permission ---@param permission OpencodePermission Event properties function M.on_permission_updated(permission) - if not permission or not permission.messageID or not permission.callID then + local tool = permission.tool + + ---@TODO this is for backward compatibility, remove later + local callID = tool and tool.callID or permission.callID + local messageID = tool and tool.messageID or permission.messageID + + if not permission or not messageID or not callID then return end @@ -782,9 +789,10 @@ function M.on_permission_updated(permission) state.current_permission = permission - local part_id = M._find_part_by_call_id(permission.callID, permission.messageID) + local part_id = M._find_part_by_call_id(callID, messageID) if part_id then M._rerender_part(part_id) + M.scroll_to_bottom() end end @@ -799,8 +807,13 @@ function M.on_permission_replied(properties) local old_permission = state.current_permission state.current_permission = nil - if old_permission and old_permission.callID then - local part_id = M._find_part_by_call_id(old_permission.callID, old_permission.messageID) + ---@TODO this is for backward compatibility, remove later + local tool = old_permission and old_permission.tool + local callID = tool and tool.callID or (old_permission and old_permission.callID) + local messageID = tool and tool.messageID or (old_permission and old_permission.messageID) + + if old_permission and messageID and callID then + local part_id = M._find_part_by_call_id(callID, messageID) if part_id then M._rerender_part(part_id) end diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json new file mode 100644 index 00000000..876b0306 --- /dev/null +++ b/tests/data/permission-ask-new-approve.expected.json @@ -0,0 +1 @@ +{"extmarks":[[1,1,0,{"ns_id":3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60a2001Kisjwk2mVB4dye]","OpencodeHint"]],"right_gravity":true,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[2,2,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[3,3,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[4,4,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[5,5,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[6,6,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[7,7,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[8,8,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[9,10,0,{"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60f1001aEWYlAaDRXQ4aJ]","OpencodeHint"]],"right_gravity":true,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[10,12,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[11,13,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[12,14,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[13,15,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[14,16,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[15,17,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[16,18,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[17,19,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[18,20,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[19,21,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[20,22,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[21,23,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[22,24,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[23,25,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[24,26,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[25,27,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[26,28,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[27,29,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[28,30,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[29,31,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[30,32,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[31,33,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[32,34,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[33,39,0,{"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-05 14:08:26)","OpencodeHint"],[" [msg_b8e7cde9e0013hRkUyt0X2yMil]","OpencodeHint"]],"right_gravity":true,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}]],"timestamp":1767622324,"lines":["----","","","@no-trust run a git status and tell me what files are changed","","[lua/opencode/ui/formatter.lua](lua/opencode/ui/formatter.lua)","","**Diagnostics:**  (11)","","----","","","** run** `Shows working tree status`","","`````bash","> git status","","On branch main","Your branch is up to date with 'origin/main'.","","Changes not staged for commit:"," (use \"git add ...\" to update what will be committed)"," (use \"git restore ...\" to discard changes in working directory)","\tmodified: lua/opencode/event_manager.lua","\tmodified: lua/opencode/ui/formatter.lua","\tmodified: lua/opencode/ui/renderer.lua","","Untracked files:"," (use \"git add ...\" to include in what will be committed)","\ttest.lua","\ttests/data/permission_ask_new.json","","no changes added to commit (use \"git add\" and/or \"git commit -a\")","","`````","","**󰻛 Created Snapshot** `5ba6e957`","","----","","","Here are the files that have been changed according to git status:","","- Modified (but not staged):"," - `lua/opencode/event_manager.lua`"," - `lua/opencode/ui/formatter.lua`"," - `lua/opencode/ui/renderer.lua`","","- Untracked files:"," - `test.lua`"," - `tests/data/permission_ask_new.json`","","No files are currently staged for commit. Let me know if you want more details (like the diff), want to stage/commit, or need help with anything else!","",""],"actions":[{"args":["5ba6e95774829e7f501299039b72b72dc32c6620"],"text":"[R]evert file","range":{"to":36,"from":36},"key":"R","display_line":36,"type":"diff_revert_selected_file"},{"args":["5ba6e95774829e7f501299039b72b72dc32c6620"],"text":"Revert [A]ll","range":{"to":36,"from":36},"key":"A","display_line":36,"type":"diff_revert_all"},{"args":["5ba6e95774829e7f501299039b72b72dc32c6620"],"text":"[D]iff","range":{"to":36,"from":36},"key":"D","display_line":36,"type":"diff_open"}]} \ No newline at end of file diff --git a/tests/data/permission-ask-new-approve.json b/tests/data/permission-ask-new-approve.json new file mode 100644 index 00000000..d6849d60 --- /dev/null +++ b/tests/data/permission-ask-new-approve.json @@ -0,0 +1 @@ +[{"properties":{"info":{"title":"New session - 2026-01-05T14:07:52.149Z","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"updated":1767622072149,"created":1767622072149}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","time":{"created":1767622074530}}},"type":"message.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a3001M2zsH9REfPdlQt","text":"@no-trust run a git status and tell me what files are changed","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye"}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a3003Ta9NcXkN0lb0lF","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","text":"Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\"}","synthetic":true}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a7001d3WhKOu2vCR4Cf","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","text":"\n00001| local context_module = require('opencode.context')\n00002| local icons = require('opencode.ui.icons')\n00003| local util = require('opencode.util')\n00004| local Output = require('opencode.ui.output')\n00005| local state = require('opencode.state')\n00006| local config = require('opencode.config')\n00007| local snapshot = require('opencode.snapshot')\n00008| local mention = require('opencode.ui.mention')\n00009| \n00010| local M = {}\n00011| \n00012| M.separator = {\n00013| '----',\n00014| '',\n00015| }\n00016| \n00017| ---@param output Output\n00018| ---@param part OpencodeMessagePart\n00019| function M._format_reasoning(output, part)\n00020| local text = vim.trim(part.text or '')\n00021| if text == '' then\n00022| return\n00023| end\n00024| \n00025| local start_line = output:get_line_count() + 1\n00026| \n00027| local title = 'Reasoning'\n00028| local time = part.time\n00029| if time and type(time) == 'table' and time.start then\n00030| local start_text = util.format_time(time.start) or ''\n00031| local end_text = (time['end'] and util.format_time(time['end'])) or nil\n00032| if end_text and end_text ~= '' then\n00033| title = string.format('%s (%s - %s)', title, start_text, end_text)\n00034| elseif start_text ~= '' then\n00035| title = string.format('%s (%s)', title, start_text)\n00036| end\n00037| end\n00038| \n00039| M._format_action(output, icons.get('reasoning') .. ' ' .. title, '')\n00040| \n00041| if config.ui.output.tools.show_reasoning_output then\n00042| output:add_empty_line()\n00043| output:add_lines(vim.split(text, '\\n'))\n00044| output:add_empty_line()\n00045| end\n00046| \n00047| local end_line = output:get_line_count()\n00048| if end_line - start_line > 1 then\n00049| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1, 'OpencodeReasoningText')\n00050| else\n00051| output:add_extmark(start_line - 1, {\n00052| line_hl_group = 'OpencodeReasoningText',\n00053| } --[[@as OutputExtmark]])\n00054| end\n00055| end\n00056| \n00057| function M._handle_permission_request(output, part)\n00058| if part.state and part.state.status == 'error' and part.state.error then\n00059| if part.state.error:match('rejected permission') then\n00060| state.current_permission = nil\n00061| else\n00062| vim.notify('Unknown part state error: ' .. part.state.error)\n00063| end\n00064| return\n00065| end\n00066| \n00067| M._format_permission_request(output)\n00068| end\n00069| \n00070| function M._format_permission_request(output)\n00071| local keys\n00072| \n00073| if require('opencode.ui.ui').is_opencode_focused() then\n00074| keys = {\n00075| config.keymap.permission.accept,\n00076| config.keymap.permission.accept_all,\n00077| config.keymap.permission.deny,\n00078| }\n00079| else\n00080| keys = {\n00081| config.get_key_for_function('editor', 'permission_accept'),\n00082| config.get_key_for_function('editor', 'permission_accept_all'),\n00083| config.get_key_for_function('editor', 'permission_deny'),\n00084| }\n00085| end\n00086| \n00087| output:add_empty_line()\n00088| output:add_line('> [!WARNING] Permission required to run this tool.')\n00089| output:add_line('>')\n00090| output:add_line(('> Accept `%s` Always `%s` Deny `%s`'):format(unpack(keys)))\n00091| output:add_empty_line()\n00092| end\n00093| \n00094| ---Calculate statistics for reverted messages and tool calls\n00095| ---@param messages {info: MessageInfo, parts: OpencodeMessagePart[]}[] All messages in the session\n00096| ---@param revert_index number Index of the message where revert occurred\n00097| ---@param revert_info SessionRevertInfo Revert information\n00098| ---@return {messages: number, tool_calls: number, files: table}\n00099| function M._calculate_revert_stats(messages, revert_index, revert_info)\n00100| local stats = {\n00101| messages = 0,\n00102| tool_calls = 0,\n00103| files = {}, -- { [filename] = { additions = n, deletions = m } }\n00104| }\n00105| \n00106| for i = revert_index, #messages do\n00107| local msg = messages[i]\n00108| if msg.info.role == 'user' then\n00109| stats.messages = stats.messages + 1\n00110| end\n00111| if msg.parts then\n00112| for _, part in ipairs(msg.parts) do\n00113| if part.type == 'tool' then\n00114| stats.tool_calls = stats.tool_calls + 1\n00115| end\n00116| end\n00117| end\n00118| end\n00119| \n00120| if revert_info.diff then\n00121| local current_file = nil\n00122| for line in revert_info.diff:gmatch('[^\\r\\n]+') do\n00123| local file_a = line:match('^%-%-%- ([ab]/.+)')\n00124| local file_b = line:match('^%+%+%+ ([ab]/.+)')\n00125| if file_b then\n00126| current_file = file_b:gsub('^[ab]/', '')\n00127| if not stats.files[current_file] then\n00128| stats.files[current_file] = { additions = 0, deletions = 0 }\n00129| end\n00130| elseif file_a then\n00131| current_file = file_a:gsub('^[ab]/', '')\n00132| if not stats.files[current_file] then\n00133| stats.files[current_file] = { additions = 0, deletions = 0 }\n00134| end\n00135| elseif line:sub(1, 1) == '+' and not line:match('^%+%+%+') then\n00136| if current_file then\n00137| stats.files[current_file].additions = stats.files[current_file].additions + 1\n00138| end\n00139| elseif line:sub(1, 1) == '-' and not line:match('^%-%-%-') then\n00140| if current_file then\n00141| stats.files[current_file].deletions = stats.files[current_file].deletions + 1\n00142| end\n00143| end\n00144| end\n00145| end\n00146| \n00147| return stats\n00148| end\n00149| \n00150| ---Format the revert callout with statistics\n00151| ---@param session_data OpencodeMessage[] All messages in the session\n00152| ---@param start_idx number Index of the message where revert occurred\n00153| ---@return Output output object representing the lines, extmarks, and actions\n00154| function M._format_revert_message(session_data, start_idx)\n00155| local output = Output.new()\n00156| local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)\n00157| local message_text = stats.messages == 1 and 'message' or 'messages'\n00158| local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'\n00159| \n00160| output:add_lines(M.separator)\n00161| output:add_line(\n00162| string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)\n00163| )\n00164| output:add_line('>')\n00165| output:add_line('> type `/redo` to restore.')\n00166| output:add_empty_line()\n00167| \n00168| if stats.files and next(stats.files) then\n00169| for file, fstats in pairs(stats.files) do\n00170| local file_diff = {}\n00171| if fstats.additions > 0 then\n00172| table.insert(file_diff, '+' .. fstats.additions)\n00173| end\n00174| if fstats.deletions > 0 then\n00175| table.insert(file_diff, '-' .. fstats.deletions)\n00176| end\n00177| if #file_diff > 0 then\n00178| local line_str = string.format(icons.get('file') .. '%s: %s', file, table.concat(file_diff, ' '))\n00179| local line_idx = output:add_line(line_str)\n00180| local col = #(' ' .. file .. ': ')\n00181| for _, diff in ipairs(file_diff) do\n00182| local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText'\n00183| output:add_extmark(line_idx - 1, {\n00184| virt_text = { { diff, hl_group } },\n00185| virt_text_pos = 'inline',\n00186| virt_text_win_col = col,\n00187| priority = 1000,\n00188| } --[[@as OutputExtmark]])\n00189| col = col + #diff + 1\n00190| end\n00191| end\n00192| end\n00193| end\n00194| return output\n00195| end\n00196| \n00197| local function add_action(output, text, action_type, args, key, line)\n00198| -- actions use api-indexing (e.g. 0 indexed)\n00199| line = (line or output:get_line_count()) - 1\n00200| output:add_action({\n00201| text = text,\n00202| type = action_type,\n00203| args = args,\n00204| key = key,\n00205| display_line = line,\n00206| range = { from = line, to = line },\n00207| })\n00208| end\n00209| \n00210| ---@param output Output Output object to write to\n00211| ---@param part OpencodeMessagePart\n00212| function M._format_patch(output, part)\n00213| if not part.hash then\n00214| return\n00215| end\n00216| \n00217| local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}\n00218| M._format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))\n00219| \n00220| -- Anchor all snapshot-level actions to the snapshot header line\n00221| add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')\n00222| add_action(output, 'Revert [A]ll', 'diff_revert_all', { part.hash }, 'A')\n00223| add_action(output, '[D]iff', 'diff_open', { part.hash }, 'D')\n00224| \n00225| if #restore_points > 0 then\n00226| for _, restore_point in ipairs(restore_points) do\n00227| output:add_line(\n00228| string.format(\n00229| ' %s Restore point `%s` - %s ',\n00230| icons.get('restore_point'),\n00231| vim.trim(restore_point.id:sub(1, 8)),\n00232| util.format_time(restore_point.created_at)\n00233| )\n00234| )\n00235| add_action(output, 'Restore [A]ll', 'diff_restore_snapshot_all', { restore_point.id }, 'A')\n00236| add_action(output, '[R]estore file', 'diff_restore_snapshot_file', { restore_point.id }, 'R')\n00237| end\n00238| end\n00239| end\n00240| \n00241| ---@param output Output Output object to write to\n00242| ---@param message MessageInfo\n00243| function M._format_error(output, message)\n00244| output:add_empty_line()\n00245| M._format_callout(output, 'ERROR', vim.inspect(message.error))\n00246| end\n00247| \n00248| ---@param message OpencodeMessage\n00249| ---@return Output\n00250| function M.format_message_header(message)\n00251| local output = Output.new()\n00252| \n00253| output:add_lines(M.separator)\n00254| local role = message.info.role or 'unknown'\n00255| local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')\n00256| \n00257| local time = message.info.time and message.info.time.created or nil\n00258| local time_text = (time and ' (' .. util.format_time(time) .. ')' or '')\n00259| local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)\n00260| local model_text = message.info.modelID and ' ' .. message.info.modelID or ''\n00261| \n00262| local debug_text = config.debug.show_ids and ' [' .. message.info.id .. ']' or ''\n00263| \n00264| local display_name\n00265| if role == 'assistant' then\n00266| local mode = message.info.mode\n00267| if mode and mode ~= '' then\n00268| display_name = mode:upper()\n00269| else\n00270| -- For the most recent assistant message, show current_mode if mode is missing\n00271| -- This handles new messages that haven't been stamped yet\n00272| local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id\n00273| if is_last_message and state.current_mode and state.current_mode ~= '' then\n00274| display_name = state.current_mode:upper()\n00275| else\n00276| display_name = 'ASSISTANT'\n00277| end\n00278| end\n00279| else\n00280| display_name = role:upper()\n00281| end\n00282| \n00283| output:add_extmark(output:get_line_count() - 1, {\n00284| virt_text = {\n00285| { icon, role_hl },\n00286| { ' ' },\n00287| { display_name, role_hl },\n00288| { model_text, 'OpencodeHint' },\n00289| { time_text, 'OpencodeHint' },\n00290| { debug_text, 'OpencodeHint' },\n00291| },\n00292| virt_text_win_col = -3,\n00293| priority = 10,\n00294| } --[[@as OutputExtmark]])\n00295| \n00296| -- Only want to show the error if we have no parts. If we have parts, they'll\n00297| -- handle rendering the error\n00298| if\n00299| role == 'assistant'\n00300| and message.info.error\n00301| and message.info.error ~= ''\n00302| and (not message.parts or #message.parts == 0)\n00303| then\n00304| local error = message.info.error\n00305| local error_message = error.data and error.data.message or vim.inspect(error)\n00306| \n00307| output:add_line('')\n00308| M._format_callout(output, 'ERROR', error_message)\n00309| end\n00310| \n00311| output:add_line('')\n00312| return output\n00313| end\n00314| \n00315| ---@param output Output Output object to write to\n00316| ---@param callout string Callout type (e.g., 'ERROR', 'TODO')\n00317| ---@param text string Callout text content\n00318| ---@param title? string Optional title for the callout\n00319| function M._format_callout(output, callout, text, title)\n00320| title = title and title .. ' ' or ''\n00321| local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win))\n00322| and vim.api.nvim_win_get_width(state.windows.output_win)\n00323| or config.ui.window_width\n00324| or 80\n00325| if #text > win_width - 4 then\n00326| local ok, substituted = pcall(vim.fn.substitute, text, '\\v(.{' .. (win_width - 8) .. '})', '\\1\\n', 'g')\n00327| text = ok and substituted or text\n00328| end\n00329| \n00330| -- Trim off any trailing newlines so there isn't an extra line in the\n00331| -- extmarks section\n00332| local lines = vim.split(text:gsub('\\n$', ''), '\\n')\n00333| if #lines == 1 and title == '' then\n00334| output:add_line('> [!' .. callout .. '] ' .. lines[1])\n00335| else\n00336| output:add_line('> [!' .. callout .. ']' .. title)\n00337| output:add_line('>')\n00338| output:add_lines(lines, '> ')\n00339| end\n00340| end\n00341| \n00342| ---@param output Output Output object to write to\n00343| ---@param text string\n00344| ---@param message? OpencodeMessage Optional message object to extract mentions from\n00345| function M._format_user_prompt(output, text, message)\n00346| local start_line = output:get_line_count()\n00347| \n00348| output:add_lines(vim.split(text, '\\n'))\n00349| \n00350| local end_line = output:get_line_count()\n00351| \n00352| local end_line_extmark_offset = 0\n00353| \n00354| local mentions = {}\n00355| if message and message.parts then\n00356| -- message.parts will only be filled out on a re-render\n00357| -- we need to collect the mentions here\n00358| for _, part in ipairs(message.parts) do\n00359| if part.type == 'file' then\n00360| -- we're rerendering this part and we have files, the space after the user prompt\n00361| -- also needs an extmark\n00362| end_line_extmark_offset = 1\n00363| if part.source and part.source.text then\n00364| table.insert(mentions, part.source.text)\n00365| end\n00366| elseif part.type == 'agent' then\n00367| if part.source then\n00368| table.insert(mentions, part.source)\n00369| end\n00370| end\n00371| end\n00372| end\n00373| \n00374| if #mentions > 0 then\n00375| mention.highlight_mentions_in_output(output, text, mentions, start_line)\n00376| end\n00377| \n00378| M._add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)\n00379| end\n00380| \n00381| ---@param output Output Output object to write to\n00382| ---@param part OpencodeMessagePart\n00383| function M._format_selection_context(output, part)\n00384| local json = context_module.decode_json_context(part.text or '', 'selection')\n00385| if not json then\n00386| return\n00387| end\n00388| local start_line = output:get_line_count()\n00389| output:add_lines(vim.split(json.content or '', '\\n'))\n00390| output:add_empty_line()\n00391| \n00392| local end_line = output:get_line_count()\n00393| \n00394| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00395| end\n00396| \n00397| ---@param output Output Output object to write to\n00398| ---@param part OpencodeMessagePart\n00399| function M._format_cursor_data_context(output, part)\n00400| local json = context_module.decode_json_context(part.text or '', 'cursor-data')\n00401| if not json then\n00402| return\n00403| end\n00404| local start_line = output:get_line_count()\n00405| output:add_line('Line ' .. json.line .. ':')\n00406| output:add_lines(vim.split(json.line_content or '', '\\n'))\n00407| output:add_empty_line()\n00408| \n00409| local end_line = output:get_line_count()\n00410| \n00411| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00412| end\n00413| \n00414| ---@param output Output Output object to write to\n00415| ---@param part OpencodeMessagePart\n00416| function M._format_diagnostics_context(output, part)\n00417| local json = context_module.decode_json_context(part.text or '', 'diagnostics')\n00418| if not json then\n00419| return\n00420| end\n00421| local start_line = output:get_line_count()\n00422| local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]\n00423| if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then\n00424| return\n00425| end\n00426| \n00427| local diagnostics_count = { error = 0, warn = 0, info = 0 }\n00428| local diagnostics_icons = {\n00429| error = icons.get('error'),\n00430| warn = icons.get('warning'),\n00431| info = icons.get('info'),\n00432| }\n00433| \n00434| for _, diag in ipairs(diagnostics) do\n00435| local name = vim.diagnostic.severity[diag.severity]:lower()\n00436| diagnostics_count[name] = diagnostics_count[name] + 1\n00437| end\n00438| \n00439| local diag_line = '**Diagnostics:**'\n00440| for name, count in pairs(diagnostics_count) do\n00441| if count > 0 then\n00442| diag_line = diag_line .. (string.format(' %s(%d)', diagnostics_icons[name], count))\n00443| end\n00444| end\n00445| output:add_line(diag_line)\n00446| output:add_empty_line()\n00447| local end_line = output:get_line_count()\n00448| \n00449| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00450| end\n00451| \n00452| ---Format and display the file path in the context\n00453| ---@param output Output Output object to write to\n00454| ---@param path string|nil File path\n00455| function M._format_context_file(output, path)\n00456| if not path or path == '' then\n00457| return\n00458| end\n00459| local cwd = vim.fn.getcwd()\n00460| if vim.startswith(path, cwd) then\n00461| path = path:sub(#cwd + 2)\n00462| end\n00463| return output:add_line(string.format('[%s](%s)', path, path))\n00464| end\n00465| \n00466| ---@param output Output Output object to write to\n00467| ---@param text string\n00468| function M._format_assistant_message(output, text)\n00469| local reference_picker = require('opencode.ui.reference_picker')\n00470| local references = reference_picker.parse_references(text, '')\n00471| \n00472| -- If no references, just add the text as-is\n00473| if #references == 0 then\n00474| output:add_lines(vim.split(text, '\\n'))\n00475| return\n00476| end\n00477| \n00478| -- Sort references by match_start position (ascending)\n00479| table.sort(references, function(a, b)\n00480| return a.match_start < b.match_start\n00481| end)\n00482| \n00483| -- Build a new text with icons inserted before each reference\n00484| local result = ''\n00485| local last_pos = 1\n00486| local ref_icon = icons.get('reference')\n00487| \n00488| for _, ref in ipairs(references) do\n00489| -- Add text before this reference\n00490| result = result .. text:sub(last_pos, ref.match_start - 1)\n00491| -- Add the icon and the reference\n00492| result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)\n00493| last_pos = ref.match_end + 1\n00494| end\n00495| \n00496| -- Add any remaining text after the last reference\n00497| if last_pos <= #text then\n00498| result = result .. text:sub(last_pos)\n00499| end\n00500| \n00501| output:add_lines(vim.split(result, '\\n'))\n00502| end\n00503| \n00504| ---@param output Output Output object to write to\n00505| ---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)\n00506| ---@param value string Value associated with the action (e.g., filename, command)\n00507| function M._format_action(output, tool_type, value)\n00508| if not tool_type or not value then\n00509| return\n00510| end\n00511| local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '')\n00512| \n00513| output:add_line(line)\n00514| end\n00515| \n00516| ---@param output Output Output object to write to\n00517| ---@param input BashToolInput data for the tool\n00518| ---@param metadata BashToolMetadata Metadata for the tool use\n00519| function M._format_bash_tool(output, input, metadata)\n00520| M._format_action(output, icons.get('run') .. ' run', input and input.description)\n00521| \n00522| if not config.ui.output.tools.show_output then\n00523| return\n00524| end\n00525| \n00526| if metadata.output or metadata.command or input.command then\n00527| local command = input.command or metadata.command or ''\n00528| local command_output = metadata.output and metadata.output ~= '' and ('\\n' .. metadata.output) or ''\n00529| M._format_code(output, vim.split('> ' .. command .. '\\n' .. command_output, '\\n'), 'bash')\n00530| end\n00531| end\n00532| \n00533| ---@param output Output Output object to write to\n00534| ---@param tool_type string Tool type (e.g., 'read', 'edit', 'write')\n00535| ---@param input FileToolInput data for the tool\n00536| ---@param metadata FileToolMetadata Metadata for the tool use\n00537| function M._format_file_tool(output, tool_type, input, metadata)\n00538| local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''\n00539| local file_type = input and util.get_markdown_filetype(input.filePath) or ''\n00540| local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }\n00541| \n00542| M._format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name)\n00543| \n00544| if not config.ui.output.tools.show_output then\n00545| return\n00546| end\n00547| \n00548| if tool_type == 'edit' and metadata.diff then\n00549| M._format_diff(output, metadata.diff, file_type)\n00550| elseif tool_type == 'write' and input and input.content then\n00551| M._format_code(output, vim.split(input.content, '\\n'), file_type)\n00552| end\n00553| end\n00554| \n00555| ---@param output Output Output object to write to\n00556| ---@param title string\n00557| ---@param input TodoToolInput\n00558| function M._format_todo_tool(output, title, input)\n00559| M._format_action(output, icons.get('plan') .. ' plan', (title or ''))\n00560| if not config.ui.output.tools.show_output then\n00561| return\n00562| end\n00563| \n00564| local todos = input and input.todos or {}\n00565| \n00566| for _, item in ipairs(todos) do\n00567| local statuses = { in_progress = '-', completed = 'x', pending = ' ' }\n00568| output:add_line(string.format('- [%s] %s ', statuses[item.status], item.content))\n00569| end\n00570| end\n00571| \n00572| ---@param output Output Output object to write to\n00573| ---@param input GlobToolInput data for the tool\n00574| ---@param metadata GlobToolMetadata Metadata for the tool use\n00575| function M._format_glob_tool(output, input, metadata)\n00576| M._format_action(output, icons.get('search') .. ' glob', input and input.pattern)\n00577| if not config.ui.output.tools.show_output then\n00578| return\n00579| end\n00580| local prefix = metadata.truncated and ' more than' or ''\n00581| output:add_line(string.format('Found%s `%d` file(s):', prefix, metadata.count or 0))\n00582| end\n00583| \n00584| ---@param output Output Output object to write to\n00585| ---@param input GrepToolInput data for the tool\n00586| ---@param metadata GrepToolMetadata Metadata for the tool use\n00587| function M._format_grep_tool(output, input, metadata)\n00588| input = input or { path = '', include = '', pattern = '' }\n00589| \n00590| local grep_str = string.format('%s` `%s', (input.path or input.include) or '', input.pattern or '')\n00591| \n00592| M._format_action(output, icons.get('search') .. ' grep', grep_str)\n00593| if not config.ui.output.tools.show_output then\n00594| return\n00595| end\n00596| local prefix = metadata.truncated and ' more than' or ''\n00597| output:add_line(\n00598| string.format('Found%s `%d` match' .. (metadata.matches ~= 1 and 'es' or ''), prefix, metadata.matches or 0)\n00599| )\n00600| end\n00601| \n00602| ---@param output Output Output object to write to\n00603| ---@param input WebFetchToolInput data for the tool\n00604| function M._format_webfetch_tool(output, input)\n00605| M._format_action(output, icons.get('web') .. ' fetch', input and input.url)\n00606| end\n00607| \n00608| ---@param output Output Output object to write to\n00609| ---@param input ListToolInput\n00610| ---@param metadata ListToolMetadata\n00611| ---@param tool_output string\n00612| function M._format_list_tool(output, input, metadata, tool_output)\n00613| M._format_action(output, icons.get('list') .. ' list', input and input.path or '')\n00614| if not config.ui.output.tools.show_output then\n00615| return\n00616| end\n00617| local lines = vim.split(vim.trim(tool_output or ''), '\\n')\n00618| if #lines < 1 or metadata.count == 0 then\n00619| output:add_line('No files found.')\n00620| return\n00621| end\n00622| if #lines > 1 then\n00623| output:add_line('Files:')\n00624| for i = 2, #lines do\n00625| local file = vim.trim(lines[i])\n00626| if file ~= '' then\n00627| output:add_line(' • ' .. file)\n00628| end\n00629| end\n00630| end\n00631| if metadata.truncated then\n00632| output:add_line(string.format('Results truncated, showing first %d files', metadata.count or '?'))\n00633| end\n00634| end\n00635| \n00636| ---@param output Output Output object to write to\n00637| ---@param part OpencodeMessagePart\n00638| function M._format_tool(output, part)\n00639| local tool = part.tool\n00640| if not tool or not part.state then\n00641| return\n00642| end\n00643| \n00644| local start_line = output:get_line_count() + 1\n00645| local input = part.state.input or {}\n00646| local metadata = part.state.metadata or {}\n00647| local tool_output = part.state.output or ''\n00648| \n00649| if state.current_permission and state.current_permission.messageID == part.messageID then\n00650| metadata = state.current_permission.metadata or metadata\n00651| end\n00652| \n00653| if tool == 'bash' then\n00654| M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])\n00655| elseif tool == 'read' or tool == 'edit' or tool == 'write' then\n00656| M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]])\n00657| elseif tool == 'todowrite' then\n00658| M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]])\n00659| elseif tool == 'glob' then\n00660| M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]])\n00661| elseif tool == 'list' then\n00662| M._format_list_tool(output, input --[[@as ListToolInput]], metadata --[[@as ListToolMetadata]], tool_output)\n00663| elseif tool == 'grep' then\n00664| M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]])\n00665| elseif tool == 'webfetch' then\n00666| M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]])\n00667| elseif tool == 'task' then\n00668| M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output)\n00669| else\n00670| M._format_action(output, icons.get('tool') .. ' tool', tool)\n00671| end\n00672| \n00673| if part.state.status == 'error' and part.state.error then\n00674| output:add_line('')\n00675| M._format_callout(output, 'ERROR', part.state.error)\n00676| ---@diagnostic disable-next-line: undefined-field\n00677| elseif part.state.input and part.state.input.error then\n00678| output:add_line('')\n00679| ---I'm not sure about the type with state.input.error\n00680| ---@diagnostic disable-next-line: undefined-field\n00681| M._format_callout(output, 'ERROR', part.state.input.error)\n00682| end\n00683| \n00684| if\n00685| state.current_permission\n00686| and (\n00687| (\n00688| state.current_permission.tool\n00689| and state.current_permission.tool.callID == part.callID\n00690| and state.current_permission.tool.messageID == part.messageID\n00691| )\n00692| ---@TODO this is for backward compatibility, remove later\n00693| or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID)\n00694| )\n00695| then\n00696| M._handle_permission_request(output, part)\n00697| end\n00698| \n00699| local end_line = output:get_line_count()\n00700| if end_line - start_line > 1 then\n00701| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1)\n00702| end\n00703| end\n00704| \n00705| ---@param output Output Output object to write to\n00706| ---@param input TaskToolInput data for the tool\n00707| ---@param metadata TaskToolMetadata Metadata for the tool use\n00708| ---@param tool_output string\n00709| function M._format_task_tool(output, input, metadata, tool_output)\n00710| local start_line = output:get_line_count() + 1\n00711| M._format_action(output, icons.get('task') .. ' task', input and input.description)\n00712| \n00713| if config.ui.output.tools.show_output then\n00714| if tool_output and tool_output ~= '' then\n00715| output:add_empty_line()\n00716| output:add_lines(vim.split(tool_output, '\\n'))\n00717| output:add_empty_line()\n00718| end\n00719| \n00720| if metadata.summary and type(metadata.summary) == 'table' then\n00721| for _, sub_part in ipairs(metadata.summary) do\n00722| if sub_part.type == 'tool' and sub_part.tool then\n00723| M._format_tool(output, sub_part)\n00724| end\n00725| end\n00726| end\n00727| end\n00728| \n00729| local end_line = output:get_line_count()\n00730| output:add_action({\n00731| text = '[S]elect Child Session',\n00732| type = 'select_child_session',\n00733| args = {},\n00734| key = 'S',\n00735| display_line = start_line - 1,\n00736| range = { from = start_line, to = end_line },\n00737| })\n00738| end\n00739| \n00740| ---@param output Output Output object to write to\n00741| ---@param lines string[]\n00742| ---@param language string\n00743| function M._format_code(output, lines, language)\n00744| output:add_empty_line()\n00745| --- NOTE: use longer code fence because lines could contain ```\n00746| output:add_line('`````' .. (language or ''))\n00747| output:add_lines(util.sanitize_lines(lines))\n00748| output:add_line('`````')\n00749| end\n00750| \n00751| ---@param output Output Output object to write to\n00752| ---@param code string\n00753| ---@param file_type string\n00754| function M._format_diff(output, code, file_type)\n00755| output:add_empty_line()\n00756| \n00757| --- NOTE: use longer code fence because code could contain ```\n00758| output:add_line('`````' .. file_type)\n00759| local lines = vim.split(code, '\\n')\n00760| if #lines > 5 then\n00761| lines = vim.list_slice(lines, 6)\n00762| end\n00763| \n00764| for _, line in ipairs(lines) do\n00765| local first_char = line:sub(1, 1)\n00766| if first_char == '+' or first_char == '-' then\n00767| local hl_group = first_char == '+' and 'OpencodeDiffAdd' or 'OpencodeDiffDelete'\n00768| output:add_line(' ' .. line:sub(2))\n00769| local line_idx = output:get_line_count()\n00770| output:add_extmark(line_idx - 1, function()\n00771| return {\n00772| end_col = 0,\n00773| end_row = line_idx,\n00774| virt_text = { { first_char, hl_group } },\n00775| hl_group = hl_group,\n00776| hl_eol = true,\n00777| priority = 5000,\n00778| right_gravity = true,\n00779| end_right_gravity = false,\n00780| virt_text_hide = false,\n00781| virt_text_pos = 'overlay',\n00782| virt_text_repeat_linebreak = false,\n00783| }\n00784| end)\n00785| else\n00786| output:add_line(line)\n00787| end\n00788| end\n00789| output:add_line('`````')\n00790| end\n00791| \n00792| ---@param output Output Output object to write to\n00793| ---@param start_line number\n00794| ---@param end_line number\n00795| ---@param hl_group string Highlight group for the border character\n00796| ---@param win_col number\n00797| ---@param text_hl_group? string Optional highlight group for the background/foreground of text lines\n00798| function M._add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group)\n00799| for line = start_line, end_line do\n00800| local extmark_opts = {\n00801| virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },\n00802| virt_text_pos = 'overlay',\n00803| virt_text_win_col = win_col,\n00804| virt_text_repeat_linebreak = true,\n00805| }\n00806| \n00807| -- Add line highlight if text_hl_group is provided\n00808| if text_hl_group then\n00809| extmark_opts.line_hl_group = text_hl_group\n00810| end\n00811| \n00812| output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]])\n00813| end\n00814| end\n00815| \n00816| ---Formats a single message part and returns the resulting output object\n00817| ---@param part OpencodeMessagePart The part to format\n00818| ---@param message? OpencodeMessage Optional message object to extract role and mentions from\n00819| ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one\n00820| ---@return Output\n00821| function M.format_part(part, message, is_last_part)\n00822| local output = Output.new()\n00823| \n00824| if not message or not message.info or not message.info.role then\n00825| return output\n00826| end\n00827| \n00828| local content_added = false\n00829| local role = message.info.role\n00830| \n00831| if role == 'user' then\n00832| if part.type == 'text' and part.text then\n00833| if part.synthetic == true then\n00834| M._format_selection_context(output, part)\n00835| M._format_cursor_data_context(output, part)\n00836| M._format_diagnostics_context(output, part)\n00837| else\n00838| M._format_user_prompt(output, vim.trim(part.text), message)\n00839| content_added = true\n00840| end\n00841| elseif part.type == 'file' then\n00842| local file_line = M._format_context_file(output, part.filename)\n00843| if file_line then\n00844| M._add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)\n00845| content_added = true\n00846| end\n00847| end\n00848| elseif role == 'assistant' then\n00849| if part.type == 'text' and part.text then\n00850| M._format_assistant_message(output, vim.trim(part.text))\n00851| content_added = true\n00852| elseif part.type == 'reasoning' and part.text then\n00853| M._format_reasoning(output, part)\n00854| content_added = true\n00855| elseif part.type == 'tool' then\n00856| M._format_tool(output, part)\n00857| content_added = true\n00858| elseif part.type == 'patch' and part.hash then\n00859| M._format_patch(output, part)\n00860| content_added = true\n00861| end\n00862| end\n00863| \n00864| if content_added then\n00865| output:add_empty_line()\n00866| end\n00867| \n00868| if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then\n00869| local error = message.info.error\n00870| local error_message = error.data and error.data.message or vim.inspect(error)\n00871| M._format_callout(output, 'ERROR', error_message)\n00872| output:add_empty_line()\n00873| end\n00874| \n00875| return output\n00876| end\n00877| \n00878| return M\n00879| \n\n(End of file - total 879 lines)\n","synthetic":true}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"file","url":"file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua","id":"prt_b8e7c60a7002ybV09VDHoQQeO7","filename":"lua/opencode/ui/formatter.lua","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","mime":"text/plain"}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a3002XHHlk77vAPApvR","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","text":"{\"content\":[{\"msg\":\"expected `SessionRevertInfo` but found `SessionRevertInfo?`.\",\"severity\":2,\"pos\":\"l156:c68\"},{\"msg\":\"expected `integer?` but found `number`.\",\"severity\":2,\"pos\":\"l490:c43\"},{\"msg\":\"expected `integer` but found `number`.\",\"severity\":2,\"pos\":\"l492:c45\"},{\"msg\":\"expected `integer?` but found `number`.\",\"severity\":2,\"pos\":\"l492:c62\"},{\"msg\":\"state.active_session may be nil\",\"severity\":2,\"pos\":\"l156:c68\"},{\"msg\":\"state.messages[#state.messages] may be nil\",\"severity\":2,\"pos\":\"l272:c74\"},{\"msg\":\"state.messages may be nil\",\"severity\":2,\"pos\":\"l272:c74\"},{\"msg\":\"Undefined field `[diag.severity]`.\",\"severity\":2,\"pos\":\"l435:c42\"},{\"msg\":\"Undefined field `command`.\",\"severity\":2,\"pos\":\"l526:c34\"},{\"msg\":\"Undefined field `command`.\",\"severity\":2,\"pos\":\"l527:c47\"},{\"msg\":\"Cannot assign `number` to `integer`.\",\"severity\":2,\"pos\":\"l493:c5\"}],\"context_type\":\"diagnostics\"}","synthetic":true}},"type":"message.part.updated"},{"properties":{"info":{"title":"New session - 2026-01-05T14:07:52.149Z","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"updated":1767622074603,"created":1767622072149}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"providerID":"github-copilot","role":"assistant","path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","time":{"created":1767622074609},"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","mode":"build","modelID":"gpt-4.1","id":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","parentID":"msg_b8e7c60a2001Kisjwk2mVB4dye","cost":0,"tokens":{"output":0,"reasoning":0,"input":0,"cache":{"write":0,"read":0}}}},"type":"message.updated"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"created":1767622074530},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","summary":{"diffs":[]}}},"type":"message.updated"},{"properties":{"info":{"title":"New session - 2026-01-05T14:07:52.149Z","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"updated":1767622074670,"created":1767622072149},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"additions":0,"files":0}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"step-start","id":"prt_b8e7c6672001F10WAiQKbzNEtE","snapshot":"5ba6e95774829e7f501299039b72b72dc32c6620","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ"}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"tool","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","id":"prt_b8e7c6686001cCV44vsFtbpF42","state":{"status":"pending","input":{},"raw":""},"callID":"call_KAC0LdAxKEbU6HTAPH5QJfGx","tool":"bash"}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"tool","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","id":"prt_b8e7c6686001cCV44vsFtbpF42","state":{"status":"running","input":{"description":"Shows working tree status","command":"git status"},"time":{"start":1767622076189}},"callID":"call_KAC0LdAxKEbU6HTAPH5QJfGx","tool":"bash"}},"type":"message.part.updated"},{"properties":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","tool":{"messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","callID":"call_KAC0LdAxKEbU6HTAPH5QJfGx"},"patterns":["git status"],"id":"per_b8e7c6744001DX6EZ9KHM15RLi","permission":"bash","always":["git status*"],"metadata":{}},"type":"permission.asked"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"created":1767622074530},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","summary":{"title":"Running git status to list changed files","diffs":[]}}},"type":"message.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"title":"Running git status for formatter.lua changes","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"updated":1767622082619,"created":1767622072149},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"additions":0,"files":0}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","requestID":"per_b8e7c6744001DX6EZ9KHM15RLi","reply":"once"},"type":"permission.replied"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"tool","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","id":"prt_b8e7c6686001cCV44vsFtbpF42","state":{"title":"Shows working tree status","input":{"description":"Shows working tree status","command":"git status"},"output":"On branch main\nYour branch is up to date with 'origin/main'.\n\nChanges not staged for commit:\n (use \"git add ...\" to update what will be committed)\n (use \"git restore ...\" to discard changes in working directory)\n\tmodified: lua/opencode/event_manager.lua\n\tmodified: lua/opencode/ui/formatter.lua\n\tmodified: lua/opencode/ui/renderer.lua\n\nUntracked files:\n (use \"git add ...\" to include in what will be committed)\n\ttest.lua\n\ttests/data/permission_ask_new.json\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n","status":"completed","metadata":{"exit":0,"description":"Shows working tree status","output":"On branch main\nYour branch is up to date with 'origin/main'.\n\nChanges not staged for commit:\n (use \"git add ...\" to update what will be committed)\n (use \"git restore ...\" to discard changes in working directory)\n\tmodified: lua/opencode/event_manager.lua\n\tmodified: lua/opencode/ui/formatter.lua\n\tmodified: lua/opencode/ui/renderer.lua\n\nUntracked files:\n (use \"git add ...\" to include in what will be committed)\n\ttest.lua\n\ttests/data/permission_ask_new.json\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n"},"time":{"start":1767622076189,"end":1767622106719}},"callID":"call_KAC0LdAxKEbU6HTAPH5QJfGx","tool":"bash"}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"step-finish","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","id":"prt_b8e7cde62001EgRfgQpjNPuSc8","reason":"tool-calls","cost":0,"snapshot":"34a4eef77f714f2e3a282c410dbda315c4781fee","tokens":{"output":23,"reasoning":0,"input":10887,"cache":{"write":0,"read":9984}}}},"type":"message.part.updated"},{"properties":{"info":{"providerID":"github-copilot","role":"assistant","path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","finish":"tool-calls","time":{"created":1767622074609},"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","mode":"build","modelID":"gpt-4.1","id":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","parentID":"msg_b8e7c60a2001Kisjwk2mVB4dye","cost":0,"tokens":{"output":23,"reasoning":0,"input":10887,"cache":{"write":0,"read":9984}}}},"type":"message.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"patch","id":"prt_b8e7cde8f001JcNpZEALOVaZnq","hash":"5ba6e95774829e7f501299039b72b72dc32c6620","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","files":["/home/francis/Projects/_nvim/opencode.nvim/tests/data/permission_ask_new.json"]}},"type":"message.part.updated"},{"properties":{"info":{"providerID":"github-copilot","role":"assistant","path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","finish":"tool-calls","time":{"created":1767622074609,"completed":1767622106776},"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","mode":"build","modelID":"gpt-4.1","id":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","parentID":"msg_b8e7c60a2001Kisjwk2mVB4dye","cost":0,"tokens":{"output":23,"reasoning":0,"input":10887,"cache":{"write":0,"read":9984}}}},"type":"message.updated"},{"properties":{"info":{"providerID":"github-copilot","role":"assistant","path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","time":{"created":1767622106782},"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","mode":"build","modelID":"gpt-4.1","id":"msg_b8e7cde9e0013hRkUyt0X2yMil","parentID":"msg_b8e7c60a2001Kisjwk2mVB4dye","cost":0,"tokens":{"output":0,"reasoning":0,"input":0,"cache":{"write":0,"read":0}}}},"type":"message.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"created":1767622074530},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","summary":{"title":"Running git status to list changed files","diffs":[{"file":"tests/data/permission_ask_new.json","after":"[{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"time\":{\"updated\":1767622072149,\"created\":1767622072149}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"time\":{\"created\":1767622074530}}},\"type\":\"message.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3001M2zsH9REfPdlQt\",\"text\":\"@no-trust run a git status and tell me what files are changed\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3003Ta9NcXkN0lb0lF\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"Called the Read tool with the following input: {\\\"filePath\\\":\\\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\\\"}\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a7001d3WhKOu2vCR4Cf\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"\\n00001| local context_module = require('opencode.context')\\n00002| local icons = require('opencode.ui.icons')\\n00003| local util = require('opencode.util')\\n00004| local Output = require('opencode.ui.output')\\n00005| local state = require('opencode.state')\\n00006| local config = require('opencode.config')\\n00007| local snapshot = require('opencode.snapshot')\\n00008| local mention = require('opencode.ui.mention')\\n00009| \\n00010| local M = {}\\n00011| \\n00012| M.separator = {\\n00013| '----',\\n00014| '',\\n00015| }\\n00016| \\n00017| ---@param output Output\\n00018| ---@param part OpencodeMessagePart\\n00019| function M._format_reasoning(output, part)\\n00020| local text = vim.trim(part.text or '')\\n00021| if text == '' then\\n00022| return\\n00023| end\\n00024| \\n00025| local start_line = output:get_line_count() + 1\\n00026| \\n00027| local title = 'Reasoning'\\n00028| local time = part.time\\n00029| if time and type(time) == 'table' and time.start then\\n00030| local start_text = util.format_time(time.start) or ''\\n00031| local end_text = (time['end'] and util.format_time(time['end'])) or nil\\n00032| if end_text and end_text ~= '' then\\n00033| title = string.format('%s (%s - %s)', title, start_text, end_text)\\n00034| elseif start_text ~= '' then\\n00035| title = string.format('%s (%s)', title, start_text)\\n00036| end\\n00037| end\\n00038| \\n00039| M._format_action(output, icons.get('reasoning') .. ' ' .. title, '')\\n00040| \\n00041| if config.ui.output.tools.show_reasoning_output then\\n00042| output:add_empty_line()\\n00043| output:add_lines(vim.split(text, '\\\\n'))\\n00044| output:add_empty_line()\\n00045| end\\n00046| \\n00047| local end_line = output:get_line_count()\\n00048| if end_line - start_line > 1 then\\n00049| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1, 'OpencodeReasoningText')\\n00050| else\\n00051| output:add_extmark(start_line - 1, {\\n00052| line_hl_group = 'OpencodeReasoningText',\\n00053| } --[[@as OutputExtmark]])\\n00054| end\\n00055| end\\n00056| \\n00057| function M._handle_permission_request(output, part)\\n00058| if part.state and part.state.status == 'error' and part.state.error then\\n00059| if part.state.error:match('rejected permission') then\\n00060| state.current_permission = nil\\n00061| else\\n00062| vim.notify('Unknown part state error: ' .. part.state.error)\\n00063| end\\n00064| return\\n00065| end\\n00066| \\n00067| M._format_permission_request(output)\\n00068| end\\n00069| \\n00070| function M._format_permission_request(output)\\n00071| local keys\\n00072| \\n00073| if require('opencode.ui.ui').is_opencode_focused() then\\n00074| keys = {\\n00075| config.keymap.permission.accept,\\n00076| config.keymap.permission.accept_all,\\n00077| config.keymap.permission.deny,\\n00078| }\\n00079| else\\n00080| keys = {\\n00081| config.get_key_for_function('editor', 'permission_accept'),\\n00082| config.get_key_for_function('editor', 'permission_accept_all'),\\n00083| config.get_key_for_function('editor', 'permission_deny'),\\n00084| }\\n00085| end\\n00086| \\n00087| output:add_empty_line()\\n00088| output:add_line('> [!WARNING] Permission required to run this tool.')\\n00089| output:add_line('>')\\n00090| output:add_line(('> Accept `%s` Always `%s` Deny `%s`'):format(unpack(keys)))\\n00091| output:add_empty_line()\\n00092| end\\n00093| \\n00094| ---Calculate statistics for reverted messages and tool calls\\n00095| ---@param messages {info: MessageInfo, parts: OpencodeMessagePart[]}[] All messages in the session\\n00096| ---@param revert_index number Index of the message where revert occurred\\n00097| ---@param revert_info SessionRevertInfo Revert information\\n00098| ---@return {messages: number, tool_calls: number, files: table}\\n00099| function M._calculate_revert_stats(messages, revert_index, revert_info)\\n00100| local stats = {\\n00101| messages = 0,\\n00102| tool_calls = 0,\\n00103| files = {}, -- { [filename] = { additions = n, deletions = m } }\\n00104| }\\n00105| \\n00106| for i = revert_index, #messages do\\n00107| local msg = messages[i]\\n00108| if msg.info.role == 'user' then\\n00109| stats.messages = stats.messages + 1\\n00110| end\\n00111| if msg.parts then\\n00112| for _, part in ipairs(msg.parts) do\\n00113| if part.type == 'tool' then\\n00114| stats.tool_calls = stats.tool_calls + 1\\n00115| end\\n00116| end\\n00117| end\\n00118| end\\n00119| \\n00120| if revert_info.diff then\\n00121| local current_file = nil\\n00122| for line in revert_info.diff:gmatch('[^\\\\r\\\\n]+') do\\n00123| local file_a = line:match('^%-%-%- ([ab]/.+)')\\n00124| local file_b = line:match('^%+%+%+ ([ab]/.+)')\\n00125| if file_b then\\n00126| current_file = file_b:gsub('^[ab]/', '')\\n00127| if not stats.files[current_file] then\\n00128| stats.files[current_file] = { additions = 0, deletions = 0 }\\n00129| end\\n00130| elseif file_a then\\n00131| current_file = file_a:gsub('^[ab]/', '')\\n00132| if not stats.files[current_file] then\\n00133| stats.files[current_file] = { additions = 0, deletions = 0 }\\n00134| end\\n00135| elseif line:sub(1, 1) == '+' and not line:match('^%+%+%+') then\\n00136| if current_file then\\n00137| stats.files[current_file].additions = stats.files[current_file].additions + 1\\n00138| end\\n00139| elseif line:sub(1, 1) == '-' and not line:match('^%-%-%-') then\\n00140| if current_file then\\n00141| stats.files[current_file].deletions = stats.files[current_file].deletions + 1\\n00142| end\\n00143| end\\n00144| end\\n00145| end\\n00146| \\n00147| return stats\\n00148| end\\n00149| \\n00150| ---Format the revert callout with statistics\\n00151| ---@param session_data OpencodeMessage[] All messages in the session\\n00152| ---@param start_idx number Index of the message where revert occurred\\n00153| ---@return Output output object representing the lines, extmarks, and actions\\n00154| function M._format_revert_message(session_data, start_idx)\\n00155| local output = Output.new()\\n00156| local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)\\n00157| local message_text = stats.messages == 1 and 'message' or 'messages'\\n00158| local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'\\n00159| \\n00160| output:add_lines(M.separator)\\n00161| output:add_line(\\n00162| string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)\\n00163| )\\n00164| output:add_line('>')\\n00165| output:add_line('> type `/redo` to restore.')\\n00166| output:add_empty_line()\\n00167| \\n00168| if stats.files and next(stats.files) then\\n00169| for file, fstats in pairs(stats.files) do\\n00170| local file_diff = {}\\n00171| if fstats.additions > 0 then\\n00172| table.insert(file_diff, '+' .. fstats.additions)\\n00173| end\\n00174| if fstats.deletions > 0 then\\n00175| table.insert(file_diff, '-' .. fstats.deletions)\\n00176| end\\n00177| if #file_diff > 0 then\\n00178| local line_str = string.format(icons.get('file') .. '%s: %s', file, table.concat(file_diff, ' '))\\n00179| local line_idx = output:add_line(line_str)\\n00180| local col = #(' ' .. file .. ': ')\\n00181| for _, diff in ipairs(file_diff) do\\n00182| local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText'\\n00183| output:add_extmark(line_idx - 1, {\\n00184| virt_text = { { diff, hl_group } },\\n00185| virt_text_pos = 'inline',\\n00186| virt_text_win_col = col,\\n00187| priority = 1000,\\n00188| } --[[@as OutputExtmark]])\\n00189| col = col + #diff + 1\\n00190| end\\n00191| end\\n00192| end\\n00193| end\\n00194| return output\\n00195| end\\n00196| \\n00197| local function add_action(output, text, action_type, args, key, line)\\n00198| -- actions use api-indexing (e.g. 0 indexed)\\n00199| line = (line or output:get_line_count()) - 1\\n00200| output:add_action({\\n00201| text = text,\\n00202| type = action_type,\\n00203| args = args,\\n00204| key = key,\\n00205| display_line = line,\\n00206| range = { from = line, to = line },\\n00207| })\\n00208| end\\n00209| \\n00210| ---@param output Output Output object to write to\\n00211| ---@param part OpencodeMessagePart\\n00212| function M._format_patch(output, part)\\n00213| if not part.hash then\\n00214| return\\n00215| end\\n00216| \\n00217| local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}\\n00218| M._format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))\\n00219| \\n00220| -- Anchor all snapshot-level actions to the snapshot header line\\n00221| add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')\\n00222| add_action(output, 'Revert [A]ll', 'diff_revert_all', { part.hash }, 'A')\\n00223| add_action(output, '[D]iff', 'diff_open', { part.hash }, 'D')\\n00224| \\n00225| if #restore_points > 0 then\\n00226| for _, restore_point in ipairs(restore_points) do\\n00227| output:add_line(\\n00228| string.format(\\n00229| ' %s Restore point `%s` - %s ',\\n00230| icons.get('restore_point'),\\n00231| vim.trim(restore_point.id:sub(1, 8)),\\n00232| util.format_time(restore_point.created_at)\\n00233| )\\n00234| )\\n00235| add_action(output, 'Restore [A]ll', 'diff_restore_snapshot_all', { restore_point.id }, 'A')\\n00236| add_action(output, '[R]estore file', 'diff_restore_snapshot_file', { restore_point.id }, 'R')\\n00237| end\\n00238| end\\n00239| end\\n00240| \\n00241| ---@param output Output Output object to write to\\n00242| ---@param message MessageInfo\\n00243| function M._format_error(output, message)\\n00244| output:add_empty_line()\\n00245| M._format_callout(output, 'ERROR', vim.inspect(message.error))\\n00246| end\\n00247| \\n00248| ---@param message OpencodeMessage\\n00249| ---@return Output\\n00250| function M.format_message_header(message)\\n00251| local output = Output.new()\\n00252| \\n00253| output:add_lines(M.separator)\\n00254| local role = message.info.role or 'unknown'\\n00255| local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')\\n00256| \\n00257| local time = message.info.time and message.info.time.created or nil\\n00258| local time_text = (time and ' (' .. util.format_time(time) .. ')' or '')\\n00259| local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)\\n00260| local model_text = message.info.modelID and ' ' .. message.info.modelID or ''\\n00261| \\n00262| local debug_text = config.debug.show_ids and ' [' .. message.info.id .. ']' or ''\\n00263| \\n00264| local display_name\\n00265| if role == 'assistant' then\\n00266| local mode = message.info.mode\\n00267| if mode and mode ~= '' then\\n00268| display_name = mode:upper()\\n00269| else\\n00270| -- For the most recent assistant message, show current_mode if mode is missing\\n00271| -- This handles new messages that haven't been stamped yet\\n00272| local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id\\n00273| if is_last_message and state.current_mode and state.current_mode ~= '' then\\n00274| display_name = state.current_mode:upper()\\n00275| else\\n00276| display_name = 'ASSISTANT'\\n00277| end\\n00278| end\\n00279| else\\n00280| display_name = role:upper()\\n00281| end\\n00282| \\n00283| output:add_extmark(output:get_line_count() - 1, {\\n00284| virt_text = {\\n00285| { icon, role_hl },\\n00286| { ' ' },\\n00287| { display_name, role_hl },\\n00288| { model_text, 'OpencodeHint' },\\n00289| { time_text, 'OpencodeHint' },\\n00290| { debug_text, 'OpencodeHint' },\\n00291| },\\n00292| virt_text_win_col = -3,\\n00293| priority = 10,\\n00294| } --[[@as OutputExtmark]])\\n00295| \\n00296| -- Only want to show the error if we have no parts. If we have parts, they'll\\n00297| -- handle rendering the error\\n00298| if\\n00299| role == 'assistant'\\n00300| and message.info.error\\n00301| and message.info.error ~= ''\\n00302| and (not message.parts or #message.parts == 0)\\n00303| then\\n00304| local error = message.info.error\\n00305| local error_message = error.data and error.data.message or vim.inspect(error)\\n00306| \\n00307| output:add_line('')\\n00308| M._format_callout(output, 'ERROR', error_message)\\n00309| end\\n00310| \\n00311| output:add_line('')\\n00312| return output\\n00313| end\\n00314| \\n00315| ---@param output Output Output object to write to\\n00316| ---@param callout string Callout type (e.g., 'ERROR', 'TODO')\\n00317| ---@param text string Callout text content\\n00318| ---@param title? string Optional title for the callout\\n00319| function M._format_callout(output, callout, text, title)\\n00320| title = title and title .. ' ' or ''\\n00321| local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win))\\n00322| and vim.api.nvim_win_get_width(state.windows.output_win)\\n00323| or config.ui.window_width\\n00324| or 80\\n00325| if #text > win_width - 4 then\\n00326| local ok, substituted = pcall(vim.fn.substitute, text, '\\\\v(.{' .. (win_width - 8) .. '})', '\\\\1\\\\n', 'g')\\n00327| text = ok and substituted or text\\n00328| end\\n00329| \\n00330| -- Trim off any trailing newlines so there isn't an extra line in the\\n00331| -- extmarks section\\n00332| local lines = vim.split(text:gsub('\\\\n$', ''), '\\\\n')\\n00333| if #lines == 1 and title == '' then\\n00334| output:add_line('> [!' .. callout .. '] ' .. lines[1])\\n00335| else\\n00336| output:add_line('> [!' .. callout .. ']' .. title)\\n00337| output:add_line('>')\\n00338| output:add_lines(lines, '> ')\\n00339| end\\n00340| end\\n00341| \\n00342| ---@param output Output Output object to write to\\n00343| ---@param text string\\n00344| ---@param message? OpencodeMessage Optional message object to extract mentions from\\n00345| function M._format_user_prompt(output, text, message)\\n00346| local start_line = output:get_line_count()\\n00347| \\n00348| output:add_lines(vim.split(text, '\\\\n'))\\n00349| \\n00350| local end_line = output:get_line_count()\\n00351| \\n00352| local end_line_extmark_offset = 0\\n00353| \\n00354| local mentions = {}\\n00355| if message and message.parts then\\n00356| -- message.parts will only be filled out on a re-render\\n00357| -- we need to collect the mentions here\\n00358| for _, part in ipairs(message.parts) do\\n00359| if part.type == 'file' then\\n00360| -- we're rerendering this part and we have files, the space after the user prompt\\n00361| -- also needs an extmark\\n00362| end_line_extmark_offset = 1\\n00363| if part.source and part.source.text then\\n00364| table.insert(mentions, part.source.text)\\n00365| end\\n00366| elseif part.type == 'agent' then\\n00367| if part.source then\\n00368| table.insert(mentions, part.source)\\n00369| end\\n00370| end\\n00371| end\\n00372| end\\n00373| \\n00374| if #mentions > 0 then\\n00375| mention.highlight_mentions_in_output(output, text, mentions, start_line)\\n00376| end\\n00377| \\n00378| M._add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)\\n00379| end\\n00380| \\n00381| ---@param output Output Output object to write to\\n00382| ---@param part OpencodeMessagePart\\n00383| function M._format_selection_context(output, part)\\n00384| local json = context_module.decode_json_context(part.text or '', 'selection')\\n00385| if not json then\\n00386| return\\n00387| end\\n00388| local start_line = output:get_line_count()\\n00389| output:add_lines(vim.split(json.content or '', '\\\\n'))\\n00390| output:add_empty_line()\\n00391| \\n00392| local end_line = output:get_line_count()\\n00393| \\n00394| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00395| end\\n00396| \\n00397| ---@param output Output Output object to write to\\n00398| ---@param part OpencodeMessagePart\\n00399| function M._format_cursor_data_context(output, part)\\n00400| local json = context_module.decode_json_context(part.text or '', 'cursor-data')\\n00401| if not json then\\n00402| return\\n00403| end\\n00404| local start_line = output:get_line_count()\\n00405| output:add_line('Line ' .. json.line .. ':')\\n00406| output:add_lines(vim.split(json.line_content or '', '\\\\n'))\\n00407| output:add_empty_line()\\n00408| \\n00409| local end_line = output:get_line_count()\\n00410| \\n00411| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00412| end\\n00413| \\n00414| ---@param output Output Output object to write to\\n00415| ---@param part OpencodeMessagePart\\n00416| function M._format_diagnostics_context(output, part)\\n00417| local json = context_module.decode_json_context(part.text or '', 'diagnostics')\\n00418| if not json then\\n00419| return\\n00420| end\\n00421| local start_line = output:get_line_count()\\n00422| local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]\\n00423| if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then\\n00424| return\\n00425| end\\n00426| \\n00427| local diagnostics_count = { error = 0, warn = 0, info = 0 }\\n00428| local diagnostics_icons = {\\n00429| error = icons.get('error'),\\n00430| warn = icons.get('warning'),\\n00431| info = icons.get('info'),\\n00432| }\\n00433| \\n00434| for _, diag in ipairs(diagnostics) do\\n00435| local name = vim.diagnostic.severity[diag.severity]:lower()\\n00436| diagnostics_count[name] = diagnostics_count[name] + 1\\n00437| end\\n00438| \\n00439| local diag_line = '**Diagnostics:**'\\n00440| for name, count in pairs(diagnostics_count) do\\n00441| if count > 0 then\\n00442| diag_line = diag_line .. (string.format(' %s(%d)', diagnostics_icons[name], count))\\n00443| end\\n00444| end\\n00445| output:add_line(diag_line)\\n00446| output:add_empty_line()\\n00447| local end_line = output:get_line_count()\\n00448| \\n00449| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00450| end\\n00451| \\n00452| ---Format and display the file path in the context\\n00453| ---@param output Output Output object to write to\\n00454| ---@param path string|nil File path\\n00455| function M._format_context_file(output, path)\\n00456| if not path or path == '' then\\n00457| return\\n00458| end\\n00459| local cwd = vim.fn.getcwd()\\n00460| if vim.startswith(path, cwd) then\\n00461| path = path:sub(#cwd + 2)\\n00462| end\\n00463| return output:add_line(string.format('[%s](%s)', path, path))\\n00464| end\\n00465| \\n00466| ---@param output Output Output object to write to\\n00467| ---@param text string\\n00468| function M._format_assistant_message(output, text)\\n00469| local reference_picker = require('opencode.ui.reference_picker')\\n00470| local references = reference_picker.parse_references(text, '')\\n00471| \\n00472| -- If no references, just add the text as-is\\n00473| if #references == 0 then\\n00474| output:add_lines(vim.split(text, '\\\\n'))\\n00475| return\\n00476| end\\n00477| \\n00478| -- Sort references by match_start position (ascending)\\n00479| table.sort(references, function(a, b)\\n00480| return a.match_start < b.match_start\\n00481| end)\\n00482| \\n00483| -- Build a new text with icons inserted before each reference\\n00484| local result = ''\\n00485| local last_pos = 1\\n00486| local ref_icon = icons.get('reference')\\n00487| \\n00488| for _, ref in ipairs(references) do\\n00489| -- Add text before this reference\\n00490| result = result .. text:sub(last_pos, ref.match_start - 1)\\n00491| -- Add the icon and the reference\\n00492| result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)\\n00493| last_pos = ref.match_end + 1\\n00494| end\\n00495| \\n00496| -- Add any remaining text after the last reference\\n00497| if last_pos <= #text then\\n00498| result = result .. text:sub(last_pos)\\n00499| end\\n00500| \\n00501| output:add_lines(vim.split(result, '\\\\n'))\\n00502| end\\n00503| \\n00504| ---@param output Output Output object to write to\\n00505| ---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)\\n00506| ---@param value string Value associated with the action (e.g., filename, command)\\n00507| function M._format_action(output, tool_type, value)\\n00508| if not tool_type or not value then\\n00509| return\\n00510| end\\n00511| local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '')\\n00512| \\n00513| output:add_line(line)\\n00514| end\\n00515| \\n00516| ---@param output Output Output object to write to\\n00517| ---@param input BashToolInput data for the tool\\n00518| ---@param metadata BashToolMetadata Metadata for the tool use\\n00519| function M._format_bash_tool(output, input, metadata)\\n00520| M._format_action(output, icons.get('run') .. ' run', input and input.description)\\n00521| \\n00522| if not config.ui.output.tools.show_output then\\n00523| return\\n00524| end\\n00525| \\n00526| if metadata.output or metadata.command or input.command then\\n00527| local command = input.command or metadata.command or ''\\n00528| local command_output = metadata.output and metadata.output ~= '' and ('\\\\n' .. metadata.output) or ''\\n00529| M._format_code(output, vim.split('> ' .. command .. '\\\\n' .. command_output, '\\\\n'), 'bash')\\n00530| end\\n00531| end\\n00532| \\n00533| ---@param output Output Output object to write to\\n00534| ---@param tool_type string Tool type (e.g., 'read', 'edit', 'write')\\n00535| ---@param input FileToolInput data for the tool\\n00536| ---@param metadata FileToolMetadata Metadata for the tool use\\n00537| function M._format_file_tool(output, tool_type, input, metadata)\\n00538| local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''\\n00539| local file_type = input and util.get_markdown_filetype(input.filePath) or ''\\n00540| local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }\\n00541| \\n00542| M._format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name)\\n00543| \\n00544| if not config.ui.output.tools.show_output then\\n00545| return\\n00546| end\\n00547| \\n00548| if tool_type == 'edit' and metadata.diff then\\n00549| M._format_diff(output, metadata.diff, file_type)\\n00550| elseif tool_type == 'write' and input and input.content then\\n00551| M._format_code(output, vim.split(input.content, '\\\\n'), file_type)\\n00552| end\\n00553| end\\n00554| \\n00555| ---@param output Output Output object to write to\\n00556| ---@param title string\\n00557| ---@param input TodoToolInput\\n00558| function M._format_todo_tool(output, title, input)\\n00559| M._format_action(output, icons.get('plan') .. ' plan', (title or ''))\\n00560| if not config.ui.output.tools.show_output then\\n00561| return\\n00562| end\\n00563| \\n00564| local todos = input and input.todos or {}\\n00565| \\n00566| for _, item in ipairs(todos) do\\n00567| local statuses = { in_progress = '-', completed = 'x', pending = ' ' }\\n00568| output:add_line(string.format('- [%s] %s ', statuses[item.status], item.content))\\n00569| end\\n00570| end\\n00571| \\n00572| ---@param output Output Output object to write to\\n00573| ---@param input GlobToolInput data for the tool\\n00574| ---@param metadata GlobToolMetadata Metadata for the tool use\\n00575| function M._format_glob_tool(output, input, metadata)\\n00576| M._format_action(output, icons.get('search') .. ' glob', input and input.pattern)\\n00577| if not config.ui.output.tools.show_output then\\n00578| return\\n00579| end\\n00580| local prefix = metadata.truncated and ' more than' or ''\\n00581| output:add_line(string.format('Found%s `%d` file(s):', prefix, metadata.count or 0))\\n00582| end\\n00583| \\n00584| ---@param output Output Output object to write to\\n00585| ---@param input GrepToolInput data for the tool\\n00586| ---@param metadata GrepToolMetadata Metadata for the tool use\\n00587| function M._format_grep_tool(output, input, metadata)\\n00588| input = input or { path = '', include = '', pattern = '' }\\n00589| \\n00590| local grep_str = string.format('%s` `%s', (input.path or input.include) or '', input.pattern or '')\\n00591| \\n00592| M._format_action(output, icons.get('search') .. ' grep', grep_str)\\n00593| if not config.ui.output.tools.show_output then\\n00594| return\\n00595| end\\n00596| local prefix = metadata.truncated and ' more than' or ''\\n00597| output:add_line(\\n00598| string.format('Found%s `%d` match' .. (metadata.matches ~= 1 and 'es' or ''), prefix, metadata.matches or 0)\\n00599| )\\n00600| end\\n00601| \\n00602| ---@param output Output Output object to write to\\n00603| ---@param input WebFetchToolInput data for the tool\\n00604| function M._format_webfetch_tool(output, input)\\n00605| M._format_action(output, icons.get('web') .. ' fetch', input and input.url)\\n00606| end\\n00607| \\n00608| ---@param output Output Output object to write to\\n00609| ---@param input ListToolInput\\n00610| ---@param metadata ListToolMetadata\\n00611| ---@param tool_output string\\n00612| function M._format_list_tool(output, input, metadata, tool_output)\\n00613| M._format_action(output, icons.get('list') .. ' list', input and input.path or '')\\n00614| if not config.ui.output.tools.show_output then\\n00615| return\\n00616| end\\n00617| local lines = vim.split(vim.trim(tool_output or ''), '\\\\n')\\n00618| if #lines < 1 or metadata.count == 0 then\\n00619| output:add_line('No files found.')\\n00620| return\\n00621| end\\n00622| if #lines > 1 then\\n00623| output:add_line('Files:')\\n00624| for i = 2, #lines do\\n00625| local file = vim.trim(lines[i])\\n00626| if file ~= '' then\\n00627| output:add_line(' • ' .. file)\\n00628| end\\n00629| end\\n00630| end\\n00631| if metadata.truncated then\\n00632| output:add_line(string.format('Results truncated, showing first %d files', metadata.count or '?'))\\n00633| end\\n00634| end\\n00635| \\n00636| ---@param output Output Output object to write to\\n00637| ---@param part OpencodeMessagePart\\n00638| function M._format_tool(output, part)\\n00639| local tool = part.tool\\n00640| if not tool or not part.state then\\n00641| return\\n00642| end\\n00643| \\n00644| local start_line = output:get_line_count() + 1\\n00645| local input = part.state.input or {}\\n00646| local metadata = part.state.metadata or {}\\n00647| local tool_output = part.state.output or ''\\n00648| \\n00649| if state.current_permission and state.current_permission.messageID == part.messageID then\\n00650| metadata = state.current_permission.metadata or metadata\\n00651| end\\n00652| \\n00653| if tool == 'bash' then\\n00654| M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])\\n00655| elseif tool == 'read' or tool == 'edit' or tool == 'write' then\\n00656| M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]])\\n00657| elseif tool == 'todowrite' then\\n00658| M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]])\\n00659| elseif tool == 'glob' then\\n00660| M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]])\\n00661| elseif tool == 'list' then\\n00662| M._format_list_tool(output, input --[[@as ListToolInput]], metadata --[[@as ListToolMetadata]], tool_output)\\n00663| elseif tool == 'grep' then\\n00664| M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]])\\n00665| elseif tool == 'webfetch' then\\n00666| M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]])\\n00667| elseif tool == 'task' then\\n00668| M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output)\\n00669| else\\n00670| M._format_action(output, icons.get('tool') .. ' tool', tool)\\n00671| end\\n00672| \\n00673| if part.state.status == 'error' and part.state.error then\\n00674| output:add_line('')\\n00675| M._format_callout(output, 'ERROR', part.state.error)\\n00676| ---@diagnostic disable-next-line: undefined-field\\n00677| elseif part.state.input and part.state.input.error then\\n00678| output:add_line('')\\n00679| ---I'm not sure about the type with state.input.error\\n00680| ---@diagnostic disable-next-line: undefined-field\\n00681| M._format_callout(output, 'ERROR', part.state.input.error)\\n00682| end\\n00683| \\n00684| if\\n00685| state.current_permission\\n00686| and (\\n00687| (\\n00688| state.current_permission.tool\\n00689| and state.current_permission.tool.callID == part.callID\\n00690| and state.current_permission.tool.messageID == part.messageID\\n00691| )\\n00692| ---@TODO this is for backward compatibility, remove later\\n00693| or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID)\\n00694| )\\n00695| then\\n00696| M._handle_permission_request(output, part)\\n00697| end\\n00698| \\n00699| local end_line = output:get_line_count()\\n00700| if end_line - start_line > 1 then\\n00701| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1)\\n00702| end\\n00703| end\\n00704| \\n00705| ---@param output Output Output object to write to\\n00706| ---@param input TaskToolInput data for the tool\\n00707| ---@param metadata TaskToolMetadata Metadata for the tool use\\n00708| ---@param tool_output string\\n00709| function M._format_task_tool(output, input, metadata, tool_output)\\n00710| local start_line = output:get_line_count() + 1\\n00711| M._format_action(output, icons.get('task') .. ' task', input and input.description)\\n00712| \\n00713| if config.ui.output.tools.show_output then\\n00714| if tool_output and tool_output ~= '' then\\n00715| output:add_empty_line()\\n00716| output:add_lines(vim.split(tool_output, '\\\\n'))\\n00717| output:add_empty_line()\\n00718| end\\n00719| \\n00720| if metadata.summary and type(metadata.summary) == 'table' then\\n00721| for _, sub_part in ipairs(metadata.summary) do\\n00722| if sub_part.type == 'tool' and sub_part.tool then\\n00723| M._format_tool(output, sub_part)\\n00724| end\\n00725| end\\n00726| end\\n00727| end\\n00728| \\n00729| local end_line = output:get_line_count()\\n00730| output:add_action({\\n00731| text = '[S]elect Child Session',\\n00732| type = 'select_child_session',\\n00733| args = {},\\n00734| key = 'S',\\n00735| display_line = start_line - 1,\\n00736| range = { from = start_line, to = end_line },\\n00737| })\\n00738| end\\n00739| \\n00740| ---@param output Output Output object to write to\\n00741| ---@param lines string[]\\n00742| ---@param language string\\n00743| function M._format_code(output, lines, language)\\n00744| output:add_empty_line()\\n00745| --- NOTE: use longer code fence because lines could contain ```\\n00746| output:add_line('`````' .. (language or ''))\\n00747| output:add_lines(util.sanitize_lines(lines))\\n00748| output:add_line('`````')\\n00749| end\\n00750| \\n00751| ---@param output Output Output object to write to\\n00752| ---@param code string\\n00753| ---@param file_type string\\n00754| function M._format_diff(output, code, file_type)\\n00755| output:add_empty_line()\\n00756| \\n00757| --- NOTE: use longer code fence because code could contain ```\\n00758| output:add_line('`````' .. file_type)\\n00759| local lines = vim.split(code, '\\\\n')\\n00760| if #lines > 5 then\\n00761| lines = vim.list_slice(lines, 6)\\n00762| end\\n00763| \\n00764| for _, line in ipairs(lines) do\\n00765| local first_char = line:sub(1, 1)\\n00766| if first_char == '+' or first_char == '-' then\\n00767| local hl_group = first_char == '+' and 'OpencodeDiffAdd' or 'OpencodeDiffDelete'\\n00768| output:add_line(' ' .. line:sub(2))\\n00769| local line_idx = output:get_line_count()\\n00770| output:add_extmark(line_idx - 1, function()\\n00771| return {\\n00772| end_col = 0,\\n00773| end_row = line_idx,\\n00774| virt_text = { { first_char, hl_group } },\\n00775| hl_group = hl_group,\\n00776| hl_eol = true,\\n00777| priority = 5000,\\n00778| right_gravity = true,\\n00779| end_right_gravity = false,\\n00780| virt_text_hide = false,\\n00781| virt_text_pos = 'overlay',\\n00782| virt_text_repeat_linebreak = false,\\n00783| }\\n00784| end)\\n00785| else\\n00786| output:add_line(line)\\n00787| end\\n00788| end\\n00789| output:add_line('`````')\\n00790| end\\n00791| \\n00792| ---@param output Output Output object to write to\\n00793| ---@param start_line number\\n00794| ---@param end_line number\\n00795| ---@param hl_group string Highlight group for the border character\\n00796| ---@param win_col number\\n00797| ---@param text_hl_group? string Optional highlight group for the background/foreground of text lines\\n00798| function M._add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group)\\n00799| for line = start_line, end_line do\\n00800| local extmark_opts = {\\n00801| virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },\\n00802| virt_text_pos = 'overlay',\\n00803| virt_text_win_col = win_col,\\n00804| virt_text_repeat_linebreak = true,\\n00805| }\\n00806| \\n00807| -- Add line highlight if text_hl_group is provided\\n00808| if text_hl_group then\\n00809| extmark_opts.line_hl_group = text_hl_group\\n00810| end\\n00811| \\n00812| output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]])\\n00813| end\\n00814| end\\n00815| \\n00816| ---Formats a single message part and returns the resulting output object\\n00817| ---@param part OpencodeMessagePart The part to format\\n00818| ---@param message? OpencodeMessage Optional message object to extract role and mentions from\\n00819| ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one\\n00820| ---@return Output\\n00821| function M.format_part(part, message, is_last_part)\\n00822| local output = Output.new()\\n00823| \\n00824| if not message or not message.info or not message.info.role then\\n00825| return output\\n00826| end\\n00827| \\n00828| local content_added = false\\n00829| local role = message.info.role\\n00830| \\n00831| if role == 'user' then\\n00832| if part.type == 'text' and part.text then\\n00833| if part.synthetic == true then\\n00834| M._format_selection_context(output, part)\\n00835| M._format_cursor_data_context(output, part)\\n00836| M._format_diagnostics_context(output, part)\\n00837| else\\n00838| M._format_user_prompt(output, vim.trim(part.text), message)\\n00839| content_added = true\\n00840| end\\n00841| elseif part.type == 'file' then\\n00842| local file_line = M._format_context_file(output, part.filename)\\n00843| if file_line then\\n00844| M._add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)\\n00845| content_added = true\\n00846| end\\n00847| end\\n00848| elseif role == 'assistant' then\\n00849| if part.type == 'text' and part.text then\\n00850| M._format_assistant_message(output, vim.trim(part.text))\\n00851| content_added = true\\n00852| elseif part.type == 'reasoning' and part.text then\\n00853| M._format_reasoning(output, part)\\n00854| content_added = true\\n00855| elseif part.type == 'tool' then\\n00856| M._format_tool(output, part)\\n00857| content_added = true\\n00858| elseif part.type == 'patch' and part.hash then\\n00859| M._format_patch(output, part)\\n00860| content_added = true\\n00861| end\\n00862| end\\n00863| \\n00864| if content_added then\\n00865| output:add_empty_line()\\n00866| end\\n00867| \\n00868| if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then\\n00869| local error = message.info.error\\n00870| local error_message = error.data and error.data.message or vim.inspect(error)\\n00871| M._format_callout(output, 'ERROR', error_message)\\n00872| output:add_empty_line()\\n00873| end\\n00874| \\n00875| return output\\n00876| end\\n00877| \\n00878| return M\\n00879| \\n\\n(End of file - total 879 lines)\\n\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"file\",\"url\":\"file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\",\"id\":\"prt_b8e7c60a7002ybV09VDHoQQeO7\",\"filename\":\"lua/opencode/ui/formatter.lua\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"mime\":\"text/plain\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3002XHHlk77vAPApvR\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"{\\\"content\\\":[{\\\"msg\\\":\\\"expected `SessionRevertInfo` but found `SessionRevertInfo?`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l156:c68\\\"},{\\\"msg\\\":\\\"expected `integer?` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l490:c43\\\"},{\\\"msg\\\":\\\"expected `integer` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l492:c45\\\"},{\\\"msg\\\":\\\"expected `integer?` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l492:c62\\\"},{\\\"msg\\\":\\\"state.active_session may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l156:c68\\\"},{\\\"msg\\\":\\\"state.messages[#state.messages] may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l272:c74\\\"},{\\\"msg\\\":\\\"state.messages may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l272:c74\\\"},{\\\"msg\\\":\\\"Undefined field `[diag.severity]`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l435:c42\\\"},{\\\"msg\\\":\\\"Undefined field `command`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l526:c34\\\"},{\\\"msg\\\":\\\"Undefined field `command`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l527:c47\\\"},{\\\"msg\\\":\\\"Cannot assign `number` to `integer`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l493:c5\\\"}],\\\"context_type\\\":\\\"diagnostics\\\"}\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"time\":{\"updated\":1767622074603,\"created\":1767622072149}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"providerID\":\"github-copilot\",\"role\":\"assistant\",\"path\":{\"root\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"cwd\":\"/home/francis/Projects/_nvim/opencode.nvim\"},\"agent\":\"build\",\"time\":{\"created\":1767622074609},\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"mode\":\"build\",\"modelID\":\"gpt-4.1\",\"id\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"parentID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"cost\":0,\"tokens\":{\"output\":0,\"reasoning\":0,\"input\":0,\"cache\":{\"write\":0,\"read\":0}}}},\"type\":\"message.updated\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"created\":1767622074530},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"summary\":{\"diffs\":[]}}},\"type\":\"message.updated\"},{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"updated\":1767622074670,\"created\":1767622072149},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"summary\":{\"deletions\":0,\"additions\":0,\"files\":0}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"step-start\",\"id\":\"prt_b8e7c6672001F10WAiQKbzNEtE\",\"snapshot\":\"5ba6e95774829e7f501299039b72b72dc32c6620\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"tool\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"id\":\"prt_b8e7c6686001cCV44vsFtbpF42\",\"state\":{\"status\":\"pending\",\"input\":{},\"raw\":\"\"},\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\",\"tool\":\"bash\"}},\"type\":\"message.part.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"tool\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"id\":\"prt_b8e7c6686001cCV44vsFtbpF42\",\"state\":{\"status\":\"running\",\"input\":{\"description\":\"Shows working tree status\",\"command\":\"git status\"},\"time\":{\"start\":1767622076189}},\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\",\"tool\":\"bash\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"tool\":{\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\"},\"patterns\":[\"git status\"],\"id\":\"per_b8e7c6744001DX6EZ9KHM15RLi\",\"permission\":\"bash\",\"always\":[\"git status*\"],\"metadata\":{}},\"type\":\"permission.asked\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"created\":1767622074530},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"summary\":{\"title\":\"Running git status to list changed files\",\"diffs\":[]}}},\"type\":\"message.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"title\":\"Running git status for formatter.lua changes\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"updated\":1767622082619,\"created\":1767622072149},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"summary\":{\"deletions\":0,\"additions\":0,\"files\":0}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"}]\n","before":"","deletions":0,"additions":1}]}}},"type":"message.updated"},{"properties":{"info":{"title":"Running git status for formatter.lua changes","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"updated":1767622106805,"created":1767622072149},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"additions":1,"files":1}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"step-start","id":"prt_b8e7ce0ca001Hb5Sp11isapDGi","snapshot":"34a4eef77f714f2e3a282c410dbda315c4781fee","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil"}},"type":"message.part.updated"},{"properties":{"delta":" the","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":" according","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":" staged","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":".lua","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":"formatter","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":"/","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":" -","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/renderer.lua`\n\n- Untracked files:\n -","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":"/","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/renderer.lua`\n\n- Untracked files:\n - `test.lua`\n - `tests/data/","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":" currently","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/renderer.lua`\n\n- Untracked files:\n - `test.lua`\n - `tests/data/permission_ask_new.json`\n\nNo files are currently","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":" want","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/renderer.lua`\n\n- Untracked files:\n - `test.lua`\n - `tests/data/permission_ask_new.json`\n\nNo files are currently staged for commit. Let me know if you want","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":" stage","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/renderer.lua`\n\n- Untracked files:\n - `test.lua`\n - `tests/data/permission_ask_new.json`\n\nNo files are currently staged for commit. Let me know if you want more details (like the diff), want to stage","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"delta":"!","part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/renderer.lua`\n\n- Untracked files:\n - `test.lua`\n - `tests/data/permission_ask_new.json`\n\nNo files are currently staged for commit. Let me know if you want more details (like the diff), want to stage/commit, or need help with anything else!","time":{"start":1767622107340}}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce0cc001nZUgxwsgHwogal","text":"Here are the files that have been changed according to git status:\n\n- Modified (but not staged):\n - `lua/opencode/event_manager.lua`\n - `lua/opencode/ui/formatter.lua`\n - `lua/opencode/ui/renderer.lua`\n\n- Untracked files:\n - `test.lua`\n - `tests/data/permission_ask_new.json`\n\nNo files are currently staged for commit. Let me know if you want more details (like the diff), want to stage/commit, or need help with anything else!","time":{"start":1767622108550,"end":1767622108550}}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"step-finish","messageID":"msg_b8e7cde9e0013hRkUyt0X2yMil","id":"prt_b8e7ce5860011MSYAyLSbWKBop","reason":"stop","cost":0,"snapshot":"34a4eef77f714f2e3a282c410dbda315c4781fee","tokens":{"output":111,"reasoning":0,"input":179,"cache":{"write":0,"read":20864}}}},"type":"message.part.updated"},{"properties":{"info":{"providerID":"github-copilot","role":"assistant","path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","finish":"stop","time":{"created":1767622106782},"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","mode":"build","modelID":"gpt-4.1","id":"msg_b8e7cde9e0013hRkUyt0X2yMil","parentID":"msg_b8e7c60a2001Kisjwk2mVB4dye","cost":0,"tokens":{"output":111,"reasoning":0,"input":179,"cache":{"write":0,"read":20864}}}},"type":"message.updated"},{"properties":{"info":{"providerID":"github-copilot","role":"assistant","path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","finish":"stop","time":{"created":1767622106782,"completed":1767622108574},"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","mode":"build","modelID":"gpt-4.1","id":"msg_b8e7cde9e0013hRkUyt0X2yMil","parentID":"msg_b8e7c60a2001Kisjwk2mVB4dye","cost":0,"tokens":{"output":111,"reasoning":0,"input":179,"cache":{"write":0,"read":20864}}}},"type":"message.updated"},{"properties":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8"},"type":"session.idle"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"created":1767622074530},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","summary":{"title":"Running git status to list changed files","diffs":[{"file":"tests/data/permission_ask_new.json","after":"[{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"time\":{\"updated\":1767622072149,\"created\":1767622072149}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"time\":{\"created\":1767622074530}}},\"type\":\"message.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3001M2zsH9REfPdlQt\",\"text\":\"@no-trust run a git status and tell me what files are changed\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3003Ta9NcXkN0lb0lF\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"Called the Read tool with the following input: {\\\"filePath\\\":\\\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\\\"}\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a7001d3WhKOu2vCR4Cf\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"\\n00001| local context_module = require('opencode.context')\\n00002| local icons = require('opencode.ui.icons')\\n00003| local util = require('opencode.util')\\n00004| local Output = require('opencode.ui.output')\\n00005| local state = require('opencode.state')\\n00006| local config = require('opencode.config')\\n00007| local snapshot = require('opencode.snapshot')\\n00008| local mention = require('opencode.ui.mention')\\n00009| \\n00010| local M = {}\\n00011| \\n00012| M.separator = {\\n00013| '----',\\n00014| '',\\n00015| }\\n00016| \\n00017| ---@param output Output\\n00018| ---@param part OpencodeMessagePart\\n00019| function M._format_reasoning(output, part)\\n00020| local text = vim.trim(part.text or '')\\n00021| if text == '' then\\n00022| return\\n00023| end\\n00024| \\n00025| local start_line = output:get_line_count() + 1\\n00026| \\n00027| local title = 'Reasoning'\\n00028| local time = part.time\\n00029| if time and type(time) == 'table' and time.start then\\n00030| local start_text = util.format_time(time.start) or ''\\n00031| local end_text = (time['end'] and util.format_time(time['end'])) or nil\\n00032| if end_text and end_text ~= '' then\\n00033| title = string.format('%s (%s - %s)', title, start_text, end_text)\\n00034| elseif start_text ~= '' then\\n00035| title = string.format('%s (%s)', title, start_text)\\n00036| end\\n00037| end\\n00038| \\n00039| M._format_action(output, icons.get('reasoning') .. ' ' .. title, '')\\n00040| \\n00041| if config.ui.output.tools.show_reasoning_output then\\n00042| output:add_empty_line()\\n00043| output:add_lines(vim.split(text, '\\\\n'))\\n00044| output:add_empty_line()\\n00045| end\\n00046| \\n00047| local end_line = output:get_line_count()\\n00048| if end_line - start_line > 1 then\\n00049| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1, 'OpencodeReasoningText')\\n00050| else\\n00051| output:add_extmark(start_line - 1, {\\n00052| line_hl_group = 'OpencodeReasoningText',\\n00053| } --[[@as OutputExtmark]])\\n00054| end\\n00055| end\\n00056| \\n00057| function M._handle_permission_request(output, part)\\n00058| if part.state and part.state.status == 'error' and part.state.error then\\n00059| if part.state.error:match('rejected permission') then\\n00060| state.current_permission = nil\\n00061| else\\n00062| vim.notify('Unknown part state error: ' .. part.state.error)\\n00063| end\\n00064| return\\n00065| end\\n00066| \\n00067| M._format_permission_request(output)\\n00068| end\\n00069| \\n00070| function M._format_permission_request(output)\\n00071| local keys\\n00072| \\n00073| if require('opencode.ui.ui').is_opencode_focused() then\\n00074| keys = {\\n00075| config.keymap.permission.accept,\\n00076| config.keymap.permission.accept_all,\\n00077| config.keymap.permission.deny,\\n00078| }\\n00079| else\\n00080| keys = {\\n00081| config.get_key_for_function('editor', 'permission_accept'),\\n00082| config.get_key_for_function('editor', 'permission_accept_all'),\\n00083| config.get_key_for_function('editor', 'permission_deny'),\\n00084| }\\n00085| end\\n00086| \\n00087| output:add_empty_line()\\n00088| output:add_line('> [!WARNING] Permission required to run this tool.')\\n00089| output:add_line('>')\\n00090| output:add_line(('> Accept `%s` Always `%s` Deny `%s`'):format(unpack(keys)))\\n00091| output:add_empty_line()\\n00092| end\\n00093| \\n00094| ---Calculate statistics for reverted messages and tool calls\\n00095| ---@param messages {info: MessageInfo, parts: OpencodeMessagePart[]}[] All messages in the session\\n00096| ---@param revert_index number Index of the message where revert occurred\\n00097| ---@param revert_info SessionRevertInfo Revert information\\n00098| ---@return {messages: number, tool_calls: number, files: table}\\n00099| function M._calculate_revert_stats(messages, revert_index, revert_info)\\n00100| local stats = {\\n00101| messages = 0,\\n00102| tool_calls = 0,\\n00103| files = {}, -- { [filename] = { additions = n, deletions = m } }\\n00104| }\\n00105| \\n00106| for i = revert_index, #messages do\\n00107| local msg = messages[i]\\n00108| if msg.info.role == 'user' then\\n00109| stats.messages = stats.messages + 1\\n00110| end\\n00111| if msg.parts then\\n00112| for _, part in ipairs(msg.parts) do\\n00113| if part.type == 'tool' then\\n00114| stats.tool_calls = stats.tool_calls + 1\\n00115| end\\n00116| end\\n00117| end\\n00118| end\\n00119| \\n00120| if revert_info.diff then\\n00121| local current_file = nil\\n00122| for line in revert_info.diff:gmatch('[^\\\\r\\\\n]+') do\\n00123| local file_a = line:match('^%-%-%- ([ab]/.+)')\\n00124| local file_b = line:match('^%+%+%+ ([ab]/.+)')\\n00125| if file_b then\\n00126| current_file = file_b:gsub('^[ab]/', '')\\n00127| if not stats.files[current_file] then\\n00128| stats.files[current_file] = { additions = 0, deletions = 0 }\\n00129| end\\n00130| elseif file_a then\\n00131| current_file = file_a:gsub('^[ab]/', '')\\n00132| if not stats.files[current_file] then\\n00133| stats.files[current_file] = { additions = 0, deletions = 0 }\\n00134| end\\n00135| elseif line:sub(1, 1) == '+' and not line:match('^%+%+%+') then\\n00136| if current_file then\\n00137| stats.files[current_file].additions = stats.files[current_file].additions + 1\\n00138| end\\n00139| elseif line:sub(1, 1) == '-' and not line:match('^%-%-%-') then\\n00140| if current_file then\\n00141| stats.files[current_file].deletions = stats.files[current_file].deletions + 1\\n00142| end\\n00143| end\\n00144| end\\n00145| end\\n00146| \\n00147| return stats\\n00148| end\\n00149| \\n00150| ---Format the revert callout with statistics\\n00151| ---@param session_data OpencodeMessage[] All messages in the session\\n00152| ---@param start_idx number Index of the message where revert occurred\\n00153| ---@return Output output object representing the lines, extmarks, and actions\\n00154| function M._format_revert_message(session_data, start_idx)\\n00155| local output = Output.new()\\n00156| local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)\\n00157| local message_text = stats.messages == 1 and 'message' or 'messages'\\n00158| local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'\\n00159| \\n00160| output:add_lines(M.separator)\\n00161| output:add_line(\\n00162| string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)\\n00163| )\\n00164| output:add_line('>')\\n00165| output:add_line('> type `/redo` to restore.')\\n00166| output:add_empty_line()\\n00167| \\n00168| if stats.files and next(stats.files) then\\n00169| for file, fstats in pairs(stats.files) do\\n00170| local file_diff = {}\\n00171| if fstats.additions > 0 then\\n00172| table.insert(file_diff, '+' .. fstats.additions)\\n00173| end\\n00174| if fstats.deletions > 0 then\\n00175| table.insert(file_diff, '-' .. fstats.deletions)\\n00176| end\\n00177| if #file_diff > 0 then\\n00178| local line_str = string.format(icons.get('file') .. '%s: %s', file, table.concat(file_diff, ' '))\\n00179| local line_idx = output:add_line(line_str)\\n00180| local col = #(' ' .. file .. ': ')\\n00181| for _, diff in ipairs(file_diff) do\\n00182| local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText'\\n00183| output:add_extmark(line_idx - 1, {\\n00184| virt_text = { { diff, hl_group } },\\n00185| virt_text_pos = 'inline',\\n00186| virt_text_win_col = col,\\n00187| priority = 1000,\\n00188| } --[[@as OutputExtmark]])\\n00189| col = col + #diff + 1\\n00190| end\\n00191| end\\n00192| end\\n00193| end\\n00194| return output\\n00195| end\\n00196| \\n00197| local function add_action(output, text, action_type, args, key, line)\\n00198| -- actions use api-indexing (e.g. 0 indexed)\\n00199| line = (line or output:get_line_count()) - 1\\n00200| output:add_action({\\n00201| text = text,\\n00202| type = action_type,\\n00203| args = args,\\n00204| key = key,\\n00205| display_line = line,\\n00206| range = { from = line, to = line },\\n00207| })\\n00208| end\\n00209| \\n00210| ---@param output Output Output object to write to\\n00211| ---@param part OpencodeMessagePart\\n00212| function M._format_patch(output, part)\\n00213| if not part.hash then\\n00214| return\\n00215| end\\n00216| \\n00217| local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}\\n00218| M._format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))\\n00219| \\n00220| -- Anchor all snapshot-level actions to the snapshot header line\\n00221| add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')\\n00222| add_action(output, 'Revert [A]ll', 'diff_revert_all', { part.hash }, 'A')\\n00223| add_action(output, '[D]iff', 'diff_open', { part.hash }, 'D')\\n00224| \\n00225| if #restore_points > 0 then\\n00226| for _, restore_point in ipairs(restore_points) do\\n00227| output:add_line(\\n00228| string.format(\\n00229| ' %s Restore point `%s` - %s ',\\n00230| icons.get('restore_point'),\\n00231| vim.trim(restore_point.id:sub(1, 8)),\\n00232| util.format_time(restore_point.created_at)\\n00233| )\\n00234| )\\n00235| add_action(output, 'Restore [A]ll', 'diff_restore_snapshot_all', { restore_point.id }, 'A')\\n00236| add_action(output, '[R]estore file', 'diff_restore_snapshot_file', { restore_point.id }, 'R')\\n00237| end\\n00238| end\\n00239| end\\n00240| \\n00241| ---@param output Output Output object to write to\\n00242| ---@param message MessageInfo\\n00243| function M._format_error(output, message)\\n00244| output:add_empty_line()\\n00245| M._format_callout(output, 'ERROR', vim.inspect(message.error))\\n00246| end\\n00247| \\n00248| ---@param message OpencodeMessage\\n00249| ---@return Output\\n00250| function M.format_message_header(message)\\n00251| local output = Output.new()\\n00252| \\n00253| output:add_lines(M.separator)\\n00254| local role = message.info.role or 'unknown'\\n00255| local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')\\n00256| \\n00257| local time = message.info.time and message.info.time.created or nil\\n00258| local time_text = (time and ' (' .. util.format_time(time) .. ')' or '')\\n00259| local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)\\n00260| local model_text = message.info.modelID and ' ' .. message.info.modelID or ''\\n00261| \\n00262| local debug_text = config.debug.show_ids and ' [' .. message.info.id .. ']' or ''\\n00263| \\n00264| local display_name\\n00265| if role == 'assistant' then\\n00266| local mode = message.info.mode\\n00267| if mode and mode ~= '' then\\n00268| display_name = mode:upper()\\n00269| else\\n00270| -- For the most recent assistant message, show current_mode if mode is missing\\n00271| -- This handles new messages that haven't been stamped yet\\n00272| local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id\\n00273| if is_last_message and state.current_mode and state.current_mode ~= '' then\\n00274| display_name = state.current_mode:upper()\\n00275| else\\n00276| display_name = 'ASSISTANT'\\n00277| end\\n00278| end\\n00279| else\\n00280| display_name = role:upper()\\n00281| end\\n00282| \\n00283| output:add_extmark(output:get_line_count() - 1, {\\n00284| virt_text = {\\n00285| { icon, role_hl },\\n00286| { ' ' },\\n00287| { display_name, role_hl },\\n00288| { model_text, 'OpencodeHint' },\\n00289| { time_text, 'OpencodeHint' },\\n00290| { debug_text, 'OpencodeHint' },\\n00291| },\\n00292| virt_text_win_col = -3,\\n00293| priority = 10,\\n00294| } --[[@as OutputExtmark]])\\n00295| \\n00296| -- Only want to show the error if we have no parts. If we have parts, they'll\\n00297| -- handle rendering the error\\n00298| if\\n00299| role == 'assistant'\\n00300| and message.info.error\\n00301| and message.info.error ~= ''\\n00302| and (not message.parts or #message.parts == 0)\\n00303| then\\n00304| local error = message.info.error\\n00305| local error_message = error.data and error.data.message or vim.inspect(error)\\n00306| \\n00307| output:add_line('')\\n00308| M._format_callout(output, 'ERROR', error_message)\\n00309| end\\n00310| \\n00311| output:add_line('')\\n00312| return output\\n00313| end\\n00314| \\n00315| ---@param output Output Output object to write to\\n00316| ---@param callout string Callout type (e.g., 'ERROR', 'TODO')\\n00317| ---@param text string Callout text content\\n00318| ---@param title? string Optional title for the callout\\n00319| function M._format_callout(output, callout, text, title)\\n00320| title = title and title .. ' ' or ''\\n00321| local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win))\\n00322| and vim.api.nvim_win_get_width(state.windows.output_win)\\n00323| or config.ui.window_width\\n00324| or 80\\n00325| if #text > win_width - 4 then\\n00326| local ok, substituted = pcall(vim.fn.substitute, text, '\\\\v(.{' .. (win_width - 8) .. '})', '\\\\1\\\\n', 'g')\\n00327| text = ok and substituted or text\\n00328| end\\n00329| \\n00330| -- Trim off any trailing newlines so there isn't an extra line in the\\n00331| -- extmarks section\\n00332| local lines = vim.split(text:gsub('\\\\n$', ''), '\\\\n')\\n00333| if #lines == 1 and title == '' then\\n00334| output:add_line('> [!' .. callout .. '] ' .. lines[1])\\n00335| else\\n00336| output:add_line('> [!' .. callout .. ']' .. title)\\n00337| output:add_line('>')\\n00338| output:add_lines(lines, '> ')\\n00339| end\\n00340| end\\n00341| \\n00342| ---@param output Output Output object to write to\\n00343| ---@param text string\\n00344| ---@param message? OpencodeMessage Optional message object to extract mentions from\\n00345| function M._format_user_prompt(output, text, message)\\n00346| local start_line = output:get_line_count()\\n00347| \\n00348| output:add_lines(vim.split(text, '\\\\n'))\\n00349| \\n00350| local end_line = output:get_line_count()\\n00351| \\n00352| local end_line_extmark_offset = 0\\n00353| \\n00354| local mentions = {}\\n00355| if message and message.parts then\\n00356| -- message.parts will only be filled out on a re-render\\n00357| -- we need to collect the mentions here\\n00358| for _, part in ipairs(message.parts) do\\n00359| if part.type == 'file' then\\n00360| -- we're rerendering this part and we have files, the space after the user prompt\\n00361| -- also needs an extmark\\n00362| end_line_extmark_offset = 1\\n00363| if part.source and part.source.text then\\n00364| table.insert(mentions, part.source.text)\\n00365| end\\n00366| elseif part.type == 'agent' then\\n00367| if part.source then\\n00368| table.insert(mentions, part.source)\\n00369| end\\n00370| end\\n00371| end\\n00372| end\\n00373| \\n00374| if #mentions > 0 then\\n00375| mention.highlight_mentions_in_output(output, text, mentions, start_line)\\n00376| end\\n00377| \\n00378| M._add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)\\n00379| end\\n00380| \\n00381| ---@param output Output Output object to write to\\n00382| ---@param part OpencodeMessagePart\\n00383| function M._format_selection_context(output, part)\\n00384| local json = context_module.decode_json_context(part.text or '', 'selection')\\n00385| if not json then\\n00386| return\\n00387| end\\n00388| local start_line = output:get_line_count()\\n00389| output:add_lines(vim.split(json.content or '', '\\\\n'))\\n00390| output:add_empty_line()\\n00391| \\n00392| local end_line = output:get_line_count()\\n00393| \\n00394| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00395| end\\n00396| \\n00397| ---@param output Output Output object to write to\\n00398| ---@param part OpencodeMessagePart\\n00399| function M._format_cursor_data_context(output, part)\\n00400| local json = context_module.decode_json_context(part.text or '', 'cursor-data')\\n00401| if not json then\\n00402| return\\n00403| end\\n00404| local start_line = output:get_line_count()\\n00405| output:add_line('Line ' .. json.line .. ':')\\n00406| output:add_lines(vim.split(json.line_content or '', '\\\\n'))\\n00407| output:add_empty_line()\\n00408| \\n00409| local end_line = output:get_line_count()\\n00410| \\n00411| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00412| end\\n00413| \\n00414| ---@param output Output Output object to write to\\n00415| ---@param part OpencodeMessagePart\\n00416| function M._format_diagnostics_context(output, part)\\n00417| local json = context_module.decode_json_context(part.text or '', 'diagnostics')\\n00418| if not json then\\n00419| return\\n00420| end\\n00421| local start_line = output:get_line_count()\\n00422| local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]\\n00423| if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then\\n00424| return\\n00425| end\\n00426| \\n00427| local diagnostics_count = { error = 0, warn = 0, info = 0 }\\n00428| local diagnostics_icons = {\\n00429| error = icons.get('error'),\\n00430| warn = icons.get('warning'),\\n00431| info = icons.get('info'),\\n00432| }\\n00433| \\n00434| for _, diag in ipairs(diagnostics) do\\n00435| local name = vim.diagnostic.severity[diag.severity]:lower()\\n00436| diagnostics_count[name] = diagnostics_count[name] + 1\\n00437| end\\n00438| \\n00439| local diag_line = '**Diagnostics:**'\\n00440| for name, count in pairs(diagnostics_count) do\\n00441| if count > 0 then\\n00442| diag_line = diag_line .. (string.format(' %s(%d)', diagnostics_icons[name], count))\\n00443| end\\n00444| end\\n00445| output:add_line(diag_line)\\n00446| output:add_empty_line()\\n00447| local end_line = output:get_line_count()\\n00448| \\n00449| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00450| end\\n00451| \\n00452| ---Format and display the file path in the context\\n00453| ---@param output Output Output object to write to\\n00454| ---@param path string|nil File path\\n00455| function M._format_context_file(output, path)\\n00456| if not path or path == '' then\\n00457| return\\n00458| end\\n00459| local cwd = vim.fn.getcwd()\\n00460| if vim.startswith(path, cwd) then\\n00461| path = path:sub(#cwd + 2)\\n00462| end\\n00463| return output:add_line(string.format('[%s](%s)', path, path))\\n00464| end\\n00465| \\n00466| ---@param output Output Output object to write to\\n00467| ---@param text string\\n00468| function M._format_assistant_message(output, text)\\n00469| local reference_picker = require('opencode.ui.reference_picker')\\n00470| local references = reference_picker.parse_references(text, '')\\n00471| \\n00472| -- If no references, just add the text as-is\\n00473| if #references == 0 then\\n00474| output:add_lines(vim.split(text, '\\\\n'))\\n00475| return\\n00476| end\\n00477| \\n00478| -- Sort references by match_start position (ascending)\\n00479| table.sort(references, function(a, b)\\n00480| return a.match_start < b.match_start\\n00481| end)\\n00482| \\n00483| -- Build a new text with icons inserted before each reference\\n00484| local result = ''\\n00485| local last_pos = 1\\n00486| local ref_icon = icons.get('reference')\\n00487| \\n00488| for _, ref in ipairs(references) do\\n00489| -- Add text before this reference\\n00490| result = result .. text:sub(last_pos, ref.match_start - 1)\\n00491| -- Add the icon and the reference\\n00492| result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)\\n00493| last_pos = ref.match_end + 1\\n00494| end\\n00495| \\n00496| -- Add any remaining text after the last reference\\n00497| if last_pos <= #text then\\n00498| result = result .. text:sub(last_pos)\\n00499| end\\n00500| \\n00501| output:add_lines(vim.split(result, '\\\\n'))\\n00502| end\\n00503| \\n00504| ---@param output Output Output object to write to\\n00505| ---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)\\n00506| ---@param value string Value associated with the action (e.g., filename, command)\\n00507| function M._format_action(output, tool_type, value)\\n00508| if not tool_type or not value then\\n00509| return\\n00510| end\\n00511| local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '')\\n00512| \\n00513| output:add_line(line)\\n00514| end\\n00515| \\n00516| ---@param output Output Output object to write to\\n00517| ---@param input BashToolInput data for the tool\\n00518| ---@param metadata BashToolMetadata Metadata for the tool use\\n00519| function M._format_bash_tool(output, input, metadata)\\n00520| M._format_action(output, icons.get('run') .. ' run', input and input.description)\\n00521| \\n00522| if not config.ui.output.tools.show_output then\\n00523| return\\n00524| end\\n00525| \\n00526| if metadata.output or metadata.command or input.command then\\n00527| local command = input.command or metadata.command or ''\\n00528| local command_output = metadata.output and metadata.output ~= '' and ('\\\\n' .. metadata.output) or ''\\n00529| M._format_code(output, vim.split('> ' .. command .. '\\\\n' .. command_output, '\\\\n'), 'bash')\\n00530| end\\n00531| end\\n00532| \\n00533| ---@param output Output Output object to write to\\n00534| ---@param tool_type string Tool type (e.g., 'read', 'edit', 'write')\\n00535| ---@param input FileToolInput data for the tool\\n00536| ---@param metadata FileToolMetadata Metadata for the tool use\\n00537| function M._format_file_tool(output, tool_type, input, metadata)\\n00538| local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''\\n00539| local file_type = input and util.get_markdown_filetype(input.filePath) or ''\\n00540| local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }\\n00541| \\n00542| M._format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name)\\n00543| \\n00544| if not config.ui.output.tools.show_output then\\n00545| return\\n00546| end\\n00547| \\n00548| if tool_type == 'edit' and metadata.diff then\\n00549| M._format_diff(output, metadata.diff, file_type)\\n00550| elseif tool_type == 'write' and input and input.content then\\n00551| M._format_code(output, vim.split(input.content, '\\\\n'), file_type)\\n00552| end\\n00553| end\\n00554| \\n00555| ---@param output Output Output object to write to\\n00556| ---@param title string\\n00557| ---@param input TodoToolInput\\n00558| function M._format_todo_tool(output, title, input)\\n00559| M._format_action(output, icons.get('plan') .. ' plan', (title or ''))\\n00560| if not config.ui.output.tools.show_output then\\n00561| return\\n00562| end\\n00563| \\n00564| local todos = input and input.todos or {}\\n00565| \\n00566| for _, item in ipairs(todos) do\\n00567| local statuses = { in_progress = '-', completed = 'x', pending = ' ' }\\n00568| output:add_line(string.format('- [%s] %s ', statuses[item.status], item.content))\\n00569| end\\n00570| end\\n00571| \\n00572| ---@param output Output Output object to write to\\n00573| ---@param input GlobToolInput data for the tool\\n00574| ---@param metadata GlobToolMetadata Metadata for the tool use\\n00575| function M._format_glob_tool(output, input, metadata)\\n00576| M._format_action(output, icons.get('search') .. ' glob', input and input.pattern)\\n00577| if not config.ui.output.tools.show_output then\\n00578| return\\n00579| end\\n00580| local prefix = metadata.truncated and ' more than' or ''\\n00581| output:add_line(string.format('Found%s `%d` file(s):', prefix, metadata.count or 0))\\n00582| end\\n00583| \\n00584| ---@param output Output Output object to write to\\n00585| ---@param input GrepToolInput data for the tool\\n00586| ---@param metadata GrepToolMetadata Metadata for the tool use\\n00587| function M._format_grep_tool(output, input, metadata)\\n00588| input = input or { path = '', include = '', pattern = '' }\\n00589| \\n00590| local grep_str = string.format('%s` `%s', (input.path or input.include) or '', input.pattern or '')\\n00591| \\n00592| M._format_action(output, icons.get('search') .. ' grep', grep_str)\\n00593| if not config.ui.output.tools.show_output then\\n00594| return\\n00595| end\\n00596| local prefix = metadata.truncated and ' more than' or ''\\n00597| output:add_line(\\n00598| string.format('Found%s `%d` match' .. (metadata.matches ~= 1 and 'es' or ''), prefix, metadata.matches or 0)\\n00599| )\\n00600| end\\n00601| \\n00602| ---@param output Output Output object to write to\\n00603| ---@param input WebFetchToolInput data for the tool\\n00604| function M._format_webfetch_tool(output, input)\\n00605| M._format_action(output, icons.get('web') .. ' fetch', input and input.url)\\n00606| end\\n00607| \\n00608| ---@param output Output Output object to write to\\n00609| ---@param input ListToolInput\\n00610| ---@param metadata ListToolMetadata\\n00611| ---@param tool_output string\\n00612| function M._format_list_tool(output, input, metadata, tool_output)\\n00613| M._format_action(output, icons.get('list') .. ' list', input and input.path or '')\\n00614| if not config.ui.output.tools.show_output then\\n00615| return\\n00616| end\\n00617| local lines = vim.split(vim.trim(tool_output or ''), '\\\\n')\\n00618| if #lines < 1 or metadata.count == 0 then\\n00619| output:add_line('No files found.')\\n00620| return\\n00621| end\\n00622| if #lines > 1 then\\n00623| output:add_line('Files:')\\n00624| for i = 2, #lines do\\n00625| local file = vim.trim(lines[i])\\n00626| if file ~= '' then\\n00627| output:add_line(' • ' .. file)\\n00628| end\\n00629| end\\n00630| end\\n00631| if metadata.truncated then\\n00632| output:add_line(string.format('Results truncated, showing first %d files', metadata.count or '?'))\\n00633| end\\n00634| end\\n00635| \\n00636| ---@param output Output Output object to write to\\n00637| ---@param part OpencodeMessagePart\\n00638| function M._format_tool(output, part)\\n00639| local tool = part.tool\\n00640| if not tool or not part.state then\\n00641| return\\n00642| end\\n00643| \\n00644| local start_line = output:get_line_count() + 1\\n00645| local input = part.state.input or {}\\n00646| local metadata = part.state.metadata or {}\\n00647| local tool_output = part.state.output or ''\\n00648| \\n00649| if state.current_permission and state.current_permission.messageID == part.messageID then\\n00650| metadata = state.current_permission.metadata or metadata\\n00651| end\\n00652| \\n00653| if tool == 'bash' then\\n00654| M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])\\n00655| elseif tool == 'read' or tool == 'edit' or tool == 'write' then\\n00656| M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]])\\n00657| elseif tool == 'todowrite' then\\n00658| M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]])\\n00659| elseif tool == 'glob' then\\n00660| M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]])\\n00661| elseif tool == 'list' then\\n00662| M._format_list_tool(output, input --[[@as ListToolInput]], metadata --[[@as ListToolMetadata]], tool_output)\\n00663| elseif tool == 'grep' then\\n00664| M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]])\\n00665| elseif tool == 'webfetch' then\\n00666| M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]])\\n00667| elseif tool == 'task' then\\n00668| M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output)\\n00669| else\\n00670| M._format_action(output, icons.get('tool') .. ' tool', tool)\\n00671| end\\n00672| \\n00673| if part.state.status == 'error' and part.state.error then\\n00674| output:add_line('')\\n00675| M._format_callout(output, 'ERROR', part.state.error)\\n00676| ---@diagnostic disable-next-line: undefined-field\\n00677| elseif part.state.input and part.state.input.error then\\n00678| output:add_line('')\\n00679| ---I'm not sure about the type with state.input.error\\n00680| ---@diagnostic disable-next-line: undefined-field\\n00681| M._format_callout(output, 'ERROR', part.state.input.error)\\n00682| end\\n00683| \\n00684| if\\n00685| state.current_permission\\n00686| and (\\n00687| (\\n00688| state.current_permission.tool\\n00689| and state.current_permission.tool.callID == part.callID\\n00690| and state.current_permission.tool.messageID == part.messageID\\n00691| )\\n00692| ---@TODO this is for backward compatibility, remove later\\n00693| or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID)\\n00694| )\\n00695| then\\n00696| M._handle_permission_request(output, part)\\n00697| end\\n00698| \\n00699| local end_line = output:get_line_count()\\n00700| if end_line - start_line > 1 then\\n00701| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1)\\n00702| end\\n00703| end\\n00704| \\n00705| ---@param output Output Output object to write to\\n00706| ---@param input TaskToolInput data for the tool\\n00707| ---@param metadata TaskToolMetadata Metadata for the tool use\\n00708| ---@param tool_output string\\n00709| function M._format_task_tool(output, input, metadata, tool_output)\\n00710| local start_line = output:get_line_count() + 1\\n00711| M._format_action(output, icons.get('task') .. ' task', input and input.description)\\n00712| \\n00713| if config.ui.output.tools.show_output then\\n00714| if tool_output and tool_output ~= '' then\\n00715| output:add_empty_line()\\n00716| output:add_lines(vim.split(tool_output, '\\\\n'))\\n00717| output:add_empty_line()\\n00718| end\\n00719| \\n00720| if metadata.summary and type(metadata.summary) == 'table' then\\n00721| for _, sub_part in ipairs(metadata.summary) do\\n00722| if sub_part.type == 'tool' and sub_part.tool then\\n00723| M._format_tool(output, sub_part)\\n00724| end\\n00725| end\\n00726| end\\n00727| end\\n00728| \\n00729| local end_line = output:get_line_count()\\n00730| output:add_action({\\n00731| text = '[S]elect Child Session',\\n00732| type = 'select_child_session',\\n00733| args = {},\\n00734| key = 'S',\\n00735| display_line = start_line - 1,\\n00736| range = { from = start_line, to = end_line },\\n00737| })\\n00738| end\\n00739| \\n00740| ---@param output Output Output object to write to\\n00741| ---@param lines string[]\\n00742| ---@param language string\\n00743| function M._format_code(output, lines, language)\\n00744| output:add_empty_line()\\n00745| --- NOTE: use longer code fence because lines could contain ```\\n00746| output:add_line('`````' .. (language or ''))\\n00747| output:add_lines(util.sanitize_lines(lines))\\n00748| output:add_line('`````')\\n00749| end\\n00750| \\n00751| ---@param output Output Output object to write to\\n00752| ---@param code string\\n00753| ---@param file_type string\\n00754| function M._format_diff(output, code, file_type)\\n00755| output:add_empty_line()\\n00756| \\n00757| --- NOTE: use longer code fence because code could contain ```\\n00758| output:add_line('`````' .. file_type)\\n00759| local lines = vim.split(code, '\\\\n')\\n00760| if #lines > 5 then\\n00761| lines = vim.list_slice(lines, 6)\\n00762| end\\n00763| \\n00764| for _, line in ipairs(lines) do\\n00765| local first_char = line:sub(1, 1)\\n00766| if first_char == '+' or first_char == '-' then\\n00767| local hl_group = first_char == '+' and 'OpencodeDiffAdd' or 'OpencodeDiffDelete'\\n00768| output:add_line(' ' .. line:sub(2))\\n00769| local line_idx = output:get_line_count()\\n00770| output:add_extmark(line_idx - 1, function()\\n00771| return {\\n00772| end_col = 0,\\n00773| end_row = line_idx,\\n00774| virt_text = { { first_char, hl_group } },\\n00775| hl_group = hl_group,\\n00776| hl_eol = true,\\n00777| priority = 5000,\\n00778| right_gravity = true,\\n00779| end_right_gravity = false,\\n00780| virt_text_hide = false,\\n00781| virt_text_pos = 'overlay',\\n00782| virt_text_repeat_linebreak = false,\\n00783| }\\n00784| end)\\n00785| else\\n00786| output:add_line(line)\\n00787| end\\n00788| end\\n00789| output:add_line('`````')\\n00790| end\\n00791| \\n00792| ---@param output Output Output object to write to\\n00793| ---@param start_line number\\n00794| ---@param end_line number\\n00795| ---@param hl_group string Highlight group for the border character\\n00796| ---@param win_col number\\n00797| ---@param text_hl_group? string Optional highlight group for the background/foreground of text lines\\n00798| function M._add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group)\\n00799| for line = start_line, end_line do\\n00800| local extmark_opts = {\\n00801| virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },\\n00802| virt_text_pos = 'overlay',\\n00803| virt_text_win_col = win_col,\\n00804| virt_text_repeat_linebreak = true,\\n00805| }\\n00806| \\n00807| -- Add line highlight if text_hl_group is provided\\n00808| if text_hl_group then\\n00809| extmark_opts.line_hl_group = text_hl_group\\n00810| end\\n00811| \\n00812| output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]])\\n00813| end\\n00814| end\\n00815| \\n00816| ---Formats a single message part and returns the resulting output object\\n00817| ---@param part OpencodeMessagePart The part to format\\n00818| ---@param message? OpencodeMessage Optional message object to extract role and mentions from\\n00819| ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one\\n00820| ---@return Output\\n00821| function M.format_part(part, message, is_last_part)\\n00822| local output = Output.new()\\n00823| \\n00824| if not message or not message.info or not message.info.role then\\n00825| return output\\n00826| end\\n00827| \\n00828| local content_added = false\\n00829| local role = message.info.role\\n00830| \\n00831| if role == 'user' then\\n00832| if part.type == 'text' and part.text then\\n00833| if part.synthetic == true then\\n00834| M._format_selection_context(output, part)\\n00835| M._format_cursor_data_context(output, part)\\n00836| M._format_diagnostics_context(output, part)\\n00837| else\\n00838| M._format_user_prompt(output, vim.trim(part.text), message)\\n00839| content_added = true\\n00840| end\\n00841| elseif part.type == 'file' then\\n00842| local file_line = M._format_context_file(output, part.filename)\\n00843| if file_line then\\n00844| M._add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)\\n00845| content_added = true\\n00846| end\\n00847| end\\n00848| elseif role == 'assistant' then\\n00849| if part.type == 'text' and part.text then\\n00850| M._format_assistant_message(output, vim.trim(part.text))\\n00851| content_added = true\\n00852| elseif part.type == 'reasoning' and part.text then\\n00853| M._format_reasoning(output, part)\\n00854| content_added = true\\n00855| elseif part.type == 'tool' then\\n00856| M._format_tool(output, part)\\n00857| content_added = true\\n00858| elseif part.type == 'patch' and part.hash then\\n00859| M._format_patch(output, part)\\n00860| content_added = true\\n00861| end\\n00862| end\\n00863| \\n00864| if content_added then\\n00865| output:add_empty_line()\\n00866| end\\n00867| \\n00868| if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then\\n00869| local error = message.info.error\\n00870| local error_message = error.data and error.data.message or vim.inspect(error)\\n00871| M._format_callout(output, 'ERROR', error_message)\\n00872| output:add_empty_line()\\n00873| end\\n00874| \\n00875| return output\\n00876| end\\n00877| \\n00878| return M\\n00879| \\n\\n(End of file - total 879 lines)\\n\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"file\",\"url\":\"file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\",\"id\":\"prt_b8e7c60a7002ybV09VDHoQQeO7\",\"filename\":\"lua/opencode/ui/formatter.lua\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"mime\":\"text/plain\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3002XHHlk77vAPApvR\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"{\\\"content\\\":[{\\\"msg\\\":\\\"expected `SessionRevertInfo` but found `SessionRevertInfo?`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l156:c68\\\"},{\\\"msg\\\":\\\"expected `integer?` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l490:c43\\\"},{\\\"msg\\\":\\\"expected `integer` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l492:c45\\\"},{\\\"msg\\\":\\\"expected `integer?` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l492:c62\\\"},{\\\"msg\\\":\\\"state.active_session may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l156:c68\\\"},{\\\"msg\\\":\\\"state.messages[#state.messages] may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l272:c74\\\"},{\\\"msg\\\":\\\"state.messages may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l272:c74\\\"},{\\\"msg\\\":\\\"Undefined field `[diag.severity]`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l435:c42\\\"},{\\\"msg\\\":\\\"Undefined field `command`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l526:c34\\\"},{\\\"msg\\\":\\\"Undefined field `command`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l527:c47\\\"},{\\\"msg\\\":\\\"Cannot assign `number` to `integer`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l493:c5\\\"}],\\\"context_type\\\":\\\"diagnostics\\\"}\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"time\":{\"updated\":1767622074603,\"created\":1767622072149}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"providerID\":\"github-copilot\",\"role\":\"assistant\",\"path\":{\"root\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"cwd\":\"/home/francis/Projects/_nvim/opencode.nvim\"},\"agent\":\"build\",\"time\":{\"created\":1767622074609},\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"mode\":\"build\",\"modelID\":\"gpt-4.1\",\"id\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"parentID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"cost\":0,\"tokens\":{\"output\":0,\"reasoning\":0,\"input\":0,\"cache\":{\"write\":0,\"read\":0}}}},\"type\":\"message.updated\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"created\":1767622074530},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"summary\":{\"diffs\":[]}}},\"type\":\"message.updated\"},{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"updated\":1767622074670,\"created\":1767622072149},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"summary\":{\"deletions\":0,\"additions\":0,\"files\":0}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"step-start\",\"id\":\"prt_b8e7c6672001F10WAiQKbzNEtE\",\"snapshot\":\"5ba6e95774829e7f501299039b72b72dc32c6620\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"tool\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"id\":\"prt_b8e7c6686001cCV44vsFtbpF42\",\"state\":{\"status\":\"pending\",\"input\":{},\"raw\":\"\"},\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\",\"tool\":\"bash\"}},\"type\":\"message.part.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"tool\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"id\":\"prt_b8e7c6686001cCV44vsFtbpF42\",\"state\":{\"status\":\"running\",\"input\":{\"description\":\"Shows working tree status\",\"command\":\"git status\"},\"time\":{\"start\":1767622076189}},\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\",\"tool\":\"bash\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"tool\":{\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\"},\"patterns\":[\"git status\"],\"id\":\"per_b8e7c6744001DX6EZ9KHM15RLi\",\"permission\":\"bash\",\"always\":[\"git status*\"],\"metadata\":{}},\"type\":\"permission.asked\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"created\":1767622074530},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"summary\":{\"title\":\"Running git status to list changed files\",\"diffs\":[]}}},\"type\":\"message.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"title\":\"Running git status for formatter.lua changes\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"updated\":1767622082619,\"created\":1767622072149},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"summary\":{\"deletions\":0,\"additions\":0,\"files\":0}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"}]\n","before":"","deletions":0,"additions":1}]}}},"type":"message.updated"},{"properties":{"info":{"title":"Running git status for formatter.lua changes","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"updated":1767622108600,"created":1767622072149},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"additions":1,"files":1}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"created":1767622074530},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","summary":{"title":"Running git status to list changed files","diffs":[{"file":"tests/data/permission_ask_new.json","after":"[{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"time\":{\"updated\":1767622072149,\"created\":1767622072149}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"time\":{\"created\":1767622074530}}},\"type\":\"message.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3001M2zsH9REfPdlQt\",\"text\":\"@no-trust run a git status and tell me what files are changed\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3003Ta9NcXkN0lb0lF\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"Called the Read tool with the following input: {\\\"filePath\\\":\\\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\\\"}\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a7001d3WhKOu2vCR4Cf\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"\\n00001| local context_module = require('opencode.context')\\n00002| local icons = require('opencode.ui.icons')\\n00003| local util = require('opencode.util')\\n00004| local Output = require('opencode.ui.output')\\n00005| local state = require('opencode.state')\\n00006| local config = require('opencode.config')\\n00007| local snapshot = require('opencode.snapshot')\\n00008| local mention = require('opencode.ui.mention')\\n00009| \\n00010| local M = {}\\n00011| \\n00012| M.separator = {\\n00013| '----',\\n00014| '',\\n00015| }\\n00016| \\n00017| ---@param output Output\\n00018| ---@param part OpencodeMessagePart\\n00019| function M._format_reasoning(output, part)\\n00020| local text = vim.trim(part.text or '')\\n00021| if text == '' then\\n00022| return\\n00023| end\\n00024| \\n00025| local start_line = output:get_line_count() + 1\\n00026| \\n00027| local title = 'Reasoning'\\n00028| local time = part.time\\n00029| if time and type(time) == 'table' and time.start then\\n00030| local start_text = util.format_time(time.start) or ''\\n00031| local end_text = (time['end'] and util.format_time(time['end'])) or nil\\n00032| if end_text and end_text ~= '' then\\n00033| title = string.format('%s (%s - %s)', title, start_text, end_text)\\n00034| elseif start_text ~= '' then\\n00035| title = string.format('%s (%s)', title, start_text)\\n00036| end\\n00037| end\\n00038| \\n00039| M._format_action(output, icons.get('reasoning') .. ' ' .. title, '')\\n00040| \\n00041| if config.ui.output.tools.show_reasoning_output then\\n00042| output:add_empty_line()\\n00043| output:add_lines(vim.split(text, '\\\\n'))\\n00044| output:add_empty_line()\\n00045| end\\n00046| \\n00047| local end_line = output:get_line_count()\\n00048| if end_line - start_line > 1 then\\n00049| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1, 'OpencodeReasoningText')\\n00050| else\\n00051| output:add_extmark(start_line - 1, {\\n00052| line_hl_group = 'OpencodeReasoningText',\\n00053| } --[[@as OutputExtmark]])\\n00054| end\\n00055| end\\n00056| \\n00057| function M._handle_permission_request(output, part)\\n00058| if part.state and part.state.status == 'error' and part.state.error then\\n00059| if part.state.error:match('rejected permission') then\\n00060| state.current_permission = nil\\n00061| else\\n00062| vim.notify('Unknown part state error: ' .. part.state.error)\\n00063| end\\n00064| return\\n00065| end\\n00066| \\n00067| M._format_permission_request(output)\\n00068| end\\n00069| \\n00070| function M._format_permission_request(output)\\n00071| local keys\\n00072| \\n00073| if require('opencode.ui.ui').is_opencode_focused() then\\n00074| keys = {\\n00075| config.keymap.permission.accept,\\n00076| config.keymap.permission.accept_all,\\n00077| config.keymap.permission.deny,\\n00078| }\\n00079| else\\n00080| keys = {\\n00081| config.get_key_for_function('editor', 'permission_accept'),\\n00082| config.get_key_for_function('editor', 'permission_accept_all'),\\n00083| config.get_key_for_function('editor', 'permission_deny'),\\n00084| }\\n00085| end\\n00086| \\n00087| output:add_empty_line()\\n00088| output:add_line('> [!WARNING] Permission required to run this tool.')\\n00089| output:add_line('>')\\n00090| output:add_line(('> Accept `%s` Always `%s` Deny `%s`'):format(unpack(keys)))\\n00091| output:add_empty_line()\\n00092| end\\n00093| \\n00094| ---Calculate statistics for reverted messages and tool calls\\n00095| ---@param messages {info: MessageInfo, parts: OpencodeMessagePart[]}[] All messages in the session\\n00096| ---@param revert_index number Index of the message where revert occurred\\n00097| ---@param revert_info SessionRevertInfo Revert information\\n00098| ---@return {messages: number, tool_calls: number, files: table}\\n00099| function M._calculate_revert_stats(messages, revert_index, revert_info)\\n00100| local stats = {\\n00101| messages = 0,\\n00102| tool_calls = 0,\\n00103| files = {}, -- { [filename] = { additions = n, deletions = m } }\\n00104| }\\n00105| \\n00106| for i = revert_index, #messages do\\n00107| local msg = messages[i]\\n00108| if msg.info.role == 'user' then\\n00109| stats.messages = stats.messages + 1\\n00110| end\\n00111| if msg.parts then\\n00112| for _, part in ipairs(msg.parts) do\\n00113| if part.type == 'tool' then\\n00114| stats.tool_calls = stats.tool_calls + 1\\n00115| end\\n00116| end\\n00117| end\\n00118| end\\n00119| \\n00120| if revert_info.diff then\\n00121| local current_file = nil\\n00122| for line in revert_info.diff:gmatch('[^\\\\r\\\\n]+') do\\n00123| local file_a = line:match('^%-%-%- ([ab]/.+)')\\n00124| local file_b = line:match('^%+%+%+ ([ab]/.+)')\\n00125| if file_b then\\n00126| current_file = file_b:gsub('^[ab]/', '')\\n00127| if not stats.files[current_file] then\\n00128| stats.files[current_file] = { additions = 0, deletions = 0 }\\n00129| end\\n00130| elseif file_a then\\n00131| current_file = file_a:gsub('^[ab]/', '')\\n00132| if not stats.files[current_file] then\\n00133| stats.files[current_file] = { additions = 0, deletions = 0 }\\n00134| end\\n00135| elseif line:sub(1, 1) == '+' and not line:match('^%+%+%+') then\\n00136| if current_file then\\n00137| stats.files[current_file].additions = stats.files[current_file].additions + 1\\n00138| end\\n00139| elseif line:sub(1, 1) == '-' and not line:match('^%-%-%-') then\\n00140| if current_file then\\n00141| stats.files[current_file].deletions = stats.files[current_file].deletions + 1\\n00142| end\\n00143| end\\n00144| end\\n00145| end\\n00146| \\n00147| return stats\\n00148| end\\n00149| \\n00150| ---Format the revert callout with statistics\\n00151| ---@param session_data OpencodeMessage[] All messages in the session\\n00152| ---@param start_idx number Index of the message where revert occurred\\n00153| ---@return Output output object representing the lines, extmarks, and actions\\n00154| function M._format_revert_message(session_data, start_idx)\\n00155| local output = Output.new()\\n00156| local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)\\n00157| local message_text = stats.messages == 1 and 'message' or 'messages'\\n00158| local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'\\n00159| \\n00160| output:add_lines(M.separator)\\n00161| output:add_line(\\n00162| string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)\\n00163| )\\n00164| output:add_line('>')\\n00165| output:add_line('> type `/redo` to restore.')\\n00166| output:add_empty_line()\\n00167| \\n00168| if stats.files and next(stats.files) then\\n00169| for file, fstats in pairs(stats.files) do\\n00170| local file_diff = {}\\n00171| if fstats.additions > 0 then\\n00172| table.insert(file_diff, '+' .. fstats.additions)\\n00173| end\\n00174| if fstats.deletions > 0 then\\n00175| table.insert(file_diff, '-' .. fstats.deletions)\\n00176| end\\n00177| if #file_diff > 0 then\\n00178| local line_str = string.format(icons.get('file') .. '%s: %s', file, table.concat(file_diff, ' '))\\n00179| local line_idx = output:add_line(line_str)\\n00180| local col = #(' ' .. file .. ': ')\\n00181| for _, diff in ipairs(file_diff) do\\n00182| local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText'\\n00183| output:add_extmark(line_idx - 1, {\\n00184| virt_text = { { diff, hl_group } },\\n00185| virt_text_pos = 'inline',\\n00186| virt_text_win_col = col,\\n00187| priority = 1000,\\n00188| } --[[@as OutputExtmark]])\\n00189| col = col + #diff + 1\\n00190| end\\n00191| end\\n00192| end\\n00193| end\\n00194| return output\\n00195| end\\n00196| \\n00197| local function add_action(output, text, action_type, args, key, line)\\n00198| -- actions use api-indexing (e.g. 0 indexed)\\n00199| line = (line or output:get_line_count()) - 1\\n00200| output:add_action({\\n00201| text = text,\\n00202| type = action_type,\\n00203| args = args,\\n00204| key = key,\\n00205| display_line = line,\\n00206| range = { from = line, to = line },\\n00207| })\\n00208| end\\n00209| \\n00210| ---@param output Output Output object to write to\\n00211| ---@param part OpencodeMessagePart\\n00212| function M._format_patch(output, part)\\n00213| if not part.hash then\\n00214| return\\n00215| end\\n00216| \\n00217| local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}\\n00218| M._format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))\\n00219| \\n00220| -- Anchor all snapshot-level actions to the snapshot header line\\n00221| add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')\\n00222| add_action(output, 'Revert [A]ll', 'diff_revert_all', { part.hash }, 'A')\\n00223| add_action(output, '[D]iff', 'diff_open', { part.hash }, 'D')\\n00224| \\n00225| if #restore_points > 0 then\\n00226| for _, restore_point in ipairs(restore_points) do\\n00227| output:add_line(\\n00228| string.format(\\n00229| ' %s Restore point `%s` - %s ',\\n00230| icons.get('restore_point'),\\n00231| vim.trim(restore_point.id:sub(1, 8)),\\n00232| util.format_time(restore_point.created_at)\\n00233| )\\n00234| )\\n00235| add_action(output, 'Restore [A]ll', 'diff_restore_snapshot_all', { restore_point.id }, 'A')\\n00236| add_action(output, '[R]estore file', 'diff_restore_snapshot_file', { restore_point.id }, 'R')\\n00237| end\\n00238| end\\n00239| end\\n00240| \\n00241| ---@param output Output Output object to write to\\n00242| ---@param message MessageInfo\\n00243| function M._format_error(output, message)\\n00244| output:add_empty_line()\\n00245| M._format_callout(output, 'ERROR', vim.inspect(message.error))\\n00246| end\\n00247| \\n00248| ---@param message OpencodeMessage\\n00249| ---@return Output\\n00250| function M.format_message_header(message)\\n00251| local output = Output.new()\\n00252| \\n00253| output:add_lines(M.separator)\\n00254| local role = message.info.role or 'unknown'\\n00255| local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')\\n00256| \\n00257| local time = message.info.time and message.info.time.created or nil\\n00258| local time_text = (time and ' (' .. util.format_time(time) .. ')' or '')\\n00259| local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)\\n00260| local model_text = message.info.modelID and ' ' .. message.info.modelID or ''\\n00261| \\n00262| local debug_text = config.debug.show_ids and ' [' .. message.info.id .. ']' or ''\\n00263| \\n00264| local display_name\\n00265| if role == 'assistant' then\\n00266| local mode = message.info.mode\\n00267| if mode and mode ~= '' then\\n00268| display_name = mode:upper()\\n00269| else\\n00270| -- For the most recent assistant message, show current_mode if mode is missing\\n00271| -- This handles new messages that haven't been stamped yet\\n00272| local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id\\n00273| if is_last_message and state.current_mode and state.current_mode ~= '' then\\n00274| display_name = state.current_mode:upper()\\n00275| else\\n00276| display_name = 'ASSISTANT'\\n00277| end\\n00278| end\\n00279| else\\n00280| display_name = role:upper()\\n00281| end\\n00282| \\n00283| output:add_extmark(output:get_line_count() - 1, {\\n00284| virt_text = {\\n00285| { icon, role_hl },\\n00286| { ' ' },\\n00287| { display_name, role_hl },\\n00288| { model_text, 'OpencodeHint' },\\n00289| { time_text, 'OpencodeHint' },\\n00290| { debug_text, 'OpencodeHint' },\\n00291| },\\n00292| virt_text_win_col = -3,\\n00293| priority = 10,\\n00294| } --[[@as OutputExtmark]])\\n00295| \\n00296| -- Only want to show the error if we have no parts. If we have parts, they'll\\n00297| -- handle rendering the error\\n00298| if\\n00299| role == 'assistant'\\n00300| and message.info.error\\n00301| and message.info.error ~= ''\\n00302| and (not message.parts or #message.parts == 0)\\n00303| then\\n00304| local error = message.info.error\\n00305| local error_message = error.data and error.data.message or vim.inspect(error)\\n00306| \\n00307| output:add_line('')\\n00308| M._format_callout(output, 'ERROR', error_message)\\n00309| end\\n00310| \\n00311| output:add_line('')\\n00312| return output\\n00313| end\\n00314| \\n00315| ---@param output Output Output object to write to\\n00316| ---@param callout string Callout type (e.g., 'ERROR', 'TODO')\\n00317| ---@param text string Callout text content\\n00318| ---@param title? string Optional title for the callout\\n00319| function M._format_callout(output, callout, text, title)\\n00320| title = title and title .. ' ' or ''\\n00321| local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win))\\n00322| and vim.api.nvim_win_get_width(state.windows.output_win)\\n00323| or config.ui.window_width\\n00324| or 80\\n00325| if #text > win_width - 4 then\\n00326| local ok, substituted = pcall(vim.fn.substitute, text, '\\\\v(.{' .. (win_width - 8) .. '})', '\\\\1\\\\n', 'g')\\n00327| text = ok and substituted or text\\n00328| end\\n00329| \\n00330| -- Trim off any trailing newlines so there isn't an extra line in the\\n00331| -- extmarks section\\n00332| local lines = vim.split(text:gsub('\\\\n$', ''), '\\\\n')\\n00333| if #lines == 1 and title == '' then\\n00334| output:add_line('> [!' .. callout .. '] ' .. lines[1])\\n00335| else\\n00336| output:add_line('> [!' .. callout .. ']' .. title)\\n00337| output:add_line('>')\\n00338| output:add_lines(lines, '> ')\\n00339| end\\n00340| end\\n00341| \\n00342| ---@param output Output Output object to write to\\n00343| ---@param text string\\n00344| ---@param message? OpencodeMessage Optional message object to extract mentions from\\n00345| function M._format_user_prompt(output, text, message)\\n00346| local start_line = output:get_line_count()\\n00347| \\n00348| output:add_lines(vim.split(text, '\\\\n'))\\n00349| \\n00350| local end_line = output:get_line_count()\\n00351| \\n00352| local end_line_extmark_offset = 0\\n00353| \\n00354| local mentions = {}\\n00355| if message and message.parts then\\n00356| -- message.parts will only be filled out on a re-render\\n00357| -- we need to collect the mentions here\\n00358| for _, part in ipairs(message.parts) do\\n00359| if part.type == 'file' then\\n00360| -- we're rerendering this part and we have files, the space after the user prompt\\n00361| -- also needs an extmark\\n00362| end_line_extmark_offset = 1\\n00363| if part.source and part.source.text then\\n00364| table.insert(mentions, part.source.text)\\n00365| end\\n00366| elseif part.type == 'agent' then\\n00367| if part.source then\\n00368| table.insert(mentions, part.source)\\n00369| end\\n00370| end\\n00371| end\\n00372| end\\n00373| \\n00374| if #mentions > 0 then\\n00375| mention.highlight_mentions_in_output(output, text, mentions, start_line)\\n00376| end\\n00377| \\n00378| M._add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)\\n00379| end\\n00380| \\n00381| ---@param output Output Output object to write to\\n00382| ---@param part OpencodeMessagePart\\n00383| function M._format_selection_context(output, part)\\n00384| local json = context_module.decode_json_context(part.text or '', 'selection')\\n00385| if not json then\\n00386| return\\n00387| end\\n00388| local start_line = output:get_line_count()\\n00389| output:add_lines(vim.split(json.content or '', '\\\\n'))\\n00390| output:add_empty_line()\\n00391| \\n00392| local end_line = output:get_line_count()\\n00393| \\n00394| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00395| end\\n00396| \\n00397| ---@param output Output Output object to write to\\n00398| ---@param part OpencodeMessagePart\\n00399| function M._format_cursor_data_context(output, part)\\n00400| local json = context_module.decode_json_context(part.text or '', 'cursor-data')\\n00401| if not json then\\n00402| return\\n00403| end\\n00404| local start_line = output:get_line_count()\\n00405| output:add_line('Line ' .. json.line .. ':')\\n00406| output:add_lines(vim.split(json.line_content or '', '\\\\n'))\\n00407| output:add_empty_line()\\n00408| \\n00409| local end_line = output:get_line_count()\\n00410| \\n00411| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00412| end\\n00413| \\n00414| ---@param output Output Output object to write to\\n00415| ---@param part OpencodeMessagePart\\n00416| function M._format_diagnostics_context(output, part)\\n00417| local json = context_module.decode_json_context(part.text or '', 'diagnostics')\\n00418| if not json then\\n00419| return\\n00420| end\\n00421| local start_line = output:get_line_count()\\n00422| local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]\\n00423| if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then\\n00424| return\\n00425| end\\n00426| \\n00427| local diagnostics_count = { error = 0, warn = 0, info = 0 }\\n00428| local diagnostics_icons = {\\n00429| error = icons.get('error'),\\n00430| warn = icons.get('warning'),\\n00431| info = icons.get('info'),\\n00432| }\\n00433| \\n00434| for _, diag in ipairs(diagnostics) do\\n00435| local name = vim.diagnostic.severity[diag.severity]:lower()\\n00436| diagnostics_count[name] = diagnostics_count[name] + 1\\n00437| end\\n00438| \\n00439| local diag_line = '**Diagnostics:**'\\n00440| for name, count in pairs(diagnostics_count) do\\n00441| if count > 0 then\\n00442| diag_line = diag_line .. (string.format(' %s(%d)', diagnostics_icons[name], count))\\n00443| end\\n00444| end\\n00445| output:add_line(diag_line)\\n00446| output:add_empty_line()\\n00447| local end_line = output:get_line_count()\\n00448| \\n00449| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\\n00450| end\\n00451| \\n00452| ---Format and display the file path in the context\\n00453| ---@param output Output Output object to write to\\n00454| ---@param path string|nil File path\\n00455| function M._format_context_file(output, path)\\n00456| if not path or path == '' then\\n00457| return\\n00458| end\\n00459| local cwd = vim.fn.getcwd()\\n00460| if vim.startswith(path, cwd) then\\n00461| path = path:sub(#cwd + 2)\\n00462| end\\n00463| return output:add_line(string.format('[%s](%s)', path, path))\\n00464| end\\n00465| \\n00466| ---@param output Output Output object to write to\\n00467| ---@param text string\\n00468| function M._format_assistant_message(output, text)\\n00469| local reference_picker = require('opencode.ui.reference_picker')\\n00470| local references = reference_picker.parse_references(text, '')\\n00471| \\n00472| -- If no references, just add the text as-is\\n00473| if #references == 0 then\\n00474| output:add_lines(vim.split(text, '\\\\n'))\\n00475| return\\n00476| end\\n00477| \\n00478| -- Sort references by match_start position (ascending)\\n00479| table.sort(references, function(a, b)\\n00480| return a.match_start < b.match_start\\n00481| end)\\n00482| \\n00483| -- Build a new text with icons inserted before each reference\\n00484| local result = ''\\n00485| local last_pos = 1\\n00486| local ref_icon = icons.get('reference')\\n00487| \\n00488| for _, ref in ipairs(references) do\\n00489| -- Add text before this reference\\n00490| result = result .. text:sub(last_pos, ref.match_start - 1)\\n00491| -- Add the icon and the reference\\n00492| result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)\\n00493| last_pos = ref.match_end + 1\\n00494| end\\n00495| \\n00496| -- Add any remaining text after the last reference\\n00497| if last_pos <= #text then\\n00498| result = result .. text:sub(last_pos)\\n00499| end\\n00500| \\n00501| output:add_lines(vim.split(result, '\\\\n'))\\n00502| end\\n00503| \\n00504| ---@param output Output Output object to write to\\n00505| ---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)\\n00506| ---@param value string Value associated with the action (e.g., filename, command)\\n00507| function M._format_action(output, tool_type, value)\\n00508| if not tool_type or not value then\\n00509| return\\n00510| end\\n00511| local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '')\\n00512| \\n00513| output:add_line(line)\\n00514| end\\n00515| \\n00516| ---@param output Output Output object to write to\\n00517| ---@param input BashToolInput data for the tool\\n00518| ---@param metadata BashToolMetadata Metadata for the tool use\\n00519| function M._format_bash_tool(output, input, metadata)\\n00520| M._format_action(output, icons.get('run') .. ' run', input and input.description)\\n00521| \\n00522| if not config.ui.output.tools.show_output then\\n00523| return\\n00524| end\\n00525| \\n00526| if metadata.output or metadata.command or input.command then\\n00527| local command = input.command or metadata.command or ''\\n00528| local command_output = metadata.output and metadata.output ~= '' and ('\\\\n' .. metadata.output) or ''\\n00529| M._format_code(output, vim.split('> ' .. command .. '\\\\n' .. command_output, '\\\\n'), 'bash')\\n00530| end\\n00531| end\\n00532| \\n00533| ---@param output Output Output object to write to\\n00534| ---@param tool_type string Tool type (e.g., 'read', 'edit', 'write')\\n00535| ---@param input FileToolInput data for the tool\\n00536| ---@param metadata FileToolMetadata Metadata for the tool use\\n00537| function M._format_file_tool(output, tool_type, input, metadata)\\n00538| local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''\\n00539| local file_type = input and util.get_markdown_filetype(input.filePath) or ''\\n00540| local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }\\n00541| \\n00542| M._format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name)\\n00543| \\n00544| if not config.ui.output.tools.show_output then\\n00545| return\\n00546| end\\n00547| \\n00548| if tool_type == 'edit' and metadata.diff then\\n00549| M._format_diff(output, metadata.diff, file_type)\\n00550| elseif tool_type == 'write' and input and input.content then\\n00551| M._format_code(output, vim.split(input.content, '\\\\n'), file_type)\\n00552| end\\n00553| end\\n00554| \\n00555| ---@param output Output Output object to write to\\n00556| ---@param title string\\n00557| ---@param input TodoToolInput\\n00558| function M._format_todo_tool(output, title, input)\\n00559| M._format_action(output, icons.get('plan') .. ' plan', (title or ''))\\n00560| if not config.ui.output.tools.show_output then\\n00561| return\\n00562| end\\n00563| \\n00564| local todos = input and input.todos or {}\\n00565| \\n00566| for _, item in ipairs(todos) do\\n00567| local statuses = { in_progress = '-', completed = 'x', pending = ' ' }\\n00568| output:add_line(string.format('- [%s] %s ', statuses[item.status], item.content))\\n00569| end\\n00570| end\\n00571| \\n00572| ---@param output Output Output object to write to\\n00573| ---@param input GlobToolInput data for the tool\\n00574| ---@param metadata GlobToolMetadata Metadata for the tool use\\n00575| function M._format_glob_tool(output, input, metadata)\\n00576| M._format_action(output, icons.get('search') .. ' glob', input and input.pattern)\\n00577| if not config.ui.output.tools.show_output then\\n00578| return\\n00579| end\\n00580| local prefix = metadata.truncated and ' more than' or ''\\n00581| output:add_line(string.format('Found%s `%d` file(s):', prefix, metadata.count or 0))\\n00582| end\\n00583| \\n00584| ---@param output Output Output object to write to\\n00585| ---@param input GrepToolInput data for the tool\\n00586| ---@param metadata GrepToolMetadata Metadata for the tool use\\n00587| function M._format_grep_tool(output, input, metadata)\\n00588| input = input or { path = '', include = '', pattern = '' }\\n00589| \\n00590| local grep_str = string.format('%s` `%s', (input.path or input.include) or '', input.pattern or '')\\n00591| \\n00592| M._format_action(output, icons.get('search') .. ' grep', grep_str)\\n00593| if not config.ui.output.tools.show_output then\\n00594| return\\n00595| end\\n00596| local prefix = metadata.truncated and ' more than' or ''\\n00597| output:add_line(\\n00598| string.format('Found%s `%d` match' .. (metadata.matches ~= 1 and 'es' or ''), prefix, metadata.matches or 0)\\n00599| )\\n00600| end\\n00601| \\n00602| ---@param output Output Output object to write to\\n00603| ---@param input WebFetchToolInput data for the tool\\n00604| function M._format_webfetch_tool(output, input)\\n00605| M._format_action(output, icons.get('web') .. ' fetch', input and input.url)\\n00606| end\\n00607| \\n00608| ---@param output Output Output object to write to\\n00609| ---@param input ListToolInput\\n00610| ---@param metadata ListToolMetadata\\n00611| ---@param tool_output string\\n00612| function M._format_list_tool(output, input, metadata, tool_output)\\n00613| M._format_action(output, icons.get('list') .. ' list', input and input.path or '')\\n00614| if not config.ui.output.tools.show_output then\\n00615| return\\n00616| end\\n00617| local lines = vim.split(vim.trim(tool_output or ''), '\\\\n')\\n00618| if #lines < 1 or metadata.count == 0 then\\n00619| output:add_line('No files found.')\\n00620| return\\n00621| end\\n00622| if #lines > 1 then\\n00623| output:add_line('Files:')\\n00624| for i = 2, #lines do\\n00625| local file = vim.trim(lines[i])\\n00626| if file ~= '' then\\n00627| output:add_line(' • ' .. file)\\n00628| end\\n00629| end\\n00630| end\\n00631| if metadata.truncated then\\n00632| output:add_line(string.format('Results truncated, showing first %d files', metadata.count or '?'))\\n00633| end\\n00634| end\\n00635| \\n00636| ---@param output Output Output object to write to\\n00637| ---@param part OpencodeMessagePart\\n00638| function M._format_tool(output, part)\\n00639| local tool = part.tool\\n00640| if not tool or not part.state then\\n00641| return\\n00642| end\\n00643| \\n00644| local start_line = output:get_line_count() + 1\\n00645| local input = part.state.input or {}\\n00646| local metadata = part.state.metadata or {}\\n00647| local tool_output = part.state.output or ''\\n00648| \\n00649| if state.current_permission and state.current_permission.messageID == part.messageID then\\n00650| metadata = state.current_permission.metadata or metadata\\n00651| end\\n00652| \\n00653| if tool == 'bash' then\\n00654| M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])\\n00655| elseif tool == 'read' or tool == 'edit' or tool == 'write' then\\n00656| M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]])\\n00657| elseif tool == 'todowrite' then\\n00658| M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]])\\n00659| elseif tool == 'glob' then\\n00660| M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]])\\n00661| elseif tool == 'list' then\\n00662| M._format_list_tool(output, input --[[@as ListToolInput]], metadata --[[@as ListToolMetadata]], tool_output)\\n00663| elseif tool == 'grep' then\\n00664| M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]])\\n00665| elseif tool == 'webfetch' then\\n00666| M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]])\\n00667| elseif tool == 'task' then\\n00668| M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output)\\n00669| else\\n00670| M._format_action(output, icons.get('tool') .. ' tool', tool)\\n00671| end\\n00672| \\n00673| if part.state.status == 'error' and part.state.error then\\n00674| output:add_line('')\\n00675| M._format_callout(output, 'ERROR', part.state.error)\\n00676| ---@diagnostic disable-next-line: undefined-field\\n00677| elseif part.state.input and part.state.input.error then\\n00678| output:add_line('')\\n00679| ---I'm not sure about the type with state.input.error\\n00680| ---@diagnostic disable-next-line: undefined-field\\n00681| M._format_callout(output, 'ERROR', part.state.input.error)\\n00682| end\\n00683| \\n00684| if\\n00685| state.current_permission\\n00686| and (\\n00687| (\\n00688| state.current_permission.tool\\n00689| and state.current_permission.tool.callID == part.callID\\n00690| and state.current_permission.tool.messageID == part.messageID\\n00691| )\\n00692| ---@TODO this is for backward compatibility, remove later\\n00693| or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID)\\n00694| )\\n00695| then\\n00696| M._handle_permission_request(output, part)\\n00697| end\\n00698| \\n00699| local end_line = output:get_line_count()\\n00700| if end_line - start_line > 1 then\\n00701| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1)\\n00702| end\\n00703| end\\n00704| \\n00705| ---@param output Output Output object to write to\\n00706| ---@param input TaskToolInput data for the tool\\n00707| ---@param metadata TaskToolMetadata Metadata for the tool use\\n00708| ---@param tool_output string\\n00709| function M._format_task_tool(output, input, metadata, tool_output)\\n00710| local start_line = output:get_line_count() + 1\\n00711| M._format_action(output, icons.get('task') .. ' task', input and input.description)\\n00712| \\n00713| if config.ui.output.tools.show_output then\\n00714| if tool_output and tool_output ~= '' then\\n00715| output:add_empty_line()\\n00716| output:add_lines(vim.split(tool_output, '\\\\n'))\\n00717| output:add_empty_line()\\n00718| end\\n00719| \\n00720| if metadata.summary and type(metadata.summary) == 'table' then\\n00721| for _, sub_part in ipairs(metadata.summary) do\\n00722| if sub_part.type == 'tool' and sub_part.tool then\\n00723| M._format_tool(output, sub_part)\\n00724| end\\n00725| end\\n00726| end\\n00727| end\\n00728| \\n00729| local end_line = output:get_line_count()\\n00730| output:add_action({\\n00731| text = '[S]elect Child Session',\\n00732| type = 'select_child_session',\\n00733| args = {},\\n00734| key = 'S',\\n00735| display_line = start_line - 1,\\n00736| range = { from = start_line, to = end_line },\\n00737| })\\n00738| end\\n00739| \\n00740| ---@param output Output Output object to write to\\n00741| ---@param lines string[]\\n00742| ---@param language string\\n00743| function M._format_code(output, lines, language)\\n00744| output:add_empty_line()\\n00745| --- NOTE: use longer code fence because lines could contain ```\\n00746| output:add_line('`````' .. (language or ''))\\n00747| output:add_lines(util.sanitize_lines(lines))\\n00748| output:add_line('`````')\\n00749| end\\n00750| \\n00751| ---@param output Output Output object to write to\\n00752| ---@param code string\\n00753| ---@param file_type string\\n00754| function M._format_diff(output, code, file_type)\\n00755| output:add_empty_line()\\n00756| \\n00757| --- NOTE: use longer code fence because code could contain ```\\n00758| output:add_line('`````' .. file_type)\\n00759| local lines = vim.split(code, '\\\\n')\\n00760| if #lines > 5 then\\n00761| lines = vim.list_slice(lines, 6)\\n00762| end\\n00763| \\n00764| for _, line in ipairs(lines) do\\n00765| local first_char = line:sub(1, 1)\\n00766| if first_char == '+' or first_char == '-' then\\n00767| local hl_group = first_char == '+' and 'OpencodeDiffAdd' or 'OpencodeDiffDelete'\\n00768| output:add_line(' ' .. line:sub(2))\\n00769| local line_idx = output:get_line_count()\\n00770| output:add_extmark(line_idx - 1, function()\\n00771| return {\\n00772| end_col = 0,\\n00773| end_row = line_idx,\\n00774| virt_text = { { first_char, hl_group } },\\n00775| hl_group = hl_group,\\n00776| hl_eol = true,\\n00777| priority = 5000,\\n00778| right_gravity = true,\\n00779| end_right_gravity = false,\\n00780| virt_text_hide = false,\\n00781| virt_text_pos = 'overlay',\\n00782| virt_text_repeat_linebreak = false,\\n00783| }\\n00784| end)\\n00785| else\\n00786| output:add_line(line)\\n00787| end\\n00788| end\\n00789| output:add_line('`````')\\n00790| end\\n00791| \\n00792| ---@param output Output Output object to write to\\n00793| ---@param start_line number\\n00794| ---@param end_line number\\n00795| ---@param hl_group string Highlight group for the border character\\n00796| ---@param win_col number\\n00797| ---@param text_hl_group? string Optional highlight group for the background/foreground of text lines\\n00798| function M._add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group)\\n00799| for line = start_line, end_line do\\n00800| local extmark_opts = {\\n00801| virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },\\n00802| virt_text_pos = 'overlay',\\n00803| virt_text_win_col = win_col,\\n00804| virt_text_repeat_linebreak = true,\\n00805| }\\n00806| \\n00807| -- Add line highlight if text_hl_group is provided\\n00808| if text_hl_group then\\n00809| extmark_opts.line_hl_group = text_hl_group\\n00810| end\\n00811| \\n00812| output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]])\\n00813| end\\n00814| end\\n00815| \\n00816| ---Formats a single message part and returns the resulting output object\\n00817| ---@param part OpencodeMessagePart The part to format\\n00818| ---@param message? OpencodeMessage Optional message object to extract role and mentions from\\n00819| ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one\\n00820| ---@return Output\\n00821| function M.format_part(part, message, is_last_part)\\n00822| local output = Output.new()\\n00823| \\n00824| if not message or not message.info or not message.info.role then\\n00825| return output\\n00826| end\\n00827| \\n00828| local content_added = false\\n00829| local role = message.info.role\\n00830| \\n00831| if role == 'user' then\\n00832| if part.type == 'text' and part.text then\\n00833| if part.synthetic == true then\\n00834| M._format_selection_context(output, part)\\n00835| M._format_cursor_data_context(output, part)\\n00836| M._format_diagnostics_context(output, part)\\n00837| else\\n00838| M._format_user_prompt(output, vim.trim(part.text), message)\\n00839| content_added = true\\n00840| end\\n00841| elseif part.type == 'file' then\\n00842| local file_line = M._format_context_file(output, part.filename)\\n00843| if file_line then\\n00844| M._add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)\\n00845| content_added = true\\n00846| end\\n00847| end\\n00848| elseif role == 'assistant' then\\n00849| if part.type == 'text' and part.text then\\n00850| M._format_assistant_message(output, vim.trim(part.text))\\n00851| content_added = true\\n00852| elseif part.type == 'reasoning' and part.text then\\n00853| M._format_reasoning(output, part)\\n00854| content_added = true\\n00855| elseif part.type == 'tool' then\\n00856| M._format_tool(output, part)\\n00857| content_added = true\\n00858| elseif part.type == 'patch' and part.hash then\\n00859| M._format_patch(output, part)\\n00860| content_added = true\\n00861| end\\n00862| end\\n00863| \\n00864| if content_added then\\n00865| output:add_empty_line()\\n00866| end\\n00867| \\n00868| if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then\\n00869| local error = message.info.error\\n00870| local error_message = error.data and error.data.message or vim.inspect(error)\\n00871| M._format_callout(output, 'ERROR', error_message)\\n00872| output:add_empty_line()\\n00873| end\\n00874| \\n00875| return output\\n00876| end\\n00877| \\n00878| return M\\n00879| \\n\\n(End of file - total 879 lines)\\n\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"file\",\"url\":\"file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\",\"id\":\"prt_b8e7c60a7002ybV09VDHoQQeO7\",\"filename\":\"lua/opencode/ui/formatter.lua\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"mime\":\"text/plain\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"text\",\"id\":\"prt_b8e7c60a3002XHHlk77vAPApvR\",\"messageID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"text\":\"{\\\"content\\\":[{\\\"msg\\\":\\\"expected `SessionRevertInfo` but found `SessionRevertInfo?`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l156:c68\\\"},{\\\"msg\\\":\\\"expected `integer?` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l490:c43\\\"},{\\\"msg\\\":\\\"expected `integer` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l492:c45\\\"},{\\\"msg\\\":\\\"expected `integer?` but found `number`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l492:c62\\\"},{\\\"msg\\\":\\\"state.active_session may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l156:c68\\\"},{\\\"msg\\\":\\\"state.messages[#state.messages] may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l272:c74\\\"},{\\\"msg\\\":\\\"state.messages may be nil\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l272:c74\\\"},{\\\"msg\\\":\\\"Undefined field `[diag.severity]`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l435:c42\\\"},{\\\"msg\\\":\\\"Undefined field `command`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l526:c34\\\"},{\\\"msg\\\":\\\"Undefined field `command`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l527:c47\\\"},{\\\"msg\\\":\\\"Cannot assign `number` to `integer`.\\\",\\\"severity\\\":2,\\\"pos\\\":\\\"l493:c5\\\"}],\\\"context_type\\\":\\\"diagnostics\\\"}\",\"synthetic\":true}},\"type\":\"message.part.updated\"},{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"time\":{\"updated\":1767622074603,\"created\":1767622072149}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"providerID\":\"github-copilot\",\"role\":\"assistant\",\"path\":{\"root\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"cwd\":\"/home/francis/Projects/_nvim/opencode.nvim\"},\"agent\":\"build\",\"time\":{\"created\":1767622074609},\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"mode\":\"build\",\"modelID\":\"gpt-4.1\",\"id\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"parentID\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"cost\":0,\"tokens\":{\"output\":0,\"reasoning\":0,\"input\":0,\"cache\":{\"write\":0,\"read\":0}}}},\"type\":\"message.updated\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"created\":1767622074530},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"summary\":{\"diffs\":[]}}},\"type\":\"message.updated\"},{\"properties\":{\"info\":{\"title\":\"New session - 2026-01-05T14:07:52.149Z\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"updated\":1767622074670,\"created\":1767622072149},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"summary\":{\"deletions\":0,\"additions\":0,\"files\":0}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"step-start\",\"id\":\"prt_b8e7c6672001F10WAiQKbzNEtE\",\"snapshot\":\"5ba6e95774829e7f501299039b72b72dc32c6620\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"tool\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"id\":\"prt_b8e7c6686001cCV44vsFtbpF42\",\"state\":{\"status\":\"pending\",\"input\":{},\"raw\":\"\"},\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\",\"tool\":\"bash\"}},\"type\":\"message.part.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"part\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"type\":\"tool\",\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"id\":\"prt_b8e7c6686001cCV44vsFtbpF42\",\"state\":{\"status\":\"running\",\"input\":{\"description\":\"Shows working tree status\",\"command\":\"git status\"},\"time\":{\"start\":1767622076189}},\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\",\"tool\":\"bash\"}},\"type\":\"message.part.updated\"},{\"properties\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"tool\":{\"messageID\":\"msg_b8e7c60f1001aEWYlAaDRXQ4aJ\",\"callID\":\"call_KAC0LdAxKEbU6HTAPH5QJfGx\"},\"patterns\":[\"git status\"],\"id\":\"per_b8e7c6744001DX6EZ9KHM15RLi\",\"permission\":\"bash\",\"always\":[\"git status*\"],\"metadata\":{}},\"type\":\"permission.asked\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"sessionID\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"created\":1767622074530},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"},\"role\":\"user\",\"id\":\"msg_b8e7c60a2001Kisjwk2mVB4dye\",\"agent\":\"build\",\"system\":\"# Code References\\n\\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\\n\\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\\n\\nExamples:\\n- CORRECT: \\\"The error is in `file://src/services/process.ts:712`\\\"\\n- INCORRECT: \\\"The error is in file://src/services/process.ts:712\\\"\\n- INCORRECT: \\\"The error is in src/services/process.ts:712\\\"\\n\\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\\n\",\"summary\":{\"title\":\"Running git status to list changed files\",\"diffs\":[]}}},\"type\":\"message.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"},{\"properties\":{\"info\":{\"title\":\"Running git status for formatter.lua changes\",\"version\":\"1.1.2\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"id\":\"ses_47183a8abffeZzOLeJ3j2KV8f8\",\"time\":{\"updated\":1767622082619,\"created\":1767622072149},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"summary\":{\"deletions\":0,\"additions\":0,\"files\":0}}},\"type\":\"session.updated\"},{\"properties\":[],\"type\":\"custom.emit_events.finished\"}]\n","before":"","deletions":0,"additions":1}],"body":"I updated the UI and event handling code by modifying `file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/event_manager.lua`, `file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua`, and `file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua` to improve formatting and rendering behavior. I also added `file:///home/francis/Projects/_nvim/opencode.nvim/test.lua` and `file:///home/francis/Projects/_nvim/opencode.nvim/tests/data/permission_ask_new.json` as new test/data files."}}},"type":"message.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"}] diff --git a/tests/data/permission-ask-new-deny.expected.json b/tests/data/permission-ask-new-deny.expected.json new file mode 100644 index 00000000..1fa60ab8 --- /dev/null +++ b/tests/data/permission-ask-new-deny.expected.json @@ -0,0 +1 @@ +{"extmarks":[[1,1,0,{"ns_id":3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-05 14:09:08)","OpencodeHint"],[" [msg_b8e7d8222001ZAc4G1t5RfHzou]","OpencodeHint"]],"right_gravity":true,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[2,2,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[3,3,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[4,4,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[5,5,0,{"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[6,8,0,{"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-05 14:09:08)","OpencodeHint"],[" [msg_b8e7d823f001HX1Rr9DYE7TYBf]","OpencodeHint"]],"right_gravity":true,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3}],[7,10,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[8,11,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[9,12,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[10,13,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[11,14,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[12,15,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[13,16,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}],[14,17,0,{"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1}]],"timestamp":1767622360,"lines":["----","","","@no-trust run a git status and tell me what files are changed","","[lua/opencode/ui/formatter.lua](lua/opencode/ui/formatter.lua)","","----","","","** run** `Shows working tree status`","","`````bash","> git status","","`````","","> [!ERROR] Error: The user rejected permission to use this specific tool call.","",""],"actions":[]} \ No newline at end of file diff --git a/tests/data/permission-ask-new-deny.json b/tests/data/permission-ask-new-deny.json new file mode 100644 index 00000000..ad97fce3 --- /dev/null +++ b/tests/data/permission-ask-new-deny.json @@ -0,0 +1 @@ +[{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"role":"user","id":"msg_b8e7d8222001ZAc4G1t5RfHzou","agent":"build","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","time":{"created":1767622148643}}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d8223001xEcei9tomaINdJ","type":"text","messageID":"msg_b8e7d8222001ZAc4G1t5RfHzou","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","text":"@no-trust run a git status and tell me what files are changed"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d8223002NzjiAm7x7Zcg8b","type":"text","synthetic":true,"messageID":"msg_b8e7d8222001ZAc4G1t5RfHzou","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","text":"Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\"}"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d82270012OhYhykY53tJuV","type":"text","synthetic":true,"messageID":"msg_b8e7d8222001ZAc4G1t5RfHzou","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","text":"\n00001| local context_module = require('opencode.context')\n00002| local icons = require('opencode.ui.icons')\n00003| local util = require('opencode.util')\n00004| local Output = require('opencode.ui.output')\n00005| local state = require('opencode.state')\n00006| local config = require('opencode.config')\n00007| local snapshot = require('opencode.snapshot')\n00008| local mention = require('opencode.ui.mention')\n00009| \n00010| local M = {}\n00011| \n00012| M.separator = {\n00013| '----',\n00014| '',\n00015| }\n00016| \n00017| ---@param output Output\n00018| ---@param part OpencodeMessagePart\n00019| function M._format_reasoning(output, part)\n00020| local text = vim.trim(part.text or '')\n00021| if text == '' then\n00022| return\n00023| end\n00024| \n00025| local start_line = output:get_line_count() + 1\n00026| \n00027| local title = 'Reasoning'\n00028| local time = part.time\n00029| if time and type(time) == 'table' and time.start then\n00030| local start_text = util.format_time(time.start) or ''\n00031| local end_text = (time['end'] and util.format_time(time['end'])) or nil\n00032| if end_text and end_text ~= '' then\n00033| title = string.format('%s (%s - %s)', title, start_text, end_text)\n00034| elseif start_text ~= '' then\n00035| title = string.format('%s (%s)', title, start_text)\n00036| end\n00037| end\n00038| \n00039| M._format_action(output, icons.get('reasoning') .. ' ' .. title, '')\n00040| \n00041| if config.ui.output.tools.show_reasoning_output then\n00042| output:add_empty_line()\n00043| output:add_lines(vim.split(text, '\\n'))\n00044| output:add_empty_line()\n00045| end\n00046| \n00047| local end_line = output:get_line_count()\n00048| if end_line - start_line > 1 then\n00049| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1, 'OpencodeReasoningText')\n00050| else\n00051| output:add_extmark(start_line - 1, {\n00052| line_hl_group = 'OpencodeReasoningText',\n00053| } --[[@as OutputExtmark]])\n00054| end\n00055| end\n00056| \n00057| function M._handle_permission_request(output, part)\n00058| if part.state and part.state.status == 'error' and part.state.error then\n00059| if part.state.error:match('rejected permission') then\n00060| state.current_permission = nil\n00061| else\n00062| vim.notify('Unknown part state error: ' .. part.state.error)\n00063| end\n00064| return\n00065| end\n00066| \n00067| M._format_permission_request(output)\n00068| end\n00069| \n00070| function M._format_permission_request(output)\n00071| local keys\n00072| \n00073| if require('opencode.ui.ui').is_opencode_focused() then\n00074| keys = {\n00075| config.keymap.permission.accept,\n00076| config.keymap.permission.accept_all,\n00077| config.keymap.permission.deny,\n00078| }\n00079| else\n00080| keys = {\n00081| config.get_key_for_function('editor', 'permission_accept'),\n00082| config.get_key_for_function('editor', 'permission_accept_all'),\n00083| config.get_key_for_function('editor', 'permission_deny'),\n00084| }\n00085| end\n00086| \n00087| output:add_empty_line()\n00088| output:add_line('> [!WARNING] Permission required to run this tool.')\n00089| output:add_line('>')\n00090| output:add_line(('> Accept `%s` Always `%s` Deny `%s`'):format(unpack(keys)))\n00091| output:add_empty_line()\n00092| end\n00093| \n00094| ---Calculate statistics for reverted messages and tool calls\n00095| ---@param messages {info: MessageInfo, parts: OpencodeMessagePart[]}[] All messages in the session\n00096| ---@param revert_index number Index of the message where revert occurred\n00097| ---@param revert_info SessionRevertInfo Revert information\n00098| ---@return {messages: number, tool_calls: number, files: table}\n00099| function M._calculate_revert_stats(messages, revert_index, revert_info)\n00100| local stats = {\n00101| messages = 0,\n00102| tool_calls = 0,\n00103| files = {}, -- { [filename] = { additions = n, deletions = m } }\n00104| }\n00105| \n00106| for i = revert_index, #messages do\n00107| local msg = messages[i]\n00108| if msg.info.role == 'user' then\n00109| stats.messages = stats.messages + 1\n00110| end\n00111| if msg.parts then\n00112| for _, part in ipairs(msg.parts) do\n00113| if part.type == 'tool' then\n00114| stats.tool_calls = stats.tool_calls + 1\n00115| end\n00116| end\n00117| end\n00118| end\n00119| \n00120| if revert_info.diff then\n00121| local current_file = nil\n00122| for line in revert_info.diff:gmatch('[^\\r\\n]+') do\n00123| local file_a = line:match('^%-%-%- ([ab]/.+)')\n00124| local file_b = line:match('^%+%+%+ ([ab]/.+)')\n00125| if file_b then\n00126| current_file = file_b:gsub('^[ab]/', '')\n00127| if not stats.files[current_file] then\n00128| stats.files[current_file] = { additions = 0, deletions = 0 }\n00129| end\n00130| elseif file_a then\n00131| current_file = file_a:gsub('^[ab]/', '')\n00132| if not stats.files[current_file] then\n00133| stats.files[current_file] = { additions = 0, deletions = 0 }\n00134| end\n00135| elseif line:sub(1, 1) == '+' and not line:match('^%+%+%+') then\n00136| if current_file then\n00137| stats.files[current_file].additions = stats.files[current_file].additions + 1\n00138| end\n00139| elseif line:sub(1, 1) == '-' and not line:match('^%-%-%-') then\n00140| if current_file then\n00141| stats.files[current_file].deletions = stats.files[current_file].deletions + 1\n00142| end\n00143| end\n00144| end\n00145| end\n00146| \n00147| return stats\n00148| end\n00149| \n00150| ---Format the revert callout with statistics\n00151| ---@param session_data OpencodeMessage[] All messages in the session\n00152| ---@param start_idx number Index of the message where revert occurred\n00153| ---@return Output output object representing the lines, extmarks, and actions\n00154| function M._format_revert_message(session_data, start_idx)\n00155| local output = Output.new()\n00156| local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)\n00157| local message_text = stats.messages == 1 and 'message' or 'messages'\n00158| local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'\n00159| \n00160| output:add_lines(M.separator)\n00161| output:add_line(\n00162| string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)\n00163| )\n00164| output:add_line('>')\n00165| output:add_line('> type `/redo` to restore.')\n00166| output:add_empty_line()\n00167| \n00168| if stats.files and next(stats.files) then\n00169| for file, fstats in pairs(stats.files) do\n00170| local file_diff = {}\n00171| if fstats.additions > 0 then\n00172| table.insert(file_diff, '+' .. fstats.additions)\n00173| end\n00174| if fstats.deletions > 0 then\n00175| table.insert(file_diff, '-' .. fstats.deletions)\n00176| end\n00177| if #file_diff > 0 then\n00178| local line_str = string.format(icons.get('file') .. '%s: %s', file, table.concat(file_diff, ' '))\n00179| local line_idx = output:add_line(line_str)\n00180| local col = #(' ' .. file .. ': ')\n00181| for _, diff in ipairs(file_diff) do\n00182| local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText'\n00183| output:add_extmark(line_idx - 1, {\n00184| virt_text = { { diff, hl_group } },\n00185| virt_text_pos = 'inline',\n00186| virt_text_win_col = col,\n00187| priority = 1000,\n00188| } --[[@as OutputExtmark]])\n00189| col = col + #diff + 1\n00190| end\n00191| end\n00192| end\n00193| end\n00194| return output\n00195| end\n00196| \n00197| local function add_action(output, text, action_type, args, key, line)\n00198| -- actions use api-indexing (e.g. 0 indexed)\n00199| line = (line or output:get_line_count()) - 1\n00200| output:add_action({\n00201| text = text,\n00202| type = action_type,\n00203| args = args,\n00204| key = key,\n00205| display_line = line,\n00206| range = { from = line, to = line },\n00207| })\n00208| end\n00209| \n00210| ---@param output Output Output object to write to\n00211| ---@param part OpencodeMessagePart\n00212| function M._format_patch(output, part)\n00213| if not part.hash then\n00214| return\n00215| end\n00216| \n00217| local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}\n00218| M._format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))\n00219| \n00220| -- Anchor all snapshot-level actions to the snapshot header line\n00221| add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')\n00222| add_action(output, 'Revert [A]ll', 'diff_revert_all', { part.hash }, 'A')\n00223| add_action(output, '[D]iff', 'diff_open', { part.hash }, 'D')\n00224| \n00225| if #restore_points > 0 then\n00226| for _, restore_point in ipairs(restore_points) do\n00227| output:add_line(\n00228| string.format(\n00229| ' %s Restore point `%s` - %s ',\n00230| icons.get('restore_point'),\n00231| vim.trim(restore_point.id:sub(1, 8)),\n00232| util.format_time(restore_point.created_at)\n00233| )\n00234| )\n00235| add_action(output, 'Restore [A]ll', 'diff_restore_snapshot_all', { restore_point.id }, 'A')\n00236| add_action(output, '[R]estore file', 'diff_restore_snapshot_file', { restore_point.id }, 'R')\n00237| end\n00238| end\n00239| end\n00240| \n00241| ---@param output Output Output object to write to\n00242| ---@param message MessageInfo\n00243| function M._format_error(output, message)\n00244| output:add_empty_line()\n00245| M._format_callout(output, 'ERROR', vim.inspect(message.error))\n00246| end\n00247| \n00248| ---@param message OpencodeMessage\n00249| ---@return Output\n00250| function M.format_message_header(message)\n00251| local output = Output.new()\n00252| \n00253| output:add_lines(M.separator)\n00254| local role = message.info.role or 'unknown'\n00255| local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')\n00256| \n00257| local time = message.info.time and message.info.time.created or nil\n00258| local time_text = (time and ' (' .. util.format_time(time) .. ')' or '')\n00259| local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)\n00260| local model_text = message.info.modelID and ' ' .. message.info.modelID or ''\n00261| \n00262| local debug_text = config.debug.show_ids and ' [' .. message.info.id .. ']' or ''\n00263| \n00264| local display_name\n00265| if role == 'assistant' then\n00266| local mode = message.info.mode\n00267| if mode and mode ~= '' then\n00268| display_name = mode:upper()\n00269| else\n00270| -- For the most recent assistant message, show current_mode if mode is missing\n00271| -- This handles new messages that haven't been stamped yet\n00272| local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id\n00273| if is_last_message and state.current_mode and state.current_mode ~= '' then\n00274| display_name = state.current_mode:upper()\n00275| else\n00276| display_name = 'ASSISTANT'\n00277| end\n00278| end\n00279| else\n00280| display_name = role:upper()\n00281| end\n00282| \n00283| output:add_extmark(output:get_line_count() - 1, {\n00284| virt_text = {\n00285| { icon, role_hl },\n00286| { ' ' },\n00287| { display_name, role_hl },\n00288| { model_text, 'OpencodeHint' },\n00289| { time_text, 'OpencodeHint' },\n00290| { debug_text, 'OpencodeHint' },\n00291| },\n00292| virt_text_win_col = -3,\n00293| priority = 10,\n00294| } --[[@as OutputExtmark]])\n00295| \n00296| -- Only want to show the error if we have no parts. If we have parts, they'll\n00297| -- handle rendering the error\n00298| if\n00299| role == 'assistant'\n00300| and message.info.error\n00301| and message.info.error ~= ''\n00302| and (not message.parts or #message.parts == 0)\n00303| then\n00304| local error = message.info.error\n00305| local error_message = error.data and error.data.message or vim.inspect(error)\n00306| \n00307| output:add_line('')\n00308| M._format_callout(output, 'ERROR', error_message)\n00309| end\n00310| \n00311| output:add_line('')\n00312| return output\n00313| end\n00314| \n00315| ---@param output Output Output object to write to\n00316| ---@param callout string Callout type (e.g., 'ERROR', 'TODO')\n00317| ---@param text string Callout text content\n00318| ---@param title? string Optional title for the callout\n00319| function M._format_callout(output, callout, text, title)\n00320| title = title and title .. ' ' or ''\n00321| local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win))\n00322| and vim.api.nvim_win_get_width(state.windows.output_win)\n00323| or config.ui.window_width\n00324| or 80\n00325| if #text > win_width - 4 then\n00326| local ok, substituted = pcall(vim.fn.substitute, text, '\\v(.{' .. (win_width - 8) .. '})', '\\1\\n', 'g')\n00327| text = ok and substituted or text\n00328| end\n00329| \n00330| -- Trim off any trailing newlines so there isn't an extra line in the\n00331| -- extmarks section\n00332| local lines = vim.split(text:gsub('\\n$', ''), '\\n')\n00333| if #lines == 1 and title == '' then\n00334| output:add_line('> [!' .. callout .. '] ' .. lines[1])\n00335| else\n00336| output:add_line('> [!' .. callout .. ']' .. title)\n00337| output:add_line('>')\n00338| output:add_lines(lines, '> ')\n00339| end\n00340| end\n00341| \n00342| ---@param output Output Output object to write to\n00343| ---@param text string\n00344| ---@param message? OpencodeMessage Optional message object to extract mentions from\n00345| function M._format_user_prompt(output, text, message)\n00346| local start_line = output:get_line_count()\n00347| \n00348| output:add_lines(vim.split(text, '\\n'))\n00349| \n00350| local end_line = output:get_line_count()\n00351| \n00352| local end_line_extmark_offset = 0\n00353| \n00354| local mentions = {}\n00355| if message and message.parts then\n00356| -- message.parts will only be filled out on a re-render\n00357| -- we need to collect the mentions here\n00358| for _, part in ipairs(message.parts) do\n00359| if part.type == 'file' then\n00360| -- we're rerendering this part and we have files, the space after the user prompt\n00361| -- also needs an extmark\n00362| end_line_extmark_offset = 1\n00363| if part.source and part.source.text then\n00364| table.insert(mentions, part.source.text)\n00365| end\n00366| elseif part.type == 'agent' then\n00367| if part.source then\n00368| table.insert(mentions, part.source)\n00369| end\n00370| end\n00371| end\n00372| end\n00373| \n00374| if #mentions > 0 then\n00375| mention.highlight_mentions_in_output(output, text, mentions, start_line)\n00376| end\n00377| \n00378| M._add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)\n00379| end\n00380| \n00381| ---@param output Output Output object to write to\n00382| ---@param part OpencodeMessagePart\n00383| function M._format_selection_context(output, part)\n00384| local json = context_module.decode_json_context(part.text or '', 'selection')\n00385| if not json then\n00386| return\n00387| end\n00388| local start_line = output:get_line_count()\n00389| output:add_lines(vim.split(json.content or '', '\\n'))\n00390| output:add_empty_line()\n00391| \n00392| local end_line = output:get_line_count()\n00393| \n00394| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00395| end\n00396| \n00397| ---@param output Output Output object to write to\n00398| ---@param part OpencodeMessagePart\n00399| function M._format_cursor_data_context(output, part)\n00400| local json = context_module.decode_json_context(part.text or '', 'cursor-data')\n00401| if not json then\n00402| return\n00403| end\n00404| local start_line = output:get_line_count()\n00405| output:add_line('Line ' .. json.line .. ':')\n00406| output:add_lines(vim.split(json.line_content or '', '\\n'))\n00407| output:add_empty_line()\n00408| \n00409| local end_line = output:get_line_count()\n00410| \n00411| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00412| end\n00413| \n00414| ---@param output Output Output object to write to\n00415| ---@param part OpencodeMessagePart\n00416| function M._format_diagnostics_context(output, part)\n00417| local json = context_module.decode_json_context(part.text or '', 'diagnostics')\n00418| if not json then\n00419| return\n00420| end\n00421| local start_line = output:get_line_count()\n00422| local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]\n00423| if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then\n00424| return\n00425| end\n00426| \n00427| local diagnostics_count = { error = 0, warn = 0, info = 0 }\n00428| local diagnostics_icons = {\n00429| error = icons.get('error'),\n00430| warn = icons.get('warning'),\n00431| info = icons.get('info'),\n00432| }\n00433| \n00434| for _, diag in ipairs(diagnostics) do\n00435| local name = vim.diagnostic.severity[diag.severity]:lower()\n00436| diagnostics_count[name] = diagnostics_count[name] + 1\n00437| end\n00438| \n00439| local diag_line = '**Diagnostics:**'\n00440| for name, count in pairs(diagnostics_count) do\n00441| if count > 0 then\n00442| diag_line = diag_line .. (string.format(' %s(%d)', diagnostics_icons[name], count))\n00443| end\n00444| end\n00445| output:add_line(diag_line)\n00446| output:add_empty_line()\n00447| local end_line = output:get_line_count()\n00448| \n00449| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00450| end\n00451| \n00452| ---Format and display the file path in the context\n00453| ---@param output Output Output object to write to\n00454| ---@param path string|nil File path\n00455| function M._format_context_file(output, path)\n00456| if not path or path == '' then\n00457| return\n00458| end\n00459| local cwd = vim.fn.getcwd()\n00460| if vim.startswith(path, cwd) then\n00461| path = path:sub(#cwd + 2)\n00462| end\n00463| return output:add_line(string.format('[%s](%s)', path, path))\n00464| end\n00465| \n00466| ---@param output Output Output object to write to\n00467| ---@param text string\n00468| function M._format_assistant_message(output, text)\n00469| local reference_picker = require('opencode.ui.reference_picker')\n00470| local references = reference_picker.parse_references(text, '')\n00471| \n00472| -- If no references, just add the text as-is\n00473| if #references == 0 then\n00474| output:add_lines(vim.split(text, '\\n'))\n00475| return\n00476| end\n00477| \n00478| -- Sort references by match_start position (ascending)\n00479| table.sort(references, function(a, b)\n00480| return a.match_start < b.match_start\n00481| end)\n00482| \n00483| -- Build a new text with icons inserted before each reference\n00484| local result = ''\n00485| local last_pos = 1\n00486| local ref_icon = icons.get('reference')\n00487| \n00488| for _, ref in ipairs(references) do\n00489| -- Add text before this reference\n00490| result = result .. text:sub(last_pos, ref.match_start - 1)\n00491| -- Add the icon and the reference\n00492| result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)\n00493| last_pos = ref.match_end + 1\n00494| end\n00495| \n00496| -- Add any remaining text after the last reference\n00497| if last_pos <= #text then\n00498| result = result .. text:sub(last_pos)\n00499| end\n00500| \n00501| output:add_lines(vim.split(result, '\\n'))\n00502| end\n00503| \n00504| ---@param output Output Output object to write to\n00505| ---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)\n00506| ---@param value string Value associated with the action (e.g., filename, command)\n00507| function M._format_action(output, tool_type, value)\n00508| if not tool_type or not value then\n00509| return\n00510| end\n00511| local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '')\n00512| \n00513| output:add_line(line)\n00514| end\n00515| \n00516| ---@param output Output Output object to write to\n00517| ---@param input BashToolInput data for the tool\n00518| ---@param metadata BashToolMetadata Metadata for the tool use\n00519| function M._format_bash_tool(output, input, metadata)\n00520| M._format_action(output, icons.get('run') .. ' run', input and input.description)\n00521| \n00522| if not config.ui.output.tools.show_output then\n00523| return\n00524| end\n00525| \n00526| if metadata.output or metadata.command or input.command then\n00527| local command = input.command or metadata.command or ''\n00528| local command_output = metadata.output and metadata.output ~= '' and ('\\n' .. metadata.output) or ''\n00529| M._format_code(output, vim.split('> ' .. command .. '\\n' .. command_output, '\\n'), 'bash')\n00530| end\n00531| end\n00532| \n00533| ---@param output Output Output object to write to\n00534| ---@param tool_type string Tool type (e.g., 'read', 'edit', 'write')\n00535| ---@param input FileToolInput data for the tool\n00536| ---@param metadata FileToolMetadata Metadata for the tool use\n00537| function M._format_file_tool(output, tool_type, input, metadata)\n00538| local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''\n00539| local file_type = input and util.get_markdown_filetype(input.filePath) or ''\n00540| local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }\n00541| \n00542| M._format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name)\n00543| \n00544| if not config.ui.output.tools.show_output then\n00545| return\n00546| end\n00547| \n00548| if tool_type == 'edit' and metadata.diff then\n00549| M._format_diff(output, metadata.diff, file_type)\n00550| elseif tool_type == 'write' and input and input.content then\n00551| M._format_code(output, vim.split(input.content, '\\n'), file_type)\n00552| end\n00553| end\n00554| \n00555| ---@param output Output Output object to write to\n00556| ---@param title string\n00557| ---@param input TodoToolInput\n00558| function M._format_todo_tool(output, title, input)\n00559| M._format_action(output, icons.get('plan') .. ' plan', (title or ''))\n00560| if not config.ui.output.tools.show_output then\n00561| return\n00562| end\n00563| \n00564| local todos = input and input.todos or {}\n00565| \n00566| for _, item in ipairs(todos) do\n00567| local statuses = { in_progress = '-', completed = 'x', pending = ' ' }\n00568| output:add_line(string.format('- [%s] %s ', statuses[item.status], item.content))\n00569| end\n00570| end\n00571| \n00572| ---@param output Output Output object to write to\n00573| ---@param input GlobToolInput data for the tool\n00574| ---@param metadata GlobToolMetadata Metadata for the tool use\n00575| function M._format_glob_tool(output, input, metadata)\n00576| M._format_action(output, icons.get('search') .. ' glob', input and input.pattern)\n00577| if not config.ui.output.tools.show_output then\n00578| return\n00579| end\n00580| local prefix = metadata.truncated and ' more than' or ''\n00581| output:add_line(string.format('Found%s `%d` file(s):', prefix, metadata.count or 0))\n00582| end\n00583| \n00584| ---@param output Output Output object to write to\n00585| ---@param input GrepToolInput data for the tool\n00586| ---@param metadata GrepToolMetadata Metadata for the tool use\n00587| function M._format_grep_tool(output, input, metadata)\n00588| input = input or { path = '', include = '', pattern = '' }\n00589| \n00590| local grep_str = string.format('%s` `%s', (input.path or input.include) or '', input.pattern or '')\n00591| \n00592| M._format_action(output, icons.get('search') .. ' grep', grep_str)\n00593| if not config.ui.output.tools.show_output then\n00594| return\n00595| end\n00596| local prefix = metadata.truncated and ' more than' or ''\n00597| output:add_line(\n00598| string.format('Found%s `%d` match' .. (metadata.matches ~= 1 and 'es' or ''), prefix, metadata.matches or 0)\n00599| )\n00600| end\n00601| \n00602| ---@param output Output Output object to write to\n00603| ---@param input WebFetchToolInput data for the tool\n00604| function M._format_webfetch_tool(output, input)\n00605| M._format_action(output, icons.get('web') .. ' fetch', input and input.url)\n00606| end\n00607| \n00608| ---@param output Output Output object to write to\n00609| ---@param input ListToolInput\n00610| ---@param metadata ListToolMetadata\n00611| ---@param tool_output string\n00612| function M._format_list_tool(output, input, metadata, tool_output)\n00613| M._format_action(output, icons.get('list') .. ' list', input and input.path or '')\n00614| if not config.ui.output.tools.show_output then\n00615| return\n00616| end\n00617| local lines = vim.split(vim.trim(tool_output or ''), '\\n')\n00618| if #lines < 1 or metadata.count == 0 then\n00619| output:add_line('No files found.')\n00620| return\n00621| end\n00622| if #lines > 1 then\n00623| output:add_line('Files:')\n00624| for i = 2, #lines do\n00625| local file = vim.trim(lines[i])\n00626| if file ~= '' then\n00627| output:add_line(' • ' .. file)\n00628| end\n00629| end\n00630| end\n00631| if metadata.truncated then\n00632| output:add_line(string.format('Results truncated, showing first %d files', metadata.count or '?'))\n00633| end\n00634| end\n00635| \n00636| ---@param output Output Output object to write to\n00637| ---@param part OpencodeMessagePart\n00638| function M._format_tool(output, part)\n00639| local tool = part.tool\n00640| if not tool or not part.state then\n00641| return\n00642| end\n00643| \n00644| local start_line = output:get_line_count() + 1\n00645| local input = part.state.input or {}\n00646| local metadata = part.state.metadata or {}\n00647| local tool_output = part.state.output or ''\n00648| \n00649| if state.current_permission and state.current_permission.messageID == part.messageID then\n00650| metadata = state.current_permission.metadata or metadata\n00651| end\n00652| \n00653| if tool == 'bash' then\n00654| M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])\n00655| elseif tool == 'read' or tool == 'edit' or tool == 'write' then\n00656| M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]])\n00657| elseif tool == 'todowrite' then\n00658| M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]])\n00659| elseif tool == 'glob' then\n00660| M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]])\n00661| elseif tool == 'list' then\n00662| M._format_list_tool(output, input --[[@as ListToolInput]], metadata --[[@as ListToolMetadata]], tool_output)\n00663| elseif tool == 'grep' then\n00664| M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]])\n00665| elseif tool == 'webfetch' then\n00666| M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]])\n00667| elseif tool == 'task' then\n00668| M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output)\n00669| else\n00670| M._format_action(output, icons.get('tool') .. ' tool', tool)\n00671| end\n00672| \n00673| if part.state.status == 'error' and part.state.error then\n00674| output:add_line('')\n00675| M._format_callout(output, 'ERROR', part.state.error)\n00676| ---@diagnostic disable-next-line: undefined-field\n00677| elseif part.state.input and part.state.input.error then\n00678| output:add_line('')\n00679| ---I'm not sure about the type with state.input.error\n00680| ---@diagnostic disable-next-line: undefined-field\n00681| M._format_callout(output, 'ERROR', part.state.input.error)\n00682| end\n00683| \n00684| if\n00685| state.current_permission\n00686| and (\n00687| (\n00688| state.current_permission.tool\n00689| and state.current_permission.tool.callID == part.callID\n00690| and state.current_permission.tool.messageID == part.messageID\n00691| )\n00692| ---@TODO this is for backward compatibility, remove later\n00693| or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID)\n00694| )\n00695| then\n00696| M._handle_permission_request(output, part)\n00697| end\n00698| \n00699| local end_line = output:get_line_count()\n00700| if end_line - start_line > 1 then\n00701| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1)\n00702| end\n00703| end\n00704| \n00705| ---@param output Output Output object to write to\n00706| ---@param input TaskToolInput data for the tool\n00707| ---@param metadata TaskToolMetadata Metadata for the tool use\n00708| ---@param tool_output string\n00709| function M._format_task_tool(output, input, metadata, tool_output)\n00710| local start_line = output:get_line_count() + 1\n00711| M._format_action(output, icons.get('task') .. ' task', input and input.description)\n00712| \n00713| if config.ui.output.tools.show_output then\n00714| if tool_output and tool_output ~= '' then\n00715| output:add_empty_line()\n00716| output:add_lines(vim.split(tool_output, '\\n'))\n00717| output:add_empty_line()\n00718| end\n00719| \n00720| if metadata.summary and type(metadata.summary) == 'table' then\n00721| for _, sub_part in ipairs(metadata.summary) do\n00722| if sub_part.type == 'tool' and sub_part.tool then\n00723| M._format_tool(output, sub_part)\n00724| end\n00725| end\n00726| end\n00727| end\n00728| \n00729| local end_line = output:get_line_count()\n00730| output:add_action({\n00731| text = '[S]elect Child Session',\n00732| type = 'select_child_session',\n00733| args = {},\n00734| key = 'S',\n00735| display_line = start_line - 1,\n00736| range = { from = start_line, to = end_line },\n00737| })\n00738| end\n00739| \n00740| ---@param output Output Output object to write to\n00741| ---@param lines string[]\n00742| ---@param language string\n00743| function M._format_code(output, lines, language)\n00744| output:add_empty_line()\n00745| --- NOTE: use longer code fence because lines could contain ```\n00746| output:add_line('`````' .. (language or ''))\n00747| output:add_lines(util.sanitize_lines(lines))\n00748| output:add_line('`````')\n00749| end\n00750| \n00751| ---@param output Output Output object to write to\n00752| ---@param code string\n00753| ---@param file_type string\n00754| function M._format_diff(output, code, file_type)\n00755| output:add_empty_line()\n00756| \n00757| --- NOTE: use longer code fence because code could contain ```\n00758| output:add_line('`````' .. file_type)\n00759| local lines = vim.split(code, '\\n')\n00760| if #lines > 5 then\n00761| lines = vim.list_slice(lines, 6)\n00762| end\n00763| \n00764| for _, line in ipairs(lines) do\n00765| local first_char = line:sub(1, 1)\n00766| if first_char == '+' or first_char == '-' then\n00767| local hl_group = first_char == '+' and 'OpencodeDiffAdd' or 'OpencodeDiffDelete'\n00768| output:add_line(' ' .. line:sub(2))\n00769| local line_idx = output:get_line_count()\n00770| output:add_extmark(line_idx - 1, function()\n00771| return {\n00772| end_col = 0,\n00773| end_row = line_idx,\n00774| virt_text = { { first_char, hl_group } },\n00775| hl_group = hl_group,\n00776| hl_eol = true,\n00777| priority = 5000,\n00778| right_gravity = true,\n00779| end_right_gravity = false,\n00780| virt_text_hide = false,\n00781| virt_text_pos = 'overlay',\n00782| virt_text_repeat_linebreak = false,\n00783| }\n00784| end)\n00785| else\n00786| output:add_line(line)\n00787| end\n00788| end\n00789| output:add_line('`````')\n00790| end\n00791| \n00792| ---@param output Output Output object to write to\n00793| ---@param start_line number\n00794| ---@param end_line number\n00795| ---@param hl_group string Highlight group for the border character\n00796| ---@param win_col number\n00797| ---@param text_hl_group? string Optional highlight group for the background/foreground of text lines\n00798| function M._add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group)\n00799| for line = start_line, end_line do\n00800| local extmark_opts = {\n00801| virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },\n00802| virt_text_pos = 'overlay',\n00803| virt_text_win_col = win_col,\n00804| virt_text_repeat_linebreak = true,\n00805| }\n00806| \n00807| -- Add line highlight if text_hl_group is provided\n00808| if text_hl_group then\n00809| extmark_opts.line_hl_group = text_hl_group\n00810| end\n00811| \n00812| output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]])\n00813| end\n00814| end\n00815| \n00816| ---Formats a single message part and returns the resulting output object\n00817| ---@param part OpencodeMessagePart The part to format\n00818| ---@param message? OpencodeMessage Optional message object to extract role and mentions from\n00819| ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one\n00820| ---@return Output\n00821| function M.format_part(part, message, is_last_part)\n00822| local output = Output.new()\n00823| \n00824| if not message or not message.info or not message.info.role then\n00825| return output\n00826| end\n00827| \n00828| local content_added = false\n00829| local role = message.info.role\n00830| \n00831| if role == 'user' then\n00832| if part.type == 'text' and part.text then\n00833| if part.synthetic == true then\n00834| M._format_selection_context(output, part)\n00835| M._format_cursor_data_context(output, part)\n00836| M._format_diagnostics_context(output, part)\n00837| else\n00838| M._format_user_prompt(output, vim.trim(part.text), message)\n00839| content_added = true\n00840| end\n00841| elseif part.type == 'file' then\n00842| local file_line = M._format_context_file(output, part.filename)\n00843| if file_line then\n00844| M._add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)\n00845| content_added = true\n00846| end\n00847| end\n00848| elseif role == 'assistant' then\n00849| if part.type == 'text' and part.text then\n00850| M._format_assistant_message(output, vim.trim(part.text))\n00851| content_added = true\n00852| elseif part.type == 'reasoning' and part.text then\n00853| M._format_reasoning(output, part)\n00854| content_added = true\n00855| elseif part.type == 'tool' then\n00856| M._format_tool(output, part)\n00857| content_added = true\n00858| elseif part.type == 'patch' and part.hash then\n00859| M._format_patch(output, part)\n00860| content_added = true\n00861| end\n00862| end\n00863| \n00864| if content_added then\n00865| output:add_empty_line()\n00866| end\n00867| \n00868| if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then\n00869| local error = message.info.error\n00870| local error_message = error.data and error.data.message or vim.inspect(error)\n00871| M._format_callout(output, 'ERROR', error_message)\n00872| output:add_empty_line()\n00873| end\n00874| \n00875| return output\n00876| end\n00877| \n00878| return M\n00879| \n\n(End of file - total 879 lines)\n"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d8227002hZiHYfLznqKXjU","type":"file","url":"file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua","mime":"text/plain","messageID":"msg_b8e7d8222001ZAc4G1t5RfHzou","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","filename":"lua/opencode/ui/formatter.lua"}}},{"type":"session.updated","properties":{"info":{"title":"New session - 2026-01-05T14:09:03.597Z","time":{"created":1767622143597,"updated":1767622148667},"version":"1.1.2","id":"ses_471829192ffebHW9UmQGzvCDMj","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"message.updated","properties":{"info":{"parentID":"msg_b8e7d8222001ZAc4G1t5RfHzou","role":"assistant","time":{"created":1767622148672},"path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","mode":"build","modelID":"gpt-4.1","providerID":"github-copilot","agent":"build","tokens":{"cache":{"read":0,"write":0},"output":0,"input":0,"reasoning":0},"id":"msg_b8e7d823f001HX1Rr9DYE7TYBf","cost":0}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_b8e7d8222001ZAc4G1t5RfHzou","role":"user","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","time":{"created":1767622148643},"summary":{"diffs":[]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","agent":"build"}}},{"type":"session.updated","properties":{"info":{"version":"1.1.2","time":{"created":1767622143597,"updated":1767622148771},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"additions":0,"files":0,"deletions":0},"id":"ses_471829192ffebHW9UmQGzvCDMj","title":"New session - 2026-01-05T14:09:03.597Z","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d8948001TJr45cBt0IJmwi","type":"step-start","snapshot":"f28b6ec26f4d2f0f1c8fdb665276f5628facc967","messageID":"msg_b8e7d823f001HX1Rr9DYE7TYBf","sessionID":"ses_471829192ffebHW9UmQGzvCDMj"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d894b001iax8ZxtH2hvz0B","type":"tool","tool":"bash","messageID":"msg_b8e7d823f001HX1Rr9DYE7TYBf","state":{"status":"pending","input":{},"raw":""},"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","callID":"call_WjZkpS6d8ZVtA7m8RrDBzA1J"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d894b001iax8ZxtH2hvz0B","type":"tool","tool":"bash","messageID":"msg_b8e7d823f001HX1Rr9DYE7TYBf","state":{"status":"running","input":{"command":"git status","description":"Shows working tree status"},"time":{"start":1767622150547}},"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","callID":"call_WjZkpS6d8ZVtA7m8RrDBzA1J"}}},{"type":"permission.asked","properties":{"patterns":["git status"],"id":"per_b8e7d89ad001fysFENZJxvDKzb","tool":{"messageID":"msg_b8e7d823f001HX1Rr9DYE7TYBf","callID":"call_WjZkpS6d8ZVtA7m8RrDBzA1J"},"permission":"bash","always":["git status*"],"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","metadata":{}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"permission.replied","properties":{"requestID":"per_b8e7d89ad001fysFENZJxvDKzb","reply":"reject","sessionID":"ses_471829192ffebHW9UmQGzvCDMj"}},{"type":"message.part.updated","properties":{"part":{"id":"prt_b8e7d894b001iax8ZxtH2hvz0B","type":"tool","tool":"bash","messageID":"msg_b8e7d823f001HX1Rr9DYE7TYBf","state":{"status":"error","error":"Error: The user rejected permission to use this specific tool call.","input":{"command":"git status","description":"Shows working tree status"},"time":{"start":1767622150547,"end":1767622156196}},"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","callID":"call_WjZkpS6d8ZVtA7m8RrDBzA1J"}}},{"type":"message.part.updated","properties":{"part":{"cost":0,"id":"prt_b8e7d9fa7001vEc0lpWy1MDUdb","type":"step-finish","snapshot":"f28b6ec26f4d2f0f1c8fdb665276f5628facc967","reason":"tool-calls","tokens":{"cache":{"read":0,"write":0},"output":23,"input":20614,"reasoning":0},"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","messageID":"msg_b8e7d823f001HX1Rr9DYE7TYBf"}}},{"type":"message.updated","properties":{"info":{"parentID":"msg_b8e7d8222001ZAc4G1t5RfHzou","role":"assistant","finish":"tool-calls","time":{"created":1767622148672},"path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","mode":"build","modelID":"gpt-4.1","providerID":"github-copilot","agent":"build","tokens":{"cache":{"read":0,"write":0},"output":23,"input":20614,"reasoning":0},"id":"msg_b8e7d823f001HX1Rr9DYE7TYBf","cost":0}}},{"type":"message.updated","properties":{"info":{"parentID":"msg_b8e7d8222001ZAc4G1t5RfHzou","role":"assistant","finish":"tool-calls","time":{"created":1767622148672,"completed":1767622156230},"path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"sessionID":"ses_471829192ffebHW9UmQGzvCDMj","mode":"build","modelID":"gpt-4.1","providerID":"github-copilot","agent":"build","tokens":{"cache":{"read":0,"write":0},"output":23,"input":20614,"reasoning":0},"id":"msg_b8e7d823f001HX1Rr9DYE7TYBf","cost":0}}},{"type":"session.idle","properties":{"sessionID":"ses_471829192ffebHW9UmQGzvCDMj"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_b8e7d8222001ZAc4G1t5RfHzou","role":"user","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","time":{"created":1767622148643},"summary":{"diffs":[]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","agent":"build"}}},{"type":"session.updated","properties":{"info":{"version":"1.1.2","time":{"created":1767622143597,"updated":1767622156244},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"additions":0,"files":0,"deletions":0},"id":"ses_471829192ffebHW9UmQGzvCDMj","title":"New session - 2026-01-05T14:09:03.597Z","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_b8e7d8222001ZAc4G1t5RfHzou","role":"user","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","time":{"created":1767622148643},"summary":{"diffs":[],"title":"Requesting git status and listing changed files"},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","agent":"build"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_b8e7d8222001ZAc4G1t5RfHzou","role":"user","sessionID":"ses_471829192ffebHW9UmQGzvCDMj","time":{"created":1767622148643},"summary":{"diffs":[],"title":"Running git status to list changed files"},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","agent":"build"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"session.updated","properties":{"info":{"version":"1.1.2","time":{"created":1767622143597,"updated":1767622165039},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"additions":0,"files":0,"deletions":0},"id":"ses_471829192ffebHW9UmQGzvCDMj","title":"Listing changed files with git status","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]}] diff --git a/tests/data/permission-ask-new.expected.json b/tests/data/permission-ask-new.expected.json new file mode 100644 index 00000000..7a632944 --- /dev/null +++ b/tests/data/permission-ask-new.expected.json @@ -0,0 +1 @@ +{"timestamp":1767622660,"lines":["----","","","@no-trust run a git status and tell me what files are changed","","[lua/opencode/ui/formatter.lua](lua/opencode/ui/formatter.lua)","","**Diagnostics:**  (11)","","----","","","** run** `Shows working tree status`","","`````bash","> git status","","`````","","> [!WARNING] Permission required to run this tool.",">","> Accept `a` Always `A` Deny `d`","",""],"extmarks":[[1,1,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60a2001Kisjwk2mVB4dye]","OpencodeHint"]]}],[2,2,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[3,3,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[4,4,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[5,5,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[6,6,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[7,7,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[8,8,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[9,10,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60f1001aEWYlAaDRXQ4aJ]","OpencodeHint"]]}],[10,12,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[11,13,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[12,14,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[13,15,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[14,16,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[15,17,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[16,18,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[17,19,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[18,20,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[19,21,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}],[20,22,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]]}]],"actions":[]} \ No newline at end of file diff --git a/tests/data/permission-ask-new.json b/tests/data/permission-ask-new.json new file mode 100644 index 00000000..d4e9d979 --- /dev/null +++ b/tests/data/permission-ask-new.json @@ -0,0 +1 @@ +[{"properties":{"info":{"title":"New session - 2026-01-05T14:07:52.149Z","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"updated":1767622072149,"created":1767622072149}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","time":{"created":1767622074530}}},"type":"message.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a3001M2zsH9REfPdlQt","text":"@no-trust run a git status and tell me what files are changed","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye"}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a3003Ta9NcXkN0lb0lF","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","text":"Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua\"}","synthetic":true}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a7001d3WhKOu2vCR4Cf","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","text":"\n00001| local context_module = require('opencode.context')\n00002| local icons = require('opencode.ui.icons')\n00003| local util = require('opencode.util')\n00004| local Output = require('opencode.ui.output')\n00005| local state = require('opencode.state')\n00006| local config = require('opencode.config')\n00007| local snapshot = require('opencode.snapshot')\n00008| local mention = require('opencode.ui.mention')\n00009| \n00010| local M = {}\n00011| \n00012| M.separator = {\n00013| '----',\n00014| '',\n00015| }\n00016| \n00017| ---@param output Output\n00018| ---@param part OpencodeMessagePart\n00019| function M._format_reasoning(output, part)\n00020| local text = vim.trim(part.text or '')\n00021| if text == '' then\n00022| return\n00023| end\n00024| \n00025| local start_line = output:get_line_count() + 1\n00026| \n00027| local title = 'Reasoning'\n00028| local time = part.time\n00029| if time and type(time) == 'table' and time.start then\n00030| local start_text = util.format_time(time.start) or ''\n00031| local end_text = (time['end'] and util.format_time(time['end'])) or nil\n00032| if end_text and end_text ~= '' then\n00033| title = string.format('%s (%s - %s)', title, start_text, end_text)\n00034| elseif start_text ~= '' then\n00035| title = string.format('%s (%s)', title, start_text)\n00036| end\n00037| end\n00038| \n00039| M._format_action(output, icons.get('reasoning') .. ' ' .. title, '')\n00040| \n00041| if config.ui.output.tools.show_reasoning_output then\n00042| output:add_empty_line()\n00043| output:add_lines(vim.split(text, '\\n'))\n00044| output:add_empty_line()\n00045| end\n00046| \n00047| local end_line = output:get_line_count()\n00048| if end_line - start_line > 1 then\n00049| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1, 'OpencodeReasoningText')\n00050| else\n00051| output:add_extmark(start_line - 1, {\n00052| line_hl_group = 'OpencodeReasoningText',\n00053| } --[[@as OutputExtmark]])\n00054| end\n00055| end\n00056| \n00057| function M._handle_permission_request(output, part)\n00058| if part.state and part.state.status == 'error' and part.state.error then\n00059| if part.state.error:match('rejected permission') then\n00060| state.current_permission = nil\n00061| else\n00062| vim.notify('Unknown part state error: ' .. part.state.error)\n00063| end\n00064| return\n00065| end\n00066| \n00067| M._format_permission_request(output)\n00068| end\n00069| \n00070| function M._format_permission_request(output)\n00071| local keys\n00072| \n00073| if require('opencode.ui.ui').is_opencode_focused() then\n00074| keys = {\n00075| config.keymap.permission.accept,\n00076| config.keymap.permission.accept_all,\n00077| config.keymap.permission.deny,\n00078| }\n00079| else\n00080| keys = {\n00081| config.get_key_for_function('editor', 'permission_accept'),\n00082| config.get_key_for_function('editor', 'permission_accept_all'),\n00083| config.get_key_for_function('editor', 'permission_deny'),\n00084| }\n00085| end\n00086| \n00087| output:add_empty_line()\n00088| output:add_line('> [!WARNING] Permission required to run this tool.')\n00089| output:add_line('>')\n00090| output:add_line(('> Accept `%s` Always `%s` Deny `%s`'):format(unpack(keys)))\n00091| output:add_empty_line()\n00092| end\n00093| \n00094| ---Calculate statistics for reverted messages and tool calls\n00095| ---@param messages {info: MessageInfo, parts: OpencodeMessagePart[]}[] All messages in the session\n00096| ---@param revert_index number Index of the message where revert occurred\n00097| ---@param revert_info SessionRevertInfo Revert information\n00098| ---@return {messages: number, tool_calls: number, files: table}\n00099| function M._calculate_revert_stats(messages, revert_index, revert_info)\n00100| local stats = {\n00101| messages = 0,\n00102| tool_calls = 0,\n00103| files = {}, -- { [filename] = { additions = n, deletions = m } }\n00104| }\n00105| \n00106| for i = revert_index, #messages do\n00107| local msg = messages[i]\n00108| if msg.info.role == 'user' then\n00109| stats.messages = stats.messages + 1\n00110| end\n00111| if msg.parts then\n00112| for _, part in ipairs(msg.parts) do\n00113| if part.type == 'tool' then\n00114| stats.tool_calls = stats.tool_calls + 1\n00115| end\n00116| end\n00117| end\n00118| end\n00119| \n00120| if revert_info.diff then\n00121| local current_file = nil\n00122| for line in revert_info.diff:gmatch('[^\\r\\n]+') do\n00123| local file_a = line:match('^%-%-%- ([ab]/.+)')\n00124| local file_b = line:match('^%+%+%+ ([ab]/.+)')\n00125| if file_b then\n00126| current_file = file_b:gsub('^[ab]/', '')\n00127| if not stats.files[current_file] then\n00128| stats.files[current_file] = { additions = 0, deletions = 0 }\n00129| end\n00130| elseif file_a then\n00131| current_file = file_a:gsub('^[ab]/', '')\n00132| if not stats.files[current_file] then\n00133| stats.files[current_file] = { additions = 0, deletions = 0 }\n00134| end\n00135| elseif line:sub(1, 1) == '+' and not line:match('^%+%+%+') then\n00136| if current_file then\n00137| stats.files[current_file].additions = stats.files[current_file].additions + 1\n00138| end\n00139| elseif line:sub(1, 1) == '-' and not line:match('^%-%-%-') then\n00140| if current_file then\n00141| stats.files[current_file].deletions = stats.files[current_file].deletions + 1\n00142| end\n00143| end\n00144| end\n00145| end\n00146| \n00147| return stats\n00148| end\n00149| \n00150| ---Format the revert callout with statistics\n00151| ---@param session_data OpencodeMessage[] All messages in the session\n00152| ---@param start_idx number Index of the message where revert occurred\n00153| ---@return Output output object representing the lines, extmarks, and actions\n00154| function M._format_revert_message(session_data, start_idx)\n00155| local output = Output.new()\n00156| local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)\n00157| local message_text = stats.messages == 1 and 'message' or 'messages'\n00158| local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'\n00159| \n00160| output:add_lines(M.separator)\n00161| output:add_line(\n00162| string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text)\n00163| )\n00164| output:add_line('>')\n00165| output:add_line('> type `/redo` to restore.')\n00166| output:add_empty_line()\n00167| \n00168| if stats.files and next(stats.files) then\n00169| for file, fstats in pairs(stats.files) do\n00170| local file_diff = {}\n00171| if fstats.additions > 0 then\n00172| table.insert(file_diff, '+' .. fstats.additions)\n00173| end\n00174| if fstats.deletions > 0 then\n00175| table.insert(file_diff, '-' .. fstats.deletions)\n00176| end\n00177| if #file_diff > 0 then\n00178| local line_str = string.format(icons.get('file') .. '%s: %s', file, table.concat(file_diff, ' '))\n00179| local line_idx = output:add_line(line_str)\n00180| local col = #(' ' .. file .. ': ')\n00181| for _, diff in ipairs(file_diff) do\n00182| local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText'\n00183| output:add_extmark(line_idx - 1, {\n00184| virt_text = { { diff, hl_group } },\n00185| virt_text_pos = 'inline',\n00186| virt_text_win_col = col,\n00187| priority = 1000,\n00188| } --[[@as OutputExtmark]])\n00189| col = col + #diff + 1\n00190| end\n00191| end\n00192| end\n00193| end\n00194| return output\n00195| end\n00196| \n00197| local function add_action(output, text, action_type, args, key, line)\n00198| -- actions use api-indexing (e.g. 0 indexed)\n00199| line = (line or output:get_line_count()) - 1\n00200| output:add_action({\n00201| text = text,\n00202| type = action_type,\n00203| args = args,\n00204| key = key,\n00205| display_line = line,\n00206| range = { from = line, to = line },\n00207| })\n00208| end\n00209| \n00210| ---@param output Output Output object to write to\n00211| ---@param part OpencodeMessagePart\n00212| function M._format_patch(output, part)\n00213| if not part.hash then\n00214| return\n00215| end\n00216| \n00217| local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}\n00218| M._format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))\n00219| \n00220| -- Anchor all snapshot-level actions to the snapshot header line\n00221| add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')\n00222| add_action(output, 'Revert [A]ll', 'diff_revert_all', { part.hash }, 'A')\n00223| add_action(output, '[D]iff', 'diff_open', { part.hash }, 'D')\n00224| \n00225| if #restore_points > 0 then\n00226| for _, restore_point in ipairs(restore_points) do\n00227| output:add_line(\n00228| string.format(\n00229| ' %s Restore point `%s` - %s ',\n00230| icons.get('restore_point'),\n00231| vim.trim(restore_point.id:sub(1, 8)),\n00232| util.format_time(restore_point.created_at)\n00233| )\n00234| )\n00235| add_action(output, 'Restore [A]ll', 'diff_restore_snapshot_all', { restore_point.id }, 'A')\n00236| add_action(output, '[R]estore file', 'diff_restore_snapshot_file', { restore_point.id }, 'R')\n00237| end\n00238| end\n00239| end\n00240| \n00241| ---@param output Output Output object to write to\n00242| ---@param message MessageInfo\n00243| function M._format_error(output, message)\n00244| output:add_empty_line()\n00245| M._format_callout(output, 'ERROR', vim.inspect(message.error))\n00246| end\n00247| \n00248| ---@param message OpencodeMessage\n00249| ---@return Output\n00250| function M.format_message_header(message)\n00251| local output = Output.new()\n00252| \n00253| output:add_lines(M.separator)\n00254| local role = message.info.role or 'unknown'\n00255| local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')\n00256| \n00257| local time = message.info.time and message.info.time.created or nil\n00258| local time_text = (time and ' (' .. util.format_time(time) .. ')' or '')\n00259| local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)\n00260| local model_text = message.info.modelID and ' ' .. message.info.modelID or ''\n00261| \n00262| local debug_text = config.debug.show_ids and ' [' .. message.info.id .. ']' or ''\n00263| \n00264| local display_name\n00265| if role == 'assistant' then\n00266| local mode = message.info.mode\n00267| if mode and mode ~= '' then\n00268| display_name = mode:upper()\n00269| else\n00270| -- For the most recent assistant message, show current_mode if mode is missing\n00271| -- This handles new messages that haven't been stamped yet\n00272| local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id\n00273| if is_last_message and state.current_mode and state.current_mode ~= '' then\n00274| display_name = state.current_mode:upper()\n00275| else\n00276| display_name = 'ASSISTANT'\n00277| end\n00278| end\n00279| else\n00280| display_name = role:upper()\n00281| end\n00282| \n00283| output:add_extmark(output:get_line_count() - 1, {\n00284| virt_text = {\n00285| { icon, role_hl },\n00286| { ' ' },\n00287| { display_name, role_hl },\n00288| { model_text, 'OpencodeHint' },\n00289| { time_text, 'OpencodeHint' },\n00290| { debug_text, 'OpencodeHint' },\n00291| },\n00292| virt_text_win_col = -3,\n00293| priority = 10,\n00294| } --[[@as OutputExtmark]])\n00295| \n00296| -- Only want to show the error if we have no parts. If we have parts, they'll\n00297| -- handle rendering the error\n00298| if\n00299| role == 'assistant'\n00300| and message.info.error\n00301| and message.info.error ~= ''\n00302| and (not message.parts or #message.parts == 0)\n00303| then\n00304| local error = message.info.error\n00305| local error_message = error.data and error.data.message or vim.inspect(error)\n00306| \n00307| output:add_line('')\n00308| M._format_callout(output, 'ERROR', error_message)\n00309| end\n00310| \n00311| output:add_line('')\n00312| return output\n00313| end\n00314| \n00315| ---@param output Output Output object to write to\n00316| ---@param callout string Callout type (e.g., 'ERROR', 'TODO')\n00317| ---@param text string Callout text content\n00318| ---@param title? string Optional title for the callout\n00319| function M._format_callout(output, callout, text, title)\n00320| title = title and title .. ' ' or ''\n00321| local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win))\n00322| and vim.api.nvim_win_get_width(state.windows.output_win)\n00323| or config.ui.window_width\n00324| or 80\n00325| if #text > win_width - 4 then\n00326| local ok, substituted = pcall(vim.fn.substitute, text, '\\v(.{' .. (win_width - 8) .. '})', '\\1\\n', 'g')\n00327| text = ok and substituted or text\n00328| end\n00329| \n00330| -- Trim off any trailing newlines so there isn't an extra line in the\n00331| -- extmarks section\n00332| local lines = vim.split(text:gsub('\\n$', ''), '\\n')\n00333| if #lines == 1 and title == '' then\n00334| output:add_line('> [!' .. callout .. '] ' .. lines[1])\n00335| else\n00336| output:add_line('> [!' .. callout .. ']' .. title)\n00337| output:add_line('>')\n00338| output:add_lines(lines, '> ')\n00339| end\n00340| end\n00341| \n00342| ---@param output Output Output object to write to\n00343| ---@param text string\n00344| ---@param message? OpencodeMessage Optional message object to extract mentions from\n00345| function M._format_user_prompt(output, text, message)\n00346| local start_line = output:get_line_count()\n00347| \n00348| output:add_lines(vim.split(text, '\\n'))\n00349| \n00350| local end_line = output:get_line_count()\n00351| \n00352| local end_line_extmark_offset = 0\n00353| \n00354| local mentions = {}\n00355| if message and message.parts then\n00356| -- message.parts will only be filled out on a re-render\n00357| -- we need to collect the mentions here\n00358| for _, part in ipairs(message.parts) do\n00359| if part.type == 'file' then\n00360| -- we're rerendering this part and we have files, the space after the user prompt\n00361| -- also needs an extmark\n00362| end_line_extmark_offset = 1\n00363| if part.source and part.source.text then\n00364| table.insert(mentions, part.source.text)\n00365| end\n00366| elseif part.type == 'agent' then\n00367| if part.source then\n00368| table.insert(mentions, part.source)\n00369| end\n00370| end\n00371| end\n00372| end\n00373| \n00374| if #mentions > 0 then\n00375| mention.highlight_mentions_in_output(output, text, mentions, start_line)\n00376| end\n00377| \n00378| M._add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)\n00379| end\n00380| \n00381| ---@param output Output Output object to write to\n00382| ---@param part OpencodeMessagePart\n00383| function M._format_selection_context(output, part)\n00384| local json = context_module.decode_json_context(part.text or '', 'selection')\n00385| if not json then\n00386| return\n00387| end\n00388| local start_line = output:get_line_count()\n00389| output:add_lines(vim.split(json.content or '', '\\n'))\n00390| output:add_empty_line()\n00391| \n00392| local end_line = output:get_line_count()\n00393| \n00394| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00395| end\n00396| \n00397| ---@param output Output Output object to write to\n00398| ---@param part OpencodeMessagePart\n00399| function M._format_cursor_data_context(output, part)\n00400| local json = context_module.decode_json_context(part.text or '', 'cursor-data')\n00401| if not json then\n00402| return\n00403| end\n00404| local start_line = output:get_line_count()\n00405| output:add_line('Line ' .. json.line .. ':')\n00406| output:add_lines(vim.split(json.line_content or '', '\\n'))\n00407| output:add_empty_line()\n00408| \n00409| local end_line = output:get_line_count()\n00410| \n00411| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00412| end\n00413| \n00414| ---@param output Output Output object to write to\n00415| ---@param part OpencodeMessagePart\n00416| function M._format_diagnostics_context(output, part)\n00417| local json = context_module.decode_json_context(part.text or '', 'diagnostics')\n00418| if not json then\n00419| return\n00420| end\n00421| local start_line = output:get_line_count()\n00422| local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]\n00423| if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then\n00424| return\n00425| end\n00426| \n00427| local diagnostics_count = { error = 0, warn = 0, info = 0 }\n00428| local diagnostics_icons = {\n00429| error = icons.get('error'),\n00430| warn = icons.get('warning'),\n00431| info = icons.get('info'),\n00432| }\n00433| \n00434| for _, diag in ipairs(diagnostics) do\n00435| local name = vim.diagnostic.severity[diag.severity]:lower()\n00436| diagnostics_count[name] = diagnostics_count[name] + 1\n00437| end\n00438| \n00439| local diag_line = '**Diagnostics:**'\n00440| for name, count in pairs(diagnostics_count) do\n00441| if count > 0 then\n00442| diag_line = diag_line .. (string.format(' %s(%d)', diagnostics_icons[name], count))\n00443| end\n00444| end\n00445| output:add_line(diag_line)\n00446| output:add_empty_line()\n00447| local end_line = output:get_line_count()\n00448| \n00449| M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)\n00450| end\n00451| \n00452| ---Format and display the file path in the context\n00453| ---@param output Output Output object to write to\n00454| ---@param path string|nil File path\n00455| function M._format_context_file(output, path)\n00456| if not path or path == '' then\n00457| return\n00458| end\n00459| local cwd = vim.fn.getcwd()\n00460| if vim.startswith(path, cwd) then\n00461| path = path:sub(#cwd + 2)\n00462| end\n00463| return output:add_line(string.format('[%s](%s)', path, path))\n00464| end\n00465| \n00466| ---@param output Output Output object to write to\n00467| ---@param text string\n00468| function M._format_assistant_message(output, text)\n00469| local reference_picker = require('opencode.ui.reference_picker')\n00470| local references = reference_picker.parse_references(text, '')\n00471| \n00472| -- If no references, just add the text as-is\n00473| if #references == 0 then\n00474| output:add_lines(vim.split(text, '\\n'))\n00475| return\n00476| end\n00477| \n00478| -- Sort references by match_start position (ascending)\n00479| table.sort(references, function(a, b)\n00480| return a.match_start < b.match_start\n00481| end)\n00482| \n00483| -- Build a new text with icons inserted before each reference\n00484| local result = ''\n00485| local last_pos = 1\n00486| local ref_icon = icons.get('reference')\n00487| \n00488| for _, ref in ipairs(references) do\n00489| -- Add text before this reference\n00490| result = result .. text:sub(last_pos, ref.match_start - 1)\n00491| -- Add the icon and the reference\n00492| result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)\n00493| last_pos = ref.match_end + 1\n00494| end\n00495| \n00496| -- Add any remaining text after the last reference\n00497| if last_pos <= #text then\n00498| result = result .. text:sub(last_pos)\n00499| end\n00500| \n00501| output:add_lines(vim.split(result, '\\n'))\n00502| end\n00503| \n00504| ---@param output Output Output object to write to\n00505| ---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)\n00506| ---@param value string Value associated with the action (e.g., filename, command)\n00507| function M._format_action(output, tool_type, value)\n00508| if not tool_type or not value then\n00509| return\n00510| end\n00511| local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '')\n00512| \n00513| output:add_line(line)\n00514| end\n00515| \n00516| ---@param output Output Output object to write to\n00517| ---@param input BashToolInput data for the tool\n00518| ---@param metadata BashToolMetadata Metadata for the tool use\n00519| function M._format_bash_tool(output, input, metadata)\n00520| M._format_action(output, icons.get('run') .. ' run', input and input.description)\n00521| \n00522| if not config.ui.output.tools.show_output then\n00523| return\n00524| end\n00525| \n00526| if metadata.output or metadata.command or input.command then\n00527| local command = input.command or metadata.command or ''\n00528| local command_output = metadata.output and metadata.output ~= '' and ('\\n' .. metadata.output) or ''\n00529| M._format_code(output, vim.split('> ' .. command .. '\\n' .. command_output, '\\n'), 'bash')\n00530| end\n00531| end\n00532| \n00533| ---@param output Output Output object to write to\n00534| ---@param tool_type string Tool type (e.g., 'read', 'edit', 'write')\n00535| ---@param input FileToolInput data for the tool\n00536| ---@param metadata FileToolMetadata Metadata for the tool use\n00537| function M._format_file_tool(output, tool_type, input, metadata)\n00538| local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''\n00539| local file_type = input and util.get_markdown_filetype(input.filePath) or ''\n00540| local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }\n00541| \n00542| M._format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name)\n00543| \n00544| if not config.ui.output.tools.show_output then\n00545| return\n00546| end\n00547| \n00548| if tool_type == 'edit' and metadata.diff then\n00549| M._format_diff(output, metadata.diff, file_type)\n00550| elseif tool_type == 'write' and input and input.content then\n00551| M._format_code(output, vim.split(input.content, '\\n'), file_type)\n00552| end\n00553| end\n00554| \n00555| ---@param output Output Output object to write to\n00556| ---@param title string\n00557| ---@param input TodoToolInput\n00558| function M._format_todo_tool(output, title, input)\n00559| M._format_action(output, icons.get('plan') .. ' plan', (title or ''))\n00560| if not config.ui.output.tools.show_output then\n00561| return\n00562| end\n00563| \n00564| local todos = input and input.todos or {}\n00565| \n00566| for _, item in ipairs(todos) do\n00567| local statuses = { in_progress = '-', completed = 'x', pending = ' ' }\n00568| output:add_line(string.format('- [%s] %s ', statuses[item.status], item.content))\n00569| end\n00570| end\n00571| \n00572| ---@param output Output Output object to write to\n00573| ---@param input GlobToolInput data for the tool\n00574| ---@param metadata GlobToolMetadata Metadata for the tool use\n00575| function M._format_glob_tool(output, input, metadata)\n00576| M._format_action(output, icons.get('search') .. ' glob', input and input.pattern)\n00577| if not config.ui.output.tools.show_output then\n00578| return\n00579| end\n00580| local prefix = metadata.truncated and ' more than' or ''\n00581| output:add_line(string.format('Found%s `%d` file(s):', prefix, metadata.count or 0))\n00582| end\n00583| \n00584| ---@param output Output Output object to write to\n00585| ---@param input GrepToolInput data for the tool\n00586| ---@param metadata GrepToolMetadata Metadata for the tool use\n00587| function M._format_grep_tool(output, input, metadata)\n00588| input = input or { path = '', include = '', pattern = '' }\n00589| \n00590| local grep_str = string.format('%s` `%s', (input.path or input.include) or '', input.pattern or '')\n00591| \n00592| M._format_action(output, icons.get('search') .. ' grep', grep_str)\n00593| if not config.ui.output.tools.show_output then\n00594| return\n00595| end\n00596| local prefix = metadata.truncated and ' more than' or ''\n00597| output:add_line(\n00598| string.format('Found%s `%d` match' .. (metadata.matches ~= 1 and 'es' or ''), prefix, metadata.matches or 0)\n00599| )\n00600| end\n00601| \n00602| ---@param output Output Output object to write to\n00603| ---@param input WebFetchToolInput data for the tool\n00604| function M._format_webfetch_tool(output, input)\n00605| M._format_action(output, icons.get('web') .. ' fetch', input and input.url)\n00606| end\n00607| \n00608| ---@param output Output Output object to write to\n00609| ---@param input ListToolInput\n00610| ---@param metadata ListToolMetadata\n00611| ---@param tool_output string\n00612| function M._format_list_tool(output, input, metadata, tool_output)\n00613| M._format_action(output, icons.get('list') .. ' list', input and input.path or '')\n00614| if not config.ui.output.tools.show_output then\n00615| return\n00616| end\n00617| local lines = vim.split(vim.trim(tool_output or ''), '\\n')\n00618| if #lines < 1 or metadata.count == 0 then\n00619| output:add_line('No files found.')\n00620| return\n00621| end\n00622| if #lines > 1 then\n00623| output:add_line('Files:')\n00624| for i = 2, #lines do\n00625| local file = vim.trim(lines[i])\n00626| if file ~= '' then\n00627| output:add_line(' • ' .. file)\n00628| end\n00629| end\n00630| end\n00631| if metadata.truncated then\n00632| output:add_line(string.format('Results truncated, showing first %d files', metadata.count or '?'))\n00633| end\n00634| end\n00635| \n00636| ---@param output Output Output object to write to\n00637| ---@param part OpencodeMessagePart\n00638| function M._format_tool(output, part)\n00639| local tool = part.tool\n00640| if not tool or not part.state then\n00641| return\n00642| end\n00643| \n00644| local start_line = output:get_line_count() + 1\n00645| local input = part.state.input or {}\n00646| local metadata = part.state.metadata or {}\n00647| local tool_output = part.state.output or ''\n00648| \n00649| if state.current_permission and state.current_permission.messageID == part.messageID then\n00650| metadata = state.current_permission.metadata or metadata\n00651| end\n00652| \n00653| if tool == 'bash' then\n00654| M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])\n00655| elseif tool == 'read' or tool == 'edit' or tool == 'write' then\n00656| M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]])\n00657| elseif tool == 'todowrite' then\n00658| M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]])\n00659| elseif tool == 'glob' then\n00660| M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]])\n00661| elseif tool == 'list' then\n00662| M._format_list_tool(output, input --[[@as ListToolInput]], metadata --[[@as ListToolMetadata]], tool_output)\n00663| elseif tool == 'grep' then\n00664| M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]])\n00665| elseif tool == 'webfetch' then\n00666| M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]])\n00667| elseif tool == 'task' then\n00668| M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output)\n00669| else\n00670| M._format_action(output, icons.get('tool') .. ' tool', tool)\n00671| end\n00672| \n00673| if part.state.status == 'error' and part.state.error then\n00674| output:add_line('')\n00675| M._format_callout(output, 'ERROR', part.state.error)\n00676| ---@diagnostic disable-next-line: undefined-field\n00677| elseif part.state.input and part.state.input.error then\n00678| output:add_line('')\n00679| ---I'm not sure about the type with state.input.error\n00680| ---@diagnostic disable-next-line: undefined-field\n00681| M._format_callout(output, 'ERROR', part.state.input.error)\n00682| end\n00683| \n00684| if\n00685| state.current_permission\n00686| and (\n00687| (\n00688| state.current_permission.tool\n00689| and state.current_permission.tool.callID == part.callID\n00690| and state.current_permission.tool.messageID == part.messageID\n00691| )\n00692| ---@TODO this is for backward compatibility, remove later\n00693| or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID)\n00694| )\n00695| then\n00696| M._handle_permission_request(output, part)\n00697| end\n00698| \n00699| local end_line = output:get_line_count()\n00700| if end_line - start_line > 1 then\n00701| M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1)\n00702| end\n00703| end\n00704| \n00705| ---@param output Output Output object to write to\n00706| ---@param input TaskToolInput data for the tool\n00707| ---@param metadata TaskToolMetadata Metadata for the tool use\n00708| ---@param tool_output string\n00709| function M._format_task_tool(output, input, metadata, tool_output)\n00710| local start_line = output:get_line_count() + 1\n00711| M._format_action(output, icons.get('task') .. ' task', input and input.description)\n00712| \n00713| if config.ui.output.tools.show_output then\n00714| if tool_output and tool_output ~= '' then\n00715| output:add_empty_line()\n00716| output:add_lines(vim.split(tool_output, '\\n'))\n00717| output:add_empty_line()\n00718| end\n00719| \n00720| if metadata.summary and type(metadata.summary) == 'table' then\n00721| for _, sub_part in ipairs(metadata.summary) do\n00722| if sub_part.type == 'tool' and sub_part.tool then\n00723| M._format_tool(output, sub_part)\n00724| end\n00725| end\n00726| end\n00727| end\n00728| \n00729| local end_line = output:get_line_count()\n00730| output:add_action({\n00731| text = '[S]elect Child Session',\n00732| type = 'select_child_session',\n00733| args = {},\n00734| key = 'S',\n00735| display_line = start_line - 1,\n00736| range = { from = start_line, to = end_line },\n00737| })\n00738| end\n00739| \n00740| ---@param output Output Output object to write to\n00741| ---@param lines string[]\n00742| ---@param language string\n00743| function M._format_code(output, lines, language)\n00744| output:add_empty_line()\n00745| --- NOTE: use longer code fence because lines could contain ```\n00746| output:add_line('`````' .. (language or ''))\n00747| output:add_lines(util.sanitize_lines(lines))\n00748| output:add_line('`````')\n00749| end\n00750| \n00751| ---@param output Output Output object to write to\n00752| ---@param code string\n00753| ---@param file_type string\n00754| function M._format_diff(output, code, file_type)\n00755| output:add_empty_line()\n00756| \n00757| --- NOTE: use longer code fence because code could contain ```\n00758| output:add_line('`````' .. file_type)\n00759| local lines = vim.split(code, '\\n')\n00760| if #lines > 5 then\n00761| lines = vim.list_slice(lines, 6)\n00762| end\n00763| \n00764| for _, line in ipairs(lines) do\n00765| local first_char = line:sub(1, 1)\n00766| if first_char == '+' or first_char == '-' then\n00767| local hl_group = first_char == '+' and 'OpencodeDiffAdd' or 'OpencodeDiffDelete'\n00768| output:add_line(' ' .. line:sub(2))\n00769| local line_idx = output:get_line_count()\n00770| output:add_extmark(line_idx - 1, function()\n00771| return {\n00772| end_col = 0,\n00773| end_row = line_idx,\n00774| virt_text = { { first_char, hl_group } },\n00775| hl_group = hl_group,\n00776| hl_eol = true,\n00777| priority = 5000,\n00778| right_gravity = true,\n00779| end_right_gravity = false,\n00780| virt_text_hide = false,\n00781| virt_text_pos = 'overlay',\n00782| virt_text_repeat_linebreak = false,\n00783| }\n00784| end)\n00785| else\n00786| output:add_line(line)\n00787| end\n00788| end\n00789| output:add_line('`````')\n00790| end\n00791| \n00792| ---@param output Output Output object to write to\n00793| ---@param start_line number\n00794| ---@param end_line number\n00795| ---@param hl_group string Highlight group for the border character\n00796| ---@param win_col number\n00797| ---@param text_hl_group? string Optional highlight group for the background/foreground of text lines\n00798| function M._add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group)\n00799| for line = start_line, end_line do\n00800| local extmark_opts = {\n00801| virt_text = { { require('opencode.ui.icons').get('border'), hl_group } },\n00802| virt_text_pos = 'overlay',\n00803| virt_text_win_col = win_col,\n00804| virt_text_repeat_linebreak = true,\n00805| }\n00806| \n00807| -- Add line highlight if text_hl_group is provided\n00808| if text_hl_group then\n00809| extmark_opts.line_hl_group = text_hl_group\n00810| end\n00811| \n00812| output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]])\n00813| end\n00814| end\n00815| \n00816| ---Formats a single message part and returns the resulting output object\n00817| ---@param part OpencodeMessagePart The part to format\n00818| ---@param message? OpencodeMessage Optional message object to extract role and mentions from\n00819| ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one\n00820| ---@return Output\n00821| function M.format_part(part, message, is_last_part)\n00822| local output = Output.new()\n00823| \n00824| if not message or not message.info or not message.info.role then\n00825| return output\n00826| end\n00827| \n00828| local content_added = false\n00829| local role = message.info.role\n00830| \n00831| if role == 'user' then\n00832| if part.type == 'text' and part.text then\n00833| if part.synthetic == true then\n00834| M._format_selection_context(output, part)\n00835| M._format_cursor_data_context(output, part)\n00836| M._format_diagnostics_context(output, part)\n00837| else\n00838| M._format_user_prompt(output, vim.trim(part.text), message)\n00839| content_added = true\n00840| end\n00841| elseif part.type == 'file' then\n00842| local file_line = M._format_context_file(output, part.filename)\n00843| if file_line then\n00844| M._add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3)\n00845| content_added = true\n00846| end\n00847| end\n00848| elseif role == 'assistant' then\n00849| if part.type == 'text' and part.text then\n00850| M._format_assistant_message(output, vim.trim(part.text))\n00851| content_added = true\n00852| elseif part.type == 'reasoning' and part.text then\n00853| M._format_reasoning(output, part)\n00854| content_added = true\n00855| elseif part.type == 'tool' then\n00856| M._format_tool(output, part)\n00857| content_added = true\n00858| elseif part.type == 'patch' and part.hash then\n00859| M._format_patch(output, part)\n00860| content_added = true\n00861| end\n00862| end\n00863| \n00864| if content_added then\n00865| output:add_empty_line()\n00866| end\n00867| \n00868| if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then\n00869| local error = message.info.error\n00870| local error_message = error.data and error.data.message or vim.inspect(error)\n00871| M._format_callout(output, 'ERROR', error_message)\n00872| output:add_empty_line()\n00873| end\n00874| \n00875| return output\n00876| end\n00877| \n00878| return M\n00879| \n\n(End of file - total 879 lines)\n","synthetic":true}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"file","url":"file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua","id":"prt_b8e7c60a7002ybV09VDHoQQeO7","filename":"lua/opencode/ui/formatter.lua","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","mime":"text/plain"}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"text","id":"prt_b8e7c60a3002XHHlk77vAPApvR","messageID":"msg_b8e7c60a2001Kisjwk2mVB4dye","text":"{\"content\":[{\"msg\":\"expected `SessionRevertInfo` but found `SessionRevertInfo?`.\",\"severity\":2,\"pos\":\"l156:c68\"},{\"msg\":\"expected `integer?` but found `number`.\",\"severity\":2,\"pos\":\"l490:c43\"},{\"msg\":\"expected `integer` but found `number`.\",\"severity\":2,\"pos\":\"l492:c45\"},{\"msg\":\"expected `integer?` but found `number`.\",\"severity\":2,\"pos\":\"l492:c62\"},{\"msg\":\"state.active_session may be nil\",\"severity\":2,\"pos\":\"l156:c68\"},{\"msg\":\"state.messages[#state.messages] may be nil\",\"severity\":2,\"pos\":\"l272:c74\"},{\"msg\":\"state.messages may be nil\",\"severity\":2,\"pos\":\"l272:c74\"},{\"msg\":\"Undefined field `[diag.severity]`.\",\"severity\":2,\"pos\":\"l435:c42\"},{\"msg\":\"Undefined field `command`.\",\"severity\":2,\"pos\":\"l526:c34\"},{\"msg\":\"Undefined field `command`.\",\"severity\":2,\"pos\":\"l527:c47\"},{\"msg\":\"Cannot assign `number` to `integer`.\",\"severity\":2,\"pos\":\"l493:c5\"}],\"context_type\":\"diagnostics\"}","synthetic":true}},"type":"message.part.updated"},{"properties":{"info":{"title":"New session - 2026-01-05T14:07:52.149Z","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"updated":1767622074603,"created":1767622072149}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"providerID":"github-copilot","role":"assistant","path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","time":{"created":1767622074609},"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","mode":"build","modelID":"gpt-4.1","id":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","parentID":"msg_b8e7c60a2001Kisjwk2mVB4dye","cost":0,"tokens":{"output":0,"reasoning":0,"input":0,"cache":{"write":0,"read":0}}}},"type":"message.updated"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"created":1767622074530},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","summary":{"diffs":[]}}},"type":"message.updated"},{"properties":{"info":{"title":"New session - 2026-01-05T14:07:52.149Z","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"updated":1767622074670,"created":1767622072149},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"additions":0,"files":0}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"step-start","id":"prt_b8e7c6672001F10WAiQKbzNEtE","snapshot":"5ba6e95774829e7f501299039b72b72dc32c6620","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ"}},"type":"message.part.updated"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"tool","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","id":"prt_b8e7c6686001cCV44vsFtbpF42","state":{"status":"pending","input":{},"raw":""},"callID":"call_KAC0LdAxKEbU6HTAPH5QJfGx","tool":"bash"}},"type":"message.part.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"part":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","type":"tool","messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","id":"prt_b8e7c6686001cCV44vsFtbpF42","state":{"status":"running","input":{"description":"Shows working tree status","command":"git status"},"time":{"start":1767622076189}},"callID":"call_KAC0LdAxKEbU6HTAPH5QJfGx","tool":"bash"}},"type":"message.part.updated"},{"properties":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","tool":{"messageID":"msg_b8e7c60f1001aEWYlAaDRXQ4aJ","callID":"call_KAC0LdAxKEbU6HTAPH5QJfGx"},"patterns":["git status"],"id":"per_b8e7c6744001DX6EZ9KHM15RLi","permission":"bash","always":["git status*"],"metadata":{}},"type":"permission.asked"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"sessionID":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"created":1767622074530},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"role":"user","id":"msg_b8e7c60a2001Kisjwk2mVB4dye","agent":"build","system":"# Code References\n\n**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**\n\nFormat: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`\n\nExamples:\n- CORRECT: \"The error is in `file://src/services/process.ts:712`\"\n- INCORRECT: \"The error is in file://src/services/process.ts:712\"\n- INCORRECT: \"The error is in src/services/process.ts:712\"\n\nThis matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.\n","summary":{"title":"Running git status to list changed files","diffs":[]}}},"type":"message.updated"},{"properties":[],"type":"custom.emit_events.finished"},{"properties":{"info":{"title":"Running git status for formatter.lua changes","version":"1.1.2","directory":"/home/francis/Projects/_nvim/opencode.nvim","id":"ses_47183a8abffeZzOLeJ3j2KV8f8","time":{"updated":1767622082619,"created":1767622072149},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"additions":0,"files":0}}},"type":"session.updated"},{"properties":[],"type":"custom.emit_events.finished"}] diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 56e57494..55b2c634 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -163,6 +163,7 @@ describe('renderer functional tests', function() -- because they involve permission prompts local skip_full_session = { 'permission-prompt', + 'permission-ask-new', 'shifting-and-multiple-perms', 'message-removal', } From 14911d58ff0d3071b7f6788649cc13985a21eb8f Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 5 Jan 2026 09:57:57 -0500 Subject: [PATCH 2/2] fix(permission): conditionnal check should exclude new format Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lua/opencode/ui/formatter.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index cb52b906..f1804631 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -690,7 +690,11 @@ function M._format_tool(output, part) and state.current_permission.tool.messageID == part.messageID ) ---@TODO this is for backward compatibility, remove later - or (state.current_permission.messageID == part.messageID and state.current_permission.callID == part.callID) + or ( + not state.current_permission.tool + and state.current_permission.messageID == part.messageID + and state.current_permission.callID == part.callID + ) ) then M._handle_permission_request(output, part)