From f6a2ed3587859bcc52c5449cd825b4925dc4e47a Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Sun, 2 Nov 2025 10:03:33 -0800 Subject: [PATCH 01/13] fix(api): mcp error with remote server Fixes #93 --- lua/opencode/api.lua | 15 +++++++--- tests/unit/api_spec.lua | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index b6a0b00c..606bdeb8 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -481,7 +481,7 @@ function M.mcp() local info = require('opencode.config_file') local mcp = info.get_mcp_servers() if not mcp then - ui.notify('No MCP configuration found. Please check your opencode config file.', 'warn') + vim.notify('No MCP configuration found. Please check your opencode config file.', vim.log.levels.WARN) return end @@ -491,11 +491,18 @@ function M.mcp() local msg = M.with_header({ '### Available MCP servers', '', - '| Name | Type | cmd |', - '|--------|------|-----|', + '| Name | Type | cmd/url |', + '|--------|------|---------|', }) for name, def in pairs(mcp) do + local cmd_or_url + if def.type == 'local' then + cmd_or_url = def.command and table.concat(def.command, ' ') + elseif def.type == 'remote' then + cmd_or_url = def.url + end + table.insert( msg, string.format( @@ -503,7 +510,7 @@ function M.mcp() (def.enabled and icons.get('status_on') or icons.get('status_off')), name, def.type, - table.concat(def.command, ' ') + cmd_or_url ) ) end diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 8d4060ed..7dc6aa08 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -214,4 +214,66 @@ describe('opencode.api', function() }) end) end) + + describe('/mcp command', function() + it('displays MCP server configuration when available', function() + local config_file = require('opencode.config_file') + local original_get_mcp_servers = config_file.get_mcp_servers + + config_file.get_mcp_servers = function() + return { + filesystem = { + type = 'local', + enabled = true, + command = { 'npx', '-y', '@modelcontextprotocol/server-filesystem' }, + }, + github = { + type = 'remote', + enabled = false, + url = 'https://example.com/mcp', + }, + } + end + + stub(ui, 'render_lines') + stub(api, 'open_input') + + api.mcp() + + assert.stub(api.open_input).was_called() + assert.stub(ui.render_lines).was_called() + + local render_args = ui.render_lines.calls[1].refs[1] + local rendered_text = table.concat(render_args, '\n') + + assert.truthy(rendered_text:match('Available MCP servers')) + assert.truthy(rendered_text:match('filesystem')) + assert.truthy(rendered_text:match('github')) + assert.truthy(rendered_text:match('local')) + assert.truthy(rendered_text:match('remote')) + + config_file.get_mcp_servers = original_get_mcp_servers + end) + + it('shows warning when no MCP configuration exists', function() + local config_file = require('opencode.config_file') + local original_get_mcp_servers = config_file.get_mcp_servers + + config_file.get_mcp_servers = function() + return nil + end + + local notify_stub = stub(vim, 'notify') + + api.mcp() + + assert.stub(notify_stub).was_called_with( + 'No MCP configuration found. Please check your opencode config file.', + vim.log.levels.WARN + ) + + config_file.get_mcp_servers = original_get_mcp_servers + notify_stub:revert() + end) + end) end) From c1b728a7fc09b36cfe69df8bfe7e46722fac74cb Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Sun, 2 Nov 2025 10:36:59 -0800 Subject: [PATCH 02/13] feat(api): add /commands to show user commands --- lua/opencode/api.lua | 35 ++++++++++++++++++++++++- lua/opencode/core.lua | 2 +- tests/unit/api_spec.lua | 58 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 606bdeb8..2fd2f63b 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -395,7 +395,7 @@ function M.select_agent() end function M.switch_mode() - local modes = require('opencode.config_file').get_opencode_agents() + local modes = require('opencode.config_file').get_opencode_agents() --[[@as string[] ]] local current_index = util.index_of(modes, state.current_mode) @@ -519,6 +519,33 @@ function M.mcp() ui.render_lines(msg) end +function M.commands_list() + local info = require('opencode.config_file') + local commands = info.get_user_commands() + if not commands then + vim.notify('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) + return + end + + state.display_route = '/commands' + M.open_input() + + local msg = M.with_header({ + '### Available User Commands', + '', + '| Name | Description |', + '|------|-------------|', + }) + + for name, def in pairs(commands) do + local desc = def.description or '' + table.insert(msg, string.format('| %s | %s |', name, desc)) + end + + table.insert(msg, '') + ui.render_lines(msg) +end + --- Runs a user-defined command by name. --- @param name string The name of the user command to run. --- @param args? string[] Additional arguments to pass to the command. @@ -1015,6 +1042,11 @@ M.commands = { fn = M.mcp, }, + commands_list = { + desc = 'Show user-defined commands', + fn = M.commands_list, + }, + permission = { desc = 'Respond to permissions (accept/accept_all/deny)', completions = { 'accept', 'accept_all', 'deny' }, @@ -1039,6 +1071,7 @@ M.slash_commands_map = { ['/agent'] = { fn = M.select_agent, desc = 'Select agent mode' }, ['/agents_init'] = { fn = M.initialize, desc = 'Initialize AGENTS.md session' }, ['/child-sessions'] = { fn = M.select_child_session, desc = 'Select child session' }, + ['/commands'] = { fn = M.commands_list, desc = 'Show user-defined commands' }, ['/compact'] = { fn = M.compact_session, desc = 'Compact current session' }, ['/mcp'] = { fn = M.mcp, desc = 'Show MCP server configuration' }, ['/models'] = { fn = M.configure_provider, desc = 'Switch provider/model' }, diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index a6a26d0b..2dd834f5 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -277,7 +277,7 @@ local function on_opencode_server() end --- Switches the current mode to the specified agent. ---- @param mode string The agent/mode to switch to +--- @param mode string|nil The agent/mode to switch to --- @return boolean success Returns true if the mode was switched successfully, false otherwise function M.switch_to_mode(mode) if not mode or mode == '' then diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 7dc6aa08..f8ddb85f 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -276,4 +276,62 @@ describe('opencode.api', function() notify_stub:revert() end) end) + + describe('/commands command', function() + it('displays user commands when available', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return { + ['build'] = { description = 'Build the project' }, + ['test'] = { description = 'Run tests' }, + ['deploy'] = { description = 'Deploy to production' }, + } + end + + stub(ui, 'render_lines') + stub(api, 'open_input') + + api.commands_list() + + assert.stub(api.open_input).was_called() + assert.stub(ui.render_lines).was_called() + + local render_args = ui.render_lines.calls[1].refs[1] + local rendered_text = table.concat(render_args, '\n') + + assert.truthy(rendered_text:match('Available User Commands')) + assert.truthy(rendered_text:match('Description')) + assert.truthy(rendered_text:match('build')) + assert.truthy(rendered_text:match('Build the project')) + assert.truthy(rendered_text:match('test')) + assert.truthy(rendered_text:match('Run tests')) + assert.truthy(rendered_text:match('deploy')) + assert.truthy(rendered_text:match('Deploy to production')) + + config_file.get_user_commands = original_get_user_commands + end) + + it('shows warning when no user commands exist', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return nil + end + + local notify_stub = stub(vim, 'notify') + + api.commands_list() + + assert.stub(notify_stub).was_called_with( + 'No user commands found. Please check your opencode config file.', + vim.log.levels.WARN + ) + + config_file.get_user_commands = original_get_user_commands + notify_stub:revert() + end) + end) end) From cb23223793fc40efeee9472a6e2d464d113de8b3 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Sun, 2 Nov 2025 14:56:18 -0800 Subject: [PATCH 03/13] feat(api): auto complete user commands Add autocomplete to `:Opencode command` and add user commands to slash commands list. --- lua/opencode/api.lua | 34 +++++++- tests/unit/api_spec.lua | 171 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 194 insertions(+), 11 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 2fd2f63b..ca3b3188 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -1022,6 +1022,16 @@ M.commands = { command = { desc = 'Run user-defined command', + completions = function() + local config_file = require('opencode.config_file') + local user_commands = config_file.get_user_commands() + if not user_commands then + return {} + end + local names = vim.tbl_keys(user_commands) + table.sort(names) + return names + end, fn = function(args) local name = args[1] if not name or name == '' then @@ -1071,7 +1081,7 @@ M.slash_commands_map = { ['/agent'] = { fn = M.select_agent, desc = 'Select agent mode' }, ['/agents_init'] = { fn = M.initialize, desc = 'Initialize AGENTS.md session' }, ['/child-sessions'] = { fn = M.select_child_session, desc = 'Select child session' }, - ['/commands'] = { fn = M.commands_list, desc = 'Show user-defined commands' }, + ['/command-list'] = { fn = M.commands_list, desc = 'Show user-defined commands' }, ['/compact'] = { fn = M.compact_session, desc = 'Compact current session' }, ['/mcp'] = { fn = M.mcp, desc = 'Show MCP server configuration' }, ['/models'] = { fn = M.configure_provider, desc = 'Switch provider/model' }, @@ -1166,9 +1176,13 @@ function M.complete_command(arg_lead, cmd_line, cursor_pos) end if num_parts <= 3 and subcmd_def.completions then + local completions = subcmd_def.completions + if type(completions) == 'function' then + completions = completions() + end return vim.tbl_filter(function(opt) return vim.startswith(opt, arg_lead) - end, subcmd_def.completions) + end, completions) end if num_parts <= 4 and subcmd_def.sub_completions then @@ -1209,6 +1223,22 @@ function M.get_slash_commands() fn = def.fn, }) end + + local config_file = require('opencode.config_file') + local user_commands = config_file.get_user_commands() + if user_commands then + for name, def in pairs(user_commands) do + table.insert(result, { + slash_cmd = '/' .. name, + desc = def.description or 'User command', + fn = function() + M.run_user_command(name, {}) + end, + args = false, + }) + end + end + return result end diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index f8ddb85f..3c9b8692 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -174,7 +174,13 @@ describe('opencode.api', function() end) it('parses multiple prefixes and passes all to send_message', function() - api.commands.run.fn({ 'agent=plan', 'model=openai/gpt-4', 'context=current_file.enabled=false', 'analyze', 'code' }) + api.commands.run.fn({ + 'agent=plan', + 'model=openai/gpt-4', + 'context=current_file.enabled=false', + 'analyze', + 'code', + }) assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('analyze code', { new_session = false, @@ -267,10 +273,9 @@ describe('opencode.api', function() api.mcp() - assert.stub(notify_stub).was_called_with( - 'No MCP configuration found. Please check your opencode config file.', - vim.log.levels.WARN - ) + assert + .stub(notify_stub) + .was_called_with('No MCP configuration found. Please check your opencode config file.', vim.log.levels.WARN) config_file.get_mcp_servers = original_get_mcp_servers notify_stub:revert() @@ -325,13 +330,161 @@ describe('opencode.api', function() api.commands_list() - assert.stub(notify_stub).was_called_with( - 'No user commands found. Please check your opencode config file.', - vim.log.levels.WARN - ) + assert + .stub(notify_stub) + .was_called_with('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) config_file.get_user_commands = original_get_user_commands notify_stub:revert() end) end) + + describe('command autocomplete', function() + it('provides user command names for completion', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return { + ['build'] = { description = 'Build the project' }, + ['test'] = { description = 'Run tests' }, + ['deploy'] = { description = 'Deploy to production' }, + } + end + + local completions = api.commands.command.completions() + + assert.truthy(vim.tbl_contains(completions, 'build')) + assert.truthy(vim.tbl_contains(completions, 'test')) + assert.truthy(vim.tbl_contains(completions, 'deploy')) + + config_file.get_user_commands = original_get_user_commands + end) + + it('returns empty array when no user commands exist', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return nil + end + + local completions = api.commands.command.completions() + + assert.same({}, completions) + + config_file.get_user_commands = original_get_user_commands + end) + + it('integrates with complete_command for Opencode command ', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return { + ['build'] = { description = 'Build the project' }, + ['test'] = { description = 'Run tests' }, + } + end + + local results = api.complete_command('b', 'Opencode command b', 18) + + assert.truthy(vim.tbl_contains(results, 'build')) + assert.falsy(vim.tbl_contains(results, 'test')) + + config_file.get_user_commands = original_get_user_commands + end) + end) + + describe('slash commands with user commands', function() + it('includes user commands in get_slash_commands', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return { + ['build'] = { description = 'Build the project' }, + ['test'] = { description = 'Run tests' }, + } + end + + local slash_commands = api.get_slash_commands() + + local build_found = false + local test_found = false + + for _, cmd in ipairs(slash_commands) do + if cmd.slash_cmd == '/build' then + build_found = true + assert.equal('Build the project', cmd.desc) + assert.is_function(cmd.fn) + assert.truthy(cmd.args) + elseif cmd.slash_cmd == '/test' then + test_found = true + assert.equal('Run tests', cmd.desc) + assert.is_function(cmd.fn) + assert.falsy(cmd.args) + end + end + + assert.truthy(build_found, 'Should include /build command') + assert.truthy(test_found, 'Should include /test command') + + config_file.get_user_commands = original_get_user_commands + end) + + it('uses default description when none provided', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return { + ['custom'] = {}, + } + end + + local slash_commands = api.get_slash_commands() + + local custom_found = false + for _, cmd in ipairs(slash_commands) do + if cmd.slash_cmd == '/custom' then + custom_found = true + assert.equal('User command', cmd.desc) + end + end + + assert.truthy(custom_found, 'Should include /custom command') + + config_file.get_user_commands = original_get_user_commands + end) + + it('includes built-in slash commands alongside user commands', function() + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return { + ['build'] = { description = 'Build the project' }, + } + end + + local slash_commands = api.get_slash_commands() + + local help_found = false + local build_found = false + + for _, cmd in ipairs(slash_commands) do + if cmd.slash_cmd == '/help' then + help_found = true + elseif cmd.slash_cmd == '/build' then + build_found = true + end + end + + assert.truthy(help_found, 'Should include built-in /help command') + assert.truthy(build_found, 'Should include user /build command') + + config_file.get_user_commands = original_get_user_commands + end) + end) end) From 0373797c8c72939694ed0a2ae6672959b7b52253 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Sun, 2 Nov 2025 15:00:49 -0800 Subject: [PATCH 04/13] test(api): custom commands don't take arguments Custom commands don't take arguments --- tests/unit/api_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 3c9b8692..683d3274 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -418,7 +418,7 @@ describe('opencode.api', function() build_found = true assert.equal('Build the project', cmd.desc) assert.is_function(cmd.fn) - assert.truthy(cmd.args) + assert.falsy(cmd.args) elseif cmd.slash_cmd == '/test' then test_found = true assert.equal('Run tests', cmd.desc) From 13a6efe12a378efe87d1165d7b7261828e5929ee Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 12:36:51 -0800 Subject: [PATCH 05/13] fix(api): custom commands with arguments I had broken it because I didn't think it was supported. But it was :) --- lua/opencode/api.lua | 6 +++--- lua/opencode/config_file.lua | 7 +++++++ tests/unit/api_spec.lua | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index ca3b3188..f34f88c0 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -1231,10 +1231,10 @@ function M.get_slash_commands() table.insert(result, { slash_cmd = '/' .. name, desc = def.description or 'User command', - fn = function() - M.run_user_command(name, {}) + fn = function(...) + M.run_user_command(name, ...) end, - args = false, + args = config_file.command_takes_arguments(def), }) end end diff --git a/lua/opencode/config_file.lua b/lua/opencode/config_file.lua index 74896aff..d5aa6687 100644 --- a/lua/opencode/config_file.lua +++ b/lua/opencode/config_file.lua @@ -126,4 +126,11 @@ function M.get_mcp_servers() return cfg and cfg.mcp or nil end +---Does this opencode user command take arguments? +---@param command OpencodeCommand +---@return boolean +function M.command_takes_arguments(command) + return command.template and command.template:find('$ARGUMENTS') ~= nil or false +end + return M diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 683d3274..17cfba93 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -404,7 +404,7 @@ describe('opencode.api', function() config_file.get_user_commands = function() return { ['build'] = { description = 'Build the project' }, - ['test'] = { description = 'Run tests' }, + ['test'] = { description = 'Run tests', template = 'Run tests with $ARGUMENTS' }, } end @@ -423,7 +423,7 @@ describe('opencode.api', function() test_found = true assert.equal('Run tests', cmd.desc) assert.is_function(cmd.fn) - assert.falsy(cmd.args) + assert.truthy(cmd.args) end end From 02a0a6652adc44e323dce6581125a5487d434963 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 16:05:52 -0800 Subject: [PATCH 06/13] fix(event_manager): log errors in callbacks We were swallowing some errors (e.g. vim.filetype.match). None of them seemed important but it's possible there will be errors in the future that we need to surface. Added util.pcall_trace that will return a full stacktrace in result, rather than just the error. --- lua/opencode/event_manager.lua | 9 +++++++-- lua/opencode/types.lua | 1 + lua/opencode/util.lua | 20 +++++++++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index d3185994..e8da1e31 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -1,6 +1,7 @@ local state = require('opencode.state') local config = require('opencode.config') local ThrottlingEmitter = require('opencode.throttling_emitter') +local util = require('opencode.util') --- @class EventInstallationUpdated --- @field type "installation.updated" @@ -278,12 +279,16 @@ function EventManager:emit(event_name, data) local event = { type = event_name, properties = data } - if require('opencode.config').debug.capture_streamed_events then + if config.debug.capture_streamed_events then table.insert(self.captured_events, vim.deepcopy(event)) end for _, callback in ipairs(listeners) do - pcall(callback, data) + local ok, result = util.pcall_trace(callback, data) + + if not ok then + vim.notify('Error calling ' .. event_name .. ' listener: ' .. result, vim.log.levels.ERROR) + end end end diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7379c8ed..104025ce 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -125,6 +125,7 @@ ---@class OpencodeDebugConfig ---@field enabled boolean +---@field capture_streamed_events any[] --- @class OpencodeProviders --- @field [string] string[] diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index b3c10c02..8498bff2 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -198,10 +198,13 @@ function M.format_time(timestamp) if timestamp > 1e12 then timestamp = math.floor(timestamp / 1000) end - + local now = os.time() - local today_start = os.time(os.date('*t', now)) - os.date('*t', now).hour * 3600 - os.date('*t', now).min * 60 - os.date('*t', now).sec - + local today_start = os.time(os.date('*t', now)) + - os.date('*t', now).hour * 3600 + - os.date('*t', now).min * 60 + - os.date('*t', now).sec + if timestamp >= today_start then return os.date('%I:%M %p', timestamp) else @@ -416,6 +419,10 @@ end --- @param filename string filename, possibly including path --- @return string markdown_filetype function M.get_markdown_filetype(filename) + if not filename or filename == '' then + return '' + end + local file_type_overrides = { javascriptreact = 'jsx', typescriptreact = 'tsx', @@ -476,4 +483,11 @@ function M.parse_run_args(args) return opts, prompt end +---pcall but returns a full stacktrace on error +function M.pcall_trace(fn, ...) + return xpcall(fn, function(err) + return debug.traceback(err, 2) + end, ...) +end + return M From 86c5379541a8b772080db86e71d11f7c9c794b05 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 16:08:08 -0800 Subject: [PATCH 07/13] fix(api): user commands don't need a full-rerender This can actually trigger a race condition between renderer.render_full_session and opencode delivering responses to the prompt. --- lua/opencode/api.lua | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index f34f88c0..883f5482 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -1,14 +1,14 @@ local core = require('opencode.core') local util = require('opencode.util') local session = require('opencode.session') -local input_window = require('opencode.ui.input_window') +local config_file = require('opencode.config_file') +local state = require('opencode.state') +local input_window = require('opencode.ui.input_window') local ui = require('opencode.ui.ui') local icons = require('opencode.ui.icons') -local state = require('opencode.state') local git_review = require('opencode.git_review') local history = require('opencode.history') -local id = require('opencode.id') local M = {} @@ -348,6 +348,8 @@ function M.debug_session() end function M.initialize() + local id = require('opencode.id') + ui.render_output(true) local new_session = core.create_new_session('AGENTS.md Initialization') @@ -382,7 +384,7 @@ function M.agent_build() end function M.select_agent() - local modes = require('opencode.config_file').get_opencode_agents() + local modes = config_file.get_opencode_agents() vim.ui.select(modes, { prompt = 'Select mode:', }, function(selection) @@ -395,7 +397,7 @@ function M.select_agent() end function M.switch_mode() - local modes = require('opencode.config_file').get_opencode_agents() --[[@as string[] ]] + local modes = config_file.get_opencode_agents() --[[@as string[] ]] local current_index = util.index_of(modes, state.current_mode) @@ -478,8 +480,7 @@ function M.help() end function M.mcp() - local info = require('opencode.config_file') - local mcp = info.get_mcp_servers() + local mcp = config_file.get_mcp_servers() if not mcp then vim.notify('No MCP configuration found. Please check your opencode config file.', vim.log.levels.WARN) return @@ -520,8 +521,7 @@ function M.mcp() end function M.commands_list() - local info = require('opencode.config_file') - local commands = info.get_user_commands() + local commands = config_file.get_user_commands() if not commands then vim.notify('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) return @@ -533,13 +533,13 @@ function M.commands_list() local msg = M.with_header({ '### Available User Commands', '', - '| Name | Description |', - '|------|-------------|', + '| Name | Description |Arguments|', + '|------|-------------|---------|', }) for name, def in pairs(commands) do local desc = def.description or '' - table.insert(msg, string.format('| %s | %s |', name, desc)) + table.insert(msg, string.format('| %s | %s | %s |', name, desc, tostring(config_file.command_takes_arguments(def)))) end table.insert(msg, '') @@ -552,7 +552,6 @@ end function M.run_user_command(name, args) M.open_input() - ui.render_output(true) if not state.active_session then vim.notify('No active session', vim.log.levels.WARN) return @@ -1023,7 +1022,6 @@ M.commands = { command = { desc = 'Run user-defined command', completions = function() - local config_file = require('opencode.config_file') local user_commands = config_file.get_user_commands() if not user_commands then return {} @@ -1224,7 +1222,6 @@ function M.get_slash_commands() }) end - local config_file = require('opencode.config_file') local user_commands = config_file.get_user_commands() if user_commands then for name, def in pairs(user_commands) do From c18d8f55bf892f103876a9220df5e52b25e8fa03 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 16:25:54 -0800 Subject: [PATCH 08/13] fix(api): don't need rerender here Renderer already subscribes to state.active_session --- lua/opencode/api.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 883f5482..4c649f8b 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -350,8 +350,6 @@ end function M.initialize() local id = require('opencode.id') - ui.render_output(true) - local new_session = core.create_new_session('AGENTS.md Initialization') if not new_session then vim.notify('Failed to create new session', vim.log.levels.ERROR) From 05d634376c10a76189e6748af5bee628596e80f8 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 17:33:44 -0800 Subject: [PATCH 09/13] fix(api): try to layout help without wrapping --- lua/opencode/api.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 4c649f8b..2ce562d8 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -449,15 +449,15 @@ function M.help() '', '### Subcommands', '', - '| Command | Description |', - '|--------------|-------------------------------------------------------|', + '| Command | Description |', + '|--------------|-------------|', }, false) if not state.windows or not state.windows.output_win then return end - local max_desc_length = math.floor((vim.api.nvim_win_get_width(state.windows.output_win) / 1.3) - 5) + local max_desc_length = vim.api.nvim_win_get_width(state.windows.output_win) - 22 local sorted_commands = vim.tbl_keys(M.commands) table.sort(sorted_commands) @@ -468,7 +468,7 @@ function M.help() if #desc > max_desc_length then desc = desc:sub(1, max_desc_length - 3) .. '...' end - table.insert(msg, string.format('| %-12s | %-53s |', name, desc)) + table.insert(msg, string.format('| %-12s | %-' .. max_desc_length .. 's |', name, desc)) end table.insert(msg, '') From 636ffdc2cc390129369045e54917dccdd7b9ce14 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 17:34:10 -0800 Subject: [PATCH 10/13] fix(api): fix race condition in submit_input_prompt As discussed in #95, there's a race condition if a display_route was used and now we're submitting an input prompt. To fix, we wait for the session to fully render before submitting the input --- lua/opencode/api.lua | 2 +- lua/opencode/core.lua | 2 +- lua/opencode/ui/renderer.lua | 6 ++++-- lua/opencode/ui/ui.lua | 12 ++++++++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 2ce562d8..a3288468 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -274,7 +274,7 @@ function M.submit_input_prompt() -- we're displaying /help or something similar, need to clear that and refresh -- the session data before sending the command state.display_route = nil - ui.render_output() + ui.render_output(true) end input_window.handle_submit() diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 2dd834f5..9ffb2389 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -77,7 +77,7 @@ function M.open(opts) -- and the windows were closed so we need to do a full refresh. This mostly happens -- when opening the window after having closed it since we're not currently clearing -- the session on api.close() - ui.render_output(false) + ui.render_output() end end end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 2742e8e5..7ae113bf 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -114,12 +114,14 @@ local function fetch_session() return require('opencode.session').get_messages(session) end +---Request all of the session data from the opencode server and render it +---@return Promise function M.render_full_session() if not output_window.mounted() or not state.api_client then - return + return Promise.new():resolve(nil) end - fetch_session():and_then(M._render_full_session_data) + return fetch_session():and_then(M._render_full_session_data) end function M._render_full_session_data(session_data) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 6d09c41d..5410428c 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -174,8 +174,16 @@ function M.clear_output() -- state.restore_points = {} end -function M.render_output(_) - renderer.render_full_session() +---Force a full rerender of the output buffer. Should be done synchronously if +---called before submitting input or doing something that might generate events +---from opencode +---@param synchronous? boolean If true, waits until session is fully rendered +function M.render_output(synchronous) + local ret = renderer.render_full_session() + + if ret and synchronous then + ret:wait() + end end function M.render_lines(lines) From 86aafa63e12a7eb812a8875491c7167299dcfff4 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 17:39:14 -0800 Subject: [PATCH 11/13] chore(types): add missing legacy_commands --- lua/opencode/types.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 104025ce..5b9a1e16 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -148,6 +148,7 @@ ---@field context OpencodeContextConfig ---@field debug OpencodeDebugConfig ---@field prompt_guard? fun(mentioned_files: string[]): boolean +---@field legacy_commands boolean ---@class MessagePartState ---@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput Input data for the tool From 900c7dffa894ca76d5e17d0e4f34c416a286c3af Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 17:40:58 -0800 Subject: [PATCH 12/13] chore(api): fix completion type --- lua/opencode/api.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index a3288468..b22cb587 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -1174,7 +1174,7 @@ function M.complete_command(arg_lead, cmd_line, cursor_pos) if num_parts <= 3 and subcmd_def.completions then local completions = subcmd_def.completions if type(completions) == 'function' then - completions = completions() + completions = completions() --[[@as string[] ]] end return vim.tbl_filter(function(opt) return vim.startswith(opt, arg_lead) From 576652098ab7526e5e4459e086561ae5039742c1 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Mon, 3 Nov 2025 17:51:59 -0800 Subject: [PATCH 13/13] chore(state): clean up types --- lua/opencode/state.lua | 39 ++++++++++++++++----------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 4974a30a..3ab00c34 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -46,6 +46,8 @@ local config = require('opencode.config') ---@field unsubscribe fun( key:string|nil, cb:fun(key:string, new_val:any, old_val:any)) ---@field is_running fun():boolean +local M = {} + -- Internal raw state table local _state = { -- ui @@ -96,10 +98,10 @@ local _listeners = {} ---@usage --- state.subscribe('foo', function(key, new, old) ... end) --- state.subscribe('*', function(key, new, old) ... end) -local function subscribe(key, cb) +function M.subscribe(key, cb) if type(key) == 'table' then for _, k in ipairs(key) do - subscribe(k, cb) + M.subscribe(k, cb) end return end @@ -113,7 +115,7 @@ end --- Unsubscribe a callback for a key (or all keys) ---@param key string|nil ---@param cb fun(key:string, new_val:any, old_val:any) -local function unsubscribe(key, cb) +function M.unsubscribe(key, cb) key = key or '*' local list = _listeners[key] if not list then @@ -148,7 +150,7 @@ local function _notify(key, new_val, old_val) end) end -local function append(key, value) +function M.append(key, value) if type(value) ~= 'table' then error('Value must be a table to append') end @@ -164,7 +166,7 @@ local function append(key, value) _notify(key, _state[key], old) end -local function remove(key, idx) +function M.remove(key, idx) if not _state[key] then return end @@ -177,6 +179,13 @@ local function remove(key, idx) _notify(key, _state[key], old) end +--- +--- Returns true if any job (run or server) is running +--- +function M.is_running() + return M.job_count > 0 +end + --- Observable state proxy. All reads/writes go through this table. --- Use `state.subscribe(key, cb)` to listen for changes. --- Use `state.unsubscribe(key, cb)` to remove listeners. @@ -184,9 +193,7 @@ end --- Example: --- state.subscribe('foo', function(key, new, old) print(key, new, old) end) --- state.foo = 42 -- triggers callback ----@type OpencodeState -local M = {} -setmetatable(M, { +return setmetatable(M, { __index = function(_, k) return _state[k] end, @@ -203,18 +210,4 @@ setmetatable(M, { __ipairs = function() return ipairs(_state) end, -}) - -M.append = append -M.remove = remove -M.subscribe = subscribe -M.unsubscribe = unsubscribe - ---- ---- Returns true if any job (run or server) is running ---- -function M.is_running() - return M.job_count > 0 -end - -return M +}) --[[@as OpencodeState]]