diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index b6a0b00c..b22cb587 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 = {} @@ -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() @@ -348,7 +348,7 @@ function M.debug_session() end function M.initialize() - ui.render_output(true) + local id = require('opencode.id') local new_session = core.create_new_session('AGENTS.md Initialization') if not new_session then @@ -382,7 +382,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 +395,7 @@ function M.select_agent() end function M.switch_mode() - local modes = require('opencode.config_file').get_opencode_agents() + local modes = config_file.get_opencode_agents() --[[@as string[] ]] local current_index = util.index_of(modes, state.current_mode) @@ -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, '') @@ -478,10 +478,9 @@ 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 - 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 +490,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 +509,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 @@ -512,13 +518,38 @@ function M.mcp() ui.render_lines(msg) end +function M.commands_list() + 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 + end + + state.display_route = '/commands' + M.open_input() + + local msg = M.with_header({ + '### Available User Commands', + '', + '| Name | Description |Arguments|', + '|------|-------------|---------|', + }) + + for name, def in pairs(commands) do + local desc = def.description or '' + table.insert(msg, string.format('| %s | %s | %s |', name, desc, tostring(config_file.command_takes_arguments(def)))) + 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. 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 @@ -988,6 +1019,15 @@ M.commands = { command = { desc = 'Run user-defined command', + completions = function() + 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 @@ -1008,6 +1048,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' }, @@ -1032,6 +1077,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' }, + ['/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' }, @@ -1126,9 +1172,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() --[[@as string[] ]] + 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 @@ -1169,6 +1219,21 @@ function M.get_slash_commands() fn = def.fn, }) end + + 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 = config_file.command_takes_arguments(def), + }) + end + end + return result 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/lua/opencode/core.lua b/lua/opencode/core.lua index a6a26d0b..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 @@ -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/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/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]] diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7379c8ed..5b9a1e16 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[] @@ -147,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 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) 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 diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 8d4060ed..17cfba93 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, @@ -214,4 +220,271 @@ 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) + + 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) + + 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', template = 'Run tests with $ARGUMENTS' }, + } + 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.falsy(cmd.args) + elseif cmd.slash_cmd == '/test' then + test_found = true + assert.equal('Run tests', cmd.desc) + assert.is_function(cmd.fn) + assert.truthy(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)