diff --git a/README.md b/README.md index f166b8be..6b9380df 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ require('opencode').setup({ ['opa'] = { 'permission_accept' }, -- Accept permission request once ['opA'] = { 'permission_accept_all' }, -- Accept all (for current tool) ['opd'] = { 'permission_deny' }, -- Deny permission request once + ['ott'] = { 'toggle_tool_output' }, -- Toggle tools output (diffs, cmd output, etc.) + ['otr'] = { 'toggle_reasoning_output' }, -- Toggle reasoning output (thinking steps) }, input_window = { [''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode) @@ -191,6 +193,7 @@ require('opencode').setup({ output = { tools = { show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true) + show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true) }, rendering = { markdown_debounce_ms = 250, -- Debounce time for markdown rendering on new data (default: 250ms) @@ -389,7 +392,8 @@ The plugin provides the following actions that can be triggered via keymaps, com | Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` | | Toggle input/output panes | `` | - | - | | Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` | -| Toggle tools output (diffs, cmd output, etc.) | - | `:Opencode toggle_tools_output` | `require('opencode.api').toggle_tools_output()` | +| Toggle tools output (diffs, cmd output, etc.) | `ott` | `:Opencode toggle_tool_output` | `require('opencode.api').toggle_tool_output()` | +| Toggle reasoning output (thinking steps) | `otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` | --- diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 4e03ea09..dd480f39 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -904,10 +904,19 @@ function M.permission_deny() end function M.toggle_tool_output() + local action_text = config.ui.output.tools.show_output and 'Hiding' or 'Showing' + vim.notify(action_text .. ' tool output display', vim.log.levels.INFO) config.values.ui.output.tools.show_output = not config.ui.output.tools.show_output ui.render_output() end +function M.toggle_reasoning_output() + local action_text = config.ui.output.tools.show_reasoning_output and 'Hiding' or 'Showing' + vim.notify(action_text .. ' reasoning output display', vim.log.levels.INFO) + config.values.ui.output.tools.show_reasoning_output = not config.ui.output.tools.show_reasoning_output + ui.render_output() +end + ---@type table M.commands = { open = { @@ -1210,6 +1219,11 @@ M.commands = { desc = 'Toggle tool output visibility in the output window', fn = M.toggle_tool_output, }, + + toggle_reasoning_output = { + desc = 'Toggle reasoning output visibility in the output window', + fn = M.toggle_reasoning_output, + }, paste_image = { desc = 'Paste image from clipboard and add to context', fn = M.paste_image, @@ -1234,6 +1248,8 @@ M.slash_commands_map = { ['/undo'] = { fn = M.undo, desc = 'Undo last action' }, ['/unshare'] = { fn = M.unshare, desc = 'Unshare current session' }, ['/rename'] = { fn = M.rename_session, desc = 'Rename current session' }, + ['/thinking'] = { fn = M.toggle_reasoning_output, desc = 'Toggle reasoning output' }, + ['/reasoning'] = { fn = M.toggle_reasoning_output, desc = 'Toggle reasoning output' }, } M.legacy_command_map = { diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 2981f268..6bf81488 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -41,6 +41,8 @@ M.defaults = { ['oPa'] = { 'permission_accept', desc = 'Accept permission' }, ['oPA'] = { 'permission_accept_all', desc = 'Accept all permissions' }, ['oPd'] = { 'permission_deny', desc = 'Deny permission' }, + ['otr'] = { 'toggle_reasoning_output', desc = 'Toggle reasoning output' }, + ['ott'] = { 'toggle_tool_output', desc = 'Toggle tool output' }, }, output_window = { [''] = { 'close' }, @@ -118,6 +120,7 @@ M.defaults = { }, tools = { show_output = true, + show_reasoning_output = true, }, always_scroll_to_bottom = false, }, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 0df3e3f7..367d6df0 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -129,6 +129,10 @@ ---@field output OpencodeUIOutputConfig ---@field input { text: { wrap: boolean } } ---@field completion OpencodeCompletionConfig +---@field highlights? OpencodeHighlightConfig + +---@class OpencodeHighlightConfig +---@field vertical_borders? { tool?: { fg?: string, bg?: string }, user?: { fg?: string, bg?: string }, assistant?: { fg?: string, bg?: string } } ---@class OpencodeUIOutputRenderingConfig ---@field markdown_debounce_ms number @@ -137,7 +141,7 @@ ---@field event_collapsing boolean ---@class OpencodeUIOutputConfig ----@field tools { show_output: boolean } +---@field tools { show_output: boolean, show_reasoning_output: boolean } ---@field rendering OpencodeUIOutputRenderingConfig ---@field always_scroll_to_bottom boolean @@ -382,7 +386,7 @@ ---@field value string|nil ---@class OpencodeMessagePart ----@field type 'text'|'file'|'agent'|'tool'|'step-start'|'patch'|string +---@field type 'text'|'file'|'agent'|'tool'|'step-start'|'patch'|'reasoning'|string ---@field id string|nil Unique identifier for tool use parts ---@field text string|nil ---@field tool string|nil Name of the tool being used @@ -399,6 +403,7 @@ ---@field callID string|nil Call identifier (used for tools) ---@field hash string|nil Hash identifier for patch parts ---@field files string[]|nil List of file paths for patch parts +---@field time { start: number, end?: number }|nil Timestamps for the part ---@class OpencodeModelModalities ---@field input ('text'|'image'|'audio'|'video')[] Supported input modalities diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 8ed8c056..7459ae62 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -14,6 +14,46 @@ M.separator = { '', } +---@param output Output +---@param part OpencodeMessagePart +function M._format_reasoning(output, part) + local text = vim.trim(part.text or '') + if text == '' then + return + end + + local start_line = output:get_line_count() + 1 + + local title = 'Reasoning' + local time = part.time + if time and type(time) == 'table' and time.start then + local start_text = util.format_time(time.start) or '' + local end_text = (time['end'] and util.format_time(time['end'])) or nil + if end_text and end_text ~= '' then + title = string.format('%s (%s - %s)', title, start_text, end_text) + elseif start_text ~= '' then + title = string.format('%s (%s)', title, start_text) + end + end + + M._format_action(output, icons.get('reasoning') .. ' ' .. title, '') + + if config.ui.output.tools.show_reasoning_output then + output:add_empty_line() + output:add_lines(vim.split(text, '\n')) + output:add_empty_line() + end + + local end_line = output:get_line_count() + if end_line - start_line > 1 then + M._add_vertical_border(output, start_line, end_line, 'OpencodeToolBorder', -1, 'OpencodeReasoningText') + else + output:add_extmark(start_line - 1, { + line_hl_group = 'OpencodeReasoningText', + } --[[@as OutputExtmark]]) + end +end + function M._handle_permission_request(output, part) if part.state and part.state.status == 'error' and part.state.error then if part.state.error:match('rejected permission') then @@ -431,14 +471,15 @@ function M._format_assistant_message(output, text) end ---@param output Output Output object to write to ----@param type string Tool type (e.g., 'run', 'read', 'edit', etc.) +---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.) ---@param value string Value associated with the action (e.g., filename, command) -function M._format_action(output, type, value) - if not type or not value then +function M._format_action(output, tool_type, value) + if not tool_type or not value then return end + local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '') - output:add_line('**' .. type .. '** `' .. value .. '`') + output:add_line(line) end ---@param output Output Output object to write to @@ -713,16 +754,24 @@ end ---@param output Output Output object to write to ---@param start_line number ---@param end_line number ----@param hl_group string +---@param hl_group string Highlight group for the border character ---@param win_col number -function M._add_vertical_border(output, start_line, end_line, hl_group, win_col) +---@param text_hl_group? string Optional highlight group for the background/foreground of text lines +function M._add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group) for line = start_line, end_line do - output:add_extmark(line - 1, { + local extmark_opts = { virt_text = { { require('opencode.ui.icons').get('border'), hl_group } }, virt_text_pos = 'overlay', virt_text_win_col = win_col, virt_text_repeat_linebreak = true, - } --[[@as OutputExtmark]]) + } + + -- Add line highlight if text_hl_group is provided + if text_hl_group then + extmark_opts.line_hl_group = text_hl_group + end + + output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]]) end end @@ -762,6 +811,9 @@ function M.format_part(part, message, is_last_part) if part.type == 'text' and part.text then M._format_assistant_message(output, vim.trim(part.text)) content_added = true + elseif part.type == 'reasoning' and part.text then + M._format_reasoning(output, part) + content_added = true elseif part.type == 'tool' then M._format_tool(output, part) content_added = true diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index bc788cfe..a58ae641 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -34,6 +34,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) else vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true }) vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true }) @@ -64,6 +65,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) end end diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index b8db41ce..73b2b586 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -25,6 +25,7 @@ local presets = { folder = ' ', attached_file = '󰌷 ', agent = '󰚩 ', + reasoning = '󰧑 ', -- statuses status_on = ' ', status_off = ' ', diff --git a/tests/data/reasoning.expected.json b/tests/data/reasoning.expected.json new file mode 100644 index 00000000..9b22b431 --- /dev/null +++ b/tests/data/reasoning.expected.json @@ -0,0 +1 @@ +{"lines":["----","","","Explain your plan, then provide the final title.","","----","","","**󰧑 Reasoning (2025-12-17 19:33:20 - 2025-12-17 19:33:21)** ","","The user is asking me to explain my plan and then provide a final title. I need to think through what they're asking for. It seems like they want me to demonstrate reasoning capabilities - showing my thought process before giving an answer. Let me break this down into steps.","","1. First, I should understand what kind of title they want - is this for a project, document, or something else?","2. Since they didn't specify the context, I should provide a general approach","3. I'll demonstrate my reasoning process transparently","4. Then I'll give them a concrete example title","","Given that this appears to be related to the OpenCode project (based on the file structure I can see), I should provide a title that reflects the nature of this AI coding assistant tool.","","Based on my reasoning above, here's my plan:","","1. Acknowledge the request for demonstrating reasoning","2. Show transparency in my thought process","3. Provide context-appropriate suggestions","","For the title, considering this is the OpenCode Neovim plugin - an AI-powered coding assistant - a good title would be:","","**\"OpenCode.nvim: AI-Powered Coding Assistant for Neovim\"**","","This title clearly identifies what the tool is (OpenCode), the platform it works with (Neovim), and its primary function (AI-powered coding assistance).","",""],"actions":[],"extmarks":[[1,1,0,{"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-12-17 19:33:20)","OpencodeHint"],[" [msg_reason_user1]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_win_col":-3,"ns_id":3}],[2,2,0,{"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"ns_id":3}],[3,3,0,{"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_win_col":-3,"ns_id":3}],[4,6,0,{"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4","OpencodeHint"],[" (2025-12-17 19:33:20)","OpencodeHint"],[" [msg_reason_asst1]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_win_col":-3,"ns_id":3}],[5,8,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[6,9,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[7,10,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[8,11,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[9,12,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[10,13,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[11,14,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[12,15,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[13,16,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[14,17,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[15,18,0,{"line_hl_group":"OpencodeReasoningText","right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}]],"timestamp":1766413340} diff --git a/tests/data/reasoning.json b/tests/data/reasoning.json new file mode 100644 index 00000000..095797cd --- /dev/null +++ b/tests/data/reasoning.json @@ -0,0 +1,150 @@ +[ + { + "properties": { + "info": { + "id": "msg_reason_user1", + "role": "user", + "sessionID": "ses_reason_1", + "time": { + "created": 1766000000000 + } + } + }, + "type": "message.updated" + }, + { + "properties": { + "part": { + "id": "prt_reason_user1_text", + "messageID": "msg_reason_user1", + "sessionID": "ses_reason_1", + "text": "Explain your plan, then provide the final title.", + "type": "text" + } + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "id": "msg_reason_asst1", + "role": "assistant", + "sessionID": "ses_reason_1", + "mode": "build", + "modelID": "claude-sonnet-4", + "providerID": "github-copilot", + "path": { + "cwd": "/workspace", + "root": "/workspace" + }, + "time": { + "created": 1766000000500 + }, + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + } + } + }, + "type": "message.updated" + }, + { + "properties": { + "part": { + "id": "prt_reason_asst1_reasoning", + "messageID": "msg_reason_asst1", + "sessionID": "ses_reason_1", + "type": "reasoning", + "text": "The user is asking me to explain my plan and then provide a final title. I need to think through what they're asking for. It seems like they want me to demonstrate reasoning capabilities - showing my thought process before giving an answer. Let me break this down into steps.", + "time": { + "start": 1766000000600 + } + } + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_reason_asst1_reasoning", + "messageID": "msg_reason_asst1", + "sessionID": "ses_reason_1", + "type": "reasoning", + "text": "The user is asking me to explain my plan and then provide a final title. I need to think through what they're asking for. It seems like they want me to demonstrate reasoning capabilities - showing my thought process before giving an answer. Let me break this down into steps.\n\n1. First, I should understand what kind of title they want - is this for a project, document, or something else?\n2. Since they didn't specify the context, I should provide a general approach\n3. I'll demonstrate my reasoning process transparently\n4. Then I'll give them a concrete example title\n\nGiven that this appears to be related to the OpenCode project (based on the file structure I can see), I should provide a title that reflects the nature of this AI coding assistant tool.", + "time": { + "start": 1766000000600, + "end": 1766000001200 + } + } + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_reason_asst1_text", + "messageID": "msg_reason_asst1", + "sessionID": "ses_reason_1", + "type": "text", + "text": "Based on my reasoning above, here's my plan:\n\n1. Acknowledge the request for demonstrating reasoning\n2. Show transparency in my thought process\n3. Provide context-appropriate suggestions\n\nFor the title, considering this is the OpenCode Neovim plugin - an AI-powered coding assistant - a good title would be:\n\n**\"OpenCode.nvim: AI-Powered Coding Assistant for Neovim\"**\n\nThis title clearly identifies what the tool is (OpenCode), the platform it works with (Neovim), and its primary function (AI-powered coding assistance)." + } + }, + "type": "message.part.updated" + }, + { + "properties": { + "part": { + "id": "prt_reason_asst1_finish", + "messageID": "msg_reason_asst1", + "sessionID": "ses_reason_1", + "type": "step-finish", + "cost": 0, + "tokens": { + "input": 0, + "output": 95, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + } + } + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "id": "msg_reason_asst1", + "role": "assistant", + "sessionID": "ses_reason_1", + "mode": "build", + "modelID": "claude-sonnet-4", + "providerID": "github-copilot", + "path": { + "cwd": "/workspace", + "root": "/workspace" + }, + "time": { + "created": 1766000000500, + "completed": 1766000001300 + }, + "tokens": { + "input": 0, + "output": 95, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + } + } + }, + "type": "message.updated" + } +]